Files
ollama/openai/responses_test.go
Devon Rifkin e51dead636 preserve tool definition and call JSON ordering (#13525)
* preserve tool definition and call JSON ordering

This is another iteration of
<https://github.com/ollama/ollama/pull/12518>, but this time we've
simplified things by relaxing the competing requirements of being
compatible AND order-preserving with templates (vs. renderers). We
maintain backwards compatibility at the cost of not guaranteeing order
for templates. We plan on moving more and more models to renderers,
which have been updated to use these new data types, and additionally
we could add an opt-in way of templates getting an order-preserved list
(e.g., via sibling template vars)

* orderedmap_test: remove testify
2026-01-05 18:03:36 -08:00

1843 lines
60 KiB
Go

package openai
import (
"encoding/json"
"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("unknown item type", func(t *testing.T) {
_, err := unmarshalResponsesInputItem([]byte(`{"type": "unknown_type"}`))
if err == nil {
t.Error("expected error, got nil")
}
})
}
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_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", "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)
}
}
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": "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": "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")
// 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")
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_Reasoning(t *testing.T) {
converter := NewResponsesStreamConverter("resp_123", "msg_456", "gpt-oss:20b")
// 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("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 != "Let me think about this math problem..." {
t.Errorf("Messages[1].Thinking = %q, want %q", assistantMsg.Thinking, "Let me think about this math problem...")
}
})
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": "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 != "This is a simple arithmetic problem. 2+2=4." {
t.Errorf("Messages[1].Thinking = %q, want \"This is a simple arithmetic problem. 2+2=4.\"",
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": "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": "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": []}
]
}`
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,
})
// 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 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_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")
// 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")
// 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,
})
// 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")
}
}
func TestResponsesStreamConverter_ResponseCreatedIncludesOutput(t *testing.T) {
// Verify that response.created includes an empty output array
converter := NewResponsesStreamConverter("resp_123", "msg_456", "gpt-oss:20b")
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")
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")
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")
}
}