From 18bfa4bf20324b36df1885993cd6e16fc85c3c85 Mon Sep 17 00:00:00 2001 From: HypoxanthineOvO Date: Wed, 16 Sep 2026 16:11:43 +0800 Subject: [PATCH] fix(gateway): normalize developer roles for strict Chat upstreams --- backend/internal/service/openai_chat_roles.go | 82 ++++++++ .../service/openai_chat_roles_test.go | 185 ++++++++++++++++++ .../openai_gateway_chat_completions_raw.go | 5 + 3 files changed, 272 insertions(+) create mode 100644 backend/internal/service/openai_chat_roles.go create mode 100644 backend/internal/service/openai_chat_roles_test.go diff --git a/backend/internal/service/openai_chat_roles.go b/backend/internal/service/openai_chat_roles.go new file mode 100644 index 0000000000..1e80101f5d --- /dev/null +++ b/backend/internal/service/openai_chat_roles.go @@ -0,0 +1,82 @@ +package service + +import ( + "encoding/json" + "errors" + "net/url" + "strings" +) + +func requiresSystemChatRole(account *Account, targetURL string) bool { + if account == nil || account.Type != AccountTypeAPIKey { + return false + } + switch account.Platform { + case PlatformDeepseek, PlatformKimi, PlatformZhipu: + return true + } + // OpenAI-compatible accounts can point at the same strict providers. Match + // the selected destination's exact hostname, never the requested model ID. + u, err := url.Parse(targetURL) + if err != nil { + return false + } + switch strings.ToLower(u.Hostname()) { + case "api.deepseek.com", "api.kimi.com", "api.moonshot.cn", "api.moonshot.ai", "open.bigmodel.cn", "api.z.ai": + return true + } + return false +} + +// normalizeStrictChatDeveloperRoles adapts native Chat requests only after an +// account and destination have been selected. Unlike the Responses bridge, it +// does not reorder/merge messages or downgrade instructions to user messages. +// RawMessage preserves unknown fields and numeric precision. The input remains +// untouched so a retry on another account sees the original developer roles. +func normalizeStrictChatDeveloperRoles(account *Account, targetURL string, body []byte) ([]byte, error) { + if !requiresSystemChatRole(account, targetURL) { + return body, nil + } + var root map[string]json.RawMessage + if json.Unmarshal(body, &root) != nil || root == nil { + return nil, errors.New("chat role normalization: invalid request object") + } + raw, exists := root["messages"] + if !exists { + return body, nil + } + var messages []json.RawMessage + if json.Unmarshal(raw, &messages) != nil { + return nil, errors.New("chat role normalization: invalid messages array") + } + changed := false + for i, raw := range messages { + var message map[string]json.RawMessage + if json.Unmarshal(raw, &message) != nil || message == nil { + return nil, errors.New("chat role normalization: invalid message object") + } + var role string + if json.Unmarshal(message["role"], &role) != nil { + return nil, errors.New("chat role normalization: invalid message role") + } + if role != "developer" { + continue + } + message["role"] = json.RawMessage(`"system"`) + updated, err := json.Marshal(message) + if err != nil { + return nil, errors.New("chat role normalization: cannot encode message") + } + messages[i] = updated + changed = true + } + if !changed { + return body, nil + } + updated, err := json.Marshal(messages) + if err != nil { + return nil, errors.New("chat role normalization: cannot encode messages") + } + root["messages"] = updated + return json.Marshal(root) +} diff --git a/backend/internal/service/openai_chat_roles_test.go b/backend/internal/service/openai_chat_roles_test.go new file mode 100644 index 0000000000..27434ad983 --- /dev/null +++ b/backend/internal/service/openai_chat_roles_test.go @@ -0,0 +1,185 @@ +//go:build unit + +package service + +import ( + "bytes" + "context" + "fmt" + "io" + "net/http" + "net/http/httptest" + "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" +) + +// Only the upstream transport is stubbed. Requests go through the actual Chat +// dispatcher, model mapping, request construction and response/usage handling. +type chatRoleContractUpstream struct { + httpUpstreamRecorder + strict bool +} + +func (u *chatRoleContractUpstream) Do(req *http.Request, proxyURL string, accountID int64, concurrency int) (*http.Response, error) { + body, err := io.ReadAll(req.Body) + if err != nil { + return nil, err + } + req.Body = io.NopCloser(bytes.NewReader(body)) + status, contentType := http.StatusOK, "application/json" + result := `{"id":"chatcmpl_roles","object":"chat.completion","choices":[{"index":0,"message":{"role":"assistant","content":"pong"},"finish_reason":"stop"}],"usage":{"prompt_tokens":9,"completion_tokens":1,"total_tokens":10}}` + if u.strict { + for _, message := range gjson.GetBytes(body, "messages").Array() { + if message.Get("role").String() == "developer" { + status = http.StatusBadRequest + result = "{\"error\":{\"message\":\"unknown variant `developer`\",\"type\":\"invalid_request_error\"}}" + } + } + } + if status == http.StatusOK && gjson.GetBytes(body, "stream").Bool() { + contentType = "text/event-stream" + result = "data: {\"choices\":[{\"index\":0,\"delta\":{\"role\":\"assistant\",\"content\":\"pong\"},\"finish_reason\":\"stop\"}]}\n\ndata: {\"choices\":[],\"usage\":{\"prompt_tokens\":9,\"completion_tokens\":1,\"total_tokens\":10}}\n\ndata: [DONE]\n\n" + } + u.resp = &http.Response{StatusCode: status, Header: http.Header{"Content-Type": {contentType}}, Body: io.NopCloser(strings.NewReader(result))} + return u.httpUpstreamRecorder.Do(req, proxyURL, accountID, concurrency) +} + +func TestForwardAsChatCompletions_StrictDeveloperRole(t *testing.T) { + gin.SetMode(gin.TestMode) + for _, stream := range []bool{false, true} { + for _, target := range []struct{ platform, model, base string }{ + {PlatformDeepseek, "deepseek-flash", "https://api.deepseek.com"}, + {PlatformKimi, "k3", "https://api.kimi.com/coding"}, + {PlatformZhipu, "glm-5.3", "https://open.bigmodel.cn/api/paas/v4"}, + {PlatformOpenAI, "deepseek-flash", "https://api.deepseek.com"}, + {PlatformOpenAI, "k3", "https://api.kimi.com/coding"}, + {PlatformOpenAI, "kimi-k3", "https://api.moonshot.ai"}, + {PlatformOpenAI, "glm-5.3", "https://open.bigmodel.cn/api/paas/v4"}, + } { + t.Run(fmt.Sprintf("%s/%s/%s/stream=%t", target.platform, target.model, target.base, stream), func(t *testing.T) { + body := []byte(fmt.Sprintf(`{"model":"example-alias","messages":[{"role":"developer","content":"Keep these instructions."},{"role":"user","content":"ping"}],"stream":%t,"max_tokens":10}`, stream)) + original := bytes.Clone(body) + upstream := &chatRoleContractUpstream{strict: true} + svc := &OpenAIGatewayService{cfg: rawChatCompletionsTestConfig(), httpUpstream: upstream} + account := rawChatCompletionsTestAccount() + account.Platform = target.platform + account.Credentials["base_url"] = target.base + account.Credentials["api_protocol"] = APIProtocolChatCompletions + account.Credentials["model_mapping"] = map[string]any{"example-alias": target.model} + account.Extra = map[string]any{openai_compat.ExtraKeyResponsesSupported: false} + + recorder := httptest.NewRecorder() + c, _ := gin.CreateTestContext(recorder) + c.Request = httptest.NewRequest(http.MethodPost, "/v1/chat/completions", bytes.NewReader(body)) + result, err := svc.ForwardAsChatCompletions(context.Background(), c, account, body, "", "") + require.NoError(t, err) + require.NotNil(t, result) + require.Equal(t, 9, result.Usage.InputTokens) + require.Equal(t, 1, result.Usage.OutputTokens) + require.Equal(t, target.model, gjson.GetBytes(upstream.lastBody, "model").String()) + require.Equal(t, "system", gjson.GetBytes(upstream.lastBody, "messages.0.role").String()) + require.Equal(t, "Keep these instructions.", gjson.GetBytes(upstream.lastBody, "messages.0.content").String()) + require.Equal(t, original, body, "per-account adaptation must not mutate the retry body") + require.Equal(t, http.StatusOK, recorder.Code) + require.Contains(t, recorder.Body.String(), "pong") + if stream { + require.Contains(t, recorder.Body.String(), "data: [DONE]") + } + + // A subsequent attempt with the same body on a compatible account + // must still see developer, not the previous account's system role. + upstream.strict = false + account.Platform = PlatformOpenAI + account.Credentials["base_url"] = "https://compatible.example.test/v1" + recorder = httptest.NewRecorder() + c, _ = gin.CreateTestContext(recorder) + c.Request = httptest.NewRequest(http.MethodPost, "/v1/chat/completions", bytes.NewReader(body)) + _, err = svc.ForwardAsChatCompletions(context.Background(), c, account, body, "", "") + require.NoError(t, err) + require.Equal(t, "developer", gjson.GetBytes(upstream.lastBody, "messages.0.role").String()) + }) + } + } +} + +func TestNormalizeStrictChatDeveloperRoles_PreservesConversation(t *testing.T) { + body := []byte(`{"model":"alias","messages":[{"role":"system","content":"first"},{"role":"developer","content":[{"type":"text","text":"developer is just text","cache_control":{"type":"ephemeral"}}],"name":"rules"},{"role":"user","content":"ping"},{"role":"assistant","content":null,"tool_calls":[{"id":"call_1","type":"function","function":{"name":"echo","arguments":"{\"role\":\"developer\"}"}}]},{"role":"tool","tool_call_id":"call_1","content":"ok"},{"role":"developer","content":"last"}],"metadata":{"large":9007199254740993,"role":"developer"},"stream":true,"reasoning_effort":"high"}`) + original := bytes.Clone(body) + want := strings.ReplaceAll(string(body), `"role":"developer","content"`, `"role":"system","content"`) + for _, platform := range []string{PlatformDeepseek, PlatformKimi, PlatformZhipu} { + t.Run(platform, func(t *testing.T) { + account := &Account{Platform: platform, Type: AccountTypeAPIKey} + got, err := normalizeStrictChatDeveloperRoles(account, "https://relay.example.test/v1/chat/completions", body) + require.NoError(t, err) + require.JSONEq(t, want, string(got)) + require.Contains(t, string(got), "9007199254740993") + require.Equal(t, original, body) + again, err := normalizeStrictChatDeveloperRoles(account, "https://relay.example.test/v1/chat/completions", got) + require.NoError(t, err) + require.Equal(t, got, again) + }) + } +} + +func TestNormalizeStrictChatDeveloperRoles_ExactOfficialHosts(t *testing.T) { + body := []byte(`{"messages":[{"role":"developer","content":"rules"}]}`) + account := &Account{Platform: PlatformOpenAI, Type: AccountTypeAPIKey} + for _, host := range []string{"api.deepseek.com", "api.kimi.com", "api.moonshot.cn", "api.moonshot.ai", "open.bigmodel.cn", "api.z.ai", "API.DEEPSEEK.COM:443"} { + t.Run(host, func(t *testing.T) { + got, err := normalizeStrictChatDeveloperRoles(account, "https://"+host+"/v1/chat/completions", body) + require.NoError(t, err) + require.JSONEq(t, `{"messages":[{"role":"system","content":"rules"}]}`, string(got)) + }) + } +} + +func TestNormalizeStrictChatDeveloperRoles_OtherTargetsUntouched(t *testing.T) { + // Neither model names nor host-like strings in the path/userinfo may enable + // normalization on an otherwise compatible provider. + body := []byte("{ \"model\": \"deepseek-flash\", \"messages\": [{\"role\":\"developer\",\"content\":\"rules\"}] }") + account := &Account{Platform: PlatformOpenAI, Type: AccountTypeAPIKey} + for _, target := range []string{ + "https://api.openai.com/v1/chat/completions", + "https://relay.example.test/v1/chat/completions", + "https://api.deepseek.com.example.test/v1/chat/completions", + "https://example.test/api.kimi.com/chat/completions", + "https://api.deepseek.com@example.test/v1/chat/completions", + "://invalid-url", + } { + got, err := normalizeStrictChatDeveloperRoles(account, target, body) + require.NoError(t, err) + require.Equal(t, body, got) + } + for _, account := range []*Account{nil, {Platform: PlatformKimi, Type: AccountTypeOAuth}, {Platform: PlatformOpenAI, Type: AccountTypeOAuth}} { + got, err := normalizeStrictChatDeveloperRoles(account, "https://api.kimi.com/coding/v1/chat/completions", body) + require.NoError(t, err) + require.Equal(t, body, got) + } +} + +func TestNormalizeStrictChatDeveloperRoles_CompatibleBodyUntouched(t *testing.T) { + account := &Account{Platform: PlatformDeepseek, Type: AccountTypeAPIKey} + for _, body := range []string{ + "{ \"messages\": [{\"role\":\"system\",\"content\":\"rules\"}] }", + `{"messages":[]}`, + `{"input":[{"role":"developer","content":"Responses is not Chat"}]}`, + } { + got, err := normalizeStrictChatDeveloperRoles(account, "https://api.deepseek.com/v1/chat/completions", []byte(body)) + require.NoError(t, err) + require.Equal(t, body, string(got)) + } +} + +func TestNormalizeStrictChatDeveloperRoles_MalformedBodyDoesNotLeak(t *testing.T) { + account := &Account{Platform: PlatformDeepseek, Type: AccountTypeAPIKey} + for _, body := range []string{`{"secret":`, `null`, `[]`, `{"messages":"secret"}`, `{"messages":["secret"]}`, `{"messages":[null]}`, `{"messages":[{"content":"secret"}]}`, `{"messages":[{"role":12,"content":"secret"}]}`} { + _, err := normalizeStrictChatDeveloperRoles(account, "https://api.deepseek.com/v1/chat/completions", []byte(body)) + require.Error(t, err) + require.NotContains(t, err.Error(), "secret") + } +} diff --git a/backend/internal/service/openai_gateway_chat_completions_raw.go b/backend/internal/service/openai_gateway_chat_completions_raw.go index dd77e700d2..f778a682bc 100644 --- a/backend/internal/service/openai_gateway_chat_completions_raw.go +++ b/backend/internal/service/openai_gateway_chat_completions_raw.go @@ -178,6 +178,11 @@ func (s *OpenAIGatewayService) forwardAsRawChatCompletions( if err != nil { return nil, err } + upstreamBody, err = normalizeStrictChatDeveloperRoles(account, targetURL, upstreamBody) + if err != nil { + writeChatCompletionsError(c, http.StatusBadRequest, "invalid_request_error", err.Error()) + return nil, err + } SetActualOpenAIUpstreamEndpoint(c, grokChatRawEndpoint) customUA := account.GetOpenAIUserAgent() if customUA == "" && account.IsGrokOAuth() {