mirror of
https://github.com/danny-avila/LibreChat.git
synced 2026-09-01 03:27:01 +00:00
🪭 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:
parent
0789a04d11
commit
a0529c9af7
38 changed files with 5699 additions and 130 deletions
|
|
@ -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'],
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
|
|
|
|||
|
|
@ -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');
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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) {
|
||||
|
|
|
|||
|
|
@ -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 },
|
||||
}),
|
||||
|
|
|
|||
135
packages/api/src/langfuse/config.ts
Normal file
135
packages/api/src/langfuse/config.ts
Normal 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;
|
||||
}
|
||||
118
packages/api/src/langfuse/destinations.ts
Normal file
118
packages/api/src/langfuse/destinations.ts
Normal 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;
|
||||
});
|
||||
}
|
||||
|
|
@ -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();
|
||||
|
|
|
|||
|
|
@ -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('; '));
|
||||
}
|
||||
}
|
||||
|
|
|
|||
111
packages/api/src/langfuse/tenantDestinations.ts
Normal file
111
packages/api/src/langfuse/tenantDestinations.ts
Normal 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,
|
||||
);
|
||||
}
|
||||
32
packages/api/src/langfuse/utils.ts
Normal file
32
packages/api/src/langfuse/utils.ts
Normal 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;
|
||||
}
|
||||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
*
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue