mirror of
https://github.com/Wei-Shaw/sub2api.git
synced 2026-10-14 03:52:26 +08:00
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:
co-authored by
Claude Opus 5.5
parent
3f8eea0807
commit
8ac2936a3d
@@ -1444,7 +1444,8 @@ func (a *Account) GetAPIProtocol() string {
|
||||
case APIProtocolChatCompletions:
|
||||
return APIProtocolChatCompletions
|
||||
}
|
||||
if a.IsOpenCodeGo() {
|
||||
// 按模型分流的供应商(多模型聚合平台)未显式配置时默认 adaptive。
|
||||
if a.routesByModel() {
|
||||
return APIProtocolAdaptive
|
||||
}
|
||||
return APIProtocolChatCompletions
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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 额度端点。
|
||||
|
||||
@@ -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"},
|
||||
},
|
||||
})
|
||||
|
||||
@@ -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, ""))
|
||||
}
|
||||
Reference in New Issue
Block a user