feat(cliproxy): claude pool gap closure - model-neutral launch, shadow warning, provider ban copy

- claude provider launch env no longer pins ANTHROPIC_MODEL/tier defaults;
  one-shot migration strips CCS-written stale pins across all historically
  shipped default generations while preserving explicit user pins
- TTY-gated once-per-install warning when a user profile named claude or
  anthropic is shadowed by the built-in provider, with rename guidance
- account-safety ban messaging parameterized by provider; Anthropic
  patterns gated to claude accounts only
- first-run notice that ccs claude routes through the local CLIProxy
  instance

Part of #1464 account pools (phase 2).
This commit is contained in:
Tam Nhu Tran
2026-06-10 22:57:24 -04:00
parent 17849c4a32
commit a257016ebb
14 changed files with 1156 additions and 63 deletions
+1 -5
View File
@@ -1,10 +1,6 @@
{
"env": {
"ANTHROPIC_BASE_URL": "http://127.0.0.1:8317/api/provider/claude",
"ANTHROPIC_AUTH_TOKEN": "ccs-internal-managed",
"ANTHROPIC_MODEL": "claude-sonnet-4-6",
"ANTHROPIC_DEFAULT_OPUS_MODEL": "claude-opus-4-7",
"ANTHROPIC_DEFAULT_SONNET_MODEL": "claude-sonnet-4-6",
"ANTHROPIC_DEFAULT_HAIKU_MODEL": "claude-haiku-4-5-20251001"
"ANTHROPIC_AUTH_TOKEN": "ccs-internal-managed"
}
}
@@ -91,6 +91,12 @@ export function suggestCliproxyBridgeName(provider: CLIProxyProvider): string {
}
function resolveBridgeModelMapping(provider: CLIProxyProvider): ModelMapping {
// claude is model-neutral: model keys are absent from its base config so that
// Claude Code's own /model selection is respected end-to-end. Return empty
// strings here; createSettingsFile skips writing empty model entries.
if (provider === 'claude') {
return { default: '', opus: '', sonnet: '', haiku: '' };
}
const mapping = getModelMappingFromConfig(provider);
return {
default: mapping.defaultModel,
+18 -8
View File
@@ -109,6 +109,15 @@ function createSettingsFile(
});
const isNative = isAnthropicDirect(baseUrl, apiKey);
// Model-neutral providers (e.g. claude built-in) pass empty strings to signal
// "omit this key". Filter them out so the written settings file does not
// contain ANTHROPIC_MODEL:'' which could be treated as an unintended override.
const modelEnv = {
...(models.default.trim() ? { ANTHROPIC_MODEL: models.default } : {}),
...(models.opus.trim() ? { ANTHROPIC_DEFAULT_OPUS_MODEL: models.opus } : {}),
...(models.sonnet.trim() ? { ANTHROPIC_DEFAULT_SONNET_MODEL: models.sonnet } : {}),
...(models.haiku.trim() ? { ANTHROPIC_DEFAULT_HAIKU_MODEL: models.haiku } : {}),
};
const settings = {
env: {
// Native mode: ANTHROPIC_API_KEY only, no BASE_URL/AUTH_TOKEN
@@ -120,10 +129,7 @@ function createSettingsFile(
ANTHROPIC_AUTH_TOKEN: apiKey,
...(isOpenRouterUrl(baseUrl) && { ANTHROPIC_API_KEY: '' }),
}),
ANTHROPIC_MODEL: models.default,
ANTHROPIC_DEFAULT_OPUS_MODEL: models.opus,
ANTHROPIC_DEFAULT_SONNET_MODEL: models.sonnet,
ANTHROPIC_DEFAULT_HAIKU_MODEL: models.haiku,
...modelEnv,
...(extraModels && extraModels.length > 0
? { ANTHROPIC_EXTRA_MODELS: extraModels.join(',') }
: {}),
@@ -202,6 +208,13 @@ function createApiProfileUnified(
});
const isNative = isAnthropicDirect(baseUrl, apiKey);
// Model-neutral providers pass empty strings; omit those keys.
const modelEnvUnified = {
...(models.default.trim() ? { ANTHROPIC_MODEL: models.default } : {}),
...(models.opus.trim() ? { ANTHROPIC_DEFAULT_OPUS_MODEL: models.opus } : {}),
...(models.sonnet.trim() ? { ANTHROPIC_DEFAULT_SONNET_MODEL: models.sonnet } : {}),
...(models.haiku.trim() ? { ANTHROPIC_DEFAULT_HAIKU_MODEL: models.haiku } : {}),
};
const settings = {
env: {
...(isNative
@@ -211,10 +224,7 @@ function createApiProfileUnified(
ANTHROPIC_AUTH_TOKEN: apiKey,
...(isOpenRouterUrl(baseUrl) && { ANTHROPIC_API_KEY: '' }),
}),
ANTHROPIC_MODEL: models.default,
ANTHROPIC_DEFAULT_OPUS_MODEL: models.opus,
ANTHROPIC_DEFAULT_SONNET_MODEL: models.sonnet,
ANTHROPIC_DEFAULT_HAIKU_MODEL: models.haiku,
...modelEnvUnified,
...(extraModels && extraModels.length > 0
? { ANTHROPIC_EXTRA_MODELS: extraModels.join(',') }
: {}),
@@ -0,0 +1,133 @@
/**
* Tests for account-safety ban copy parameterization (Gap 3).
*
* Verifies that handleBanDetection uses provider-appropriate copy:
* - "Anthropic" for the claude provider
* - "Google" for gemini / agy / codex
* and that isBanResponse matches both shared and Anthropic-specific patterns.
*/
import * as fs from 'fs';
import * as os from 'os';
import * as path from 'path';
import { describe, expect, it, spyOn, afterEach, beforeEach } from 'bun:test';
import { isBanResponse, handleBanDetection } from '../accounts/account-safety';
// ── isBanResponse ─────────────────────────────────────────────────────────────
describe('isBanResponse', () => {
it.each([
'disabled in this account',
'violation of terms of service',
'account has been disabled',
'account is disabled',
'account has been suspended',
'account has been banned',
])('matches shared ban pattern for all providers: %s', (pattern) => {
expect(isBanResponse(pattern)).toBe(true);
expect(isBanResponse(pattern.toUpperCase())).toBe(true);
// Also matches when provider is specified
expect(isBanResponse(pattern, 'gemini')).toBe(true);
expect(isBanResponse(pattern, 'claude')).toBe(true);
});
it.each(['your account has been blocked', 'account is blocked'])(
'matches Anthropic-specific ban pattern for claude provider: %s',
(pattern) => {
expect(isBanResponse(pattern, 'claude')).toBe(true);
}
);
it.each(['your account has been blocked', 'account is blocked'])(
'does NOT match Anthropic-specific ban pattern for non-claude providers: %s',
(pattern) => {
expect(isBanResponse(pattern, 'gemini')).toBe(false);
expect(isBanResponse(pattern, 'agy')).toBe(false);
expect(isBanResponse(pattern, 'codex')).toBe(false);
// Without provider argument also should not match
expect(isBanResponse(pattern)).toBe(false);
}
);
it('does not match bare "usage policy" substring for any provider (avoids false positives)', () => {
// A rate-limit message mentioning "usage policy" should not trigger ban
const msg = 'Request exceeds usage policy limits for your plan';
expect(isBanResponse(msg)).toBe(false);
expect(isBanResponse(msg, 'gemini')).toBe(false);
expect(isBanResponse(msg, 'claude')).toBe(false);
});
it('returns false for a benign error message', () => {
expect(isBanResponse('rate limit exceeded')).toBe(false);
expect(isBanResponse('network timeout')).toBe(false);
expect(isBanResponse('')).toBe(false);
});
});
// ── handleBanDetection ban copy ────────────────────────────────────────────────
describe('handleBanDetection ban copy', () => {
const stderrLines: string[] = [];
let writeSpy: ReturnType<typeof spyOn>;
let consoleSpy: ReturnType<typeof spyOn>;
let tempHome: string;
let originalCcsHome: string | undefined;
beforeEach(() => {
originalCcsHome = process.env.CCS_HOME;
tempHome = fs.mkdtempSync(path.join(os.tmpdir(), 'ccs-ban-copy-'));
process.env.CCS_HOME = tempHome;
});
const setup = () => {
stderrLines.length = 0;
writeSpy = spyOn(process.stderr, 'write').mockImplementation(
(chunk: string | Uint8Array): boolean => {
stderrLines.push(typeof chunk === 'string' ? chunk : '');
return true;
}
);
// Also capture console.error calls
consoleSpy = spyOn(console, 'error').mockImplementation((...args: unknown[]) => {
stderrLines.push(String(args[0] ?? ''));
});
};
afterEach(() => {
writeSpy?.mockRestore();
consoleSpy?.mockRestore();
process.env.CCS_HOME = originalCcsHome;
fs.rmSync(tempHome, { recursive: true, force: true });
});
it('uses "Anthropic" actor copy for the claude provider', () => {
setup();
handleBanDetection('claude', 'test@example.com', 'account has been disabled');
const output = stderrLines.join(' ');
expect(output).toContain('Anthropic');
expect(output).not.toContain('Google');
});
it('uses "Google" actor copy for the gemini provider', () => {
setup();
handleBanDetection('gemini', 'test@example.com', 'account has been disabled');
const output = stderrLines.join(' ');
expect(output).toContain('Google');
expect(output).not.toContain('Anthropic');
});
it('uses "Google" actor copy for the agy provider', () => {
setup();
handleBanDetection('agy', 'test@example.com', 'account has been banned');
const output = stderrLines.join(' ');
expect(output).toContain('Google');
});
it('returns false for a non-ban error (no actor copy emitted)', () => {
setup();
const result = handleBanDetection('claude', 'test@example.com', 'rate limit exceeded');
expect(result).toBe(false);
// No ban message written
expect(stderrLines.join(' ')).not.toContain('Anthropic');
});
});
@@ -0,0 +1,214 @@
/**
* Tests for claude-shadow-warning.ts
*
* Gap 2: shadow warning — emitted once when a user profile named 'claude' or
* 'anthropic' is present; not repeated; not emitted without a collision.
* Gap 4: routing notice — emitted once on first claude provider launch.
*
* These tests use a temp CCS_HOME and a non-TTY stderr mock to confirm
* write behaviour without relying on an interactive terminal.
*/
import * as fs from 'fs';
import * as os from 'os';
import * as path from 'path';
import { afterEach, beforeEach, describe, expect, it, spyOn } from 'bun:test';
// Module under test — imported after CCS_HOME is set up in beforeEach via dynamic re-import.
// Because Bun caches modules, we test the exported functions in isolation by
// controlling the file-system state they depend on.
import { maybeWarnClaudeShadow, maybeShowClaudeRoutingNotice } from '../claude-shadow-warning';
// ── Helpers ───────────────────────────────────────────────────────────────────
function writeLegacyConfig(ccsDir: string, profiles: Record<string, string>): void {
const configPath = path.join(ccsDir, 'config.json');
fs.mkdirSync(ccsDir, { recursive: true });
fs.writeFileSync(configPath, JSON.stringify({ profiles }), 'utf-8');
}
function markerDir(ccsDir: string): string {
return path.join(ccsDir, 'cliproxy');
}
function shadowMarker(ccsDir: string): string {
return path.join(markerDir(ccsDir), '.claude-shadow-warned');
}
function routingMarker(ccsDir: string): string {
return path.join(markerDir(ccsDir), '.claude-routing-noticed');
}
// ── Test setup ────────────────────────────────────────────────────────────────
let tempHome: string;
let originalCcsHome: string | undefined;
let stderrLines: string[];
let stderrSpy: ReturnType<typeof spyOn>;
let originalIsTTYDescriptor: PropertyDescriptor | undefined;
beforeEach(() => {
originalCcsHome = process.env.CCS_HOME;
tempHome = fs.mkdtempSync(path.join(os.tmpdir(), 'ccs-shadow-warn-'));
process.env.CCS_HOME = tempHome;
// Collect stderr writes without printing them
stderrLines = [];
stderrSpy = spyOn(process.stderr, 'write').mockImplementation(
(chunk: string | Uint8Array): boolean => {
stderrLines.push(typeof chunk === 'string' ? chunk : '');
return true;
}
);
// Save exact isTTY property descriptor so afterEach can restore it precisely
originalIsTTYDescriptor = Object.getOwnPropertyDescriptor(process.stderr, 'isTTY');
// Simulate a TTY so the TTY guard does not short-circuit
Object.defineProperty(process.stderr, 'isTTY', { value: true, configurable: true });
});
afterEach(() => {
process.env.CCS_HOME = originalCcsHome;
fs.rmSync(tempHome, { recursive: true, force: true });
stderrSpy.mockRestore();
// Restore isTTY to exactly what it was before the test
if (originalIsTTYDescriptor !== undefined) {
Object.defineProperty(process.stderr, 'isTTY', originalIsTTYDescriptor);
} else {
delete (process.stderr as { isTTY?: boolean }).isTTY;
}
});
// ── Shadow warning (Gap 2) ────────────────────────────────────────────────────
describe('maybeWarnClaudeShadow', () => {
it('does not emit a warning when no shadowed profiles exist', () => {
const ccsDir = path.join(tempHome, '.ccs');
writeLegacyConfig(ccsDir, { myprofile: '/some/path' });
maybeWarnClaudeShadow();
expect(stderrLines.join('')).not.toContain('shadowed');
expect(stderrLines.join('')).not.toContain('claude');
});
it('emits a warning when a profile named "claude" exists', () => {
const ccsDir = path.join(tempHome, '.ccs');
writeLegacyConfig(ccsDir, { claude: '/path/to/claude.settings.json' });
maybeWarnClaudeShadow();
const output = stderrLines.join('');
expect(output).toContain('claude');
expect(output).toContain('shadowed');
// Ensure warn() prefix is not doubled ([!] [!] ...)
expect(output).not.toMatch(/\[!\]\s*\[!\]/);
});
it('emits a warning when a profile named "anthropic" exists', () => {
const ccsDir = path.join(tempHome, '.ccs');
writeLegacyConfig(ccsDir, { anthropic: '/path/to/anthropic.settings.json' });
maybeWarnClaudeShadow();
const output = stderrLines.join('');
expect(output).toContain('anthropic');
expect(output).toContain('shadowed');
// Ensure warn() prefix is not doubled
expect(output).not.toMatch(/\[!\]\s*\[!\]/);
});
it('creates the dismissal marker after warning', () => {
const ccsDir = path.join(tempHome, '.ccs');
writeLegacyConfig(ccsDir, { claude: '/path/to/settings.json' });
maybeWarnClaudeShadow();
expect(fs.existsSync(shadowMarker(ccsDir))).toBe(true);
});
it('does not repeat the warning when the marker already exists', () => {
const ccsDir = path.join(tempHome, '.ccs');
writeLegacyConfig(ccsDir, { claude: '/path/to/settings.json' });
// Pre-create marker
const mDir = markerDir(ccsDir);
fs.mkdirSync(mDir, { recursive: true });
fs.writeFileSync(shadowMarker(ccsDir), 'already-shown');
maybeWarnClaudeShadow();
const output = stderrLines.join('');
expect(output).not.toContain('shadowed');
});
it('does not warn when stderr is not a TTY', () => {
Object.defineProperty(process.stderr, 'isTTY', { value: false, configurable: true });
const ccsDir = path.join(tempHome, '.ccs');
writeLegacyConfig(ccsDir, { claude: '/path/to/settings.json' });
maybeWarnClaudeShadow();
expect(stderrLines.join('')).not.toContain('shadowed');
});
it('emits a warning when an account profile named "claude" exists in profiles.json (legacy mode)', () => {
const ccsDir = path.join(tempHome, '.ccs');
fs.mkdirSync(ccsDir, { recursive: true });
// Write an empty settings config (no settings profiles named claude)
writeLegacyConfig(ccsDir, { myprofile: '/some/path' });
// Write profiles.json with an account profile named 'claude'
const profilesData = {
version: '2.0.0',
profiles: { claude: { type: 'account', created: new Date().toISOString(), last_used: null } },
default: null,
};
fs.writeFileSync(path.join(ccsDir, 'profiles.json'), JSON.stringify(profilesData), 'utf-8');
maybeWarnClaudeShadow();
const output = stderrLines.join('');
expect(output).toContain('claude');
expect(output).toContain('shadowed');
});
});
// ── Routing notice (Gap 4) ────────────────────────────────────────────────────
describe('maybeShowClaudeRoutingNotice', () => {
it('emits routing notice on first call', () => {
maybeShowClaudeRoutingNotice();
const output = stderrLines.join('');
expect(output).toContain('CLIProxy');
// Ensure info() prefix is not doubled ([i] [i] ...)
expect(output).not.toMatch(/\[i\]\s*\[i\]/);
});
it('creates the routing notice marker after first call', () => {
const ccsDir = path.join(tempHome, '.ccs');
maybeShowClaudeRoutingNotice();
expect(fs.existsSync(routingMarker(ccsDir))).toBe(true);
});
it('does not repeat the routing notice when the marker already exists', () => {
const ccsDir = path.join(tempHome, '.ccs');
const mDir = markerDir(ccsDir);
fs.mkdirSync(mDir, { recursive: true });
fs.writeFileSync(routingMarker(ccsDir), 'already-shown');
maybeShowClaudeRoutingNotice();
// No output at all
expect(stderrLines.join('')).not.toContain('CLIProxy');
});
it('does not emit when stderr is not a TTY', () => {
Object.defineProperty(process.stderr, 'isTTY', { value: false, configurable: true });
maybeShowClaudeRoutingNotice();
expect(stderrLines.join('')).not.toContain('CLIProxy');
});
});
+24 -6
View File
@@ -568,8 +568,9 @@ export function restoreAutoPausedAccounts(provider: CLIProxyProvider): void {
saveAutoPaused(data);
}
// Error patterns that indicate Google has disabled/banned an account
const BAN_PATTERNS = [
// Error patterns that indicate a provider has disabled/banned an account.
// Shared patterns apply to all providers (Google and Anthropic OAuth flows).
const SHARED_BAN_PATTERNS = [
'disabled in this account',
'violation of terms of service',
'account has been disabled',
@@ -578,12 +579,28 @@ const BAN_PATTERNS = [
'account has been banned',
];
// Anthropic-specific disable patterns. Only applied when provider === 'claude'
// to avoid false-positive auto-pause on Google/Codex errors that may reference
// "policy" in rate-limit or scope messages.
const ANTHROPIC_BAN_PATTERNS = ['your account has been blocked', 'account is blocked'];
/**
* Check if an error message indicates an account ban/disable.
* Pass the provider so Anthropic-only patterns cannot trip Google providers.
*/
export function isBanResponse(errorMessage: string): boolean {
export function isBanResponse(errorMessage: string, provider?: CLIProxyProvider): boolean {
const lower = errorMessage.toLowerCase();
return BAN_PATTERNS.some((pattern) => lower.includes(pattern));
if (SHARED_BAN_PATTERNS.some((pattern) => lower.includes(pattern))) return true;
if (provider === 'claude' && ANTHROPIC_BAN_PATTERNS.some((pattern) => lower.includes(pattern))) {
return true;
}
return false;
}
/** Return the actor name (Google, Anthropic, etc.) for ban copy. */
function banActor(provider: CLIProxyProvider): string {
if (provider === 'claude') return 'Anthropic';
return 'Google';
}
/**
@@ -595,10 +612,11 @@ export function handleBanDetection(
accountId: string,
errorMessage: string
): boolean {
if (!isBanResponse(errorMessage)) return false;
if (!isBanResponse(errorMessage, provider)) return false;
const actor = banActor(provider);
console.error('');
console.error(warn('Account safety: account appears disabled by Google'));
console.error(warn(`Account safety: account appears disabled by ${actor}`));
console.error(` Account "${maskEmail(accountId)}" (${provider}) returned:`);
console.error(` "${truncate(errorMessage, 120)}"`);
console.error('');
+179
View File
@@ -0,0 +1,179 @@
/**
* Claude built-in provider — shadow warning and first-run routing notice
*
* Shadow warning
* --------------
* The claude and anthropic provider names are reserved built-ins (priority 0.5).
* A user who has a settings profile or account profile named 'claude' or 'anthropic'
* will silently get the built-in rather than their own profile. This module emits
* a one-line, TTY-only, once-per-install warning with a rename hint in that case.
*
* First-run routing notice
* ------------------------
* On the very first launch of `ccs claude` a one-line notice is printed to stderr
* reminding the user that traffic routes through the local CLIProxy instance and
* that bare `ccs` still launches native Claude Code.
*
* Persistence: marker files inside ~/.ccs/cliproxy/ are used for both notices.
*/
import * as fs from 'fs';
import * as path from 'path';
import { warn, info } from '../utils/ui';
import {
getCcsDir,
loadOrCreateUnifiedConfig,
isUnifiedMode,
} from '../config/config-loader-facade';
import { readConfig } from '../utils/config-manager';
import { ProfileRegistry } from '../auth/profile-registry';
/** Marker file name inside ~/.ccs/cliproxy/ */
const SHADOW_WARNED_MARKER = '.claude-shadow-warned';
/** Names that the claude built-in shadows */
const SHADOWED_NAMES = new Set(['claude', 'anthropic']);
/** Get path to the once-per-install dismissal marker. */
function getShadowWarnedMarkerPath(): string {
return path.join(getCcsDir(), 'cliproxy', SHADOW_WARNED_MARKER);
}
/** Return true if this install has already shown the shadow warning. */
function shadowWarnAlreadyShown(): boolean {
try {
return fs.existsSync(getShadowWarnedMarkerPath());
} catch {
return true; // If we can't read, skip warning.
}
}
/** Persist the warning dismissal. */
function markShadowWarnShown(): void {
try {
const dir = path.join(getCcsDir(), 'cliproxy');
fs.mkdirSync(dir, { recursive: true });
fs.writeFileSync(getShadowWarnedMarkerPath(), new Date().toISOString(), {
encoding: 'utf8',
flag: 'w',
});
} catch {
// Best-effort — failure to persist is not fatal.
}
}
/**
* Check if the user has a settings or account profile named 'claude' or 'anthropic'
* that is being shadowed by the built-in provider.
*/
function detectShadowedProfile(): string | null {
try {
if (isUnifiedMode()) {
const unified = loadOrCreateUnifiedConfig();
const profileNames = Object.keys(unified.profiles || {});
const accountNames = Object.keys(unified.accounts || {});
const variantNames = Object.keys(unified.cliproxy?.variants || {});
for (const name of [...profileNames, ...accountNames, ...variantNames]) {
if (SHADOWED_NAMES.has(name.toLowerCase())) return name;
}
} else {
const config = readConfig();
const profileNames = Object.keys(config.profiles || {});
const cliproxyNames = Object.keys(config.cliproxy || {});
// Also check account-based profiles from profiles.json (mirror unified accounts check).
const registry = new ProfileRegistry();
const accountProfileNames = registry.listProfiles();
for (const name of [...profileNames, ...cliproxyNames, ...accountProfileNames]) {
if (SHADOWED_NAMES.has(name.toLowerCase())) return name;
}
}
} catch {
// If config is unreadable, skip the check silently.
}
return null;
}
/**
* Emit the shadow warning if conditions are met.
*
* Conditions:
* - Running in a TTY (no warning in pipes/CI)
* - Warning not already shown for this install
* - User has a profile named 'claude' or 'anthropic' that the built-in shadows
*
* Writes to stderr so it does not pollute stdout piped output.
*/
export function maybeWarnClaudeShadow(): void {
// TTY guard — no warning in non-interactive or CI environments
if (!process.stderr.isTTY) return;
if (shadowWarnAlreadyShown()) return;
const shadowedName = detectShadowedProfile();
if (!shadowedName) return;
markShadowWarnShown();
process.stderr.write('\n');
process.stderr.write(
warn(
`Profile '${shadowedName}' is shadowed by the built-in claude provider and cannot be reached via 'ccs ${shadowedName}'.`
) + '\n'
);
process.stderr.write(
` Rename it to continue using it: ccs config (or edit ~/.ccs/config.yaml / config.json)\n`
);
process.stderr.write('\n');
}
// ── First-run routing notice ──────────────────────────────────────────────────
/** Marker file for the once-per-install routing notice. */
const ROUTING_NOTICE_MARKER = '.claude-routing-noticed';
function getRoutingNoticeMarkerPath(): string {
return path.join(getCcsDir(), 'cliproxy', ROUTING_NOTICE_MARKER);
}
function routingNoticeAlreadyShown(): boolean {
try {
return fs.existsSync(getRoutingNoticeMarkerPath());
} catch {
return true;
}
}
function markRoutingNoticeShown(): void {
try {
const dir = path.join(getCcsDir(), 'cliproxy');
fs.mkdirSync(dir, { recursive: true });
fs.writeFileSync(getRoutingNoticeMarkerPath(), new Date().toISOString(), {
encoding: 'utf8',
flag: 'w',
});
} catch {
// Best-effort.
}
}
/**
* Print a one-time routing notice when the claude built-in provider first launches.
*
* Informs the user that traffic is routed through the local CLIProxy instance
* (not the Anthropic API directly) and that bare `ccs` still uses native Claude Code.
*
* TTY-only; written to stderr.
*/
export function maybeShowClaudeRoutingNotice(): void {
if (!process.stderr.isTTY) return;
if (routingNoticeAlreadyShown()) return;
markRoutingNoticeShown();
process.stderr.write(
info('ccs claude: traffic routes through the local CLIProxy instance.') + '\n'
);
process.stderr.write(
` Native Claude Code (direct Anthropic API) is still available via bare \`ccs\`.\n`
);
}
@@ -0,0 +1,383 @@
/**
* Gap 1: claude provider is model-neutral (no ANTHROPIC_MODEL pins in env output).
* Snapshot test proving:
* - getClaudeEnvVars('claude') emits no model env vars
* - getClaudeEnvVars('gemini') still emits model env vars (no spillover)
* - ensureProviderSettings does not write model pins for claude
*/
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 { getClaudeEnvVars, ensureProviderSettings } from '../env-builder';
import { clearConfigCache } from '../base-config-loader';
const MODEL_KEYS = [
'ANTHROPIC_MODEL',
'ANTHROPIC_DEFAULT_OPUS_MODEL',
'ANTHROPIC_DEFAULT_SONNET_MODEL',
'ANTHROPIC_DEFAULT_HAIKU_MODEL',
] as const;
describe('claude provider model-neutral passthrough (Gap 1)', () => {
let tempHome: string;
let originalCcsHome: string | undefined;
beforeEach(() => {
originalCcsHome = process.env.CCS_HOME;
tempHome = fs.mkdtempSync(path.join(os.tmpdir(), 'ccs-claude-neutral-'));
process.env.CCS_HOME = tempHome;
clearConfigCache();
});
afterEach(() => {
process.env.CCS_HOME = originalCcsHome;
fs.rmSync(tempHome, { recursive: true, force: true });
clearConfigCache();
});
it('emits no model env vars for the claude provider', () => {
const env = getClaudeEnvVars('claude');
for (const key of MODEL_KEYS) {
expect(env[key]).toBeUndefined();
}
});
it('still sets ANTHROPIC_BASE_URL and ANTHROPIC_AUTH_TOKEN for claude', () => {
const env = getClaudeEnvVars('claude');
expect(env.ANTHROPIC_BASE_URL).toBe('http://127.0.0.1:8317/api/provider/claude');
expect(env.ANTHROPIC_AUTH_TOKEN).toBeDefined();
});
it('still emits model env vars for gemini provider (no spillover)', () => {
const env = getClaudeEnvVars('gemini');
for (const key of MODEL_KEYS) {
expect(typeof env[key]).toBe('string');
expect((env[key] as string).length).toBeGreaterThan(0);
}
});
it('ensureProviderSettings does not write model pins for claude', () => {
process.env.CCS_HOME = tempHome;
const ccsDir = path.join(tempHome, '.ccs');
fs.mkdirSync(ccsDir, { recursive: true });
ensureProviderSettings('claude');
// Non-cursor providers use the legacy top-level path: ~/.ccs/claude.settings.json
const settingsPath = path.join(ccsDir, 'claude.settings.json');
expect(fs.existsSync(settingsPath)).toBe(true);
const written = JSON.parse(fs.readFileSync(settingsPath, 'utf-8')) as {
env: Record<string, string | undefined>;
};
for (const key of MODEL_KEYS) {
// Model keys must not be present (or must be undefined/empty)
const value = written.env[key];
expect(!value || value.trim().length === 0).toBe(true);
}
// 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_AUTH_TOKEN).toBeDefined();
});
// ── Upgrade-path: existing claude.settings.json with stale default model pins ──
it('strips stale default model pins from existing claude.settings.json on ensureProviderSettings', () => {
process.env.CCS_HOME = tempHome;
const ccsDir = path.join(tempHome, '.ccs');
fs.mkdirSync(ccsDir, { recursive: true });
// Simulate a user who ran `ccs claude` before the model-neutral change.
// These are the exact default values that were auto-written by older CCS.
const stalePins = {
ANTHROPIC_MODEL: 'claude-sonnet-4-6',
ANTHROPIC_DEFAULT_OPUS_MODEL: 'claude-opus-4-7',
ANTHROPIC_DEFAULT_SONNET_MODEL: 'claude-sonnet-4-6',
ANTHROPIC_DEFAULT_HAIKU_MODEL: 'claude-haiku-4-5-20251001',
};
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',
...stalePins,
},
}),
'utf-8'
);
ensureProviderSettings('claude');
const repaired = JSON.parse(fs.readFileSync(settingsPath, 'utf-8')) as {
env: Record<string, string | undefined>;
};
// All stale pins must be removed
for (const key of MODEL_KEYS) {
const value = repaired.env[key];
expect(!value || value.trim().length === 0).toBe(true);
}
// 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_AUTH_TOKEN).toBeDefined();
});
it('preserves user-customised model pin that differs from the stale default', () => {
process.env.CCS_HOME = tempHome;
const ccsDir = path.join(tempHome, '.ccs');
fs.mkdirSync(ccsDir, { recursive: true });
const settingsPath = path.join(ccsDir, 'claude.settings.json');
// User has customised ANTHROPIC_MODEL to a non-default value
fs.writeFileSync(
settingsPath,
JSON.stringify({
env: {
ANTHROPIC_BASE_URL: 'http://127.0.0.1:8317/api/provider/claude',
ANTHROPIC_AUTH_TOKEN: 'ccs-internal-managed',
ANTHROPIC_MODEL: 'claude-opus-4-7', // customised — not the stale sonnet default
},
}),
'utf-8'
);
ensureProviderSettings('claude');
const result = JSON.parse(fs.readFileSync(settingsPath, 'utf-8')) as {
env: Record<string, string | undefined>;
};
// Custom value must be preserved
expect(result.env.ANTHROPIC_MODEL).toBe('claude-opus-4-7');
});
// ── One-shot migration guard (launch N+1) ─────────────────────────────────────
it('stale-pin migration runs at most once (marker prevents re-strip on launch N+1)', () => {
process.env.CCS_HOME = tempHome;
const ccsDir = path.join(tempHome, '.ccs');
fs.mkdirSync(ccsDir, { recursive: true });
// Pre-place the migration marker so migration is considered done.
const markerDir = path.join(ccsDir, 'cliproxy');
fs.mkdirSync(markerDir, { recursive: true });
fs.writeFileSync(path.join(markerDir, '.claude-model-migrated'), new Date().toISOString());
// Write a settings file that contains stale default pins.
const stalePins = {
ANTHROPIC_MODEL: 'claude-sonnet-4-6',
ANTHROPIC_DEFAULT_OPUS_MODEL: 'claude-opus-4-7',
};
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',
...stalePins,
},
}),
'utf-8'
);
ensureProviderSettings('claude');
const result = JSON.parse(fs.readFileSync(settingsPath, 'utf-8')) as {
env: Record<string, string | undefined>;
};
// Migration already marked done — stale pins must NOT be stripped again.
// This proves a user re-pin that equals a stale default value survives on launch N+1.
expect(result.env.ANTHROPIC_MODEL).toBe('claude-sonnet-4-6');
expect(result.env.ANTHROPIC_DEFAULT_OPUS_MODEL).toBe('claude-opus-4-7');
});
it('settings file without ANTHROPIC_MODEL triggers no rewrite after migration marker set', () => {
process.env.CCS_HOME = tempHome;
const ccsDir = path.join(tempHome, '.ccs');
fs.mkdirSync(ccsDir, { recursive: true });
// Pre-place the migration marker.
const markerDir = path.join(ccsDir, 'cliproxy');
fs.mkdirSync(markerDir, { recursive: true });
fs.writeFileSync(path.join(markerDir, '.claude-model-migrated'), new Date().toISOString());
const settingsPath = path.join(ccsDir, 'claude.settings.json');
const originalContent = JSON.stringify(
{
env: {
ANTHROPIC_BASE_URL: 'http://127.0.0.1:8317/api/provider/claude',
ANTHROPIC_AUTH_TOKEN: 'ccs-internal-managed',
},
},
null,
2
);
fs.writeFileSync(settingsPath, originalContent + '\n', 'utf-8');
const statBefore = fs.statSync(settingsPath);
ensureProviderSettings('claude');
const statAfter = fs.statSync(settingsPath);
// mtime should not change — no rewrite triggered
expect(statAfter.mtimeMs).toBe(statBefore.mtimeMs);
const result = JSON.parse(fs.readFileSync(settingsPath, 'utf-8')) as {
env: Record<string, string | undefined>;
};
// ANTHROPIC_MODEL must still be absent
expect(result.env.ANTHROPIC_MODEL).toBeUndefined();
});
// ── Historical-default set coverage ──────────────────────────────────────────
it('strips gen-2 pins (sonnet-4-5-20250929 / opus-4-5-20251101) on migration', () => {
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',
ANTHROPIC_MODEL: 'claude-sonnet-4-5-20250929',
ANTHROPIC_DEFAULT_OPUS_MODEL: 'claude-opus-4-5-20251101',
ANTHROPIC_DEFAULT_SONNET_MODEL: 'claude-sonnet-4-5-20250929',
ANTHROPIC_DEFAULT_HAIKU_MODEL: 'claude-haiku-4-5-20251001',
},
}),
'utf-8'
);
ensureProviderSettings('claude');
const result = JSON.parse(fs.readFileSync(settingsPath, 'utf-8')) as {
env: Record<string, string | undefined>;
};
for (const key of MODEL_KEYS) {
const value = result.env[key];
expect(!value || value.trim().length === 0).toBe(true);
}
});
it('strips gen-3 mixed pin (opus-4-6) on migration', () => {
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',
ANTHROPIC_MODEL: 'claude-sonnet-4-6',
ANTHROPIC_DEFAULT_OPUS_MODEL: 'claude-opus-4-6',
ANTHROPIC_DEFAULT_SONNET_MODEL: 'claude-sonnet-4-6',
ANTHROPIC_DEFAULT_HAIKU_MODEL: 'claude-haiku-3-5-20241022',
},
}),
'utf-8'
);
ensureProviderSettings('claude');
const result = JSON.parse(fs.readFileSync(settingsPath, 'utf-8')) as {
env: Record<string, string | undefined>;
};
for (const key of MODEL_KEYS) {
const value = result.env[key];
expect(!value || value.trim().length === 0).toBe(true);
}
});
it('preserves an explicit user pin to a value not in any stale-defaults set (e.g. claude-opus-4-8)', () => {
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',
ANTHROPIC_MODEL: 'claude-opus-4-8',
},
}),
'utf-8'
);
ensureProviderSettings('claude');
const result = JSON.parse(fs.readFileSync(settingsPath, 'utf-8')) as {
env: Record<string, string | undefined>;
};
// claude-opus-4-8 is not in any stale-defaults set; must survive migration
expect(result.env.ANTHROPIC_MODEL).toBe('claude-opus-4-8');
});
// ── Fresh-file migration marker and tier-pin survival ─────────────────────────
it('marks migration done on fresh-file creation so tier pins written by ccs claude --config survive', () => {
process.env.CCS_HOME = tempHome;
const ccsDir = path.join(tempHome, '.ccs');
fs.mkdirSync(ccsDir, { recursive: true });
const settingsPath = path.join(ccsDir, 'claude.settings.json');
const markerPath = path.join(ccsDir, 'cliproxy', '.claude-model-migrated');
// Step 1: fresh ensureProviderSettings creates the file and the marker.
expect(fs.existsSync(settingsPath)).toBe(false);
ensureProviderSettings('claude');
expect(fs.existsSync(settingsPath)).toBe(true);
expect(fs.existsSync(markerPath)).toBe(true);
// Step 2: simulate 'ccs claude --config' pinning all four tier models.
const tierPins = {
ANTHROPIC_MODEL: 'claude-sonnet-4-6',
ANTHROPIC_DEFAULT_OPUS_MODEL: 'claude-sonnet-4-6',
ANTHROPIC_DEFAULT_SONNET_MODEL: 'claude-sonnet-4-6',
ANTHROPIC_DEFAULT_HAIKU_MODEL: 'claude-sonnet-4-6',
};
const existing = JSON.parse(fs.readFileSync(settingsPath, 'utf-8')) as {
env: Record<string, string>;
};
fs.writeFileSync(
settingsPath,
JSON.stringify({ ...existing, env: { ...existing.env, ...tierPins } }, null, 2) + '\n',
'utf-8'
);
// Step 3: second ensureProviderSettings must NOT strip the user-written pins
// because the migration marker already exists.
ensureProviderSettings('claude');
const result = JSON.parse(fs.readFileSync(settingsPath, 'utf-8')) as {
env: Record<string, string | undefined>;
};
expect(result.env.ANTHROPIC_MODEL).toBe('claude-sonnet-4-6');
expect(result.env.ANTHROPIC_DEFAULT_OPUS_MODEL).toBe('claude-sonnet-4-6');
expect(result.env.ANTHROPIC_DEFAULT_SONNET_MODEL).toBe('claude-sonnet-4-6');
expect(result.env.ANTHROPIC_DEFAULT_HAIKU_MODEL).toBe('claude-sonnet-4-6');
});
});
+23 -16
View File
@@ -16,10 +16,10 @@ interface BaseSettings {
env: {
ANTHROPIC_BASE_URL: string;
ANTHROPIC_AUTH_TOKEN: string;
ANTHROPIC_MODEL: string;
ANTHROPIC_DEFAULT_OPUS_MODEL: string;
ANTHROPIC_DEFAULT_SONNET_MODEL: string;
ANTHROPIC_DEFAULT_HAIKU_MODEL: string;
ANTHROPIC_MODEL?: string;
ANTHROPIC_DEFAULT_OPUS_MODEL?: string;
ANTHROPIC_DEFAULT_SONNET_MODEL?: string;
ANTHROPIC_DEFAULT_HAIKU_MODEL?: string;
};
}
@@ -66,16 +66,20 @@ export function loadBaseConfig(provider: CLIProxyProvider): BaseSettings {
throw new Error('Missing or invalid "env" object');
}
const required = [
'ANTHROPIC_MODEL',
'ANTHROPIC_DEFAULT_OPUS_MODEL',
'ANTHROPIC_DEFAULT_SONNET_MODEL',
'ANTHROPIC_DEFAULT_HAIKU_MODEL',
];
// claude provider is model-neutral: it does not pin model env vars so that
// the user's own Claude Code model selection is respected end-to-end.
if (provider !== 'claude') {
const required = [
'ANTHROPIC_MODEL',
'ANTHROPIC_DEFAULT_OPUS_MODEL',
'ANTHROPIC_DEFAULT_SONNET_MODEL',
'ANTHROPIC_DEFAULT_HAIKU_MODEL',
];
for (const field of required) {
if (!settings.env[field as keyof BaseSettings['env']]) {
throw new Error(`Missing required field: env.${field}`);
for (const field of required) {
if (!settings.env[field as keyof BaseSettings['env']]) {
throw new Error(`Missing required field: env.${field}`);
}
}
}
@@ -90,14 +94,17 @@ export function loadBaseConfig(provider: CLIProxyProvider): BaseSettings {
/**
* Get model mapping from base config
* Extracts model names from env vars
* Extracts model names from env vars.
* Returns undefined model fields for the claude provider (model-neutral passthrough).
*/
export function getModelMappingFromConfig(provider: CLIProxyProvider): ProviderModelMapping {
const config = loadBaseConfig(provider);
// claude is model-neutral: ANTHROPIC_MODEL is absent from its config; callers
// that need model IDs (e.g. getClaudeEnvVars) guard on provider !== 'claude'.
return {
defaultModel: config.env.ANTHROPIC_MODEL,
claudeModel: config.env.ANTHROPIC_MODEL,
defaultModel: config.env.ANTHROPIC_MODEL ?? '',
claudeModel: config.env.ANTHROPIC_MODEL ?? '',
opusModel: config.env.ANTHROPIC_DEFAULT_OPUS_MODEL,
sonnetModel: config.env.ANTHROPIC_DEFAULT_SONNET_MODEL,
haikuModel: config.env.ANTHROPIC_DEFAULT_HAIKU_MODEL,
+140 -26
View File
@@ -31,7 +31,7 @@ import {
normalizeIFlowLegacyModelAliases,
normalizeModelIdForProvider,
} from '../ai-providers/model-id-normalizer';
import { getGlobalEnvConfig } from '../../config/config-loader-facade';
import { getGlobalEnvConfig, getCcsDir } from '../../config/config-loader-facade';
/** Settings file structure for user overrides */
interface ProviderSettings {
@@ -52,6 +52,9 @@ const REQUIRED_PROVIDER_ENV_KEYS = [
'ANTHROPIC_DEFAULT_SONNET_MODEL',
'ANTHROPIC_DEFAULT_HAIKU_MODEL',
] as const;
/** Minimum required env vars for the claude built-in provider (model-neutral). */
const REQUIRED_CLAUDE_ENV_KEYS = ['ANTHROPIC_BASE_URL', 'ANTHROPIC_AUTH_TOKEN'] as const;
const CURSOR_LEGACY_ENV_OVERRIDE_KEYS = new Set([
'ANTHROPIC_BASE_URL',
'ANTHROPIC_AUTH_TOKEN',
@@ -200,28 +203,18 @@ export function getModelMapping(provider: CLIProxyProvider): ProviderModelMappin
* Get environment variables for Claude CLI (bundled defaults)
* Uses provider-specific endpoint (e.g., /api/provider/gemini) for explicit routing.
* This enables concurrent gemini/codex usage - each session routes to its provider via URL 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).
*/
export function getClaudeEnvVars(
provider: CLIProxyProvider,
port: number = CLIPROXY_DEFAULT_PORT
): NodeJS.ProcessEnv {
const models = getModelMapping(provider);
// Base env vars from config file (includes ANTHROPIC_MAX_TOKENS, etc.)
const baseEnvVars = getEnvVarsFromConfig(provider);
// Core env vars that we always set dynamically
const coreEnvVars = {
// Provider-specific endpoint - routes to correct provider via URL path
ANTHROPIC_BASE_URL: `http://127.0.0.1:${port}/api/provider/${provider}`,
ANTHROPIC_AUTH_TOKEN: getEffectiveApiKey(),
ANTHROPIC_MODEL: models.claudeModel,
ANTHROPIC_DEFAULT_OPUS_MODEL: models.opusModel || models.claudeModel,
ANTHROPIC_DEFAULT_SONNET_MODEL: models.sonnetModel || models.claudeModel,
ANTHROPIC_DEFAULT_HAIKU_MODEL: models.haikuModel || models.claudeModel,
};
// Filter out core env vars from base config to avoid conflicts
// Filter out model pins and URL/auth from base config (we set them dynamically)
const {
ANTHROPIC_BASE_URL: _baseUrl,
ANTHROPIC_AUTH_TOKEN: _authToken,
@@ -232,6 +225,22 @@ export function getClaudeEnvVars(
...additionalEnvVars
} = baseEnvVars;
// Core transport env vars set dynamically for all providers
const coreEnvVars: NodeJS.ProcessEnv = {
ANTHROPIC_BASE_URL: `http://127.0.0.1:${port}/api/provider/${provider}`,
ANTHROPIC_AUTH_TOKEN: getEffectiveApiKey(),
};
// Model pins: omitted for claude provider (model-neutral passthrough).
// For all other providers, set model vars from the base config model mapping.
if (provider !== 'claude') {
const models = getModelMapping(provider);
coreEnvVars.ANTHROPIC_MODEL = models.claudeModel;
coreEnvVars.ANTHROPIC_DEFAULT_OPUS_MODEL = models.opusModel || models.claudeModel;
coreEnvVars.ANTHROPIC_DEFAULT_SONNET_MODEL = models.sonnetModel || models.claudeModel;
coreEnvVars.ANTHROPIC_DEFAULT_HAIKU_MODEL = models.haikuModel || models.claudeModel;
}
// Merge core env vars with additional env vars from base config
const mergedEnv = {
...coreEnvVars,
@@ -509,6 +518,78 @@ export function getEffectiveEnvVars(
return { ...globalEnv, ...getClaudeEnvVars(provider, port) };
}
/**
* All historically-shipped default model pins that CCS auto-wrote into
* claude.settings.json before the model-neutral passthrough change.
* A key is removed only when the stored value exactly matches one of the values
* in that key's set, so user-customised values are always preserved.
*/
const CLAUDE_STALE_MODEL_DEFAULTS: Record<string, Set<string>> = {
ANTHROPIC_MODEL: new Set([
'claude-sonnet-4-20250514',
'claude-sonnet-4-5-20250929',
'claude-sonnet-4-6',
]),
ANTHROPIC_DEFAULT_OPUS_MODEL: new Set([
'claude-opus-4-20250514',
'claude-opus-4-5-20251101',
'claude-opus-4-6',
'claude-opus-4-7',
]),
ANTHROPIC_DEFAULT_SONNET_MODEL: new Set([
'claude-sonnet-4-20250514',
'claude-sonnet-4-5-20250929',
'claude-sonnet-4-6',
]),
ANTHROPIC_DEFAULT_HAIKU_MODEL: new Set([
'claude-haiku-3-5-20241022',
'claude-haiku-4-5-20251001',
]),
};
/** Marker file that records when the one-time stale-pin migration has run. */
const CLAUDE_MODEL_MIGRATED_MARKER = '.claude-model-migrated';
/** Return true if the one-time stale-pin migration has already been applied. */
function claudeModelMigrationDone(): boolean {
try {
return fs.existsSync(path.join(getCcsDir(), 'cliproxy', CLAUDE_MODEL_MIGRATED_MARKER));
} catch {
return true; // Cannot read — treat as done to avoid repeated rewrites.
}
}
/** Record that the one-time stale-pin migration has been applied. */
function markClaudeModelMigrationDone(): void {
try {
const dir = path.join(getCcsDir(), 'cliproxy');
fs.mkdirSync(dir, { recursive: true });
fs.writeFileSync(path.join(dir, CLAUDE_MODEL_MIGRATED_MARKER), new Date().toISOString(), {
encoding: 'utf8',
flag: 'w',
});
} catch {
// Best-effort — failure to persist is not fatal.
}
}
/**
* Remove stale model pins from an existing claude.settings.json env block.
* Only removes keys whose values appear in the set of historically-shipped
* defaults for that key, preserving user-customised values.
* Returns true when at least one key was removed (signals file needs rewriting).
*/
function migrateClaudeStaleModelPins(env: Record<string, string>): boolean {
let mutated = false;
for (const [key, staleValues] of Object.entries(CLAUDE_STALE_MODEL_DEFAULTS)) {
if (staleValues.has(env[key])) {
delete env[key];
mutated = true;
}
}
return mutated;
}
/**
* Copy bundled settings template to user directory if not exists
* Called during installation/first run
@@ -525,8 +606,13 @@ export function ensureProviderSettings(provider: CLIProxyProvider): void {
};
// Create initial file when missing.
// A freshly created file has no stale pins by construction, so mark migration
// done immediately to prevent the one-time strip from running unnecessarily.
if (!fs.existsSync(settingsPath)) {
writeSettings({ env: defaultEnv });
if (provider === 'claude') {
markClaudeModelMigrationDone();
}
return;
}
@@ -564,11 +650,28 @@ export function ensureProviderSettings(provider: CLIProxyProvider): void {
: {};
let mutated = !(envCandidate && typeof envCandidate === 'object' && !Array.isArray(envCandidate));
for (const key of REQUIRED_PROVIDER_ENV_KEYS) {
// One-time migration: strip stale model pins written by older CCS versions into
// claude.settings.json. Guarded by a marker file so a user-re-pin that happens
// to equal a stale default value is not silently stripped on every subsequent launch.
if (provider === 'claude' && !claudeModelMigrationDone()) {
if (migrateClaudeStaleModelPins(mergedEnv)) {
mutated = true;
}
markClaudeModelMigrationDone();
}
// claude is model-neutral: only transport keys (URL + auth) are required; model pins are omitted.
const requiredKeys =
provider === 'claude' ? REQUIRED_CLAUDE_ENV_KEYS : REQUIRED_PROVIDER_ENV_KEYS;
for (const key of requiredKeys) {
const current = mergedEnv[key];
if (typeof current !== 'string' || current.trim().length === 0) {
mergedEnv[key] = defaultEnv[key] || '';
mutated = true;
const fallback = defaultEnv[key];
if (fallback) {
mergedEnv[key] = fallback;
mutated = true;
}
}
}
@@ -689,7 +792,6 @@ export function getRemoteEnvVars(
// Priority 3: Base config defaults
if (Object.keys(userEnvVars).length === 0) {
const models = getModelMapping(provider);
const baseEnvVars = getEnvVarsFromConfig(provider);
// Filter out URL/auth from base config (we'll set those from remote config)
const {
@@ -697,13 +799,25 @@ export function getRemoteEnvVars(
ANTHROPIC_AUTH_TOKEN: _authToken,
...additionalEnvVars
} = baseEnvVars;
userEnvVars = {
...additionalEnvVars,
ANTHROPIC_MODEL: models.claudeModel,
ANTHROPIC_DEFAULT_OPUS_MODEL: models.opusModel || models.claudeModel,
ANTHROPIC_DEFAULT_SONNET_MODEL: models.sonnetModel || models.claudeModel,
ANTHROPIC_DEFAULT_HAIKU_MODEL: models.haikuModel || models.claudeModel,
};
// claude is model-neutral: omit model pins so Claude Code's own /model
// selection is respected on remote launches too.
if (provider === 'claude') {
// Filter out undefined values coming from NodeJS.ProcessEnv spread.
userEnvVars = Object.fromEntries(
Object.entries(additionalEnvVars).filter(
(entry): entry is [string, string] => typeof entry[1] === 'string'
)
);
} else {
const models = getModelMapping(provider);
userEnvVars = {
...additionalEnvVars,
ANTHROPIC_MODEL: models.claudeModel,
ANTHROPIC_DEFAULT_OPUS_MODEL: models.opusModel || models.claudeModel,
ANTHROPIC_DEFAULT_SONNET_MODEL: models.sonnetModel || models.claudeModel,
ANTHROPIC_DEFAULT_HAIKU_MODEL: models.haikuModel || models.claudeModel,
};
}
}
// Build final env: global + user settings + remote URL/auth override
@@ -484,4 +484,17 @@ describe('ensureModelConfiguration', () => {
await ensureModelConfiguration('codex', cfg, false);
expect(mockReconcileCodexModel).not.toHaveBeenCalled();
});
// claude is model-neutral passthrough — must never auto-prompt at launch
it('claude non-composite → configureProviderModel NOT called (model-neutral)', async () => {
const cfg = { isComposite: false, customSettingsPath: undefined } as ExecutorConfig;
await ensureModelConfiguration('claude', cfg, false);
expect(mockConfigureProviderModel).not.toHaveBeenCalled();
});
it('claude composite → configureProviderModel NOT called', async () => {
const cfg = { isComposite: true } as ExecutorConfig;
await ensureModelConfiguration('claude', cfg, false);
expect(mockConfigureProviderModel).not.toHaveBeenCalled();
});
});
+5 -1
View File
@@ -369,13 +369,17 @@ export function runAccountSafetyGuards(
* Ensure provider model is configured on first run.
* Skipped for composite variants and remote proxy mode.
* Also reconciles Codex model for active plan.
*
* claude is model-neutral passthrough: the user's own /model selection inside
* Claude Code governs which model is used, so no auto-prompt at launch.
* Use `ccs claude --config` for an explicit pin opt-in.
*/
export async function ensureModelConfiguration(
provider: CLIProxyProvider,
cfg: ExecutorConfig,
verbose: boolean
): Promise<void> {
if (!cfg.isComposite && supportsModelConfig(provider)) {
if (!cfg.isComposite && provider !== 'claude' && supportsModelConfig(provider)) {
await configureProviderModel(provider, false, cfg.customSettingsPath);
}
+11
View File
@@ -67,6 +67,7 @@ import { resolveExecutorProxy, resolveExecutorProxyConfig } from './proxy-resolv
import { buildProxyChain } from './proxy-chain-builder';
import { warnBrokenModels } from './model-warnings';
import { launchClaude } from './claude-launcher';
import { maybeWarnClaudeShadow, maybeShowClaudeRoutingNotice } from '../claude-shadow-warning';
/** Local alias so internal call sites need no change */
const resolveRuntimeQuotaMonitorProviders = _resolveRuntimeQuotaMonitorProviders;
@@ -161,6 +162,11 @@ export async function execClaudeWithCLIProxy(
const providerConfig = getProviderConfig(provider);
log(`Provider: ${providerConfig.displayName}`);
// claude built-in: warn once if a user profile is being shadowed
if (provider === 'claude') {
maybeWarnClaudeShadow();
}
// Variables for local proxy mode
let sessionId: string | undefined;
@@ -279,6 +285,11 @@ export async function execClaudeWithCLIProxy(
// 5. Check for broken models (multi-tier for composite)
warnBrokenModels({ provider, cfg, compositeProviders, skipLocalAuth });
// 5a. claude built-in: one-time routing notice (first launch only)
if (provider === 'claude') {
maybeShowClaudeRoutingNotice();
}
// 6. Ensure user settings file exists
ensureProviderSettingsFile(provider);
+6 -1
View File
@@ -122,12 +122,17 @@ export async function showProviderShortcutHelp(
writeLine('');
writeLine(` ${providerEntry?.summary || 'CLIProxy OAuth provider shortcut'}.`);
writeLine('');
const configSummary =
provider === 'claude'
? 'Pin a model for this session (optional; use /model inside Claude Code instead)'
: 'Open the provider config flow';
writeCommandTable(
'Common Commands',
[
{ name: `ccs ${provider} --auth`, summary: 'Authenticate the provider account via CLIProxy' },
{ name: `ccs ${provider} --accounts`, summary: 'List or manage stored CLIProxy accounts' },
{ name: `ccs ${provider} --config`, summary: 'Open the provider config flow' },
{ name: `ccs ${provider} --config`, summary: configSummary },
{ name: `ccs ${provider} "task"`, summary: 'Run Claude through this provider shortcut' },
],
writeLine