/** * Model ID normalization helpers. * * Handles provider-aware compatibility between dotted and hyphenated Claude * model version formats (e.g., 4.6 vs 4-6). */ import { CLIProxyProvider } from './types'; /** Env vars that carry model identifiers. */ export const MODEL_ENV_VAR_KEYS = [ 'ANTHROPIC_MODEL', 'ANTHROPIC_DEFAULT_OPUS_MODEL', 'ANTHROPIC_DEFAULT_SONNET_MODEL', 'ANTHROPIC_DEFAULT_HAIKU_MODEL', ] as const; type ProviderLike = CLIProxyProvider | string | null | undefined; const CLAUDE_DOTTED_VERSION_REGEX = /claude-(sonnet|opus|haiku)-(\d+)\.(\d+)(?=(?:$|-|\[|\(|\/))/gi; const CLAUDE_DOTTED_THINKING_REGEX = /claude-(sonnet|opus|haiku)-(\d+)\.(\d+)-thinking(?=(?:$|-|\[|\(|\/))/gi; const DEPRECATED_ANTIGRAVITY_SONNET_46_THINKING_REGEX = /claude-sonnet-4(?:[.-])6-thinking(?=(?:$|-|\[|\(|\/))/gi; const CANONICAL_ANTIGRAVITY_SONNET_46_MODEL = 'claude-sonnet-4-6'; const DENIED_ANTIGRAVITY_MODEL_REGEX = /claude-(?:opus|sonnet)-4(?:[.-])5(?:-thinking)?(?=(?:$|[^a-z0-9]))/i; const DENIED_ANTIGRAVITY_OPUS_45_REGEX = /claude-opus-4(?:[.-])5(?:-thinking)?(?=(?:$|[^a-z0-9]))/gi; const DENIED_ANTIGRAVITY_SONNET_45_REGEX = /claude-sonnet-4(?:[.-])5(?:-thinking)?(?=(?:$|[^a-z0-9]))/gi; const CANONICAL_ANTIGRAVITY_OPUS_46_MODEL = 'claude-opus-4-6-thinking'; const CODEX_EFFORT_SUFFIX_REGEX = /-(xhigh|high|medium)$/i; const IFLOW_LEGACY_MODEL_ALIASES: Readonly> = Object.freeze({ 'iflow-default': 'qwen3-coder-plus', 'kimi-k2.5': 'kimi-k2', 'deepseek-v3.2-chat': 'deepseek-v3.2', 'glm-4.7': 'glm-4.6', 'minimax-m2.5': 'qwen3-coder-plus', }); function trimModelId(model: string): string { return model.trim(); } function splitBaseModelAndSuffix(model: string): { baseModel: string; suffix: string } { const openParenIndex = model.indexOf('('); const openBracketIndex = model.indexOf('['); const suffixIndexCandidates = [openParenIndex, openBracketIndex].filter((index) => index >= 0); if (suffixIndexCandidates.length === 0) { return { baseModel: model, suffix: '' }; } const suffixStart = Math.min(...suffixIndexCandidates); return { baseModel: model.slice(0, suffixStart), suffix: model.slice(suffixStart), }; } /** * Extract provider segment from `/api/provider/{provider}` request paths. * * @example * extractProviderFromPathname('/api/provider/agy/v1/messages') * // => 'agy' */ export function extractProviderFromPathname(pathname: string): string | null { const match = pathname.match(/\/api\/provider\/([^/]+)/i); if (!match?.[1]) return null; return match[1].toLowerCase(); } /** Whether the provider uses Antigravity model routing conventions. */ export function isAntigravityProvider(provider: ProviderLike): boolean { if (typeof provider !== 'string') return false; const normalized = provider.trim().toLowerCase(); return normalized === 'agy' || normalized === 'antigravity'; } /** Whether provider maps to Codex model canonicalization rules. */ export function isCodexProvider(provider: ProviderLike): boolean { if (typeof provider !== 'string') return false; return provider.trim().toLowerCase() === 'codex'; } /** Whether provider maps to iFlow model alias canonicalization rules. */ export function isIFlowProvider(provider: ProviderLike): boolean { if (typeof provider !== 'string') return false; return provider.trim().toLowerCase() === 'iflow'; } /** Normalize Codex effort-suffixed IDs to canonical IDs. */ export function stripCodexEffortSuffix(model: string): string { return model.replace(CODEX_EFFORT_SUFFIX_REGEX, ''); } /** * Normalize known legacy iFlow model aliases to current upstream model IDs. * Preserves suffixes such as (budget) and [1m]. */ export function normalizeIFlowLegacyModelAliases(model: string): string { const trimmed = trimModelId(model); const { baseModel, suffix } = splitBaseModelAndSuffix(trimmed); const replacement = IFLOW_LEGACY_MODEL_ALIASES[baseModel.trim().toLowerCase()]; if (!replacement) { return trimmed; } return `${replacement}${suffix}`; } /** Normalize Claude dotted major.minor IDs to hyphenated format. */ export function normalizeClaudeDottedMajorMinor(model: string): string { return model.replace( CLAUDE_DOTTED_VERSION_REGEX, (_match: string, family: string, major: string, minor: string) => `claude-${family.toLowerCase()}-${major}-${minor}` ); } /** * Normalize only dotted Claude thinking IDs to hyphenated format. * Keeps non-thinking dotted IDs unchanged. */ export function normalizeClaudeDottedThinkingMajorMinor(model: string): string { return model.replace( CLAUDE_DOTTED_THINKING_REGEX, (_match: string, family: string, major: string, minor: string) => `claude-${family.toLowerCase()}-${major}-${minor}-thinking` ); } /** * Antigravity no longer exposes `claude-sonnet-4-6-thinking`. * Canonicalize legacy aliases to `claude-sonnet-4-6` while preserving suffixes. */ export function normalizeDeprecatedAntigravityModelAliases(model: string): string { return model.replace( DEPRECATED_ANTIGRAVITY_SONNET_46_THINKING_REGEX, CANONICAL_ANTIGRAVITY_SONNET_46_MODEL ); } /** * Migrate denylisted Antigravity Claude 4.5 aliases to supported Claude 4.6 IDs. * This is intended for repairing stale local settings at runtime. */ export function migrateDeniedAntigravityModelAliases(model: string): string { const normalized = normalizeClaudeDottedMajorMinor(trimModelId(model)); return normalized .replace(DENIED_ANTIGRAVITY_OPUS_45_REGEX, CANONICAL_ANTIGRAVITY_OPUS_46_MODEL) .replace(DENIED_ANTIGRAVITY_SONNET_45_REGEX, CANONICAL_ANTIGRAVITY_SONNET_46_MODEL); } /** * Deprecated Antigravity models that are denylisted and should not be used. */ export function isDeniedAntigravityModelId(model: string): boolean { return DENIED_ANTIGRAVITY_MODEL_REGEX.test(trimModelId(model)); } /** * Returns deny reason when model ID is blocked for the given provider. */ export function getDeniedModelIdReasonForProvider( model: string, provider: ProviderLike ): string | null { if (!isAntigravityProvider(provider)) return null; const normalized = normalizeClaudeDottedMajorMinor(trimModelId(model)); if (!isDeniedAntigravityModelId(normalized)) return null; return 'Antigravity denylist: Claude Opus 4.5 and Claude Sonnet 4.5 are deprecated.'; } /** * Normalize model ID for a specific provider. * Antigravity requires hyphenated Claude major.minor model IDs. * * @example * normalizeModelIdForProvider('claude-opus-4.6-thinking', 'agy') * // => 'claude-opus-4-6-thinking' * * @example * normalizeModelIdForProvider('claude-opus-4.6-thinking', 'gemini') * // => 'claude-opus-4.6-thinking' */ export function normalizeModelIdForProvider(model: string, provider: ProviderLike): string { const trimmedModel = trimModelId(model); if (isIFlowProvider(provider)) { return normalizeIFlowLegacyModelAliases(trimmedModel); } if (!isAntigravityProvider(provider)) return trimmedModel; const normalizedDottedVersion = normalizeClaudeDottedMajorMinor(trimmedModel); return normalizeDeprecatedAntigravityModelAliases(normalizedDottedVersion); } /** * Canonicalize model ID for provider-specific compatibility. * - Codex: strip effort suffixes. * - Antigravity: normalize dotted/historical aliases. */ export function canonicalizeModelIdForProvider(model: string, provider: ProviderLike): string { const trimmedModel = trimModelId(model); const withoutCodexSuffix = isCodexProvider(provider) ? stripCodexEffortSuffix(trimmedModel) : trimmedModel; return normalizeModelIdForProvider(withoutCodexSuffix, provider); } /** * Normalize model ID for request routing. * - Antigravity routes: normalize all dotted Claude major.minor forms. * - Root/composite routes: normalize only thinking forms to avoid mutating * valid non-thinking dotted IDs used by other providers. * * @example * normalizeModelIdForRouting('claude-sonnet-4.6-thinking', null) * // => 'claude-sonnet-4-6' * * @example * normalizeModelIdForRouting('claude-sonnet-4.6', null) * // => 'claude-sonnet-4.6' */ export function normalizeModelIdForRouting(model: string, provider: ProviderLike): string { const trimmedModel = trimModelId(model); if (isAntigravityProvider(provider)) { return normalizeModelIdForProvider(trimmedModel, provider); } // Explicit provider routes still apply provider-specific canonicalization. if (typeof provider === 'string' && provider.trim().length > 0) { return normalizeModelIdForProvider(trimmedModel, provider); } const normalizedThinking = normalizeClaudeDottedThinkingMajorMinor(trimmedModel); return normalizeDeprecatedAntigravityModelAliases(normalizedThinking); } /** * Normalize model-related env vars for a provider. * Returns original object when no changes are required. * * @example * normalizeModelEnvVarsForProvider( * { ANTHROPIC_MODEL: 'claude-sonnet-4.6-thinking' }, * 'agy' * ) * // => { ANTHROPIC_MODEL: 'claude-sonnet-4-6' } */ export function normalizeModelEnvVarsForProvider( envVars: NodeJS.ProcessEnv, provider: ProviderLike, keys: readonly string[] = MODEL_ENV_VAR_KEYS ): NodeJS.ProcessEnv { let nextEnv: NodeJS.ProcessEnv | null = null; for (const key of keys) { const value = envVars[key]; if (typeof value !== 'string' || value.trim().length === 0) continue; const normalizedValue = normalizeModelIdForProvider(value, provider); if (normalizedValue === value) continue; if (!nextEnv) nextEnv = { ...envVars }; nextEnv[key] = normalizedValue; } return nextEnv ?? envVars; }