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 { describe, it, expect } from 'vitest';
|
||||||
import { createClientFromConfig } from './index.js';
|
import { createClientFromConfig, anthropicToGitHubModel, createAutoFallbackClient } from './index.js';
|
||||||
import { AnthropicClient } from '../models/anthropic.js';
|
import { AnthropicClient } from '../models/anthropic.js';
|
||||||
import { OpenAIClient } from '../models/openai.js';
|
import { OpenAIClient } from '../models/openai.js';
|
||||||
import { OllamaClient } from '../models/local/ollama.js';
|
import { OllamaClient } from '../models/local/ollama.js';
|
||||||
import { LlamaCppClient } from '../models/local/llamacpp.js';
|
import { LlamaCppClient } from '../models/local/llamacpp.js';
|
||||||
import { GeminiClient } from '../models/gemini.js';
|
import { GeminiClient } from '../models/gemini.js';
|
||||||
import { BedrockClient } from '../models/bedrock.js';
|
import { BedrockClient } from '../models/bedrock.js';
|
||||||
|
import { GitHubModelsClient } from '../models/github.js';
|
||||||
|
|
||||||
describe('createClientFromConfig', () => {
|
describe('createClientFromConfig', () => {
|
||||||
it('creates AnthropicClient for anthropic provider', () => {
|
it('creates AnthropicClient for anthropic provider', () => {
|
||||||
@@ -94,3 +95,45 @@ describe('createClientFromConfig', () => {
|
|||||||
expect(client).toBeInstanceOf(BedrockClient);
|
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 {
|
function createModelRouter(config: Config): ModelRouter {
|
||||||
const models = config.models;
|
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[]>();
|
const tierFallbacks = new Map<ModelTier, ModelClient[]>();
|
||||||
if (models.default.fallback) {
|
|
||||||
tierFallbacks.set('default', [createClientFromConfig(models.default.fallback)]);
|
const tierConfigs: { tier: ModelTier; cfg: typeof models.default | undefined }[] = [
|
||||||
}
|
{ tier: 'default', cfg: models.default },
|
||||||
if (models.fast?.fallback) {
|
{ tier: 'fast', cfg: models.fast },
|
||||||
tierFallbacks.set('fast', [createClientFromConfig(models.fast.fallback)]);
|
{ tier: 'complex', cfg: models.complex },
|
||||||
}
|
{ tier: 'local', cfg: models.local },
|
||||||
if (models.complex?.fallback) {
|
];
|
||||||
tierFallbacks.set('complex', [createClientFromConfig(models.complex.fallback)]);
|
|
||||||
}
|
const autoFallbackTiers: string[] = [];
|
||||||
if (models.local?.fallback) {
|
for (const { tier, cfg } of tierConfigs) {
|
||||||
tierFallbacks.set('local', [createClientFromConfig(models.local.fallback)]);
|
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) {
|
if (tierFallbacks.size > 0) {
|
||||||
const tierNames = Array.from(tierFallbacks.keys()).join(', ');
|
const tierNames = Array.from(tierFallbacks.keys()).join(', ');
|
||||||
console.log(`Per-tier fallbacks configured for: ${tierNames}`);
|
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}, ` +
|
console.log(`Model router: default=${models.default.provider}/${models.default.model}, ` +
|
||||||
`fallback=[${models.fallback_chain.join(', ')}]`);
|
`fallback=[${models.fallback_chain.join(', ')}]`);
|
||||||
|
|||||||
Reference in New Issue
Block a user