Browse Source

feat: 增加环境变量控制网络错误是否计入熔断器

Ding 4 months ago
parent
commit
1c6155a9f7

+ 11 - 3
.env.example

@@ -38,10 +38,18 @@ SESSION_TTL=300                         # Session 过期时间(秒,默认 30
 STORE_SESSION_MESSAGES=false            # 是否存储请求 messages 到 Redis(用于实时监控页面查看详情,默认:false)
                                         # 警告:启用后会增加 Redis 内存使用,且可能包含敏感信息
 
+# 熔断器配置
+# 功能说明:控制网络错误是否计入熔断器失败计数
+# - false (默认):网络错误(DNS 解析失败、连接超时、代理连接失败等)不计入熔断器,仅供应商错误(4xx/5xx HTTP 响应)计入
+# - true:所有错误(包括网络错误)都计入熔断器失败计数
+# 使用场景:
+# - 默认关闭:适用于网络不稳定环境(如使用代理),避免因临时网络抖动触发熔断器
+# - 启用:适用于网络稳定环境,连续网络错误也应触发熔断保护,避免持续请求不可达的供应商
+ENABLE_CIRCUIT_BREAKER_ON_NETWORK_ERRORS=false
+
 # 多提供商类型支持(实验性功能)
-# 功能说明:启用除 Claude 以外的其他供应商类型支持
-# - false (默认):仅支持 Claude 类型供应商
-# - true:支持 Codex、Gemini CLI、OpenAI Compatible 等其他类型
+# - false (默认):仅支持 Claude、Codex类型供应商
+# - true:支持 Gemini CLI、OpenAI Compatible 等其他类型
 # 警告:其他类型功能仍在开发中,暂不建议启用
 ENABLE_MULTI_PROVIDER_TYPES=false
 

+ 101 - 0
CLAUDE.md

@@ -289,6 +289,102 @@ if (proxyConfig) {
 - **model_prices** - 模型价格 (支持 Claude 和 OpenAI 格式、缓存 Token 定价)
 - **statistics** - 统计数据 (小时级别聚合)
 
+## 模型重定向详解
+
+### 功能定义
+
+**模型重定向**是供应商级别的配置功能,允许将 Claude Code 客户端请求的 Claude 模型名称自动重定向到上游供应商实际支持的模型。
+
+### 工作原理
+
+```
+Claude Code 客户端请求: claude-sonnet-4-5-20250929
+    ↓
+[CCH 模型重定向]
+    ↓
+实际转发到上游供应商: glm-4.6 (智谱) / gemini-pro (Google)
+```
+
+**关键点**:
+
+- **源模型**(用户请求):必须是 Claude 模型(如 `claude-sonnet-4-5-20250929`、`claude-opus-4`)
+- **目标模型**(实际转发):可以是任何上游供应商支持的模型(如 `glm-4.6`、`gemini-pro`、`gpt-4o`)
+- **计费基准**:始终使用**源模型**(用户请求的模型)进行计费,保持用户端费用透明
+
+### 配置方式
+
+在**设置 → 供应商管理 → 编辑供应商**页面的"模型重定向"部分:
+
+1. **用户请求的模型**:输入 Claude Code 客户端请求的模型(如 `claude-sonnet-4-5-20250929`)
+2. **实际转发的模型**:输入上游供应商支持的模型(如 `glm-4.6`)
+3. 点击"添加"按钮保存规则
+
+**配置示例**:
+
+```json
+{
+  "claude-sonnet-4-5-20250929": "glm-4.6",
+  "claude-opus-4": "gemini-2.5-pro",
+  "claude-3-5-sonnet-20241022": "gpt-4o"
+}
+```
+
+### 使用场景
+
+1. **接入第三方 AI 服务**
+   - Claude Code 客户端只认 Anthropic 模型
+   - 通过重定向,可以将请求转发到智谱、Google、OpenAI 等第三方服务
+   - 用户无需修改客户端配置
+
+2. **成本优化**
+   - 将昂贵的 Claude 模型重定向到性能相近但更便宜的第三方模型
+   - 示例:`claude-opus-4` → `gemini-2.5-pro`(假设 Gemini 更便宜)
+
+3. **供应商切换**
+   - 快速切换不同供应商而不影响客户端
+   - 支持 A/B 测试不同模型的效果
+
+4. **模型升级管理**
+   - 自动将旧版本模型升级到新版本
+   - 示例:`claude-3-opus` → `claude-opus-4`
+
+### 计费说明
+
+**重要**:系统使用**源模型**(用户请求的 Claude 模型)进行计费,而不是重定向后的目标模型。
+
+- **用户请求**:`claude-sonnet-4-5-20250929`
+- **实际转发**:`glm-4.6`
+- **计费依据**:`claude-sonnet-4-5-20250929` 的价格表
+- **数据库记录**:
+  - `message_request.original_model` = `claude-sonnet-4-5-20250929`(计费)
+  - `message_request.model` = `glm-4.6`(实际使用)
+
+### 技术实现
+
+**数据存储**:
+
+- 表字段:`providers.model_redirects` (JSONB)
+- 数据格式:`{ "源模型": "目标模型" }` 的键值对
+
+**执行时机**:
+
+1. 供应商选择完成后
+2. 请求转发前
+3. `ModelRedirector.apply()` 检查并应用重定向规则(参见 `src/app/v1/_lib/proxy/model-redirector.ts`)
+
+**日志追踪**:
+
+- 重定向会在请求日志中显示"已重定向"标记
+- 详细信息包含源模型和目标模型
+- Session note 记录完整的重定向路径
+
+### 注意事项
+
+1. **模型兼容性**:确保目标模型的能力与源模型匹配(如支持 tools、thinking 等功能)
+2. **价格配置**:需要在价格表中配置源模型的价格,用于正确计费
+3. **供应商类型**:建议配置 `joinClaudePool = true`,允许非 Anthropic 供应商加入 Claude 调度池
+4. **测试验证**:配置后建议先测试,确保重定向生效且响应格式正确
+
 ## 环境变量
 
 关键环境变量 (参见 `.env.example`):
@@ -309,6 +405,11 @@ ENABLE_RATE_LIMIT=true             # 启用限流功能
 SESSION_TTL=300                    # Session 缓存过期时间(秒)
 STORE_SESSION_MESSAGES=false       # 是否存储请求 messages(用于实时监控)
 
+# 熔断器配置
+ENABLE_CIRCUIT_BREAKER_ON_NETWORK_ERRORS=false  # 网络错误是否计入熔断器(默认:false)
+                                                # false: 仅 HTTP 4xx/5xx 错误计入熔断器
+                                                # true: 网络错误(DNS 失败、连接超时等)也计入熔断器
+
 # Cookie 安全策略
 ENABLE_SECURE_COOKIES=true         # 是否强制 HTTPS Cookie(默认:true)
                                    # 设置为 false 允许 HTTP 访问,但会降低安全性

+ 21 - 5
src/app/api/internal/data-gen/route.ts

@@ -1,6 +1,6 @@
 import { NextRequest, NextResponse } from "next/server";
 import { getSession } from "@/lib/auth";
-import { generateLogs } from "@/lib/data-generator/generator";
+import { generateLogs, generateUserBreakdown } from "@/lib/data-generator/generator";
 import type { GeneratorParams } from "@/lib/data-generator/types";
 
 // 需要数据库连接
@@ -16,13 +16,25 @@ export async function POST(request: NextRequest) {
   try {
     const body = await request.json();
 
-    const { startDate, endDate, totalRecords, totalCostCny, models, userIds, providerIds } = body;
+    const {
+      mode,
+      serviceName,
+      startDate,
+      endDate,
+      totalRecords,
+      totalCostCny,
+      models,
+      userIds,
+      providerIds,
+    } = body;
 
     if (!startDate || !endDate) {
       return NextResponse.json({ error: "startDate and endDate are required" }, { status: 400 });
     }
 
     const params: GeneratorParams = {
+      mode: mode || "usage",
+      serviceName: serviceName || "AI大模型推理服务",
       startDate: new Date(startDate),
       endDate: new Date(endDate),
       totalRecords,
@@ -32,9 +44,13 @@ export async function POST(request: NextRequest) {
       providerIds,
     };
 
-    const result = await generateLogs(params);
-
-    return NextResponse.json(result);
+    if (params.mode === "userBreakdown") {
+      const result = await generateUserBreakdown(params);
+      return NextResponse.json(result);
+    } else {
+      const result = await generateLogs(params);
+      return NextResponse.json(result);
+    }
   } catch (error) {
     console.error("Error generating logs:", error);
     return NextResponse.json(

+ 2 - 2
src/app/dashboard/_components/user/forms/user-form.tsx

@@ -106,8 +106,8 @@ export function UserForm({ user, onSuccess }: UserFormProps) {
       <TextField
         label="供应商分组"
         maxLength={50}
-        placeholder="例如: premium, economy(可选)"
-        description="指定用户专属的供应商分组,留空则使用全局供应商池"
+        placeholder="例如: premium 或 premium,economy(可选)"
+        description="指定用户专属的供应商分组(支持多个,逗号分隔)。系统将只从 groupTag 匹配的供应商中选择。留空=使用所有供应商"
         {...form.getFieldProps("providerGroup")}
       />
 

+ 221 - 8
src/app/internal/data-gen/_components/data-generator-page.tsx

@@ -1,11 +1,13 @@
 "use client";
 
-import { useState } from "react";
+import { useState, useMemo } from "react";
 import { Button } from "@/components/ui/button";
 import { Input } from "@/components/ui/input";
 import { Label } from "@/components/ui/label";
 import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card";
 import { Alert, AlertDescription } from "@/components/ui/alert";
+import { Tabs, TabsList, TabsTrigger } from "@/components/ui/tabs";
+import { Switch } from "@/components/ui/switch";
 import {
   Table,
   TableBody,
@@ -15,11 +17,13 @@ import {
   TableRow,
 } from "@/components/ui/table";
 import { AlertCircle, Download, FileDown, Loader2, Settings } from "lucide-react";
-import type { GeneratorResult } from "@/lib/data-generator/types";
+import type { GeneratorResult, UserBreakdownResult } from "@/lib/data-generator/types";
 
 export function DataGeneratorPage() {
+  const [mode, setMode] = useState<"usage" | "userBreakdown">("usage");
   const [startDate, setStartDate] = useState<string>("");
   const [endDate, setEndDate] = useState<string>("");
+  const [serviceName, setServiceName] = useState<string>("AI大模型推理服务");
   const [totalCostCny, setTotalCostCny] = useState<string>("");
   const [totalRecords, setTotalRecords] = useState<string>("");
   const [models, setModels] = useState<string>("");
@@ -29,19 +33,27 @@ export function DataGeneratorPage() {
   const [loading, setLoading] = useState(false);
   const [error, setError] = useState<string | null>(null);
   const [result, setResult] = useState<GeneratorResult | null>(null);
+  const [userBreakdownResult, setUserBreakdownResult] = useState<UserBreakdownResult | null>(null);
   const [showParams, setShowParams] = useState(true);
+  const [collapseByUser, setCollapseByUser] = useState(true); // 默认折叠
 
   const handleGenerate = async () => {
     setLoading(true);
     setError(null);
     setResult(null);
+    setUserBreakdownResult(null);
 
     try {
       const payload: Record<string, unknown> = {
+        mode,
         startDate,
         endDate,
       };
 
+      if (mode === "userBreakdown") {
+        payload.serviceName = serviceName;
+      }
+
       if (totalCostCny) {
         payload.totalCostCny = parseFloat(totalCostCny);
       }
@@ -78,9 +90,14 @@ export function DataGeneratorPage() {
         throw new Error(data.error || "Failed to generate logs");
       }
 
-      const data: GeneratorResult = await response.json();
-      setResult(data);
-      setShowParams(false); // 生成成功后自动关闭参数框
+      if (mode === "userBreakdown") {
+        const data: UserBreakdownResult = await response.json();
+        setUserBreakdownResult(data);
+      } else {
+        const data: GeneratorResult = await response.json();
+        setResult(data);
+      }
+      setShowParams(false);
     } catch (err) {
       setError(err instanceof Error ? err.message : "Unknown error");
     } finally {
@@ -135,9 +152,58 @@ export function DataGeneratorPage() {
     });
   };
 
+  // 折叠后的用户数据(按用户聚合)
+  const collapsedUserData = useMemo(() => {
+    if (!userBreakdownResult || !collapseByUser) return null;
+
+    const userMap = new Map<
+      string,
+      {
+        userName: string;
+        serviceName: string;
+        models: Set<string>;
+        totalCalls: number;
+        totalCost: number;
+      }
+    >();
+
+    for (const item of userBreakdownResult.items) {
+      const existing = userMap.get(item.userName);
+      if (existing) {
+        existing.models.add(item.model);
+        existing.totalCalls += item.totalCalls;
+        existing.totalCost += item.totalCost;
+      } else {
+        userMap.set(item.userName, {
+          userName: item.userName,
+          serviceName: item.serviceName,
+          models: new Set([item.model]),
+          totalCalls: item.totalCalls,
+          totalCost: item.totalCost,
+        });
+      }
+    }
+
+    return Array.from(userMap.values())
+      .map((user) => ({
+        userName: user.userName,
+        serviceModel: `${user.serviceName} - ${Array.from(user.models).join("、")}`,
+        totalCalls: user.totalCalls,
+        totalCost: user.totalCost,
+      }))
+      .sort((a, b) => b.totalCost - a.totalCost);
+  }, [userBreakdownResult, collapseByUser]);
+
   return (
     <div className="space-y-6 p-6">
-      {!showParams && result && (
+      <Tabs value={mode} onValueChange={(v) => setMode(v as "usage" | "userBreakdown")}>
+        <TabsList>
+          <TabsTrigger value="usage">用量数据生成</TabsTrigger>
+          <TabsTrigger value="userBreakdown">按用户显示用量</TabsTrigger>
+        </TabsList>
+      </Tabs>
+
+      {!showParams && (result || userBreakdownResult) && (
         <div className="flex justify-end">
           <Button variant="outline" size="sm" onClick={() => setShowParams(true)}>
             <Settings className="mr-2 h-4 w-4" />
@@ -219,6 +285,17 @@ export function DataGeneratorPage() {
                   onChange={(e) => setProviderIds(e.target.value)}
                 />
               </div>
+              {mode === "userBreakdown" && (
+                <div className="space-y-2">
+                  <Label htmlFor="serviceName">服务名称</Label>
+                  <Input
+                    id="serviceName"
+                    placeholder="AI大模型推理服务"
+                    value={serviceName}
+                    onChange={(e) => setServiceName(e.target.value)}
+                  />
+                </div>
+              )}
             </div>
 
             <Button onClick={handleGenerate} disabled={loading || !startDate || !endDate}>
@@ -258,12 +335,12 @@ export function DataGeneratorPage() {
                 </CardTitle>
               </CardHeader>
             </Card>
-            <Card>
+            {/* <Card>
               <CardHeader className="pb-2">
                 <CardDescription>总成本</CardDescription>
                 <CardTitle className="text-2xl">${result.summary.totalCost.toFixed(4)}</CardTitle>
               </CardHeader>
-            </Card>
+            </Card> */}
             <Card>
               <CardHeader className="pb-2">
                 <CardDescription>总成本(人民币)</CardDescription>
@@ -360,6 +437,142 @@ export function DataGeneratorPage() {
           </Card>
         </div>
       )}
+
+      {userBreakdownResult && (
+        <div id="export-content" className="space-y-6">
+          <div className="flex justify-end gap-2">
+            <Button variant="outline" size="sm" onClick={handleExportScreenshot}>
+              <Download className="mr-2 h-4 w-4" />
+              导出截图
+            </Button>
+            <Button variant="outline" size="sm" onClick={handleExportPDF}>
+              <FileDown className="mr-2 h-4 w-4" />
+              导出 PDF
+            </Button>
+          </div>
+
+          <div className="grid grid-cols-1 md:grid-cols-5 gap-4">
+            <Card>
+              <CardHeader className="pb-2">
+                <CardDescription>时间范围</CardDescription>
+                <CardTitle className="text-sm">
+                  <div>
+                    {new Date(startDate).toLocaleString("zh-CN", {
+                      month: "numeric",
+                      day: "numeric",
+                      hour: "2-digit",
+                      minute: "2-digit",
+                    })}
+                  </div>
+                  <div className="text-muted-foreground text-xs">至</div>
+                  <div>
+                    {new Date(endDate).toLocaleString("zh-CN", {
+                      month: "numeric",
+                      day: "numeric",
+                      hour: "2-digit",
+                      minute: "2-digit",
+                    })}
+                  </div>
+                </CardTitle>
+              </CardHeader>
+            </Card>
+            <Card>
+              <CardHeader className="pb-2">
+                <CardDescription>总用户数</CardDescription>
+                <CardTitle className="text-2xl">
+                  {userBreakdownResult.summary.uniqueUsers.toLocaleString()}
+                </CardTitle>
+              </CardHeader>
+            </Card>
+            <Card>
+              <CardHeader className="pb-2">
+                <CardDescription>总调用数</CardDescription>
+                <CardTitle className="text-2xl">
+                  {userBreakdownResult.summary.totalCalls.toLocaleString()}
+                </CardTitle>
+              </CardHeader>
+            </Card>
+            <Card>
+              <CardHeader className="pb-2">
+                <CardDescription>总成本(人民币)</CardDescription>
+                <CardTitle className="text-2xl">
+                  ¥{(userBreakdownResult.summary.totalCost * 7.1).toFixed(2)}
+                </CardTitle>
+              </CardHeader>
+            </Card>
+          </div>
+
+          <Card>
+            <CardHeader>
+              <div className="flex items-center justify-between">
+                <div>
+                  <CardTitle>用户用量明细</CardTitle>
+                  <CardDescription>
+                    共{" "}
+                    {collapseByUser ? collapsedUserData?.length : userBreakdownResult.items.length}{" "}
+                    条记录
+                  </CardDescription>
+                </div>
+                <div className="flex items-center space-x-2">
+                  <Switch
+                    id="collapse-mode"
+                    checked={collapseByUser}
+                    onCheckedChange={setCollapseByUser}
+                  />
+                  <Label htmlFor="collapse-mode" className="cursor-pointer">
+                    按用户折叠
+                  </Label>
+                </div>
+              </div>
+            </CardHeader>
+            <CardContent>
+              <div className="rounded-md border max-h-[600px] overflow-auto">
+                <Table>
+                  <TableHeader className="sticky top-0 bg-background">
+                    <TableRow>
+                      <TableHead>用户名</TableHead>
+                      {!collapseByUser && <TableHead>密钥</TableHead>}
+                      <TableHead>服务模型</TableHead>
+                      <TableHead className="text-right">总调用数</TableHead>
+                      <TableHead className="text-right">总调用额度</TableHead>
+                    </TableRow>
+                  </TableHeader>
+                  <TableBody>
+                    {collapseByUser
+                      ? collapsedUserData?.map((item, idx) => (
+                          <TableRow key={idx}>
+                            <TableCell>{item.userName}</TableCell>
+                            <TableCell className="font-mono text-xs">{item.serviceModel}</TableCell>
+                            <TableCell className="text-right">
+                              {item.totalCalls.toLocaleString()}
+                            </TableCell>
+                            <TableCell className="text-right font-mono text-xs">
+                              ¥{(item.totalCost * 7.1).toFixed(2)}
+                            </TableCell>
+                          </TableRow>
+                        ))
+                      : userBreakdownResult.items.map((item, idx) => (
+                          <TableRow key={idx}>
+                            <TableCell>{item.userName}</TableCell>
+                            <TableCell className="font-mono text-xs">{item.keyName}</TableCell>
+                            <TableCell className="font-mono text-xs">
+                              {item.serviceName} - {item.model}
+                            </TableCell>
+                            <TableCell className="text-right">
+                              {item.totalCalls.toLocaleString()}
+                            </TableCell>
+                            <TableCell className="text-right font-mono text-xs">
+                              ¥{(item.totalCost * 7.1).toFixed(2)}
+                            </TableCell>
+                          </TableRow>
+                        ))}
+                  </TableBody>
+                </Table>
+              </div>
+            </CardContent>
+          </Card>
+        </div>
+      )}
     </div>
   );
 }

+ 0 - 1
src/app/settings/notifications/page.tsx

@@ -18,7 +18,6 @@ import {
   updateNotificationSettingsAction,
   testWebhookAction,
 } from "@/actions/notifications";
-import type { NotificationSettings } from "@/repository/notifications";
 
 /**
  * 通知设置表单 Schema

+ 19 - 20
src/app/settings/providers/_components/forms/provider-form.tsx

@@ -445,12 +445,7 @@ export function ProviderForm({
           <div className="text-sm font-medium">路由配置</div>
           <div className="grid grid-cols-3 gap-4">
             <div className="space-y-2">
-              <Label htmlFor={isEdit ? "edit-priority" : "priority"}>
-                优先级
-                <span className="text-xs text-muted-foreground ml-1">
-                  (数值越小优先级越高,0为最高)
-                </span>
-              </Label>
+              <Label htmlFor={isEdit ? "edit-priority" : "priority"}>优先级</Label>
               <Input
                 id={isEdit ? "edit-priority" : "priority"}
                 type="number"
@@ -461,12 +456,13 @@ export function ProviderForm({
                 min="0"
                 step="1"
               />
+              <p className="text-xs text-muted-foreground">
+                数值越小优先级越高(0
+                最高)。系统只从最高优先级的供应商中选择。建议:主力=0,备用=1,紧急备份=2
+              </p>
             </div>
             <div className="space-y-2">
-              <Label htmlFor={isEdit ? "edit-weight" : "weight"}>
-                权重
-                <span className="text-xs text-muted-foreground ml-1">(负载均衡)</span>
-              </Label>
+              <Label htmlFor={isEdit ? "edit-weight" : "weight"}>权重</Label>
               <Input
                 id={isEdit ? "edit-weight" : "weight"}
                 type="number"
@@ -477,32 +473,30 @@ export function ProviderForm({
                 min="1"
                 step="1"
               />
+              <p className="text-xs text-muted-foreground">
+                加权随机概率。同优先级内,权重越高被选中概率越大。例如权重 1:2:3 的概率为
+                16%:33%:50%
+              </p>
             </div>
             <div className="space-y-2">
-              <Label htmlFor={isEdit ? "edit-cost" : "cost"}>
-                成本倍率
-                <span className="text-xs text-muted-foreground ml-1">(相对官方定价)</span>
-              </Label>
+              <Label htmlFor={isEdit ? "edit-cost" : "cost"}>成本倍率</Label>
               <Input
                 id={isEdit ? "edit-cost" : "cost"}
                 type="number"
                 value={costMultiplier}
                 onChange={(e) => setCostMultiplier(parseFloat(e.target.value) || 1.0)}
-                placeholder="1.0 表示官方价格"
+                placeholder="1.0"
                 disabled={isPending}
                 min="0"
                 step="0.0001"
               />
               <p className="text-xs text-muted-foreground">
-                例如填 0.6 表示按官方价格的 60% 计费,填 1.0 表示官方价格(支持最多4位小数)
+                成本计算倍数。官方供应商=1.0,便宜 20%=0.8,贵 20%=1.2(支持最多 4 位小数)
               </p>
             </div>
           </div>
           <div className="space-y-2">
-            <Label htmlFor={isEdit ? "edit-group" : "group"}>
-              供应商分组
-              <span className="text-xs text-muted-foreground ml-1">(用于用户绑定)</span>
-            </Label>
+            <Label htmlFor={isEdit ? "edit-group" : "group"}>供应商分组</Label>
             <Input
               id={isEdit ? "edit-group" : "group"}
               value={groupTag}
@@ -510,6 +504,11 @@ export function ProviderForm({
               placeholder="例如: premium, economy"
               disabled={isPending}
             />
+            <p className="text-xs text-muted-foreground">
+              供应商分组标签。只有用户的 providerGroup
+              与此值匹配时,该用户才能使用此供应商。示例:设置为 &quot;premium&quot; 表示只供
+              providerGroup=&quot;premium&quot; 的用户使用
+            </p>
           </div>
         </div>
 

+ 22 - 28
src/app/settings/providers/_components/model-redirect-editor.tsx

@@ -1,11 +1,10 @@
 "use client";
 import { useState } from "react";
-import { Plus, Trash2, AlertCircle } from "lucide-react";
+import { Plus, X, ArrowRight, AlertCircle } from "lucide-react";
 import { Button } from "@/components/ui/button";
 import { Input } from "@/components/ui/input";
 import { Label } from "@/components/ui/label";
 import { Badge } from "@/components/ui/badge";
-import { Card } from "@/components/ui/card";
 
 interface ModelRedirectEditorProps {
   value: Record<string, string>;
@@ -76,38 +75,31 @@ export function ModelRedirectEditor({
           <div className="text-xs font-medium text-muted-foreground">
             当前规则 ({redirects.length})
           </div>
-          <div className="space-y-2">
+          <div className="space-y-1">
             {redirects.map(([source, target]) => (
-              <Card
+              <div
                 key={source}
-                className="p-3 flex items-center justify-between gap-3 hover:bg-muted/50 transition-colors"
+                className="group flex items-center gap-2 py-2 px-3 rounded-md hover:bg-muted/50 transition-colors"
               >
-                <div className="flex items-center gap-3 flex-1 min-w-0">
-                  <div className="flex-1 min-w-0">
-                    <div className="text-xs text-muted-foreground mb-1">源模型</div>
-                    <Badge variant="outline" className="font-mono text-xs">
-                      {source}
-                    </Badge>
-                  </div>
-                  <div className="text-muted-foreground">→</div>
-                  <div className="flex-1 min-w-0">
-                    <div className="text-xs text-muted-foreground mb-1">目标模型</div>
-                    <Badge variant="secondary" className="font-mono text-xs">
-                      {target}
-                    </Badge>
-                  </div>
-                </div>
+                <Badge variant="outline" className="font-mono text-xs shrink-0">
+                  {source}
+                </Badge>
+                <ArrowRight className="h-3 w-3 text-muted-foreground shrink-0" />
+                <Badge variant="secondary" className="font-mono text-xs shrink-0">
+                  {target}
+                </Badge>
+                <div className="flex-1" />
                 <Button
                   type="button"
                   variant="ghost"
                   size="sm"
                   onClick={() => handleRemove(source)}
                   disabled={disabled}
-                  className="shrink-0 h-8 w-8 p-0"
+                  className="h-6 w-6 p-0 opacity-0 group-hover:opacity-100 transition-opacity"
                 >
-                  <Trash2 className="h-4 w-4 text-destructive" />
+                  <X className="h-3 w-3 text-muted-foreground" />
                 </Button>
-              </Card>
+              </div>
             ))}
           </div>
         </div>
@@ -119,14 +111,14 @@ export function ModelRedirectEditor({
         <div className="grid grid-cols-[1fr_auto_1fr_auto] gap-2 items-end">
           <div className="space-y-1">
             <Label htmlFor="new-source" className="text-xs">
-              源模型名称
+              用户请求的模型
             </Label>
             <Input
               id="new-source"
               value={newSource}
               onChange={(e) => setNewSource(e.target.value)}
               onKeyDown={handleKeyDown}
-              placeholder="例如: gpt-5"
+              placeholder="例如: claude-sonnet-4-5-20250929"
               disabled={disabled}
               className="font-mono text-sm"
             />
@@ -136,14 +128,14 @@ export function ModelRedirectEditor({
 
           <div className="space-y-1">
             <Label htmlFor="new-target" className="text-xs">
-              目标模型名称
+              实际转发的模型
             </Label>
             <Input
               id="new-target"
               value={newTarget}
               onChange={(e) => setNewTarget(e.target.value)}
               onKeyDown={handleKeyDown}
-              placeholder="例如: claude-sonnet-4.5"
+              placeholder="例如: glm-4.6"
               disabled={disabled}
               className="font-mono text-sm"
             />
@@ -171,7 +163,9 @@ export function ModelRedirectEditor({
 
         {/* 帮助文本 */}
         <p className="text-xs text-muted-foreground">
-          输入源模型和目标模型名称,然后点击&ldquo;添加&rdquo;按钮。重定向规则将在请求时自动应用。
+          将 Claude Code 客户端请求的模型(如
+          claude-sonnet-4.5)重定向到上游供应商实际支持的模型(如
+          glm-4.6、gemini-pro)。用于成本优化或接入第三方 AI 服务。
         </p>
       </div>
 

+ 61 - 19
src/app/settings/providers/_components/provider-list-item.tsx

@@ -4,6 +4,7 @@ import { useRouter } from "next/navigation";
 import { Button } from "@/components/ui/button";
 import { Dialog, DialogContent, DialogTrigger } from "@/components/ui/dialog";
 import { Badge } from "@/components/ui/badge";
+import { Tooltip, TooltipContent, TooltipTrigger } from "@/components/ui/tooltip";
 import { Edit, Globe, Key, RotateCcw, Copy } from "lucide-react";
 import type { ProviderDisplay } from "@/types/provider";
 import type { User } from "@/types/user";
@@ -337,9 +338,18 @@ export function ProviderListItem({
           {/* 优先级 */}
           <div className="min-w-0 text-center">
             <div className="text-muted-foreground">优先级</div>
-            <div className="w-full text-center font-medium tabular-nums truncate text-foreground">
-              <span>{item.priority}</span>
-            </div>
+            <Tooltip>
+              <TooltipTrigger asChild>
+                <div className="w-full text-center font-medium tabular-nums truncate text-foreground cursor-help">
+                  <span>{item.priority}</span>
+                </div>
+              </TooltipTrigger>
+              <TooltipContent side="bottom" className="max-w-xs">
+                <p className="text-xs">
+                  数值越小优先级越高(0 最高)。系统只从最高优先级的供应商中选择。
+                </p>
+              </TooltipContent>
+            </Tooltip>
           </div>
 
           {/* 权重 */}
@@ -348,13 +358,22 @@ export function ProviderListItem({
             {canEdit ? (
               <Popover open={showWeight} onOpenChange={handleWeightPopover}>
                 <PopoverTrigger asChild>
-                  <button
-                    type="button"
-                    aria-label="编辑权重"
-                    className="w-full text-center font-medium tabular-nums truncate text-foreground cursor-pointer hover:text-primary/80 transition-colors"
-                  >
-                    <span>{weight}</span>
-                  </button>
+                  <Tooltip>
+                    <TooltipTrigger asChild>
+                      <button
+                        type="button"
+                        aria-label="编辑权重"
+                        className="w-full text-center font-medium tabular-nums truncate text-foreground cursor-pointer hover:text-primary/80 transition-colors"
+                      >
+                        <span>{weight}</span>
+                      </button>
+                    </TooltipTrigger>
+                    <TooltipContent side="bottom" className="max-w-xs">
+                      <p className="text-xs">
+                        加权随机概率。同优先级内,权重越高被选中概率越大。点击可编辑。
+                      </p>
+                    </TooltipContent>
+                  </Tooltip>
                 </PopoverTrigger>
                 <PopoverContent align="center" side="bottom" sideOffset={6} className="w-64 p-3">
                   <div className="mb-2 flex items-center justify-between text-[11px] text-muted-foreground">
@@ -371,26 +390,49 @@ export function ProviderListItem({
                 </PopoverContent>
               </Popover>
             ) : (
-              <div className="w-full text-center font-medium tabular-nums truncate text-foreground">
-                <span>{weight}</span>
-              </div>
+              <Tooltip>
+                <TooltipTrigger asChild>
+                  <div className="w-full text-center font-medium tabular-nums truncate text-foreground cursor-help">
+                    <span>{weight}</span>
+                  </div>
+                </TooltipTrigger>
+                <TooltipContent side="bottom" className="max-w-xs">
+                  <p className="text-xs">加权随机概率。同优先级内,权重越高被选中概率越大。</p>
+                </TooltipContent>
+              </Tooltip>
             )}
           </div>
 
           {/* 成本倍率 */}
           <div className="min-w-0 text-center">
             <div className="text-muted-foreground">倍率</div>
-            <div className="w-full text-center font-medium tabular-nums truncate text-foreground">
-              <span>{item.costMultiplier.toFixed(2)}x</span>
-            </div>
+            <Tooltip>
+              <TooltipTrigger asChild>
+                <div className="w-full text-center font-medium tabular-nums truncate text-foreground cursor-help">
+                  <span>{item.costMultiplier.toFixed(2)}x</span>
+                </div>
+              </TooltipTrigger>
+              <TooltipContent side="bottom" className="max-w-xs">
+                <p className="text-xs">成本计算倍数。1.0x=官方价格,0.8x=便宜 20%,1.2x=贵 20%</p>
+              </TooltipContent>
+            </Tooltip>
           </div>
 
           {/* 分组 */}
           <div className="min-w-0 text-center">
             <div className="text-muted-foreground">分组</div>
-            <div className="w-full text-center font-medium truncate text-foreground">
-              <span>{item.groupTag || "-"}</span>
-            </div>
+            <Tooltip>
+              <TooltipTrigger asChild>
+                <div className="w-full text-center font-medium truncate text-foreground cursor-help">
+                  <span>{item.groupTag || "-"}</span>
+                </div>
+              </TooltipTrigger>
+              <TooltipContent side="bottom" className="max-w-xs">
+                <p className="text-xs">
+                  只有 providerGroup 包含此标签的用户才能使用此供应商。未设置表示所有用户可用。
+                </p>
+              </TooltipContent>
+            </Tooltip>
           </div>
         </div>
 

+ 29 - 0
src/app/v1/_lib/proxy/forwarder.ts

@@ -18,6 +18,7 @@ import { mapClientFormatToTransformer, mapProviderTypeToTransformer } from "./fo
 import { isOfficialCodexClient, sanitizeCodexRequest } from "../codex/utils/request-sanitizer";
 import { createProxyAgentForProvider } from "@/lib/proxy-agent";
 import type { Dispatcher } from "undici";
+import { getEnvConfig } from "@/lib/config/env.schema";
 
 const MAX_RETRY_ATTEMPTS = 3;
 
@@ -179,6 +180,34 @@ export class ProxyForwarder {
             providerId: currentProvider.id,
             providerName: currentProvider.name,
           });
+
+          // ⭐ 检查是否启用了网络错误计入熔断器
+          const env = getEnvConfig();
+          if (env.ENABLE_CIRCUIT_BREAKER_ON_NETWORK_ERRORS) {
+            logger.warn(
+              "ProxyForwarder: Network error will be counted towards circuit breaker (enabled by config)",
+              {
+                providerId: currentProvider.id,
+                providerName: currentProvider.name,
+                errorType: err.constructor.name,
+                errorCode: err.code,
+              }
+            );
+
+            // 记录到失败列表(避免重新选择)
+            failedProviderIds.push(currentProvider.id);
+
+            // 计入熔断器
+            await recordFailure(currentProvider.id, lastError);
+          } else {
+            logger.debug(
+              "ProxyForwarder: Network error not counted towards circuit breaker (disabled by default)",
+              {
+                providerId: currentProvider.id,
+                providerName: currentProvider.name,
+              }
+            );
+          }
         }
 
         // ⭐ 4. 供应商错误处理(所有 4xx/5xx HTTP 错误,计入熔断器,直接切换)

+ 2 - 1
src/app/v1/_lib/proxy/model-redirector.ts

@@ -6,7 +6,8 @@ import type { ProxySession } from "./session";
  * 模型重定向器
  *
  * 根据供应商配置的 modelRedirects 重写请求中的模型名称
- * 例如:将 "gpt-5" 重定向为 "gpt-5-codex"
+ * 例如:将 Claude Code 客户端请求的 "claude-sonnet-4-5-20250929" 重定向为上游供应商支持的 "glm-4.6"
+ * 用于接入第三方 AI 服务或成本优化
  */
 export class ModelRedirector {
   /**

+ 0 - 1
src/lib/api/action-adapter-openapi.ts

@@ -14,7 +14,6 @@ import type { ActionResult } from "@/actions/types";
 import { logger } from "@/lib/logger";
 
 // Server Action 函数签名 (支持两种格式)
-// eslint-disable-next-line @typescript-eslint/no-explicit-any
 type ServerAction =
   // eslint-disable-next-line @typescript-eslint/no-explicit-any
   | ((...args: any[]) => Promise<ActionResult<any>>) // 标准格式

+ 1 - 0
src/lib/config/env.schema.ts

@@ -37,6 +37,7 @@ export const EnvSchema = z.object({
   LOG_LEVEL: z.enum(["fatal", "error", "warn", "info", "debug", "trace"]).default("info"),
   TZ: z.string().default("Asia/Shanghai"),
   ENABLE_MULTI_PROVIDER_TYPES: z.string().default("false").transform(booleanTransform),
+  ENABLE_CIRCUIT_BREAKER_ON_NETWORK_ERRORS: z.string().default("false").transform(booleanTransform),
 });
 
 /**

+ 50 - 0
src/lib/data-generator/generator.ts

@@ -6,6 +6,8 @@ import type {
   WeightedItem,
   ProviderInfo,
   ModelInfo,
+  UserBreakdownResult,
+  UserBreakdownItem,
 } from "./types";
 import type { ProviderChainItem } from "@/types/message";
 import { analyzeLogDistribution } from "./analyzer";
@@ -287,3 +289,51 @@ export async function generateLogs(params: GeneratorParams): Promise<GeneratorRe
     },
   };
 }
+
+export async function generateUserBreakdown(params: GeneratorParams): Promise<UserBreakdownResult> {
+  const logsResult = await generateLogs(params);
+  const serviceName = params.serviceName || "AI大模型推理服务";
+
+  const breakdownMap = new Map<string, UserBreakdownItem>();
+
+  for (const log of logsResult.logs) {
+    if (log.statusCode !== 200) continue;
+
+    const key = `${log.userName}|${log.keyName}|${log.model}`;
+    const existing = breakdownMap.get(key);
+
+    if (existing) {
+      existing.totalCalls++;
+      existing.totalCost += parseFloat(log.costUsd);
+    } else {
+      breakdownMap.set(key, {
+        userName: log.userName,
+        keyName: log.keyName,
+        model: log.model,
+        serviceName,
+        totalCalls: 1,
+        totalCost: parseFloat(log.costUsd),
+      });
+    }
+  }
+
+  const items = Array.from(breakdownMap.values()).sort((a, b) => b.totalCost - a.totalCost);
+
+  const uniqueUsers = new Set(items.map((item) => item.userName)).size;
+  const uniqueKeys = new Set(items.map((item) => item.keyName)).size;
+  const uniqueModels = new Set(items.map((item) => item.model)).size;
+
+  const totalCalls = items.reduce((sum, item) => sum + item.totalCalls, 0);
+  const totalCost = items.reduce((sum, item) => sum + item.totalCost, 0);
+
+  return {
+    items,
+    summary: {
+      totalCalls,
+      totalCost,
+      uniqueUsers,
+      uniqueKeys,
+      uniqueModels,
+    },
+  };
+}

+ 22 - 0
src/lib/data-generator/types.ts

@@ -57,6 +57,8 @@ export interface LogDistribution {
 export interface GeneratorParams {
   startDate: Date;
   endDate: Date;
+  mode?: "usage" | "userBreakdown";
+  serviceName?: string;
   totalRecords?: number;
   totalCostCny?: number;
   models?: string[];
@@ -98,3 +100,23 @@ export interface GeneratorResult {
     totalCacheReadTokens: number;
   };
 }
+
+export interface UserBreakdownItem {
+  userName: string;
+  keyName: string;
+  model: string;
+  serviceName: string;
+  totalCalls: number;
+  totalCost: number;
+}
+
+export interface UserBreakdownResult {
+  items: UserBreakdownItem[];
+  summary: {
+    totalCalls: number;
+    totalCost: number;
+    uniqueUsers: number;
+    uniqueKeys: number;
+    uniqueModels: number;
+  };
+}