mirror of
https://github.com/tiennm99/ccs.git
synced 2026-07-16 20:17:45 +00:00
269 lines
9.4 KiB
TypeScript
269 lines
9.4 KiB
TypeScript
/**
|
|
* 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<Record<string, string>> = 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;
|
|
}
|