feat: auto same-model fallback via GitHub Models when primary Anthropic provider fails

When a tier uses the Anthropic provider and has no user-configured inline
fallback, automatically insert a GitHub Models client for the equivalent
model as a tier fallback. This ensures the same model is tried via an
alternative provider before degrading to the global fallback chain (which
may be a much weaker local model).

Mapping: claude-sonnet-4-20250514 → claude-sonnet-4, etc.
This commit is contained in:
William Valentin
2026-02-07 13:52:53 -08:00
parent d5694649bf
commit 5984c42bfd
2 changed files with 127 additions and 13 deletions
+44 -1
View File
@@ -1,11 +1,12 @@
import { describe, it, expect } from 'vitest';
import { createClientFromConfig } from './index.js';
import { createClientFromConfig, anthropicToGitHubModel, createAutoFallbackClient } from './index.js';
import { AnthropicClient } from '../models/anthropic.js';
import { OpenAIClient } from '../models/openai.js';
import { OllamaClient } from '../models/local/ollama.js';
import { LlamaCppClient } from '../models/local/llamacpp.js';
import { GeminiClient } from '../models/gemini.js';
import { BedrockClient } from '../models/bedrock.js';
import { GitHubModelsClient } from '../models/github.js';
describe('createClientFromConfig', () => {
it('creates AnthropicClient for anthropic provider', () => {
@@ -94,3 +95,45 @@ describe('createClientFromConfig', () => {
expect(client).toBeInstanceOf(BedrockClient);
});
});
describe('anthropicToGitHubModel', () => {
it('maps claude-sonnet-4-20250514 to claude-sonnet-4', () => {
expect(anthropicToGitHubModel('claude-sonnet-4-20250514')).toBe('claude-sonnet-4');
});
it('maps claude-opus-4-20250514 to claude-opus-4', () => {
expect(anthropicToGitHubModel('claude-opus-4-20250514')).toBe('claude-opus-4');
});
it('maps claude-3-5-haiku-20241022 to claude-haiku-4', () => {
expect(anthropicToGitHubModel('claude-3-5-haiku-20241022')).toBe('claude-haiku-4');
});
it('strips date suffix for unknown versioned models', () => {
expect(anthropicToGitHubModel('claude-sonnet-5-20260101')).toBe('claude-sonnet-5');
});
it('returns undefined for models without date suffix', () => {
expect(anthropicToGitHubModel('llama3.2:1b')).toBeUndefined();
});
});
describe('createAutoFallbackClient', () => {
it('creates a GitHubModelsClient for anthropic provider', () => {
const client = createAutoFallbackClient({
provider: 'anthropic',
model: 'claude-sonnet-4-20250514',
});
expect(client).toBeInstanceOf(GitHubModelsClient);
});
it('returns undefined for non-anthropic providers', () => {
expect(createAutoFallbackClient({ provider: 'openai', model: 'gpt-4o' })).toBeUndefined();
expect(createAutoFallbackClient({ provider: 'ollama', model: 'llama3.2:1b' })).toBeUndefined();
expect(createAutoFallbackClient({ provider: 'gemini', model: 'gemini-2.5-pro' })).toBeUndefined();
});
it('returns undefined for unmappable anthropic models', () => {
expect(createAutoFallbackClient({ provider: 'anthropic', model: 'custom-model' })).toBeUndefined();
});
});
+83 -12
View File
@@ -131,6 +131,50 @@ export function createClientFromConfig(cfg: ModelConfig): ModelClient {
}
}
/**
* Map an Anthropic model identifier to its GitHub Models equivalent.
* Returns undefined if no mapping is known.
*/
export function anthropicToGitHubModel(anthropicModel: string): string | undefined {
// Mapping from Anthropic versioned names → GitHub Copilot short names
const MAPPINGS: Record<string, string> = {
'claude-sonnet-4-20250514': 'claude-sonnet-4',
'claude-opus-4-20250514': 'claude-opus-4',
'claude-3-5-haiku-20241022': 'claude-haiku-4',
};
if (MAPPINGS[anthropicModel]) return MAPPINGS[anthropicModel];
// Try stripping date suffix (e.g. "claude-sonnet-4-20260101" → "claude-sonnet-4")
const dateMatch = anthropicModel.match(/^(.+)-\d{8}$/);
if (dateMatch) return dateMatch[1];
return undefined;
}
/**
* For a given tier config using the Anthropic provider, create a GitHub Models
* client with the equivalent model as an automatic same-model fallback.
* Returns undefined if no mapping exists or the tier isn't Anthropic.
*/
export function createAutoFallbackClient(tierConfig: { provider: string; model: string }): ModelClient | undefined {
if (tierConfig.provider !== 'anthropic') return undefined;
const githubModel = anthropicToGitHubModel(tierConfig.model);
if (!githubModel) return undefined;
return new GitHubModelsClient({
model: githubModel,
onLoginRequired: async () => {
const { loginGitHub } = await import('../auth/index.js');
return loginGitHub((userCode, verificationUri) => {
console.log(`GitHub login required. Visit: ${verificationUri}`);
console.log(`Enter code: ${userCode}`);
});
},
});
}
function createModelRouter(config: Config): ModelRouter {
const models = config.models;
@@ -160,25 +204,52 @@ function createModelRouter(config: Config): ModelRouter {
}
}
// Build per-tier fallbacks from inline fallback config
// Build per-tier fallbacks from inline fallback config + auto same-model fallbacks.
// Auto-fallback: when a tier uses Anthropic, automatically insert a GitHub Models
// client for the same model *before* any user-configured inline fallbacks.
// This ensures the same model is tried via an alternative provider before
// degrading to the global fallback chain (which may be a much weaker local model).
const tierFallbacks = new Map<ModelTier, ModelClient[]>();
if (models.default.fallback) {
tierFallbacks.set('default', [createClientFromConfig(models.default.fallback)]);
}
if (models.fast?.fallback) {
tierFallbacks.set('fast', [createClientFromConfig(models.fast.fallback)]);
}
if (models.complex?.fallback) {
tierFallbacks.set('complex', [createClientFromConfig(models.complex.fallback)]);
}
if (models.local?.fallback) {
tierFallbacks.set('local', [createClientFromConfig(models.local.fallback)]);
const tierConfigs: { tier: ModelTier; cfg: typeof models.default | undefined }[] = [
{ tier: 'default', cfg: models.default },
{ tier: 'fast', cfg: models.fast },
{ tier: 'complex', cfg: models.complex },
{ tier: 'local', cfg: models.local },
];
const autoFallbackTiers: string[] = [];
for (const { tier, cfg } of tierConfigs) {
if (!cfg) continue;
const fallbackList: ModelClient[] = [];
// Auto same-model fallback (only when user hasn't configured an inline fallback)
if (!cfg.fallback) {
const autoClient = createAutoFallbackClient(cfg);
if (autoClient) {
fallbackList.push(autoClient);
autoFallbackTiers.push(tier);
}
}
// User-configured inline fallback
if (cfg.fallback) {
fallbackList.push(createClientFromConfig(cfg.fallback));
}
if (fallbackList.length > 0) {
tierFallbacks.set(tier, fallbackList);
}
}
if (tierFallbacks.size > 0) {
const tierNames = Array.from(tierFallbacks.keys()).join(', ');
console.log(`Per-tier fallbacks configured for: ${tierNames}`);
}
if (autoFallbackTiers.length > 0) {
console.log(`Auto same-model fallback (via GitHub Models) for: ${autoFallbackTiers.join(', ')}`);
}
console.log(`Model router: default=${models.default.provider}/${models.default.model}, ` +
`fallback=[${models.fallback_chain.join(', ')}]`);