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 { clearQuotaCache } from '../../../src/cliproxy/quota-response-cache'; afterEach(() => { clearQuotaCache(); mock.restore(); }); 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'); 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: haikuModel, }, }, null, 2 ), 'utf-8' ); return { tmpDir, settingsPath }; } async function importCompatibilityModule(cacheTag: string) { return import(`../../../src/cliproxy/codex-plan-compatibility?${cacheTag}=${Date.now()}`); } const identity = (message: string) => message; describe('codex plan compatibility reconcile', () => { it('keeps saved Codex settings intact and warns about runtime fallback for free-plan accounts', async () => { const { tmpDir, settingsPath } = createCodexSettingsFixture('gpt-5.3-codex-spark'); const errorSpy = spyOn(console, 'error').mockImplementation(() => {}); try { const { reconcileCodexModelForActivePlan } = await importCompatibilityModule('free-plan'); await reconcileCodexModelForActivePlan( { currentModel: 'gpt-5.3-codex', verbose: false, }, { getDefaultAccount: () => ({ id: 'free@example.com' }) as never, fetchCodexQuota: async () => ({ success: true, windows: [], coreUsage: { fiveHour: null, weekly: null }, planType: 'free', lastUpdated: Date.now(), accountId: 'free@example.com', }), formatInfo: identity, formatWarn: identity, } ); const repaired = JSON.parse(fs.readFileSync(settingsPath, 'utf-8')) as { env: Record; }; expect(repaired.env.ANTHROPIC_MODEL).toBe('gpt-5.3-codex'); expect(repaired.env.ANTHROPIC_DEFAULT_OPUS_MODEL).toBe('gpt-5.3-codex'); expect(repaired.env.ANTHROPIC_DEFAULT_SONNET_MODEL).toBe('gpt-5.3-codex'); expect(repaired.env.ANTHROPIC_DEFAULT_HAIKU_MODEL).toBe('gpt-5.3-codex-spark'); expect(errorSpy).toHaveBeenCalledWith( 'Codex free plan detected. Keeping saved model "gpt-5.3-codex" in settings; runtime requests will fall back to "gpt-5-codex" when needed.' ); } 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(); const errorSpy = spyOn(console, 'error').mockImplementation(() => {}); try { const { reconcileCodexModelForActivePlan } = await importCompatibilityModule('missing-default-account'); await reconcileCodexModelForActivePlan( { currentModel: 'gpt-5.3-codex', verbose: false, }, { getDefaultAccount: () => null, fetchCodexQuota: async () => { throw new Error('should not fetch quota without a default account'); }, formatInfo: identity, formatWarn: identity, } ); 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(); const errorSpy = spyOn(console, 'error').mockImplementation(() => {}); try { const { reconcileCodexModelForActivePlan } = await importCompatibilityModule(planType); await reconcileCodexModelForActivePlan( { currentModel: 'gpt-5.3-codex', verbose: false, }, { getDefaultAccount: () => ({ id: `${planType}@example.com` }) as never, fetchCodexQuota: async () => ({ success: true, windows: [], coreUsage: { fiveHour: null, weekly: null }, planType, lastUpdated: Date.now(), accountId: `${planType}@example.com`, }), formatInfo: identity, formatWarn: identity, } ); 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(); const errorSpy = spyOn(console, 'error').mockImplementation(() => {}); try { const { reconcileCodexModelForActivePlan } = await importCompatibilityModule('unknown-plan'); await reconcileCodexModelForActivePlan( { currentModel: 'gpt-5.3-codex', verbose: false, }, { getDefaultAccount: () => ({ id: 'unknown@example.com' }) as never, fetchCodexQuota: async () => ({ success: false, windows: [], coreUsage: { fiveHour: null, weekly: null }, planType: null, lastUpdated: Date.now(), accountId: 'unknown@example.com', error: 'network timeout', }), formatInfo: identity, formatWarn: identity, } ); 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 }); } }); it('warns and keeps settings unchanged when quota succeeds without a plan type', async () => { const { tmpDir, settingsPath } = createCodexSettingsFixture(); const errorSpy = spyOn(console, 'error').mockImplementation(() => {}); try { const { reconcileCodexModelForActivePlan } = await importCompatibilityModule('missing-plan-type'); await reconcileCodexModelForActivePlan( { currentModel: 'gpt-5.3-codex', verbose: false, }, { getDefaultAccount: () => ({ id: 'missing-plan@example.com' }) as never, fetchCodexQuota: async () => ({ success: true, windows: [], coreUsage: { fiveHour: null, weekly: null }, planType: null, lastUpdated: Date.now(), accountId: 'missing-plan@example.com', }), formatInfo: identity, formatWarn: identity, } ); 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 }); } }); });