package llm import ( "context" "encoding/json" "net/http" "net/http/httptest" "strings" "testing" ) func TestBuildResponsesRequest(t *testing.T) { req := ChatRequest{ Model: "gpt-test", Messages: []Message{ {Role: RoleSystem, Content: "be concise"}, {Role: RoleUser, Content: "hi"}, { Role: RoleAssistant, Content: "let me check", ToolCalls: []ToolCall{{ ID: "call_1", Type: "function", Function: FunctionCall{ Name: "file_read", Arguments: `{"path":"README.md"}`, }, }}, }, {Role: RoleTool, ToolCallID: "call_1", Content: "contents"}, {Role: RoleAssistant, Content: "done"}, }, Tools: []Tool{{ Type: "function", Function: ToolFunction{ Name: "file_read", Description: "Read a local file", Parameters: json.RawMessage(`{"type":"object","properties":{}}`), }, }}, ToolChoice: "auto", Extra: map[string]any{ "reasoning": "middle", "thinking": "high", "temperature": 0.2, }, } payload := buildResponsesRequest(req) data, err := json.Marshal(payload) if err != nil { t.Fatal(err) } var raw map[string]any if err := json.Unmarshal(data, &raw); err != nil { t.Fatal(err) } if raw["model"] != "gpt-test" || raw["stream"] != true { t.Fatalf("model/stream = %#v / %#v", raw["model"], raw["stream"]) } if raw["instructions"] != "be concise" { t.Fatalf("instructions = %#v", raw["instructions"]) } if raw["tool_choice"] != "auto" { t.Fatalf("tool_choice = %#v", raw["tool_choice"]) } input, ok := raw["input"].([]any) if !ok || len(input) != 5 { t.Fatalf("input = %#v", raw["input"]) } checkInputItem := func(index int, wantType, wantKey, wantValue string) { t.Helper() item, ok := input[index].(map[string]any) if !ok { t.Fatalf("input[%d] = %#v", index, input[index]) } if item[wantKey] != wantValue { t.Fatalf("input[%d].%s = %#v, want %q", index, wantKey, item[wantKey], wantValue) } if wantType != "" && item["type"] != wantType { t.Fatalf("input[%d].type = %#v, want %q", index, item["type"], wantType) } } checkInputItem(0, "", "role", "user") checkInputItem(1, "", "role", "assistant") checkInputItem(2, "function_call", "call_id", "call_1") checkInputItem(3, "function_call_output", "call_id", "call_1") checkInputItem(4, "", "role", "assistant") if item := input[2].(map[string]any); item["name"] != "file_read" || item["arguments"] != `{"path":"README.md"}` { t.Fatalf("function_call item = %#v", item) } if item := input[3].(map[string]any); item["output"] != "contents" { t.Fatalf("function_call_output item = %#v", item) } tools, ok := raw["tools"].([]any) if !ok || len(tools) != 1 { t.Fatalf("tools = %#v", raw["tools"]) } tool := tools[0].(map[string]any) if tool["type"] != "function" || tool["name"] != "file_read" || tool["description"] != "Read a local file" { t.Fatalf("tool = %#v", tool) } if _, ok := tool["parameters"].(map[string]any); !ok { t.Fatalf("tool parameters = %#v", tool["parameters"]) } reasoning, ok := raw["reasoning"].(map[string]any) if !ok || reasoning["effort"] != "medium" { t.Fatalf("reasoning = %#v", raw["reasoning"]) } if raw["thinking"] != "high" { t.Fatalf("thinking = %#v", raw["thinking"]) } if raw["temperature"] != 0.2 { t.Fatalf("temperature = %#v", raw["temperature"]) } } func TestResponsesInstructionsCombinesSystemMessages(t *testing.T) { payload := buildResponsesRequest(ChatRequest{ Model: "gpt-test", Messages: []Message{ {Role: RoleSystem, Content: "first instruction"}, {Role: RoleUser, Content: "hi"}, {Role: RoleSystem, Content: "second instruction"}, }, }) data, err := json.Marshal(payload) if err != nil { t.Fatal(err) } var raw map[string]any if err := json.Unmarshal(data, &raw); err != nil { t.Fatal(err) } if raw["instructions"] != "first instruction\n\nsecond instruction" { t.Fatalf("instructions = %#v", raw["instructions"]) } input, ok := raw["input"].([]any) if !ok || len(input) != 1 { t.Fatalf("input = %#v", raw["input"]) } item, ok := input[0].(map[string]any) if !ok || item["role"] != "user" { t.Fatalf("input[0] = %#v", input[0]) } } func TestOpenAIResponsesClientStreamsContentAndToolCalls(t *testing.T) { server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { if r.URL.Path != "/v1/responses" { t.Fatalf("path = %s", r.URL.Path) } if got := r.Header.Get("Authorization"); got != "Bearer sk-test" { t.Fatalf("authorization = %q", got) } body := mustReadBody(t, r) var raw map[string]any if err := json.NewDecoder(strings.NewReader(body)).Decode(&raw); err != nil { t.Fatal(err) } if raw["stream"] != true { t.Fatal("request did not enable stream") } if raw["tool_choice"] != "auto" { t.Fatalf("tool_choice = %#v", raw["tool_choice"]) } input, ok := raw["input"].([]any) if !ok || len(input) == 0 { t.Fatalf("input = %#v", raw["input"]) } w.Header().Set("Content-Type", "text/event-stream") events := []string{ `{"type":"response.output_item.added","output_index":0,"item":{"type":"message","id":"msg_1","role":"assistant","content":[]}}`, `{"type":"response.output_text.delta","item_id":"msg_1","output_index":0,"content_index":0,"delta":"Hel"}`, `{"type":"response.output_text.delta","item_id":"msg_1","output_index":0,"content_index":0,"delta":"lo"}`, `{"type":"response.output_item.added","output_index":1,"item":{"type":"function_call","id":"fc_1","call_id":"call_1","name":"file_read","arguments":""}}`, `{"type":"response.function_call_arguments.delta","item_id":"fc_1","output_index":1,"delta":"{\"path\""}`, `{"type":"response.function_call_arguments.delta","item_id":"fc_1","output_index":1,"delta":":\"README.md\"}"}`, `{"type":"response.function_call_arguments.done","item_id":"fc_1","output_index":1,"name":"file_read","arguments":"{\"path\":\"README.md\"}"}`, `{"type":"response.output_item.done","output_index":1,"item":{"type":"function_call","id":"fc_1","call_id":"call_1","name":"file_read","arguments":"{\"path\":\"README.md\"}"}}`, `{"type":"response.output_item.done","output_index":0,"item":{"type":"message","id":"msg_1","role":"assistant","content":[{"type":"output_text","text":"Hello"}]}}`, `{"type":"response.completed","response":{"id":"resp_1","status":"completed","usage":{"input_tokens":10,"output_tokens":20,"total_tokens":30}}}`, } for _, event := range events { _, _ = w.Write([]byte("data: " + event + "\n\n")) } })) defer server.Close() client := NewOpenAIResponsesClient(server.URL, "sk-test", server.Client()) var content strings.Builder var calls []ToolCallDelta var finishes []string var usage *Usage err := client.ChatStream(context.Background(), ChatRequest{ Model: "gpt-test", Messages: []Message{{Role: RoleUser, Content: "hi"}}, ToolChoice: "auto", Tools: []Tool{{ Type: "function", Function: ToolFunction{ Name: "file_read", Description: "Read a local file", Parameters: json.RawMessage(`{"type":"object"}`), }, }}, }, func(event StreamEvent) error { content.WriteString(event.Content) calls = append(calls, event.ToolCalls...) if event.Usage != nil { usage = event.Usage } if event.FinishReason != "" { finishes = append(finishes, event.FinishReason) } return nil }) if err != nil { t.Fatal(err) } if content.String() != "Hello" { t.Fatalf("content = %q", content.String()) } if len(calls) != 1 { t.Fatalf("calls = %#v", calls) } call := calls[0] if call.Index != 1 || call.ID != "call_1" || call.Name != "file_read" { t.Fatalf("call = %#v", call) } if call.Arguments != `{"path":"README.md"}` { t.Fatalf("arguments = %q", call.Arguments) } if len(finishes) != 1 || finishes[0] != "stop" { t.Fatalf("finishes = %#v", finishes) } if usage == nil || usage.PromptTokens != 10 || usage.CompletionTokens != 20 || usage.TotalTokens != 30 { t.Fatalf("usage = %#v", usage) } } func TestOpenAIResponsesClientErrorEvent(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":"error","code":"invalid_request_error","message":"bad request"}` + "\n\n")) })) defer server.Close() client := NewOpenAIResponsesClient(server.URL, "sk-test", server.Client()) err := client.ChatStream(context.Background(), ChatRequest{Model: "test"}, func(StreamEvent) error { return nil }) if err == nil || !strings.Contains(err.Error(), "bad request") { t.Fatalf("err = %v", err) } } func TestOpenAIResponsesClientFailedEvent(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.failed","response":{"id":"resp_1","status":"failed","error":{"code":"server_error","message":"boom"}}}` + "\n\n")) })) defer server.Close() client := NewOpenAIResponsesClient(server.URL, "sk-test", server.Client()) err := client.ChatStream(context.Background(), ChatRequest{Model: "test"}, func(StreamEvent) error { return nil }) if err == nil || !strings.Contains(err.Error(), "boom") { t.Fatalf("err = %v", err) } } func TestOpenAIResponsesClientIncomplete(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","item_id":"msg_1","output_index":0,"content_index":0,"delta":"partial"}` + "\n\n")) _, _ = w.Write([]byte(`data: {"type":"response.incomplete","response":{"id":"resp_1","status":"incomplete","incomplete_details":{"reason":"max_tokens"}}}` + "\n\n")) })) defer server.Close() client := NewOpenAIResponsesClient(server.URL, "sk-test", server.Client()) var content strings.Builder var finishes []string err := client.ChatStream(context.Background(), ChatRequest{Model: "test"}, func(event StreamEvent) error { content.WriteString(event.Content) if event.FinishReason != "" { finishes = append(finishes, event.FinishReason) } return nil }) if err != nil { t.Fatal(err) } if content.String() != "partial" { t.Fatalf("content = %q", content.String()) } if len(finishes) != 1 || finishes[0] != "length" { t.Fatalf("finishes = %#v", finishes) } } func TestOpenAIResponsesClientRetriesTransientHTTPError(t *testing.T) { attempts := 0 server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { attempts++ if attempts == 1 { http.Error(w, "temporary", http.StatusBadGateway) return } w.Header().Set("Content-Type", "text/event-stream") _, _ = w.Write([]byte(`data: {"type":"response.output_text.delta","item_id":"msg_1","output_index":0,"content_index":0,"delta":"ok"}` + "\n\n")) _, _ = w.Write([]byte(`data: {"type":"response.completed","response":{"id":"resp_1","status":"completed"}}` + "\n\n")) })) defer server.Close() client := NewOpenAIResponsesClient(server.URL, "sk-test", server.Client()) var content strings.Builder err := client.ChatStream(context.Background(), ChatRequest{Model: "test"}, func(event StreamEvent) error { content.WriteString(event.Content) return nil }) if err != nil { t.Fatal(err) } if attempts != 2 { t.Fatalf("attempts = %d, want 2", attempts) } if content.String() != "ok" { t.Fatalf("content = %q", content.String()) } }