Просмотр исходного кода

fix: harden provider api test diagnostics

Abner 2 месяцев назад
Родитель
Сommit
0d0cce6d3d
5 измененных файлов с 223 добавлено и 58 удалено
  1. 4 0
      .env.example
  2. 1 0
      README.en.md
  3. 1 0
      README.md
  4. 216 57
      src/actions/providers.ts
  5. 1 1
      src/lib/logger.ts

+ 4 - 0
.env.example

@@ -18,6 +18,10 @@ APP_PORT=23000
 APP_URL=                                   # 应用访问地址(留空自动检测,生产环境建议显式配置)
                                            # 示例:https://your-domain.com 或 http://192.168.1.100:23000
 
+# API 测试配置
+# API 测试请求超时时间(毫秒),范围 5000-120000。未设置时默认 15000。
+API_TEST_TIMEOUT_MS=15000
+
 # Cookie 安全策略
 # 功能说明:控制是否强制 HTTPS Cookie(设置 cookie 的 secure 属性)
 # - true (默认):仅允许 HTTPS 传输 Cookie,浏览器会自动放行 localhost 的 HTTP

+ 1 - 0
README.en.md

@@ -251,6 +251,7 @@ Docker Compose is the **preferred deployment method** — it automatically provi
 | `ENABLE_CIRCUIT_BREAKER_ON_NETWORK_ERRORS` | `false`                  | When `true`, network errors also trip the circuit breaker for quicker isolation.                     |
 | `APP_PORT`                                 | `23000`                  | Production port (override via container or process manager).                                         |
 | `APP_URL`                                  | empty                    | Populate to expose correct `servers` entries in OpenAPI docs.                                        |
+| `API_TEST_TIMEOUT_MS`                      | `15000`                  | Timeout (ms) for provider API connectivity tests. Accepts 5000-120000 for regional tuning.          |
 
 > Boolean values should be `true/false` or `1/0` without quotes; otherwise Zod may coerce strings incorrectly. See `.env.example` for the full list.
 

+ 1 - 0
README.md

@@ -251,6 +251,7 @@ Docker Compose 是**首选部署方式**,自动配置数据库、Redis 和应
 | `ENABLE_CIRCUIT_BREAKER_ON_NETWORK_ERRORS` | `false`                  | 是否将网络错误计入熔断器;开启后能更激进地阻断异常线路。                     |
 | `APP_PORT`                                 | `23000`                  | 生产端口,可被容器或进程管理器覆盖。                                         |
 | `APP_URL`                                  | 空                       | 设置后 OpenAPI 文档 `servers` 将展示正确域名/端口。                          |
+| `API_TEST_TIMEOUT_MS`                      | `15000`                  | 供应商 API 测试超时时间(毫秒,范围 5000-120000),跨境网络可适当提高。      |
 
 > 布尔变量请直接写 `true/false` 或 `1/0`,勿加引号,避免被 Zod 转换为真值。更多字段参考 `.env.example`。
 

+ 216 - 57
src/actions/providers.ts

@@ -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;

+ 1 - 1
src/lib/logger.ts

@@ -51,7 +51,7 @@ const pinoInstance = pino({
   // timestamp 是顶级配置项,返回格式化的时间字符串
   timestamp: enablePrettyTransport
     ? undefined // pino-pretty 会处理时间格式
-    : () => `,"time":"${new Date().toISOString()}"`,
+    : pino.stdTimeFunctions.isoTime,
   formatters: {
     level: (label) => {
       return { level: label };