From 6429781e8fc7ec5d72aedb7b2409f26f0c739e00 Mon Sep 17 00:00:00 2001 From: Tam Nhu Tran Date: Sat, 21 Feb 2026 02:14:09 +0700 Subject: [PATCH] fix(cliproxy): normalize provider-aware Claude model IDs --- src/cliproxy/config/env-builder.ts | 40 ++++--- src/cliproxy/config/thinking-config.ts | 10 +- src/cliproxy/executor/env-resolver.ts | 6 +- src/cliproxy/index.ts | 10 ++ src/cliproxy/model-catalog.ts | 8 +- src/cliproxy/model-id-normalizer.ts | 104 ++++++++++++++++++ src/cliproxy/tool-sanitization-proxy.ts | 24 +++- .../cliproxy/composite-env-routing.test.ts | 21 ++++ .../unit/cliproxy/composite-thinking.test.ts | 19 ++++ .../cliproxy/env-builder-provider-url.test.ts | 25 ++++- .../cliproxy/model-catalog-compat.test.ts | 17 +++ .../unit/cliproxy/model-id-normalizer.test.ts | 85 ++++++++++++++ ...ool-sanitization-proxy-integration.test.ts | 71 ++++++++++++ 13 files changed, 408 insertions(+), 32 deletions(-) create mode 100644 src/cliproxy/model-id-normalizer.ts create mode 100644 tests/unit/cliproxy/model-catalog-compat.test.ts create mode 100644 tests/unit/cliproxy/model-id-normalizer.test.ts diff --git a/src/cliproxy/config/env-builder.ts b/src/cliproxy/config/env-builder.ts index d4353572..277f4d41 100644 --- a/src/cliproxy/config/env-builder.ts +++ b/src/cliproxy/config/env-builder.ts @@ -19,6 +19,11 @@ import { CLIPROXY_DEFAULT_PORT, } from './port-manager'; import { getProviderSettingsPath } from './path-resolver'; +import { + MODEL_ENV_VAR_KEYS, + normalizeModelEnvVarsForProvider, + normalizeModelIdForProvider, +} from '../model-id-normalizer'; /** Settings file structure for user overrides */ interface ProviderSettings { @@ -30,14 +35,6 @@ const DEPRECATED_MODEL_PREFIX = 'gemini-claude-'; /** Replacement prefix matching actual upstream model names */ const UPSTREAM_MODEL_PREFIX = 'claude-'; -/** Env vars that contain model names and may need migration */ -const MODEL_ENV_KEYS = [ - 'ANTHROPIC_MODEL', - 'ANTHROPIC_DEFAULT_OPUS_MODEL', - 'ANTHROPIC_DEFAULT_SONNET_MODEL', - 'ANTHROPIC_DEFAULT_HAIKU_MODEL', -]; - /** * Migrate deprecated gemini-claude-* model names to upstream claude-* names in a settings file. * CLIProxyAPI registry no longer recognizes the gemini-claude-* prefix convention. @@ -49,7 +46,7 @@ function migrateDeprecatedModelNames(settingsPath: string, settings: ProviderSet if (!settings.env || typeof settings.env !== 'object') return false; let migrated = false; - for (const key of MODEL_ENV_KEYS) { + for (const key of MODEL_ENV_VAR_KEYS) { const value = settings.env[key]; if (typeof value !== 'string') continue; @@ -124,10 +121,12 @@ export function getClaudeEnvVars( } = baseEnvVars; // Merge core env vars with additional env vars from base config - return { + const mergedEnv = { ...coreEnvVars, ...additionalEnvVars, // Includes ANTHROPIC_MAX_TOKENS, etc. }; + + return normalizeModelEnvVarsForProvider(mergedEnv, provider); } /** @@ -174,7 +173,7 @@ function ensureRequiredEnvVars( ); } - return result; + return normalizeModelEnvVarsForProvider(result, provider); } /** Localhost hostnames used for local CLIProxy endpoints */ @@ -458,7 +457,7 @@ export function getRemoteEnvVars( ANTHROPIC_AUTH_TOKEN: remoteConfig.authToken || getEffectiveApiKey(), }; - return env; + return normalizeModelEnvVarsForProvider(env, provider) as Record; } /** Remote config for composite variant (passed from env-resolver) */ @@ -519,10 +518,19 @@ export function getCompositeEnvVars( const validPort = validatePort(port); // Defensive: handle missing tiers gracefully - const opusModel = tiers.opus?.model; - const sonnetModel = tiers.sonnet?.model; - const haikuModel = tiers.haiku?.model; - const defaultModel = tiers[defaultTier]?.model; + const opusModel = tiers.opus?.model + ? normalizeModelIdForProvider(tiers.opus.model, tiers.opus.provider) + : undefined; + const sonnetModel = tiers.sonnet?.model + ? normalizeModelIdForProvider(tiers.sonnet.model, tiers.sonnet.provider) + : undefined; + const haikuModel = tiers.haiku?.model + ? normalizeModelIdForProvider(tiers.haiku.model, tiers.haiku.provider) + : undefined; + const defaultTierModel = tiers[defaultTier]; + const defaultModel = defaultTierModel?.model + ? normalizeModelIdForProvider(defaultTierModel.model, defaultTierModel.provider) + : undefined; // If default tier is missing, we cannot proceed meaningfully if (!defaultModel) { diff --git a/src/cliproxy/config/thinking-config.ts b/src/cliproxy/config/thinking-config.ts index 840b87e3..76e67752 100644 --- a/src/cliproxy/config/thinking-config.ts +++ b/src/cliproxy/config/thinking-config.ts @@ -8,6 +8,7 @@ import { ThinkingConfig, DEFAULT_THINKING_TIER_DEFAULTS } from '../../config/uni import { getThinkingConfig } from '../../config/unified-config-loader'; import { supportsThinking } from '../model-catalog'; import { isThinkingOffValue, validateThinking } from '../thinking-validator'; +import { normalizeModelIdForProvider } from '../model-id-normalizer'; import { warn } from '../../utils/ui'; /** Model tier types for thinking budget defaults */ @@ -19,22 +20,23 @@ export type ModelTier = 'opus' | 'sonnet' | 'haiku'; */ function normalizeModelForThinkingLookup(model: string, provider: CLIProxyProvider): string { const withoutExtendedContext = model.replace(/\[1m\]$/i, '').trim(); + const providerNormalized = normalizeModelIdForProvider(withoutExtendedContext, provider); - if (provider !== 'codex') return withoutExtendedContext; + if (provider !== 'codex') return providerNormalized; // New codex suffix form: gpt-5.3-codex-high -> gpt-5.3-codex - const codexSuffixMatch = withoutExtendedContext.match(/^(.*)-(xhigh|high|medium)$/i); + const codexSuffixMatch = providerNormalized.match(/^(.*)-(xhigh|high|medium)$/i); if (codexSuffixMatch?.[1]) { return codexSuffixMatch[1].trim(); } // Legacy codex suffix form: gpt-5.3-codex(high) -> gpt-5.3-codex - const codexLegacyMatch = withoutExtendedContext.match(/^(.*)\((xhigh|high|medium)\)$/i); + const codexLegacyMatch = providerNormalized.match(/^(.*)\((xhigh|high|medium)\)$/i); if (codexLegacyMatch?.[1]) { return codexLegacyMatch[1].trim(); } - return withoutExtendedContext; + return providerNormalized; } /** diff --git a/src/cliproxy/executor/env-resolver.ts b/src/cliproxy/executor/env-resolver.ts index ea9dbcdb..ace7a18d 100644 --- a/src/cliproxy/executor/env-resolver.ts +++ b/src/cliproxy/executor/env-resolver.ts @@ -24,6 +24,7 @@ import { stripClaudeCodeEnv } from '../../utils/shell-executor'; import { CodexReasoningProxy } from '../codex-reasoning-proxy'; import { ToolSanitizationProxy } from '../tool-sanitization-proxy'; import { HttpsTunnelProxy } from '../https-tunnel-proxy'; +import { normalizeModelIdForProvider } from '../model-id-normalizer'; export interface RemoteProxyConfig { host: string; @@ -271,10 +272,11 @@ export function applyFallback( } as const; const result = { ...env }; const originalModel = result[tierEnvMap[failedTier]]; - result[tierEnvMap[failedTier]] = fallback.model; + const normalizedFallbackModel = normalizeModelIdForProvider(fallback.model, fallback.provider); + result[tierEnvMap[failedTier]] = normalizedFallbackModel; // If failed tier is default tier, also update ANTHROPIC_MODEL if (result.ANTHROPIC_MODEL === originalModel) { - result.ANTHROPIC_MODEL = fallback.model; + result.ANTHROPIC_MODEL = normalizedFallbackModel; } return result; } diff --git a/src/cliproxy/index.ts b/src/cliproxy/index.ts index 0975e3cb..a250bb2d 100644 --- a/src/cliproxy/index.ts +++ b/src/cliproxy/index.ts @@ -84,6 +84,16 @@ export { // Model catalog and configuration export type { ModelEntry, ProviderCatalog } from './model-catalog'; export { MODEL_CATALOG, supportsModelConfig, getProviderCatalog, findModel } from './model-catalog'; +export { + MODEL_ENV_VAR_KEYS, + extractProviderFromPathname, + isAntigravityProvider, + normalizeClaudeDottedMajorMinor, + normalizeClaudeDottedThinkingMajorMinor, + normalizeModelIdForProvider, + normalizeModelIdForRouting, + normalizeModelEnvVarsForProvider, +} from './model-id-normalizer'; export { hasUserSettings, getCurrentModel, diff --git a/src/cliproxy/model-catalog.ts b/src/cliproxy/model-catalog.ts index e03d6b6a..13b6c45c 100644 --- a/src/cliproxy/model-catalog.ts +++ b/src/cliproxy/model-catalog.ts @@ -6,6 +6,7 @@ */ import { CLIProxyProvider } from './types'; +import { normalizeModelIdForProvider } from './model-id-normalizer'; /** * Thinking support configuration for a model. @@ -322,7 +323,12 @@ export function findModel(provider: CLIProxyProvider, modelId: string): ModelEnt const catalog = MODEL_CATALOG[provider]; if (!catalog || !modelId) return undefined; const normalizedId = modelId.trim().toLowerCase(); - return catalog.models.find((m) => m.id.toLowerCase() === normalizedId); + const providerNormalizedId = normalizeModelIdForProvider(normalizedId, provider) + .trim() + .toLowerCase(); + const lookupCandidates = new Set([normalizedId, providerNormalizedId]); + + return catalog.models.find((m) => lookupCandidates.has(m.id.toLowerCase())); } /** diff --git a/src/cliproxy/model-id-normalizer.ts b/src/cliproxy/model-id-normalizer.ts new file mode 100644 index 00000000..7d8ee86b --- /dev/null +++ b/src/cliproxy/model-id-normalizer.ts @@ -0,0 +1,104 @@ +/** + * 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; + +/** Extract provider segment from /api/provider/{provider} paths. */ +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'; +} + +/** 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` + ); +} + +/** + * Normalize model ID for a specific provider. + * Antigravity requires hyphenated Claude major.minor model IDs. + */ +export function normalizeModelIdForProvider(model: string, provider: ProviderLike): string { + if (!isAntigravityProvider(provider)) return model; + return normalizeClaudeDottedMajorMinor(model); +} + +/** + * 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. + */ +export function normalizeModelIdForRouting(model: string, provider: ProviderLike): string { + if (isAntigravityProvider(provider)) { + return normalizeClaudeDottedMajorMinor(model); + } + return normalizeClaudeDottedThinkingMajorMinor(model); +} + +/** + * Normalize model-related env vars for a provider. + * Returns original object when no changes are required. + */ +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; +} diff --git a/src/cliproxy/tool-sanitization-proxy.ts b/src/cliproxy/tool-sanitization-proxy.ts index 36c7c40f..3dc0b379 100644 --- a/src/cliproxy/tool-sanitization-proxy.ts +++ b/src/cliproxy/tool-sanitization-proxy.ts @@ -18,6 +18,7 @@ import * as os from 'os'; import { URL } from 'url'; import { ToolNameMapper, type Tool, type ContentBlock } from './tool-name-mapper'; import { sanitizeToolSchemas } from './schema-sanitizer'; +import { extractProviderFromPathname, normalizeModelIdForRouting } from './model-id-normalizer'; import { getCcsDir } from '../utils/config-manager'; export interface ToolSanitizationProxyConfig { @@ -206,12 +207,25 @@ export class ToolSanitizationProxy { // Create mapper for this request const mapper = new ToolNameMapper(); - // Sanitize tools if present + // Normalize dotted Claude model IDs for provider-compatible routing. let modifiedBody = parsed; - if (isRecord(parsed) && Array.isArray(parsed.tools)) { + if (isRecord(modifiedBody) && typeof modifiedBody.model === 'string') { + const providerFromPath = extractProviderFromPathname(fullUpstreamUrl.pathname); + const normalizedModel = normalizeModelIdForRouting(modifiedBody.model, providerFromPath); + if (normalizedModel !== modifiedBody.model) { + this.writeLog( + 'warn', + `[tool-sanitization-proxy] Model normalized for provider routing (${providerFromPath ?? 'root'}): "${modifiedBody.model}" → "${normalizedModel}"` + ); + modifiedBody = { ...modifiedBody, model: normalizedModel }; + } + } + + // Sanitize tools if present + if (isRecord(modifiedBody) && Array.isArray(modifiedBody.tools)) { // Step 1: Sanitize input_schema properties (remove non-standard JSON Schema properties) const schemaResult = sanitizeToolSchemas( - parsed.tools as Array<{ name: string; input_schema?: Record }> + modifiedBody.tools as Array<{ name: string; input_schema?: Record }> ); if (schemaResult.totalRemoved > 0) { @@ -228,7 +242,7 @@ export class ToolSanitizationProxy { // Step 2: Sanitize tool names (truncate to 64 chars for Gemini) const sanitizedTools = mapper.registerTools(schemaResult.tools as Tool[]); - modifiedBody = { ...parsed, tools: sanitizedTools }; + modifiedBody = { ...modifiedBody, tools: sanitizedTools }; // Log sanitization warnings if (mapper.hasChanges()) { @@ -252,7 +266,7 @@ export class ToolSanitizationProxy { } // Check if streaming is requested - const isStreaming = isRecord(parsed) && parsed.stream === true; + const isStreaming = isRecord(modifiedBody) && modifiedBody.stream === true; if (isStreaming) { await this.forwardJsonStreaming(req, res, fullUpstreamUrl, modifiedBody, mapper); diff --git a/tests/unit/cliproxy/composite-env-routing.test.ts b/tests/unit/cliproxy/composite-env-routing.test.ts index 18978f21..281aae1b 100644 --- a/tests/unit/cliproxy/composite-env-routing.test.ts +++ b/tests/unit/cliproxy/composite-env-routing.test.ts @@ -71,4 +71,25 @@ describe('buildClaudeEnvironment - composite remote routing', () => { // Gemini thinking overrides may be normalized to numeric budgets, and [1m] may be auto-appended. expect(env.ANTHROPIC_MODEL).toMatch(/\([^)]+\)(\[1m\])?$/); }); + + it('normalizes dotted Claude model IDs for antigravity tiers in composite mode', () => { + const env = buildClaudeEnvironment({ + provider: 'agy', + useRemoteProxy: false, + localPort: 8318, + verbose: false, + isComposite: true, + compositeTiers: { + opus: { provider: 'agy', model: 'claude-opus-4.6-thinking' }, + sonnet: { provider: 'gemini', model: 'gemini-2.5-pro' }, + haiku: { provider: 'codex', model: 'gpt-5.1-codex-mini' }, + }, + compositeDefaultTier: 'opus', + }); + + expect(env.ANTHROPIC_MODEL).toMatch(/^claude-opus-4-6-thinking(\([^)]+\))?$/); + expect(env.ANTHROPIC_DEFAULT_OPUS_MODEL).toMatch(/^claude-opus-4-6-thinking(\([^)]+\))?$/); + expect(env.ANTHROPIC_DEFAULT_SONNET_MODEL).toMatch(/^gemini-2.5-pro(\([^)]+\))?$/); + expect(env.ANTHROPIC_DEFAULT_HAIKU_MODEL).toBe('gpt-5.1-codex-mini'); + }); }); diff --git a/tests/unit/cliproxy/composite-thinking.test.ts b/tests/unit/cliproxy/composite-thinking.test.ts index 4f66dad4..fe1ad59c 100644 --- a/tests/unit/cliproxy/composite-thinking.test.ts +++ b/tests/unit/cliproxy/composite-thinking.test.ts @@ -378,6 +378,25 @@ describe('applyThinkingConfig - composite variant integration', () => { expect(result.ANTHROPIC_DEFAULT_SONNET_MODEL).toBe('claude-sonnet-4-5-thinking(high)'); }); + it('handles dotted agy Claude IDs for thinking capability lookup', () => { + const envVars: NodeJS.ProcessEnv = { + ANTHROPIC_MODEL: 'claude-opus-4.5-thinking', + ANTHROPIC_DEFAULT_OPUS_MODEL: 'claude-opus-4.5-thinking', + ANTHROPIC_DEFAULT_SONNET_MODEL: 'claude-sonnet-4.5-thinking', + ANTHROPIC_DEFAULT_HAIKU_MODEL: 'claude-haiku-4.5', + }; + + const result = applyThinkingConfig(envVars, 'agy' as CLIProxyProvider, 'high'); + + // Capability lookup should succeed for dotted agy IDs after normalization. + // Main model value is validated for budget models; tier values keep raw override. + expect(result.ANTHROPIC_MODEL).toBe('claude-opus-4.5-thinking(24576)'); + expect(result.ANTHROPIC_DEFAULT_OPUS_MODEL).toBe('claude-opus-4.5-thinking(high)'); + expect(result.ANTHROPIC_DEFAULT_SONNET_MODEL).toBe('claude-sonnet-4.5-thinking(high)'); + // Haiku does not support thinking in agy catalog. + expect(result.ANTHROPIC_DEFAULT_HAIKU_MODEL).toBe('claude-haiku-4.5'); + }); + it('should handle numeric budgets in per-tier thinking', () => { const envVars: NodeJS.ProcessEnv = { ANTHROPIC_MODEL: 'claude-sonnet-4-5-thinking', diff --git a/tests/unit/cliproxy/env-builder-provider-url.test.ts b/tests/unit/cliproxy/env-builder-provider-url.test.ts index 0705638c..bae0639d 100644 --- a/tests/unit/cliproxy/env-builder-provider-url.test.ts +++ b/tests/unit/cliproxy/env-builder-provider-url.test.ts @@ -13,7 +13,7 @@ interface EnvSettings { ANTHROPIC_DEFAULT_HAIKU_MODEL: string; } -function writeCodexSettings(settingsPath: string, env: EnvSettings): void { +function writeSettings(settingsPath: string, env: EnvSettings): void { fs.writeFileSync(settingsPath, JSON.stringify({ env }, null, 2)); } @@ -31,7 +31,7 @@ describe('getEffectiveEnvVars local provider URL normalization', () => { }); it('rewrites local root URL to provider endpoint', () => { - writeCodexSettings(settingsPath, { + writeSettings(settingsPath, { ANTHROPIC_BASE_URL: 'http://127.0.0.1:8317', ANTHROPIC_AUTH_TOKEN: 'ccs-internal-managed', ANTHROPIC_MODEL: 'gpt-5.3-codex-xhigh', @@ -45,7 +45,7 @@ describe('getEffectiveEnvVars local provider URL normalization', () => { }); it('rewrites wrong local provider path to the requested provider', () => { - writeCodexSettings(settingsPath, { + writeSettings(settingsPath, { ANTHROPIC_BASE_URL: 'http://localhost:8317/api/provider/my-codex-variant?debug=1', ANTHROPIC_AUTH_TOKEN: 'ccs-internal-managed', ANTHROPIC_MODEL: 'gpt-5.3-codex-xhigh', @@ -59,7 +59,7 @@ describe('getEffectiveEnvVars local provider URL normalization', () => { }); it('does not rewrite localhost URLs targeting non-cliproxy ports', () => { - writeCodexSettings(settingsPath, { + writeSettings(settingsPath, { ANTHROPIC_BASE_URL: 'http://127.0.0.1:11434', ANTHROPIC_AUTH_TOKEN: 'ccs-internal-managed', ANTHROPIC_MODEL: 'gpt-5.3-codex-xhigh', @@ -71,4 +71,21 @@ describe('getEffectiveEnvVars local provider URL normalization', () => { const env = getEffectiveEnvVars('codex', 8317, settingsPath); expect(env.ANTHROPIC_BASE_URL).toBe('http://127.0.0.1:11434'); }); + + it('normalizes dotted Claude major.minor IDs for agy provider settings', () => { + writeSettings(settingsPath, { + ANTHROPIC_BASE_URL: 'http://127.0.0.1:8317/api/provider/agy', + ANTHROPIC_AUTH_TOKEN: 'ccs-internal-managed', + ANTHROPIC_MODEL: 'claude-sonnet-4.6-thinking', + ANTHROPIC_DEFAULT_OPUS_MODEL: 'claude-opus-4.6-thinking', + ANTHROPIC_DEFAULT_SONNET_MODEL: 'claude-sonnet-4.6-thinking', + ANTHROPIC_DEFAULT_HAIKU_MODEL: 'claude-haiku-4.5', + }); + + const env = getEffectiveEnvVars('agy', 8317, settingsPath); + expect(env.ANTHROPIC_MODEL).toBe('claude-sonnet-4-6-thinking'); + expect(env.ANTHROPIC_DEFAULT_OPUS_MODEL).toBe('claude-opus-4-6-thinking'); + expect(env.ANTHROPIC_DEFAULT_SONNET_MODEL).toBe('claude-sonnet-4-6-thinking'); + expect(env.ANTHROPIC_DEFAULT_HAIKU_MODEL).toBe('claude-haiku-4-5'); + }); }); diff --git a/tests/unit/cliproxy/model-catalog-compat.test.ts b/tests/unit/cliproxy/model-catalog-compat.test.ts new file mode 100644 index 00000000..90fad46f --- /dev/null +++ b/tests/unit/cliproxy/model-catalog-compat.test.ts @@ -0,0 +1,17 @@ +import { describe, it, expect } from 'bun:test'; +import { findModel, supportsThinking } from '../../../src/cliproxy/model-catalog'; + +describe('model-catalog compatibility lookups', () => { + it('finds agy Claude models using dotted major.minor IDs', () => { + const dottedThinking = findModel('agy', 'claude-sonnet-4.5-thinking'); + const dottedNonThinking = findModel('agy', 'claude-sonnet-4.5'); + + expect(dottedThinking?.id).toBe('claude-sonnet-4-5-thinking'); + expect(dottedNonThinking?.id).toBe('claude-sonnet-4-5'); + }); + + it('supports thinking checks for dotted agy model IDs', () => { + expect(supportsThinking('agy', 'claude-opus-4.6-thinking')).toBe(true); + expect(supportsThinking('agy', 'claude-sonnet-4.5')).toBe(false); + }); +}); diff --git a/tests/unit/cliproxy/model-id-normalizer.test.ts b/tests/unit/cliproxy/model-id-normalizer.test.ts new file mode 100644 index 00000000..d13bca6b --- /dev/null +++ b/tests/unit/cliproxy/model-id-normalizer.test.ts @@ -0,0 +1,85 @@ +import { describe, it, expect } from 'bun:test'; +import { + extractProviderFromPathname, + isAntigravityProvider, + normalizeClaudeDottedMajorMinor, + normalizeClaudeDottedThinkingMajorMinor, + normalizeModelIdForProvider, + normalizeModelIdForRouting, + normalizeModelEnvVarsForProvider, +} from '../../../src/cliproxy/model-id-normalizer'; + +describe('model-id-normalizer', () => { + describe('provider parsing', () => { + it('extracts provider from provider route path', () => { + expect(extractProviderFromPathname('/api/provider/agy/v1/messages')).toBe('agy'); + expect(extractProviderFromPathname('/api/provider/antigravity')).toBe('antigravity'); + expect(extractProviderFromPathname('/v1/messages')).toBeNull(); + }); + + it('detects antigravity provider aliases', () => { + expect(isAntigravityProvider('agy')).toBe(true); + expect(isAntigravityProvider('antigravity')).toBe(true); + expect(isAntigravityProvider('gemini')).toBe(false); + expect(isAntigravityProvider(undefined)).toBe(false); + }); + }); + + describe('model normalization', () => { + it('normalizes dotted Claude major.minor to hyphen format', () => { + expect(normalizeClaudeDottedMajorMinor('claude-sonnet-4.6-thinking')).toBe( + 'claude-sonnet-4-6-thinking' + ); + expect(normalizeClaudeDottedMajorMinor('claude-opus-4.6')).toBe('claude-opus-4-6'); + }); + + it('normalizes only dotted thinking variants for root/composite routing', () => { + expect(normalizeClaudeDottedThinkingMajorMinor('claude-sonnet-4.6-thinking')).toBe( + 'claude-sonnet-4-6-thinking' + ); + expect(normalizeClaudeDottedThinkingMajorMinor('claude-sonnet-4.6')).toBe( + 'claude-sonnet-4.6' + ); + }); + + it('applies provider-aware routing normalization', () => { + expect(normalizeModelIdForRouting('claude-sonnet-4.6-thinking', null)).toBe( + 'claude-sonnet-4-6-thinking' + ); + expect(normalizeModelIdForRouting('claude-sonnet-4.6', null)).toBe('claude-sonnet-4.6'); + expect(normalizeModelIdForRouting('claude-sonnet-4.6', 'agy')).toBe('claude-sonnet-4-6'); + }); + + it('applies provider-only normalization for antigravity', () => { + expect(normalizeModelIdForProvider('claude-opus-4.6-thinking', 'agy')).toBe( + 'claude-opus-4-6-thinking' + ); + expect(normalizeModelIdForProvider('claude-opus-4.6-thinking', 'gemini')).toBe( + 'claude-opus-4.6-thinking' + ); + }); + }); + + describe('env normalization', () => { + it('normalizes model env vars for antigravity only', () => { + const input: NodeJS.ProcessEnv = { + ANTHROPIC_MODEL: 'claude-sonnet-4.6-thinking', + ANTHROPIC_DEFAULT_OPUS_MODEL: 'claude-opus-4.6-thinking', + ANTHROPIC_DEFAULT_SONNET_MODEL: 'claude-sonnet-4.6', + ANTHROPIC_DEFAULT_HAIKU_MODEL: 'claude-haiku-4.5', + UNRELATED: 'keep-me', + }; + + const normalized = normalizeModelEnvVarsForProvider(input, 'agy'); + expect(normalized.ANTHROPIC_MODEL).toBe('claude-sonnet-4-6-thinking'); + expect(normalized.ANTHROPIC_DEFAULT_OPUS_MODEL).toBe('claude-opus-4-6-thinking'); + expect(normalized.ANTHROPIC_DEFAULT_SONNET_MODEL).toBe('claude-sonnet-4-6'); + expect(normalized.ANTHROPIC_DEFAULT_HAIKU_MODEL).toBe('claude-haiku-4-5'); + expect(normalized.UNRELATED).toBe('keep-me'); + + const unchanged = normalizeModelEnvVarsForProvider(input, 'gemini'); + expect(unchanged.ANTHROPIC_MODEL).toBe('claude-sonnet-4.6-thinking'); + expect(unchanged.UNRELATED).toBe('keep-me'); + }); + }); +}); diff --git a/tests/unit/cliproxy/tool-sanitization-proxy-integration.test.ts b/tests/unit/cliproxy/tool-sanitization-proxy-integration.test.ts index 03a9bc5f..dd7dd2ac 100644 --- a/tests/unit/cliproxy/tool-sanitization-proxy-integration.test.ts +++ b/tests/unit/cliproxy/tool-sanitization-proxy-integration.test.ts @@ -184,6 +184,77 @@ describe('ToolSanitizationProxy Integration', () => { } }); + it('normalizes dotted Claude thinking model IDs for root/composite routes', async () => { + const proxy = new ToolSanitizationProxy({ + upstreamBaseUrl: `http://127.0.0.1:${mockUpstreamPort}`, + }); + const port = await proxy.start(); + + try { + await fetch(`http://127.0.0.1:${port}/v1/messages`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ + model: 'claude-sonnet-4.6-thinking', + messages: [{ role: 'user', content: 'test' }], + }), + }); + + expect(lastRequest).not.toBeNull(); + expect((lastRequest!.body as Record).model).toBe( + 'claude-sonnet-4-6-thinking' + ); + } finally { + proxy.stop(); + } + }); + + it('keeps dotted non-thinking model IDs unchanged', async () => { + const proxy = new ToolSanitizationProxy({ + upstreamBaseUrl: `http://127.0.0.1:${mockUpstreamPort}`, + }); + const port = await proxy.start(); + + try { + await fetch(`http://127.0.0.1:${port}/v1/messages`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ + model: 'claude-sonnet-4.5', + messages: [{ role: 'user', content: 'test' }], + }), + }); + + expect(lastRequest).not.toBeNull(); + expect((lastRequest!.body as Record).model).toBe('claude-sonnet-4.5'); + } finally { + proxy.stop(); + } + }); + + it('normalizes dotted Claude major.minor IDs on antigravity provider route', async () => { + const proxy = new ToolSanitizationProxy({ + upstreamBaseUrl: `http://127.0.0.1:${mockUpstreamPort}`, + }); + const port = await proxy.start(); + + try { + await fetch(`http://127.0.0.1:${port}/api/provider/agy/v1/messages`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ + model: 'claude-opus-4.6', + messages: [{ role: 'user', content: 'test' }], + }), + }); + + expect(lastRequest).not.toBeNull(); + expect((lastRequest!.body as Record).model).toBe('claude-opus-4-6'); + } finally { + proxy.stop(); + } + }); + it('preserves other tool properties during sanitization', async () => { const proxy = new ToolSanitizationProxy({ upstreamBaseUrl: `http://127.0.0.1:${mockUpstreamPort}`,