Procházet zdrojové kódy

fix: 修复非 Claude 模型请求时的供应商格式错配问题

## 问题描述

当用户使用 Claude CLI 请求 /v1/messages 端点(Claude Messages API 格式),
但请求的模型不是 claude-* 开头(如 gemini-3-pro-preview)时,系统会错误
地选择 provider_type: "codex" 的供应商,导致请求格式与供应商类型不匹配。

根本原因:系统根据**模型名称**(是否以 `claude-` 开头)判断目标供应商类型,
而不是根据**请求格式**(session.originalFormat)。

## 修复方案

### 1. 新增格式兼容性检查函数
- 新增 `checkFormatProviderTypeCompatibility()` 辅助函数
- 根据 ClientFormat 和 ProviderType 判断兼容性
- 映射关系:
  * claude → claude | claude-auth
  * response → codex
  * openai → openai-compatible
  * gemini → gemini
  * gemini-cli → gemini-cli

### 2. 修改供应商筛选逻辑
- 在模型匹配检查**之前**增加格式类型匹配检查(Step 1b)
- 只选择与请求格式兼容的供应商类型
- 向后兼容:如果 session.originalFormat 未设置,跳过此检查

### 3. 优化 targetType 计算
- 将 decisionContext.targetType 的判断从基于模型名称改为基于 session.originalFormat
- 修复:不再使用 `requestedModel.startsWith("claude-")` 判断

### 4. 增加过滤原因
- 新增 `format_type_mismatch` 过滤原因
- 记录格式不兼容的供应商及详细原因

### 5. 扩展类型定义
- 扩展 decisionContext.targetType 支持所有供应商类型
- 更新过滤原因枚举

## 影响范围

- ✅ 修复了 Claude 格式请求非 Claude 模型时的格式错配问题
- ✅ 支持 claude 类型供应商通过 allowedModels 或 model_redirects 处理非 Claude 模型
- ✅ 向后兼容:不影响现有的正常请求

## 测试验证

- ✅ TypeScript 类型检查通过
- ✅ 代码格式化通过
- ✅ 数据库查询验证:ccr 供应商(provider_type: "claude")成功处理 gemini-3-pro-preview 请求

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <[email protected]>
sususu před 3 měsíci
rodič
revize
e27ff6b856
3 změnil soubory, kde provedl 104 přidání a 7 odebrání
  1. 14 0
      CLAUDE.md
  2. 88 6
      src/app/v1/_lib/proxy/provider-selector.ts
  3. 2 1
      src/types/message.ts

+ 14 - 0
CLAUDE.md

@@ -658,6 +658,20 @@ WITH latest_prices AS (
 SELECT ... LIMIT 50 OFFSET 0;
 ```
 
+### 10. Database MCP
+
+Objective:
+You are required to use the `db` MCP server to interact with a database.
+
+Capabilities:
+With this server, you can perform the following actions:
+
+- View the structure of database tables.
+- Query and inspect data within the tables.
+
+Prerequisite:
+Before performing any operations, you must first consult the database schema definition to understand its structure. The schema is defined in the following file: @src/drizzle/schema.ts.
+
 ## 常见任务
 
 ### 添加新的供应商类型

+ 88 - 6
src/app/v1/_lib/proxy/provider-selector.ts

@@ -6,6 +6,7 @@ import { isCircuitOpen, getCircuitState } from "@/lib/circuit-breaker";
 import { ProxyResponses } from "./responses";
 import { logger } from "@/lib/logger";
 import type { ProxySession } from "./session";
+import type { ClientFormat } from "./format-mapper";
 import type { ProviderChainItem } from "@/types/message";
 
 /**
@@ -87,6 +88,44 @@ function providerSupportsModel(provider: Provider, requestedModel: string): bool
   return false;
 }
 
+/**
+ * 根据原始请求格式限制可选供应商类型
+ *
+ * 核心逻辑:确保客户端请求格式与供应商类型兼容,避免格式错配
+ *
+ * 映射关系:
+ * - claude → claude | claude-auth
+ * - response → codex
+ * - openai → openai-compatible
+ * - gemini → gemini
+ * - gemini-cli → gemini-cli
+ *
+ * @param format - 客户端请求格式(从 session.originalFormat 获取)
+ * @param providerType - 供应商类型
+ * @returns 是否兼容
+ *
+ * 向后兼容:调用方在 originalFormat 未设置时应跳过此检查
+ */
+function checkFormatProviderTypeCompatibility(
+  format: ClientFormat,
+  providerType: Provider["providerType"]
+): boolean {
+  switch (format) {
+    case "claude":
+      return providerType === "claude" || providerType === "claude-auth";
+    case "response":
+      return providerType === "codex";
+    case "openai":
+      return providerType === "openai-compatible";
+    case "gemini":
+      return providerType === "gemini";
+    case "gemini-cli":
+      return providerType === "gemini-cli";
+    default:
+      return true; // 未知格式回退为兼容(不会主动过滤)
+  }
+}
+
 export class ProxyProviderResolver {
   static async ensure(
     session: ProxySession,
@@ -115,7 +154,9 @@ export class ProxyProviderResolver {
         decisionContext: {
           totalProviders: 0, // 复用不需要筛选
           enabledProviders: 0,
-          targetType: reusedProvider.providerType as "claude" | "codex",
+          targetType: reusedProvider.providerType as NonNullable<
+            ProviderChainItem["decisionContext"]
+          >["targetType"],
           requestedModel: session.getCurrentModel() || "",
           groupFilterApplied: false,
           beforeHealthCheck: 0,
@@ -196,7 +237,9 @@ export class ProxyProviderResolver {
               : {
                   totalProviders: 0,
                   enabledProviders: 0,
-                  targetType: session.provider.providerType as "claude" | "codex",
+                  targetType: session.provider.providerType as NonNullable<
+                    ProviderChainItem["decisionContext"]
+                  >["targetType"],
                   requestedModel: session.getCurrentModel() || "",
                   groupFilterApplied: false,
                   beforeHealthCheck: 0,
@@ -251,7 +294,9 @@ export class ProxyProviderResolver {
             decisionContext: successContext || {
               totalProviders: 0,
               enabledProviders: 0,
-              targetType: session.provider.providerType as "claude" | "codex",
+              targetType: session.provider.providerType as NonNullable<
+                ProviderChainItem["decisionContext"]
+              >["targetType"],
               requestedModel: session.getCurrentModel() || "",
               groupFilterApplied: false,
               beforeHealthCheck: 0,
@@ -399,11 +444,29 @@ export class ProxyProviderResolver {
     const allProviders = await findProviderList();
     const requestedModel = session?.getCurrentModel() || "";
 
+    // 原始请求格式映射到目标供应商类型;缺省为 claude 以兼容历史请求
+    const targetType: "claude" | "codex" | "openai-compatible" | "gemini" | "gemini-cli" = (() => {
+      switch (session?.originalFormat) {
+        case "claude":
+          return "claude";
+        case "response":
+          return "codex";
+        case "openai":
+          return "openai-compatible";
+        case "gemini":
+          return "gemini";
+        case "gemini-cli":
+          return "gemini-cli";
+        default:
+          return "claude"; // 默认回退到 claude(向后兼容)
+      }
+    })();
+
     // === 初始化决策上下文 ===
     const context: NonNullable<ProviderChainItem["decisionContext"]> = {
       totalProviders: allProviders.length,
       enabledProviders: 0,
-      targetType: requestedModel.startsWith("claude-") ? "claude" : "codex", // 根据模型名推断
+      targetType, // 根据原始请求格式推断目标供应商类型(修复:不再根据模型名推断
       requestedModel, // 新增:记录请求的模型
       groupFilterApplied: false,
       beforeHealthCheck: 0,
@@ -415,14 +478,26 @@ export class ProxyProviderResolver {
       excludedProviderIds: excludeIds.length > 0 ? excludeIds : undefined,
     };
 
-    // Step 1: 基础过滤 + 模型匹配(新逻辑)
+    // Step 1: 基础过滤 + 格式/模型匹配(新逻辑)
     const enabledProviders = allProviders.filter((provider) => {
       // 1a. 基础过滤
       if (!provider.isEnabled || excludeIds.includes(provider.id)) {
         return false;
       }
 
-      // 1b. 模型匹配(新逻辑)
+      // 1b. 格式类型匹配(新增)
+      // 根据 session.originalFormat 限制候选供应商类型,避免格式错配
+      if (session?.originalFormat) {
+        const isFormatCompatible = checkFormatProviderTypeCompatibility(
+          session.originalFormat,
+          provider.providerType
+        );
+        if (!isFormatCompatible) {
+          return false; // 过滤掉格式不兼容的供应商
+        }
+      }
+
+      // 1c. 模型匹配(保留原有逻辑)
       if (!requestedModel) {
         // 没有模型信息时,只选择 Anthropic 提供商(向后兼容)
         return provider.providerType === "claude";
@@ -440,6 +515,7 @@ export class ProxyProviderResolver {
           | "circuit_open"
           | "rate_limited"
           | "excluded"
+          | "format_type_mismatch"
           | "type_mismatch"
           | "model_not_allowed"
           | "disabled" = "disabled";
@@ -451,6 +527,12 @@ export class ProxyProviderResolver {
         } else if (excludeIds.includes(p.id)) {
           reason = "excluded";
           details = "已在前序尝试中失败";
+        } else if (
+          session?.originalFormat &&
+          !checkFormatProviderTypeCompatibility(session.originalFormat, p.providerType)
+        ) {
+          reason = "format_type_mismatch";
+          details = `原始格式 ${session.originalFormat} 与供应商类型 ${p.providerType} 不兼容`;
         } else if (requestedModel && !providerSupportsModel(p, requestedModel)) {
           reason = "model_not_allowed";
           details = `不支持模型 ${requestedModel}`;

+ 2 - 1
src/types/message.ts

@@ -91,7 +91,7 @@ export interface ProviderChainItem {
     // --- 供应商池状态 ---
     totalProviders: number; // 系统总供应商数
     enabledProviders: number; // 启用的供应商数
-    targetType: "claude" | "codex"; // 目标类型
+    targetType: "claude" | "codex" | "openai-compatible" | "gemini" | "gemini-cli"; // 目标类型(基于请求格式推断)
     requestedModel?: string; // 请求的模型名称(用于追踪)
 
     // --- 用户分组筛选 ---
@@ -113,6 +113,7 @@ export interface ProviderChainItem {
         | "circuit_open"
         | "rate_limited"
         | "excluded"
+        | "format_type_mismatch" // 请求格式与供应商类型不兼容
         | "type_mismatch"
         | "model_not_allowed"
         | "disabled";