From 747c70636950e72fcbdf0bf92fe54f7800fa0a8a Mon Sep 17 00:00:00 2001 From: Tam Nhu Tran Date: Tue, 3 Mar 2026 00:39:16 +0700 Subject: [PATCH] fix(agy): harden preset canonicalization and migration coverage --- src/cliproxy/config/env-builder.ts | 20 ++- src/cliproxy/config/generator.ts | 4 +- src/cliproxy/model-id-normalizer.ts | 21 ++- src/web-server/routes/settings-routes.ts | 57 +++++-- tests/unit/cliproxy/config-generator.test.js | 30 ++++ .../cliproxy/env-builder-provider-url.test.ts | 61 ++++++++ .../cliproxy/model-catalog-compat.test.ts | 8 + .../unit/cliproxy/model-id-normalizer.test.ts | 15 ++ ...ings-routes-model-canonicalization.test.ts | 148 +++++++++++++++++- 9 files changed, 338 insertions(+), 26 deletions(-) diff --git a/src/cliproxy/config/env-builder.ts b/src/cliproxy/config/env-builder.ts index de906406..cca3b409 100644 --- a/src/cliproxy/config/env-builder.ts +++ b/src/cliproxy/config/env-builder.ts @@ -21,6 +21,7 @@ import { } from './port-manager'; import { getProviderSettingsPath } from './path-resolver'; import { + canonicalizeModelIdForProvider, MODEL_ENV_VAR_KEYS, normalizeModelEnvVarsForProvider, normalizeModelIdForProvider, @@ -563,13 +564,30 @@ export function ensureProviderSettings(provider: CLIProxyProvider): void { 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); + const canonical = canonicalizeModelIdForProvider(current, provider); if (canonical !== current) { mergedEnv[key] = canonical; mutated = true; } } + const presetsCandidate = parsed.presets; + if (Array.isArray(presetsCandidate)) { + for (const preset of presetsCandidate) { + 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 = canonicalizeModelIdForProvider(value, provider); + if (canonical !== value) { + presetRecord[key] = canonical; + mutated = true; + } + } + } + } + if (!mutated) { return; } diff --git a/src/cliproxy/config/generator.ts b/src/cliproxy/config/generator.ts index 89d80979..b6f9a8e9 100644 --- a/src/cliproxy/config/generator.ts +++ b/src/cliproxy/config/generator.ts @@ -45,7 +45,7 @@ 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 DEPRECATED_ANTIGRAVITY_SONNET_46_THINKING_REGEX = /^claude-sonnet-4(?:[.-])6-thinking$/i; const CANONICAL_ANTIGRAVITY_SONNET_46_ALIAS = 'claude-sonnet-4-6'; /** @@ -116,7 +116,7 @@ function normalizeAntigravityAlias(rawAlias: string): string { return normalizeAntigravityAlias(migratedPrefix); } - if (normalized.toLowerCase() === DEPRECATED_ANTIGRAVITY_SONNET_46_THINKING_ALIAS) { + if (DEPRECATED_ANTIGRAVITY_SONNET_46_THINKING_REGEX.test(normalized)) { return CANONICAL_ANTIGRAVITY_SONNET_46_ALIAS; } diff --git a/src/cliproxy/model-id-normalizer.ts b/src/cliproxy/model-id-normalizer.ts index 0f28abfa..23b587f0 100644 --- a/src/cliproxy/model-id-normalizer.ts +++ b/src/cliproxy/model-id-normalizer.ts @@ -25,6 +25,10 @@ const DEPRECATED_ANTIGRAVITY_SONNET_46_THINKING_REGEX = const CANONICAL_ANTIGRAVITY_SONNET_46_MODEL = 'claude-sonnet-4-6'; const CODEX_EFFORT_SUFFIX_REGEX = /-(xhigh|high|medium)$/i; +function trimModelId(model: string): string { + return model.trim(); +} + /** * Extract provider segment from `/api/provider/{provider}` request paths. * @@ -101,8 +105,9 @@ export function normalizeDeprecatedAntigravityModelAliases(model: string): strin * // => 'claude-opus-4.6-thinking' */ export function normalizeModelIdForProvider(model: string, provider: ProviderLike): string { - if (!isAntigravityProvider(provider)) return model; - const normalizedDottedVersion = normalizeClaudeDottedMajorMinor(model); + const trimmedModel = trimModelId(model); + if (!isAntigravityProvider(provider)) return trimmedModel; + const normalizedDottedVersion = normalizeClaudeDottedMajorMinor(trimmedModel); return normalizeDeprecatedAntigravityModelAliases(normalizedDottedVersion); } @@ -112,7 +117,10 @@ export function normalizeModelIdForProvider(model: string, provider: ProviderLik * - Antigravity: normalize dotted/historical aliases. */ export function canonicalizeModelIdForProvider(model: string, provider: ProviderLike): string { - const withoutCodexSuffix = isCodexProvider(provider) ? stripCodexEffortSuffix(model) : model; + const trimmedModel = trimModelId(model); + const withoutCodexSuffix = isCodexProvider(provider) + ? stripCodexEffortSuffix(trimmedModel) + : trimmedModel; return normalizeModelIdForProvider(withoutCodexSuffix, provider); } @@ -131,14 +139,15 @@ export function canonicalizeModelIdForProvider(model: string, provider: Provider * // => 'claude-sonnet-4.6' */ export function normalizeModelIdForRouting(model: string, provider: ProviderLike): string { + const trimmedModel = trimModelId(model); if (isAntigravityProvider(provider)) { - return normalizeModelIdForProvider(model, provider); + return normalizeModelIdForProvider(trimmedModel, provider); } // Explicit non-AGY provider routes should pass through unchanged. if (typeof provider === 'string' && provider.trim().length > 0) { - return model; + return trimmedModel; } - const normalizedThinking = normalizeClaudeDottedThinkingMajorMinor(model); + const normalizedThinking = normalizeClaudeDottedThinkingMajorMinor(trimmedModel); return normalizeDeprecatedAntigravityModelAliases(normalizedThinking); } diff --git a/src/web-server/routes/settings-routes.ts b/src/web-server/routes/settings-routes.ts index 8dc07223..6a9f1bcc 100644 --- a/src/web-server/routes/settings-routes.ts +++ b/src/web-server/routes/settings-routes.ts @@ -190,6 +190,35 @@ function canonicalizeProfileSettings(profileOrVariant: string, settings: Setting return changed ? next : settings; } +function writeSettingsAtomically(settingsPath: string, settings: Settings): void { + const tempPath = settingsPath + '.tmp'; + fs.writeFileSync(tempPath, JSON.stringify(settings, null, 2) + '\n'); + fs.renameSync(tempPath, settingsPath); +} + +function loadCanonicalProfileSettings( + profileOrVariant: string, + settingsPath: string, + persist = false, + strictPersist = false +): Settings { + const loaded = loadSettings(settingsPath); + const canonicalized = canonicalizeProfileSettings(profileOrVariant, loaded); + + if (persist && canonicalized !== loaded) { + try { + writeSettingsAtomically(settingsPath, canonicalized); + } catch (error) { + if (strictPersist) { + throw error; + } + logRouteError(`Failed to persist canonicalized settings for ${profileOrVariant}`, error); + } + } + + return canonicalized; +} + /** * Helper: Mask API keys in settings */ @@ -220,8 +249,8 @@ router.get('/:profile', (req: Request, res: Response): void => { return; } + const settings = loadCanonicalProfileSettings(profile, settingsPath, true); const stat = fs.statSync(settingsPath); - const settings = canonicalizeProfileSettings(profile, loadSettings(settingsPath)); const masked = maskApiKeys(settings); res.json({ @@ -248,8 +277,8 @@ router.get('/:profile/raw', (req: Request, res: Response): void => { return; } + const settings = loadCanonicalProfileSettings(profile, settingsPath, true); const stat = fs.statSync(settingsPath); - const settings = canonicalizeProfileSettings(profile, loadSettings(settingsPath)); res.json({ profile, @@ -370,7 +399,7 @@ router.get('/:profile/presets', (req: Request, res: Response): void => { return; } - const settings = canonicalizeProfileSettings(profile, loadSettings(settingsPath)); + const settings = loadCanonicalProfileSettings(profile, settingsPath, true); res.json({ presets: settings.presets || [] }); } catch (error) { respondInternalError(res, error, 'Internal server error.'); @@ -398,7 +427,7 @@ router.post('/:profile/presets', (req: Request, res: Response): void => { fs.writeFileSync(settingsPath, JSON.stringify({ env: {}, presets: [] }, null, 2) + '\n'); } - const settings = loadSettings(settingsPath); + const settings = loadCanonicalProfileSettings(profile, settingsPath, false); settings.presets = settings.presets || []; // Check for duplicate name @@ -424,13 +453,12 @@ router.post('/:profile/presets', (req: Request, res: Response): void => { }; settings.presets.push(preset); + const canonicalizedSettings = canonicalizeProfileSettings(profile, settings); + writeSettingsAtomically(settingsPath, canonicalizedSettings); - // Atomic write: temp file + rename - const tempPath = settingsPath + '.tmp'; - fs.writeFileSync(tempPath, JSON.stringify(settings, null, 2) + '\n'); - fs.renameSync(tempPath, settingsPath); - - res.status(201).json({ preset }); + const persistedPreset = + canonicalizedSettings.presets?.find((entry) => entry.name === name) || preset; + res.status(201).json({ preset: persistedPreset }); } catch (error) { respondInternalError(res, error, 'Internal server error.'); } @@ -449,18 +477,15 @@ router.delete('/:profile/presets/:name', (req: Request, res: Response): void => return; } - const settings = loadSettings(settingsPath); + const settings = loadCanonicalProfileSettings(profile, settingsPath, false); if (!settings.presets || !settings.presets.some((p) => p.name === name)) { res.status(404).json({ error: 'Preset not found' }); return; } settings.presets = settings.presets.filter((p) => p.name !== name); - - // Atomic write: temp file + rename - const tempPath = settingsPath + '.tmp'; - fs.writeFileSync(tempPath, JSON.stringify(settings, null, 2) + '\n'); - fs.renameSync(tempPath, settingsPath); + const canonicalizedSettings = canonicalizeProfileSettings(profile, settings); + writeSettingsAtomically(settingsPath, canonicalizedSettings); res.json({ success: true }); } catch (error) { diff --git a/tests/unit/cliproxy/config-generator.test.js b/tests/unit/cliproxy/config-generator.test.js index 84ebe19f..6e3bfd10 100644 --- a/tests/unit/cliproxy/config-generator.test.js +++ b/tests/unit/cliproxy/config-generator.test.js @@ -800,5 +800,35 @@ oauth-model-alias: 'Should remove deprecated gemini-claude alias after regeneration' ); }); + + it('normalizes dotted deprecated sonnet thinking aliases during regeneration', () => { + const cliproxyDir = path.join(testDir, '.ccs', 'cliproxy'); + fs.mkdirSync(cliproxyDir, { recursive: true }); + + const initialConfig = `# CLIProxyAPI config generated by CCS v11 +port: 8317 +api-keys: + - "ccs-internal-managed" +auth-dir: "${cliproxyDir.replace(/\\/g, '/')}/auth" +oauth-model-alias: + antigravity: + - name: claude-sonnet-4-6-thinking + alias: claude-sonnet-4.6-thinking + fork: true +`; + fs.writeFileSync(path.join(cliproxyDir, 'config.yaml'), initialConfig); + + regenerateConfig(); + + const newConfig = fs.readFileSync(path.join(cliproxyDir, 'config.yaml'), 'utf-8'); + assert( + newConfig.includes('alias: claude-sonnet-4-6'), + 'Should migrate dotted deprecated sonnet thinking alias to canonical model ID' + ); + assert( + !newConfig.includes('alias: claude-sonnet-4.6-thinking'), + 'Should remove dotted deprecated sonnet thinking alias after regeneration' + ); + }); }); }); diff --git a/tests/unit/cliproxy/env-builder-provider-url.test.ts b/tests/unit/cliproxy/env-builder-provider-url.test.ts index cde0abb7..6b1efdd7 100644 --- a/tests/unit/cliproxy/env-builder-provider-url.test.ts +++ b/tests/unit/cliproxy/env-builder-provider-url.test.ts @@ -245,6 +245,15 @@ describe('getEffectiveEnvVars local provider URL normalization', () => { ANTHROPIC_DEFAULT_SONNET_MODEL: 'claude-sonnet-4.6-thinking', ANTHROPIC_DEFAULT_HAIKU_MODEL: 'claude-sonnet-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-sonnet-4-5', + }, + ], }, null, 2 @@ -255,11 +264,63 @@ describe('getEffectiveEnvVars local provider URL normalization', () => { const repaired = JSON.parse(fs.readFileSync(agySettingsPath, 'utf-8')) as { env?: Record; + presets?: Array>; }; 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'); + expect(repaired.presets?.[0]?.default).toBe('claude-sonnet-4-6'); + expect(repaired.presets?.[0]?.opus).toBe('claude-opus-4-6-thinking'); + expect(repaired.presets?.[0]?.sonnet).toBe('claude-sonnet-4-6'); + expect(repaired.presets?.[0]?.haiku).toBe('claude-sonnet-4-5'); + }); + + it('migrates codex effort-suffixed preset IDs during ensureProviderSettings', () => { + process.env.CCS_HOME = tempHome; + const codexSettingsPath = path.join(tempHome, '.ccs', 'codex.settings.json'); + fs.mkdirSync(path.dirname(codexSettingsPath), { recursive: true }); + fs.writeFileSync( + codexSettingsPath, + JSON.stringify( + { + env: { + ANTHROPIC_BASE_URL: 'http://127.0.0.1:8317/api/provider/codex', + ANTHROPIC_AUTH_TOKEN: 'ccs-internal-managed', + ANTHROPIC_MODEL: ' gpt-5.3-codex-xhigh ', + ANTHROPIC_DEFAULT_OPUS_MODEL: 'gpt-5.3-codex-xhigh', + ANTHROPIC_DEFAULT_SONNET_MODEL: 'gpt-5.3-codex-high', + ANTHROPIC_DEFAULT_HAIKU_MODEL: 'gpt-5-mini-medium', + }, + presets: [ + { + name: 'legacy', + default: 'gpt-5.3-codex-xhigh', + opus: 'gpt-5.3-codex-xhigh', + sonnet: 'gpt-5.3-codex-high', + haiku: 'gpt-5-mini-medium', + }, + ], + }, + null, + 2 + ) + ); + + ensureProviderSettings('codex'); + + const repaired = JSON.parse(fs.readFileSync(codexSettingsPath, 'utf-8')) as { + env?: Record; + presets?: Array>; + }; + expect(repaired.env?.ANTHROPIC_MODEL).toBe('gpt-5.3-codex'); + expect(repaired.env?.ANTHROPIC_DEFAULT_OPUS_MODEL).toBe('gpt-5.3-codex'); + expect(repaired.env?.ANTHROPIC_DEFAULT_SONNET_MODEL).toBe('gpt-5.3-codex'); + expect(repaired.env?.ANTHROPIC_DEFAULT_HAIKU_MODEL).toBe('gpt-5-mini'); + expect(repaired.presets?.[0]?.default).toBe('gpt-5.3-codex'); + expect(repaired.presets?.[0]?.opus).toBe('gpt-5.3-codex'); + expect(repaired.presets?.[0]?.sonnet).toBe('gpt-5.3-codex'); + expect(repaired.presets?.[0]?.haiku).toBe('gpt-5-mini'); }); it('recovers malformed provider settings files by writing defaults and backup copy', () => { diff --git a/tests/unit/cliproxy/model-catalog-compat.test.ts b/tests/unit/cliproxy/model-catalog-compat.test.ts index 90fad46f..40154419 100644 --- a/tests/unit/cliproxy/model-catalog-compat.test.ts +++ b/tests/unit/cliproxy/model-catalog-compat.test.ts @@ -14,4 +14,12 @@ describe('model-catalog compatibility lookups', () => { expect(supportsThinking('agy', 'claude-opus-4.6-thinking')).toBe(true); expect(supportsThinking('agy', 'claude-sonnet-4.5')).toBe(false); }); + + it('maps legacy sonnet 4.6 thinking aliases to canonical agy model', () => { + const dottedLegacy = findModel('agy', 'claude-sonnet-4.6-thinking'); + const hyphenLegacy = findModel('agy', 'claude-sonnet-4-6-thinking'); + + expect(dottedLegacy?.id).toBe('claude-sonnet-4-6'); + expect(hyphenLegacy?.id).toBe('claude-sonnet-4-6'); + }); }); diff --git a/tests/unit/cliproxy/model-id-normalizer.test.ts b/tests/unit/cliproxy/model-id-normalizer.test.ts index e594551b..328db484 100644 --- a/tests/unit/cliproxy/model-id-normalizer.test.ts +++ b/tests/unit/cliproxy/model-id-normalizer.test.ts @@ -78,6 +78,21 @@ describe('model-id-normalizer', () => { 'claude-sonnet-4-6-thinking' ); }); + + it('trims and canonicalizes provider model IDs with surrounding whitespace', () => { + expect(canonicalizeModelIdForProvider(' gpt-5.3-codex-high ', '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' + ); + expect(normalizeModelIdForRouting(' claude-sonnet-4.6-thinking ', null)).toBe( + 'claude-sonnet-4-6' + ); + }); }); describe('env normalization', () => { diff --git a/tests/unit/web-server/settings-routes-model-canonicalization.test.ts b/tests/unit/web-server/settings-routes-model-canonicalization.test.ts index 09975419..5f7f861c 100644 --- a/tests/unit/web-server/settings-routes-model-canonicalization.test.ts +++ b/tests/unit/web-server/settings-routes-model-canonicalization.test.ts @@ -115,7 +115,15 @@ describe('settings-routes model canonicalization', () => { ANTHROPIC_BASE_URL: 'http://127.0.0.1:8317/api/provider/agy', ANTHROPIC_AUTH_TOKEN: 'ccs-internal-managed', }, - presets: [], + presets: [ + { + name: 'legacy-existing', + default: 'claude-sonnet-4-6-thinking', + opus: 'claude-opus-4.6-thinking', + sonnet: 'claude-sonnet-4.6-thinking', + haiku: 'claude-haiku-4.5', + }, + ], }); const response = await fetch(`${baseUrl}/api/settings/agy/presets`, { @@ -144,6 +152,144 @@ describe('settings-routes model canonicalization', () => { 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'); + expect(persisted.presets[1]?.default).toBe('claude-sonnet-4-6'); + expect(persisted.presets[1]?.opus).toBe('claude-opus-4-6-thinking'); + expect(persisted.presets[1]?.sonnet).toBe('claude-sonnet-4-6'); + expect(persisted.presets[1]?.haiku).toBe('claude-haiku-4-5'); + }); + + it('canonicalizes and persists AGY preset aliases on GET /: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', + ANTHROPIC_MODEL: 'claude-sonnet-4.6-thinking', + }, + 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', + }, + ], + }); + + const response = await fetch(`${baseUrl}/api/settings/agy/presets`); + expect(response.status).toBe(200); + + const body = (await response.json()) as { presets: Array> }; + expect(body.presets[0]?.default).toBe('claude-sonnet-4-6'); + expect(body.presets[0]?.opus).toBe('claude-opus-4-6-thinking'); + expect(body.presets[0]?.sonnet).toBe('claude-sonnet-4-6'); + expect(body.presets[0]?.haiku).toBe('claude-haiku-4-5'); + + const persisted = JSON.parse(fs.readFileSync(settingsPath, 'utf8')) as { + env: Record; + presets: Array>; + }; + expect(persisted.env.ANTHROPIC_MODEL).toBe('claude-sonnet-4-6'); + 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 existing settings on DELETE /:profile/presets/:name writes', 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', + ANTHROPIC_MODEL: 'claude-sonnet-4.6-thinking', + }, + presets: [ + { + name: 'remove-me', + default: 'claude-sonnet-4-6-thinking', + opus: 'claude-opus-4.6-thinking', + sonnet: 'claude-sonnet-4.6-thinking', + haiku: 'claude-haiku-4.5', + }, + ], + }); + + const response = await fetch(`${baseUrl}/api/settings/agy/presets/remove-me`, { + method: 'DELETE', + }); + 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'); + expect(persisted.presets.length).toBe(0); + }); + + it('does not mutate settings on duplicate preset POST failure', 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', + ANTHROPIC_MODEL: 'claude-sonnet-4.6-thinking', + }, + presets: [ + { + name: 'existing', + default: 'claude-sonnet-4-6-thinking', + opus: 'claude-opus-4.6-thinking', + sonnet: 'claude-sonnet-4.6-thinking', + haiku: 'claude-haiku-4.5', + }, + ], + }); + const before = fs.readFileSync(settingsPath, 'utf8'); + + const response = await fetch(`${baseUrl}/api/settings/agy/presets`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ + name: 'existing', + default: 'claude-sonnet-4-6-thinking', + }), + }); + expect(response.status).toBe(409); + + const after = fs.readFileSync(settingsPath, 'utf8'); + expect(after).toBe(before); + }); + + it('does not mutate settings on missing preset DELETE failure', 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', + ANTHROPIC_MODEL: 'claude-sonnet-4.6-thinking', + }, + presets: [ + { + name: 'keep-me', + default: 'claude-sonnet-4-6-thinking', + opus: 'claude-opus-4.6-thinking', + sonnet: 'claude-sonnet-4.6-thinking', + haiku: 'claude-haiku-4.5', + }, + ], + }); + const before = fs.readFileSync(settingsPath, 'utf8'); + + const response = await fetch(`${baseUrl}/api/settings/agy/presets/does-not-exist`, { + method: 'DELETE', + }); + expect(response.status).toBe(404); + + const after = fs.readFileSync(settingsPath, 'utf8'); + expect(after).toBe(before); }); it('canonicalizes AGY aliases via provider alias profile names', async () => {