Parcourir la source

Merge pull request #132 from Silentely/dev

新增供应商模型测试功能,修复部分i88n问题,
Ding il y a 2 mois
Parent
commit
bcbc335c15
32 fichiers modifiés avec 1031 ajouts et 77 suppressions
  1. 1 1
      VERSION
  2. 2 0
      messages/en/index.ts
  3. 32 0
      messages/en/settings.json
  4. 2 0
      messages/ja/index.ts
  5. 2 0
      messages/ru/index.ts
  6. 2 0
      messages/zh-CN/index.ts
  7. 32 0
      messages/zh-CN/settings.json
  8. 2 0
      messages/zh-TW/index.ts
  9. 25 0
      messages/zh-TW/settings.json
  10. 8 0
      public/seed/litellm-prices.json
  11. 427 5
      src/actions/providers.ts
  12. 1 1
      src/app/[locale]/dashboard/_components/statistics/chart.tsx
  13. 1 1
      src/app/[locale]/dashboard/leaderboard/_components/leaderboard-view.tsx
  14. 9 9
      src/app/[locale]/dashboard/logs/_components/usage-logs-filters.tsx
  15. 5 5
      src/app/[locale]/dashboard/logs/_components/usage-logs-view.tsx
  16. 0 3
      src/app/[locale]/dashboard/quotas/layout.tsx
  17. 1 1
      src/app/[locale]/layout.tsx
  18. 4 4
      src/app/[locale]/settings/data/_components/database-status.tsx
  19. 384 0
      src/app/[locale]/settings/providers/_components/forms/api-test-button.tsx
  20. 63 7
      src/app/[locale]/settings/providers/_components/forms/provider-form.tsx
  21. 0 3
      src/app/[locale]/settings/providers/_components/provider-rich-list-item.tsx
  22. 0 2
      src/app/[locale]/usage-doc/layout.tsx
  23. 0 2
      src/app/[locale]/usage-doc/page.tsx
  24. 4 4
      src/components/customs/version-checker.tsx
  25. 1 1
      src/components/ui/card.tsx
  26. 1 3
      src/components/ui/language-switcher.tsx
  27. 1 1
      src/lib/hooks/use-format-currency.ts
  28. 16 5
      src/lib/proxy-agent.ts
  29. 0 14
      src/lib/redis/client.ts
  30. 2 2
      src/lib/utils/error-messages.ts
  31. 2 2
      src/lib/utils/zod-i18n.ts
  32. 1 1
      src/repository/leaderboard.ts

+ 1 - 1
VERSION

@@ -1 +1 @@
-0.2.34
+0.2.38

+ 2 - 0
messages/en/index.ts

@@ -10,6 +10,7 @@ import quota from "./quota.json";
 import settings from "./settings.json";
 import settings from "./settings.json";
 import ui from "./ui.json";
 import ui from "./ui.json";
 import usage from "./usage.json";
 import usage from "./usage.json";
+import users from "./users.json";
 import validation from "./validation.json";
 import validation from "./validation.json";
 import internal from "./internal.json";
 import internal from "./internal.json";
 
 
@@ -26,6 +27,7 @@ export default {
   settings,
   settings,
   ui,
   ui,
   usage,
   usage,
+  users,
   validation,
   validation,
   internal,
   internal,
 };
 };

+ 32 - 0
messages/en/settings.json

@@ -575,6 +575,31 @@
     "editProvider": "Edit Provider",
     "editProvider": "Edit Provider",
     "enabledStatus": "enabled",
     "enabledStatus": "enabled",
     "form": {
     "form": {
+      "apiTest": {
+        "fillUrlFirst": "Please fill in provider URL first",
+        "invalidUrl": "Provider URL is invalid (http/https only)",
+        "fillKeyFirst": "Please fill in API key first",
+        "testFailed": "Test failed",
+        "testFailedRetry": "Test failed, please retry",
+        "noResult": "Test succeeded but no result returned",
+        "testSuccess": "Model test succeeded",
+        "testApi": "Provider Model Test",
+        "testing": "Testing...",
+        "apiFormat": "Provider type",
+        "selectApiFormat": "Select provider type to test",
+        "apiFormatDesc": "Defaults to the routing configuration unless manually changed",
+        "formatAnthropicMessages": "Claude (Anthropic Messages API)",
+        "formatOpenAIChat": "OpenAI Compatible",
+        "formatOpenAIResponses": "Codex (Response API)",
+        "testModel": "Test model",
+        "testModelDesc": "Leave empty to use the default model or type one manually",
+        "model": "Model",
+        "responseTime": "Response time",
+        "usage": "Token usage",
+        "response": "Response preview",
+        "error": "Error message",
+        "unknown": "Unknown"
+      },
       "proxyTest": {
       "proxyTest": {
         "fillUrlFirst": "Please fill in provider URL first",
         "fillUrlFirst": "Please fill in provider URL first",
         "testFailed": "Test failed",
         "testFailed": "Test failed",
@@ -984,6 +1009,13 @@
             "desc": "Test accessing provider URL via proxy (HEAD request, no credits consumed)"
             "desc": "Test accessing provider URL via proxy (HEAD request, no credits consumed)"
           }
           }
         },
         },
+        "apiTest": {
+          "title": "Provider Model Test",
+          "summary": "Verify provider & model connectivity",
+          "desc": "Validate whether the selected provider type and model respond correctly. Defaults to the routing configuration unless overridden.",
+          "testLabel": "Provider Model Test",
+          "notice": "Note: This sends a real non-streaming request and may consume a small quota. Confirm provider URL, API key, and model before running."
+        },
         "codexStrategy": {
         "codexStrategy": {
           "title": "Codex Instructions Policy",
           "title": "Codex Instructions Policy",
           "summary": {
           "summary": {

+ 2 - 0
messages/ja/index.ts

@@ -11,6 +11,7 @@ import quota from "./quota.json";
 import settings from "./settings.json";
 import settings from "./settings.json";
 import ui from "./ui.json";
 import ui from "./ui.json";
 import usage from "./usage.json";
 import usage from "./usage.json";
+import users from "./users.json";
 import validation from "./validation.json";
 import validation from "./validation.json";
 
 
 export default {
 export default {
@@ -27,5 +28,6 @@ export default {
   settings,
   settings,
   ui,
   ui,
   usage,
   usage,
+  users,
   validation,
   validation,
 };
 };

+ 2 - 0
messages/ru/index.ts

@@ -10,6 +10,7 @@ import quota from "./quota.json";
 import settings from "./settings.json";
 import settings from "./settings.json";
 import ui from "./ui.json";
 import ui from "./ui.json";
 import usage from "./usage.json";
 import usage from "./usage.json";
+import users from "./users.json";
 import validation from "./validation.json";
 import validation from "./validation.json";
 import internal from "./internal.json";
 import internal from "./internal.json";
 
 
@@ -26,6 +27,7 @@ export default {
   settings,
   settings,
   ui,
   ui,
   usage,
   usage,
+  users,
   validation,
   validation,
   internal,
   internal,
 };
 };

+ 2 - 0
messages/zh-CN/index.ts

@@ -11,6 +11,7 @@ import quota from "./quota.json";
 import settings from "./settings.json";
 import settings from "./settings.json";
 import ui from "./ui.json";
 import ui from "./ui.json";
 import usage from "./usage.json";
 import usage from "./usage.json";
+import users from "./users.json";
 import validation from "./validation.json";
 import validation from "./validation.json";
 
 
 export default {
 export default {
@@ -27,5 +28,6 @@ export default {
   settings,
   settings,
   ui,
   ui,
   usage,
   usage,
+  users,
   validation,
   validation,
 };
 };

+ 32 - 0
messages/zh-CN/settings.json

@@ -208,6 +208,31 @@
         "proxyError": "代理错误:",
         "proxyError": "代理错误:",
         "networkError": "网络错误:"
         "networkError": "网络错误:"
       },
       },
+      "apiTest": {
+        "fillUrlFirst": "请先填写供应商 URL",
+        "invalidUrl": "供应商 URL 无效,仅支持 http/https",
+        "fillKeyFirst": "请先填写 API 密钥",
+        "testFailed": "测试失败",
+        "testFailedRetry": "测试失败,请重试",
+        "noResult": "测试成功但未返回结果",
+        "testSuccess": "模型测试成功",
+        "testApi": "供应商模型测试",
+        "testing": "测试中...",
+        "apiFormat": "供应商类型",
+        "selectApiFormat": "选择要测试的供应商类型",
+        "apiFormatDesc": "默认同步路由配置中的供应商类型,除非手动修改",
+        "formatAnthropicMessages": "Claude (Anthropic Messages API)",
+        "formatOpenAIChat": "OpenAI Compatible",
+        "formatOpenAIResponses": "Codex (Response API)",
+        "testModel": "测试模型",
+        "testModelDesc": "可手动输入,不填写则使用默认模型",
+        "model": "模型",
+        "responseTime": "响应时间",
+        "usage": "Token 用量",
+        "response": "响应内容",
+        "error": "错误信息",
+        "unknown": "未知"
+      },
       "modelSelect": {
       "modelSelect": {
         "allowAllModels": "允许所有 {type} 模型",
         "allowAllModels": "允许所有 {type} 模型",
         "selectedCount": "已选择 {count} 个模型",
         "selectedCount": "已选择 {count} 个模型",
@@ -596,6 +621,13 @@
             "desc": "测试通过配置的代理访问供应商 URL(使用 HEAD 请求,不消耗额度)"
             "desc": "测试通过配置的代理访问供应商 URL(使用 HEAD 请求,不消耗额度)"
           }
           }
         },
         },
+        "apiTest": {
+          "title": "供应商模型测试",
+          "summary": "验证供应商与模型连通性",
+          "desc": "测试供应商模型是否可用,默认与路由配置中选择的供应商类型保持一致。",
+          "testLabel": "供应商模型测试",
+          "notice": "注意:测试将向供应商发送真实请求(非流式),可能消耗少量额度。请确认供应商 URL、API 密钥及模型配置正确。"
+        },
         "codexStrategy": {
         "codexStrategy": {
           "title": "Codex Instructions 策略",
           "title": "Codex Instructions 策略",
           "summary": {
           "summary": {

+ 2 - 0
messages/zh-TW/index.ts

@@ -10,6 +10,7 @@ import quota from "./quota.json";
 import settings from "./settings.json";
 import settings from "./settings.json";
 import ui from "./ui.json";
 import ui from "./ui.json";
 import usage from "./usage.json";
 import usage from "./usage.json";
+import users from "./users.json";
 import validation from "./validation.json";
 import validation from "./validation.json";
 import internal from "./internal.json";
 import internal from "./internal.json";
 
 
@@ -26,6 +27,7 @@ export default {
   settings,
   settings,
   ui,
   ui,
   usage,
   usage,
+  users,
   validation,
   validation,
   internal,
   internal,
 };
 };

+ 25 - 0
messages/zh-TW/settings.json

@@ -571,6 +571,31 @@
         "proxyError": "代理錯誤:",
         "proxyError": "代理錯誤:",
         "networkError": "網路錯誤:"
         "networkError": "網路錯誤:"
       },
       },
+      "apiTest": {
+        "fillUrlFirst": "請先填寫供應商 URL",
+        "invalidUrl": "供應商 URL 無效,僅支援 http/https",
+        "fillKeyFirst": "請先填寫 API 金鑰",
+        "testFailed": "測試失敗",
+        "testFailedRetry": "測試失敗,請重試",
+        "noResult": "測試成功但未返回結果",
+        "testSuccess": "模型測試成功",
+        "testApi": "供應商模型測試",
+        "testing": "測試中...",
+        "apiFormat": "供應商類型",
+        "selectApiFormat": "選擇要測試的供應商類型",
+        "apiFormatDesc": "預設與路由設定同步,除非手動修改",
+        "formatAnthropicMessages": "Claude (Anthropic Messages API)",
+        "formatOpenAIChat": "OpenAI Compatible",
+        "formatOpenAIResponses": "Codex (Response API)",
+        "testModel": "測試模型",
+        "testModelDesc": "可手動輸入,留空則使用預設模型",
+        "model": "模型",
+        "responseTime": "回應時間",
+        "usage": "Token 用量",
+        "response": "回應內容",
+        "error": "錯誤訊息",
+        "unknown": "未知"
+      },
       "modelSelect": {
       "modelSelect": {
         "allowAllModels": "允許所有 {type} 模型",
         "allowAllModels": "允許所有 {type} 模型",
         "selectedCount": "已選擇 {count} 個模型",
         "selectedCount": "已選擇 {count} 個模型",

+ 8 - 0
public/seed/litellm-prices.json

@@ -8903,6 +8903,14 @@
             "/v1/images/generations"
             "/v1/images/generations"
         ]
         ]
     },
     },
+    "fal_ai/fal-ai/flux/schnell": {
+        "litellm_provider": "fal_ai",
+        "mode": "image_generation",
+        "output_cost_per_image": 0.003,
+        "supported_endpoints": [
+            "/v1/images/generations"
+        ]
+    },
     "fal_ai/fal-ai/imagen4/preview": {
     "fal_ai/fal-ai/imagen4/preview": {
         "litellm_provider": "fal_ai",
         "litellm_provider": "fal_ai",
         "mode": "image_generation",
         "mode": "image_generation",

+ 427 - 5
src/actions/providers.ts

@@ -20,7 +20,7 @@ import {
   saveProviderCircuitConfig,
   saveProviderCircuitConfig,
   deleteProviderCircuitConfig,
   deleteProviderCircuitConfig,
 } from "@/lib/redis/circuit-breaker-config";
 } from "@/lib/redis/circuit-breaker-config";
-import { isValidProxyUrl } from "@/lib/proxy-agent";
+import { isValidProxyUrl, type ProviderProxyConfig } from "@/lib/proxy-agent";
 import { CodexInstructionsCache } from "@/lib/codex-instructions-cache";
 import { CodexInstructionsCache } from "@/lib/codex-instructions-cache";
 import { isClientAbortError } from "@/app/v1/_lib/proxy/errors";
 import { isClientAbortError } from "@/app/v1/_lib/proxy/errors";
 
 
@@ -606,6 +606,18 @@ export async function testProviderProxy(data: {
       return { ok: false, error: "无权限执行此操作" };
       return { ok: false, error: "无权限执行此操作" };
     }
     }
 
 
+    const providerUrlValidation = validateProviderUrlForConnectivity(data.providerUrl);
+    if (!providerUrlValidation.valid) {
+      return {
+        ok: true,
+        data: {
+          success: false,
+          message: providerUrlValidation.error.message,
+          details: providerUrlValidation.error.details,
+        },
+      };
+    }
+
     // 验证代理 URL 格式
     // 验证代理 URL 格式
     if (data.proxyUrl && !isValidProxyUrl(data.proxyUrl)) {
     if (data.proxyUrl && !isValidProxyUrl(data.proxyUrl)) {
       return {
       return {
@@ -627,12 +639,13 @@ export async function testProviderProxy(data: {
     const { createProxyAgentForProvider } = await import("@/lib/proxy-agent");
     const { createProxyAgentForProvider } = await import("@/lib/proxy-agent");
 
 
     // 构造临时 Provider 对象(用于创建代理 agent)
     // 构造临时 Provider 对象(用于创建代理 agent)
-    const tempProvider = {
+    // 使用类型安全的 ProviderProxyConfig 接口,避免 any
+    const tempProvider: ProviderProxyConfig = {
       id: -1,
       id: -1,
-      proxyUrl: data.proxyUrl,
+      name: "test-connection",
+      proxyUrl: data.proxyUrl ?? null,
       proxyFallbackToDirect: data.proxyFallbackToDirect ?? false,
       proxyFallbackToDirect: data.proxyFallbackToDirect ?? false,
-      // eslint-disable-next-line @typescript-eslint/no-explicit-any
-    } as any;
+    };
 
 
     try {
     try {
       // 创建代理配置
       // 创建代理配置
@@ -741,3 +754,412 @@ export async function getUnmaskedProviderKey(id: number): Promise<ActionResult<{
     return { ok: false, error: message };
     return { ok: false, error: message };
   }
   }
 }
 }
+
+type ProviderApiTestArgs = {
+  providerUrl: string;
+  apiKey: string;
+  model?: string;
+  proxyUrl?: string | null;
+  proxyFallbackToDirect?: boolean;
+};
+
+type ProviderApiTestResult = ActionResult<
+  | {
+      success: true;
+      message: string;
+      details?: {
+        responseTime?: number;
+        model?: string;
+        usage?: Record<string, unknown>;
+        content?: string;
+      };
+    }
+  | {
+      success: false;
+      message: string;
+      details?: {
+        responseTime?: number;
+        error?: string;
+      };
+    }
+>;
+
+type ProviderApiResponse = Record<string, unknown> & {
+  model?: string;
+  usage?: Record<string, unknown>;
+  content?: Array<{ type?: string; text?: string } | string | Record<string, unknown>>;
+  choices?: Array<{
+    message?: {
+      content?: string;
+    };
+  }>;
+  output?: Array<
+    | {
+        type?: string;
+        content?: Array<{
+          type?: string;
+          text?: string;
+        } | Record<string, unknown>>;
+      }
+    | Record<string, unknown>
+  >;
+};
+
+function extractFirstTextSnippet(
+  entries: ProviderApiResponse["content"],
+  maxLength = 100
+): string | undefined {
+  if (!Array.isArray(entries)) {
+    return undefined;
+  }
+
+  for (const entry of entries) {
+    if (typeof entry === "string") {
+      return entry.substring(0, maxLength);
+    }
+
+    if (entry && typeof entry === "object" && "text" in entry) {
+      const textValue = (entry as { text?: unknown }).text;
+      if (typeof textValue === "string") {
+        return textValue.substring(0, maxLength);
+      }
+    }
+  }
+
+  return undefined;
+}
+
+function clipText(value: unknown, maxLength = 100): string | undefined {
+  return typeof value === "string" ? value.substring(0, maxLength) : undefined;
+}
+
+type ProviderUrlValidationError = {
+  message: string;
+  details: {
+    error: string;
+    errorType: "InvalidProviderUrl" | "BlockedUrl" | "BlockedPort";
+  };
+};
+
+function validateProviderUrlForConnectivity(
+  providerUrl: string
+): { valid: true; normalizedUrl: string } | { valid: false; error: ProviderUrlValidationError } {
+  const trimmedUrl = providerUrl.trim();
+
+  try {
+    const parsedProviderUrl = new URL(trimmedUrl);
+
+    if (!["https:", "http:"].includes(parsedProviderUrl.protocol)) {
+      return {
+        valid: false,
+        error: {
+          message: "供应商地址格式无效",
+          details: {
+            error: "仅支持 HTTP 和 HTTPS 协议",
+            errorType: "InvalidProviderUrl",
+          },
+        },
+      };
+    }
+
+    const hostname = parsedProviderUrl.hostname.toLowerCase();
+    const blockedPatterns = [
+      /^localhost$/i,
+      /^127\.\d+\.\d+\.\d+$/,
+      /^10\.\d+\.\d+\.\d+$/,
+      /^172\.(1[6-9]|2\d|3[01])\.\d+\.\d+$/,
+      /^192\.168\.\d+\.\d+$/,
+      /^169\.254\.\d+\.\d+$/,
+      /^::1$/,
+      /^fe80:/i,
+      /^fc00:/i,
+      /^fd00:/i,
+    ];
+
+    if (blockedPatterns.some((pattern) => pattern.test(hostname))) {
+      return {
+        valid: false,
+        error: {
+          message: "供应商地址安全检查失败",
+          details: {
+            error: "不允许访问内部网络地址",
+            errorType: "BlockedUrl",
+          },
+        },
+      };
+    }
+
+    const port = parsedProviderUrl.port ? parseInt(parsedProviderUrl.port, 10) : null;
+    const dangerousPorts = [22, 23, 25, 3306, 5432, 6379, 27017, 9200];
+    if (port && dangerousPorts.includes(port)) {
+      return {
+        valid: false,
+        error: {
+          message: "供应商地址端口检查失败",
+          details: {
+            error: "不允许访问内部服务端口",
+            errorType: "BlockedPort",
+          },
+        },
+      };
+    }
+
+    return { valid: true, normalizedUrl: trimmedUrl };
+  } catch (error) {
+    return {
+      valid: false,
+      error: {
+        message: "供应商地址格式无效",
+        details: {
+          error: error instanceof Error ? error.message : "URL 解析失败",
+          errorType: "InvalidProviderUrl",
+        },
+      },
+    };
+  }
+}
+
+async function executeProviderApiTest(
+  data: ProviderApiTestArgs,
+  options: {
+    path: string;
+    defaultModel: string;
+    headers: (apiKey: string) => Record<string, string>;
+    body: (model: string) => unknown;
+    successMessage: string;
+    extract: (result: ProviderApiResponse) => {
+      model?: string;
+      usage?: Record<string, unknown>;
+      content?: string;
+    };
+  }
+): Promise<ProviderApiTestResult> {
+  try {
+    const session = await getSession();
+    if (!session || session.user.role !== "admin") {
+      return { ok: false, error: "无权限执行此操作" };
+    }
+
+    if (data.proxyUrl && !isValidProxyUrl(data.proxyUrl)) {
+      return {
+        ok: true,
+        data: {
+          success: false,
+          message: "代理地址格式无效",
+          details: {
+            error: "支持格式: http://, https://, socks5://, socks4://",
+          },
+        },
+      };
+    }
+
+    const providerUrlValidation = validateProviderUrlForConnectivity(data.providerUrl);
+    if (!providerUrlValidation.valid) {
+      return {
+        ok: true,
+        data: {
+          success: false,
+          message: providerUrlValidation.error.message,
+          details: providerUrlValidation.error.details,
+        },
+      };
+    }
+
+    const normalizedProviderUrl = providerUrlValidation.normalizedUrl.replace(/\/$/, "");
+
+    const startTime = Date.now();
+    const { createProxyAgentForProvider } = await import("@/lib/proxy-agent");
+
+    const tempProvider: ProviderProxyConfig = {
+      id: -1,
+      name: "api-test",
+      proxyUrl: data.proxyUrl ?? null,
+      proxyFallbackToDirect: data.proxyFallbackToDirect ?? false,
+    };
+
+    const url = normalizedProviderUrl + options.path;
+    const model = data.model || options.defaultModel;
+
+    try {
+      const proxyConfig = createProxyAgentForProvider(tempProvider, url);
+
+      interface UndiciFetchOptions extends RequestInit {
+        dispatcher?: unknown;
+      }
+
+      const init: UndiciFetchOptions = {
+        method: "POST",
+        headers: options.headers(data.apiKey),
+        body: JSON.stringify(options.body(model)),
+        signal: AbortSignal.timeout(30000),
+      };
+
+      if (proxyConfig) {
+        init.dispatcher = proxyConfig.agent;
+      }
+
+      const response = await fetch(url, init);
+      const responseTime = Date.now() - startTime;
+
+      if (!response.ok) {
+        const errorText = await response.text();
+        let errorDetail: string | undefined;
+        try {
+          const errorJson = JSON.parse(errorText);
+          errorDetail = errorJson.error?.message || errorJson.message;
+        } catch {
+          errorDetail = undefined;
+        }
+
+        logger.error("Provider API test failed", {
+          providerUrl: normalizedProviderUrl,
+          path: options.path,
+          status: response.status,
+          errorDetail: errorDetail ?? errorText,
+        });
+
+        return {
+          ok: true,
+          data: {
+            success: false,
+            message: `API 返回错误: HTTP ${response.status}`,
+            details: {
+              responseTime,
+              error: "API 请求失败,查看日志以获得更多信息",
+            },
+          },
+        };
+      }
+
+      const result = await response.json();
+      const extracted = options.extract(result);
+
+      return {
+        ok: true,
+        data: {
+          success: true,
+          message: options.successMessage,
+          details: {
+            responseTime,
+            ...extracted,
+          },
+        },
+      };
+    } catch (error) {
+      const responseTime = Date.now() - startTime;
+      const err = error as Error & { code?: string };
+
+      return {
+        ok: true,
+        data: {
+          success: false,
+          message: `连接失败: ${err.message}`,
+          details: {
+            responseTime,
+            error: err.message,
+          },
+        },
+      };
+    }
+  } catch (error) {
+    logger.error("测试供应商 API 失败:", error);
+    const message = error instanceof Error ? error.message : "测试失败";
+    return { ok: false, error: message };
+  }
+}
+
+/**
+ * 测试 Anthropic Messages API 连通性
+ */
+export async function testProviderAnthropicMessages(
+  data: ProviderApiTestArgs
+): Promise<ProviderApiTestResult> {
+  return executeProviderApiTest(data, {
+    path: "/v1/messages",
+    defaultModel: "claude-3-5-sonnet-20241022",
+    headers: (apiKey) => ({
+      "Content-Type": "application/json",
+      "anthropic-version": "2023-06-01",
+      "x-api-key": apiKey,
+    }),
+    body: (model) => ({
+      model,
+      max_tokens: 100,
+      messages: [{ role: "user", content: "Hello" }],
+    }),
+    successMessage: "Anthropic Messages API 测试成功",
+    extract: (result) => ({
+      model: result?.model,
+      usage: result?.usage,
+      content: extractFirstTextSnippet(result?.content),
+    }),
+  });
+}
+
+/**
+ * 测试 OpenAI Chat Completions API 连通性
+ */
+export async function testProviderOpenAIChatCompletions(
+  data: ProviderApiTestArgs
+): Promise<ProviderApiTestResult> {
+  return executeProviderApiTest(data, {
+    path: "/v1/chat/completions",
+    defaultModel: "gpt-4.1",
+    headers: (apiKey) => ({
+      "Content-Type": "application/json",
+      Authorization: `Bearer ${apiKey}`,
+    }),
+    body: (model) => ({
+      model,
+      messages: [
+        { role: "developer", content: "你是一个有帮助的助手。" },
+        { role: "user", content: "你好" },
+      ],
+    }),
+    successMessage: "OpenAI Chat Completions API 测试成功",
+    extract: (result) => ({
+      model: result?.model,
+      usage: result?.usage,
+      content: clipText(result?.choices?.[0]?.message?.content),
+    }),
+  });
+}
+
+/**
+ * 测试 OpenAI Responses API 连通性
+ */
+export async function testProviderOpenAIResponses(
+  data: ProviderApiTestArgs
+): Promise<ProviderApiTestResult> {
+  return executeProviderApiTest(data, {
+    path: "/v1/responses",
+    defaultModel: "gpt-4.1",
+    headers: (apiKey) => ({
+      "Content-Type": "application/json",
+      Authorization: `Bearer ${apiKey}`,
+    }),
+    body: (model) => ({
+      model,
+      input: "讲一个简短的故事",
+    }),
+    successMessage: "OpenAI Responses API 测试成功",
+    extract: (result) => {
+      let content: string | undefined;
+      if (result?.output && Array.isArray(result.output)) {
+        const firstOutput = result.output[0];
+        if (firstOutput?.type === "message" && Array.isArray(firstOutput.content)) {
+          const textContent = firstOutput.content.find(
+            (c: { type?: string }) => c?.type === "output_text"
+          ) as { text?: unknown } | undefined;
+          content = clipText(textContent?.text);
+        }
+      }
+
+      return {
+        model: result?.model,
+        usage: result?.usage,
+        content,
+      };
+    },
+  });
+}

+ 1 - 1
src/app/[locale]/dashboard/_components/statistics/chart.tsx

@@ -119,7 +119,7 @@ export function UserStatisticsChart({
     });
     });
 
 
     return config;
     return config;
-  }, [data.users]);
+  }, [data.users, t]);
 
 
   const userMap = React.useMemo(() => {
   const userMap = React.useMemo(() => {
     return new Map(data.users.map((user) => [user.dataKey, user]));
     return new Map(data.users.map((user) => [user.dataKey, user]));

+ 1 - 1
src/app/[locale]/dashboard/leaderboard/_components/leaderboard-view.tsx

@@ -57,7 +57,7 @@ export function LeaderboardView({ isAdmin }: LeaderboardViewProps) {
     return () => {
     return () => {
       cancelled = true;
       cancelled = true;
     };
     };
-  }, [scope]);
+  }, [scope, t]);
 
 
   if (loading) {
   if (loading) {
     return (
     return (

+ 9 - 9
src/app/[locale]/dashboard/logs/_components/usage-logs-filters.tsx

@@ -160,9 +160,9 @@ export function UsageLogsFilters({
 
 
   return (
   return (
     <div className="space-y-4">
     <div className="space-y-4">
-      <div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4">
+      <div className="grid grid-cols-1 gap-4 md:grid-cols-2 lg:grid-cols-12">
         {/* 时间范围 */}
         {/* 时间范围 */}
-        <div className="space-y-2">
+        <div className="space-y-2 lg:col-span-4">
           <Label>{t("logs.filters.startTime")}</Label>
           <Label>{t("logs.filters.startTime")}</Label>
           <Input
           <Input
             type="datetime-local"
             type="datetime-local"
@@ -176,7 +176,7 @@ export function UsageLogsFilters({
           />
           />
         </div>
         </div>
 
 
-        <div className="space-y-2">
+        <div className="space-y-2 lg:col-span-4">
           <Label>{t("logs.filters.endTime")}</Label>
           <Label>{t("logs.filters.endTime")}</Label>
           <Input
           <Input
             type="datetime-local"
             type="datetime-local"
@@ -192,7 +192,7 @@ export function UsageLogsFilters({
 
 
         {/* 用户选择(仅 Admin) */}
         {/* 用户选择(仅 Admin) */}
         {isAdmin && (
         {isAdmin && (
-          <div className="space-y-2">
+          <div className="space-y-2 lg:col-span-4">
             <Label>{t("logs.filters.user")}</Label>
             <Label>{t("logs.filters.user")}</Label>
             <Select
             <Select
               value={localFilters.userId?.toString() || ""}
               value={localFilters.userId?.toString() || ""}
@@ -213,7 +213,7 @@ export function UsageLogsFilters({
         )}
         )}
 
 
         {/* Key 选择 */}
         {/* Key 选择 */}
-        <div className="space-y-2">
+        <div className="space-y-2 lg:col-span-4">
           <Label>{t("logs.filters.apiKey")}</Label>
           <Label>{t("logs.filters.apiKey")}</Label>
           <Select
           <Select
             value={localFilters.keyId?.toString() || ""}
             value={localFilters.keyId?.toString() || ""}
@@ -240,7 +240,7 @@ export function UsageLogsFilters({
 
 
         {/* 供应商选择 */}
         {/* 供应商选择 */}
         {isAdmin && (
         {isAdmin && (
-          <div className="space-y-2">
+          <div className="space-y-2 lg:col-span-4">
             <Label>{t("logs.filters.provider")}</Label>
             <Label>{t("logs.filters.provider")}</Label>
             <Select
             <Select
               value={localFilters.providerId?.toString() || ""}
               value={localFilters.providerId?.toString() || ""}
@@ -266,7 +266,7 @@ export function UsageLogsFilters({
         )}
         )}
 
 
         {/* 模型选择 */}
         {/* 模型选择 */}
-        <div className="space-y-2">
+        <div className="space-y-2 lg:col-span-4">
           <Label>{t("logs.filters.model")}</Label>
           <Label>{t("logs.filters.model")}</Label>
           <Select
           <Select
             value={localFilters.model || ""}
             value={localFilters.model || ""}
@@ -288,7 +288,7 @@ export function UsageLogsFilters({
         </div>
         </div>
 
 
         {/* Endpoint 选择 */}
         {/* Endpoint 选择 */}
-        <div className="space-y-2">
+        <div className="space-y-2 lg:col-span-4">
           <Label>{t("logs.filters.endpoint")}</Label>
           <Label>{t("logs.filters.endpoint")}</Label>
           <Select
           <Select
             value={localFilters.endpoint || "all"}
             value={localFilters.endpoint || "all"}
@@ -323,7 +323,7 @@ export function UsageLogsFilters({
         </div>
         </div>
 
 
         {/* 状态码选择 */}
         {/* 状态码选择 */}
-        <div className="space-y-2">
+        <div className="space-y-2 lg:col-span-4">
           <Label>{t("logs.filters.statusCode")}</Label>
           <Label>{t("logs.filters.statusCode")}</Label>
           <Select
           <Select
             value={localFilters.statusCode?.toString() || ""}
             value={localFilters.statusCode?.toString() || ""}

+ 5 - 5
src/app/[locale]/dashboard/logs/_components/usage-logs-view.tsx

@@ -1,6 +1,6 @@
 "use client";
 "use client";
 
 
-import { useState, useTransition, useEffect, useRef } from "react";
+import { useState, useTransition, useEffect, useRef, useCallback } from "react";
 import { useRouter, useSearchParams } from "next/navigation";
 import { useRouter, useSearchParams } from "next/navigation";
 import { getUsageLogs } from "@/actions/usage-logs";
 import { getUsageLogs } from "@/actions/usage-logs";
 import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card";
 import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card";
@@ -95,7 +95,7 @@ export function UsageLogsView({
 
 
   // 加载数据
   // 加载数据
   // shouldDetectNew: 是否检测新增记录(只在刷新时为 true,筛选/翻页时为 false)
   // shouldDetectNew: 是否检测新增记录(只在刷新时为 true,筛选/翻页时为 false)
-  const loadData = async (shouldDetectNew = false) => {
+  const loadData = useCallback(async (shouldDetectNew = false) => {
     startTransition(async () => {
     startTransition(async () => {
       const result = await getUsageLogs(filtersRef.current);
       const result = await getUsageLogs(filtersRef.current);
       if (result.ok && result.data) {
       if (result.ok && result.data) {
@@ -125,7 +125,7 @@ export function UsageLogsView({
         setData(null);
         setData(null);
       }
       }
     });
     });
-  };
+  }, [startTransition, t]);
 
 
   // 手动刷新(检测新增)
   // 手动刷新(检测新增)
   const handleManualRefresh = async () => {
   const handleManualRefresh = async () => {
@@ -148,7 +148,7 @@ export function UsageLogsView({
     }
     }
 
 
     previousParamsRef.current = currentParams;
     previousParamsRef.current = currentParams;
-  }, [params]);
+  }, [params, loadData]);
 
 
   // 自动轮询(3秒间隔,检测新增)
   // 自动轮询(3秒间隔,检测新增)
   useEffect(() => {
   useEffect(() => {
@@ -161,7 +161,7 @@ export function UsageLogsView({
     }, 3000); // 3 秒间隔
     }, 3000); // 3 秒间隔
 
 
     return () => clearInterval(intervalId);
     return () => clearInterval(intervalId);
-  }, [isAutoRefresh]);  
+  }, [isAutoRefresh, loadData]);  
 
 
   // 处理筛选条件变更
   // 处理筛选条件变更
   const handleFilterChange = (newFilters: Omit<typeof filters, 'page'>) => {
   const handleFilterChange = (newFilters: Omit<typeof filters, 'page'>) => {

+ 0 - 3
src/app/[locale]/dashboard/quotas/layout.tsx

@@ -1,14 +1,11 @@
 import { Tabs, TabsList, TabsTrigger } from "@/components/ui/tabs";
 import { Tabs, TabsList, TabsTrigger } from "@/components/ui/tabs";
 import { Link } from "@/i18n/routing";
 import { Link } from "@/i18n/routing";
-import { useTranslations } from "next-intl";
 import { getTranslations } from "next-intl/server";
 import { getTranslations } from "next-intl/server";
 
 
 export default async function QuotasLayout({
 export default async function QuotasLayout({
   children,
   children,
-  params,
 }: {
 }: {
   children: React.ReactNode;
   children: React.ReactNode;
-  params: Promise<{ locale: string }>;
 }) {
 }) {
   const t = await getTranslations("quota.layout");
   const t = await getTranslations("quota.layout");
 
 

+ 1 - 1
src/app/[locale]/layout.tsx

@@ -7,7 +7,7 @@ import { getSystemSettings } from "@/repository/system-config";
 import { logger } from "@/lib/logger";
 import { logger } from "@/lib/logger";
 import { NextIntlClientProvider } from "next-intl";
 import { NextIntlClientProvider } from "next-intl";
 import { getMessages } from "next-intl/server";
 import { getMessages } from "next-intl/server";
-import { locales, defaultLocale, localeNamesInEnglish, type Locale } from "@/i18n/config";
+import { locales, type Locale } from "@/i18n/config";
 import { notFound } from "next/navigation";
 import { notFound } from "next/navigation";
 
 
 const FALLBACK_TITLE = "Claude Code Hub";
 const FALLBACK_TITLE = "Claude Code Hub";

+ 4 - 4
src/app/[locale]/settings/data/_components/database-status.tsx

@@ -1,6 +1,6 @@
 "use client";
 "use client";
 
 
-import { useEffect, useState } from "react";
+import { useEffect, useState, useCallback } from "react";
 import { Database, Table, AlertCircle, RefreshCw } from "lucide-react";
 import { Database, Table, AlertCircle, RefreshCw } from "lucide-react";
 import { useTranslations } from "next-intl";
 import { useTranslations } from "next-intl";
 import { Button } from "@/components/ui/button";
 import { Button } from "@/components/ui/button";
@@ -12,7 +12,7 @@ export function DatabaseStatusDisplay() {
   const [isLoading, setIsLoading] = useState(true);
   const [isLoading, setIsLoading] = useState(true);
   const [error, setError] = useState<string | null>(null);
   const [error, setError] = useState<string | null>(null);
 
 
-  const fetchStatus = async () => {
+  const fetchStatus = useCallback(async () => {
     setIsLoading(true);
     setIsLoading(true);
     setError(null);
     setError(null);
 
 
@@ -35,11 +35,11 @@ export function DatabaseStatusDisplay() {
     } finally {
     } finally {
       setIsLoading(false);
       setIsLoading(false);
     }
     }
-  };
+  }, [t]);
 
 
   useEffect(() => {
   useEffect(() => {
     fetchStatus();
     fetchStatus();
-  }, []);
+  }, [fetchStatus]);
 
 
   if (isLoading) {
   if (isLoading) {
     return (
     return (

+ 384 - 0
src/app/[locale]/settings/providers/_components/forms/api-test-button.tsx

@@ -0,0 +1,384 @@
+"use client";
+
+import { useEffect, useMemo, useState } from "react";
+import { Button } from "@/components/ui/button";
+import { Loader2, CheckCircle2, XCircle, Activity } from "lucide-react";
+import {
+  testProviderAnthropicMessages,
+  testProviderOpenAIChatCompletions,
+  testProviderOpenAIResponses,
+  getUnmaskedProviderKey,
+} from "@/actions/providers";
+import { toast } from "sonner";
+import { useTranslations } from "next-intl";
+import {
+  Select,
+  SelectContent,
+  SelectItem,
+  SelectTrigger,
+  SelectValue,
+} from "@/components/ui/select";
+import { Input } from "@/components/ui/input";
+import { Label } from "@/components/ui/label";
+import { isValidUrl } from "@/lib/utils/validation";
+import type { ProviderType } from "@/types/provider";
+
+type ApiFormat = "anthropic-messages" | "openai-chat" | "openai-responses";
+
+const providerTypeToApiFormat: Partial<Record<ProviderType, ApiFormat>> = {
+  claude: "anthropic-messages",
+  "claude-auth": "anthropic-messages",
+  codex: "openai-responses",
+  "openai-compatible": "openai-chat",
+};
+
+const apiFormatDefaultModel: Record<ApiFormat, string> = {
+  "anthropic-messages": "claude-3-5-sonnet-20241022",
+  "openai-chat": "gpt-4.1",
+  "openai-responses": "gpt-4.1",
+};
+
+const resolveApiFormatFromProvider = (providerType?: ProviderType | null): ApiFormat =>
+  (providerType ? providerTypeToApiFormat[providerType] : undefined) ?? "anthropic-messages";
+
+const getDefaultModelForFormat = (format: ApiFormat) => apiFormatDefaultModel[format];
+
+interface ApiTestButtonProps {
+  providerUrl: string;
+  apiKey: string;
+  proxyUrl?: string | null;
+  proxyFallbackToDirect?: boolean;
+  disabled?: boolean;
+  providerId?: number;
+  providerType?: ProviderType | null;
+  allowedModels?: string[];
+  enableMultiProviderTypes: boolean;
+}
+
+/**
+ * API 连通性测试按钮组件
+ *
+ * 支持测试三种API格式:
+ * - Anthropic Messages API (v1/messages)
+ * - OpenAI Chat Completions API (v1/chat/completions)
+ * - OpenAI Responses API (v1/responses)
+ */
+export function ApiTestButton({
+  providerUrl,
+  apiKey,
+  proxyUrl,
+  proxyFallbackToDirect = false,
+  disabled = false,
+  providerId,
+  providerType,
+  allowedModels = [],
+  enableMultiProviderTypes,
+}: ApiTestButtonProps) {
+  const t = useTranslations("settings.providers.form.apiTest");
+  const providerTypeT = useTranslations("settings.providers.form.providerTypes");
+  const normalizedAllowedModels = useMemo(() => {
+    const unique = new Set<string>();
+    allowedModels.forEach((model) => {
+      const trimmed = model.trim();
+      if (trimmed) {
+        unique.add(trimmed);
+      }
+    });
+    return Array.from(unique);
+  }, [allowedModels]);
+
+  const initialApiFormat = resolveApiFormatFromProvider(providerType);
+  const [isTesting, setIsTesting] = useState(false);
+  const [apiFormat, setApiFormat] = useState<ApiFormat>(initialApiFormat);
+  const [isApiFormatManuallySelected, setIsApiFormatManuallySelected] = useState(false);
+  const [testModel, setTestModel] = useState(() => {
+    const whitelistDefault = normalizedAllowedModels[0];
+    return whitelistDefault ?? getDefaultModelForFormat(initialApiFormat);
+  });
+  const [isModelManuallyEdited, setIsModelManuallyEdited] = useState(false);
+  const [testResult, setTestResult] = useState<{
+    success: boolean;
+    message: string;
+    details?: {
+      responseTime?: number;
+      model?: string;
+      usage?: Record<string, unknown> | string | number;
+      content?: string;
+      error?: string;
+    };
+  } | null>(null);
+
+  useEffect(() => {
+    if (isApiFormatManuallySelected) return;
+    const resolvedFormat = resolveApiFormatFromProvider(providerType);
+    if (resolvedFormat !== apiFormat) {
+      setApiFormat(resolvedFormat);
+    }
+  }, [apiFormat, isApiFormatManuallySelected, providerType]);
+
+  useEffect(() => {
+    if (isModelManuallyEdited) {
+      return;
+    }
+
+    const whitelistDefault = normalizedAllowedModels[0];
+    const defaultModel = whitelistDefault ?? getDefaultModelForFormat(apiFormat);
+    setTestModel(defaultModel);
+  }, [apiFormat, isModelManuallyEdited, normalizedAllowedModels]);
+
+  const handleTest = async () => {
+    // 验证必填字段
+    if (!providerUrl.trim()) {
+      toast.error(t("fillUrlFirst"));
+      return;
+    }
+
+    if (!isValidUrl(providerUrl.trim()) || !/^https?:\/\//.test(providerUrl.trim())) {
+      toast.error(t("invalidUrl"));
+      return;
+    }
+
+    setIsTesting(true);
+    setTestResult(null);
+
+    try {
+      let resolvedKey = apiKey.trim();
+      if (!resolvedKey && providerId) {
+        const result = await getUnmaskedProviderKey(providerId);
+        if (!result.ok) {
+          toast.error(result.error || t("fillKeyFirst"));
+          return;
+        }
+
+        if (!result.data?.key) {
+          toast.error(t("fillKeyFirst"));
+          return;
+        }
+
+        resolvedKey = result.data.key;
+      }
+
+      if (!resolvedKey) {
+        toast.error(t("fillKeyFirst"));
+        return;
+      }
+
+      let response;
+
+      switch (apiFormat) {
+        case "anthropic-messages":
+          response = await testProviderAnthropicMessages({
+            providerUrl: providerUrl.trim(),
+            apiKey: resolvedKey,
+            model: testModel.trim() || undefined,
+            proxyUrl: proxyUrl?.trim() || null,
+            proxyFallbackToDirect,
+          });
+          break;
+
+        case "openai-chat":
+          response = await testProviderOpenAIChatCompletions({
+            providerUrl: providerUrl.trim(),
+            apiKey: resolvedKey,
+            model: testModel.trim() || undefined,
+            proxyUrl: proxyUrl?.trim() || null,
+            proxyFallbackToDirect,
+          });
+          break;
+
+        case "openai-responses":
+          response = await testProviderOpenAIResponses({
+            providerUrl: providerUrl.trim(),
+            apiKey: resolvedKey,
+            model: testModel.trim() || undefined,
+            proxyUrl: proxyUrl?.trim() || null,
+            proxyFallbackToDirect,
+          });
+          break;
+      }
+
+      if (!response.ok) {
+        toast.error(response.error || t("testFailed"));
+        return;
+      }
+
+      if (!response.data) {
+        toast.error(t("noResult"));
+        return;
+      }
+
+      setTestResult(response.data);
+
+      // 显示测试结果
+      if (response.data.success) {
+        const details = response.data.details;
+        const responseTime = details?.responseTime ? `${details.responseTime}ms` : "N/A";
+        const model = details?.model || t("unknown");
+
+        toast.success(t("testSuccess"), {
+          description: `${t("model")}: ${model} | ${t("responseTime")}: ${responseTime}`,
+        });
+      } else {
+        const errorMessage = response.data.details?.error || response.data.message;
+
+        toast.error(t("testFailed"), {
+          description: errorMessage,
+          duration: 5000,
+        });
+      }
+    } catch (error) {
+      console.error("测试 API 连通性失败:", error);
+      toast.error(t("testFailedRetry"));
+    } finally {
+      setIsTesting(false);
+    }
+  };
+
+  // 获取按钮内容
+  const getButtonContent = () => {
+    if (isTesting) {
+      return (
+        <>
+          <Loader2 className="h-4 w-4 mr-2 animate-spin" />
+          {t("testing")}
+        </>
+      );
+    }
+
+    if (testResult) {
+      if (testResult.success) {
+        return (
+          <>
+            <CheckCircle2 className="h-4 w-4 mr-2 text-green-600" />
+            {t("testSuccess")}
+          </>
+        );
+      } else {
+        return (
+          <>
+            <XCircle className="h-4 w-4 mr-2 text-red-600" />
+            {t("testFailed")}
+          </>
+        );
+      }
+    }
+
+    return (
+      <>
+        <Activity className="h-4 w-4 mr-2" />
+        {t("testApi")}
+      </>
+    );
+  };
+
+  // 获取默认模型占位符
+  return (
+    <div className="space-y-4">
+      <div className="space-y-2">
+        <Label htmlFor="api-format">{t("apiFormat")}</Label>
+        <Select
+          value={apiFormat}
+          onValueChange={(value) => {
+            setIsApiFormatManuallySelected(true);
+            setApiFormat(value as ApiFormat);
+          }}
+        >
+          <SelectTrigger id="api-format">
+            <SelectValue placeholder={t("selectApiFormat")} />
+          </SelectTrigger>
+          <SelectContent>
+            <SelectItem value="anthropic-messages">
+              {t("formatAnthropicMessages")}
+            </SelectItem>
+            <SelectItem value="openai-chat" disabled={!enableMultiProviderTypes}>
+              <>
+                {t("formatOpenAIChat")}
+                {!enableMultiProviderTypes && providerTypeT("openaiCompatibleDisabled")}
+              </>
+            </SelectItem>
+            <SelectItem value="openai-responses">{t("formatOpenAIResponses")}</SelectItem>
+          </SelectContent>
+        </Select>
+        <div className="text-xs text-muted-foreground">{t("apiFormatDesc")}</div>
+      </div>
+
+      <div className="space-y-2">
+        <Label htmlFor="test-model">{t("testModel")}</Label>
+        <Input
+          id="test-model"
+          value={testModel}
+          onChange={(e) => {
+            const value = e.target.value;
+            setIsModelManuallyEdited(true);
+            setTestModel(value);
+          }}
+          placeholder={getDefaultModelForFormat(apiFormat)}
+          disabled={isTesting}
+        />
+        <div className="text-xs text-muted-foreground">{t("testModelDesc")}</div>
+      </div>
+
+      <Button
+        type="button"
+        variant="outline"
+        size="sm"
+        onClick={handleTest}
+        disabled={
+          disabled ||
+          isTesting ||
+          !providerUrl.trim() ||
+          (!apiKey.trim() && !providerId)
+        }
+      >
+        {getButtonContent()}
+      </Button>
+
+      {/* 显示详细测试结果 */}
+      {testResult && !isTesting && (
+        <div
+          className={`text-xs p-3 rounded-md ${
+            testResult.success
+              ? "bg-green-50 text-green-700 border border-green-200"
+              : "bg-red-50 text-red-700 border border-red-200"
+          }`}
+        >
+          <div className="font-medium mb-2">{testResult.message}</div>
+          {testResult.details && (
+            <div className="space-y-1 text-xs opacity-80">
+              {testResult.details.model && (
+                <div>
+                  <span className="font-medium">{t("model")}:</span> {testResult.details.model}
+                </div>
+              )}
+              {testResult.details.responseTime !== undefined && (
+                <div>
+                  <span className="font-medium">{t("responseTime")}:</span>{" "}
+                  {testResult.details.responseTime}ms
+                </div>
+              )}
+              {testResult.details.usage && (
+                <div>
+                  <span className="font-medium">{t("usage")}:</span>{" "}
+                  {typeof testResult.details.usage === "object"
+                    ? JSON.stringify(testResult.details.usage)
+                    : String(testResult.details.usage)}
+                </div>
+              )}
+              {testResult.details.content && (
+                <div>
+                  <span className="font-medium">{t("response")}:</span>{" "}
+                  {testResult.details.content}
+                </div>
+              )}
+              {testResult.details.error && (
+                <div>
+                  <span className="font-medium">{t("error")}:</span> {testResult.details.error}
+                </div>
+              )}
+            </div>
+          )}
+        </div>
+      )}
+    </div>
+  );
+}

+ 63 - 7
src/app/[locale]/settings/providers/_components/forms/provider-form.tsx

@@ -32,6 +32,7 @@ import { toast } from "sonner";
 import { ModelMultiSelect } from "../model-multi-select";
 import { ModelMultiSelect } from "../model-multi-select";
 import { ModelRedirectEditor } from "../model-redirect-editor";
 import { ModelRedirectEditor } from "../model-redirect-editor";
 import { ProxyTestButton } from "./proxy-test-button";
 import { ProxyTestButton } from "./proxy-test-button";
+import { ApiTestButton } from "./api-test-button";
 import { ChevronDown } from "lucide-react";
 import { ChevronDown } from "lucide-react";
 import { useTranslations } from "next-intl";
 import { useTranslations } from "next-intl";
 
 
@@ -122,12 +123,13 @@ export function ProviderForm({
     useState<CodexInstructionsStrategy>(sourceProvider?.codexInstructionsStrategy ?? "auto");
     useState<CodexInstructionsStrategy>(sourceProvider?.codexInstructionsStrategy ?? "auto");
 
 
   // 折叠区域状态管理
   // 折叠区域状态管理
-  type SectionKey = "routing" | "rateLimit" | "circuitBreaker" | "proxy" | "codexStrategy";
+  type SectionKey = "routing" | "rateLimit" | "circuitBreaker" | "proxy" | "apiTest" | "codexStrategy";
   const [openSections, setOpenSections] = useState<Record<SectionKey, boolean>>({
   const [openSections, setOpenSections] = useState<Record<SectionKey, boolean>>({
     routing: false,
     routing: false,
     rateLimit: false,
     rateLimit: false,
     circuitBreaker: false,
     circuitBreaker: false,
     proxy: false,
     proxy: false,
+    apiTest: false,
     codexStrategy: false,
     codexStrategy: false,
   });
   });
 
 
@@ -137,7 +139,7 @@ export function ProviderForm({
     if (saved) {
     if (saved) {
       try {
       try {
         const parsed = JSON.parse(saved);
         const parsed = JSON.parse(saved);
-        setOpenSections(parsed);
+        setOpenSections((prev) => ({ ...prev, ...parsed }));
       } catch (e) {
       } catch (e) {
         console.error("Failed to parse saved sections state:", e);
         console.error("Failed to parse saved sections state:", e);
       }
       }
@@ -170,6 +172,7 @@ export function ProviderForm({
       rateLimit: true,
       rateLimit: true,
       circuitBreaker: true,
       circuitBreaker: true,
       proxy: true,
       proxy: true,
+      apiTest: true,
       codexStrategy: true,
       codexStrategy: true,
     });
     });
   };
   };
@@ -181,6 +184,7 @@ export function ProviderForm({
       rateLimit: false,
       rateLimit: false,
       circuitBreaker: false,
       circuitBreaker: false,
       proxy: false,
       proxy: false,
+      apiTest: false,
       codexStrategy: false,
       codexStrategy: false,
     });
     });
   };
   };
@@ -439,7 +443,7 @@ export function ProviderForm({
         </div>
         </div>
 
 
         {/* Codex 支持:供应商类型和模型重定向 */}
         {/* Codex 支持:供应商类型和模型重定向 */}
-        <Collapsible open={openSections.routing} onOpenChange={(open) => toggleSection("routing")}>
+        <Collapsible open={openSections.routing} onOpenChange={() => toggleSection("routing")}>
           <CollapsibleTrigger asChild>
           <CollapsibleTrigger asChild>
             <button
             <button
               type="button"
               type="button"
@@ -709,7 +713,7 @@ export function ProviderForm({
         {/* 限流配置 */}
         {/* 限流配置 */}
         <Collapsible
         <Collapsible
           open={openSections.rateLimit}
           open={openSections.rateLimit}
-          onOpenChange={(open) => toggleSection("rateLimit")}
+          onOpenChange={() => toggleSection("rateLimit")}
         >
         >
           <CollapsibleTrigger asChild>
           <CollapsibleTrigger asChild>
             <button
             <button
@@ -823,7 +827,7 @@ export function ProviderForm({
         {/* 熔断器配置 */}
         {/* 熔断器配置 */}
         <Collapsible
         <Collapsible
           open={openSections.circuitBreaker}
           open={openSections.circuitBreaker}
-          onOpenChange={(open) => toggleSection("circuitBreaker")}
+          onOpenChange={() => toggleSection("circuitBreaker")}
         >
         >
           <CollapsibleTrigger asChild>
           <CollapsibleTrigger asChild>
             <button
             <button
@@ -926,7 +930,7 @@ export function ProviderForm({
         </Collapsible>
         </Collapsible>
 
 
         {/* 代理配置 */}
         {/* 代理配置 */}
-        <Collapsible open={openSections.proxy} onOpenChange={(open) => toggleSection("proxy")}>
+        <Collapsible open={openSections.proxy} onOpenChange={() => toggleSection("proxy")}>
           <CollapsibleTrigger asChild>
           <CollapsibleTrigger asChild>
             <button
             <button
               type="button"
               type="button"
@@ -1016,11 +1020,63 @@ export function ProviderForm({
           </CollapsibleContent>
           </CollapsibleContent>
         </Collapsible>
         </Collapsible>
 
 
+        {/* API 测试 */}
+        <Collapsible
+          open={openSections.apiTest}
+          onOpenChange={() => toggleSection("apiTest")}
+        >
+          <CollapsibleTrigger asChild>
+            <button
+              type="button"
+              className="flex items-center justify-between w-full py-4 border-t hover:bg-muted/50 transition-colors"
+              disabled={isPending}
+            >
+              <div className="flex items-center gap-2">
+                <ChevronDown
+                  className={`h-4 w-4 transition-transform ${
+                    openSections.apiTest ? "rotate-180" : ""
+                  }`}
+                />
+                <span className="text-sm font-medium">{t("sections.apiTest.title")}</span>
+              </div>
+              <span className="text-xs text-muted-foreground">
+                {t("sections.apiTest.summary")}
+              </span>
+            </button>
+          </CollapsibleTrigger>
+          <CollapsibleContent className="space-y-4 pb-4">
+            <div className="space-y-4">
+              <div className="space-y-1">
+                <p className="text-xs text-muted-foreground">
+                  {t("sections.apiTest.desc")}
+                </p>
+              </div>
+
+              <div className="space-y-2">
+                <ApiTestButton
+                  providerUrl={url}
+                  apiKey={key}
+                  proxyUrl={proxyUrl}
+                  proxyFallbackToDirect={proxyFallbackToDirect}
+                  providerId={provider?.id}
+                  providerType={providerType}
+                  allowedModels={allowedModels}
+                  enableMultiProviderTypes={enableMultiProviderTypes}
+                  disabled={isPending || !url.trim()}
+                />
+                <p className="text-xs text-muted-foreground">
+                  {t("sections.apiTest.notice")}
+                </p>
+              </div>
+            </div>
+          </CollapsibleContent>
+        </Collapsible>
+
         {/* Codex Instructions 策略配置 - 仅 Codex 供应商显示 */}
         {/* Codex Instructions 策略配置 - 仅 Codex 供应商显示 */}
         {providerType === "codex" && (
         {providerType === "codex" && (
           <Collapsible
           <Collapsible
             open={openSections.codexStrategy}
             open={openSections.codexStrategy}
-            onOpenChange={(open) => toggleSection("codexStrategy")}
+            onOpenChange={() => toggleSection("codexStrategy")}
           >
           >
             <CollapsibleTrigger asChild>
             <CollapsibleTrigger asChild>
               <button
               <button

+ 0 - 3
src/app/[locale]/settings/providers/_components/provider-rich-list-item.tsx

@@ -84,14 +84,11 @@ export function ProviderRichListItem({
   const canEdit = currentUser?.role === "admin";
   const canEdit = currentUser?.role === "admin";
   const tTypes = useTranslations("settings.providers.types");
   const tTypes = useTranslations("settings.providers.types");
   const tList = useTranslations("settings.providers.list");
   const tList = useTranslations("settings.providers.list");
-  const tCommon = useTranslations("settings.common");
 
 
   // 获取供应商类型配置
   // 获取供应商类型配置
   const typeConfig = getProviderTypeConfig(provider.providerType);
   const typeConfig = getProviderTypeConfig(provider.providerType);
   const TypeIcon = typeConfig.icon;
   const TypeIcon = typeConfig.icon;
   const typeKey = getProviderTypeTranslationKey(provider.providerType);
   const typeKey = getProviderTypeTranslationKey(provider.providerType);
-  const typeLabel = tTypes(`${typeKey}.label`);
-  const typeDescription = tTypes(`${typeKey}.description`);
 
 
   // 处理编辑
   // 处理编辑
   const handleEdit = () => {
   const handleEdit = () => {

+ 0 - 2
src/app/[locale]/usage-doc/layout.tsx

@@ -16,10 +16,8 @@ export const metadata: Metadata = {
  */
  */
 export default async function UsageDocLayout({
 export default async function UsageDocLayout({
   children,
   children,
-  params,
 }: {
 }: {
   children: React.ReactNode;
   children: React.ReactNode;
-  params: Promise<{ locale: string }>;
 }) {
 }) {
   const session = await getSession();
   const session = await getSession();
 
 

+ 0 - 2
src/app/[locale]/usage-doc/page.tsx

@@ -813,8 +813,6 @@ source ${shellConfig.split(" ")[0]}`}
     const config = cli.vsCodeExtension;
     const config = cli.vsCodeExtension;
     if (!config) return null;
     if (!config) return null;
 
 
-    const configPath = config.configPath[os === "macos" ? "macos" : "windows"];
-
     if (cli.id === "claude-code") {
     if (cli.id === "claude-code") {
       return (
       return (
         <div className="space-y-3">
         <div className="space-y-3">

+ 4 - 4
src/components/customs/version-checker.tsx

@@ -1,6 +1,6 @@
 "use client";
 "use client";
 
 
-import { useEffect, useState } from "react";
+import { useEffect, useState, useCallback } from "react";
 import { ExternalLink, RefreshCw } from "lucide-react";
 import { ExternalLink, RefreshCw } from "lucide-react";
 import { Button } from "@/components/ui/button";
 import { Button } from "@/components/ui/button";
 import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card";
 import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card";
@@ -21,7 +21,7 @@ export function VersionChecker() {
   const [versionInfo, setVersionInfo] = useState<VersionInfo | null>(null);
   const [versionInfo, setVersionInfo] = useState<VersionInfo | null>(null);
   const [loading, setLoading] = useState(true);
   const [loading, setLoading] = useState(true);
 
 
-  const checkVersion = async () => {
+  const checkVersion = useCallback(async () => {
     setLoading(true);
     setLoading(true);
     try {
     try {
       const response = await fetch("/api/version");
       const response = await fetch("/api/version");
@@ -38,11 +38,11 @@ export function VersionChecker() {
     } finally {
     } finally {
       setLoading(false);
       setLoading(false);
     }
     }
-  };
+  }, [t]);
 
 
   useEffect(() => {
   useEffect(() => {
     checkVersion();
     checkVersion();
-  }, []);
+  }, [checkVersion]);
 
 
   if (!versionInfo && loading) {
   if (!versionInfo && loading) {
     return (
     return (

+ 1 - 1
src/components/ui/card.tsx

@@ -65,7 +65,7 @@ function CardContent({ className, ...props }: React.ComponentProps<"div">) {
   return (
   return (
     <div
     <div
       data-slot="card-content"
       data-slot="card-content"
-      className={cn("px-6", className)}
+      className={cn("px-3 md:px-4 lg:px-6", className)}
       {...props}
       {...props}
     />
     />
   )
   )

+ 1 - 3
src/components/ui/language-switcher.tsx

@@ -3,7 +3,6 @@
 import * as React from "react";
 import * as React from "react";
 import { useLocale } from "next-intl";
 import { useLocale } from "next-intl";
 import { useRouter, usePathname } from "@/i18n/routing";
 import { useRouter, usePathname } from "@/i18n/routing";
-import { useParams } from "next/navigation";
 import { locales, localeLabels, type Locale } from "@/i18n/config";
 import { locales, localeLabels, type Locale } from "@/i18n/config";
 import { Languages } from "lucide-react";
 import { Languages } from "lucide-react";
 import {
 import {
@@ -39,7 +38,6 @@ export function LanguageSwitcher({ className, size = "sm" }: LanguageSwitcherPro
   const currentLocale = useLocale() as Locale;
   const currentLocale = useLocale() as Locale;
   const router = useRouter();
   const router = useRouter();
   const pathname = usePathname();
   const pathname = usePathname();
-  const params = useParams();
   const [isTransitioning, setIsTransitioning] = React.useState(false);
   const [isTransitioning, setIsTransitioning] = React.useState(false);
 
 
   // Handle locale change
   // Handle locale change
@@ -65,7 +63,7 @@ export function LanguageSwitcher({ className, size = "sm" }: LanguageSwitcherPro
         setIsTransitioning(false);
         setIsTransitioning(false);
       }
       }
     },
     },
-    [currentLocale, pathname, router, isTransitioning, params]
+    [currentLocale, pathname, router, isTransitioning]
   );
   );
 
 
   return (
   return (

+ 1 - 1
src/lib/hooks/use-format-currency.ts

@@ -42,7 +42,7 @@ export function useFormatCurrency() {
         minimumFractionDigits: fractionDigits,
         minimumFractionDigits: fractionDigits,
         maximumFractionDigits: fractionDigits,
         maximumFractionDigits: fractionDigits,
       });
       });
-    } catch (error) {
+    } catch {
       // Fallback to manual formatting if currency is not supported
       // Fallback to manual formatting if currency is not supported
       const formatted = amount.toLocaleString(config.locale, {
       const formatted = amount.toLocaleString(config.locale, {
         minimumFractionDigits: fractionDigits,
         minimumFractionDigits: fractionDigits,

+ 16 - 5
src/lib/proxy-agent.ts

@@ -13,6 +13,17 @@ export interface ProxyConfig {
   proxyUrl: string;
   proxyUrl: string;
 }
 }
 
 
+/**
+ * 最小的供应商代理配置接口(用于类型安全)
+ * 仅包含创建代理 Agent 所需的必要字段
+ */
+export interface ProviderProxyConfig {
+  id: number;
+  name?: string;
+  proxyUrl: string | null;
+  proxyFallbackToDirect: boolean;
+}
+
 /**
 /**
  * 为供应商创建代理 Agent(如果配置了代理)
  * 为供应商创建代理 Agent(如果配置了代理)
  *
  *
@@ -22,12 +33,12 @@ export interface ProxyConfig {
  * - socks5:// - SOCKS5 代理
  * - socks5:// - SOCKS5 代理
  * - socks4:// - SOCKS4 代理
  * - socks4:// - SOCKS4 代理
  *
  *
- * @param provider 供应商配置
+ * @param provider 供应商配置(Provider 或 ProviderProxyConfig)
  * @param targetUrl 目标请求 URL
  * @param targetUrl 目标请求 URL
  * @returns 代理配置对象,如果未配置代理则返回 null
  * @returns 代理配置对象,如果未配置代理则返回 null
  */
  */
 export function createProxyAgentForProvider(
 export function createProxyAgentForProvider(
-  provider: Provider,
+  provider: Provider | ProviderProxyConfig,
   targetUrl: string
   targetUrl: string
 ): ProxyConfig | null {
 ): ProxyConfig | null {
   // 未配置代理
   // 未配置代理
@@ -52,7 +63,7 @@ export function createProxyAgentForProvider(
       agent = new SocksProxyAgent(proxyUrl);
       agent = new SocksProxyAgent(proxyUrl);
       logger.debug("SOCKS ProxyAgent created", {
       logger.debug("SOCKS ProxyAgent created", {
         providerId: provider.id,
         providerId: provider.id,
-        providerName: provider.name,
+        providerName: provider.name ?? "unknown",
         protocol: parsedProxy.protocol,
         protocol: parsedProxy.protocol,
         proxyHost: parsedProxy.hostname,
         proxyHost: parsedProxy.hostname,
         proxyPort: parsedProxy.port,
         proxyPort: parsedProxy.port,
@@ -63,7 +74,7 @@ export function createProxyAgentForProvider(
       agent = new ProxyAgent(proxyUrl);
       agent = new ProxyAgent(proxyUrl);
       logger.debug("HTTP/HTTPS ProxyAgent created", {
       logger.debug("HTTP/HTTPS ProxyAgent created", {
         providerId: provider.id,
         providerId: provider.id,
-        providerName: provider.name,
+        providerName: provider.name ?? "unknown",
         protocol: parsedProxy.protocol,
         protocol: parsedProxy.protocol,
         proxyHost: parsedProxy.hostname,
         proxyHost: parsedProxy.hostname,
         proxyPort: parsedProxy.port,
         proxyPort: parsedProxy.port,
@@ -83,7 +94,7 @@ export function createProxyAgentForProvider(
   } catch (error) {
   } catch (error) {
     logger.error("Failed to create ProxyAgent", {
     logger.error("Failed to create ProxyAgent", {
       providerId: provider.id,
       providerId: provider.id,
-      providerName: provider.name,
+      providerName: provider.name ?? "unknown",
       proxyUrl: maskProxyUrl(proxyUrl),
       proxyUrl: maskProxyUrl(proxyUrl),
       error: error instanceof Error ? error.message : String(error),
       error: error instanceof Error ? error.message : String(error),
     });
     });

+ 0 - 14
src/lib/redis/client.ts

@@ -3,20 +3,6 @@ import { logger } from "@/lib/logger";
 
 
 let redisClient: Redis | null = null;
 let redisClient: Redis | null = null;
 
 
-/**
- * Mask password in a URL for safe logging.
- * Example: rediss://user:pass@host:6379 -> rediss://user:***@host:6379
- */
-function maskRedisUrl(urlStr: string): string {
-  try {
-    const u = new URL(urlStr);
-    if (u.password) u.password = "***";
-    return u.toString();
-  } catch {
-    return urlStr.replace(/:(?:[^:@]+)@/, ":***@");
-  }
-}
-
 /**
 /**
  * Build ioredis connection options with protocol-based TLS detection.
  * Build ioredis connection options with protocol-based TLS detection.
  * - When `rediss://` is used, explicitly enable TLS via `tls: {}`
  * - When `rediss://` is used, explicitly enable TLS via `tls: {}`

+ 2 - 2
src/lib/utils/error-messages.ts

@@ -149,7 +149,7 @@ export function getErrorMessage(
 ): string {
 ): string {
   try {
   try {
     return t(code, params);
     return t(code, params);
-  } catch (error) {
+  } catch {
     // Fallback to generic error message if translation key not found
     // Fallback to generic error message if translation key not found
     return t("INTERNAL_ERROR");
     return t("INTERNAL_ERROR");
   }
   }
@@ -178,7 +178,7 @@ export async function getErrorMessageServer(
     const { getTranslations } = await import("next-intl/server");
     const { getTranslations } = await import("next-intl/server");
     const t = await getTranslations({ locale, namespace: "errors" });
     const t = await getTranslations({ locale, namespace: "errors" });
     return t(code, params);
     return t(code, params);
-  } catch (error) {
+  } catch {
     // Fallback to generic error message
     // Fallback to generic error message
     return "An error occurred";
     return "An error occurred";
   }
   }

+ 2 - 2
src/lib/utils/zod-i18n.ts

@@ -55,7 +55,7 @@ export function setZodErrorMap(
 
 
     try {
     try {
       return { message: t(code, params) };
       return { message: t(code, params) };
-    } catch (error) {
+    } catch {
       // Fallback to Zod default message
       // Fallback to Zod default message
       return { message: _ctx.defaultError };
       return { message: _ctx.defaultError };
     }
     }
@@ -91,7 +91,7 @@ export async function getZodErrorMapServer(locale: string) {
 
 
     try {
     try {
       return { message: t(code, params) };
       return { message: t(code, params) };
-    } catch (error) {
+    } catch {
       return { message: _ctx.defaultError };
       return { message: _ctx.defaultError };
     }
     }
   };
   };

+ 1 - 1
src/repository/leaderboard.ts

@@ -2,7 +2,7 @@
 
 
 import { db } from "@/drizzle/db";
 import { db } from "@/drizzle/db";
 import { messageRequest, users, providers } from "@/drizzle/schema";
 import { messageRequest, users, providers } from "@/drizzle/schema";
-import { and, gte, lt, desc, sql, isNull } from "drizzle-orm";
+import { and, desc, sql, isNull } from "drizzle-orm";
 import { getEnvConfig } from "@/lib/config";
 import { getEnvConfig } from "@/lib/config";
 
 
 /**
 /**