|
|
@@ -30,9 +30,43 @@ import { isClientAbortError } from "@/app/v1/_lib/proxy/errors";
|
|
|
import { PROVIDER_TIMEOUT_DEFAULTS } from "@/lib/constants/provider.constants";
|
|
|
import { GeminiAuth } from "@/app/v1/_lib/gemini/auth";
|
|
|
|
|
|
+const API_TEST_TIMEOUT_LIMITS = {
|
|
|
+ DEFAULT: 15000,
|
|
|
+ MIN: 5000,
|
|
|
+ MAX: 120000,
|
|
|
+} as const;
|
|
|
+
|
|
|
+function resolveApiTestTimeoutMs(): number {
|
|
|
+ const rawValue = process.env.API_TEST_TIMEOUT_MS?.trim();
|
|
|
+ if (!rawValue) {
|
|
|
+ return API_TEST_TIMEOUT_LIMITS.DEFAULT;
|
|
|
+ }
|
|
|
+
|
|
|
+ const parsed = Number.parseInt(rawValue, 10);
|
|
|
+ if (!Number.isFinite(parsed)) {
|
|
|
+ logger.warn("API test timeout env is invalid, falling back to default", {
|
|
|
+ envValue: rawValue,
|
|
|
+ defaultTimeout: API_TEST_TIMEOUT_LIMITS.DEFAULT,
|
|
|
+ });
|
|
|
+ return API_TEST_TIMEOUT_LIMITS.DEFAULT;
|
|
|
+ }
|
|
|
+
|
|
|
+ if (parsed < API_TEST_TIMEOUT_LIMITS.MIN || parsed > API_TEST_TIMEOUT_LIMITS.MAX) {
|
|
|
+ logger.warn("API test timeout env is out of supported range", {
|
|
|
+ envValue: parsed,
|
|
|
+ min: API_TEST_TIMEOUT_LIMITS.MIN,
|
|
|
+ max: API_TEST_TIMEOUT_LIMITS.MAX,
|
|
|
+ defaultTimeout: API_TEST_TIMEOUT_LIMITS.DEFAULT,
|
|
|
+ });
|
|
|
+ return API_TEST_TIMEOUT_LIMITS.DEFAULT;
|
|
|
+ }
|
|
|
+
|
|
|
+ return parsed;
|
|
|
+}
|
|
|
+
|
|
|
// API 测试配置常量
|
|
|
const API_TEST_CONFIG = {
|
|
|
- TIMEOUT_MS: 15000, // 15 秒超时
|
|
|
+ TIMEOUT_MS: resolveApiTestTimeoutMs(),
|
|
|
MAX_RESPONSE_PREVIEW_LENGTH: 500, // 响应内容预览最大长度(增加到 500 字符以显示更多内容)
|
|
|
TEST_MAX_TOKENS: 100, // 测试请求的最大 token 数
|
|
|
TEST_PROMPT: "Hello", // 测试请求的默认提示词
|
|
|
@@ -42,6 +76,9 @@ const API_TEST_CONFIG = {
|
|
|
MAX_STREAM_ITERATIONS: 10000, // 最大迭代次数(防止无限循环)
|
|
|
} as const;
|
|
|
|
|
|
+const PROXY_RETRY_STATUS_CODES = new Set([502, 504, 520, 521, 522, 523, 524, 525, 526, 527, 530]);
|
|
|
+const CLOUDFLARE_ERROR_STATUS_CODES = new Set([520, 521, 522, 523, 524, 525, 526, 527, 530]);
|
|
|
+
|
|
|
// 获取服务商数据
|
|
|
export async function getProviders(): Promise<ProviderDisplay[]> {
|
|
|
try {
|
|
|
@@ -987,6 +1024,101 @@ function clipText(value: unknown, maxLength?: number): string | undefined {
|
|
|
return typeof value === "string" ? value.substring(0, limit) : undefined;
|
|
|
}
|
|
|
|
|
|
+function sanitizeErrorTextForLogging(text: string, maxLength = 500): string {
|
|
|
+ if (!text) {
|
|
|
+ return text;
|
|
|
+ }
|
|
|
+
|
|
|
+ let sanitized = text;
|
|
|
+ sanitized = sanitized.replace(/\b(?:sk|rk|pk)-[a-zA-Z0-9]{16,}\b/giu, "[REDACTED_KEY]");
|
|
|
+ sanitized = sanitized.replace(
|
|
|
+ /\b[A-Za-z0-9._%+-]+@[A-Za-z0-9.-]+\.[A-Za-z]{2,}\b/g,
|
|
|
+ "[EMAIL]"
|
|
|
+ );
|
|
|
+ sanitized = sanitized.replace(/Bearer\s+[A-Za-z0-9._\-]+/gi, "Bearer [REDACTED]");
|
|
|
+ sanitized = sanitized.replace(
|
|
|
+ /(password|token|secret)\s*[:=]\s*['\"]?[^'"\s]+['\"]?/gi,
|
|
|
+ "$1:***"
|
|
|
+ );
|
|
|
+ sanitized = sanitized.replace(/\/[\w.-]+\.(?:env|ya?ml|json|conf|ini)/gi, "[PATH]");
|
|
|
+
|
|
|
+ if (sanitized.length > maxLength) {
|
|
|
+ return `${sanitized.slice(0, maxLength)}... (truncated)`;
|
|
|
+ }
|
|
|
+
|
|
|
+ return sanitized;
|
|
|
+}
|
|
|
+
|
|
|
+function extractErrorMessage(errorJson: unknown): string | undefined {
|
|
|
+ if (!errorJson || typeof errorJson !== "object") {
|
|
|
+ return undefined;
|
|
|
+ }
|
|
|
+
|
|
|
+ const candidates: Array<(obj: Record<string, unknown>) => unknown> = [
|
|
|
+ (obj) => (obj.error as Record<string, unknown> | undefined)?.message,
|
|
|
+ (obj) => obj.message,
|
|
|
+ (obj) => (obj as { error_message?: unknown }).error_message,
|
|
|
+ (obj) => obj.detail,
|
|
|
+ (obj) => (obj.error as Record<string, unknown> | undefined)?.error,
|
|
|
+ (obj) => obj.error,
|
|
|
+ ];
|
|
|
+
|
|
|
+ for (const getter of candidates) {
|
|
|
+ let value: unknown;
|
|
|
+ try {
|
|
|
+ value = getter(errorJson as Record<string, unknown>);
|
|
|
+ } catch {
|
|
|
+ continue;
|
|
|
+ }
|
|
|
+
|
|
|
+ const normalized = normalizeErrorValue(value);
|
|
|
+ if (normalized) {
|
|
|
+ return normalized;
|
|
|
+ }
|
|
|
+ }
|
|
|
+
|
|
|
+ return undefined;
|
|
|
+}
|
|
|
+
|
|
|
+function normalizeErrorValue(value: unknown): string | undefined {
|
|
|
+ if (typeof value === "string") {
|
|
|
+ const trimmed = value.trim();
|
|
|
+ return trimmed.length > 0 ? trimmed : undefined;
|
|
|
+ }
|
|
|
+
|
|
|
+ if (typeof value === "number" || typeof value === "boolean") {
|
|
|
+ return String(value);
|
|
|
+ }
|
|
|
+
|
|
|
+ if (value && typeof value === "object") {
|
|
|
+ try {
|
|
|
+ const serialized = JSON.stringify(value);
|
|
|
+ const trimmed = serialized.trim();
|
|
|
+ return trimmed === "{}" || trimmed === "[]" ? undefined : trimmed;
|
|
|
+ } catch {
|
|
|
+ return undefined;
|
|
|
+ }
|
|
|
+ }
|
|
|
+
|
|
|
+ return undefined;
|
|
|
+}
|
|
|
+
|
|
|
+function detectCloudflareGatewayError(response: Response): boolean {
|
|
|
+ const cfRay = response.headers.get("cf-ray");
|
|
|
+ const cfCacheStatus = response.headers.get("cf-cache-status");
|
|
|
+ const server = response.headers.get("server");
|
|
|
+ const via = response.headers.get("via");
|
|
|
+
|
|
|
+ const headerIndicatesCloudflare = Boolean(
|
|
|
+ cfRay ||
|
|
|
+ cfCacheStatus ||
|
|
|
+ (server && server.toLowerCase().includes("cloudflare")) ||
|
|
|
+ (via && via.toLowerCase().includes("cloudflare"))
|
|
|
+ );
|
|
|
+
|
|
|
+ return headerIndicatesCloudflare && CLOUDFLARE_ERROR_STATUS_CODES.has(response.status);
|
|
|
+}
|
|
|
+
|
|
|
/**
|
|
|
* 流式响应解析结果
|
|
|
*/
|
|
|
@@ -1477,7 +1609,7 @@ async function executeProviderApiTest(
|
|
|
options: {
|
|
|
path: string | ((model: string, apiKey: string) => string);
|
|
|
defaultModel: string;
|
|
|
- headers: (apiKey: string) => Record<string, string>;
|
|
|
+ headers: (apiKey: string, context: { providerUrl: string }) => Record<string, string>;
|
|
|
body: (model: string) => unknown;
|
|
|
successMessage: string;
|
|
|
extract: (result: ProviderApiResponse) => {
|
|
|
@@ -1544,7 +1676,7 @@ async function executeProviderApiTest(
|
|
|
const init: UndiciFetchOptions = {
|
|
|
method: "POST",
|
|
|
headers: {
|
|
|
- ...options.headers(data.apiKey),
|
|
|
+ ...options.headers(data.apiKey, { providerUrl: normalizedProviderUrl }),
|
|
|
// 使用更完整的请求头,模拟真实 Claude CLI 行为
|
|
|
// 避免被 Cloudflare Bot 检测拦截
|
|
|
"User-Agent": "claude-cli/2.0.33 (external, cli)",
|
|
|
@@ -1564,27 +1696,21 @@ async function executeProviderApiTest(
|
|
|
let response = await fetch(url, init);
|
|
|
let responseTime = Date.now() - startTime;
|
|
|
|
|
|
- // ⭐ 代理失败降级逻辑:检测常见代理相关错误
|
|
|
- // 520: Cloudflare "Web Server Returned Unknown Error"(常见于代理被 CDN 拦截)
|
|
|
- // 502: Bad Gateway(代理无法连接上游)
|
|
|
- // 504: Gateway Timeout(代理超时)
|
|
|
- const isProxyRelatedError = proxyConfig && [520, 502, 504].includes(response.status);
|
|
|
+ const shouldAttemptDirectRetry =
|
|
|
+ Boolean(proxyConfig?.fallbackToDirect) &&
|
|
|
+ PROXY_RETRY_STATUS_CODES.has(response.status);
|
|
|
|
|
|
- if (isProxyRelatedError && proxyConfig.fallbackToDirect) {
|
|
|
- // 克隆响应,避免消费原始响应体
|
|
|
- const proxyResponse = response.clone();
|
|
|
- const errorText = await proxyResponse.text();
|
|
|
- const isCloudflareError = errorText.includes("cloudflare");
|
|
|
+ if (shouldAttemptDirectRetry) {
|
|
|
+ const isCloudflareError = detectCloudflareGatewayError(response);
|
|
|
|
|
|
logger.warn("Provider API test: Proxy returned error, falling back to direct connection", {
|
|
|
providerId: tempProvider.id,
|
|
|
providerName: tempProvider.name,
|
|
|
proxyStatus: response.status,
|
|
|
- proxyUrl: proxyConfig.proxyUrl,
|
|
|
- isCloudflareError,
|
|
|
+ proxyUrl: proxyConfig?.proxyUrl,
|
|
|
+ fallbackReason: isCloudflareError ? "cloudflare" : "proxy-error",
|
|
|
});
|
|
|
|
|
|
- // 移除代理配置,直连重试
|
|
|
const fallbackInit = { ...init };
|
|
|
delete fallbackInit.dispatcher;
|
|
|
|
|
|
@@ -1598,15 +1724,16 @@ async function executeProviderApiTest(
|
|
|
providerName: tempProvider.name,
|
|
|
directStatus: response.status,
|
|
|
directResponseTime: responseTime,
|
|
|
+ fallbackReason: isCloudflareError ? "cloudflare" : "proxy-error",
|
|
|
});
|
|
|
} catch (directError) {
|
|
|
const directResponseTime = Date.now() - fallbackStartTime;
|
|
|
logger.error("Provider API test: Direct connection also failed", {
|
|
|
providerId: tempProvider.id,
|
|
|
error: directError,
|
|
|
+ fallbackReason: isCloudflareError ? "cloudflare" : "proxy-error",
|
|
|
});
|
|
|
|
|
|
- // 直连也失败,返回组合错误信息
|
|
|
return {
|
|
|
ok: true,
|
|
|
data: {
|
|
|
@@ -1614,7 +1741,9 @@ async function executeProviderApiTest(
|
|
|
message: `代理和直连均失败`,
|
|
|
details: {
|
|
|
responseTime: directResponseTime,
|
|
|
- error: `代理错误: HTTP ${response.status} (${isCloudflareError ? "Cloudflare" : "Unknown"})\n直连错误: ${directError instanceof Error ? directError.message : String(directError)}`,
|
|
|
+ error: `代理错误: HTTP ${response.status} (${isCloudflareError ? "Cloudflare" : "Proxy"})\n直连错误: ${
|
|
|
+ directError instanceof Error ? directError.message : String(directError)
|
|
|
+ }`,
|
|
|
},
|
|
|
},
|
|
|
};
|
|
|
@@ -1623,37 +1752,26 @@ async function executeProviderApiTest(
|
|
|
|
|
|
if (!response.ok) {
|
|
|
const errorText = await response.text();
|
|
|
+ const sanitizedErrorText = sanitizeErrorTextForLogging(errorText);
|
|
|
|
|
|
// 添加 trace 日志记录原始错误响应
|
|
|
logger.trace("Provider API test raw error response", {
|
|
|
providerUrl: normalizedProviderUrl.replace(/:\/\/[^@]*@/, "://***@"),
|
|
|
status: response.status,
|
|
|
- rawErrorText: errorText,
|
|
|
+ rawErrorText: sanitizedErrorText,
|
|
|
+ rawErrorLength: errorText.length,
|
|
|
});
|
|
|
|
|
|
let errorDetail: string | undefined;
|
|
|
try {
|
|
|
const errorJson = JSON.parse(errorText);
|
|
|
+ errorDetail = extractErrorMessage(errorJson);
|
|
|
|
|
|
- // 尝试多种错误路径提取错误信息
|
|
|
- errorDetail =
|
|
|
- errorJson.error?.message || // OpenAI 标准格式
|
|
|
- errorJson.error?.error || // 嵌套 error 对象
|
|
|
- errorJson.message || // 简单 message 字段
|
|
|
- errorJson.error_message || // error_message 字段
|
|
|
- errorJson.detail || // detail 字段
|
|
|
- (errorJson.error && typeof errorJson.error === 'string' ? errorJson.error : undefined); // error 字段是字符串
|
|
|
-
|
|
|
- // 如果以上都没有,尝试将整个 error 对象序列化
|
|
|
- if (!errorDetail && errorJson.error && typeof errorJson.error === 'object') {
|
|
|
- errorDetail = JSON.stringify(errorJson.error);
|
|
|
- }
|
|
|
-
|
|
|
- // 添加 trace 日志记录解析结果
|
|
|
logger.trace("Provider API test parsed error", {
|
|
|
providerUrl: normalizedProviderUrl.replace(/:\/\/[^@]*@/, "://***@"),
|
|
|
extractedDetail: errorDetail,
|
|
|
- errorJsonKeys: Object.keys(errorJson),
|
|
|
+ errorJsonKeys:
|
|
|
+ errorJson && typeof errorJson === "object" ? Object.keys(errorJson) : undefined,
|
|
|
});
|
|
|
} catch (parseError) {
|
|
|
logger.trace("Provider API test failed to parse error JSON", {
|
|
|
@@ -1857,19 +1975,50 @@ async function executeProviderApiTest(
|
|
|
/**
|
|
|
* 测试 Anthropic Messages API 连通性
|
|
|
*/
|
|
|
+function getHostnameFromUrl(url: string): string | null {
|
|
|
+ try {
|
|
|
+ return new URL(url).hostname.toLowerCase();
|
|
|
+ } catch {
|
|
|
+ return null;
|
|
|
+ }
|
|
|
+}
|
|
|
+
|
|
|
+function resolveAnthropicAuthHeaders(apiKey: string, providerUrl: string): Record<string, string> {
|
|
|
+ const headers: Record<string, string> = {
|
|
|
+ "Content-Type": "application/json",
|
|
|
+ "anthropic-version": "2023-06-01",
|
|
|
+ };
|
|
|
+
|
|
|
+ const hostname = getHostnameFromUrl(providerUrl);
|
|
|
+ const isOfficialAnthropic = hostname
|
|
|
+ ? hostname.endsWith("anthropic.com") || hostname.endsWith("claude.ai")
|
|
|
+ : false;
|
|
|
+ const looksLikeProxy = hostname
|
|
|
+ ? /proxy|relay|gateway|router|openai|api2d|openrouter|worker|gpt/i.test(hostname)
|
|
|
+ : false;
|
|
|
+
|
|
|
+ if (isOfficialAnthropic) {
|
|
|
+ headers["x-api-key"] = apiKey;
|
|
|
+ return headers;
|
|
|
+ }
|
|
|
+
|
|
|
+ if (looksLikeProxy) {
|
|
|
+ headers.Authorization = `Bearer ${apiKey}`;
|
|
|
+ return headers;
|
|
|
+ }
|
|
|
+
|
|
|
+ headers["x-api-key"] = apiKey;
|
|
|
+ headers.Authorization = `Bearer ${apiKey}`;
|
|
|
+ return headers;
|
|
|
+}
|
|
|
+
|
|
|
export async function testProviderAnthropicMessages(
|
|
|
data: ProviderApiTestArgs
|
|
|
): Promise<ProviderApiTestResult> {
|
|
|
return executeProviderApiTest(data, {
|
|
|
path: "/v1/messages",
|
|
|
defaultModel: "claude-sonnet-4-5-20250929",
|
|
|
- headers: (apiKey) => ({
|
|
|
- "Content-Type": "application/json",
|
|
|
- "anthropic-version": "2023-06-01",
|
|
|
- // 同时发送两种认证头,兼容官方 API 和第三方中转站
|
|
|
- "x-api-key": apiKey,
|
|
|
- Authorization: `Bearer ${apiKey}`,
|
|
|
- }),
|
|
|
+ headers: (apiKey, context) => resolveAnthropicAuthHeaders(apiKey, context.providerUrl),
|
|
|
body: (model) => ({
|
|
|
model,
|
|
|
max_tokens: API_TEST_CONFIG.TEST_MAX_TOKENS,
|
|
|
@@ -1894,10 +2043,13 @@ export async function testProviderOpenAIChatCompletions(
|
|
|
return executeProviderApiTest(data, {
|
|
|
path: "/v1/chat/completions",
|
|
|
defaultModel: "gpt-5.1-codex",
|
|
|
- headers: (apiKey) => ({
|
|
|
- "Content-Type": "application/json",
|
|
|
- Authorization: `Bearer ${apiKey}`,
|
|
|
- }),
|
|
|
+ headers: (apiKey, context) => {
|
|
|
+ void context;
|
|
|
+ return {
|
|
|
+ "Content-Type": "application/json",
|
|
|
+ Authorization: `Bearer ${apiKey}`,
|
|
|
+ };
|
|
|
+ },
|
|
|
body: (model) => ({
|
|
|
model,
|
|
|
max_tokens: API_TEST_CONFIG.TEST_MAX_TOKENS,
|
|
|
@@ -1924,10 +2076,13 @@ export async function testProviderOpenAIResponses(
|
|
|
return executeProviderApiTest(data, {
|
|
|
path: "/v1/responses",
|
|
|
defaultModel: "gpt-5.1-codex",
|
|
|
- headers: (apiKey) => ({
|
|
|
- "Content-Type": "application/json",
|
|
|
- Authorization: `Bearer ${apiKey}`,
|
|
|
- }),
|
|
|
+ headers: (apiKey, context) => {
|
|
|
+ void context;
|
|
|
+ return {
|
|
|
+ "Content-Type": "application/json",
|
|
|
+ Authorization: `Bearer ${apiKey}`,
|
|
|
+ };
|
|
|
+ },
|
|
|
body: (model) => ({
|
|
|
model,
|
|
|
// 注意:不包含 max_output_tokens,因为某些中转服务不支持此参数
|
|
|
@@ -1983,7 +2138,8 @@ export async function testProviderGemini(
|
|
|
return `/v1beta/models/${model}:generateContent`;
|
|
|
},
|
|
|
defaultModel: "gemini-1.5-pro",
|
|
|
- headers: (apiKey) => {
|
|
|
+ headers: (apiKey, context) => {
|
|
|
+ void context;
|
|
|
const headers: Record<string, string> = {
|
|
|
"Content-Type": "application/json",
|
|
|
};
|
|
|
@@ -1992,12 +2148,15 @@ export async function testProviderGemini(
|
|
|
}
|
|
|
return headers;
|
|
|
},
|
|
|
- body: (model) => ({
|
|
|
- contents: [{ parts: [{ text: API_TEST_CONFIG.TEST_PROMPT }] }],
|
|
|
- generationConfig: {
|
|
|
- maxOutputTokens: API_TEST_CONFIG.TEST_MAX_TOKENS,
|
|
|
- },
|
|
|
- }),
|
|
|
+ body: (model) => {
|
|
|
+ void model;
|
|
|
+ return {
|
|
|
+ contents: [{ parts: [{ text: API_TEST_CONFIG.TEST_PROMPT }] }],
|
|
|
+ generationConfig: {
|
|
|
+ maxOutputTokens: API_TEST_CONFIG.TEST_MAX_TOKENS,
|
|
|
+ },
|
|
|
+ };
|
|
|
+ },
|
|
|
successMessage: "Gemini API 测试成功",
|
|
|
extract: (result) => {
|
|
|
const geminiResult = result as GeminiResponse;
|