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:
@@ -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
|
||||
}
|
||||
Reference in New Issue
Block a user