Merge pull request #660 from kaitranntt/kai/feat/cliproxy-multi-provider-analytics

fix(agy): remove deprecated 4.5 selector options
This commit is contained in:
Kai (Tam Nhu) Tran authored and GitHub committed 2026-03-03 09:14:43 -05:00
commit a73d9a4c82
33 files changed
+914 -176

No files matched your search

@@ -0,0 +1,48 @@
import { afterEach, beforeEach, describe, expect, it } from 'bun:test';
import * as fs from 'fs';
import * as os from 'os';
import * as path from 'path';
import { createApiProfile } from '../../../src/api/services/profile-writer';
describe('profile-writer AGY denylist', () => {
let tempHome = '';
let originalCcsHome: string | undefined;
beforeEach(() => {
tempHome = fs.mkdtempSync(path.join(os.tmpdir(), 'ccs-profile-writer-'));
originalCcsHome = process.env.CCS_HOME;
process.env.CCS_HOME = tempHome;
});
afterEach(() => {
if (originalCcsHome === undefined) {
delete process.env.CCS_HOME;
} else {
process.env.CCS_HOME = originalCcsHome;
}
if (tempHome && fs.existsSync(tempHome)) {
fs.rmSync(tempHome, { recursive: true, force: true });
}
});
it('rejects denylisted AGY 4.5 models on profile create', () => {
const result = createApiProfile(
'agy-denied',
'http://127.0.0.1:8317/api/provider/agy',
'test-token',
{
default: 'claude-opus-4.5',
opus: 'claude-opus-4.5',
sonnet: 'claude-sonnet-4.5',
haiku: 'claude-haiku-4.5',
},
'claude'
);
expect(result.success).toBe(false);
expect(result.error).toContain('denylist');
const settingsPath = path.join(tempHome, '.ccs', 'agy-denied.settings.json');
expect(fs.existsSync(settingsPath)).toBe(false);
});
});
@@ -10,7 +10,7 @@ import {
const validTier = {
provider: 'agy',
model: 'claude-sonnet-4-5-thinking',
model: 'claude-sonnet-4-6',
};
describe('validateCompositeDefaultTier', () => {
@@ -77,4 +77,17 @@ describe('validateCompositeTiers', () => {
expect(error).toContain("Circular fallback in tier 'opus'");
});
it('rejects denylisted agy 4.5 models', () => {
const error = validateCompositeTiers(
{
opus: { provider: 'agy', model: 'claude-opus-4.5-thinking' },
sonnet: { provider: 'gemini', model: 'gemini-2.5-pro' },
haiku: { provider: 'gemini', model: 'gemini-2.5-flash' },
},
{ defaultTier: 'opus', requireAllTiers: true }
);
expect(error).toContain('denylist');
});
});
@@ -73,7 +73,7 @@ cliproxy:
default_tier: 'sonnet',
tiers: {
opus: { provider: 'gemini', model: 'gemini-3-pro-preview' },
sonnet: { provider: 'agy', model: 'claude-sonnet-4-5-thinking' },
sonnet: { provider: 'agy', model: 'claude-sonnet-4-6' },
haiku: { provider: 'agy', model: 'claude-haiku-4-5-20251001' },
},
settings: 'cliproxy/composite-test.settings.json',
@@ -99,7 +99,7 @@ cliproxy:
expect(result.success).toBe(true);
expect(result.variant?.tiers?.opus.model).toBe('claude-opus-4-6-thinking');
expect(result.variant?.tiers?.sonnet.model).toBe('claude-sonnet-4-5-thinking'); // Unchanged
expect(result.variant?.tiers?.sonnet.model).toBe('claude-sonnet-4-6'); // Unchanged
expect(result.variant?.tiers?.haiku.model).toBe('claude-haiku-4-5-20251001'); // Unchanged
});
@@ -110,7 +110,7 @@ cliproxy:
default_tier: 'sonnet',
tiers: {
opus: { provider: 'gemini', model: 'gemini-3-pro-preview' },
sonnet: { provider: 'agy', model: 'claude-sonnet-4-5-thinking' },
sonnet: { provider: 'agy', model: 'claude-sonnet-4-6' },
haiku: { provider: 'agy', model: 'claude-haiku-4-5-20251001' },
},
settings: 'cliproxy/composite-test.settings.json',
@@ -144,7 +144,7 @@ cliproxy:
default_tier: 'sonnet',
tiers: {
opus: { provider: 'gemini', model: 'gemini-3-pro-preview' },
sonnet: { provider: 'agy', model: 'claude-sonnet-4-5-thinking' },
sonnet: { provider: 'agy', model: 'claude-sonnet-4-6' },
haiku: { provider: 'agy', model: 'claude-haiku-4-5-20251001' },
},
settings: 'cliproxy/composite-test.settings.json',
@@ -188,7 +188,7 @@ cliproxy:
thinking: 'xhigh',
account: 'team-a',
},
sonnet: { provider: 'agy', model: 'claude-sonnet-4-5-thinking' },
sonnet: { provider: 'agy', model: 'claude-sonnet-4-6' },
haiku: { provider: 'agy', model: 'claude-haiku-4-5-20251001' },
},
settings: 'cliproxy/composite-test.settings.json',
@@ -228,7 +228,7 @@ cliproxy:
default_tier: 'sonnet',
tiers: {
opus: { provider: 'gemini', model: 'gemini-3-pro-preview' },
sonnet: { provider: 'agy', model: 'claude-sonnet-4-5-thinking' },
sonnet: { provider: 'agy', model: 'claude-sonnet-4-6' },
haiku: { provider: 'agy', model: 'claude-haiku-4-5-20251001' },
},
settings: customSettingsPath,
@@ -244,9 +244,9 @@ cliproxy:
env: {
ANTHROPIC_BASE_URL: 'http://127.0.0.1:8318',
ANTHROPIC_AUTH_TOKEN: 'ccs-internal-managed',
ANTHROPIC_MODEL: 'claude-sonnet-4-5-thinking',
ANTHROPIC_MODEL: 'claude-sonnet-4-6',
ANTHROPIC_DEFAULT_OPUS_MODEL: 'gemini-3-pro-preview',
ANTHROPIC_DEFAULT_SONNET_MODEL: 'claude-sonnet-4-5-thinking',
ANTHROPIC_DEFAULT_SONNET_MODEL: 'claude-sonnet-4-6',
ANTHROPIC_DEFAULT_HAIKU_MODEL: 'claude-haiku-4-5-20251001',
CUSTOM_ENV: 'preserve-this',
},
@@ -261,7 +261,7 @@ cliproxy:
const result = updateCompositeVariant('test', {
tiers: {
sonnet: { provider: 'agy', model: 'claude-sonnet-4-5-thinking(high)' },
sonnet: { provider: 'agy', model: 'claude-sonnet-4-6(high)' },
},
});
@@ -273,7 +273,7 @@ cliproxy:
hooks: { PreToolUse: unknown[] };
customPreset: boolean;
};
expect(updatedSettings.env.ANTHROPIC_MODEL).toBe('claude-sonnet-4-5-thinking(high)');
expect(updatedSettings.env.ANTHROPIC_MODEL).toBe('claude-sonnet-4-6(high)');
expect(updatedSettings.env.CUSTOM_ENV).toBe('preserve-this');
expect(updatedSettings.hooks.PreToolUse.length).toBe(1);
expect(updatedSettings.customPreset).toBe(true);
@@ -377,7 +377,7 @@ cliproxy:
defaultTier: 'sonnet',
tiers: {
opus: { provider: 'gemini', model: 'gemini-2.5-pro' },
sonnet: { provider: 'agy', model: 'claude-sonnet-4-5-thinking' },
sonnet: { provider: 'agy', model: 'claude-sonnet-4-6' },
} as unknown as {
opus: { provider: 'gemini' | 'codex' | 'agy'; model: string };
sonnet: { provider: 'gemini' | 'codex' | 'agy'; model: string };
@@ -395,7 +395,7 @@ cliproxy:
defaultTier: 'sonnet',
tiers: {
opus: null,
sonnet: { provider: 'agy', model: 'claude-sonnet-4-5-thinking' },
sonnet: { provider: 'agy', model: 'claude-sonnet-4-6' },
haiku: { provider: 'agy', model: 'claude-haiku-4-5-20251001' },
} as unknown as {
opus: { provider: 'gemini' | 'codex' | 'agy'; model: string };
@@ -414,7 +414,7 @@ cliproxy:
defaultTier: 'sonnet',
tiers: {
opus: { provider: 'gemini', model: 'gemini-2.5-pro' },
sonnet: { provider: 'agy', model: 'claude-sonnet-4-5-thinking' },
sonnet: { provider: 'agy', model: 'claude-sonnet-4-6' },
haiku: { provider: 'agy', model: 'claude-haiku-4-5-20251001' },
},
});
@@ -479,7 +479,7 @@ cliproxy:
default_tier: 'sonnet',
tiers: {
opus: { provider: 'gemini', model: 'gemini-3-pro-preview' },
sonnet: { provider: 'agy', model: 'claude-sonnet-4-5-thinking' },
sonnet: { provider: 'agy', model: 'claude-sonnet-4-6' },
haiku: { provider: 'agy', model: 'claude-haiku-4-5-20251001' },
},
settings: 'cliproxy/composite-test.settings.json',
@@ -547,7 +547,7 @@ cliproxy:
model: gemini-3-pro-preview
sonnet:
provider: agy
model: claude-sonnet-4-5-thinking
model: claude-sonnet-4-6
haiku:
provider: agy
model: claude-haiku-4-5-20251001
@@ -588,7 +588,7 @@ cliproxy:
thinking: xhigh
sonnet:
provider: agy
model: claude-sonnet-4-5-thinking
model: claude-sonnet-4-6
thinking: medium
haiku:
provider: agy
@@ -632,7 +632,7 @@ cliproxy:
model: claude-opus-4-6-thinking
sonnet:
provider: agy
model: claude-sonnet-4-5-thinking
model: claude-sonnet-4-6
haiku:
provider: agy
model: claude-haiku-4-5-20251001
@@ -671,7 +671,7 @@ cliproxy:
model: claude-opus-4-6-thinking
sonnet:
provider: agy
model: claude-sonnet-4-5-thinking
model: claude-sonnet-4-6
settings: cliproxy/bad.settings.json
port: 8318
good:
+8 -1
View File
@@ -584,9 +584,16 @@ auth-dir: "${cliproxyDir.replace(/\\/g, '/')}/auth"
const config = fs.readFileSync(path.join(cliproxyDir, 'config.yaml'), 'utf-8');
// Claude aliases should have fork: true
assert(config.includes('claude-sonnet-4-5'), 'Should include Claude sonnet model');
assert(config.includes('claude-sonnet-4-6'), 'Should include Claude Sonnet 4.6 model');
assert(config.includes('fork: true'), 'Should include fork: true for Claude aliases');
assert(
!config.includes('claude-sonnet-4-5'),
'Should not include denylisted Claude Sonnet 4.5 aliases'
);
assert(
!config.includes('claude-opus-4-5'),
'Should not include denylisted Claude Opus 4.5 aliases'
);
assert(
!config.includes('alias: gemini-claude-'),
'Should not emit deprecated gemini-claude aliases'
@@ -118,6 +118,50 @@ describe('getEffectiveEnvVars local provider URL normalization', () => {
expect(persisted.env.ANTHROPIC_DEFAULT_SONNET_MODEL).toBe('claude-sonnet-4-6');
});
it('migrates denylisted AGY 4.5 model IDs to supported 4.6 fallbacks', () => {
writeSettings(
settingsPath,
{
ANTHROPIC_BASE_URL: 'http://127.0.0.1:8317/api/provider/agy',
ANTHROPIC_AUTH_TOKEN: 'ccs-internal-managed',
ANTHROPIC_MODEL: 'claude-opus-4.5',
ANTHROPIC_DEFAULT_OPUS_MODEL: 'claude-opus-4-5-thinking',
ANTHROPIC_DEFAULT_SONNET_MODEL: 'claude-sonnet-4.5-thinking',
ANTHROPIC_DEFAULT_HAIKU_MODEL: 'claude-sonnet-4-5',
},
{
presets: [
{
name: 'legacy-45',
default: 'claude-opus-4.5',
opus: 'claude-opus-4-5-thinking',
sonnet: 'claude-sonnet-4.5-thinking',
haiku: 'claude-sonnet-4-5',
},
],
}
);
const env = getEffectiveEnvVars('agy', 8317, settingsPath);
expect(env.ANTHROPIC_MODEL).toBe('claude-opus-4-6-thinking');
expect(env.ANTHROPIC_DEFAULT_OPUS_MODEL).toBe('claude-opus-4-6-thinking');
expect(env.ANTHROPIC_DEFAULT_SONNET_MODEL).toBe('claude-sonnet-4-6');
expect(env.ANTHROPIC_DEFAULT_HAIKU_MODEL).toBe('claude-sonnet-4-6');
const persisted = JSON.parse(fs.readFileSync(settingsPath, 'utf-8')) as {
env: Record<string, string>;
presets?: Array<Record<string, string>>;
};
expect(persisted.env.ANTHROPIC_MODEL).toBe('claude-opus-4-6-thinking');
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-sonnet-4-6');
expect(persisted.presets?.[0]?.default).toBe('claude-opus-4-6-thinking');
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-sonnet-4-6');
});
it('migrates codex preset model mappings to canonical IDs', () => {
writeSettings(
settingsPath,
@@ -269,11 +313,11 @@ describe('getEffectiveEnvVars local provider URL normalization', () => {
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.env?.ANTHROPIC_DEFAULT_HAIKU_MODEL).toBe('claude-sonnet-4-6');
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');
expect(repaired.presets?.[0]?.haiku).toBe('claude-sonnet-4-6');
});
it('migrates codex effort-suffixed preset IDs during ensureProviderSettings', () => {
@@ -3,16 +3,28 @@ import { findModel, supportsThinking } from '../../../src/cliproxy/model-catalog
describe('model-catalog compatibility lookups', () => {
it('finds agy Claude models using dotted major.minor IDs', () => {
const dottedThinking = findModel('agy', 'claude-sonnet-4.5-thinking');
const dottedNonThinking = findModel('agy', 'claude-sonnet-4.5');
const dottedThinking = findModel('agy', 'claude-opus-4.6-thinking');
const dottedNonThinking = findModel('agy', 'claude-sonnet-4.6');
expect(dottedThinking?.id).toBe('claude-sonnet-4-5-thinking');
expect(dottedNonThinking?.id).toBe('claude-sonnet-4-5');
expect(dottedThinking?.id).toBe('claude-opus-4-6-thinking');
expect(dottedNonThinking?.id).toBe('claude-sonnet-4-6');
});
it('maps legacy agy 4.5 IDs to canonical 4.6 catalog models', () => {
const legacyOpusThinking = findModel('agy', 'claude-opus-4.5-thinking');
const legacySonnetThinking = findModel('agy', 'claude-sonnet-4.5-thinking');
const legacySonnet = findModel('agy', 'claude-sonnet-4.5');
expect(legacyOpusThinking?.id).toBe('claude-opus-4-6-thinking');
expect(legacySonnetThinking?.id).toBe('claude-sonnet-4-6');
expect(legacySonnet?.id).toBe('claude-sonnet-4-6');
});
it('supports thinking checks for dotted agy model IDs', () => {
expect(supportsThinking('agy', 'claude-opus-4.6-thinking')).toBe(true);
expect(supportsThinking('agy', 'claude-sonnet-4.5')).toBe(false);
expect(supportsThinking('agy', 'claude-sonnet-4.6')).toBe(true);
expect(supportsThinking('agy', 'claude-opus-4.5-thinking')).toBe(true);
expect(supportsThinking('agy', 'claude-sonnet-4.5')).toBe(true);
});
it('maps legacy sonnet 4.6 thinking aliases to canonical agy model', () => {
+43 -47
View File
@@ -69,20 +69,11 @@ describe('Model Catalog', () => {
assert.strictEqual(MODEL_CATALOG.agy.defaultModel, 'claude-opus-4-6-thinking');
});
it('includes Claude Opus 4.5 Thinking', () => {
it('includes Claude Opus 4.6 Thinking', () => {
const { MODEL_CATALOG } = modelCatalog;
const opus = MODEL_CATALOG.agy.models.find((m) => m.id === 'claude-opus-4-5-thinking');
assert(opus, 'Should include Claude Opus 4.5 Thinking');
assert.strictEqual(opus.name, 'Claude Opus 4.5 Thinking');
});
it('includes Claude Sonnet 4.5 Thinking', () => {
const { MODEL_CATALOG } = modelCatalog;
const sonnetThinking = MODEL_CATALOG.agy.models.find(
(m) => m.id === 'claude-sonnet-4-5-thinking'
);
assert(sonnetThinking, 'Should include Claude Sonnet 4.5 Thinking');
assert.strictEqual(sonnetThinking.name, 'Claude Sonnet 4.5 Thinking');
const opus = MODEL_CATALOG.agy.models.find((m) => m.id === 'claude-opus-4-6-thinking');
assert(opus, 'Should include Claude Opus 4.6 Thinking');
assert.strictEqual(opus.name, 'Claude Opus 4.6 Thinking');
});
it('includes Claude Sonnet 4.6', () => {
@@ -92,11 +83,12 @@ describe('Model Catalog', () => {
assert.strictEqual(sonnet.name, 'Claude Sonnet 4.6');
});
it('includes Claude Sonnet 4.5', () => {
it('does not include deprecated Claude 4.5 AGY models', () => {
const { MODEL_CATALOG } = modelCatalog;
const sonnet = MODEL_CATALOG.agy.models.find((m) => m.id === 'claude-sonnet-4-5');
assert(sonnet, 'Should include Claude Sonnet 4.5');
assert.strictEqual(sonnet.name, 'Claude Sonnet 4.5');
const ids = MODEL_CATALOG.agy.models.map((m) => m.id);
assert.strictEqual(ids.includes('claude-opus-4-5-thinking'), false);
assert.strictEqual(ids.includes('claude-sonnet-4-5-thinking'), false);
assert.strictEqual(ids.includes('claude-sonnet-4-5'), false);
});
it('includes Gemini 3 Pro (free via Antigravity)', () => {
@@ -108,9 +100,9 @@ describe('Model Catalog', () => {
assert.strictEqual(gem3.tier, undefined, 'AGY models should not have paid tier');
});
it('has 6 models total', () => {
it('has 3 models total', () => {
const { MODEL_CATALOG } = modelCatalog;
assert.strictEqual(MODEL_CATALOG.agy.models.length, 6);
assert.strictEqual(MODEL_CATALOG.agy.models.length, 3);
});
});
@@ -203,11 +195,11 @@ describe('Model Catalog', () => {
});
describe('findModel', () => {
it('finds Claude Opus 4.5 Thinking in agy', () => {
it('finds Claude Opus 4.6 Thinking in agy', () => {
const { findModel } = modelCatalog;
const model = findModel('agy', 'claude-opus-4-5-thinking');
const model = findModel('agy', 'claude-opus-4-6-thinking');
assert(model, 'Should find model');
assert.strictEqual(model.name, 'Claude Opus 4.5 Thinking');
assert.strictEqual(model.name, 'Claude Opus 4.6 Thinking');
});
it('finds Gemini 2.5 Pro in gemini', () => {
@@ -217,6 +209,16 @@ describe('Model Catalog', () => {
assert.strictEqual(model.name, 'Gemini 2.5 Pro');
});
it('maps legacy AGY 4.5 IDs to supported 4.6 models', () => {
const { findModel } = modelCatalog;
const legacyOpus = findModel('agy', 'claude-opus-4-5-thinking');
const legacySonnetThinking = findModel('agy', 'claude-sonnet-4-5-thinking');
const legacySonnet = findModel('agy', 'claude-sonnet-4-5');
assert.strictEqual(legacyOpus?.id, 'claude-opus-4-6-thinking');
assert.strictEqual(legacySonnetThinking?.id, 'claude-sonnet-4-6');
assert.strictEqual(legacySonnet?.id, 'claude-sonnet-4-6');
});
it('returns undefined for unknown model', () => {
const { findModel } = modelCatalog;
const model = findModel('agy', 'unknown-model');
@@ -269,19 +271,17 @@ describe('Model Catalog', () => {
});
describe('Thinking models ordering', () => {
it('Claude Opus 4.5 Thinking is not deprecated', () => {
it('Claude Opus 4.6 Thinking is not deprecated', () => {
const { MODEL_CATALOG } = modelCatalog;
const opus = MODEL_CATALOG.agy.models.find((m) => m.id === 'claude-opus-4-5-thinking');
assert(opus, 'Should include Claude Opus 4.5 Thinking');
const opus = MODEL_CATALOG.agy.models.find((m) => m.id === 'claude-opus-4-6-thinking');
assert(opus, 'Should include Claude Opus 4.6 Thinking');
assert.strictEqual(opus.deprecated, undefined, 'Should not be marked as deprecated');
});
it('Claude Sonnet 4.5 Thinking is not deprecated', () => {
it('Claude Sonnet 4.6 is not deprecated', () => {
const { MODEL_CATALOG } = modelCatalog;
const sonnetThinking = MODEL_CATALOG.agy.models.find(
(m) => m.id === 'claude-sonnet-4-5-thinking'
);
assert(sonnetThinking, 'Should include Claude Sonnet 4.5 Thinking');
const sonnetThinking = MODEL_CATALOG.agy.models.find((m) => m.id === 'claude-sonnet-4-6');
assert(sonnetThinking, 'Should include Claude Sonnet 4.6');
assert.strictEqual(
sonnetThinking.deprecated,
undefined,
@@ -294,31 +294,27 @@ describe('Model Catalog', () => {
const models = MODEL_CATALOG.agy.models;
// Find indices of thinking models
const opusIdx = models.findIndex((m) => m.id === 'claude-opus-4-5-thinking');
const sonnetThinkingIdx = models.findIndex((m) => m.id === 'claude-sonnet-4-5-thinking');
const opusIdx = models.findIndex((m) => m.id === 'claude-opus-4-6-thinking');
const sonnetThinkingIdx = models.findIndex((m) => m.id === 'claude-sonnet-4-6');
// Find indices of non-thinking models
const sonnetIdx = models.findIndex((m) => m.id === 'claude-sonnet-4-5');
// Find indices of the remaining non-Claude model
const geminiIdx = models.findIndex((m) => m.id === 'gemini-3-pro-preview');
// Thinking models should come before non-thinking models
assert(opusIdx < sonnetIdx, 'Opus Thinking should be above non-thinking Sonnet');
assert(opusIdx < geminiIdx, 'Opus Thinking should be above non-thinking Gemini');
assert(sonnetThinkingIdx < sonnetIdx, 'Sonnet Thinking should be above non-thinking Sonnet');
assert(sonnetThinkingIdx < geminiIdx, 'Sonnet Thinking should be above non-thinking Gemini');
// Primary Claude choices should appear ahead of Gemini fallback.
assert(opusIdx < geminiIdx, 'Opus should be above Gemini');
assert(sonnetThinkingIdx < geminiIdx, 'Sonnet should be above Gemini');
});
});
describe('isModelDeprecated', () => {
it('returns false for thinking models (no longer deprecated)', () => {
it('returns false for active AGY Claude models', () => {
const { isModelDeprecated } = modelCatalog;
assert.strictEqual(isModelDeprecated('agy', 'claude-opus-4-5-thinking'), false);
assert.strictEqual(isModelDeprecated('agy', 'claude-sonnet-4-5-thinking'), false);
assert.strictEqual(isModelDeprecated('agy', 'claude-opus-4-6-thinking'), false);
assert.strictEqual(isModelDeprecated('agy', 'claude-sonnet-4-6'), false);
});
it('returns false for non-deprecated models', () => {
const { isModelDeprecated } = modelCatalog;
assert.strictEqual(isModelDeprecated('agy', 'claude-sonnet-4-5'), false);
assert.strictEqual(isModelDeprecated('agy', 'gemini-3-pro-preview'), false);
});
@@ -329,15 +325,15 @@ describe('Model Catalog', () => {
});
describe('getModelDeprecationReason', () => {
it('returns undefined for thinking models (no longer deprecated)', () => {
it('returns undefined for active AGY Claude models', () => {
const { getModelDeprecationReason } = modelCatalog;
assert.strictEqual(getModelDeprecationReason('agy', 'claude-opus-4-5-thinking'), undefined);
assert.strictEqual(getModelDeprecationReason('agy', 'claude-sonnet-4-5-thinking'), undefined);
assert.strictEqual(getModelDeprecationReason('agy', 'claude-opus-4-6-thinking'), undefined);
assert.strictEqual(getModelDeprecationReason('agy', 'claude-sonnet-4-6'), undefined);
});
it('returns undefined for non-deprecated models', () => {
const { getModelDeprecationReason } = modelCatalog;
assert.strictEqual(getModelDeprecationReason('agy', 'claude-sonnet-4-5'), undefined);
assert.strictEqual(getModelDeprecationReason('agy', 'gemini-3-pro-preview'), undefined);
});
});
});
@@ -2,7 +2,10 @@ import { describe, it, expect } from 'bun:test';
import {
canonicalizeModelIdForProvider,
extractProviderFromPathname,
getDeniedModelIdReasonForProvider,
isDeniedAntigravityModelId,
isAntigravityProvider,
migrateDeniedAntigravityModelAliases,
normalizeClaudeDottedMajorMinor,
normalizeClaudeDottedThinkingMajorMinor,
normalizeModelIdForProvider,
@@ -64,6 +67,13 @@ describe('model-id-normalizer', () => {
expect(normalizeModelIdForProvider('claude-opus-4.6-thinking', 'agy')).toBe(
'claude-opus-4-6-thinking'
);
expect(normalizeModelIdForProvider('claude-opus-4.5-thinking', 'agy')).toBe(
'claude-opus-4-5-thinking'
);
expect(normalizeModelIdForProvider('claude-sonnet-4.5-thinking', 'agy')).toBe(
'claude-sonnet-4-5-thinking'
);
expect(normalizeModelIdForProvider('claude-sonnet-4.5', 'agy')).toBe('claude-sonnet-4-5');
expect(normalizeModelIdForProvider('claude-opus-4.6-thinking', 'gemini')).toBe(
'claude-opus-4.6-thinking'
);
@@ -98,23 +108,41 @@ describe('model-id-normalizer', () => {
describe('env normalization', () => {
it('normalizes model env vars for antigravity only', () => {
const input: NodeJS.ProcessEnv = {
ANTHROPIC_MODEL: 'claude-sonnet-4.6-thinking',
ANTHROPIC_DEFAULT_OPUS_MODEL: 'claude-opus-4.6-thinking',
ANTHROPIC_DEFAULT_SONNET_MODEL: 'claude-sonnet-4.6',
ANTHROPIC_MODEL: 'claude-sonnet-4.5-thinking',
ANTHROPIC_DEFAULT_OPUS_MODEL: 'claude-opus-4.5-thinking',
ANTHROPIC_DEFAULT_SONNET_MODEL: 'claude-sonnet-4.5',
ANTHROPIC_DEFAULT_HAIKU_MODEL: 'claude-haiku-4.5',
UNRELATED: 'keep-me',
};
const normalized = normalizeModelEnvVarsForProvider(input, 'agy');
expect(normalized.ANTHROPIC_MODEL).toBe('claude-sonnet-4-6');
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_MODEL).toBe('claude-sonnet-4-5-thinking');
expect(normalized.ANTHROPIC_DEFAULT_OPUS_MODEL).toBe('claude-opus-4-5-thinking');
expect(normalized.ANTHROPIC_DEFAULT_SONNET_MODEL).toBe('claude-sonnet-4-5');
expect(normalized.ANTHROPIC_DEFAULT_HAIKU_MODEL).toBe('claude-haiku-4-5');
expect(normalized.UNRELATED).toBe('keep-me');
const unchanged = normalizeModelEnvVarsForProvider(input, 'gemini');
expect(unchanged.ANTHROPIC_MODEL).toBe('claude-sonnet-4.6-thinking');
expect(unchanged.ANTHROPIC_MODEL).toBe('claude-sonnet-4.5-thinking');
expect(unchanged.UNRELATED).toBe('keep-me');
});
it('flags denylisted antigravity models', () => {
expect(isDeniedAntigravityModelId('claude-opus-4-5-thinking')).toBe(true);
expect(isDeniedAntigravityModelId('claude-sonnet-4.5')).toBe(true);
expect(getDeniedModelIdReasonForProvider('claude-sonnet-4.5', 'agy')).toContain('denylist');
expect(getDeniedModelIdReasonForProvider('claude-sonnet-4.5', 'gemini')).toBeNull();
expect(getDeniedModelIdReasonForProvider('claude-haiku-4.5', 'agy')).toBeNull();
});
it('migrates denylisted antigravity IDs to supported 4.6 fallbacks', () => {
expect(migrateDeniedAntigravityModelAliases('claude-opus-4.5')).toBe(
'claude-opus-4-6-thinking'
);
expect(migrateDeniedAntigravityModelAliases('claude-sonnet-4-5-thinking(8192)')).toBe(
'claude-sonnet-4-6(8192)'
);
expect(migrateDeniedAntigravityModelAliases('claude-haiku-4-5')).toBe('claude-haiku-4-5');
});
});
});
@@ -253,6 +253,31 @@ describe('ToolSanitizationProxy Integration', () => {
}
});
it('rejects denylisted AGY 4.5 model IDs on antigravity provider route', async () => {
const proxy = new ToolSanitizationProxy({
upstreamBaseUrl: `http://127.0.0.1:${mockUpstreamPort}`,
});
const port = await proxy.start();
try {
const response = await fetch(`http://127.0.0.1:${port}/api/provider/agy/v1/messages`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
model: 'claude-sonnet-4.5',
messages: [{ role: 'user', content: 'test' }],
}),
});
expect(response.status).toBe(400);
const body = (await response.json()) as { error: string };
expect(body.error).toContain('denylist');
expect(lastRequest).toBeNull();
} finally {
proxy.stop();
}
});
it('does not rewrite AGY legacy aliases on non-AGY provider routes', async () => {
const proxy = new ToolSanitizationProxy({
upstreamBaseUrl: `http://127.0.0.1:${mockUpstreamPort}`,
@@ -83,6 +83,15 @@ cliproxy:
expect(result.error).toContain('Changing provider requires model update');
});
it('rejects denylisted AGY 4.5 model updates', () => {
const result = updateVariant('demo', {
provider: 'agy',
model: 'claude-sonnet-4.5',
});
expect(result.success).toBe(false);
expect(result.error).toContain('denylist');
});
it('updates provider and regenerates provider-specific core env in same settings file', () => {
const result = updateVariant('demo', {
provider: 'codex',
@@ -91,6 +91,33 @@ describe('cliproxy-stats-routes model update canonicalization', () => {
expect(persisted.env.ANTHROPIC_DEFAULT_HAIKU_MODEL).toBe('claude-haiku-4-5');
});
it('rejects denylisted AGY 4.5 model updates', 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',
ANTHROPIC_DEFAULT_OPUS_MODEL: 'claude-opus-4-6-thinking',
ANTHROPIC_DEFAULT_SONNET_MODEL: 'claude-sonnet-4-6',
ANTHROPIC_DEFAULT_HAIKU_MODEL: 'claude-haiku-4-5',
});
const response = await fetch(`${baseUrl}/api/cliproxy/models/agy`, {
method: 'PUT',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ model: 'claude-opus-4.5' }),
});
expect(response.status).toBe(400);
const body = (await response.json()) as { error: string };
expect(body.error).toContain('denylist');
const persisted = JSON.parse(fs.readFileSync(settingsPath, 'utf8')) as {
env: Record<string, string>;
};
expect(persisted.env.ANTHROPIC_MODEL).toBe('claude-sonnet-4-6');
});
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, {
@@ -0,0 +1,74 @@
import { afterEach, beforeEach, describe, expect, it } from 'bun:test';
import * as fs from 'fs';
import * as os from 'os';
import * as path from 'path';
import {
createSettingsFile,
updateSettingsFile,
} from '../../../src/web-server/routes/route-helpers';
describe('route-helpers AGY denylist', () => {
let tempHome = '';
let originalCcsHome: string | undefined;
beforeEach(() => {
tempHome = fs.mkdtempSync(path.join(os.tmpdir(), 'ccs-route-helpers-'));
originalCcsHome = process.env.CCS_HOME;
process.env.CCS_HOME = tempHome;
});
afterEach(() => {
if (originalCcsHome === undefined) {
delete process.env.CCS_HOME;
} else {
process.env.CCS_HOME = originalCcsHome;
}
if (tempHome && fs.existsSync(tempHome)) {
fs.rmSync(tempHome, { recursive: true, force: true });
}
});
it('rejects denylisted AGY models on settings create', () => {
expect(() =>
createSettingsFile(
'agy-denied',
'http://127.0.0.1:8317/api/provider/agy',
'test-token',
{
model: 'claude-sonnet-4.5',
opusModel: 'claude-opus-4.5',
sonnetModel: 'claude-sonnet-4.5',
haikuModel: 'claude-haiku-4.5',
}
)
).toThrow(/denylist/i);
});
it('rejects denylisted AGY models on settings update', () => {
const settingsDir = path.join(tempHome, '.ccs');
fs.mkdirSync(settingsDir, { recursive: true });
const settingsPath = path.join(settingsDir, 'agy-profile.settings.json');
fs.writeFileSync(
settingsPath,
JSON.stringify(
{
env: {
ANTHROPIC_BASE_URL: 'http://127.0.0.1:8317/api/provider/agy',
ANTHROPIC_AUTH_TOKEN: 'test-token',
ANTHROPIC_MODEL: 'claude-sonnet-4-6',
ANTHROPIC_DEFAULT_OPUS_MODEL: 'claude-opus-4-6-thinking',
ANTHROPIC_DEFAULT_SONNET_MODEL: 'claude-sonnet-4-6',
ANTHROPIC_DEFAULT_HAIKU_MODEL: 'claude-haiku-4-5',
},
},
null,
2
) + '\n'
);
expect(() => updateSettingsFile('agy-profile', { model: 'claude-opus-4.5' })).toThrow(
/denylist/i
);
});
});
@@ -324,6 +324,88 @@ describe('settings-routes model canonicalization', () => {
expect(persisted.env.ANTHROPIC_DEFAULT_HAIKU_MODEL).toBe('claude-haiku-4-5');
});
it('rejects denylisted AGY models 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.5',
ANTHROPIC_DEFAULT_OPUS_MODEL: 'claude-opus-4-6-thinking',
ANTHROPIC_DEFAULT_SONNET_MODEL: 'claude-sonnet-4-6',
ANTHROPIC_DEFAULT_HAIKU_MODEL: 'claude-haiku-4.5',
},
presets: [],
},
}),
});
expect(response.status).toBe(400);
const body = (await response.json()) as { error?: string };
expect(body.error).toContain('denylist');
});
it('rejects denylisted AGY models 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: 'legacy',
default: 'claude-opus-4.5-thinking',
}),
});
expect(response.status).toBe(400);
const body = (await response.json()) as { error?: string };
expect(body.error).toContain('denylist');
});
it('drops denylisted AGY presets on read canonicalization', 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',
},
presets: [
{
name: 'denylisted',
default: 'claude-sonnet-4.5',
opus: 'claude-opus-4-6-thinking',
sonnet: 'claude-sonnet-4-6',
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<Record<string, string>> };
expect(body.presets.length).toBe(0);
const persisted = JSON.parse(fs.readFileSync(settingsPath, 'utf8')) as {
presets: Array<Record<string, string>>;
};
expect(persisted.presets.length).toBe(0);
});
it('does not crash on malformed non-string preset model values', async () => {
const settingsPath = path.join(tempHome, '.ccs', 'agy.settings.json');
writeSettings(settingsPath, {