diff --git a/packages/api/src/agents/__tests__/run-summarization.test.ts b/packages/api/src/agents/__tests__/run-summarization.test.ts index 2bc0da253a98..8c4f94d8ad93 100644 --- a/packages/api/src/agents/__tests__/run-summarization.test.ts +++ b/packages/api/src/agents/__tests__/run-summarization.test.ts @@ -218,7 +218,7 @@ describe('summarizationConfig field passthrough', () => { const agents = await callAndCapture({ summarizationConfig: { enabled: true, - trigger: { type: 'token_count', value: 8000 }, + trigger: { type: 'token_ratio', value: 0.8 }, provider: 'anthropic', model: 'claude-3-haiku', parameters: { temperature: 0.2 }, @@ -233,7 +233,7 @@ describe('summarizationConfig field passthrough', () => { // `enabled` is not forwarded to the agent-level config — it is resolved // into the separate `summarizationEnabled` boolean on the agent input. expect(agents[0].summarizationEnabled).toBe(true); - expect(config.trigger).toEqual({ type: 'token_count', value: 8000 }); + expect(config.trigger).toEqual({ type: 'token_ratio', value: 0.8 }); expect(config.provider).toBe('anthropic'); expect(config.model).toBe('claude-3-haiku'); expect(config.parameters).toEqual({ temperature: 0.2 }); @@ -254,6 +254,31 @@ describe('summarizationConfig field passthrough', () => { expect(config.provider).toBe('openAI'); expect(config.model).toBe('gpt-4o'); }); + + it('preserves `token_ratio` trigger with `value: 0` (documented, extreme-but-valid)', async () => { + const agents = await callAndCapture({ + summarizationConfig: { + enabled: true, + trigger: { type: 'token_ratio', value: 0 }, + }, + }); + const config = agents[0].summarizationConfig as Record; + expect(config.trigger).toEqual({ type: 'token_ratio', value: 0 }); + }); + + it.each([ + ['remaining_tokens', 500], + ['messages_to_refine', 4], + ] as const)('passes %s trigger through unchanged', async (type, value) => { + const agents = await callAndCapture({ + summarizationConfig: { + enabled: true, + trigger: { type, value }, + }, + }); + const config = agents[0].summarizationConfig as Record; + expect(config.trigger).toEqual({ type, value }); + }); }); // --------------------------------------------------------------------------- diff --git a/packages/api/src/agents/run.ts b/packages/api/src/agents/run.ts index b6b5e6a14d90..cb4eda576322 100644 --- a/packages/api/src/agents/run.ts +++ b/packages/api/src/agents/run.ts @@ -195,7 +195,7 @@ function shapeSummarizationConfig( const provider = config?.provider ?? fallbackProvider; const model = config?.model ?? fallbackModel; const trigger = - config?.trigger?.type && config?.trigger?.value + config?.trigger?.type && typeof config?.trigger?.value === 'number' ? { type: config.trigger.type, value: config.trigger.value } : undefined; diff --git a/packages/data-provider/specs/config-schemas.spec.ts b/packages/data-provider/specs/config-schemas.spec.ts index 6e76bced06ac..c5bb6639cd68 100644 --- a/packages/data-provider/specs/config-schemas.spec.ts +++ b/packages/data-provider/specs/config-schemas.spec.ts @@ -7,6 +7,8 @@ import { interfaceSchema, fileStorageSchema, fileStrategiesSchema, + summarizationTriggerSchema, + summarizationConfigSchema, } from '../src/config'; import { tModelSpecPresetSchema, EModelEndpoint } from '../src/schemas'; import { FileSources } from '../src/types/files'; @@ -502,3 +504,109 @@ describe('interfaceSchema', () => { expect(result.modelSelect).toBe(false); }); }); + +describe('summarizationTriggerSchema', () => { + it.each([ + ['token_ratio', 0.8], + ['remaining_tokens', 500], + ['messages_to_refine', 4], + ] as const)('accepts documented trigger type "%s" with a sensible value', (type, value) => { + const result = summarizationTriggerSchema.safeParse({ type, value }); + expect(result.success).toBe(true); + }); + + it('rejects the legacy/typoed "token_count" trigger type', () => { + const result = summarizationTriggerSchema.safeParse({ + type: 'token_count', + value: 8000, + }); + expect(result.success).toBe(false); + }); + + it('rejects unknown trigger types', () => { + const result = summarizationTriggerSchema.safeParse({ + type: 'never_heard_of_it', + value: 1, + }); + expect(result.success).toBe(false); + }); + + it('rejects negative values on any trigger type', () => { + expect(summarizationTriggerSchema.safeParse({ type: 'token_ratio', value: -0.5 }).success).toBe( + false, + ); + expect( + summarizationTriggerSchema.safeParse({ type: 'remaining_tokens', value: -1 }).success, + ).toBe(false); + expect( + summarizationTriggerSchema.safeParse({ type: 'messages_to_refine', value: -1 }).success, + ).toBe(false); + }); + + it('rejects zero for count-based triggers where it has no meaningful effect', () => { + expect( + summarizationTriggerSchema.safeParse({ type: 'remaining_tokens', value: 0 }).success, + ).toBe(false); + expect( + summarizationTriggerSchema.safeParse({ type: 'messages_to_refine', value: 0 }).success, + ).toBe(false); + }); + + it('rejects token_ratio values > 1 to catch the "80 meant as 80%" mistake', () => { + expect(summarizationTriggerSchema.safeParse({ type: 'token_ratio', value: 80 }).success).toBe( + false, + ); + expect(summarizationTriggerSchema.safeParse({ type: 'token_ratio', value: 1.01 }).success).toBe( + false, + ); + }); + + it('accepts token_ratio values at the inclusive 0 and 1 bounds per docs', () => { + expect(summarizationTriggerSchema.safeParse({ type: 'token_ratio', value: 0 }).success).toBe( + true, + ); + expect(summarizationTriggerSchema.safeParse({ type: 'token_ratio', value: 1 }).success).toBe( + true, + ); + }); + + it('allows remaining_tokens and messages_to_refine values above 1 (token/message counts)', () => { + expect( + summarizationTriggerSchema.safeParse({ type: 'remaining_tokens', value: 2000 }).success, + ).toBe(true); + expect( + summarizationTriggerSchema.safeParse({ type: 'messages_to_refine', value: 20 }).success, + ).toBe(true); + }); + + it('rejects non-finite values (Infinity, NaN) for every trigger type', () => { + for (const type of ['token_ratio', 'remaining_tokens', 'messages_to_refine'] as const) { + expect(summarizationTriggerSchema.safeParse({ type, value: Infinity }).success).toBe(false); + expect(summarizationTriggerSchema.safeParse({ type, value: -Infinity }).success).toBe(false); + expect(summarizationTriggerSchema.safeParse({ type, value: NaN }).success).toBe(false); + } + }); + + it('requires integer values for count-based triggers', () => { + expect( + summarizationTriggerSchema.safeParse({ type: 'remaining_tokens', value: 500.5 }).success, + ).toBe(false); + expect( + summarizationTriggerSchema.safeParse({ type: 'messages_to_refine', value: 2.5 }).success, + ).toBe(false); + }); + + it('still allows fractional values for token_ratio', () => { + expect(summarizationTriggerSchema.safeParse({ type: 'token_ratio', value: 0.8 }).success).toBe( + true, + ); + }); + + it('parses inside the full summarization config', () => { + const result = summarizationConfigSchema.safeParse({ + enabled: true, + trigger: { type: 'token_ratio', value: 0.8 }, + }); + expect(result.success).toBe(true); + }); +}); diff --git a/packages/data-provider/src/config.ts b/packages/data-provider/src/config.ts index c0f614decd9b..7203c8d89bbb 100644 --- a/packages/data-provider/src/config.ts +++ b/packages/data-provider/src/config.ts @@ -1020,10 +1020,20 @@ export const memorySchema = z.object({ export type TMemoryConfig = DeepPartial>; -export const summarizationTriggerSchema = z.object({ - type: z.enum(['token_count']), - value: z.number().positive(), -}); +export const summarizationTriggerSchema = z.discriminatedUnion('type', [ + z.object({ + type: z.literal('token_ratio'), + value: z.number().finite().min(0).max(1), + }), + z.object({ + type: z.literal('remaining_tokens'), + value: z.number().finite().int().positive(), + }), + z.object({ + type: z.literal('messages_to_refine'), + value: z.number().finite().int().positive(), + }), +]); export const contextPruningSchema = z.object({ enabled: z.boolean().optional(), diff --git a/packages/data-schemas/src/app/service.spec.ts b/packages/data-schemas/src/app/service.spec.ts new file mode 100644 index 000000000000..80298b3e1821 --- /dev/null +++ b/packages/data-schemas/src/app/service.spec.ts @@ -0,0 +1,80 @@ +import type { DeepPartial, TCustomConfig } from 'librechat-data-provider'; +import { loadSummarizationConfig } from './service'; +import logger from '~/config/winston'; + +jest.mock('~/config/winston', () => ({ + __esModule: true, + default: { + warn: jest.fn(), + info: jest.fn(), + error: jest.fn(), + debug: jest.fn(), + }, +})); + +describe('loadSummarizationConfig', () => { + const warnSpy = logger.warn as jest.Mock; + + beforeEach(() => { + warnSpy.mockClear(); + }); + + it('returns undefined when no summarization config is provided', () => { + expect(loadSummarizationConfig({} as DeepPartial)).toBeUndefined(); + }); + + it('accepts a valid token_ratio trigger', () => { + const result = loadSummarizationConfig({ + summarization: { + enabled: true, + trigger: { type: 'token_ratio', value: 0.8 }, + }, + } as DeepPartial); + + expect(result).toBeDefined(); + expect(result?.enabled).toBe(true); + expect(result?.trigger).toEqual({ type: 'token_ratio', value: 0.8 }); + expect(warnSpy).not.toHaveBeenCalled(); + }); + + it('emits a targeted migration warning when trigger.type is the legacy "token_count"', () => { + const result = loadSummarizationConfig({ + summarization: { + trigger: { type: 'token_count', value: 8000 }, + }, + } as unknown as DeepPartial); + + expect(result).toBeUndefined(); + expect(warnSpy).toHaveBeenCalledTimes(1); + const message = String(warnSpy.mock.calls[0][0]); + expect(message).toContain('token_count'); + expect(message).toContain('token_ratio'); + expect(message).toContain('remaining_tokens'); + expect(message).toContain('messages_to_refine'); + expect(message).toContain('fall back'); + }); + + it('falls back to the generic warning when trigger is a bare string (not an object)', () => { + const result = loadSummarizationConfig({ + summarization: { + trigger: 'token_count', + }, + } as unknown as DeepPartial); + + expect(result).toBeUndefined(); + expect(warnSpy).toHaveBeenCalledTimes(1); + expect(String(warnSpy.mock.calls[0][0])).toContain('Invalid summarization config'); + }); + + it('falls back to the generic warning for other schema violations', () => { + const result = loadSummarizationConfig({ + summarization: { + trigger: { type: 'token_ratio', value: 80 }, + }, + } as unknown as DeepPartial); + + expect(result).toBeUndefined(); + expect(warnSpy).toHaveBeenCalledTimes(1); + expect(String(warnSpy.mock.calls[0][0])).toContain('Invalid summarization config'); + }); +}); diff --git a/packages/data-schemas/src/app/service.ts b/packages/data-schemas/src/app/service.ts index 91407b06c40d..57a5e603ac34 100644 --- a/packages/data-schemas/src/app/service.ts +++ b/packages/data-schemas/src/app/service.ts @@ -15,12 +15,30 @@ import { loadEndpoints } from './endpoints'; import { loadOCRConfig } from './ocr'; import logger from '~/config/winston'; -function loadSummarizationConfig(config: DeepPartial): AppConfig['summarization'] { +export function loadSummarizationConfig( + config: DeepPartial, +): AppConfig['summarization'] { const raw = config.summarization; if (!raw || typeof raw !== 'object') { return undefined; } + if ( + raw.trigger && + typeof raw.trigger === 'object' && + (raw.trigger as { type?: unknown }).type === 'token_count' + ) { + logger.warn( + "[AppService] `summarization.trigger.type: 'token_count'` is no longer supported. " + + "Use 'token_ratio' (0-1), 'remaining_tokens' (positive integer), or " + + "'messages_to_refine' (positive integer). Your `summarization` config will be " + + 'ignored and summarization will fall back to self-summarize defaults (the ' + + "agent's own provider/model, fires on every pruning event) until this is " + + 'corrected.', + ); + return undefined; + } + const parsed = summarizationConfigSchema.safeParse(raw); if (!parsed.success) { logger.warn('[AppService] Invalid summarization config', parsed.error.flatten());