fix(cliproxy): normalize provider-aware Claude model IDs

This commit is contained in:
Tam Nhu Tran committed 2026-02-21 02:14:09 +07:00
1 parent 6a9abd2a48
commit 6429781e8f
13 files changed
+408 -32

No files matched your search

@@ -71,4 +71,25 @@ describe('buildClaudeEnvironment - composite remote routing', () => {
// Gemini thinking overrides may be normalized to numeric budgets, and [1m] may be auto-appended.
expect(env.ANTHROPIC_MODEL).toMatch(/\([^)]+\)(\[1m\])?$/);
});
it('normalizes dotted Claude model IDs for antigravity tiers in composite mode', () => {
const env = buildClaudeEnvironment({
provider: 'agy',
useRemoteProxy: false,
localPort: 8318,
verbose: false,
isComposite: true,
compositeTiers: {
opus: { provider: 'agy', model: 'claude-opus-4.6-thinking' },
sonnet: { provider: 'gemini', model: 'gemini-2.5-pro' },
haiku: { provider: 'codex', model: 'gpt-5.1-codex-mini' },
},
compositeDefaultTier: 'opus',
});
expect(env.ANTHROPIC_MODEL).toMatch(/^claude-opus-4-6-thinking(\([^)]+\))?$/);
expect(env.ANTHROPIC_DEFAULT_OPUS_MODEL).toMatch(/^claude-opus-4-6-thinking(\([^)]+\))?$/);
expect(env.ANTHROPIC_DEFAULT_SONNET_MODEL).toMatch(/^gemini-2.5-pro(\([^)]+\))?$/);
expect(env.ANTHROPIC_DEFAULT_HAIKU_MODEL).toBe('gpt-5.1-codex-mini');
});
});
@@ -378,6 +378,25 @@ describe('applyThinkingConfig - composite variant integration', () => {
expect(result.ANTHROPIC_DEFAULT_SONNET_MODEL).toBe('claude-sonnet-4-5-thinking(high)');
});
it('handles dotted agy Claude IDs for thinking capability lookup', () => {
const envVars: NodeJS.ProcessEnv = {
ANTHROPIC_MODEL: 'claude-opus-4.5-thinking',
ANTHROPIC_DEFAULT_OPUS_MODEL: 'claude-opus-4.5-thinking',
ANTHROPIC_DEFAULT_SONNET_MODEL: 'claude-sonnet-4.5-thinking',
ANTHROPIC_DEFAULT_HAIKU_MODEL: 'claude-haiku-4.5',
};
const result = applyThinkingConfig(envVars, 'agy' as CLIProxyProvider, 'high');
// Capability lookup should succeed for dotted agy IDs after normalization.
// Main model value is validated for budget models; tier values keep raw override.
expect(result.ANTHROPIC_MODEL).toBe('claude-opus-4.5-thinking(24576)');
expect(result.ANTHROPIC_DEFAULT_OPUS_MODEL).toBe('claude-opus-4.5-thinking(high)');
expect(result.ANTHROPIC_DEFAULT_SONNET_MODEL).toBe('claude-sonnet-4.5-thinking(high)');
// Haiku does not support thinking in agy catalog.
expect(result.ANTHROPIC_DEFAULT_HAIKU_MODEL).toBe('claude-haiku-4.5');
});
it('should handle numeric budgets in per-tier thinking', () => {
const envVars: NodeJS.ProcessEnv = {
ANTHROPIC_MODEL: 'claude-sonnet-4-5-thinking',
@@ -13,7 +13,7 @@ interface EnvSettings {
ANTHROPIC_DEFAULT_HAIKU_MODEL: string;
}
function writeCodexSettings(settingsPath: string, env: EnvSettings): void {
function writeSettings(settingsPath: string, env: EnvSettings): void {
fs.writeFileSync(settingsPath, JSON.stringify({ env }, null, 2));
}
@@ -31,7 +31,7 @@ describe('getEffectiveEnvVars local provider URL normalization', () => {
});
it('rewrites local root URL to provider endpoint', () => {
writeCodexSettings(settingsPath, {
writeSettings(settingsPath, {
ANTHROPIC_BASE_URL: 'http://127.0.0.1:8317',
ANTHROPIC_AUTH_TOKEN: 'ccs-internal-managed',
ANTHROPIC_MODEL: 'gpt-5.3-codex-xhigh',
@@ -45,7 +45,7 @@ describe('getEffectiveEnvVars local provider URL normalization', () => {
});
it('rewrites wrong local provider path to the requested provider', () => {
writeCodexSettings(settingsPath, {
writeSettings(settingsPath, {
ANTHROPIC_BASE_URL: 'http://localhost:8317/api/provider/my-codex-variant?debug=1',
ANTHROPIC_AUTH_TOKEN: 'ccs-internal-managed',
ANTHROPIC_MODEL: 'gpt-5.3-codex-xhigh',
@@ -59,7 +59,7 @@ describe('getEffectiveEnvVars local provider URL normalization', () => {
});
it('does not rewrite localhost URLs targeting non-cliproxy ports', () => {
writeCodexSettings(settingsPath, {
writeSettings(settingsPath, {
ANTHROPIC_BASE_URL: 'http://127.0.0.1:11434',
ANTHROPIC_AUTH_TOKEN: 'ccs-internal-managed',
ANTHROPIC_MODEL: 'gpt-5.3-codex-xhigh',
@@ -71,4 +71,21 @@ describe('getEffectiveEnvVars local provider URL normalization', () => {
const env = getEffectiveEnvVars('codex', 8317, settingsPath);
expect(env.ANTHROPIC_BASE_URL).toBe('http://127.0.0.1:11434');
});
it('normalizes dotted Claude major.minor IDs for agy provider settings', () => {
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 env = getEffectiveEnvVars('agy', 8317, settingsPath);
expect(env.ANTHROPIC_MODEL).toBe('claude-sonnet-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-thinking');
expect(env.ANTHROPIC_DEFAULT_HAIKU_MODEL).toBe('claude-haiku-4-5');
});
});
@@ -0,0 +1,17 @@
import { describe, it, expect } from 'bun:test';
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');
expect(dottedThinking?.id).toBe('claude-sonnet-4-5-thinking');
expect(dottedNonThinking?.id).toBe('claude-sonnet-4-5');
});
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);
});
});
@@ -0,0 +1,85 @@
import { describe, it, expect } from 'bun:test';
import {
extractProviderFromPathname,
isAntigravityProvider,
normalizeClaudeDottedMajorMinor,
normalizeClaudeDottedThinkingMajorMinor,
normalizeModelIdForProvider,
normalizeModelIdForRouting,
normalizeModelEnvVarsForProvider,
} from '../../../src/cliproxy/model-id-normalizer';
describe('model-id-normalizer', () => {
describe('provider parsing', () => {
it('extracts provider from provider route path', () => {
expect(extractProviderFromPathname('/api/provider/agy/v1/messages')).toBe('agy');
expect(extractProviderFromPathname('/api/provider/antigravity')).toBe('antigravity');
expect(extractProviderFromPathname('/v1/messages')).toBeNull();
});
it('detects antigravity provider aliases', () => {
expect(isAntigravityProvider('agy')).toBe(true);
expect(isAntigravityProvider('antigravity')).toBe(true);
expect(isAntigravityProvider('gemini')).toBe(false);
expect(isAntigravityProvider(undefined)).toBe(false);
});
});
describe('model normalization', () => {
it('normalizes dotted Claude major.minor to hyphen format', () => {
expect(normalizeClaudeDottedMajorMinor('claude-sonnet-4.6-thinking')).toBe(
'claude-sonnet-4-6-thinking'
);
expect(normalizeClaudeDottedMajorMinor('claude-opus-4.6')).toBe('claude-opus-4-6');
});
it('normalizes only dotted thinking variants for root/composite routing', () => {
expect(normalizeClaudeDottedThinkingMajorMinor('claude-sonnet-4.6-thinking')).toBe(
'claude-sonnet-4-6-thinking'
);
expect(normalizeClaudeDottedThinkingMajorMinor('claude-sonnet-4.6')).toBe(
'claude-sonnet-4.6'
);
});
it('applies provider-aware routing normalization', () => {
expect(normalizeModelIdForRouting('claude-sonnet-4.6-thinking', null)).toBe(
'claude-sonnet-4-6-thinking'
);
expect(normalizeModelIdForRouting('claude-sonnet-4.6', null)).toBe('claude-sonnet-4.6');
expect(normalizeModelIdForRouting('claude-sonnet-4.6', 'agy')).toBe('claude-sonnet-4-6');
});
it('applies provider-only normalization for antigravity', () => {
expect(normalizeModelIdForProvider('claude-opus-4.6-thinking', 'agy')).toBe(
'claude-opus-4-6-thinking'
);
expect(normalizeModelIdForProvider('claude-opus-4.6-thinking', 'gemini')).toBe(
'claude-opus-4.6-thinking'
);
});
});
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_DEFAULT_HAIKU_MODEL: 'claude-haiku-4.5',
UNRELATED: 'keep-me',
};
const normalized = normalizeModelEnvVarsForProvider(input, 'agy');
expect(normalized.ANTHROPIC_MODEL).toBe('claude-sonnet-4-6-thinking');
expect(normalized.ANTHROPIC_DEFAULT_OPUS_MODEL).toBe('claude-opus-4-6-thinking');
expect(normalized.ANTHROPIC_DEFAULT_SONNET_MODEL).toBe('claude-sonnet-4-6');
expect(normalized.ANTHROPIC_DEFAULT_HAIKU_MODEL).toBe('claude-haiku-4-5');
expect(normalized.UNRELATED).toBe('keep-me');
const unchanged = normalizeModelEnvVarsForProvider(input, 'gemini');
expect(unchanged.ANTHROPIC_MODEL).toBe('claude-sonnet-4.6-thinking');
expect(unchanged.UNRELATED).toBe('keep-me');
});
});
});
@@ -184,6 +184,77 @@ describe('ToolSanitizationProxy Integration', () => {
}
});
it('normalizes dotted Claude thinking model IDs for root/composite 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}/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('keeps dotted non-thinking model IDs unchanged', 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}/v1/messages`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
model: 'claude-sonnet-4.5',
messages: [{ role: 'user', content: 'test' }],
}),
});
expect(lastRequest).not.toBeNull();
expect((lastRequest!.body as Record<string, unknown>).model).toBe('claude-sonnet-4.5');
} finally {
proxy.stop();
}
});
it('normalizes dotted Claude major.minor IDs on antigravity provider route', 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/agy/v1/messages`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
model: 'claude-opus-4.6',
messages: [{ role: 'user', content: 'test' }],
}),
});
expect(lastRequest).not.toBeNull();
expect((lastRequest!.body as Record<string, unknown>).model).toBe('claude-opus-4-6');
} finally {
proxy.stop();
}
});
it('preserves other tool properties during sanitization', async () => {
const proxy = new ToolSanitizationProxy({
upstreamBaseUrl: `http://127.0.0.1:${mockUpstreamPort}`,