diff --git a/src/config/schema.test.ts b/src/config/schema.test.ts index 7415888..fa7b703 100644 --- a/src/config/schema.test.ts +++ b/src/config/schema.test.ts @@ -89,6 +89,57 @@ describe('configSchema — routing', () => { }); }); +describe('configSchema — per-tier fallback', () => { + const minimalConfig = { + telegram: { bot_token: 'test', allowed_chat_ids: [1] }, + models: { default: { provider: 'anthropic', model: 'claude-3' } }, + }; + + it('accepts per-tier fallback config', () => { + const result = configSchema.parse({ + ...minimalConfig, + models: { + default: { + provider: 'anthropic', + model: 'claude-sonnet-4-5-20250929', + fallback: { provider: 'github', model: 'claude-sonnet-4-5-20250929' }, + }, + fast: { + provider: 'anthropic', + model: 'claude-haiku-4-5-20251001', + fallback: { provider: 'github', model: 'claude-haiku-4-5-20251001' }, + }, + }, + }); + expect(result.models.default.fallback?.provider).toBe('github'); + expect(result.models.fast?.fallback?.provider).toBe('github'); + }); + + it('works without fallback field (backward compat)', () => { + const result = configSchema.parse(minimalConfig); + expect(result.models.default.fallback).toBeUndefined(); + }); + + it('fallback does not itself accept a nested fallback', () => { + const result = configSchema.parse({ + ...minimalConfig, + models: { + default: { + provider: 'anthropic', + model: 'claude-3', + fallback: { + provider: 'github', + model: 'claude-3', + fallback: { provider: 'ollama', model: 'llama' }, + }, + }, + }, + }); + // Zod strips unknown keys from the base schema, so nested fallback is dropped + expect((result.models.default.fallback as Record)?.fallback).toBeUndefined(); + }); +}); + describe('configSchema automation', () => { const baseConfig = { telegram: { bot_token: 'test-token', allowed_chat_ids: [123] }, diff --git a/src/config/schema.ts b/src/config/schema.ts index 642c842..26e0f6d 100644 --- a/src/config/schema.ts +++ b/src/config/schema.ts @@ -18,7 +18,7 @@ const serverSchema = z.object({ auth_http: z.boolean().default(true), }); -const modelConfigSchema = z.object({ +const modelConfigBaseSchema = z.object({ provider: z.enum(['anthropic', 'openai', 'gemini', 'ollama', 'llamacpp', 'openrouter', 'bedrock', 'github']), model: z.string(), endpoint: z.string().optional(), @@ -29,6 +29,10 @@ const modelConfigSchema = z.object({ context_window: z.number().optional(), }); +const modelConfigSchema = modelConfigBaseSchema.extend({ + fallback: modelConfigBaseSchema.optional(), +}); + const modelsSchema = z.object({ local: modelConfigSchema.optional(), fast: modelConfigSchema.optional(),