Files
workbuddy-gateway/responses.go
T
CangShui 1ee094ffa1 feat: OpenAI Responses API + session normalization (v1.8.1)
- add /v1/responses (and /responses): OpenAI Responses API support
  - streaming semantic events, non-streaming aggregation, function tools
  - input item / content / tool / tool_choice conversion to chat format
- ensure first message is system: fix upstream 11128
  "first message is not system prompt" for non-harness clients
  (assistant/tool-leading or missing system); normalize developer role
- fix tool call delta merging (applyToolCallDelta) across streamed chunks
- refactor handleChatCompletions: extract upstreamChat for 429 cooldown
  fallback across the account pool
- bump version to 1.8.1
- tests: 37 passing (session normalization, responses conversion,
  tool call merge, responses<->chat mapping)
2026-09-13 17:43:01 +08:00

700 lines
22 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
package main
import (
"bufio"
"encoding/json"
"fmt"
"io"
"log"
"net/http"
"strings"
"sync/atomic"
"time"
"github.com/google/uuid"
)
// -----------------------------------------------------------------------------
// OpenAI Responses API (/v1/responses) -> 上游 Chat Completions 转译
//
// 网关只与上游 /v2/chat/completions 通信,此处负责双向协议转换:
// - 请求:input / instructions / tools(扁平) / tool_choice / max_output_tokens /
// reasoning.effort -> chat messages / tools(嵌套 function) / max_tokens / reasoning_effort
// - 非流式响应:chat.completion -> response{object:"response", output:[...]}
// - 流式响应:上游 SSE 增量 -> Responses 语义事件(response.output_text.delta、
// response.function_call_arguments.delta、response.completed ...)
// -----------------------------------------------------------------------------
// handleResponses 处理 POST /v1/responses。
func handleResponses(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodPost {
writeOpenAIError(w, http.StatusMethodNotAllowed, "method_not_allowed", "仅支持 POST 请求")
return
}
reqID := atomic.AddUint64(&reqCounter, 1)
startTime := time.Now()
bodyBytes, err := io.ReadAll(r.Body)
if err != nil {
writeOpenAIError(w, http.StatusBadRequest, "read_error", "读取请求体失败")
return
}
defer r.Body.Close()
var respReq map[string]any
if err := json.Unmarshal(bodyBytes, &respReq); err != nil {
writeOpenAIError(w, http.StatusBadRequest, "invalid_json", "无效的 JSON 请求体")
return
}
modelName, _ := respReq["model"].(string)
if modelName == "" {
modelName = "hy4-preview"
}
isStream, _ := respReq["stream"].(bool)
chatReq, err := responsesToChatRequest(respReq, modelName)
if err != nil {
writeOpenAIError(w, http.StatusBadRequest, "invalid_request", err.Error())
return
}
chatReq["stream"] = true // 上游强制流式,非流式由网关本地聚合
applyThinkingRules(chatReq, modelName)
sanitizeMessages(chatReq)
ensureLeadingSystemMessage(chatReq)
upstreamBytes, err := json.Marshal(chatReq)
if err != nil {
writeOpenAIError(w, http.StatusInternalServerError, "encode_error", "序列化请求失败")
return
}
log.Printf("[#%d] POST /v1/responses -> Upstream [Model: %s, Stream: %v]", reqID, modelName, isStream)
resp, acc, prof, ok := upstreamChat(w, r, reqID, upstreamBytes, startTime)
if !ok {
return
}
if isStream {
streamResponsesResponse(w, resp, modelName, reqID, acc, prof, startTime)
} else {
writeResponsesAggregate(w, resp, modelName, reqID, acc, prof, startTime)
}
}
// responsesToChatRequest 将 Responses 请求体转换为上游 Chat Completions 请求体。
func responsesToChatRequest(respReq map[string]any, modelName string) (map[string]any, error) {
chat := map[string]any{"model": modelName}
messages := []any{}
if instructions, ok := respReq["instructions"].(string); ok && strings.TrimSpace(instructions) != "" {
messages = append(messages, map[string]any{"role": "system", "content": instructions})
}
switch input := respReq["input"].(type) {
case string:
if strings.TrimSpace(input) != "" {
messages = append(messages, map[string]any{"role": "user", "content": input})
}
case []any:
for _, itemAny := range input {
switch item := itemAny.(type) {
case string:
messages = append(messages, map[string]any{"role": "user", "content": item})
case map[string]any:
messages = append(messages, convertResponsesInputItem(item)...)
}
}
}
if len(messages) == 0 {
return nil, fmt.Errorf("input 为空:Responses 请求必须提供 input 或 instructions")
}
chat["messages"] = messages
if v, ok := respReq["temperature"]; ok && v != nil {
chat["temperature"] = v
}
if v, ok := respReq["top_p"]; ok && v != nil {
chat["top_p"] = v
}
if v, ok := respReq["max_output_tokens"]; ok && v != nil {
chat["max_tokens"] = v
}
if tools := convertResponsesTools(respReq["tools"]); len(tools) > 0 {
chat["tools"] = tools
}
if tc := convertResponsesToolChoice(respReq["tool_choice"]); tc != nil {
chat["tool_choice"] = tc
}
if reasoning, ok := respReq["reasoning"].(map[string]any); ok {
if effort, ok := reasoning["effort"].(string); ok && effort != "" && effort != "none" {
chat["reasoning_effort"] = effort
}
}
return chat, nil
}
// convertResponsesInputItem 将单个 Responses input item 转换为 0..1 条 chat 消息。
func convertResponsesInputItem(item map[string]any) []any {
switch typ, _ := item["type"].(string); typ {
case "function_call":
callID, _ := item["call_id"].(string)
if callID == "" {
callID, _ = item["id"].(string)
}
name, _ := item["name"].(string)
args, _ := item["arguments"].(string)
return []any{map[string]any{
"role": "assistant",
"content": nil,
"tool_calls": []any{map[string]any{
"id": ifEmpty(callID, "call_"+compactUUID()),
"type": "function",
"function": map[string]any{"name": name, "arguments": args},
}},
}}
case "function_call_output":
callID, _ := item["call_id"].(string)
return []any{map[string]any{
"role": "tool",
"tool_call_id": callID,
"content": stringifyToolOutput(item["output"]),
}}
case "reasoning":
// 上游无法接收 reasoning item,忽略(历史上下文不影响后续对话)
return nil
}
role, _ := item["role"].(string)
if role == "" {
role = "user"
}
return []any{map[string]any{"role": role, "content": convertResponsesContent(item["content"])}}
}
// convertResponsesContent 将 Responses content(string 或 parts 数组)转换为 chat content。
func convertResponsesContent(content any) any {
switch c := content.(type) {
case nil:
return ""
case string:
return c
case []any:
parts := make([]any, 0, len(c))
for _, pAny := range c {
p, ok := pAny.(map[string]any)
if !ok {
if s, ok := pAny.(string); ok {
parts = append(parts, map[string]any{"type": "text", "text": s})
}
continue
}
switch typ, _ := p["type"].(string); typ {
case "input_text", "output_text", "text":
if t, ok := p["text"].(string); ok {
parts = append(parts, map[string]any{"type": "text", "text": t})
}
case "refusal":
if t, ok := p["refusal"].(string); ok {
parts = append(parts, map[string]any{"type": "text", "text": t})
}
case "input_image":
url, _ := p["image_url"].(string)
if url != "" {
parts = append(parts, map[string]any{"type": "image_url", "image_url": map[string]any{"url": url}})
}
}
}
if len(parts) == 0 {
return ""
}
return parts
default:
return fmt.Sprintf("%v", c)
}
}
// stringifyToolOutput 将 function_call_output 的 output 统一转为字符串。
func stringifyToolOutput(output any) string {
switch o := output.(type) {
case nil:
return ""
case string:
return o
default:
if b, err := json.Marshal(o); err == nil {
return string(b)
}
return fmt.Sprintf("%v", o)
}
}
// convertResponsesTools 将 Responses 扁平 function 工具转换为 chat 嵌套 function 工具。
func convertResponsesTools(toolsAny any) []any {
arr, ok := toolsAny.([]any)
if !ok {
return nil
}
out := make([]any, 0, len(arr))
for _, tAny := range arr {
t, ok := tAny.(map[string]any)
if !ok {
continue
}
typ, _ := t["type"].(string)
if typ != "" && typ != "function" {
continue // 仅支持 function 工具
}
name, _ := t["name"].(string)
desc, _ := t["description"].(string)
params := t["parameters"]
if name == "" {
// 兼容旧式嵌套 {type:"function", function:{...}}
if fn, ok := t["function"].(map[string]any); ok {
name, _ = fn["name"].(string)
desc, _ = fn["description"].(string)
params = fn["parameters"]
}
}
if name == "" {
continue
}
fn := map[string]any{"name": name}
if desc != "" {
fn["description"] = desc
}
if params != nil {
fn["parameters"] = params
}
out = append(out, map[string]any{"type": "function", "function": fn})
}
return out
}
// convertResponsesToolChoice 将 Responses tool_choice 转换为 chat tool_choice。
func convertResponsesToolChoice(tc any) any {
switch v := tc.(type) {
case string:
if v == "auto" || v == "none" || v == "required" {
return v
}
case map[string]any:
if typ, _ := v["type"].(string); typ == "function" {
name, _ := v["name"].(string)
if name == "" {
if fn, ok := v["function"].(map[string]any); ok {
name, _ = fn["name"].(string)
}
}
if name != "" {
return map[string]any{"type": "function", "function": map[string]any{"name": name}}
}
}
}
return nil
}
// writeResponsesAggregate 聚合上游 SSE 后转换为 Responses 非流式响应。
func writeResponsesAggregate(w http.ResponseWriter, resp *http.Response, modelName string, reqID uint64, acc *Account, prof *upstreamProfile, startTime time.Time) {
defer resp.Body.Close()
completionJSON, err := aggregateCompletion(resp.Body, modelName)
if err != nil {
log.Printf("[#%d] 聚合响应失败: %v", reqID, err)
writeOpenAIError(w, http.StatusInternalServerError, "aggregate_error", "聚合上游流式响应失败: "+err.Error())
return
}
out, err := chatCompletionToResponses(completionJSON, modelName)
if err != nil {
log.Printf("[#%d] Responses 转换失败: %v", reqID, err)
writeOpenAIError(w, http.StatusInternalServerError, "translate_error", "转换 Responses 响应失败: "+err.Error())
return
}
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(http.StatusOK)
_, _ = w.Write(out)
log.Printf("[#%d] Responses 非流式响应完成 (账号 %s [%s], 耗时 %v)", reqID, acc.Path, prof.Label, time.Since(startTime))
}
// chatCompletionToResponses 将 chat.completion JSON 转换为 Responses 响应对象。
func chatCompletionToResponses(chatJSON []byte, modelName string) ([]byte, error) {
var chat map[string]any
if err := json.Unmarshal(chatJSON, &chat); err != nil {
return nil, err
}
var content, reasoning string
var toolCalls []any
if choices, ok := chat["choices"].([]any); ok && len(choices) > 0 {
if choice, ok := choices[0].(map[string]any); ok {
if msg, ok := choice["message"].(map[string]any); ok {
content, _ = msg["content"].(string)
reasoning, _ = msg["reasoning_content"].(string)
toolCalls, _ = msg["tool_calls"].([]any)
}
}
}
output := []any{}
if reasoning != "" {
output = append(output, map[string]any{
"id": "rs_" + compactUUID(),
"type": "reasoning",
"status": "completed",
"summary": []any{map[string]any{"type": "summary_text", "text": reasoning}},
})
}
if content != "" || len(toolCalls) == 0 {
output = append(output, map[string]any{
"id": "msg_" + compactUUID(),
"type": "message",
"status": "completed",
"role": "assistant",
"content": []any{
map[string]any{"type": "output_text", "text": content, "annotations": []any{}},
},
})
}
for _, tcAny := range toolCalls {
tc, ok := tcAny.(map[string]any)
if !ok {
continue
}
callID, _ := tc["id"].(string)
name, args := "", ""
if fn, ok := tc["function"].(map[string]any); ok {
name, _ = fn["name"].(string)
args, _ = fn["arguments"].(string)
}
output = append(output, map[string]any{
"id": "fc_" + compactUUID(),
"type": "function_call",
"status": "completed",
"call_id": ifEmpty(callID, "call_"+compactUUID()),
"name": name,
"arguments": args,
})
}
now := time.Now().Unix()
created := now
if v, ok := chat["created"].(float64); ok && v > 0 && int64(v) <= now {
created = int64(v)
}
respID, _ := chat["id"].(string)
if respID == "" {
respID = "resp_" + compactUUID()
} else {
respID = "resp_" + strings.TrimPrefix(respID, "chatcmpl-")
}
result := buildResponsesEnvelope(respID, modelName, created)
result["status"] = "completed"
result["output"] = output
result["completed_at"] = now
if usage, ok := chat["usage"].(map[string]any); ok {
result["usage"] = toResponsesUsage(usage)
}
return json.Marshal(result)
}
// buildResponsesEnvelope 构造 Responses 响应骨架(含规范要求的常见字段)。
func buildResponsesEnvelope(id, model string, createdAt int64) map[string]any {
return map[string]any{
"id": id,
"object": "response",
"created_at": createdAt,
"completed_at": nil,
"status": "in_progress",
"error": nil,
"incomplete_details": nil,
"instructions": nil,
"max_output_tokens": nil,
"model": model,
"output": []any{},
"parallel_tool_calls": true,
"previous_response_id": nil,
"reasoning": map[string]any{"effort": nil, "summary": nil},
"store": true,
"temperature": 1.0,
"text": map[string]any{"format": map[string]any{"type": "text"}},
"tool_choice": "auto",
"tools": []any{},
"top_p": 1.0,
"truncation": "disabled",
"usage": nil,
"metadata": map[string]any{},
}
}
// toResponsesUsage 将 chat usage 转换为 Responses usage 结构。
func toResponsesUsage(u map[string]any) map[string]any {
in := numOr0(u["prompt_tokens"])
out := numOr0(u["completion_tokens"])
total := numOr0(u["total_tokens"])
if total == 0 {
total = in + out
}
cached, reasoningTokens := 0.0, 0.0
if d, ok := u["prompt_tokens_details"].(map[string]any); ok {
cached = numOr0(d["cached_tokens"])
}
if d, ok := u["completion_tokens_details"].(map[string]any); ok {
reasoningTokens = numOr0(d["reasoning_tokens"])
}
return map[string]any{
"input_tokens": int64(in),
"input_tokens_details": map[string]any{"cached_tokens": int64(cached)},
"output_tokens": int64(out),
"output_tokens_details": map[string]any{"reasoning_tokens": int64(reasoningTokens)},
"total_tokens": int64(total),
}
}
func numOr0(v any) float64 {
if f, ok := v.(float64); ok {
return f
}
return 0
}
// streamResponsesResponse 将上游 Chat Completions SSE 实时转译为 Responses 语义事件流。
func streamResponsesResponse(w http.ResponseWriter, resp *http.Response, modelName string, reqID uint64, acc *Account, prof *upstreamProfile, startTime time.Time) {
defer resp.Body.Close()
w.Header().Set("Content-Type", "text/event-stream")
w.Header().Set("Cache-Control", "no-cache")
w.Header().Set("Connection", "keep-alive")
w.Header().Set("X-Accel-Buffering", "no")
flusher, ok := w.(http.Flusher)
if !ok {
writeOpenAIError(w, http.StatusInternalServerError, "streaming_unsupported", "服务器不支持流式响应 Flush")
return
}
seq := 0
emit := func(eventType string, data map[string]any) {
data["type"] = eventType
data["sequence_number"] = seq
seq++
b, err := json.Marshal(data)
if err != nil {
return
}
_, _ = fmt.Fprintf(w, "event: %s\ndata: %s\n\n", eventType, b)
flusher.Flush()
}
created := time.Now().Unix()
respID := "resp_" + compactUUID()
envelope := buildResponsesEnvelope(respID, modelName, created)
emit("response.created", map[string]any{"response": envelope})
emit("response.in_progress", map[string]any{"response": envelope})
nextIndex := 0
idxToItem := map[int]map[string]any{}
// reasoning item 状态
reasoningOpen := false
reasoningIndex := -1
reasoningItemID := ""
var reasoningSB strings.Builder
// message item 状态
msgOpen := false
msgIndex := -1
msgItemID := ""
var msgSB strings.Builder
// function_call item 状态
toolCalls := map[int]*mergedToolCall{}
var toolOrder []int
tcItemID := map[int]string{}
tcIndex := map[int]int{}
openReasoning := func() {
if reasoningOpen {
return
}
reasoningOpen = true
reasoningIndex = nextIndex
nextIndex++
reasoningItemID = "rs_" + compactUUID()
emit("response.output_item.added", map[string]any{
"output_index": reasoningIndex,
"item": map[string]any{"id": reasoningItemID, "type": "reasoning", "status": "in_progress", "summary": []any{}},
})
emit("response.reasoning_summary_part.added", map[string]any{
"item_id": reasoningItemID, "output_index": reasoningIndex, "summary_index": 0,
"part": map[string]any{"type": "summary_text", "text": ""},
})
}
openMessage := func() {
if msgOpen {
return
}
msgOpen = true
msgIndex = nextIndex
nextIndex++
msgItemID = "msg_" + compactUUID()
emit("response.output_item.added", map[string]any{
"output_index": msgIndex,
"item": map[string]any{"id": msgItemID, "type": "message", "status": "in_progress", "role": "assistant", "content": []any{}},
})
emit("response.content_part.added", map[string]any{
"item_id": msgItemID, "output_index": msgIndex, "content_index": 0,
"part": map[string]any{"type": "output_text", "annotations": []any{}, "text": ""},
})
}
var usage map[string]any
scanner := bufio.NewScanner(resp.Body)
scanner.Buffer(make([]byte, 64*1024), 4*1024*1024)
for scanner.Scan() {
data := stripDataPrefix(scanner.Text())
if data == "" || data == "[DONE]" {
continue
}
var chunk map[string]any
if json.Unmarshal([]byte(data), &chunk) != nil {
continue
}
if u, ok := chunk["usage"].(map[string]any); ok {
usage = u
}
choices, _ := chunk["choices"].([]any)
for _, c := range choices {
choice, _ := c.(map[string]any)
delta, _ := choice["delta"].(map[string]any)
if delta == nil {
continue
}
if rc, ok := delta["reasoning_content"].(string); ok && rc != "" {
openReasoning()
reasoningSB.WriteString(rc)
emit("response.reasoning_summary_text.delta", map[string]any{
"item_id": reasoningItemID, "output_index": reasoningIndex, "summary_index": 0, "delta": rc,
})
}
if ct, ok := delta["content"].(string); ok && ct != "" {
openMessage()
msgSB.WriteString(ct)
emit("response.output_text.delta", map[string]any{
"item_id": msgItemID, "output_index": msgIndex, "content_index": 0, "delta": ct,
})
}
if tcs, ok := delta["tool_calls"].([]any); ok && len(tcs) > 0 {
// 先提取本次参数增量,供合并后补发 delta 事件
type argDelta struct {
idx int
arg string
}
var argDeltas []argDelta
for _, tcAny := range tcs {
tc, ok := tcAny.(map[string]any)
if !ok {
continue
}
idx := 0
if v, ok := tc["index"].(float64); ok {
idx = int(v)
}
if fn, ok := tc["function"].(map[string]any); ok {
if a, ok := fn["arguments"].(string); ok && a != "" {
argDeltas = append(argDeltas, argDelta{idx: idx, arg: a})
}
}
}
before := len(toolOrder)
applyToolCallDelta(toolCalls, &toolOrder, tcs)
for i := before; i < len(toolOrder); i++ {
idx := toolOrder[i]
st := toolCalls[idx]
if st.ID == "" {
st.ID = "call_" + compactUUID()
}
tcItemID[idx] = "fc_" + compactUUID()
tcIndex[idx] = nextIndex
nextIndex++
emit("response.output_item.added", map[string]any{
"output_index": tcIndex[idx],
"item": map[string]any{
"id": tcItemID[idx], "type": "function_call", "status": "in_progress",
"call_id": st.ID, "name": st.Name, "arguments": "",
},
})
}
for _, ad := range argDeltas {
emit("response.function_call_arguments.delta", map[string]any{
"item_id": tcItemID[ad.idx], "output_index": tcIndex[ad.idx], "delta": ad.arg,
})
}
}
}
}
// 若无任何输出,补一个空 message,保证 output 非空且事件序列完整
if !reasoningOpen && !msgOpen && len(toolOrder) == 0 {
openMessage()
}
// 收尾:reasoning
if reasoningOpen {
text := reasoningSB.String()
emit("response.reasoning_summary_text.done", map[string]any{"item_id": reasoningItemID, "output_index": reasoningIndex, "summary_index": 0, "text": text})
emit("response.reasoning_summary_part.done", map[string]any{"item_id": reasoningItemID, "output_index": reasoningIndex, "summary_index": 0, "part": map[string]any{"type": "summary_text", "text": text}})
item := map[string]any{"id": reasoningItemID, "type": "reasoning", "status": "completed", "summary": []any{map[string]any{"type": "summary_text", "text": text}}}
emit("response.output_item.done", map[string]any{"output_index": reasoningIndex, "item": item})
idxToItem[reasoningIndex] = item
}
// 收尾:message
if msgOpen {
text := msgSB.String()
emit("response.output_text.done", map[string]any{"item_id": msgItemID, "output_index": msgIndex, "content_index": 0, "text": text})
emit("response.content_part.done", map[string]any{"item_id": msgItemID, "output_index": msgIndex, "content_index": 0, "part": map[string]any{"type": "output_text", "annotations": []any{}, "text": text}})
item := map[string]any{"id": msgItemID, "type": "message", "status": "completed", "role": "assistant", "content": []any{map[string]any{"type": "output_text", "annotations": []any{}, "text": text}}}
emit("response.output_item.done", map[string]any{"output_index": msgIndex, "item": item})
idxToItem[msgIndex] = item
}
// 收尾:function calls
for _, idx := range toolOrder {
st := toolCalls[idx]
args := st.Args.String()
emit("response.function_call_arguments.done", map[string]any{"item_id": tcItemID[idx], "output_index": tcIndex[idx], "name": st.Name, "arguments": args})
item := map[string]any{"id": tcItemID[idx], "type": "function_call", "status": "completed", "call_id": st.ID, "name": st.Name, "arguments": args}
emit("response.output_item.done", map[string]any{"output_index": tcIndex[idx], "item": item})
idxToItem[tcIndex[idx]] = item
}
output := make([]any, 0, len(idxToItem))
for i := 0; i < nextIndex; i++ {
if item, ok := idxToItem[i]; ok {
output = append(output, item)
}
}
final := buildResponsesEnvelope(respID, modelName, created)
final["status"] = "completed"
final["output"] = output
final["completed_at"] = time.Now().Unix()
if usage != nil {
final["usage"] = toResponsesUsage(usage)
}
emit("response.completed", map[string]any{"response": final})
_, _ = fmt.Fprintf(w, "data: [DONE]\n\n")
flusher.Flush()
log.Printf("[#%d] Responses 流式输出完成 (账号 %s [%s], 耗时 %v)", reqID, acc.Path, prof.Label, time.Since(startTime))
}
// compactUUID 返回去掉连字符的随机 UUID。
func compactUUID() string {
return strings.ReplaceAll(uuid.NewString(), "-", "")
}