diff --git a/backend/internal/service/openai_gateway_chat_completions.go b/backend/internal/service/openai_gateway_chat_completions.go index 37d83dc191..dc14a8c493 100644 --- a/backend/internal/service/openai_gateway_chat_completions.go +++ b/backend/internal/service/openai_gateway_chat_completions.go @@ -117,7 +117,12 @@ func (s *OpenAIGatewayService) forwardAsChatCompletions( // OpenCode Go:按模型原生协议分流(与 inbound 协议正交)。 // 规则未命中一律兜底 Chat Completions,只有显式 Responses 才走下方转换链。 if account.IsOpenCodeGo() { - proto := openCodeGoNativeProtocol(account, resolveOpenCodeGoMappedModel(account, body, defaultMappedModel)) + mapped, rewritten, rewriteErr := rewriteOpenCodeGoRequestModel(account, body, defaultMappedModel) + if rewriteErr != nil { + return nil, rewriteErr + } + body = rewritten + proto := openCodeGoNativeProtocol(account, mapped) if proto != APIProtocolResponses { if isResponsesShape { if proto == APIProtocolAnthropic { diff --git a/backend/internal/service/openai_gateway_forward.go b/backend/internal/service/openai_gateway_forward.go index b13892a43a..92dcbc140e 100644 --- a/backend/internal/service/openai_gateway_forward.go +++ b/backend/internal/service/openai_gateway_forward.go @@ -156,10 +156,20 @@ func (s *OpenAIGatewayService) Forward(ctx context.Context, c *gin.Context, acco } if account.IsOpenCodeGo() { - mapped := normalizeOpenAIModelForUpstream(account, resolveOpenAIForwardModel(account, originalModel, "")) + mapped, rewritten, rewriteErr := rewriteOpenCodeGoRequestModel(account, body, "") + if rewriteErr != nil { + return nil, rewriteErr + } + if mapped != originalModel { + body = rewritten + originalBody = rewritten + requestView = newOpenAIRequestView(body) + reqModel, reqStream, promptCacheKey = requestView.Model, requestView.Stream, requestView.PromptCacheKey + originalModel = reqModel + } switch openCodeGoNativeProtocol(account, mapped) { case APIProtocolAnthropic: - return s.forwardResponsesViaNativeAnthropic(ctx, c, account, body, reqModel) + return s.forwardResponsesViaNativeAnthropic(ctx, c, account, body, mapped) case APIProtocolResponses: break default: diff --git a/backend/internal/service/openai_gateway_messages.go b/backend/internal/service/openai_gateway_messages.go index 857dc590a1..7aa0b7fe47 100644 --- a/backend/internal/service/openai_gateway_messages.go +++ b/backend/internal/service/openai_gateway_messages.go @@ -46,13 +46,18 @@ func (s *OpenAIGatewayService) ForwardAsAnthropic( // OpenCode Go:按模型原生协议分流。规则未命中兜底 Chat Completions。 if account.IsOpenCodeGo() { - switch openCodeGoNativeProtocol(account, resolveOpenCodeGoMappedModel(account, body, defaultMappedModel)) { + mapped, rewritten, rewriteErr := rewriteOpenCodeGoRequestModel(account, body, defaultMappedModel) + if rewriteErr != nil { + return nil, rewriteErr + } + body = rewritten + switch openCodeGoNativeProtocol(account, mapped) { case APIProtocolAnthropic: - return s.forwardAnthropicViaNativeAnthropicEndpoint(ctx, c, account, body, defaultMappedModel) + return s.forwardAnthropicViaNativeAnthropicEndpoint(ctx, c, account, body, mapped) case APIProtocolResponses: break default: - return s.forwardAnthropicViaRawChatCompletions(ctx, c, account, body, defaultMappedModel) + return s.forwardAnthropicViaRawChatCompletions(ctx, c, account, body, mapped) } } else if account.IsAnthropicProtocol() || account.IsAdaptiveAPIProtocol() { // 入口分流(国产供应商 Anthropic 协议):上游为供应商原生 Anthropic 端点时, diff --git a/backend/internal/service/openai_gateway_messages_anthropic_native.go b/backend/internal/service/openai_gateway_messages_anthropic_native.go index 263eceba04..e0041961ad 100644 --- a/backend/internal/service/openai_gateway_messages_anthropic_native.go +++ b/backend/internal/service/openai_gateway_messages_anthropic_native.go @@ -146,6 +146,25 @@ func resolveOpenCodeGoMappedModel(account *Account, body []byte, defaultMappedMo return normalizeOpenAIModelForUpstream(account, billing) } +// rewriteOpenCodeGoRequestModel applies account model_mapping to the request +// body so every OpenCode protocol path (Responses / Chat Completions / +// Anthropic Messages) sends the mapped ID as the real upstream model. +func rewriteOpenCodeGoRequestModel(account *Account, body []byte, defaultMappedModel string) (string, []byte, error) { + mapped := resolveOpenCodeGoMappedModel(account, body, defaultMappedModel) + original := strings.TrimSpace(gjson.GetBytes(body, "model").String()) + if mapped == "" { + mapped = original + } + if mapped == "" || mapped == original || len(body) == 0 { + return mapped, body, nil + } + rewritten, err := sjson.SetBytes(body, "model", mapped) + if err != nil { + return mapped, body, fmt.Errorf("rewrite OpenCode mapped model: %w", err) + } + return mapped, rewritten, nil +} + func (s *OpenAIGatewayService) buildNativeAnthropicUpstreamRequest( ctx context.Context, c *gin.Context, diff --git a/backend/internal/service/openai_gateway_opencode_mapping_test.go b/backend/internal/service/openai_gateway_opencode_mapping_test.go new file mode 100644 index 0000000000..581cd8c6cf --- /dev/null +++ b/backend/internal/service/openai_gateway_opencode_mapping_test.go @@ -0,0 +1,124 @@ +//go:build unit + +package service + +import ( + "context" + "io" + "net/http" + "strings" + "testing" + + "github.com/gin-gonic/gin" + "github.com/stretchr/testify/require" + "github.com/tidwall/gjson" +) + +func openCodeMappedTestAccount() *Account { + return &Account{ + ID: 801, + Name: "oc", + Platform: PlatformOpenCodeGo, + Type: AccountTypeAPIKey, + Concurrency: 1, + Credentials: map[string]any{ + "api_key": "sk-opencode", + "account_mode": AccountModeZen, + "api_protocol": APIProtocolAdaptive, + "base_url": "https://opencode.ai/zen/v1", + "api_base_urls": map[string]any{ + APIProtocolChatCompletions: "https://opencode.ai/zen/v1", + APIProtocolAnthropic: "https://opencode.ai/zen", + APIProtocolResponses: "https://opencode.ai/zen/v1", + }, + "model_mapping": map[string]any{ + "opencode/muse-spark-1.3-contributior-free": "muse-spark-1.3-contributior-free", + "opencode/claude-sonnet-4": "claude-sonnet-4", + "opencode/glm-5.3": "glm-5.3", + }, + }, + } +} + +func TestOpenCodeGatewayAppliesMappedModelOnAllIngresses(t *testing.T) { + gin.SetMode(gin.TestMode) + + tests := []struct { + name string + path string + body string + wantURL string + wantModel string + wantNotModel string + forward func(*OpenAIGatewayService, *gin.Context, *Account, []byte) error + upstream *http.Response + }{ + { + name: "responses muse-spark", + path: "/v1/responses", + body: `{"model":"opencode/muse-spark-1.3-contributior-free","stream":false,"input":"hi"}`, + wantURL: "https://opencode.ai/zen/v1/responses", + wantModel: "muse-spark-1.3-contributior-free", + wantNotModel: "opencode/muse-spark-1.3-contributior-free", + upstream: &http.Response{ + StatusCode: http.StatusOK, + Header: http.Header{"Content-Type": []string{"application/json"}, "x-request-id": []string{"rid-oc-responses"}}, + Body: io.NopCloser(strings.NewReader( + `{"id":"resp_oc","object":"response","model":"muse-spark-1.3-contributior-free","status":"completed","output":[{"type":"message","role":"assistant","content":[{"type":"output_text","text":"ok"}]}],"usage":{"input_tokens":2,"output_tokens":1,"total_tokens":3}}`, + )), + }, + forward: func(svc *OpenAIGatewayService, c *gin.Context, account *Account, body []byte) error { + SetOpenAIClientTransport(c, OpenAIClientTransportHTTP) + _, err := svc.Forward(context.Background(), c, account, body) + return err + }, + }, + { + name: "chat completions glm", + path: "/v1/chat/completions", + body: `{"model":"opencode/glm-5.3","stream":false,"messages":[{"role":"user","content":"hi"}]}`, + wantURL: "https://opencode.ai/zen/v1/chat/completions", + wantModel: "glm-5.3", + wantNotModel: "opencode/glm-5.3", + upstream: &http.Response{ + StatusCode: http.StatusOK, + Header: http.Header{"Content-Type": []string{"application/json"}, "x-request-id": []string{"rid-oc-cc"}}, + Body: io.NopCloser(strings.NewReader( + `{"id":"chatcmpl_oc","object":"chat.completion","model":"glm-5.3","choices":[{"index":0,"message":{"role":"assistant","content":"ok"},"finish_reason":"stop"}],"usage":{"prompt_tokens":2,"completion_tokens":1,"total_tokens":3}}`, + )), + }, + forward: func(svc *OpenAIGatewayService, c *gin.Context, account *Account, body []byte) error { + _, err := svc.ForwardAsChatCompletions(context.Background(), c, account, body, "", "") + return err + }, + }, + { + name: "messages claude", + path: "/v1/messages", + body: `{"model":"opencode/claude-sonnet-4","max_tokens":32,"stream":false,"messages":[{"role":"user","content":"hi"}]}`, + wantURL: "https://opencode.ai/zen/v1/messages", + wantModel: "claude-sonnet-4", + wantNotModel: "opencode/claude-sonnet-4", + upstream: nativeAnthropicBufferedResponse(), + forward: func(svc *OpenAIGatewayService, c *gin.Context, account *Account, body []byte) error { + _, err := svc.ForwardAsAnthropic(context.Background(), c, account, body, "", "") + return err + }, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + upstream := &httpUpstreamRecorder{resp: tt.upstream} + svc := &OpenAIGatewayService{cfg: rawChatCompletionsTestConfig(), httpUpstream: upstream} + c := adaptiveProtocolTestContext(tt.path, []byte(tt.body)) + + err := tt.forward(svc, c, openCodeMappedTestAccount(), []byte(tt.body)) + require.NoError(t, err) + require.NotNil(t, upstream.lastReq) + require.Equal(t, tt.wantURL, upstream.lastReq.URL.String()) + require.Equal(t, tt.wantModel, gjson.GetBytes(upstream.lastBody, "model").String()) + require.NotContains(t, string(upstream.lastBody), tt.wantNotModel) + }) + } +}