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'); }); });