fix(executor): enhance image generation tool handling for responses-lite requests

This commit is contained in:
Supra4E8C
2026-07-10 18:54:31 +08:00
parent 04109920e4
commit 631f7a652c
4 changed files with 179 additions and 16 deletions
+21 -4
View File
@@ -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
}
@@ -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))
@@ -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)
@@ -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)