From 99ed8090b152974dacf1219e94e36246664bd302 Mon Sep 17 00:00:00 2001 From: walker1211 <13750528578@163.com> Date: Sat, 20 Jun 2026 13:40:23 +0800 Subject: [PATCH] =?UTF-8?q?fix(cliproxy):=20=E6=8C=89=20backend=20?= =?UTF-8?q?=E7=94=9F=E6=88=90=20provider=20=E8=B7=AF=E7=94=B1?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../__tests__/cliproxy-profile-bridge.test.ts | 58 ++++++++--- src/api/services/profile-writer.ts | 27 ++++-- .../env-builder-provider-url.test.ts | 70 ++++++++++++-- src/cliproxy/config/env-builder.ts | 60 ++++-------- src/cliproxy/config/provider-route.ts | 42 ++++++++ .../env-resolver-codex-fallback.test.ts | 56 ++++++++++- src/cliproxy/executor/env-resolver.ts | 6 +- src/cliproxy/executor/proxy-chain-builder.ts | 8 +- src/targets/codex-cliproxy-provider-config.ts | 95 +++++++++++++++---- .../unit/api/cliproxy-profile-bridge.test.ts | 25 ++++- .../codex-cliproxy-provider-config.test.ts | 68 ++++++++++++- .../unit/utils/browser/browser-setup.test.ts | 35 ++++++- .../profile-routes-cliproxy-bridge.test.ts | 6 ++ 13 files changed, 450 insertions(+), 106 deletions(-) create mode 100644 src/cliproxy/config/provider-route.ts diff --git a/src/api/services/__tests__/cliproxy-profile-bridge.test.ts b/src/api/services/__tests__/cliproxy-profile-bridge.test.ts index 7948a85e..f5b622ce 100644 --- a/src/api/services/__tests__/cliproxy-profile-bridge.test.ts +++ b/src/api/services/__tests__/cliproxy-profile-bridge.test.ts @@ -1,14 +1,13 @@ /** - * Regression tests for the `ccs api create --cliproxy-provider claude` bridge path. + * Regression tests for the `ccs api create --cliproxy-provider ` bridge path. * - * PR #1554 fixed the main CLIProxy env-builder to use the root URL for the built-in - * claude provider. This file locks the same rule on the parallel api-create bridge path + * The bridge path must use the same route rules as the main CLIProxy env-builder * (resolveCliproxyBridgeProfile / listCliproxyBridgeProviders) so both paths stay - * consistent. + * consistent across backends. * - * Background: CLIProxyAPI registers /v1/messages at the ROOT. The /api/provider/ - * prefix is a Plus-only route for non-Claude providers. Using /api/provider/claude - * returns 404 on the base CLIProxyAPI installation. + * Background: the original CLIProxyAPI backend registers Claude-compatible routes at + * the root. The /api/provider/ prefix is a Plus-only route. Using provider-scoped + * routes against the original backend returns 404. */ import * as fs from 'fs'; @@ -23,7 +22,7 @@ import { resolveCliproxyBridgeMetadata } from '../cliproxy-profile-bridge'; import { invalidateConfigCache } from '../../../config/config-loader-facade'; import { clearConfigCache } from '../../../cliproxy/config/base-config-loader'; -describe('cliproxy-profile-bridge: claude provider uses root URL', () => { +describe('cliproxy-profile-bridge: backend-aware provider route paths', () => { let tempHome: string; let originalCcsHome: string | undefined; @@ -37,12 +36,28 @@ describe('cliproxy-profile-bridge: claude provider uses root URL', () => { }); afterEach(() => { - process.env.CCS_HOME = originalCcsHome; + if (originalCcsHome !== undefined) { + process.env.CCS_HOME = originalCcsHome; + } else { + delete process.env.CCS_HOME; + } invalidateConfigCache(); clearConfigCache(); fs.rmSync(tempHome, { recursive: true, force: true }); }); + function writeBackendConfig(backend: 'original' | 'plus'): void { + const ccsDir = path.join(tempHome, '.ccs'); + fs.mkdirSync(ccsDir, { recursive: true }); + fs.writeFileSync( + path.join(ccsDir, 'config.yaml'), + ['version: 1', 'cliproxy:', ` backend: ${backend}`, ''].join('\n'), + 'utf8' + ); + invalidateConfigCache(); + clearConfigCache(); + } + // ── resolveCliproxyBridgeProfile ────────────────────────────────────────── it('resolveCliproxyBridgeProfile(claude) produces root base URL', () => { @@ -65,13 +80,20 @@ describe('cliproxy-profile-bridge: claude provider uses root URL', () => { expect(profile.models.haiku).toBe(''); }); - it('resolveCliproxyBridgeProfile(gemini) still uses scoped /api/provider path', () => { + it('resolveCliproxyBridgeProfile(gemini) uses root URL on the original backend', () => { const profile = resolveCliproxyBridgeProfile('gemini'); - expect(profile.baseUrl).toContain('/api/provider/gemini'); - expect(profile.routePath).toBe('/api/provider/gemini'); + expect(profile.baseUrl).toBe('http://127.0.0.1:8317/'); + expect(profile.routePath).toBe('/'); }); - it('resolveCliproxyBridgeProfile(codex) still uses scoped /api/provider path', () => { + it('resolveCliproxyBridgeProfile(codex) uses root URL on the original backend', () => { + const profile = resolveCliproxyBridgeProfile('codex'); + expect(profile.baseUrl).toBe('http://127.0.0.1:8317/'); + expect(profile.routePath).toBe('/'); + }); + + it('resolveCliproxyBridgeProfile(codex) uses scoped path on the plus backend', () => { + writeBackendConfig('plus'); const profile = resolveCliproxyBridgeProfile('codex'); expect(profile.baseUrl).toContain('/api/provider/codex'); expect(profile.routePath).toBe('/api/provider/codex'); @@ -87,7 +109,15 @@ describe('cliproxy-profile-bridge: claude provider uses root URL', () => { expect(claudeInfo?.routePath).not.toContain('/api/provider/claude'); }); - it('listCliproxyBridgeProviders keeps scoped routePaths for non-claude providers', () => { + it('listCliproxyBridgeProviders shows root routePaths for original-backend providers', () => { + const providers = listCliproxyBridgeProviders(); + for (const info of providers) { + expect(info.routePath).toBe('/'); + } + }); + + it('listCliproxyBridgeProviders keeps scoped routePaths for plus-backend non-claude providers', () => { + writeBackendConfig('plus'); const providers = listCliproxyBridgeProviders(); for (const info of providers) { if (info.provider === 'claude') continue; diff --git a/src/api/services/profile-writer.ts b/src/api/services/profile-writer.ts index 0c0df0e0..dc366eae 100644 --- a/src/api/services/profile-writer.ts +++ b/src/api/services/profile-writer.ts @@ -37,6 +37,7 @@ import { loadConfigSafe, mutateConfig, } from '../../config/config-loader-facade'; +import { ProfileError } from '../../errors/error-types'; /** Check if URL is an OpenRouter endpoint */ function isOpenRouterUrl(baseUrl: string): boolean { @@ -333,19 +334,27 @@ export function createCliproxyBridgeProfile( resolved.target, provider ); + const detectedBridge = resolveCliproxyBridgeMetadata({ + env: { + ANTHROPIC_BASE_URL: resolved.baseUrl, + ANTHROPIC_AUTH_TOKEN: resolved.apiKey, + }, + }); return { ...result, name: resolved.name, provider, target: resolved.target, - cliproxyBridge: - resolveCliproxyBridgeMetadata({ - env: { - ANTHROPIC_BASE_URL: resolved.baseUrl, - ANTHROPIC_AUTH_TOKEN: resolved.apiKey, - }, - }) ?? null, + cliproxyBridge: detectedBridge ?? { + provider, + providerDisplayName: resolved.providerDisplayName, + routePath: resolved.routePath, + currentBaseUrl: resolved.baseUrl, + source: resolved.source, + usesCurrentTarget: true, + usesCurrentAuthToken: true, + }, }; } @@ -361,7 +370,7 @@ export function updateApiProfileTarget( if (isUnifiedMode()) { mutateConfig((config) => { if (!config.profiles[name]) { - throw new Error(`API profile not found: ${name}`); + throw new ProfileError(`API profile not found: ${name}`, name); } if (target === 'claude') { @@ -412,7 +421,7 @@ function removeApiProfileUnified(name: string): void { const profile = config.profiles[name]; if (!profile) { - throw new Error(`API profile not found: ${name}`); + throw new ProfileError(`API profile not found: ${name}`, name); } if (profile.settings) { diff --git a/src/cliproxy/config/__tests__/env-builder-provider-url.test.ts b/src/cliproxy/config/__tests__/env-builder-provider-url.test.ts index 83bd4d72..37d8b932 100644 --- a/src/cliproxy/config/__tests__/env-builder-provider-url.test.ts +++ b/src/cliproxy/config/__tests__/env-builder-provider-url.test.ts @@ -3,6 +3,8 @@ import * as os from 'os'; import * as path from 'path'; import { afterEach, beforeEach, describe, expect, it } from 'bun:test'; import { ensureProviderSettings, getEffectiveEnvVars } from '../env-builder'; +import { invalidateConfigCache } from '../../../config/config-loader-facade'; +import { clearConfigCache } from '../base-config-loader'; interface EnvSettings { ANTHROPIC_BASE_URL: string; @@ -30,14 +32,35 @@ describe('getEffectiveEnvVars local provider URL normalization', () => { originalCcsHome = process.env.CCS_HOME; tempHome = fs.mkdtempSync(path.join(os.tmpdir(), 'ccs-env-url-')); settingsPath = path.join(tempHome, 'codex.settings.json'); + process.env.CCS_HOME = tempHome; + invalidateConfigCache(); + clearConfigCache(); }); afterEach(() => { - process.env.CCS_HOME = originalCcsHome; + if (originalCcsHome !== undefined) { + process.env.CCS_HOME = originalCcsHome; + } else { + delete process.env.CCS_HOME; + } + invalidateConfigCache(); + clearConfigCache(); fs.rmSync(tempHome, { recursive: true, force: true }); }); - it('rewrites local root URL to provider endpoint without stripping codex effort suffixes', () => { + function writeBackendConfig(backend: 'original' | 'plus'): void { + const ccsDir = path.join(tempHome, '.ccs'); + fs.mkdirSync(ccsDir, { recursive: true }); + fs.writeFileSync( + path.join(ccsDir, 'config.yaml'), + ['version: 1', 'cliproxy:', ` backend: ${backend}`, ''].join('\n'), + 'utf8' + ); + invalidateConfigCache(); + clearConfigCache(); + } + + it('keeps local root URL for the original backend without stripping codex effort suffixes', () => { writeSettings(settingsPath, { ANTHROPIC_BASE_URL: 'http://127.0.0.1:8317', ANTHROPIC_AUTH_TOKEN: 'ccs-internal-managed', @@ -48,7 +71,7 @@ describe('getEffectiveEnvVars local provider URL normalization', () => { }); const env = getEffectiveEnvVars('codex', 8317, settingsPath); - expect(env.ANTHROPIC_BASE_URL).toBe('http://127.0.0.1:8317/api/provider/codex'); + expect(env.ANTHROPIC_BASE_URL).toBe('http://127.0.0.1:8317'); expect(env.ANTHROPIC_MODEL).toBe('gpt-5.3-codex-xhigh'); expect(env.ANTHROPIC_DEFAULT_OPUS_MODEL).toBe('gpt-5.3-codex-xhigh'); expect(env.ANTHROPIC_DEFAULT_SONNET_MODEL).toBe('gpt-5.3-codex-high'); @@ -63,7 +86,37 @@ describe('getEffectiveEnvVars local provider URL normalization', () => { expect(persisted.env.ANTHROPIC_DEFAULT_HAIKU_MODEL).toBe('gpt-5.4-mini-medium'); }); - it('rewrites wrong local provider path to the requested provider', () => { + it('rewrites local root URL to provider endpoint for the plus backend', () => { + writeBackendConfig('plus'); + writeSettings(settingsPath, { + ANTHROPIC_BASE_URL: 'http://127.0.0.1:8317', + ANTHROPIC_AUTH_TOKEN: 'ccs-internal-managed', + ANTHROPIC_MODEL: 'gpt-5.3-codex-xhigh', + ANTHROPIC_DEFAULT_OPUS_MODEL: 'gpt-5.3-codex-xhigh', + ANTHROPIC_DEFAULT_SONNET_MODEL: 'gpt-5.3-codex-high', + ANTHROPIC_DEFAULT_HAIKU_MODEL: 'gpt-5.4-mini-medium', + }); + + const env = getEffectiveEnvVars('codex', 8317, settingsPath); + expect(env.ANTHROPIC_BASE_URL).toBe('http://127.0.0.1:8317/api/provider/codex'); + }); + + it('rewrites wrong local provider path to root for the original backend', () => { + writeSettings(settingsPath, { + ANTHROPIC_BASE_URL: 'http://localhost:8317/api/provider/my-codex-variant?debug=1', + ANTHROPIC_AUTH_TOKEN: 'ccs-internal-managed', + ANTHROPIC_MODEL: 'gpt-5.3-codex-xhigh', + ANTHROPIC_DEFAULT_OPUS_MODEL: 'gpt-5.3-codex-xhigh', + ANTHROPIC_DEFAULT_SONNET_MODEL: 'gpt-5.3-codex-high', + ANTHROPIC_DEFAULT_HAIKU_MODEL: 'gpt-5.4-mini-medium', + }); + + const env = getEffectiveEnvVars('codex', 8317, settingsPath); + expect(env.ANTHROPIC_BASE_URL).toBe('http://localhost:8317'); + }); + + it('rewrites wrong local provider path to the requested provider for the plus backend', () => { + writeBackendConfig('plus'); writeSettings(settingsPath, { ANTHROPIC_BASE_URL: 'http://localhost:8317/api/provider/my-codex-variant?debug=1', ANTHROPIC_AUTH_TOKEN: 'ccs-internal-managed', @@ -285,7 +338,6 @@ describe('getEffectiveEnvVars local provider URL normalization', () => { }); it('repairs existing provider settings files that are missing env keys', () => { - process.env.CCS_HOME = tempHome; const agySettingsPath = path.join(tempHome, '.ccs', 'agy.settings.json'); fs.mkdirSync(path.dirname(agySettingsPath), { recursive: true }); fs.writeFileSync( @@ -308,7 +360,7 @@ describe('getEffectiveEnvVars local provider URL normalization', () => { hooks?: Record; }; expect(repaired.hooks?.PreToolUse).toBeDefined(); - expect(repaired.env?.ANTHROPIC_BASE_URL).toBe('http://127.0.0.1:8317/api/provider/agy'); + expect(repaired.env?.ANTHROPIC_BASE_URL).toBe('http://127.0.0.1:8317'); expect(repaired.env?.ANTHROPIC_MODEL).toBeDefined(); expect(repaired.env?.ANTHROPIC_DEFAULT_OPUS_MODEL).toBeDefined(); expect(repaired.env?.ANTHROPIC_DEFAULT_SONNET_MODEL).toBeDefined(); @@ -316,7 +368,7 @@ describe('getEffectiveEnvVars local provider URL normalization', () => { }); it('imports legacy cursor settings into the dedicated provider path without reusing legacy transport auth', () => { - process.env.CCS_HOME = tempHome; + writeBackendConfig('plus'); const legacySettingsPath = path.join(tempHome, '.ccs', 'cursor.settings.json'); const providerSettingsPath = path.join( tempHome, @@ -375,7 +427,6 @@ describe('getEffectiveEnvVars local provider URL normalization', () => { }); it('migrates deprecated agy sonnet 4.6 thinking IDs during ensureProviderSettings', () => { - process.env.CCS_HOME = tempHome; const agySettingsPath = path.join(tempHome, '.ccs', 'agy.settings.json'); fs.mkdirSync(path.dirname(agySettingsPath), { recursive: true }); fs.writeFileSync( @@ -422,7 +473,6 @@ describe('getEffectiveEnvVars local provider URL normalization', () => { }); it('preserves codex effort-suffixed IDs during ensureProviderSettings', () => { - process.env.CCS_HOME = tempHome; const codexSettingsPath = path.join(tempHome, '.ccs', 'codex.settings.json'); fs.mkdirSync(path.dirname(codexSettingsPath), { recursive: true }); fs.writeFileSync( @@ -458,6 +508,7 @@ describe('getEffectiveEnvVars local provider URL normalization', () => { env?: Record; presets?: Array>; }; + expect(repaired.env?.ANTHROPIC_BASE_URL).toBe('http://127.0.0.1:8317'); expect(repaired.env?.ANTHROPIC_MODEL).toBe('gpt-5.3-codex-xhigh'); expect(repaired.env?.ANTHROPIC_DEFAULT_OPUS_MODEL).toBe('gpt-5.3-codex-xhigh'); expect(repaired.env?.ANTHROPIC_DEFAULT_SONNET_MODEL).toBe('gpt-5.3-codex-high'); @@ -469,7 +520,6 @@ describe('getEffectiveEnvVars local provider URL normalization', () => { }); it('recovers malformed provider settings files by writing defaults and backup copy', () => { - process.env.CCS_HOME = tempHome; const agySettingsPath = path.join(tempHome, '.ccs', 'agy.settings.json'); fs.mkdirSync(path.dirname(agySettingsPath), { recursive: true }); fs.writeFileSync(agySettingsPath, '{"env": {"ANTHROPIC_MODEL": "claude-sonnet-4-6-thinking",}'); diff --git a/src/cliproxy/config/env-builder.ts b/src/cliproxy/config/env-builder.ts index d99bd8f4..6cc77712 100644 --- a/src/cliproxy/config/env-builder.ts +++ b/src/cliproxy/config/env-builder.ts @@ -36,6 +36,10 @@ import { getOutputLimitsEnv, getCcsDir, } from '../../config/config-loader-facade'; +import { buildCliproxyProviderPath, buildLocalProviderBaseUrl } from './provider-route'; +import { ConfigError } from '../../errors/error-types'; + +export { buildCliproxyProviderPath, buildLocalProviderBaseUrl } from './provider-route'; /** Settings file structure for user overrides */ interface ProviderSettings { @@ -205,14 +209,12 @@ export function getModelMapping(provider: CLIProxyProvider): ProviderModelMappin /** * Get environment variables for Claude CLI (bundled defaults) - * Uses provider-specific endpoints (e.g., /api/provider/gemini) for explicit routing - * except for the built-in claude provider. + * Uses the backend-aware route path for the selected provider. * - * Root-URL exception: the claude provider always uses the CLIProxy ROOT endpoint - * (http://127.0.0.1:) instead of /api/provider/claude. CLIProxyAPI's Claude Code - * contract registers /v1/messages at the root; the /api/provider/ prefix is a Plus-only - * feature for non-Claude providers. buildCliproxyProviderPath() encodes this rule and is - * used here and by the api-create bridge path so both remain consistent. + * Root-URL rule: the original CLIProxy backend uses the root endpoint + * (http://127.0.0.1:) and routes by model. The /api/provider/ prefix is + * only used for Plus-backend non-Claude providers. buildCliproxyProviderPath() + * encodes this rule and is reused by the api-create bridge path. * * For the claude built-in provider the model env vars are intentionally omitted so that * the user's own Claude Code /model selection is honored end-to-end (model-neutral passthrough). @@ -375,25 +377,6 @@ function ensureRequiredEnvVars( /** Localhost hostnames used for local CLIProxy endpoints */ const LOCALHOST_NAMES = new Set(['127.0.0.1', 'localhost', '0.0.0.0']); -/** - * Return the CLIProxy route path for a provider. - * - * - claude uses the root path (empty string → "/" after buildProxyUrl normalises it) - * because CLIProxyAPI's Claude Code contract registers /v1/messages at the root; the - * /api/provider/ prefix is Plus-only and only for non-Claude providers. - * - all other providers use the scoped /api/provider/ path. - * - * Exported so the profile-bridge can reuse the same rule (DRY). - */ -export function buildCliproxyProviderPath(provider: CLIProxyProvider): string { - return provider === 'claude' ? '' : `/api/provider/${provider}`; -} - -function buildLocalProviderBaseUrl(provider: CLIProxyProvider, port: number): string { - const rootUrl = `http://127.0.0.1:${port}`; - return provider === 'claude' ? rootUrl : `${rootUrl}/api/provider/${provider}`; -} - /** * Normalize local CLIProxy endpoint to the expected provider route. * Only rewrites localhost URLs that target the active local port. @@ -415,11 +398,11 @@ function normalizeLocalProviderBaseUrl( : 80; if (!Number.isFinite(effectivePort) || effectivePort !== port) return baseUrl; - if (provider === 'claude') { + const expectedPath = buildCliproxyProviderPath(provider); + if (expectedPath === '') { return parsed.origin; } - const expectedPath = `/api/provider/${provider}`; if (parsed.pathname === expectedPath && !parsed.search && !parsed.hash) return baseUrl; parsed.pathname = expectedPath; @@ -437,7 +420,6 @@ function normalizeLocalProviderBaseUrl( */ function rewriteLocalhostUrls( envVars: NodeJS.ProcessEnv, - provider: CLIProxyProvider, remoteConfig: RemoteProxyRewriteConfig ): NodeJS.ProcessEnv { const result = { ...envVars }; @@ -458,10 +440,7 @@ function rewriteLocalhostUrls( const standardWebPort = normalizedProtocol === 'https' ? 443 : 80; const portSuffix = effectivePort === standardWebPort ? '' : `:${effectivePort}`; const remoteRootUrl = `${normalizedProtocol}://${remoteConfig.host}${portSuffix}`; - const remoteBaseUrl = - provider === 'claude' ? remoteRootUrl : `${remoteRootUrl}/api/provider/${provider}`; - - result.ANTHROPIC_BASE_URL = remoteBaseUrl; + result.ANTHROPIC_BASE_URL = remoteRootUrl; // Update auth token if provided if (remoteConfig.authToken) { @@ -517,7 +496,7 @@ export function getEffectiveEnvVars( envVars = ensureRequiredEnvVars(envVars, provider, port); // Apply remote rewrite if configured if (remoteRewriteConfig) { - envVars = rewriteLocalhostUrls(envVars, provider, remoteRewriteConfig); + envVars = rewriteLocalhostUrls(envVars, remoteRewriteConfig); } return envVars; } @@ -553,7 +532,7 @@ export function getEffectiveEnvVars( envVars = ensureRequiredEnvVars(envVars, provider, port); // Apply remote rewrite if configured if (remoteRewriteConfig) { - envVars = rewriteLocalhostUrls(envVars, provider, remoteRewriteConfig); + envVars = rewriteLocalhostUrls(envVars, remoteRewriteConfig); } return envVars; } @@ -696,10 +675,11 @@ export function ensureProviderSettings(provider: CLIProxyProvider): void { let parsed: Record; try { const value = JSON.parse(rawContent) as unknown; - if (!value || typeof value !== 'object' || Array.isArray(value)) { - throw new Error('settings root must be an object'); + if (value && typeof value === 'object' && !Array.isArray(value)) { + parsed = value as Record; + } else { + throw new SyntaxError('settings root must be an object'); } - parsed = value as Record; } catch { // Preserve corrupt payload for manual inspection, then recover with defaults. const backupPath = `${settingsPath}.corrupt-${Date.now()}`; @@ -744,7 +724,7 @@ export function ensureProviderSettings(provider: CLIProxyProvider): void { } } - if (provider === 'claude' && typeof mergedEnv.ANTHROPIC_BASE_URL === 'string') { + if (typeof mergedEnv.ANTHROPIC_BASE_URL === 'string') { const normalizedBaseUrl = normalizeLocalProviderBaseUrl( mergedEnv.ANTHROPIC_BASE_URL, provider, @@ -1002,7 +982,7 @@ export function getCompositeEnvVars( // If default tier is missing, we cannot proceed meaningfully if (!defaultModel) { - throw new Error(`Missing model for default tier '${defaultTier}'`); + throw new ConfigError(`Missing model for default tier '${defaultTier}'`); } // Determine base URL and auth token based on remote vs local mode diff --git a/src/cliproxy/config/provider-route.ts b/src/cliproxy/config/provider-route.ts new file mode 100644 index 00000000..0ccac5c3 --- /dev/null +++ b/src/cliproxy/config/provider-route.ts @@ -0,0 +1,42 @@ +import { loadOrCreateUnifiedConfig } from '../../config/config-loader-facade'; +import { DEFAULT_BACKEND } from '../binary/platform-detector'; +import type { CLIProxyBackend, CLIProxyProvider } from '../types'; + +export function getConfiguredCliproxyBackend(): CLIProxyBackend { + try { + const backend = loadOrCreateUnifiedConfig().cliproxy?.backend; + return backend === 'plus' || backend === 'original' ? backend : DEFAULT_BACKEND; + } catch { + return DEFAULT_BACKEND; + } +} + +export function usesScopedProviderRoutes( + backend: CLIProxyBackend = getConfiguredCliproxyBackend() +): boolean { + return backend === 'plus'; +} + +/** + * Return the CLIProxy route path for a provider. + * + * The original backend routes Claude-compatible traffic at the root and relies + * on model-based provider selection. The Plus backend exposes provider-scoped + * routes for non-Claude providers. + */ +export function buildCliproxyProviderPath( + provider: CLIProxyProvider, + backend: CLIProxyBackend = getConfiguredCliproxyBackend() +): string { + if (provider === 'claude') return ''; + return usesScopedProviderRoutes(backend) ? `/api/provider/${provider}` : ''; +} + +export function buildLocalProviderBaseUrl( + provider: CLIProxyProvider, + port: number, + backend: CLIProxyBackend = getConfiguredCliproxyBackend() +): string { + const rootUrl = `http://127.0.0.1:${port}`; + return `${rootUrl}${buildCliproxyProviderPath(provider, backend)}`; +} diff --git a/src/cliproxy/executor/__tests__/env-resolver-codex-fallback.test.ts b/src/cliproxy/executor/__tests__/env-resolver-codex-fallback.test.ts index 01fa203d..fa185182 100644 --- a/src/cliproxy/executor/__tests__/env-resolver-codex-fallback.test.ts +++ b/src/cliproxy/executor/__tests__/env-resolver-codex-fallback.test.ts @@ -1,11 +1,27 @@ import * as fs from 'fs'; import * as os from 'os'; import * as path from 'path'; -import { afterEach, describe, expect, it } from 'bun:test'; +import { afterEach, beforeEach, describe, expect, it } from 'bun:test'; import { buildClaudeEnvironment, resolveCliproxyImageAnalysisEnv } from '../env-resolver'; import type { ImageAnalysisStatus } from '../../../utils/hooks'; +import { invalidateConfigCache } from '../../../config/config-loader-facade'; +import { clearConfigCache } from '../../config/base-config-loader'; const tempDirs: string[] = []; +let originalCcsHome: string | undefined; +let configHome = ''; + +function writeBackendConfig(backend: 'original' | 'plus'): void { + const ccsDir = path.join(configHome, '.ccs'); + fs.mkdirSync(ccsDir, { recursive: true }); + fs.writeFileSync( + path.join(ccsDir, 'config.yaml'), + ['version: 1', 'cliproxy:', ` backend: ${backend}`, ''].join('\n'), + 'utf8' + ); + invalidateConfigCache(); + clearConfigCache(); +} function createCodexSettingsFile(models: { defaultModel: string; @@ -70,7 +86,23 @@ function createImageAnalysisStatus( } describe('buildClaudeEnvironment codex fallback normalization', () => { + beforeEach(() => { + originalCcsHome = process.env.CCS_HOME; + configHome = fs.mkdtempSync(path.join(os.tmpdir(), 'ccs-env-resolver-home-')); + tempDirs.push(configHome); + process.env.CCS_HOME = configHome; + invalidateConfigCache(); + clearConfigCache(); + }); + afterEach(() => { + if (originalCcsHome !== undefined) { + process.env.CCS_HOME = originalCcsHome; + } else { + delete process.env.CCS_HOME; + } + invalidateConfigCache(); + clearConfigCache(); while (tempDirs.length > 0) { const tempDir = tempDirs.pop(); if (tempDir) { @@ -99,6 +131,7 @@ describe('buildClaudeEnvironment codex fallback normalization', () => { expect(env.ANTHROPIC_DEFAULT_OPUS_MODEL).toBe('gpt-5.3-codex(xhigh)'); expect(env.ANTHROPIC_DEFAULT_SONNET_MODEL).toBe('gpt-5.3-codex(high)'); expect(env.ANTHROPIC_DEFAULT_HAIKU_MODEL).toBe('gpt-5.4-mini(medium)'); + expect(env.ANTHROPIC_BASE_URL).toBe('http://127.0.0.1:8317'); }); it('keeps codex effort aliases when reasoning proxy is active', () => { @@ -122,6 +155,27 @@ describe('buildClaudeEnvironment codex fallback normalization', () => { expect(env.ANTHROPIC_DEFAULT_OPUS_MODEL).toBe('gpt-5.3-codex-xhigh'); expect(env.ANTHROPIC_DEFAULT_SONNET_MODEL).toBe('gpt-5.3-codex-high'); expect(env.ANTHROPIC_DEFAULT_HAIKU_MODEL).toBe('gpt-5.4-mini-medium'); + expect(env.ANTHROPIC_BASE_URL).toBe('http://127.0.0.1:9444'); + }); + + it('keeps scoped codex reasoning URL when the plus backend is active', () => { + writeBackendConfig('plus'); + const settingsPath = createCodexSettingsFile({ + defaultModel: 'gpt-5.3-codex-high', + opusModel: 'gpt-5.3-codex-xhigh', + sonnetModel: 'gpt-5.3-codex-high', + haikuModel: 'gpt-5.4-mini-medium', + }); + + const env = buildClaudeEnvironment({ + provider: 'codex', + useRemoteProxy: false, + localPort: 8317, + customSettingsPath: settingsPath, + codexReasoningPort: 9444, + verbose: false, + }); + expect(env.ANTHROPIC_BASE_URL).toBe('http://127.0.0.1:9444/api/provider/codex'); }); diff --git a/src/cliproxy/executor/env-resolver.ts b/src/cliproxy/executor/env-resolver.ts index ccce224b..1883f2f1 100644 --- a/src/cliproxy/executor/env-resolver.ts +++ b/src/cliproxy/executor/env-resolver.ts @@ -39,6 +39,7 @@ import { import type { ProxyTarget } from '../proxy/proxy-target-resolver'; import { getEffectiveApiKey } from '../auth/auth-token-manager'; import { isSettings, type Settings } from '../../types/config'; +import { buildCliproxyProviderPath } from '../config/provider-route'; export interface RemoteProxyConfig { host: string; @@ -373,7 +374,10 @@ export function buildClaudeEnvironment(config: ProxyChainConfig): Record | null): string return CODEX_CLIPROXY_PROVIDER_ENV_KEY; } -function isProviderReady(provider: Record, envKey: string): boolean { +const LOCALHOST_NAMES = new Set(['127.0.0.1', 'localhost', '0.0.0.0']); + +function getEffectiveUrlPort(url: URL): number { + if (url.port) return Number.parseInt(url.port, 10); + return url.protocol === 'https:' ? 443 : 80; +} + +function isManagedLocalUrl(url: URL): boolean { + return ['http:', 'https:'].includes(url.protocol) && LOCALHOST_NAMES.has(url.hostname); +} + +function shouldUseManagedLocalBaseUrl(current: URL, expected: URL): boolean { + if (!isManagedLocalUrl(current) || !isManagedLocalUrl(expected)) { + return false; + } + + const currentPort = getEffectiveUrlPort(current); + const expectedPort = getEffectiveUrlPort(expected); + if (currentPort !== expectedPort) { + return true; + } + + const currentPath = current.pathname.replace(/\/+$/, '') || '/'; + const expectedPath = expected.pathname.replace(/\/+$/, '') || '/'; + return currentPath !== expectedPath; +} + +function resolveProviderBaseUrl( + provider: Record, + fallbackBaseUrl: string +): string { + const baseUrl = provider.base_url; + if (!isValidCodexCliproxyBaseUrl(baseUrl)) { + return fallbackBaseUrl; + } + + const trimmed = baseUrl.trim(); + try { + const current = new URL(trimmed); + const expected = new URL(fallbackBaseUrl); + if (shouldUseManagedLocalBaseUrl(current, expected)) { + return fallbackBaseUrl; + } + } catch { + return fallbackBaseUrl; + } + + return trimmed; +} + +function isProviderReady( + provider: Record, + envKey: string, + expectedBaseUrl: string +): boolean { return ( provider.name === CODEX_CLIPROXY_PROVIDER_NAME && isValidCodexCliproxyBaseUrl(provider.base_url) && + typeof provider.base_url === 'string' && + resolveProviderBaseUrl(provider, expectedBaseUrl) === provider.base_url.trim() && provider.env_key === envKey && provider.wire_api === 'responses' && provider.requires_openai_auth === false && @@ -92,17 +150,6 @@ function buildProviderConfig(baseUrl: string, envKey: string): Record, - fallbackBaseUrl: string -): string { - const baseUrl = provider.base_url; - if (isValidCodexCliproxyBaseUrl(baseUrl)) { - return baseUrl.trim(); - } - return fallbackBaseUrl; -} - function appendProviderBlock(rawText: string, baseUrl: string): string { const prefix = rawText.trimEnd(); const providerBlock = stringifyTomlObject({ @@ -135,10 +182,16 @@ export async function ensureCodexCliproxyProviderConfig( const fileProbe = await probeTomlObjectFile(configPath, 'Codex user config', displayPath); if (fileProbe.diagnostics.readError) { - throw new Error(`Cannot repair ${displayPath}: ${fileProbe.diagnostics.readError}`); + throw new ConfigError( + `Cannot repair ${displayPath}: ${fileProbe.diagnostics.readError}`, + configPath + ); } if (fileProbe.diagnostics.parseError) { - throw new Error(`Cannot repair ${displayPath}: ${fileProbe.diagnostics.parseError}`); + throw new ConfigError( + `Cannot repair ${displayPath}: ${fileProbe.diagnostics.parseError}`, + configPath + ); } const config = fileProbe.config ?? {}; @@ -148,7 +201,10 @@ export async function ensureCodexCliproxyProviderConfig( const normalizedModelAlias = normalizeTopLevelCodexModelAlias(config); if (modelProvidersValue !== undefined && !providers) { - throw new Error(`Cannot repair ${displayPath}: [model_providers] must be a table.`); + throw new ConfigError( + `Cannot repair ${displayPath}: [model_providers] must be a table.`, + configPath + ); } if (!providers || !Object.prototype.hasOwnProperty.call(providers, CODEX_CLIPROXY_PROVIDER_ID)) { @@ -174,13 +230,14 @@ export async function ensureCodexCliproxyProviderConfig( const currentProvider = asObject(providers[CODEX_CLIPROXY_PROVIDER_ID]); if (!currentProvider) { - throw new Error( - `Cannot repair ${displayPath}: [model_providers.${CODEX_CLIPROXY_PROVIDER_ID}] must be a table.` + throw new ConfigError( + `Cannot repair ${displayPath}: [model_providers.${CODEX_CLIPROXY_PROVIDER_ID}] must be a table.`, + configPath ); } const envKey = resolveProviderEnvKey(currentProvider); - const providerReady = isProviderReady(currentProvider, envKey); + const providerReady = isProviderReady(currentProvider, envKey, expectedBaseUrl); if (!providerReady) { providers[CODEX_CLIPROXY_PROVIDER_ID] = { diff --git a/tests/unit/api/cliproxy-profile-bridge.test.ts b/tests/unit/api/cliproxy-profile-bridge.test.ts index 0f35ec2b..b4b7425e 100644 --- a/tests/unit/api/cliproxy-profile-bridge.test.ts +++ b/tests/unit/api/cliproxy-profile-bridge.test.ts @@ -8,6 +8,8 @@ import { resolveCliproxyBridgeProfile, suggestCliproxyBridgeName, } from '../../../src/api/services/cliproxy-profile-bridge'; +import { invalidateConfigCache } from '../../../src/config/config-loader-facade'; +import { clearConfigCache } from '../../../src/cliproxy/config/base-config-loader'; describe('cliproxy-profile-bridge', () => { let tempHome = ''; @@ -17,6 +19,8 @@ describe('cliproxy-profile-bridge', () => { tempHome = fs.mkdtempSync(path.join(os.tmpdir(), 'ccs-cliproxy-bridge-')); originalCcsHome = process.env.CCS_HOME; process.env.CCS_HOME = tempHome; + invalidateConfigCache(); + clearConfigCache(); }); afterEach(() => { @@ -25,18 +29,32 @@ describe('cliproxy-profile-bridge', () => { } else { process.env.CCS_HOME = originalCcsHome; } + invalidateConfigCache(); + clearConfigCache(); if (tempHome && fs.existsSync(tempHome)) { fs.rmSync(tempHome, { recursive: true, force: true }); } }); - it('resolves routed profile payload for a local CLIProxy provider', () => { + function writeBackendConfig(backend: 'original' | 'plus'): void { + const ccsDir = path.join(tempHome, '.ccs'); + fs.mkdirSync(ccsDir, { recursive: true }); + fs.writeFileSync( + path.join(ccsDir, 'config.yaml'), + ['version: 1', 'cliproxy:', ` backend: ${backend}`, ''].join('\n'), + 'utf8' + ); + invalidateConfigCache(); + clearConfigCache(); + } + + it('resolves routed profile payload for a local original-backend CLIProxy provider', () => { const bridge = resolveCliproxyBridgeProfile('gemini'); expect(bridge.name).toBe('gemini-api'); - expect(bridge.baseUrl).toBe('http://127.0.0.1:8317/api/provider/gemini'); - expect(bridge.routePath).toBe('/api/provider/gemini'); + expect(bridge.baseUrl).toBe('http://127.0.0.1:8317/'); + expect(bridge.routePath).toBe('/'); expect(bridge.models.default.length).toBeGreaterThan(0); }); @@ -49,6 +67,7 @@ describe('cliproxy-profile-bridge', () => { }); it('detects CLIProxy-backed profile metadata and normalizes localhost loopback URLs', () => { + writeBackendConfig('plus'); const metadata = resolveCliproxyBridgeMetadata({ env: { ANTHROPIC_BASE_URL: 'http://localhost:8317/api/provider/gemini', diff --git a/tests/unit/targets/codex-cliproxy-provider-config.test.ts b/tests/unit/targets/codex-cliproxy-provider-config.test.ts index 8edf4edd..62792f81 100644 --- a/tests/unit/targets/codex-cliproxy-provider-config.test.ts +++ b/tests/unit/targets/codex-cliproxy-provider-config.test.ts @@ -6,24 +6,61 @@ import { buildCodexCliproxyProviderBaseUrl, ensureCodexCliproxyProviderConfig, } from '../../../src/targets/codex-cliproxy-provider-config'; +import { invalidateConfigCache } from '../../../src/config/config-loader-facade'; +import { clearConfigCache } from '../../../src/cliproxy/config/base-config-loader'; describe('codex cliproxy provider config repair', () => { let tempHome: string; let codexHome: string; let configPath: string; let env: NodeJS.ProcessEnv; + let originalCcsHome: string | undefined; beforeEach(() => { + originalCcsHome = process.env.CCS_HOME; tempHome = fs.mkdtempSync(path.join(os.tmpdir(), 'ccs-codex-provider-config-')); codexHome = path.join(tempHome, '.codex'); configPath = path.join(codexHome, 'config.toml'); env = { CODEX_HOME: codexHome } as NodeJS.ProcessEnv; + process.env.CCS_HOME = tempHome; + invalidateConfigCache(); + clearConfigCache(); }); afterEach(() => { + if (originalCcsHome !== undefined) { + process.env.CCS_HOME = originalCcsHome; + } else { + delete process.env.CCS_HOME; + } + invalidateConfigCache(); + clearConfigCache(); fs.rmSync(tempHome, { recursive: true, force: true }); }); + function writeBackendConfig(backend: 'original' | 'plus'): void { + const ccsDir = path.join(tempHome, '.ccs'); + fs.mkdirSync(ccsDir, { recursive: true }); + fs.writeFileSync( + path.join(ccsDir, 'config.yaml'), + ['version: 1', 'cliproxy:', ` backend: ${backend}`, ''].join('\n'), + 'utf8' + ); + invalidateConfigCache(); + clearConfigCache(); + } + + it('uses the original backend root URL by default', () => { + expect(buildCodexCliproxyProviderBaseUrl(8317)).toBe('http://127.0.0.1:8317'); + }); + + it('uses the plus backend scoped Codex URL when configured', () => { + writeBackendConfig('plus'); + expect(buildCodexCliproxyProviderBaseUrl(8317)).toBe( + 'http://127.0.0.1:8317/api/provider/codex' + ); + }); + it('creates the cliproxy model provider when config.toml is missing', async () => { const result = await ensureCodexCliproxyProviderConfig(8317, env); @@ -32,6 +69,7 @@ describe('codex cliproxy provider config repair', () => { const rawText = fs.readFileSync(configPath, 'utf8'); expect(rawText).toContain('[model_providers.cliproxy]'); expect(rawText).toContain('name = "CLIProxy Codex"'); + expect(rawText).toContain('base_url = "http://127.0.0.1:8317"'); expect(rawText).toContain('env_key = "CLIPROXY_API_KEY"'); expect(rawText).not.toContain('model_provider = "cliproxy"'); }); @@ -78,7 +116,7 @@ wire_api = "responses" expect(result.changed).toBe(true); expect(result.envKey).toBe('CLIPROXY_API_KEY'); const rawText = fs.readFileSync(configPath, 'utf8'); - expect(rawText).toContain('base_url = "http://localhost:8317/api/provider/codex"'); + expect(rawText).toContain('base_url = "http://127.0.0.1:9321"'); expect(rawText).toContain('env_key = "CLIPROXY_API_KEY"'); expect(rawText).toContain('requires_openai_auth = false'); expect(rawText).toContain('supports_websockets = false'); @@ -142,7 +180,29 @@ supports_websockets = false expect(fs.readFileSync(configPath, 'utf8')).toBe(rawText); }); - it('leaves a ready localhost provider unchanged', async () => { + it('repairs a stale ready localhost provider to the original backend root URL', async () => { + fs.mkdirSync(codexHome, { recursive: true }); + const rawText = `[model_providers.cliproxy] +name = "CLIProxy Codex" +base_url = "http://localhost:8317/api/provider/codex" +env_key = "CLIPROXY_API_KEY" +wire_api = "responses" +requires_openai_auth = false +supports_websockets = false +`; + fs.writeFileSync(configPath, rawText, 'utf8'); + + const result = await ensureCodexCliproxyProviderConfig(8317, env); + + expect(result.changed).toBe(true); + expect(result.envKey).toBe('CLIPROXY_API_KEY'); + const repairedText = fs.readFileSync(configPath, 'utf8'); + expect(repairedText).toContain('base_url = "http://127.0.0.1:8317"'); + expect(repairedText).not.toContain('/api/provider/codex'); + }); + + it('leaves a ready plus-backend localhost provider unchanged', async () => { + writeBackendConfig('plus'); fs.mkdirSync(codexHome, { recursive: true }); const rawText = `[model_providers.cliproxy] name = "CLIProxy Codex" @@ -188,7 +248,7 @@ supports_websockets = false [model_providers.cliproxy] name = "CLIProxy Codex" -base_url = "http://localhost:8317/api/provider/codex" +base_url = "http://127.0.0.1:8317" env_key = "CLIPROXY_API_KEY" wire_api = "responses" requires_openai_auth = false @@ -239,7 +299,7 @@ supports_websockets = false [model_providers.cliproxy] name = "CLIProxy Codex" -base_url = "http://localhost:8317/api/provider/codex" +base_url = "http://127.0.0.1:8317" env_key = "CLIPROXY_API_KEY" wire_api = "responses" requires_openai_auth = false diff --git a/tests/unit/utils/browser/browser-setup.test.ts b/tests/unit/utils/browser/browser-setup.test.ts index 755dc8d7..05530319 100644 --- a/tests/unit/utils/browser/browser-setup.test.ts +++ b/tests/unit/utils/browser/browser-setup.test.ts @@ -1,9 +1,12 @@ -import { afterEach, describe, expect, it } from 'bun:test'; +import { afterEach, beforeEach, describe, expect, it } from 'bun:test'; import { mkdtempSync, rmSync } from 'node:fs'; import { tmpdir } from 'node:os'; import { join } from 'node:path'; import type { UnifiedConfig } from '../../../../src/config/unified-config-types'; -import { runBrowserSetup, type BrowserSetupDeps } from '../../../../src/utils/browser/browser-setup'; +import { + runBrowserSetup, + type BrowserSetupDeps, +} from '../../../../src/utils/browser/browser-setup'; function createUnifiedConfig(userDataDir: string): UnifiedConfig { return { @@ -96,8 +99,36 @@ function createUnifiedConfig(userDataDir: string): UnifiedConfig { describe('browser setup', () => { let tempDir = ''; + let originalBrowserUserDataDir: string | undefined; + let originalBrowserProfileDir: string | undefined; + let originalBrowserDevtoolsPort: string | undefined; + + beforeEach(() => { + originalBrowserUserDataDir = process.env.CCS_BROWSER_USER_DATA_DIR; + originalBrowserProfileDir = process.env.CCS_BROWSER_PROFILE_DIR; + originalBrowserDevtoolsPort = process.env.CCS_BROWSER_DEVTOOLS_PORT; + delete process.env.CCS_BROWSER_USER_DATA_DIR; + delete process.env.CCS_BROWSER_PROFILE_DIR; + delete process.env.CCS_BROWSER_DEVTOOLS_PORT; + }); afterEach(() => { + if (originalBrowserUserDataDir !== undefined) { + process.env.CCS_BROWSER_USER_DATA_DIR = originalBrowserUserDataDir; + } else { + delete process.env.CCS_BROWSER_USER_DATA_DIR; + } + if (originalBrowserProfileDir !== undefined) { + process.env.CCS_BROWSER_PROFILE_DIR = originalBrowserProfileDir; + } else { + delete process.env.CCS_BROWSER_PROFILE_DIR; + } + if (originalBrowserDevtoolsPort !== undefined) { + process.env.CCS_BROWSER_DEVTOOLS_PORT = originalBrowserDevtoolsPort; + } else { + delete process.env.CCS_BROWSER_DEVTOOLS_PORT; + } + if (tempDir) { rmSync(tempDir, { recursive: true, force: true }); tempDir = ''; diff --git a/tests/unit/web-server/profile-routes-cliproxy-bridge.test.ts b/tests/unit/web-server/profile-routes-cliproxy-bridge.test.ts index d12a28d6..c98cc00a 100644 --- a/tests/unit/web-server/profile-routes-cliproxy-bridge.test.ts +++ b/tests/unit/web-server/profile-routes-cliproxy-bridge.test.ts @@ -5,6 +5,8 @@ import * as os from 'os'; import * as path from 'path'; import type { Server } from 'http'; import profileRoutes from '../../../src/web-server/routes/profile-routes'; +import { invalidateConfigCache } from '../../../src/config/config-loader-facade'; +import { clearConfigCache } from '../../../src/cliproxy/config/base-config-loader'; describe('profile-routes cliproxy bridge', () => { let server: Server; @@ -42,6 +44,8 @@ describe('profile-routes cliproxy bridge', () => { tempHome = fs.mkdtempSync(path.join(os.tmpdir(), 'ccs-profile-routes-cliproxy-bridge-')); originalCcsHome = process.env.CCS_HOME; process.env.CCS_HOME = tempHome; + invalidateConfigCache(); + clearConfigCache(); }); afterEach(() => { @@ -50,6 +54,8 @@ describe('profile-routes cliproxy bridge', () => { } else { delete process.env.CCS_HOME; } + invalidateConfigCache(); + clearConfigCache(); if (tempHome && fs.existsSync(tempHome)) { fs.rmSync(tempHome, { recursive: true, force: true });