Files
m1saka f988c47fec
build / build (push) Successful in 2m29s
Fix silent data loss in function_call arguments/output non-string JSON parsing
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.
2026-08-23 14:15:01 +08:00

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)
}
}