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
348 lines
9.8 KiB
Go
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
|
|
}
|