mirror of
https://github.com/tiennm99/ccs.git
synced 2026-07-16 12:15:57 +00:00
fix(codex): canonicalize dashboard presets and migrate suffixed settings
This commit is contained in:
@@ -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 {
|
||||
|
||||
@@ -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');
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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',
|
||||
},
|
||||
},
|
||||
{
|
||||
|
||||
Reference in New Issue
Block a user