76ba500417
CI / docker (push) Successful in 2m4s
OpenAI-compatible AI gateway with Vue admin UI, multi-provider egress, ingress key governance, monitoring, and security controls. Co-authored-by: Cursor <cursoragent@cursor.com>
641 lines
16 KiB
Go
641 lines
16 KiB
Go
package provider
|
|
|
|
import (
|
|
"bufio"
|
|
"encoding/json"
|
|
"fmt"
|
|
"io"
|
|
"net/http"
|
|
"strings"
|
|
)
|
|
|
|
// CanAdaptResponses reports whether responses ingress can be served via chat completions.
|
|
func CanAdaptResponses(allowsResponses, allowsChat bool) bool {
|
|
return allowsChat && !allowsResponses
|
|
}
|
|
|
|
// ResponsesToChatCompletion converts an OpenAI Responses API request to chat completions format.
|
|
func ResponsesToChatCompletion(body []byte) ([]byte, error) {
|
|
var req map[string]json.RawMessage
|
|
if err := json.Unmarshal(body, &req); err != nil {
|
|
return nil, fmt.Errorf("invalid json: %w", err)
|
|
}
|
|
|
|
out := make(map[string]any)
|
|
if raw, ok := req["model"]; ok {
|
|
var model string
|
|
if err := json.Unmarshal(raw, &model); err != nil || model == "" {
|
|
return nil, fmt.Errorf("model is required")
|
|
}
|
|
out["model"] = model
|
|
}
|
|
|
|
messages := make([]map[string]any, 0, 4)
|
|
if raw, ok := req["instructions"]; ok {
|
|
var instructions string
|
|
if err := json.Unmarshal(raw, &instructions); err == nil && instructions != "" {
|
|
messages = append(messages, map[string]any{"role": "system", "content": instructions})
|
|
} else {
|
|
var instructionParts []json.RawMessage
|
|
if err := json.Unmarshal(raw, &instructionParts); err == nil {
|
|
if text := joinContentPartTexts(instructionParts); text != "" {
|
|
messages = append(messages, map[string]any{"role": "system", "content": text})
|
|
}
|
|
}
|
|
}
|
|
}
|
|
if raw, ok := req["input"]; ok {
|
|
inputMsgs, err := parseResponsesInput(raw)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
messages = append(messages, inputMsgs...)
|
|
}
|
|
if len(messages) == 0 {
|
|
return nil, fmt.Errorf("input is required")
|
|
}
|
|
out["messages"] = messages
|
|
|
|
copyField(req, out, "stream")
|
|
copyField(req, out, "temperature")
|
|
copyField(req, out, "top_p")
|
|
copyField(req, out, "user")
|
|
copyField(req, out, "stop")
|
|
copyField(req, out, "tools")
|
|
copyField(req, out, "tool_choice")
|
|
copyField(req, out, "parallel_tool_calls")
|
|
copyField(req, out, "seed")
|
|
copyField(req, out, "n")
|
|
copyField(req, out, "presence_penalty")
|
|
copyField(req, out, "frequency_penalty")
|
|
copyField(req, out, "logit_bias")
|
|
copyField(req, out, "response_format")
|
|
|
|
if raw, ok := req["max_output_tokens"]; ok {
|
|
var v int
|
|
if err := json.Unmarshal(raw, &v); err == nil && v > 0 {
|
|
out["max_tokens"] = v
|
|
}
|
|
} else {
|
|
copyField(req, out, "max_tokens")
|
|
}
|
|
|
|
if raw, ok := req["text"]; ok {
|
|
if rf := responsesTextToResponseFormat(raw); rf != nil {
|
|
out["response_format"] = rf
|
|
}
|
|
}
|
|
|
|
return json.Marshal(out)
|
|
}
|
|
|
|
func copyField(src map[string]json.RawMessage, dst map[string]any, key string) {
|
|
if raw, ok := src[key]; ok && len(raw) > 0 && string(raw) != "null" {
|
|
var v any
|
|
if json.Unmarshal(raw, &v) == nil {
|
|
dst[key] = v
|
|
}
|
|
}
|
|
}
|
|
|
|
func responsesTextToResponseFormat(raw json.RawMessage) any {
|
|
var text struct {
|
|
Format struct {
|
|
Type string `json:"type"`
|
|
} `json:"format"`
|
|
}
|
|
if err := json.Unmarshal(raw, &text); err != nil || text.Format.Type == "" {
|
|
return nil
|
|
}
|
|
switch text.Format.Type {
|
|
case "json_object":
|
|
return map[string]any{"type": "json_object"}
|
|
case "text":
|
|
return map[string]any{"type": "text"}
|
|
default:
|
|
return map[string]any{"type": text.Format.Type}
|
|
}
|
|
}
|
|
|
|
func parseResponsesInput(raw json.RawMessage) ([]map[string]any, error) {
|
|
var asString string
|
|
if err := json.Unmarshal(raw, &asString); err == nil {
|
|
if asString == "" {
|
|
return nil, fmt.Errorf("input is required")
|
|
}
|
|
return []map[string]any{{"role": "user", "content": asString}}, nil
|
|
}
|
|
|
|
var asArray []json.RawMessage
|
|
if err := json.Unmarshal(raw, &asArray); err != nil {
|
|
return nil, fmt.Errorf("invalid input")
|
|
}
|
|
out := make([]map[string]any, 0, len(asArray))
|
|
for _, item := range asArray {
|
|
msg, err := parseResponsesInputItem(item)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
out = append(out, msg)
|
|
}
|
|
return out, nil
|
|
}
|
|
|
|
func parseResponsesInputItem(raw json.RawMessage) (map[string]any, error) {
|
|
var asString string
|
|
if err := json.Unmarshal(raw, &asString); err == nil {
|
|
return map[string]any{"role": "user", "content": asString}, nil
|
|
}
|
|
|
|
var item map[string]json.RawMessage
|
|
if err := json.Unmarshal(raw, &item); err != nil {
|
|
return nil, fmt.Errorf("invalid input item")
|
|
}
|
|
|
|
itemType := ""
|
|
if rawType, ok := item["type"]; ok {
|
|
_ = json.Unmarshal(rawType, &itemType)
|
|
}
|
|
|
|
role := "user"
|
|
if rawRole, ok := item["role"]; ok {
|
|
_ = json.Unmarshal(rawRole, &role)
|
|
}
|
|
if role == "" {
|
|
role = "user"
|
|
}
|
|
role = normalizeResponsesRole(role)
|
|
|
|
switch itemType {
|
|
case "message":
|
|
if rawContent, ok := item["content"]; ok {
|
|
content, err := parseResponsesContent(rawContent)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
return map[string]any{"role": role, "content": content}, nil
|
|
}
|
|
case "input_text":
|
|
var text string
|
|
if rawText, ok := item["text"]; ok {
|
|
_ = json.Unmarshal(rawText, &text)
|
|
}
|
|
if text != "" {
|
|
return map[string]any{"role": role, "content": text}, nil
|
|
}
|
|
case "function_call_output":
|
|
return parseFunctionCallOutputItem(item, role)
|
|
}
|
|
|
|
if rawContent, ok := item["content"]; ok {
|
|
content, err := parseResponsesContent(rawContent)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
return map[string]any{"role": role, "content": content}, nil
|
|
}
|
|
if rawText, ok := item["text"]; ok {
|
|
var text string
|
|
if err := json.Unmarshal(rawText, &text); err == nil {
|
|
return map[string]any{"role": role, "content": text}, nil
|
|
}
|
|
}
|
|
if rawInput, ok := item["input"]; ok {
|
|
var text string
|
|
if err := json.Unmarshal(rawInput, &text); err == nil {
|
|
return map[string]any{"role": role, "content": text}, nil
|
|
}
|
|
}
|
|
return nil, fmt.Errorf("unsupported input item")
|
|
}
|
|
|
|
func normalizeResponsesRole(role string) string {
|
|
switch role {
|
|
case "developer":
|
|
return "system"
|
|
default:
|
|
return role
|
|
}
|
|
}
|
|
|
|
func parseResponsesContent(raw json.RawMessage) (any, error) {
|
|
var asString string
|
|
if err := json.Unmarshal(raw, &asString); err == nil {
|
|
return asString, nil
|
|
}
|
|
var parts []json.RawMessage
|
|
if err := json.Unmarshal(raw, &parts); err != nil {
|
|
return nil, fmt.Errorf("invalid content")
|
|
}
|
|
converted := make([]any, 0, len(parts))
|
|
for _, part := range parts {
|
|
if item, ok := convertResponsesContentPart(part); ok {
|
|
converted = append(converted, item)
|
|
}
|
|
}
|
|
if len(converted) == 0 {
|
|
return "", nil
|
|
}
|
|
if len(converted) == 1 {
|
|
if text, ok := converted[0].(string); ok {
|
|
return text, nil
|
|
}
|
|
if m, ok := converted[0].(map[string]any); ok {
|
|
if m["type"] == "text" {
|
|
if text, ok := m["text"].(string); ok {
|
|
return text, nil
|
|
}
|
|
}
|
|
}
|
|
}
|
|
return converted, nil
|
|
}
|
|
|
|
func convertResponsesContentPart(raw json.RawMessage) (any, bool) {
|
|
var asString string
|
|
if err := json.Unmarshal(raw, &asString); err == nil {
|
|
return asString, true
|
|
}
|
|
var part map[string]json.RawMessage
|
|
if err := json.Unmarshal(raw, &part); err != nil {
|
|
return nil, false
|
|
}
|
|
typ := ""
|
|
if rawType, ok := part["type"]; ok {
|
|
_ = json.Unmarshal(rawType, &typ)
|
|
}
|
|
switch typ {
|
|
case "input_text", "text", "output_text":
|
|
var text string
|
|
if rawText, ok := part["text"]; ok {
|
|
_ = json.Unmarshal(rawText, &text)
|
|
}
|
|
if text == "" {
|
|
return nil, false
|
|
}
|
|
return map[string]any{"type": "text", "text": text}, true
|
|
case "input_image":
|
|
out := map[string]any{"type": "image_url", "image_url": map[string]any{}}
|
|
img := out["image_url"].(map[string]any)
|
|
if rawURL, ok := part["image_url"]; ok {
|
|
var url string
|
|
if err := json.Unmarshal(rawURL, &url); err == nil {
|
|
img["url"] = url
|
|
} else {
|
|
var nested map[string]any
|
|
if err := json.Unmarshal(rawURL, &nested); err == nil {
|
|
for k, v := range nested {
|
|
img[k] = v
|
|
}
|
|
}
|
|
}
|
|
}
|
|
if rawDetail, ok := part["detail"]; ok {
|
|
var detail string
|
|
if json.Unmarshal(rawDetail, &detail) == nil {
|
|
img["detail"] = detail
|
|
}
|
|
}
|
|
if img["url"] == nil {
|
|
return nil, false
|
|
}
|
|
return out, true
|
|
case "image_url":
|
|
var v any
|
|
_ = json.Unmarshal(raw, &v)
|
|
return v, true
|
|
case "input_file":
|
|
var fileID, filename string
|
|
if rawID, ok := part["file_id"]; ok {
|
|
_ = json.Unmarshal(rawID, &fileID)
|
|
}
|
|
if rawName, ok := part["filename"]; ok {
|
|
_ = json.Unmarshal(rawName, &filename)
|
|
}
|
|
label := filename
|
|
if label == "" {
|
|
label = fileID
|
|
}
|
|
if label == "" {
|
|
return nil, false
|
|
}
|
|
return map[string]any{"type": "text", "text": "[file:" + label + "]"}, true
|
|
default:
|
|
return nil, false
|
|
}
|
|
}
|
|
|
|
func joinContentPartTexts(parts []json.RawMessage) string {
|
|
var texts []string
|
|
for _, part := range parts {
|
|
if item, ok := convertResponsesContentPart(part); ok {
|
|
if text, ok := item.(string); ok {
|
|
texts = append(texts, text)
|
|
} else if m, ok := item.(map[string]any); ok && m["type"] == "text" {
|
|
if text, ok := m["text"].(string); ok {
|
|
texts = append(texts, text)
|
|
}
|
|
}
|
|
}
|
|
}
|
|
return strings.Join(texts, "\n")
|
|
}
|
|
|
|
func parseFunctionCallOutputItem(item map[string]json.RawMessage, role string) (map[string]any, error) {
|
|
var output, callID string
|
|
if rawOutput, ok := item["output"]; ok {
|
|
_ = json.Unmarshal(rawOutput, &output)
|
|
}
|
|
if rawCallID, ok := item["call_id"]; ok {
|
|
_ = json.Unmarshal(rawCallID, &callID)
|
|
}
|
|
if output == "" {
|
|
return nil, fmt.Errorf("unsupported input item")
|
|
}
|
|
msg := map[string]any{"role": "tool", "content": output}
|
|
if callID != "" {
|
|
msg["tool_call_id"] = callID
|
|
}
|
|
if role != "" && role != "user" {
|
|
msg["role"] = role
|
|
}
|
|
return msg, nil
|
|
}
|
|
|
|
// ChatCompletionToResponses converts a chat completions response to Responses API format.
|
|
func ChatCompletionToResponses(body []byte) ([]byte, error) {
|
|
var chat map[string]any
|
|
if err := json.Unmarshal(body, &chat); err != nil {
|
|
return nil, err
|
|
}
|
|
if _, ok := chat["error"]; ok {
|
|
return body, nil
|
|
}
|
|
|
|
content := extractChatContent(chat)
|
|
chatID, _ := chat["id"].(string)
|
|
model, _ := chat["model"].(string)
|
|
created := extractCreatedAt(chat)
|
|
respID := responsesIDFromChatID(chatID)
|
|
msgID := messageIDFromResponseID(respID)
|
|
usage := chatUsageToResponses(chat["usage"])
|
|
|
|
resp := buildResponseObject(respID, msgID, model, created, content, usage, "completed")
|
|
return json.Marshal(resp)
|
|
}
|
|
|
|
func extractCreatedAt(chat map[string]any) int64 {
|
|
switch v := chat["created"].(type) {
|
|
case float64:
|
|
return int64(v)
|
|
case int64:
|
|
return v
|
|
case int:
|
|
return int64(v)
|
|
default:
|
|
return 0
|
|
}
|
|
}
|
|
|
|
func responsesIDFromChatID(chatID string) string {
|
|
if chatID == "" {
|
|
return "resp_adapted"
|
|
}
|
|
if strings.HasPrefix(chatID, "resp_") {
|
|
return chatID
|
|
}
|
|
if strings.HasPrefix(chatID, "chatcmpl-") {
|
|
return "resp_" + strings.TrimPrefix(chatID, "chatcmpl-")
|
|
}
|
|
return "resp_" + chatID
|
|
}
|
|
|
|
func messageIDFromResponseID(respID string) string {
|
|
if strings.HasPrefix(respID, "resp_") {
|
|
return "msg_" + strings.TrimPrefix(respID, "resp_")
|
|
}
|
|
return "msg_" + respID
|
|
}
|
|
|
|
func buildOutputTextContent(text string) map[string]any {
|
|
return map[string]any{
|
|
"type": "output_text",
|
|
"text": text,
|
|
"annotations": []any{},
|
|
}
|
|
}
|
|
|
|
func buildOutputMessage(msgID, content, status string) map[string]any {
|
|
return map[string]any{
|
|
"type": "message",
|
|
"id": msgID,
|
|
"role": "assistant",
|
|
"status": status,
|
|
"content": []map[string]any{buildOutputTextContent(content)},
|
|
}
|
|
}
|
|
|
|
func buildResponseObject(respID, msgID, model string, created int64, content string, usage map[string]any, status string) map[string]any {
|
|
msgStatus := status
|
|
if status == "in_progress" || status == "queued" {
|
|
msgStatus = "in_progress"
|
|
}
|
|
resp := map[string]any{
|
|
"id": respID,
|
|
"object": "response",
|
|
"created_at": created,
|
|
"status": status,
|
|
"model": model,
|
|
"output_text": content,
|
|
"error": nil,
|
|
"incomplete_details": nil,
|
|
"parallel_tool_calls": true,
|
|
"output": []map[string]any{buildOutputMessage(msgID, content, msgStatus)},
|
|
}
|
|
if usage != nil {
|
|
resp["usage"] = usage
|
|
}
|
|
return resp
|
|
}
|
|
|
|
func extractChatContent(chat map[string]any) string {
|
|
choices, ok := chat["choices"].([]any)
|
|
if !ok || len(choices) == 0 {
|
|
return ""
|
|
}
|
|
choice, ok := choices[0].(map[string]any)
|
|
if !ok {
|
|
return ""
|
|
}
|
|
if msg, ok := choice["message"].(map[string]any); ok {
|
|
if content, ok := msg["content"].(string); ok {
|
|
return content
|
|
}
|
|
}
|
|
if delta, ok := choice["delta"].(map[string]any); ok {
|
|
if content, ok := delta["content"].(string); ok {
|
|
return content
|
|
}
|
|
}
|
|
return ""
|
|
}
|
|
|
|
func chatUsageToResponses(usage any) map[string]any {
|
|
u, ok := usage.(map[string]any)
|
|
if !ok {
|
|
return nil
|
|
}
|
|
in, hasIn := numberAsInt(u["prompt_tokens"])
|
|
out, hasOut := numberAsInt(u["completion_tokens"])
|
|
total, hasTotal := numberAsInt(u["total_tokens"])
|
|
if !hasIn && !hasOut && !hasTotal {
|
|
return nil
|
|
}
|
|
if !hasTotal && hasIn && hasOut {
|
|
total = in + out
|
|
}
|
|
return map[string]any{
|
|
"input_tokens": in,
|
|
"output_tokens": out,
|
|
"total_tokens": total,
|
|
"input_tokens_details": map[string]any{
|
|
"cached_tokens": 0,
|
|
},
|
|
"output_tokens_details": map[string]any{
|
|
"reasoning_tokens": 0,
|
|
},
|
|
}
|
|
}
|
|
|
|
func numberAsInt(v any) (int, bool) {
|
|
switch n := v.(type) {
|
|
case float64:
|
|
return int(n), true
|
|
case int:
|
|
return n, true
|
|
case int64:
|
|
return int(n), true
|
|
default:
|
|
return 0, false
|
|
}
|
|
}
|
|
|
|
// AdaptResponsesStream wraps a chat-completions SSE stream as Responses API SSE events.
|
|
func AdaptResponsesStream(chatBody []byte, upstream io.Reader, w io.Writer, flusher http.Flusher) (*StreamResult, error) {
|
|
var req map[string]any
|
|
_ = json.Unmarshal(chatBody, &req)
|
|
model, _ := req["model"].(string)
|
|
|
|
result := &StreamResult{StatusCode: http.StatusOK}
|
|
respID := "resp_adapted"
|
|
msgID := "msg_adapted"
|
|
seq := 0
|
|
var fullText string
|
|
var usage map[string]any
|
|
started := false
|
|
|
|
writeEvent := func(eventType string, payload map[string]any) error {
|
|
payload["type"] = eventType
|
|
payload["sequence_number"] = seq
|
|
seq++
|
|
b, err := json.Marshal(payload)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
if _, err := fmt.Fprintf(w, "event: %s\ndata: %s\n\n", eventType, b); err != nil {
|
|
return err
|
|
}
|
|
if flusher != nil {
|
|
flusher.Flush()
|
|
}
|
|
return nil
|
|
}
|
|
|
|
emitCreated := func(status string) error {
|
|
response := buildResponseObject(respID, msgID, model, 0, "", nil, status)
|
|
response["output"] = []any{}
|
|
response["output_text"] = ""
|
|
return writeEvent("response.created", map[string]any{"response": response})
|
|
}
|
|
|
|
scanner := bufio.NewScanner(upstream)
|
|
scanner.Buffer(make([]byte, 64*1024), 1024*1024)
|
|
for scanner.Scan() {
|
|
line := scanner.Text()
|
|
if !strings.HasPrefix(line, "data: ") {
|
|
continue
|
|
}
|
|
data := strings.TrimPrefix(line, "data: ")
|
|
if data == "[DONE]" {
|
|
break
|
|
}
|
|
tokensIn, tokensOut := parseStreamChunk([]byte(data))
|
|
result.TokensIn += tokensIn
|
|
result.TokensOut += tokensOut
|
|
|
|
var chunk map[string]any
|
|
if err := json.Unmarshal([]byte(data), &chunk); err != nil {
|
|
continue
|
|
}
|
|
if id, ok := chunk["id"].(string); ok && id != "" {
|
|
respID = responsesIDFromChatID(id)
|
|
msgID = messageIDFromResponseID(respID)
|
|
}
|
|
if m, ok := chunk["model"].(string); ok && m != "" {
|
|
model = m
|
|
}
|
|
if u := chatUsageToResponses(chunk["usage"]); u != nil {
|
|
usage = u
|
|
}
|
|
|
|
delta := extractChatContent(chunk)
|
|
if delta == "" {
|
|
continue
|
|
}
|
|
|
|
if !started {
|
|
started = true
|
|
if err := emitCreated("in_progress"); err != nil {
|
|
return result, err
|
|
}
|
|
if err := writeEvent("response.in_progress", map[string]any{
|
|
"response": buildResponseObject(respID, msgID, model, 0, "", nil, "in_progress"),
|
|
}); err != nil {
|
|
return result, err
|
|
}
|
|
if err := writeEvent("response.output_item.added", map[string]any{
|
|
"output_index": 0,
|
|
"item": map[string]any{
|
|
"type": "message", "id": msgID, "role": "assistant", "status": "in_progress", "content": []any{},
|
|
},
|
|
}); err != nil {
|
|
return result, err
|
|
}
|
|
}
|
|
|
|
fullText += delta
|
|
if err := writeEvent("response.output_text.delta", map[string]any{
|
|
"item_id": msgID, "output_index": 0, "content_index": 0, "delta": delta, "logprobs": []any{},
|
|
}); err != nil {
|
|
return result, err
|
|
}
|
|
}
|
|
if err := scanner.Err(); err != nil {
|
|
return result, err
|
|
}
|
|
|
|
if started {
|
|
_ = writeEvent("response.output_text.done", map[string]any{
|
|
"item_id": msgID, "output_index": 0, "content_index": 0, "text": fullText, "logprobs": []any{},
|
|
})
|
|
doneItem := buildOutputMessage(msgID, fullText, "completed")
|
|
_ = writeEvent("response.output_item.done", map[string]any{
|
|
"output_index": 0,
|
|
"item": doneItem,
|
|
})
|
|
final := buildResponseObject(respID, msgID, model, 0, fullText, usage, "completed")
|
|
_ = writeEvent("response.completed", map[string]any{"response": final})
|
|
}
|
|
return result, nil
|
|
}
|