feat(llm): support OpenAI Responses API via per-provider api_type
This commit is contained in:
@@ -0,0 +1,305 @@
|
||||
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["tool_choice"] != "auto" {
|
||||
t.Fatalf("tool_choice = %#v", raw["tool_choice"])
|
||||
}
|
||||
|
||||
input, ok := raw["input"].([]any)
|
||||
if !ok || len(input) != 6 {
|
||||
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", "system")
|
||||
checkInputItem(1, "", "role", "user")
|
||||
checkInputItem(2, "", "role", "assistant")
|
||||
checkInputItem(3, "function_call", "call_id", "call_1")
|
||||
checkInputItem(4, "function_call_output", "call_id", "call_1")
|
||||
checkInputItem(5, "", "role", "assistant")
|
||||
if item := input[3].(map[string]any); item["name"] != "file_read" || item["arguments"] != `{"path":"README.md"}` {
|
||||
t.Fatalf("function_call item = %#v", item)
|
||||
}
|
||||
if item := input[4].(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 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())
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user