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 */
interface ProviderSettings {
env: NodeJS.ProcessEnv;
presets?: Array<Record<string, unknown>>;
}
/** Model name prefix that was deprecated in CLIProxyAPI registry */
const DEPRECATED_MODEL_PREFIX = 'gemini-claude-';
/** Replacement prefix matching actual upstream model names */
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.
@@ -68,6 +75,58 @@ function migrateDeprecatedModelNames(settingsPath: string, settings: ProviderSet
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 */
export interface RemoteProxyRewriteConfig {
host: string;
@@ -285,6 +344,8 @@ export function getEffectiveEnvVars(
if (settings.env && typeof settings.env === 'object') {
// Migrate deprecated gemini-claude-* model names if present
migrateDeprecatedModelNames(expandedPath, settings);
// Migrate codex effort suffixes to canonical IDs if present
migrateCodexEffortSuffixes(expandedPath, provider, settings);
// Custom variant settings found - merge with global env
envVars = { ...globalEnv, ...settings.env };
// 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') {
// Migrate deprecated gemini-claude-* model names if present
migrateDeprecatedModelNames(settingsPath, settings);
// Migrate codex effort suffixes to canonical IDs if present
migrateCodexEffortSuffixes(settingsPath, provider, settings);
// User override found - merge with global env
envVars = { ...globalEnv, ...settings.env };
// Ensure required vars are present (fall back to defaults if missing)
@@ -403,6 +466,7 @@ export function getRemoteEnvVars(
const settings: ProviderSettings = JSON.parse(content);
if (settings.env && typeof settings.env === 'object') {
migrateDeprecatedModelNames(expandedPath, settings);
migrateCodexEffortSuffixes(expandedPath, provider, settings);
userEnvVars = settings.env as Record<string, string>;
}
} catch {
@@ -421,6 +485,7 @@ export function getRemoteEnvVars(
const settings: ProviderSettings = JSON.parse(content);
if (settings.env && typeof settings.env === 'object') {
migrateDeprecatedModelNames(settingsPath, settings);
migrateCodexEffortSuffixes(settingsPath, provider, settings);
userEnvVars = settings.env as Record<string, string>;
}
} catch {
+81 -10
View File
@@ -22,6 +22,14 @@ import { deduplicateCcsHooks } from '../../utils/websearch/hook-utils';
import type { Settings } from '../../types/config';
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
@@ -42,6 +50,59 @@ function resolveSettingsPath(profileOrVariant: string): string {
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
*/
@@ -73,7 +134,7 @@ router.get('/:profile', (req: Request, res: Response): void => {
}
const stat = fs.statSync(settingsPath);
const settings = loadSettings(settingsPath);
const settings = canonicalizeCodexSettings(profile, loadSettings(settingsPath));
const masked = maskApiKeys(settings);
res.json({
@@ -101,7 +162,7 @@ router.get('/:profile/raw', (req: Request, res: Response): void => {
}
const stat = fs.statSync(settingsPath);
const settings = loadSettings(settingsPath);
const settings = canonicalizeCodexSettings(profile, loadSettings(settingsPath));
res.json({
profile,
@@ -137,14 +198,16 @@ router.put('/:profile', (req: Request, res: Response): void => {
return;
}
const normalizedSettings = canonicalizeCodexSettings(profile, settings as Settings);
// Deduplicate CCS hooks to prevent accumulation (fixes #450)
// 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();
// Check for missing required fields (warning, not blocking - runtime fills defaults)
const missingFields = checkRequiredEnvVars(settings);
const missingFields = checkRequiredEnvVars(normalizedSettings);
const settingsPath = resolveSettingsPath(profile);
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
let backupPath: string | undefined;
const newContent = JSON.stringify(settings, null, 2) + '\n';
const newContent = JSON.stringify(normalizedSettings, null, 2) + '\n';
if (fileExists) {
const existingContent = fs.readFileSync(settingsPath, 'utf8');
// Only create backup if content differs
@@ -220,7 +283,7 @@ router.get('/:profile/presets', (req: Request, res: Response): void => {
return;
}
const settings = loadSettings(settingsPath);
const settings = canonicalizeCodexSettings(profile, loadSettings(settingsPath));
res.json({ presets: settings.presets || [] });
} catch (error) {
res.status(500).json({ error: (error as Error).message });
@@ -257,12 +320,20 @@ router.post('/:profile/presets', (req: Request, res: Response): void => {
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 = {
name,
default: defaultModel,
opus: opus || defaultModel,
sonnet: sonnet || defaultModel,
haiku: haiku || defaultModel,
default: normalizedDefaultModel,
opus: normalizedOpusModel,
sonnet: normalizedSonnetModel,
haiku: normalizedHaikuModel,
};
settings.presets.push(preset);
@@ -13,8 +13,12 @@ interface EnvSettings {
ANTHROPIC_DEFAULT_HAIKU_MODEL: string;
}
function writeSettings(settingsPath: string, env: EnvSettings): void {
fs.writeFileSync(settingsPath, JSON.stringify({ env }, null, 2));
function writeSettings(
settingsPath: string,
env: EnvSettings,
extras?: Record<string, unknown>
): void {
fs.writeFileSync(settingsPath, JSON.stringify({ env, ...(extras || {}) }, null, 2));
}
describe('getEffectiveEnvVars local provider URL normalization', () => {
@@ -42,6 +46,18 @@ describe('getEffectiveEnvVars local provider URL normalization', () => {
const env = getEffectiveEnvVars('codex', 8317, settingsPath);
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', () => {
@@ -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_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',
description: 'Supports up to xhigh effort',
presetMapping: {
default: 'gpt-5.3-codex-xhigh',
opus: 'gpt-5.3-codex-xhigh',
sonnet: 'gpt-5.3-codex-high',
haiku: 'gpt-5-mini-medium',
default: 'gpt-5.3-codex',
opus: 'gpt-5.3-codex',
sonnet: 'gpt-5.3-codex',
haiku: 'gpt-5-mini',
},
},
{
@@ -151,10 +151,10 @@ export const MODEL_CATALOGS: Record<string, ProviderCatalog> = {
name: 'GPT-5.2 Codex',
description: 'Previous stable Codex model',
presetMapping: {
default: 'gpt-5.2-codex-xhigh',
opus: 'gpt-5.2-codex-xhigh',
sonnet: 'gpt-5.2-codex-high',
haiku: 'gpt-5-mini-medium',
default: 'gpt-5.2-codex',
opus: 'gpt-5.2-codex',
sonnet: 'gpt-5.2-codex',
haiku: 'gpt-5-mini',
},
},
{
@@ -162,10 +162,10 @@ export const MODEL_CATALOGS: Record<string, ProviderCatalog> = {
name: 'GPT-5 Mini',
description: 'Fast, capped at high effort (no xhigh)',
presetMapping: {
default: 'gpt-5-mini-medium',
opus: 'gpt-5.3-codex-xhigh',
sonnet: 'gpt-5-mini-high',
haiku: 'gpt-5-mini-medium',
default: 'gpt-5-mini',
opus: 'gpt-5.3-codex',
sonnet: 'gpt-5-mini',
haiku: 'gpt-5-mini',
},
},
{
@@ -174,9 +174,9 @@ export const MODEL_CATALOGS: Record<string, ProviderCatalog> = {
description: 'Legacy most capable Codex model',
presetMapping: {
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',
haiku: 'gpt-5.1-codex-mini-high',
haiku: 'gpt-5.1-codex-mini',
},
},
{