Files
zhanlu_proxy/internal/openai/responses_test.go
T
m1saka 2517b4f730 Add OpenAI Responses API (/v1/responses) endpoint
Translate Responses API requests (input→messages, instructions→system,
max_output_tokens→max_tokens, text.format→response_format, flat tools→nested
{function:{…}}) to upstream chat/completions, then convert responses back to
Responses format (streaming SSE event lifecycle + non-streaming JSON).

Verified against OpenAI migration guide and Python SDK Response model:
- Echo back required fields parallel_tool_calls/tool_choice/tools
- Include content:[] in reasoning items, logprobs:[] in output_text parts
- Support function_call/function_call_output multi-turn input items
- Map usage fields prompt_tokens→input_tokens, completion_tokens→output_tokens
2026-08-20 10:02:11 +08:00

306 lines
9.0 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"])
}
}
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)
}
}