mirror of
https://github.com/tiennm99/ccs.git
synced 2026-09-20 05:11:48 +00:00
fix(cliproxy): harden agy model canonicalization and migration paths
This commit is contained in:
1 parent
3a26abac71
commit
0b57e41b3a
7 files changed
+464
-19
No files matched your search
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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 });
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
|
||||
Reference in new issue
Block a user