From c8c3c74fdecdcc5a7abe7981250a1c3b0f69feb8 Mon Sep 17 00:00:00 2001 From: William Valentin Date: Sat, 7 Feb 2026 12:08:17 -0800 Subject: [PATCH] feat: add per-tier fallback field to model config schema Each model tier (fast, default, complex, local) can now specify an optional fallback provider config that the router will try before falling through to the global fallback chain. Co-Authored-By: Claude Opus 4.6 --- src/config/schema.test.ts | 51 +++++++++++++++++++++++++++++++++++++++ src/config/schema.ts | 6 ++++- 2 files changed, 56 insertions(+), 1 deletion(-) 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(),