diff --git a/src/cliproxy/config/env-builder.ts b/src/cliproxy/config/env-builder.ts index f90a0691..de906406 100644 --- a/src/cliproxy/config/env-builder.ts +++ b/src/cliproxy/config/env-builder.ts @@ -60,7 +60,11 @@ function stripCodexEffortSuffix(modelId: string): string { * * Returns true if migration was performed and file was updated. */ -function migrateDeprecatedModelNames(settingsPath: string, settings: ProviderSettings): boolean { +function migrateDeprecatedModelNames( + settingsPath: string, + provider: CLIProxyProvider, + settings: ProviderSettings +): boolean { if (!settings.env || typeof settings.env !== 'object') return false; let migrated = false; @@ -68,13 +72,36 @@ function migrateDeprecatedModelNames(settingsPath: string, settings: ProviderSet const value = settings.env[key]; if (typeof value !== 'string') continue; - // Check if the base model name (before any suffixes) uses the deprecated prefix - if (value.toLowerCase().startsWith(DEPRECATED_MODEL_PREFIX)) { - settings.env[key] = UPSTREAM_MODEL_PREFIX + value.slice(DEPRECATED_MODEL_PREFIX.length); + let canonical = value; + // Check if the base model name (before any suffixes) uses the deprecated prefix. + if (canonical.toLowerCase().startsWith(DEPRECATED_MODEL_PREFIX)) { + canonical = UPSTREAM_MODEL_PREFIX + canonical.slice(DEPRECATED_MODEL_PREFIX.length); + } + canonical = normalizeModelIdForProvider(canonical, provider); + + if (canonical !== value) { + settings.env[key] = canonical; migrated = true; } } + if (provider === 'agy' && Array.isArray(settings.presets)) { + for (const preset of settings.presets) { + if (!preset || typeof preset !== 'object') continue; + const presetRecord = preset as Record; + + for (const key of PRESET_MODEL_KEYS) { + const value = presetRecord[key]; + if (typeof value !== 'string') continue; + const canonical = normalizeModelIdForProvider(value, provider); + if (canonical !== value) { + presetRecord[key] = canonical; + migrated = true; + } + } + } + } + if (migrated) { try { fs.writeFileSync(settingsPath, JSON.stringify(settings, null, 2) + '\n', { mode: 0o600 }); @@ -409,7 +436,7 @@ export function getEffectiveEnvVars( if (settings.env && typeof settings.env === 'object') { // Migrate deprecated gemini-claude-* model names if present - migrateDeprecatedModelNames(expandedPath, settings); + migrateDeprecatedModelNames(expandedPath, provider, settings); // Migrate codex effort suffixes to canonical IDs if present migrateCodexEffortSuffixes(expandedPath, provider, settings); // Migrate legacy iFlow placeholders to supported model IDs @@ -444,7 +471,7 @@ export function getEffectiveEnvVars( if (settings.env && typeof settings.env === 'object') { // Migrate deprecated gemini-claude-* model names if present - migrateDeprecatedModelNames(settingsPath, settings); + migrateDeprecatedModelNames(settingsPath, provider, settings); // Migrate codex effort suffixes to canonical IDs if present migrateCodexEffortSuffixes(settingsPath, provider, settings); // Migrate legacy iFlow placeholders to supported model IDs @@ -532,6 +559,17 @@ export function ensureProviderSettings(provider: CLIProxyProvider): void { } } + // Canonicalize provider-specific model aliases (e.g., AGY Sonnet 4.6 thinking legacy IDs). + for (const key of MODEL_ENV_VAR_KEYS) { + const current = mergedEnv[key]; + if (typeof current !== 'string' || current.trim().length === 0) continue; + const canonical = normalizeModelIdForProvider(current, provider); + if (canonical !== current) { + mergedEnv[key] = canonical; + mutated = true; + } + } + if (!mutated) { return; } @@ -584,7 +622,7 @@ export function getRemoteEnvVars( const content = fs.readFileSync(expandedPath, 'utf-8'); const settings: ProviderSettings = JSON.parse(content); if (settings.env && typeof settings.env === 'object') { - migrateDeprecatedModelNames(expandedPath, settings); + migrateDeprecatedModelNames(expandedPath, provider, settings); migrateCodexEffortSuffixes(expandedPath, provider, settings); migrateIFlowPlaceholderModel(expandedPath, provider, settings); userEnvVars = settings.env as Record; @@ -604,7 +642,7 @@ export function getRemoteEnvVars( const content = fs.readFileSync(settingsPath, 'utf-8'); const settings: ProviderSettings = JSON.parse(content); if (settings.env && typeof settings.env === 'object') { - migrateDeprecatedModelNames(settingsPath, settings); + migrateDeprecatedModelNames(settingsPath, provider, settings); migrateCodexEffortSuffixes(settingsPath, provider, settings); migrateIFlowPlaceholderModel(settingsPath, provider, settings); userEnvVars = settings.env as Record; diff --git a/src/cliproxy/config/generator.ts b/src/cliproxy/config/generator.ts index 0b88f6f0..89d80979 100644 --- a/src/cliproxy/config/generator.ts +++ b/src/cliproxy/config/generator.ts @@ -32,8 +32,9 @@ export const CCS_CONTROL_PANEL_SECRET = 'ccs'; * v8: Added Gemini 3.1 preview aliases for provider routing compatibility * v9: Added resilient alias compatibility expansion and cache-assisted alias enrichment * v10: Migrated deprecated gemini-claude-* aliases to upstream claude-* aliases + * v11: Migrated deprecated claude-sonnet-4-6-thinking aliases to claude-sonnet-4-6 */ -export const CLIPROXY_CONFIG_VERSION = 10; +export const CLIPROXY_CONFIG_VERSION = 11; interface OAuthModelAliasEntry { name: string; @@ -44,6 +45,8 @@ interface OAuthModelAliasEntry { const GEMINI_MINOR_COMPAT_RANGE = [1, 2, 3, 4, 5, 6, 7, 8, 9] as const; const DEPRECATED_ANTIGRAVITY_ALIAS_PREFIX = 'gemini-claude-'; const UPSTREAM_CLAUDE_ALIAS_PREFIX = 'claude-'; +const DEPRECATED_ANTIGRAVITY_SONNET_46_THINKING_ALIAS = 'claude-sonnet-4-6-thinking'; +const CANONICAL_ANTIGRAVITY_SONNET_46_ALIAS = 'claude-sonnet-4-6'; /** * Default Antigravity oauth-model-alias entries. @@ -58,7 +61,8 @@ const DEFAULT_ANTIGRAVITY_ALIASES: OAuthModelAliasEntry[] = [ { name: 'gemini-3-pro-high', alias: 'gemini-3.1-pro-preview-customtools' }, { name: 'gemini-3-flash', alias: 'gemini-3-flash-preview' }, { name: 'claude-sonnet-4-6', alias: 'claude-sonnet-4-6', fork: true }, - { name: 'claude-sonnet-4-6-thinking', alias: 'claude-sonnet-4-6-thinking', fork: true }, + // Backward compatibility: legacy sonnet thinking alias now routes to canonical model ID. + { name: 'claude-sonnet-4-6-thinking', alias: 'claude-sonnet-4-6', fork: true }, { name: 'claude-sonnet-4-5', alias: 'claude-sonnet-4-5', fork: true }, { name: 'claude-sonnet-4-5-thinking', alias: 'claude-sonnet-4-5-thinking', fork: true }, { name: 'claude-opus-4-5-thinking', alias: 'claude-opus-4-5-thinking', fork: true }, @@ -107,10 +111,15 @@ function sanitizeYamlScalar(rawValue: string): string { function normalizeAntigravityAlias(rawAlias: string): string { const normalized = sanitizeYamlScalar(rawAlias); if (normalized.toLowerCase().startsWith(DEPRECATED_ANTIGRAVITY_ALIAS_PREFIX)) { - return ( - UPSTREAM_CLAUDE_ALIAS_PREFIX + normalized.slice(DEPRECATED_ANTIGRAVITY_ALIAS_PREFIX.length) - ); + const migratedPrefix = + UPSTREAM_CLAUDE_ALIAS_PREFIX + normalized.slice(DEPRECATED_ANTIGRAVITY_ALIAS_PREFIX.length); + return normalizeAntigravityAlias(migratedPrefix); } + + if (normalized.toLowerCase() === DEPRECATED_ANTIGRAVITY_SONNET_46_THINKING_ALIAS) { + return CANONICAL_ANTIGRAVITY_SONNET_46_ALIAS; + } + return normalized; } diff --git a/src/cliproxy/model-catalog.ts b/src/cliproxy/model-catalog.ts index eb6a87f5..4e135c0d 100644 --- a/src/cliproxy/model-catalog.ts +++ b/src/cliproxy/model-catalog.ts @@ -104,23 +104,17 @@ export const MODEL_CATALOG: Partial> = }, }, { - id: 'claude-sonnet-4-6-thinking', - name: 'Claude Sonnet 4.6 Thinking', - description: 'Latest Sonnet with extended thinking', + id: 'claude-sonnet-4-6', + name: 'Claude Sonnet 4.6', + description: 'Latest Sonnet with thinking budget support', thinking: { type: 'budget', min: 1024, - max: 128000, + max: 64000, zeroAllowed: true, dynamicAllowed: true, }, }, - { - id: 'claude-sonnet-4-6', - name: 'Claude Sonnet 4.6', - description: 'Latest Sonnet baseline', - thinking: { type: 'none' }, - }, { id: 'claude-sonnet-4-5-thinking', name: 'Claude Sonnet 4.5 Thinking', diff --git a/src/cliproxy/model-config.ts b/src/cliproxy/model-config.ts index cb1ddb34..0400a54a 100644 --- a/src/cliproxy/model-config.ts +++ b/src/cliproxy/model-config.ts @@ -13,6 +13,7 @@ import { getProviderSettingsPath, getClaudeEnvVars } from './config-generator'; import { CLIProxyProvider } from './types'; import { initUI, color, bold, dim, ok, info, header } from '../utils/ui'; import { getCcsDir } from '../utils/config-manager'; +import { normalizeModelIdForProvider } from './model-id-normalizer'; const CODEX_EFFORT_SUFFIX_REGEX = /-(xhigh|high|medium)$/i; @@ -21,9 +22,9 @@ function stripCodexEffortSuffix(model: string, provider: CLIProxyProvider): stri return model.replace(CODEX_EFFORT_SUFFIX_REGEX, ''); } -function normalizeCodexTierModel(provider: CLIProxyProvider, model: string): string { - if (provider !== 'codex') return model; - return stripCodexEffortSuffix(model, provider); +function canonicalizeModelForProvider(provider: CLIProxyProvider, model: string): string { + const withoutCodexSuffix = stripCodexEffortSuffix(model, provider); + return normalizeModelIdForProvider(withoutCodexSuffix, provider); } /** @@ -50,7 +51,8 @@ export function getCurrentModel( try { const settings = JSON.parse(fs.readFileSync(settingsPath, 'utf8')); - return settings.env?.ANTHROPIC_MODEL; + const model = settings.env?.ANTHROPIC_MODEL; + return typeof model === 'string' ? canonicalizeModelForProvider(provider, model) : model; } catch { return undefined; } @@ -134,7 +136,7 @@ export async function configureProviderModel( // Find default index - use current model if configured, otherwise catalog default const currentModel = getCurrentModel(provider, customSettingsPath); const targetModel = currentModel - ? stripCodexEffortSuffix(currentModel, provider) + ? canonicalizeModelForProvider(provider, currentModel) : catalog.defaultModel; const defaultIdx = catalog.models.findIndex((m) => m.id === targetModel); const safeDefaultIdx = defaultIdx >= 0 ? defaultIdx : 0; @@ -157,10 +159,10 @@ export async function configureProviderModel( // Get base env vars for defaults const baseEnv = getClaudeEnvVars(provider); - const selectedDefaultModel = normalizeCodexTierModel(provider, selectedModel); - const selectedOpusModel = normalizeCodexTierModel(provider, selectedModel); - const selectedSonnetModel = normalizeCodexTierModel(provider, selectedModel); - const selectedHaikuModel = normalizeCodexTierModel( + const selectedDefaultModel = canonicalizeModelForProvider(provider, selectedModel); + const selectedOpusModel = canonicalizeModelForProvider(provider, selectedModel); + const selectedSonnetModel = canonicalizeModelForProvider(provider, selectedModel); + const selectedHaikuModel = canonicalizeModelForProvider( provider, baseEnv.ANTHROPIC_DEFAULT_HAIKU_MODEL || selectedModel ); @@ -251,7 +253,7 @@ export async function showCurrentConfig(provider: CLIProxyProvider): Promise 'claude-sonnet-4-6-thinking' + * // => 'claude-sonnet-4-6' * * @example * normalizeModelIdForRouting('claude-sonnet-4.6', null) @@ -95,9 +132,14 @@ export function normalizeModelIdForProvider(model: string, provider: ProviderLik */ export function normalizeModelIdForRouting(model: string, provider: ProviderLike): string { if (isAntigravityProvider(provider)) { - return normalizeClaudeDottedMajorMinor(model); + return normalizeModelIdForProvider(model, provider); } - return normalizeClaudeDottedThinkingMajorMinor(model); + // Explicit non-AGY provider routes should pass through unchanged. + if (typeof provider === 'string' && provider.trim().length > 0) { + return model; + } + const normalizedThinking = normalizeClaudeDottedThinkingMajorMinor(model); + return normalizeDeprecatedAntigravityModelAliases(normalizedThinking); } /** @@ -109,7 +151,7 @@ export function normalizeModelIdForRouting(model: string, provider: ProviderLike * { ANTHROPIC_MODEL: 'claude-sonnet-4.6-thinking' }, * 'agy' * ) - * // => { ANTHROPIC_MODEL: 'claude-sonnet-4-6-thinking' } + * // => { ANTHROPIC_MODEL: 'claude-sonnet-4-6' } */ export function normalizeModelEnvVarsForProvider( envVars: NodeJS.ProcessEnv, diff --git a/src/cliproxy/services/variant-settings.ts b/src/cliproxy/services/variant-settings.ts index 36862948..4dbca1fc 100644 --- a/src/cliproxy/services/variant-settings.ts +++ b/src/cliproxy/services/variant-settings.ts @@ -18,6 +18,7 @@ import { ensureProfileHooks } from '../../utils/websearch/profile-hook-injector' import { ensureProfileHooks as ensureImageAnalyzerHooks } from '../../utils/hooks/image-analyzer-profile-hook-injector'; import { getEffectiveApiKey } from '../auth-token-manager'; import { warn } from '../../utils/ui'; +import { normalizeModelIdForProvider } from '../model-id-normalizer'; /** Environment settings structure */ interface SettingsEnv { @@ -41,8 +42,10 @@ function canonicalizeModelForProvider( provider: CLIProxyProfileName | undefined, model: string ): string { - if (provider !== 'codex') return model; - return model.replace(CODEX_EFFORT_SUFFIX_REGEX, ''); + const withoutCodexSuffix = + provider === 'codex' ? model.replace(CODEX_EFFORT_SUFFIX_REGEX, '') : model; + if (!provider) return withoutCodexSuffix; + return normalizeModelIdForProvider(withoutCodexSuffix, provider); } /** diff --git a/src/web-server/routes/cliproxy-stats-routes.ts b/src/web-server/routes/cliproxy-stats-routes.ts index 4d446b90..1bd66127 100644 --- a/src/web-server/routes/cliproxy-stats-routes.ts +++ b/src/web-server/routes/cliproxy-stats-routes.ts @@ -51,6 +51,10 @@ import { } from '../../cliproxy/platform-detector'; import { loadOrCreateUnifiedConfig } from '../../config/unified-config-loader'; import { CLIPROXY_DEFAULT_PORT } from '../../cliproxy/config/port-manager'; +import { + MODEL_ENV_VAR_KEYS, + canonicalizeModelIdForProvider, +} from '../../cliproxy/model-id-normalizer'; const router = Router(); @@ -596,16 +600,43 @@ router.put('/models/:provider', async (req: Request, res: Response): Promise; + [key: string]: unknown; + }; + const canonicalModel = canonicalizeModelIdForProvider(model, provider); + const env = + settings.env && typeof settings.env === 'object' && !Array.isArray(settings.env) + ? settings.env + : {}; + + const previousDefault = + typeof env.ANTHROPIC_MODEL === 'string' ? env.ANTHROPIC_MODEL : canonicalModel; + const previousCanonicalDefault = canonicalizeModelIdForProvider(previousDefault, provider); + + for (const key of MODEL_ENV_VAR_KEYS) { + if (key === 'ANTHROPIC_MODEL') { + env[key] = canonicalModel; + continue; + } + + const current = env[key]; + if (typeof current !== 'string') { + env[key] = canonicalModel; + continue; + } + + const canonicalCurrent = canonicalizeModelIdForProvider(current, provider); + env[key] = canonicalCurrent === previousCanonicalDefault ? canonicalModel : canonicalCurrent; + } + settings.env = env; // Write atomically const tempPath = settingsPath + '.tmp'; fs.writeFileSync(tempPath, JSON.stringify(settings, null, 2) + '\n'); fs.renameSync(tempPath, settingsPath); - res.json({ success: true, provider, model }); + res.json({ success: true, provider, model: canonicalModel }); } catch (error) { res.status(500).json({ error: (error as Error).message }); } diff --git a/src/web-server/routes/settings-routes.ts b/src/web-server/routes/settings-routes.ts index 54a3e265..8dc07223 100644 --- a/src/web-server/routes/settings-routes.ts +++ b/src/web-server/routes/settings-routes.ts @@ -25,9 +25,11 @@ import { mutateUnifiedConfig, } from '../../config/unified-config-loader'; import type { Settings } from '../../types/config'; +import type { CLIProxyProvider } from '../../cliproxy/types'; +import { mapExternalProviderName } from '../../cliproxy/provider-capabilities'; +import { canonicalizeModelIdForProvider } from '../../cliproxy/model-id-normalizer'; const router = Router(); -const CODEX_EFFORT_SUFFIX_REGEX = /-(xhigh|high|medium)$/i; const MODEL_ENV_KEYS = [ 'ANTHROPIC_MODEL', 'ANTHROPIC_DEFAULT_OPUS_MODEL', @@ -122,18 +124,30 @@ function resolveSettingsPath(profileOrVariant: string): string { return path.join(ccsDir, `${profileOrVariant}.settings.json`); } -function isCodexProfile(profileOrVariant: string): boolean { - if (profileOrVariant.toLowerCase() === 'codex') return true; +function resolveProviderForProfile(profileOrVariant: string): CLIProxyProvider | null { + const directProvider = mapExternalProviderName(profileOrVariant); + if (directProvider) { + return directProvider; + } + const variants = listVariants(); - return variants[profileOrVariant]?.provider === 'codex'; + const variantProvider = variants[profileOrVariant]?.provider; + if (typeof variantProvider === 'string') { + return mapExternalProviderName(variantProvider); + } + + return null; } -function stripCodexEffortSuffix(modelId: string): string { - return modelId.replace(CODEX_EFFORT_SUFFIX_REGEX, ''); +function canonicalizeProfileModelId(profileOrVariant: string, modelId: string): string { + const provider = resolveProviderForProfile(profileOrVariant); + if (!provider) return modelId; + return canonicalizeModelIdForProvider(modelId, provider); } -function canonicalizeCodexSettings(profileOrVariant: string, settings: Settings): Settings { - if (!isCodexProfile(profileOrVariant)) return settings; +function canonicalizeProfileSettings(profileOrVariant: string, settings: Settings): Settings { + const provider = resolveProviderForProfile(profileOrVariant); + if (!provider) return settings; let changed = false; const next: Settings = { ...settings }; @@ -143,7 +157,7 @@ function canonicalizeCodexSettings(profileOrVariant: string, settings: Settings) for (const key of MODEL_ENV_KEYS) { const value = env[key]; if (typeof value !== 'string') continue; - const canonical = stripCodexEffortSuffix(value); + const canonical = canonicalizeModelIdForProvider(value, provider); if (canonical !== value) { env[key] = canonical; changed = true; @@ -159,7 +173,8 @@ function canonicalizeCodexSettings(profileOrVariant: string, settings: Settings) for (const key of PRESET_MODEL_KEYS) { const value = normalizedPreset[key]; - const canonical = stripCodexEffortSuffix(value); + if (typeof value !== 'string') continue; + const canonical = canonicalizeModelIdForProvider(value, provider); if (canonical !== value) { normalizedPreset[key] = canonical; presetChanged = true; @@ -206,7 +221,7 @@ router.get('/:profile', (req: Request, res: Response): void => { } const stat = fs.statSync(settingsPath); - const settings = canonicalizeCodexSettings(profile, loadSettings(settingsPath)); + const settings = canonicalizeProfileSettings(profile, loadSettings(settingsPath)); const masked = maskApiKeys(settings); res.json({ @@ -234,7 +249,7 @@ router.get('/:profile/raw', (req: Request, res: Response): void => { } const stat = fs.statSync(settingsPath); - const settings = canonicalizeCodexSettings(profile, loadSettings(settingsPath)); + const settings = canonicalizeProfileSettings(profile, loadSettings(settingsPath)); res.json({ profile, @@ -270,7 +285,7 @@ router.put('/:profile', (req: Request, res: Response): void => { return; } - const normalizedSettings = canonicalizeCodexSettings(profile, settings as Settings); + const normalizedSettings = canonicalizeProfileSettings(profile, settings as Settings); // Deduplicate CCS hooks to prevent accumulation (fixes #450) // This handles cases where duplicate hooks were added by previous versions @@ -355,7 +370,7 @@ router.get('/:profile/presets', (req: Request, res: Response): void => { return; } - const settings = canonicalizeCodexSettings(profile, loadSettings(settingsPath)); + const settings = canonicalizeProfileSettings(profile, loadSettings(settingsPath)); res.json({ presets: settings.presets || [] }); } catch (error) { respondInternalError(res, error, 'Internal server error.'); @@ -393,7 +408,7 @@ router.post('/:profile/presets', (req: Request, res: Response): void => { } const normalizePresetModel = (modelId: string): string => - isCodexProfile(profile) ? stripCodexEffortSuffix(modelId) : modelId; + canonicalizeProfileModelId(profile, modelId); const normalizedDefaultModel = normalizePresetModel(defaultModel); const normalizedOpusModel = normalizePresetModel(opus || defaultModel); diff --git a/tests/unit/cliproxy/config-generator.test.js b/tests/unit/cliproxy/config-generator.test.js index d32269f1..84ebe19f 100644 --- a/tests/unit/cliproxy/config-generator.test.js +++ b/tests/unit/cliproxy/config-generator.test.js @@ -792,8 +792,8 @@ oauth-model-alias: const newConfig = fs.readFileSync(path.join(cliproxyDir, 'config.yaml'), 'utf-8'); assert( - newConfig.includes('alias: claude-sonnet-4-6-thinking'), - 'Should include normalized upstream Claude alias' + newConfig.includes('alias: claude-sonnet-4-6'), + 'Should migrate deprecated sonnet thinking alias to canonical model ID' ); assert( !newConfig.includes('alias: gemini-claude-sonnet-4-6-thinking'), diff --git a/tests/unit/cliproxy/env-builder-provider-url.test.ts b/tests/unit/cliproxy/env-builder-provider-url.test.ts index 9c6f9002..cde0abb7 100644 --- a/tests/unit/cliproxy/env-builder-provider-url.test.ts +++ b/tests/unit/cliproxy/env-builder-provider-url.test.ts @@ -105,10 +105,17 @@ describe('getEffectiveEnvVars local provider URL normalization', () => { }); const env = getEffectiveEnvVars('agy', 8317, settingsPath); - expect(env.ANTHROPIC_MODEL).toBe('claude-sonnet-4-6-thinking'); + expect(env.ANTHROPIC_MODEL).toBe('claude-sonnet-4-6'); 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_SONNET_MODEL).toBe('claude-sonnet-4-6'); expect(env.ANTHROPIC_DEFAULT_HAIKU_MODEL).toBe('claude-haiku-4-5'); + + const persisted = JSON.parse(fs.readFileSync(settingsPath, 'utf-8')) as { + env: Record; + }; + expect(persisted.env.ANTHROPIC_MODEL).toBe('claude-sonnet-4-6'); + expect(persisted.env.ANTHROPIC_DEFAULT_OPUS_MODEL).toBe('claude-opus-4-6-thinking'); + expect(persisted.env.ANTHROPIC_DEFAULT_SONNET_MODEL).toBe('claude-sonnet-4-6'); }); it('migrates codex preset model mappings to canonical IDs', () => { @@ -222,6 +229,39 @@ describe('getEffectiveEnvVars local provider URL normalization', () => { expect(repaired.env?.ANTHROPIC_DEFAULT_HAIKU_MODEL).toBeDefined(); }); + it('migrates deprecated agy sonnet 4.6 thinking IDs during ensureProviderSettings', () => { + process.env.CCS_HOME = tempHome; + const agySettingsPath = path.join(tempHome, '.ccs', 'agy.settings.json'); + fs.mkdirSync(path.dirname(agySettingsPath), { recursive: true }); + fs.writeFileSync( + agySettingsPath, + JSON.stringify( + { + env: { + 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(8192)', + ANTHROPIC_DEFAULT_OPUS_MODEL: 'claude-opus-4-6-thinking', + ANTHROPIC_DEFAULT_SONNET_MODEL: 'claude-sonnet-4.6-thinking', + ANTHROPIC_DEFAULT_HAIKU_MODEL: 'claude-sonnet-4-5', + }, + }, + null, + 2 + ) + ); + + ensureProviderSettings('agy'); + + const repaired = JSON.parse(fs.readFileSync(agySettingsPath, 'utf-8')) as { + env?: Record; + }; + expect(repaired.env?.ANTHROPIC_MODEL).toBe('claude-sonnet-4-6(8192)'); + expect(repaired.env?.ANTHROPIC_DEFAULT_OPUS_MODEL).toBe('claude-opus-4-6-thinking'); + expect(repaired.env?.ANTHROPIC_DEFAULT_SONNET_MODEL).toBe('claude-sonnet-4-6'); + expect(repaired.env?.ANTHROPIC_DEFAULT_HAIKU_MODEL).toBe('claude-sonnet-4-5'); + }); + it('recovers malformed provider settings files by writing defaults and backup copy', () => { process.env.CCS_HOME = tempHome; const agySettingsPath = path.join(tempHome, '.ccs', 'agy.settings.json'); diff --git a/tests/unit/cliproxy/model-catalog.test.js b/tests/unit/cliproxy/model-catalog.test.js index 3cd6d7e8..73c0e74e 100644 --- a/tests/unit/cliproxy/model-catalog.test.js +++ b/tests/unit/cliproxy/model-catalog.test.js @@ -85,15 +85,6 @@ describe('Model Catalog', () => { assert.strictEqual(sonnetThinking.name, 'Claude Sonnet 4.5 Thinking'); }); - it('includes Claude Sonnet 4.6 Thinking', () => { - const { MODEL_CATALOG } = modelCatalog; - const sonnetThinking = MODEL_CATALOG.agy.models.find( - (m) => m.id === 'claude-sonnet-4-6-thinking' - ); - assert(sonnetThinking, 'Should include Claude Sonnet 4.6 Thinking'); - assert.strictEqual(sonnetThinking.name, 'Claude Sonnet 4.6 Thinking'); - }); - it('includes Claude Sonnet 4.6', () => { const { MODEL_CATALOG } = modelCatalog; const sonnet = MODEL_CATALOG.agy.models.find((m) => m.id === 'claude-sonnet-4-6'); @@ -117,9 +108,9 @@ describe('Model Catalog', () => { assert.strictEqual(gem3.tier, undefined, 'AGY models should not have paid tier'); }); - it('has 7 models total', () => { + it('has 6 models total', () => { const { MODEL_CATALOG } = modelCatalog; - assert.strictEqual(MODEL_CATALOG.agy.models.length, 7); + assert.strictEqual(MODEL_CATALOG.agy.models.length, 6); }); }); diff --git a/tests/unit/cliproxy/model-id-normalizer.test.ts b/tests/unit/cliproxy/model-id-normalizer.test.ts index d13bca6b..e594551b 100644 --- a/tests/unit/cliproxy/model-id-normalizer.test.ts +++ b/tests/unit/cliproxy/model-id-normalizer.test.ts @@ -1,5 +1,6 @@ import { describe, it, expect } from 'bun:test'; import { + canonicalizeModelIdForProvider, extractProviderFromPathname, isAntigravityProvider, normalizeClaudeDottedMajorMinor, @@ -44,13 +45,22 @@ describe('model-id-normalizer', () => { it('applies provider-aware routing normalization', () => { expect(normalizeModelIdForRouting('claude-sonnet-4.6-thinking', null)).toBe( - 'claude-sonnet-4-6-thinking' + 'claude-sonnet-4-6' ); expect(normalizeModelIdForRouting('claude-sonnet-4.6', null)).toBe('claude-sonnet-4.6'); expect(normalizeModelIdForRouting('claude-sonnet-4.6', 'agy')).toBe('claude-sonnet-4-6'); + expect(normalizeModelIdForRouting('claude-sonnet-4-6-thinking', 'claude')).toBe( + 'claude-sonnet-4-6-thinking' + ); + expect(normalizeModelIdForRouting('claude-sonnet-4.6-thinking', 'claude')).toBe( + 'claude-sonnet-4.6-thinking' + ); }); it('applies provider-only normalization for antigravity', () => { + expect(normalizeModelIdForProvider('claude-sonnet-4.6-thinking', 'agy')).toBe( + 'claude-sonnet-4-6' + ); expect(normalizeModelIdForProvider('claude-opus-4.6-thinking', 'agy')).toBe( 'claude-opus-4-6-thinking' ); @@ -58,6 +68,16 @@ describe('model-id-normalizer', () => { 'claude-opus-4.6-thinking' ); }); + + it('applies provider canonicalization for codex and antigravity', () => { + expect(canonicalizeModelIdForProvider('gpt-5.3-codex-xhigh', 'codex')).toBe('gpt-5.3-codex'); + expect(canonicalizeModelIdForProvider('claude-sonnet-4.6-thinking', 'agy')).toBe( + 'claude-sonnet-4-6' + ); + expect(canonicalizeModelIdForProvider('claude-sonnet-4-6-thinking', 'claude')).toBe( + 'claude-sonnet-4-6-thinking' + ); + }); }); describe('env normalization', () => { @@ -71,7 +91,7 @@ describe('model-id-normalizer', () => { }; const normalized = normalizeModelEnvVarsForProvider(input, 'agy'); - expect(normalized.ANTHROPIC_MODEL).toBe('claude-sonnet-4-6-thinking'); + expect(normalized.ANTHROPIC_MODEL).toBe('claude-sonnet-4-6'); 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'); diff --git a/tests/unit/cliproxy/tool-sanitization-proxy-integration.test.ts b/tests/unit/cliproxy/tool-sanitization-proxy-integration.test.ts index dd7dd2ac..a07ee192 100644 --- a/tests/unit/cliproxy/tool-sanitization-proxy-integration.test.ts +++ b/tests/unit/cliproxy/tool-sanitization-proxy-integration.test.ts @@ -201,9 +201,7 @@ describe('ToolSanitizationProxy Integration', () => { }); expect(lastRequest).not.toBeNull(); - expect((lastRequest!.body as Record).model).toBe( - 'claude-sonnet-4-6-thinking' - ); + expect((lastRequest!.body as Record).model).toBe('claude-sonnet-4-6'); } finally { proxy.stop(); } @@ -255,6 +253,31 @@ describe('ToolSanitizationProxy Integration', () => { } }); + it('does not rewrite AGY legacy aliases on non-AGY provider 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}/api/provider/claude/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('preserves other tool properties during sanitization', async () => { const proxy = new ToolSanitizationProxy({ upstreamBaseUrl: `http://127.0.0.1:${mockUpstreamPort}`, diff --git a/tests/unit/web-server/cliproxy-stats-routes-model-update.test.ts b/tests/unit/web-server/cliproxy-stats-routes-model-update.test.ts new file mode 100644 index 00000000..1b15727a --- /dev/null +++ b/tests/unit/web-server/cliproxy-stats-routes-model-update.test.ts @@ -0,0 +1,123 @@ +import { afterAll, afterEach, beforeAll, beforeEach, describe, expect, it } from 'bun:test'; +import express from 'express'; +import * as fs from 'fs'; +import * as os from 'os'; +import * as path from 'path'; +import type { Server } from 'http'; +import cliproxyStatsRoutes from '../../../src/web-server/routes/cliproxy-stats-routes'; + +function writeSettings(filePath: string, env: Record): void { + fs.mkdirSync(path.dirname(filePath), { recursive: true }); + fs.writeFileSync(filePath, JSON.stringify({ env }, null, 2) + '\n'); +} + +describe('cliproxy-stats-routes model update canonicalization', () => { + let server: Server; + let baseUrl = ''; + let tempHome = ''; + let originalCcsHome: string | undefined; + + beforeAll(async () => { + const app = express(); + app.use(express.json()); + app.use('/api/cliproxy', cliproxyStatsRoutes); + + await new Promise((resolve, reject) => { + server = app.listen(0, '127.0.0.1'); + const onError = (error: Error) => reject(error); + server.once('error', onError); + server.once('listening', () => { + server.off('error', onError); + resolve(); + }); + }); + + const address = server.address(); + if (!address || typeof address === 'string') { + throw new Error('Unable to resolve test server port'); + } + baseUrl = `http://127.0.0.1:${address.port}`; + }); + + afterAll(async () => { + await new Promise((resolve) => server.close(() => resolve())); + }); + + beforeEach(() => { + tempHome = fs.mkdtempSync(path.join(os.tmpdir(), 'ccs-cliproxy-model-route-')); + originalCcsHome = process.env.CCS_HOME; + process.env.CCS_HOME = tempHome; + }); + + afterEach(() => { + if (originalCcsHome !== undefined) { + process.env.CCS_HOME = originalCcsHome; + } else { + delete process.env.CCS_HOME; + } + + if (tempHome && fs.existsSync(tempHome)) { + fs.rmSync(tempHome, { recursive: true, force: true }); + } + }); + + it('updates linked tiers while preserving intentionally distinct tier models', async () => { + const settingsPath = path.join(tempHome, '.ccs', 'cliproxy', 'agy.settings.json'); + 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 response = await fetch(`${baseUrl}/api/cliproxy/models/agy`, { + method: 'PUT', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ model: 'claude-sonnet-4-6-thinking(8192)' }), + }); + expect(response.status).toBe(200); + + const body = (await response.json()) as { model: string }; + expect(body.model).toBe('claude-sonnet-4-6(8192)'); + + const persisted = JSON.parse(fs.readFileSync(settingsPath, 'utf8')) as { + env: Record; + }; + expect(persisted.env.ANTHROPIC_MODEL).toBe('claude-sonnet-4-6(8192)'); + expect(persisted.env.ANTHROPIC_DEFAULT_OPUS_MODEL).toBe('claude-opus-4-6-thinking'); + expect(persisted.env.ANTHROPIC_DEFAULT_SONNET_MODEL).toBe('claude-sonnet-4-6(8192)'); + expect(persisted.env.ANTHROPIC_DEFAULT_HAIKU_MODEL).toBe('claude-haiku-4-5'); + }); + + it('canonicalizes Codex effort suffix and syncs linked core model env vars', async () => { + const settingsPath = path.join(tempHome, '.ccs', 'cliproxy', 'codex.settings.json'); + writeSettings(settingsPath, { + ANTHROPIC_BASE_URL: 'http://127.0.0.1:8317/api/provider/codex', + ANTHROPIC_AUTH_TOKEN: 'ccs-internal-managed', + ANTHROPIC_MODEL: 'gpt-5.3-codex', + ANTHROPIC_DEFAULT_OPUS_MODEL: 'gpt-5.3-codex', + ANTHROPIC_DEFAULT_SONNET_MODEL: 'gpt-5.3-codex', + ANTHROPIC_DEFAULT_HAIKU_MODEL: 'gpt-5-mini', + }); + + const response = await fetch(`${baseUrl}/api/cliproxy/models/codex`, { + method: 'PUT', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ model: 'gpt-5.3-codex-xhigh' }), + }); + expect(response.status).toBe(200); + + const body = (await response.json()) as { model: string }; + expect(body.model).toBe('gpt-5.3-codex'); + + const persisted = JSON.parse(fs.readFileSync(settingsPath, 'utf8')) as { + env: Record; + }; + expect(persisted.env.ANTHROPIC_MODEL).toBe('gpt-5.3-codex'); + expect(persisted.env.ANTHROPIC_DEFAULT_OPUS_MODEL).toBe('gpt-5.3-codex'); + expect(persisted.env.ANTHROPIC_DEFAULT_SONNET_MODEL).toBe('gpt-5.3-codex'); + expect(persisted.env.ANTHROPIC_DEFAULT_HAIKU_MODEL).toBe('gpt-5-mini'); + }); +}); diff --git a/tests/unit/web-server/settings-routes-model-canonicalization.test.ts b/tests/unit/web-server/settings-routes-model-canonicalization.test.ts new file mode 100644 index 00000000..09975419 --- /dev/null +++ b/tests/unit/web-server/settings-routes-model-canonicalization.test.ts @@ -0,0 +1,208 @@ +import { afterAll, afterEach, beforeAll, beforeEach, describe, expect, it } from 'bun:test'; +import express from 'express'; +import * as fs from 'fs'; +import * as os from 'os'; +import * as path from 'path'; +import type { Server } from 'http'; +import settingsRoutes from '../../../src/web-server/routes/settings-routes'; + +function writeSettings(filePath: string, value: Record): void { + fs.mkdirSync(path.dirname(filePath), { recursive: true }); + fs.writeFileSync(filePath, JSON.stringify(value, null, 2) + '\n'); +} + +describe('settings-routes model canonicalization', () => { + let server: Server; + let baseUrl = ''; + let tempHome = ''; + let originalCcsHome: string | undefined; + + beforeAll(async () => { + const app = express(); + app.use(express.json()); + app.use('/api/settings', settingsRoutes); + + await new Promise((resolve, reject) => { + server = app.listen(0, '127.0.0.1'); + const onError = (error: Error) => reject(error); + server.once('error', onError); + server.once('listening', () => { + server.off('error', onError); + resolve(); + }); + }); + + const address = server.address(); + if (!address || typeof address === 'string') { + throw new Error('Unable to resolve test server port'); + } + baseUrl = `http://127.0.0.1:${address.port}`; + }); + + afterAll(async () => { + await new Promise((resolve) => server.close(() => resolve())); + }); + + beforeEach(() => { + tempHome = fs.mkdtempSync(path.join(os.tmpdir(), 'ccs-settings-routes-')); + originalCcsHome = process.env.CCS_HOME; + process.env.CCS_HOME = tempHome; + }); + + afterEach(() => { + if (originalCcsHome !== undefined) { + process.env.CCS_HOME = originalCcsHome; + } else { + delete process.env.CCS_HOME; + } + + if (tempHome && fs.existsSync(tempHome)) { + fs.rmSync(tempHome, { recursive: true, force: true }); + } + }); + + it('canonicalizes AGY env and preset model IDs on PUT /:profile', async () => { + const settingsPath = path.join(tempHome, '.ccs', 'agy.settings.json'); + writeSettings(settingsPath, { env: {} }); + + const response = await fetch(`${baseUrl}/api/settings/agy`, { + method: 'PUT', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ + settings: { + env: { + 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(8192)', + 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', + }, + presets: [ + { + name: 'legacy', + default: 'claude-sonnet-4-6-thinking', + opus: 'claude-opus-4.6-thinking', + sonnet: 'claude-sonnet-4.6-thinking', + haiku: 'claude-haiku-4.5', + }, + ], + }, + }), + }); + expect(response.status).toBe(200); + + const persisted = JSON.parse(fs.readFileSync(settingsPath, 'utf8')) as { + env: Record; + presets: Array>; + }; + + expect(persisted.env.ANTHROPIC_MODEL).toBe('claude-sonnet-4-6(8192)'); + expect(persisted.env.ANTHROPIC_DEFAULT_OPUS_MODEL).toBe('claude-opus-4-6-thinking'); + expect(persisted.env.ANTHROPIC_DEFAULT_SONNET_MODEL).toBe('claude-sonnet-4-6'); + expect(persisted.env.ANTHROPIC_DEFAULT_HAIKU_MODEL).toBe('claude-haiku-4-5'); + + expect(persisted.presets[0]?.default).toBe('claude-sonnet-4-6'); + expect(persisted.presets[0]?.opus).toBe('claude-opus-4-6-thinking'); + expect(persisted.presets[0]?.sonnet).toBe('claude-sonnet-4-6'); + expect(persisted.presets[0]?.haiku).toBe('claude-haiku-4-5'); + }); + + it('canonicalizes AGY preset values on POST /:profile/presets', async () => { + const settingsPath = path.join(tempHome, '.ccs', 'agy.settings.json'); + writeSettings(settingsPath, { + env: { + ANTHROPIC_BASE_URL: 'http://127.0.0.1:8317/api/provider/agy', + ANTHROPIC_AUTH_TOKEN: 'ccs-internal-managed', + }, + presets: [], + }); + + const response = await fetch(`${baseUrl}/api/settings/agy/presets`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ + name: 'new-legacy', + default: 'claude-sonnet-4-6-thinking', + opus: 'claude-opus-4.6-thinking', + sonnet: 'claude-sonnet-4.6-thinking', + haiku: 'claude-haiku-4.5', + }), + }); + expect(response.status).toBe(201); + + const body = (await response.json()) as { preset: Record }; + expect(body.preset.default).toBe('claude-sonnet-4-6'); + expect(body.preset.opus).toBe('claude-opus-4-6-thinking'); + expect(body.preset.sonnet).toBe('claude-sonnet-4-6'); + expect(body.preset.haiku).toBe('claude-haiku-4-5'); + + const persisted = JSON.parse(fs.readFileSync(settingsPath, 'utf8')) as { + presets: Array>; + }; + expect(persisted.presets[0]?.default).toBe('claude-sonnet-4-6'); + expect(persisted.presets[0]?.opus).toBe('claude-opus-4-6-thinking'); + expect(persisted.presets[0]?.sonnet).toBe('claude-sonnet-4-6'); + expect(persisted.presets[0]?.haiku).toBe('claude-haiku-4-5'); + }); + + it('canonicalizes AGY aliases via provider alias profile names', async () => { + const settingsPath = path.join(tempHome, '.ccs', 'antigravity.settings.json'); + writeSettings(settingsPath, { env: {} }); + + const response = await fetch(`${baseUrl}/api/settings/antigravity`, { + method: 'PUT', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ + settings: { + env: { + 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', + }, + presets: [], + }, + }), + }); + expect(response.status).toBe(200); + + const persisted = JSON.parse(fs.readFileSync(settingsPath, 'utf8')) as { + env: Record; + }; + expect(persisted.env.ANTHROPIC_MODEL).toBe('claude-sonnet-4-6'); + expect(persisted.env.ANTHROPIC_DEFAULT_OPUS_MODEL).toBe('claude-opus-4-6-thinking'); + expect(persisted.env.ANTHROPIC_DEFAULT_SONNET_MODEL).toBe('claude-sonnet-4-6'); + expect(persisted.env.ANTHROPIC_DEFAULT_HAIKU_MODEL).toBe('claude-haiku-4-5'); + }); + + it('does not crash on malformed non-string preset model values', async () => { + const settingsPath = path.join(tempHome, '.ccs', 'agy.settings.json'); + writeSettings(settingsPath, { + env: { + ANTHROPIC_BASE_URL: 'http://127.0.0.1:8317/api/provider/agy', + ANTHROPIC_AUTH_TOKEN: 'ccs-internal-managed', + }, + presets: [ + { + name: 'malformed', + default: 123, + opus: null, + sonnet: 'claude-sonnet-4.6-thinking', + haiku: 'claude-haiku-4.5', + }, + ], + }); + + const response = await fetch(`${baseUrl}/api/settings/agy/raw`); + expect(response.status).toBe(200); + + const body = (await response.json()) as { + settings: { presets: Array> }; + }; + expect(body.settings.presets[0]?.default).toBe(123); + expect(body.settings.presets[0]?.sonnet).toBe('claude-sonnet-4-6'); + }); +}); diff --git a/ui/src/lib/model-catalogs.ts b/ui/src/lib/model-catalogs.ts index 0a8e98f3..0927ece4 100644 --- a/ui/src/lib/model-catalogs.ts +++ b/ui/src/lib/model-catalogs.ts @@ -22,7 +22,7 @@ export const MODEL_CATALOGS: Record = { presetMapping: { default: 'claude-opus-4-6-thinking', opus: 'claude-opus-4-6-thinking', - sonnet: 'claude-sonnet-4-6-thinking', + sonnet: 'claude-sonnet-4-6', haiku: 'claude-sonnet-4-6', }, }, @@ -33,25 +33,14 @@ export const MODEL_CATALOGS: Record = { presetMapping: { default: 'claude-opus-4-5-thinking', opus: 'claude-opus-4-5-thinking', - sonnet: 'claude-sonnet-4-6-thinking', - haiku: 'claude-sonnet-4-6', - }, - }, - { - id: 'claude-sonnet-4-6-thinking', - name: 'Claude Sonnet 4.6 Thinking', - description: 'Latest Sonnet with extended thinking', - presetMapping: { - default: 'claude-sonnet-4-6-thinking', - opus: 'claude-opus-4-6-thinking', - sonnet: 'claude-sonnet-4-6-thinking', + sonnet: 'claude-sonnet-4-6', haiku: 'claude-sonnet-4-6', }, }, { id: 'claude-sonnet-4-6', name: 'Claude Sonnet 4.6', - description: 'Latest Sonnet baseline', + description: 'Latest Sonnet with thinking budget support', presetMapping: { default: 'claude-sonnet-4-6', opus: 'claude-opus-4-6-thinking',