mirror of
https://github.com/Wei-Shaw/sub2api.git
synced 2026-09-24 16:05:44 +08:00
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:
@@ -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()
|
||||
|
||||
|
||||
Reference in New Issue
Block a user