Files
zhanlu_proxy/internal/openai/responses.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

348 lines
9.8 KiB
Go

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
}