mirror of
https://github.com/tiennm99/ccs.git
synced 2026-09-20 05:11:48 +00:00
feat(api): add Anthropic direct API key support
Support native Anthropic API keys (sk-ant-...) via `ccs api create --preset anthropic`. Profiles use ANTHROPIC_API_KEY env var without BASE_URL, letting Claude CLI authenticate natively with x-api-key header instead of proxy Bearer token. - Add anthropic preset to provider catalog - Branch profile-writer env structure (native vs proxy) - Add validateAnthropicKey() preflight with x-api-key header - Auto-detect api.anthropic.com URL in interactive flow - Fix hasApiKey/isConfigured to recognize ANTHROPIC_API_KEY - Update delegation-validator for native mode profiles - Add Droid target API key fallback (ANTHROPIC_API_KEY) Closes #688 Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
1 parent
0c4d5244c8
commit
fedb4d4cde
11 files changed
+301
-20
No files matched your search
@@ -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;
|
||||
|
||||
@@ -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: '' }),
|
||||
},
|
||||
};
|
||||
|
||||
|
||||
+30
-2
@@ -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<void> {
|
||||
}
|
||||
}
|
||||
|
||||
// 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<void> {
|
||||
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'],
|
||||
|
||||
@@ -261,9 +261,9 @@ async function handleCreate(args: string[]): Promise<void> {
|
||||
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<void> {
|
||||
// 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<void> {
|
||||
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<void> {
|
||||
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')}`);
|
||||
|
||||
@@ -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'],
|
||||
|
||||
@@ -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<Record<string, ProviderPresetId>>
|
||||
});
|
||||
|
||||
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',
|
||||
|
||||
@@ -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<ValidationResult> {
|
||||
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();
|
||||
});
|
||||
}
|
||||
@@ -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`,
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
Reference in new issue
Block a user