From 8ac2936a3d8cc9c61b02e3f92f10de343afbed78 Mon Sep 17 00:00:00 2001 From: SavitarC <71271200+SavitarC@users.noreply.github.com> Date: Sun, 27 Sep 2026 11:08:41 +0800 Subject: [PATCH] =?UTF-8?q?refactor(gateway):=20=E4=B8=89=E4=B8=AA?= =?UTF-8?q?=E5=85=A5=E7=AB=99=E5=85=A5=E5=8F=A3=E7=9A=84=E4=B8=8A=E6=B8=B8?= =?UTF-8?q?=E5=8D=8F=E8=AE=AE=E5=88=86=E6=B5=81=E6=94=B6=E6=95=9B=E4=B8=BA?= =?UTF-8?q?=20resolveUpstreamProtocol?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 此前 /v1/responses、/v1/chat/completions、/v1/messages 各自用一串 if 决定 上游协议:OpenCode 按模型规则、国产 adaptive 按入站、显式 api_protocol、 Responses 探针四个来源的优先级靠语句先后隐式决定,且每个入口都有 IsOpenCodeGo 特判。 - provider profile 新增 Routing(by_inbound / by_model),OpenCode 登记为 by_model;routesByModel 取代转发路径与 GetAPIProtocol 默认值中的 IsOpenCodeGo 判断。 - 新增 upstream_protocol_routing.go:resolveUpstreamProtocol 以单一函数给出 上游协议(按模型分流 → adaptive 按入站 → 显式 anthropic → CC 直转条件 → Responses),resolveModelRoutedProtocol 以 profile 规则泛化 OpenCode 的规则解析;三个入口改为 switch 该结果选择既有转换路径,Responses 形状 请求体的转换合并为 convertResponsesShapedChatBody。 转换函数本身不变。新增分流矩阵测试经真实入口遍历 平台 × api_protocol × 探针 × 账号类型 × 接入模式 × protocol_rules × 入站 × 模型(2045 组), 与改动前逐条 diff 仅 6 条不同:智谱 adaptive 的非 API Key 账号(UI 无法 创建)在 /v1/responses 入站时由 Responses 改为回落 CC,与 /v1/chat/completions 入口一致;这 6 组前后都在发出上游请求前报错。 协议规则与映射模型相关的类型、函数已被所有按模型分流的供应商共用,同时去掉 OpenCode 前缀(OpenCodeGoProtocolRule → ProtocolRule、matchOpenCodeGoProtocolRules → matchProtocolRules、NormalizeOpenCodeGoProtocolRulesCredentials → NormalizeProtocolRulesCredentials、resolveOpenCodeGoMappedModel → resolveMappedUpstreamModel 等),并删除收敛后已无调用方的 defaultOpenCodeProtocolRules。 Co-Authored-By: Claude Opus 5.5 --- backend/internal/service/account.go | 3 +- backend/internal/service/admin_account.go | 8 +- .../openai_gateway_chat_completions.go | 99 ++--- .../service/openai_gateway_forward.go | 41 +-- .../service/openai_gateway_messages.go | 39 +- ...penai_gateway_messages_anthropic_native.go | 2 +- backend/internal/service/opencode_go.go | 84 ++--- backend/internal/service/opencode_go_test.go | 30 +- backend/internal/service/provider_profile.go | 29 +- .../internal/service/provider_profile_test.go | 9 +- .../service/upstream_protocol_routing.go | 92 +++++ .../upstream_protocol_routing_matrix_test.go | 344 ++++++++++++++++++ .../service/upstream_protocol_routing_test.go | 119 ++++++ 13 files changed, 704 insertions(+), 195 deletions(-) create mode 100644 backend/internal/service/upstream_protocol_routing.go create mode 100644 backend/internal/service/upstream_protocol_routing_matrix_test.go create mode 100644 backend/internal/service/upstream_protocol_routing_test.go diff --git a/backend/internal/service/account.go b/backend/internal/service/account.go index 783fffc349..97a797580c 100644 --- a/backend/internal/service/account.go +++ b/backend/internal/service/account.go @@ -1444,7 +1444,8 @@ func (a *Account) GetAPIProtocol() string { case APIProtocolChatCompletions: return APIProtocolChatCompletions } - if a.IsOpenCodeGo() { + // 按模型分流的供应商(多模型聚合平台)未显式配置时默认 adaptive。 + if a.routesByModel() { return APIProtocolAdaptive } return APIProtocolChatCompletions diff --git a/backend/internal/service/admin_account.go b/backend/internal/service/admin_account.go index ef4a47fa1f..256e488d09 100644 --- a/backend/internal/service/admin_account.go +++ b/backend/internal/service/admin_account.go @@ -323,7 +323,7 @@ func (s *adminServiceImpl) DuplicateAccount(ctx context.Context, id int64, actor if err := NormalizeHeaderOverrideCredentials(input.Credentials); err != nil { return nil, err } - if err := NormalizeOpenCodeGoProtocolRulesCredentials(input.Credentials); err != nil { + if err := NormalizeProtocolRulesCredentials(input.Credentials); err != nil { return nil, err } duplicate, err := buildAccountForCreate(input, accountExtra) @@ -522,7 +522,7 @@ func (s *adminServiceImpl) CreateAccount(ctx context.Context, input *CreateAccou if err := NormalizeHeaderOverrideCredentials(input.Credentials); err != nil { return nil, err } - if err := NormalizeOpenCodeGoProtocolRulesCredentials(input.Credentials); err != nil { + if err := NormalizeProtocolRulesCredentials(input.Credentials); err != nil { return nil, err } // Never persist ephemeral SSO/password secrets after OAuth conversion. @@ -655,7 +655,7 @@ func (s *adminServiceImpl) UpdateAccount(ctx context.Context, id int64, input *U if err := NormalizeHeaderOverrideCredentials(account.Credentials); err != nil { return nil, err } - if err := NormalizeOpenCodeGoProtocolRulesCredentials(account.Credentials); err != nil { + if err := NormalizeProtocolRulesCredentials(account.Credentials); err != nil { return nil, err } // Strip SSO/password residue that must never sit next to OAuth tokens. @@ -1102,7 +1102,7 @@ func (s *adminServiceImpl) BulkUpdateAccounts(ctx context.Context, input *BulkUp if err := NormalizeHeaderOverrideCredentials(input.Credentials); err != nil { return nil, err } - if err := NormalizeOpenCodeGoProtocolRulesCredentials(input.Credentials); err != nil { + if err := NormalizeProtocolRulesCredentials(input.Credentials); err != nil { return nil, err } // Bulk may mix platforms; always drop ephemeral SSO/password keys (cookie diff --git a/backend/internal/service/openai_gateway_chat_completions.go b/backend/internal/service/openai_gateway_chat_completions.go index 7ffd20916f..66c2471ae7 100644 --- a/backend/internal/service/openai_gateway_chat_completions.go +++ b/backend/internal/service/openai_gateway_chat_completions.go @@ -110,88 +110,43 @@ func (s *OpenAIGatewayService) forwardAsChatCompletions( } // Cursor compatibility: some clients send a Responses-shaped body to the - // /v1/chat/completions URL. Detect it before adaptive routing so adaptive + // /v1/chat/completions URL. Detect it before protocol routing so adaptive // accounts never forward the body unchanged to a Chat Completions endpoint. isResponsesShape := !gjson.GetBytes(body, "messages").Exists() && gjson.GetBytes(body, "input").Exists() - - // OpenCode Go:按模型原生协议分流(与 inbound 协议正交)。 - // 规则未命中一律兜底 Chat Completions,只有显式 Responses 才走下方转换链。 - if account.IsOpenCodeGo() { - mapped := resolveOpenCodeGoMappedModel(account, body, defaultMappedModel) - if IsOpenCodeUnsupportedModel(mapped) { - return nil, writeOpenCodeUnsupportedModelError(c, false, mapped) - } - proto := openCodeGoNativeProtocol(account, mapped) - if proto != APIProtocolResponses { - if isResponsesShape { - if proto == APIProtocolAnthropic { - return s.forwardResponsesViaNativeAnthropic(ctx, c, account, body, "") - } - var responsesReq apicompat.ResponsesRequest - if err := json.Unmarshal(body, &responsesReq); err != nil { - return nil, fmt.Errorf("parse responses-shaped chat completions request: %w", err) - } - chatReq, err := apicompat.ResponsesToChatCompletionsRequestWithOptions( - &responsesReq, - &apicompat.ResponsesToChatOptions{ReasoningContentByID: s.reasoningContentByID}, - ) - if err != nil { - return nil, fmt.Errorf("convert responses-shaped chat completions request: %w", err) - } - chatBody, err := json.Marshal(chatReq) - if err != nil { - return nil, fmt.Errorf("marshal converted chat completions request: %w", err) - } - return s.forwardAsRawChatCompletions(ctx, c, account, chatBody, defaultMappedModel) - } - if proto == APIProtocolAnthropic { - return s.forwardChatCompletionsViaNativeAnthropic(ctx, c, account, body, defaultMappedModel) - } - return s.forwardAsRawChatCompletions(ctx, c, account, body, defaultMappedModel) - } + inbound := APIProtocolChatCompletions + if isResponsesShape { + inbound = APIProtocolResponses } + // 按模型分流与 adaptive 账号按请求体形状处理 Responses 形状入站;其余账号 + // 维持原样把请求体交给所选路径。 + convertResponsesShape := isResponsesShape && (account.routesByModel() || account.IsAdaptiveAPIProtocol()) - // 自适应账号的标准 Chat Completions 入站使用供应商原生 CC 端点。 - // Responses 形状下,DeepSeek / Kimi 继续走下方原生 Responses 链;GLM - // 没有 Responses 端点,先转换成 Chat Completions 再直转。 - if account.IsAdaptiveAPIProtocol() && !account.IsOpenCodeGo() { - if !isResponsesShape { - return s.forwardAsRawChatCompletions(ctx, c, account, body, defaultMappedModel) + // 上游协议统一由 resolveUpstreamProtocol 判定。Anthropic 分流必须先于 + // ShouldUseResponsesAPI:Anthropic 协议账号经 probe 落标 + // openai_responses_supported=false,否则会命中 CC 直转。 + routingModel := upstreamRoutingModel(account, body, defaultMappedModel) + if account.IsOpenCodeGo() && IsOpenCodeUnsupportedModel(routingModel) { + return nil, writeOpenCodeUnsupportedModelError(c, false, routingModel) + } + switch resolveUpstreamProtocol(account, inbound, routingModel) { + case APIProtocolAnthropic: + if convertResponsesShape { + return s.forwardResponsesViaNativeAnthropic(ctx, c, account, body, "") } - if !account.SupportsNativeCNResponses() { - var responsesReq apicompat.ResponsesRequest - if err := json.Unmarshal(body, &responsesReq); err != nil { - return nil, fmt.Errorf("parse responses-shaped chat completions request: %w", err) - } - chatReq, err := apicompat.ResponsesToChatCompletionsRequestWithOptions( - &responsesReq, - &apicompat.ResponsesToChatOptions{ReasoningContentByID: s.reasoningContentByID}, - ) + // CC 入站经 CC→Responses→Anthropic 转换链直通供应商原生 Anthropic 端点。 + return s.forwardChatCompletionsViaNativeAnthropic(ctx, c, account, body, defaultMappedModel) + case APIProtocolChatCompletions: + if convertResponsesShape { + chatBody, err := s.convertResponsesShapedChatBody(body) if err != nil { - return nil, fmt.Errorf("convert responses-shaped chat completions request: %w", err) - } - chatBody, err := json.Marshal(chatReq) - if err != nil { - return nil, fmt.Errorf("marshal converted chat completions request: %w", err) + return nil, err } return s.forwardAsRawChatCompletions(ctx, c, account, chatBody, defaultMappedModel) } - // DeepSeek / Kimi 原生 Responses 请求继续走下方 Responses→Chat 回程转换。 - } - - // 入口分流(国产供应商 Anthropic 协议):上游为供应商原生 Anthropic 端点, - // CC 入站请求经 CC→Responses→Anthropic 转换链直通该端点。必须先于 - // ShouldUseResponsesAPI 分流:该类账号经 probe 落标 - // openai_responses_supported=false,会先命中下方的 CC 直转分支。 - if account.IsAnthropicProtocol() { - return s.forwardChatCompletionsViaNativeAnthropic(ctx, c, account, body, defaultMappedModel) - } - - // 固定 chat_completions 的 CN 账号,以及强制或已探测确认不支持 Responses - // 的其他 APIKey 账号,均走 CC 直转。 - if shouldForwardOpenAIResponsesViaRawChatCompletions(account) { return s.forwardAsRawChatCompletions(ctx, c, account, body, defaultMappedModel) } + // 上游为 Responses(含 adaptive 下 Responses 形状入站且供应商有原生 + // Responses 端点):走下方 Chat Completions ↔ Responses 转换链。 SetActualOpenAIUpstreamEndpoint(c, openAIResponsesUpstreamEndpoint) startTime := time.Now() @@ -424,7 +379,7 @@ func (s *OpenAIGatewayService) forwardAsChatCompletions( return s.forwardAsChatCompletions(markAgentIdentityTaskRecoveryTried(ctx), c, account, body, promptCacheKey, defaultMappedModel, compatPromptCacheTenantIsolated) } if account.Type == AccountTypeAPIKey && - !account.IsOpenCodeGo() && + !account.routesByModel() && openai_compat.ResolveResponsesSupport(account.Extra) == openai_compat.ResponsesSupportUnknown && !isResponsesEndpointSupportedByStatus(resp.StatusCode) { logger.L().Info("openai chat_completions: /responses unsupported, falling back to raw chat completions", diff --git a/backend/internal/service/openai_gateway_forward.go b/backend/internal/service/openai_gateway_forward.go index 78dd47afe6..03d239774b 100644 --- a/backend/internal/service/openai_gateway_forward.go +++ b/backend/internal/service/openai_gateway_forward.go @@ -159,27 +159,6 @@ func (s *OpenAIGatewayService) Forward(ctx context.Context, c *gin.Context, acco return s.forwardGrokResponses(ctx, c, account, body, originalModel, reqStream, startTime) } - if account.IsOpenCodeGo() { - mapped := resolveOpenCodeGoMappedModel(account, body, "") - if IsOpenCodeUnsupportedModel(mapped) { - return nil, writeOpenCodeUnsupportedModelError(c, false, mapped) - } - switch openCodeGoNativeProtocol(account, mapped) { - case APIProtocolAnthropic: - return s.forwardResponsesViaNativeAnthropic(ctx, c, account, body, "") - case APIProtocolResponses: - break - default: - return s.forwardResponsesViaRawChatCompletions(ctx, c, account, body) - } - } - - // CN 供应商 anthropic 协议账号:/v1/responses 入站是交叉协议组合 - // (Responses 客户端 × Anthropic 上游),转成 Anthropic 请求走原生端点。 - // 不能落到下面的 raw-CC 分支——其 URL 构造会把 anthropic base 当 CC base 用。 - if account.IsAnthropicProtocol() { - return s.forwardResponsesViaNativeAnthropic(ctx, c, account, body, reqModel) - } if account.IsOpenAIApiKey() { if normalized, changed, normalizeErr := normalizeOpenAIParallelToolCallsWithoutTools(body, responsesLite); normalizeErr != nil { return nil, normalizeErr @@ -198,7 +177,23 @@ func (s *OpenAIGatewayService) Forward(ctx context.Context, c *gin.Context, acco originalModel = reqModel } - if shouldForwardOpenAIResponsesViaRawChatCompletions(account) { + // 上游协议统一由 resolveUpstreamProtocol 判定。OpenAI API Key 账号只会落到 + // Responses / Chat Completions,上面的归一化对两条路径都生效。 + routingModel := upstreamRoutingModel(account, body, "") + if account.IsOpenCodeGo() && IsOpenCodeUnsupportedModel(routingModel) { + return nil, writeOpenCodeUnsupportedModelError(c, false, routingModel) + } + switch resolveUpstreamProtocol(account, APIProtocolResponses, routingModel) { + case APIProtocolAnthropic: + // Responses 客户端 × Anthropic 上游:转成 Anthropic 请求走原生端点。不能落到 + // raw-CC 分支——其 URL 构造会把 anthropic base 当 CC base 用。 + // 按模型分流的账号不以请求模型兜底模型映射。 + defaultMappedModel := reqModel + if account.routesByModel() { + defaultMappedModel = "" + } + return s.forwardResponsesViaNativeAnthropic(ctx, c, account, body, defaultMappedModel) + case APIProtocolChatCompletions: return s.forwardResponsesViaRawChatCompletions(ctx, c, account, body) } SetActualOpenAIUpstreamEndpoint(c, openAIResponsesUpstreamEndpoint) @@ -1355,7 +1350,7 @@ func shouldForwardOpenAIResponsesViaRawChatCompletions(account *Account) bool { if account == nil || account.Type != AccountTypeAPIKey { return false } - if account.IsOpenCodeGo() { + if account.routesByModel() { // Model protocol_rules are the authority. Probe Extra must not collapse // Grok/GPT/Muse into Chat Completions. return false diff --git a/backend/internal/service/openai_gateway_messages.go b/backend/internal/service/openai_gateway_messages.go index a45dd5e565..9594a79a73 100644 --- a/backend/internal/service/openai_gateway_messages.go +++ b/backend/internal/service/openai_gateway_messages.go @@ -53,32 +53,21 @@ func (s *OpenAIGatewayService) ForwardAsAnthropic( return nil, err } - // OpenCode Go:按模型原生协议分流。规则未命中兜底 Chat Completions。 - if account.IsOpenCodeGo() { - mapped := resolveOpenCodeGoMappedModel(account, body, defaultMappedModel) - if IsOpenCodeUnsupportedModel(mapped) { - return nil, writeOpenCodeUnsupportedModelError(c, true, mapped) - } - switch openCodeGoNativeProtocol(account, mapped) { - case APIProtocolAnthropic: - return s.forwardAnthropicViaNativeAnthropicEndpoint(ctx, c, account, body, defaultMappedModel) - case APIProtocolResponses: - break - default: - return s.forwardAnthropicViaRawChatCompletions(ctx, c, account, body, defaultMappedModel) - } - } else if account.IsAnthropicProtocol() || account.IsAdaptiveAPIProtocol() { - // 入口分流(国产供应商 Anthropic 协议):上游为供应商原生 Anthropic 端点时, - // /v1/messages 请求零转换直通(仅模型名映射 + 少量 body 清洗),完整保留 - // thinking / tool_use / cache 语义,适配 Claude Code 等原生客户端。 - // 必须先于 ShouldUseResponsesAPI 分流:Anthropic 协议账号经 probe 落标 - // openai_responses_supported=false,会先命中下方的 CC 直转分支。 - return s.forwardAnthropicViaNativeAnthropicEndpoint(ctx, c, account, body, defaultMappedModel) + // 上游协议统一由 resolveUpstreamProtocol 判定。Anthropic 分流必须先于 + // ShouldUseResponsesAPI:Anthropic 协议账号经 probe 落标 + // openai_responses_supported=false,否则会命中 CC 直转。 + routingModel := upstreamRoutingModel(account, body, defaultMappedModel) + if account.IsOpenCodeGo() && IsOpenCodeUnsupportedModel(routingModel) { + return nil, writeOpenCodeUnsupportedModelError(c, true, routingModel) } - - // 固定 chat_completions 的 CN 账号,以及不支持 Responses 的其他 APIKey - // 账号,均将 Messages 转为 CC;固定 responses 的 CN 账号不受探针旧值覆盖。 - if shouldForwardOpenAIResponsesViaRawChatCompletions(account) { + switch resolveUpstreamProtocol(account, APIProtocolAnthropic, routingModel) { + case APIProtocolAnthropic: + // 上游为供应商原生 Anthropic 端点:/v1/messages 零转换直通(仅模型名映射 + + // 少量 body 清洗),完整保留 thinking / tool_use / cache 语义。 + return s.forwardAnthropicViaNativeAnthropicEndpoint(ctx, c, account, body, defaultMappedModel) + case APIProtocolChatCompletions: + // 固定 chat_completions 的多协议账号、规则命中 CC 的按模型分流账号,以及 + // 不支持 Responses 的其他 APIKey 账号,均将 Messages 转为 CC。 return s.forwardAnthropicViaRawChatCompletions(ctx, c, account, body, defaultMappedModel) } SetActualOpenAIUpstreamEndpoint(c, openAIResponsesUpstreamEndpoint) diff --git a/backend/internal/service/openai_gateway_messages_anthropic_native.go b/backend/internal/service/openai_gateway_messages_anthropic_native.go index 0026046a58..eb061c9750 100644 --- a/backend/internal/service/openai_gateway_messages_anthropic_native.go +++ b/backend/internal/service/openai_gateway_messages_anthropic_native.go @@ -140,7 +140,7 @@ func (s *OpenAIGatewayService) nativeAnthropicTargetURL(account *Account) (strin return strings.TrimRight(validatedURL, "/") + "/v1/messages", nil } -func resolveOpenCodeGoMappedModel(account *Account, body []byte, defaultMappedModel string) string { +func resolveMappedUpstreamModel(account *Account, body []byte, defaultMappedModel string) string { original := strings.TrimSpace(gjson.GetBytes(body, "model").String()) billing := resolveOpenAIForwardModel(account, original, defaultMappedModel) return normalizeOpenAIModelForUpstream(account, billing) diff --git a/backend/internal/service/opencode_go.go b/backend/internal/service/opencode_go.go index 073a7bd116..d052451a56 100644 --- a/backend/internal/service/opencode_go.go +++ b/backend/internal/service/opencode_go.go @@ -19,9 +19,9 @@ const ( // the UI does not pick a model. glm-5.3 is a Chat Completions catalog ID. DefaultOpenCodeGoTestModel = "glm-5.3" - openCodeGoProtocolRulesKey = "protocol_rules" - maxOpenCodeGoProtocolRules = 64 - maxOpenCodeGoProtocolPatternLength = 128 + protocolRulesCredentialKey = "protocol_rules" + maxProtocolRules = 64 + maxProtocolRulePatternLength = 128 ) // DefaultOpenCodeGoModelIDs 是官方文档当前公开的模型 ID 目录, @@ -101,16 +101,16 @@ func writeOpenCodeUnsupportedModelError(c *gin.Context, isAnthropic bool, model return fmt.Errorf("opencode unsupported model: %s", model) } -// OpenCodeGoProtocolRule is one model-pattern → native protocol mapping. +// ProtocolRule is one model-pattern → native protocol mapping. // Pattern is an exact ID or a suffix glob (foo* / *). First match wins. -type OpenCodeGoProtocolRule struct { +type ProtocolRule struct { Pattern string `json:"pattern"` Protocol string `json:"protocol"` } // DefaultOpenCodeGoProtocolRules is the built-in adaptive routing table for Go. -func DefaultOpenCodeGoProtocolRules() []OpenCodeGoProtocolRule { - return []OpenCodeGoProtocolRule{ +func DefaultOpenCodeGoProtocolRules() []ProtocolRule { + return []ProtocolRule{ {Pattern: "grok-*", Protocol: APIProtocolResponses}, {Pattern: "gpt-*", Protocol: APIProtocolResponses}, {Pattern: "muse-spark-*", Protocol: APIProtocolResponses}, @@ -122,8 +122,8 @@ func DefaultOpenCodeGoProtocolRules() []OpenCodeGoProtocolRule { // DefaultOpenCodeZenProtocolRules 对齐 https://opencode.ai/docs/zen/ 端点表: // GPT/Grok/Muse Spark → Responses,Claude/Qwen(除 qwen3.8-max) → Anthropic, // qwen3.8-max 及其余模型 → Chat Completions。 -func DefaultOpenCodeZenProtocolRules() []OpenCodeGoProtocolRule { - return []OpenCodeGoProtocolRule{ +func DefaultOpenCodeZenProtocolRules() []ProtocolRule { + return []ProtocolRule{ {Pattern: "grok-*", Protocol: APIProtocolResponses}, {Pattern: "gpt-*", Protocol: APIProtocolResponses}, {Pattern: "muse-spark-*", Protocol: APIProtocolResponses}, @@ -133,12 +133,7 @@ func DefaultOpenCodeZenProtocolRules() []OpenCodeGoProtocolRule { } } -// defaultOpenCodeProtocolRules 从 provider profile 取该接入模式的内置分流表。 -func defaultOpenCodeProtocolRules(mode string) []OpenCodeGoProtocolRule { - return LookupProviderProfile(PlatformOpenCodeGo).Endpoints(mode).ProtocolRules -} - -func isNativeOpenCodeGoProtocol(protocol string) bool { +func isNativeUpstreamProtocol(protocol string) bool { switch protocol { case APIProtocolChatCompletions, APIProtocolAnthropic, APIProtocolResponses: return true @@ -147,7 +142,7 @@ func isNativeOpenCodeGoProtocol(protocol string) bool { } } -func openCodeGoPatternMatches(pattern, model string) bool { +func protocolRulePatternMatches(pattern, model string) bool { pattern = strings.ToLower(strings.TrimSpace(pattern)) model = normalizeOpenCodeGoModelID(model) if pattern == "" || model == "" { @@ -162,12 +157,12 @@ func openCodeGoPatternMatches(pattern, model string) bool { return model == pattern } -func matchOpenCodeGoProtocolRules(model string, rules []OpenCodeGoProtocolRule) string { +func matchProtocolRules(model string, rules []ProtocolRule) string { for _, rule := range rules { - if !isNativeOpenCodeGoProtocol(rule.Protocol) { + if !isNativeUpstreamProtocol(rule.Protocol) { continue } - if openCodeGoPatternMatches(rule.Pattern, model) { + if protocolRulePatternMatches(rule.Pattern, model) { return rule.Protocol } } @@ -178,33 +173,33 @@ func matchOpenCodeGoProtocolRules(model string, rules []OpenCodeGoProtocolRule) // 未命中时为 Chat Completions。账号自定义 protocol_rules 见 // ResolveOpenCodeGoUpstreamProtocol。 func OpenCodeGoModelProtocol(model string) string { - return matchOpenCodeGoProtocolRules(model, DefaultOpenCodeGoProtocolRules()) + return matchProtocolRules(model, DefaultOpenCodeGoProtocolRules()) } -func (a *Account) openCodeGoProtocolRules() ([]OpenCodeGoProtocolRule, bool) { +func (a *Account) configuredProtocolRules() ([]ProtocolRule, bool) { if a == nil || a.Credentials == nil { return nil, false } - raw, ok := a.Credentials[openCodeGoProtocolRulesKey] + raw, ok := a.Credentials[protocolRulesCredentialKey] if !ok || raw == nil { return nil, false } - rules, err := parseOpenCodeGoProtocolRules(raw) + rules, err := parseProtocolRules(raw) if err != nil { return nil, false } return rules, true } -func parseOpenCodeGoProtocolRules(raw any) ([]OpenCodeGoProtocolRule, error) { - items, err := openCodeGoProtocolRuleItems(raw) +func parseProtocolRules(raw any) ([]ProtocolRule, error) { + items, err := protocolRuleItems(raw) if err != nil { return nil, err } - if len(items) > maxOpenCodeGoProtocolRules { - return nil, fmt.Errorf("protocol_rules supports at most %d entries", maxOpenCodeGoProtocolRules) + if len(items) > maxProtocolRules { + return nil, fmt.Errorf("protocol_rules supports at most %d entries", maxProtocolRules) } - rules := make([]OpenCodeGoProtocolRule, 0, len(items)) + rules := make([]ProtocolRule, 0, len(items)) for i, item := range items { entry, ok := item.(map[string]any) if !ok { @@ -212,20 +207,20 @@ func parseOpenCodeGoProtocolRules(raw any) ([]OpenCodeGoProtocolRule, error) { } pattern, _ := entry["pattern"].(string) protocol, _ := entry["protocol"].(string) - pattern, err := normalizeOpenCodeGoProtocolPattern(pattern) + pattern, err := normalizeProtocolRulePattern(pattern) if err != nil { return nil, fmt.Errorf("protocol_rules[%d]: %w", i, err) } protocol = strings.TrimSpace(protocol) - if !isNativeOpenCodeGoProtocol(protocol) { + if !isNativeUpstreamProtocol(protocol) { return nil, fmt.Errorf("protocol_rules[%d]: protocol must be chat_completions, anthropic, or responses", i) } - rules = append(rules, OpenCodeGoProtocolRule{Pattern: pattern, Protocol: protocol}) + rules = append(rules, ProtocolRule{Pattern: pattern, Protocol: protocol}) } return rules, nil } -func openCodeGoProtocolRuleItems(raw any) ([]any, error) { +func protocolRuleItems(raw any) ([]any, error) { switch items := raw.(type) { case []any: return items, nil @@ -240,12 +235,12 @@ func openCodeGoProtocolRuleItems(raw any) ([]any, error) { } } -func normalizeOpenCodeGoProtocolPattern(pattern string) (string, error) { +func normalizeProtocolRulePattern(pattern string) (string, error) { pattern = strings.ToLower(strings.TrimSpace(pattern)) if pattern == "" { return "", fmt.Errorf("pattern is required") } - if len(pattern) > maxOpenCodeGoProtocolPatternLength { + if len(pattern) > maxProtocolRulePatternLength { return "", fmt.Errorf("pattern is too long") } if strings.ContainsAny(pattern, " \t") { @@ -258,17 +253,17 @@ func normalizeOpenCodeGoProtocolPattern(pattern string) (string, error) { return pattern, nil } -// NormalizeOpenCodeGoProtocolRulesCredentials 校验并原地规范化 credentials.protocol_rules。 +// NormalizeProtocolRulesCredentials 校验并原地规范化 credentials.protocol_rules。 // 未携带该字段时为 no-op,使旧账号继续使用内置默认表。 -func NormalizeOpenCodeGoProtocolRulesCredentials(credentials map[string]any) error { +func NormalizeProtocolRulesCredentials(credentials map[string]any) error { if credentials == nil { return nil } - raw, ok := credentials[openCodeGoProtocolRulesKey] + raw, ok := credentials[protocolRulesCredentialKey] if !ok || raw == nil { return nil } - rules, err := parseOpenCodeGoProtocolRules(raw) + rules, err := parseProtocolRules(raw) if err != nil { return infraerrors.New(http.StatusBadRequest, "INVALID_OPENCODE_GO_PROTOCOL_RULES", err.Error()) } @@ -279,7 +274,7 @@ func NormalizeOpenCodeGoProtocolRulesCredentials(credentials map[string]any) err "protocol": rule.Protocol, }) } - credentials[openCodeGoProtocolRulesKey] = encoded + credentials[protocolRulesCredentialKey] = encoded return nil } @@ -327,19 +322,12 @@ func openCodeGoNativeProtocol(account *Account, model string) string { // ResolveOpenCodeGoUpstreamProtocol 按账号协议配置与模型规则决定上游协议。 // 显式 pinned 协议优先;adaptive(默认)先走 credentials.protocol_rules, // 未配置时回落内置默认表;已配置但未命中则走 Chat Completions。 +// 规则见 resolveModelRoutedProtocol。 func (a *Account) ResolveOpenCodeGoUpstreamProtocol(model string) string { if a == nil || !a.IsOpenCodeGo() { return "" } - switch a.GetAPIProtocol() { - case APIProtocolChatCompletions, APIProtocolAnthropic, APIProtocolResponses: - return a.GetAPIProtocol() - default: - if rules, present := a.openCodeGoProtocolRules(); present { - return matchOpenCodeGoProtocolRules(model, rules) - } - return matchOpenCodeGoProtocolRules(model, defaultOpenCodeProtocolRules(a.GetOpenCodeAccountMode())) - } + return a.resolveModelRoutedProtocol(model) } // openCodeGoQuotaURL 根据 base_url 解析 OpenCode Go 额度端点。 diff --git a/backend/internal/service/opencode_go_test.go b/backend/internal/service/opencode_go_test.go index 2583d30ac1..a8dbce1003 100644 --- a/backend/internal/service/opencode_go_test.go +++ b/backend/internal/service/opencode_go_test.go @@ -67,7 +67,7 @@ func TestResolveOpenCodeGoUpstreamProtocolUsesAccountRules(t *testing.T) { Platform: PlatformOpenCodeGo, Credentials: map[string]any{ "api_protocol": APIProtocolAdaptive, - openCodeGoProtocolRulesKey: []any{ + protocolRulesCredentialKey: []any{ map[string]any{"pattern": "grok-*", "protocol": APIProtocolChatCompletions}, map[string]any{"pattern": "deepseek-v4-flash", "protocol": APIProtocolResponses}, map[string]any{"pattern": "qwen*", "protocol": APIProtocolAnthropic}, @@ -86,7 +86,7 @@ func TestResolveOpenCodeGoUpstreamProtocolEmptyRulesAreAllChatCompletions(t *tes account := &Account{ Platform: PlatformOpenCodeGo, Credentials: map[string]any{ - openCodeGoProtocolRulesKey: []any{}, + protocolRulesCredentialKey: []any{}, }, } require.Equal(t, APIProtocolChatCompletions, account.ResolveOpenCodeGoUpstreamProtocol("grok-4.6")) @@ -98,7 +98,7 @@ func TestResolveOpenCodeGoUpstreamProtocolFirstMatchWins(t *testing.T) { account := &Account{ Platform: PlatformOpenCodeGo, Credentials: map[string]any{ - openCodeGoProtocolRulesKey: []any{ + protocolRulesCredentialKey: []any{ map[string]any{"pattern": "gpt-5.6-luna", "protocol": APIProtocolChatCompletions}, map[string]any{"pattern": "gpt-*", "protocol": APIProtocolResponses}, }, @@ -114,7 +114,7 @@ func TestOpenCodeGoNativeProtocolUnmatchedFallsBackToChatCompletions(t *testing. Platform: PlatformOpenCodeGo, Credentials: map[string]any{ "api_protocol": APIProtocolAdaptive, - openCodeGoProtocolRulesKey: []any{ + protocolRulesCredentialKey: []any{ map[string]any{"pattern": "grok-*", "protocol": APIProtocolResponses}, map[string]any{"pattern": "gpt-*", "protocol": APIProtocolResponses}, map[string]any{"pattern": "qwen*", "protocol": APIProtocolAnthropic}, @@ -136,12 +136,12 @@ func TestOpenCodeGoNativeProtocolUnmatchedFallsBackToChatCompletions(t *testing. func TestParseOpenCodeGoProtocolRulesAcceptsTypedMaps(t *testing.T) { t.Parallel() - rules, err := parseOpenCodeGoProtocolRules([]map[string]any{ + rules, err := parseProtocolRules([]map[string]any{ {"pattern": "grok-*", "protocol": APIProtocolResponses}, {"pattern": "qwen*", "protocol": APIProtocolAnthropic}, }) require.NoError(t, err) - require.Equal(t, []OpenCodeGoProtocolRule{ + require.Equal(t, []ProtocolRule{ {Pattern: "grok-*", Protocol: APIProtocolResponses}, {Pattern: "qwen*", Protocol: APIProtocolAnthropic}, }, rules) @@ -178,31 +178,31 @@ func TestStampOpenAIResponsesUpstreamEndpoint(t *testing.T) { func TestNormalizeOpenCodeGoProtocolRulesCredentials(t *testing.T) { t.Parallel() - require.NoError(t, NormalizeOpenCodeGoProtocolRulesCredentials(nil)) - require.NoError(t, NormalizeOpenCodeGoProtocolRulesCredentials(map[string]any{})) + require.NoError(t, NormalizeProtocolRulesCredentials(nil)) + require.NoError(t, NormalizeProtocolRulesCredentials(map[string]any{})) creds := map[string]any{ - openCodeGoProtocolRulesKey: []any{ + protocolRulesCredentialKey: []any{ map[string]any{"pattern": " Grok-* ", "protocol": APIProtocolResponses}, }, } - require.NoError(t, NormalizeOpenCodeGoProtocolRulesCredentials(creds)) - rules, ok := creds[openCodeGoProtocolRulesKey].([]any) + require.NoError(t, NormalizeProtocolRulesCredentials(creds)) + rules, ok := creds[protocolRulesCredentialKey].([]any) require.True(t, ok) require.NotEmpty(t, rules) entry, ok := rules[0].(map[string]any) require.True(t, ok) require.Equal(t, "grok-*", entry["pattern"]) - err := NormalizeOpenCodeGoProtocolRulesCredentials(map[string]any{ - openCodeGoProtocolRulesKey: []any{ + err := NormalizeProtocolRulesCredentials(map[string]any{ + protocolRulesCredentialKey: []any{ map[string]any{"pattern": "*grok", "protocol": APIProtocolResponses}, }, }) require.Error(t, err) - err = NormalizeOpenCodeGoProtocolRulesCredentials(map[string]any{ - openCodeGoProtocolRulesKey: []any{ + err = NormalizeProtocolRulesCredentials(map[string]any{ + protocolRulesCredentialKey: []any{ map[string]any{"pattern": "grok-*", "protocol": "adaptive"}, }, }) diff --git a/backend/internal/service/provider_profile.go b/backend/internal/service/provider_profile.go index 7fee5d96df..111cf968fb 100644 --- a/backend/internal/service/provider_profile.go +++ b/backend/internal/service/provider_profile.go @@ -11,9 +11,21 @@ type ProviderEndpoints struct { BaseURLs map[string]string // ProtocolRules 是按模型分流的内置默认表(首条命中生效);为空表示该模式 // 不按模型分流,由账号 api_protocol 决定上游协议。 - ProtocolRules []OpenCodeGoProtocolRule + ProtocolRules []ProtocolRule } +// ProviderRouting 决定多协议供应商账号如何选择上游协议。 +type ProviderRouting string + +const ( + // ProviderRoutingByInbound 适用于单一厂商:模型在各原生端点都可用,adaptive 账号 + // 按入站协议选同协议端点,供应商不提供该协议时回落 Chat Completions。 + ProviderRoutingByInbound ProviderRouting = "by_inbound" + // ProviderRoutingByModel 适用于多模型聚合平台:每个模型只在某一协议端点提供, + // 按 protocol_rules 以模型名选协议,与入站协议无关。 + ProviderRoutingByModel ProviderRouting = "by_model" +) + // ProviderProfile 描述一个多协议 API Key 供应商(国产厂商或多模型聚合平台)的 // 静态数据:各接入模式的默认端点、内置分流规则与端点路径约定。 // 额度解析、会话头等行为差异不在这里,仍由各自的代码路径处理。 @@ -22,6 +34,8 @@ type ProviderProfile struct { // DefaultMode 在 credentials.account_mode 缺失或不在 Modes 中时使用。 DefaultMode string Modes map[string]ProviderEndpoints + // Routing 为空时按 ProviderRoutingByInbound 处理。 + Routing ProviderRouting // ResponsesPath 覆盖 Responses 端点路径(按 buildOpenAIEndpointURL 的版本感知 // 规则拼接),空值为 /v1/responses。对自定义 base_url 的账号同样生效。 ResponsesPath string @@ -50,12 +64,13 @@ func (p *ProviderProfile) SupportsProtocol(mode, protocol string) bool { // providerProfiles 是多协议 API Key 供应商的单一数据来源。新增同类供应商时在此 // 登记一条即可获得默认端点、原生 Responses 能力判定(有 responses 条目即支持)、 -// Responses 路径约定与 IsMultiProtocolAPIKeyProvider 归属。 +// 上游协议分流方式、Responses 路径约定与 IsMultiProtocolAPIKeyProvider 归属。 // 默认端点需与前端 credentialsBuilder.ts 的预设保持一致。 var providerProfiles = map[string]*ProviderProfile{ PlatformKimi: { Platform: PlatformKimi, DefaultMode: AccountModePayG, + Routing: ProviderRoutingByInbound, Modes: map[string]ProviderEndpoints{ AccountModePayG: { BaseURLs: map[string]string{ @@ -76,6 +91,7 @@ var providerProfiles = map[string]*ProviderProfile{ PlatformZhipu: { Platform: PlatformZhipu, DefaultMode: AccountModePayG, + Routing: ProviderRoutingByInbound, // 智谱没有原生 Responses 端点,故不登记 responses 条目。 Modes: map[string]ProviderEndpoints{ AccountModePayG: { @@ -95,6 +111,7 @@ var providerProfiles = map[string]*ProviderProfile{ PlatformDeepseek: { Platform: PlatformDeepseek, DefaultMode: AccountModePayG, + Routing: ProviderRoutingByInbound, // DeepSeek 官方 Responses 端点为 /responses(无 /v1 前缀,适配 Codex)。 ResponsesPath: "/responses", Modes: map[string]ProviderEndpoints{ @@ -110,6 +127,7 @@ var providerProfiles = map[string]*ProviderProfile{ PlatformMiniMax: { Platform: PlatformMiniMax, DefaultMode: AccountModePayG, + Routing: ProviderRoutingByInbound, Modes: map[string]ProviderEndpoints{ // 按量付费与 Coding/Token Plan 共用推理域名,靠 API Key 区分套餐。 AccountModePayG: { @@ -131,6 +149,7 @@ var providerProfiles = map[string]*ProviderProfile{ PlatformOpenCodeGo: { Platform: PlatformOpenCodeGo, DefaultMode: AccountModeGo, + Routing: ProviderRoutingByModel, Modes: map[string]ProviderEndpoints{ AccountModeGo: { BaseURLs: map[string]string{ @@ -179,3 +198,9 @@ func (a *Account) providerSupportsProtocol(protocol string) bool { profile := a.providerProfile() return profile != nil && profile.SupportsProtocol(a.GetCredential("account_mode"), protocol) } + +// routesByModel 报告账号所在供应商是否按模型名分流上游协议(多模型聚合平台)。 +func (a *Account) routesByModel() bool { + profile := a.providerProfile() + return profile != nil && profile.Routing == ProviderRoutingByModel +} diff --git a/backend/internal/service/provider_profile_test.go b/backend/internal/service/provider_profile_test.go index 1dc9f94175..e9fc2994bd 100644 --- a/backend/internal/service/provider_profile_test.go +++ b/backend/internal/service/provider_profile_test.go @@ -150,7 +150,7 @@ func TestProviderProfile_BaseURLKeysAreNativeProtocols(t *testing.T) { for mode, endpoints := range profile.Modes { require.NotEmpty(t, endpoints.BaseURLs[APIProtocolChatCompletions], "%s/%s must have a chat_completions base", platform, mode) for protocol, baseURL := range endpoints.BaseURLs { - require.True(t, isNativeOpenCodeGoProtocol(protocol), "%s/%s has unknown protocol key %q", platform, mode, protocol) + require.True(t, isNativeUpstreamProtocol(protocol), "%s/%s has unknown protocol key %q", platform, mode, protocol) require.NotEmpty(t, baseURL, "%s/%s/%s", platform, mode, protocol) } } @@ -180,8 +180,9 @@ func TestProviderProfile_ResponsesPathMatchesLegacy(t *testing.T) { func TestProviderProfile_OpenCodeProtocolRulesByMode(t *testing.T) { t.Parallel() - require.Equal(t, DefaultOpenCodeGoProtocolRules(), defaultOpenCodeProtocolRules(AccountModeGo)) - require.Equal(t, DefaultOpenCodeGoProtocolRules(), defaultOpenCodeProtocolRules("")) - require.Equal(t, DefaultOpenCodeZenProtocolRules(), defaultOpenCodeProtocolRules(AccountModeZen)) + openCode := LookupProviderProfile(PlatformOpenCodeGo) + require.Equal(t, DefaultOpenCodeGoProtocolRules(), openCode.Endpoints(AccountModeGo).ProtocolRules) + require.Equal(t, DefaultOpenCodeGoProtocolRules(), openCode.Endpoints("").ProtocolRules) + require.Equal(t, DefaultOpenCodeZenProtocolRules(), openCode.Endpoints(AccountModeZen).ProtocolRules) require.Empty(t, LookupProviderProfile(PlatformKimi).Endpoints(AccountModeCoding).ProtocolRules) } diff --git a/backend/internal/service/upstream_protocol_routing.go b/backend/internal/service/upstream_protocol_routing.go new file mode 100644 index 0000000000..c0c6df333c --- /dev/null +++ b/backend/internal/service/upstream_protocol_routing.go @@ -0,0 +1,92 @@ +package service + +import ( + "encoding/json" + "fmt" + + "github.com/Wei-Shaw/sub2api/internal/pkg/apicompat" +) + +// resolveUpstreamProtocol 决定 OpenAI 网关本次请求发往上游的协议 +// (chat_completions / responses / anthropic),是 /v1/responses、 +// /v1/chat/completions、/v1/messages 三个入站入口共用的唯一判定。 +// 只做判断,不改写请求体、不发请求;各入口再按「入站 × 上游」选择转换路径。 +// +// inbound 为入站协议;/v1/chat/completions 收到 Responses 形状请求体时按 +// responses 计。model 仅按模型分流的账号使用,取 upstreamRoutingModel 的结果。 +// +// 优先级: +// 1. 按模型分流的供应商(多模型聚合平台,如 OpenCode):显式 api_protocol → +// 账号 protocol_rules → profile 内置规则 → Chat Completions,与入站协议无关。 +// 2. adaptive(按入站协议分流):供应商提供入站协议的原生端点则走同协议, +// 否则回落 Chat Completions。 +// 3. 显式 anthropic 协议 → Anthropic。 +// 4. shouldForwardOpenAIResponsesViaRawChatCompletions(显式 chat_completions、 +// 探针确认不支持 Responses 等)→ Chat Completions。 +// 5. 其余 → Responses。 +// +// Grok 在 Responses / Chat Completions 入口有专属链路,须在调用本函数前分流。 +func resolveUpstreamProtocol(account *Account, inbound, model string) string { + if account.routesByModel() { + return account.resolveModelRoutedProtocol(model) + } + if account.IsAdaptiveAPIProtocol() { + if account.providerSupportsProtocol(inbound) { + return inbound + } + return APIProtocolChatCompletions + } + if account.IsAnthropicProtocol() { + return APIProtocolAnthropic + } + if shouldForwardOpenAIResponsesViaRawChatCompletions(account) { + return APIProtocolChatCompletions + } + return APIProtocolResponses +} + +// resolveModelRoutedProtocol 按模型名解析按模型分流账号的上游协议。 +// 显式 pinned 协议优先;adaptive 先走 credentials.protocol_rules,未配置时回落 +// profile 对应接入模式的内置规则;规则未命中或结果未知一律 Chat Completions, +// 避免落入 Responses 转换链。 +func (a *Account) resolveModelRoutedProtocol(model string) string { + switch proto := a.GetAPIProtocol(); proto { + case APIProtocolChatCompletions, APIProtocolAnthropic, APIProtocolResponses: + return proto + } + rules, present := a.configuredProtocolRules() + if !present { + rules = a.providerProfile().Endpoints(a.GetCredential("account_mode")).ProtocolRules + } + return matchProtocolRules(model, rules) +} + +// upstreamRoutingModel 返回参与协议分流的模型名:仅按模型分流的账号需要,按账号 +// 模型映射与上游归一后的名字匹配规则;其余账号的分流与模型无关,返回空串。 +func upstreamRoutingModel(account *Account, body []byte, defaultMappedModel string) string { + if !account.routesByModel() { + return "" + } + return resolveMappedUpstreamModel(account, body, defaultMappedModel) +} + +// convertResponsesShapedChatBody 把发到 /v1/chat/completions 的 Responses 形状 +// 请求体(Cursor 兼容)转换为 Chat Completions 请求体。 +func (s *OpenAIGatewayService) convertResponsesShapedChatBody(body []byte) ([]byte, error) { + var responsesReq apicompat.ResponsesRequest + if err := json.Unmarshal(body, &responsesReq); err != nil { + return nil, fmt.Errorf("parse responses-shaped chat completions request: %w", err) + } + chatReq, err := apicompat.ResponsesToChatCompletionsRequestWithOptions( + &responsesReq, + &apicompat.ResponsesToChatOptions{ReasoningContentByID: s.reasoningContentByID}, + ) + if err != nil { + return nil, fmt.Errorf("convert responses-shaped chat completions request: %w", err) + } + chatBody, err := json.Marshal(chatReq) + if err != nil { + return nil, fmt.Errorf("marshal converted chat completions request: %w", err) + } + return chatBody, nil +} diff --git a/backend/internal/service/upstream_protocol_routing_matrix_test.go b/backend/internal/service/upstream_protocol_routing_matrix_test.go new file mode 100644 index 0000000000..fc3a5165a1 --- /dev/null +++ b/backend/internal/service/upstream_protocol_routing_matrix_test.go @@ -0,0 +1,344 @@ +//go:build unit + +package service + +import ( + "context" + "errors" + "fmt" + "os" + "sort" + "strings" + "testing" + + "github.com/Wei-Shaw/sub2api/internal/pkg/openai_compat" + "github.com/gin-gonic/gin" + "github.com/stretchr/testify/require" + "github.com/tidwall/gjson" +) + +// 上游协议分流矩阵:经真实入口(Forward / ForwardAsChatCompletions / +// ForwardAsAnthropic)遍历 平台 × api_protocol × 探针 × 账号类型 × 接入模式 × +// protocol_rules × 入站 × 模型,从录制到的上游 URL 与请求体推断实际走的协议 +// 与是否做了 Responses 形状转换,并与 legacyUpstreamRouting(统一判定前三个 +// 入口各自的分流顺序)逐条比对。 +// +// 设置 ROUTING_MATRIX_DUMP= 时把全部观测(含 URL、上游模型、错误)按行 +// 写入文件,便于在两个版本间直接 diff。 + +type routingMatrixIngress struct { + name string + path string + responsesBody bool + forward func(*OpenAIGatewayService, *gin.Context, *Account, []byte) error +} + +func routingMatrixIngresses() []routingMatrixIngress { + return []routingMatrixIngress{ + { + name: "responses", path: "/v1/responses", responsesBody: true, + forward: func(svc *OpenAIGatewayService, c *gin.Context, account *Account, body []byte) error { + _, err := svc.Forward(context.Background(), c, account, body) + return err + }, + }, + { + name: "chat", path: "/v1/chat/completions", + forward: func(svc *OpenAIGatewayService, c *gin.Context, account *Account, body []byte) error { + _, err := svc.ForwardAsChatCompletions(context.Background(), c, account, body, "", "") + return err + }, + }, + { + name: "chat_responses_shape", path: "/v1/chat/completions", responsesBody: true, + forward: func(svc *OpenAIGatewayService, c *gin.Context, account *Account, body []byte) error { + _, err := svc.ForwardAsChatCompletions(context.Background(), c, account, body, "", "") + return err + }, + }, + { + name: "messages", path: "/v1/messages", + forward: func(svc *OpenAIGatewayService, c *gin.Context, account *Account, body []byte) error { + _, err := svc.ForwardAsAnthropic(context.Background(), c, account, body, "", "") + return err + }, + }, + } +} + +type routingMatrixCase struct { + platform string + apiProtocol string + mode string + accountType string + probe string + rules bool + ingress routingMatrixIngress + model string +} + +func (tc routingMatrixCase) key() string { + return fmt.Sprintf("%s|proto=%s|mode=%s|type=%s|probe=%s|rules=%t|%s|%s", + tc.platform, tc.apiProtocol, tc.mode, tc.accountType, tc.probe, tc.rules, tc.ingress.name, tc.model) +} + +func (tc routingMatrixCase) account() *Account { + credentials := map[string]any{ + "api_key": "sk-test", + "base_url": "http://base.example", + "api_base_urls": map[string]any{ + APIProtocolChatCompletions: "http://cc.example", + APIProtocolResponses: "http://responses.example", + APIProtocolAnthropic: "http://anthropic.example", + }, + } + if tc.apiProtocol != "" { + credentials["api_protocol"] = tc.apiProtocol + } + if tc.mode != "" { + credentials["account_mode"] = tc.mode + } + if tc.rules { + credentials["protocol_rules"] = []any{ + map[string]any{"pattern": "glm-*", "protocol": APIProtocolAnthropic}, + map[string]any{"pattern": "gpt-*", "protocol": APIProtocolResponses}, + } + } + extra := map[string]any{} + switch tc.probe { + case "yes": + extra[openai_compat.ExtraKeyResponsesSupported] = true + case "no": + extra[openai_compat.ExtraKeyResponsesSupported] = false + } + return &Account{ + ID: 801, + Name: "routing-matrix", + Platform: tc.platform, + Type: tc.accountType, + Concurrency: 1, + Credentials: credentials, + Extra: extra, + } +} + +func (tc routingMatrixCase) body() []byte { + switch { + case tc.ingress.responsesBody: + return []byte(fmt.Sprintf(`{"model":%q,"input":"hello","max_output_tokens":32,"stream":false}`, tc.model)) + case tc.ingress.name == "messages": + return []byte(fmt.Sprintf(`{"model":%q,"max_tokens":32,"messages":[{"role":"user","content":"hello"}],"stream":false}`, tc.model)) + default: + return []byte(fmt.Sprintf(`{"model":%q,"messages":[{"role":"user","content":"hello"}],"stream":false}`, tc.model)) + } +} + +func routingMatrixCases() []routingMatrixCase { + var cases []routingMatrixCase + protocols := []string{"", APIProtocolAdaptive, APIProtocolChatCompletions, APIProtocolAnthropic, APIProtocolResponses} + types := []string{AccountTypeAPIKey, AccountTypeUpstream, AccountTypeOAuth} + probes := []string{"", "yes", "no"} + add := func(platform string, modes, models []string, rulesOptions []bool, probeOptions, typeOptions []string, ingresses []routingMatrixIngress) { + for _, proto := range protocols { + for _, mode := range modes { + for _, accountType := range typeOptions { + for _, probe := range probeOptions { + for _, rules := range rulesOptions { + for _, ingress := range ingresses { + for _, model := range models { + cases = append(cases, routingMatrixCase{ + platform: platform, apiProtocol: proto, mode: mode, accountType: accountType, + probe: probe, rules: rules, ingress: ingress, model: model, + }) + } + } + } + } + } + } + } + } + all := routingMatrixIngresses() + add(PlatformKimi, []string{AccountModePayG}, []string{"kimi-k2"}, []bool{false}, probes, types, all) + add(PlatformZhipu, []string{AccountModePayG}, []string{"glm-4.7"}, []bool{false}, probes, types, all) + add(PlatformDeepseek, []string{AccountModePayG}, []string{"deepseek-chat"}, []bool{false}, probes, types, all) + add(PlatformOpenAI, []string{""}, []string{"gpt-5"}, []bool{false}, probes, types, all) + add(PlatformOpenCodeGo, []string{AccountModeGo, AccountModeZen}, + []string{"gpt-5.6-luna", "minimax-m3", "glm-5.3", "claude-sonnet-4"}, + []bool{false, true}, []string{"", "no"}, []string{AccountTypeAPIKey, AccountTypeUpstream}, all) + // Grok 仅 /v1/messages 经统一判定;另两个入口在判定前走 Grok 专属链路。 + add(PlatformGrok, []string{""}, []string{"grok-4"}, []bool{false}, probes, types, all[3:]) + return cases +} + +type routingObservation struct { + urls []string + lastBody []byte + errText string + panicText string +} + +func (o routingObservation) protocol() string { + if len(o.urls) == 0 { + return "" + } + last := o.urls[len(o.urls)-1] + switch { + case strings.HasSuffix(last, "/chat/completions"): + return APIProtocolChatCompletions + case strings.HasSuffix(last, "/messages"): + return APIProtocolAnthropic + case strings.HasSuffix(last, "/responses"): + return APIProtocolResponses + default: + return "other:" + last + } +} + +// bodyKind 报告最后一个上游请求体的形状:Chat Completions 上游收到 messages +// 说明 Responses 形状请求体已被转换,收到 input 说明原样透传。 +func (o routingObservation) bodyKind() string { + switch { + case gjson.GetBytes(o.lastBody, "messages").Exists(): + return "messages" + case gjson.GetBytes(o.lastBody, "input").Exists(): + return "input" + default: + return "other" + } +} + +func (o routingObservation) String() string { + if o.panicText != "" { + return "panic: " + o.panicText + } + if len(o.urls) == 0 { + return "no-upstream: " + o.errText + } + return fmt.Sprintf("urls=%s body=%s model=%s", + strings.Join(o.urls, ","), o.bodyKind(), gjson.GetBytes(o.lastBody, "model").String()) +} + +func observeRouting(tc routingMatrixCase) (obs routingObservation) { + upstream := &httpUpstreamRecorder{err: errors.New("stop after capture")} + svc := &OpenAIGatewayService{cfg: rawChatCompletionsTestConfig(), httpUpstream: upstream} + body := tc.body() + defer func() { + if r := recover(); r != nil { + obs.panicText = fmt.Sprint(r) + } + for _, req := range upstream.requests { + obs.urls = append(obs.urls, req.URL.String()) + } + obs.lastBody = upstream.lastBody + }() + err := tc.ingress.forward(svc, adaptiveProtocolTestContext(tc.ingress.path, body), tc.account(), body) + if err != nil { + obs.errText = err.Error() + } + return obs +} + +// legacyUpstreamRouting 复述统一判定前三个入口各自的分流顺序,返回预期的上游协议 +// 以及 Chat Completions 上游是否先把 Responses 形状请求体转换成 messages。 +func legacyUpstreamRouting(account *Account, ingress routingMatrixIngress, model string) (string, bool) { + openCode := account.IsOpenCodeGo() + var openCodeProto string + if openCode { + openCodeProto = openCodeGoNativeProtocol(account, model) + } + switch ingress.name { + case "responses": + if openCode && openCodeProto != APIProtocolResponses { + return openCodeProto, false + } + if account.IsAnthropicProtocol() { + return APIProtocolAnthropic, false + } + // 有意差异:adaptive 且供应商无原生 Responses 端点时,统一判定对任何账号类型 + // 都回落 Chat Completions(旧 /v1/responses 入口对非 API Key 账号会误发到 + // 不存在的 /responses;Chat Completions 入口本就如此处理)。 + if account.IsAdaptiveAPIProtocol() && !account.SupportsNativeCNResponses() { + return APIProtocolChatCompletions, false + } + if shouldForwardOpenAIResponsesViaRawChatCompletions(account) { + return APIProtocolChatCompletions, false + } + return APIProtocolResponses, false + case "chat", "chat_responses_shape": + shape := ingress.responsesBody + if openCode && openCodeProto != APIProtocolResponses { + return openCodeProto, shape + } + if account.IsAdaptiveAPIProtocol() && !openCode { + if !shape { + return APIProtocolChatCompletions, false + } + if !account.SupportsNativeCNResponses() { + return APIProtocolChatCompletions, true + } + } + if account.IsAnthropicProtocol() { + return APIProtocolAnthropic, false + } + if shouldForwardOpenAIResponsesViaRawChatCompletions(account) { + return APIProtocolChatCompletions, false + } + return APIProtocolResponses, false + case "messages": + if openCode { + if openCodeProto != APIProtocolResponses { + return openCodeProto, false + } + } else if account.IsAnthropicProtocol() || account.IsAdaptiveAPIProtocol() { + return APIProtocolAnthropic, false + } + if shouldForwardOpenAIResponsesViaRawChatCompletions(account) { + return APIProtocolChatCompletions, false + } + return APIProtocolResponses, false + } + panic("unknown ingress " + ingress.name) +} + +func TestUpstreamProtocolRoutingMatrixMatchesLegacy(t *testing.T) { + gin.SetMode(gin.TestMode) + + cases := routingMatrixCases() + observations := make([]routingObservation, len(cases)) + dump := make([]string, 0, len(cases)) + for i, tc := range cases { + observations[i] = observeRouting(tc) + dump = append(dump, tc.key()+" => "+observations[i].String()) + } + if path := os.Getenv("ROUTING_MATRIX_DUMP"); path != "" { + sort.Strings(dump) + require.NoError(t, os.WriteFile(path, []byte(strings.Join(dump, "\n")+"\n"), 0o644)) + } + + compared := 0 + for i, tc := range cases { + obs := observations[i] + if tc.accountType == AccountTypeAPIKey && tc.platform != PlatformGrok { + require.NotEmpty(t, obs.urls, "API Key 账号应到达上游:%s\n%s", tc.key(), obs) + } + if obs.panicText != "" || len(obs.urls) == 0 { + // 请求在到达上游前就失败(凭证/类型不适配),无法从 URL 推断协议; + // 这类组合由 ROUTING_MATRIX_DUMP 的跨版本 diff 覆盖。 + continue + } + compared++ + account := tc.account() + model := resolveMappedUpstreamModel(account, tc.body(), "") + wantProto, wantConverted := legacyUpstreamRouting(account, tc.ingress, model) + require.Equal(t, wantProto, obs.protocol(), "%s\n%s", tc.key(), obs) + if wantProto == APIProtocolChatCompletions && tc.ingress.name == "chat_responses_shape" { + wantKind := "input" + if wantConverted { + wantKind = "messages" + } + require.Equal(t, wantKind, obs.bodyKind(), "%s\n%s", tc.key(), obs) + } + } + require.Positive(t, compared) +} diff --git a/backend/internal/service/upstream_protocol_routing_test.go b/backend/internal/service/upstream_protocol_routing_test.go new file mode 100644 index 0000000000..df487b1f2a --- /dev/null +++ b/backend/internal/service/upstream_protocol_routing_test.go @@ -0,0 +1,119 @@ +//go:build unit + +package service + +import ( + "testing" + + "github.com/Wei-Shaw/sub2api/internal/pkg/openai_compat" + "github.com/stretchr/testify/require" +) + +func routingTestAccount(platform, accountType string, credentials, extra map[string]any) *Account { + if credentials == nil { + credentials = map[string]any{} + } + credentials["api_key"] = "sk-test" + return &Account{Platform: platform, Type: accountType, Credentials: credentials, Extra: extra} +} + +func TestResolveUpstreamProtocol(t *testing.T) { + t.Parallel() + + const ( + cc = APIProtocolChatCompletions + responses = APIProtocolResponses + anthropic = APIProtocolAnthropic + ) + adaptive := func(platform string) *Account { + return routingTestAccount(platform, AccountTypeAPIKey, map[string]any{"api_protocol": APIProtocolAdaptive}, nil) + } + pinned := func(platform, protocol string) *Account { + return routingTestAccount(platform, AccountTypeAPIKey, map[string]any{"api_protocol": protocol}, nil) + } + openCode := func(mode string, credentials map[string]any) *Account { + if credentials == nil { + credentials = map[string]any{} + } + credentials["account_mode"] = mode + return routingTestAccount(PlatformOpenCodeGo, AccountTypeAPIKey, credentials, nil) + } + openAIWithProbe := func(accountType string, supported any) *Account { + extra := map[string]any{} + if supported != nil { + extra[openai_compat.ExtraKeyResponsesSupported] = supported + } + return routingTestAccount(PlatformOpenAI, accountType, nil, extra) + } + + cases := []struct { + name string + account *Account + inbound string + model string + want string + }{ + // 按入站协议分流(国产单一厂商 adaptive) + {"kimi adaptive anthropic inbound", adaptive(PlatformKimi), anthropic, "", anthropic}, + {"kimi adaptive cc inbound", adaptive(PlatformKimi), cc, "", cc}, + {"kimi adaptive responses inbound", adaptive(PlatformKimi), responses, "", responses}, + {"zhipu adaptive responses inbound falls back to cc", adaptive(PlatformZhipu), responses, "", cc}, + {"zhipu adaptive anthropic inbound", adaptive(PlatformZhipu), anthropic, "", anthropic}, + + // 显式固定协议,与入站无关 + {"kimi pinned anthropic", pinned(PlatformKimi, anthropic), cc, "", anthropic}, + {"kimi pinned cc", pinned(PlatformKimi, cc), responses, "", cc}, + {"kimi pinned responses", pinned(PlatformKimi, responses), anthropic, "", responses}, + {"zhipu pinned responses is unsupported and stays cc", pinned(PlatformZhipu, responses), responses, "", cc}, + + // 按模型分流(多模型聚合平台),与入站无关 + {"opencode go gpt", openCode(AccountModeGo, nil), anthropic, "gpt-5.6-luna", responses}, + {"opencode go minimax", openCode(AccountModeGo, nil), responses, "minimax-m3", anthropic}, + {"opencode go glm falls back to cc", openCode(AccountModeGo, nil), anthropic, "glm-5.3", cc}, + {"opencode go claude has no rule", openCode(AccountModeGo, nil), anthropic, "claude-sonnet-4", cc}, + {"opencode zen claude", openCode(AccountModeZen, nil), cc, "claude-sonnet-4", anthropic}, + {"opencode account rules override defaults", openCode(AccountModeGo, map[string]any{ + "protocol_rules": []any{map[string]any{"pattern": "glm-*", "protocol": anthropic}}, + }), cc, "glm-5.3", anthropic}, + {"opencode account rules miss falls back to cc", openCode(AccountModeGo, map[string]any{ + "protocol_rules": []any{map[string]any{"pattern": "glm-*", "protocol": anthropic}}, + }), cc, "gpt-5.6-luna", cc}, + {"opencode pinned protocol beats rules", openCode(AccountModeGo, map[string]any{"api_protocol": cc}), anthropic, "gpt-5.6-luna", cc}, + {"opencode ignores responses probe", func() *Account { + account := openCode(AccountModeGo, nil) + account.Extra = map[string]any{openai_compat.ExtraKeyResponsesSupported: false} + return account + }(), cc, "gpt-5.6-luna", responses}, + + // 无 profile 的账号按探针决定 Responses / CC + {"openai apikey unknown probe", openAIWithProbe(AccountTypeAPIKey, nil), cc, "", responses}, + {"openai apikey probe unsupported", openAIWithProbe(AccountTypeAPIKey, false), anthropic, "", cc}, + {"openai oauth ignores probe", openAIWithProbe(AccountTypeOAuth, false), cc, "", responses}, + } + for _, tc := range cases { + require.Equal(t, tc.want, resolveUpstreamProtocol(tc.account, tc.inbound, tc.model), tc.name) + } +} + +func TestRoutesByModelOnlyForAggregators(t *testing.T) { + t.Parallel() + + for platform, profile := range providerProfiles { + account := &Account{Platform: platform} + require.Equal(t, platform == PlatformOpenCodeGo, account.routesByModel(), platform) + require.Equal(t, account.IsOpenCodeGo(), profile.Routing == ProviderRoutingByModel, platform) + } + require.False(t, (&Account{Platform: PlatformOpenAI}).routesByModel()) + require.False(t, (*Account)(nil).routesByModel()) +} + +func TestUpstreamRoutingModelOnlyForModelRoutedAccounts(t *testing.T) { + t.Parallel() + + body := []byte(`{"model":"gpt-5.6-luna","input":"hi"}`) + openCode := routingTestAccount(PlatformOpenCodeGo, AccountTypeAPIKey, map[string]any{ + "model_mapping": map[string]any{"gpt-5.6-luna": "glm-5.3"}, + }, nil) + require.Equal(t, "glm-5.3", upstreamRoutingModel(openCode, body, "")) + require.Empty(t, upstreamRoutingModel(routingTestAccount(PlatformKimi, AccountTypeAPIKey, nil, nil), body, "")) +}