From 011278204928e6704076d339added6e1cbb6c86c Mon Sep 17 00:00:00 2001 From: DaydreamCoding <22166516+DaydreamCoding@users.noreply.github.com> Date: Thu, 25 Jun 2026 17:04:02 +0800 Subject: [PATCH] =?UTF-8?q?fix(gateway-openai):=20codex=20spark=20?= =?UTF-8?q?=E5=89=A5=E7=A6=BB=20image=5Fgeneration=20=E5=B7=A5=E5=85=B7?= =?UTF-8?q?=EF=BC=8C=E4=BF=AE=E5=A4=8D=20502?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit gpt-5.3-codex-spark 为 text-only,不支持 image_generation 工具;codex CLI 默认 在 /responses 的 tools[] 携带该工具,导致 ChatGPT 后端返回 400 invalid_request_error(param=tools),被 mapUpstreamError 兜底成 502 "Upstream request failed"(真实 400 见 ops_error_logs.upstream_errors)。 新增 stripCodexSparkImageGenerationTools,对 spark 剥离客户端携带的 image_generation 工具(tools 清空则删键),接入三条路径: - applyCodexOAuthTransformWithOptions:OAuth 全入口(responses/messages/chat-completions) - buildUpstreamRequestOpenAIPassthrough 无条件 spark 块:覆盖 OAuth+APIKey /responses,不受 image-generation 开关影响 - WS stripCodexSparkImageGenerationToolFromRawPayload:覆盖 WebSocket /responses bridge 注入(ensureOpenAIResponsesImageGenerationTool)本就跳过 spark;真正的缺口 在 normalizeOpenAIResponsesImageGenerationTools 会规范化并保留客户端工具。 临时绕过=换非 spark 模型;关 bridge/allow_image_generation 无效(工具是客户端带的)。 Co-Authored-By: Claude Opus 4.8 (1M context) --- .../service/openai_codex_transform.go | 40 +++++++++++++ .../service/openai_codex_transform_test.go | 59 +++++++++++++++++++ .../service/openai_gateway_service.go | 13 ++++ .../openai_gateway_service_hotpath_test.go | 41 +++++++++++++ .../internal/service/openai_ws_forwarder.go | 29 +++++++++ .../openai_ws_forwarder_ingress_test.go | 27 +++++++++ 6 files changed, 209 insertions(+) diff --git a/backend/internal/service/openai_codex_transform.go b/backend/internal/service/openai_codex_transform.go index c75cfa13ee..a6aa85192f 100644 --- a/backend/internal/service/openai_codex_transform.go +++ b/backend/internal/service/openai_codex_transform.go @@ -224,6 +224,11 @@ func applyCodexOAuthTransformWithOptions(reqBody map[string]any, opts codexOAuth if isCodexSparkModel(normalizedModel) && applyCodexSparkImageUnsupportedInstructions(reqBody) { result.Modified = true } + // gpt-5.3-codex-spark rejects the image_generation tool upstream (HTTP 400, + // param=tools); Codex CLI advertises it by default, so strip it for spark. + if isCodexSparkModel(normalizedModel) && stripCodexSparkImageGenerationTools(reqBody) { + result.Modified = true + } // 续链场景保留 item_reference 与 id,避免 call_id 上下文丢失。 if input, ok := reqBody["input"].([]any); ok { @@ -602,6 +607,41 @@ func hasOpenAIImageGenerationTool(reqBody map[string]any) bool { return false } +// stripCodexSparkImageGenerationTools removes image_generation tool entries from +// reqBody["tools"]. gpt-5.3-codex-spark rejects that tool upstream with HTTP 400 +// (invalid_request_error, param=tools), and Codex CLI advertises it by default, so +// it must be dropped for spark. When the tools list becomes empty the key is removed. +// Returns true when the body was modified. +func stripCodexSparkImageGenerationTools(reqBody map[string]any) bool { + rawTools, ok := reqBody["tools"] + if !ok || rawTools == nil { + return false + } + tools, ok := rawTools.([]any) + if !ok { + return false + } + filtered := make([]any, 0, len(tools)) + removed := false + for _, rawTool := range tools { + if toolMap, ok := rawTool.(map[string]any); ok && + strings.TrimSpace(firstNonEmptyString(toolMap["type"])) == "image_generation" { + removed = true + continue + } + filtered = append(filtered, rawTool) + } + if !removed { + return false + } + if len(filtered) == 0 { + delete(reqBody, "tools") + } else { + reqBody["tools"] = filtered + } + return true +} + func hasOpenAIInputImage(reqBody map[string]any) bool { if reqBody == nil { return false diff --git a/backend/internal/service/openai_codex_transform_test.go b/backend/internal/service/openai_codex_transform_test.go index 4c182b8eed..bbe9b1f187 100644 --- a/backend/internal/service/openai_codex_transform_test.go +++ b/backend/internal/service/openai_codex_transform_test.go @@ -751,6 +751,65 @@ func TestApplyCodexOAuthTransform_DoesNotAddSparkImageUnsupportedForNonSpark(t * require.NotContains(t, instructions, codexSparkImageUnsupportedMarker) } +// gpt-5.3-codex-spark rejects the image_generation tool upstream (HTTP 400 +// invalid_request_error, param=tools). Codex CLI advertises that tool by default, +// so the OAuth transform must strip it for spark while keeping the rest. +func TestApplyCodexOAuthTransform_StripsImageGenerationToolForSpark(t *testing.T) { + reqBody := map[string]any{ + "model": "gpt-5.3-codex-spark", + "input": "hello", + "tools": []any{ + map[string]any{"type": "function", "name": "shell"}, + map[string]any{"type": "image_generation", "output_format": "png"}, + }, + } + + result := applyCodexOAuthTransform(reqBody, true, false) + require.True(t, result.Modified) + require.False(t, hasOpenAIImageGenerationTool(reqBody)) + + tools, ok := reqBody["tools"].([]any) + require.True(t, ok) + require.Len(t, tools, 1) + first, ok := tools[0].(map[string]any) + require.True(t, ok) + require.Equal(t, "function", first["type"]) + require.Equal(t, "shell", first["name"]) +} + +// Spark reasoning-effort aliases (e.g. -low/-high) normalize to gpt-5.3-codex-spark, +// so they must be stripped too. +func TestApplyCodexOAuthTransform_StripsImageGenerationToolForSparkAlias(t *testing.T) { + reqBody := map[string]any{ + "model": "gpt-5.3-codex-spark-high", + "input": "hello", + "tools": []any{ + map[string]any{"type": "image_generation", "output_format": "png"}, + }, + } + + result := applyCodexOAuthTransform(reqBody, true, false) + require.True(t, result.Modified) + require.False(t, hasOpenAIImageGenerationTool(reqBody)) + // tools became empty after stripping the only entry; the key is dropped. + _, hasTools := reqBody["tools"] + require.False(t, hasTools) +} + +// Non-spark Codex models support image_generation; the tool must be preserved. +func TestApplyCodexOAuthTransform_KeepsImageGenerationToolForNonSpark(t *testing.T) { + reqBody := map[string]any{ + "model": "gpt-5.3-codex", + "input": "hello", + "tools": []any{ + map[string]any{"type": "image_generation", "output_format": "png"}, + }, + } + + applyCodexOAuthTransform(reqBody, true, false) + require.True(t, hasOpenAIImageGenerationTool(reqBody)) +} + func TestNormalizeOpenAIResponsesImageOnlyModel_BuildsImageToolRequest(t *testing.T) { reqBody := map[string]any{ "model": "gpt-image-2", diff --git a/backend/internal/service/openai_gateway_service.go b/backend/internal/service/openai_gateway_service.go index 2bed9e0394..4ba126e9a3 100644 --- a/backend/internal/service/openai_gateway_service.go +++ b/backend/internal/service/openai_gateway_service.go @@ -2602,6 +2602,19 @@ func (s *OpenAIGatewayService) Forward(ctx context.Context, c *gin.Context, acco } } + // gpt-5.3-codex-spark also rejects the image_generation tool (HTTP 400, + // param=tools). Strip it here so both APIKey and OAuth /responses paths are + // covered regardless of the image-generation feature gate. + if isCodexSparkModel(upstreamModel) && openAIRequestBodyHasImageGenerationTool(body) { + decoded, decodeErr := ensureReqBody() + if decodeErr != nil { + return nil, decodeErr + } + if stripCodexSparkImageGenerationTools(decoded) { + markDecodedModified() + } + } + if account.Type == AccountTypeOAuth { decoded, decodeErr := ensureReqBody() if decodeErr != nil { diff --git a/backend/internal/service/openai_gateway_service_hotpath_test.go b/backend/internal/service/openai_gateway_service_hotpath_test.go index 56085b4b65..aee69fffa9 100644 --- a/backend/internal/service/openai_gateway_service_hotpath_test.go +++ b/backend/internal/service/openai_gateway_service_hotpath_test.go @@ -477,6 +477,47 @@ func TestOpenAIGatewayService_Forward_HTTPDeletesPreviousResponseIDWhenPresent(t } } +func TestOpenAIGatewayService_Forward_StripsImageGenerationToolForSparkAPIKey(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-apikey", + Platform: PlatformOpenAI, + Type: AccountTypeAPIKey, + Concurrency: 1, + Credentials: map[string]any{ + "api_key": "sk-test", + "base_url": "https://example.com", + }, + Extra: map[string]any{"use_responses_api": true}, + } + rec := httptest.NewRecorder() + c, _ := gin.CreateTestContext(rec) + c.Request = httptest.NewRequest(http.MethodPost, "/openai/v1/responses", nil) + // Allow image generation so the tool is normalized (not gated out), reproducing + // the leak the strip must override. + c.Set("api_key", &APIKey{Group: &Group{AllowImageGeneration: true}}) + SetOpenAIClientTransport(c, OpenAIClientTransportHTTP) + + body := []byte(`{"model":"gpt-5.3-codex-spark","stream":false,"input":"hi","tools":[{"type":"function","name":"shell"},{"type":"image_generation","output_format":"png"}]}`) + result, err := svc.Forward(context.Background(), c, account, body) + require.NoError(t, err) + require.NotNil(t, result) + require.NotNil(t, upstream.lastReq) + require.False(t, gjson.GetBytes(upstream.lastBody, `tools.#(type=="image_generation")`).Exists()) + require.True(t, gjson.GetBytes(upstream.lastBody, `tools.#(type=="function")`).Exists()) +} + func TestOpenAIRequestBodyMayContainEmptyBase64InputImageSeesEscapedJSON(t *testing.T) { body := []byte(`{"input":[{"type":"message","content":[{"type":"input_image","image_` + "\\u0075" + `rl":"data:image/png;base64` + "\\u002c" + ` "}]}]}`) diff --git a/backend/internal/service/openai_ws_forwarder.go b/backend/internal/service/openai_ws_forwarder.go index b8c5903507..3574d2d660 100644 --- a/backend/internal/service/openai_ws_forwarder.go +++ b/backend/internal/service/openai_ws_forwarder.go @@ -2435,6 +2435,29 @@ func (s *OpenAIGatewayService) forwardOpenAIWSV2( // ProxyResponsesWebSocketFromClient 处理客户端入站 WebSocket(OpenAI Responses WS Mode)并转发到上游。 // 当前实现按“单请求 -> 终止事件 -> 下一请求”的顺序代理,适配 Codex CLI 的 turn 模式。 +// stripCodexSparkImageGenerationToolFromRawPayload removes the image_generation +// tool from a raw /responses payload when the upstream model is gpt-5.3-codex-spark. +// Spark rejects that tool upstream with HTTP 400 (invalid_request_error, param=tools); +// Codex clients advertise it by default. Returns the (possibly unchanged) payload, +// whether it changed, and any JSON decode error. +func stripCodexSparkImageGenerationToolFromRawPayload(payload []byte, model string) ([]byte, bool, error) { + if !isCodexSparkModel(model) || !openAIRequestBodyHasImageGenerationTool(payload) { + return payload, false, nil + } + payloadMap := make(map[string]any) + if err := json.Unmarshal(payload, &payloadMap); err != nil { + return payload, false, err + } + if !stripCodexSparkImageGenerationTools(payloadMap) { + return payload, false, nil + } + rebuilt, err := json.Marshal(payloadMap) + if err != nil { + return payload, false, err + } + return rebuilt, true, nil +} + func (s *OpenAIGatewayService) ProxyResponsesWebSocketFromClient( ctx context.Context, c *gin.Context, @@ -2668,6 +2691,12 @@ func (s *OpenAIGatewayService) ProxyResponsesWebSocketFromClient( } normalized = next } + if stripped, changed, stripErr := stripCodexSparkImageGenerationToolFromRawPayload(normalized, upstreamModel); stripErr != nil { + return openAIWSClientPayload{}, NewOpenAIWSClientCloseError(coderws.StatusPolicyViolation, "invalid websocket request payload", stripErr) + } else if changed { + normalized = stripped + logOpenAIWSModeInfo("ingress_ws_codex_spark_image_tool_stripped account_id=%d", account.ID) + } imageIntent := IsImageGenerationIntent(openAIResponsesEndpoint, originalModel, normalized) if imageIntent && !imageGenerationAllowed { return openAIWSClientPayload{}, NewOpenAIWSClientCloseError(coderws.StatusPolicyViolation, ImageGenerationPermissionMessage(), nil) diff --git a/backend/internal/service/openai_ws_forwarder_ingress_test.go b/backend/internal/service/openai_ws_forwarder_ingress_test.go index 31c9a1420c..0d19a189b0 100644 --- a/backend/internal/service/openai_ws_forwarder_ingress_test.go +++ b/backend/internal/service/openai_ws_forwarder_ingress_test.go @@ -142,6 +142,33 @@ func TestDropPreviousResponseIDFromRawPayload(t *testing.T) { }) } +func TestStripCodexSparkImageGenerationToolFromRawPayload(t *testing.T) { + t.Run("strips_image_generation_for_spark", func(t *testing.T) { + payload := []byte(`{"type":"response.create","model":"gpt-5.3-codex-spark","tools":[{"type":"function","name":"shell"},{"type":"image_generation","output_format":"png"}]}`) + updated, changed, err := stripCodexSparkImageGenerationToolFromRawPayload(payload, "gpt-5.3-codex-spark") + require.NoError(t, err) + require.True(t, changed) + require.False(t, gjson.GetBytes(updated, `tools.#(type=="image_generation")`).Exists()) + require.True(t, gjson.GetBytes(updated, `tools.#(type=="function")`).Exists()) + }) + + t.Run("keeps_image_generation_for_non_spark", func(t *testing.T) { + payload := []byte(`{"type":"response.create","model":"gpt-5.3-codex","tools":[{"type":"image_generation","output_format":"png"}]}`) + updated, changed, err := stripCodexSparkImageGenerationToolFromRawPayload(payload, "gpt-5.3-codex") + require.NoError(t, err) + require.False(t, changed) + require.Equal(t, string(payload), string(updated)) + }) + + t.Run("noop_when_no_image_tool", func(t *testing.T) { + payload := []byte(`{"type":"response.create","model":"gpt-5.3-codex-spark","tools":[{"type":"function","name":"shell"}]}`) + updated, changed, err := stripCodexSparkImageGenerationToolFromRawPayload(payload, "gpt-5.3-codex-spark") + require.NoError(t, err) + require.False(t, changed) + require.Equal(t, string(payload), string(updated)) + }) +} + func TestAlignStoreDisabledPreviousResponseID(t *testing.T) { t.Parallel()