diff --git a/docs/system-architecture/provider-flows.md b/docs/system-architecture/provider-flows.md index 534ccc1f..0663f768 100644 --- a/docs/system-architecture/provider-flows.md +++ b/docs/system-architecture/provider-flows.md @@ -467,6 +467,34 @@ function selectBestAccount(accounts: AccountInfo[]): AccountInfo | null { └─ Droid: config file (~/.factory/settings.json) ``` +### Anthropic Direct API Key + +``` ++===========================================================================+ +| Anthropic Direct API Key (Native Auth) | ++===========================================================================+ + + 1. User creates profile: ccs api create --preset anthropic + | + v + 2. Key stored in ~/.ccs/.settings.json + | env: { ANTHROPIC_API_KEY: "sk-ant-..." } + | (NO ANTHROPIC_BASE_URL, NO ANTHROPIC_AUTH_TOKEN) + v + 3. Profile detection: settings-based + | + v + 4. Key passed via ANTHROPIC_API_KEY env var + | Claude CLI uses native endpoint (api.anthropic.com) + v + 5. Claude CLI authenticates with x-api-key header + + Detection logic (profile-writer.ts): + - apiKey.startsWith('sk-ant-') -> native mode + - baseUrl.includes('api.anthropic.com') -> native mode + - Otherwise -> proxy mode (existing behavior) +``` + --- ## Image Analysis Hook Flow (v7.34) diff --git a/src/api/services/profile-reader.ts b/src/api/services/profile-reader.ts index 190426da..ee0efc2f 100644 --- a/src/api/services/profile-reader.ts +++ b/src/api/services/profile-reader.ts @@ -49,7 +49,7 @@ export function isApiProfileConfigured(apiName: string): boolean { if (!fs.existsSync(settingsPath)) return false; const settings = JSON.parse(fs.readFileSync(settingsPath, 'utf8')); - const token = settings?.env?.ANTHROPIC_AUTH_TOKEN || ''; + const token = settings?.env?.ANTHROPIC_AUTH_TOKEN || settings?.env?.ANTHROPIC_API_KEY || ''; return token.length > 0 && !token.includes('YOUR_') && !token.includes('your-'); } catch { return false; diff --git a/src/api/services/profile-writer.ts b/src/api/services/profile-writer.ts index 1b9ae1b3..eb969f9e 100644 --- a/src/api/services/profile-writer.ts +++ b/src/api/services/profile-writer.ts @@ -32,6 +32,11 @@ function isOpenRouterUrl(baseUrl: string): boolean { return baseUrl.toLowerCase().includes('openrouter.ai'); } +/** Detect Anthropic direct API profile (native auth, no proxy) */ +function isAnthropicDirect(baseUrl: string, apiKey: string): boolean { + return apiKey.startsWith('sk-ant-') || baseUrl.includes('api.anthropic.com'); +} + function resolveProviderFromBaseUrl(baseUrl: string): CLIProxyProvider | null { if (baseUrl.trim().length === 0) { return null; @@ -76,17 +81,23 @@ function createSettingsFile( model: models.default, }); + const isNative = isAnthropicDirect(baseUrl, apiKey); const settings = { env: { - ANTHROPIC_BASE_URL: baseUrl, - ANTHROPIC_AUTH_TOKEN: apiKey, + // Native mode: ANTHROPIC_API_KEY only, no BASE_URL/AUTH_TOKEN + // Proxy mode: ANTHROPIC_BASE_URL + AUTH_TOKEN (existing behavior) + ...(isNative + ? { ANTHROPIC_API_KEY: apiKey } + : { + ANTHROPIC_BASE_URL: baseUrl, + 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, CCS_DROID_PROVIDER: droidProvider, - // OpenRouter requires explicitly blanking the API key to prevent conflicts - ...(isOpenRouterUrl(baseUrl) && { ANTHROPIC_API_KEY: '' }), }, }; @@ -151,17 +162,21 @@ function createApiProfileUnified( model: models.default, }); + const isNative = isAnthropicDirect(baseUrl, apiKey); const settings = { env: { - ANTHROPIC_BASE_URL: baseUrl, - ANTHROPIC_AUTH_TOKEN: apiKey, + ...(isNative + ? { ANTHROPIC_API_KEY: apiKey } + : { + ANTHROPIC_BASE_URL: baseUrl, + 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, CCS_DROID_PROVIDER: droidProvider, - // OpenRouter requires explicitly blanking the API key to prevent conflicts - ...(isOpenRouterUrl(baseUrl) && { ANTHROPIC_API_KEY: '' }), }, }; diff --git a/src/ccs.ts b/src/ccs.ts index 25c27c11..a1abfb31 100644 --- a/src/ccs.ts +++ b/src/ccs.ts @@ -10,7 +10,11 @@ import { detectCloudSyncPath, } from './utils/config-manager'; import { expandPath } from './utils/helpers'; -import { validateGlmKey, validateMiniMaxKey } from './utils/api-key-validator'; +import { + validateGlmKey, + validateMiniMaxKey, + validateAnthropicKey, +} from './utils/api-key-validator'; import { ErrorManager } from './utils/error-manager'; import { execClaudeWithCLIProxy, @@ -1044,6 +1048,30 @@ async function main(): Promise { } } + // Pre-flight validation for Anthropic direct profiles (ANTHROPIC_API_KEY + no BASE_URL) + { + const preflightSettingsPath = getSettingsPath(profileInfo.name); + const preflightSettings = loadSettings(preflightSettingsPath); + const anthropicApiKey = preflightSettings.env?.['ANTHROPIC_API_KEY']; + const hasBaseUrl = !!preflightSettings.env?.['ANTHROPIC_BASE_URL']; + if (anthropicApiKey && !hasBaseUrl) { + const validation = await validateAnthropicKey(anthropicApiKey); + if (!validation.valid) { + console.error(''); + console.error(fail(validation.error || 'API key validation failed')); + if (validation.suggestion) { + console.error(''); + console.error(validation.suggestion); + } + console.error(''); + console.error( + info(`To skip validation: CCS_SKIP_PREFLIGHT=1 ccs ${profileInfo.name} "prompt"`) + ); + process.exit(1); + } + } + } + // Check if this is GLMT profile (requires proxy) if (profileInfo.name === 'glmt') { if (resolvedTarget !== 'claude') { @@ -1106,7 +1134,7 @@ async function main(): Promise { const creds: TargetCredentials = { profile: profileInfo.name, baseUrl: settingsEnv['ANTHROPIC_BASE_URL'] || '', - apiKey: settingsEnv['ANTHROPIC_AUTH_TOKEN'] || '', + apiKey: settingsEnv['ANTHROPIC_AUTH_TOKEN'] || settingsEnv['ANTHROPIC_API_KEY'] || '', model: settingsEnv['ANTHROPIC_MODEL'], provider: resolveDroidProvider({ provider: settingsEnv['CCS_DROID_PROVIDER'] || settingsEnv['DROID_PROVIDER'], diff --git a/src/commands/api-command.ts b/src/commands/api-command.ts index f44f6f49..fe41af3d 100644 --- a/src/commands/api-command.ts +++ b/src/commands/api-command.ts @@ -261,9 +261,9 @@ async function handleCreate(args: string[]): Promise { process.exit(1); } - // Step 2: Base URL (use preset if provided) - let baseUrl = parsedArgs.baseUrl || preset?.baseUrl; - if (!baseUrl) { + // Step 2: Base URL (use preset if provided; skip prompt for presets with empty baseUrl like anthropic) + let baseUrl = parsedArgs.baseUrl ?? preset?.baseUrl ?? ''; + if (!baseUrl && !preset) { baseUrl = await InteractivePrompt.input( 'API Base URL (e.g., https://api.example.com/v1 - without /chat/completions)', { validate: validateUrl } @@ -297,10 +297,21 @@ async function handleCreate(args: string[]): Promise { // Show preset info console.log(info(`Using preset: ${preset.name}`)); console.log(dim(` ${preset.description}`)); - console.log(dim(` Base URL: ${preset.baseUrl}`)); + if (preset.baseUrl) { + console.log(dim(` Base URL: ${preset.baseUrl}`)); + } else { + console.log(dim(` Auth: Native Anthropic API (x-api-key header)`)); + } console.log(''); } + // Auto-detect Anthropic direct API when user enters api.anthropic.com URL without preset + if (baseUrl && baseUrl.includes('api.anthropic.com') && !preset) { + console.log(''); + console.log(info('Anthropic Direct API detected. Base URL will be omitted for native auth.')); + baseUrl = ''; + } + // OpenRouter detection: offer interactive model picker let openRouterModel: string | undefined; let openRouterTierMapping: { opus?: string; sonnet?: string; haiku?: string } | undefined; @@ -425,7 +436,7 @@ async function handleCreate(args: string[]): Promise { console.log(''); console.log(info('Creating API profile...')); - const result = createApiProfile(name, baseUrl, apiKey, models, resolvedTarget); + const result = createApiProfile(name, baseUrl || '', apiKey, models, resolvedTarget); if (!result.success) { console.log(fail(`Failed to create API profile: ${result.error}`)); @@ -658,6 +669,7 @@ async function showHelp(): Promise { console.log(` ${color('ccs api create', 'command')}`); console.log(''); console.log(` ${dim('# Quick setup with preset')}`); + console.log(` ${color('ccs api create --preset anthropic', 'command')}`); console.log(` ${color('ccs api create --preset openrouter', 'command')}`); console.log(` ${color('ccs api create --preset alibaba-coding-plan', 'command')}`); console.log(` ${color('ccs api create --preset alibaba', 'command')} ${dim('# alias')}`); diff --git a/src/commands/help-command.ts b/src/commands/help-command.ts index 8c849d36..cc879dee 100644 --- a/src/commands/help-command.ts +++ b/src/commands/help-command.ts @@ -140,6 +140,7 @@ Run ${color('ccs config', 'command')} for web dashboard`.trim(); ['ccs ollama', 'Local Ollama (http://localhost:11434)'], ['ccs ollama-cloud', 'Ollama Cloud (API key required)'], ['', ''], // Spacer + ['ccs api create --preset anthropic', 'Anthropic direct API key (sk-ant-...)'], ['ccs api create', 'Create custom API profile'], ['ccs api remove', 'Remove an API profile'], ['ccs api list', 'List all API profiles'], diff --git a/src/shared/provider-preset-catalog.ts b/src/shared/provider-preset-catalog.ts index 551e3a4d..804cbea8 100644 --- a/src/shared/provider-preset-catalog.ts +++ b/src/shared/provider-preset-catalog.ts @@ -8,6 +8,7 @@ export type PresetCategory = 'recommended' | 'alternative'; export const PROVIDER_PRESET_IDS = [ + 'anthropic', 'openrouter', 'alibaba-coding-plan', 'ollama', @@ -57,6 +58,20 @@ export const PROVIDER_PRESET_ALIASES: Readonly> }); const RAW_PROVIDER_PRESET_DEFINITIONS: readonly ProviderPresetDefinition[] = [ + { + id: 'anthropic', + name: 'Anthropic (Direct API)', + description: 'Use your own Anthropic API key (sk-ant-...)', + baseUrl: '', + defaultProfileName: 'anthropic', + defaultModel: 'claude-sonnet-4-5-20250929', + apiKeyPlaceholder: 'sk-ant-api03-...', + apiKeyHint: 'Get key at console.anthropic.com/settings/keys', + category: 'recommended', + requiresApiKey: true, + badge: 'Direct', + featured: true, + }, { id: 'openrouter', name: 'OpenRouter', diff --git a/src/utils/api-key-validator.ts b/src/utils/api-key-validator.ts index cc67c8ac..95c29838 100644 --- a/src/utils/api-key-validator.ts +++ b/src/utils/api-key-validator.ts @@ -166,3 +166,76 @@ export async function validateMiniMaxKey( timeoutMs ); } + +/** Validate Anthropic direct API key using x-api-key header (not Bearer) */ +export async function validateAnthropicKey( + apiKey: string, + timeoutMs = 2000 +): Promise { + if (process.env.CCS_SKIP_PREFLIGHT === '1') { + return { valid: true }; + } + + if (!apiKey || DEFAULT_PLACEHOLDERS.includes(apiKey.toUpperCase())) { + return { + valid: false, + error: 'Anthropic API key not configured', + suggestion: 'Set ANTHROPIC_API_KEY in your profile settings.json', + }; + } + + return new Promise((resolve) => { + let resolved = false; + const safeResolve = (result: ValidationResult) => { + if (resolved) return; + resolved = true; + resolve(result); + }; + + const options: https.RequestOptions = { + hostname: 'api.anthropic.com', + port: 443, + path: '/v1/models', + method: 'GET', + headers: { + 'x-api-key': apiKey, + 'anthropic-version': '2023-06-01', + 'User-Agent': 'CCS-Preflight/1.0', + }, + }; + + const req = https.request(options, (res) => { + clearTimeout(timeoutId); + + if (res.statusCode === 200) { + safeResolve({ valid: true }); + } else if (res.statusCode === 401 || res.statusCode === 403) { + safeResolve({ + valid: false, + error: 'Anthropic API key rejected', + suggestion: + 'Your key may have expired. To fix:\n' + + ' 1. Go to console.anthropic.com/settings/keys and regenerate your API key\n' + + ' 2. Update ANTHROPIC_API_KEY in your profile settings.json\n' + + ' 3. Or run: ccs api create --preset anthropic', + }); + } else { + safeResolve({ valid: true }); + } + + res.resume(); + }); + + req.on('error', () => { + clearTimeout(timeoutId); + safeResolve({ valid: true }); + }); + + const timeoutId = setTimeout(() => { + req.destroy(); + safeResolve({ valid: true }); + }, timeoutMs); + + req.end(); + }); +} diff --git a/src/utils/delegation-validator.ts b/src/utils/delegation-validator.ts index 1ae2df86..1aa5f3a1 100644 --- a/src/utils/delegation-validator.ts +++ b/src/utils/delegation-validator.ts @@ -69,16 +69,17 @@ export class DelegationValidator { } // Validate API key exists and is not default - const apiKey = settings.env?.ANTHROPIC_AUTH_TOKEN; + // Support both proxy mode (ANTHROPIC_AUTH_TOKEN) and native mode (ANTHROPIC_API_KEY) + const apiKey = settings.env?.ANTHROPIC_AUTH_TOKEN || settings.env?.ANTHROPIC_API_KEY; if (!apiKey) { return { valid: false, error: `API key not configured for ${profileName}`, suggestion: - `Missing ANTHROPIC_AUTH_TOKEN in settings.\n\n` + + `Missing ANTHROPIC_AUTH_TOKEN or ANTHROPIC_API_KEY in settings.\n\n` + `Edit: ${settingsPath}\n` + - `Set: env.ANTHROPIC_AUTH_TOKEN to your API key`, + `Set: env.ANTHROPIC_AUTH_TOKEN (proxy) or env.ANTHROPIC_API_KEY (direct) to your API key`, }; } diff --git a/src/web-server/routes/route-helpers.ts b/src/web-server/routes/route-helpers.ts index 2b38e486..58a04ca9 100644 --- a/src/web-server/routes/route-helpers.ts +++ b/src/web-server/routes/route-helpers.ts @@ -116,7 +116,11 @@ export function isConfigured(profileName: string, config: Config): boolean { if (!fs.existsSync(expandedPath)) return false; const settings = loadSettings(expandedPath); - return !!(settings.env?.ANTHROPIC_BASE_URL && settings.env?.ANTHROPIC_AUTH_TOKEN); + // Proxy mode: BASE_URL + AUTH_TOKEN; Native mode: ANTHROPIC_API_KEY only + return !!( + (settings.env?.ANTHROPIC_BASE_URL && settings.env?.ANTHROPIC_AUTH_TOKEN) || + settings.env?.ANTHROPIC_API_KEY + ); } catch { return false; } diff --git a/tests/unit/api/profile-writer-anthropic.test.ts b/tests/unit/api/profile-writer-anthropic.test.ts new file mode 100644 index 00000000..c354eb76 --- /dev/null +++ b/tests/unit/api/profile-writer-anthropic.test.ts @@ -0,0 +1,104 @@ +import { afterEach, beforeEach, describe, expect, it } from 'bun:test'; +import * as fs from 'fs'; +import * as os from 'os'; +import * as path from 'path'; +import { createApiProfile } from '../../../src/api/services/profile-writer'; + +describe('profile-writer Anthropic direct', () => { + let tempHome = ''; + let originalCcsHome: string | undefined; + + beforeEach(() => { + tempHome = fs.mkdtempSync(path.join(os.tmpdir(), 'ccs-profile-writer-')); + originalCcsHome = process.env.CCS_HOME; + process.env.CCS_HOME = tempHome; + }); + + afterEach(() => { + if (originalCcsHome === undefined) { + delete process.env.CCS_HOME; + } else { + process.env.CCS_HOME = originalCcsHome; + } + + if (tempHome && fs.existsSync(tempHome)) { + fs.rmSync(tempHome, { recursive: true, force: true }); + } + }); + + it('creates native env structure for sk-ant- API key', () => { + const result = createApiProfile( + 'anthropic-test', + '', + 'sk-ant-api03-testkey123', + { default: 'claude-sonnet-4-5-20250929', opus: 'claude-opus-4-5-20251101', sonnet: 'claude-sonnet-4-5-20250929', haiku: 'claude-haiku-4-5-20251001' } + ); + + expect(result.success).toBe(true); + + const settingsPath = path.join(tempHome, '.ccs', 'anthropic-test.settings.json'); + const settings = JSON.parse(fs.readFileSync(settingsPath, 'utf8')); + + // Native mode: ANTHROPIC_API_KEY present, no BASE_URL or AUTH_TOKEN + expect(settings.env.ANTHROPIC_API_KEY).toBe('sk-ant-api03-testkey123'); + expect(settings.env.ANTHROPIC_BASE_URL).toBeUndefined(); + expect(settings.env.ANTHROPIC_AUTH_TOKEN).toBeUndefined(); + expect(settings.env.ANTHROPIC_MODEL).toBe('claude-sonnet-4-5-20250929'); + }); + + it('creates native env structure for api.anthropic.com URL', () => { + const result = createApiProfile( + 'anthropic-url', + 'https://api.anthropic.com', + 'some-key-123', + { default: 'claude-sonnet-4-5-20250929', opus: 'claude-sonnet-4-5-20250929', sonnet: 'claude-sonnet-4-5-20250929', haiku: 'claude-sonnet-4-5-20250929' } + ); + + expect(result.success).toBe(true); + + const settingsPath = path.join(tempHome, '.ccs', 'anthropic-url.settings.json'); + const settings = JSON.parse(fs.readFileSync(settingsPath, 'utf8')); + + expect(settings.env.ANTHROPIC_API_KEY).toBe('some-key-123'); + expect(settings.env.ANTHROPIC_BASE_URL).toBeUndefined(); + expect(settings.env.ANTHROPIC_AUTH_TOKEN).toBeUndefined(); + }); + + it('creates proxy env structure for non-Anthropic keys', () => { + const result = createApiProfile( + 'proxy-test', + 'https://api.z.ai/api/anthropic', + 'ghp_testkey123', + { default: 'glm-5', opus: 'glm-5', sonnet: 'glm-5', haiku: 'glm-5' } + ); + + expect(result.success).toBe(true); + + const settingsPath = path.join(tempHome, '.ccs', 'proxy-test.settings.json'); + const settings = JSON.parse(fs.readFileSync(settingsPath, 'utf8')); + + // Proxy mode: BASE_URL + AUTH_TOKEN, no ANTHROPIC_API_KEY + expect(settings.env.ANTHROPIC_BASE_URL).toBe('https://api.z.ai/api/anthropic'); + expect(settings.env.ANTHROPIC_AUTH_TOKEN).toBe('ghp_testkey123'); + expect(settings.env.ANTHROPIC_API_KEY).toBeUndefined(); + }); + + it('preserves OpenRouter ANTHROPIC_API_KEY blank behavior', () => { + const result = createApiProfile( + 'openrouter-test', + 'https://openrouter.ai/api', + 'sk-or-testkey', + { default: 'anthropic/claude-opus-4.5', opus: 'anthropic/claude-opus-4.5', sonnet: 'anthropic/claude-opus-4.5', haiku: 'anthropic/claude-opus-4.5' } + ); + + expect(result.success).toBe(true); + + const settingsPath = path.join(tempHome, '.ccs', 'openrouter-test.settings.json'); + const settings = JSON.parse(fs.readFileSync(settingsPath, 'utf8')); + + // OpenRouter: proxy mode with ANTHROPIC_API_KEY explicitly blank + expect(settings.env.ANTHROPIC_BASE_URL).toBe('https://openrouter.ai/api'); + expect(settings.env.ANTHROPIC_AUTH_TOKEN).toBe('sk-or-testkey'); + expect(settings.env.ANTHROPIC_API_KEY).toBe(''); + }); +});