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..2404b202 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,20 +81,27 @@ 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: '' }), }, }; + fs.mkdirSync(ccsDir, { recursive: true }); fs.writeFileSync(settingsPath, JSON.stringify(settings, null, 2) + '\n', 'utf8'); // Inject WebSearch hooks into profile settings @@ -151,17 +163,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 0c8ebded..1555d5f4 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') { @@ -1103,14 +1131,17 @@ async function main(): Promise { console.error(fail(`Target adapter not found for "${resolvedTarget}"`)); process.exit(1); } + const directAnthropicBaseUrl = + settingsEnv['ANTHROPIC_BASE_URL'] || + (settingsEnv['ANTHROPIC_API_KEY'] ? 'https://api.anthropic.com' : ''); const creds: TargetCredentials = { profile: profileInfo.name, - baseUrl: settingsEnv['ANTHROPIC_BASE_URL'] || '', - apiKey: settingsEnv['ANTHROPIC_AUTH_TOKEN'] || '', + baseUrl: directAnthropicBaseUrl, + apiKey: settingsEnv['ANTHROPIC_AUTH_TOKEN'] || settingsEnv['ANTHROPIC_API_KEY'] || '', model: settingsEnv['ANTHROPIC_MODEL'], provider: resolveDroidProvider({ provider: settingsEnv['CCS_DROID_PROVIDER'] || settingsEnv['DROID_PROVIDER'], - baseUrl: settingsEnv['ANTHROPIC_BASE_URL'], + baseUrl: directAnthropicBaseUrl, model: settingsEnv['ANTHROPIC_MODEL'], }), reasoningOverride: droidReasoningOverride, diff --git a/src/commands/api-command.ts b/src/commands/api-command.ts index f8b49150..82903383 100644 --- a/src/commands/api-command.ts +++ b/src/commands/api-command.ts @@ -292,9 +292,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 } @@ -328,10 +328,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; @@ -456,7 +467,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}`)); @@ -946,6 +957,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/env-command.ts b/src/commands/env-command.ts index f18a5f1a..a2f1aee5 100644 --- a/src/commands/env-command.ts +++ b/src/commands/env-command.ts @@ -55,7 +55,7 @@ export function formatExportLine(shell: ShellType, key: string, value: string): * can discover the model without additional configuration. */ export function transformToOpenAI(envVars: Record): Record { const baseUrl = envVars['ANTHROPIC_BASE_URL'] || ''; - const apiKey = envVars['ANTHROPIC_AUTH_TOKEN'] || ''; + const apiKey = envVars['ANTHROPIC_AUTH_TOKEN'] || envVars['ANTHROPIC_API_KEY'] || ''; const model = envVars['ANTHROPIC_MODEL'] || ''; const result: Record = {}; if (apiKey) result['OPENAI_API_KEY'] = apiKey; diff --git a/src/commands/help-command.ts b/src/commands/help-command.ts index ca11447f..460ba555 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 discover --register', 'Discover/register orphan settings files'], ['ccs api copy ', 'Duplicate API profile'], 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/profile-routes.ts b/src/web-server/routes/profile-routes.ts index a71247e8..f26071a1 100644 --- a/src/web-server/routes/profile-routes.ts +++ b/src/web-server/routes/profile-routes.ts @@ -21,7 +21,7 @@ import { validateApiName, } from '../../api/services'; import { normalizeDroidProvider } from '../../targets/droid-provider'; -import { updateSettingsFile, parseTarget } from './route-helpers'; +import { isAnthropicDirectProfile, updateSettingsFile, parseTarget } from './route-helpers'; const router = Router(); @@ -97,6 +97,9 @@ router.post('/', (req: Request, res: Response): void => { const { name, baseUrl, apiKey, model, opusModel, sonnetModel, haikuModel, target } = req.body; const providerHint = req.body?.droidProvider ?? req.body?.provider; const parsedProvider = normalizeDroidProvider(providerHint); + const normalizedBaseUrl = typeof baseUrl === 'string' ? baseUrl.trim() : ''; + const normalizedApiKey = typeof apiKey === 'string' ? apiKey.trim() : ''; + const allowsEmptyBaseUrl = isAnthropicDirectProfile(normalizedBaseUrl, normalizedApiKey); const parsedTarget = parseTarget(target); if (target !== undefined && parsedTarget === null) { @@ -110,8 +113,10 @@ router.post('/', (req: Request, res: Response): void => { return; } - if (!name || !baseUrl || !apiKey) { - res.status(400).json({ error: 'Missing required fields: name, baseUrl, apiKey' }); + if (!name || !normalizedApiKey || (!normalizedBaseUrl && !allowsEmptyBaseUrl)) { + res.status(400).json({ + error: 'Missing required fields: name, apiKey, and baseUrl for proxy profiles', + }); return; } @@ -133,8 +138,8 @@ router.post('/', (req: Request, res: Response): void => { // Create profile using unified-config-aware service const result = createApiProfile( name, - baseUrl, - apiKey, + normalizedBaseUrl, + normalizedApiKey, { default: model || '', opus: opusModel || model || '', @@ -355,6 +360,8 @@ router.put('/:name', (req: Request, res: Response): void => { const { baseUrl, apiKey, model, opusModel, sonnetModel, haikuModel, target } = req.body; const providerHint = req.body?.droidProvider ?? req.body?.provider; const parsedProvider = normalizeDroidProvider(providerHint); + const normalizedBaseUrl = typeof baseUrl === 'string' ? baseUrl.trim() : baseUrl; + const normalizedApiKey = typeof apiKey === 'string' ? apiKey.trim() : apiKey; const parsedTarget = parseTarget(target); if (target !== undefined && parsedTarget === null) { @@ -375,11 +382,7 @@ router.put('/:name', (req: Request, res: Response): void => { } // Validate required fields if provided (prevent setting to empty) - if (baseUrl !== undefined && !baseUrl.trim()) { - res.status(400).json({ error: 'baseUrl cannot be empty' }); - return; - } - if (apiKey !== undefined && !apiKey.trim()) { + if (normalizedApiKey !== undefined && !normalizedApiKey) { res.status(400).json({ error: 'apiKey cannot be empty' }); return; } @@ -402,8 +405,8 @@ router.put('/:name', (req: Request, res: Response): void => { if (hasSettingsUpdates) { updateSettingsFile(name, { - baseUrl, - apiKey, + baseUrl: normalizedBaseUrl, + apiKey: normalizedApiKey, model, opusModel, sonnetModel, diff --git a/src/web-server/routes/route-helpers.ts b/src/web-server/routes/route-helpers.ts index 2b38e486..7b8f785e 100644 --- a/src/web-server/routes/route-helpers.ts +++ b/src/web-server/routes/route-helpers.ts @@ -82,6 +82,19 @@ function canonicalizeModelForProvider( return canonicalizeModelIdForProvider(value, provider); } +function isOpenRouterUrl(baseUrl: string): boolean { + return baseUrl.toLowerCase().includes('openrouter.ai'); +} + +export function isAnthropicDirectProfile( + baseUrl: string | undefined | null, + apiKey: string | undefined | null +): boolean { + const normalizedBaseUrl = baseUrl?.trim().toLowerCase() || ''; + const normalizedApiKey = apiKey?.trim() || ''; + return normalizedApiKey.startsWith('sk-ant-') || normalizedBaseUrl.includes('api.anthropic.com'); +} + /** * Read config safely with fallback. * Uses loadConfigSafe which supports both unified (config.yaml) and legacy (config.json). @@ -116,7 +129,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; } @@ -165,11 +182,19 @@ export function createSettingsFile( baseUrl, model: canonicalModel, }); + const normalizedBaseUrl = baseUrl.trim(); + const normalizedApiKey = apiKey.trim(); + const isNative = isAnthropicDirectProfile(normalizedBaseUrl, normalizedApiKey); const settings: Settings = { env: { - ANTHROPIC_BASE_URL: baseUrl, - ANTHROPIC_AUTH_TOKEN: apiKey, + ...(isNative + ? { ANTHROPIC_API_KEY: normalizedApiKey } + : { + ANTHROPIC_BASE_URL: normalizedBaseUrl, + ANTHROPIC_AUTH_TOKEN: normalizedApiKey, + ...(isOpenRouterUrl(normalizedBaseUrl) && { ANTHROPIC_API_KEY: '' }), + }), ...(canonicalModel && { ANTHROPIC_MODEL: canonicalModel }), ...(canonicalOpusModel && { ANTHROPIC_DEFAULT_OPUS_MODEL: canonicalOpusModel }), ...(canonicalSonnetModel && { ANTHROPIC_DEFAULT_SONNET_MODEL: canonicalSonnetModel }), @@ -205,8 +230,23 @@ export function updateSettingsFile( } const settings = loadSettings(settingsPath); + const currentBaseUrl = settings.env?.ANTHROPIC_BASE_URL?.trim() || ''; + const currentApiKey = + settings.env?.ANTHROPIC_API_KEY?.trim() || settings.env?.ANTHROPIC_AUTH_TOKEN?.trim() || ''; + const nextBaseUrl = updates.baseUrl !== undefined ? updates.baseUrl.trim() : currentBaseUrl; + const nextApiKey = updates.apiKey !== undefined ? updates.apiKey.trim() : currentApiKey; + const isNative = isAnthropicDirectProfile(nextBaseUrl, nextApiKey); + + if (!nextApiKey) { + throw new ValidationError('apiKey cannot be empty', 'apiKey'); + } + + if (!isNative && nextBaseUrl.length === 0) { + throw new ValidationError('baseUrl cannot be empty', 'baseUrl'); + } + const providerForValidation = - resolveProviderForModelCanonicalization(updates.baseUrl, updates.provider) ?? + resolveProviderForModelCanonicalization(nextBaseUrl, updates.provider) ?? resolveProviderForModelCanonicalization( settings.env?.ANTHROPIC_BASE_URL, updates.provider ?? settings.env?.CCS_DROID_PROVIDER @@ -243,14 +283,19 @@ export function updateSettingsFile( throw new ValidationError(deniedReason, 'model'); } - if (updates.baseUrl) { - settings.env = settings.env || {}; - settings.env.ANTHROPIC_BASE_URL = updates.baseUrl; - } - - if (updates.apiKey) { - settings.env = settings.env || {}; - settings.env.ANTHROPIC_AUTH_TOKEN = updates.apiKey; + settings.env = settings.env || {}; + if (isNative) { + delete settings.env.ANTHROPIC_BASE_URL; + delete settings.env.ANTHROPIC_AUTH_TOKEN; + settings.env.ANTHROPIC_API_KEY = nextApiKey; + } else { + settings.env.ANTHROPIC_BASE_URL = nextBaseUrl; + settings.env.ANTHROPIC_AUTH_TOKEN = nextApiKey; + if (isOpenRouterUrl(nextBaseUrl)) { + settings.env.ANTHROPIC_API_KEY = ''; + } else { + delete settings.env.ANTHROPIC_API_KEY; + } } if (updates.model !== undefined) { @@ -299,7 +344,7 @@ export function updateSettingsFile( settings.env = settings.env || {}; const resolvedProvider = resolveDroidProvider({ provider: updates.provider ?? settings.env.CCS_DROID_PROVIDER, - baseUrl: updates.baseUrl ?? settings.env.ANTHROPIC_BASE_URL, + baseUrl: nextBaseUrl, model: canonicalModel ?? settings.env.ANTHROPIC_MODEL, }); settings.env.CCS_DROID_PROVIDER = resolvedProvider; diff --git a/src/web-server/routes/settings-routes.ts b/src/web-server/routes/settings-routes.ts index 69dc878e..4f293f7b 100644 --- a/src/web-server/routes/settings-routes.ts +++ b/src/web-server/routes/settings-routes.ts @@ -366,6 +366,9 @@ const REQUIRED_ENV_KEYS = ['ANTHROPIC_BASE_URL', 'ANTHROPIC_AUTH_TOKEN'] as cons /** Check if settings have required fields (returns missing list for warnings) */ function checkRequiredEnvVars(settings: Settings): string[] { const env = settings?.env || {}; + if (env.ANTHROPIC_API_KEY?.trim() && !env.ANTHROPIC_BASE_URL?.trim()) { + return []; + } return REQUIRED_ENV_KEYS.filter((key) => !env[key]?.trim()); } 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(''); + }); +}); diff --git a/tests/unit/commands/env-command.test.ts b/tests/unit/commands/env-command.test.ts index 9c2fcfd7..8837005d 100644 --- a/tests/unit/commands/env-command.test.ts +++ b/tests/unit/commands/env-command.test.ts @@ -134,6 +134,18 @@ describe('env-command', () => { expect(result).toEqual({}); }); + it('maps native Anthropic API key to OpenAI format', () => { + const result = transformToOpenAI({ + ANTHROPIC_API_KEY: 'sk-ant-api03-test', + ANTHROPIC_MODEL: 'claude-sonnet-4-5-20250929', + }); + + expect(result).toEqual({ + OPENAI_API_KEY: 'sk-ant-api03-test', + OPENAI_MODEL: 'claude-sonnet-4-5-20250929', + }); + }); + it('only extracts relevant vars', () => { const result = transformToOpenAI({ ANTHROPIC_BASE_URL: 'http://localhost:8317', diff --git a/tests/unit/web-server/profile-routes-anthropic-direct.test.ts b/tests/unit/web-server/profile-routes-anthropic-direct.test.ts new file mode 100644 index 00000000..a5416914 --- /dev/null +++ b/tests/unit/web-server/profile-routes-anthropic-direct.test.ts @@ -0,0 +1,115 @@ +import { afterAll, afterEach, beforeAll, beforeEach, describe, expect, it } from 'bun:test'; +import express from 'express'; +import * as fs from 'fs'; +import * as os from 'os'; +import * as path from 'path'; +import type { Server } from 'http'; +import profileRoutes from '../../../src/web-server/routes/profile-routes'; + +describe('profile-routes Anthropic direct', () => { + let server: Server; + let baseUrl = ''; + let tempHome = ''; + let originalCcsHome: string | undefined; + + beforeAll(async () => { + const app = express(); + app.use(express.json()); + app.use('/api/profiles', profileRoutes); + + await new Promise((resolve, reject) => { + server = app.listen(0, '127.0.0.1'); + const onError = (error: Error) => reject(error); + server.once('error', onError); + server.once('listening', () => { + server.off('error', onError); + resolve(); + }); + }); + + const address = server.address(); + if (!address || typeof address === 'string') { + throw new Error('Unable to resolve test server port'); + } + baseUrl = `http://127.0.0.1:${address.port}`; + }); + + afterAll(async () => { + await new Promise((resolve) => server.close(() => resolve())); + }); + + beforeEach(() => { + tempHome = fs.mkdtempSync(path.join(os.tmpdir(), 'ccs-profile-routes-')); + originalCcsHome = process.env.CCS_HOME; + process.env.CCS_HOME = tempHome; + }); + + afterEach(() => { + if (originalCcsHome !== undefined) { + process.env.CCS_HOME = originalCcsHome; + } else { + delete process.env.CCS_HOME; + } + + if (tempHome && fs.existsSync(tempHome)) { + fs.rmSync(tempHome, { recursive: true, force: true }); + } + }); + + it('creates direct Anthropic profiles without baseUrl', async () => { + const response = await fetch(`${baseUrl}/api/profiles`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ + name: 'anthropic-direct', + baseUrl: '', + apiKey: 'sk-ant-api03-create', + model: 'claude-sonnet-4-5-20250929', + }), + }); + + expect(response.status).toBe(201); + + const settingsPath = path.join(tempHome, '.ccs', 'anthropic-direct.settings.json'); + const persisted = JSON.parse(fs.readFileSync(settingsPath, 'utf8')) as { + env: Record; + }; + + expect(persisted.env.ANTHROPIC_API_KEY).toBe('sk-ant-api03-create'); + expect(persisted.env.ANTHROPIC_BASE_URL).toBeUndefined(); + expect(persisted.env.ANTHROPIC_AUTH_TOKEN).toBeUndefined(); + }); + + it('updates proxy profiles into direct Anthropic mode', async () => { + const createResponse = await fetch(`${baseUrl}/api/profiles`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ + name: 'anthropic-switch', + baseUrl: 'https://api.z.ai/api/anthropic', + apiKey: 'proxy-token', + model: 'glm-5', + }), + }); + expect(createResponse.status).toBe(201); + + const updateResponse = await fetch(`${baseUrl}/api/profiles/anthropic-switch`, { + method: 'PUT', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ + baseUrl: '', + apiKey: 'sk-ant-api03-update', + }), + }); + expect(updateResponse.status).toBe(200); + + const settingsPath = path.join(tempHome, '.ccs', 'anthropic-switch.settings.json'); + const persisted = JSON.parse(fs.readFileSync(settingsPath, 'utf8')) as { + env: Record; + }; + + expect(persisted.env.ANTHROPIC_API_KEY).toBe('sk-ant-api03-update'); + expect(persisted.env.ANTHROPIC_BASE_URL).toBeUndefined(); + expect(persisted.env.ANTHROPIC_AUTH_TOKEN).toBeUndefined(); + }); +}); diff --git a/tests/unit/web-server/route-helpers-model-denylist.test.ts b/tests/unit/web-server/route-helpers-model-denylist.test.ts index f9df3665..d2de4519 100644 --- a/tests/unit/web-server/route-helpers-model-denylist.test.ts +++ b/tests/unit/web-server/route-helpers-model-denylist.test.ts @@ -211,4 +211,52 @@ describe('route-helpers AGY denylist', () => { expect(persisted.env.ANTHROPIC_DEFAULT_SONNET_MODEL).toBe('deepseek-v3.2'); expect(persisted.env.ANTHROPIC_DEFAULT_HAIKU_MODEL).toBe('qwen3-coder-plus'); }); + + it('creates native Anthropic settings without base URL', () => { + createSettingsFile('anthropic-direct', '', 'sk-ant-api03-test', { + model: 'claude-sonnet-4-5-20250929', + }); + + const settingsPath = path.join(tempHome, '.ccs', 'anthropic-direct.settings.json'); + const persisted = JSON.parse(fs.readFileSync(settingsPath, 'utf8')) as { + env: Record; + }; + + expect(persisted.env.ANTHROPIC_API_KEY).toBe('sk-ant-api03-test'); + expect(persisted.env.ANTHROPIC_BASE_URL).toBeUndefined(); + expect(persisted.env.ANTHROPIC_AUTH_TOKEN).toBeUndefined(); + }); + + it('switches proxy settings to native Anthropic mode on update', () => { + const settingsDir = path.join(tempHome, '.ccs'); + fs.mkdirSync(settingsDir, { recursive: true }); + const settingsPath = path.join(settingsDir, 'anthropic-update.settings.json'); + fs.writeFileSync( + settingsPath, + JSON.stringify( + { + env: { + ANTHROPIC_BASE_URL: 'https://api.z.ai/api/anthropic', + ANTHROPIC_AUTH_TOKEN: 'proxy-token', + ANTHROPIC_MODEL: 'claude-sonnet-4-5-20250929', + }, + }, + null, + 2 + ) + '\n' + ); + + updateSettingsFile('anthropic-update', { + baseUrl: '', + apiKey: 'sk-ant-api03-test', + }); + + const persisted = JSON.parse(fs.readFileSync(settingsPath, 'utf8')) as { + env: Record; + }; + + expect(persisted.env.ANTHROPIC_API_KEY).toBe('sk-ant-api03-test'); + expect(persisted.env.ANTHROPIC_BASE_URL).toBeUndefined(); + expect(persisted.env.ANTHROPIC_AUTH_TOKEN).toBeUndefined(); + }); }); diff --git a/ui/src/components/profiles/profile-create-dialog.tsx b/ui/src/components/profiles/profile-create-dialog.tsx index 0131b28a..f8993fb3 100644 --- a/ui/src/components/profiles/profile-create-dialog.tsx +++ b/ui/src/components/profiles/profile-create-dialog.tsx @@ -51,12 +51,18 @@ import type { CategorizedModel } from '@/lib/openrouter-types'; import type { CliTarget } from '@/lib/api-client'; import i18n from '@/lib/i18n'; +const optionalUrlSchema = z + .string() + .refine((value) => value.trim().length === 0 || z.string().url().safeParse(value).success, { + message: 'Invalid URL format', + }); + const schema = z.object({ name: z .string() .min(1, 'Name is required') .regex(/^[a-zA-Z][a-zA-Z0-9._-]*$/, 'Must start with letter, only letters/numbers/.-_'), - baseUrl: z.string().url('Invalid URL format'), + baseUrl: optionalUrlSchema, apiKey: z.string(), // Validation handled conditionally in onSubmit model: z.string().optional(), opusModel: z.string().optional(), @@ -389,9 +395,7 @@ export function ProfileCreateDialog({ {/* Base URL - always editable, pre-filled from preset */}
- + ) : currentPreset ? (

- Pre-filled from {currentPreset.name}. You can customize if needed. + {currentPreset.baseUrl + ? `Pre-filled from ${currentPreset.name}. You can customize if needed.` + : `Optional for ${currentPreset.name}. Leave blank to use native Anthropic auth.`}

) : (

diff --git a/ui/src/components/profiles/profile-dialog.tsx b/ui/src/components/profiles/profile-dialog.tsx index f880a6e4..2e160bde 100644 --- a/ui/src/components/profiles/profile-dialog.tsx +++ b/ui/src/components/profiles/profile-dialog.tsx @@ -19,13 +19,18 @@ import { ChevronDown, ChevronRight } from 'lucide-react'; import { useTranslation } from 'react-i18next'; const DEFAULT_MODEL = 'claude-sonnet-4-6'; +const optionalUrlSchema = z + .string() + .refine((value) => value.trim().length === 0 || z.string().url().safeParse(value).success, { + message: 'Invalid URL', + }); const schema = z.object({ name: z .string() .min(1, 'Name is required') .regex(/^[a-zA-Z][a-zA-Z0-9._-]*$/, 'Invalid profile name'), - baseUrl: z.string().url('Invalid URL'), + baseUrl: optionalUrlSchema, apiKey: z.string().min(10, 'API key must be at least 10 characters'), model: z.string().optional(), opusModel: z.string().optional(),