fix(gateway): /v1/responses 转 Anthropic 时统一以去空白的请求模型兜底映射

forwardResponsesViaNativeAnthropic 的 defaultMappedModel 是账号 model_mapping
未命中时的兜底模型。此前按模型分流的账号(OpenCode)传空串、其余账号传
reqModel,源自两处分支各自实现。映射未命中且请求模型带首尾空白时,前者的
计费模型名保留空白(上游模型名因 normalizeOpenAIModelForUpstream 已去空白,
两者不一致,定价查找可能落空),后者为去空白的名字。

统一传 reqModel,并移除入口里最后一处 routesByModel 分支。仅影响按模型
分流账号收到带首尾空白模型名的请求:计费模型名改为去空白后的名字;发往
上游的请求不变。新增测试覆盖 OpenCode 与 Kimi 两类账号。

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 8ac2936a3d
commit 105c955c88
2 changed files with 47 additions and 6 deletions
@@ -187,12 +187,8 @@ func (s *OpenAIGatewayService) Forward(ctx context.Context, c *gin.Context, acco
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)
// 账号映射未命中时以去除首尾空白的请求模型兜底,计费名与上游模型名一致。
return s.forwardResponsesViaNativeAnthropic(ctx, c, account, body, reqModel)
case APIProtocolChatCompletions:
return s.forwardResponsesViaRawChatCompletions(ctx, c, account, body)
}
@@ -3,10 +3,14 @@
package service
import (
"context"
"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"
)
func routingTestAccount(platform, accountType string, credentials, extra map[string]any) *Account {
@@ -117,3 +121,44 @@ func TestUpstreamRoutingModelOnlyForModelRoutedAccounts(t *testing.T) {
require.Equal(t, "glm-5.3", upstreamRoutingModel(openCode, body, ""))
require.Empty(t, upstreamRoutingModel(routingTestAccount(PlatformKimi, AccountTypeAPIKey, nil, nil), body, ""))
}
// /v1/responses 转 Anthropic 上游时,计费模型名与上游模型名都应是去除首尾空白的
// 请求模型;按模型分流(OpenCode)与显式 anthropic 协议(Kimi)账号行为一致。
func TestResponsesToNativeAnthropicTrimsBillingModel(t *testing.T) {
gin.SetMode(gin.TestMode)
openCode := &Account{
ID: 703,
Name: "opencode-go",
Platform: PlatformOpenCodeGo,
Type: AccountTypeAPIKey,
Concurrency: 1,
Credentials: map[string]any{
"api_key": "sk-test",
"account_mode": AccountModeGo,
"api_base_urls": map[string]any{
APIProtocolAnthropic: "http://anthropic.example",
},
},
}
for _, tc := range []struct {
name string
account *Account
model string
}{
{"opencode by model", openCode, "minimax-m3"},
{"kimi pinned anthropic", nativeAnthropicTestAccount(), "kimi-k2"},
} {
t.Run(tc.name, func(t *testing.T) {
body := []byte(`{"model":" ` + tc.model + ` ","input":"hello","stream":false}`)
upstream := &httpUpstreamRecorder{resp: nativeAnthropicStreamResponse()}
svc := &OpenAIGatewayService{cfg: rawChatCompletionsTestConfig(), httpUpstream: upstream}
result, err := svc.Forward(context.Background(), adaptiveProtocolTestContext("/v1/responses", body), tc.account, body)
require.NoError(t, err)
require.True(t, strings.HasSuffix(upstream.lastReq.URL.Path, "/v1/messages"), upstream.lastReq.URL.String())
require.Equal(t, tc.model, gjson.GetBytes(upstream.lastBody, "model").String())
require.Equal(t, tc.model, result.BillingModel)
})
}
}