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.
This commit is contained in:
Thieu Nguyen authored and GitHub committed 2026-06-26 15:41:15 -04:00
1 parent 36ea9064e7
commit ba0e768fbf
6 files changed
+100 -23

No files matched your search

+41
View File
@@ -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;
}
}
+11 -8
View File
@@ -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;
}
@@ -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<string, unknown>).auth;
}
if (providerReady && !normalizedModelAlias) {
+4 -1
View File
@@ -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(
@@ -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(
@@ -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);
});