Files
ccs/src/utils/api-key-validator.ts
T
BbChip0103andClaude Opus 4.6 fedb4d4cde 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>
2026-03-05 13:13:10 +09:00

242 lines
5.9 KiB
TypeScript

/**
* API Key Pre-flight Validator
*
* Quick validation of API keys before Claude CLI launch.
* Catches expired keys early with actionable error messages.
*/
import * as http from 'http';
import * as https from 'https';
import { URL } from 'url';
export interface ValidationResult {
valid: boolean;
error?: string;
suggestion?: string;
}
/** Default placeholders that indicate unconfigured keys */
const DEFAULT_PLACEHOLDERS = [
'YOUR_GLM_API_KEY_HERE',
'YOUR_KIMI_API_KEY_HERE',
'YOUR_MINIMAX_API_KEY_HERE',
'YOUR_API_KEY_HERE',
'YOUR-API-KEY-HERE',
'PLACEHOLDER',
'',
];
interface ProviderConfig {
name: string;
profile: string;
defaultBaseUrl: string;
path: string;
displayName: string;
dashboardUrl: string;
}
async function validateProviderKey(
apiKey: string,
config: ProviderConfig,
baseUrl?: 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: 'API key not configured',
suggestion:
`Set ANTHROPIC_AUTH_TOKEN in ~/.ccs/${config.profile}.settings.json\n` +
`Or run: ccs config -> API Profiles -> ${config.name}`,
};
}
const targetBase = baseUrl || config.defaultBaseUrl;
let url: URL;
try {
url = new URL(config.path, targetBase);
} catch {
return { valid: true };
}
return new Promise((resolve) => {
let resolved = false;
const safeResolve = (result: ValidationResult) => {
if (resolved) return;
resolved = true;
resolve(result);
};
const isHttps = url.protocol === 'https:';
const httpModule = isHttps ? https : http;
const defaultPort = isHttps ? 443 : 80;
const options: https.RequestOptions = {
hostname: url.hostname,
port: url.port || defaultPort,
path: url.pathname,
method: 'GET',
headers: {
Authorization: `Bearer ${apiKey}`,
'User-Agent': 'CCS-Preflight/1.0',
},
};
const req = httpModule.request(options, (res) => {
clearTimeout(timeoutId);
if (res.statusCode === 200) {
safeResolve({ valid: true });
} else if (res.statusCode === 401 || res.statusCode === 403) {
safeResolve({
valid: false,
error: `API key rejected by ${config.displayName}`,
suggestion:
`Your key may have expired. To fix:\n` +
` 1. Go to ${config.dashboardUrl} and regenerate your API key\n` +
` 2. Update ~/.ccs/${config.profile}.settings.json with new key\n` +
` 3. Or run: ccs config -> API Profiles -> ${config.name}`,
});
} else {
if (process.env.CCS_DEBUG === '1') {
console.error(
`[CCS-Preflight] Unexpected status ${res.statusCode} from ${url.href} - fail-open`
);
}
safeResolve({ valid: true });
}
res.resume();
});
req.on('error', () => {
clearTimeout(timeoutId);
safeResolve({ valid: true });
});
const timeoutId = setTimeout(() => {
req.destroy();
safeResolve({ valid: true });
}, timeoutMs);
req.end();
});
}
export async function validateGlmKey(
apiKey: string,
baseUrl?: string,
timeoutMs?: number
): Promise<ValidationResult> {
return validateProviderKey(
apiKey,
{
name: 'GLM',
profile: 'glm',
defaultBaseUrl: 'https://api.z.ai',
path: '/api/anthropic/v1/models',
displayName: 'Z.AI',
dashboardUrl: 'Z.AI dashboard',
},
baseUrl,
timeoutMs
);
}
export async function validateMiniMaxKey(
apiKey: string,
baseUrl?: string,
timeoutMs?: number
): Promise<ValidationResult> {
return validateProviderKey(
apiKey,
{
name: 'MiniMax',
profile: 'mm',
defaultBaseUrl: 'https://api.minimax.io',
path: '/anthropic/v1/models',
displayName: 'MiniMax',
dashboardUrl: 'platform.minimax.io',
},
baseUrl,
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();
});
}