From 631f7a652c350d5481a61181566cdacf68839302 Mon Sep 17 00:00:00 2001 From: Supra4E8C Date: Fri, 10 Jul 2026 18:54:31 +0800 Subject: [PATCH] fix(executor): enhance image generation tool handling for responses-lite requests --- internal/runtime/executor/codex_executor.go | 25 +++- .../executor/codex_executor_imagegen_test.go | 108 ++++++++++++++++-- .../executor/codex_websockets_executor.go | 4 +- .../codex_websockets_executor_test.go | 58 ++++++++++ 4 files changed, 179 insertions(+), 16 deletions(-) diff --git a/internal/runtime/executor/codex_executor.go b/internal/runtime/executor/codex_executor.go index 6f5854a3..3f0e6eb0 100644 --- a/internal/runtime/executor/codex_executor.go +++ b/internal/runtime/executor/codex_executor.go @@ -38,6 +38,8 @@ const ( codexUserAgent = "codex-tui/0.135.0 (Mac OS 26.5.0; arm64) iTerm.app/3.6.10 (codex-tui; 0.135.0)" codexOriginator = "codex-tui" codexDefaultImageToolModel = "gpt-image-2" + codexResponsesLiteHeader = "X-OpenAI-Internal-Codex-Responses-Lite" + codexResponsesLiteMetadata = "client_metadata.ws_request_header_x_openai_internal_codex_responses_lite" ) var dataTag = []byte("data:") @@ -782,7 +784,7 @@ func (e *CodexExecutor) Execute(ctx context.Context, auth *cliproxyauth.Auth, re body, _ = sjson.DeleteBytes(body, "stream_options") body = normalizeCodexInstructions(body) if e.cfg == nil || e.cfg.DisableImageGeneration == config.DisableImageGenerationOff { - body = ensureImageGenerationTool(body, baseModel, auth) + body = ensureImageGenerationTool(body, baseModel, auth, opts.Headers) } body = sanitizeOpenAIResponsesReasoningEncryptedContent(ctx, "codex executor", body) body = normalizeCodexParallelToolCallsForTools(body) @@ -959,7 +961,7 @@ func (e *CodexExecutor) executeCompact(ctx context.Context, auth *cliproxyauth.A body, _ = sjson.DeleteBytes(body, "stream") body = normalizeCodexInstructions(body) if e.cfg == nil || e.cfg.DisableImageGeneration == config.DisableImageGenerationOff { - body = ensureImageGenerationTool(body, baseModel, auth) + body = ensureImageGenerationTool(body, baseModel, auth, opts.Headers) } body = sanitizeOpenAIResponsesReasoningEncryptedContent(ctx, "codex executor", body) body = normalizeCodexParallelToolCallsForTools(body) @@ -1070,7 +1072,7 @@ func (e *CodexExecutor) ExecuteStream(ctx context.Context, auth *cliproxyauth.Au body, _ = sjson.SetBytes(body, "model", baseModel) body = normalizeCodexInstructions(body) if e.cfg == nil || e.cfg.DisableImageGeneration == config.DisableImageGenerationOff { - body = ensureImageGenerationTool(body, baseModel, auth) + body = ensureImageGenerationTool(body, baseModel, auth, opts.Headers) } body = sanitizeOpenAIResponsesReasoningEncryptedContent(ctx, "codex executor", body) body = normalizeCodexParallelToolCallsForTools(body) @@ -1767,7 +1769,22 @@ func isImageGenerationFunctionTool(tool gjson.Result) bool { return false } -func ensureImageGenerationTool(body []byte, baseModel string, auth *cliproxyauth.Auth) []byte { +func isCodexResponsesLiteRequest(body []byte, headers http.Header) bool { + if strings.EqualFold(strings.TrimSpace(headers.Get(codexResponsesLiteHeader)), "true") { + return true + } + // Codex Desktop mirrors websocket-only request headers into client_metadata. + value := gjson.GetBytes(body, codexResponsesLiteMetadata) + if !value.Exists() { + return false + } + return value.Type == gjson.True || value.Type == gjson.String && strings.EqualFold(strings.TrimSpace(value.String()), "true") +} + +func ensureImageGenerationTool(body []byte, baseModel string, auth *cliproxyauth.Auth, headers http.Header) []byte { + if isCodexResponsesLiteRequest(body, headers) { + return body + } if strings.HasSuffix(baseModel, "spark") { return body } diff --git a/internal/runtime/executor/codex_executor_imagegen_test.go b/internal/runtime/executor/codex_executor_imagegen_test.go index b85192c4..dfb50584 100644 --- a/internal/runtime/executor/codex_executor_imagegen_test.go +++ b/internal/runtime/executor/codex_executor_imagegen_test.go @@ -1,15 +1,103 @@ package executor import ( + "context" + "io" + "net/http" + "net/http/httptest" "testing" + "github.com/router-for-me/CLIProxyAPI/v7/internal/config" cliproxyauth "github.com/router-for-me/CLIProxyAPI/v7/sdk/cliproxy/auth" + cliproxyexecutor "github.com/router-for-me/CLIProxyAPI/v7/sdk/cliproxy/executor" + sdktranslator "github.com/router-for-me/CLIProxyAPI/v7/sdk/translator" "github.com/tidwall/gjson" ) +func TestCodexExecutorExecuteResponsesLiteHeaderDoesNotInjectImageGenerationTool(t *testing.T) { + var gotBody []byte + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + body, errRead := io.ReadAll(r.Body) + if errRead != nil { + t.Fatalf("read request body: %v", errRead) + } + gotBody = body + w.Header().Set("Content-Type", "text/event-stream") + _, _ = w.Write([]byte("data: {\"type\":\"response.completed\",\"response\":{\"id\":\"resp_1\",\"object\":\"response\",\"status\":\"completed\",\"output\":[],\"usage\":{\"input_tokens\":0,\"output_tokens\":0,\"total_tokens\":0}}}\n\n")) + })) + defer server.Close() + + executor := NewCodexExecutor(&config.Config{}) + auth := &cliproxyauth.Auth{ + Provider: "codex", + Attributes: map[string]string{ + "api_key": "test", + "base_url": server.URL, + "plan_type": "pro", + }, + } + headers := make(http.Header) + headers.Set("X-OpenAI-Internal-Codex-Responses-Lite", "true") + + _, err := executor.Execute(context.Background(), auth, cliproxyexecutor.Request{ + Model: "gpt-5.6-sol", + Payload: []byte(`{"model":"gpt-5.6-sol","input":"hello"}`), + }, cliproxyexecutor.Options{ + SourceFormat: sdktranslator.FromString("openai-response"), + Headers: headers, + }) + if err != nil { + t.Fatalf("Execute() error = %v", err) + } + if tools := gjson.GetBytes(gotBody, "tools"); tools.Exists() { + t.Fatalf("unexpected tools in responses-lite upstream payload: %s", tools.Raw) + } +} + +func TestEnsureImageGenerationTool_ResponsesLiteMetadataDoesNotInjectTool(t *testing.T) { + body := []byte(`{"model":"gpt-5.6-sol","client_metadata":{"ws_request_header_x_openai_internal_codex_responses_lite":"true"},"input":[{"role":"user","content":"hello"}]}`) + result := ensureImageGenerationTool(body, "gpt-5.6-sol", nil, nil) + + if string(result) != string(body) { + t.Fatalf("expected responses-lite body to be unchanged, got %s", string(result)) + } + if gjson.GetBytes(result, "tools").Exists() { + t.Fatalf("expected no injected tools for responses-lite request, got %s", gjson.GetBytes(result, "tools").Raw) + } +} + +func TestEnsureImageGenerationTool_ResponsesLiteBooleanMetadataDoesNotInjectTool(t *testing.T) { + body := []byte(`{"model":"gpt-5.6-sol","client_metadata":{"ws_request_header_x_openai_internal_codex_responses_lite":true},"input":"hello"}`) + result := ensureImageGenerationTool(body, "gpt-5.6-sol", nil, nil) + + if string(result) != string(body) { + t.Fatalf("expected responses-lite body to be unchanged, got %s", string(result)) + } +} + +func TestEnsureImageGenerationTool_ResponsesLiteHeaderDoesNotInjectTool(t *testing.T) { + body := []byte(`{"model":"gpt-5.6-sol","input":"hello"}`) + headers := make(http.Header) + headers.Set("X-OpenAI-Internal-Codex-Responses-Lite", "true") + result := ensureImageGenerationTool(body, "gpt-5.6-sol", nil, headers) + + if string(result) != string(body) { + t.Fatalf("expected responses-lite body to be unchanged, got %s", string(result)) + } +} + +func TestEnsureImageGenerationTool_ResponsesLiteFalseMetadataStillInjectsTool(t *testing.T) { + body := []byte(`{"model":"gpt-5.6-sol","client_metadata":{"ws_request_header_x_openai_internal_codex_responses_lite":"false"},"input":"hello"}`) + result := ensureImageGenerationTool(body, "gpt-5.6-sol", nil, nil) + + if got := gjson.GetBytes(result, "tools.0.type").String(); got != "image_generation" { + t.Fatalf("tools.0.type = %q, want image_generation; body=%s", got, result) + } +} + func TestEnsureImageGenerationTool_NoTools(t *testing.T) { body := []byte(`{"model":"gpt-5.4","input":"draw a cat"}`) - result := ensureImageGenerationTool(body, "gpt-5.4", nil) + result := ensureImageGenerationTool(body, "gpt-5.4", nil, nil) tools := gjson.GetBytes(result, "tools") if !tools.IsArray() { @@ -29,7 +117,7 @@ func TestEnsureImageGenerationTool_NoTools(t *testing.T) { func TestEnsureImageGenerationTool_ExistingToolsWithoutImageGen(t *testing.T) { body := []byte(`{"model":"gpt-5.4","tools":[{"type":"function","name":"get_weather","parameters":{}}]}`) - result := ensureImageGenerationTool(body, "gpt-5.4", nil) + result := ensureImageGenerationTool(body, "gpt-5.4", nil, nil) tools := gjson.GetBytes(result, "tools") arr := tools.Array() @@ -46,7 +134,7 @@ func TestEnsureImageGenerationTool_ExistingToolsWithoutImageGen(t *testing.T) { func TestEnsureImageGenerationTool_AlreadyPresent(t *testing.T) { body := []byte(`{"model":"gpt-5.4","tools":[{"type":"image_generation","output_format":"webp"},{"type":"function","name":"f1"}]}`) - result := ensureImageGenerationTool(body, "gpt-5.4", nil) + result := ensureImageGenerationTool(body, "gpt-5.4", nil, nil) tools := gjson.GetBytes(result, "tools") arr := tools.Array() @@ -60,7 +148,7 @@ func TestEnsureImageGenerationTool_AlreadyPresent(t *testing.T) { func TestEnsureImageGenerationTool_ImageGenNamespaceDoesNotInjectTool(t *testing.T) { body := []byte(`{"model":"gpt-5.4","tools":[{"type":"namespace","name":"image_gen","tools":[{"type":"function","name":"imagegen","parameters":{}}]}]}`) - result := ensureImageGenerationTool(body, "gpt-5.4", nil) + result := ensureImageGenerationTool(body, "gpt-5.4", nil, nil) if string(result) != string(body) { t.Fatalf("expected body to be unchanged, got %s", string(result)) @@ -69,7 +157,7 @@ func TestEnsureImageGenerationTool_ImageGenNamespaceDoesNotInjectTool(t *testing func TestEnsureImageGenerationTool_FlattenedImageGenFunctionDoesNotInjectTool(t *testing.T) { body := []byte(`{"model":"gpt-5.4","tools":[{"type":"function","name":"image_gen.imagegen","parameters":{}}]}`) - result := ensureImageGenerationTool(body, "gpt-5.4", nil) + result := ensureImageGenerationTool(body, "gpt-5.4", nil, nil) if string(result) != string(body) { t.Fatalf("expected body to be unchanged, got %s", string(result)) @@ -78,7 +166,7 @@ func TestEnsureImageGenerationTool_FlattenedImageGenFunctionDoesNotInjectTool(t func TestEnsureImageGenerationTool_SimilarNamespaceStillInjectsTool(t *testing.T) { body := []byte(`{"model":"gpt-5.4","tools":[{"type":"namespace","name":"image_tools","tools":[{"type":"function","name":"imagegen","parameters":{}}]}]}`) - result := ensureImageGenerationTool(body, "gpt-5.4", nil) + result := ensureImageGenerationTool(body, "gpt-5.4", nil, nil) tools := gjson.GetBytes(result, "tools").Array() if len(tools) != 2 { @@ -91,7 +179,7 @@ func TestEnsureImageGenerationTool_SimilarNamespaceStillInjectsTool(t *testing.T func TestEnsureImageGenerationTool_EmptyToolsArray(t *testing.T) { body := []byte(`{"model":"gpt-5.4","tools":[]}`) - result := ensureImageGenerationTool(body, "gpt-5.4", nil) + result := ensureImageGenerationTool(body, "gpt-5.4", nil, nil) tools := gjson.GetBytes(result, "tools") arr := tools.Array() @@ -105,7 +193,7 @@ func TestEnsureImageGenerationTool_EmptyToolsArray(t *testing.T) { func TestEnsureImageGenerationTool_WebSearchAndImageGen(t *testing.T) { body := []byte(`{"model":"gpt-5.4","tools":[{"type":"web_search"}]}`) - result := ensureImageGenerationTool(body, "gpt-5.4", nil) + result := ensureImageGenerationTool(body, "gpt-5.4", nil, nil) tools := gjson.GetBytes(result, "tools") arr := tools.Array() @@ -122,7 +210,7 @@ func TestEnsureImageGenerationTool_WebSearchAndImageGen(t *testing.T) { func TestEnsureImageGenerationTool_GPT53CodexSparkDoesNotInjectTool(t *testing.T) { body := []byte(`{"model":"gpt-5.3-codex-spark","input":"draw a cat"}`) - result := ensureImageGenerationTool(body, "gpt-5.3-codex-spark", nil) + result := ensureImageGenerationTool(body, "gpt-5.3-codex-spark", nil, nil) if string(result) != string(body) { t.Fatalf("expected body to be unchanged, got %s", string(result)) @@ -138,7 +226,7 @@ func TestEnsureImageGenerationTool_FreeCodexAuthDoesNotInjectTool(t *testing.T) Provider: "codex", Attributes: map[string]string{"plan_type": "free"}, } - result := ensureImageGenerationTool(body, "gpt-5.4", freeAuth) + result := ensureImageGenerationTool(body, "gpt-5.4", freeAuth, nil) if string(result) != string(body) { t.Fatalf("expected body to be unchanged, got %s", string(result)) diff --git a/internal/runtime/executor/codex_websockets_executor.go b/internal/runtime/executor/codex_websockets_executor.go index 72dca607..0c81235c 100644 --- a/internal/runtime/executor/codex_websockets_executor.go +++ b/internal/runtime/executor/codex_websockets_executor.go @@ -212,7 +212,7 @@ func (e *CodexWebsocketsExecutor) Execute(ctx context.Context, auth *cliproxyaut body, _ = sjson.DeleteBytes(body, "safety_identifier") body = normalizeCodexInstructions(body) if e.cfg == nil || e.cfg.DisableImageGeneration == config.DisableImageGenerationOff { - body = ensureImageGenerationTool(body, baseModel, auth) + body = ensureImageGenerationTool(body, baseModel, auth, opts.Headers) } body = sanitizeOpenAIResponsesReasoningEncryptedContent(ctx, "codex websockets executor", body) @@ -434,7 +434,7 @@ func (e *CodexWebsocketsExecutor) ExecuteStream(ctx context.Context, auth *clipr body, _ = sjson.SetBytes(body, "model", baseModel) body = normalizeCodexInstructions(body) if e.cfg == nil || e.cfg.DisableImageGeneration == config.DisableImageGenerationOff { - body = ensureImageGenerationTool(body, baseModel, auth) + body = ensureImageGenerationTool(body, baseModel, auth, opts.Headers) } body = sanitizeOpenAIResponsesReasoningEncryptedContent(ctx, "codex websockets executor", body) diff --git a/internal/runtime/executor/codex_websockets_executor_test.go b/internal/runtime/executor/codex_websockets_executor_test.go index 32260524..753259a5 100644 --- a/internal/runtime/executor/codex_websockets_executor_test.go +++ b/internal/runtime/executor/codex_websockets_executor_test.go @@ -40,6 +40,64 @@ func TestBuildCodexWebsocketRequestBodyPreservesPreviousResponseID(t *testing.T) } } +func TestCodexWebsocketsExecuteResponsesLiteDoesNotInjectImageGenerationTool(t *testing.T) { + upgrader := websocket.Upgrader{CheckOrigin: func(*http.Request) bool { return true }} + capturedPayload := make(chan []byte, 1) + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + conn, err := upgrader.Upgrade(w, r, nil) + if err != nil { + t.Fatalf("upgrade websocket: %v", err) + } + defer func() { _ = conn.Close() }() + + _, payload, errRead := conn.ReadMessage() + if errRead != nil { + t.Fatalf("read upstream websocket message: %v", errRead) + } + capturedPayload <- bytes.Clone(payload) + + completed := []byte(`{"type":"response.completed","response":{"id":"resp-1","output":[],"usage":{"input_tokens":0,"output_tokens":0,"total_tokens":0}}}`) + if errWrite := conn.WriteMessage(websocket.TextMessage, completed); errWrite != nil { + t.Fatalf("write completed websocket message: %v", errWrite) + } + })) + defer server.Close() + + exec := NewCodexWebsocketsExecutor(&config.Config{}) + auth := &cliproxyauth.Auth{ + Provider: "codex", + Attributes: map[string]string{ + "api_key": "sk-test", + "base_url": server.URL, + "plan_type": "pro", + }, + } + req := cliproxyexecutor.Request{ + Model: "gpt-5.6-sol", + Payload: []byte(`{"model":"gpt-5.6-sol","input":[{"type":"additional_tools","role":"developer","tools":[{"type":"custom","name":"exec"}]},{"role":"user","content":"hello"}],"client_metadata":{"ws_request_header_x_openai_internal_codex_responses_lite":"true"}}`), + } + opts := cliproxyexecutor.Options{SourceFormat: sdktranslator.FromString("codex")} + + if _, err := exec.Execute(context.Background(), auth, req, opts); err != nil { + t.Fatalf("Execute() error = %v", err) + } + + select { + case payload := <-capturedPayload: + if tools := gjson.GetBytes(payload, "tools"); tools.Exists() { + t.Fatalf("unexpected tools in responses-lite upstream payload: %s", tools.Raw) + } + if got := gjson.GetBytes(payload, "input.0.type").String(); got != "additional_tools" { + t.Fatalf("input.0.type = %q, want additional_tools; payload=%s", got, payload) + } + if got := gjson.GetBytes(payload, "client_metadata.ws_request_header_x_openai_internal_codex_responses_lite").String(); got != "true" { + t.Fatalf("responses-lite metadata = %q, want true; payload=%s", got, payload) + } + case <-time.After(5 * time.Second): + t.Fatal("timed out waiting for upstream websocket payload") + } +} + func TestCodexWebsocketsExecutePreservesPreviousResponseIDUpstream(t *testing.T) { upgrader := websocket.Upgrader{CheckOrigin: func(*http.Request) bool { return true }} capturedPayload := make(chan []byte, 1) -- 2.54.0