Merge pull request #3473 from DaydreamCoding/fix/gateway-openai-codex-spark-strip-image-tool

fix(gateway-openai): codex spark 剥离 image_generation 工具,修复 502
This commit is contained in:
Wesley Liddick
2026-06-25 17:43:24 +08:00
committed by GitHub
6 changed files with 209 additions and 0 deletions
@@ -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
@@ -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",
@@ -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 {
@@ -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" + ` "}]}]}`)
@@ -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)
@@ -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()