fix(cliproxy): harden agy model canonicalization and migration paths

This commit is contained in:
Tam Nhu Tran
2026-03-02 21:43:33 +07:00
parent 3a26abac71
commit 0b57e41b3a
7 changed files with 464 additions and 19 deletions
+26
View File
@@ -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);
}
+35 -4
View File
@@ -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<voi
}
// Read and update settings
const settings = JSON.parse(fs.readFileSync(settingsPath, 'utf8'));
settings.env = settings.env || {};
settings.env.ANTHROPIC_MODEL = model;
const settings = JSON.parse(fs.readFileSync(settingsPath, 'utf8')) as {
env?: Record<string, unknown>;
[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 });
}
+30 -15
View File
@@ -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);
@@ -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', () => {
@@ -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<string, unknown>).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}`,
@@ -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<string, string>): 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<void>((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<void>((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<string, string>;
};
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<string, string>;
};
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');
});
});
@@ -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<string, unknown>): 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<void>((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<void>((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<string, string>;
presets: Array<Record<string, string>>;
};
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<string, string> };
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<Record<string, string>>;
};
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<string, string>;
};
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<Record<string, unknown>> };
};
expect(body.settings.presets[0]?.default).toBe(123);
expect(body.settings.presets[0]?.sonnet).toBe('claude-sonnet-4-6');
});
});