From 8c10941142a564a4e608daef4d3b6b772e35105f Mon Sep 17 00:00:00 2001 From: YanzheL Date: Sun, 29 Mar 2026 20:52:29 +0800 Subject: [PATCH] fix(openai): normalize gpt-5.4-xhigh compat mapping --- .../internal/service/openai_compat_model.go | 103 ++++++++++++++ .../service/openai_compat_model_test.go | 129 ++++++++++++++++++ .../service/openai_gateway_messages.go | 3 +- .../internal/service/openai_model_mapping.go | 1 + .../service/openai_model_mapping_test.go | 13 ++ 5 files changed, 248 insertions(+), 1 deletion(-) create mode 100644 backend/internal/service/openai_compat_model.go create mode 100644 backend/internal/service/openai_compat_model_test.go diff --git a/backend/internal/service/openai_compat_model.go b/backend/internal/service/openai_compat_model.go new file mode 100644 index 00000000..5f140d01 --- /dev/null +++ b/backend/internal/service/openai_compat_model.go @@ -0,0 +1,103 @@ +package service + +import ( + "strings" + + "github.com/Wei-Shaw/sub2api/internal/pkg/apicompat" +) + +func NormalizeOpenAICompatRequestedModel(model string) string { + trimmed := strings.TrimSpace(model) + if trimmed == "" { + return "" + } + + normalized, _, ok := splitOpenAICompatReasoningModel(trimmed) + if !ok || normalized == "" { + return trimmed + } + return normalized +} + +func applyOpenAICompatModelNormalization(req *apicompat.AnthropicRequest) { + if req == nil { + return + } + + originalModel := strings.TrimSpace(req.Model) + if originalModel == "" { + return + } + + normalizedModel, derivedEffort, hasReasoningSuffix := splitOpenAICompatReasoningModel(originalModel) + if hasReasoningSuffix && normalizedModel != "" { + req.Model = normalizedModel + } + + if req.OutputConfig != nil && strings.TrimSpace(req.OutputConfig.Effort) != "" { + return + } + + claudeEffort := openAIReasoningEffortToClaudeOutputEffort(derivedEffort) + if claudeEffort == "" { + return + } + + if req.OutputConfig == nil { + req.OutputConfig = &apicompat.AnthropicOutputConfig{} + } + req.OutputConfig.Effort = claudeEffort +} + +func splitOpenAICompatReasoningModel(model string) (normalizedModel string, reasoningEffort string, ok bool) { + trimmed := strings.TrimSpace(model) + if trimmed == "" { + return "", "", false + } + + modelID := trimmed + if strings.Contains(modelID, "/") { + parts := strings.Split(modelID, "/") + modelID = parts[len(parts)-1] + } + modelID = strings.TrimSpace(modelID) + if !strings.HasPrefix(strings.ToLower(modelID), "gpt-") { + return trimmed, "", false + } + + parts := strings.FieldsFunc(strings.ToLower(modelID), func(r rune) bool { + switch r { + case '-', '_', ' ': + return true + default: + return false + } + }) + if len(parts) == 0 { + return trimmed, "", false + } + + last := strings.NewReplacer("-", "", "_", "", " ", "").Replace(parts[len(parts)-1]) + switch last { + case "none", "minimal": + case "low", "medium", "high": + reasoningEffort = last + case "xhigh", "extrahigh": + reasoningEffort = "xhigh" + default: + return trimmed, "", false + } + + return normalizeCodexModel(modelID), reasoningEffort, true +} + +func openAIReasoningEffortToClaudeOutputEffort(effort string) string { + switch strings.TrimSpace(effort) { + case "low", "medium", "high": + return effort + case "xhigh": + return "max" + default: + return "" + } +} diff --git a/backend/internal/service/openai_compat_model_test.go b/backend/internal/service/openai_compat_model_test.go new file mode 100644 index 00000000..32c646d4 --- /dev/null +++ b/backend/internal/service/openai_compat_model_test.go @@ -0,0 +1,129 @@ +package service + +import ( + "bytes" + "context" + "io" + "net/http" + "net/http/httptest" + "strings" + "testing" + + "github.com/Wei-Shaw/sub2api/internal/pkg/apicompat" + "github.com/gin-gonic/gin" + "github.com/stretchr/testify/require" + "github.com/tidwall/gjson" +) + +func TestNormalizeOpenAICompatRequestedModel(t *testing.T) { + t.Parallel() + + tests := []struct { + name string + input string + want string + }{ + {name: "gpt reasoning alias strips xhigh", input: "gpt-5.4-xhigh", want: "gpt-5.4"}, + {name: "gpt reasoning alias strips none", input: "gpt-5.4-none", want: "gpt-5.4"}, + {name: "codex max model stays intact", input: "gpt-5.1-codex-max", want: "gpt-5.1-codex-max"}, + {name: "non openai model unchanged", input: "claude-opus-4-6", want: "claude-opus-4-6"}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + require.Equal(t, tt.want, NormalizeOpenAICompatRequestedModel(tt.input)) + }) + } +} + +func TestApplyOpenAICompatModelNormalization(t *testing.T) { + t.Parallel() + + t.Run("derives xhigh from model suffix when output config missing", func(t *testing.T) { + req := &apicompat.AnthropicRequest{Model: "gpt-5.4-xhigh"} + + applyOpenAICompatModelNormalization(req) + + require.Equal(t, "gpt-5.4", req.Model) + require.NotNil(t, req.OutputConfig) + require.Equal(t, "max", req.OutputConfig.Effort) + }) + + t.Run("explicit output config wins over model suffix", func(t *testing.T) { + req := &apicompat.AnthropicRequest{ + Model: "gpt-5.4-xhigh", + OutputConfig: &apicompat.AnthropicOutputConfig{Effort: "low"}, + } + + applyOpenAICompatModelNormalization(req) + + require.Equal(t, "gpt-5.4", req.Model) + require.NotNil(t, req.OutputConfig) + require.Equal(t, "low", req.OutputConfig.Effort) + }) + + t.Run("non openai model is untouched", func(t *testing.T) { + req := &apicompat.AnthropicRequest{Model: "claude-opus-4-6"} + + applyOpenAICompatModelNormalization(req) + + require.Equal(t, "claude-opus-4-6", req.Model) + require.Nil(t, req.OutputConfig) + }) +} + +func TestForwardAsAnthropic_NormalizesRoutingAndEffortForGpt54XHigh(t *testing.T) { + t.Parallel() + gin.SetMode(gin.TestMode) + + rec := httptest.NewRecorder() + c, _ := gin.CreateTestContext(rec) + body := []byte(`{"model":"gpt-5.4-xhigh","max_tokens":16,"messages":[{"role":"user","content":"hello"}],"stream":false}`) + c.Request = httptest.NewRequest(http.MethodPost, "/v1/messages", bytes.NewReader(body)) + c.Request.Header.Set("Content-Type", "application/json") + + upstreamBody := strings.Join([]string{ + `data: {"type":"response.completed","response":{"id":"resp_1","object":"response","model":"gpt-5.4","status":"completed","output":[{"type":"message","id":"msg_1","role":"assistant","status":"completed","content":[{"type":"output_text","text":"ok"}]}],"usage":{"input_tokens":5,"output_tokens":2,"total_tokens":7}}}`, + "", + "data: [DONE]", + "", + }, "\n") + upstream := &httpUpstreamRecorder{resp: &http.Response{ + StatusCode: http.StatusOK, + Header: http.Header{"Content-Type": []string{"text/event-stream"}, "x-request-id": []string{"rid_compat"}}, + Body: io.NopCloser(strings.NewReader(upstreamBody)), + }} + + svc := &OpenAIGatewayService{httpUpstream: upstream} + account := &Account{ + ID: 1, + Name: "openai-oauth", + Platform: PlatformOpenAI, + Type: AccountTypeOAuth, + Concurrency: 1, + Credentials: map[string]any{ + "access_token": "oauth-token", + "chatgpt_account_id": "chatgpt-acc", + "model_mapping": map[string]any{ + "gpt-5.4": "gpt-5.4", + }, + }, + } + + result, err := svc.ForwardAsAnthropic(context.Background(), c, account, body, "", "gpt-5.1") + require.NoError(t, err) + require.NotNil(t, result) + require.Equal(t, "gpt-5.4-xhigh", result.Model) + require.Equal(t, "gpt-5.4", result.UpstreamModel) + require.Equal(t, "gpt-5.4", result.BillingModel) + require.NotNil(t, result.ReasoningEffort) + require.Equal(t, "xhigh", *result.ReasoningEffort) + + require.Equal(t, "gpt-5.4", gjson.GetBytes(upstream.lastBody, "model").String()) + require.Equal(t, "xhigh", gjson.GetBytes(upstream.lastBody, "reasoning.effort").String()) + require.Equal(t, http.StatusOK, rec.Code) + require.Equal(t, "gpt-5.4-xhigh", gjson.GetBytes(rec.Body.Bytes(), "model").String()) + require.Equal(t, "ok", gjson.GetBytes(rec.Body.Bytes(), "content.0.text").String()) + t.Logf("upstream body: %s", string(upstream.lastBody)) + t.Logf("response body: %s", rec.Body.String()) +} diff --git a/backend/internal/service/openai_gateway_messages.go b/backend/internal/service/openai_gateway_messages.go index 6a29823a..3df91b56 100644 --- a/backend/internal/service/openai_gateway_messages.go +++ b/backend/internal/service/openai_gateway_messages.go @@ -40,6 +40,7 @@ func (s *OpenAIGatewayService) ForwardAsAnthropic( return nil, fmt.Errorf("parse anthropic request: %w", err) } originalModel := anthropicReq.Model + applyOpenAICompatModelNormalization(&anthropicReq) clientStream := anthropicReq.Stream // client's original stream preference // 2. Convert Anthropic → Responses @@ -59,7 +60,7 @@ func (s *OpenAIGatewayService) ForwardAsAnthropic( } // 3. Model mapping - mappedModel := resolveOpenAIForwardModel(account, originalModel, defaultMappedModel) + mappedModel := resolveOpenAIForwardModel(account, anthropicReq.Model, defaultMappedModel) responsesReq.Model = mappedModel logger.L().Debug("openai messages: model mapping applied", diff --git a/backend/internal/service/openai_model_mapping.go b/backend/internal/service/openai_model_mapping.go index 9bf3fba3..a718f121 100644 --- a/backend/internal/service/openai_model_mapping.go +++ b/backend/internal/service/openai_model_mapping.go @@ -4,6 +4,7 @@ package service // forwarding. Group-level default mapping only applies when the account itself // did not match any explicit model_mapping rule. func resolveOpenAIForwardModel(account *Account, requestedModel, defaultMappedModel string) string { + requestedModel = NormalizeOpenAICompatRequestedModel(requestedModel) if account == nil { if defaultMappedModel != "" { return defaultMappedModel diff --git a/backend/internal/service/openai_model_mapping_test.go b/backend/internal/service/openai_model_mapping_test.go index edbb968b..6c67fd89 100644 --- a/backend/internal/service/openai_model_mapping_test.go +++ b/backend/internal/service/openai_model_mapping_test.go @@ -58,6 +58,19 @@ func TestResolveOpenAIForwardModel(t *testing.T) { defaultMappedModel: "gpt-4o-mini", expectedModel: "gpt-5.4", }, + { + name: "normalizes gpt reasoning alias before passthrough mapping", + account: &Account{ + Credentials: map[string]any{ + "model_mapping": map[string]any{ + "gpt-5.4": "gpt-5.4", + }, + }, + }, + requestedModel: "gpt-5.4-xhigh", + defaultMappedModel: "gpt-5.1", + expectedModel: "gpt-5.4", + }, } for _, tt := range tests {