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(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()}`); } describe('codex plan compatibility reconcile', () => { it('repairs stale paid-only Codex settings for free-plan accounts before launch', async () => { const { tmpDir, settingsPath } = createCodexSettingsFixture('gpt-5.3-codex-spark'); 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 }); } }); 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 }); } }); });