后端 - 新增 proxy/convert 三协议(chat/messages/responses)请求、响应与 SSE 流式互转, 以 Chat 为中间模型;usage.go 统一提取三协议 token 用量(含单测) - gateway: 跨协议调度(渠道未声明客户端协议时转为渠道首选格式), streamResponse 按 \n\n 分块逐行转换直通,bufferResponse 转换失败时剥非 JSON 前缀 - gateway: 新增 SetUsageRecorder 注入异步用量记录器 - auth_llm: 修复 key_prefix 查询长度错配([:8] vs 存储的 [:12])导致全部 401; 修复长度 8-11 的 key 切片越界 panic;统一 unauthorized 响应 - usage: 日报表改为增量累加 upsert,避免多次 flush 互相清零;记录协议/错误码/时延等字段 - channel: 新增渠道并发槽 TryAcquire;健康检查支持可配置参数 - api: 新增 admin 渠道/模型/系统配置管理端点(旧端点保留兼容) 前端 - 新增渠道管理、模型管理、系统配置视图与 ChannelModelsDrawer - 新增 ui 基础组件(Button/Badge/Input/Modal)与 protocol.ts - 调整 Toast 样式、密钥页、路由菜单;dev 代理默认指向 3000 端口
377 lines
10 KiB
Go
377 lines
10 KiB
Go
package convert
|
||
|
||
import (
|
||
"encoding/json"
|
||
"strings"
|
||
)
|
||
|
||
// ---------------------------------------------------------------------------
|
||
// 请求:Responses → Chat
|
||
|
||
// responsesToChatReq 将 OpenAI Responses 请求转为 Chat 请求。
|
||
func responsesToChatReq(body []byte) ([]byte, error) {
|
||
var m map[string]json.RawMessage
|
||
if err := json.Unmarshal(body, &m); err != nil {
|
||
return nil, err
|
||
}
|
||
out := map[string]any{"model": str(rawJSON(m, "model"))}
|
||
if v, ok := m["stream"]; ok && string(v) == "true" {
|
||
out["stream"] = true
|
||
}
|
||
if v, ok := m["temperature"]; ok {
|
||
out["temperature"] = v
|
||
}
|
||
if v, ok := m["top_p"]; ok {
|
||
out["top_p"] = v
|
||
}
|
||
if v, ok := m["max_output_tokens"]; ok {
|
||
out["max_tokens"] = v
|
||
}
|
||
|
||
var msgs []any
|
||
if ins := str(rawJSON(m, "instructions")); ins != "" {
|
||
msgs = append(msgs, map[string]any{"role": "system", "content": ins})
|
||
}
|
||
msgs = append(msgs, responsesInputToChat(rawJSON(m, "input"))...)
|
||
out["messages"] = msgs
|
||
|
||
if raw := rawJSON(m, "tools"); raw != nil {
|
||
var tools []map[string]any
|
||
if json.Unmarshal(raw, &tools) == nil {
|
||
chatTools := make([]any, 0, len(tools))
|
||
for _, t := range tools {
|
||
chatTools = append(chatTools, map[string]any{
|
||
"type": "function",
|
||
"function": map[string]any{
|
||
"name": t["name"],
|
||
"description": t["description"],
|
||
"parameters": t["parameters"],
|
||
},
|
||
})
|
||
}
|
||
out["tools"] = chatTools
|
||
}
|
||
}
|
||
return json.Marshal(out)
|
||
}
|
||
|
||
// responsesInputToChat 把 Responses input 转成 Chat messages。
|
||
// input 支持字符串或条目数组(message / function_call / function_call_output)。
|
||
func responsesInputToChat(raw json.RawMessage) []any {
|
||
if len(raw) == 0 || string(raw) == "null" {
|
||
return nil
|
||
}
|
||
// 字符串输入
|
||
if s := str(raw); s != "" {
|
||
return []any{map[string]any{"role": "user", "content": s}}
|
||
}
|
||
var items []map[string]any
|
||
if err := json.Unmarshal(raw, &items); err != nil || items == nil {
|
||
return nil
|
||
}
|
||
var out []any
|
||
for _, item := range items {
|
||
switch item["type"] {
|
||
case "function_call":
|
||
out = append(out, map[string]any{
|
||
"role": "assistant",
|
||
"content": "",
|
||
"tool_calls": []any{map[string]any{
|
||
"id": strField(item["call_id"]),
|
||
"type": "function",
|
||
"function": map[string]any{
|
||
"name": strField(item["name"]),
|
||
"arguments": strField(item["arguments"]),
|
||
},
|
||
}},
|
||
})
|
||
case "function_call_output":
|
||
out = append(out, map[string]any{
|
||
"role": "tool",
|
||
"tool_call_id": strField(item["call_id"]),
|
||
"content": strField(item["output"]),
|
||
})
|
||
default: // message 条目
|
||
role, _ := item["role"].(string)
|
||
if role == "" {
|
||
role = "user"
|
||
}
|
||
if content, ok := item["content"].(string); ok {
|
||
out = append(out, map[string]any{"role": role, "content": content})
|
||
} else if blocks, ok := item["content"].([]any); ok {
|
||
var text []string
|
||
var contentBlocks []any
|
||
for _, b := range blocks {
|
||
bm, ok := b.(map[string]any)
|
||
if !ok {
|
||
continue
|
||
}
|
||
switch bm["type"] {
|
||
case "input_text", "text":
|
||
if t, _ := bm["text"].(string); t != "" {
|
||
text = append(text, t)
|
||
contentBlocks = append(contentBlocks, map[string]any{"type": "text", "text": t})
|
||
}
|
||
case "input_image":
|
||
var url string
|
||
if s, ok := bm["image_url"].(string); ok {
|
||
url = s
|
||
} else if m, ok := bm["image_url"].(map[string]any); ok {
|
||
url, _ = m["url"].(string)
|
||
}
|
||
if url != "" {
|
||
contentBlocks = append(contentBlocks, map[string]any{"type": "image_url", "image_url": map[string]any{"url": url}})
|
||
}
|
||
}
|
||
}
|
||
hasImage := false
|
||
for _, cb := range contentBlocks {
|
||
if m, _ := cb.(map[string]any); m["type"] == "image_url" {
|
||
hasImage = true
|
||
break
|
||
}
|
||
}
|
||
if hasImage {
|
||
out = append(out, map[string]any{"role": role, "content": contentBlocks})
|
||
} else {
|
||
out = append(out, map[string]any{"role": role, "content": strings.Join(text, "")})
|
||
}
|
||
}
|
||
}
|
||
}
|
||
return out
|
||
}
|
||
|
||
// chatContentToResponsesBlocks 把 Chat 用户消息 content 转 Responses input 块数组(input_text / input_image)。
|
||
func chatContentToResponsesBlocks(content json.RawMessage) []any {
|
||
// 纯字符串 → 单个 input_text
|
||
var s string
|
||
if json.Unmarshal(content, &s) == nil && s != "" {
|
||
return []any{map[string]any{"type": "input_text", "text": s}}
|
||
}
|
||
// 数组 → 按块转换(text / image_url)
|
||
var arr []map[string]any
|
||
if json.Unmarshal(content, &arr) == nil && arr != nil {
|
||
var out []any
|
||
for _, b := range arr {
|
||
switch b["type"] {
|
||
case "text", "input_text":
|
||
if t, _ := b["text"].(string); t != "" {
|
||
out = append(out, map[string]any{"type": "input_text", "text": t})
|
||
}
|
||
case "image_url":
|
||
var url string
|
||
if iu, ok := b["image_url"].(map[string]any); ok {
|
||
url, _ = iu["url"].(string)
|
||
} else if s, ok := b["image_url"].(string); ok {
|
||
url = s
|
||
}
|
||
if url != "" {
|
||
out = append(out, map[string]any{"type": "input_image", "image_url": url})
|
||
}
|
||
}
|
||
}
|
||
return out
|
||
}
|
||
return nil
|
||
}
|
||
|
||
// ---------------------------------------------------------------------------
|
||
// 请求:Chat → Responses
|
||
|
||
// chatToResponsesReq 将 Chat 请求转为 Responses 请求。
|
||
func chatToResponsesReq(body []byte) ([]byte, error) {
|
||
var req chatReq
|
||
if err := json.Unmarshal(body, &req); err != nil {
|
||
return nil, err
|
||
}
|
||
out := map[string]any{"model": req.Model}
|
||
if req.Stream {
|
||
out["stream"] = true
|
||
}
|
||
if req.Temperature != nil {
|
||
out["temperature"] = *req.Temperature
|
||
}
|
||
if req.TopP != nil {
|
||
out["top_p"] = *req.TopP
|
||
}
|
||
if req.MaxTokens != nil {
|
||
out["max_output_tokens"] = *req.MaxTokens
|
||
}
|
||
|
||
var system []string
|
||
var input []any
|
||
for _, m := range req.Messages {
|
||
if m.Role == "system" {
|
||
if s := str(m.Content); s != "" {
|
||
system = append(system, s)
|
||
}
|
||
continue
|
||
}
|
||
switch m.Role {
|
||
case "tool":
|
||
input = append(input, map[string]any{
|
||
"type": "function_call_output",
|
||
"call_id": m.ToolCallID,
|
||
"output": str(m.Content),
|
||
})
|
||
case "assistant":
|
||
if len(m.ToolCalls) > 0 {
|
||
for _, tc := range m.ToolCalls {
|
||
input = append(input, map[string]any{
|
||
"type": "function_call",
|
||
"call_id": tc.ID,
|
||
"name": tc.Function.Name,
|
||
"arguments": tc.Function.Arguments,
|
||
})
|
||
}
|
||
} else if s := str(m.Content); s != "" {
|
||
input = append(input, map[string]any{"type": "message", "role": "assistant", "content": []any{
|
||
map[string]any{"type": "input_text", "text": s},
|
||
}})
|
||
}
|
||
default:
|
||
if blocks := chatContentToResponsesBlocks(m.Content); len(blocks) > 0 {
|
||
input = append(input, map[string]any{"type": "message", "role": "user", "content": blocks})
|
||
}
|
||
}
|
||
}
|
||
if len(system) > 0 {
|
||
out["instructions"] = strings.Join(system, "\n")
|
||
}
|
||
// input 必须是数组:部分上游只接受数组,单对象会被拒(400 Mismatch type)。
|
||
out["input"] = input
|
||
|
||
if len(req.Tools) > 0 {
|
||
tools := make([]any, 0, len(req.Tools))
|
||
for _, t := range req.Tools {
|
||
tools = append(tools, map[string]any{
|
||
"type": "function",
|
||
"name": t.Function.Name,
|
||
"description": t.Function.Description,
|
||
"parameters": rawOrObject(t.Function.Parameters),
|
||
})
|
||
}
|
||
out["tools"] = tools
|
||
}
|
||
return json.Marshal(out)
|
||
}
|
||
|
||
// ---------------------------------------------------------------------------
|
||
// 响应:Responses → Chat
|
||
|
||
// responsesToChatResp 将 Responses 响应(非流式)转为 Chat 响应。
|
||
func responsesToChatResp(body []byte) ([]byte, error) {
|
||
var m map[string]json.RawMessage
|
||
if err := json.Unmarshal(body, &m); err != nil {
|
||
return nil, err
|
||
}
|
||
var text string
|
||
var toolCalls []any
|
||
if raw := rawJSON(m, "output"); raw != nil {
|
||
var outputs []map[string]any
|
||
if json.Unmarshal(raw, &outputs) == nil {
|
||
for _, o := range outputs {
|
||
switch o["type"] {
|
||
case "message":
|
||
if content, ok := o["content"].([]any); ok {
|
||
for _, c := range content {
|
||
if cm, ok := c.(map[string]any); ok {
|
||
if t, _ := cm["text"].(string); t != "" {
|
||
text += t
|
||
}
|
||
}
|
||
}
|
||
}
|
||
case "function_call":
|
||
toolCalls = append(toolCalls, map[string]any{
|
||
"id": strField(o["call_id"]),
|
||
"type": "function",
|
||
"function": map[string]any{
|
||
"name": strField(o["name"]),
|
||
"arguments": strField(o["arguments"]),
|
||
},
|
||
})
|
||
}
|
||
}
|
||
}
|
||
}
|
||
msg := map[string]any{"role": "assistant", "content": text}
|
||
if len(toolCalls) > 0 {
|
||
msg["tool_calls"] = toolCalls
|
||
}
|
||
finish := "stop"
|
||
switch {
|
||
case string(rawJSON(m, "status")) == `"incomplete"`:
|
||
finish = "length" // 截断优先,客户端可据此区分
|
||
case len(toolCalls) > 0:
|
||
finish = "tool_calls"
|
||
}
|
||
var prompt, completion int64
|
||
if u := rawJSON(m, "usage"); u != nil {
|
||
var us struct {
|
||
InputTokens int64 `json:"input_tokens"`
|
||
OutputTokens int64 `json:"output_tokens"`
|
||
}
|
||
_ = json.Unmarshal(u, &us)
|
||
prompt, completion = us.InputTokens, us.OutputTokens
|
||
}
|
||
return json.Marshal(map[string]any{
|
||
"id": "chatcmpl-" + strings.TrimPrefix(str(rawJSON(m, "id")), "resp_"),
|
||
"object": "chat.completion",
|
||
"model": str(rawJSON(m, "model")),
|
||
"choices": []any{map[string]any{"index": 0, "message": msg, "finish_reason": finish}},
|
||
"usage": map[string]any{
|
||
"prompt_tokens": prompt,
|
||
"completion_tokens": completion,
|
||
"total_tokens": prompt + completion,
|
||
},
|
||
})
|
||
}
|
||
|
||
// ---------------------------------------------------------------------------
|
||
// 响应:Chat → Responses
|
||
|
||
// chatToResponsesResp 将 Chat 响应(非流式)转为 Responses 响应。
|
||
func chatToResponsesResp(body []byte) ([]byte, error) {
|
||
var r chatResp
|
||
if err := json.Unmarshal(body, &r); err != nil {
|
||
return nil, err
|
||
}
|
||
output := make([]any, 0, 2)
|
||
var finish = "completed"
|
||
if len(r.Choices) > 0 {
|
||
msg := r.Choices[0].Message
|
||
if msg.Content != "" {
|
||
output = append(output, map[string]any{
|
||
"type": "message",
|
||
"role": "assistant",
|
||
"content": []any{map[string]any{"type": "output_text", "text": msg.Content}},
|
||
})
|
||
}
|
||
for _, tc := range msg.ToolCalls {
|
||
output = append(output, map[string]any{
|
||
"type": "function_call",
|
||
"call_id": tc.ID,
|
||
"name": tc.Function.Name,
|
||
"arguments": tc.Function.Arguments,
|
||
})
|
||
}
|
||
if r.Choices[0].FinishReason == "length" {
|
||
finish = "incomplete"
|
||
}
|
||
}
|
||
return json.Marshal(map[string]any{
|
||
"id": "resp_" + strings.TrimPrefix(r.ID, "chatcmpl-"),
|
||
"object": "response",
|
||
"model": r.Model,
|
||
"status": finish,
|
||
"output": output,
|
||
"usage": map[string]any{
|
||
"input_tokens": r.Usage.PromptTokens,
|
||
"output_tokens": r.Usage.CompletionTokens,
|
||
"total_tokens": r.Usage.PromptTokens + r.Usage.CompletionTokens,
|
||
},
|
||
})
|
||
}
|