package providers import ( "context" "encoding/json" "fmt" "net/http" "net/http/httptest" "strings" "testing" ) // staticTokenSource implements TokenSource for testing. type staticTokenSource struct { token string } func (s *staticTokenSource) Token() (string, error) { return s.token, nil } // mustJSON marshals v to JSON string; panics on error. func mustJSON(v any) string { b, err := json.Marshal(v) if err != nil { panic(err) } return string(b) } // writeSSEDone writes a minimal SSE response with just a completed event and [DONE]. func writeSSEDone(w http.ResponseWriter) { w.Header().Set("Content-Type", "text/event-stream") fmt.Fprintf(w, "data: %s\n\n", mustJSON(codexSSEEvent{ Type: "response.completed", Response: &codexAPIResponse{ID: "resp-1", Status: "completed"}, })) fmt.Fprint(w, "data: [DONE]\n\n") } func TestCodexProviderName(t *testing.T) { p := NewCodexProvider("openai-codex", &staticTokenSource{token: "test"}, "", "gpt-4o") if p.Name() != "openai-codex" { t.Errorf("Name() = %q, want %q", p.Name(), "openai-codex") } } func TestCodexProviderDefaultModel(t *testing.T) { p := NewCodexProvider("test", &staticTokenSource{token: "test"}, "", "") if p.DefaultModel() != "gpt-5.3-codex" { t.Errorf("DefaultModel() = %q, want %q", p.DefaultModel(), "gpt-5.3-codex") } p2 := NewCodexProvider("test", &staticTokenSource{token: "test"}, "", "o3") if p2.DefaultModel() != "o3" { t.Errorf("DefaultModel() = %q, want %q", p2.DefaultModel(), "o3") } } func TestCodexProviderBuildRequestBody(t *testing.T) { p := NewCodexProvider("test", &staticTokenSource{token: "test"}, "", "gpt-4o") req := ChatRequest{ Model: "gpt-4o", Messages: []Message{ {Role: "system", Content: "You are helpful."}, {Role: "user", Content: "Hello"}, {Role: "assistant", Content: "Hi there!"}, {Role: "user", Content: "How are you?"}, }, Options: map[string]any{ OptMaxTokens: 1024, OptTemperature: 0.7, }, } body := p.buildRequestBody(req, false) // Check model if body["model"] != "gpt-4o" { t.Errorf("model = %v, want gpt-4o", body["model"]) } // Check stream if body["stream"] != false { t.Errorf("stream = %v, want false", body["stream"]) } // Check store is false if body["store"] != false { t.Errorf("store = %v, want false", body["store"]) } // Check instructions extracted from system message if body["instructions"] != "You are helpful." { t.Errorf("instructions = %v, want 'You are helpful.'", body["instructions"]) } // Check input items (should exclude system messages) input, ok := body["input"].([]any) if !ok { t.Fatalf("input is not []interface{}: %T", body["input"]) } if len(input) != 3 { t.Fatalf("input length = %d, want 3 (user + assistant + user)", len(input)) } // chatgpt.com backend does not support max_output_tokens or temperature if _, ok := body["max_output_tokens"]; ok { t.Error("max_output_tokens should not be set (unsupported by chatgpt.com backend)") } if _, ok := body["temperature"]; ok { t.Error("temperature should not be set (unsupported by chatgpt.com backend)") } } func TestCodexProviderBuildRequestBodyWithTools(t *testing.T) { p := NewCodexProvider("test", &staticTokenSource{token: "test"}, "", "gpt-4o") req := ChatRequest{ Messages: []Message{{Role: "user", Content: "What's the weather?"}}, Tools: []ToolDefinition{ { Function: ToolFunctionSchema{ Name: "get_weather", Description: "Get current weather", Parameters: map[string]any{ "type": "object", "properties": map[string]any{ "city": map[string]any{"type": "string"}, }, "required": []string{"city"}, }, }, }, }, } body := p.buildRequestBody(req, true) tools, ok := body["tools"].([]map[string]any) if !ok { t.Fatalf("tools is not []map[string]interface{}: %T", body["tools"]) } if len(tools) != 1 { t.Fatalf("tools length = %d, want 1", len(tools)) } tool := tools[0] if tool["type"] != "function" { t.Errorf("tool type = %v, want function", tool["type"]) } if tool["name"] != "get_weather" { t.Errorf("tool name = %v, want get_weather", tool["name"]) } if _, hasStrict := tool["strict"]; hasStrict { t.Error("tool should not have strict field") } } func TestCodexProviderBuildRequestBodyToolCallMessages(t *testing.T) { p := NewCodexProvider("test", &staticTokenSource{token: "test"}, "", "gpt-4o") req := ChatRequest{ Messages: []Message{ {Role: "user", Content: "What's the weather?"}, { Role: "assistant", ToolCalls: []ToolCall{ {ID: "call_123", Name: "get_weather", Arguments: map[string]any{"city": "London"}}, }, }, {Role: "tool", ToolCallID: "call_123", Content: `{"temp": 15}`}, }, } body := p.buildRequestBody(req, false) input, ok := body["input"].([]any) if !ok { t.Fatalf("input is not []interface{}: %T", body["input"]) } if len(input) != 3 { t.Fatalf("input length = %d, want 3", len(input)) } // Second item should be function_call fc, ok := input[1].(map[string]any) if !ok { t.Fatalf("input[1] is not map: %T", input[1]) } if fc["type"] != "function_call" { t.Errorf("input[1] type = %v, want function_call", fc["type"]) } if fc["name"] != "get_weather" { t.Errorf("input[1] name = %v, want get_weather", fc["name"]) } // Third item should be function_call_output fco, ok := input[2].(map[string]any) if !ok { t.Fatalf("input[2] is not map: %T", input[2]) } if fco["type"] != "function_call_output" { t.Errorf("input[2] type = %v, want function_call_output", fco["type"]) } if fco["call_id"] != "fc_123" { t.Errorf("input[2] call_id = %v, want fc_123", fco["call_id"]) } } func TestCodexProviderBuildRequestBodyThinking(t *testing.T) { p := NewCodexProvider("test", &staticTokenSource{token: "test"}, "", "gpt-4o") req := ChatRequest{ Messages: []Message{{Role: "user", Content: "Think about this"}}, Options: map[string]any{OptThinkingLevel: "high"}, } body := p.buildRequestBody(req, false) reasoning, ok := body["reasoning"].(map[string]any) if !ok { t.Fatalf("reasoning is not map: %T", body["reasoning"]) } if reasoning["effort"] != "high" { t.Errorf("reasoning effort = %v, want high", reasoning["effort"]) } } func TestCodexProviderChat(t *testing.T) { // Mock Responses API server server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { // Verify headers if r.Header.Get("Authorization") != "Bearer test-token" { t.Errorf("Authorization = %q, want Bearer test-token", r.Header.Get("Authorization")) } if r.Header.Get("OpenAI-Beta") != "responses=v1" { t.Errorf("OpenAI-Beta = %q, want responses=v1", r.Header.Get("OpenAI-Beta")) } // Verify endpoint if r.URL.Path != "/codex/responses" { t.Errorf("path = %q, want /responses", r.URL.Path) } // Verify request body var body map[string]any json.NewDecoder(r.Body).Decode(&body) if body["model"] != "gpt-4o" { t.Errorf("request model = %v, want gpt-4o", body["model"]) } if body["store"] != false { t.Errorf("request store = %v, want false", body["store"]) } // Return SSE mock response (Chat delegates to ChatStream) w.Header().Set("Content-Type", "text/event-stream") fmt.Fprintf(w, "data: %s\n\n", mustJSON(codexSSEEvent{Type: "response.output_text.delta", Delta: "Hello! I'm doing great."})) fmt.Fprintf(w, "data: %s\n\n", mustJSON(codexSSEEvent{ Type: "response.output_item.done", Item: &codexItem{Type: "message", Role: "assistant"}, })) fmt.Fprintf(w, "data: %s\n\n", mustJSON(codexSSEEvent{ Type: "response.completed", Response: &codexAPIResponse{ ID: "resp-123", Status: "completed", Usage: &codexUsage{InputTokens: 10, OutputTokens: 8, TotalTokens: 18}, }, })) fmt.Fprint(w, "data: [DONE]\n\n") })) defer server.Close() p := NewCodexProvider("openai-codex", &staticTokenSource{token: "test-token"}, server.URL, "gpt-4o") p.retryConfig.Attempts = 1 result, err := p.Chat(context.Background(), ChatRequest{ Messages: []Message{{Role: "user", Content: "Hello"}}, }) if err != nil { t.Fatalf("Chat: %v", err) } if result.Content != "Hello! I'm doing great." { t.Errorf("Content = %q, want 'Hello! I'm doing great.'", result.Content) } if result.FinishReason != "stop" { t.Errorf("FinishReason = %q, want stop", result.FinishReason) } if result.Usage == nil { t.Fatal("Usage is nil") } if result.Usage.PromptTokens != 10 { t.Errorf("PromptTokens = %d, want 10", result.Usage.PromptTokens) } } func TestCodexProviderChatStream(t *testing.T) { server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { w.Header().Set("Content-Type", "text/event-stream") flusher := w.(http.Flusher) events := []string{ `{"type":"response.output_text.delta","delta":"Hello"}`, `{"type":"response.output_text.delta","delta":" world"}`, `{"type":"response.completed","response":{"usage":{"input_tokens":5,"output_tokens":2,"total_tokens":7}}}`, } for _, e := range events { fmt.Fprintf(w, "data: %s\n\n", e) flusher.Flush() } fmt.Fprint(w, "data: [DONE]\n\n") flusher.Flush() })) defer server.Close() p := NewCodexProvider("openai-codex", &staticTokenSource{token: "test"}, server.URL, "gpt-4o") p.retryConfig.Attempts = 1 var chunks []string result, err := p.ChatStream(context.Background(), ChatRequest{ Messages: []Message{{Role: "user", Content: "Hi"}}, }, func(chunk StreamChunk) { if chunk.Content != "" { chunks = append(chunks, chunk.Content) } }) if err != nil { t.Fatalf("ChatStream: %v", err) } if result.Content != "Hello world" { t.Errorf("Content = %q, want 'Hello world'", result.Content) } if len(chunks) != 2 { t.Errorf("chunks = %v, want 2 chunks", chunks) } if result.Usage == nil { t.Fatal("Usage is nil") } if result.Usage.TotalTokens != 7 { t.Errorf("TotalTokens = %d, want 7", result.Usage.TotalTokens) } } func TestCodexProviderChatStreamToolCalls(t *testing.T) { server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { w.Header().Set("Content-Type", "text/event-stream") flusher := w.(http.Flusher) events := []string{ `{"type":"response.function_call_arguments.delta","item_id":"item_1","delta":"{\"city\""}`, `{"type":"response.function_call_arguments.delta","item_id":"item_1","delta":":\"London\"}"}`, `{"type":"response.output_item.done","item":{"type":"function_call","id":"item_1","call_id":"call_abc","name":"get_weather","arguments":"{\"city\":\"London\"}"}}`, `{"type":"response.completed","response":{"usage":{"input_tokens":10,"output_tokens":5,"total_tokens":15}}}`, } for _, e := range events { fmt.Fprintf(w, "data: %s\n\n", e) flusher.Flush() } fmt.Fprint(w, "data: [DONE]\n\n") flusher.Flush() })) defer server.Close() p := NewCodexProvider("test", &staticTokenSource{token: "test"}, server.URL, "gpt-4o") p.retryConfig.Attempts = 1 result, err := p.ChatStream(context.Background(), ChatRequest{ Messages: []Message{{Role: "user", Content: "Weather?"}}, }, nil) if err != nil { t.Fatalf("ChatStream: %v", err) } if result.FinishReason != "tool_calls" { t.Errorf("FinishReason = %q, want tool_calls", result.FinishReason) } if len(result.ToolCalls) != 1 { t.Fatalf("ToolCalls length = %d, want 1", len(result.ToolCalls)) } tc := result.ToolCalls[0] if tc.Name != "get_weather" { t.Errorf("ToolCall name = %q, want get_weather", tc.Name) } if tc.ID != "call_abc" { t.Errorf("ToolCall ID = %q, want call_abc", tc.ID) } } func TestCodexProviderChatToolCallsNonStream(t *testing.T) { server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { w.Header().Set("Content-Type", "text/event-stream") fmt.Fprintf(w, "data: %s\n\n", mustJSON(codexSSEEvent{ Type: "response.output_item.done", Item: &codexItem{ Type: "function_call", ID: "item_1", CallID: "call_xyz", Name: "search", Arguments: `{"query":"test"}`, }, })) fmt.Fprintf(w, "data: %s\n\n", mustJSON(codexSSEEvent{ Type: "response.completed", Response: &codexAPIResponse{ ID: "resp-456", Status: "completed", Usage: &codexUsage{InputTokens: 5, OutputTokens: 3, TotalTokens: 8}, }, })) fmt.Fprint(w, "data: [DONE]\n\n") })) defer server.Close() p := NewCodexProvider("test", &staticTokenSource{token: "test"}, server.URL, "gpt-4o") p.retryConfig.Attempts = 1 result, err := p.Chat(context.Background(), ChatRequest{ Messages: []Message{{Role: "user", Content: "Search"}}, }) if err != nil { t.Fatalf("Chat: %v", err) } if result.FinishReason != "tool_calls" { t.Errorf("FinishReason = %q, want tool_calls", result.FinishReason) } if len(result.ToolCalls) != 1 { t.Fatalf("ToolCalls = %d, want 1", len(result.ToolCalls)) } if result.ToolCalls[0].Arguments["query"] != "test" { t.Errorf("ToolCall arg query = %v, want test", result.ToolCalls[0].Arguments["query"]) } } func TestCodexProviderHTTPError(t *testing.T) { server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { w.WriteHeader(http.StatusTooManyRequests) w.Write([]byte(`{"error":"rate limited"}`)) })) defer server.Close() p := NewCodexProvider("test", &staticTokenSource{token: "test"}, server.URL, "gpt-4o") p.retryConfig.Attempts = 1 _, err := p.Chat(context.Background(), ChatRequest{ Messages: []Message{{Role: "user", Content: "Hi"}}, }) if err == nil { t.Fatal("expected error, got nil") } if !strings.Contains(err.Error(), "rate limited") { t.Errorf("error = %q, expected to contain 'rate limited'", err.Error()) } } func TestCodexProviderMultipleSystemMessages(t *testing.T) { p := NewCodexProvider("test", &staticTokenSource{token: "test"}, "", "gpt-4o") req := ChatRequest{ Messages: []Message{ {Role: "system", Content: "First instruction."}, {Role: "system", Content: "Second instruction."}, {Role: "user", Content: "Hello"}, }, } body := p.buildRequestBody(req, false) expected := "First instruction.\n\nSecond instruction." if body["instructions"] != expected { t.Errorf("instructions = %q, want %q", body["instructions"], expected) } } func TestCodexProviderStreamReasoning(t *testing.T) { server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { w.Header().Set("Content-Type", "text/event-stream") flusher := w.(http.Flusher) events := []string{ `{"type":"response.output_item.done","item":{"type":"reasoning","summary":[{"type":"summary_text","text":"Thinking about this..."}]}}`, `{"type":"response.output_text.delta","delta":"The answer is 42."}`, `{"type":"response.completed","response":{"usage":{"input_tokens":10,"output_tokens":20,"total_tokens":30,"output_tokens_details":{"reasoning_tokens":15}}}}`, } for _, e := range events { fmt.Fprintf(w, "data: %s\n\n", e) flusher.Flush() } fmt.Fprint(w, "data: [DONE]\n\n") flusher.Flush() })) defer server.Close() p := NewCodexProvider("test", &staticTokenSource{token: "test"}, server.URL, "gpt-4o") p.retryConfig.Attempts = 1 var thinkingChunks []string result, err := p.ChatStream(context.Background(), ChatRequest{ Messages: []Message{{Role: "user", Content: "Think"}}, }, func(chunk StreamChunk) { if chunk.Thinking != "" { thinkingChunks = append(thinkingChunks, chunk.Thinking) } }) if err != nil { t.Fatalf("ChatStream: %v", err) } if result.Thinking != "Thinking about this..." { t.Errorf("Thinking = %q, want 'Thinking about this...'", result.Thinking) } if result.Content != "The answer is 42." { t.Errorf("Content = %q, want 'The answer is 42.'", result.Content) } if result.Usage != nil && result.Usage.ThinkingTokens != 15 { t.Errorf("ThinkingTokens = %d, want 15", result.Usage.ThinkingTokens) } } // Verify the endpoint format (apiBase + /responses) func TestCodexProviderEndpoint(t *testing.T) { var capturedPath string server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { capturedPath = r.URL.Path writeSSEDone(w) })) defer server.Close() p := NewCodexProvider("test", &staticTokenSource{token: "test"}, server.URL, "gpt-4o") p.retryConfig.Attempts = 1 p.Chat(context.Background(), ChatRequest{ Messages: []Message{{Role: "user", Content: "test"}}, }) if capturedPath != "/codex/responses" { t.Errorf("endpoint path = %q, want /responses", capturedPath) } } // Verify trailing slash is stripped from apiBase func TestCodexProviderAPIBaseTrailingSlash(t *testing.T) { var capturedPath string server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { capturedPath = r.URL.Path writeSSEDone(w) })) defer server.Close() p := NewCodexProvider("test", &staticTokenSource{token: "test"}, server.URL+"/", "gpt-4o") p.retryConfig.Attempts = 1 p.Chat(context.Background(), ChatRequest{ Messages: []Message{{Role: "user", Content: "test"}}, }) if capturedPath != "/codex/responses" { t.Errorf("endpoint path = %q, want /responses (trailing slash should be stripped)", capturedPath) } } // Ensure token is used from TokenSource func TestCodexProviderTokenSource(t *testing.T) { var capturedAuth string server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { capturedAuth = r.Header.Get("Authorization") writeSSEDone(w) })) defer server.Close() p := NewCodexProvider("test", &staticTokenSource{token: "my-oauth-token"}, server.URL, "gpt-4o") p.retryConfig.Attempts = 1 p.Chat(context.Background(), ChatRequest{ Messages: []Message{{Role: "user", Content: "test"}}, }) if capturedAuth != "Bearer my-oauth-token" { t.Errorf("Authorization = %q, want 'Bearer my-oauth-token'", capturedAuth) } } // Verify request body includes image content func TestCodexProviderBuildRequestBodyWithImages(t *testing.T) { p := NewCodexProvider("test", &staticTokenSource{token: "test"}, "", "gpt-4o") req := ChatRequest{ Messages: []Message{ { Role: "user", Content: "What's this?", Images: []ImageContent{{MimeType: "image/png", Data: "base64data"}}, }, }, } body := p.buildRequestBody(req, false) input, ok := body["input"].([]any) if !ok || len(input) != 1 { t.Fatalf("expected 1 input item, got %v", body["input"]) } item, ok := input[0].(map[string]any) if !ok { t.Fatalf("input[0] is not map: %T", input[0]) } content, ok := item["content"].([]map[string]any) if !ok { t.Fatalf("content is not []map: %T", item["content"]) } // Should have image + text if len(content) != 2 { t.Fatalf("content length = %d, want 2 (image + text)", len(content)) } if content[0]["type"] != "input_image" { t.Errorf("content[0] type = %v, want input_image", content[0]["type"]) } if content[1]["type"] != "input_text" { t.Errorf("content[1] type = %v, want input_text", content[1]["type"]) } }