From ba0e768fbf5d92d380caf1b8a1eb6cc2cf9d15fc Mon Sep 17 00:00:00 2001 From: Thieu Nguyen <79964592+thieung@users.noreply.github.com> Date: Sat, 27 Jun 2026 02:41:15 +0700 Subject: [PATCH] fix(codex): normalize CLIProxy responses routes (#1605) Normalize Codex Responses base URLs so CCS-backed Codex launches use /backend-api/codex on the original CLIProxy backend. Share the Codex Responses route builder with ccsxp provider repair, and repair stale native Codex provider auth blocks when env_key token injection is used. --- src/cliproxy/config/provider-route.ts | 41 +++++++++++++++++++ src/targets/codex-adapter.ts | 19 +++++---- src/targets/codex-cliproxy-provider-config.ts | 11 ++--- tests/unit/targets/codex-adapter.test.ts | 5 ++- .../codex-cliproxy-provider-config.test.ts | 34 +++++++++++++-- .../codex-settings-bridge-launch.test.ts | 13 ++++-- 6 files changed, 100 insertions(+), 23 deletions(-) diff --git a/src/cliproxy/config/provider-route.ts b/src/cliproxy/config/provider-route.ts index 0ccac5c3..e6f23a0e 100644 --- a/src/cliproxy/config/provider-route.ts +++ b/src/cliproxy/config/provider-route.ts @@ -40,3 +40,44 @@ export function buildLocalProviderBaseUrl( const rootUrl = `http://127.0.0.1:${port}`; return `${rootUrl}${buildCliproxyProviderPath(provider, backend)}`; } + +export function buildCodexResponsesProviderPath( + backend: CLIProxyBackend = getConfiguredCliproxyBackend() +): string { + return usesScopedProviderRoutes(backend) ? '/api/provider/codex' : '/backend-api/codex'; +} + +export function buildLocalCodexResponsesBaseUrl( + port: number, + backend: CLIProxyBackend = getConfiguredCliproxyBackend() +): string { + return `http://127.0.0.1:${port}${buildCodexResponsesProviderPath(backend)}`; +} + +export function normalizeCodexResponsesBaseUrl( + baseUrl: string, + backend: CLIProxyBackend = getConfiguredCliproxyBackend() +): string { + const trimmed = baseUrl.trim(); + if (!trimmed) return baseUrl; + + try { + const parsed = new URL(trimmed); + if (!['http:', 'https:'].includes(parsed.protocol)) return baseUrl; + + const currentPath = parsed.pathname.replace(/\/+$/, '') || '/'; + const expectedPath = buildCodexResponsesProviderPath(backend); + if (currentPath === expectedPath) return trimmed; + + const legacyCodexPath = '/api/provider/codex'; + const managedPaths = new Set(['/', legacyCodexPath]); + if (!managedPaths.has(currentPath)) return trimmed; + + parsed.pathname = expectedPath; + parsed.search = ''; + parsed.hash = ''; + return parsed.toString().replace(/\/$/, ''); + } catch { + return baseUrl; + } +} diff --git a/src/targets/codex-adapter.ts b/src/targets/codex-adapter.ts index 9e91f4db..9ef937b5 100644 --- a/src/targets/codex-adapter.ts +++ b/src/targets/codex-adapter.ts @@ -1,7 +1,7 @@ import { ChildProcess, spawn } from 'child_process'; import * as fs from 'fs'; import type { ProfileType } from '../types/profile'; -import { runCleanup } from '../errors'; +import { ConfigError, runCleanup } from '../errors'; import { expandPath } from '../utils/helpers'; import { wireChildProcessSignals } from '../utils/signal-forwarder'; import { @@ -25,6 +25,7 @@ import { } from './codex-detector'; import { createLogger } from '../services/logging'; import { getEffectiveApiKey } from '../cliproxy/auth/auth-token-manager'; +import { normalizeCodexResponsesBaseUrl } from '../cliproxy/config/provider-route'; import { resolveLifecyclePort } from '../cliproxy/config/port-manager'; import { getModelMaxLevel } from '../cliproxy/model-catalog'; import { parseCodexModelTuningAlias } from '../cliproxy/ai-providers/model-id-normalizer'; @@ -53,7 +54,7 @@ function buildConfigOverrideArgs(overrides: string[]): string[] { function buildConfigOverrideSupportError(binaryInfo?: TargetBinaryInfo): Error { const versionSummary = binaryInfo?.version ? ` (${binaryInfo.version})` : ''; - return new Error( + return new ConfigError( `Codex CLI${versionSummary} does not advertise --config overrides. Upgrade Codex before using CCS-backed Codex profiles or runtime reasoning overrides.` ); } @@ -100,7 +101,7 @@ function normalizeCodexReasoningOverride(value: string | number | undefined): st if (typeof value === 'string' && CODEX_REASONING_LEVELS.has(value)) { return value; } - throw new Error( + throw new ConfigError( 'Codex target supports reasoning levels only: minimal, low, medium, high, xhigh.' ); } @@ -272,7 +273,7 @@ export class CodexAdapter implements TargetAdapter { const providerRepair = await ensureCodexCliproxyProviderConfig(resolveLifecyclePort()); this.ccsxpCliproxyEnvKey = providerRepair.envKey; } catch (error) { - throw new Error( + throw new ConfigError( `ccsxp could not repair the native Codex cliproxy provider: ${(error as Error).message}` ); } @@ -316,14 +317,14 @@ export class CodexAdapter implements TargetAdapter { } if (!creds?.baseUrl?.trim() || !creds.apiKey?.trim()) { - throw new Error( + throw new ConfigError( 'Codex target requires base URL and API key for CCS-backed profile launches.' ); } const disallowedFlags = findDisallowedCodexManagedFlags(userArgs); if (disallowedFlags.length > 0) { - throw new Error( + throw new ConfigError( `Codex target does not allow ${disallowedFlags.join(', ')} when CCS manages the runtime provider. Remove native Codex provider selection flags and retry.` ); } @@ -331,7 +332,9 @@ export class CodexAdapter implements TargetAdapter { const overrides = [ `model_provider=${formatTomlString(CODEX_RUNTIME_PROVIDER_ID)}`, `model_providers.${CODEX_RUNTIME_PROVIDER_ID}.name=${formatTomlString('CCS Runtime')}`, - `model_providers.${CODEX_RUNTIME_PROVIDER_ID}.base_url=${formatTomlString(creds.baseUrl)}`, + `model_providers.${CODEX_RUNTIME_PROVIDER_ID}.base_url=${formatTomlString( + normalizeCodexResponsesBaseUrl(creds.baseUrl) + )}`, `model_providers.${CODEX_RUNTIME_PROVIDER_ID}.env_key=${formatTomlString(CODEX_RUNTIME_ENV_KEY)}`, `model_providers.${CODEX_RUNTIME_PROVIDER_ID}.wire_api=${formatTomlString('responses')}`, ]; @@ -360,7 +363,7 @@ export class CodexAdapter implements TargetAdapter { } if (profileType !== 'default') { if (!creds.apiKey?.trim()) { - throw new Error('Codex target requires an API key for CCS-backed profile launches.'); + throw new ConfigError('Codex target requires an API key for CCS-backed profile launches.'); } env[CODEX_RUNTIME_ENV_KEY] = creds.apiKey; } diff --git a/src/targets/codex-cliproxy-provider-config.ts b/src/targets/codex-cliproxy-provider-config.ts index a2c93252..750e113b 100644 --- a/src/targets/codex-cliproxy-provider-config.ts +++ b/src/targets/codex-cliproxy-provider-config.ts @@ -9,9 +9,8 @@ import { import { getModelMaxLevel } from '../cliproxy/model-catalog'; import { parseCodexModelTuningAlias } from '../cliproxy/ai-providers/model-id-normalizer'; import { - buildLocalProviderBaseUrl, + buildLocalCodexResponsesBaseUrl, getConfiguredCliproxyBackend, - usesScopedProviderRoutes, } from '../cliproxy/config/provider-route'; import { ConfigError } from '../errors/error-types'; @@ -52,11 +51,7 @@ export function buildCodexCliproxyProviderBaseUrl(port: number): string { // (issue #1597). Use the chatgpt_base_url-compatible "/backend-api/codex" alias, // which both backends serve; keep the provider-scoped alias for the Plus backend // to preserve its existing per-provider routing. - const backend = getConfiguredCliproxyBackend(); - if (usesScopedProviderRoutes(backend)) { - return buildLocalProviderBaseUrl('codex', port, backend); - } - return `http://127.0.0.1:${port}/backend-api/codex`; + return buildLocalCodexResponsesBaseUrl(port, getConfiguredCliproxyBackend()); } export function isCcsxpCliproxyShortcut(env: NodeJS.ProcessEnv = process.env): boolean { @@ -150,6 +145,7 @@ function isProviderReady( typeof provider.base_url === 'string' && resolveProviderBaseUrl(provider, expectedBaseUrl) === provider.base_url.trim() && provider.env_key === envKey && + provider.auth === undefined && provider.wire_api === 'responses' && provider.requires_openai_auth === false && provider.supports_websockets === false @@ -261,6 +257,7 @@ export async function ensureCodexCliproxyProviderConfig( ...currentProvider, ...buildProviderConfig(resolveProviderBaseUrl(currentProvider, expectedBaseUrl), envKey), }; + delete (providers[CODEX_CLIPROXY_PROVIDER_ID] as Record).auth; } if (providerReady && !normalizedModelAlias) { diff --git a/tests/unit/targets/codex-adapter.test.ts b/tests/unit/targets/codex-adapter.test.ts index 6d4927d9..86a3f2fb 100644 --- a/tests/unit/targets/codex-adapter.test.ts +++ b/tests/unit/targets/codex-adapter.test.ts @@ -128,7 +128,7 @@ describe('CodexAdapter', () => { profileType: 'cliproxy', creds: { profile: 'codex', - baseUrl: 'http://127.0.0.1:8317/api/provider/codex', + baseUrl: 'http://127.0.0.1:8317', apiKey: 'cliproxy-token', model: 'gpt-5.4', reasoningOverride: 'high', @@ -143,6 +143,9 @@ describe('CodexAdapter', () => { expect(args).toContain('-c'); expect(args).toContain('model_provider="ccs_runtime"'); + expect(args).toContain( + 'model_providers.ccs_runtime.base_url="http://127.0.0.1:8317/backend-api/codex"' + ); expect(args).toContain('model_providers.ccs_runtime.env_key="CCS_CODEX_API_KEY"'); expect(args).toContain('model="gpt-5.4"'); expect(args).toContain( diff --git a/tests/unit/targets/codex-cliproxy-provider-config.test.ts b/tests/unit/targets/codex-cliproxy-provider-config.test.ts index 9b1b99d2..a064cd5f 100644 --- a/tests/unit/targets/codex-cliproxy-provider-config.test.ts +++ b/tests/unit/targets/codex-cliproxy-provider-config.test.ts @@ -55,9 +55,7 @@ describe('codex cliproxy provider config repair', () => { // original CLIProxy backend serves the Codex Responses API only under the // "/backend-api/codex" alias, never at the bare root. Returning the root here // makes Codex call "http://127.0.0.1:8317/responses" -> 404 (issue #1597). - expect(buildCodexCliproxyProviderBaseUrl(8317)).toBe( - 'http://127.0.0.1:8317/backend-api/codex' - ); + expect(buildCodexCliproxyProviderBaseUrl(8317)).toBe('http://127.0.0.1:8317/backend-api/codex'); }); it('produces a Codex Responses endpoint the original backend actually serves (regression #1597)', () => { @@ -135,6 +133,36 @@ wire_api = "responses" expect(rawText).toContain('supports_websockets = false'); }); + it('removes native provider auth when ccsxp injects the token through env_key', async () => { + fs.mkdirSync(codexHome, { recursive: true }); + fs.writeFileSync( + configPath, + `[model_providers.cliproxy] +name = "CLIProxy Codex" +base_url = "http://127.0.0.1:8317/backend-api/codex" +env_key = "CLIPROXY_API_KEY" +wire_api = "responses" +requires_openai_auth = false +supports_websockets = false + +[model_providers.cliproxy.auth] +command = "/tmp/cliproxy-token" +timeout_ms = 5000 +refresh_interval_ms = 300000 +`, + 'utf8' + ); + + const result = await ensureCodexCliproxyProviderConfig(8317, env); + + expect(result.changed).toBe(true); + expect(result.envKey).toBe('CLIPROXY_API_KEY'); + const rawText = fs.readFileSync(configPath, 'utf8'); + expect(rawText).toContain('env_key = "CLIPROXY_API_KEY"'); + expect(rawText).not.toContain('[model_providers.cliproxy.auth]'); + expect(rawText).not.toContain('cliproxy-token'); + }); + it('preserves custom cliproxy provider values while repairing other fields', async () => { fs.mkdirSync(codexHome, { recursive: true }); fs.writeFileSync( diff --git a/tests/unit/targets/codex-settings-bridge-launch.test.ts b/tests/unit/targets/codex-settings-bridge-launch.test.ts index 6bf1c75c..384226d0 100644 --- a/tests/unit/targets/codex-settings-bridge-launch.test.ts +++ b/tests/unit/targets/codex-settings-bridge-launch.test.ts @@ -124,21 +124,26 @@ exit 0 const argsLog = fs.readFileSync(codexArgsLogPath, 'utf8'); expect(argsLog).toContain('model_provider="ccs_runtime"'); - expect(argsLog).toContain('model_providers.ccs_runtime.base_url="http://127.0.0.1:8317/api/provider/codex"'); + expect(argsLog).toContain( + 'model_providers.ccs_runtime.base_url="http://127.0.0.1:8317/backend-api/codex"' + ); expect(argsLog).toContain('model_reasoning_effort="high"'); expect(argsLog).not.toContain('mcp_servers.ccs_browser.command='); expect(argsLog).not.toContain('mcp_servers.ccs_browser.args=["-y","@playwright/mcp@0.0.70"]'); expect(argsLog).toContain('smoke'); expect(fs.readFileSync(codexEnvLogPath, 'utf8')).toBe('bridge-token'); - }); + }, 15000); it('rejects native Codex profile flags when CCS manages the bridge runtime', () => { if (process.platform === 'win32') return; - const result = runCcs(['codex-api', '--target', 'codex', '--profile', 'other', 'smoke'], baseEnv); + const result = runCcs( + ['codex-api', '--target', 'codex', '--profile', 'other', 'smoke'], + baseEnv + ); expect(result.status).toBe(1); expect(result.stderr).toContain('does not allow --profile/-p'); expect(fs.existsSync(codexArgsLogPath)).toBe(false); - }); + }, 15000); });