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