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

This commit is contained in:
Tam Nhu Tran
2026-02-21 02:14:09 +07:00
parent 6a9abd2a48
commit 6429781e8f
13 changed files with 408 additions and 32 deletions
+24 -16
View File
@@ -19,6 +19,11 @@ import {
CLIPROXY_DEFAULT_PORT,
} from './port-manager';
import { getProviderSettingsPath } from './path-resolver';
import {
MODEL_ENV_VAR_KEYS,
normalizeModelEnvVarsForProvider,
normalizeModelIdForProvider,
} from '../model-id-normalizer';
/** Settings file structure for user overrides */
interface ProviderSettings {
@@ -30,14 +35,6 @@ const DEPRECATED_MODEL_PREFIX = 'gemini-claude-';
/** Replacement prefix matching actual upstream model names */
const UPSTREAM_MODEL_PREFIX = 'claude-';
/** Env vars that contain model names and may need migration */
const MODEL_ENV_KEYS = [
'ANTHROPIC_MODEL',
'ANTHROPIC_DEFAULT_OPUS_MODEL',
'ANTHROPIC_DEFAULT_SONNET_MODEL',
'ANTHROPIC_DEFAULT_HAIKU_MODEL',
];
/**
* Migrate deprecated gemini-claude-* model names to upstream claude-* names in a settings file.
* CLIProxyAPI registry no longer recognizes the gemini-claude-* prefix convention.
@@ -49,7 +46,7 @@ function migrateDeprecatedModelNames(settingsPath: string, settings: ProviderSet
if (!settings.env || typeof settings.env !== 'object') return false;
let migrated = false;
for (const key of MODEL_ENV_KEYS) {
for (const key of MODEL_ENV_VAR_KEYS) {
const value = settings.env[key];
if (typeof value !== 'string') continue;
@@ -124,10 +121,12 @@ export function getClaudeEnvVars(
} = baseEnvVars;
// Merge core env vars with additional env vars from base config
return {
const mergedEnv = {
...coreEnvVars,
...additionalEnvVars, // Includes ANTHROPIC_MAX_TOKENS, etc.
};
return normalizeModelEnvVarsForProvider(mergedEnv, provider);
}
/**
@@ -174,7 +173,7 @@ function ensureRequiredEnvVars(
);
}
return result;
return normalizeModelEnvVarsForProvider(result, provider);
}
/** Localhost hostnames used for local CLIProxy endpoints */
@@ -458,7 +457,7 @@ export function getRemoteEnvVars(
ANTHROPIC_AUTH_TOKEN: remoteConfig.authToken || getEffectiveApiKey(),
};
return env;
return normalizeModelEnvVarsForProvider(env, provider) as Record<string, string>;
}
/** Remote config for composite variant (passed from env-resolver) */
@@ -519,10 +518,19 @@ export function getCompositeEnvVars(
const validPort = validatePort(port);
// Defensive: handle missing tiers gracefully
const opusModel = tiers.opus?.model;
const sonnetModel = tiers.sonnet?.model;
const haikuModel = tiers.haiku?.model;
const defaultModel = tiers[defaultTier]?.model;
const opusModel = tiers.opus?.model
? normalizeModelIdForProvider(tiers.opus.model, tiers.opus.provider)
: undefined;
const sonnetModel = tiers.sonnet?.model
? normalizeModelIdForProvider(tiers.sonnet.model, tiers.sonnet.provider)
: undefined;
const haikuModel = tiers.haiku?.model
? normalizeModelIdForProvider(tiers.haiku.model, tiers.haiku.provider)
: undefined;
const defaultTierModel = tiers[defaultTier];
const defaultModel = defaultTierModel?.model
? normalizeModelIdForProvider(defaultTierModel.model, defaultTierModel.provider)
: undefined;
// If default tier is missing, we cannot proceed meaningfully
if (!defaultModel) {
+6 -4
View File
@@ -8,6 +8,7 @@ import { ThinkingConfig, DEFAULT_THINKING_TIER_DEFAULTS } from '../../config/uni
import { getThinkingConfig } from '../../config/unified-config-loader';
import { supportsThinking } from '../model-catalog';
import { isThinkingOffValue, validateThinking } from '../thinking-validator';
import { normalizeModelIdForProvider } from '../model-id-normalizer';
import { warn } from '../../utils/ui';
/** Model tier types for thinking budget defaults */
@@ -19,22 +20,23 @@ export type ModelTier = 'opus' | 'sonnet' | 'haiku';
*/
function normalizeModelForThinkingLookup(model: string, provider: CLIProxyProvider): string {
const withoutExtendedContext = model.replace(/\[1m\]$/i, '').trim();
const providerNormalized = normalizeModelIdForProvider(withoutExtendedContext, provider);
if (provider !== 'codex') return withoutExtendedContext;
if (provider !== 'codex') return providerNormalized;
// New codex suffix form: gpt-5.3-codex-high -> gpt-5.3-codex
const codexSuffixMatch = withoutExtendedContext.match(/^(.*)-(xhigh|high|medium)$/i);
const codexSuffixMatch = providerNormalized.match(/^(.*)-(xhigh|high|medium)$/i);
if (codexSuffixMatch?.[1]) {
return codexSuffixMatch[1].trim();
}
// Legacy codex suffix form: gpt-5.3-codex(high) -> gpt-5.3-codex
const codexLegacyMatch = withoutExtendedContext.match(/^(.*)\((xhigh|high|medium)\)$/i);
const codexLegacyMatch = providerNormalized.match(/^(.*)\((xhigh|high|medium)\)$/i);
if (codexLegacyMatch?.[1]) {
return codexLegacyMatch[1].trim();
}
return withoutExtendedContext;
return providerNormalized;
}
/**
+4 -2
View File
@@ -24,6 +24,7 @@ import { stripClaudeCodeEnv } from '../../utils/shell-executor';
import { CodexReasoningProxy } from '../codex-reasoning-proxy';
import { ToolSanitizationProxy } from '../tool-sanitization-proxy';
import { HttpsTunnelProxy } from '../https-tunnel-proxy';
import { normalizeModelIdForProvider } from '../model-id-normalizer';
export interface RemoteProxyConfig {
host: string;
@@ -271,10 +272,11 @@ export function applyFallback(
} as const;
const result = { ...env };
const originalModel = result[tierEnvMap[failedTier]];
result[tierEnvMap[failedTier]] = fallback.model;
const normalizedFallbackModel = normalizeModelIdForProvider(fallback.model, fallback.provider);
result[tierEnvMap[failedTier]] = normalizedFallbackModel;
// If failed tier is default tier, also update ANTHROPIC_MODEL
if (result.ANTHROPIC_MODEL === originalModel) {
result.ANTHROPIC_MODEL = fallback.model;
result.ANTHROPIC_MODEL = normalizedFallbackModel;
}
return result;
}
+10
View File
@@ -84,6 +84,16 @@ export {
// Model catalog and configuration
export type { ModelEntry, ProviderCatalog } from './model-catalog';
export { MODEL_CATALOG, supportsModelConfig, getProviderCatalog, findModel } from './model-catalog';
export {
MODEL_ENV_VAR_KEYS,
extractProviderFromPathname,
isAntigravityProvider,
normalizeClaudeDottedMajorMinor,
normalizeClaudeDottedThinkingMajorMinor,
normalizeModelIdForProvider,
normalizeModelIdForRouting,
normalizeModelEnvVarsForProvider,
} from './model-id-normalizer';
export {
hasUserSettings,
getCurrentModel,
+7 -1
View File
@@ -6,6 +6,7 @@
*/
import { CLIProxyProvider } from './types';
import { normalizeModelIdForProvider } from './model-id-normalizer';
/**
* Thinking support configuration for a model.
@@ -322,7 +323,12 @@ export function findModel(provider: CLIProxyProvider, modelId: string): ModelEnt
const catalog = MODEL_CATALOG[provider];
if (!catalog || !modelId) return undefined;
const normalizedId = modelId.trim().toLowerCase();
return catalog.models.find((m) => m.id.toLowerCase() === normalizedId);
const providerNormalizedId = normalizeModelIdForProvider(normalizedId, provider)
.trim()
.toLowerCase();
const lookupCandidates = new Set([normalizedId, providerNormalizedId]);
return catalog.models.find((m) => lookupCandidates.has(m.id.toLowerCase()));
}
/**
+104
View File
@@ -0,0 +1,104 @@
/**
* Model ID normalization helpers.
*
* Handles provider-aware compatibility between dotted and hyphenated Claude
* model version formats (e.g., 4.6 vs 4-6).
*/
import { CLIProxyProvider } from './types';
/** Env vars that carry model identifiers. */
export const MODEL_ENV_VAR_KEYS = [
'ANTHROPIC_MODEL',
'ANTHROPIC_DEFAULT_OPUS_MODEL',
'ANTHROPIC_DEFAULT_SONNET_MODEL',
'ANTHROPIC_DEFAULT_HAIKU_MODEL',
] as const;
type ProviderLike = CLIProxyProvider | string | null | undefined;
const CLAUDE_DOTTED_VERSION_REGEX = /claude-(sonnet|opus|haiku)-(\d+)\.(\d+)(?=(?:$|-|\[|\(|\/))/gi;
const CLAUDE_DOTTED_THINKING_REGEX =
/claude-(sonnet|opus|haiku)-(\d+)\.(\d+)-thinking(?=(?:$|-|\[|\(|\/))/gi;
/** Extract provider segment from /api/provider/{provider} paths. */
export function extractProviderFromPathname(pathname: string): string | null {
const match = pathname.match(/\/api\/provider\/([^/]+)/i);
if (!match?.[1]) return null;
return match[1].toLowerCase();
}
/** Whether the provider uses Antigravity model routing conventions. */
export function isAntigravityProvider(provider: ProviderLike): boolean {
if (typeof provider !== 'string') return false;
const normalized = provider.trim().toLowerCase();
return normalized === 'agy' || normalized === 'antigravity';
}
/** Normalize Claude dotted major.minor IDs to hyphenated format. */
export function normalizeClaudeDottedMajorMinor(model: string): string {
return model.replace(
CLAUDE_DOTTED_VERSION_REGEX,
(_match: string, family: string, major: string, minor: string) =>
`claude-${family.toLowerCase()}-${major}-${minor}`
);
}
/**
* Normalize only dotted Claude thinking IDs to hyphenated format.
* Keeps non-thinking dotted IDs unchanged.
*/
export function normalizeClaudeDottedThinkingMajorMinor(model: string): string {
return model.replace(
CLAUDE_DOTTED_THINKING_REGEX,
(_match: string, family: string, major: string, minor: string) =>
`claude-${family.toLowerCase()}-${major}-${minor}-thinking`
);
}
/**
* Normalize model ID for a specific provider.
* Antigravity requires hyphenated Claude major.minor model IDs.
*/
export function normalizeModelIdForProvider(model: string, provider: ProviderLike): string {
if (!isAntigravityProvider(provider)) return model;
return normalizeClaudeDottedMajorMinor(model);
}
/**
* Normalize model ID for request routing.
* - Antigravity routes: normalize all dotted Claude major.minor forms.
* - Root/composite routes: normalize only thinking forms to avoid mutating
* valid non-thinking dotted IDs used by other providers.
*/
export function normalizeModelIdForRouting(model: string, provider: ProviderLike): string {
if (isAntigravityProvider(provider)) {
return normalizeClaudeDottedMajorMinor(model);
}
return normalizeClaudeDottedThinkingMajorMinor(model);
}
/**
* Normalize model-related env vars for a provider.
* Returns original object when no changes are required.
*/
export function normalizeModelEnvVarsForProvider(
envVars: NodeJS.ProcessEnv,
provider: ProviderLike,
keys: readonly string[] = MODEL_ENV_VAR_KEYS
): NodeJS.ProcessEnv {
let nextEnv: NodeJS.ProcessEnv | null = null;
for (const key of keys) {
const value = envVars[key];
if (typeof value !== 'string' || value.trim().length === 0) continue;
const normalizedValue = normalizeModelIdForProvider(value, provider);
if (normalizedValue === value) continue;
if (!nextEnv) nextEnv = { ...envVars };
nextEnv[key] = normalizedValue;
}
return nextEnv ?? envVars;
}
+19 -5
View File
@@ -18,6 +18,7 @@ import * as os from 'os';
import { URL } from 'url';
import { ToolNameMapper, type Tool, type ContentBlock } from './tool-name-mapper';
import { sanitizeToolSchemas } from './schema-sanitizer';
import { extractProviderFromPathname, normalizeModelIdForRouting } from './model-id-normalizer';
import { getCcsDir } from '../utils/config-manager';
export interface ToolSanitizationProxyConfig {
@@ -206,12 +207,25 @@ export class ToolSanitizationProxy {
// Create mapper for this request
const mapper = new ToolNameMapper();
// Sanitize tools if present
// Normalize dotted Claude model IDs for provider-compatible routing.
let modifiedBody = parsed;
if (isRecord(parsed) && Array.isArray(parsed.tools)) {
if (isRecord(modifiedBody) && typeof modifiedBody.model === 'string') {
const providerFromPath = extractProviderFromPathname(fullUpstreamUrl.pathname);
const normalizedModel = normalizeModelIdForRouting(modifiedBody.model, providerFromPath);
if (normalizedModel !== modifiedBody.model) {
this.writeLog(
'warn',
`[tool-sanitization-proxy] Model normalized for provider routing (${providerFromPath ?? 'root'}): "${modifiedBody.model}" → "${normalizedModel}"`
);
modifiedBody = { ...modifiedBody, model: normalizedModel };
}
}
// Sanitize tools if present
if (isRecord(modifiedBody) && Array.isArray(modifiedBody.tools)) {
// Step 1: Sanitize input_schema properties (remove non-standard JSON Schema properties)
const schemaResult = sanitizeToolSchemas(
parsed.tools as Array<{ name: string; input_schema?: Record<string, unknown> }>
modifiedBody.tools as Array<{ name: string; input_schema?: Record<string, unknown> }>
);
if (schemaResult.totalRemoved > 0) {
@@ -228,7 +242,7 @@ export class ToolSanitizationProxy {
// Step 2: Sanitize tool names (truncate to 64 chars for Gemini)
const sanitizedTools = mapper.registerTools(schemaResult.tools as Tool[]);
modifiedBody = { ...parsed, tools: sanitizedTools };
modifiedBody = { ...modifiedBody, tools: sanitizedTools };
// Log sanitization warnings
if (mapper.hasChanges()) {
@@ -252,7 +266,7 @@ export class ToolSanitizationProxy {
}
// Check if streaming is requested
const isStreaming = isRecord(parsed) && parsed.stream === true;
const isStreaming = isRecord(modifiedBody) && modifiedBody.stream === true;
if (isStreaming) {
await this.forwardJsonStreaming(req, res, fullUpstreamUrl, modifiedBody, mapper);
@@ -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}`,