fix(cliproxy): use root URL for Claude provider (#1554)

Built-in Claude provider now uses the CLIProxy root URL instead of /api/provider/claude (which 404'd since base CLIProxy registers /v1/messages at root). Repairs stale local Claude settings, keeps non-Claude providers on their scoped routes, and applies the same root-URL rule to the ccs api create --cliproxy-provider claude bridge path via a shared buildCliproxyProviderPath helper. Closes #1545.
This commit is contained in:
Kai (Tam Nhu) Tran authored and GitHub committed 2026-06-16 10:23:48 -04:00
1 parent 81e9bdea5d
commit 49a7b9dc85
6 files changed
+293 -31

No files matched your search

+1 -1
View File
@@ -1,6 +1,6 @@
{ {
"env": { "env": {
"ANTHROPIC_BASE_URL": "http://127.0.0.1:8317/api/provider/claude", "ANTHROPIC_BASE_URL": "http://127.0.0.1:8317",
"ANTHROPIC_AUTH_TOKEN": "ccs-internal-managed" "ANTHROPIC_AUTH_TOKEN": "ccs-internal-managed"
} }
} }
@@ -0,0 +1,131 @@
/**
* Regression tests for the `ccs api create --cliproxy-provider claude` 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
* (resolveCliproxyBridgeProfile / listCliproxyBridgeProviders) so both paths stay
* consistent.
*
* 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.
*/
import * as fs from 'fs';
import * as os from 'os';
import * as path from 'path';
import { afterEach, beforeEach, describe, expect, it } from 'bun:test';
import {
resolveCliproxyBridgeProfile,
listCliproxyBridgeProviders,
} from '../cliproxy-profile-bridge';
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', () => {
let tempHome: string;
let originalCcsHome: string | undefined;
beforeEach(() => {
originalCcsHome = process.env.CCS_HOME;
// Empty temp dir → loadOrCreateUnifiedConfig defaults to local target (127.0.0.1:8317).
tempHome = fs.mkdtempSync(path.join(os.tmpdir(), 'ccs-bridge-test-'));
process.env.CCS_HOME = tempHome;
invalidateConfigCache();
clearConfigCache();
});
afterEach(() => {
process.env.CCS_HOME = originalCcsHome;
invalidateConfigCache();
clearConfigCache();
fs.rmSync(tempHome, { recursive: true, force: true });
});
// ── resolveCliproxyBridgeProfile ──────────────────────────────────────────
it('resolveCliproxyBridgeProfile(claude) produces root base URL', () => {
const profile = resolveCliproxyBridgeProfile('claude');
// Must be the root URL — NOT /api/provider/claude.
expect(profile.baseUrl).toBe('http://127.0.0.1:8317/');
expect(profile.baseUrl).not.toContain('/api/provider/claude');
});
it('resolveCliproxyBridgeProfile(claude) reports root routePath', () => {
const profile = resolveCliproxyBridgeProfile('claude');
expect(profile.routePath).toBe('/');
});
it('resolveCliproxyBridgeProfile(claude) does not leak model pins (model-neutral)', () => {
const profile = resolveCliproxyBridgeProfile('claude');
expect(profile.models.default).toBe('');
expect(profile.models.opus).toBe('');
expect(profile.models.sonnet).toBe('');
expect(profile.models.haiku).toBe('');
});
it('resolveCliproxyBridgeProfile(gemini) still uses scoped /api/provider path', () => {
const profile = resolveCliproxyBridgeProfile('gemini');
expect(profile.baseUrl).toContain('/api/provider/gemini');
expect(profile.routePath).toBe('/api/provider/gemini');
});
it('resolveCliproxyBridgeProfile(codex) still uses scoped /api/provider path', () => {
const profile = resolveCliproxyBridgeProfile('codex');
expect(profile.baseUrl).toContain('/api/provider/codex');
expect(profile.routePath).toBe('/api/provider/codex');
});
// ── listCliproxyBridgeProviders ───────────────────────────────────────────
it('listCliproxyBridgeProviders shows root routePath for claude', () => {
const providers = listCliproxyBridgeProviders();
const claudeInfo = providers.find((p) => p.provider === 'claude');
expect(claudeInfo).toBeDefined();
expect(claudeInfo?.routePath).toBe('/');
expect(claudeInfo?.routePath).not.toContain('/api/provider/claude');
});
it('listCliproxyBridgeProviders keeps scoped routePaths for non-claude providers', () => {
const providers = listCliproxyBridgeProviders();
for (const info of providers) {
if (info.provider === 'claude') continue;
expect(info.routePath).toBe(`/api/provider/${info.provider}`);
}
});
// ── resolveCliproxyBridgeMetadata fallback behaviour under root URL ───────
//
// When a claude profile stores the fixed root URL (http://127.0.0.1:8317/),
// extractProviderFromPathname('/') returns null (no /api/provider/ segment),
// so resolveCliproxyBridgeMetadata returns null for that settings object.
// Dashboard routes fall back to mapExternalProviderName(profile.name) or the
// profile's cliproxyProvider field — both benign paths that still identify the
// provider correctly. This test locks the null-return so a future change to
// extractProviderFromPathname cannot silently introduce a regression.
it('resolveCliproxyBridgeMetadata returns null for a root-URL claude settings object (benign fallback locked)', () => {
const settings = {
env: {
ANTHROPIC_BASE_URL: 'http://127.0.0.1:8317/',
ANTHROPIC_AUTH_TOKEN: 'ccs-internal-managed',
},
};
// extractProviderFromPathname cannot identify the provider from a root path.
// The caller falls back to profile.name / cliproxyBridge from other sources.
expect(resolveCliproxyBridgeMetadata(settings)).toBeNull();
});
it('resolveCliproxyBridgeMetadata still resolves non-claude providers from scoped URL', () => {
const settings = {
env: {
ANTHROPIC_BASE_URL: 'http://127.0.0.1:8317/api/provider/gemini',
ANTHROPIC_AUTH_TOKEN: 'ccs-internal-managed',
},
};
const meta = resolveCliproxyBridgeMetadata(settings);
expect(meta).not.toBeNull();
expect(meta?.provider).toBe('gemini');
});
});
+20 -9
View File
@@ -2,6 +2,7 @@ import * as fs from 'fs';
import * as path from 'path'; import * as path from 'path';
import { buildProxyUrl, getProxyTarget } from '../../cliproxy/proxy/proxy-target-resolver'; import { buildProxyUrl, getProxyTarget } from '../../cliproxy/proxy/proxy-target-resolver';
import { buildCliproxyProviderPath } from '../../cliproxy/config/env-builder';
import { getEffectiveApiKey } from '../../cliproxy/auth/auth-token-manager'; import { getEffectiveApiKey } from '../../cliproxy/auth/auth-token-manager';
import { getModelMappingFromConfig } from '../../cliproxy/config/base-config-loader'; import { getModelMappingFromConfig } from '../../cliproxy/config/base-config-loader';
import { import {
@@ -107,13 +108,18 @@ function resolveBridgeModelMapping(provider: CLIProxyProvider): ModelMapping {
} }
export function listCliproxyBridgeProviders(): CliproxyBridgeProviderInfo[] { export function listCliproxyBridgeProviders(): CliproxyBridgeProviderInfo[] {
return CLIPROXY_PROVIDER_IDS.map((provider) => ({ return CLIPROXY_PROVIDER_IDS.map((provider) => {
provider, const providerPath = buildCliproxyProviderPath(provider);
displayName: getProviderDisplayName(provider), return {
description: getProviderDescription(provider), provider,
defaultProfileName: getDefaultCliproxyBridgeName(provider), displayName: getProviderDisplayName(provider),
routePath: `/api/provider/${provider}`, description: getProviderDescription(provider),
})); defaultProfileName: getDefaultCliproxyBridgeName(provider),
// claude uses root path (CLIProxyAPI registers /v1/messages at root);
// all other providers use their scoped /api/provider/<x> route.
routePath: providerPath === '' ? '/' : providerPath,
};
});
} }
export function resolveCliproxyBridgeProfile( export function resolveCliproxyBridgeProfile(
@@ -125,8 +131,13 @@ export function resolveCliproxyBridgeProfile(
): ResolvedCliproxyBridgeProfile { ): ResolvedCliproxyBridgeProfile {
const target = getProxyTarget(); const target = getProxyTarget();
const profileName = options.name?.trim() || suggestCliproxyBridgeName(provider); const profileName = options.name?.trim() || suggestCliproxyBridgeName(provider);
const baseUrl = buildProxyUrl(target, `/api/provider/${provider}`); // Use the shared path helper so the claude provider always resolves to the
// CLIProxy root URL (same rule as buildLocalProviderBaseUrl in env-builder).
const providerPath = buildCliproxyProviderPath(provider);
const baseUrl = buildProxyUrl(target, providerPath);
const apiKey = target.authToken ?? getEffectiveApiKey(); const apiKey = target.authToken ?? getEffectiveApiKey();
// Expose the canonical route path: root for claude, scoped path for others.
const routePath = providerPath === '' ? '/' : providerPath;
return { return {
name: profileName, name: profileName,
@@ -136,7 +147,7 @@ export function resolveCliproxyBridgeProfile(
apiKey, apiKey,
models: resolveBridgeModelMapping(provider), models: resolveBridgeModelMapping(provider),
target: options.target || 'claude', target: options.target || 'claude',
routePath: `/api/provider/${provider}`, routePath,
source: target.isRemote ? 'remote' : 'local', source: target.isRemote ? 'remote' : 'local',
}; };
} }
@@ -10,7 +10,12 @@ import * as fs from 'fs';
import * as os from 'os'; import * as os from 'os';
import * as path from 'path'; import * as path from 'path';
import { afterEach, beforeEach, describe, expect, it } from 'bun:test'; import { afterEach, beforeEach, describe, expect, it } from 'bun:test';
import { getClaudeEnvVars, ensureProviderSettings, getRemoteEnvVars } from '../env-builder'; import {
getClaudeEnvVars,
ensureProviderSettings,
getEffectiveEnvVars,
getRemoteEnvVars,
} from '../env-builder';
import { clearConfigCache } from '../base-config-loader'; import { clearConfigCache } from '../base-config-loader';
const MODEL_KEYS = [ const MODEL_KEYS = [
@@ -45,10 +50,10 @@ describe('claude provider model-neutral passthrough (Gap 1)', () => {
} }
}); });
it('still sets ANTHROPIC_BASE_URL and ANTHROPIC_AUTH_TOKEN for claude', () => { it('sets root ANTHROPIC_BASE_URL and ANTHROPIC_AUTH_TOKEN for claude', () => {
const env = getClaudeEnvVars('claude'); const env = getClaudeEnvVars('claude');
expect(env.ANTHROPIC_BASE_URL).toBe('http://127.0.0.1:8317/api/provider/claude'); expect(env.ANTHROPIC_BASE_URL).toBe('http://127.0.0.1:8317');
expect(env.ANTHROPIC_AUTH_TOKEN).toBeDefined(); expect(env.ANTHROPIC_AUTH_TOKEN).toBeDefined();
}); });
@@ -83,10 +88,57 @@ describe('claude provider model-neutral passthrough (Gap 1)', () => {
} }
// Transport keys must be present // Transport keys must be present
expect(written.env.ANTHROPIC_BASE_URL).toBe('http://127.0.0.1:8317/api/provider/claude'); expect(written.env.ANTHROPIC_BASE_URL).toBe('http://127.0.0.1:8317');
expect(written.env.ANTHROPIC_AUTH_TOKEN).toBeDefined(); expect(written.env.ANTHROPIC_AUTH_TOKEN).toBeDefined();
}); });
it('normalizes stale claude provider-scoped base URL to CLIProxy root at read level', () => {
process.env.CCS_HOME = tempHome;
const ccsDir = path.join(tempHome, '.ccs');
fs.mkdirSync(ccsDir, { recursive: true });
const settingsPath = path.join(ccsDir, 'claude.settings.json');
fs.writeFileSync(
settingsPath,
JSON.stringify({
env: {
ANTHROPIC_BASE_URL: 'http://127.0.0.1:8317/api/provider/claude',
ANTHROPIC_AUTH_TOKEN: 'ccs-internal-managed',
},
}),
'utf-8'
);
const env = getEffectiveEnvVars('claude', 8317, settingsPath);
expect(env.ANTHROPIC_BASE_URL).toBe('http://127.0.0.1:8317');
});
it('repairs stale claude provider-scoped base URL in stored default settings', () => {
process.env.CCS_HOME = tempHome;
const ccsDir = path.join(tempHome, '.ccs');
fs.mkdirSync(ccsDir, { recursive: true });
const settingsPath = path.join(ccsDir, 'claude.settings.json');
fs.writeFileSync(
settingsPath,
JSON.stringify({
env: {
ANTHROPIC_BASE_URL: 'http://127.0.0.1:8317/api/provider/claude',
ANTHROPIC_AUTH_TOKEN: 'ccs-internal-managed',
},
}),
'utf-8'
);
ensureProviderSettings('claude');
const repaired = JSON.parse(fs.readFileSync(settingsPath, 'utf-8')) as {
env: Record<string, string | undefined>;
};
expect(repaired.env.ANTHROPIC_BASE_URL).toBe('http://127.0.0.1:8317');
});
// ── Upgrade-path: existing claude.settings.json with stale default model pins ── // ── Upgrade-path: existing claude.settings.json with stale default model pins ──
it('strips stale default model pins from existing claude.settings.json on ensureProviderSettings', () => { it('strips stale default model pins from existing claude.settings.json on ensureProviderSettings', () => {
@@ -107,7 +159,7 @@ describe('claude provider model-neutral passthrough (Gap 1)', () => {
settingsPath, settingsPath,
JSON.stringify({ JSON.stringify({
env: { env: {
ANTHROPIC_BASE_URL: 'http://127.0.0.1:8317/api/provider/claude', ANTHROPIC_BASE_URL: 'http://127.0.0.1:8317',
ANTHROPIC_AUTH_TOKEN: 'ccs-internal-managed', ANTHROPIC_AUTH_TOKEN: 'ccs-internal-managed',
...stalePins, ...stalePins,
}, },
@@ -128,7 +180,7 @@ describe('claude provider model-neutral passthrough (Gap 1)', () => {
} }
// Transport keys must still be present // Transport keys must still be present
expect(repaired.env.ANTHROPIC_BASE_URL).toBe('http://127.0.0.1:8317/api/provider/claude'); expect(repaired.env.ANTHROPIC_BASE_URL).toBe('http://127.0.0.1:8317');
expect(repaired.env.ANTHROPIC_AUTH_TOKEN).toBeDefined(); expect(repaired.env.ANTHROPIC_AUTH_TOKEN).toBeDefined();
}); });
@@ -143,7 +195,7 @@ describe('claude provider model-neutral passthrough (Gap 1)', () => {
settingsPath, settingsPath,
JSON.stringify({ JSON.stringify({
env: { env: {
ANTHROPIC_BASE_URL: 'http://127.0.0.1:8317/api/provider/claude', ANTHROPIC_BASE_URL: 'http://127.0.0.1:8317',
ANTHROPIC_AUTH_TOKEN: 'ccs-internal-managed', ANTHROPIC_AUTH_TOKEN: 'ccs-internal-managed',
ANTHROPIC_MODEL: 'claude-opus-4-7', // customised — not the stale sonnet default ANTHROPIC_MODEL: 'claude-opus-4-7', // customised — not the stale sonnet default
}, },
@@ -183,7 +235,7 @@ describe('claude provider model-neutral passthrough (Gap 1)', () => {
settingsPath, settingsPath,
JSON.stringify({ JSON.stringify({
env: { env: {
ANTHROPIC_BASE_URL: 'http://127.0.0.1:8317/api/provider/claude', ANTHROPIC_BASE_URL: 'http://127.0.0.1:8317',
ANTHROPIC_AUTH_TOKEN: 'ccs-internal-managed', ANTHROPIC_AUTH_TOKEN: 'ccs-internal-managed',
...stalePins, ...stalePins,
}, },
@@ -217,7 +269,7 @@ describe('claude provider model-neutral passthrough (Gap 1)', () => {
const originalContent = JSON.stringify( const originalContent = JSON.stringify(
{ {
env: { env: {
ANTHROPIC_BASE_URL: 'http://127.0.0.1:8317/api/provider/claude', ANTHROPIC_BASE_URL: 'http://127.0.0.1:8317',
ANTHROPIC_AUTH_TOKEN: 'ccs-internal-managed', ANTHROPIC_AUTH_TOKEN: 'ccs-internal-managed',
}, },
}, },
@@ -252,7 +304,7 @@ describe('claude provider model-neutral passthrough (Gap 1)', () => {
settingsPath, settingsPath,
JSON.stringify({ JSON.stringify({
env: { env: {
ANTHROPIC_BASE_URL: 'http://127.0.0.1:8317/api/provider/claude', ANTHROPIC_BASE_URL: 'http://127.0.0.1:8317',
ANTHROPIC_AUTH_TOKEN: 'ccs-internal-managed', ANTHROPIC_AUTH_TOKEN: 'ccs-internal-managed',
ANTHROPIC_MODEL: 'claude-sonnet-4-5-20250929', ANTHROPIC_MODEL: 'claude-sonnet-4-5-20250929',
ANTHROPIC_DEFAULT_OPUS_MODEL: 'claude-opus-4-5-20251101', ANTHROPIC_DEFAULT_OPUS_MODEL: 'claude-opus-4-5-20251101',
@@ -285,7 +337,7 @@ describe('claude provider model-neutral passthrough (Gap 1)', () => {
settingsPath, settingsPath,
JSON.stringify({ JSON.stringify({
env: { env: {
ANTHROPIC_BASE_URL: 'http://127.0.0.1:8317/api/provider/claude', ANTHROPIC_BASE_URL: 'http://127.0.0.1:8317',
ANTHROPIC_AUTH_TOKEN: 'ccs-internal-managed', ANTHROPIC_AUTH_TOKEN: 'ccs-internal-managed',
ANTHROPIC_MODEL: 'claude-sonnet-4-6', ANTHROPIC_MODEL: 'claude-sonnet-4-6',
ANTHROPIC_DEFAULT_OPUS_MODEL: 'claude-opus-4-6', ANTHROPIC_DEFAULT_OPUS_MODEL: 'claude-opus-4-6',
@@ -318,7 +370,7 @@ describe('claude provider model-neutral passthrough (Gap 1)', () => {
settingsPath, settingsPath,
JSON.stringify({ JSON.stringify({
env: { env: {
ANTHROPIC_BASE_URL: 'http://127.0.0.1:8317/api/provider/claude', ANTHROPIC_BASE_URL: 'http://127.0.0.1:8317',
ANTHROPIC_AUTH_TOKEN: 'ccs-internal-managed', ANTHROPIC_AUTH_TOKEN: 'ccs-internal-managed',
ANTHROPIC_MODEL: 'claude-opus-4-8', ANTHROPIC_MODEL: 'claude-opus-4-8',
}, },
@@ -400,7 +452,7 @@ describe('claude provider model-neutral passthrough (Gap 1)', () => {
settingsPath, settingsPath,
JSON.stringify({ JSON.stringify({
env: { env: {
ANTHROPIC_BASE_URL: 'http://127.0.0.1:8317/api/provider/claude', ANTHROPIC_BASE_URL: 'http://127.0.0.1:8317',
ANTHROPIC_AUTH_TOKEN: 'ccs-internal-managed', ANTHROPIC_AUTH_TOKEN: 'ccs-internal-managed',
ANTHROPIC_MODEL: 'claude-sonnet-4-5-20250929', ANTHROPIC_MODEL: 'claude-sonnet-4-5-20250929',
ANTHROPIC_DEFAULT_OPUS_MODEL: 'claude-opus-4-5-20251101', ANTHROPIC_DEFAULT_OPUS_MODEL: 'claude-opus-4-5-20251101',
@@ -464,7 +516,7 @@ describe('claude provider model-neutral passthrough (Gap 1)', () => {
const originalContent = const originalContent =
JSON.stringify({ JSON.stringify({
env: { env: {
ANTHROPIC_BASE_URL: 'http://127.0.0.1:8317/api/provider/claude', ANTHROPIC_BASE_URL: 'http://127.0.0.1:8317',
ANTHROPIC_AUTH_TOKEN: 'ccs-internal-managed', ANTHROPIC_AUTH_TOKEN: 'ccs-internal-managed',
ANTHROPIC_MODEL: 'claude-sonnet-4-6', ANTHROPIC_MODEL: 'claude-sonnet-4-6',
ANTHROPIC_DEFAULT_OPUS_MODEL: 'claude-opus-4-7', ANTHROPIC_DEFAULT_OPUS_MODEL: 'claude-opus-4-7',
@@ -502,7 +554,7 @@ describe('claude provider model-neutral passthrough (Gap 1)', () => {
settingsPath, settingsPath,
JSON.stringify({ JSON.stringify({
env: { env: {
ANTHROPIC_BASE_URL: 'http://127.0.0.1:8317/api/provider/claude', ANTHROPIC_BASE_URL: 'http://127.0.0.1:8317',
ANTHROPIC_AUTH_TOKEN: 'ccs-internal-managed', ANTHROPIC_AUTH_TOKEN: 'ccs-internal-managed',
ANTHROPIC_MODEL: 'claude-opus-4-8', // custom — not a historical default ANTHROPIC_MODEL: 'claude-opus-4-8', // custom — not a historical default
}, },
@@ -533,7 +585,7 @@ describe('claude provider model-neutral passthrough (Gap 1)', () => {
customPath, customPath,
JSON.stringify({ JSON.stringify({
env: { env: {
ANTHROPIC_BASE_URL: 'http://127.0.0.1:8317/api/provider/claude', ANTHROPIC_BASE_URL: 'http://127.0.0.1:8317',
ANTHROPIC_AUTH_TOKEN: 'ccs-internal-managed', ANTHROPIC_AUTH_TOKEN: 'ccs-internal-managed',
ANTHROPIC_MODEL: 'claude-sonnet-4-6', // equals a stale default, but explicit ANTHROPIC_MODEL: 'claude-sonnet-4-6', // equals a stale default, but explicit
}, },
@@ -568,7 +620,7 @@ describe('claude provider model-neutral passthrough (Gap 1)', () => {
settingsPath, settingsPath,
JSON.stringify({ JSON.stringify({
env: { env: {
ANTHROPIC_BASE_URL: 'http://127.0.0.1:8317/api/provider/claude', ANTHROPIC_BASE_URL: 'http://127.0.0.1:8317',
ANTHROPIC_AUTH_TOKEN: 'ccs-internal-managed', ANTHROPIC_AUTH_TOKEN: 'ccs-internal-managed',
ANTHROPIC_MODEL: 'claude-sonnet-4-6', // equals a stale default, but post-migration = explicit ANTHROPIC_MODEL: 'claude-sonnet-4-6', // equals a stale default, but post-migration = explicit
}, },
+47 -4
View File
@@ -205,8 +205,14 @@ export function getModelMapping(provider: CLIProxyProvider): ProviderModelMappin
/** /**
* Get environment variables for Claude CLI (bundled defaults) * Get environment variables for Claude CLI (bundled defaults)
* Uses provider-specific endpoint (e.g., /api/provider/gemini) for explicit routing. * Uses provider-specific endpoints (e.g., /api/provider/gemini) for explicit routing
* This enables concurrent gemini/codex usage - each session routes to its provider via URL path. * except for the built-in claude 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.
* *
* For the claude built-in provider the model env vars are intentionally omitted so that * 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). * the user's own Claude Code /model selection is honored end-to-end (model-neutral passthrough).
@@ -231,7 +237,7 @@ export function getClaudeEnvVars(
// Core transport env vars set dynamically for all providers // Core transport env vars set dynamically for all providers
const coreEnvVars: NodeJS.ProcessEnv = { const coreEnvVars: NodeJS.ProcessEnv = {
ANTHROPIC_BASE_URL: `http://127.0.0.1:${port}/api/provider/${provider}`, ANTHROPIC_BASE_URL: buildLocalProviderBaseUrl(provider, port),
ANTHROPIC_AUTH_TOKEN: getEffectiveApiKey(), ANTHROPIC_AUTH_TOKEN: getEffectiveApiKey(),
}; };
@@ -369,6 +375,25 @@ function ensureRequiredEnvVars(
/** Localhost hostnames used for local CLIProxy endpoints */ /** Localhost hostnames used for local CLIProxy endpoints */
const LOCALHOST_NAMES = new Set(['127.0.0.1', 'localhost', '0.0.0.0']); 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. * Normalize local CLIProxy endpoint to the expected provider route.
* Only rewrites localhost URLs that target the active local port. * Only rewrites localhost URLs that target the active local port.
@@ -390,6 +415,10 @@ function normalizeLocalProviderBaseUrl(
: 80; : 80;
if (!Number.isFinite(effectivePort) || effectivePort !== port) return baseUrl; if (!Number.isFinite(effectivePort) || effectivePort !== port) return baseUrl;
if (provider === 'claude') {
return parsed.origin;
}
const expectedPath = `/api/provider/${provider}`; const expectedPath = `/api/provider/${provider}`;
if (parsed.pathname === expectedPath && !parsed.search && !parsed.hash) return baseUrl; if (parsed.pathname === expectedPath && !parsed.search && !parsed.hash) return baseUrl;
@@ -428,7 +457,9 @@ function rewriteLocalhostUrls(
// Omit port suffix for standard web ports (80/443) for cleaner URLs // Omit port suffix for standard web ports (80/443) for cleaner URLs
const standardWebPort = normalizedProtocol === 'https' ? 443 : 80; const standardWebPort = normalizedProtocol === 'https' ? 443 : 80;
const portSuffix = effectivePort === standardWebPort ? '' : `:${effectivePort}`; const portSuffix = effectivePort === standardWebPort ? '' : `:${effectivePort}`;
const remoteBaseUrl = `${normalizedProtocol}://${remoteConfig.host}${portSuffix}/api/provider/${provider}`; const remoteRootUrl = `${normalizedProtocol}://${remoteConfig.host}${portSuffix}`;
const remoteBaseUrl =
provider === 'claude' ? remoteRootUrl : `${remoteRootUrl}/api/provider/${provider}`;
result.ANTHROPIC_BASE_URL = remoteBaseUrl; result.ANTHROPIC_BASE_URL = remoteBaseUrl;
@@ -713,6 +744,18 @@ export function ensureProviderSettings(provider: CLIProxyProvider): void {
} }
} }
if (provider === 'claude' && typeof mergedEnv.ANTHROPIC_BASE_URL === 'string') {
const normalizedBaseUrl = normalizeLocalProviderBaseUrl(
mergedEnv.ANTHROPIC_BASE_URL,
provider,
CLIPROXY_DEFAULT_PORT
);
if (normalizedBaseUrl !== mergedEnv.ANTHROPIC_BASE_URL) {
mergedEnv.ANTHROPIC_BASE_URL = normalizedBaseUrl;
mutated = true;
}
}
// Canonicalize provider-specific model aliases (e.g., AGY Sonnet 4.6 thinking legacy IDs). // Canonicalize provider-specific model aliases (e.g., AGY Sonnet 4.6 thinking legacy IDs).
for (const key of MODEL_ENV_VAR_KEYS) { for (const key of MODEL_ENV_VAR_KEYS) {
const current = mergedEnv[key]; const current = mergedEnv[key];
@@ -654,6 +654,31 @@ describe('persist command Claude extension parity', () => {
expect(renderedLogs).not.toContain('Native Codex target:'); expect(renderedLogs).not.toContain('Native Codex target:');
}); });
it('persists claude CLIProxy profile with root CLIProxy base URL', async () => {
await writeUnifiedConfig();
const settingsPath = path.join(tempRoot, '.claude', 'settings.json');
await fs.promises.mkdir(path.dirname(settingsPath), { recursive: true });
const originalConsoleLog = console.log;
console.log = () => {};
try {
await withScopedHome(() => handlePersistCommand(['claude', '--yes']));
} finally {
console.log = originalConsoleLog;
}
const persisted = JSON.parse(await fs.promises.readFile(settingsPath, 'utf8')) as {
env: Record<string, string | undefined>;
};
expect(persisted.env.ANTHROPIC_BASE_URL).toBe('http://127.0.0.1:8317');
expect(persisted.env.ANTHROPIC_BASE_URL).not.toContain('/api/provider/claude');
expect(persisted.env.ANTHROPIC_AUTH_TOKEN).toBeDefined();
expect(persisted.env.ANTHROPIC_MODEL).toBeUndefined();
});
it('blocks Codex CLIProxy profiles from Claude settings persistence', async () => { it('blocks Codex CLIProxy profiles from Claude settings persistence', async () => {
await writeUnifiedConfig(); await writeUnifiedConfig();