From 1f29fa0b6ab366e558eadbe050e79e294f2eaa43 Mon Sep 17 00:00:00 2001 From: Tam Nhu Tran Date: Sat, 7 Mar 2026 15:58:36 +0700 Subject: [PATCH] fix(api): complete anthropic direct profile support --- src/api/services/profile-writer.ts | 1 + src/ccs.ts | 7 +- src/commands/env-command.ts | 2 +- src/web-server/routes/profile-routes.ts | 27 ++-- src/web-server/routes/route-helpers.ts | 65 ++++++++-- src/web-server/routes/settings-routes.ts | 3 + tests/unit/commands/env-command.test.ts | 12 ++ .../profile-routes-anthropic-direct.test.ts | 115 ++++++++++++++++++ .../route-helpers-model-denylist.test.ts | 48 ++++++++ .../profiles/profile-create-dialog.tsx | 16 ++- ui/src/components/profiles/profile-dialog.tsx | 7 +- 11 files changed, 270 insertions(+), 33 deletions(-) create mode 100644 tests/unit/web-server/profile-routes-anthropic-direct.test.ts diff --git a/src/api/services/profile-writer.ts b/src/api/services/profile-writer.ts index eb969f9e..2404b202 100644 --- a/src/api/services/profile-writer.ts +++ b/src/api/services/profile-writer.ts @@ -101,6 +101,7 @@ function createSettingsFile( }, }; + fs.mkdirSync(ccsDir, { recursive: true }); fs.writeFileSync(settingsPath, JSON.stringify(settings, null, 2) + '\n', 'utf8'); // Inject WebSearch hooks into profile settings diff --git a/src/ccs.ts b/src/ccs.ts index a1abfb31..57fa6d55 100644 --- a/src/ccs.ts +++ b/src/ccs.ts @@ -1131,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'] || '', + 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/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/web-server/routes/profile-routes.ts b/src/web-server/routes/profile-routes.ts index f8c87cab..aa9034de 100644 --- a/src/web-server/routes/profile-routes.ts +++ b/src/web-server/routes/profile-routes.ts @@ -14,7 +14,7 @@ import { } from '../../api/services/profile-writer'; import { apiProfileExists, listApiProfiles } from '../../api/services/profile-reader'; import { normalizeDroidProvider } from '../../targets/droid-provider'; -import { updateSettingsFile, parseTarget } from './route-helpers'; +import { isAnthropicDirectProfile, updateSettingsFile, parseTarget } from './route-helpers'; const router = Router(); @@ -50,6 +50,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) { @@ -63,8 +66,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; } @@ -86,8 +91,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 || '', @@ -119,6 +124,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) { @@ -139,11 +146,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; } @@ -166,8 +169,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 58a04ca9..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). @@ -169,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 }), @@ -209,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 @@ -247,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) { @@ -303,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/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 b6873580..a73a2ad9 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-5-20250929'; +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(),