refactor(01-01): extract model client logic into src/daemon/models.ts
- Move createClientFromConfig, anthropicToGitHubModel, createAutoFallbackClient, createModelRouter to dedicated module - Add re-exports from daemon/index.ts for backward compatibility - clientFactory.test.ts passes without modification - Reduces daemon/index.ts by ~248 lines
This commit is contained in:
+4
-251
@@ -1,10 +1,10 @@
|
|||||||
import { Lifecycle } from './lifecycle.js';
|
import { Lifecycle } from './lifecycle.js';
|
||||||
import type { Config, ModelConfig } from '../config/index.js';
|
import { createClientFromConfig, anthropicToGitHubModel, createAutoFallbackClient, createModelRouter } from './models.js';
|
||||||
|
import type { Config } from '../config/index.js';
|
||||||
import type { AudioTranscriptionConfig } from '../models/media.js';
|
import type { AudioTranscriptionConfig } from '../models/media.js';
|
||||||
import type { Attachment } from '../channels/types.js';
|
import type { Attachment } from '../channels/types.js';
|
||||||
import { isSupportedAudio, transcribeAudio } from '../models/media.js';
|
import { isSupportedAudio, transcribeAudio } from '../models/media.js';
|
||||||
import { AnthropicClient, OpenAIClient, OllamaClient, LlamaCppClient, GeminiClient, BedrockClient, GitHubModelsClient, ModelRouter, DEFAULT_RETRY_CONFIG } from '../models/index.js';
|
import { ModelRouter } from '../models/index.js';
|
||||||
import type { ModelClient, RetryConfig, ModelTier } from '../models/index.js';
|
|
||||||
import { AgentOrchestrator, type DelegationConfig } from '../backends/index.js';
|
import { AgentOrchestrator, type DelegationConfig } from '../backends/index.js';
|
||||||
import { OutboundAttachmentCollector } from '../backends/native/attachments.js';
|
import { OutboundAttachmentCollector } from '../backends/native/attachments.js';
|
||||||
import { SessionStore, SessionManager, parseDuration } from '../session/index.js';
|
import { SessionStore, SessionManager, parseDuration } from '../session/index.js';
|
||||||
@@ -67,254 +67,6 @@ function loadSystemPrompt(config: Config): string {
|
|||||||
return result.prompt;
|
return result.prompt;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* Create a ModelClient from a provider config entry.
|
|
||||||
* Dispatches on the `provider` field so all tiers and fallback entries
|
|
||||||
* use the correct client implementation.
|
|
||||||
*/
|
|
||||||
export function createClientFromConfig(cfg: ModelConfig): ModelClient {
|
|
||||||
switch (cfg.provider) {
|
|
||||||
case 'anthropic':
|
|
||||||
return new AnthropicClient({
|
|
||||||
model: cfg.model,
|
|
||||||
apiKey: cfg.api_key,
|
|
||||||
authToken: cfg.auth_token,
|
|
||||||
});
|
|
||||||
case 'openai':
|
|
||||||
return new OpenAIClient({
|
|
||||||
model: cfg.model,
|
|
||||||
apiKey: cfg.api_key,
|
|
||||||
});
|
|
||||||
case 'ollama':
|
|
||||||
return new OllamaClient({
|
|
||||||
model: cfg.model,
|
|
||||||
host: cfg.endpoint,
|
|
||||||
numGpu: cfg.num_gpu,
|
|
||||||
});
|
|
||||||
case 'llamacpp':
|
|
||||||
return new LlamaCppClient({
|
|
||||||
endpoint: cfg.endpoint ?? 'http://localhost:8080',
|
|
||||||
model: cfg.model,
|
|
||||||
authToken: cfg.auth_token,
|
|
||||||
});
|
|
||||||
case 'gemini':
|
|
||||||
return new GeminiClient({
|
|
||||||
model: cfg.model,
|
|
||||||
apiKey: cfg.api_key,
|
|
||||||
});
|
|
||||||
case 'openrouter':
|
|
||||||
return new OpenAIClient({
|
|
||||||
model: cfg.model,
|
|
||||||
apiKey: cfg.api_key ?? process.env.OPENROUTER_API_KEY,
|
|
||||||
baseURL: cfg.endpoint ?? 'https://openrouter.ai/api/v1',
|
|
||||||
});
|
|
||||||
case 'zhipuai':
|
|
||||||
return new OpenAIClient({
|
|
||||||
model: cfg.model,
|
|
||||||
apiKey: cfg.api_key ?? process.env.ZHIPUAI_API_KEY,
|
|
||||||
baseURL: cfg.endpoint ?? 'https://api.z.ai/api/paas/v4',
|
|
||||||
});
|
|
||||||
case 'xai':
|
|
||||||
return new OpenAIClient({
|
|
||||||
model: cfg.model,
|
|
||||||
apiKey: cfg.api_key ?? process.env.XAI_API_KEY,
|
|
||||||
baseURL: cfg.endpoint ?? 'https://api.x.ai/v1',
|
|
||||||
});
|
|
||||||
case 'bedrock':
|
|
||||||
return new BedrockClient({
|
|
||||||
model: cfg.model,
|
|
||||||
region: cfg.endpoint,
|
|
||||||
accessKeyId: cfg.api_key,
|
|
||||||
secretAccessKey: cfg.auth_token,
|
|
||||||
});
|
|
||||||
case 'github':
|
|
||||||
return new GitHubModelsClient({
|
|
||||||
model: anthropicToGitHubModel(cfg.model) ?? cfg.model,
|
|
||||||
apiKey: cfg.api_key,
|
|
||||||
endpoint: cfg.endpoint,
|
|
||||||
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}`);
|
|
||||||
});
|
|
||||||
},
|
|
||||||
});
|
|
||||||
default:
|
|
||||||
throw new Error(`Unknown model provider: ${(cfg as Record<string, unknown>).provider}`);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Map an Anthropic model identifier to its GitHub Models equivalent.
|
|
||||||
* Returns undefined if no mapping is known.
|
|
||||||
*
|
|
||||||
* Anthropic uses hyphens and date suffixes: claude-sonnet-4-5-20250929
|
|
||||||
* GitHub Copilot uses dots, no dates: claude-sonnet-4.5
|
|
||||||
*/
|
|
||||||
export function anthropicToGitHubModel(anthropicModel: string): string | undefined {
|
|
||||||
// Explicit mappings for known models
|
|
||||||
const MAPPINGS: Record<string, string> = {
|
|
||||||
// Sonnet family
|
|
||||||
'claude-sonnet-4-20250514': 'claude-sonnet-4',
|
|
||||||
'claude-sonnet-4-5-20250929': 'claude-sonnet-4.5',
|
|
||||||
// Opus family
|
|
||||||
'claude-opus-4-20250514': 'claude-opus-4',
|
|
||||||
'claude-opus-4-5-20250918': 'claude-opus-4.5',
|
|
||||||
'claude-opus-4-6-20250715': 'claude-opus-4.6',
|
|
||||||
// Haiku family
|
|
||||||
'claude-3-5-haiku-20241022': 'claude-haiku-4.5',
|
|
||||||
'claude-haiku-4-5-20251001': 'claude-haiku-4.5',
|
|
||||||
};
|
|
||||||
|
|
||||||
if (MAPPINGS[anthropicModel]) return MAPPINGS[anthropicModel];
|
|
||||||
|
|
||||||
// Generic fallback: strip date suffix, then convert trailing -N to .N
|
|
||||||
// only when preceded by another digit (i.e. "4-5" → "4.5", not "sonnet-5" → "sonnet.5")
|
|
||||||
// e.g. "claude-sonnet-4-7-20260301" → "claude-sonnet-4-7" → "claude-sonnet-4.7"
|
|
||||||
const dateMatch = anthropicModel.match(/^(.+)-\d{8}$/);
|
|
||||||
if (dateMatch) {
|
|
||||||
const base = dateMatch[1];
|
|
||||||
// Convert "claude-sonnet-4-5" → "claude-sonnet-4.5" (digit-hyphen-digit at end)
|
|
||||||
const dotted = base.replace(/(\d)-(\d+)$/, '$1.$2');
|
|
||||||
return dotted;
|
|
||||||
}
|
|
||||||
|
|
||||||
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}`);
|
|
||||||
});
|
|
||||||
},
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
export function createModelRouter(config: Config): ModelRouter {
|
|
||||||
const models = config.models;
|
|
||||||
|
|
||||||
const defaultClient = createClientFromConfig(models.default);
|
|
||||||
|
|
||||||
const fastClient = models.fast ? createClientFromConfig(models.fast) : undefined;
|
|
||||||
const complexClient = models.complex ? createClientFromConfig(models.complex) : undefined;
|
|
||||||
const localClient = models.local ? createClientFromConfig(models.local) : undefined;
|
|
||||||
|
|
||||||
// Build fallback chain — each entry references a tier name or 'local'
|
|
||||||
const fallbackChain: ModelClient[] = [];
|
|
||||||
for (const providerName of models.fallback_chain) {
|
|
||||||
if (providerName === 'local' && localClient) {
|
|
||||||
fallbackChain.push(localClient);
|
|
||||||
} else if (providerName === 'default') {
|
|
||||||
// Allows re-trying the default provider in the chain
|
|
||||||
fallbackChain.push(defaultClient);
|
|
||||||
} else if (providerName === 'fast' && fastClient) {
|
|
||||||
fallbackChain.push(fastClient);
|
|
||||||
} else if (providerName === 'complex' && complexClient) {
|
|
||||||
fallbackChain.push(complexClient);
|
|
||||||
} else if (models.local_providers?.[providerName]) {
|
|
||||||
// Named provider from local_providers map
|
|
||||||
fallbackChain.push(createClientFromConfig(models.local_providers[providerName]));
|
|
||||||
} else {
|
|
||||||
console.warn(`Fallback chain entry "${providerName}" not found — skipping`);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// 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 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(', ')}]`);
|
|
||||||
|
|
||||||
// Build retry config if enabled
|
|
||||||
const retryConfig: RetryConfig | undefined = config.retry.enabled ? {
|
|
||||||
maxRetries: config.retry.max_retries,
|
|
||||||
initialDelayMs: config.retry.initial_delay_ms,
|
|
||||||
backoffMultiplier: config.retry.backoff_multiplier,
|
|
||||||
maxDelayMs: config.retry.max_delay_ms,
|
|
||||||
nonRetryablePatterns: DEFAULT_RETRY_CONFIG.nonRetryablePatterns,
|
|
||||||
} : undefined;
|
|
||||||
|
|
||||||
if (retryConfig) {
|
|
||||||
console.log(`Retry policy: max_retries=${retryConfig.maxRetries}, initial_delay=${retryConfig.initialDelayMs}ms`);
|
|
||||||
}
|
|
||||||
|
|
||||||
return new ModelRouter({
|
|
||||||
default: defaultClient,
|
|
||||||
fast: fastClient,
|
|
||||||
complex: complexClient,
|
|
||||||
local: localClient,
|
|
||||||
fallbackChain,
|
|
||||||
tierFallbacks,
|
|
||||||
retryConfig,
|
|
||||||
labels: {
|
|
||||||
default: `${models.default.provider}/${models.default.model}`,
|
|
||||||
...(models.fast ? { fast: `${models.fast.provider}/${models.fast.model}` } : {}),
|
|
||||||
...(models.complex ? { complex: `${models.complex.provider}/${models.complex.model}` } : {}),
|
|
||||||
...(models.local ? { local: `${models.local.provider}/${models.local.model}` } : {}),
|
|
||||||
},
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Create the unified message handler for the channel registry.
|
* Create the unified message handler for the channel registry.
|
||||||
* Each channel+sender pair gets its own AgentOrchestrator backed by a persistent session.
|
* Each channel+sender pair gets its own AgentOrchestrator backed by a persistent session.
|
||||||
@@ -1085,3 +837,4 @@ export async function startDaemon(config: Config): Promise<DaemonContext> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export { Lifecycle } from './lifecycle.js';
|
export { Lifecycle } from './lifecycle.js';
|
||||||
|
export { createClientFromConfig, anthropicToGitHubModel, createAutoFallbackClient, createModelRouter } from './models.js';
|
||||||
|
|||||||
@@ -0,0 +1,251 @@
|
|||||||
|
import type { Config, ModelConfig } from '../config/index.js';
|
||||||
|
import { AnthropicClient, OpenAIClient, OllamaClient, LlamaCppClient, GeminiClient, BedrockClient, GitHubModelsClient, ModelRouter, DEFAULT_RETRY_CONFIG } from '../models/index.js';
|
||||||
|
import type { ModelClient, RetryConfig, ModelTier } from '../models/index.js';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Create a ModelClient from a provider config entry.
|
||||||
|
* Dispatches on the `provider` field so all tiers and fallback entries
|
||||||
|
* use the correct client implementation.
|
||||||
|
*/
|
||||||
|
export function createClientFromConfig(cfg: ModelConfig): ModelClient {
|
||||||
|
switch (cfg.provider) {
|
||||||
|
case 'anthropic':
|
||||||
|
return new AnthropicClient({
|
||||||
|
model: cfg.model,
|
||||||
|
apiKey: cfg.api_key,
|
||||||
|
authToken: cfg.auth_token,
|
||||||
|
});
|
||||||
|
case 'openai':
|
||||||
|
return new OpenAIClient({
|
||||||
|
model: cfg.model,
|
||||||
|
apiKey: cfg.api_key,
|
||||||
|
});
|
||||||
|
case 'ollama':
|
||||||
|
return new OllamaClient({
|
||||||
|
model: cfg.model,
|
||||||
|
host: cfg.endpoint,
|
||||||
|
numGpu: cfg.num_gpu,
|
||||||
|
});
|
||||||
|
case 'llamacpp':
|
||||||
|
return new LlamaCppClient({
|
||||||
|
endpoint: cfg.endpoint ?? 'http://localhost:8080',
|
||||||
|
model: cfg.model,
|
||||||
|
authToken: cfg.auth_token,
|
||||||
|
});
|
||||||
|
case 'gemini':
|
||||||
|
return new GeminiClient({
|
||||||
|
model: cfg.model,
|
||||||
|
apiKey: cfg.api_key,
|
||||||
|
});
|
||||||
|
case 'openrouter':
|
||||||
|
return new OpenAIClient({
|
||||||
|
model: cfg.model,
|
||||||
|
apiKey: cfg.api_key ?? process.env.OPENROUTER_API_KEY,
|
||||||
|
baseURL: cfg.endpoint ?? 'https://openrouter.ai/api/v1',
|
||||||
|
});
|
||||||
|
case 'zhipuai':
|
||||||
|
return new OpenAIClient({
|
||||||
|
model: cfg.model,
|
||||||
|
apiKey: cfg.api_key ?? process.env.ZHIPUAI_API_KEY,
|
||||||
|
baseURL: cfg.endpoint ?? 'https://api.z.ai/api/paas/v4',
|
||||||
|
});
|
||||||
|
case 'xai':
|
||||||
|
return new OpenAIClient({
|
||||||
|
model: cfg.model,
|
||||||
|
apiKey: cfg.api_key ?? process.env.XAI_API_KEY,
|
||||||
|
baseURL: cfg.endpoint ?? 'https://api.x.ai/v1',
|
||||||
|
});
|
||||||
|
case 'bedrock':
|
||||||
|
return new BedrockClient({
|
||||||
|
model: cfg.model,
|
||||||
|
region: cfg.endpoint,
|
||||||
|
accessKeyId: cfg.api_key,
|
||||||
|
secretAccessKey: cfg.auth_token,
|
||||||
|
});
|
||||||
|
case 'github':
|
||||||
|
return new GitHubModelsClient({
|
||||||
|
model: anthropicToGitHubModel(cfg.model) ?? cfg.model,
|
||||||
|
apiKey: cfg.api_key,
|
||||||
|
endpoint: cfg.endpoint,
|
||||||
|
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}`);
|
||||||
|
});
|
||||||
|
},
|
||||||
|
});
|
||||||
|
default:
|
||||||
|
throw new Error(`Unknown model provider: ${(cfg as Record<string, unknown>).provider}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Map an Anthropic model identifier to its GitHub Models equivalent.
|
||||||
|
* Returns undefined if no mapping is known.
|
||||||
|
*
|
||||||
|
* Anthropic uses hyphens and date suffixes: claude-sonnet-4-5-20250929
|
||||||
|
* GitHub Copilot uses dots, no dates: claude-sonnet-4.5
|
||||||
|
*/
|
||||||
|
export function anthropicToGitHubModel(anthropicModel: string): string | undefined {
|
||||||
|
// Explicit mappings for known models
|
||||||
|
const MAPPINGS: Record<string, string> = {
|
||||||
|
// Sonnet family
|
||||||
|
'claude-sonnet-4-20250514': 'claude-sonnet-4',
|
||||||
|
'claude-sonnet-4-5-20250929': 'claude-sonnet-4.5',
|
||||||
|
// Opus family
|
||||||
|
'claude-opus-4-20250514': 'claude-opus-4',
|
||||||
|
'claude-opus-4-5-20250918': 'claude-opus-4.5',
|
||||||
|
'claude-opus-4-6-20250715': 'claude-opus-4.6',
|
||||||
|
// Haiku family
|
||||||
|
'claude-3-5-haiku-20241022': 'claude-haiku-4.5',
|
||||||
|
'claude-haiku-4-5-20251001': 'claude-haiku-4.5',
|
||||||
|
};
|
||||||
|
|
||||||
|
if (MAPPINGS[anthropicModel]) return MAPPINGS[anthropicModel];
|
||||||
|
|
||||||
|
// Generic fallback: strip date suffix, then convert trailing -N to .N
|
||||||
|
// only when preceded by another digit (i.e. "4-5" → "4.5", not "sonnet-5" → "sonnet.5")
|
||||||
|
// e.g. "claude-sonnet-4-7-20260301" → "claude-sonnet-4-7" → "claude-sonnet-4.7"
|
||||||
|
const dateMatch = anthropicModel.match(/^(.+)-\d{8}$/);
|
||||||
|
if (dateMatch) {
|
||||||
|
const base = dateMatch[1];
|
||||||
|
// Convert "claude-sonnet-4-5" → "claude-sonnet-4.5" (digit-hyphen-digit at end)
|
||||||
|
const dotted = base.replace(/(\d)-(\d+)$/, '$1.$2');
|
||||||
|
return dotted;
|
||||||
|
}
|
||||||
|
|
||||||
|
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}`);
|
||||||
|
});
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export function createModelRouter(config: Config): ModelRouter {
|
||||||
|
const models = config.models;
|
||||||
|
|
||||||
|
const defaultClient = createClientFromConfig(models.default);
|
||||||
|
|
||||||
|
const fastClient = models.fast ? createClientFromConfig(models.fast) : undefined;
|
||||||
|
const complexClient = models.complex ? createClientFromConfig(models.complex) : undefined;
|
||||||
|
const localClient = models.local ? createClientFromConfig(models.local) : undefined;
|
||||||
|
|
||||||
|
// Build fallback chain — each entry references a tier name or 'local'
|
||||||
|
const fallbackChain: ModelClient[] = [];
|
||||||
|
for (const providerName of models.fallback_chain) {
|
||||||
|
if (providerName === 'local' && localClient) {
|
||||||
|
fallbackChain.push(localClient);
|
||||||
|
} else if (providerName === 'default') {
|
||||||
|
// Allows re-trying the default provider in the chain
|
||||||
|
fallbackChain.push(defaultClient);
|
||||||
|
} else if (providerName === 'fast' && fastClient) {
|
||||||
|
fallbackChain.push(fastClient);
|
||||||
|
} else if (providerName === 'complex' && complexClient) {
|
||||||
|
fallbackChain.push(complexClient);
|
||||||
|
} else if (models.local_providers?.[providerName]) {
|
||||||
|
// Named provider from local_providers map
|
||||||
|
fallbackChain.push(createClientFromConfig(models.local_providers[providerName]));
|
||||||
|
} else {
|
||||||
|
console.warn(`Fallback chain entry "${providerName}" not found — skipping`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 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 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(', ')}]`);
|
||||||
|
|
||||||
|
// Build retry config if enabled
|
||||||
|
const retryConfig: RetryConfig | undefined = config.retry.enabled ? {
|
||||||
|
maxRetries: config.retry.max_retries,
|
||||||
|
initialDelayMs: config.retry.initial_delay_ms,
|
||||||
|
backoffMultiplier: config.retry.backoff_multiplier,
|
||||||
|
maxDelayMs: config.retry.max_delay_ms,
|
||||||
|
nonRetryablePatterns: DEFAULT_RETRY_CONFIG.nonRetryablePatterns,
|
||||||
|
} : undefined;
|
||||||
|
|
||||||
|
if (retryConfig) {
|
||||||
|
console.log(`Retry policy: max_retries=${retryConfig.maxRetries}, initial_delay=${retryConfig.initialDelayMs}ms`);
|
||||||
|
}
|
||||||
|
|
||||||
|
return new ModelRouter({
|
||||||
|
default: defaultClient,
|
||||||
|
fast: fastClient,
|
||||||
|
complex: complexClient,
|
||||||
|
local: localClient,
|
||||||
|
fallbackChain,
|
||||||
|
tierFallbacks,
|
||||||
|
retryConfig,
|
||||||
|
labels: {
|
||||||
|
default: `${models.default.provider}/${models.default.model}`,
|
||||||
|
...(models.fast ? { fast: `${models.fast.provider}/${models.fast.model}` } : {}),
|
||||||
|
...(models.complex ? { complex: `${models.complex.provider}/${models.complex.model}` } : {}),
|
||||||
|
...(models.local ? { local: `${models.local.provider}/${models.local.model}` } : {}),
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user