Files
CLIProxyAPI/internal/runtime/executor/codex_executor_imagegen_test.go
Luis Pater 81d70f5d9f feat(executor): add normalization for parallel tool calls in Codex executors
- Introduced `normalizeCodexWebsocketParallelToolCalls` and `normalizeCodexParallelToolCalls` to enforce consistent `parallel_tool_calls` handling.
- Updated WebSocket and HTTP executor logic to support headers during parallel tool normalization.
- Refactored redundant logic by consolidating normalization routines for improved clarity and maintainability.
2026-07-18 03:58:03 +08:00

289 lines
11 KiB
Go

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)
}
parallelToolCalls := gjson.GetBytes(gotBody, "parallel_tool_calls")
if !parallelToolCalls.Exists() || parallelToolCalls.Bool() {
t.Fatalf("responses-lite parallel_tool_calls should be false: %s", gotBody)
}
}
func TestCodexExecutorExecuteStreamResponsesLiteHeaderForcesParallelToolCallsFalse(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(codexResponsesLiteHeader, "true")
result, errExecute := executor.ExecuteStream(context.Background(), auth, cliproxyexecutor.Request{
Model: "gpt-5.6-luna",
Payload: []byte(`{"model":"gpt-5.6-luna","input":"hello"}`),
}, cliproxyexecutor.Options{
SourceFormat: sdktranslator.FromString("openai-response"),
Headers: headers,
})
if errExecute != nil {
t.Fatalf("ExecuteStream() error = %v", errExecute)
}
for chunk := range result.Chunks {
if chunk.Err != nil {
t.Fatalf("stream chunk error = %v", chunk.Err)
}
}
parallelToolCalls := gjson.GetBytes(gotBody, "parallel_tool_calls")
if !parallelToolCalls.Exists() || parallelToolCalls.Bool() {
t.Fatalf("responses-lite parallel_tool_calls should be false: %s", gotBody)
}
}
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, nil)
tools := gjson.GetBytes(result, "tools")
if !tools.IsArray() {
t.Fatalf("expected tools array, got %v", tools.Type)
}
arr := tools.Array()
if len(arr) != 1 {
t.Fatalf("expected 1 tool, got %d", len(arr))
}
if arr[0].Get("type").String() != "image_generation" {
t.Fatalf("expected type=image_generation, got %s", arr[0].Get("type").String())
}
if arr[0].Get("output_format").String() != "png" {
t.Fatalf("expected output_format=png, got %s", arr[0].Get("output_format").String())
}
}
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, nil)
tools := gjson.GetBytes(result, "tools")
arr := tools.Array()
if len(arr) != 2 {
t.Fatalf("expected 2 tools, got %d", len(arr))
}
if arr[0].Get("type").String() != "function" {
t.Fatalf("expected first tool type=function, got %s", arr[0].Get("type").String())
}
if arr[1].Get("type").String() != "image_generation" {
t.Fatalf("expected second tool type=image_generation, got %s", arr[1].Get("type").String())
}
}
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, nil)
tools := gjson.GetBytes(result, "tools")
arr := tools.Array()
if len(arr) != 2 {
t.Fatalf("expected 2 tools (no duplicate), got %d", len(arr))
}
if arr[0].Get("output_format").String() != "webp" {
t.Fatalf("expected original output_format=webp preserved, got %s", arr[0].Get("output_format").String())
}
}
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, nil)
if string(result) != string(body) {
t.Fatalf("expected body to be unchanged, got %s", string(result))
}
}
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, nil)
if string(result) != string(body) {
t.Fatalf("expected body to be unchanged, got %s", string(result))
}
}
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, nil)
tools := gjson.GetBytes(result, "tools").Array()
if len(tools) != 2 {
t.Fatalf("expected 2 tools, got %d", len(tools))
}
if tools[1].Get("type").String() != "image_generation" {
t.Fatalf("expected second tool type=image_generation, got %s", tools[1].Get("type").String())
}
}
func TestEnsureImageGenerationTool_EmptyToolsArray(t *testing.T) {
body := []byte(`{"model":"gpt-5.4","tools":[]}`)
result := ensureImageGenerationTool(body, "gpt-5.4", nil, nil)
tools := gjson.GetBytes(result, "tools")
arr := tools.Array()
if len(arr) != 1 {
t.Fatalf("expected 1 tool, got %d", len(arr))
}
if arr[0].Get("type").String() != "image_generation" {
t.Fatalf("expected type=image_generation, got %s", arr[0].Get("type").String())
}
}
func TestEnsureImageGenerationTool_WebSearchAndImageGen(t *testing.T) {
body := []byte(`{"model":"gpt-5.4","tools":[{"type":"web_search"}]}`)
result := ensureImageGenerationTool(body, "gpt-5.4", nil, nil)
tools := gjson.GetBytes(result, "tools")
arr := tools.Array()
if len(arr) != 2 {
t.Fatalf("expected 2 tools, got %d", len(arr))
}
if arr[0].Get("type").String() != "web_search" {
t.Fatalf("expected first tool type=web_search, got %s", arr[0].Get("type").String())
}
if arr[1].Get("type").String() != "image_generation" {
t.Fatalf("expected second tool type=image_generation, got %s", arr[1].Get("type").String())
}
}
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, nil)
if string(result) != string(body) {
t.Fatalf("expected body to be unchanged, got %s", string(result))
}
if gjson.GetBytes(result, "tools").Exists() {
t.Fatalf("expected no tools for gpt-5.3-codex-spark, got %s", gjson.GetBytes(result, "tools").Raw)
}
}
func TestEnsureImageGenerationTool_FreeCodexAuthDoesNotInjectTool(t *testing.T) {
body := []byte(`{"model":"gpt-5.4","input":"draw a cat"}`)
freeAuth := &cliproxyauth.Auth{
Provider: "codex",
Attributes: map[string]string{"plan_type": "free"},
}
result := ensureImageGenerationTool(body, "gpt-5.4", freeAuth, nil)
if string(result) != string(body) {
t.Fatalf("expected body to be unchanged, got %s", string(result))
}
if gjson.GetBytes(result, "tools").Exists() {
t.Fatalf("expected no tools for free codex auth, got %s", gjson.GetBytes(result, "tools").Raw)
}
}