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:
@@ -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
@@ -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(', ')}]`);
|
||||
|
||||
Reference in New Issue
Block a user