fix(cliproxy): 按 backend 生成 provider 路由

This commit is contained in:
walker1211 committed 2026-06-20 13:40:23 +08:00
1 parent b811cacaef
commit 99ed8090b1
13 files changed
+450 -106

No files matched your search

@@ -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 <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/<x>
* 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/<x> 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;
+18 -9
View File
@@ -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) {
@@ -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<string, unknown>;
};
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<string, string>;
presets?: Array<Record<string, string>>;
};
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",}');
+20 -40
View File
@@ -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:<port>) 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:<port>) and routes by model. The /api/provider/<x> 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/<x> 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<string, unknown>;
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<string, unknown>;
} else {
throw new SyntaxError('settings root must be an object');
}
parsed = value as Record<string, unknown>;
} 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
+42
View File
@@ -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)}`;
}
@@ -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');
});
+5 -1
View File
@@ -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<string,
if (codexReasoningPort) {
// Codex reasoning proxy is the outermost layer for codex provider
finalBaseUrl = `http://127.0.0.1:${codexReasoningPort}/api/provider/codex`;
const providerPath = useRemoteProxy
? '/api/provider/codex'
: buildCliproxyProviderPath('codex');
finalBaseUrl = `http://127.0.0.1:${codexReasoningPort}${providerPath}`;
}
const effectiveEnvVars = {
+5 -3
View File
@@ -33,6 +33,7 @@ import {
import { shouldDisableCodexReasoning } from './thinking-override-resolver';
import type { CLIProxyProvider, ExecutorConfig, ResolvedProxyConfig } from '../types';
import type { ThinkingConfig } from '../../config/unified-config-types';
import { buildCliproxyProviderPath } from '../config/provider-route';
// ── Proxy constructor types (for dependency injection in tests) ───────────────
@@ -162,9 +163,10 @@ export async function buildProxyChain(context: ProxyChainContext): Promise<Proxy
stripPathPrefix,
});
codexReasoningPort = await codexReasoningProxy.start();
log(
`Codex reasoning proxy active: http://127.0.0.1:${codexReasoningPort}/api/provider/codex`
);
const providerPath = useRemoteProxy
? '/api/provider/codex'
: buildCliproxyProviderPath('codex');
log(`Codex reasoning proxy active: http://127.0.0.1:${codexReasoningPort}${providerPath}`);
} catch (error) {
const err = error as Error;
codexReasoningProxy = null;
+76 -19
View File
@@ -8,6 +8,8 @@ import {
} from '../web-server/services/compatible-cli-toml-file-service';
import { getModelMaxLevel } from '../cliproxy/model-catalog';
import { parseCodexModelTuningAlias } from '../cliproxy/ai-providers/model-id-normalizer';
import { buildLocalProviderBaseUrl } from '../cliproxy/config/provider-route';
import { ConfigError } from '../errors/error-types';
export const CCSXP_CLIPROXY_SHORTCUT_ENV = 'CCSXP_CLIPROXY_SHORTCUT';
export const CODEX_CLIPROXY_PROVIDER_ID = 'cliproxy';
@@ -37,7 +39,7 @@ function resolveCodexConfigPath(env: NodeJS.ProcessEnv = process.env): {
}
export function buildCodexCliproxyProviderBaseUrl(port: number): string {
return `http://127.0.0.1:${port}/api/provider/codex`;
return buildLocalProviderBaseUrl('codex', port);
}
export function isCcsxpCliproxyShortcut(env: NodeJS.ProcessEnv = process.env): boolean {
@@ -70,10 +72,66 @@ function resolveProviderEnvKey(provider: Record<string, unknown> | null): string
return CODEX_CLIPROXY_PROVIDER_ENV_KEY;
}
function isProviderReady(provider: Record<string, unknown>, 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<string, unknown>,
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<string, unknown>,
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<string, un
};
}
function resolveProviderBaseUrl(
provider: Record<string, unknown>,
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] = {
+22 -3
View File
@@ -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',
@@ -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
+33 -2
View File
@@ -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 = '';
@@ -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 });