🪭 feat: Add opt-in Langfuse fanout gateway + collector (#13872)

* feat: add opt-in Langfuse fanout collector

* feat: fan out Langfuse feedback scores

* docs: prepare Langfuse fanout for OSS setup

* fix: clarify Langfuse fanout collector config

* test: stabilize librechat suite

* test: fix upload dialog import order

* fix: omit empty Langfuse tenant fields

* fix: gate tenant Langfuse fanout

* test: cover central Langfuse env fallback

* style: format Langfuse fanout config

* feat: route langfuse fanout by destination

* docs: clarify langfuse compose destination scope

* test: remove unrelated suite stabilization

* style: sort agent imports

* fix: treat blank tenant fanout toggle as disabled

* fix: rename tenant fanout emergency toggle

* test: guard langfuse fanout collector config drift

* feat: tune langfuse fanout batching

* test: render fanout helm tests without dependencies

* fix: narrow remote agent run config

* refactor: share string normalization helper

* fix: align langfuse fanout env parsing

* fix(langfuse): align score fanout toggles with traces

* fix(langfuse): keep central fanout config collector-only

* fix(langfuse): type fanout collector config

* fix(langfuse): harden tenant fanout config

* feat(langfuse): support media fanout gateway

* fix(langfuse): route tenant fanout through destination URL

* fix(langfuse): harden fanout routing checks

* ci(langfuse): test fanout gateway changes

* ci(langfuse): check fanout go formatting

* fix(langfuse): satisfy api typecheck
This commit is contained in:
Ravi Kumar L 2026-06-26 17:26:39 +02:00 committed by GitHub
parent 0789a04d11
commit a0529c9af7
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
38 changed files with 5699 additions and 130 deletions

View file

@ -203,6 +203,16 @@ function makeAppConfig(customEndpoints: TestCustomEndpoint[]): AppConfig {
beforeEach(() => {
jest.clearAllMocks();
delete process.env.LANGFUSE_PUBLIC_KEY;
delete process.env.LANGFUSE_SECRET_KEY;
delete process.env.LANGFUSE_BASE_URL;
delete process.env.LANGFUSE_BASEURL;
delete process.env.LANGFUSE_HOST;
delete process.env.LANGFUSE_FANOUT_ENABLED;
delete process.env.LANGFUSE_FANOUT_COLLECTOR_URL;
delete process.env.LANGFUSE_FANOUT_TENANT_BASE_URL;
delete process.env.LANGFUSE_FANOUT_TENANT_DESTINATIONS;
delete process.env.LANGFUSE_FANOUT_TENANT_EXPORT_DISABLED;
});
// ---------------------------------------------------------------------------
@ -1111,10 +1121,12 @@ async function callAndCaptureRunConfig({
overrides,
user,
tenantId,
appConfig,
}: {
overrides?: Record<string, unknown>;
user?: Record<string, unknown>;
tenantId?: string;
appConfig?: AppConfig;
} = {}): Promise<Record<string, unknown>> {
const agents = [makeAgent(overrides)];
const signal = new AbortController().signal;
@ -1126,6 +1138,7 @@ async function callAndCaptureRunConfig({
streamUsage: true,
user: user as never,
tenantId,
appConfig,
});
const createMock = Run.create as jest.Mock;
@ -1168,6 +1181,543 @@ describe('Langfuse run config', () => {
tags: ['tenant:tenant-2'],
});
});
it('adds tenant Langfuse credentials from tenant-scoped app config', async () => {
const callArgs = await callAndCaptureRunConfig({
tenantId: 'tenant-1',
appConfig: {
langfuse: {
publicKey: 'pk-tenant-1',
secretKey: 'sk-tenant-1',
baseUrl: 'https://cloud.langfuse.com',
fanout: {
enabled: true,
collectorUrl: 'http://langfuse-fanout-collector:4318',
},
},
} as unknown as AppConfig,
});
expect(callArgs.langfuse).toEqual({
deterministicTraceId: true,
publicKey: 'pk-tenant-1',
secretKey: 'sk-tenant-1',
baseUrl: 'http://langfuse-fanout-collector:4318/tenant/eu',
metadata: { 'librechat.tenant.id': 'tenant-1' },
librechatTraceAttributes: {
'librechat.langfuse.tenant_export.enabled': 'true',
'librechat.langfuse.destination': 'eu',
},
tags: ['tenant:tenant-1'],
});
});
it('uses central env Langfuse config when deployment fanout is not enabled', async () => {
process.env.LANGFUSE_PUBLIC_KEY = 'pk-central';
process.env.LANGFUSE_SECRET_KEY = 'sk-central';
process.env.LANGFUSE_BASE_URL = 'https://central.langfuse.example';
const callArgs = await callAndCaptureRunConfig({
tenantId: 'tenant-1',
appConfig: {
langfuse: {
publicKey: 'pk-tenant-1',
secretKey: 'sk-tenant-1',
baseUrl: 'https://cloud.langfuse.com',
},
} as AppConfig,
});
expect(callArgs.langfuse).toEqual({
deterministicTraceId: true,
publicKey: 'pk-central',
secretKey: 'sk-central',
baseUrl: 'https://central.langfuse.example',
metadata: { 'librechat.tenant.id': 'tenant-1' },
tags: ['tenant:tenant-1'],
});
});
it('uses deployment fanout collector URL without auth when only tenant keys are configured', async () => {
process.env.LANGFUSE_PUBLIC_KEY = 'pk-central';
process.env.LANGFUSE_SECRET_KEY = 'sk-central';
process.env.LANGFUSE_BASE_URL = 'https://central.langfuse.example';
process.env.LANGFUSE_FANOUT_ENABLED = 'true';
process.env.LANGFUSE_FANOUT_COLLECTOR_URL = 'http://collector-from-env:4318';
process.env.LANGFUSE_FANOUT_TENANT_BASE_URL = 'https://cloud.langfuse.com';
const callArgs = await callAndCaptureRunConfig({
tenantId: 'tenant-1',
appConfig: {
langfuse: {
publicKey: 'pk-tenant-1',
secretKey: 'sk-tenant-1',
},
} as AppConfig,
});
expect(callArgs.langfuse).toEqual({
deterministicTraceId: true,
baseUrl: 'http://collector-from-env:4318',
metadata: { 'librechat.tenant.id': 'tenant-1' },
tags: ['tenant:tenant-1'],
});
});
it('routes tenant fanout traces to the configured destination for the tenant base URL', async () => {
process.env.LANGFUSE_FANOUT_ENABLED = 'true';
process.env.LANGFUSE_FANOUT_COLLECTOR_URL = 'http://collector-from-env:4318';
const callArgs = await callAndCaptureRunConfig({
tenantId: 'tenant-1',
appConfig: {
langfuse: {
publicKey: 'pk-tenant-1',
secretKey: 'sk-tenant-1',
baseUrl: 'https://us.cloud.langfuse.com',
},
} as AppConfig,
});
expect(callArgs.langfuse).toMatchObject({
publicKey: 'pk-tenant-1',
secretKey: 'sk-tenant-1',
baseUrl: 'http://collector-from-env:4318/tenant/us',
metadata: { 'librechat.tenant.id': 'tenant-1' },
librechatTraceAttributes: {
'librechat.langfuse.tenant_export.enabled': 'true',
'librechat.langfuse.destination': 'us',
},
});
});
it('normalizes trailing slashes when building the tenant-scoped fanout URL', async () => {
process.env.LANGFUSE_FANOUT_ENABLED = 'true';
process.env.LANGFUSE_FANOUT_COLLECTOR_URL = 'http://collector-from-env:4318/';
const callArgs = await callAndCaptureRunConfig({
tenantId: 'tenant-1',
appConfig: {
langfuse: {
publicKey: 'pk-tenant-1',
secretKey: 'sk-tenant-1',
baseUrl: 'https://cloud.langfuse.com',
},
} as AppConfig,
});
expect((callArgs.langfuse as { baseUrl?: string } | undefined)?.baseUrl).toBe(
'http://collector-from-env:4318/tenant/eu',
);
});
it.each(['1', 'yes', 'on'])(
'routes tenant fanout traces when global fanout is %s',
async (value) => {
process.env.LANGFUSE_FANOUT_ENABLED = value;
process.env.LANGFUSE_FANOUT_COLLECTOR_URL = 'http://collector-from-env:4318';
const callArgs = await callAndCaptureRunConfig({
tenantId: 'tenant-1',
appConfig: {
langfuse: {
publicKey: 'pk-tenant-1',
secretKey: 'sk-tenant-1',
baseUrl: 'https://us.cloud.langfuse.com',
},
} as AppConfig,
});
expect(callArgs.langfuse).toMatchObject({
publicKey: 'pk-tenant-1',
secretKey: 'sk-tenant-1',
baseUrl: 'http://collector-from-env:4318/tenant/us',
librechatTraceAttributes: {
'librechat.langfuse.tenant_export.enabled': 'true',
'librechat.langfuse.destination': 'us',
},
});
},
);
it.each(['false', '0', 'no', 'off'])(
'uses central env Langfuse config when global fanout is %s',
async (value) => {
process.env.LANGFUSE_PUBLIC_KEY = 'pk-central';
process.env.LANGFUSE_SECRET_KEY = 'sk-central';
process.env.LANGFUSE_BASE_URL = 'https://central.langfuse.example';
process.env.LANGFUSE_FANOUT_ENABLED = value;
process.env.LANGFUSE_FANOUT_COLLECTOR_URL = 'http://collector-from-env:4318';
const callArgs = await callAndCaptureRunConfig({
tenantId: 'tenant-1',
appConfig: {
langfuse: {
publicKey: 'pk-tenant-1',
secretKey: 'sk-tenant-1',
baseUrl: 'https://cloud.langfuse.com',
},
} as AppConfig,
});
expect(callArgs.langfuse).toEqual({
deterministicTraceId: true,
publicKey: 'pk-central',
secretKey: 'sk-central',
baseUrl: 'https://central.langfuse.example',
metadata: { 'librechat.tenant.id': 'tenant-1' },
tags: ['tenant:tenant-1'],
});
},
);
it('does not append a tenant route to baseUrl when fanout is disabled', async () => {
process.env.LANGFUSE_PUBLIC_KEY = 'pk-central';
process.env.LANGFUSE_SECRET_KEY = 'sk-central';
process.env.LANGFUSE_BASE_URL = 'https://central.langfuse.example';
process.env.LANGFUSE_FANOUT_ENABLED = 'false';
process.env.LANGFUSE_FANOUT_COLLECTOR_URL = 'http://collector-from-env:4318';
const callArgs = await callAndCaptureRunConfig({
tenantId: 'tenant-1',
appConfig: {
langfuse: {
publicKey: 'pk-tenant-1',
secretKey: 'sk-tenant-1',
baseUrl: 'https://cloud.langfuse.com',
},
} as AppConfig,
});
expect(callArgs.langfuse).toMatchObject({
publicKey: 'pk-central',
secretKey: 'sk-central',
baseUrl: 'https://central.langfuse.example',
});
expect(callArgs.langfuse).not.toMatchObject({
baseUrl: 'http://collector-from-env:4318/tenant/eu',
librechatTraceAttributes: expect.any(Object),
});
});
it('uses central env Langfuse config when fanout has no collector URL', async () => {
process.env.LANGFUSE_PUBLIC_KEY = 'pk-central';
process.env.LANGFUSE_SECRET_KEY = 'sk-central';
process.env.LANGFUSE_BASE_URL = 'https://central.langfuse.example';
process.env.LANGFUSE_FANOUT_ENABLED = 'true';
const callArgs = await callAndCaptureRunConfig({
tenantId: 'tenant-1',
appConfig: {
langfuse: {
publicKey: 'pk-tenant-1',
secretKey: 'sk-tenant-1',
baseUrl: 'https://cloud.langfuse.com',
},
} as AppConfig,
});
expect(callArgs.langfuse).toEqual({
deterministicTraceId: true,
publicKey: 'pk-central',
secretKey: 'sk-central',
baseUrl: 'https://central.langfuse.example',
metadata: { 'librechat.tenant.id': 'tenant-1' },
tags: ['tenant:tenant-1'],
});
});
it('uses deployment fanout collector URL without auth when the tenant base URL is not a configured destination', async () => {
process.env.LANGFUSE_PUBLIC_KEY = 'pk-central';
process.env.LANGFUSE_SECRET_KEY = 'sk-central';
process.env.LANGFUSE_BASE_URL = 'https://central.langfuse.example';
process.env.LANGFUSE_FANOUT_ENABLED = 'true';
process.env.LANGFUSE_FANOUT_COLLECTOR_URL = 'http://collector-from-env:4318';
process.env.LANGFUSE_FANOUT_TENANT_DESTINATIONS = 'eu=https://cloud.langfuse.com';
const callArgs = await callAndCaptureRunConfig({
tenantId: 'tenant-1',
appConfig: {
langfuse: {
publicKey: 'pk-tenant-1',
secretKey: 'sk-tenant-1',
baseUrl: 'https://unconfigured-langfuse.example.com',
},
} as AppConfig,
});
expect(callArgs.langfuse).toEqual({
deterministicTraceId: true,
baseUrl: 'http://collector-from-env:4318',
metadata: { 'librechat.tenant.id': 'tenant-1' },
tags: ['tenant:tenant-1'],
});
});
it('uses deployment fanout collector URL without auth when tenant Langfuse config has no keys', async () => {
process.env.LANGFUSE_PUBLIC_KEY = 'pk-central';
process.env.LANGFUSE_SECRET_KEY = 'sk-central';
process.env.LANGFUSE_BASE_URL = 'https://central.langfuse.example';
process.env.LANGFUSE_FANOUT_ENABLED = 'true';
process.env.LANGFUSE_FANOUT_COLLECTOR_URL = 'http://collector-from-env:4318';
const callArgs = await callAndCaptureRunConfig({
tenantId: 'tenant-1',
appConfig: {
langfuse: {},
} as AppConfig,
});
expect(callArgs.langfuse).toEqual({
deterministicTraceId: true,
baseUrl: 'http://collector-from-env:4318',
metadata: { 'librechat.tenant.id': 'tenant-1' },
tags: ['tenant:tenant-1'],
});
});
it('uses deployment fanout collector URL without auth when app config is missing under fanout env', async () => {
process.env.LANGFUSE_PUBLIC_KEY = 'pk-central';
process.env.LANGFUSE_SECRET_KEY = 'sk-central';
process.env.LANGFUSE_BASE_URL = 'https://central.langfuse.example';
process.env.LANGFUSE_FANOUT_ENABLED = 'true';
process.env.LANGFUSE_FANOUT_COLLECTOR_URL = 'http://collector-from-env:4318';
const callArgs = await callAndCaptureRunConfig({
tenantId: 'tenant-1',
});
expect(callArgs.langfuse).toEqual({
deterministicTraceId: true,
baseUrl: 'http://collector-from-env:4318',
metadata: { 'librechat.tenant.id': 'tenant-1' },
tags: ['tenant:tenant-1'],
});
});
it('uses deployment fanout collector URL without auth when tenant fanout export is disabled', async () => {
process.env.LANGFUSE_PUBLIC_KEY = 'pk-central';
process.env.LANGFUSE_SECRET_KEY = 'sk-central';
process.env.LANGFUSE_BASE_URL = 'https://central.langfuse.example';
process.env.LANGFUSE_FANOUT_ENABLED = 'true';
process.env.LANGFUSE_FANOUT_COLLECTOR_URL = 'http://collector-from-env:4318';
process.env.LANGFUSE_FANOUT_TENANT_EXPORT_DISABLED = 'true';
const callArgs = await callAndCaptureRunConfig({
tenantId: 'tenant-1',
appConfig: {
langfuse: {
publicKey: 'pk-tenant-1',
secretKey: 'sk-tenant-1',
},
} as AppConfig,
});
expect(callArgs.langfuse).toEqual({
deterministicTraceId: true,
baseUrl: 'http://collector-from-env:4318',
metadata: { 'librechat.tenant.id': 'tenant-1' },
tags: ['tenant:tenant-1'],
});
});
it('does not disable tenant fanout export for a blank emergency toggle', async () => {
process.env.LANGFUSE_PUBLIC_KEY = 'pk-central';
process.env.LANGFUSE_SECRET_KEY = 'sk-central';
process.env.LANGFUSE_FANOUT_ENABLED = 'true';
process.env.LANGFUSE_FANOUT_COLLECTOR_URL = 'http://collector-from-env:4318';
process.env.LANGFUSE_FANOUT_TENANT_EXPORT_DISABLED = ' ';
const callArgs = await callAndCaptureRunConfig({
tenantId: 'tenant-1',
appConfig: {
langfuse: {
publicKey: 'pk-tenant-1',
secretKey: 'sk-tenant-1',
baseUrl: 'https://cloud.langfuse.com',
},
} as AppConfig,
});
expect(callArgs.langfuse).toEqual({
deterministicTraceId: true,
baseUrl: 'http://collector-from-env:4318/tenant/eu',
metadata: { 'librechat.tenant.id': 'tenant-1' },
publicKey: 'pk-tenant-1',
secretKey: 'sk-tenant-1',
tags: ['tenant:tenant-1'],
librechatTraceAttributes: {
'librechat.langfuse.tenant_export.enabled': 'true',
'librechat.langfuse.destination': 'eu',
},
});
});
it.each(['true', '1', 'yes', 'on'])(
'uses deployment fanout collector URL without auth when the emergency toggle is %s',
async (value) => {
process.env.LANGFUSE_PUBLIC_KEY = 'pk-central';
process.env.LANGFUSE_SECRET_KEY = 'sk-central';
process.env.LANGFUSE_FANOUT_ENABLED = 'true';
process.env.LANGFUSE_FANOUT_COLLECTOR_URL = 'http://collector-from-env:4318';
process.env.LANGFUSE_FANOUT_TENANT_EXPORT_DISABLED = value;
const callArgs = await callAndCaptureRunConfig({
tenantId: 'tenant-1',
appConfig: {
langfuse: {
publicKey: 'pk-tenant-1',
secretKey: 'sk-tenant-1',
baseUrl: 'https://cloud.langfuse.com',
},
} as AppConfig,
});
expect(callArgs.langfuse).toEqual({
deterministicTraceId: true,
baseUrl: 'http://collector-from-env:4318',
metadata: { 'librechat.tenant.id': 'tenant-1' },
tags: ['tenant:tenant-1'],
});
},
);
it.each(['false', '0', 'no', 'off'])(
'routes tenant fanout traces when the emergency toggle is %s',
async (value) => {
process.env.LANGFUSE_PUBLIC_KEY = 'pk-central';
process.env.LANGFUSE_SECRET_KEY = 'sk-central';
process.env.LANGFUSE_FANOUT_ENABLED = 'true';
process.env.LANGFUSE_FANOUT_COLLECTOR_URL = 'http://collector-from-env:4318';
process.env.LANGFUSE_FANOUT_TENANT_EXPORT_DISABLED = value;
const callArgs = await callAndCaptureRunConfig({
tenantId: 'tenant-1',
appConfig: {
langfuse: {
publicKey: 'pk-tenant-1',
secretKey: 'sk-tenant-1',
baseUrl: 'https://cloud.langfuse.com',
},
} as AppConfig,
});
expect(callArgs.langfuse).toEqual({
deterministicTraceId: true,
baseUrl: 'http://collector-from-env:4318/tenant/eu',
metadata: { 'librechat.tenant.id': 'tenant-1' },
publicKey: 'pk-tenant-1',
secretKey: 'sk-tenant-1',
tags: ['tenant:tenant-1'],
librechatTraceAttributes: {
'librechat.langfuse.tenant_export.enabled': 'true',
'librechat.langfuse.destination': 'eu',
},
});
},
);
it('uses central env Langfuse config when tenant fanout.enabled=false overrides deployment fanout env', async () => {
process.env.LANGFUSE_PUBLIC_KEY = 'pk-central';
process.env.LANGFUSE_SECRET_KEY = 'sk-central';
process.env.LANGFUSE_BASE_URL = 'https://central.langfuse.example';
process.env.LANGFUSE_FANOUT_ENABLED = 'true';
process.env.LANGFUSE_FANOUT_COLLECTOR_URL = 'http://collector-from-env:4318';
const callArgs = await callAndCaptureRunConfig({
tenantId: 'tenant-1',
appConfig: {
langfuse: {
publicKey: 'pk-tenant-1',
secretKey: 'sk-tenant-1',
baseUrl: 'https://cloud.langfuse.com',
fanout: {
enabled: false,
},
},
} as AppConfig,
});
expect(callArgs.langfuse).toEqual({
deterministicTraceId: true,
publicKey: 'pk-central',
secretKey: 'sk-central',
baseUrl: 'https://central.langfuse.example',
metadata: { 'librechat.tenant.id': 'tenant-1' },
tags: ['tenant:tenant-1'],
});
});
it('uses central env Langfuse config when tenant fanout.enabled is the string false', async () => {
process.env.LANGFUSE_PUBLIC_KEY = 'pk-central';
process.env.LANGFUSE_SECRET_KEY = 'sk-central';
process.env.LANGFUSE_BASE_URL = 'https://central.langfuse.example';
process.env.LANGFUSE_FANOUT_ENABLED = 'true';
process.env.LANGFUSE_FANOUT_COLLECTOR_URL = 'http://collector-from-env:4318';
const callArgs = await callAndCaptureRunConfig({
tenantId: 'tenant-1',
appConfig: {
langfuse: {
publicKey: 'pk-tenant-1',
secretKey: 'sk-tenant-1',
baseUrl: 'https://cloud.langfuse.com',
fanout: {
enabled: 'false',
},
},
} as unknown as AppConfig,
});
expect(callArgs.langfuse).toEqual({
deterministicTraceId: true,
publicKey: 'pk-central',
secretKey: 'sk-central',
baseUrl: 'https://central.langfuse.example',
metadata: { 'librechat.tenant.id': 'tenant-1' },
tags: ['tenant:tenant-1'],
});
});
it('honors tenant Langfuse enabled=false as a tracing opt-out', async () => {
const callArgs = await callAndCaptureRunConfig({
tenantId: 'tenant-1',
appConfig: {
langfuse: {
enabled: false,
publicKey: 'pk-tenant-1',
secretKey: 'sk-tenant-1',
},
} as AppConfig,
});
expect(callArgs.langfuse).toEqual({
deterministicTraceId: true,
enabled: false,
metadata: { 'librechat.tenant.id': 'tenant-1' },
tags: ['tenant:tenant-1'],
});
});
it('honors tenant Langfuse enabled as the string false', async () => {
const callArgs = await callAndCaptureRunConfig({
tenantId: 'tenant-1',
appConfig: {
langfuse: {
enabled: 'false',
publicKey: 'pk-tenant-1',
secretKey: 'sk-tenant-1',
},
} as unknown as AppConfig,
});
expect(callArgs.langfuse).toEqual({
deterministicTraceId: true,
enabled: false,
metadata: { 'librechat.tenant.id': 'tenant-1' },
tags: ['tenant:tenant-1'],
});
});
});
// ---------------------------------------------------------------------------

View file

@ -1,5 +1,5 @@
import { createAgentChatCompletion } from './service';
import type { ChatCompletionDependencies } from './service';
import { createAgentChatCompletion } from './service';
jest.mock('@librechat/data-schemas', () => ({
logger: {
@ -10,7 +10,11 @@ jest.mock('@librechat/data-schemas', () => ({
},
}));
type CreateRunArgs = { user?: Record<string, unknown> };
type CreateRunArgs = {
user?: Record<string, unknown>;
tenantId?: string;
appConfig?: Record<string, unknown>;
};
type ProcessStreamConfig = { configurable?: Record<string, unknown> };
function createMockReq(user?: Record<string, unknown>) {
@ -104,4 +108,36 @@ describe('createAgentChatCompletion - MCP permission user propagation', () => {
expect(streamConfig.configurable?.user).toEqual({ id: 'api-user' });
expect(streamConfig.configurable?.user).not.toHaveProperty('role');
});
it('forwards appConfig and tenantId to createRun', async () => {
const appConfig = {
endpoints: {
agents: { capabilities: ['execute_code'] },
},
langfuse: {
publicKey: 'pk-tenant-1',
secretKey: 'sk-tenant-1',
},
interfaceConfig: {
modelSelect: true,
},
};
deps.appConfig = appConfig as never;
const req = createMockReq({
id: 'user-123',
tenantId: 'tenant-1',
role: 'USER',
});
await createAgentChatCompletion(req, createMockRes(), deps);
expect(createRun).toHaveBeenCalledTimes(1);
const runArgs = createRun.mock.calls[0][0] as CreateRunArgs;
expect(runArgs.tenantId).toBe('tenant-1');
expect(runArgs.appConfig).toEqual({
endpoints: appConfig.endpoints,
langfuse: appConfig.langfuse,
});
expect(runArgs.appConfig).not.toHaveProperty('interfaceConfig');
});
});

View file

@ -69,15 +69,17 @@ export interface ChatCompletionDependencies {
/** Create agent run */
createRun?: CreateRunFn;
/**
* App config. Optional, but required for agents with `execute_code` in
* their tools: the helper derives `codeEnvAvailable` from
* App config. Optional for basic chat, but required for tenant-scoped
* Langfuse fanout and for agents with `execute_code` in their tools:
* tenant Langfuse keys are forwarded to `createRun`, and the helper derives
* `codeEnvAvailable` from
* `appConfig?.endpoints?.agents?.capabilities` and forwards it into
* `deps.initializeAgent`. When `appConfig` is omitted, the resolved
* `codeEnvAvailable` is `undefined`, so `initializeAgent` skips the
* `execute_code` `bash_tool` + `read_file` expansion entirely and
* code-requesting agents silently lose sandbox tools. Pass `appConfig`
* (even a minimal shape with just `endpoints.agents.capabilities`) to
* keep code execution working.
* keep tenant tracing and code execution working.
*/
appConfig?: AppConfig;
/** Tool execute options for event-driven tool execution */
@ -176,6 +178,7 @@ type CreateRunFn = (params: {
requestBody: Record<string, unknown>;
user: Record<string, unknown>;
tenantId?: string;
appConfig?: Pick<AppConfig, 'endpoints' | 'langfuse'>;
tokenCounter?: (message: unknown) => number;
}) => Promise<{
Graph?: unknown;
@ -526,6 +529,12 @@ export async function createAgentChatCompletion(
},
user: safeUser,
tenantId: typeof reqUser?.tenantId === 'string' ? reqUser.tenantId : undefined,
appConfig: deps.appConfig
? {
endpoints: deps.appConfig.endpoints,
langfuse: deps.appConfig.langfuse,
}
: undefined,
});
if (run) {

View file

@ -39,6 +39,7 @@ import { getProviderConfig } from '~/endpoints/config/providers';
import { extractDefaultParams } from '~/endpoints/openai/llm';
import { resolveHeaders, createSafeUser } from '~/utils/env';
import { getOpenAIConfig } from '~/endpoints/openai/config';
import { buildLangfuseConfig } from '~/langfuse/config';
import { resolveConfigHeaders } from '~/utils/headers';
import { applyTestRunHook } from '~/agents/testHook';
import { isUserProvided } from '~/utils/common';
@ -849,17 +850,6 @@ function buildSubagentConfigs(
return configs;
}
function buildLangfuseConfig(tenantIdInput?: unknown) {
const tenantId = typeof tenantIdInput === 'string' ? tenantIdInput.trim() : '';
return {
deterministicTraceId: true,
...(tenantId !== '' && {
metadata: { 'librechat.tenant.id': tenantId },
tags: [`tenant:${tenantId}`],
}),
};
}
/**
* Creates a new Run instance with custom handlers and configuration.
*
@ -1165,7 +1155,7 @@ export async function createRun({
// feedback can be scored against the trace without a lookup (see the
// feedback route in api/server/routes/messages.js). No-op unless Langfuse
// tracing is enabled. Requires @librechat/agents >= 3.2.21.
langfuse: buildLangfuseConfig(tenantId ?? user?.tenantId),
langfuse: buildLangfuseConfig({ appConfig, tenantId: tenantId ?? user?.tenantId }),
...(enableToolOutputReferences && {
toolOutputReferences: { enabled: true },
}),

View file

@ -0,0 +1,135 @@
import type { AppConfig } from '@librechat/data-schemas';
import type { RunConfig } from '@librechat/agents';
import { resolveLangfuseTenantDestination } from './tenantDestinations';
import { isTrueEnv, normalizeBoolean } from './utils';
import { normalizeString } from '~/utils/text';
type LangfuseRunConfig = NonNullable<RunConfig['langfuse']>;
type LangfuseAppConfig = NonNullable<AppConfig['langfuse']>;
export type LangfuseFanoutConfig = LangfuseAppConfig['fanout'] & {
collectorUrl?: string;
};
type LangfuseRunConfigWithTraceAttributes = LangfuseRunConfig & {
librechatTraceAttributes?: Record<string, string | number | boolean | null | undefined>;
};
const TENANT_EXPORT_ATTRIBUTE = 'librechat.langfuse.tenant_export.enabled';
const TENANT_DESTINATION_ATTRIBUTE = 'librechat.langfuse.destination';
const DEFAULT_BASE_URL = 'https://cloud.langfuse.com';
function appendPath(baseUrl: string, path: string): string {
return `${baseUrl.replace(/\/+$/, '')}${path}`;
}
export function isLangfuseTenantExportEnabled(): boolean {
return !isTrueEnv(process.env.LANGFUSE_FANOUT_TENANT_EXPORT_DISABLED);
}
export function isLangfuseFanoutEnabled(fanout?: LangfuseFanoutConfig): boolean {
const enabled = normalizeBoolean(fanout?.enabled);
return enabled !== false && (enabled === true || isTrueEnv(process.env.LANGFUSE_FANOUT_ENABLED));
}
function mergeTraceMetadata(
base: LangfuseRunConfig['metadata'],
tenantId?: string,
): LangfuseRunConfig['metadata'] | undefined {
if (!tenantId) {
return base;
}
return {
...(base ?? {}),
'librechat.tenant.id': tenantId,
};
}
function mergeTags(tags: string[] | undefined, tenantId?: string): string[] | undefined {
if (!tenantId) {
return tags;
}
return [...new Set([...(tags ?? []), `tenant:${tenantId}`])];
}
function applyCentralEnvConfig(langfuse: LangfuseRunConfigWithTraceAttributes): void {
const publicKey = normalizeString(process.env.LANGFUSE_PUBLIC_KEY);
const secretKey = normalizeString(process.env.LANGFUSE_SECRET_KEY);
if (publicKey && secretKey) {
langfuse.publicKey = publicKey;
langfuse.secretKey = secretKey;
langfuse.baseUrl =
normalizeString(process.env.LANGFUSE_BASE_URL) ??
normalizeString(process.env.LANGFUSE_HOST) ??
normalizeString(process.env.LANGFUSE_BASEURL) ??
DEFAULT_BASE_URL;
}
}
export function buildLangfuseConfig({
appConfig,
tenantId,
}: {
appConfig?: AppConfig;
tenantId?: string;
} = {}): LangfuseRunConfig {
const normalizedTenantId = normalizeString(tenantId);
const config = appConfig?.langfuse;
const langfuse: LangfuseRunConfigWithTraceAttributes = {
deterministicTraceId: true,
};
const metadata = mergeTraceMetadata(undefined, normalizedTenantId);
const tags = mergeTags(undefined, normalizedTenantId);
if (metadata) {
langfuse.metadata = metadata;
}
if (tags) {
langfuse.tags = tags;
}
if (normalizeBoolean(config?.enabled) === false) {
return {
...langfuse,
enabled: false,
};
}
const publicKey = normalizeString(config?.publicKey);
const secretKey = normalizeString(config?.secretKey);
const hasTenantCredentials = Boolean(publicKey && secretKey);
const fanout = config?.fanout as LangfuseFanoutConfig | undefined;
const fanoutEnabled = isLangfuseFanoutEnabled(fanout);
const fanoutCollectorUrl =
normalizeString(fanout?.collectorUrl) ??
normalizeString(process.env.LANGFUSE_FANOUT_COLLECTOR_URL);
const tenantDestination = resolveLangfuseTenantDestination(config?.baseUrl);
const tenantExportDestination = hasTenantCredentials ? tenantDestination : undefined;
const tenantExportCollectorUrl = fanoutCollectorUrl;
const tenantExportEnabled =
hasTenantCredentials &&
fanoutEnabled &&
isLangfuseTenantExportEnabled() &&
tenantExportDestination != null &&
tenantExportCollectorUrl != null;
if (tenantExportEnabled && tenantExportDestination && tenantExportCollectorUrl) {
langfuse.publicKey = publicKey;
langfuse.secretKey = secretKey;
langfuse.baseUrl = appendPath(
tenantExportCollectorUrl,
`/tenant/${tenantExportDestination.key}`,
);
// TODO: Add support in @librechat/agents for Langfuse additionalHeaders and
// route by headers if we need multiple tenant Langfuse exports for one run.
// The destination-scoped URL is the current app-to-gateway routing contract.
langfuse.librechatTraceAttributes = {
...(langfuse.librechatTraceAttributes ?? {}),
[TENANT_EXPORT_ATTRIBUTE]: 'true',
[TENANT_DESTINATION_ATTRIBUTE]: tenantExportDestination.key,
};
} else if (fanoutEnabled && fanoutCollectorUrl) {
langfuse.baseUrl = fanoutCollectorUrl;
} else {
applyCentralEnvConfig(langfuse);
}
return langfuse;
}

View file

@ -0,0 +1,118 @@
import type { AppConfig } from '@librechat/data-schemas';
import type { LangfuseFanoutConfig } from './config';
import { isLangfuseFanoutEnabled, isLangfuseTenantExportEnabled } from './config';
import { isFalseEnv, normalizeBoolean, toBasicAuthorization } from './utils';
import { resolveLangfuseTenantDestination } from './tenantDestinations';
import { normalizeString } from '~/utils/text';
const DEFAULT_BASE_URL = 'https://cloud.langfuse.com';
export type LangfuseScoreDestination = {
name: 'central' | 'tenant';
baseUrl: string;
authorization: string;
};
function isSampleRateEnabled(value?: string): boolean {
if (value == null || value.trim() === '') {
return true;
}
const parsed = Number(value);
return !Number.isFinite(parsed) || parsed !== 0;
}
function isTracingEnabled(): boolean {
return (
!isFalseEnv(process.env.LANGFUSE_TRACING_ENABLED) &&
isSampleRateEnabled(process.env.LANGFUSE_SAMPLE_RATE)
);
}
function getCentralEnvBaseUrl(): string {
return (
normalizeString(process.env.LANGFUSE_BASE_URL) ??
normalizeString(process.env.LANGFUSE_HOST) ??
normalizeString(process.env.LANGFUSE_BASEURL) ??
DEFAULT_BASE_URL
);
}
function getCentralScoreDestination(): LangfuseScoreDestination | undefined {
if (!isTracingEnabled()) {
return undefined;
}
// Central feedback scores are sent directly by the app, not through the
// collector, so they use LibreChat's normal central Langfuse credentials.
// LANGFUSE_FANOUT_CENTRAL_AUTH_HEADER is intentionally collector-only.
const publicKey = normalizeString(process.env.LANGFUSE_PUBLIC_KEY);
const secretKey = normalizeString(process.env.LANGFUSE_SECRET_KEY);
if (!publicKey || !secretKey) {
return undefined;
}
return {
name: 'central',
baseUrl: getCentralEnvBaseUrl(),
authorization: toBasicAuthorization(publicKey, secretKey),
};
}
function getTenantScoreDestination(appConfig?: AppConfig): LangfuseScoreDestination | undefined {
if (!isTracingEnabled()) {
return undefined;
}
if (!isLangfuseTenantExportEnabled()) {
return undefined;
}
const config = appConfig?.langfuse;
if (normalizeBoolean(config?.enabled) === false) {
return undefined;
}
const fanout = config?.fanout as LangfuseFanoutConfig | undefined;
if (!isLangfuseFanoutEnabled(fanout)) {
return undefined;
}
const fanoutCollectorUrl =
normalizeString(fanout?.collectorUrl) ??
normalizeString(process.env.LANGFUSE_FANOUT_COLLECTOR_URL);
if (!fanoutCollectorUrl) {
return undefined;
}
const publicKey = normalizeString(config?.publicKey);
const secretKey = normalizeString(config?.secretKey);
if (!publicKey || !secretKey) {
return undefined;
}
const destination = resolveLangfuseTenantDestination(config?.baseUrl);
if (!destination) {
return undefined;
}
return {
name: 'tenant',
baseUrl: destination.baseUrl,
authorization: toBasicAuthorization(publicKey, secretKey),
};
}
/**
* Score fanout uses Langfuse's direct REST API. Trace fanout may use the OTLP
* collector via appConfig.langfuse.fanout.collectorUrl/LANGFUSE_FANOUT_COLLECTOR_URL.
*/
export function getScoreDestinations(appConfig?: AppConfig): LangfuseScoreDestination[] {
const destinations = [getCentralScoreDestination(), getTenantScoreDestination(appConfig)].filter(
(destination): destination is LangfuseScoreDestination => Boolean(destination),
);
const seen = new Set<string>();
return destinations.filter((destination) => {
const key = `${destination.baseUrl}\n${destination.authorization}`;
if (seen.has(key)) {
return false;
}
seen.add(key);
return true;
});
}

View file

@ -1,8 +1,11 @@
import type { AppConfig } from '@librechat/data-schemas';
jest.mock(
'@librechat/data-schemas',
() => ({
logger: {
debug: jest.fn(),
error: jest.fn(),
},
}),
{ virtual: true },
@ -17,6 +20,14 @@ const langfuseEnvKeys = [
'LANGFUSE_TRACING_ENABLED',
'LANGFUSE_SAMPLE_RATE',
'LANGFUSE_TRACING_ENVIRONMENT',
'LANGFUSE_FANOUT_ENABLED',
'LANGFUSE_FANOUT_COLLECTOR_URL',
'LANGFUSE_FANOUT_TENANT_BASE_URL',
'LANGFUSE_FANOUT_TENANT_DESTINATIONS',
'LANGFUSE_FANOUT_TENANT_EU_BASE_URL',
'LANGFUSE_FANOUT_TENANT_US_BASE_URL',
'LANGFUSE_FANOUT_TENANT_JP_BASE_URL',
'LANGFUSE_FANOUT_TENANT_EXPORT_DISABLED',
];
let fetchMock: jest.SpiedFunction<typeof fetch>;
@ -31,6 +42,11 @@ function setLangfuseCredentials() {
process.env.LANGFUSE_SECRET_KEY = 'secret-key';
}
function enableTenantFanout() {
process.env.LANGFUSE_FANOUT_ENABLED = 'true';
process.env.LANGFUSE_FANOUT_COLLECTOR_URL = 'http://collector:4318';
}
async function loadFeedback(): Promise<typeof import('./feedback')> {
jest.resetModules();
return import('./feedback');
@ -40,6 +56,21 @@ function getFetchMock(): jest.SpiedFunction<typeof fetch> {
return fetchMock;
}
function getTenantAuthorization(
publicKey = 'tenant-public-key',
secretKey = 'tenant-secret-key',
): string {
return `Basic ${Buffer.from(`${publicKey}:${secretKey}`).toString('base64')}`;
}
function getCentralAuthorization(): string {
return getTenantAuthorization('public-key', 'secret-key');
}
function appConfigWithLangfuse(langfuse: AppConfig['langfuse']): AppConfig {
return { langfuse } as AppConfig;
}
describe('Langfuse feedback scores', () => {
beforeEach(() => {
clearLangfuseEnv();
@ -120,18 +151,656 @@ describe('Langfuse feedback scores', () => {
);
});
it('skips scores when Langfuse tracing is disabled', async () => {
process.env.LANGFUSE_TRACING_ENABLED = 'false';
it('posts feedback scores to central fanout and tenant Langfuse projects', async () => {
enableTenantFanout();
process.env.LANGFUSE_BASE_URL = 'http://central-langfuse:3000';
process.env.LANGFUSE_FANOUT_TENANT_BASE_URL = 'http://tenant-langfuse:3000';
const { sendFeedbackScore } = await loadFeedback();
await sendFeedbackScore({
traceId: 'trace-id',
feedback: { rating: 'thumbsDown' },
feedback: { rating: 'thumbsDown', tag: 'wrong' },
metadata: { tenantId: 'tenant-a' },
appConfig: {
langfuse: {
publicKey: 'tenant-public-key',
secretKey: 'tenant-secret-key',
baseUrl: 'http://tenant-langfuse:3000',
},
} as AppConfig,
});
expect(getFetchMock()).not.toHaveBeenCalled();
expect(getFetchMock()).toHaveBeenCalledTimes(2);
expect(getFetchMock()).toHaveBeenNthCalledWith(
1,
'http://central-langfuse:3000/api/public/scores',
expect.objectContaining({
method: 'POST',
headers: expect.objectContaining({
Authorization: getCentralAuthorization(),
}),
}),
);
expect(getFetchMock()).toHaveBeenNthCalledWith(
2,
'http://tenant-langfuse:3000/api/public/scores',
expect.objectContaining({
method: 'POST',
headers: expect.objectContaining({
Authorization: getTenantAuthorization(),
}),
}),
);
const [, tenantInit] = getFetchMock().mock.calls[1];
expect(JSON.parse(tenantInit?.body as string)).toMatchObject({
id: 'feedback-trace-id',
traceId: 'trace-id',
name: 'user-feedback',
value: 0,
metadata: {
rating: 'thumbsDown',
tag: 'wrong',
tenantId: 'tenant-a',
},
});
});
it('skips tenant feedback scores when tenant keys are configured without a tenant base URL', async () => {
enableTenantFanout();
process.env.LANGFUSE_BASE_URL = 'http://central-langfuse:3000';
process.env.LANGFUSE_FANOUT_TENANT_BASE_URL = 'http://tenant-langfuse:3000';
const { sendFeedbackScore } = await loadFeedback();
await sendFeedbackScore({
traceId: 'trace-id',
feedback: { rating: 'thumbsUp' },
appConfig: appConfigWithLangfuse({
publicKey: 'tenant-public-key',
secretKey: 'tenant-secret-key',
}),
});
expect(getFetchMock()).toHaveBeenCalledTimes(1);
expect(getFetchMock()).toHaveBeenCalledWith(
'http://central-langfuse:3000/api/public/scores',
expect.objectContaining({
method: 'POST',
headers: expect.objectContaining({ Authorization: getCentralAuthorization() }),
}),
);
});
it('posts tenant feedback scores to the configured destination for the tenant base URL', async () => {
enableTenantFanout();
process.env.LANGFUSE_BASE_URL = 'http://central-langfuse:3000';
const { sendFeedbackScore } = await loadFeedback();
await sendFeedbackScore({
traceId: 'trace-id',
feedback: { rating: 'thumbsUp' },
appConfig: appConfigWithLangfuse({
publicKey: 'tenant-public-key',
secretKey: 'tenant-secret-key',
baseUrl: 'https://us.cloud.langfuse.com',
}),
});
expect(getFetchMock()).toHaveBeenNthCalledWith(
2,
'https://us.cloud.langfuse.com/api/public/scores',
expect.objectContaining({
method: 'POST',
headers: expect.objectContaining({
Authorization: getTenantAuthorization(),
}),
}),
);
});
it('skips tenant feedback scores when the tenant base URL is not a configured destination', async () => {
process.env.LANGFUSE_BASE_URL = 'http://central-langfuse:3000';
process.env.LANGFUSE_FANOUT_TENANT_DESTINATIONS = 'eu=https://cloud.langfuse.com';
const { sendFeedbackScore } = await loadFeedback();
await sendFeedbackScore({
traceId: 'trace-id',
feedback: { rating: 'thumbsUp' },
appConfig: appConfigWithLangfuse({
publicKey: 'tenant-public-key',
secretKey: 'tenant-secret-key',
baseUrl: 'https://unconfigured-langfuse.example.com',
}),
});
expect(getFetchMock()).toHaveBeenCalledTimes(1);
expect(getFetchMock()).toHaveBeenCalledWith(
'http://central-langfuse:3000/api/public/scores',
expect.objectContaining({
method: 'POST',
headers: expect.objectContaining({ Authorization: getCentralAuthorization() }),
}),
);
});
it('deletes feedback scores from central and tenant Langfuse projects', async () => {
enableTenantFanout();
process.env.LANGFUSE_BASE_URL = 'http://central-langfuse:3000';
process.env.LANGFUSE_FANOUT_TENANT_BASE_URL = 'http://tenant-langfuse:3000';
const { sendFeedbackScore } = await loadFeedback();
await sendFeedbackScore({
traceId: 'trace-id',
feedback: null,
appConfig: {
langfuse: {
publicKey: 'tenant-public-key',
secretKey: 'tenant-secret-key',
baseUrl: 'http://tenant-langfuse:3000',
},
} as AppConfig,
});
expect(getFetchMock()).toHaveBeenCalledTimes(2);
expect(getFetchMock()).toHaveBeenNthCalledWith(
1,
'http://central-langfuse:3000/api/public/scores/feedback-trace-id',
expect.objectContaining({
method: 'DELETE',
headers: { Authorization: getCentralAuthorization() },
}),
);
expect(getFetchMock()).toHaveBeenNthCalledWith(
2,
'http://tenant-langfuse:3000/api/public/scores/feedback-trace-id',
expect.objectContaining({
method: 'DELETE',
headers: {
Authorization: getTenantAuthorization(),
},
}),
);
});
it('posts feedback scores to tenant Langfuse when no central destination is configured', async () => {
enableTenantFanout();
delete process.env.LANGFUSE_PUBLIC_KEY;
delete process.env.LANGFUSE_SECRET_KEY;
process.env.LANGFUSE_FANOUT_TENANT_BASE_URL = 'http://tenant-langfuse:3000';
const { sendFeedbackScore } = await loadFeedback();
await sendFeedbackScore({
traceId: 'trace-id',
feedback: { rating: 'thumbsUp' },
appConfig: appConfigWithLangfuse({
publicKey: 'tenant-public-key',
secretKey: 'tenant-secret-key',
baseUrl: 'http://tenant-langfuse:3000',
}),
});
expect(getFetchMock()).toHaveBeenCalledTimes(1);
expect(getFetchMock()).toHaveBeenCalledWith(
'http://tenant-langfuse:3000/api/public/scores',
expect.objectContaining({
method: 'POST',
headers: expect.objectContaining({ Authorization: getTenantAuthorization() }),
}),
);
});
it('skips tenant scores when tenant Langfuse is disabled but keeps central scores', async () => {
enableTenantFanout();
process.env.LANGFUSE_BASE_URL = 'http://central-langfuse:3000';
const { sendFeedbackScore } = await loadFeedback();
await sendFeedbackScore({
traceId: 'trace-id',
feedback: { rating: 'thumbsUp' },
appConfig: appConfigWithLangfuse({
enabled: false,
publicKey: 'tenant-public-key',
secretKey: 'tenant-secret-key',
}),
});
expect(getFetchMock()).toHaveBeenCalledTimes(1);
expect(getFetchMock()).toHaveBeenCalledWith(
'http://central-langfuse:3000/api/public/scores',
expect.objectContaining({
method: 'POST',
headers: expect.objectContaining({ Authorization: getCentralAuthorization() }),
}),
);
});
it('skips tenant scores when tenant Langfuse enabled is the string false', async () => {
enableTenantFanout();
process.env.LANGFUSE_BASE_URL = 'http://central-langfuse:3000';
const { sendFeedbackScore } = await loadFeedback();
await sendFeedbackScore({
traceId: 'trace-id',
feedback: { rating: 'thumbsUp' },
appConfig: appConfigWithLangfuse({
enabled: 'false',
publicKey: 'tenant-public-key',
secretKey: 'tenant-secret-key',
baseUrl: 'https://cloud.langfuse.com',
} as unknown as AppConfig['langfuse']),
});
expect(getFetchMock()).toHaveBeenCalledTimes(1);
expect(getFetchMock()).toHaveBeenCalledWith(
'http://central-langfuse:3000/api/public/scores',
expect.objectContaining({
method: 'POST',
headers: expect.objectContaining({ Authorization: getCentralAuthorization() }),
}),
);
});
it('skips tenant scores when tenant fanout export is disabled but keeps central scores', async () => {
enableTenantFanout();
process.env.LANGFUSE_BASE_URL = 'http://central-langfuse:3000';
process.env.LANGFUSE_FANOUT_TENANT_BASE_URL = 'http://tenant-langfuse:3000';
process.env.LANGFUSE_FANOUT_TENANT_EXPORT_DISABLED = 'true';
const { sendFeedbackScore } = await loadFeedback();
await sendFeedbackScore({
traceId: 'trace-id',
feedback: { rating: 'thumbsUp' },
appConfig: appConfigWithLangfuse({
publicKey: 'tenant-public-key',
secretKey: 'tenant-secret-key',
}),
});
expect(getFetchMock()).toHaveBeenCalledTimes(1);
expect(getFetchMock()).toHaveBeenCalledWith(
'http://central-langfuse:3000/api/public/scores',
expect.objectContaining({
method: 'POST',
headers: expect.objectContaining({ Authorization: getCentralAuthorization() }),
}),
);
});
it('does not disable tenant scores for a blank emergency toggle', async () => {
enableTenantFanout();
process.env.LANGFUSE_BASE_URL = 'http://central-langfuse:3000';
process.env.LANGFUSE_FANOUT_TENANT_EXPORT_DISABLED = ' ';
const { sendFeedbackScore } = await loadFeedback();
await sendFeedbackScore({
traceId: 'trace-id',
feedback: { rating: 'thumbsUp' },
appConfig: appConfigWithLangfuse({
publicKey: 'tenant-public-key',
secretKey: 'tenant-secret-key',
baseUrl: 'https://cloud.langfuse.com',
}),
});
expect(getFetchMock()).toHaveBeenCalledTimes(2);
expect(getFetchMock()).toHaveBeenCalledWith(
'http://central-langfuse:3000/api/public/scores',
expect.objectContaining({
method: 'POST',
headers: expect.objectContaining({ Authorization: getCentralAuthorization() }),
}),
);
expect(getFetchMock()).toHaveBeenCalledWith(
'https://cloud.langfuse.com/api/public/scores',
expect.objectContaining({
method: 'POST',
headers: expect.objectContaining({ Authorization: getTenantAuthorization() }),
}),
);
});
it.each(['true', '1', 'yes', 'on'])(
'disables tenant scores when the emergency toggle is %s',
async (value) => {
enableTenantFanout();
process.env.LANGFUSE_BASE_URL = 'http://central-langfuse:3000';
process.env.LANGFUSE_FANOUT_TENANT_EXPORT_DISABLED = value;
const { sendFeedbackScore } = await loadFeedback();
await sendFeedbackScore({
traceId: 'trace-id',
feedback: { rating: 'thumbsUp' },
appConfig: appConfigWithLangfuse({
publicKey: 'tenant-public-key',
secretKey: 'tenant-secret-key',
baseUrl: 'https://cloud.langfuse.com',
}),
});
expect(getFetchMock()).toHaveBeenCalledTimes(1);
expect(getFetchMock()).toHaveBeenCalledWith(
'http://central-langfuse:3000/api/public/scores',
expect.objectContaining({
method: 'POST',
headers: expect.objectContaining({ Authorization: getCentralAuthorization() }),
}),
);
},
);
it.each(['false', '0', 'no', 'off'])(
'does not disable tenant scores when the emergency toggle is %s',
async (value) => {
enableTenantFanout();
process.env.LANGFUSE_BASE_URL = 'http://central-langfuse:3000';
process.env.LANGFUSE_FANOUT_TENANT_EXPORT_DISABLED = value;
const { sendFeedbackScore } = await loadFeedback();
await sendFeedbackScore({
traceId: 'trace-id',
feedback: { rating: 'thumbsUp' },
appConfig: appConfigWithLangfuse({
publicKey: 'tenant-public-key',
secretKey: 'tenant-secret-key',
baseUrl: 'https://cloud.langfuse.com',
}),
});
expect(getFetchMock()).toHaveBeenCalledTimes(2);
expect(getFetchMock()).toHaveBeenCalledWith(
'https://cloud.langfuse.com/api/public/scores',
expect.objectContaining({
method: 'POST',
headers: expect.objectContaining({ Authorization: getTenantAuthorization() }),
}),
);
},
);
it('skips tenant scores when global fanout is disabled', async () => {
process.env.LANGFUSE_BASE_URL = 'http://central-langfuse:3000';
const { sendFeedbackScore } = await loadFeedback();
await sendFeedbackScore({
traceId: 'trace-id',
feedback: { rating: 'thumbsUp' },
appConfig: appConfigWithLangfuse({
publicKey: 'tenant-public-key',
secretKey: 'tenant-secret-key',
baseUrl: 'https://cloud.langfuse.com',
}),
});
expect(getFetchMock()).toHaveBeenCalledTimes(1);
expect(getFetchMock()).toHaveBeenCalledWith(
'http://central-langfuse:3000/api/public/scores',
expect.objectContaining({
method: 'POST',
headers: expect.objectContaining({ Authorization: getCentralAuthorization() }),
}),
);
});
it('skips tenant scores when tenant fanout is disabled in app config', async () => {
enableTenantFanout();
process.env.LANGFUSE_BASE_URL = 'http://central-langfuse:3000';
const { sendFeedbackScore } = await loadFeedback();
await sendFeedbackScore({
traceId: 'trace-id',
feedback: { rating: 'thumbsUp' },
appConfig: appConfigWithLangfuse({
publicKey: 'tenant-public-key',
secretKey: 'tenant-secret-key',
baseUrl: 'https://cloud.langfuse.com',
fanout: { enabled: false },
}),
});
expect(getFetchMock()).toHaveBeenCalledTimes(1);
expect(getFetchMock()).toHaveBeenCalledWith(
'http://central-langfuse:3000/api/public/scores',
expect.objectContaining({
method: 'POST',
headers: expect.objectContaining({ Authorization: getCentralAuthorization() }),
}),
);
});
it('skips tenant scores when tenant fanout enabled is the string false', async () => {
enableTenantFanout();
process.env.LANGFUSE_BASE_URL = 'http://central-langfuse:3000';
const { sendFeedbackScore } = await loadFeedback();
await sendFeedbackScore({
traceId: 'trace-id',
feedback: { rating: 'thumbsUp' },
appConfig: appConfigWithLangfuse({
publicKey: 'tenant-public-key',
secretKey: 'tenant-secret-key',
baseUrl: 'https://cloud.langfuse.com',
fanout: { enabled: 'false' },
} as unknown as AppConfig['langfuse']),
});
expect(getFetchMock()).toHaveBeenCalledTimes(1);
expect(getFetchMock()).toHaveBeenCalledWith(
'http://central-langfuse:3000/api/public/scores',
expect.objectContaining({
method: 'POST',
headers: expect.objectContaining({ Authorization: getCentralAuthorization() }),
}),
);
});
it('skips tenant scores when fanout has no collector URL', async () => {
process.env.LANGFUSE_FANOUT_ENABLED = 'true';
process.env.LANGFUSE_BASE_URL = 'http://central-langfuse:3000';
const { sendFeedbackScore } = await loadFeedback();
await sendFeedbackScore({
traceId: 'trace-id',
feedback: { rating: 'thumbsUp' },
appConfig: appConfigWithLangfuse({
publicKey: 'tenant-public-key',
secretKey: 'tenant-secret-key',
baseUrl: 'https://cloud.langfuse.com',
}),
});
expect(getFetchMock()).toHaveBeenCalledTimes(1);
expect(getFetchMock()).toHaveBeenCalledWith(
'http://central-langfuse:3000/api/public/scores',
expect.objectContaining({
method: 'POST',
headers: expect.objectContaining({ Authorization: getCentralAuthorization() }),
}),
);
});
it('deduplicates matching central and tenant score destinations', async () => {
enableTenantFanout();
process.env.LANGFUSE_PUBLIC_KEY = 'tenant-public-key';
process.env.LANGFUSE_SECRET_KEY = 'tenant-secret-key';
process.env.LANGFUSE_BASE_URL = 'https://cloud.langfuse.com';
const { sendFeedbackScore } = await loadFeedback();
await sendFeedbackScore({
traceId: 'trace-id',
feedback: { rating: 'thumbsUp' },
appConfig: appConfigWithLangfuse({
publicKey: 'tenant-public-key',
secretKey: 'tenant-secret-key',
baseUrl: 'https://cloud.langfuse.com',
}),
});
expect(getFetchMock()).toHaveBeenCalledTimes(1);
expect(getFetchMock()).toHaveBeenCalledWith(
'https://cloud.langfuse.com/api/public/scores',
expect.objectContaining({ method: 'POST' }),
);
});
it('attempts every destination and reports partial feedback score failures', async () => {
enableTenantFanout();
process.env.LANGFUSE_BASE_URL = 'http://central-langfuse:3000';
process.env.LANGFUSE_FANOUT_TENANT_BASE_URL = 'http://tenant-langfuse:3000';
fetchMock
.mockResolvedValueOnce(new Response('central down', { status: 500 }))
.mockResolvedValueOnce(new Response(null, { status: 200 }));
const { sendFeedbackScore } = await loadFeedback();
const { logger } = await import('@librechat/data-schemas');
await expect(
sendFeedbackScore({
traceId: 'trace-id',
feedback: { rating: 'thumbsUp' },
appConfig: appConfigWithLangfuse({
publicKey: 'tenant-public-key',
secretKey: 'tenant-secret-key',
baseUrl: 'http://tenant-langfuse:3000',
}),
}),
).rejects.toThrow('langfuse central score create failed: score create 500: central down');
expect(getFetchMock()).toHaveBeenCalledTimes(2);
expect(logger.error).toHaveBeenCalledWith(
expect.stringContaining('[langfuse] central feedback score send failed'),
expect.any(Error),
);
});
it('reports tenant feedback score failures after central succeeds', async () => {
enableTenantFanout();
process.env.LANGFUSE_BASE_URL = 'http://central-langfuse:3000';
process.env.LANGFUSE_FANOUT_TENANT_BASE_URL = 'http://tenant-langfuse:3000';
fetchMock
.mockResolvedValueOnce(new Response(null, { status: 200 }))
.mockResolvedValueOnce(new Response('tenant down', { status: 503 }));
const { sendFeedbackScore } = await loadFeedback();
const { logger } = await import('@librechat/data-schemas');
await expect(
sendFeedbackScore({
traceId: 'trace-id',
feedback: { rating: 'thumbsUp' },
appConfig: appConfigWithLangfuse({
publicKey: 'tenant-public-key',
secretKey: 'tenant-secret-key',
baseUrl: 'http://tenant-langfuse:3000',
}),
}),
).rejects.toThrow('langfuse tenant score create failed: score create 503: tenant down');
expect(getFetchMock()).toHaveBeenCalledTimes(2);
expect(logger.debug).toHaveBeenCalledWith(
expect.stringContaining('[langfuse] central feedback score sent'),
);
expect(logger.error).toHaveBeenCalledWith(
expect.stringContaining('[langfuse] tenant feedback score send failed'),
expect.any(Error),
);
});
it('aggregates feedback score failures when every destination fails', async () => {
enableTenantFanout();
process.env.LANGFUSE_BASE_URL = 'http://central-langfuse:3000';
process.env.LANGFUSE_FANOUT_TENANT_BASE_URL = 'http://tenant-langfuse:3000';
fetchMock
.mockResolvedValueOnce(new Response('central down', { status: 500 }))
.mockResolvedValueOnce(new Response('tenant down', { status: 503 }));
const { sendFeedbackScore } = await loadFeedback();
await expect(
sendFeedbackScore({
traceId: 'trace-id',
feedback: { rating: 'thumbsUp' },
appConfig: appConfigWithLangfuse({
publicKey: 'tenant-public-key',
secretKey: 'tenant-secret-key',
baseUrl: 'http://tenant-langfuse:3000',
}),
}),
).rejects.toThrow(
'langfuse central score create failed: score create 500: central down; langfuse tenant score create failed: score create 503: tenant down',
);
expect(getFetchMock()).toHaveBeenCalledTimes(2);
});
it.each(['false', '0', 'no', 'off'])(
'skips scores when Langfuse tracing is disabled with %s',
async (value) => {
process.env.LANGFUSE_TRACING_ENABLED = value;
const { sendFeedbackScore } = await loadFeedback();
await sendFeedbackScore({
traceId: 'trace-id',
feedback: { rating: 'thumbsDown' },
});
expect(getFetchMock()).not.toHaveBeenCalled();
},
);
it.each(['true', '1', 'yes', 'on'])(
'enables tenant scores when global fanout is %s',
async (value) => {
process.env.LANGFUSE_FANOUT_ENABLED = value;
process.env.LANGFUSE_FANOUT_COLLECTOR_URL = 'http://collector:4318';
process.env.LANGFUSE_BASE_URL = 'http://central-langfuse:3000';
const { sendFeedbackScore } = await loadFeedback();
await sendFeedbackScore({
traceId: 'trace-id',
feedback: { rating: 'thumbsUp' },
appConfig: appConfigWithLangfuse({
publicKey: 'tenant-public-key',
secretKey: 'tenant-secret-key',
baseUrl: 'https://cloud.langfuse.com',
}),
});
expect(getFetchMock()).toHaveBeenCalledTimes(2);
expect(getFetchMock()).toHaveBeenCalledWith(
'https://cloud.langfuse.com/api/public/scores',
expect.objectContaining({
method: 'POST',
headers: expect.objectContaining({ Authorization: getTenantAuthorization() }),
}),
);
},
);
it.each(['false', '0', 'no', 'off'])(
'keeps tenant scores disabled when global fanout is %s',
async (value) => {
process.env.LANGFUSE_FANOUT_ENABLED = value;
process.env.LANGFUSE_FANOUT_COLLECTOR_URL = 'http://collector:4318';
process.env.LANGFUSE_BASE_URL = 'http://central-langfuse:3000';
const { sendFeedbackScore } = await loadFeedback();
await sendFeedbackScore({
traceId: 'trace-id',
feedback: { rating: 'thumbsUp' },
appConfig: appConfigWithLangfuse({
publicKey: 'tenant-public-key',
secretKey: 'tenant-secret-key',
baseUrl: 'https://cloud.langfuse.com',
}),
});
expect(getFetchMock()).toHaveBeenCalledTimes(1);
expect(getFetchMock()).toHaveBeenCalledWith(
'http://central-langfuse:3000/api/public/scores',
expect.objectContaining({
method: 'POST',
headers: expect.objectContaining({ Authorization: getCentralAuthorization() }),
}),
);
},
);
it('skips scores when Langfuse sampling is set to zero', async () => {
process.env.LANGFUSE_SAMPLE_RATE = '0';
const { sendFeedbackScore } = await loadFeedback();

View file

@ -1,4 +1,6 @@
import { logger } from '@librechat/data-schemas';
import type { AppConfig } from '@librechat/data-schemas';
import { getScoreDestinations, type LangfuseScoreDestination } from './destinations';
export type LangfuseFeedback = {
rating?: 'thumbsUp' | 'thumbsDown';
@ -13,39 +15,23 @@ export type SendFeedbackScoreParams = {
feedback?: LangfuseFeedback | null;
metadata?: LangfuseFeedbackMetadata;
observationId?: string;
appConfig?: AppConfig;
};
const DEFAULT_BASE_URL = 'https://cloud.langfuse.com';
const BASE =
process.env.LANGFUSE_BASE_URL ??
process.env.LANGFUSE_HOST ??
process.env.LANGFUSE_BASEURL ??
DEFAULT_BASE_URL;
function isFalseEnv(value?: string): boolean {
return value != null && ['0', 'false', 'no', 'off'].includes(value.trim().toLowerCase());
}
function isSampleRateEnabled(value?: string): boolean {
if (value == null || value.trim() === '') {
return true;
}
const parsed = Number(value);
return !Number.isFinite(parsed) || parsed !== 0;
}
const ENABLED =
Boolean(process.env.LANGFUSE_PUBLIC_KEY && process.env.LANGFUSE_SECRET_KEY) &&
!isFalseEnv(process.env.LANGFUSE_TRACING_ENABLED) &&
isSampleRateEnabled(process.env.LANGFUSE_SAMPLE_RATE);
const AUTHORIZATION = ENABLED
? 'Basic ' +
Buffer.from(`${process.env.LANGFUSE_PUBLIC_KEY}:${process.env.LANGFUSE_SECRET_KEY}`).toString(
'base64',
)
: undefined;
const ENVIRONMENT = process.env.LANGFUSE_TRACING_ENVIRONMENT;
type LangfuseScorePayload = {
id: string;
traceId: string;
name: 'user-feedback';
value: number;
dataType: 'BOOLEAN';
comment?: string;
metadata: Record<string, string | number | boolean>;
observationId?: string;
environment?: string;
};
function cleanMetadata(
metadata: LangfuseFeedbackMetadata,
): Record<string, string | number | boolean> {
@ -61,30 +47,47 @@ function cleanMetadata(
);
}
export async function sendFeedbackScore({
async function deleteScore(destination: LangfuseScoreDestination, scoreId: string): Promise<void> {
const res = await fetch(
`${destination.baseUrl}/api/public/scores/${encodeURIComponent(scoreId)}`,
{
method: 'DELETE',
headers: { Authorization: destination.authorization },
},
);
if (!res.ok && res.status !== 404) {
throw new Error(`score delete ${res.status}: ${await res.text()}`);
}
}
async function createScore(
destination: LangfuseScoreDestination,
payload: LangfuseScorePayload,
): Promise<void> {
const res = await fetch(`${destination.baseUrl}/api/public/scores`, {
method: 'POST',
headers: { Authorization: destination.authorization, 'Content-Type': 'application/json' },
body: JSON.stringify(payload),
});
if (!res.ok) {
throw new Error(`score create ${res.status}: ${await res.text()}`);
}
}
function buildScorePayload({
scoreId,
traceId,
feedback,
metadata = {},
metadata,
observationId,
}: SendFeedbackScoreParams): Promise<void> {
if (!ENABLED || !AUTHORIZATION || !traceId) {
return;
}
const scoreId = `feedback-${traceId}`;
if (!feedback?.rating) {
const res = await fetch(`${BASE}/api/public/scores/${encodeURIComponent(scoreId)}`, {
method: 'DELETE',
headers: { Authorization: AUTHORIZATION },
});
if (!res.ok && res.status !== 404) {
throw new Error(`langfuse score delete ${res.status}: ${await res.text()}`);
}
return;
}
const body = {
}: {
scoreId: string;
traceId: string;
feedback: LangfuseFeedback;
metadata: LangfuseFeedbackMetadata;
observationId?: string;
}): LangfuseScorePayload {
return {
id: scoreId,
traceId,
name: 'user-feedback',
@ -95,14 +98,64 @@ export async function sendFeedbackScore({
...(observationId ? { observationId } : {}),
...(ENVIRONMENT ? { environment: ENVIRONMENT } : {}),
};
const res = await fetch(`${BASE}/api/public/scores`, {
method: 'POST',
headers: { Authorization: AUTHORIZATION, 'Content-Type': 'application/json' },
body: JSON.stringify(body),
});
if (!res.ok) {
throw new Error(`langfuse score create ${res.status}: ${await res.text()}`);
}
logger.debug(`[langfuse] feedback score sent for trace ${traceId} (${feedback.rating})`);
}
export async function sendFeedbackScore({
traceId,
feedback,
metadata = {},
observationId,
appConfig,
}: SendFeedbackScoreParams): Promise<void> {
if (!traceId) {
return;
}
const destinations = getScoreDestinations(appConfig);
if (destinations.length === 0) {
return;
}
const scoreId = `feedback-${traceId}`;
const payload = feedback?.rating
? buildScorePayload({ scoreId, traceId, feedback, metadata, observationId })
: undefined;
const results = await Promise.allSettled(
destinations.map((destination) =>
payload ? createScore(destination, payload) : deleteScore(destination, scoreId),
),
);
const failures: string[] = [];
results.forEach((result, index) => {
const destination = destinations[index];
if (!destination) {
return;
}
if (result.status === 'fulfilled') {
logger.debug(
`[langfuse] ${destination.name} feedback score ${
payload ? 'sent' : 'deleted'
} for trace ${traceId} (${feedback?.rating ?? 'none'})`,
);
return;
}
logger.error(
`[langfuse] ${destination.name} feedback score ${
payload ? 'send' : 'delete'
} failed for trace ${traceId}:`,
result.reason,
);
failures.push(
`langfuse ${destination.name} score ${payload ? 'create' : 'delete'} failed: ${
result.reason instanceof Error ? result.reason.message : String(result.reason)
}`,
);
});
if (failures.length > 0) {
throw new Error(failures.join('; '));
}
}

View file

@ -0,0 +1,111 @@
import { normalizeString } from '~/utils/text';
const DEFAULT_TENANT_DESTINATIONS: Array<[string, string]> = [
['eu', 'https://cloud.langfuse.com'],
['us', 'https://us.cloud.langfuse.com'],
['jp', 'https://jp.cloud.langfuse.com'],
];
const DESTINATIONS_ENV = 'LANGFUSE_FANOUT_TENANT_DESTINATIONS';
const LEGACY_TENANT_BASE_URL_ENV = 'LANGFUSE_FANOUT_TENANT_BASE_URL';
export type LangfuseTenantDestination = {
key: string;
baseUrl: string;
};
function normalizeDestinationKey(value: string): string | undefined {
const normalized = value
.trim()
.toLowerCase()
.replace(/[^a-z0-9_-]+/g, '_');
return /^[a-z][a-z0-9_-]*$/.test(normalized) ? normalized : undefined;
}
function normalizeBaseUrl(value: unknown): string | undefined {
const normalized = normalizeString(value);
if (!normalized) {
return undefined;
}
try {
const url = new URL(normalized);
if (url.protocol !== 'https:' && url.protocol !== 'http:') {
return undefined;
}
url.pathname = url.pathname.replace(/\/+$/, '');
url.search = '';
url.hash = '';
return url.toString().replace(/\/+$/, '');
} catch {
return undefined;
}
}
function destinationEnvName(key: string): string {
return `LANGFUSE_FANOUT_TENANT_${key.toUpperCase().replace(/[^A-Z0-9]+/g, '_')}_BASE_URL`;
}
function parseDestinationList(value: string | undefined): LangfuseTenantDestination[] {
if (!value) {
return [];
}
return value
.split(',')
.map((item) => item.trim())
.filter(Boolean)
.map((item) => {
const index = item.indexOf('=');
if (index < 0) {
return undefined;
}
const key = normalizeDestinationKey(item.slice(0, index));
const baseUrl = normalizeBaseUrl(item.slice(index + 1));
return key && baseUrl ? { key, baseUrl } : undefined;
})
.filter((destination): destination is LangfuseTenantDestination => Boolean(destination));
}
function uniqueDestinations(
destinations: LangfuseTenantDestination[],
): LangfuseTenantDestination[] {
const byKey = new Map<string, LangfuseTenantDestination>();
for (const destination of destinations) {
byKey.set(destination.key, destination);
}
return [...byKey.values()];
}
export function getLangfuseTenantDestinations(): LangfuseTenantDestination[] {
const configuredValue = normalizeString(process.env[DESTINATIONS_ENV]);
const configured = parseDestinationList(configuredValue);
if (configuredValue) {
return uniqueDestinations(configured);
}
const legacyBaseUrl = normalizeBaseUrl(process.env[LEGACY_TENANT_BASE_URL_ENV]);
const defaults = DEFAULT_TENANT_DESTINATIONS.map(([key, defaultBaseUrl]) => ({
key,
baseUrl:
normalizeBaseUrl(process.env[destinationEnvName(key)]) ??
(key === 'eu' ? legacyBaseUrl : undefined) ??
defaultBaseUrl,
}));
return uniqueDestinations(defaults);
}
export function resolveLangfuseTenantDestination(
baseUrl: unknown,
): LangfuseTenantDestination | undefined {
const normalizedBaseUrl = normalizeBaseUrl(baseUrl);
if (!normalizedBaseUrl) {
return undefined;
}
return getLangfuseTenantDestinations().find(
(destination) => destination.baseUrl === normalizedBaseUrl,
);
}

View file

@ -0,0 +1,32 @@
export function toBasicAuthorization(publicKey: string, secretKey: string): string {
return `Basic ${Buffer.from(`${publicKey}:${secretKey}`).toString('base64')}`;
}
const TRUE_ENV_VALUES = new Set(['1', 'true', 'yes', 'on']);
const FALSE_ENV_VALUES = new Set(['0', 'false', 'no', 'off']);
export function normalizeBoolean(value: unknown): boolean | undefined {
if (typeof value === 'boolean') {
return value;
}
if (typeof value !== 'string') {
return undefined;
}
const normalized = value.trim().toLowerCase();
if (TRUE_ENV_VALUES.has(normalized)) {
return true;
}
if (FALSE_ENV_VALUES.has(normalized)) {
return false;
}
return undefined;
}
export function isTrueEnv(value: unknown): boolean {
return normalizeBoolean(value) === true;
}
export function isFalseEnv(value: unknown): boolean {
return normalizeBoolean(value) === false;
}

View file

@ -1,4 +1,4 @@
import { processTextWithTokenLimit, TokenCountFn } from './text';
import { normalizeString, processTextWithTokenLimit, TokenCountFn } from './text';
import Tokenizer, { countTokens } from './tokenizer';
jest.mock('@librechat/data-schemas', () => ({
@ -9,6 +9,15 @@ jest.mock('@librechat/data-schemas', () => ({
},
}));
describe('normalizeString', () => {
it('trims non-empty strings and treats blank or non-string values as undefined', () => {
expect(normalizeString(' value ')).toBe('value');
expect(normalizeString(' ')).toBeUndefined();
expect(normalizeString(null)).toBeUndefined();
expect(normalizeString(123)).toBeUndefined();
});
});
/**
* OLD IMPLEMENTATION (Binary Search) - kept for comparison testing
* This is the original algorithm that caused CPU spikes

View file

@ -3,6 +3,10 @@ import { logger } from '@librechat/data-schemas';
/** Token count function that can be sync or async */
export type TokenCountFn = (text: string) => number | Promise<number>;
export function normalizeString(value: unknown): string | undefined {
return typeof value === 'string' && value.trim() !== '' ? value.trim() : undefined;
}
/**
* Safety buffer multiplier applied to character position estimates during truncation.
*