From 0b57e41b3a0c1d277d202dc84f6c9c13be76e222 Mon Sep 17 00:00:00 2001 From: Tam Nhu Tran Date: Mon, 2 Mar 2026 21:43:33 +0700 Subject: [PATCH] fix(cliproxy): harden agy model canonicalization and migration paths --- src/cliproxy/model-id-normalizer.ts | 26 +++ .../routes/cliproxy-stats-routes.ts | 39 +++- src/web-server/routes/settings-routes.ts | 45 ++-- .../unit/cliproxy/model-id-normalizer.test.ts | 17 ++ ...ool-sanitization-proxy-integration.test.ts | 25 +++ ...cliproxy-stats-routes-model-update.test.ts | 123 +++++++++++ ...ings-routes-model-canonicalization.test.ts | 208 ++++++++++++++++++ 7 files changed, 464 insertions(+), 19 deletions(-) create mode 100644 tests/unit/web-server/cliproxy-stats-routes-model-update.test.ts create mode 100644 tests/unit/web-server/settings-routes-model-canonicalization.test.ts diff --git a/src/cliproxy/model-id-normalizer.ts b/src/cliproxy/model-id-normalizer.ts index 9f83ec3c..0f28abfa 100644 --- a/src/cliproxy/model-id-normalizer.ts +++ b/src/cliproxy/model-id-normalizer.ts @@ -23,6 +23,7 @@ const CLAUDE_DOTTED_THINKING_REGEX = const DEPRECATED_ANTIGRAVITY_SONNET_46_THINKING_REGEX = /claude-sonnet-4(?:[.-])6-thinking(?=(?:$|-|\[|\(|\/))/gi; const CANONICAL_ANTIGRAVITY_SONNET_46_MODEL = 'claude-sonnet-4-6'; +const CODEX_EFFORT_SUFFIX_REGEX = /-(xhigh|high|medium)$/i; /** * Extract provider segment from `/api/provider/{provider}` request paths. @@ -44,6 +45,17 @@ export function isAntigravityProvider(provider: ProviderLike): boolean { 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'; +} + +/** Normalize Codex effort-suffixed IDs to canonical IDs. */ +export function stripCodexEffortSuffix(model: string): string { + return model.replace(CODEX_EFFORT_SUFFIX_REGEX, ''); +} + /** Normalize Claude dotted major.minor IDs to hyphenated format. */ export function normalizeClaudeDottedMajorMinor(model: string): string { return model.replace( @@ -94,6 +106,16 @@ export function normalizeModelIdForProvider(model: string, provider: ProviderLik 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 withoutCodexSuffix = isCodexProvider(provider) ? stripCodexEffortSuffix(model) : model; + return normalizeModelIdForProvider(withoutCodexSuffix, provider); +} + /** * Normalize model ID for request routing. * - Antigravity routes: normalize all dotted Claude major.minor forms. @@ -112,6 +134,10 @@ export function normalizeModelIdForRouting(model: string, provider: ProviderLike if (isAntigravityProvider(provider)) { return normalizeModelIdForProvider(model, provider); } + // 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); } 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/model-id-normalizer.test.ts b/tests/unit/cliproxy/model-id-normalizer.test.ts index af025fa5..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, @@ -48,6 +49,12 @@ describe('model-id-normalizer', () => { ); 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', () => { @@ -61,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', () => { diff --git a/tests/unit/cliproxy/tool-sanitization-proxy-integration.test.ts b/tests/unit/cliproxy/tool-sanitization-proxy-integration.test.ts index 32c591cb..a07ee192 100644 --- a/tests/unit/cliproxy/tool-sanitization-proxy-integration.test.ts +++ b/tests/unit/cliproxy/tool-sanitization-proxy-integration.test.ts @@ -253,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'); + }); +});