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
This commit is contained in:
2026-08-20 10:02:11 +08:00
parent c8938cb514
commit 2517b4f730
6 changed files with 1576 additions and 4 deletions
+347
View File
@@ -0,0 +1,347 @@
package openai
import (
"encoding/json"
"errors"
)
// --- Responses API request parsing & conversion ---
// ParseResponsesRequest converts a raw OpenAI Responses API (POST /v1/responses)
// request body into a ChatCompletionRequest suitable for the upstream gateway.
// It translates input→messages, instructions→system message, max_output_tokens→
// max_tokens, and reshapes tools from the Responses flat format to the Chat
// Completions nested {function:{…}} format.
func ParseResponsesRequest(body []byte) (ChatCompletionRequest, error) {
var raw map[string]json.RawMessage
if err := json.Unmarshal(body, &raw); err != nil {
return ChatCompletionRequest{}, err
}
var req ChatCompletionRequest
if v, ok := raw["model"]; ok {
_ = json.Unmarshal(v, &req.Model)
}
if v, ok := raw["stream"]; ok {
_ = json.Unmarshal(v, &req.Stream)
}
// Build messages from instructions + input.
messages := make([]map[string]any, 0, 4)
if v, ok := raw["instructions"]; ok {
var s string
if json.Unmarshal(v, &s) == nil && s != "" {
messages = append(messages, map[string]any{"role": "system", "content": s})
}
}
if v, ok := raw["input"]; ok {
msgs, err := responsesInputToMessages(v)
if err != nil {
return req, err
}
messages = append(messages, msgs...)
}
req.Messages = messages
// Extra: renamed + pass-through fields sent to upstream as-is.
extra := map[string]json.RawMessage{}
if v, ok := raw["max_output_tokens"]; ok {
extra["max_tokens"] = v
}
for _, key := range []string{
"temperature", "top_p", "top_k", "frequency_penalty",
"presence_penalty", "stop", "seed", "user",
} {
if v, ok := raw[key]; ok {
extra[key] = v
}
}
if v, ok := raw["tools"]; ok {
if translated, err := translateResponsesTools(v); err == nil {
extra["tools"] = translated
} else {
extra["tools"] = v // fall back to pass-through
}
}
if v, ok := raw["tool_choice"]; ok {
extra["tool_choice"] = v
}
// Structured Outputs: Responses API uses text.format instead of
// response_format. Translate text.format → response_format for the
// upstream Chat Completions endpoint.
if v, ok := raw["text"]; ok {
if rf, err := translateTextFormat(v); err == nil {
extra["response_format"] = rf
}
}
req.Extra = extra
return req, nil
}
// translateTextFormat converts the Responses API "text" field (containing a
// "format" sub-object) into a Chat Completions response_format value.
// - {text:{format:{type:"json_object"}}} → {type:"json_object"}
// - {text:{format:{type:"json_schema",name,schema,strict}}} →
// {type:"json_schema",json_schema:{name,schema,strict}}
func translateTextFormat(textRaw json.RawMessage) (json.RawMessage, error) {
var text struct {
Format map[string]any `json:"format"`
}
if err := json.Unmarshal(textRaw, &text); err != nil {
return nil, err
}
if len(text.Format) == 0 {
return nil, errors.New("empty text.format")
}
t, _ := text.Format["type"].(string)
switch t {
case "json_object":
return json.Marshal(map[string]any{"type": "json_object"})
case "json_schema":
js := map[string]any{}
for _, k := range []string{"name", "schema", "strict"} {
if v, ok := text.Format[k]; ok {
js[k] = v
}
}
return json.Marshal(map[string]any{"type": "json_schema", "json_schema": js})
default:
return json.Marshal(text.Format) // pass through unknown types
}
}
// ResponsesMeta holds fields from the Responses API request that should be
// echoed back in the response object (the OpenAI SDK requires them).
type ResponsesMeta struct {
ParallelToolCalls any
ToolChoice any
Tools any
Temperature any
TopP any
MaxOutputTokens any
}
// ParseResponsesMeta extracts echo-back fields from the raw request body.
func ParseResponsesMeta(body []byte) ResponsesMeta {
var m map[string]any
_ = json.Unmarshal(body, &m)
meta := ResponsesMeta{
ParallelToolCalls: false,
ToolChoice: "auto",
Tools: []any{},
}
if v, ok := m["parallel_tool_calls"]; ok {
meta.ParallelToolCalls = v
}
if v, ok := m["tool_choice"]; ok {
meta.ToolChoice = v
}
// Tools are echoed back in the Responses API flat format (not the
// translated Chat Completions format).
if v, ok := m["tools"]; ok {
meta.Tools = v
} else {
meta.Tools = []any{}
}
if v, ok := m["temperature"]; ok {
meta.Temperature = v
}
if v, ok := m["top_p"]; ok {
meta.TopP = v
}
if v, ok := m["max_output_tokens"]; ok {
meta.MaxOutputTokens = v
}
return meta
}
// responsesInputToMessages converts the Responses API "input" field (which may
// be a plain string or an array of input items) into Chat Completions messages.
func responsesInputToMessages(input json.RawMessage) ([]map[string]any, error) {
// Case 1: input is a plain string.
var s string
if json.Unmarshal(input, &s) == nil {
return []map[string]any{{"role": "user", "content": s}}, nil
}
// Case 2: input is an array of items.
var items []map[string]json.RawMessage
if err := json.Unmarshal(input, &items); err != nil {
return nil, err
}
messages := make([]map[string]any, 0, len(items))
for _, item := range items {
// Determine the type — most items are message-like with a role.
var role string
if v, ok := item["role"]; ok {
_ = json.Unmarshal(v, &role)
}
var itemType string
if v, ok := item["type"]; ok {
_ = json.Unmarshal(v, &itemType)
}
switch {
case itemType == "function_call":
// An assistant tool call from a previous turn.
msg := map[string]any{"role": "assistant"}
tc := map[string]any{"type": "function"}
inner := map[string]any{}
if v, ok := item["name"]; ok {
var name string
_ = json.Unmarshal(v, &name)
inner["name"] = name
}
if v, ok := item["arguments"]; ok {
var args string
_ = json.Unmarshal(v, &args)
inner["arguments"] = args
}
if v, ok := item["call_id"]; ok {
var id string
_ = json.Unmarshal(v, &id)
tc["id"] = id
}
tc["function"] = inner
msg["tool_calls"] = []any{tc}
messages = append(messages, msg)
case itemType == "function_call_output":
// A tool result from a previous turn.
msg := map[string]any{"role": "tool"}
if v, ok := item["call_id"]; ok {
var id string
_ = json.Unmarshal(v, &id)
msg["tool_call_id"] = id
}
if v, ok := item["output"]; ok {
var out string
_ = json.Unmarshal(v, &out)
msg["content"] = out
}
messages = append(messages, msg)
default:
// Standard message item with role + content.
if role == "" {
role = "user"
}
// "developer" maps to "system" for broad upstream compatibility.
if role == "developer" {
role = "system"
}
msg := map[string]any{"role": role}
if v, ok := item["content"]; ok {
msg["content"] = convertContentParts(v)
} else {
msg["content"] = ""
}
messages = append(messages, msg)
}
}
return messages, nil
}
// convertContentParts converts a Responses API content field (string or array
// of content parts) into the Chat Completions content format.
func convertContentParts(raw json.RawMessage) any {
// Content is a plain string.
var s string
if json.Unmarshal(raw, &s) == nil {
return s
}
// Content is an array of parts.
var parts []map[string]any
if json.Unmarshal(raw, &parts) != nil {
return string(raw) // fallback
}
result := make([]map[string]any, 0, len(parts))
for _, p := range parts {
pt, _ := p["type"].(string)
switch pt {
case "input_text", "output_text", "text":
result = append(result, map[string]any{"type": "text", "text": p["text"]})
case "input_image":
img := map[string]any{"type": "image_url"}
if url, ok := p["image_url"]; ok {
img["image_url"] = map[string]any{"url": url}
} else if d, ok := p["image"]; ok {
img["image_url"] = map[string]any{"url": d}
}
result = append(result, img)
default:
// Pass through unknown part types as-is.
result = append(result, p)
}
}
if len(result) == 0 {
return ""
}
return result
}
// translateResponsesTools converts tools from the Responses API flat format
// to the Chat Completions nested {type:"function",function:{…}} format.
func translateResponsesTools(raw json.RawMessage) (json.RawMessage, error) {
var tools []map[string]any
if err := json.Unmarshal(raw, &tools); err != nil {
return nil, err
}
out := make([]map[string]any, 0, len(tools))
for _, t := range tools {
tt, _ := t["type"].(string)
if tt != "function" && tt != "" {
// Non-function tool types (web_search, file_search, etc.) —
// pass through as-is; upstream may or may not support them.
out = append(out, t)
continue
}
fn := map[string]any{}
for _, key := range []string{"name", "description", "parameters", "strict"} {
if v, ok := t[key]; ok {
fn[key] = v
}
}
out = append(out, map[string]any{"type": "function", "function": fn})
}
return json.Marshal(out)
}
// --- Responses API response building ---
// UsageToResponses converts a Chat Completions usage value (as decoded by
// json.Unmarshal into any) to the Responses API usage field names
// (input_tokens / output_tokens instead of prompt_tokens / completion_tokens).
func UsageToResponses(v any) any {
if v == nil {
return nil
}
b, err := json.Marshal(v)
if err != nil {
return v
}
var m map[string]any
if json.Unmarshal(b, &m) != nil {
return v
}
out := map[string]any{}
if pt, ok := m["prompt_tokens"]; ok {
out["input_tokens"] = pt
}
if ct, ok := m["completion_tokens"]; ok {
out["output_tokens"] = ct
}
if tt, ok := m["total_tokens"]; ok {
out["total_tokens"] = tt
}
if d, ok := m["prompt_tokens_details"]; ok {
out["input_tokens_details"] = d
} else if d, ok := m["input_tokens_details"]; ok {
out["input_tokens_details"] = d
}
if d, ok := m["completion_tokens_details"]; ok {
out["output_tokens_details"] = d
} else if d, ok := m["output_tokens_details"]; ok {
out["output_tokens_details"] = d
}
return out
}
+305
View File
@@ -0,0 +1,305 @@
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)
}
}