fix(codex): canonicalize dashboard presets and migrate suffixed settings

This commit is contained in:
Tam Nhu Tran
2026-02-23 22:52:44 +07:00
parent dd9c850153
commit be96b84e61
4 changed files with 213 additions and 26 deletions
+65
View File
@@ -28,12 +28,19 @@ import {
/** Settings file structure for user overrides */ /** Settings file structure for user overrides */
interface ProviderSettings { interface ProviderSettings {
env: NodeJS.ProcessEnv; env: NodeJS.ProcessEnv;
presets?: Array<Record<string, unknown>>;
} }
/** Model name prefix that was deprecated in CLIProxyAPI registry */ /** Model name prefix that was deprecated in CLIProxyAPI registry */
const DEPRECATED_MODEL_PREFIX = 'gemini-claude-'; const DEPRECATED_MODEL_PREFIX = 'gemini-claude-';
/** Replacement prefix matching actual upstream model names */ /** Replacement prefix matching actual upstream model names */
const UPSTREAM_MODEL_PREFIX = 'claude-'; const UPSTREAM_MODEL_PREFIX = 'claude-';
const CODEX_EFFORT_SUFFIX_REGEX = /-(xhigh|high|medium)$/i;
const PRESET_MODEL_KEYS = ['default', 'opus', 'sonnet', 'haiku'] as const;
function stripCodexEffortSuffix(modelId: string): string {
return modelId.replace(CODEX_EFFORT_SUFFIX_REGEX, '');
}
/** /**
* Migrate deprecated gemini-claude-* model names to upstream claude-* names in a settings file. * Migrate deprecated gemini-claude-* model names to upstream claude-* names in a settings file.
@@ -68,6 +75,58 @@ function migrateDeprecatedModelNames(settingsPath: string, settings: ProviderSet
return migrated; return migrated;
} }
/**
* Migrate codex effort-suffixed model IDs in settings to canonical IDs.
* Example: gpt-5.3-codex-xhigh -> gpt-5.3-codex
*/
function migrateCodexEffortSuffixes(
settingsPath: string,
provider: CLIProxyProvider,
settings: ProviderSettings
): boolean {
if (provider !== 'codex') return false;
if (!settings.env || typeof settings.env !== 'object') return false;
let migrated = false;
for (const key of MODEL_ENV_VAR_KEYS) {
const value = settings.env[key];
if (typeof value !== 'string') continue;
const canonical = stripCodexEffortSuffix(value);
if (canonical !== value) {
settings.env[key] = canonical;
migrated = true;
}
}
if (Array.isArray(settings.presets)) {
for (const preset of settings.presets) {
if (!preset || typeof preset !== 'object') continue;
const presetRecord = preset as Record<string, unknown>;
for (const key of PRESET_MODEL_KEYS) {
const value = presetRecord[key];
if (typeof value !== 'string') continue;
const canonical = stripCodexEffortSuffix(value);
if (canonical !== value) {
presetRecord[key] = canonical;
migrated = true;
}
}
}
}
if (migrated) {
try {
fs.writeFileSync(settingsPath, JSON.stringify(settings, null, 2) + '\n', { mode: 0o600 });
} catch {
// Best-effort migration — don't block startup if write fails
}
}
return migrated;
}
/** Remote proxy configuration for URL rewriting */ /** Remote proxy configuration for URL rewriting */
export interface RemoteProxyRewriteConfig { export interface RemoteProxyRewriteConfig {
host: string; host: string;
@@ -285,6 +344,8 @@ export function getEffectiveEnvVars(
if (settings.env && typeof settings.env === 'object') { if (settings.env && typeof settings.env === 'object') {
// Migrate deprecated gemini-claude-* model names if present // Migrate deprecated gemini-claude-* model names if present
migrateDeprecatedModelNames(expandedPath, settings); migrateDeprecatedModelNames(expandedPath, settings);
// Migrate codex effort suffixes to canonical IDs if present
migrateCodexEffortSuffixes(expandedPath, provider, settings);
// Custom variant settings found - merge with global env // Custom variant settings found - merge with global env
envVars = { ...globalEnv, ...settings.env }; envVars = { ...globalEnv, ...settings.env };
// Ensure required vars are present (fall back to defaults if missing) // Ensure required vars are present (fall back to defaults if missing)
@@ -316,6 +377,8 @@ export function getEffectiveEnvVars(
if (settings.env && typeof settings.env === 'object') { if (settings.env && typeof settings.env === 'object') {
// Migrate deprecated gemini-claude-* model names if present // Migrate deprecated gemini-claude-* model names if present
migrateDeprecatedModelNames(settingsPath, settings); migrateDeprecatedModelNames(settingsPath, settings);
// Migrate codex effort suffixes to canonical IDs if present
migrateCodexEffortSuffixes(settingsPath, provider, settings);
// User override found - merge with global env // User override found - merge with global env
envVars = { ...globalEnv, ...settings.env }; envVars = { ...globalEnv, ...settings.env };
// Ensure required vars are present (fall back to defaults if missing) // Ensure required vars are present (fall back to defaults if missing)
@@ -403,6 +466,7 @@ export function getRemoteEnvVars(
const settings: ProviderSettings = JSON.parse(content); const settings: ProviderSettings = JSON.parse(content);
if (settings.env && typeof settings.env === 'object') { if (settings.env && typeof settings.env === 'object') {
migrateDeprecatedModelNames(expandedPath, settings); migrateDeprecatedModelNames(expandedPath, settings);
migrateCodexEffortSuffixes(expandedPath, provider, settings);
userEnvVars = settings.env as Record<string, string>; userEnvVars = settings.env as Record<string, string>;
} }
} catch { } catch {
@@ -421,6 +485,7 @@ export function getRemoteEnvVars(
const settings: ProviderSettings = JSON.parse(content); const settings: ProviderSettings = JSON.parse(content);
if (settings.env && typeof settings.env === 'object') { if (settings.env && typeof settings.env === 'object') {
migrateDeprecatedModelNames(settingsPath, settings); migrateDeprecatedModelNames(settingsPath, settings);
migrateCodexEffortSuffixes(settingsPath, provider, settings);
userEnvVars = settings.env as Record<string, string>; userEnvVars = settings.env as Record<string, string>;
} }
} catch { } catch {
+81 -10
View File
@@ -22,6 +22,14 @@ import { deduplicateCcsHooks } from '../../utils/websearch/hook-utils';
import type { Settings } from '../../types/config'; import type { Settings } from '../../types/config';
const router = Router(); const router = Router();
const CODEX_EFFORT_SUFFIX_REGEX = /-(xhigh|high|medium)$/i;
const MODEL_ENV_KEYS = [
'ANTHROPIC_MODEL',
'ANTHROPIC_DEFAULT_OPUS_MODEL',
'ANTHROPIC_DEFAULT_SONNET_MODEL',
'ANTHROPIC_DEFAULT_HAIKU_MODEL',
] as const;
const PRESET_MODEL_KEYS = ['default', 'opus', 'sonnet', 'haiku'] as const;
/** /**
* Helper: Resolve settings path for profile or variant * Helper: Resolve settings path for profile or variant
@@ -42,6 +50,59 @@ function resolveSettingsPath(profileOrVariant: string): string {
return path.join(ccsDir, `${profileOrVariant}.settings.json`); return path.join(ccsDir, `${profileOrVariant}.settings.json`);
} }
function isCodexProfile(profileOrVariant: string): boolean {
if (profileOrVariant.toLowerCase() === 'codex') return true;
const variants = listVariants();
return variants[profileOrVariant]?.provider === 'codex';
}
function stripCodexEffortSuffix(modelId: string): string {
return modelId.replace(CODEX_EFFORT_SUFFIX_REGEX, '');
}
function canonicalizeCodexSettings(profileOrVariant: string, settings: Settings): Settings {
if (!isCodexProfile(profileOrVariant)) return settings;
let changed = false;
const next: Settings = { ...settings };
if (settings.env && typeof settings.env === 'object') {
const env = { ...settings.env };
for (const key of MODEL_ENV_KEYS) {
const value = env[key];
if (typeof value !== 'string') continue;
const canonical = stripCodexEffortSuffix(value);
if (canonical !== value) {
env[key] = canonical;
changed = true;
}
}
next.env = env;
}
if (Array.isArray(settings.presets)) {
const normalizedPresets = settings.presets.map((preset) => {
const normalizedPreset = { ...preset };
let presetChanged = false;
for (const key of PRESET_MODEL_KEYS) {
const value = normalizedPreset[key];
const canonical = stripCodexEffortSuffix(value);
if (canonical !== value) {
normalizedPreset[key] = canonical;
presetChanged = true;
}
}
if (presetChanged) changed = true;
return normalizedPreset;
});
next.presets = normalizedPresets;
}
return changed ? next : settings;
}
/** /**
* Helper: Mask API keys in settings * Helper: Mask API keys in settings
*/ */
@@ -73,7 +134,7 @@ router.get('/:profile', (req: Request, res: Response): void => {
} }
const stat = fs.statSync(settingsPath); const stat = fs.statSync(settingsPath);
const settings = loadSettings(settingsPath); const settings = canonicalizeCodexSettings(profile, loadSettings(settingsPath));
const masked = maskApiKeys(settings); const masked = maskApiKeys(settings);
res.json({ res.json({
@@ -101,7 +162,7 @@ router.get('/:profile/raw', (req: Request, res: Response): void => {
} }
const stat = fs.statSync(settingsPath); const stat = fs.statSync(settingsPath);
const settings = loadSettings(settingsPath); const settings = canonicalizeCodexSettings(profile, loadSettings(settingsPath));
res.json({ res.json({
profile, profile,
@@ -137,14 +198,16 @@ router.put('/:profile', (req: Request, res: Response): void => {
return; return;
} }
const normalizedSettings = canonicalizeCodexSettings(profile, settings as Settings);
// Deduplicate CCS hooks to prevent accumulation (fixes #450) // Deduplicate CCS hooks to prevent accumulation (fixes #450)
// This handles cases where duplicate hooks were added by previous versions // This handles cases where duplicate hooks were added by previous versions
deduplicateCcsHooks(settings as Record<string, unknown>); deduplicateCcsHooks(normalizedSettings as Record<string, unknown>);
const ccsDir = getCcsDir(); const ccsDir = getCcsDir();
// Check for missing required fields (warning, not blocking - runtime fills defaults) // Check for missing required fields (warning, not blocking - runtime fills defaults)
const missingFields = checkRequiredEnvVars(settings); const missingFields = checkRequiredEnvVars(normalizedSettings);
const settingsPath = resolveSettingsPath(profile); const settingsPath = resolveSettingsPath(profile);
const fileExists = fs.existsSync(settingsPath); const fileExists = fs.existsSync(settingsPath);
@@ -163,7 +226,7 @@ router.put('/:profile', (req: Request, res: Response): void => {
// Create backup only if file exists AND content actually changed // Create backup only if file exists AND content actually changed
let backupPath: string | undefined; let backupPath: string | undefined;
const newContent = JSON.stringify(settings, null, 2) + '\n'; const newContent = JSON.stringify(normalizedSettings, null, 2) + '\n';
if (fileExists) { if (fileExists) {
const existingContent = fs.readFileSync(settingsPath, 'utf8'); const existingContent = fs.readFileSync(settingsPath, 'utf8');
// Only create backup if content differs // Only create backup if content differs
@@ -220,7 +283,7 @@ router.get('/:profile/presets', (req: Request, res: Response): void => {
return; return;
} }
const settings = loadSettings(settingsPath); const settings = canonicalizeCodexSettings(profile, loadSettings(settingsPath));
res.json({ presets: settings.presets || [] }); res.json({ presets: settings.presets || [] });
} catch (error) { } catch (error) {
res.status(500).json({ error: (error as Error).message }); res.status(500).json({ error: (error as Error).message });
@@ -257,12 +320,20 @@ router.post('/:profile/presets', (req: Request, res: Response): void => {
return; return;
} }
const normalizePresetModel = (modelId: string): string =>
isCodexProfile(profile) ? stripCodexEffortSuffix(modelId) : modelId;
const normalizedDefaultModel = normalizePresetModel(defaultModel);
const normalizedOpusModel = normalizePresetModel(opus || defaultModel);
const normalizedSonnetModel = normalizePresetModel(sonnet || defaultModel);
const normalizedHaikuModel = normalizePresetModel(haiku || defaultModel);
const preset = { const preset = {
name, name,
default: defaultModel, default: normalizedDefaultModel,
opus: opus || defaultModel, opus: normalizedOpusModel,
sonnet: sonnet || defaultModel, sonnet: normalizedSonnetModel,
haiku: haiku || defaultModel, haiku: normalizedHaikuModel,
}; };
settings.presets.push(preset); settings.presets.push(preset);
@@ -13,8 +13,12 @@ interface EnvSettings {
ANTHROPIC_DEFAULT_HAIKU_MODEL: string; ANTHROPIC_DEFAULT_HAIKU_MODEL: string;
} }
function writeSettings(settingsPath: string, env: EnvSettings): void { function writeSettings(
fs.writeFileSync(settingsPath, JSON.stringify({ env }, null, 2)); settingsPath: string,
env: EnvSettings,
extras?: Record<string, unknown>
): void {
fs.writeFileSync(settingsPath, JSON.stringify({ env, ...(extras || {}) }, null, 2));
} }
describe('getEffectiveEnvVars local provider URL normalization', () => { describe('getEffectiveEnvVars local provider URL normalization', () => {
@@ -42,6 +46,18 @@ describe('getEffectiveEnvVars local provider URL normalization', () => {
const env = getEffectiveEnvVars('codex', 8317, settingsPath); const env = getEffectiveEnvVars('codex', 8317, settingsPath);
expect(env.ANTHROPIC_BASE_URL).toBe('http://127.0.0.1:8317/api/provider/codex'); expect(env.ANTHROPIC_BASE_URL).toBe('http://127.0.0.1:8317/api/provider/codex');
expect(env.ANTHROPIC_MODEL).toBe('gpt-5.3-codex');
expect(env.ANTHROPIC_DEFAULT_OPUS_MODEL).toBe('gpt-5.3-codex');
expect(env.ANTHROPIC_DEFAULT_SONNET_MODEL).toBe('gpt-5.3-codex');
expect(env.ANTHROPIC_DEFAULT_HAIKU_MODEL).toBe('gpt-5-mini');
const persisted = JSON.parse(fs.readFileSync(settingsPath, 'utf-8')) as {
env: Record<string, string>;
};
expect(persisted.env.ANTHROPIC_MODEL).toBe('gpt-5.3-codex');
expect(persisted.env.ANTHROPIC_DEFAULT_OPUS_MODEL).toBe('gpt-5.3-codex');
expect(persisted.env.ANTHROPIC_DEFAULT_SONNET_MODEL).toBe('gpt-5.3-codex');
expect(persisted.env.ANTHROPIC_DEFAULT_HAIKU_MODEL).toBe('gpt-5-mini');
}); });
it('rewrites wrong local provider path to the requested provider', () => { it('rewrites wrong local provider path to the requested provider', () => {
@@ -88,4 +104,39 @@ describe('getEffectiveEnvVars local provider URL normalization', () => {
expect(env.ANTHROPIC_DEFAULT_SONNET_MODEL).toBe('claude-sonnet-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'); expect(env.ANTHROPIC_DEFAULT_HAIKU_MODEL).toBe('claude-haiku-4-5');
}); });
it('migrates codex preset model mappings to canonical IDs', () => {
writeSettings(
settingsPath,
{
ANTHROPIC_BASE_URL: 'http://127.0.0.1:8317/api/provider/codex',
ANTHROPIC_AUTH_TOKEN: 'ccs-internal-managed',
ANTHROPIC_MODEL: 'gpt-5.3-codex-xhigh',
ANTHROPIC_DEFAULT_OPUS_MODEL: 'gpt-5.3-codex-xhigh',
ANTHROPIC_DEFAULT_SONNET_MODEL: 'gpt-5.3-codex-high',
ANTHROPIC_DEFAULT_HAIKU_MODEL: 'gpt-5-mini-medium',
},
{
presets: [
{
name: 'legacy-codex',
default: 'gpt-5.3-codex-xhigh',
opus: 'gpt-5.3-codex-xhigh',
sonnet: 'gpt-5.3-codex-high',
haiku: 'gpt-5-mini-medium',
},
],
}
);
getEffectiveEnvVars('codex', 8317, settingsPath);
const persisted = JSON.parse(fs.readFileSync(settingsPath, 'utf-8')) as {
presets: Array<Record<string, string>>;
};
expect(persisted.presets[0]?.default).toBe('gpt-5.3-codex');
expect(persisted.presets[0]?.opus).toBe('gpt-5.3-codex');
expect(persisted.presets[0]?.sonnet).toBe('gpt-5.3-codex');
expect(persisted.presets[0]?.haiku).toBe('gpt-5-mini');
});
}); });
+14 -14
View File
@@ -140,10 +140,10 @@ export const MODEL_CATALOGS: Record<string, ProviderCatalog> = {
name: 'GPT-5.3 Codex', name: 'GPT-5.3 Codex',
description: 'Supports up to xhigh effort', description: 'Supports up to xhigh effort',
presetMapping: { presetMapping: {
default: 'gpt-5.3-codex-xhigh', default: 'gpt-5.3-codex',
opus: 'gpt-5.3-codex-xhigh', opus: 'gpt-5.3-codex',
sonnet: 'gpt-5.3-codex-high', sonnet: 'gpt-5.3-codex',
haiku: 'gpt-5-mini-medium', haiku: 'gpt-5-mini',
}, },
}, },
{ {
@@ -151,10 +151,10 @@ export const MODEL_CATALOGS: Record<string, ProviderCatalog> = {
name: 'GPT-5.2 Codex', name: 'GPT-5.2 Codex',
description: 'Previous stable Codex model', description: 'Previous stable Codex model',
presetMapping: { presetMapping: {
default: 'gpt-5.2-codex-xhigh', default: 'gpt-5.2-codex',
opus: 'gpt-5.2-codex-xhigh', opus: 'gpt-5.2-codex',
sonnet: 'gpt-5.2-codex-high', sonnet: 'gpt-5.2-codex',
haiku: 'gpt-5-mini-medium', haiku: 'gpt-5-mini',
}, },
}, },
{ {
@@ -162,10 +162,10 @@ export const MODEL_CATALOGS: Record<string, ProviderCatalog> = {
name: 'GPT-5 Mini', name: 'GPT-5 Mini',
description: 'Fast, capped at high effort (no xhigh)', description: 'Fast, capped at high effort (no xhigh)',
presetMapping: { presetMapping: {
default: 'gpt-5-mini-medium', default: 'gpt-5-mini',
opus: 'gpt-5.3-codex-xhigh', opus: 'gpt-5.3-codex',
sonnet: 'gpt-5-mini-high', sonnet: 'gpt-5-mini',
haiku: 'gpt-5-mini-medium', haiku: 'gpt-5-mini',
}, },
}, },
{ {
@@ -174,9 +174,9 @@ export const MODEL_CATALOGS: Record<string, ProviderCatalog> = {
description: 'Legacy most capable Codex model', description: 'Legacy most capable Codex model',
presetMapping: { presetMapping: {
default: 'gpt-5.1-codex-max', default: 'gpt-5.1-codex-max',
opus: 'gpt-5.1-codex-max-high', opus: 'gpt-5.1-codex-max',
sonnet: 'gpt-5.1-codex-max', sonnet: 'gpt-5.1-codex-max',
haiku: 'gpt-5.1-codex-mini-high', haiku: 'gpt-5.1-codex-mini',
}, },
}, },
{ {