diff --git a/config/base-claude.settings.json b/config/base-claude.settings.json index e9986c70..284681cc 100644 --- a/config/base-claude.settings.json +++ b/config/base-claude.settings.json @@ -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" } } diff --git a/src/api/services/cliproxy-profile-bridge.ts b/src/api/services/cliproxy-profile-bridge.ts index 25ab7d21..6c8a5ca7 100644 --- a/src/api/services/cliproxy-profile-bridge.ts +++ b/src/api/services/cliproxy-profile-bridge.ts @@ -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, diff --git a/src/api/services/profile-writer.ts b/src/api/services/profile-writer.ts index 2ef17caa..0c0df0e0 100644 --- a/src/api/services/profile-writer.ts +++ b/src/api/services/profile-writer.ts @@ -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(',') } : {}), diff --git a/src/cliproxy/__tests__/account-safety-ban-copy.test.ts b/src/cliproxy/__tests__/account-safety-ban-copy.test.ts new file mode 100644 index 00000000..1814d413 --- /dev/null +++ b/src/cliproxy/__tests__/account-safety-ban-copy.test.ts @@ -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; + let consoleSpy: ReturnType; + 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'); + }); +}); diff --git a/src/cliproxy/__tests__/claude-shadow-warning.test.ts b/src/cliproxy/__tests__/claude-shadow-warning.test.ts new file mode 100644 index 00000000..c8087fa2 --- /dev/null +++ b/src/cliproxy/__tests__/claude-shadow-warning.test.ts @@ -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): 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; +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'); + }); +}); diff --git a/src/cliproxy/accounts/account-safety.ts b/src/cliproxy/accounts/account-safety.ts index b3f48784..3cde38e8 100644 --- a/src/cliproxy/accounts/account-safety.ts +++ b/src/cliproxy/accounts/account-safety.ts @@ -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(''); diff --git a/src/cliproxy/claude-shadow-warning.ts b/src/cliproxy/claude-shadow-warning.ts new file mode 100644 index 00000000..76496dd9 --- /dev/null +++ b/src/cliproxy/claude-shadow-warning.ts @@ -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` + ); +} diff --git a/src/cliproxy/config/__tests__/claude-model-neutral.test.ts b/src/cliproxy/config/__tests__/claude-model-neutral.test.ts new file mode 100644 index 00000000..989b642f --- /dev/null +++ b/src/cliproxy/config/__tests__/claude-model-neutral.test.ts @@ -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; + }; + + 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; + }; + + // 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; + }; + + // 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; + }; + + // 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; + }; + // 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; + }; + + 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; + }; + + 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; + }; + + // 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; + }; + 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; + }; + 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'); + }); +}); diff --git a/src/cliproxy/config/base-config-loader.ts b/src/cliproxy/config/base-config-loader.ts index 4f64121c..d4ea308f 100644 --- a/src/cliproxy/config/base-config-loader.ts +++ b/src/cliproxy/config/base-config-loader.ts @@ -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, diff --git a/src/cliproxy/config/env-builder.ts b/src/cliproxy/config/env-builder.ts index 4b66f87f..0b9d08fb 100644 --- a/src/cliproxy/config/env-builder.ts +++ b/src/cliproxy/config/env-builder.ts @@ -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> = { + 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): 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 diff --git a/src/cliproxy/executor/__tests__/auth-coordinator.test.ts b/src/cliproxy/executor/__tests__/auth-coordinator.test.ts index d7617fe6..bfe8ec14 100644 --- a/src/cliproxy/executor/__tests__/auth-coordinator.test.ts +++ b/src/cliproxy/executor/__tests__/auth-coordinator.test.ts @@ -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(); + }); }); diff --git a/src/cliproxy/executor/auth-coordinator.ts b/src/cliproxy/executor/auth-coordinator.ts index 4e1f2f42..9a30b930 100644 --- a/src/cliproxy/executor/auth-coordinator.ts +++ b/src/cliproxy/executor/auth-coordinator.ts @@ -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 { - if (!cfg.isComposite && supportsModelConfig(provider)) { + if (!cfg.isComposite && provider !== 'claude' && supportsModelConfig(provider)) { await configureProviderModel(provider, false, cfg.customSettingsPath); } diff --git a/src/cliproxy/executor/index.ts b/src/cliproxy/executor/index.ts index 0a5ee7fa..9a4ee77a 100644 --- a/src/cliproxy/executor/index.ts +++ b/src/cliproxy/executor/index.ts @@ -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); diff --git a/src/commands/help-command.ts b/src/commands/help-command.ts index c5fd340b..bc7e5c7f 100644 --- a/src/commands/help-command.ts +++ b/src/commands/help-command.ts @@ -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