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

This commit is contained in:
Tam Nhu Tran committed 2026-03-02 21:43:33 +07:00
1 parent 3a26abac71
commit 0b57e41b3a
7 files changed
+464 -19

No files matched your search

+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);