diff --git a/backend/internal/service/openai_gateway_chat_completions_raw.go b/backend/internal/service/openai_gateway_chat_completions_raw.go index 2f46a1c76c..4e3cfb440e 100644 --- a/backend/internal/service/openai_gateway_chat_completions_raw.go +++ b/backend/internal/service/openai_gateway_chat_completions_raw.go @@ -78,7 +78,7 @@ func (s *OpenAIGatewayService) forwardAsRawChatCompletions( // 2. Resolve model mapping (same as ForwardAsChatCompletions) billingModel := resolveOpenAIForwardModel(account, originalModel, defaultMappedModel) upstreamModel := normalizeOpenAIModelForUpstream(account, billingModel) - reasoningEffort := extractOpenAIReasoningEffortFromBody(body, firstNonEmpty(upstreamModel, billingModel, originalModel)) + reasoningEffort := extractOpenAIReasoningEffortFromBody(body, upstreamModel, billingModel, originalModel) // 国产模型默认 effort 补充:需要 mappedModel 判定,推迟到 billingModel 算出之后。 reasoningEffort = ApplyThinkingEnabledFallback(reasoningEffort, body, billingModel) diff --git a/backend/internal/service/openai_gateway_forward.go b/backend/internal/service/openai_gateway_forward.go index 96f33c20f4..974f8eefaa 100644 --- a/backend/internal/service/openai_gateway_forward.go +++ b/backend/internal/service/openai_gateway_forward.go @@ -755,7 +755,7 @@ func (s *OpenAIGatewayService) Forward(ctx context.Context, c *gin.Context, acco } defer func() { _ = resp.Body.Close() }() - reasoningEffort := extractOpenAIReasoningEffortFromBody(body, firstNonEmpty(upstreamModel, billingModel, originalModel)) + reasoningEffort := extractOpenAIReasoningEffortFromBody(body, upstreamModel, billingModel, originalModel) // 国产模型默认 effort 补充:此处 reqModel 已被 mapping 重写为 billingModel(见 // line 2510-2515 的 GetMappedModel + reqModel 赋值),可直接作为 mappedModel。 reasoningEffort = ApplyThinkingEnabledFallback(reasoningEffort, body, reqModel) diff --git a/backend/internal/service/openai_gateway_messages_chat_fallback.go b/backend/internal/service/openai_gateway_messages_chat_fallback.go index 97eecf014b..f66fbc9ada 100644 --- a/backend/internal/service/openai_gateway_messages_chat_fallback.go +++ b/backend/internal/service/openai_gateway_messages_chat_fallback.go @@ -72,7 +72,7 @@ func (s *OpenAIGatewayService) forwardAnthropicViaRawChatCompletions( chatReq.StreamOptions = &apicompat.ChatStreamOptions{IncludeUsage: true} } - reasoningEffort := extractOpenAIReasoningEffortFromBody(body, firstNonEmpty(upstreamModel, billingModel, originalModel)) + reasoningEffort := extractOpenAIReasoningEffortFromBody(body, upstreamModel, billingModel, originalModel) reasoningEffort = ApplyThinkingEnabledFallback(reasoningEffort, body, billingModel) serviceTier := extractOpenAIServiceTierFromBody(body) diff --git a/backend/internal/service/openai_gateway_request_body.go b/backend/internal/service/openai_gateway_request_body.go index 606a0d716e..f1e353b2af 100644 --- a/backend/internal/service/openai_gateway_request_body.go +++ b/backend/internal/service/openai_gateway_request_body.go @@ -331,6 +331,17 @@ func deriveOpenAIReasoningEffortFromModel(model string) string { return normalizeOpenAIReasoningEffortForModel(parts[len(parts)-1], modelID) } +// deriveOpenAIReasoningEffortFromModelCandidates 依次对每个候选模型做后缀推导, +// 返回第一个非空结果。 +func deriveOpenAIReasoningEffortFromModelCandidates(models []string) string { + for _, model := range models { + if value := deriveOpenAIReasoningEffortFromModel(model); value != "" { + return value + } + } + return "" +} + type openAIRequestView struct { body []byte Model string @@ -571,20 +582,24 @@ func detectOpenAIPassthroughInstructionsRejectReason(reqModel string, body []byt return "" } -func extractOpenAIReasoningEffortFromBody(body []byte, requestedModel string) *string { +// extractOpenAIReasoningEffortFromBody 按优先级传入模型候选(如 upstreamModel, +// billingModel, originalModel):显式 effort 的模型归一化(max 保留判定)用第一个 +// 非空候选;body 未携带 effort 时的模型后缀推导依次尝试每个候选——OAuth 的 +// normalizeCodexModel 会剥掉 upstreamModel 的 effort 后缀,只有原始模型名还留着。 +func extractOpenAIReasoningEffortFromBody(body []byte, modelCandidates ...string) *string { reasoningEffort := strings.TrimSpace(gjson.GetBytes(body, "reasoning.effort").String()) if reasoningEffort == "" { reasoningEffort = strings.TrimSpace(gjson.GetBytes(body, "reasoning_effort").String()) } if reasoningEffort != "" { - normalized := normalizeOpenAIReasoningEffortForModel(reasoningEffort, requestedModel) + normalized := normalizeOpenAIReasoningEffortForModel(reasoningEffort, firstNonEmpty(modelCandidates...)) if normalized == "" { return nil } return &normalized } - value := deriveOpenAIReasoningEffortFromModel(requestedModel) + value := deriveOpenAIReasoningEffortFromModelCandidates(modelCandidates) if value == "" { return nil } @@ -1159,15 +1174,16 @@ func getOpenAIRequestBodyMap(_ *gin.Context, body []byte) (map[string]any, error return reqBody, nil } -func extractOpenAIReasoningEffort(reqBody map[string]any, requestedModel string) *string { - if value, present := getOpenAIReasoningEffortFromReqBody(reqBody, requestedModel); present { +// extractOpenAIReasoningEffort 的模型候选语义同 extractOpenAIReasoningEffortFromBody。 +func extractOpenAIReasoningEffort(reqBody map[string]any, modelCandidates ...string) *string { + if value, present := getOpenAIReasoningEffortFromReqBody(reqBody, firstNonEmpty(modelCandidates...)); present { if value == "" { return nil } return &value } - value := deriveOpenAIReasoningEffortFromModel(requestedModel) + value := deriveOpenAIReasoningEffortFromModelCandidates(modelCandidates) if value == "" { return nil } diff --git a/backend/internal/service/openai_gateway_responses_chat_fallback.go b/backend/internal/service/openai_gateway_responses_chat_fallback.go index 9f2cb84864..247c195f24 100644 --- a/backend/internal/service/openai_gateway_responses_chat_fallback.go +++ b/backend/internal/service/openai_gateway_responses_chat_fallback.go @@ -48,7 +48,7 @@ func (s *OpenAIGatewayService) forwardResponsesViaRawChatCompletions( billingModel := resolveOpenAIForwardModel(account, originalModel, "") upstreamModel := normalizeOpenAIModelForUpstream(account, billingModel) - reasoningEffort := extractOpenAIReasoningEffortFromBody(body, firstNonEmpty(upstreamModel, billingModel, originalModel)) + reasoningEffort := extractOpenAIReasoningEffortFromBody(body, upstreamModel, billingModel, originalModel) // 国产模型默认 effort 补充:需要 mappedModel 判定,推迟到 billingModel 算出之后。 reasoningEffort = ApplyThinkingEnabledFallback(reasoningEffort, body, billingModel) chatReq.Model = upstreamModel diff --git a/backend/internal/service/openai_reasoning_effort_candidates_test.go b/backend/internal/service/openai_reasoning_effort_candidates_test.go new file mode 100644 index 0000000000..66a3e16dfb --- /dev/null +++ b/backend/internal/service/openai_reasoning_effort_candidates_test.go @@ -0,0 +1,122 @@ +package service + +import ( + "context" + "io" + "net/http" + "net/http/httptest" + "strings" + "testing" + + "github.com/Wei-Shaw/sub2api/internal/config" + "github.com/gin-gonic/gin" + "github.com/stretchr/testify/require" + "github.com/tidwall/gjson" +) + +func TestExtractOpenAIReasoningEffortFromBodyModelCandidates(t *testing.T) { + bodyWithoutEffort := []byte(`{"model":"whatever","input":"hello"}`) + bodyWithMax := []byte(`{"model":"sol","reasoning":{"effort":"max"},"input":"hello"}`) + + tests := []struct { + name string + body []byte + candidates []string + want string // "" 表示期望 nil + }{ + { + name: "后缀推导回退到原始模型(OAuth 上游模型已剥后缀)", + body: bodyWithoutEffort, + candidates: []string{"gpt-5.4", "gpt-5.4", "gpt-5.4-xhigh"}, + want: "xhigh", + }, + { + name: "GPT-5.6 后缀 max 经原始模型推导保留", + body: bodyWithoutEffort, + candidates: []string{"gpt-5.6-sol", "gpt-5.6-sol", "gpt-5.6-sol-max"}, + want: "max", + }, + { + name: "显式 max 用第一个非空候选(映射后模型)判定", + body: bodyWithMax, + candidates: []string{"gpt-5.6-sol", "sol"}, + want: "max", + }, + { + name: "显式 max 非 5.6 首候选仍折叠为 xhigh", + body: bodyWithMax, + candidates: []string{"gpt-5.4", "sol"}, + want: "xhigh", + }, + { + name: "所有候选均无后缀时返回 nil", + body: bodyWithoutEffort, + candidates: []string{"gpt-5.4", "gpt-5.4", "gpt-5.4"}, + want: "", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got := extractOpenAIReasoningEffortFromBody(tt.body, tt.candidates...) + if tt.want == "" { + require.Nil(t, got) + return + } + require.NotNil(t, got) + require.Equal(t, tt.want, *got) + }) + } +} + +func TestExtractOpenAIReasoningEffortModelCandidates(t *testing.T) { + reqBody := map[string]any{"model": "gpt-5.3-codex-high", "input": "hello"} + + got := extractOpenAIReasoningEffort(reqBody, "gpt-5.3-codex", "gpt-5.3-codex-high") + + require.NotNil(t, got) + require.Equal(t, "high", *got) +} + +// 回归:OAuth 账号请求后缀式模型(无显式 reasoning 字段)时,上游模型被 +// normalizeCodexModel 剥掉 effort 后缀,用量元数据的 effort 必须仍能从 +// 原始模型名后缀推导出来。 +func TestOpenAIGatewayServiceForwardOAuthDerivesEffortFromSuffixModel(t *testing.T) { + gin.SetMode(gin.TestMode) + upstream := &httpUpstreamRecorder{ + resp: &http.Response{ + StatusCode: http.StatusOK, + Header: http.Header{"Content-Type": []string{"application/json"}}, + Body: io.NopCloser(strings.NewReader(`{"usage":{"input_tokens":1,"output_tokens":2}}`)), + }, + } + cfg := &config.Config{} + cfg.Security.URLAllowlist.Enabled = false + svc := &OpenAIGatewayService{cfg: cfg, httpUpstream: upstream} + account := &Account{ + ID: 11, + Name: "openai-oauth-suffix", + Platform: PlatformOpenAI, + Type: AccountTypeOAuth, + Concurrency: 1, + Credentials: map[string]any{ + "access_token": "oauth-token", + "chatgpt_account_id": "chatgpt-acc", + }, + Status: StatusActive, + Schedulable: true, + } + rec := httptest.NewRecorder() + c, _ := gin.CreateTestContext(rec) + c.Request = httptest.NewRequest(http.MethodPost, "/openai/v1/responses", nil) + SetOpenAIClientTransport(c, OpenAIClientTransportHTTP) + + body := []byte(`{"model":"gpt-5.3-codex-xhigh","instructions":"suffix-test","input":"hello","stream":false}`) + result, err := svc.Forward(context.Background(), c, account, body) + + require.NoError(t, err) + require.NotNil(t, result) + require.Equal(t, "gpt-5.3-codex", gjson.GetBytes(upstream.lastBody, "model").String()) + require.NotNil(t, result.ReasoningEffort) + require.Equal(t, "xhigh", *result.ReasoningEffort) +} diff --git a/backend/internal/service/openai_ws_forwarder_ingress.go b/backend/internal/service/openai_ws_forwarder_ingress.go index 9dea4118da..4a5fa20774 100644 --- a/backend/internal/service/openai_ws_forwarder_ingress.go +++ b/backend/internal/service/openai_ws_forwarder_ingress.go @@ -920,7 +920,7 @@ func (s *OpenAIGatewayService) ProxyResponsesWebSocketFromClient( Model: originalModel, UpstreamModel: mappedModel, ServiceTier: extractOpenAIServiceTierFromBody(payload), - ReasoningEffort: ApplyThinkingEnabledFallback(extractOpenAIReasoningEffortFromBody(payload, firstNonEmpty(mappedModel, originalModel)), payload, mappedModel), + ReasoningEffort: ApplyThinkingEnabledFallback(extractOpenAIReasoningEffortFromBody(payload, mappedModel, originalModel), payload, mappedModel), Stream: reqStream, OpenAIWSMode: true, ResponseHeaders: lease.HandshakeHeaders(), diff --git a/backend/internal/service/openai_ws_forwarder_v2.go b/backend/internal/service/openai_ws_forwarder_v2.go index 40a3cc0194..24853d4a6c 100644 --- a/backend/internal/service/openai_ws_forwarder_v2.go +++ b/backend/internal/service/openai_ws_forwarder_v2.go @@ -693,7 +693,7 @@ func (s *OpenAIGatewayService) forwardOpenAIWSV2( ImageCount: imageCounter.Count(), ImageOutputSizes: imageCounter.Sizes(), ServiceTier: extractOpenAIServiceTier(reqBody), - ReasoningEffort: extractOpenAIReasoningEffort(reqBody, firstNonEmpty(mappedModel, originalModel)), + ReasoningEffort: extractOpenAIReasoningEffort(reqBody, mappedModel, originalModel), Stream: reqStream, OpenAIWSMode: true, ResponseHeaders: lease.HandshakeHeaders(), diff --git a/backend/internal/service/openai_ws_http_bridge.go b/backend/internal/service/openai_ws_http_bridge.go index df1a8067cb..dce0c7b6db 100644 --- a/backend/internal/service/openai_ws_http_bridge.go +++ b/backend/internal/service/openai_ws_http_bridge.go @@ -263,7 +263,7 @@ func (s *OpenAIGatewayService) proxyOpenAIWSHTTPBridgeTurn( Model: originalModel, UpstreamModel: mappedModel, ServiceTier: extractOpenAIServiceTierFromBody(body), - ReasoningEffort: ApplyThinkingEnabledFallback(extractOpenAIReasoningEffortFromBody(body, firstNonEmpty(mappedModel, originalModel)), body, mappedModel), + ReasoningEffort: ApplyThinkingEnabledFallback(extractOpenAIReasoningEffortFromBody(body, mappedModel, originalModel), body, mappedModel), Stream: reqStream, OpenAIWSMode: true, ResponseHeaders: cloneHeader(resp.Header),