mirror of
https://github.com/tiennm99/ccs.git
synced 2026-10-11 12:09:03 +00:00
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:
1 parent
81e9bdea5d
commit
49a7b9dc85
6 files changed
+293
-31
No files matched your search
@@ -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');
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -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
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -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();
|
||||||
|
|
||||||
|
|||||||
Reference in new issue
Block a user