mirror of
https://github.com/ollama/ollama.git
synced 2026-11-12 03:46:14 -06:00
3458 lines
115 KiB
Go
3458 lines
115 KiB
Go
package openai
|
|
|
|
import (
|
|
"encoding/json"
|
|
"reflect"
|
|
"strings"
|
|
"testing"
|
|
"time"
|
|
|
|
"github.com/ollama/ollama/api"
|
|
)
|
|
|
|
func TestResponsesInputMessage_UnmarshalJSON(t *testing.T) {
|
|
tests := []struct {
|
|
name string
|
|
json string
|
|
want ResponsesInputMessage
|
|
wantErr bool
|
|
}{
|
|
{
|
|
name: "text content",
|
|
json: `{"type": "message", "role": "user", "content": [{"type": "input_text", "text": "hello"}]}`,
|
|
want: ResponsesInputMessage{
|
|
Type: "message",
|
|
Role: "user",
|
|
Content: []ResponsesContent{ResponsesTextContent{Type: "input_text", Text: "hello"}},
|
|
},
|
|
},
|
|
{
|
|
name: "image content",
|
|
json: `{"type": "message", "role": "user", "content": [{"type": "input_image", "detail": "auto", "image_url": "https://example.com/img.png"}]}`,
|
|
want: ResponsesInputMessage{
|
|
Type: "message",
|
|
Role: "user",
|
|
Content: []ResponsesContent{ResponsesImageContent{
|
|
Type: "input_image",
|
|
Detail: "auto",
|
|
ImageURL: "https://example.com/img.png",
|
|
}},
|
|
},
|
|
},
|
|
{
|
|
name: "multiple content items",
|
|
json: `{"type": "message", "role": "user", "content": [{"type": "input_text", "text": "hello"}, {"type": "input_text", "text": "world"}]}`,
|
|
want: ResponsesInputMessage{
|
|
Type: "message",
|
|
Role: "user",
|
|
Content: []ResponsesContent{
|
|
ResponsesTextContent{Type: "input_text", Text: "hello"},
|
|
ResponsesTextContent{Type: "input_text", Text: "world"},
|
|
},
|
|
},
|
|
},
|
|
{
|
|
name: "unknown content type",
|
|
json: `{"type": "message", "role": "user", "content": [{"type": "unknown"}]}`,
|
|
wantErr: true,
|
|
},
|
|
}
|
|
|
|
for _, tt := range tests {
|
|
t.Run(tt.name, func(t *testing.T) {
|
|
var got ResponsesInputMessage
|
|
err := json.Unmarshal([]byte(tt.json), &got)
|
|
|
|
if tt.wantErr {
|
|
if err == nil {
|
|
t.Error("expected error, got nil")
|
|
}
|
|
return
|
|
}
|
|
|
|
if err != nil {
|
|
t.Fatalf("unexpected error: %v", err)
|
|
}
|
|
|
|
if got.Type != tt.want.Type {
|
|
t.Errorf("Type = %q, want %q", got.Type, tt.want.Type)
|
|
}
|
|
|
|
if got.Role != tt.want.Role {
|
|
t.Errorf("Role = %q, want %q", got.Role, tt.want.Role)
|
|
}
|
|
|
|
if len(got.Content) != len(tt.want.Content) {
|
|
t.Fatalf("len(Content) = %d, want %d", len(got.Content), len(tt.want.Content))
|
|
}
|
|
|
|
for i := range tt.want.Content {
|
|
switch wantContent := tt.want.Content[i].(type) {
|
|
case ResponsesTextContent:
|
|
gotContent, ok := got.Content[i].(ResponsesTextContent)
|
|
if !ok {
|
|
t.Fatalf("Content[%d] type = %T, want ResponsesTextContent", i, got.Content[i])
|
|
}
|
|
if gotContent != wantContent {
|
|
t.Errorf("Content[%d] = %+v, want %+v", i, gotContent, wantContent)
|
|
}
|
|
case ResponsesImageContent:
|
|
gotContent, ok := got.Content[i].(ResponsesImageContent)
|
|
if !ok {
|
|
t.Fatalf("Content[%d] type = %T, want ResponsesImageContent", i, got.Content[i])
|
|
}
|
|
if gotContent != wantContent {
|
|
t.Errorf("Content[%d] = %+v, want %+v", i, gotContent, wantContent)
|
|
}
|
|
}
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
func TestResponsesInput_UnmarshalJSON(t *testing.T) {
|
|
tests := []struct {
|
|
name string
|
|
json string
|
|
wantText string
|
|
wantItems int
|
|
wantErr bool
|
|
}{
|
|
{
|
|
name: "plain string",
|
|
json: `"hello world"`,
|
|
wantText: "hello world",
|
|
},
|
|
{
|
|
name: "array with one message",
|
|
json: `[{"type": "message", "role": "user", "content": [{"type": "input_text", "text": "hello"}]}]`,
|
|
wantItems: 1,
|
|
},
|
|
{
|
|
name: "array with multiple messages",
|
|
json: `[{"type": "message", "role": "system", "content": [{"type": "input_text", "text": "you are helpful"}]}, {"type": "message", "role": "user", "content": [{"type": "input_text", "text": "hello"}]}]`,
|
|
wantItems: 2,
|
|
},
|
|
{
|
|
name: "invalid input",
|
|
json: `123`,
|
|
wantErr: true,
|
|
},
|
|
}
|
|
|
|
for _, tt := range tests {
|
|
t.Run(tt.name, func(t *testing.T) {
|
|
var got ResponsesInput
|
|
err := json.Unmarshal([]byte(tt.json), &got)
|
|
|
|
if tt.wantErr {
|
|
if err == nil {
|
|
t.Error("expected error, got nil")
|
|
}
|
|
return
|
|
}
|
|
|
|
if err != nil {
|
|
t.Fatalf("unexpected error: %v", err)
|
|
}
|
|
|
|
if got.Text != tt.wantText {
|
|
t.Errorf("Text = %q, want %q", got.Text, tt.wantText)
|
|
}
|
|
|
|
if len(got.Items) != tt.wantItems {
|
|
t.Errorf("len(Items) = %d, want %d", len(got.Items), tt.wantItems)
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
func TestUnmarshalResponsesInputItem(t *testing.T) {
|
|
t.Run("message item", func(t *testing.T) {
|
|
got, err := unmarshalResponsesInputItem([]byte(`{"type": "message", "role": "user", "content": [{"type": "input_text", "text": "hello"}]}`))
|
|
if err != nil {
|
|
t.Fatalf("unexpected error: %v", err)
|
|
}
|
|
|
|
msg, ok := got.(ResponsesInputMessage)
|
|
if !ok {
|
|
t.Fatalf("got type %T, want ResponsesInputMessage", got)
|
|
}
|
|
|
|
if msg.Role != "user" {
|
|
t.Errorf("Role = %q, want %q", msg.Role, "user")
|
|
}
|
|
})
|
|
|
|
t.Run("function_call item", func(t *testing.T) {
|
|
got, err := unmarshalResponsesInputItem([]byte(`{"type": "function_call", "call_id": "call_abc123", "name": "get_weather", "arguments": "{\"city\":\"Paris\"}"}`))
|
|
if err != nil {
|
|
t.Fatalf("unexpected error: %v", err)
|
|
}
|
|
|
|
fc, ok := got.(ResponsesFunctionCall)
|
|
if !ok {
|
|
t.Fatalf("got type %T, want ResponsesFunctionCall", got)
|
|
}
|
|
|
|
if fc.Type != "function_call" {
|
|
t.Errorf("Type = %q, want %q", fc.Type, "function_call")
|
|
}
|
|
if fc.CallID != "call_abc123" {
|
|
t.Errorf("CallID = %q, want %q", fc.CallID, "call_abc123")
|
|
}
|
|
if fc.Name != "get_weather" {
|
|
t.Errorf("Name = %q, want %q", fc.Name, "get_weather")
|
|
}
|
|
})
|
|
|
|
t.Run("function_call_output item", func(t *testing.T) {
|
|
got, err := unmarshalResponsesInputItem([]byte(`{"type": "function_call_output", "call_id": "call_abc123", "output": "the result"}`))
|
|
if err != nil {
|
|
t.Fatalf("unexpected error: %v", err)
|
|
}
|
|
|
|
output, ok := got.(ResponsesFunctionCallOutput)
|
|
if !ok {
|
|
t.Fatalf("got type %T, want ResponsesFunctionCallOutput", got)
|
|
}
|
|
|
|
if output.Type != "function_call_output" {
|
|
t.Errorf("Type = %q, want %q", output.Type, "function_call_output")
|
|
}
|
|
if output.CallID != "call_abc123" {
|
|
t.Errorf("CallID = %q, want %q", output.CallID, "call_abc123")
|
|
}
|
|
if output.Output != "the result" {
|
|
t.Errorf("Output = %q, want %q", output.Output, "the result")
|
|
}
|
|
})
|
|
|
|
t.Run("function_call_output item with content array", func(t *testing.T) {
|
|
got, err := unmarshalResponsesInputItem([]byte(`{"type": "function_call_output", "call_id": "call_abc123", "output": [{"type": "input_text", "text": "the result"}]}`))
|
|
if err != nil {
|
|
t.Fatalf("unexpected error: %v", err)
|
|
}
|
|
|
|
output, ok := got.(ResponsesFunctionCallOutput)
|
|
if !ok {
|
|
t.Fatalf("got type %T, want ResponsesFunctionCallOutput", got)
|
|
}
|
|
|
|
if output.Type != "function_call_output" {
|
|
t.Errorf("Type = %q, want %q", output.Type, "function_call_output")
|
|
}
|
|
if output.CallID != "call_abc123" {
|
|
t.Errorf("CallID = %q, want %q", output.CallID, "call_abc123")
|
|
}
|
|
if output.Output != "the result" {
|
|
t.Errorf("Output = %q, want %q", output.Output, "the result")
|
|
}
|
|
})
|
|
|
|
t.Run("tool_search_call item", func(t *testing.T) {
|
|
got, err := unmarshalResponsesInputItem([]byte(`{"type":"tool_search_call","id":"tsc_1","call_id":"call_search","execution":"client","status":"completed","arguments":{"query":"order lookup","limit":5}}`))
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
|
|
call, ok := got.(ResponsesToolSearchCall)
|
|
if !ok {
|
|
t.Fatalf("got type %T, want ResponsesToolSearchCall", got)
|
|
}
|
|
if call.CallID != "call_search" || call.Execution != "client" {
|
|
t.Fatalf("call = %#v", call)
|
|
}
|
|
if query, ok := call.Arguments.Get("query"); !ok || query != "order lookup" {
|
|
t.Fatalf("query = %#v, %v", query, ok)
|
|
}
|
|
})
|
|
|
|
t.Run("tool_search_output item", func(t *testing.T) {
|
|
got, err := unmarshalResponsesInputItem([]byte(`{"type":"tool_search_output","id":"tso_1","call_id":"call_search","execution":"client","status":"completed","tools":[{"type":"function","name":"lookup_order","x_client_field":true}]}`))
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
|
|
output, ok := got.(ResponsesToolSearchOutput)
|
|
if !ok {
|
|
t.Fatalf("got type %T, want ResponsesToolSearchOutput", got)
|
|
}
|
|
if output.CallID != "call_search" || len(output.Tools) != 1 {
|
|
t.Fatalf("output = %#v", output)
|
|
}
|
|
if !json.Valid(output.Tools[0]) {
|
|
t.Fatalf("tool is not valid JSON: %s", output.Tools[0])
|
|
}
|
|
})
|
|
|
|
t.Run("web_search_call item", func(t *testing.T) {
|
|
got, err := unmarshalResponsesInputItem([]byte(`{"type":"web_search_call","id":"ws_123","status":"completed","action":{"type":"search","query":"Parth Sareen"}}`))
|
|
if err != nil {
|
|
t.Fatalf("unexpected error: %v", err)
|
|
}
|
|
|
|
call, ok := got.(ResponsesWebSearchCall)
|
|
if !ok {
|
|
t.Fatalf("got type %T, want ResponsesWebSearchCall", got)
|
|
}
|
|
if call.ID != "ws_123" || call.Action == nil || call.Action.Query != "Parth Sareen" {
|
|
t.Fatalf("call = %#v", call)
|
|
}
|
|
})
|
|
|
|
t.Run("unknown item type", func(t *testing.T) {
|
|
_, err := unmarshalResponsesInputItem([]byte(`{"type": "unknown_type"}`))
|
|
if err == nil {
|
|
t.Error("expected error, got nil")
|
|
}
|
|
if err != nil && err.Error() != `unknown input item type: "unknown_type"` {
|
|
t.Errorf("unexpected error message: %v", err)
|
|
}
|
|
})
|
|
|
|
t.Run("missing type field", func(t *testing.T) {
|
|
_, err := unmarshalResponsesInputItem([]byte(`{"content": "hello"}`))
|
|
if err == nil {
|
|
t.Error("expected error for missing type, got nil")
|
|
}
|
|
if err != nil && err.Error() != "input item missing required 'type' field" {
|
|
t.Errorf("unexpected error message: %v", err)
|
|
}
|
|
})
|
|
|
|
t.Run("empty type field", func(t *testing.T) {
|
|
_, err := unmarshalResponsesInputItem([]byte(`{"type": ""}`))
|
|
if err == nil {
|
|
t.Error("expected error for empty type, got nil")
|
|
}
|
|
if err != nil && err.Error() != "input item missing required 'type' field" {
|
|
t.Errorf("unexpected error message: %v", err)
|
|
}
|
|
})
|
|
}
|
|
|
|
func TestResponsesStandaloneFunctionOutput(t *testing.T) {
|
|
for _, tt := range []struct {
|
|
name string
|
|
fields string
|
|
wantName string
|
|
wantError bool
|
|
}{
|
|
{"omitted call ID", `"name":"handoff","namespace":"workspace",`, "workspace.handoff", false},
|
|
{"null call ID", `"call_id":null,"name":"handoff","namespace":"workspace",`, "workspace.handoff", false},
|
|
{"no namespace", `"name":"handoff",`, "handoff", false},
|
|
{"null namespace", `"name":"handoff","namespace":null,`, "handoff", false},
|
|
{"empty call ID", `"call_id":"","name":"handoff",`, "", true},
|
|
{"blank call ID", `"call_id":" ","name":"handoff",`, "", true},
|
|
{"missing name", ``, "", true},
|
|
{"null name", `"name":null,`, "", true},
|
|
{"empty name", `"name":"",`, "", true},
|
|
{"blank name", `"name":" ",`, "", true},
|
|
{"namespace only", `"namespace":"workspace",`, "", true},
|
|
} {
|
|
t.Run(tt.name, func(t *testing.T) {
|
|
body := []byte(`{"model":"test","input":[{"type":"function_call_output","id":"fco_handoff",` + tt.fields + `"output":[{"type":"input_text","text":"Continue the task."}]}]}`)
|
|
var request ResponsesRequest
|
|
err := json.Unmarshal(body, &request)
|
|
if tt.wantError {
|
|
if err == nil {
|
|
t.Fatal("accepted invalid standalone output")
|
|
}
|
|
return
|
|
}
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
output := request.Input.Items[0].(ResponsesFunctionCallOutput)
|
|
if output.ID != "fco_handoff" || output.Name != "handoff" || output.CallID != "" || len(output.OutputItems) != 1 {
|
|
t.Fatalf("standalone identity or content changed: %+v", output)
|
|
}
|
|
chat, err := FromResponsesRequest(request)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if len(chat.Messages) != 1 {
|
|
t.Fatalf("got %d messages, want the standalone output only", len(chat.Messages))
|
|
}
|
|
message := chat.Messages[0]
|
|
if message.Role != "tool" || message.ToolName != tt.wantName || message.ToolCallID != "" || len(message.ToolCalls) != 0 || message.Content != "Continue the task." {
|
|
t.Fatalf("standalone output lost its name/content or gained a call: %+v", message)
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
func TestFromResponsesRequestIgnoresReplayedWebSearchCall(t *testing.T) {
|
|
req := ResponsesRequest{
|
|
Model: "test",
|
|
Input: ResponsesInput{Items: []ResponsesInputItem{
|
|
ResponsesInputMessage{Type: "message", Role: "user", Content: []ResponsesContent{ResponsesTextContent{Type: "input_text", Text: "Who is Parth Sareen?"}}},
|
|
ResponsesWebSearchCall{ID: "ws_123", Type: "web_search_call", Status: "completed", Action: &ResponsesWebSearchAction{Type: "search", Query: "Parth Sareen"}},
|
|
ResponsesInputMessage{Type: "message", Role: "assistant", Content: []ResponsesContent{ResponsesOutputTextContent{Type: "output_text", Text: "He works at Ollama."}}},
|
|
ResponsesInputMessage{Type: "message", Role: "user", Content: []ResponsesContent{ResponsesTextContent{Type: "input_text", Text: "What do you think of him?"}}},
|
|
}},
|
|
}
|
|
|
|
chat, err := FromResponsesRequest(req)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if len(chat.Messages) != 3 {
|
|
t.Fatalf("messages = %#v", chat.Messages)
|
|
}
|
|
if chat.Messages[0].Role != "user" || chat.Messages[1].Role != "assistant" || chat.Messages[1].Content != "He works at Ollama." || chat.Messages[2].Role != "user" {
|
|
t.Fatalf("messages = %#v", chat.Messages)
|
|
}
|
|
}
|
|
|
|
func TestFromResponsesRequestMergesMessageAfterFunctionCall(t *testing.T) {
|
|
var req ResponsesRequest
|
|
err := json.Unmarshal([]byte(`{
|
|
"model": "kimi-k3:cloud",
|
|
"input": [
|
|
{"role": "user", "content": "Find Ollama and inspect the current directory."},
|
|
{"type": "web_search_call", "id": "ws_test", "status": "completed", "action": {"type": "search", "query": "Ollama"}},
|
|
{"type": "function_call", "call_id": "call_test", "name": "exec_command", "arguments": "{\"cmd\":\"pwd\"}"},
|
|
{"type": "message", "role": "assistant", "content": [{"type": "output_text", "text": "I found Ollama and will inspect the directory."}]},
|
|
{"type": "function_call_output", "call_id": "call_test", "output": "/tmp"}
|
|
]
|
|
}`), &req)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
|
|
chat, err := FromResponsesRequest(req)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if len(chat.Messages) != 3 {
|
|
t.Fatalf("messages = %#v", chat.Messages)
|
|
}
|
|
|
|
assistant := chat.Messages[1]
|
|
if assistant.Role != "assistant" || assistant.Content != "I found Ollama and will inspect the directory." || len(assistant.ToolCalls) != 1 {
|
|
t.Fatalf("assistant message = %#v", assistant)
|
|
}
|
|
if assistant.ToolCalls[0].ID != "call_test" || assistant.ToolCalls[0].Function.Name != "exec_command" {
|
|
t.Fatalf("tool call = %#v", assistant.ToolCalls[0])
|
|
}
|
|
|
|
tool := chat.Messages[2]
|
|
if tool.Role != "tool" || tool.ToolCallID != "call_test" || tool.Content != "/tmp" {
|
|
t.Fatalf("tool message = %#v", tool)
|
|
}
|
|
}
|
|
|
|
func TestFromResponsesRequestAgentMessage(t *testing.T) {
|
|
var req ResponsesRequest
|
|
err := json.Unmarshal([]byte(`{
|
|
"model": "test-model",
|
|
"input": [
|
|
{"role": "user", "content": "Delegate the parser analysis."},
|
|
{"type": "function_call", "call_id": "call_spawn", "namespace": "collaboration", "name": "spawn_agent", "arguments": "{\"task_name\":\"worker\"}"},
|
|
{"type": "function_call_output", "call_id": "call_spawn", "output": "{\"task_name\":\"/root/worker\"}"},
|
|
{"type": "agent_message", "id": "amsg_1", "author": "/root", "recipient": "/root/worker", "content": [
|
|
{"type": "input_text", "text": "Message Type: NEW_TASK\nTask name: /root/worker\nSender: /root\nPayload:\n"},
|
|
{"type": "encrypted_content", "encrypted_content": "Analyze the parser module and report issues."},
|
|
{"type": "input_image", "file_id": "file_stale", "image_url": "data:image/png;base64,Zml4dHVyZQ=="}
|
|
]}
|
|
]
|
|
}`), &req)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
|
|
chat, err := FromResponsesRequest(req)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if len(chat.Messages) != 4 {
|
|
t.Fatalf("messages = %#v", chat.Messages)
|
|
}
|
|
|
|
agentMsg := chat.Messages[3]
|
|
want := api.Message{
|
|
Role: "user",
|
|
Content: "Agent message from \"/root\" to \"/root/worker\":\n" +
|
|
"Message Type: NEW_TASK\nTask name: /root/worker\nSender: /root\nPayload:\n" +
|
|
"Analyze the parser module and report issues.",
|
|
Images: []api.ImageData{[]byte("fixture")},
|
|
}
|
|
if !reflect.DeepEqual(agentMsg, want) {
|
|
t.Fatalf("agent message = %#v", agentMsg)
|
|
}
|
|
}
|
|
|
|
func TestResponsesRequest_UnmarshalJSON(t *testing.T) {
|
|
tests := []struct {
|
|
name string
|
|
json string
|
|
check func(t *testing.T, req ResponsesRequest)
|
|
wantErr bool
|
|
}{
|
|
{
|
|
name: "simple string input",
|
|
json: `{"model": "gpt-oss:20b", "input": "hello"}`,
|
|
check: func(t *testing.T, req ResponsesRequest) {
|
|
if req.Model != "gpt-oss:20b" {
|
|
t.Errorf("Model = %q, want %q", req.Model, "gpt-oss:20b")
|
|
}
|
|
if req.Input.Text != "hello" {
|
|
t.Errorf("Input.Text = %q, want %q", req.Input.Text, "hello")
|
|
}
|
|
},
|
|
},
|
|
{
|
|
name: "array input with messages",
|
|
json: `{"model": "gpt-oss:20b", "input": [{"type": "message", "role": "user", "content": [{"type": "input_text", "text": "hello"}]}]}`,
|
|
check: func(t *testing.T, req ResponsesRequest) {
|
|
if len(req.Input.Items) != 1 {
|
|
t.Fatalf("len(Input.Items) = %d, want 1", len(req.Input.Items))
|
|
}
|
|
msg, ok := req.Input.Items[0].(ResponsesInputMessage)
|
|
if !ok {
|
|
t.Fatalf("Input.Items[0] type = %T, want ResponsesInputMessage", req.Input.Items[0])
|
|
}
|
|
if msg.Role != "user" {
|
|
t.Errorf("Role = %q, want %q", msg.Role, "user")
|
|
}
|
|
},
|
|
},
|
|
{
|
|
name: "with temperature",
|
|
json: `{"model": "gpt-oss:20b", "input": "hello", "temperature": 0.5}`,
|
|
check: func(t *testing.T, req ResponsesRequest) {
|
|
if req.Temperature == nil || *req.Temperature != 0.5 {
|
|
t.Errorf("Temperature = %v, want 0.5", req.Temperature)
|
|
}
|
|
},
|
|
},
|
|
}
|
|
|
|
for _, tt := range tests {
|
|
t.Run(tt.name, func(t *testing.T) {
|
|
var got ResponsesRequest
|
|
err := json.Unmarshal([]byte(tt.json), &got)
|
|
|
|
if tt.wantErr {
|
|
if err == nil {
|
|
t.Error("expected error, got nil")
|
|
}
|
|
return
|
|
}
|
|
|
|
if err != nil {
|
|
t.Fatalf("unexpected error: %v", err)
|
|
}
|
|
|
|
if tt.check != nil {
|
|
tt.check(t, got)
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
func TestFromResponsesRequest_Tools(t *testing.T) {
|
|
reqJSON := `{
|
|
"model": "gpt-oss:20b",
|
|
"input": "hello",
|
|
"tools": [
|
|
{
|
|
"type": "function",
|
|
"name": "shell",
|
|
"description": "Runs a shell command",
|
|
"strict": false,
|
|
"parameters": {
|
|
"type": "object",
|
|
"properties": {
|
|
"command": {
|
|
"type": "array",
|
|
"items": {"type": "string"},
|
|
"description": "The command to execute"
|
|
}
|
|
},
|
|
"required": ["command"]
|
|
}
|
|
}
|
|
]
|
|
}`
|
|
|
|
var req ResponsesRequest
|
|
if err := json.Unmarshal([]byte(reqJSON), &req); err != nil {
|
|
t.Fatalf("failed to unmarshal request: %v", err)
|
|
}
|
|
|
|
// Check that tools were parsed
|
|
if len(req.Tools) != 1 {
|
|
t.Fatalf("expected 1 tool, got %d", len(req.Tools))
|
|
}
|
|
|
|
if req.Tools[0].Name != "shell" {
|
|
t.Errorf("expected tool name 'shell', got %q", req.Tools[0].Name)
|
|
}
|
|
|
|
// Convert and check
|
|
chatReq, err := FromResponsesRequest(req)
|
|
if err != nil {
|
|
t.Fatalf("failed to convert request: %v", err)
|
|
}
|
|
|
|
if len(chatReq.Tools) != 1 {
|
|
t.Fatalf("expected 1 converted tool, got %d", len(chatReq.Tools))
|
|
}
|
|
|
|
tool := chatReq.Tools[0]
|
|
if tool.Type != "function" {
|
|
t.Errorf("expected tool type 'function', got %q", tool.Type)
|
|
}
|
|
if tool.Function.Name != "shell" {
|
|
t.Errorf("expected function name 'shell', got %q", tool.Function.Name)
|
|
}
|
|
if tool.Function.Description != "Runs a shell command" {
|
|
t.Errorf("expected function description 'Runs a shell command', got %q", tool.Function.Description)
|
|
}
|
|
if tool.Function.Parameters.Type != "object" {
|
|
t.Errorf("expected parameters type 'object', got %q", tool.Function.Parameters.Type)
|
|
}
|
|
if len(tool.Function.Parameters.Required) != 1 || tool.Function.Parameters.Required[0] != "command" {
|
|
t.Errorf("expected required ['command'], got %v", tool.Function.Parameters.Required)
|
|
}
|
|
}
|
|
|
|
func TestFromResponsesRequest_WebSearchTool(t *testing.T) {
|
|
var req ResponsesRequest
|
|
if err := json.Unmarshal([]byte(`{
|
|
"model":"gpt-oss:20b", "input":"latest news",
|
|
"tools":[
|
|
{"type":"web_search"},
|
|
{"type":"function", "name":"web_search", "description":"client collision", "parameters":{"type":"object"}},
|
|
{"type":"function", "name":"weather", "description":"weather", "parameters":{"type":"object"}}
|
|
]
|
|
}`), &req); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if !HasWebSearchTool(req.Tools) {
|
|
t.Fatal("built-in web_search was not detected")
|
|
}
|
|
chat, err := FromResponsesRequest(req)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if len(chat.Tools) != 2 {
|
|
t.Fatalf("converted tool count = %d, want 2", len(chat.Tools))
|
|
}
|
|
if chat.Tools[0].Function.Name != "web_search" {
|
|
t.Errorf("first tool = %q, want web_search", chat.Tools[0].Function.Name)
|
|
}
|
|
if chat.Tools[0].Function.Parameters.Type != "object" || len(chat.Tools[0].Function.Parameters.Required) != 1 || chat.Tools[0].Function.Parameters.Required[0] != "query" {
|
|
t.Errorf("web_search parameters = %#v, want required query object", chat.Tools[0].Function.Parameters)
|
|
}
|
|
if chat.Tools[1].Function.Name != "weather" {
|
|
t.Errorf("second tool = %q, want weather", chat.Tools[1].Function.Name)
|
|
}
|
|
}
|
|
|
|
func TestFromResponsesRequest_ToolSearch(t *testing.T) {
|
|
description := "Find the project tools needed to continue."
|
|
request := ResponsesRequest{
|
|
Tools: []ResponsesTool{
|
|
{
|
|
Type: "tool_search",
|
|
Execution: "client",
|
|
Description: &description,
|
|
Parameters: map[string]any{
|
|
"type": "object",
|
|
"properties": map[string]any{"query": map[string]any{"type": "string"}},
|
|
"required": []any{"query"},
|
|
},
|
|
},
|
|
{Type: "function", Name: "tool_search"},
|
|
{Type: "function", Name: "exec_command"},
|
|
},
|
|
}
|
|
|
|
chat, err := FromResponsesRequest(request)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if len(chat.Tools) != 2 {
|
|
t.Fatalf("tools = %#v, want tool_search and exec_command", chat.Tools)
|
|
}
|
|
if got := chat.Tools[0].Function.Name; got != "tool_search" {
|
|
t.Fatalf("tool name = %q", got)
|
|
}
|
|
if got := chat.Tools[0].Function.Description; got != description {
|
|
t.Fatalf("description = %q", got)
|
|
}
|
|
if got := chat.Tools[0].Function.Parameters.Required; len(got) != 1 || got[0] != "query" {
|
|
t.Fatalf("required = %#v", got)
|
|
}
|
|
if got := chat.Tools[1].Function.Name; got != "exec_command" {
|
|
t.Fatalf("core tool name = %q", got)
|
|
}
|
|
}
|
|
|
|
func TestFromResponsesRequestRejectsHostedToolSearch(t *testing.T) {
|
|
_, err := FromResponsesRequest(ResponsesRequest{Tools: []ResponsesTool{{Type: "tool_search", Execution: "server"}}})
|
|
if err == nil {
|
|
t.Fatal("expected hosted tool search to be rejected")
|
|
}
|
|
}
|
|
|
|
func TestFromResponsesRequest_ToolSearchOutputBecomesToolContent(t *testing.T) {
|
|
var request ResponsesRequest
|
|
if err := json.Unmarshal([]byte(`{
|
|
"model":"test",
|
|
"tools":[{
|
|
"type":"tool_search",
|
|
"execution":"client",
|
|
"description":"Find tools",
|
|
"parameters":{"type":"object","properties":{"query":{"type":"string"}},"required":["query"]}
|
|
}],
|
|
"input":[
|
|
{"type":"tool_search_call","id":"tsc_1","call_id":"call_search","execution":"client","status":"completed","arguments":{"query":"orders","limit":5}},
|
|
{"type":"tool_search_output","id":"tso_1","call_id":"call_search","execution":"client","status":"completed","tools":[
|
|
{"type":"function","name":"lookup_order","description":"Look up an order","defer_loading":true,"x_client_field":"preserved","parameters":{"type":"object"}}
|
|
]}
|
|
]
|
|
}`), &request); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
|
|
chat, err := FromResponsesRequest(request)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if len(chat.Messages) != 2 {
|
|
t.Fatalf("messages = %#v", chat.Messages)
|
|
}
|
|
call := chat.Messages[0]
|
|
if call.Role != "assistant" || len(call.ToolCalls) != 1 || call.ToolCalls[0].Function.Name != "tool_search" {
|
|
t.Fatalf("search call message = %#v", call)
|
|
}
|
|
if query, ok := call.ToolCalls[0].Function.Arguments.Get("query"); !ok || query != "orders" {
|
|
t.Fatalf("query = %#v, %v", query, ok)
|
|
}
|
|
output := chat.Messages[1]
|
|
if output.Role != "tool" || output.ToolName != "tool_search" || output.ToolCallID != "call_search" {
|
|
t.Fatalf("search output message = %#v", output)
|
|
}
|
|
var content []map[string]any
|
|
if err := json.Unmarshal([]byte(output.Content), &content); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if len(content) != 1 || content[0]["name"] != "lookup_order" || content[0]["x_client_field"] != "preserved" {
|
|
t.Fatalf("content = %#v", content)
|
|
}
|
|
if len(chat.Tools) != 1 || chat.Tools[0].Function.Name != "tool_search" {
|
|
t.Fatalf("native tools = %#v; discovered tool must remain content-only", chat.Tools)
|
|
}
|
|
}
|
|
|
|
func TestFromResponsesRequest_ToolSearchOutputFlattensNamespaceMembers(t *testing.T) {
|
|
namespace := json.RawMessage(`{
|
|
"type":"namespace",
|
|
"name":"mcp__openaiDeveloperDocs",
|
|
"description":"Official OpenAI documentation tools",
|
|
"tools":[
|
|
{"type":"function","name":"search_openai_docs","description":"Search docs","strict":false,"defer_loading":true,"x_client_field":true,"parameters":{"type":"object","properties":{"query":{"type":"string"}},"required":["query"]}}
|
|
]
|
|
}`)
|
|
request := ResponsesRequest{
|
|
Tools: []ResponsesTool{{Type: "tool_search", Execution: "client"}},
|
|
Input: ResponsesInput{Items: []ResponsesInputItem{
|
|
ResponsesToolSearchCall{
|
|
Type: "tool_search_call",
|
|
CallID: "call_search",
|
|
Execution: "client",
|
|
},
|
|
ResponsesToolSearchOutput{
|
|
Type: "tool_search_output",
|
|
CallID: "call_search",
|
|
Execution: "client",
|
|
Tools: []json.RawMessage{
|
|
namespace,
|
|
json.RawMessage(`{"type":"namespace","name":"mcp__codex_apps__github","tools":[{"type":"function","name":"_search","parameters":{"type":"object"}}]}`),
|
|
json.RawMessage(`{"type":"function","name":"plain","x_client_field":"preserved"}`),
|
|
},
|
|
},
|
|
}},
|
|
}
|
|
|
|
chat, err := FromResponsesRequest(request)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if len(chat.Messages) != 2 {
|
|
t.Fatalf("messages = %#v", chat.Messages)
|
|
}
|
|
var content []map[string]any
|
|
if err := json.Unmarshal([]byte(chat.Messages[1].Content), &content); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if len(content) != 3 {
|
|
t.Fatalf("tools = %#v", content)
|
|
}
|
|
if got := content[0]["name"]; got != "mcp__openaiDeveloperDocs.search_openai_docs" {
|
|
t.Fatalf("docs tool name = %#v", got)
|
|
}
|
|
if got := content[0]["description"]; got != "Search docs" {
|
|
t.Fatalf("docs description = %#v", got)
|
|
}
|
|
if got := content[0]["strict"]; got != false {
|
|
t.Fatalf("docs strict = %#v", got)
|
|
}
|
|
if got := content[0]["defer_loading"]; got != true {
|
|
t.Fatalf("docs defer_loading = %#v", got)
|
|
}
|
|
parameters, ok := content[0]["parameters"].(map[string]any)
|
|
if !ok || parameters["type"] != "object" {
|
|
t.Fatalf("docs parameters = %#v", content[0]["parameters"])
|
|
}
|
|
properties, ok := parameters["properties"].(map[string]any)
|
|
if !ok {
|
|
t.Fatalf("docs parameter properties = %#v", parameters["properties"])
|
|
}
|
|
query, ok := properties["query"].(map[string]any)
|
|
if !ok || query["type"] != "string" {
|
|
t.Fatalf("docs query parameter = %#v", properties["query"])
|
|
}
|
|
required, ok := parameters["required"].([]any)
|
|
if !ok || len(required) != 1 || required[0] != "query" {
|
|
t.Fatalf("docs required parameters = %#v", parameters["required"])
|
|
}
|
|
if got := content[0]["x_client_field"]; got != true {
|
|
t.Fatalf("docs custom field = %#v", got)
|
|
}
|
|
if got := content[1]["name"]; got != "mcp__codex_apps__github._search" {
|
|
t.Fatalf("app tool name = %#v", got)
|
|
}
|
|
if got := content[2]["name"]; got != "plain" {
|
|
t.Fatalf("plain tool name = %#v", got)
|
|
}
|
|
if got := string(request.Input.Items[1].(ResponsesToolSearchOutput).Tools[0]); got != string(namespace) {
|
|
t.Fatalf("Responses tool_search_output was mutated: %s", got)
|
|
}
|
|
if len(chat.Tools) != 1 || chat.Tools[0].Function.Name != "tool_search" {
|
|
t.Fatalf("native tools = %#v; discovered tools must remain content-only", chat.Tools)
|
|
}
|
|
}
|
|
|
|
func TestFromResponsesRequestRejectsIncompleteToolSearchNamespace(t *testing.T) {
|
|
tests := []json.RawMessage{
|
|
json.RawMessage(`{"type":"namespace","tools":[{"type":"function","name":"search"}]}`),
|
|
json.RawMessage(`{"type":"namespace","name":"docs","tools":[{"type":"function"}]}`),
|
|
}
|
|
for _, tool := range tests {
|
|
t.Run(string(tool), func(t *testing.T) {
|
|
_, err := FromResponsesRequest(ResponsesRequest{
|
|
Input: ResponsesInput{Items: []ResponsesInputItem{ResponsesToolSearchOutput{
|
|
Type: "tool_search_output",
|
|
Tools: []json.RawMessage{tool},
|
|
}}},
|
|
})
|
|
if err == nil {
|
|
t.Fatal("expected incomplete namespace to be rejected")
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
func TestFromResponsesRequest_WebSearchIgnoresUnknownControls(t *testing.T) {
|
|
for _, declaration := range []string{
|
|
`{"type":"web_search","filters":{"allowed_domains":["example.com"]}}`,
|
|
`{"type":"web_search","user_location":{"type":"approximate"}}`,
|
|
`{"type":"web_search","search_context_size":"high"}`,
|
|
} {
|
|
t.Run(declaration, func(t *testing.T) {
|
|
var tool ResponsesTool
|
|
if err := json.Unmarshal([]byte(declaration), &tool); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
request, err := FromResponsesRequest(ResponsesRequest{Model: "test", Input: ResponsesInput{Text: "hi"}, Tools: []ResponsesTool{tool}})
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if len(request.Tools) != 1 || request.Tools[0].Function.Name != "web_search" {
|
|
t.Fatalf("tools = %#v, want web_search", request.Tools)
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
// TestFromResponsesRequest_NamespaceTools covers the "namespace" tool
|
|
// declaration: one namespace whose members are the real functions must
|
|
// expand to namespace-qualified function tools with their schemas intact,
|
|
// since dropping the members leaves the model with a single schema-less
|
|
// pseudo-function and makes every namespaced call undeclarable.
|
|
func TestFromResponsesRequest_NamespaceTools(t *testing.T) {
|
|
reqJSON := `{
|
|
"model": "gpt-oss:20b",
|
|
"input": "hello",
|
|
"tools": [
|
|
{
|
|
"type": "namespace",
|
|
"name": "muse",
|
|
"description": "Muse Code tool set.",
|
|
"tools": [
|
|
{
|
|
"type": "function",
|
|
"name": "bash",
|
|
"description": "Runs a shell command",
|
|
"strict": true,
|
|
"parameters": {
|
|
"type": "object",
|
|
"properties": {
|
|
"command": {"type": "string"}
|
|
},
|
|
"required": ["command"]
|
|
}
|
|
},
|
|
{
|
|
"type": "function",
|
|
"name": "muse.read_file",
|
|
"description": "Reads a file",
|
|
"strict": true,
|
|
"parameters": {
|
|
"type": "object",
|
|
"properties": {
|
|
"path": {"type": "string"}
|
|
},
|
|
"required": ["path"]
|
|
}
|
|
}
|
|
]
|
|
},
|
|
{
|
|
"type": "function",
|
|
"name": "plain",
|
|
"description": "A non-namespaced tool",
|
|
"strict": false,
|
|
"parameters": {"type": "object"}
|
|
}
|
|
]
|
|
}`
|
|
|
|
var req ResponsesRequest
|
|
if err := json.Unmarshal([]byte(reqJSON), &req); err != nil {
|
|
t.Fatalf("failed to unmarshal request: %v", err)
|
|
}
|
|
|
|
chatReq, err := FromResponsesRequest(req)
|
|
if err != nil {
|
|
t.Fatalf("failed to convert request: %v", err)
|
|
}
|
|
|
|
if len(chatReq.Tools) != 3 {
|
|
t.Fatalf("expected 3 converted tools, got %d: %v", len(chatReq.Tools), chatReq.Tools)
|
|
}
|
|
|
|
// Member functions carry the namespace-qualified name; an already
|
|
// qualified member is not double-prefixed.
|
|
if got := chatReq.Tools[0].Function.Name; got != "muse.bash" {
|
|
t.Errorf("expected function name 'muse.bash', got %q", got)
|
|
}
|
|
if got := chatReq.Tools[1].Function.Name; got != "muse.read_file" {
|
|
t.Errorf("expected function name 'muse.read_file', got %q", got)
|
|
}
|
|
if got := chatReq.Tools[2].Function.Name; got != "plain" {
|
|
t.Errorf("expected function name 'plain', got %q", got)
|
|
}
|
|
|
|
// Member schemas and descriptions survive the expansion.
|
|
if got := chatReq.Tools[0].Function.Description; got != "Runs a shell command" {
|
|
t.Errorf("expected bash description, got %q", got)
|
|
}
|
|
if req := chatReq.Tools[0].Function.Parameters.Required; len(req) != 1 || req[0] != "command" {
|
|
t.Errorf("expected required ['command'], got %v", req)
|
|
}
|
|
if chatReq.Tools[0].Type != "function" {
|
|
t.Errorf("expected member type 'function', got %q", chatReq.Tools[0].Type)
|
|
}
|
|
}
|
|
|
|
func TestConvertToolsQualifiesNamespaceMemberNames(t *testing.T) {
|
|
tests := []struct {
|
|
name string
|
|
namespace string
|
|
member string
|
|
want string
|
|
}{
|
|
{name: "dot member", namespace: "muse", member: "bash", want: "muse.bash"},
|
|
{name: "qualified dot member", namespace: "muse", member: "muse.read_file", want: "muse.read_file"},
|
|
{name: "Codex plugin member", namespace: "mcp__codex_apps__notion", member: "_search", want: "mcp__codex_apps__notion_search"},
|
|
{name: "qualified Codex plugin member", namespace: "mcp__codex_apps__notion", member: "mcp__codex_apps__notion_search", want: "mcp__codex_apps__notion_search"},
|
|
}
|
|
|
|
for _, tt := range tests {
|
|
t.Run(tt.name, func(t *testing.T) {
|
|
tools, err := convertTools(ResponsesTool{
|
|
Type: "namespace",
|
|
Name: tt.namespace,
|
|
Tools: []ResponsesTool{{
|
|
Type: "function",
|
|
Name: tt.member,
|
|
}},
|
|
})
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if len(tools) != 1 {
|
|
t.Fatalf("tools = %#v, want one tool", tools)
|
|
}
|
|
if got := tools[0].Function.Name; got != tt.want {
|
|
t.Fatalf("tool name = %q, want %q", got, tt.want)
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
func TestFromResponsesRequestPreservesFunctionCallNamespace(t *testing.T) {
|
|
tests := []struct {
|
|
name string
|
|
call ResponsesFunctionCall
|
|
want string
|
|
withTools bool
|
|
}{
|
|
{
|
|
name: "namespaced call",
|
|
call: ResponsesFunctionCall{
|
|
Type: "function_call",
|
|
CallID: "call_1",
|
|
Namespace: "mcp__codex_apps__notion",
|
|
Name: "_search",
|
|
Arguments: `{}`,
|
|
},
|
|
want: "mcp__codex_apps__notion_search",
|
|
},
|
|
{
|
|
name: "legacy dotted call",
|
|
call: ResponsesFunctionCall{
|
|
Type: "function_call",
|
|
CallID: "call_1",
|
|
Name: "mcp__codex_apps__notion._search",
|
|
Arguments: `{}`,
|
|
},
|
|
want: "mcp__codex_apps__notion_search",
|
|
withTools: true,
|
|
},
|
|
}
|
|
|
|
for _, tt := range tests {
|
|
t.Run(tt.name, func(t *testing.T) {
|
|
req := ResponsesRequest{
|
|
Model: "test",
|
|
Input: ResponsesInput{Items: []ResponsesInputItem{tt.call}},
|
|
}
|
|
if tt.withTools {
|
|
req.Tools = []ResponsesTool{{
|
|
Type: "namespace",
|
|
Name: "mcp__codex_apps__notion",
|
|
Tools: []ResponsesTool{{Type: "function", Name: "_search"}},
|
|
}}
|
|
}
|
|
|
|
chatReq, err := FromResponsesRequest(req)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if got := chatReq.Messages[0].ToolCalls[0].Function.Name; got != tt.want {
|
|
t.Fatalf("tool name = %q, want %q", got, tt.want)
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
func TestToResponseRestoresFunctionCallNamespace(t *testing.T) {
|
|
request := ResponsesRequest{Tools: []ResponsesTool{{
|
|
Type: "namespace",
|
|
Name: "mcp__codex_apps__notion",
|
|
Tools: []ResponsesTool{{Type: "function", Name: "_search"}},
|
|
}}}
|
|
response := ToResponse("test", "resp_1", "item_1", api.ChatResponse{
|
|
Message: api.Message{ToolCalls: []api.ToolCall{{
|
|
ID: "call_1",
|
|
Function: api.ToolCallFunction{
|
|
Name: "mcp__codex_apps__notion_search",
|
|
Arguments: api.ToolCallFunctionArguments{},
|
|
},
|
|
}}},
|
|
}, request)
|
|
|
|
if len(response.Output) != 1 {
|
|
t.Fatalf("output = %#v, want one function call", response.Output)
|
|
}
|
|
if got := response.Output[0].Namespace; got != "mcp__codex_apps__notion" {
|
|
t.Fatalf("namespace = %q", got)
|
|
}
|
|
if got := response.Output[0].Name; got != "_search" {
|
|
t.Fatalf("name = %q", got)
|
|
}
|
|
}
|
|
|
|
func TestToResponseEmitsClientToolSearchCall(t *testing.T) {
|
|
response := ToResponse("test", "resp_1", "item_1", api.ChatResponse{
|
|
Message: api.Message{ToolCalls: []api.ToolCall{{
|
|
ID: "call_search",
|
|
Function: api.ToolCallFunction{
|
|
Name: "tool_search",
|
|
Arguments: testArgs(map[string]any{"query": "orders", "limit": 5}),
|
|
},
|
|
}}},
|
|
}, ResponsesRequest{Tools: []ResponsesTool{{Type: "tool_search", Execution: "client"}}})
|
|
|
|
if len(response.Output) != 1 {
|
|
t.Fatalf("output = %#v", response.Output)
|
|
}
|
|
item := response.Output[0]
|
|
if item.Type != "tool_search_call" || item.CallID != "call_search" || item.Execution != "client" || item.Status != "completed" ||
|
|
!strings.HasPrefix(item.ID, "tsc_") {
|
|
t.Fatalf("item = %#v", item)
|
|
}
|
|
encoded, err := json.Marshal(item)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
var wire struct {
|
|
Arguments map[string]any `json:"arguments"`
|
|
}
|
|
if err := json.Unmarshal(encoded, &wire); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if wire.Arguments["query"] != "orders" || wire.Arguments["limit"] != float64(5) {
|
|
t.Fatalf("arguments = %#v; want an object, not a JSON string", wire.Arguments)
|
|
}
|
|
}
|
|
|
|
func TestToResponseRestoresNamespaceFromToolSearchOutput(t *testing.T) {
|
|
request := ResponsesRequest{
|
|
Tools: []ResponsesTool{{Type: "tool_search", Execution: "client"}},
|
|
Input: ResponsesInput{Items: []ResponsesInputItem{ResponsesToolSearchOutput{
|
|
Type: "tool_search_output",
|
|
CallID: "call_search",
|
|
Execution: "client",
|
|
Tools: []json.RawMessage{
|
|
json.RawMessage(`{"type":"namespace","name":"mcp__codex_apps__notion","tools":[{"type":"function","name":"_search"}]}`),
|
|
json.RawMessage(`{"type":"namespace","name":"mcp__openaiDeveloperDocs","tools":[{"type":"function","name":"search_openai_docs"}]}`),
|
|
},
|
|
}}},
|
|
}
|
|
|
|
tests := []struct {
|
|
callName string
|
|
wantNamespace string
|
|
wantName string
|
|
}{
|
|
{callName: "mcp__openaiDeveloperDocs.search_openai_docs", wantNamespace: "mcp__openaiDeveloperDocs", wantName: "search_openai_docs"},
|
|
{callName: "mcp__openaiDeveloperDocs:search_openai_docs", wantNamespace: "mcp__openaiDeveloperDocs", wantName: "search_openai_docs"},
|
|
{callName: "mcp__codex_apps__notion._search", wantNamespace: "mcp__codex_apps__notion", wantName: "_search"},
|
|
{callName: "mcp__codex_apps__notion:_search", wantNamespace: "mcp__codex_apps__notion", wantName: "_search"},
|
|
{callName: "mcp__codex_apps__notion_search", wantNamespace: "mcp__codex_apps__notion", wantName: "_search"},
|
|
{callName: "mcp__other:search", wantName: "mcp__other:search"},
|
|
}
|
|
for _, tt := range tests {
|
|
t.Run(tt.callName, func(t *testing.T) {
|
|
response := ToResponse("test", "resp_1", "item_1", api.ChatResponse{
|
|
Message: api.Message{ToolCalls: []api.ToolCall{{
|
|
ID: "call_1",
|
|
Function: api.ToolCallFunction{
|
|
Name: tt.callName,
|
|
Arguments: api.ToolCallFunctionArguments{},
|
|
},
|
|
}}},
|
|
}, request)
|
|
|
|
if len(response.Output) != 1 {
|
|
t.Fatalf("output = %#v", response.Output)
|
|
}
|
|
if got := response.Output[0].Namespace; got != tt.wantNamespace {
|
|
t.Fatalf("namespace = %q, want %q", got, tt.wantNamespace)
|
|
}
|
|
if got := response.Output[0].Name; got != tt.wantName {
|
|
t.Fatalf("name = %q, want %q", got, tt.wantName)
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
func TestResponsesStreamConverterRestoresFunctionCallNamespace(t *testing.T) {
|
|
request := ResponsesRequest{Tools: []ResponsesTool{{
|
|
Type: "namespace",
|
|
Name: "mcp__codex_apps__notion",
|
|
Tools: []ResponsesTool{{Type: "function", Name: "_search"}},
|
|
}}}
|
|
converter := NewResponsesStreamConverter("resp_1", "item_1", "test", request)
|
|
events := converter.Process(api.ChatResponse{Message: api.Message{ToolCalls: []api.ToolCall{{
|
|
ID: "call_1",
|
|
Function: api.ToolCallFunction{
|
|
Name: "mcp__codex_apps__notion_search",
|
|
Arguments: api.ToolCallFunctionArguments{},
|
|
},
|
|
}}}})
|
|
|
|
var item map[string]any
|
|
for _, event := range events {
|
|
if event.Event == "response.output_item.done" {
|
|
item = event.Data.(map[string]any)["item"].(map[string]any)
|
|
}
|
|
}
|
|
if item == nil {
|
|
t.Fatal("missing response.output_item.done")
|
|
}
|
|
if got := item["namespace"]; got != "mcp__codex_apps__notion" {
|
|
t.Fatalf("namespace = %q", got)
|
|
}
|
|
if got := item["name"]; got != "_search" {
|
|
t.Fatalf("name = %q", got)
|
|
}
|
|
}
|
|
|
|
func TestResponsesStreamConverterRestoresLegacyDottedFunctionCallNamespace(t *testing.T) {
|
|
request := ResponsesRequest{Tools: []ResponsesTool{{
|
|
Type: "namespace",
|
|
Name: "mcp__codex_apps__notion",
|
|
Tools: []ResponsesTool{{Type: "function", Name: "_search"}},
|
|
}}}
|
|
converter := NewResponsesStreamConverter("resp_1", "item_1", "test", request)
|
|
events := converter.Process(api.ChatResponse{Message: api.Message{ToolCalls: []api.ToolCall{{
|
|
ID: "call_1",
|
|
Function: api.ToolCallFunction{
|
|
Name: "mcp__codex_apps__notion._search",
|
|
Arguments: api.ToolCallFunctionArguments{},
|
|
},
|
|
}}}})
|
|
|
|
var item map[string]any
|
|
for _, event := range events {
|
|
if event.Event == "response.output_item.done" {
|
|
item = event.Data.(map[string]any)["item"].(map[string]any)
|
|
}
|
|
}
|
|
if item == nil {
|
|
t.Fatal("missing response.output_item.done")
|
|
}
|
|
if got := item["namespace"]; got != "mcp__codex_apps__notion" {
|
|
t.Fatalf("namespace = %q", got)
|
|
}
|
|
if got := item["name"]; got != "_search" {
|
|
t.Fatalf("name = %q", got)
|
|
}
|
|
}
|
|
|
|
func TestFromResponsesRequest_ReasoningEffort(t *testing.T) {
|
|
tests := []struct {
|
|
name string
|
|
effort string
|
|
directThink *api.ThinkValue
|
|
wantThink any
|
|
wantErr bool
|
|
}{
|
|
{
|
|
name: "unset",
|
|
},
|
|
{
|
|
name: "low",
|
|
effort: "low",
|
|
wantThink: "low",
|
|
},
|
|
{
|
|
name: "medium",
|
|
effort: "medium",
|
|
wantThink: "medium",
|
|
},
|
|
{
|
|
name: "high",
|
|
effort: "high",
|
|
wantThink: "high",
|
|
},
|
|
{
|
|
name: "max",
|
|
effort: "max",
|
|
wantThink: "max",
|
|
},
|
|
{
|
|
name: "minimal clamps to low",
|
|
effort: "minimal",
|
|
wantThink: "low",
|
|
},
|
|
{
|
|
name: "xhigh clamps to max",
|
|
effort: "xhigh",
|
|
wantThink: "max",
|
|
},
|
|
{
|
|
name: "ultra clamps to max",
|
|
effort: "ultra",
|
|
wantThink: "max",
|
|
},
|
|
{
|
|
name: "none",
|
|
effort: "none",
|
|
wantThink: false,
|
|
},
|
|
{
|
|
name: "Ollama boolean override takes precedence",
|
|
effort: "medium",
|
|
directThink: &api.ThinkValue{Value: true},
|
|
wantThink: true,
|
|
},
|
|
{
|
|
name: "Ollama string override stays exact",
|
|
effort: "high",
|
|
directThink: &api.ThinkValue{Value: "max"},
|
|
wantThink: "max",
|
|
},
|
|
{
|
|
name: "invalid Ollama override",
|
|
directThink: &api.ThinkValue{Value: 3},
|
|
wantErr: true,
|
|
},
|
|
{
|
|
name: "invalid",
|
|
effort: "extreme",
|
|
wantErr: true,
|
|
},
|
|
}
|
|
|
|
for _, tt := range tests {
|
|
t.Run(tt.name, func(t *testing.T) {
|
|
req := ResponsesRequest{
|
|
Model: "deepseek-v4-flash",
|
|
Input: ResponsesInput{Text: "hi"},
|
|
Think: tt.directThink,
|
|
}
|
|
if tt.effort != "" {
|
|
req.Reasoning.Effort = tt.effort
|
|
}
|
|
|
|
chatReq, err := FromResponsesRequest(req)
|
|
if tt.wantErr {
|
|
if err == nil {
|
|
t.Fatal("expected error, got nil")
|
|
}
|
|
return
|
|
}
|
|
if err != nil {
|
|
t.Fatalf("unexpected error: %v", err)
|
|
}
|
|
|
|
if tt.wantThink == nil {
|
|
if chatReq.Think != nil {
|
|
t.Fatalf("Think = %#v, want nil", chatReq.Think)
|
|
}
|
|
return
|
|
}
|
|
|
|
if chatReq.Think == nil {
|
|
t.Fatalf("Think = nil, want %v", tt.wantThink)
|
|
}
|
|
if chatReq.Think.Value != tt.wantThink {
|
|
t.Errorf("Think.Value = %v, want %v", chatReq.Think.Value, tt.wantThink)
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
func TestFromResponsesRequest_FunctionCallOutput(t *testing.T) {
|
|
// Test a complete tool call round-trip:
|
|
// 1. User message asking about weather
|
|
// 2. Assistant's function call (from previous response)
|
|
// 3. Function call output (the tool result)
|
|
reqJSON := `{
|
|
"model": "gpt-oss:20b",
|
|
"input": [
|
|
{"type": "message", "role": "user", "content": [{"type": "input_text", "text": "what is the weather?"}]},
|
|
{"type": "function_call", "call_id": "call_abc123", "name": "get_weather", "arguments": "{\"city\":\"Paris\"}"},
|
|
{"type": "function_call_output", "call_id": "call_abc123", "name": "stale_name", "namespace": "stale_namespace", "output": "sunny, 72F"}
|
|
]
|
|
}`
|
|
|
|
var req ResponsesRequest
|
|
if err := json.Unmarshal([]byte(reqJSON), &req); err != nil {
|
|
t.Fatalf("failed to unmarshal request: %v", err)
|
|
}
|
|
|
|
// Check that input items were parsed
|
|
if len(req.Input.Items) != 3 {
|
|
t.Fatalf("expected 3 input items, got %d", len(req.Input.Items))
|
|
}
|
|
|
|
// Verify the function_call item
|
|
fc, ok := req.Input.Items[1].(ResponsesFunctionCall)
|
|
if !ok {
|
|
t.Fatalf("Input.Items[1] type = %T, want ResponsesFunctionCall", req.Input.Items[1])
|
|
}
|
|
if fc.Name != "get_weather" {
|
|
t.Errorf("Name = %q, want %q", fc.Name, "get_weather")
|
|
}
|
|
|
|
// Verify the function_call_output item
|
|
fcOutput, ok := req.Input.Items[2].(ResponsesFunctionCallOutput)
|
|
if !ok {
|
|
t.Fatalf("Input.Items[2] type = %T, want ResponsesFunctionCallOutput", req.Input.Items[2])
|
|
}
|
|
if fcOutput.CallID != "call_abc123" {
|
|
t.Errorf("CallID = %q, want %q", fcOutput.CallID, "call_abc123")
|
|
}
|
|
|
|
// Convert and check
|
|
chatReq, err := FromResponsesRequest(req)
|
|
if err != nil {
|
|
t.Fatalf("failed to convert request: %v", err)
|
|
}
|
|
|
|
if len(chatReq.Messages) != 3 {
|
|
t.Fatalf("expected 3 messages, got %d", len(chatReq.Messages))
|
|
}
|
|
|
|
// Check the user message
|
|
userMsg := chatReq.Messages[0]
|
|
if userMsg.Role != "user" {
|
|
t.Errorf("expected role 'user', got %q", userMsg.Role)
|
|
}
|
|
|
|
// Check the assistant message with tool call
|
|
assistantMsg := chatReq.Messages[1]
|
|
if assistantMsg.Role != "assistant" {
|
|
t.Errorf("expected role 'assistant', got %q", assistantMsg.Role)
|
|
}
|
|
if len(assistantMsg.ToolCalls) != 1 {
|
|
t.Fatalf("expected 1 tool call, got %d", len(assistantMsg.ToolCalls))
|
|
}
|
|
if assistantMsg.ToolCalls[0].ID != "call_abc123" {
|
|
t.Errorf("expected tool call ID 'call_abc123', got %q", assistantMsg.ToolCalls[0].ID)
|
|
}
|
|
if assistantMsg.ToolCalls[0].Function.Name != "get_weather" {
|
|
t.Errorf("expected function name 'get_weather', got %q", assistantMsg.ToolCalls[0].Function.Name)
|
|
}
|
|
|
|
// Check the tool response message
|
|
toolMsg := chatReq.Messages[2]
|
|
if toolMsg.Role != "tool" {
|
|
t.Errorf("expected role 'tool', got %q", toolMsg.Role)
|
|
}
|
|
if toolMsg.Content != "sunny, 72F" {
|
|
t.Errorf("expected content 'sunny, 72F', got %q", toolMsg.Content)
|
|
}
|
|
if toolMsg.ToolCallID != "call_abc123" {
|
|
t.Errorf("expected ToolCallID 'call_abc123', got %q", toolMsg.ToolCallID)
|
|
}
|
|
if toolMsg.ToolName != "" {
|
|
t.Errorf("paired output name %q would override the original call's name", toolMsg.ToolName)
|
|
}
|
|
}
|
|
|
|
func TestFromResponsesRequest_FunctionCallOutputContentArray(t *testing.T) {
|
|
reqJSON := `{
|
|
"model": "gpt-oss:20b",
|
|
"input": [
|
|
{"type": "message", "role": "user", "content": [{"type": "input_text", "text": "what is the weather?"}]},
|
|
{"type": "function_call", "call_id": "call_abc123", "name": "get_weather", "arguments": "{\"city\":\"Paris\"}"},
|
|
{"type": "function_call_output", "call_id": "call_abc123", "output": [{"type": "input_text", "text": "sunny"}, {"type": "input_text", "text": ", 72F"}]}
|
|
]
|
|
}`
|
|
|
|
var req ResponsesRequest
|
|
if err := json.Unmarshal([]byte(reqJSON), &req); err != nil {
|
|
t.Fatalf("failed to unmarshal request: %v", err)
|
|
}
|
|
|
|
chatReq, err := FromResponsesRequest(req)
|
|
if err != nil {
|
|
t.Fatalf("failed to convert request: %v", err)
|
|
}
|
|
|
|
if len(chatReq.Messages) != 3 {
|
|
t.Fatalf("expected 3 messages, got %d", len(chatReq.Messages))
|
|
}
|
|
|
|
toolMsg := chatReq.Messages[2]
|
|
if toolMsg.Role != "tool" {
|
|
t.Errorf("expected role 'tool', got %q", toolMsg.Role)
|
|
}
|
|
if toolMsg.Content != "sunny, 72F" {
|
|
t.Errorf("expected content 'sunny, 72F', got %q", toolMsg.Content)
|
|
}
|
|
if toolMsg.ToolCallID != "call_abc123" {
|
|
t.Errorf("expected ToolCallID 'call_abc123', got %q", toolMsg.ToolCallID)
|
|
}
|
|
}
|
|
|
|
func TestFromResponsesRequest_FunctionCallOutputContentArrayWithImage(t *testing.T) {
|
|
// 1x1 red PNG pixel
|
|
pngBase64 := "iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mP8z8DwHwAFBQIAX8jx0gAAAABJRU5ErkJggg=="
|
|
|
|
reqJSON := `{
|
|
"model": "gpt-oss:20b",
|
|
"input": [
|
|
{"type": "message", "role": "user", "content": [{"type": "input_text", "text": "inspect the image"}]},
|
|
{"type": "function_call", "call_id": "call_abc123", "name": "inspect_image", "arguments": "{}"},
|
|
{"type": "function_call_output", "call_id": "call_abc123", "output": [
|
|
{"type": "input_text", "text": "attached image"},
|
|
{"type": "input_image", "detail": "auto", "image_url": "data:image/png;base64,` + pngBase64 + `"}
|
|
]}
|
|
]
|
|
}`
|
|
|
|
var req ResponsesRequest
|
|
if err := json.Unmarshal([]byte(reqJSON), &req); err != nil {
|
|
t.Fatalf("failed to unmarshal request: %v", err)
|
|
}
|
|
|
|
chatReq, err := FromResponsesRequest(req)
|
|
if err != nil {
|
|
t.Fatalf("failed to convert request: %v", err)
|
|
}
|
|
|
|
if len(chatReq.Messages) != 3 {
|
|
t.Fatalf("expected 3 messages, got %d", len(chatReq.Messages))
|
|
}
|
|
|
|
toolMsg := chatReq.Messages[2]
|
|
if toolMsg.Role != "tool" {
|
|
t.Errorf("expected role 'tool', got %q", toolMsg.Role)
|
|
}
|
|
if toolMsg.Content != "attached image" {
|
|
t.Errorf("expected content 'attached image', got %q", toolMsg.Content)
|
|
}
|
|
if len(toolMsg.Images) != 1 {
|
|
t.Fatalf("expected 1 image, got %d", len(toolMsg.Images))
|
|
}
|
|
if len(toolMsg.Images[0]) == 0 {
|
|
t.Error("expected non-empty image data")
|
|
}
|
|
if toolMsg.ToolCallID != "call_abc123" {
|
|
t.Errorf("expected ToolCallID 'call_abc123', got %q", toolMsg.ToolCallID)
|
|
}
|
|
}
|
|
|
|
func TestFromResponsesRequest_FunctionCallMerge(t *testing.T) {
|
|
t.Run("function call merges with preceding assistant message", func(t *testing.T) {
|
|
// When assistant message has content followed by function_call,
|
|
// they should be merged into a single message
|
|
reqJSON := `{
|
|
"model": "gpt-oss:20b",
|
|
"input": [
|
|
{"type": "message", "role": "user", "content": [{"type": "input_text", "text": "what is the weather?"}]},
|
|
{"type": "message", "role": "assistant", "content": [{"type": "output_text", "text": "I'll check the weather for you."}]},
|
|
{"type": "function_call", "call_id": "call_abc123", "name": "get_weather", "arguments": "{\"city\":\"Paris\"}"}
|
|
]
|
|
}`
|
|
|
|
var req ResponsesRequest
|
|
if err := json.Unmarshal([]byte(reqJSON), &req); err != nil {
|
|
t.Fatalf("failed to unmarshal request: %v", err)
|
|
}
|
|
|
|
chatReq, err := FromResponsesRequest(req)
|
|
if err != nil {
|
|
t.Fatalf("failed to convert request: %v", err)
|
|
}
|
|
|
|
// Should have 2 messages: user and assistant (with content + tool call merged)
|
|
if len(chatReq.Messages) != 2 {
|
|
t.Fatalf("expected 2 messages, got %d", len(chatReq.Messages))
|
|
}
|
|
|
|
// Check user message
|
|
if chatReq.Messages[0].Role != "user" {
|
|
t.Errorf("Messages[0].Role = %q, want %q", chatReq.Messages[0].Role, "user")
|
|
}
|
|
|
|
// Check assistant message has both content and tool call
|
|
assistantMsg := chatReq.Messages[1]
|
|
if assistantMsg.Role != "assistant" {
|
|
t.Errorf("Messages[1].Role = %q, want %q", assistantMsg.Role, "assistant")
|
|
}
|
|
if assistantMsg.Content != "I'll check the weather for you." {
|
|
t.Errorf("Messages[1].Content = %q, want %q", assistantMsg.Content, "I'll check the weather for you.")
|
|
}
|
|
if len(assistantMsg.ToolCalls) != 1 {
|
|
t.Fatalf("expected 1 tool call, got %d", len(assistantMsg.ToolCalls))
|
|
}
|
|
if assistantMsg.ToolCalls[0].Function.Name != "get_weather" {
|
|
t.Errorf("ToolCalls[0].Function.Name = %q, want %q", assistantMsg.ToolCalls[0].Function.Name, "get_weather")
|
|
}
|
|
})
|
|
|
|
t.Run("function call without preceding assistant creates new message", func(t *testing.T) {
|
|
// When there's no preceding assistant message, function_call creates its own message
|
|
reqJSON := `{
|
|
"model": "gpt-oss:20b",
|
|
"input": [
|
|
{"type": "message", "role": "user", "content": [{"type": "input_text", "text": "what is the weather?"}]},
|
|
{"type": "function_call", "call_id": "call_abc123", "name": "get_weather", "arguments": "{\"city\":\"Paris\"}"}
|
|
]
|
|
}`
|
|
|
|
var req ResponsesRequest
|
|
if err := json.Unmarshal([]byte(reqJSON), &req); err != nil {
|
|
t.Fatalf("failed to unmarshal request: %v", err)
|
|
}
|
|
|
|
chatReq, err := FromResponsesRequest(req)
|
|
if err != nil {
|
|
t.Fatalf("failed to convert request: %v", err)
|
|
}
|
|
|
|
// Should have 2 messages: user and assistant (tool call only)
|
|
if len(chatReq.Messages) != 2 {
|
|
t.Fatalf("expected 2 messages, got %d", len(chatReq.Messages))
|
|
}
|
|
|
|
// Check assistant message has tool call but no content
|
|
assistantMsg := chatReq.Messages[1]
|
|
if assistantMsg.Role != "assistant" {
|
|
t.Errorf("Messages[1].Role = %q, want %q", assistantMsg.Role, "assistant")
|
|
}
|
|
if assistantMsg.Content != "" {
|
|
t.Errorf("Messages[1].Content = %q, want empty", assistantMsg.Content)
|
|
}
|
|
if len(assistantMsg.ToolCalls) != 1 {
|
|
t.Fatalf("expected 1 tool call, got %d", len(assistantMsg.ToolCalls))
|
|
}
|
|
})
|
|
|
|
t.Run("multiple function calls merge into same assistant message", func(t *testing.T) {
|
|
// Multiple consecutive function_calls should all merge into the same assistant message
|
|
reqJSON := `{
|
|
"model": "gpt-oss:20b",
|
|
"input": [
|
|
{"type": "message", "role": "user", "content": [{"type": "input_text", "text": "check weather and time"}]},
|
|
{"type": "message", "role": "assistant", "content": [{"type": "output_text", "text": "I'll check both."}]},
|
|
{"type": "function_call", "call_id": "call_1", "name": "get_weather", "arguments": "{\"city\":\"Paris\"}"},
|
|
{"type": "function_call", "call_id": "call_2", "name": "get_time", "arguments": "{\"city\":\"Paris\"}"}
|
|
]
|
|
}`
|
|
|
|
var req ResponsesRequest
|
|
if err := json.Unmarshal([]byte(reqJSON), &req); err != nil {
|
|
t.Fatalf("failed to unmarshal request: %v", err)
|
|
}
|
|
|
|
chatReq, err := FromResponsesRequest(req)
|
|
if err != nil {
|
|
t.Fatalf("failed to convert request: %v", err)
|
|
}
|
|
|
|
// Should have 2 messages: user and assistant (content + both tool calls)
|
|
if len(chatReq.Messages) != 2 {
|
|
t.Fatalf("expected 2 messages, got %d", len(chatReq.Messages))
|
|
}
|
|
|
|
// Assistant has content + both tool calls
|
|
assistantMsg := chatReq.Messages[1]
|
|
if assistantMsg.Content != "I'll check both." {
|
|
t.Errorf("Messages[1].Content = %q, want %q", assistantMsg.Content, "I'll check both.")
|
|
}
|
|
if len(assistantMsg.ToolCalls) != 2 {
|
|
t.Fatalf("expected 2 tool calls, got %d", len(assistantMsg.ToolCalls))
|
|
}
|
|
if assistantMsg.ToolCalls[0].Function.Name != "get_weather" {
|
|
t.Errorf("ToolCalls[0].Function.Name = %q, want %q", assistantMsg.ToolCalls[0].Function.Name, "get_weather")
|
|
}
|
|
if assistantMsg.ToolCalls[1].Function.Name != "get_time" {
|
|
t.Errorf("ToolCalls[1].Function.Name = %q, want %q", assistantMsg.ToolCalls[1].Function.Name, "get_time")
|
|
}
|
|
})
|
|
|
|
t.Run("new assistant message starts fresh tool call group", func(t *testing.T) {
|
|
// assistant → tool_call → tool_call → assistant → tool_call
|
|
// Should result in 2 assistant messages with their respective tool calls
|
|
reqJSON := `{
|
|
"model": "gpt-oss:20b",
|
|
"input": [
|
|
{"type": "message", "role": "user", "content": [{"type": "input_text", "text": "do multiple things"}]},
|
|
{"type": "message", "role": "assistant", "content": [{"type": "output_text", "text": "First batch."}]},
|
|
{"type": "function_call", "call_id": "call_1", "name": "func_a", "arguments": "{}"},
|
|
{"type": "function_call", "call_id": "call_2", "name": "func_b", "arguments": "{}"},
|
|
{"type": "message", "role": "assistant", "content": [{"type": "output_text", "text": "Second batch."}]},
|
|
{"type": "function_call", "call_id": "call_3", "name": "func_c", "arguments": "{}"}
|
|
]
|
|
}`
|
|
|
|
var req ResponsesRequest
|
|
if err := json.Unmarshal([]byte(reqJSON), &req); err != nil {
|
|
t.Fatalf("failed to unmarshal request: %v", err)
|
|
}
|
|
|
|
chatReq, err := FromResponsesRequest(req)
|
|
if err != nil {
|
|
t.Fatalf("failed to convert request: %v", err)
|
|
}
|
|
|
|
// Should have 3 messages:
|
|
// 1. user
|
|
// 2. assistant "First batch." + tool calls [func_a, func_b]
|
|
// 3. assistant "Second batch." + tool calls [func_c]
|
|
if len(chatReq.Messages) != 3 {
|
|
t.Fatalf("expected 3 messages, got %d", len(chatReq.Messages))
|
|
}
|
|
|
|
asst1 := chatReq.Messages[1]
|
|
if asst1.Content != "First batch." {
|
|
t.Errorf("Messages[1].Content = %q, want %q", asst1.Content, "First batch.")
|
|
}
|
|
if len(asst1.ToolCalls) != 2 {
|
|
t.Fatalf("expected 2 tool calls in Messages[1], got %d", len(asst1.ToolCalls))
|
|
}
|
|
if asst1.ToolCalls[0].Function.Name != "func_a" {
|
|
t.Errorf("Messages[1].ToolCalls[0] = %q, want %q", asst1.ToolCalls[0].Function.Name, "func_a")
|
|
}
|
|
if asst1.ToolCalls[1].Function.Name != "func_b" {
|
|
t.Errorf("Messages[1].ToolCalls[1] = %q, want %q", asst1.ToolCalls[1].Function.Name, "func_b")
|
|
}
|
|
|
|
asst2 := chatReq.Messages[2]
|
|
if asst2.Content != "Second batch." {
|
|
t.Errorf("Messages[2].Content = %q, want %q", asst2.Content, "Second batch.")
|
|
}
|
|
if len(asst2.ToolCalls) != 1 {
|
|
t.Fatalf("expected 1 tool call in Messages[2], got %d", len(asst2.ToolCalls))
|
|
}
|
|
if asst2.ToolCalls[0].Function.Name != "func_c" {
|
|
t.Errorf("Messages[2].ToolCalls[0] = %q, want %q", asst2.ToolCalls[0].Function.Name, "func_c")
|
|
}
|
|
})
|
|
|
|
t.Run("function call merges with assistant that has thinking", func(t *testing.T) {
|
|
// reasoning → assistant (gets thinking) → function_call → should merge
|
|
reqJSON := `{
|
|
"model": "gpt-oss:20b",
|
|
"input": [
|
|
{"type": "message", "role": "user", "content": [{"type": "input_text", "text": "think and act"}]},
|
|
{"type": "reasoning", "id": "rs_1", "encrypted_content": "Let me think...", "summary": [{"type":"summary_text","text":"Let me think..."}]},
|
|
{"type": "message", "role": "assistant", "content": [{"type": "output_text", "text": "I thought about it."}]},
|
|
{"type": "function_call", "call_id": "call_1", "name": "do_thing", "arguments": "{}"}
|
|
]
|
|
}`
|
|
|
|
var req ResponsesRequest
|
|
if err := json.Unmarshal([]byte(reqJSON), &req); err != nil {
|
|
t.Fatalf("failed to unmarshal request: %v", err)
|
|
}
|
|
|
|
chatReq, err := FromResponsesRequest(req)
|
|
if err != nil {
|
|
t.Fatalf("failed to convert request: %v", err)
|
|
}
|
|
|
|
// Should have 2 messages: user and assistant (thinking + content + tool call)
|
|
if len(chatReq.Messages) != 2 {
|
|
t.Fatalf("expected 2 messages, got %d", len(chatReq.Messages))
|
|
}
|
|
|
|
asst := chatReq.Messages[1]
|
|
if asst.Thinking != "Let me think..." {
|
|
t.Errorf("Messages[1].Thinking = %q, want %q", asst.Thinking, "Let me think...")
|
|
}
|
|
if asst.Content != "I thought about it." {
|
|
t.Errorf("Messages[1].Content = %q, want %q", asst.Content, "I thought about it.")
|
|
}
|
|
if len(asst.ToolCalls) != 1 {
|
|
t.Fatalf("expected 1 tool call, got %d", len(asst.ToolCalls))
|
|
}
|
|
if asst.ToolCalls[0].Function.Name != "do_thing" {
|
|
t.Errorf("ToolCalls[0].Function.Name = %q, want %q", asst.ToolCalls[0].Function.Name, "do_thing")
|
|
}
|
|
})
|
|
|
|
t.Run("mixed thinking and content with multiple tool calls", func(t *testing.T) {
|
|
// Test:
|
|
// 1. reasoning → assistant (empty content, gets thinking) → tc (merges)
|
|
// 2. assistant with content → tc → tc (both merge)
|
|
// Result: 2 assistant messages
|
|
reqJSON := `{
|
|
"model": "gpt-oss:20b",
|
|
"input": [
|
|
{"type": "message", "role": "user", "content": [{"type": "input_text", "text": "complex task"}]},
|
|
{"type": "reasoning", "id": "rs_1", "encrypted_content": "Thinking first...", "summary": [{"type":"summary_text","text":"Thinking first..."}]},
|
|
{"type": "message", "role": "assistant", "content": ""},
|
|
{"type": "function_call", "call_id": "call_1", "name": "think_action", "arguments": "{}"},
|
|
{"type": "message", "role": "assistant", "content": [{"type": "output_text", "text": "Now doing more."}]},
|
|
{"type": "function_call", "call_id": "call_2", "name": "action_a", "arguments": "{}"},
|
|
{"type": "function_call", "call_id": "call_3", "name": "action_b", "arguments": "{}"}
|
|
]
|
|
}`
|
|
|
|
var req ResponsesRequest
|
|
if err := json.Unmarshal([]byte(reqJSON), &req); err != nil {
|
|
t.Fatalf("failed to unmarshal request: %v", err)
|
|
}
|
|
|
|
chatReq, err := FromResponsesRequest(req)
|
|
if err != nil {
|
|
t.Fatalf("failed to convert request: %v", err)
|
|
}
|
|
|
|
// Should have 3 messages:
|
|
// 1. user
|
|
// 2. assistant with thinking + tool call [think_action]
|
|
// 3. assistant with content "Now doing more." + tool calls [action_a, action_b]
|
|
if len(chatReq.Messages) != 3 {
|
|
t.Fatalf("expected 3 messages, got %d", len(chatReq.Messages))
|
|
}
|
|
|
|
// First assistant: thinking + tool call
|
|
asst1 := chatReq.Messages[1]
|
|
if asst1.Thinking != "Thinking first..." {
|
|
t.Errorf("Messages[1].Thinking = %q, want %q", asst1.Thinking, "Thinking first...")
|
|
}
|
|
if asst1.Content != "" {
|
|
t.Errorf("Messages[1].Content = %q, want empty", asst1.Content)
|
|
}
|
|
if len(asst1.ToolCalls) != 1 {
|
|
t.Fatalf("expected 1 tool call in Messages[1], got %d", len(asst1.ToolCalls))
|
|
}
|
|
if asst1.ToolCalls[0].Function.Name != "think_action" {
|
|
t.Errorf("Messages[1].ToolCalls[0] = %q, want %q", asst1.ToolCalls[0].Function.Name, "think_action")
|
|
}
|
|
|
|
// Second assistant: content + 2 tool calls
|
|
asst2 := chatReq.Messages[2]
|
|
if asst2.Content != "Now doing more." {
|
|
t.Errorf("Messages[2].Content = %q, want %q", asst2.Content, "Now doing more.")
|
|
}
|
|
if len(asst2.ToolCalls) != 2 {
|
|
t.Fatalf("expected 2 tool calls in Messages[2], got %d", len(asst2.ToolCalls))
|
|
}
|
|
if asst2.ToolCalls[0].Function.Name != "action_a" {
|
|
t.Errorf("Messages[2].ToolCalls[0] = %q, want %q", asst2.ToolCalls[0].Function.Name, "action_a")
|
|
}
|
|
if asst2.ToolCalls[1].Function.Name != "action_b" {
|
|
t.Errorf("Messages[2].ToolCalls[1] = %q, want %q", asst2.ToolCalls[1].Function.Name, "action_b")
|
|
}
|
|
})
|
|
}
|
|
|
|
func TestDecodeImageURL(t *testing.T) {
|
|
// Valid PNG base64 (1x1 red pixel)
|
|
validPNG := "data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mP8z8DwHwAFBQIAX8jx0gAAAABJRU5ErkJggg=="
|
|
|
|
t.Run("valid png", func(t *testing.T) {
|
|
img, err := decodeImageURL(validPNG)
|
|
if err != nil {
|
|
t.Fatalf("unexpected error: %v", err)
|
|
}
|
|
if len(img) == 0 {
|
|
t.Error("expected non-empty image data")
|
|
}
|
|
})
|
|
|
|
t.Run("valid jpeg", func(t *testing.T) {
|
|
// Just test the prefix validation with minimal base64
|
|
_, err := decodeImageURL("data:image/jpeg;base64,/9j/4AAQSkZJRg==")
|
|
if err != nil {
|
|
t.Fatalf("unexpected error: %v", err)
|
|
}
|
|
})
|
|
|
|
t.Run("blank mime type", func(t *testing.T) {
|
|
_, err := decodeImageURL("data:;base64,dGVzdA==")
|
|
if err != nil {
|
|
t.Fatalf("unexpected error: %v", err)
|
|
}
|
|
})
|
|
|
|
t.Run("invalid mime type", func(t *testing.T) {
|
|
_, err := decodeImageURL("data:image/gif;base64,R0lGODlhAQABAIAAAAAAAP///yH5BAEAAAAALAAAAAABAAEAAAIBRAA7")
|
|
if err == nil {
|
|
t.Error("expected error for unsupported mime type")
|
|
}
|
|
})
|
|
|
|
t.Run("invalid base64", func(t *testing.T) {
|
|
_, err := decodeImageURL("data:image/png;base64,not-valid-base64!")
|
|
if err == nil {
|
|
t.Error("expected error for invalid base64")
|
|
}
|
|
})
|
|
|
|
t.Run("not a data url", func(t *testing.T) {
|
|
_, err := decodeImageURL("https://example.com/image.png")
|
|
if err == nil {
|
|
t.Error("expected error for non-data URL")
|
|
}
|
|
})
|
|
}
|
|
|
|
func TestFromResponsesRequest_Images(t *testing.T) {
|
|
// 1x1 red PNG pixel
|
|
pngBase64 := "iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mP8z8DwHwAFBQIAX8jx0gAAAABJRU5ErkJggg=="
|
|
|
|
reqJSON := `{
|
|
"model": "llava",
|
|
"input": [
|
|
{"type": "message", "role": "user", "content": [
|
|
{"type": "input_text", "text": "What is in this image?"},
|
|
{"type": "input_image", "detail": "auto", "image_url": "data:image/png;base64,` + pngBase64 + `"}
|
|
]}
|
|
]
|
|
}`
|
|
|
|
var req ResponsesRequest
|
|
if err := json.Unmarshal([]byte(reqJSON), &req); err != nil {
|
|
t.Fatalf("failed to unmarshal request: %v", err)
|
|
}
|
|
|
|
chatReq, err := FromResponsesRequest(req)
|
|
if err != nil {
|
|
t.Fatalf("failed to convert request: %v", err)
|
|
}
|
|
|
|
if len(chatReq.Messages) != 1 {
|
|
t.Fatalf("expected 1 message, got %d", len(chatReq.Messages))
|
|
}
|
|
|
|
msg := chatReq.Messages[0]
|
|
if msg.Role != "user" {
|
|
t.Errorf("expected role 'user', got %q", msg.Role)
|
|
}
|
|
if msg.Content != "What is in this image?" {
|
|
t.Errorf("expected content 'What is in this image?', got %q", msg.Content)
|
|
}
|
|
if len(msg.Images) != 1 {
|
|
t.Fatalf("expected 1 image, got %d", len(msg.Images))
|
|
}
|
|
if len(msg.Images[0]) == 0 {
|
|
t.Error("expected non-empty image data")
|
|
}
|
|
}
|
|
|
|
func TestResponsesStreamConverter_TextOnly(t *testing.T) {
|
|
converter := NewResponsesStreamConverter("resp_123", "msg_456", "gpt-oss:20b", ResponsesRequest{})
|
|
|
|
// First chunk with content
|
|
events := converter.Process(api.ChatResponse{
|
|
Message: api.Message{
|
|
Content: "Hello",
|
|
},
|
|
})
|
|
|
|
// Should have: response.created, response.in_progress, output_item.added, content_part.added, output_text.delta
|
|
if len(events) != 5 {
|
|
t.Fatalf("expected 5 events, got %d", len(events))
|
|
}
|
|
|
|
if events[0].Event != "response.created" {
|
|
t.Errorf("events[0].Event = %q, want %q", events[0].Event, "response.created")
|
|
}
|
|
if events[1].Event != "response.in_progress" {
|
|
t.Errorf("events[1].Event = %q, want %q", events[1].Event, "response.in_progress")
|
|
}
|
|
if events[2].Event != "response.output_item.added" {
|
|
t.Errorf("events[2].Event = %q, want %q", events[2].Event, "response.output_item.added")
|
|
}
|
|
if events[3].Event != "response.content_part.added" {
|
|
t.Errorf("events[3].Event = %q, want %q", events[3].Event, "response.content_part.added")
|
|
}
|
|
if events[4].Event != "response.output_text.delta" {
|
|
t.Errorf("events[4].Event = %q, want %q", events[4].Event, "response.output_text.delta")
|
|
}
|
|
|
|
// Second chunk with more content
|
|
events = converter.Process(api.ChatResponse{
|
|
Message: api.Message{
|
|
Content: " World",
|
|
},
|
|
})
|
|
|
|
// Should only have output_text.delta (no more created/in_progress/added)
|
|
if len(events) != 1 {
|
|
t.Fatalf("expected 1 event, got %d", len(events))
|
|
}
|
|
if events[0].Event != "response.output_text.delta" {
|
|
t.Errorf("events[0].Event = %q, want %q", events[0].Event, "response.output_text.delta")
|
|
}
|
|
|
|
// Final chunk
|
|
events = converter.Process(api.ChatResponse{
|
|
Message: api.Message{},
|
|
Done: true,
|
|
})
|
|
|
|
// Should have: output_text.done, content_part.done, output_item.done, response.completed
|
|
if len(events) != 4 {
|
|
t.Fatalf("expected 4 events, got %d", len(events))
|
|
}
|
|
if events[0].Event != "response.output_text.done" {
|
|
t.Errorf("events[0].Event = %q, want %q", events[0].Event, "response.output_text.done")
|
|
}
|
|
// Check that accumulated text is present
|
|
data := events[0].Data.(map[string]any)
|
|
if data["text"] != "Hello World" {
|
|
t.Errorf("accumulated text = %q, want %q", data["text"], "Hello World")
|
|
}
|
|
}
|
|
|
|
func TestResponsesStreamConverter_ToolCalls(t *testing.T) {
|
|
converter := NewResponsesStreamConverter("resp_123", "msg_456", "gpt-oss:20b", ResponsesRequest{})
|
|
|
|
events := converter.Process(api.ChatResponse{
|
|
Message: api.Message{
|
|
ToolCalls: []api.ToolCall{
|
|
{
|
|
ID: "call_abc",
|
|
Function: api.ToolCallFunction{
|
|
Name: "get_weather",
|
|
Arguments: testArgs(map[string]any{"city": "Paris"}),
|
|
},
|
|
},
|
|
},
|
|
},
|
|
})
|
|
|
|
// Should have: created, in_progress, output_item.added, arguments.delta, arguments.done, output_item.done
|
|
if len(events) != 6 {
|
|
t.Fatalf("expected 6 events, got %d", len(events))
|
|
}
|
|
|
|
if events[2].Event != "response.output_item.added" {
|
|
t.Errorf("events[2].Event = %q, want %q", events[2].Event, "response.output_item.added")
|
|
}
|
|
if events[3].Event != "response.function_call_arguments.delta" {
|
|
t.Errorf("events[3].Event = %q, want %q", events[3].Event, "response.function_call_arguments.delta")
|
|
}
|
|
if events[4].Event != "response.function_call_arguments.done" {
|
|
t.Errorf("events[4].Event = %q, want %q", events[4].Event, "response.function_call_arguments.done")
|
|
}
|
|
if events[5].Event != "response.output_item.done" {
|
|
t.Errorf("events[5].Event = %q, want %q", events[5].Event, "response.output_item.done")
|
|
}
|
|
}
|
|
|
|
func TestResponsesStreamConverter_EmitFunctionCallItems(t *testing.T) {
|
|
converter := NewResponsesStreamConverter("resp_test", "msg_test", "test", ResponsesRequest{})
|
|
events := converter.Process(api.ChatResponse{Message: api.Message{Thinking: "Check both cities."}})
|
|
events = append(events, converter.EmitFunctionCallItems([]api.ToolCall{
|
|
{ID: "call_paris", Function: api.ToolCallFunction{Name: "get_weather", Arguments: testArgs(map[string]any{"city": "Paris"})}},
|
|
{ID: "call_london", Function: api.ToolCallFunction{Name: "get_weather", Arguments: testArgs(map[string]any{"city": "London"})}},
|
|
})...)
|
|
events = append(events, converter.Process(api.ChatResponse{Message: api.Message{Content: "The weather is ready."}, Done: true})...)
|
|
|
|
var addedIDs, completed, output []any
|
|
for _, event := range events {
|
|
data := event.Data.(map[string]any)
|
|
switch event.Event {
|
|
case "response.output_item.added":
|
|
if data["output_index"] != len(addedIDs) {
|
|
t.Errorf("added output_index = %v, want %d", data["output_index"], len(addedIDs))
|
|
}
|
|
addedIDs = append(addedIDs, data["item"].(map[string]any)["id"])
|
|
case "response.output_item.done":
|
|
item := data["item"].(map[string]any)
|
|
index := len(completed)
|
|
if data["output_index"] != index || index >= len(addedIDs) || item["id"] != addedIDs[index] {
|
|
t.Errorf("item completed out of order or at the wrong index: %#v", data)
|
|
}
|
|
completed = append(completed, item)
|
|
case "response.completed":
|
|
output = data["response"].(map[string]any)["output"].([]any)
|
|
}
|
|
}
|
|
wantTypes := []string{"reasoning", "function_call", "function_call", "message"}
|
|
if len(addedIDs) != len(wantTypes) || len(completed) != len(wantTypes) {
|
|
t.Fatalf("added %d and completed %d items, want %d", len(addedIDs), len(completed), len(wantTypes))
|
|
}
|
|
for i, want := range wantTypes {
|
|
if got := completed[i].(map[string]any)["type"]; got != want {
|
|
t.Errorf("completed item %d type = %v, want %s", i, got, want)
|
|
}
|
|
}
|
|
if !reflect.DeepEqual(output, completed) {
|
|
t.Fatalf("final output = %#v, want completed items %#v", output, completed)
|
|
}
|
|
if got := output[3].(map[string]any)["content"].([]map[string]any)[0]["text"]; got != "The weather is ready." {
|
|
t.Errorf("text after client calls = %v", got)
|
|
}
|
|
}
|
|
|
|
func TestResponsesStreamConverter_ToolSearchCall(t *testing.T) {
|
|
request := ResponsesRequest{Tools: []ResponsesTool{{Type: "tool_search", Execution: "client"}}}
|
|
converter := NewResponsesStreamConverter("resp_123", "msg_456", "test", request)
|
|
events := converter.Process(api.ChatResponse{
|
|
Message: api.Message{ToolCalls: []api.ToolCall{{
|
|
ID: "call_search",
|
|
Function: api.ToolCallFunction{
|
|
Name: "tool_search",
|
|
Arguments: testArgs(map[string]any{"query": "orders", "limit": 5}),
|
|
},
|
|
}}},
|
|
})
|
|
|
|
if len(events) != 4 {
|
|
t.Fatalf("events = %#v; want created, in_progress, item added, and item done", events)
|
|
}
|
|
if events[2].Event != "response.output_item.added" || events[3].Event != "response.output_item.done" {
|
|
t.Fatalf("events = %#v", events)
|
|
}
|
|
item := events[3].Data.(map[string]any)["item"].(map[string]any)
|
|
if item["type"] != "tool_search_call" || item["call_id"] != "call_search" || item["execution"] != "client" || item["status"] != "completed" ||
|
|
!strings.HasPrefix(item["id"].(string), "tsc_") {
|
|
t.Fatalf("item = %#v", item)
|
|
}
|
|
encoded, err := json.Marshal(item)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
var wire struct {
|
|
Arguments map[string]any `json:"arguments"`
|
|
}
|
|
if err := json.Unmarshal(encoded, &wire); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if wire.Arguments["query"] != "orders" || wire.Arguments["limit"] != float64(5) {
|
|
t.Fatalf("arguments = %#v", wire.Arguments)
|
|
}
|
|
for _, event := range events {
|
|
if event.Event == "response.function_call_arguments.delta" || event.Event == "response.function_call_arguments.done" {
|
|
t.Fatalf("tool_search_call must not use function_call argument events: %#v", events)
|
|
}
|
|
}
|
|
}
|
|
|
|
func TestResponsesStreamConverter_Reasoning(t *testing.T) {
|
|
converter := NewResponsesStreamConverter("resp_123", "msg_456", "gpt-oss:20b", ResponsesRequest{})
|
|
|
|
// First chunk with thinking
|
|
events := converter.Process(api.ChatResponse{
|
|
Message: api.Message{
|
|
Thinking: "Let me think...",
|
|
},
|
|
})
|
|
|
|
// Should have: created, in_progress, output_item.added (reasoning), reasoning_summary_text.delta
|
|
if len(events) != 4 {
|
|
t.Fatalf("expected 4 events, got %d", len(events))
|
|
}
|
|
|
|
if events[2].Event != "response.output_item.added" {
|
|
t.Errorf("events[2].Event = %q, want %q", events[2].Event, "response.output_item.added")
|
|
}
|
|
// Check it's a reasoning item
|
|
data := events[2].Data.(map[string]any)
|
|
item := data["item"].(map[string]any)
|
|
if item["type"] != "reasoning" {
|
|
t.Errorf("item type = %q, want %q", item["type"], "reasoning")
|
|
}
|
|
|
|
if events[3].Event != "response.reasoning_summary_text.delta" {
|
|
t.Errorf("events[3].Event = %q, want %q", events[3].Event, "response.reasoning_summary_text.delta")
|
|
}
|
|
|
|
// Second chunk with text content (reasoning should close first)
|
|
events = converter.Process(api.ChatResponse{
|
|
Message: api.Message{
|
|
Content: "The answer is 42",
|
|
},
|
|
})
|
|
|
|
// Should have: reasoning_summary_text.done, output_item.done (reasoning), output_item.added (message), content_part.added, output_text.delta
|
|
if len(events) != 5 {
|
|
t.Fatalf("expected 5 events, got %d", len(events))
|
|
}
|
|
|
|
if events[0].Event != "response.reasoning_summary_text.done" {
|
|
t.Errorf("events[0].Event = %q, want %q", events[0].Event, "response.reasoning_summary_text.done")
|
|
}
|
|
if events[1].Event != "response.output_item.done" {
|
|
t.Errorf("events[1].Event = %q, want %q", events[1].Event, "response.output_item.done")
|
|
}
|
|
// Check the reasoning done item has encrypted_content
|
|
doneData := events[1].Data.(map[string]any)
|
|
doneItem := doneData["item"].(map[string]any)
|
|
if doneItem["encrypted_content"] != "Let me think..." {
|
|
t.Errorf("encrypted_content = %q, want %q", doneItem["encrypted_content"], "Let me think...")
|
|
}
|
|
}
|
|
|
|
func TestFromResponsesRequest_ReasoningMerge(t *testing.T) {
|
|
t.Run("web search carries consecutive reasoning into the answer", func(t *testing.T) {
|
|
for _, encrypted := range []string{"first thought", "opaque-provider-reasoning"} {
|
|
request := ResponsesRequest{Input: ResponsesInput{Items: []ResponsesInputItem{
|
|
ResponsesInputMessage{Role: "user", Content: []ResponsesContent{ResponsesTextContent{Type: "input_text", Text: "search"}}},
|
|
ResponsesReasoningInput{EncryptedContent: encrypted, Summary: []ResponsesReasoningSummary{{Type: "summary_text", Text: "first thought"}}},
|
|
ResponsesWebSearchCall{Type: "web_search_call"},
|
|
ResponsesReasoningInput{Summary: []ResponsesReasoningSummary{{Type: "summary_text", Text: "second thought"}, {Type: "summary_text", Text: "third thought"}}},
|
|
ResponsesReasoningInput{EncryptedContent: "opaque-without-summary"},
|
|
ResponsesInputMessage{Role: "assistant", Content: []ResponsesContent{ResponsesTextContent{Type: "input_text", Text: "the answer"}}},
|
|
}}}
|
|
chat, err := FromResponsesRequest(request)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
want := []api.Message{{Role: "user", Content: "search"}, {Role: "assistant", Content: "the answer", Thinking: "first thought\nsecond thought\nthird thought"}}
|
|
if !reflect.DeepEqual(chat.Messages, want) {
|
|
t.Fatalf("messages=%+v, want=%+v", chat.Messages, want)
|
|
}
|
|
}
|
|
})
|
|
|
|
t.Run("reasoning preserves a pending tool pair", func(t *testing.T) {
|
|
for _, assistant := range []string{"", `,{"role":"assistant","content":"Checking the result"}`} {
|
|
var req ResponsesRequest
|
|
body := `{"model":"test","input":[
|
|
{"type":"reasoning","encrypted_content":"first native thought","summary":[{"type":"summary_text","text":"first native thought"}]},
|
|
{"type":"function_call","call_id":"call_1","name":"shell","arguments":"{}"},
|
|
{"type":"reasoning","summary":[{"type":"summary_text","text":"Remember kiwi-42"}],"encrypted_content":"opaque"}` + assistant + `,
|
|
{"type":"function_call_output","call_id":"call_1","output":"done"}
|
|
]}`
|
|
if err := json.Unmarshal([]byte(body), &req); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
chat, err := FromResponsesRequest(req)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if len(chat.Messages) != 2 || len(chat.Messages[0].ToolCalls) != 1 || chat.Messages[0].ToolCalls[0].ID != "call_1" || chat.Messages[1].ToolCallID != "call_1" {
|
|
t.Fatalf("tool pair split: %+v", chat.Messages)
|
|
}
|
|
if strings.Contains(chat.Messages[0].Content, "Remember kiwi-42") || chat.Messages[0].Thinking != "first native thought\nRemember kiwi-42" {
|
|
t.Fatalf("reasoning lost: %+v", chat.Messages[0])
|
|
}
|
|
if assistant != "" && !strings.Contains(chat.Messages[0].Content, "Checking the result") {
|
|
t.Fatalf("assistant text lost: %+v", chat.Messages[0])
|
|
}
|
|
}
|
|
})
|
|
t.Run("successive calls preserve both reasoning items", func(t *testing.T) {
|
|
var req ResponsesRequest
|
|
if err := json.Unmarshal([]byte(`{"model":"test","input":[
|
|
{"type":"reasoning","encrypted_content":"first native thought","summary":[{"type":"summary_text","text":"first native thought"}]},
|
|
{"type":"function_call","call_id":"call_1","name":"first","arguments":"{}"},
|
|
{"type":"reasoning","summary":[{"type":"summary_text","text":"second readable thought"}]},
|
|
{"type":"function_call","call_id":"call_2","name":"second","arguments":"{}"}
|
|
]}`), &req); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
chat, err := FromResponsesRequest(req)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if len(chat.Messages) != 1 || len(chat.Messages[0].ToolCalls) != 2 || chat.Messages[0].Thinking != "first native thought\nsecond readable thought" || chat.Messages[0].Content != "" {
|
|
t.Fatalf("reasoning or tool calls lost: %+v", chat.Messages)
|
|
}
|
|
})
|
|
|
|
t.Run("reasoning merged with following message", func(t *testing.T) {
|
|
reqJSON := `{
|
|
"model": "qwen3",
|
|
"input": [
|
|
{"type": "message", "role": "user", "content": [{"type": "input_text", "text": "solve 2+2"}]},
|
|
{"type": "reasoning", "id": "rs_123", "encrypted_content": "Let me think about this math problem...", "summary": [{"type": "summary_text", "text": "Thinking about math"}]},
|
|
{"type": "message", "role": "assistant", "content": [{"type": "input_text", "text": "The answer is 4"}]}
|
|
]
|
|
}`
|
|
|
|
var req ResponsesRequest
|
|
if err := json.Unmarshal([]byte(reqJSON), &req); err != nil {
|
|
t.Fatalf("failed to unmarshal request: %v", err)
|
|
}
|
|
|
|
chatReq, err := FromResponsesRequest(req)
|
|
if err != nil {
|
|
t.Fatalf("failed to convert request: %v", err)
|
|
}
|
|
|
|
// Should have 2 messages: user and assistant (with thinking merged)
|
|
if len(chatReq.Messages) != 2 {
|
|
t.Fatalf("expected 2 messages, got %d", len(chatReq.Messages))
|
|
}
|
|
|
|
// Check user message
|
|
if chatReq.Messages[0].Role != "user" {
|
|
t.Errorf("Messages[0].Role = %q, want %q", chatReq.Messages[0].Role, "user")
|
|
}
|
|
|
|
// Check assistant message has both content and thinking
|
|
assistantMsg := chatReq.Messages[1]
|
|
if assistantMsg.Role != "assistant" {
|
|
t.Errorf("Messages[1].Role = %q, want %q", assistantMsg.Role, "assistant")
|
|
}
|
|
if assistantMsg.Content != "The answer is 4" {
|
|
t.Errorf("Messages[1].Content = %q, want %q", assistantMsg.Content, "The answer is 4")
|
|
}
|
|
if assistantMsg.Thinking != "Thinking about math" {
|
|
t.Errorf("Messages[1].Thinking = %q, want %q", assistantMsg.Thinking, "Thinking about math")
|
|
}
|
|
})
|
|
|
|
t.Run("reasoning merged with following function call", func(t *testing.T) {
|
|
reqJSON := `{
|
|
"model": "qwen3",
|
|
"input": [
|
|
{"type": "message", "role": "user", "content": [{"type": "input_text", "text": "what is the weather?"}]},
|
|
{"type": "reasoning", "id": "rs_123", "encrypted_content": "I need to call a tool for this...", "summary": [{"type":"summary_text","text":"I need to call a tool for this..."}]},
|
|
{"type": "function_call", "call_id": "call_abc", "name": "get_weather", "arguments": "{\"city\":\"Paris\"}"}
|
|
]
|
|
}`
|
|
|
|
var req ResponsesRequest
|
|
if err := json.Unmarshal([]byte(reqJSON), &req); err != nil {
|
|
t.Fatalf("failed to unmarshal request: %v", err)
|
|
}
|
|
|
|
chatReq, err := FromResponsesRequest(req)
|
|
if err != nil {
|
|
t.Fatalf("failed to convert request: %v", err)
|
|
}
|
|
|
|
// Should have 2 messages: user and assistant (with thinking + tool call)
|
|
if len(chatReq.Messages) != 2 {
|
|
t.Fatalf("expected 2 messages, got %d", len(chatReq.Messages))
|
|
}
|
|
|
|
// Check assistant message has both tool call and thinking
|
|
assistantMsg := chatReq.Messages[1]
|
|
if assistantMsg.Role != "assistant" {
|
|
t.Errorf("Messages[1].Role = %q, want %q", assistantMsg.Role, "assistant")
|
|
}
|
|
if assistantMsg.Thinking != "I need to call a tool for this..." {
|
|
t.Errorf("Messages[1].Thinking = %q, want %q", assistantMsg.Thinking, "I need to call a tool for this...")
|
|
}
|
|
if len(assistantMsg.ToolCalls) != 1 {
|
|
t.Fatalf("expected 1 tool call, got %d", len(assistantMsg.ToolCalls))
|
|
}
|
|
if assistantMsg.ToolCalls[0].Function.Name != "get_weather" {
|
|
t.Errorf("ToolCalls[0].Function.Name = %q, want %q", assistantMsg.ToolCalls[0].Function.Name, "get_weather")
|
|
}
|
|
})
|
|
|
|
t.Run("multi-turn conversation with reasoning", func(t *testing.T) {
|
|
// Simulates: user asks -> model thinks + responds -> user follows up
|
|
reqJSON := `{
|
|
"model": "qwen3",
|
|
"input": [
|
|
{"type": "message", "role": "user", "content": [{"type": "input_text", "text": "What is 2+2?"}]},
|
|
{"type": "reasoning", "id": "rs_001", "encrypted_content": "This is a simple arithmetic problem. 2+2=4.", "summary": [{"type": "summary_text", "text": "Calculating 2+2"}]},
|
|
{"type": "message", "role": "assistant", "content": [{"type": "input_text", "text": "The answer is 4."}]},
|
|
{"type": "message", "role": "user", "content": [{"type": "input_text", "text": "Now multiply that by 3"}]}
|
|
]
|
|
}`
|
|
|
|
var req ResponsesRequest
|
|
if err := json.Unmarshal([]byte(reqJSON), &req); err != nil {
|
|
t.Fatalf("failed to unmarshal request: %v", err)
|
|
}
|
|
|
|
chatReq, err := FromResponsesRequest(req)
|
|
if err != nil {
|
|
t.Fatalf("failed to convert request: %v", err)
|
|
}
|
|
|
|
// Should have 3 messages:
|
|
// 1. user: "What is 2+2?"
|
|
// 2. assistant: thinking + "The answer is 4."
|
|
// 3. user: "Now multiply that by 3"
|
|
if len(chatReq.Messages) != 3 {
|
|
t.Fatalf("expected 3 messages, got %d", len(chatReq.Messages))
|
|
}
|
|
|
|
// Check first user message
|
|
if chatReq.Messages[0].Role != "user" || chatReq.Messages[0].Content != "What is 2+2?" {
|
|
t.Errorf("Messages[0] = {Role: %q, Content: %q}, want {Role: \"user\", Content: \"What is 2+2?\"}",
|
|
chatReq.Messages[0].Role, chatReq.Messages[0].Content)
|
|
}
|
|
|
|
// Check assistant message has merged thinking + content
|
|
if chatReq.Messages[1].Role != "assistant" {
|
|
t.Errorf("Messages[1].Role = %q, want \"assistant\"", chatReq.Messages[1].Role)
|
|
}
|
|
if chatReq.Messages[1].Content != "The answer is 4." {
|
|
t.Errorf("Messages[1].Content = %q, want \"The answer is 4.\"", chatReq.Messages[1].Content)
|
|
}
|
|
if chatReq.Messages[1].Thinking != "Calculating 2+2" {
|
|
t.Errorf("Messages[1].Thinking = %q, want \"Calculating 2+2\"",
|
|
chatReq.Messages[1].Thinking)
|
|
}
|
|
|
|
// Check second user message
|
|
if chatReq.Messages[2].Role != "user" || chatReq.Messages[2].Content != "Now multiply that by 3" {
|
|
t.Errorf("Messages[2] = {Role: %q, Content: %q}, want {Role: \"user\", Content: \"Now multiply that by 3\"}",
|
|
chatReq.Messages[2].Role, chatReq.Messages[2].Content)
|
|
}
|
|
})
|
|
|
|
t.Run("multi-turn with tool calls and reasoning", func(t *testing.T) {
|
|
// Simulates: user asks -> model thinks + calls tool -> tool responds -> model thinks + responds -> user follows up
|
|
reqJSON := `{
|
|
"model": "qwen3",
|
|
"input": [
|
|
{"type": "message", "role": "user", "content": [{"type": "input_text", "text": "What is the weather in Paris?"}]},
|
|
{"type": "reasoning", "id": "rs_001", "encrypted_content": "I need to call the weather API for Paris.", "summary": [{"type":"summary_text","text":"I need to call the weather API for Paris."}]},
|
|
{"type": "function_call", "call_id": "call_abc", "name": "get_weather", "arguments": "{\"city\":\"Paris\"}"},
|
|
{"type": "function_call_output", "call_id": "call_abc", "output": "Sunny, 72°F"},
|
|
{"type": "reasoning", "id": "rs_002", "encrypted_content": "The weather API returned sunny and 72°F. I should format this nicely.", "summary": [{"type":"summary_text","text":"The weather API returned sunny and 72°F. I should format this nicely."}]},
|
|
{"type": "message", "role": "assistant", "content": [{"type": "input_text", "text": "It's sunny and 72°F in Paris!"}]},
|
|
{"type": "message", "role": "user", "content": [{"type": "input_text", "text": "What about London?"}]}
|
|
]
|
|
}`
|
|
|
|
var req ResponsesRequest
|
|
if err := json.Unmarshal([]byte(reqJSON), &req); err != nil {
|
|
t.Fatalf("failed to unmarshal request: %v", err)
|
|
}
|
|
|
|
chatReq, err := FromResponsesRequest(req)
|
|
if err != nil {
|
|
t.Fatalf("failed to convert request: %v", err)
|
|
}
|
|
|
|
// Should have 5 messages:
|
|
// 1. user: "What is the weather in Paris?"
|
|
// 2. assistant: thinking + tool call
|
|
// 3. tool: "Sunny, 72°F"
|
|
// 4. assistant: thinking + "It's sunny and 72°F in Paris!"
|
|
// 5. user: "What about London?"
|
|
if len(chatReq.Messages) != 5 {
|
|
t.Fatalf("expected 5 messages, got %d", len(chatReq.Messages))
|
|
}
|
|
|
|
// Message 1: user
|
|
if chatReq.Messages[0].Role != "user" {
|
|
t.Errorf("Messages[0].Role = %q, want \"user\"", chatReq.Messages[0].Role)
|
|
}
|
|
|
|
// Message 2: assistant with thinking + tool call
|
|
if chatReq.Messages[1].Role != "assistant" {
|
|
t.Errorf("Messages[1].Role = %q, want \"assistant\"", chatReq.Messages[1].Role)
|
|
}
|
|
if chatReq.Messages[1].Thinking != "I need to call the weather API for Paris." {
|
|
t.Errorf("Messages[1].Thinking = %q, want \"I need to call the weather API for Paris.\"", chatReq.Messages[1].Thinking)
|
|
}
|
|
if len(chatReq.Messages[1].ToolCalls) != 1 || chatReq.Messages[1].ToolCalls[0].Function.Name != "get_weather" {
|
|
t.Errorf("Messages[1].ToolCalls not as expected")
|
|
}
|
|
|
|
// Message 3: tool response
|
|
if chatReq.Messages[2].Role != "tool" || chatReq.Messages[2].Content != "Sunny, 72°F" {
|
|
t.Errorf("Messages[2] = {Role: %q, Content: %q}, want {Role: \"tool\", Content: \"Sunny, 72°F\"}",
|
|
chatReq.Messages[2].Role, chatReq.Messages[2].Content)
|
|
}
|
|
|
|
// Message 4: assistant with thinking + content
|
|
if chatReq.Messages[3].Role != "assistant" {
|
|
t.Errorf("Messages[3].Role = %q, want \"assistant\"", chatReq.Messages[3].Role)
|
|
}
|
|
if chatReq.Messages[3].Thinking != "The weather API returned sunny and 72°F. I should format this nicely." {
|
|
t.Errorf("Messages[3].Thinking = %q, want correct thinking", chatReq.Messages[3].Thinking)
|
|
}
|
|
if chatReq.Messages[3].Content != "It's sunny and 72°F in Paris!" {
|
|
t.Errorf("Messages[3].Content = %q, want \"It's sunny and 72°F in Paris!\"", chatReq.Messages[3].Content)
|
|
}
|
|
|
|
// Message 5: user follow-up
|
|
if chatReq.Messages[4].Role != "user" || chatReq.Messages[4].Content != "What about London?" {
|
|
t.Errorf("Messages[4] = {Role: %q, Content: %q}, want {Role: \"user\", Content: \"What about London?\"}",
|
|
chatReq.Messages[4].Role, chatReq.Messages[4].Content)
|
|
}
|
|
})
|
|
|
|
t.Run("trailing reasoning creates separate message", func(t *testing.T) {
|
|
reqJSON := `{
|
|
"model": "qwen3",
|
|
"input": [
|
|
{"type": "message", "role": "user", "content": [{"type": "input_text", "text": "think about this"}]},
|
|
{"type": "reasoning", "id": "rs_123", "encrypted_content": "Still thinking...", "summary": [{"type":"summary_text","text":"Still thinking..."}]}
|
|
]
|
|
}`
|
|
|
|
var req ResponsesRequest
|
|
if err := json.Unmarshal([]byte(reqJSON), &req); err != nil {
|
|
t.Fatalf("failed to unmarshal request: %v", err)
|
|
}
|
|
|
|
chatReq, err := FromResponsesRequest(req)
|
|
if err != nil {
|
|
t.Fatalf("failed to convert request: %v", err)
|
|
}
|
|
|
|
// Should have 2 messages: user and assistant (thinking only)
|
|
if len(chatReq.Messages) != 2 {
|
|
t.Fatalf("expected 2 messages, got %d", len(chatReq.Messages))
|
|
}
|
|
|
|
// Check assistant message has only thinking
|
|
assistantMsg := chatReq.Messages[1]
|
|
if assistantMsg.Role != "assistant" {
|
|
t.Errorf("Messages[1].Role = %q, want %q", assistantMsg.Role, "assistant")
|
|
}
|
|
if assistantMsg.Thinking != "Still thinking..." {
|
|
t.Errorf("Messages[1].Thinking = %q, want %q", assistantMsg.Thinking, "Still thinking...")
|
|
}
|
|
if assistantMsg.Content != "" {
|
|
t.Errorf("Messages[1].Content = %q, want empty", assistantMsg.Content)
|
|
}
|
|
})
|
|
}
|
|
|
|
func TestToResponse_WithReasoning(t *testing.T) {
|
|
response := ToResponse("gpt-oss:20b", "resp_123", "msg_456", api.ChatResponse{
|
|
CreatedAt: time.Now(),
|
|
Message: api.Message{
|
|
Thinking: "Analyzing the question...",
|
|
Content: "The answer is 42",
|
|
},
|
|
Done: true,
|
|
}, ResponsesRequest{})
|
|
|
|
// Should have 2 output items: reasoning + message
|
|
if len(response.Output) != 2 {
|
|
t.Fatalf("expected 2 output items, got %d", len(response.Output))
|
|
}
|
|
|
|
// First item should be reasoning
|
|
if response.Output[0].Type != "reasoning" {
|
|
t.Errorf("Output[0].Type = %q, want %q", response.Output[0].Type, "reasoning")
|
|
}
|
|
if len(response.Output[0].Summary) != 1 {
|
|
t.Fatalf("expected 1 summary item, got %d", len(response.Output[0].Summary))
|
|
}
|
|
if response.Output[0].Summary[0].Text != "Analyzing the question..." {
|
|
t.Errorf("Summary[0].Text = %q, want %q", response.Output[0].Summary[0].Text, "Analyzing the question...")
|
|
}
|
|
if response.Output[0].EncryptedContent != "Analyzing the question..." {
|
|
t.Errorf("EncryptedContent = %q, want %q", response.Output[0].EncryptedContent, "Analyzing the question...")
|
|
}
|
|
|
|
// Second item should be message
|
|
if response.Output[1].Type != "message" {
|
|
t.Errorf("Output[1].Type = %q, want %q", response.Output[1].Type, "message")
|
|
}
|
|
if response.Output[1].Content[0].Text != "The answer is 42" {
|
|
t.Errorf("Content[0].Text = %q, want %q", response.Output[1].Content[0].Text, "The answer is 42")
|
|
}
|
|
}
|
|
|
|
func TestToResponse_UsageIncludesCachedTokens(t *testing.T) {
|
|
response := ToResponse("gpt-oss:20b", "resp_123", "msg_456", api.ChatResponse{
|
|
CreatedAt: time.Now(),
|
|
Message: api.Message{Content: "The answer is 42"},
|
|
Done: true,
|
|
Metrics: api.Metrics{
|
|
PromptEvalCount: 10,
|
|
PromptEvalCachedCount: testIntPtr(4),
|
|
EvalCount: 3,
|
|
},
|
|
}, ResponsesRequest{})
|
|
|
|
if response.Usage == nil {
|
|
t.Fatal("expected usage")
|
|
}
|
|
if response.Usage.InputTokens != 10 || response.Usage.InputTokensDetails.CachedTokens != 4 || response.Usage.TotalTokens != 13 {
|
|
t.Errorf("unexpected usage: %+v", response.Usage)
|
|
}
|
|
|
|
data, err := json.Marshal(response.Usage)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if !strings.Contains(string(data), `"input_tokens_details":{"cached_tokens":4}`) {
|
|
t.Errorf("unexpected usage json: %s", data)
|
|
}
|
|
}
|
|
|
|
func TestFromResponsesRequest_Instructions(t *testing.T) {
|
|
reqJSON := `{
|
|
"model": "gpt-oss:20b",
|
|
"instructions": "You are a helpful pirate. Always respond in pirate speak.",
|
|
"input": "Hello"
|
|
}`
|
|
|
|
var req ResponsesRequest
|
|
if err := json.Unmarshal([]byte(reqJSON), &req); err != nil {
|
|
t.Fatalf("failed to unmarshal request: %v", err)
|
|
}
|
|
|
|
chatReq, err := FromResponsesRequest(req)
|
|
if err != nil {
|
|
t.Fatalf("failed to convert request: %v", err)
|
|
}
|
|
|
|
// Should have 2 messages: system (instructions) + user
|
|
if len(chatReq.Messages) != 2 {
|
|
t.Fatalf("expected 2 messages, got %d", len(chatReq.Messages))
|
|
}
|
|
|
|
// First message should be system with instructions
|
|
if chatReq.Messages[0].Role != "system" {
|
|
t.Errorf("Messages[0].Role = %q, want %q", chatReq.Messages[0].Role, "system")
|
|
}
|
|
if chatReq.Messages[0].Content != "You are a helpful pirate. Always respond in pirate speak." {
|
|
t.Errorf("Messages[0].Content = %q, want instructions", chatReq.Messages[0].Content)
|
|
}
|
|
|
|
// Second message should be user
|
|
if chatReq.Messages[1].Role != "user" {
|
|
t.Errorf("Messages[1].Role = %q, want %q", chatReq.Messages[1].Role, "user")
|
|
}
|
|
if chatReq.Messages[1].Content != "Hello" {
|
|
t.Errorf("Messages[1].Content = %q, want %q", chatReq.Messages[1].Content, "Hello")
|
|
}
|
|
}
|
|
|
|
func TestFromResponsesRequest_MaxOutputTokens(t *testing.T) {
|
|
reqJSON := `{
|
|
"model": "gpt-oss:20b",
|
|
"input": "Write a story",
|
|
"max_output_tokens": 100
|
|
}`
|
|
|
|
var req ResponsesRequest
|
|
if err := json.Unmarshal([]byte(reqJSON), &req); err != nil {
|
|
t.Fatalf("failed to unmarshal request: %v", err)
|
|
}
|
|
|
|
chatReq, err := FromResponsesRequest(req)
|
|
if err != nil {
|
|
t.Fatalf("failed to convert request: %v", err)
|
|
}
|
|
|
|
// Check that num_predict is set in options
|
|
numPredict, ok := chatReq.Options["num_predict"]
|
|
if !ok {
|
|
t.Fatal("expected num_predict in options")
|
|
}
|
|
if numPredict != 100 {
|
|
t.Errorf("num_predict = %v, want 100", numPredict)
|
|
}
|
|
}
|
|
|
|
func TestFromResponsesRequest_Truncation(t *testing.T) {
|
|
boolPtr := func(b bool) *bool { return &b }
|
|
|
|
tests := []struct {
|
|
name string
|
|
reqJSON string
|
|
wantTrunc *bool
|
|
wantErr bool
|
|
}{
|
|
{
|
|
name: "default is disabled",
|
|
reqJSON: `{
|
|
"model": "gpt-oss:20b",
|
|
"input": "hello"
|
|
}`,
|
|
wantTrunc: boolPtr(false),
|
|
},
|
|
{
|
|
name: "explicitly disabled",
|
|
reqJSON: `{
|
|
"model": "gpt-oss:20b",
|
|
"input": "hello",
|
|
"truncation": "disabled"
|
|
}`,
|
|
wantTrunc: boolPtr(false),
|
|
},
|
|
{
|
|
name: "explicitly auto",
|
|
reqJSON: `{
|
|
"model": "gpt-oss:20b",
|
|
"input": "hello",
|
|
"truncation": "auto"
|
|
}`,
|
|
wantTrunc: boolPtr(true),
|
|
},
|
|
{
|
|
name: "unknown truncation strategy",
|
|
reqJSON: `{
|
|
"model": "gpt-oss:20b",
|
|
"input": "hello",
|
|
"truncation": "invalid_strategy"
|
|
}`,
|
|
wantErr: true,
|
|
},
|
|
}
|
|
|
|
for _, tt := range tests {
|
|
t.Run(tt.name, func(t *testing.T) {
|
|
var req ResponsesRequest
|
|
if err := json.Unmarshal([]byte(tt.reqJSON), &req); err != nil {
|
|
t.Fatalf("failed to unmarshal request: %v", err)
|
|
}
|
|
|
|
chatReq, err := FromResponsesRequest(req)
|
|
if tt.wantErr {
|
|
if err == nil {
|
|
t.Fatal("expected error, got nil")
|
|
}
|
|
return
|
|
}
|
|
if err != nil {
|
|
t.Fatalf("unexpected error: %v", err)
|
|
}
|
|
if chatReq.Truncate == nil {
|
|
t.Fatal("expected Truncate to be set, got nil")
|
|
}
|
|
if *chatReq.Truncate != *tt.wantTrunc {
|
|
t.Errorf("Truncate = %v, want %v", *chatReq.Truncate, *tt.wantTrunc)
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
func TestFromResponsesRequest_TextFormatJsonSchema(t *testing.T) {
|
|
reqJSON := `{
|
|
"model": "gpt-oss:20b",
|
|
"input": "Give me info about John who is 30",
|
|
"text": {
|
|
"format": {
|
|
"type": "json_schema",
|
|
"name": "person",
|
|
"strict": true,
|
|
"schema": {
|
|
"type": "object",
|
|
"properties": {
|
|
"name": {"type": "string"},
|
|
"age": {"type": "integer"}
|
|
},
|
|
"required": ["name", "age"]
|
|
}
|
|
}
|
|
}
|
|
}`
|
|
|
|
var req ResponsesRequest
|
|
if err := json.Unmarshal([]byte(reqJSON), &req); err != nil {
|
|
t.Fatalf("failed to unmarshal request: %v", err)
|
|
}
|
|
|
|
// Verify the text format was parsed
|
|
if req.Text == nil || req.Text.Format == nil {
|
|
t.Fatal("expected Text.Format to be set")
|
|
}
|
|
if req.Text.Format.Type != "json_schema" {
|
|
t.Errorf("Text.Format.Type = %q, want %q", req.Text.Format.Type, "json_schema")
|
|
}
|
|
|
|
chatReq, err := FromResponsesRequest(req)
|
|
if err != nil {
|
|
t.Fatalf("failed to convert request: %v", err)
|
|
}
|
|
|
|
// Check that Format is set
|
|
if chatReq.Format == nil {
|
|
t.Fatal("expected Format to be set")
|
|
}
|
|
|
|
// Verify the schema is passed through
|
|
var schema map[string]any
|
|
if err := json.Unmarshal(chatReq.Format, &schema); err != nil {
|
|
t.Fatalf("failed to unmarshal format: %v", err)
|
|
}
|
|
if schema["type"] != "object" {
|
|
t.Errorf("schema type = %v, want %q", schema["type"], "object")
|
|
}
|
|
props, ok := schema["properties"].(map[string]any)
|
|
if !ok {
|
|
t.Fatal("expected properties in schema")
|
|
}
|
|
if _, ok := props["name"]; !ok {
|
|
t.Error("expected 'name' in schema properties")
|
|
}
|
|
if _, ok := props["age"]; !ok {
|
|
t.Error("expected 'age' in schema properties")
|
|
}
|
|
}
|
|
|
|
func TestFromResponsesRequest_TextFormatText(t *testing.T) {
|
|
// When format type is "text", Format should be nil (no constraint)
|
|
reqJSON := `{
|
|
"model": "gpt-oss:20b",
|
|
"input": "Hello",
|
|
"text": {
|
|
"format": {
|
|
"type": "text"
|
|
}
|
|
}
|
|
}`
|
|
|
|
var req ResponsesRequest
|
|
if err := json.Unmarshal([]byte(reqJSON), &req); err != nil {
|
|
t.Fatalf("failed to unmarshal request: %v", err)
|
|
}
|
|
|
|
chatReq, err := FromResponsesRequest(req)
|
|
if err != nil {
|
|
t.Fatalf("failed to convert request: %v", err)
|
|
}
|
|
|
|
// Format should be nil for "text" type
|
|
if chatReq.Format != nil {
|
|
t.Errorf("expected Format to be nil for text type, got %s", string(chatReq.Format))
|
|
}
|
|
}
|
|
|
|
func TestResponsesInputMessage_ShorthandFormats(t *testing.T) {
|
|
t.Run("string content shorthand", func(t *testing.T) {
|
|
// Content can be a plain string instead of an array of content items
|
|
jsonStr := `{"type": "message", "role": "user", "content": "Hello world"}`
|
|
|
|
var msg ResponsesInputMessage
|
|
if err := json.Unmarshal([]byte(jsonStr), &msg); err != nil {
|
|
t.Fatalf("unexpected error: %v", err)
|
|
}
|
|
|
|
if msg.Role != "user" {
|
|
t.Errorf("Role = %q, want %q", msg.Role, "user")
|
|
}
|
|
if len(msg.Content) != 1 {
|
|
t.Fatalf("len(Content) = %d, want 1", len(msg.Content))
|
|
}
|
|
|
|
textContent, ok := msg.Content[0].(ResponsesTextContent)
|
|
if !ok {
|
|
t.Fatalf("Content[0] type = %T, want ResponsesTextContent", msg.Content[0])
|
|
}
|
|
if textContent.Text != "Hello world" {
|
|
t.Errorf("Content[0].Text = %q, want %q", textContent.Text, "Hello world")
|
|
}
|
|
if textContent.Type != "input_text" {
|
|
t.Errorf("Content[0].Type = %q, want %q", textContent.Type, "input_text")
|
|
}
|
|
})
|
|
|
|
t.Run("output_text content type", func(t *testing.T) {
|
|
// Previous assistant responses come back with output_text content type
|
|
jsonStr := `{"type": "message", "role": "assistant", "content": [{"type": "output_text", "text": "I am an assistant"}]}`
|
|
|
|
var msg ResponsesInputMessage
|
|
if err := json.Unmarshal([]byte(jsonStr), &msg); err != nil {
|
|
t.Fatalf("unexpected error: %v", err)
|
|
}
|
|
|
|
if msg.Role != "assistant" {
|
|
t.Errorf("Role = %q, want %q", msg.Role, "assistant")
|
|
}
|
|
if len(msg.Content) != 1 {
|
|
t.Fatalf("len(Content) = %d, want 1", len(msg.Content))
|
|
}
|
|
|
|
outputContent, ok := msg.Content[0].(ResponsesOutputTextContent)
|
|
if !ok {
|
|
t.Fatalf("Content[0] type = %T, want ResponsesOutputTextContent", msg.Content[0])
|
|
}
|
|
if outputContent.Text != "I am an assistant" {
|
|
t.Errorf("Content[0].Text = %q, want %q", outputContent.Text, "I am an assistant")
|
|
}
|
|
})
|
|
}
|
|
|
|
func TestUnmarshalResponsesInputItem_ShorthandMessage(t *testing.T) {
|
|
t.Run("message without type field", func(t *testing.T) {
|
|
// When type is omitted but role is present, treat as message
|
|
jsonStr := `{"role": "user", "content": "Hello"}`
|
|
|
|
item, err := unmarshalResponsesInputItem([]byte(jsonStr))
|
|
if err != nil {
|
|
t.Fatalf("unexpected error: %v", err)
|
|
}
|
|
|
|
msg, ok := item.(ResponsesInputMessage)
|
|
if !ok {
|
|
t.Fatalf("got type %T, want ResponsesInputMessage", item)
|
|
}
|
|
if msg.Role != "user" {
|
|
t.Errorf("Role = %q, want %q", msg.Role, "user")
|
|
}
|
|
if len(msg.Content) != 1 {
|
|
t.Fatalf("len(Content) = %d, want 1", len(msg.Content))
|
|
}
|
|
})
|
|
|
|
t.Run("message with both type and role", func(t *testing.T) {
|
|
// Explicit type should still work
|
|
jsonStr := `{"type": "message", "role": "system", "content": "You are helpful"}`
|
|
|
|
item, err := unmarshalResponsesInputItem([]byte(jsonStr))
|
|
if err != nil {
|
|
t.Fatalf("unexpected error: %v", err)
|
|
}
|
|
|
|
msg, ok := item.(ResponsesInputMessage)
|
|
if !ok {
|
|
t.Fatalf("got type %T, want ResponsesInputMessage", item)
|
|
}
|
|
if msg.Role != "system" {
|
|
t.Errorf("Role = %q, want %q", msg.Role, "system")
|
|
}
|
|
})
|
|
}
|
|
|
|
func TestFromResponsesRequest_ShorthandFormats(t *testing.T) {
|
|
t.Run("shorthand message without type", func(t *testing.T) {
|
|
// Real-world format from OpenAI SDK
|
|
reqJSON := `{
|
|
"model": "gpt-4.1",
|
|
"input": [
|
|
{"role": "user", "content": "What is the weather in Tokyo?"}
|
|
]
|
|
}`
|
|
|
|
var req ResponsesRequest
|
|
if err := json.Unmarshal([]byte(reqJSON), &req); err != nil {
|
|
t.Fatalf("failed to unmarshal request: %v", err)
|
|
}
|
|
|
|
if len(req.Input.Items) != 1 {
|
|
t.Fatalf("expected 1 input item, got %d", len(req.Input.Items))
|
|
}
|
|
|
|
msg, ok := req.Input.Items[0].(ResponsesInputMessage)
|
|
if !ok {
|
|
t.Fatalf("Input.Items[0] type = %T, want ResponsesInputMessage", req.Input.Items[0])
|
|
}
|
|
if msg.Role != "user" {
|
|
t.Errorf("Role = %q, want %q", msg.Role, "user")
|
|
}
|
|
|
|
chatReq, err := FromResponsesRequest(req)
|
|
if err != nil {
|
|
t.Fatalf("failed to convert request: %v", err)
|
|
}
|
|
|
|
if len(chatReq.Messages) != 1 {
|
|
t.Fatalf("expected 1 message, got %d", len(chatReq.Messages))
|
|
}
|
|
if chatReq.Messages[0].Content != "What is the weather in Tokyo?" {
|
|
t.Errorf("Content = %q, want %q", chatReq.Messages[0].Content, "What is the weather in Tokyo?")
|
|
}
|
|
})
|
|
|
|
t.Run("conversation with output_text from previous response", func(t *testing.T) {
|
|
// Simulates a multi-turn conversation where previous assistant response is sent back
|
|
reqJSON := `{
|
|
"model": "gpt-4.1",
|
|
"input": [
|
|
{"role": "user", "content": "Hello"},
|
|
{"type": "message", "role": "assistant", "content": [{"type": "output_text", "text": "Hi there!"}]},
|
|
{"role": "user", "content": "How are you?"}
|
|
]
|
|
}`
|
|
|
|
var req ResponsesRequest
|
|
if err := json.Unmarshal([]byte(reqJSON), &req); err != nil {
|
|
t.Fatalf("failed to unmarshal request: %v", err)
|
|
}
|
|
|
|
chatReq, err := FromResponsesRequest(req)
|
|
if err != nil {
|
|
t.Fatalf("failed to convert request: %v", err)
|
|
}
|
|
|
|
if len(chatReq.Messages) != 3 {
|
|
t.Fatalf("expected 3 messages, got %d", len(chatReq.Messages))
|
|
}
|
|
|
|
// Check first user message
|
|
if chatReq.Messages[0].Role != "user" || chatReq.Messages[0].Content != "Hello" {
|
|
t.Errorf("Messages[0] = {Role: %q, Content: %q}, want {Role: \"user\", Content: \"Hello\"}",
|
|
chatReq.Messages[0].Role, chatReq.Messages[0].Content)
|
|
}
|
|
|
|
// Check assistant message (output_text should be converted to content)
|
|
if chatReq.Messages[1].Role != "assistant" || chatReq.Messages[1].Content != "Hi there!" {
|
|
t.Errorf("Messages[1] = {Role: %q, Content: %q}, want {Role: \"assistant\", Content: \"Hi there!\"}",
|
|
chatReq.Messages[1].Role, chatReq.Messages[1].Content)
|
|
}
|
|
|
|
// Check second user message
|
|
if chatReq.Messages[2].Role != "user" || chatReq.Messages[2].Content != "How are you?" {
|
|
t.Errorf("Messages[2] = {Role: %q, Content: %q}, want {Role: \"user\", Content: \"How are you?\"}",
|
|
chatReq.Messages[2].Role, chatReq.Messages[2].Content)
|
|
}
|
|
})
|
|
}
|
|
|
|
func TestResponsesStreamConverter_OutputIncludesContent(t *testing.T) {
|
|
// Verify that response.output_item.done includes content field for messages
|
|
converter := NewResponsesStreamConverter("resp_123", "msg_456", "gpt-oss:20b", ResponsesRequest{})
|
|
|
|
// First chunk
|
|
converter.Process(api.ChatResponse{
|
|
Message: api.Message{Content: "Hello World"},
|
|
})
|
|
|
|
// Final chunk
|
|
events := converter.Process(api.ChatResponse{
|
|
Message: api.Message{},
|
|
Done: true,
|
|
})
|
|
|
|
// Find the output_item.done event
|
|
var outputItemDone map[string]any
|
|
for _, event := range events {
|
|
if event.Event == "response.output_item.done" {
|
|
outputItemDone = event.Data.(map[string]any)
|
|
break
|
|
}
|
|
}
|
|
|
|
if outputItemDone == nil {
|
|
t.Fatal("expected response.output_item.done event")
|
|
}
|
|
|
|
item := outputItemDone["item"].(map[string]any)
|
|
if item["type"] != "message" {
|
|
t.Errorf("item.type = %q, want %q", item["type"], "message")
|
|
}
|
|
|
|
content, ok := item["content"].([]map[string]any)
|
|
if !ok {
|
|
t.Fatalf("item.content type = %T, want []map[string]any", item["content"])
|
|
}
|
|
if len(content) != 1 {
|
|
t.Fatalf("len(content) = %d, want 1", len(content))
|
|
}
|
|
if content[0]["type"] != "output_text" {
|
|
t.Errorf("content[0].type = %q, want %q", content[0]["type"], "output_text")
|
|
}
|
|
if content[0]["text"] != "Hello World" {
|
|
t.Errorf("content[0].text = %q, want %q", content[0]["text"], "Hello World")
|
|
}
|
|
}
|
|
|
|
func TestResponsesStreamConverter_ResponseCompletedIncludesOutput(t *testing.T) {
|
|
// Verify that response.completed includes the output array
|
|
converter := NewResponsesStreamConverter("resp_123", "msg_456", "gpt-oss:20b", ResponsesRequest{})
|
|
|
|
// Process some content
|
|
converter.Process(api.ChatResponse{
|
|
Message: api.Message{Content: "Test response"},
|
|
})
|
|
|
|
// Final chunk
|
|
events := converter.Process(api.ChatResponse{
|
|
Message: api.Message{},
|
|
Done: true,
|
|
Metrics: api.Metrics{
|
|
PromptEvalCount: 10,
|
|
PromptEvalCachedCount: testIntPtr(4),
|
|
EvalCount: 3,
|
|
},
|
|
})
|
|
|
|
// Find the response.completed event
|
|
var responseCompleted map[string]any
|
|
for _, event := range events {
|
|
if event.Event == "response.completed" {
|
|
responseCompleted = event.Data.(map[string]any)
|
|
break
|
|
}
|
|
}
|
|
|
|
if responseCompleted == nil {
|
|
t.Fatal("expected response.completed event")
|
|
}
|
|
|
|
response := responseCompleted["response"].(map[string]any)
|
|
output, ok := response["output"].([]any)
|
|
if !ok {
|
|
t.Fatalf("response.output type = %T, want []any", response["output"])
|
|
}
|
|
|
|
if len(output) != 1 {
|
|
t.Fatalf("len(output) = %d, want 1", len(output))
|
|
}
|
|
|
|
item := output[0].(map[string]any)
|
|
if item["type"] != "message" {
|
|
t.Errorf("output[0].type = %q, want %q", item["type"], "message")
|
|
}
|
|
usage := response["usage"].(map[string]any)
|
|
inputDetails := usage["input_tokens_details"].(map[string]any)
|
|
if inputDetails["cached_tokens"] != 4 {
|
|
t.Errorf("cached_tokens = %v, want 4", inputDetails["cached_tokens"])
|
|
}
|
|
}
|
|
|
|
func TestResponsesStreamConverter_ResponseCreatedIncludesOutput(t *testing.T) {
|
|
// Verify that response.created includes an empty output array
|
|
converter := NewResponsesStreamConverter("resp_123", "msg_456", "gpt-oss:20b", ResponsesRequest{})
|
|
|
|
events := converter.Process(api.ChatResponse{
|
|
Message: api.Message{Content: "Hi"},
|
|
})
|
|
|
|
// First event should be response.created
|
|
if events[0].Event != "response.created" {
|
|
t.Fatalf("events[0].Event = %q, want %q", events[0].Event, "response.created")
|
|
}
|
|
|
|
data := events[0].Data.(map[string]any)
|
|
response := data["response"].(map[string]any)
|
|
|
|
output, ok := response["output"].([]any)
|
|
if !ok {
|
|
t.Fatalf("response.output type = %T, want []any", response["output"])
|
|
}
|
|
|
|
// Should be empty array initially
|
|
if len(output) != 0 {
|
|
t.Errorf("len(output) = %d, want 0", len(output))
|
|
}
|
|
}
|
|
|
|
func TestResponsesStreamConverter_SequenceNumbers(t *testing.T) {
|
|
// Verify that events include incrementing sequence numbers
|
|
converter := NewResponsesStreamConverter("resp_123", "msg_456", "gpt-oss:20b", ResponsesRequest{})
|
|
|
|
events := converter.Process(api.ChatResponse{
|
|
Message: api.Message{Content: "Hello"},
|
|
})
|
|
|
|
for i, event := range events {
|
|
data := event.Data.(map[string]any)
|
|
seqNum, ok := data["sequence_number"].(int)
|
|
if !ok {
|
|
t.Fatalf("events[%d] missing sequence_number", i)
|
|
}
|
|
if seqNum != i {
|
|
t.Errorf("events[%d].sequence_number = %d, want %d", i, seqNum, i)
|
|
}
|
|
}
|
|
|
|
// Process more content, sequence should continue
|
|
moreEvents := converter.Process(api.ChatResponse{
|
|
Message: api.Message{Content: " World"},
|
|
})
|
|
|
|
expectedSeq := len(events)
|
|
for i, event := range moreEvents {
|
|
data := event.Data.(map[string]any)
|
|
seqNum := data["sequence_number"].(int)
|
|
if seqNum != expectedSeq+i {
|
|
t.Errorf("moreEvents[%d].sequence_number = %d, want %d", i, seqNum, expectedSeq+i)
|
|
}
|
|
}
|
|
}
|
|
|
|
func TestResponsesStreamConverter_FunctionCallStatus(t *testing.T) {
|
|
// Verify that function call items include status field
|
|
converter := NewResponsesStreamConverter("resp_123", "msg_456", "gpt-oss:20b", ResponsesRequest{})
|
|
|
|
events := converter.Process(api.ChatResponse{
|
|
Message: api.Message{
|
|
ToolCalls: []api.ToolCall{
|
|
{
|
|
ID: "call_abc",
|
|
Function: api.ToolCallFunction{
|
|
Name: "get_weather",
|
|
Arguments: testArgs(map[string]any{"city": "Paris"}),
|
|
},
|
|
},
|
|
},
|
|
},
|
|
})
|
|
|
|
// Find output_item.added event
|
|
var addedItem map[string]any
|
|
var doneItem map[string]any
|
|
for _, event := range events {
|
|
data := event.Data.(map[string]any)
|
|
if data["type"] == "response.output_item.added" {
|
|
item := data["item"].(map[string]any)
|
|
if item["type"] == "function_call" {
|
|
addedItem = item
|
|
}
|
|
}
|
|
if data["type"] == "response.output_item.done" {
|
|
item := data["item"].(map[string]any)
|
|
if item["type"] == "function_call" {
|
|
doneItem = item
|
|
}
|
|
}
|
|
}
|
|
|
|
if addedItem == nil {
|
|
t.Fatal("expected function_call output_item.added event")
|
|
}
|
|
if addedItem["status"] != "in_progress" {
|
|
t.Errorf("output_item.added status = %q, want %q", addedItem["status"], "in_progress")
|
|
}
|
|
|
|
if doneItem == nil {
|
|
t.Fatal("expected function_call output_item.done event")
|
|
}
|
|
if doneItem["status"] != "completed" {
|
|
t.Errorf("output_item.done status = %q, want %q", doneItem["status"], "completed")
|
|
}
|
|
}
|
|
|
|
func TestResponsesStreamConverter_WebSearchCall(t *testing.T) {
|
|
converter := NewResponsesStreamConverter("resp_123", "msg_456", "gpt-oss:20b", ResponsesRequest{})
|
|
call := ResponsesWebSearchCall{
|
|
ID: "ws_123",
|
|
Type: "web_search_call",
|
|
Status: "completed",
|
|
Action: &ResponsesWebSearchAction{
|
|
Type: "search",
|
|
Query: "Ollama news",
|
|
},
|
|
}
|
|
outputIndex, events := converter.StartWebSearchCall(call)
|
|
events = append(events, converter.FinishWebSearchCall(call, outputIndex)...)
|
|
wantTypes := []string{
|
|
"response.output_item.added",
|
|
"response.web_search_call.in_progress",
|
|
"response.web_search_call.searching",
|
|
"response.web_search_call.completed",
|
|
"response.output_item.done",
|
|
}
|
|
if len(events) != len(wantTypes) {
|
|
t.Fatalf("event count = %d, want %d", len(events), len(wantTypes))
|
|
}
|
|
for i, event := range events {
|
|
if event.Event != wantTypes[i] {
|
|
t.Errorf("event[%d] = %q, want %q", i, event.Event, wantTypes[i])
|
|
}
|
|
if got := event.Data.(map[string]any)["sequence_number"]; got != i {
|
|
t.Errorf("event[%d] sequence = %v, want %d", i, got, i)
|
|
}
|
|
}
|
|
done := events[len(events)-1].Data.(map[string]any)["item"].(map[string]any)
|
|
action := done["action"].(map[string]any)
|
|
if action["query"] != "Ollama news" {
|
|
t.Errorf("done query = %q", action["query"])
|
|
}
|
|
}
|
|
|
|
func TestResponsesStreamConverter_FinishMessageItem(t *testing.T) {
|
|
converter := NewResponsesStreamConverter("resp_123", "msg_456", "gpt-oss:20b", ResponsesRequest{})
|
|
|
|
// Process some text content first
|
|
textEvents := converter.Process(api.ChatResponse{Message: api.Message{Role: "assistant", Content: "pre-search text"}})
|
|
if len(textEvents) == 0 {
|
|
t.Fatal("expected events from Process")
|
|
}
|
|
|
|
// FinishMessageItem should close the message item
|
|
finishEvents := converter.FinishMessageItem()
|
|
wantTypes := []string{
|
|
"response.output_text.done",
|
|
"response.content_part.done",
|
|
"response.output_item.done",
|
|
}
|
|
if len(finishEvents) != len(wantTypes) {
|
|
t.Fatalf("event count = %d, want %d", len(finishEvents), len(wantTypes))
|
|
}
|
|
for i, event := range finishEvents {
|
|
if event.Event != wantTypes[i] {
|
|
t.Errorf("event[%d] = %q, want %q", i, event.Event, wantTypes[i])
|
|
}
|
|
}
|
|
|
|
// buildFinalOutput should include the completed message item
|
|
output := converter.buildFinalOutput()
|
|
if len(output) != 1 {
|
|
t.Fatalf("output count = %d, want 1", len(output))
|
|
}
|
|
item := output[0].(map[string]any)
|
|
if item["type"] != "message" {
|
|
t.Fatalf("output type = %v, want message", item["type"])
|
|
}
|
|
content := item["content"].([]map[string]any)
|
|
if content[0]["text"] != "pre-search text" {
|
|
t.Fatalf("text = %v, want 'pre-search text'", content[0]["text"])
|
|
}
|
|
preSearchID := item["id"]
|
|
|
|
// Calling FinishMessageItem again without content should be a no-op
|
|
if events := converter.FinishMessageItem(); len(events) != 0 {
|
|
t.Fatalf("expected no events, got %d", len(events))
|
|
}
|
|
|
|
// A later model leg must use a fresh message item ID, and both message
|
|
// items must be present in the terminal response output.
|
|
finalEvents := converter.Process(api.ChatResponse{
|
|
Message: api.Message{Role: "assistant", Content: "final text"},
|
|
Done: true,
|
|
})
|
|
var finalMessageID any
|
|
var finalOutput []any
|
|
for _, event := range finalEvents {
|
|
data := event.Data.(map[string]any)
|
|
switch event.Event {
|
|
case "response.output_item.added":
|
|
added := data["item"].(map[string]any)
|
|
if added["type"] == "message" {
|
|
finalMessageID = added["id"]
|
|
}
|
|
case "response.completed":
|
|
response := data["response"].(map[string]any)
|
|
finalOutput = response["output"].([]any)
|
|
}
|
|
}
|
|
if finalMessageID == nil || finalMessageID == preSearchID {
|
|
t.Fatalf("message item IDs were not rotated: pre-search=%v final=%v", preSearchID, finalMessageID)
|
|
}
|
|
if len(finalOutput) != 2 {
|
|
t.Fatalf("terminal output count = %d, want 2: %#v", len(finalOutput), finalOutput)
|
|
}
|
|
if got := finalOutput[1].(map[string]any)["id"]; got != finalMessageID {
|
|
t.Fatalf("terminal final message ID = %v, want %v", got, finalMessageID)
|
|
}
|
|
}
|
|
|
|
func TestResponsesStreamConverter_FinalOutputKeepsStreamedItemOrder(t *testing.T) {
|
|
converter := NewResponsesStreamConverter("resp_order", "msg_order", "test-model", ResponsesRequest{})
|
|
var events []ResponsesStreamEvent
|
|
|
|
events = append(events, converter.Process(api.ChatResponse{Message: api.Message{Thinking: "before search"}})...)
|
|
call := ResponsesWebSearchCall{
|
|
ID: "ws_order_1",
|
|
Type: "web_search_call",
|
|
Status: "completed",
|
|
Action: &ResponsesWebSearchAction{Type: "search", Query: "test"},
|
|
}
|
|
outputIndex, searchEvents := converter.StartWebSearchCall(call)
|
|
events = append(events, searchEvents...)
|
|
events = append(events, converter.FinishWebSearchCall(call, outputIndex)...)
|
|
events = append(events, converter.Process(api.ChatResponse{Message: api.Message{Thinking: "after search"}})...)
|
|
events = append(events, converter.Process(api.ChatResponse{Message: api.Message{Content: "answer"}, Done: true})...)
|
|
|
|
doneIDs := map[int]any{}
|
|
var finalOutput []any
|
|
for _, event := range events {
|
|
data := event.Data.(map[string]any)
|
|
switch event.Event {
|
|
case "response.output_item.done":
|
|
item := data["item"].(map[string]any)
|
|
doneIDs[data["output_index"].(int)] = item["id"]
|
|
case "response.completed":
|
|
response := data["response"].(map[string]any)
|
|
finalOutput = response["output"].([]any)
|
|
}
|
|
}
|
|
if len(finalOutput) != 4 {
|
|
t.Fatalf("terminal output count = %d, want 4: %#v", len(finalOutput), finalOutput)
|
|
}
|
|
for outputIndex, raw := range finalOutput {
|
|
item := raw.(map[string]any)
|
|
if got, want := item["id"], doneIDs[outputIndex]; got != want {
|
|
t.Fatalf("terminal output[%d] ID = %v, streamed done ID = %v; output=%#v", outputIndex, got, want, finalOutput)
|
|
}
|
|
}
|
|
}
|
|
|
|
func TestResponsesStreamConverter_TextThenToolCompletion(t *testing.T) {
|
|
converter := NewResponsesStreamConverter("resp_test", "msg_test", "test-model", ResponsesRequest{})
|
|
events := converter.Process(api.ChatResponse{Message: api.Message{Content: "checking weather"}})
|
|
events = append(events, converter.Process(api.ChatResponse{
|
|
Message: api.Message{ToolCalls: []api.ToolCall{{ID: "call_weather", Function: api.ToolCallFunction{Name: "weather", Arguments: testArgs(map[string]any{"city": "SF"})}}}},
|
|
Done: true,
|
|
})...)
|
|
done := map[int]any{}
|
|
for _, event := range events {
|
|
data := event.Data.(map[string]any)
|
|
if event.Event == "response.output_item.done" {
|
|
index := data["output_index"].(int)
|
|
if _, exists := done[index]; exists {
|
|
t.Fatalf("output index %d closed twice", index)
|
|
}
|
|
done[index] = data["item"]
|
|
}
|
|
}
|
|
last := events[len(events)-1]
|
|
if last.Event != "response.completed" {
|
|
t.Fatalf("last event=%s", last.Event)
|
|
}
|
|
output := last.Data.(map[string]any)["response"].(map[string]any)["output"].([]any)
|
|
if len(output) != 2 || !reflect.DeepEqual(output[0], done[0]) || !reflect.DeepEqual(output[1], done[1]) {
|
|
t.Fatalf("terminal output disagrees with streamed items: output=%#v done=%#v", output, done)
|
|
}
|
|
if events := converter.ResponseFailed(map[string]any{"error": ResponsesError{Code: "server_error", Message: "late error"}}); len(events) != 0 {
|
|
t.Fatalf("failure emitted after completion: %#v", events)
|
|
}
|
|
if events := converter.Process(api.ChatResponse{Done: true}); len(events) != 0 {
|
|
t.Fatalf("completion emitted twice: %#v", events)
|
|
}
|
|
}
|
|
|
|
func TestFromResponsesRequestAgentMessageWithoutRouting(t *testing.T) {
|
|
var req ResponsesRequest
|
|
err := json.Unmarshal([]byte(`{
|
|
"model": "test-model",
|
|
"input": [{"type": "agent_message", "content": [
|
|
{"type": "encrypted_content", "encrypted_content": "bare payload"}
|
|
]}]
|
|
}`), &req)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
|
|
chat, err := FromResponsesRequest(req)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if len(chat.Messages) != 1 {
|
|
t.Fatalf("messages = %#v", chat.Messages)
|
|
}
|
|
// No routing fields: payload passes through without an envelope.
|
|
if chat.Messages[0].Role != "user" || chat.Messages[0].Content != "bare payload" {
|
|
t.Fatalf("agent message = %#v", chat.Messages[0])
|
|
}
|
|
}
|
|
|
|
func TestFromResponsesRequestAcceptsEncryptedContentPart(t *testing.T) {
|
|
var req ResponsesRequest
|
|
err := json.Unmarshal([]byte(`{
|
|
"model": "test-model",
|
|
"input": [{"type": "message", "role": "user", "content": [
|
|
{"type": "input_text", "text": "prefix "},
|
|
{"type": "encrypted_content", "encrypted_content": "opaque to us"}
|
|
]}]
|
|
}`), &req)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
|
|
chat, err := FromResponsesRequest(req)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if len(chat.Messages) != 1 || chat.Messages[0].Content != "prefix opaque to us" {
|
|
t.Fatalf("messages = %#v", chat.Messages)
|
|
}
|
|
}
|
|
|
|
func TestResponsesReportRequestedSampling(t *testing.T) {
|
|
value := func(v float64) *float64 { return &v }
|
|
tests := []struct {
|
|
name string
|
|
temperature, topP *float64
|
|
want map[string]any
|
|
}{
|
|
// The model's defaults applied; the response must not claim others.
|
|
{"omitted", nil, nil, map[string]any{"temperature": nil, "top_p": nil}},
|
|
{"explicit", value(0.3), value(0.7), map[string]any{"temperature": 0.3, "top_p": 0.7}},
|
|
{"explicit zero", value(0), value(0), map[string]any{"temperature": 0.0, "top_p": 0.0}},
|
|
}
|
|
for _, tt := range tests {
|
|
t.Run(tt.name, func(t *testing.T) {
|
|
request := ResponsesRequest{Model: "m", Temperature: tt.temperature, TopP: tt.topP}
|
|
chat := api.ChatResponse{Message: api.Message{Content: "hi"}, Done: true}
|
|
created := NewResponsesStreamConverter("resp_1", "item_1", "m", request).Process(chat)[0]
|
|
for builder, response := range map[string]any{
|
|
"ToResponse": ToResponse("m", "resp_1", "item_1", chat, request),
|
|
"stream": created.Data.(map[string]any)["response"],
|
|
} {
|
|
data, err := json.Marshal(response)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
var fields map[string]any
|
|
if err := json.Unmarshal(data, &fields); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
for key, want := range tt.want {
|
|
if got, ok := fields[key]; !ok || got != want {
|
|
t.Errorf("%s %s = %v (present %v), want %v", builder, key, got, ok, want)
|
|
}
|
|
}
|
|
}
|
|
})
|
|
}
|
|
}
|