refactor(gateway): 三个入站入口的上游协议分流收敛为 resolveUpstreamProtocol

此前 /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 <noreply@anthropic.com>
This commit is contained in:
SavitarC
2026-10-09 10:10:32 +08:00
co-authored by Claude Opus 5.5
parent 3f8eea0807
commit 8ac2936a3d
13 changed files with 704 additions and 195 deletions
+2 -1
View File
@@ -1444,7 +1444,8 @@ func (a *Account) GetAPIProtocol() string {
case APIProtocolChatCompletions:
return APIProtocolChatCompletions
}
if a.IsOpenCodeGo() {
// 按模型分流的供应商(多模型聚合平台)未显式配置时默认 adaptive。
if a.routesByModel() {
return APIProtocolAdaptive
}
return APIProtocolChatCompletions
+4 -4
View File
@@ -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
@@ -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",
@@ -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
@@ -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)
@@ -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)
+36 -48
View File
@@ -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 额度端点。
+15 -15
View File
@@ -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"},
},
})
+27 -2
View File
@@ -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
}
@@ -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)
}
@@ -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
}
@@ -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=<path> 时把全部观测(含 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)
}
@@ -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, ""))
}