mirror of
https://github.com/catlog22/Claude-Code-Workflow.git
synced 2026-02-05 01:50:27 +08:00
- #70: Fix API Key Tester URL handling - normalize trailing slashes before version suffix detection to prevent double-slash URLs like //models - #69: Fix memory embedder ignoring CodexLens config - add error handling for CodexLensConfig.load() with fallback to defaults - #68: Fix ccw cli using wrong Python environment - add getCodexLensVenvPython() to resolve correct venv path on Windows/Unix - #67: Fix LiteLLM API Provider test endpoint - actually test API key connection instead of just checking ccw-litellm installation - #66: Fix help-routes.ts path configuration - use correct 'ccw-help' directory name and refactor getIndexDir to pure function - #63: Fix CodexLens install state refresh - add cache invalidation after config save in codexlens-manager.js Also includes targeted unit tests for the URL normalization logic. Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
167 lines
5.4 KiB
TypeScript
167 lines
5.4 KiB
TypeScript
/**
|
|
* API Key Tester Service
|
|
* Shared module for testing API key connectivity across different provider types.
|
|
* Used by both manual testing (litellm-api-routes.ts) and health check service.
|
|
*/
|
|
|
|
import type { ProviderType } from '../../types/litellm-api-config.js';
|
|
|
|
/**
|
|
* Validate API base URL format
|
|
* Note: This is a local development tool, so we allow localhost and internal networks
|
|
* for users who run local API gateways or proxies.
|
|
* @param url - The URL to validate
|
|
* @returns Object with valid flag and optional error message
|
|
*/
|
|
export function validateApiBaseUrl(url: string): { valid: boolean; error?: string } {
|
|
try {
|
|
const parsed = new URL(url);
|
|
|
|
// Must be HTTP or HTTPS
|
|
if (parsed.protocol !== 'https:' && parsed.protocol !== 'http:') {
|
|
return { valid: false, error: 'URL must use HTTP or HTTPS protocol' };
|
|
}
|
|
|
|
return { valid: true };
|
|
} catch {
|
|
return { valid: false, error: 'Invalid URL format' };
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Result of an API key connection test
|
|
*/
|
|
export interface TestResult {
|
|
/** Whether the API key is valid */
|
|
valid: boolean;
|
|
/** Error message if validation failed */
|
|
error?: string;
|
|
/** Latency in milliseconds (only if valid) */
|
|
latencyMs?: number;
|
|
}
|
|
|
|
/**
|
|
* Get default API base URL for a provider type
|
|
*/
|
|
export function getDefaultApiBase(providerType: ProviderType): string {
|
|
const defaults: Record<string, string> = {
|
|
openai: 'https://api.openai.com/v1',
|
|
anthropic: 'https://api.anthropic.com/v1',
|
|
custom: 'https://api.openai.com/v1', // Assume OpenAI-compatible by default
|
|
};
|
|
return defaults[providerType] || defaults.openai;
|
|
}
|
|
|
|
/**
|
|
* Test API key connection by making a minimal API request
|
|
* @param providerType - The type of provider (openai, anthropic, custom)
|
|
* @param apiBase - The base URL for the API
|
|
* @param apiKey - The API key to test
|
|
* @param timeout - Timeout in milliseconds (default: 10000)
|
|
* @returns TestResult indicating if the key is valid
|
|
*/
|
|
export async function testApiKeyConnection(
|
|
providerType: ProviderType,
|
|
apiBase: string,
|
|
apiKey: string,
|
|
timeout: number = 10000
|
|
): Promise<TestResult> {
|
|
// Validate URL to prevent SSRF
|
|
const urlValidation = validateApiBaseUrl(apiBase);
|
|
if (!urlValidation.valid) {
|
|
return { valid: false, error: urlValidation.error };
|
|
}
|
|
|
|
// Normalize apiBase: remove trailing slashes to prevent URL construction issues
|
|
// e.g., "https://api.openai.com/v1/" -> "https://api.openai.com/v1"
|
|
const normalizedApiBase = apiBase.replace(/\/+$/, '');
|
|
|
|
const controller = new AbortController();
|
|
const timeoutId = setTimeout(() => controller.abort(), timeout);
|
|
const startTime = Date.now();
|
|
|
|
try {
|
|
if (providerType === 'anthropic') {
|
|
// Anthropic format: Use /v1/models endpoint (no cost, no model dependency)
|
|
// This validates the API key without making a billable request
|
|
const response = await fetch(`${normalizedApiBase}/models`, {
|
|
method: 'GET',
|
|
headers: {
|
|
'x-api-key': apiKey,
|
|
'anthropic-version': '2023-06-01',
|
|
},
|
|
signal: controller.signal,
|
|
});
|
|
|
|
clearTimeout(timeoutId);
|
|
const latencyMs = Date.now() - startTime;
|
|
|
|
if (response.ok) {
|
|
return { valid: true, latencyMs };
|
|
}
|
|
|
|
// Parse error response
|
|
const errorBody = await response.json().catch(() => ({}));
|
|
const errorMessage = (errorBody as any)?.error?.message || response.statusText;
|
|
|
|
// 401 = invalid API key
|
|
if (response.status === 401) {
|
|
return { valid: false, error: 'Invalid API key' };
|
|
}
|
|
if (response.status === 403) {
|
|
return { valid: false, error: 'Access denied - check API key permissions' };
|
|
}
|
|
if (response.status === 429) {
|
|
// Rate limited means the key is valid but being throttled
|
|
return { valid: true, latencyMs };
|
|
}
|
|
|
|
return { valid: false, error: errorMessage };
|
|
} else {
|
|
// OpenAI-compatible format: GET /v{N}/models
|
|
// Detect if URL already ends with a version pattern like /v1, /v2, /v4, etc.
|
|
const hasVersionSuffix = /\/v\d+$/.test(normalizedApiBase);
|
|
const modelsUrl = hasVersionSuffix ? `${normalizedApiBase}/models` : `${normalizedApiBase}/v1/models`;
|
|
const response = await fetch(modelsUrl, {
|
|
method: 'GET',
|
|
headers: {
|
|
'Authorization': `Bearer ${apiKey}`,
|
|
},
|
|
signal: controller.signal,
|
|
});
|
|
|
|
clearTimeout(timeoutId);
|
|
const latencyMs = Date.now() - startTime;
|
|
|
|
if (response.ok) {
|
|
return { valid: true, latencyMs };
|
|
}
|
|
|
|
// Parse error response
|
|
const errorBody = await response.json().catch(() => ({}));
|
|
const errorMessage = (errorBody as any)?.error?.message || response.statusText;
|
|
|
|
if (response.status === 401) {
|
|
return { valid: false, error: 'Invalid API key' };
|
|
}
|
|
if (response.status === 403) {
|
|
return { valid: false, error: 'Access denied - check API key permissions' };
|
|
}
|
|
if (response.status === 429) {
|
|
// Rate limited means the key is valid but being throttled
|
|
return { valid: true, latencyMs };
|
|
}
|
|
|
|
return { valid: false, error: errorMessage };
|
|
}
|
|
} catch (err) {
|
|
clearTimeout(timeoutId);
|
|
|
|
if ((err as Error).name === 'AbortError') {
|
|
return { valid: false, error: 'Connection timed out' };
|
|
}
|
|
|
|
return { valid: false, error: `Connection failed: ${(err as Error).message}` };
|
|
}
|
|
}
|