From 551591ef18eda0a3cbd5a9ed99c5865d2bb20dfb Mon Sep 17 00:00:00 2001 From: Tam Nhu Tran Date: Mon, 16 Mar 2026 13:43:23 -0400 Subject: [PATCH 1/3] fix(cliproxy): make codex defaults free-plan safe --- config/base-codex.settings.json | 8 +- docs/codebase-summary.md | 3 +- docs/project-roadmap.md | 6 +- src/cliproxy/codex-plan-compatibility.ts | 89 ++++++++++++++ src/cliproxy/executor/index.ts | 9 ++ src/cliproxy/index.ts | 5 + src/cliproxy/model-catalog.ts | 93 ++++++++++++-- src/cliproxy/model-config.ts | 6 +- .../cliproxy/codex-plan-compatibility.test.ts | 114 ++++++++++++++++++ .../cliproxy/variant-update-service.test.ts | 6 +- 10 files changed, 314 insertions(+), 25 deletions(-) create mode 100644 src/cliproxy/codex-plan-compatibility.ts create mode 100644 tests/unit/cliproxy/codex-plan-compatibility.test.ts diff --git a/config/base-codex.settings.json b/config/base-codex.settings.json index 390d568d..5dae2d86 100644 --- a/config/base-codex.settings.json +++ b/config/base-codex.settings.json @@ -2,9 +2,9 @@ "env": { "ANTHROPIC_BASE_URL": "http://127.0.0.1:8317/api/provider/codex", "ANTHROPIC_AUTH_TOKEN": "ccs-internal-managed", - "ANTHROPIC_MODEL": "gpt-5.3-codex", - "ANTHROPIC_DEFAULT_OPUS_MODEL": "gpt-5.3-codex", - "ANTHROPIC_DEFAULT_SONNET_MODEL": "gpt-5.3-codex", - "ANTHROPIC_DEFAULT_HAIKU_MODEL": "gpt-5.1-codex-mini" + "ANTHROPIC_MODEL": "gpt-5-codex", + "ANTHROPIC_DEFAULT_OPUS_MODEL": "gpt-5-codex", + "ANTHROPIC_DEFAULT_SONNET_MODEL": "gpt-5-codex", + "ANTHROPIC_DEFAULT_HAIKU_MODEL": "gpt-5-codex-mini" } } diff --git a/docs/codebase-summary.md b/docs/codebase-summary.md index ae78f67a..507a1901 100644 --- a/docs/codebase-summary.md +++ b/docs/codebase-summary.md @@ -1,6 +1,6 @@ # CCS Codebase Summary -Last Updated: 2026-02-24 +Last Updated: 2026-03-16 Comprehensive overview of the modularized CCS codebase structure following the Phase 9 modularization effort (Settings, Analytics, Auth Monitor splits + Test Infrastructure), v7.1 Remote CLIProxy feature, v7.2 Kiro + GitHub Copilot (ghcp) OAuth providers, v7.14 Hybrid Quota Management, v7.34 Image Analysis Hook, and account-context validation hardening. @@ -96,6 +96,7 @@ src/ │ ├── auth-handler.ts # Authentication handling │ ├── model-catalog.ts # Provider model definitions │ ├── model-config.ts # Model configuration +│ ├── codex-plan-compatibility.ts # Codex free/paid model fallback guardrails │ ├── service-manager.ts # Background service │ ├── proxy-detector.ts # Running proxy detection │ ├── startup-lock.ts # Race condition prevention diff --git a/docs/project-roadmap.md b/docs/project-roadmap.md index 8a721c93..6da3c1b8 100644 --- a/docs/project-roadmap.md +++ b/docs/project-roadmap.md @@ -1,6 +1,6 @@ # CCS Project Roadmap -Last Updated: 2026-02-12 +Last Updated: 2026-03-16 Forward-looking roadmap documenting current priorities, GitHub issues, and future feature plans. @@ -39,6 +39,10 @@ All major modularization work is complete. The codebase evolved from monolithic ## Current Status +### Recent Fixes + +- **#724**: Codex startup is now free-plan safe. CCS defaults new Codex sessions to a cross-plan model and auto-repairs stale paid-only Codex defaults when the active account is on the free plan. + ### Maintainability Hardening Kickoff - Issue owner: Stream D for **#542** diff --git a/src/cliproxy/codex-plan-compatibility.ts b/src/cliproxy/codex-plan-compatibility.ts new file mode 100644 index 00000000..91a30380 --- /dev/null +++ b/src/cliproxy/codex-plan-compatibility.ts @@ -0,0 +1,89 @@ +import { getDefaultAccount } from './account-manager'; +import { fetchCodexQuota } from './quota-fetcher-codex'; +import { getCachedQuota, setCachedQuota } from './quota-response-cache'; +import type { CodexQuotaResult } from './quota-types'; +import { updateSettingsModel } from './services/variant-settings'; +import { info, warn } from '../utils/ui'; + +export type CodexPlanType = CodexQuotaResult['planType']; + +const FREE_SAFE_DEFAULT_MODEL = 'gpt-5-codex'; +const FREE_SAFE_FAST_MODEL = 'gpt-5-codex-mini'; +const CODEX_EFFORT_SUFFIX_REGEX = /-(xhigh|high|medium)$/i; +const CODEX_PAREN_SUFFIX_REGEX = /\((xhigh|high|medium)\)$/i; +const EXTENDED_CONTEXT_SUFFIX_REGEX = /\[1m\]$/i; + +const FREE_PLAN_FALLBACKS = new Map([ + ['gpt-5.3-codex', FREE_SAFE_DEFAULT_MODEL], + ['gpt-5.3-codex-spark', FREE_SAFE_FAST_MODEL], + ['gpt-5.4', FREE_SAFE_DEFAULT_MODEL], +]); + +function normalizeCodexModelId(model: string): string { + return model + .trim() + .replace(EXTENDED_CONTEXT_SUFFIX_REGEX, '') + .replace(CODEX_PAREN_SUFFIX_REGEX, '') + .replace(CODEX_EFFORT_SUFFIX_REGEX, '') + .trim() + .toLowerCase(); +} + +export function getDefaultCodexModel(): string { + return FREE_SAFE_DEFAULT_MODEL; +} + +export function getFreePlanFallbackCodexModel(model: string): string | null { + return FREE_PLAN_FALLBACKS.get(normalizeCodexModelId(model)) ?? null; +} + +export async function reconcileCodexModelForActivePlan(options: { + settingsPath: string; + currentModel: string | undefined; + verbose: boolean; +}): Promise { + const { settingsPath, currentModel, verbose } = options; + if (!currentModel) return; + + const fallbackModel = getFreePlanFallbackCodexModel(currentModel); + if (!fallbackModel) return; + + const defaultAccount = getDefaultAccount('codex'); + if (!defaultAccount) { + console.error( + warn( + `Configured Codex model "${normalizeCodexModelId(currentModel)}" may require a paid Codex plan. ` + + `If startup fails, switch to "${fallbackModel}" with "ccs codex --config".` + ) + ); + return; + } + + const cachedQuota = getCachedQuota('codex', defaultAccount.id); + const quota = cachedQuota ?? (await fetchCodexQuota(defaultAccount.id, verbose)); + if (!cachedQuota) { + setCachedQuota('codex', defaultAccount.id, quota); + } + + if (quota.planType === 'free') { + updateSettingsModel(settingsPath, fallbackModel, 'codex'); + console.error( + info( + `Codex free plan detected. Switched unsupported model "${normalizeCodexModelId(currentModel)}" ` + + `to "${fallbackModel}".` + ) + ); + return; + } + + if (quota.planType) { + return; + } + + console.error( + warn( + `Could not verify Codex plan for model "${normalizeCodexModelId(currentModel)}". ` + + `If startup fails with model_not_supported, switch to "${fallbackModel}" via "ccs codex --config".` + ) + ); +} diff --git a/src/cliproxy/executor/index.ts b/src/cliproxy/executor/index.ts index 0cb45f6d..9945ef4d 100644 --- a/src/cliproxy/executor/index.ts +++ b/src/cliproxy/executor/index.ts @@ -32,6 +32,7 @@ import { isAuthenticated } from '../auth-handler'; import { CLIProxyProvider, CLIProxyBackend, PLUS_ONLY_PROVIDERS, ExecutorConfig } from '../types'; import { DEFAULT_BACKEND } from '../platform-detector'; import { configureProviderModel, getCurrentModel } from '../model-config'; +import { reconcileCodexModelForActivePlan } from '../codex-plan-compatibility'; import { resolveProxyConfig, PROXY_CLI_FLAGS } from '../proxy-config-resolver'; import { supportsModelConfig, isModelBroken, getModelIssueUrl, findModel } from '../model-catalog'; import { CodexReasoningProxy } from '../codex-reasoning-proxy'; @@ -731,6 +732,14 @@ export async function execClaudeWithCLIProxy( // 6. Ensure user settings file exists ensureProviderSettings(provider); + if (provider === 'codex' && !cfg.isComposite && !skipLocalAuth) { + await reconcileCodexModelForActivePlan({ + settingsPath: cfg.customSettingsPath || getProviderSettingsPath(provider), + currentModel: getCurrentModel(provider, cfg.customSettingsPath), + verbose, + }); + } + // Local proxy mode: generate config, spawn/join proxy, track session let proxy: ChildProcess | null = null; let configPath: string | undefined; diff --git a/src/cliproxy/index.ts b/src/cliproxy/index.ts index a250bb2d..375a2b34 100644 --- a/src/cliproxy/index.ts +++ b/src/cliproxy/index.ts @@ -100,6 +100,11 @@ export { configureProviderModel, showCurrentConfig, } from './model-config'; +export { + getDefaultCodexModel, + getFreePlanFallbackCodexModel, + reconcileCodexModelForActivePlan, +} from './codex-plan-compatibility'; // Executor export { execClaudeWithCLIProxy, isPortAvailable, findAvailablePort } from './cliproxy-executor'; diff --git a/src/cliproxy/model-catalog.ts b/src/cliproxy/model-catalog.ts index 8194e640..d9644ed5 100644 --- a/src/cliproxy/model-catalog.ts +++ b/src/cliproxy/model-catalog.ts @@ -147,15 +147,59 @@ export const MODEL_CATALOG: Partial> = codex: { provider: 'codex', displayName: 'Copilot Codex', - defaultModel: 'gpt-5.3-codex', + defaultModel: 'gpt-5-codex', models: [ { - id: 'gpt-5.3-codex', - name: 'GPT-5.3 Codex', - description: 'Supports up to xhigh effort', + id: 'gpt-5-codex', + name: 'GPT-5 Codex', + description: 'Cross-plan safe Codex default', thinking: { type: 'levels', - levels: ['medium', 'high', 'xhigh'], + levels: ['low', 'medium', 'high'], + maxLevel: 'high', + dynamicAllowed: false, + }, + }, + { + id: 'gpt-5-codex-mini', + name: 'GPT-5 Codex Mini', + description: 'Faster and cheaper Codex option', + thinking: { + type: 'levels', + levels: ['low', 'medium', 'high'], + maxLevel: 'high', + dynamicAllowed: false, + }, + }, + { + id: 'gpt-5-mini', + name: 'GPT-5 Mini', + description: 'Legacy mini model ID kept for backwards compatibility', + thinking: { + type: 'levels', + levels: ['low', 'medium', 'high'], + maxLevel: 'high', + dynamicAllowed: false, + }, + }, + { + id: 'gpt-5.1-codex-mini', + name: 'GPT-5.1 Codex Mini', + description: 'Legacy fast Codex mini model', + thinking: { + type: 'levels', + levels: ['low', 'medium', 'high'], + maxLevel: 'high', + dynamicAllowed: false, + }, + }, + { + id: 'gpt-5.1-codex-max', + name: 'GPT-5.1 Codex Max', + description: 'Higher-effort Codex model with xhigh support', + thinking: { + type: 'levels', + levels: ['low', 'medium', 'high', 'xhigh'], maxLevel: 'xhigh', dynamicAllowed: false, }, @@ -163,22 +207,47 @@ export const MODEL_CATALOG: Partial> = { id: 'gpt-5.2-codex', name: 'GPT-5.2 Codex', - description: 'Previous stable Codex model', + description: 'Cross-plan Codex model with xhigh support', thinking: { type: 'levels', - levels: ['medium', 'high', 'xhigh'], + levels: ['low', 'medium', 'high', 'xhigh'], maxLevel: 'xhigh', dynamicAllowed: false, }, }, { - id: 'gpt-5-mini', - name: 'GPT-5 Mini', - description: 'Capped at high effort (no xhigh)', + id: 'gpt-5.3-codex', + name: 'GPT-5.3 Codex', + tier: 'pro', + description: 'Paid Codex plans only', thinking: { type: 'levels', - levels: ['medium', 'high'], - maxLevel: 'high', + levels: ['low', 'medium', 'high', 'xhigh'], + maxLevel: 'xhigh', + dynamicAllowed: false, + }, + }, + { + id: 'gpt-5.3-codex-spark', + name: 'GPT-5.3 Codex Spark', + tier: 'pro', + description: 'Paid Codex plans only, ultra-fast coding model', + thinking: { + type: 'levels', + levels: ['low', 'medium', 'high', 'xhigh'], + maxLevel: 'xhigh', + dynamicAllowed: false, + }, + }, + { + id: 'gpt-5.4', + name: 'GPT-5.4', + tier: 'pro', + description: 'Paid Codex plans only, latest GPT-5 family model', + thinking: { + type: 'levels', + levels: ['low', 'medium', 'high', 'xhigh'], + maxLevel: 'xhigh', dynamicAllowed: false, }, }, diff --git a/src/cliproxy/model-config.ts b/src/cliproxy/model-config.ts index 0400a54a..21c02ef6 100644 --- a/src/cliproxy/model-config.ts +++ b/src/cliproxy/model-config.ts @@ -146,9 +146,7 @@ export async function configureProviderModel( console.error(header(`Configure ${catalog.displayName} Model`)); console.error(''); console.error(dim(' Select which model to use for this provider.')); - console.error( - dim(' Models marked [Paid Tier] require a paid Google account (not free tier).') - ); + console.error(dim(' Models marked [Pro]/[Ultra] require a paid provider plan.')); console.error(dim(' Models marked [DEPRECATED] are not recommended for use.')); console.error(''); @@ -274,7 +272,7 @@ export async function showCurrentConfig(provider: CLIProxyProvider): Promise { diff --git a/tests/unit/cliproxy/codex-plan-compatibility.test.ts b/tests/unit/cliproxy/codex-plan-compatibility.test.ts new file mode 100644 index 00000000..b3ce52ea --- /dev/null +++ b/tests/unit/cliproxy/codex-plan-compatibility.test.ts @@ -0,0 +1,114 @@ +import * as fs from 'fs'; +import * as os from 'os'; +import * as path from 'path'; +import { afterEach, describe, expect, it, mock, spyOn } from 'bun:test'; +import { getProviderCatalog, getModelMaxLevel } from '../../../src/cliproxy/model-catalog'; +import { + getDefaultCodexModel, + getFreePlanFallbackCodexModel, +} from '../../../src/cliproxy/codex-plan-compatibility'; + +afterEach(() => { + mock.restore(); +}); + +describe('codex plan compatibility', () => { + it('uses a cross-plan safe Codex default', () => { + expect(getDefaultCodexModel()).toBe('gpt-5-codex'); + expect(getProviderCatalog('codex')?.defaultModel).toBe('gpt-5-codex'); + }); + + it('maps paid-only free-plan models to safe fallbacks', () => { + expect(getFreePlanFallbackCodexModel('gpt-5.3-codex')).toBe('gpt-5-codex'); + expect(getFreePlanFallbackCodexModel('gpt-5.3-codex-xhigh')).toBe('gpt-5-codex'); + expect(getFreePlanFallbackCodexModel('gpt-5.3-codex(high)')).toBe('gpt-5-codex'); + expect(getFreePlanFallbackCodexModel('gpt-5.4')).toBe('gpt-5-codex'); + expect(getFreePlanFallbackCodexModel('gpt-5.3-codex-spark')).toBe('gpt-5-codex-mini'); + }); + + it('does not rewrite cross-plan or already-safe Codex models', () => { + expect(getFreePlanFallbackCodexModel('gpt-5-codex')).toBeNull(); + expect(getFreePlanFallbackCodexModel('gpt-5.2-codex')).toBeNull(); + expect(getFreePlanFallbackCodexModel('gpt-5.1-codex-mini')).toBeNull(); + }); + + it('tracks Codex thinking caps for current safe defaults and paid models', () => { + expect(getModelMaxLevel('codex', 'gpt-5-codex')).toBe('high'); + expect(getModelMaxLevel('codex', 'gpt-5-codex-mini')).toBe('high'); + expect(getModelMaxLevel('codex', 'gpt-5.2-codex')).toBe('xhigh'); + expect(getModelMaxLevel('codex', 'gpt-5.3-codex')).toBe('xhigh'); + }); + + it('repairs stale paid-only Codex settings for free-plan accounts before launch', async () => { + const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'ccs-codex-plan-compat-')); + const settingsPath = path.join(tmpDir, 'codex.settings.json'); + + fs.writeFileSync( + settingsPath, + JSON.stringify( + { + env: { + ANTHROPIC_BASE_URL: 'http://127.0.0.1:8317/api/provider/codex', + ANTHROPIC_AUTH_TOKEN: 'ccs-internal-managed', + ANTHROPIC_MODEL: 'gpt-5.3-codex', + ANTHROPIC_DEFAULT_OPUS_MODEL: 'gpt-5.3-codex', + ANTHROPIC_DEFAULT_SONNET_MODEL: 'gpt-5.3-codex', + ANTHROPIC_DEFAULT_HAIKU_MODEL: 'gpt-5-codex-mini', + }, + }, + null, + 2 + ), + 'utf-8' + ); + + mock.module('../../../src/cliproxy/account-manager', () => ({ + getDefaultAccount: () => ({ id: 'free@example.com' }), + })); + mock.module('../../../src/cliproxy/quota-fetcher-codex', () => ({ + fetchCodexQuota: async () => ({ + success: true, + windows: [], + coreUsage: { fiveHour: null, weekly: null }, + planType: 'free', + lastUpdated: Date.now(), + accountId: 'free@example.com', + }), + })); + mock.module('../../../src/cliproxy/quota-response-cache', () => ({ + getCachedQuota: () => null, + setCachedQuota: () => {}, + })); + mock.module('../../../src/utils/ui', () => ({ + info: (message: string) => message, + warn: (message: string) => message, + })); + + const errorSpy = spyOn(console, 'error').mockImplementation(() => {}); + + try { + const { reconcileCodexModelForActivePlan } = await import( + `../../../src/cliproxy/codex-plan-compatibility?free-plan=${Date.now()}` + ); + + await reconcileCodexModelForActivePlan({ + settingsPath, + currentModel: 'gpt-5.3-codex', + verbose: false, + }); + + const repaired = JSON.parse(fs.readFileSync(settingsPath, 'utf-8')) as { + env: Record; + }; + expect(repaired.env.ANTHROPIC_MODEL).toBe('gpt-5-codex'); + expect(repaired.env.ANTHROPIC_DEFAULT_OPUS_MODEL).toBe('gpt-5-codex'); + expect(repaired.env.ANTHROPIC_DEFAULT_SONNET_MODEL).toBe('gpt-5-codex'); + expect(repaired.env.ANTHROPIC_DEFAULT_HAIKU_MODEL).toBe('gpt-5-codex-mini'); + expect(errorSpy).toHaveBeenCalledWith( + 'Codex free plan detected. Switched unsupported model "gpt-5.3-codex" to "gpt-5-codex".' + ); + } finally { + fs.rmSync(tmpDir, { recursive: true, force: true }); + } + }); +}); diff --git a/tests/unit/cliproxy/variant-update-service.test.ts b/tests/unit/cliproxy/variant-update-service.test.ts index 28467094..fe63fb7d 100644 --- a/tests/unit/cliproxy/variant-update-service.test.ts +++ b/tests/unit/cliproxy/variant-update-service.test.ts @@ -112,7 +112,7 @@ cliproxy: expect(settings.env.ANTHROPIC_MODEL).toBe('gpt-5.1-codex-mini'); expect(settings.env.ANTHROPIC_DEFAULT_OPUS_MODEL).toBe('gpt-5.1-codex-mini'); expect(settings.env.ANTHROPIC_DEFAULT_SONNET_MODEL).toBe('gpt-5.1-codex-mini'); - expect(settings.env.ANTHROPIC_DEFAULT_HAIKU_MODEL).toBe('gpt-5.1-codex-mini'); + expect(settings.env.ANTHROPIC_DEFAULT_HAIKU_MODEL).toBe('gpt-5-codex-mini'); expect(settings.env.CUSTOM_FLAG).toBe('keep-me'); expect(settings.hooks.PreToolUse.length).toBe(1); @@ -134,7 +134,7 @@ cliproxy: expect(settings.env.ANTHROPIC_MODEL).toBe('gpt-5.3-codex'); expect(settings.env.ANTHROPIC_DEFAULT_OPUS_MODEL).toBe('gpt-5.3-codex'); expect(settings.env.ANTHROPIC_DEFAULT_SONNET_MODEL).toBe('gpt-5.3-codex'); - expect(settings.env.ANTHROPIC_DEFAULT_HAIKU_MODEL).toBe('gpt-5.1-codex-mini'); + expect(settings.env.ANTHROPIC_DEFAULT_HAIKU_MODEL).toBe('gpt-5-codex-mini'); const modelOnly = updateVariant('demo', { model: 'gpt-5.3-codex' }); expect(modelOnly.success).toBe(true); @@ -145,6 +145,6 @@ cliproxy: expect(settings.env.ANTHROPIC_MODEL).toBe('gpt-5.3-codex'); expect(settings.env.ANTHROPIC_DEFAULT_OPUS_MODEL).toBe('gpt-5.3-codex'); expect(settings.env.ANTHROPIC_DEFAULT_SONNET_MODEL).toBe('gpt-5.3-codex'); - expect(settings.env.ANTHROPIC_DEFAULT_HAIKU_MODEL).toBe('gpt-5.1-codex-mini'); + expect(settings.env.ANTHROPIC_DEFAULT_HAIKU_MODEL).toBe('gpt-5-codex-mini'); }); }); From ce600a7f229ee06418e1cff37648e74ed74f4a8f Mon Sep 17 00:00:00 2001 From: Tam Nhu Tran Date: Mon, 16 Mar 2026 14:00:38 -0400 Subject: [PATCH 2/3] test(cliproxy): cover codex plan fallback edge cases --- ...codex-plan-compatibility-reconcile.test.ts | 233 ++++++++++++++++++ .../cliproxy/codex-plan-compatibility.test.ts | 82 +----- 2 files changed, 234 insertions(+), 81 deletions(-) create mode 100644 tests/unit/cliproxy/codex-plan-compatibility-reconcile.test.ts diff --git a/tests/unit/cliproxy/codex-plan-compatibility-reconcile.test.ts b/tests/unit/cliproxy/codex-plan-compatibility-reconcile.test.ts new file mode 100644 index 00000000..d11d6e6e --- /dev/null +++ b/tests/unit/cliproxy/codex-plan-compatibility-reconcile.test.ts @@ -0,0 +1,233 @@ +import * as fs from 'fs'; +import * as os from 'os'; +import * as path from 'path'; +import { afterEach, describe, expect, it, mock, spyOn } from 'bun:test'; + +afterEach(() => { + mock.restore(); +}); + +function createCodexSettingsFixture(): { tmpDir: string; settingsPath: string } { + const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'ccs-codex-plan-compat-')); + const settingsPath = path.join(tmpDir, 'codex.settings.json'); + + fs.writeFileSync( + settingsPath, + JSON.stringify( + { + env: { + ANTHROPIC_BASE_URL: 'http://127.0.0.1:8317/api/provider/codex', + ANTHROPIC_AUTH_TOKEN: 'ccs-internal-managed', + ANTHROPIC_MODEL: 'gpt-5.3-codex', + ANTHROPIC_DEFAULT_OPUS_MODEL: 'gpt-5.3-codex', + ANTHROPIC_DEFAULT_SONNET_MODEL: 'gpt-5.3-codex', + ANTHROPIC_DEFAULT_HAIKU_MODEL: 'gpt-5-codex-mini', + }, + }, + null, + 2 + ), + 'utf-8' + ); + + return { tmpDir, settingsPath }; +} + +async function importCompatibilityModule(cacheTag: string) { + return import(`../../../src/cliproxy/codex-plan-compatibility?${cacheTag}=${Date.now()}`); +} + +describe('codex plan compatibility reconcile', () => { + it('repairs stale paid-only Codex settings for free-plan accounts before launch', async () => { + const { tmpDir, settingsPath } = createCodexSettingsFixture(); + + mock.module('../../../src/cliproxy/account-manager', () => ({ + getDefaultAccount: () => ({ id: 'free@example.com' }), + })); + mock.module('../../../src/cliproxy/quota-fetcher-codex', () => ({ + fetchCodexQuota: async () => ({ + success: true, + windows: [], + coreUsage: { fiveHour: null, weekly: null }, + planType: 'free', + lastUpdated: Date.now(), + accountId: 'free@example.com', + }), + })); + mock.module('../../../src/cliproxy/quota-response-cache', () => ({ + getCachedQuota: () => null, + setCachedQuota: () => {}, + })); + mock.module('../../../src/utils/ui', () => ({ + info: (message: string) => message, + warn: (message: string) => message, + })); + + const errorSpy = spyOn(console, 'error').mockImplementation(() => {}); + + try { + const { reconcileCodexModelForActivePlan } = await importCompatibilityModule('free-plan'); + + await reconcileCodexModelForActivePlan({ + settingsPath, + currentModel: 'gpt-5.3-codex', + verbose: false, + }); + + const repaired = JSON.parse(fs.readFileSync(settingsPath, 'utf-8')) as { + env: Record; + }; + expect(repaired.env.ANTHROPIC_MODEL).toBe('gpt-5-codex'); + expect(repaired.env.ANTHROPIC_DEFAULT_OPUS_MODEL).toBe('gpt-5-codex'); + expect(repaired.env.ANTHROPIC_DEFAULT_SONNET_MODEL).toBe('gpt-5-codex'); + expect(repaired.env.ANTHROPIC_DEFAULT_HAIKU_MODEL).toBe('gpt-5-codex-mini'); + expect(errorSpy).toHaveBeenCalledWith( + 'Codex free plan detected. Switched unsupported model "gpt-5.3-codex" to "gpt-5-codex".' + ); + } finally { + fs.rmSync(tmpDir, { recursive: true, force: true }); + } + }); + + it('warns and leaves settings untouched when no default Codex account is available', async () => { + const { tmpDir, settingsPath } = createCodexSettingsFixture(); + + mock.module('../../../src/cliproxy/account-manager', () => ({ + getDefaultAccount: () => null, + })); + mock.module('../../../src/cliproxy/quota-fetcher-codex', () => ({ + fetchCodexQuota: async () => { + throw new Error('should not fetch quota without a default account'); + }, + })); + mock.module('../../../src/cliproxy/quota-response-cache', () => ({ + getCachedQuota: () => null, + setCachedQuota: () => {}, + })); + mock.module('../../../src/utils/ui', () => ({ + info: (message: string) => message, + warn: (message: string) => message, + })); + + const errorSpy = spyOn(console, 'error').mockImplementation(() => {}); + + try { + const { reconcileCodexModelForActivePlan } = + await importCompatibilityModule('missing-default-account'); + + await reconcileCodexModelForActivePlan({ + settingsPath, + currentModel: 'gpt-5.3-codex', + verbose: false, + }); + + const settings = JSON.parse(fs.readFileSync(settingsPath, 'utf-8')) as { + env: Record; + }; + expect(settings.env.ANTHROPIC_MODEL).toBe('gpt-5.3-codex'); + expect(errorSpy).toHaveBeenCalledWith( + 'Configured Codex model "gpt-5.3-codex" may require a paid Codex plan. If startup fails, switch to "gpt-5-codex" with "ccs codex --config".' + ); + } finally { + fs.rmSync(tmpDir, { recursive: true, force: true }); + } + }); + + it('keeps paid-plan Codex settings unchanged for plus and team accounts', async () => { + for (const planType of ['plus', 'team'] as const) { + const { tmpDir, settingsPath } = createCodexSettingsFixture(); + + mock.module('../../../src/cliproxy/account-manager', () => ({ + getDefaultAccount: () => ({ id: `${planType}@example.com` }), + })); + mock.module('../../../src/cliproxy/quota-fetcher-codex', () => ({ + fetchCodexQuota: async () => ({ + success: true, + windows: [], + coreUsage: { fiveHour: null, weekly: null }, + planType, + lastUpdated: Date.now(), + accountId: `${planType}@example.com`, + }), + })); + mock.module('../../../src/cliproxy/quota-response-cache', () => ({ + getCachedQuota: () => null, + setCachedQuota: () => {}, + })); + mock.module('../../../src/utils/ui', () => ({ + info: (message: string) => message, + warn: (message: string) => message, + })); + + const errorSpy = spyOn(console, 'error').mockImplementation(() => {}); + + try { + const { reconcileCodexModelForActivePlan } = await importCompatibilityModule(planType); + + await reconcileCodexModelForActivePlan({ + settingsPath, + currentModel: 'gpt-5.3-codex', + verbose: false, + }); + + const settings = JSON.parse(fs.readFileSync(settingsPath, 'utf-8')) as { + env: Record; + }; + expect(settings.env.ANTHROPIC_MODEL).toBe('gpt-5.3-codex'); + expect(errorSpy).not.toHaveBeenCalled(); + } finally { + fs.rmSync(tmpDir, { recursive: true, force: true }); + mock.restore(); + } + } + }); + + it('warns and keeps settings unchanged when Codex plan verification fails', async () => { + const { tmpDir, settingsPath } = createCodexSettingsFixture(); + + mock.module('../../../src/cliproxy/account-manager', () => ({ + getDefaultAccount: () => ({ id: 'unknown@example.com' }), + })); + mock.module('../../../src/cliproxy/quota-fetcher-codex', () => ({ + fetchCodexQuota: async () => ({ + success: false, + windows: [], + coreUsage: { fiveHour: null, weekly: null }, + planType: null, + lastUpdated: Date.now(), + accountId: 'unknown@example.com', + error: 'network timeout', + }), + })); + mock.module('../../../src/cliproxy/quota-response-cache', () => ({ + getCachedQuota: () => null, + setCachedQuota: () => {}, + })); + mock.module('../../../src/utils/ui', () => ({ + info: (message: string) => message, + warn: (message: string) => message, + })); + + const errorSpy = spyOn(console, 'error').mockImplementation(() => {}); + + try { + const { reconcileCodexModelForActivePlan } = await importCompatibilityModule('unknown-plan'); + + await reconcileCodexModelForActivePlan({ + settingsPath, + currentModel: 'gpt-5.3-codex', + verbose: false, + }); + + const settings = JSON.parse(fs.readFileSync(settingsPath, 'utf-8')) as { + env: Record; + }; + expect(settings.env.ANTHROPIC_MODEL).toBe('gpt-5.3-codex'); + expect(errorSpy).toHaveBeenCalledWith( + 'Could not verify Codex plan for model "gpt-5.3-codex". If startup fails with model_not_supported, switch to "gpt-5-codex" via "ccs codex --config".' + ); + } finally { + fs.rmSync(tmpDir, { recursive: true, force: true }); + } + }); +}); diff --git a/tests/unit/cliproxy/codex-plan-compatibility.test.ts b/tests/unit/cliproxy/codex-plan-compatibility.test.ts index b3ce52ea..cf84fd28 100644 --- a/tests/unit/cliproxy/codex-plan-compatibility.test.ts +++ b/tests/unit/cliproxy/codex-plan-compatibility.test.ts @@ -1,17 +1,10 @@ -import * as fs from 'fs'; -import * as os from 'os'; -import * as path from 'path'; -import { afterEach, describe, expect, it, mock, spyOn } from 'bun:test'; +import { describe, expect, it } from 'bun:test'; import { getProviderCatalog, getModelMaxLevel } from '../../../src/cliproxy/model-catalog'; import { getDefaultCodexModel, getFreePlanFallbackCodexModel, } from '../../../src/cliproxy/codex-plan-compatibility'; -afterEach(() => { - mock.restore(); -}); - describe('codex plan compatibility', () => { it('uses a cross-plan safe Codex default', () => { expect(getDefaultCodexModel()).toBe('gpt-5-codex'); @@ -38,77 +31,4 @@ describe('codex plan compatibility', () => { expect(getModelMaxLevel('codex', 'gpt-5.2-codex')).toBe('xhigh'); expect(getModelMaxLevel('codex', 'gpt-5.3-codex')).toBe('xhigh'); }); - - it('repairs stale paid-only Codex settings for free-plan accounts before launch', async () => { - const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'ccs-codex-plan-compat-')); - const settingsPath = path.join(tmpDir, 'codex.settings.json'); - - fs.writeFileSync( - settingsPath, - JSON.stringify( - { - env: { - ANTHROPIC_BASE_URL: 'http://127.0.0.1:8317/api/provider/codex', - ANTHROPIC_AUTH_TOKEN: 'ccs-internal-managed', - ANTHROPIC_MODEL: 'gpt-5.3-codex', - ANTHROPIC_DEFAULT_OPUS_MODEL: 'gpt-5.3-codex', - ANTHROPIC_DEFAULT_SONNET_MODEL: 'gpt-5.3-codex', - ANTHROPIC_DEFAULT_HAIKU_MODEL: 'gpt-5-codex-mini', - }, - }, - null, - 2 - ), - 'utf-8' - ); - - mock.module('../../../src/cliproxy/account-manager', () => ({ - getDefaultAccount: () => ({ id: 'free@example.com' }), - })); - mock.module('../../../src/cliproxy/quota-fetcher-codex', () => ({ - fetchCodexQuota: async () => ({ - success: true, - windows: [], - coreUsage: { fiveHour: null, weekly: null }, - planType: 'free', - lastUpdated: Date.now(), - accountId: 'free@example.com', - }), - })); - mock.module('../../../src/cliproxy/quota-response-cache', () => ({ - getCachedQuota: () => null, - setCachedQuota: () => {}, - })); - mock.module('../../../src/utils/ui', () => ({ - info: (message: string) => message, - warn: (message: string) => message, - })); - - const errorSpy = spyOn(console, 'error').mockImplementation(() => {}); - - try { - const { reconcileCodexModelForActivePlan } = await import( - `../../../src/cliproxy/codex-plan-compatibility?free-plan=${Date.now()}` - ); - - await reconcileCodexModelForActivePlan({ - settingsPath, - currentModel: 'gpt-5.3-codex', - verbose: false, - }); - - const repaired = JSON.parse(fs.readFileSync(settingsPath, 'utf-8')) as { - env: Record; - }; - expect(repaired.env.ANTHROPIC_MODEL).toBe('gpt-5-codex'); - expect(repaired.env.ANTHROPIC_DEFAULT_OPUS_MODEL).toBe('gpt-5-codex'); - expect(repaired.env.ANTHROPIC_DEFAULT_SONNET_MODEL).toBe('gpt-5-codex'); - expect(repaired.env.ANTHROPIC_DEFAULT_HAIKU_MODEL).toBe('gpt-5-codex-mini'); - expect(errorSpy).toHaveBeenCalledWith( - 'Codex free plan detected. Switched unsupported model "gpt-5.3-codex" to "gpt-5-codex".' - ); - } finally { - fs.rmSync(tmpDir, { recursive: true, force: true }); - } - }); }); From e6b363524bc28c5b9d3b1b675cb2cf9f749a936a Mon Sep 17 00:00:00 2001 From: Tam Nhu Tran Date: Mon, 16 Mar 2026 14:31:55 -0400 Subject: [PATCH 3/3] fix(cliproxy): remap codex haiku fallback on free plans --- src/cliproxy/codex-plan-compatibility.ts | 4 +- src/cliproxy/services/variant-settings.ts | 10 +++- ...codex-plan-compatibility-reconcile.test.ts | 59 ++++++++++++++++++- 3 files changed, 67 insertions(+), 6 deletions(-) diff --git a/src/cliproxy/codex-plan-compatibility.ts b/src/cliproxy/codex-plan-compatibility.ts index 91a30380..41177e00 100644 --- a/src/cliproxy/codex-plan-compatibility.ts +++ b/src/cliproxy/codex-plan-compatibility.ts @@ -66,7 +66,9 @@ export async function reconcileCodexModelForActivePlan(options: { } if (quota.planType === 'free') { - updateSettingsModel(settingsPath, fallbackModel, 'codex'); + updateSettingsModel(settingsPath, fallbackModel, 'codex', { + rewriteHaikuModel: (haikuModel) => getFreePlanFallbackCodexModel(haikuModel) ?? haikuModel, + }); console.error( info( `Codex free plan detected. Switched unsupported model "${normalizeCodexModelId(currentModel)}" ` + diff --git a/src/cliproxy/services/variant-settings.ts b/src/cliproxy/services/variant-settings.ts index 4dbca1fc..7c7d2038 100644 --- a/src/cliproxy/services/variant-settings.ts +++ b/src/cliproxy/services/variant-settings.ts @@ -290,7 +290,10 @@ export function deleteSettingsFile(settingsPath: string): boolean { export function updateSettingsModel( settingsPath: string, model: string, - provider?: CLIProxyProfileName + provider?: CLIProxyProfileName, + options?: { + rewriteHaikuModel?: (model: string) => string; + } ): void { const fileName = path.basename(settingsPath); if (fileName.startsWith('composite-')) { @@ -316,10 +319,13 @@ export function updateSettingsModel( settings.env.ANTHROPIC_DEFAULT_OPUS_MODEL = normalizedModel; settings.env.ANTHROPIC_DEFAULT_SONNET_MODEL = normalizedModel; if (provider === 'codex' && settings.env.ANTHROPIC_DEFAULT_HAIKU_MODEL) { - settings.env.ANTHROPIC_DEFAULT_HAIKU_MODEL = canonicalizeModelForProvider( + const normalizedHaikuModel = canonicalizeModelForProvider( provider, settings.env.ANTHROPIC_DEFAULT_HAIKU_MODEL ); + settings.env.ANTHROPIC_DEFAULT_HAIKU_MODEL = options?.rewriteHaikuModel + ? options.rewriteHaikuModel(normalizedHaikuModel) + : normalizedHaikuModel; } } else { // Clear model settings to use defaults diff --git a/tests/unit/cliproxy/codex-plan-compatibility-reconcile.test.ts b/tests/unit/cliproxy/codex-plan-compatibility-reconcile.test.ts index d11d6e6e..b22ac1fa 100644 --- a/tests/unit/cliproxy/codex-plan-compatibility-reconcile.test.ts +++ b/tests/unit/cliproxy/codex-plan-compatibility-reconcile.test.ts @@ -7,7 +7,10 @@ afterEach(() => { mock.restore(); }); -function createCodexSettingsFixture(): { tmpDir: string; settingsPath: string } { +function createCodexSettingsFixture(haikuModel: string = 'gpt-5-codex-mini'): { + tmpDir: string; + settingsPath: string; +} { const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'ccs-codex-plan-compat-')); const settingsPath = path.join(tmpDir, 'codex.settings.json'); @@ -21,7 +24,7 @@ function createCodexSettingsFixture(): { tmpDir: string; settingsPath: string } ANTHROPIC_MODEL: 'gpt-5.3-codex', ANTHROPIC_DEFAULT_OPUS_MODEL: 'gpt-5.3-codex', ANTHROPIC_DEFAULT_SONNET_MODEL: 'gpt-5.3-codex', - ANTHROPIC_DEFAULT_HAIKU_MODEL: 'gpt-5-codex-mini', + ANTHROPIC_DEFAULT_HAIKU_MODEL: haikuModel, }, }, null, @@ -39,7 +42,7 @@ async function importCompatibilityModule(cacheTag: string) { describe('codex plan compatibility reconcile', () => { it('repairs stale paid-only Codex settings for free-plan accounts before launch', async () => { - const { tmpDir, settingsPath } = createCodexSettingsFixture(); + const { tmpDir, settingsPath } = createCodexSettingsFixture('gpt-5.3-codex-spark'); mock.module('../../../src/cliproxy/account-manager', () => ({ getDefaultAccount: () => ({ id: 'free@example.com' }), @@ -230,4 +233,54 @@ describe('codex plan compatibility reconcile', () => { fs.rmSync(tmpDir, { recursive: true, force: true }); } }); + + it('warns and keeps settings unchanged when quota succeeds without a plan type', async () => { + const { tmpDir, settingsPath } = createCodexSettingsFixture(); + + mock.module('../../../src/cliproxy/account-manager', () => ({ + getDefaultAccount: () => ({ id: 'missing-plan@example.com' }), + })); + mock.module('../../../src/cliproxy/quota-fetcher-codex', () => ({ + fetchCodexQuota: async () => ({ + success: true, + windows: [], + coreUsage: { fiveHour: null, weekly: null }, + planType: null, + lastUpdated: Date.now(), + accountId: 'missing-plan@example.com', + }), + })); + mock.module('../../../src/cliproxy/quota-response-cache', () => ({ + getCachedQuota: () => null, + setCachedQuota: () => {}, + })); + mock.module('../../../src/utils/ui', () => ({ + info: (message: string) => message, + warn: (message: string) => message, + })); + + const errorSpy = spyOn(console, 'error').mockImplementation(() => {}); + + try { + const { reconcileCodexModelForActivePlan } = + await importCompatibilityModule('missing-plan-type'); + + await reconcileCodexModelForActivePlan({ + settingsPath, + currentModel: 'gpt-5.3-codex', + verbose: false, + }); + + const settings = JSON.parse(fs.readFileSync(settingsPath, 'utf-8')) as { + env: Record; + }; + expect(settings.env.ANTHROPIC_MODEL).toBe('gpt-5.3-codex'); + expect(settings.env.ANTHROPIC_DEFAULT_HAIKU_MODEL).toBe('gpt-5-codex-mini'); + expect(errorSpy).toHaveBeenCalledWith( + 'Could not verify Codex plan for model "gpt-5.3-codex". If startup fails with model_not_supported, switch to "gpt-5-codex" via "ccs codex --config".' + ); + } finally { + fs.rmSync(tmpDir, { recursive: true, force: true }); + } + }); });