319 lines · 11.3 KB · Go
RawDownload
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") // Issue #5551: Tool schema with 13-branch oneOf causes ChatGPT Codex upstream// to abort silently. The executor must simplify the complex union without destroying// property names, enum values, or sibling properties.func TestCodexExecutorSimplifiesComplexOneOfToolSchema(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",		},	} 	// 13-branch oneOf reproduction payload from Issue #5551	requestPayload := []byte(`{		"model": "gpt-5.5",		"messages": [{"role": "user", "content": "Reply with exactly: OK"}],		"tools": [{			"type": "function",			"function": {				"name": "t1",				"description": "test tool",				"parameters": {					"type": "object",					"properties": {						"action": {							"type": "string",							"enum": ["p.list","m.list","s.list","s.create","s.send","s.fork","s.status","s.messages","sch.list","sch.create","sch.run","sch.delete","sch.toggle"],							"oneOf": [								{"const": "p.list", "description": "List projects"},								{"const": "m.list", "description": "List models"},								{"const": "s.list", "description": "List sessions"},								{"const": "s.create", "description": "Create session"},								{"const": "s.send", "description": "Send prompt"},								{"const": "s.fork", "description": "Fork session"},								{"const": "s.status", "description": "Session status"},								{"const": "s.messages", "description": "Session messages"},								{"const": "sch.list", "description": "List schedule"},								{"const": "sch.create", "description": "Create schedule"},								{"const": "sch.run", "description": "Run schedule"},								{"const": "sch.delete", "description": "Delete schedule"},								{"const": "sch.toggle", "description": "Toggle schedule"}							],							"description": "Action to perform"						},						"target_id": {							"type": "string",							"description": "Optional target"						}					},					"required": ["action"]				}			}		}]	}`) 	_, err := executor.Execute(context.Background(), auth, cliproxyexecutor.Request{		Model:   "gpt-5.5",		Payload: requestPayload,	}, cliproxyexecutor.Options{		SourceFormat: sdktranslator.FromString("openai"),		Stream:       false,	})	if err != nil {		t.Fatalf("Execute() error = %v", err)	} 	tools := gjson.GetBytes(gotBody, "tools").Array()	if len(tools) == 0 {		t.Fatalf("expected tools in upstream payload, got: %s", gotBody)	} 	// Find tool t1	var t1Tool gjson.Result	for _, tool := range tools {		if tool.Get("name").String() == "t1" {			t1Tool = tool			break		}	}	if !t1Tool.Exists() {		t1Tool = tools[0]	} 	// Complex oneOf must be removed from action property	if t1Tool.Get("parameters.properties.action.oneOf").Exists() {		t.Fatalf("expected complex oneOf to be removed from tool parameters, got: %s", t1Tool.Get("parameters").Raw)	}	// Action type and enum must be preserved	if t1Tool.Get("parameters.properties.action.type").String() != "string" {		t.Fatalf("expected action.type = string, got: %s", t1Tool.Get("parameters.properties.action.type").String())	}	if len(t1Tool.Get("parameters.properties.action.enum").Array()) != 13 {		t.Fatalf("expected action.enum to retain all 13 items")	}	// Sibling property target_id must be preserved	if t1Tool.Get("parameters.properties.target_id.type").String() != "string" {		t.Fatalf("sibling property target_id was dropped")	}	// Required field must be preserved	if t1Tool.Get("parameters.required.0").String() != "action" {		t.Fatalf("required list was dropped")	}} // Issue #5551: If ChatGPT Codex upstream terminates immediately with response.incomplete,// reason: "max_output_tokens", and output_tokens: 0 (no output produced), it must be treated// as an upstream failure rather than a silent successful completion.func TestCodexExecutorExecuteStream_ZeroTokenIncompleteResponseIsFailure(t *testing.T) {	server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {		w.Header().Set("Content-Type", "text/event-stream")		_, _ = w.Write([]byte(`data: {"type":"response.incomplete","response":{"id":"resp_1","model":"gpt-5.5","status":"incomplete","incomplete_details":{"reason":"max_output_tokens"},"output":[],"usage":{"input_tokens":10,"output_tokens":0,"total_tokens":10}}}` + "\n\n"))	}))	defer server.Close() 	executor := NewCodexExecutor(&config.Config{})	auth := &cliproxyauth.Auth{		Provider: "codex",		Attributes: map[string]string{			"base_url": server.URL,			"api_key":  "test",		},	} 	res, err := executor.ExecuteStream(context.Background(), auth, cliproxyexecutor.Request{		Model:   "gpt-5.5",		Payload: []byte(`{"model":"gpt-5.5","messages":[{"role":"user","content":"hello"}]}`),	}, cliproxyexecutor.Options{		SourceFormat: sdktranslator.FromString("openai"),		Stream:       true,	})	if err != nil {		// Error returned before streaming is also valid failure		return	} 	// If streaming started, chunks must surface an error rather than silent completion	sawError := false	for chunk := range res.Chunks {		if chunk.Err != nil {			sawError = true			break		}	}	if !sawError {		t.Fatalf("expected stream to fail on zero-token response.incomplete, but it completed without error")	}} // Issue #5551: If a stream emitted output text deltas before response.incomplete,// it produced partial output and must NOT be treated as a zero-token empty failure.func TestCodexExecutorExecuteStream_PartialDeltasIncompleteResponseIsSuccessful(t *testing.T) {	server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {		w.Header().Set("Content-Type", "text/event-stream")		_, _ = w.Write([]byte("data: {\"type\":\"response.output_text.delta\",\"delta\":\"Hello world\"}\n\n"))		_, _ = w.Write([]byte(`data: {"type":"response.incomplete","response":{"id":"resp_1","model":"gpt-5.5","status":"incomplete","incomplete_details":{"reason":"max_output_tokens"},"output":[],"usage":{"input_tokens":10,"output_tokens":0,"total_tokens":10}}}` + "\n\n"))	}))	defer server.Close() 	executor := NewCodexExecutor(&config.Config{})	auth := &cliproxyauth.Auth{		Provider: "codex",		Attributes: map[string]string{			"base_url": server.URL,			"api_key":  "test",		},	} 	res, err := executor.ExecuteStream(context.Background(), auth, cliproxyexecutor.Request{		Model:   "gpt-5.5",		Payload: []byte(`{"model":"gpt-5.5","messages":[{"role":"user","content":"hello"}]}`),	}, cliproxyexecutor.Options{		SourceFormat: sdktranslator.FromString("openai"),		Stream:       true,	})	if err != nil {		t.Fatalf("ExecuteStream() unexpected initial error: %v", err)	} 	sawDelta := false	sawError := false	for chunk := range res.Chunks {		if chunk.Err != nil {			sawError = true		}		if len(chunk.Payload) > 0 {			sawDelta = true		}	}	if sawError {		t.Fatalf("expected partial output stream to finish without failure, but got chunk error")	}	if !sawDelta {		t.Fatalf("expected to receive output delta")	}} // Issue #5551: Delta seen during the buffering phase must be remembered when transitioning// to the streaming goroutine so zero-token response.incomplete is not falsely flagged as empty.func TestCodexExecutorExecuteStream_BufferingPartialDeltasIncompleteResponseIsSuccessful(t *testing.T) {	server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {		w.Header().Set("Content-Type", "text/event-stream")		_, _ = w.Write([]byte("data: {\"type\":\"response.output_text.delta\",\"delta\":\"Buffered chunk\"}\n\n"))		_, _ = w.Write([]byte(`data: {"type":"response.incomplete","response":{"id":"resp_1","model":"gpt-5.5","status":"incomplete","incomplete_details":{"reason":"max_output_tokens"},"output":[],"usage":{"input_tokens":10,"output_tokens":0,"total_tokens":10}}}` + "\n\n"))	}))	defer server.Close() 	executor := NewCodexExecutor(&config.Config{		Codex: config.CodexConfig{			StreamBootstrapBuffering: true,		},	})	auth := &cliproxyauth.Auth{		Provider: "codex",		Attributes: map[string]string{			"base_url": server.URL,			"api_key":  "test",		},	} 	res, err := executor.ExecuteStream(context.Background(), auth, cliproxyexecutor.Request{		Model:   "gpt-5.5",		Payload: []byte(`{"model":"gpt-5.5","messages":[{"role":"user","content":"hello"}]}`),	}, cliproxyexecutor.Options{		SourceFormat: sdktranslator.FromString("openai"),		Stream:       true,	})	if err != nil {		t.Fatalf("ExecuteStream() unexpected initial error: %v", err)	} 	sawDelta := false	sawError := false	for chunk := range res.Chunks {		if chunk.Err != nil {			sawError = true		}		if len(chunk.Payload) > 0 {			sawDelta = true		}	}	if sawError {		t.Fatalf("expected buffered partial output stream to finish without failure, but got chunk error")	}	if !sawDelta {		t.Fatalf("expected to receive buffered output delta")	}} // Issue #5551: An empty string delta ("") before response.incomplete must NOT be treated as// meaningful output, and the stream must correctly surface the zero-token failure.func TestCodexExecutorExecuteStream_EmptyDeltaDoesNotBypassZeroTokenFailure(t *testing.T) {	server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {		w.Header().Set("Content-Type", "text/event-stream")		_, _ = w.Write([]byte("data: {\"type\":\"response.output_text.delta\",\"delta\":\"\"}\n\n"))		_, _ = w.Write([]byte(`data: {"type":"response.incomplete","response":{"id":"resp_1","model":"gpt-5.5","status":"incomplete","incomplete_details":{"reason":"max_output_tokens"},"output":[],"usage":{"input_tokens":10,"output_tokens":0,"total_tokens":10}}}` + "\n\n"))	}))	defer server.Close() 	executor := NewCodexExecutor(&config.Config{})	auth := &cliproxyauth.Auth{		Provider: "codex",		Attributes: map[string]string{			"base_url": server.URL,			"api_key":  "test",		},	} 	res, err := executor.ExecuteStream(context.Background(), auth, cliproxyexecutor.Request{		Model:   "gpt-5.5",		Payload: []byte(`{"model":"gpt-5.5","messages":[{"role":"user","content":"hello"}]}`),	}, cliproxyexecutor.Options{		SourceFormat: sdktranslator.FromString("openai"),		Stream:       true,	})	if err != nil {		return	} 	sawError := false	for chunk := range res.Chunks {		if chunk.Err != nil {			sawError = true			break		}	}	if !sawError {		t.Fatalf("expected stream with empty delta to fail on zero-token response.incomplete, but it succeeded")	}}