build / build (push) Successful in 2m29s
responsesInputToMessages unmarshaled function_call.arguments and function_call_output.output as bare strings, silently dropping the value when it arrived as an object or content-parts array. This caused the model to lose tool-call context in multi-turn conversations, increasing the likelihood of malformed tool-call JSON. Add rawJSONToString (re-encodes non-string values as JSON strings) and outputToString (extracts text from content-parts arrays, re-encodes other non-string values). Add 3 regression tests covering object arguments, array output, and object output.
390 lines
12 KiB
Go
390 lines
12 KiB
Go
package openai
|
|
|
|
import (
|
|
"encoding/json"
|
|
"testing"
|
|
)
|
|
|
|
func TestParseResponsesRequest_StringInput(t *testing.T) {
|
|
body := `{"model":"GLM-4.7","input":"hello world","stream":false}`
|
|
req, err := ParseResponsesRequest([]byte(body))
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if req.Model != "GLM-4.7" {
|
|
t.Fatalf("model = %q", req.Model)
|
|
}
|
|
if len(req.Messages) != 1 {
|
|
t.Fatalf("messages = %d items", len(req.Messages))
|
|
}
|
|
if req.Messages[0]["role"] != "user" {
|
|
t.Fatalf("role = %v", req.Messages[0]["role"])
|
|
}
|
|
if req.Messages[0]["content"] != "hello world" {
|
|
t.Fatalf("content = %v", req.Messages[0]["content"])
|
|
}
|
|
}
|
|
|
|
func TestParseResponsesRequest_Instructions(t *testing.T) {
|
|
body := `{"model":"GLM-4.7","instructions":"be helpful","input":"hi"}`
|
|
req, err := ParseResponsesRequest([]byte(body))
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if len(req.Messages) != 2 {
|
|
t.Fatalf("messages = %d items", len(req.Messages))
|
|
}
|
|
if req.Messages[0]["role"] != "system" {
|
|
t.Fatalf("first role = %v", req.Messages[0]["role"])
|
|
}
|
|
if req.Messages[0]["content"] != "be helpful" {
|
|
t.Fatalf("first content = %v", req.Messages[0]["content"])
|
|
}
|
|
if req.Messages[1]["role"] != "user" {
|
|
t.Fatalf("second role = %v", req.Messages[1]["role"])
|
|
}
|
|
}
|
|
|
|
func TestParseResponsesRequest_ArrayInput(t *testing.T) {
|
|
body := `{"model":"GLM-4.7","input":[
|
|
{"role":"user","content":"hello"},
|
|
{"role":"assistant","content":"hi there"},
|
|
{"role":"user","content":"how are you?"}
|
|
]}`
|
|
req, err := ParseResponsesRequest([]byte(body))
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if len(req.Messages) != 3 {
|
|
t.Fatalf("messages = %d items", len(req.Messages))
|
|
}
|
|
if req.Messages[0]["role"] != "user" || req.Messages[0]["content"] != "hello" {
|
|
t.Fatalf("msg[0] = %v", req.Messages[0])
|
|
}
|
|
if req.Messages[1]["role"] != "assistant" || req.Messages[1]["content"] != "hi there" {
|
|
t.Fatalf("msg[1] = %v", req.Messages[1])
|
|
}
|
|
if req.Messages[2]["role"] != "user" || req.Messages[2]["content"] != "how are you?" {
|
|
t.Fatalf("msg[2] = %v", req.Messages[2])
|
|
}
|
|
}
|
|
|
|
func TestParseResponsesRequest_ContentParts(t *testing.T) {
|
|
body := `{"model":"GLM-4.7","input":[
|
|
{"role":"user","content":[
|
|
{"type":"input_text","text":"describe this"},
|
|
{"type":"input_image","image_url":"data:image/png;base64,abc"}
|
|
]}
|
|
]}`
|
|
req, err := ParseResponsesRequest([]byte(body))
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if len(req.Messages) != 1 {
|
|
t.Fatalf("messages = %d items", len(req.Messages))
|
|
}
|
|
content, ok := req.Messages[0]["content"].([]map[string]any)
|
|
if !ok {
|
|
t.Fatalf("content type = %T", req.Messages[0]["content"])
|
|
}
|
|
if len(content) != 2 {
|
|
t.Fatalf("content parts = %d", len(content))
|
|
}
|
|
if content[0]["type"] != "text" || content[0]["text"] != "describe this" {
|
|
t.Fatalf("content[0] = %v", content[0])
|
|
}
|
|
if content[1]["type"] != "image_url" {
|
|
t.Fatalf("content[1] type = %v", content[1])
|
|
}
|
|
}
|
|
|
|
func TestParseResponsesRequest_MaxOutputTokens(t *testing.T) {
|
|
body := `{"model":"GLM-4.7","input":"hi","max_output_tokens":500}`
|
|
req, err := ParseResponsesRequest([]byte(body))
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if v, ok := req.Extra["max_tokens"]; !ok {
|
|
t.Fatal("max_tokens not in Extra")
|
|
} else {
|
|
var n int
|
|
_ = json.Unmarshal(v, &n)
|
|
if n != 500 {
|
|
t.Fatalf("max_tokens = %d", n)
|
|
}
|
|
}
|
|
}
|
|
|
|
func TestParseResponsesRequest_Tools(t *testing.T) {
|
|
body := `{"model":"GLM-4.7","input":"hi","tools":[
|
|
{"type":"function","name":"get_weather","description":"get weather","parameters":{"type":"object"}}
|
|
]}`
|
|
req, err := ParseResponsesRequest([]byte(body))
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
raw, ok := req.Extra["tools"]
|
|
if !ok {
|
|
t.Fatal("tools not in Extra")
|
|
}
|
|
var tools []map[string]any
|
|
if err := json.Unmarshal(raw, &tools); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if len(tools) != 1 {
|
|
t.Fatalf("tools = %d", len(tools))
|
|
}
|
|
if tools[0]["type"] != "function" {
|
|
t.Fatalf("tool type = %v", tools[0]["type"])
|
|
}
|
|
fn, ok := tools[0]["function"].(map[string]any)
|
|
if !ok {
|
|
t.Fatalf("function type = %T", tools[0]["function"])
|
|
}
|
|
if fn["name"] != "get_weather" {
|
|
t.Fatalf("function name = %v", fn["name"])
|
|
}
|
|
}
|
|
|
|
func TestParseResponsesRequest_FunctionCallInput(t *testing.T) {
|
|
body := `{"model":"GLM-4.7","input":[
|
|
{"role":"user","content":"what's the weather?"},
|
|
{"type":"function_call","call_id":"call_123","name":"get_weather","arguments":"{\"city\":\"NYC\"}"},
|
|
{"type":"function_call_output","call_id":"call_123","output":"sunny 72F"}
|
|
]}`
|
|
req, err := ParseResponsesRequest([]byte(body))
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if len(req.Messages) != 3 {
|
|
t.Fatalf("messages = %d items", len(req.Messages))
|
|
}
|
|
// First message is user text
|
|
if req.Messages[0]["role"] != "user" {
|
|
t.Fatalf("msg[0] role = %v", req.Messages[0]["role"])
|
|
}
|
|
// Second message is assistant with tool_calls
|
|
if req.Messages[1]["role"] != "assistant" {
|
|
t.Fatalf("msg[1] role = %v", req.Messages[1]["role"])
|
|
}
|
|
tcs, ok := req.Messages[1]["tool_calls"].([]any)
|
|
if !ok || len(tcs) != 1 {
|
|
t.Fatalf("msg[1] tool_calls = %v", req.Messages[1]["tool_calls"])
|
|
}
|
|
tc, _ := tcs[0].(map[string]any)
|
|
if tc["id"] != "call_123" {
|
|
t.Fatalf("tool call id = %v", tc["id"])
|
|
}
|
|
fn, _ := tc["function"].(map[string]any)
|
|
if fn["name"] != "get_weather" {
|
|
t.Fatalf("function name = %v", fn["name"])
|
|
}
|
|
// Third message is tool result
|
|
if req.Messages[2]["role"] != "tool" {
|
|
t.Fatalf("msg[2] role = %v", req.Messages[2]["role"])
|
|
}
|
|
if req.Messages[2]["tool_call_id"] != "call_123" {
|
|
t.Fatalf("msg[2] tool_call_id = %v", req.Messages[2]["tool_call_id"])
|
|
}
|
|
if req.Messages[2]["content"] != "sunny 72F" {
|
|
t.Fatalf("msg[2] content = %v", req.Messages[2]["content"])
|
|
}
|
|
}
|
|
|
|
// TestParseResponsesRequest_FunctionCallObjectArguments verifies that when
|
|
// function_call.arguments arrives as a JSON object (not a string), it is
|
|
// re-encoded as a JSON string instead of being silently dropped.
|
|
func TestParseResponsesRequest_FunctionCallObjectArguments(t *testing.T) {
|
|
body := `{"model":"GLM-4.7","input":[
|
|
{"type":"function_call","call_id":"call_456","name":"task","arguments":{"operation":"create","summary":"test"}}
|
|
]}`
|
|
req, err := ParseResponsesRequest([]byte(body))
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if len(req.Messages) != 1 {
|
|
t.Fatalf("messages = %d items", len(req.Messages))
|
|
}
|
|
tc, _ := req.Messages[0]["tool_calls"].([]any)
|
|
if len(tc) != 1 {
|
|
t.Fatalf("tool_calls = %v", req.Messages[0]["tool_calls"])
|
|
}
|
|
fn, _ := tc[0].(map[string]any)["function"].(map[string]any)
|
|
args, _ := fn["arguments"].(string)
|
|
if args == "" {
|
|
t.Fatalf("arguments was dropped (empty)")
|
|
}
|
|
// The re-encoded string must be valid JSON containing the original fields.
|
|
var parsed map[string]any
|
|
if err := json.Unmarshal([]byte(args), &parsed); err != nil {
|
|
t.Fatalf("arguments not valid JSON: %v", err)
|
|
}
|
|
if parsed["operation"] != "create" {
|
|
t.Fatalf("operation = %v", parsed["operation"])
|
|
}
|
|
}
|
|
|
|
// TestParseResponsesRequest_FunctionCallOutputArray verifies that when
|
|
// function_call_output.output arrives as an array of content parts, the
|
|
// text is extracted instead of being silently dropped.
|
|
func TestParseResponsesRequest_FunctionCallOutputArray(t *testing.T) {
|
|
body := `{"model":"GLM-4.7","input":[
|
|
{"type":"function_call","call_id":"call_789","name":"get_weather","arguments":"{\"city\":\"NYC\"}"},
|
|
{"type":"function_call_output","call_id":"call_789","output":[{"type":"output_text","text":"sunny 72F"}]}
|
|
]}`
|
|
req, err := ParseResponsesRequest([]byte(body))
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if len(req.Messages) != 2 {
|
|
t.Fatalf("messages = %d items", len(req.Messages))
|
|
}
|
|
toolMsg := req.Messages[1]
|
|
if toolMsg["role"] != "tool" {
|
|
t.Fatalf("msg[1] role = %v", toolMsg["role"])
|
|
}
|
|
content, _ := toolMsg["content"].(string)
|
|
if content != "sunny 72F" {
|
|
t.Fatalf("content = %q, want %q", content, "sunny 72F")
|
|
}
|
|
}
|
|
|
|
// TestParseResponsesRequest_FunctionCallOutputObject verifies that when
|
|
// function_call_output.output is a bare JSON object, it is re-encoded as a
|
|
// JSON string instead of being silently dropped.
|
|
func TestParseResponsesRequest_FunctionCallOutputObject(t *testing.T) {
|
|
body := `{"model":"GLM-4.7","input":[
|
|
{"type":"function_call","call_id":"call_obj","name":"run","arguments":"{}"},
|
|
{"type":"function_call_output","call_id":"call_obj","output":{"result":"success","code":200}}
|
|
]}`
|
|
req, err := ParseResponsesRequest([]byte(body))
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
toolMsg := req.Messages[1]
|
|
content, _ := toolMsg["content"].(string)
|
|
if content == "" {
|
|
t.Fatal("content was dropped (empty)")
|
|
}
|
|
var parsed map[string]any
|
|
if err := json.Unmarshal([]byte(content), &parsed); err != nil {
|
|
t.Fatalf("content not valid JSON: %v", err)
|
|
}
|
|
if parsed["result"] != "success" {
|
|
t.Fatalf("result = %v", parsed["result"])
|
|
}
|
|
}
|
|
|
|
func TestUsageToResponses(t *testing.T) {
|
|
usage := map[string]any{
|
|
"prompt_tokens": 10,
|
|
"completion_tokens": 20,
|
|
"total_tokens": 30,
|
|
"prompt_tokens_details": map[string]any{"cached_tokens": 4},
|
|
"completion_tokens_details": map[string]any{"reasoning_tokens": 5},
|
|
}
|
|
result := UsageToResponses(usage)
|
|
m, ok := result.(map[string]any)
|
|
if !ok {
|
|
t.Fatalf("result type = %T", result)
|
|
}
|
|
if m["input_tokens"] != float64(10) {
|
|
t.Fatalf("input_tokens = %v", m["input_tokens"])
|
|
}
|
|
if m["output_tokens"] != float64(20) {
|
|
t.Fatalf("output_tokens = %v", m["output_tokens"])
|
|
}
|
|
if m["total_tokens"] != float64(30) {
|
|
t.Fatalf("total_tokens = %v", m["total_tokens"])
|
|
}
|
|
if d, ok := m["input_tokens_details"].(map[string]any); !ok || d["cached_tokens"] != float64(4) {
|
|
t.Fatalf("input_tokens_details = %v", m["input_tokens_details"])
|
|
}
|
|
if d, ok := m["output_tokens_details"].(map[string]any); !ok || d["reasoning_tokens"] != float64(5) {
|
|
t.Fatalf("output_tokens_details = %v", m["output_tokens_details"])
|
|
}
|
|
}
|
|
|
|
func TestParseResponsesRequest_TextFormat(t *testing.T) {
|
|
body := `{"model":"GLM-4.7","input":"Jane, 54","text":{"format":{"type":"json_schema","name":"person","strict":true,"schema":{"type":"object"}}}}`
|
|
req, err := ParseResponsesRequest([]byte(body))
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
rf, ok := req.Extra["response_format"]
|
|
if !ok {
|
|
t.Fatal("response_format not in Extra")
|
|
}
|
|
var m map[string]any
|
|
if err := json.Unmarshal(rf, &m); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if m["type"] != "json_schema" {
|
|
t.Fatalf("type = %v", m["type"])
|
|
}
|
|
js, ok := m["json_schema"].(map[string]any)
|
|
if !ok {
|
|
t.Fatalf("json_schema = %v", m["json_schema"])
|
|
}
|
|
if js["name"] != "person" {
|
|
t.Fatalf("name = %v", js["name"])
|
|
}
|
|
if js["strict"] != true {
|
|
t.Fatalf("strict = %v", js["strict"])
|
|
}
|
|
}
|
|
|
|
func TestParseResponsesRequest_TextFormatJsonObject(t *testing.T) {
|
|
body := `{"model":"GLM-4.7","input":"hi","text":{"format":{"type":"json_object"}}}`
|
|
req, err := ParseResponsesRequest([]byte(body))
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
rf, ok := req.Extra["response_format"]
|
|
if !ok {
|
|
t.Fatal("response_format not in Extra")
|
|
}
|
|
var m map[string]any
|
|
if err := json.Unmarshal(rf, &m); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if m["type"] != "json_object" {
|
|
t.Fatalf("type = %v", m["type"])
|
|
}
|
|
}
|
|
|
|
func TestParseResponsesMeta_Defaults(t *testing.T) {
|
|
meta := ParseResponsesMeta([]byte(`{"model":"GLM-4.7","input":"hi"}`))
|
|
if meta.ParallelToolCalls != false {
|
|
t.Fatalf("parallel_tool_calls = %v", meta.ParallelToolCalls)
|
|
}
|
|
if meta.ToolChoice != "auto" {
|
|
t.Fatalf("tool_choice = %v", meta.ToolChoice)
|
|
}
|
|
tools, ok := meta.Tools.([]any)
|
|
if !ok || len(tools) != 0 {
|
|
t.Fatalf("tools = %v", meta.Tools)
|
|
}
|
|
}
|
|
|
|
func TestParseResponsesMeta_EchoBack(t *testing.T) {
|
|
body := `{"model":"GLM-4.7","input":"hi","parallel_tool_calls":true,"tool_choice":"required","tools":[{"type":"function","name":"get_weather"}],"temperature":0.7,"max_output_tokens":500}`
|
|
meta := ParseResponsesMeta([]byte(body))
|
|
if meta.ParallelToolCalls != true {
|
|
t.Fatalf("parallel_tool_calls = %v", meta.ParallelToolCalls)
|
|
}
|
|
if meta.ToolChoice != "required" {
|
|
t.Fatalf("tool_choice = %v", meta.ToolChoice)
|
|
}
|
|
tools, ok := meta.Tools.([]any)
|
|
if !ok || len(tools) != 1 {
|
|
t.Fatalf("tools = %v", meta.Tools)
|
|
}
|
|
if meta.Temperature != 0.7 {
|
|
t.Fatalf("temperature = %v", meta.Temperature)
|
|
}
|
|
if meta.MaxOutputTokens != float64(500) {
|
|
t.Fatalf("max_output_tokens = %v", meta.MaxOutputTokens)
|
|
}
|
|
}
|