mirror of
https://github.com/tiennm99/goclaw.git
synced 2026-06-10 22:11:08 +00:00
bdb60de7ae
- Update go.mod and Dockerfile to Go 1.26 - Apply `go fix ./...` stdlib modernizations across 170+ files - Add `go fix` to post-implementation checklist in CLAUDE.md - Fix go fix misapplied rewrite in loop_history.go
628 lines
19 KiB
Go
628 lines
19 KiB
Go
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"])
|
|
}
|
|
}
|