mirror of
https://github.com/Wei-Shaw/sub2api.git
synced 2026-10-31 00:24:38 +00:00
fix(opencode): rewrite mapped model onto all three upstream protocols
Apply account model_mapping to the request body before OpenCode protocol routing so /v1/responses, /v1/chat/completions, and /v1/messages all send the mapped ID as the real upstream model.
This commit is contained in:
@@ -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 {
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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 端点时,
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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)
|
||||
})
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user