feat: 三协议互转网关 + 鉴权修复 + 管理端增强

后端
- 新增 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 端口
This commit is contained in:
Sakurasan
2026-08-31 22:29:09 +08:00
parent e472ed93d5
commit f81b364436
34 changed files with 5171 additions and 179 deletions
+203
View File
@@ -0,0 +1,203 @@
// 三协议互转注册表:OpenAI Chat / OpenAI Responses / Anthropic Messages。
// 网关以 Chat 形状作为标准中间模型:非跨 chat 的转换经 chat 中转。
// 请求/响应(非流式)走 JSON 转换;流式走逐行 SSE 转换(stream_transform.go)。
package convert
import (
"bytes"
"encoding/json"
"fmt"
)
// 协议标识。
const (
ProtoChat = "chat"
ProtoMessages = "messages"
ProtoResponses = "responses"
)
// trimBody 去掉首尾空白。部分上游(如 OpenRouter)会在 JSON 前输出空白或
// SSE 注释行再跟正文,直接 Unmarshal 会失败。
func trimBody(body []byte) []byte {
return bytes.TrimSpace(body)
}
// CleanJSON 剥离非 JSON 前缀(空白、SSE 注释、`data:` 行)并压缩为标准 JSON。
// 部分上游(如 OpenRouter)的 non-stream 响应在 JSON 前夹带空白/注释;
// 原样透传会让客户端解析失败。找不到 JSON 对象时原样返回。
func CleanJSON(body []byte) []byte {
i := bytes.IndexByte(body, '{')
if i < 0 {
return body
}
var v any
if err := json.Unmarshal(bytes.TrimSpace(body[i:]), &v); err != nil {
return body
}
out, err := json.Marshal(v)
if err != nil {
return body
}
return out
}
// ConvertRequest 转换请求体。from==to 时原样返回。
func ConvertRequest(body []byte, from, to string) ([]byte, error) {
if from == to {
return body, nil
}
body = trimBody(body)
switch {
case from == ProtoMessages && to == ProtoChat:
return messagesToChatReq(body)
case from == ProtoChat && to == ProtoMessages:
return chatToMessagesReq(body)
case from == ProtoResponses && to == ProtoChat:
return responsesToChatReq(body)
case from == ProtoChat && to == ProtoResponses:
return chatToResponsesReq(body)
case from == ProtoResponses && to == ProtoMessages:
mid, err := responsesToChatReq(body)
if err != nil {
return nil, err
}
return chatToMessagesReq(mid)
case from == ProtoMessages && to == ProtoResponses:
mid, err := messagesToChatReq(body)
if err != nil {
return nil, err
}
return chatToResponsesReq(mid)
}
return nil, fmt.Errorf("unsupported request conversion %s->%s", from, to)
}
// ConvertResponse 转换响应体(非流式)。from==to 时原样返回。
func ConvertResponse(body []byte, from, to string) ([]byte, error) {
if from == to {
return body, nil
}
body = trimBody(body)
switch {
case from == ProtoMessages && to == ProtoChat:
return messagesToChatResp(body)
case from == ProtoChat && to == ProtoMessages:
return chatToMessagesResp(body)
case from == ProtoResponses && to == ProtoChat:
return responsesToChatResp(body)
case from == ProtoChat && to == ProtoResponses:
return chatToResponsesResp(body)
case from == ProtoResponses && to == ProtoMessages:
mid, err := responsesToChatResp(body)
if err != nil {
return nil, err
}
return chatToMessagesResp(mid)
case from == ProtoMessages && to == ProtoResponses:
mid, err := messagesToChatResp(body)
if err != nil {
return nil, err
}
return chatToResponsesResp(mid)
}
return nil, fmt.Errorf("unsupported response conversion %s->%s", from, to)
}
// NewStreamTransformer 构造流式逐行转换器:输入上游 SSE 一行,返回客户端 SSE 行。
// 返回 nil 表示丢弃该行或无需转换(from==to)。
func NewStreamTransformer(from, to string) func([]byte) []byte {
switch {
case from == ProtoMessages && to == ProtoChat:
return newMessagesToChat().line
case from == ProtoChat && to == ProtoMessages:
return newChatToMessages().line
case from == ProtoResponses && to == ProtoChat:
return newResponsesToChat().line
case from == ProtoChat && to == ProtoResponses:
return newChatToResponses().line
case from == ProtoResponses && to == ProtoMessages:
return newResponsesToMessages().line
case from == ProtoMessages && to == ProtoResponses:
return newMessagesToResponses().line
}
return nil
}
// ---------------------------------------------------------------------------
// 工具函数
// str 返回字符串字段;json.RawMessage 为字符串字面量时去引号。
func str(raw json.RawMessage) string {
if len(raw) == 0 || string(raw) == "null" {
return ""
}
var s string
if json.Unmarshal(raw, &s) == nil {
return s
}
// 数组/对象:尝试取 type=text 的 text
var arr []map[string]any
if json.Unmarshal(raw, &arr) == nil {
var parts []string
for _, b := range arr {
if t, _ := b["type"].(string); t == "text" || t == "input_text" || t == "output_text" {
if txt, _ := b["text"].(string); txt != "" {
parts = append(parts, txt)
}
}
}
return joinNonEmpty(parts, "\n")
}
return ""
}
func joinNonEmpty(parts []string, sep string) string {
out := ""
for _, p := range parts {
if p == "" {
continue
}
if out != "" {
out += sep
}
out += p
}
return out
}
// rawJSON 安全取字段;不存在或 null 返回 nil。
func rawJSON(m map[string]json.RawMessage, key string) json.RawMessage {
raw, ok := m[key]
if !ok || string(raw) == "null" {
return nil
}
return raw
}
// rawOrObject 把 RawMessage 解为 map;非对象返回空对象。
func rawOrObject(raw json.RawMessage) any {
if len(raw) == 0 || string(raw) == "null" {
return map[string]any{}
}
var m map[string]any
if json.Unmarshal(raw, &m) == nil {
return m
}
return map[string]any{}
}
// intOrNil 取指针值,nil 时返回默认值。
func intOrNil(p *int, def int) any {
if p == nil {
return def
}
return *p
}
// strField 取 any 中的字符串字段。
func strField(v any) string {
if s, ok := v.(string); ok {
return s
}
return ""
}
+504
View File
@@ -0,0 +1,504 @@
package convert
import (
"encoding/json"
"strings"
)
// ---------------------------------------------------------------------------
// 请求:Chat → Messages
type chatTool struct {
Type string `json:"type"`
Function struct {
Name string `json:"name"`
Description string `json:"description"`
Parameters json.RawMessage `json:"parameters"`
} `json:"function"`
}
type chatMsg struct {
Role string `json:"role"`
Content json.RawMessage `json:"content"`
ToolCallID string `json:"tool_call_id"`
ToolCalls []struct {
ID string `json:"id"`
Function struct {
Name string `json:"name"`
Arguments string `json:"arguments"`
} `json:"function"`
} `json:"tool_calls"`
}
type chatReq struct {
Model string `json:"model"`
Messages []chatMsg `json:"messages"`
Tools []chatTool `json:"tools"`
Temperature *float64 `json:"temperature"`
TopP *float64 `json:"top_p"`
MaxTokens *int `json:"max_tokens"`
Stop []string `json:"stop"`
Stream bool `json:"stream"`
}
// chatToMessagesReq 将 OpenAI Chat 请求转为 Anthropic Messages 请求。
func chatToMessagesReq(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,
"max_tokens": intOrNil(req.MaxTokens, 1024), // Anthropic 必填
}
if req.Stream {
out["stream"] = true
}
if req.Temperature != nil {
out["temperature"] = *req.Temperature
}
if req.TopP != nil {
out["top_p"] = *req.TopP
}
if len(req.Stop) > 0 {
out["stop_sequences"] = req.Stop
}
var system []string
msgs := make([]any, 0, len(req.Messages))
for _, m := range req.Messages {
if m.Role == "system" {
if s := str(m.Content); s != "" {
system = append(system, s)
}
continue
}
msgs = append(msgs, chatMsgToAnthropic(m))
}
if len(system) > 0 {
out["system"] = strings.Join(system, "\n")
}
out["messages"] = msgs
if len(req.Tools) > 0 {
tools := make([]any, 0, len(req.Tools))
for _, t := range req.Tools {
var params any
if len(t.Function.Parameters) > 0 && string(t.Function.Parameters) != "null" {
_ = json.Unmarshal(t.Function.Parameters, &params)
}
tools = append(tools, map[string]any{
"name": t.Function.Name,
"description": t.Function.Description,
"input_schema": params,
})
}
out["tools"] = tools
}
return json.Marshal(out)
}
// chatMsgToAnthropic 单条消息转 Anthropic 内容。
func chatMsgToAnthropic(m chatMsg) any {
switch m.Role {
case "assistant":
content := make([]any, 0, 2)
if s := str(m.Content); s != "" {
content = append(content, map[string]any{"type": "text", "text": s})
}
for _, tc := range m.ToolCalls {
var input any
if tc.Function.Arguments != "" {
_ = json.Unmarshal([]byte(tc.Function.Arguments), &input)
}
content = append(content, map[string]any{
"type": "tool_use",
"id": tc.ID,
"name": tc.Function.Name,
"input": input,
})
}
return map[string]any{"role": "assistant", "content": content}
case "tool":
return map[string]any{"role": "user", "content": []any{
map[string]any{"type": "tool_result", "tool_use_id": m.ToolCallID, "content": str(m.Content)},
}}
default: // user
var arr []map[string]any
if json.Unmarshal(m.Content, &arr) == nil && arr != nil {
blocks := make([]any, 0, len(arr))
for _, b := range arr {
switch b["type"] {
case "text", "input_text":
if t, _ := b["text"].(string); t != "" {
blocks = append(blocks, map[string]any{"type": "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 != "" {
blocks = append(blocks, anthropicImageBlock(url))
}
}
}
if len(blocks) > 0 {
return map[string]any{"role": "user", "content": blocks}
}
}
return map[string]any{"role": "user", "content": str(m.Content)}
}
}
// ---------------------------------------------------------------------------
// 请求:Messages → Chat
type messagesReq struct {
Model string `json:"model"`
System json.RawMessage `json:"system"`
Messages []struct {
Role string `json:"role"`
Content json.RawMessage `json:"content"`
} `json:"messages"`
Tools []struct {
Name string `json:"name"`
Description string `json:"description"`
InputSchema json.RawMessage `json:"input_schema"`
} `json:"tools"`
Temperature *float64 `json:"temperature"`
TopP *float64 `json:"top_p"`
MaxTokens *int `json:"max_tokens"`
StopSequence []string `json:"stop_sequences"`
Stream bool `json:"stream"`
}
// messagesToChatReq 将 Anthropic Messages 请求转为 OpenAI Chat 请求。
func messagesToChatReq(body []byte) ([]byte, error) {
var req messagesReq
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_tokens"] = *req.MaxTokens
}
if len(req.StopSequence) > 0 {
out["stop"] = req.StopSequence
}
msgs := make([]any, 0, len(req.Messages)+1)
if s := str(req.System); s != "" {
msgs = append(msgs, map[string]any{"role": "system", "content": s})
}
for _, m := range req.Messages {
msgs = append(msgs, anthropicMsgToChat(m.Role, m.Content)...)
}
out["messages"] = msgs
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",
"function": map[string]any{
"name": t.Name,
"description": t.Description,
"parameters": rawOrObject(t.InputSchema),
},
})
}
out["tools"] = tools
}
return json.Marshal(out)
}
// anthropicMsgToChat 将一条 Anthropic 消息拆成 0..N 条 Chat 消息。
func anthropicMsgToChat(role string, content json.RawMessage) []any {
// 块数组优先(tool_use / tool_result 需要分块解析)
var blocks []map[string]any
if json.Unmarshal(content, &blocks) == nil && blocks != nil {
var out []any
var toolMsgs []any // tool_result 单独收集,保证排在 assistant(tool_calls) 之后
var textParts []string
var contentBlocks []any // text / image_url 块,保留原始顺序
var toolCalls []any
for _, b := range blocks {
switch b["type"] {
case "text":
if t, _ := b["text"].(string); t != "" {
textParts = append(textParts, t)
contentBlocks = append(contentBlocks, map[string]any{"type": "text", "text": t})
}
case "image":
if cb := chatImageBlock(b); cb != nil {
contentBlocks = append(contentBlocks, cb)
}
case "tool_use":
id, _ := b["id"].(string)
name, _ := b["name"].(string)
args, _ := json.Marshal(b["input"])
toolCalls = append(toolCalls, map[string]any{
"id": id,
"type": "function",
"function": map[string]any{
"name": name,
"arguments": string(args),
},
})
case "tool_result":
callID, _ := b["tool_use_id"].(string)
res := strField(b["content"])
toolMsgs = append(toolMsgs, map[string]any{"role": "tool", "tool_call_id": callID, "content": res})
}
}
hasImage := false
for _, cb := range contentBlocks {
if m, _ := cb.(map[string]any); m["type"] == "image_url" {
hasImage = true
break
}
}
if hasImage || len(textParts) > 0 || len(toolCalls) > 0 {
msg := map[string]any{"role": role}
switch {
case hasImage:
msg["content"] = contentBlocks
case len(textParts) > 0:
msg["content"] = strings.Join(textParts, "")
}
if len(toolCalls) > 0 {
msg["tool_calls"] = toolCalls
}
out = append(out, msg)
}
out = append(out, toolMsgs...)
if len(out) > 0 {
return out
}
}
// 纯文本
if s := str(content); s != "" {
return []any{map[string]any{"role": role, "content": s}}
}
return nil
}
// ---------------------------------------------------------------------------
// 响应:Messages → Chat
type messagesResp struct {
ID string `json:"id"`
Model string `json:"model"`
Content []struct {
Type string `json:"type"`
Text string `json:"text"`
ID string `json:"id"`
Name string `json:"name"`
Input json.RawMessage `json:"input"`
} `json:"content"`
StopReason string `json:"stop_reason"`
Usage struct {
InputTokens int64 `json:"input_tokens"`
OutputTokens int64 `json:"output_tokens"`
} `json:"usage"`
}
// messagesToChatResp 将 Anthropic Messages 响应(非流式)转为 Chat 响应。
func messagesToChatResp(body []byte) ([]byte, error) {
var r messagesResp
if err := json.Unmarshal(body, &r); err != nil {
return nil, err
}
var text string
var toolCalls []any
for _, c := range r.Content {
switch c.Type {
case "text":
text += c.Text
case "tool_use":
args, _ := json.Marshal(c.Input)
toolCalls = append(toolCalls, map[string]any{
"id": c.ID,
"type": "function",
"function": map[string]any{
"name": c.Name,
"arguments": string(args),
},
})
}
}
msg := map[string]any{"role": "assistant", "content": text}
if len(toolCalls) > 0 {
msg["tool_calls"] = toolCalls
}
return json.Marshal(map[string]any{
"id": "chatcmpl-" + strings.TrimPrefix(r.ID, "msg_"),
"object": "chat.completion",
"model": r.Model,
"created": 0,
"choices": []any{map[string]any{
"index": 0,
"message": msg,
"finish_reason": messagesStopToChat(r.StopReason),
}},
"usage": map[string]any{
"prompt_tokens": r.Usage.InputTokens,
"completion_tokens": r.Usage.OutputTokens,
"total_tokens": r.Usage.InputTokens + r.Usage.OutputTokens,
},
})
}
// ---------------------------------------------------------------------------
// 响应:Chat → Messages
type chatResp struct {
ID string `json:"id"`
Model string `json:"model"`
Choices []struct {
Message struct {
Role string `json:"role"`
Content string `json:"content"`
ToolCalls []struct {
ID string `json:"id"`
Function struct {
Name string `json:"name"`
Arguments string `json:"arguments"`
} `json:"function"`
} `json:"tool_calls"`
} `json:"message"`
FinishReason string `json:"finish_reason"`
} `json:"choices"`
Usage struct {
PromptTokens int64 `json:"prompt_tokens"`
CompletionTokens int64 `json:"completion_tokens"`
} `json:"usage"`
}
// chatToMessagesResp 将 Chat 响应(非流式)转为 Messages 响应。
func chatToMessagesResp(body []byte) ([]byte, error) {
var r chatResp
if err := json.Unmarshal(body, &r); err != nil {
return nil, err
}
content := make([]any, 0, 2)
var finish = "end_turn"
if len(r.Choices) > 0 {
msg := r.Choices[0].Message
if msg.Content != "" {
content = append(content, map[string]any{"type": "text", "text": msg.Content})
}
for _, tc := range msg.ToolCalls {
var input any
_ = json.Unmarshal([]byte(tc.Function.Arguments), &input)
content = append(content, map[string]any{
"type": "tool_use",
"id": tc.ID,
"name": tc.Function.Name,
"input": input,
})
}
finish = chatStopToMessages(r.Choices[0].FinishReason)
}
return json.Marshal(map[string]any{
"id": "msg_" + strings.TrimPrefix(r.ID, "chatcmpl-"),
"type": "message",
"role": "assistant",
"model": r.Model,
"content": content,
"stop_reason": finish,
"usage": map[string]any{
"input_tokens": r.Usage.PromptTokens,
"output_tokens": r.Usage.CompletionTokens,
},
})
}
// ---------------------------------------------------------------------------
// 辅助
// splitDataURL 解析 data:media_type;base64,data 形式的 URL;非该形式返回 ok=false。
func splitDataURL(url string) (media, data string, ok bool) {
if !strings.HasPrefix(url, "data:") {
return "", "", false
}
i := strings.Index(url, ";base64,")
if i < 0 {
return "", "", false
}
return url[len("data:"):i], url[i+len(";base64,"):], true
}
// chatImageBlock 把 Anthropic image 块转 OpenAI image_url 块。
// 仅支持 base64 与 url source;其他类型(如 Files API 的 file_id)不支持,跳过。
func chatImageBlock(b map[string]any) any {
src, ok := b["source"].(map[string]any)
if !ok {
return nil
}
switch src["type"] {
case "base64":
media, _ := src["media_type"].(string)
data, _ := src["data"].(string)
if data == "" {
return nil
}
if media == "" {
media = "image/png"
}
return map[string]any{"type": "image_url", "image_url": map[string]any{"url": "data:" + media + ";base64," + data}}
case "url":
url, _ := src["url"].(string)
if url == "" {
return nil
}
return map[string]any{"type": "image_url", "image_url": map[string]any{"url": url}}
}
return nil
}
// anthropicImageBlock 把 OpenAI image_url 的 url 转 Anthropic image 块。
// data URL → base64 source;http(s) URL → url source。
func anthropicImageBlock(url string) any {
if media, data, ok := splitDataURL(url); ok {
if media == "" {
media = "image/png"
}
return map[string]any{"type": "image", "source": map[string]any{"type": "base64", "media_type": media, "data": data}}
}
return map[string]any{"type": "image", "source": map[string]any{"type": "url", "url": url}}
}
func messagesStopToChat(s string) string {
switch s {
case "tool_use":
return "tool_calls"
case "max_tokens":
return "length"
default:
return "stop"
}
}
func chatStopToMessages(s string) string {
switch s {
case "tool_calls":
return "tool_use"
case "length":
return "max_tokens"
default:
return "end_turn"
}
}
@@ -0,0 +1,376 @@
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,
},
})
}
@@ -0,0 +1,642 @@
package convert
import (
"encoding/json"
"strings"
)
// sseState 记录上一行 event 名。
type sseState struct {
event string
}
// parseLine 解析一行 SSE;返回是否 data 行及其内容、是否 [DONE]。
// data: 后可跟空格(标准)或紧贴 JSON(部分上游会省略空格)。
func (s *sseState) parseLine(line []byte) (isData bool, data string, done bool) {
strLine := strings.TrimRight(string(line), "\r\n")
switch {
case strings.HasPrefix(strLine, "event: "):
s.event = strings.TrimSpace(strings.TrimPrefix(strLine, "event: "))
return false, "", false
case strLine == "data: [DONE]" || strLine == "data:[DONE]":
return true, "[DONE]", true
case strings.HasPrefix(strLine, "data:"):
return true, strings.TrimLeft(strings.TrimPrefix(strLine, "data:"), " "), false
default:
return false, "", false
}
}
func eventData(line string) map[string]any {
var m map[string]any
_ = json.Unmarshal([]byte(line), &m)
return m
}
func dataLine(obj any) []byte {
b, _ := json.Marshal(obj)
return append(append([]byte("data: "), b...), '\n', '\n')
}
func eventLine(name string, obj any) []byte {
b, _ := json.Marshal(obj)
out := append([]byte("event: "+name+"\ndata: "), b...)
return append(out, '\n', '\n')
}
// joinLines 拼接多条 SSE 行。
func joinLines(lines [][]byte) []byte {
var s []string
for _, l := range lines {
s = append(s, string(l))
}
return []byte(strings.Join(s, ""))
}
// ---------------------------------------------------------------------------
// Messages → Chat
type messagesToChat struct {
sseState
id, model string
toolIdx map[int]int // messages content block index → chat tool_calls index(顺序编号,避开文本块)
nextTool int
}
func newMessagesToChat() *messagesToChat { return &messagesToChat{toolIdx: map[int]int{}} }
func (t *messagesToChat) line(line []byte) []byte {
isData, data, done := t.parseLine(line)
if !isData {
return nil
}
if done {
return []byte("data: [DONE]\n\n")
}
m := eventData(data)
evt, _ := m["type"].(string)
switch evt {
case "message_start":
msg, _ := m["message"].(map[string]any)
t.id, _ = msg["id"].(string)
t.model, _ = msg["model"].(string)
return dataLine(map[string]any{
"id": "chatcmpl-" + strings.TrimPrefix(t.id, "msg_"), "object": "chat.completion.chunk", "model": t.model,
"choices": []any{map[string]any{"index": 0, "delta": map[string]any{"role": "assistant"}, "finish_reason": nil}},
})
case "content_block_start":
cb, _ := m["content_block"].(map[string]any)
if cb == nil || cb["type"] != "tool_use" {
return nil
}
blockIdx, _ := m["index"].(float64)
tool := t.nextTool
t.nextTool++
t.toolIdx[int(blockIdx)] = tool
toolID, _ := cb["id"].(string)
name, _ := cb["name"].(string)
return dataLine(map[string]any{
"id": "chatcmpl-" + strings.TrimPrefix(t.id, "msg_"), "object": "chat.completion.chunk", "model": t.model,
"choices": []any{map[string]any{"index": 0, "delta": map[string]any{
"tool_calls": []any{map[string]any{"index": tool, "id": toolID, "type": "function", "function": map[string]any{"name": name, "arguments": ""}}},
}, "finish_reason": nil}},
})
case "content_block_delta":
delta, _ := m["delta"].(map[string]any)
deltaType, _ := delta["type"].(string)
if deltaType == "input_json_delta" {
blockIdx, _ := m["index"].(float64)
tool, ok := t.toolIdx[int(blockIdx)]
if !ok {
return nil
}
partial, _ := delta["partial_json"].(string)
if partial == "" {
return nil
}
return dataLine(map[string]any{
"id": "chatcmpl-" + strings.TrimPrefix(t.id, "msg_"), "object": "chat.completion.chunk", "model": t.model,
"choices": []any{map[string]any{"index": 0, "delta": map[string]any{
"tool_calls": []any{map[string]any{"index": tool, "function": map[string]any{"arguments": partial}}},
}, "finish_reason": nil}},
})
}
text, _ := delta["text"].(string)
if text == "" {
return nil
}
return dataLine(map[string]any{
"id": "chatcmpl-" + strings.TrimPrefix(t.id, "msg_"), "object": "chat.completion.chunk", "model": t.model,
"choices": []any{map[string]any{"index": 0, "delta": map[string]any{"content": text}, "finish_reason": nil}},
})
case "message_delta":
delta, _ := m["delta"].(map[string]any)
stop, _ := delta["stop_reason"].(string)
var out [][]byte
out = append(out, dataLine(map[string]any{
"id": "chatcmpl-" + strings.TrimPrefix(t.id, "msg_"), "object": "chat.completion.chunk", "model": t.model,
"choices": []any{map[string]any{"index": 0, "delta": map[string]any{}, "finish_reason": messagesStopToChat(stop)}},
}))
if u, ok := m["usage"]; ok {
out = append(out, dataLine(map[string]any{
"id": "chatcmpl-" + strings.TrimPrefix(t.id, "msg_"), "object": "chat.completion.chunk", "model": t.model,
"choices": []any{}, "usage": u,
}))
}
return joinLines(out)
case "message_stop":
return []byte("data: [DONE]\n\n")
}
return nil
}
// ---------------------------------------------------------------------------
// Chat → Messages
type chatToMessages struct {
sseState
started bool // message_start 已发出
nextIndex int // 下一个 content block index(顺序分配)
textIndex int // 文本块 index;-1 = 未开始
toolIdx map[int]int // chat delta.tool_calls[].index → messages block index
openBlocks []int // 已开始未停止的 block index,按开始顺序
model string
stopReason string
usage any
}
func newChatToMessages() *chatToMessages {
return &chatToMessages{textIndex: -1, toolIdx: map[int]int{}}
}
func (t *chatToMessages) line(line []byte) []byte {
isData, data, done := t.parseLine(line)
if !isData {
return nil
}
if done {
// 汇聚最终:先对每个已开始未停止的块发 content_block_stop,再 message_delta + message_stop
var out [][]byte
for _, idx := range t.openBlocks {
out = append(out, eventLine("content_block_stop", map[string]any{"type": "content_block_stop", "index": idx}))
}
md := map[string]any{"type": "message_delta", "delta": map[string]any{
"stop_reason": stopReasonOrEnd(t.stopReason), "stop_sequence": nil,
}}
if t.usage != nil {
md["usage"] = t.usage
}
out = append(out, eventLine("message_delta", md))
out = append(out, eventLine("message_stop", map[string]any{"type": "message_stop"}))
return joinLines(out)
}
m := eventData(data)
// chat 块:delta / finish_reason 在 choices[0] 内
delta := map[string]any{}
if choices, ok := m["choices"].([]any); ok && len(choices) > 0 {
if c0, ok := choices[0].(map[string]any); ok {
if d, ok := c0["delta"].(map[string]any); ok {
delta = d
}
if fr, _ := c0["finish_reason"].(string); fr != "" {
t.stopReason = fr
}
}
}
if t.model == "" {
t.model, _ = m["model"].(string)
}
id, _ := m["id"].(string)
var out [][]byte
// message_start 只在实际有内容(文本或工具)时发出,避免 reasoning_content 块
//(带 role 无 content)提前开出一个空文本块。
ensureStarted := func() {
if t.started {
return
}
t.started = true
out = append(out, eventLine("message_start", map[string]any{
"type": "message_start",
"message": map[string]any{
"id": "msg_" + strings.TrimPrefix(id, "chatcmpl-"), "type": "message", "role": "assistant",
"model": t.model, "content": []any{}, "usage": map[string]any{"input_tokens": 0, "output_tokens": 0},
},
}))
}
// 文本:delta.content(string;兼容 {type:text,text} 数组)
if content := deltaText(delta); content != "" {
if t.textIndex < 0 {
t.textIndex = t.nextIndex
t.nextIndex++
ensureStarted()
out = append(out, eventLine("content_block_start", map[string]any{
"type": "content_block_start", "index": t.textIndex, "content_block": map[string]any{"type": "text", "text": ""},
}))
t.openBlocks = append(t.openBlocks, t.textIndex)
}
out = append(out, eventLine("content_block_delta", map[string]any{
"type": "content_block_delta", "index": t.textIndex, "delta": map[string]any{"type": "text_delta", "text": content},
}))
}
// 工具调用:delta.tool_calls(并行调用各 index 独立成块;arguments 支持整段/分段两种流式)
if tcs, ok := delta["tool_calls"].([]any); ok {
for _, tc := range tcs {
call, ok := tc.(map[string]any)
if !ok {
continue
}
idx, _ := call["index"].(float64)
tcIdx := int(idx)
fn, _ := call["function"].(map[string]any)
name, _ := fn["name"].(string)
args, _ := fn["arguments"].(string)
blockIdx, seen := t.toolIdx[tcIdx]
if !seen {
blockIdx = t.nextIndex
t.nextIndex++
t.toolIdx[tcIdx] = blockIdx
toolID, _ := call["id"].(string)
ensureStarted()
out = append(out, eventLine("content_block_start", map[string]any{
"type": "content_block_start", "index": blockIdx, "content_block": map[string]any{
"type": "tool_use", "id": toolID, "name": name, "input": map[string]any{},
},
}))
t.openBlocks = append(t.openBlocks, blockIdx)
}
if args != "" {
out = append(out, eventLine("content_block_delta", map[string]any{
"type": "content_block_delta", "index": blockIdx, "delta": map[string]any{"type": "input_json_delta", "partial_json": args},
}))
}
}
}
if u, ok := m["usage"]; ok {
t.usage = u
}
return joinLines(out)
}
// deltaText 取 chat delta.content 文本(string 或 [{type:text,text}] 数组拼接)。
func deltaText(delta map[string]any) string {
if s, ok := delta["content"].(string); ok {
return s
}
if arr, ok := delta["content"].([]any); ok {
var parts []string
for _, b := range arr {
if bm, ok := b.(map[string]any); ok {
if t, _ := bm["text"].(string); t != "" {
parts = append(parts, t)
}
}
}
return strings.Join(parts, "")
}
return ""
}
func stopReasonOrEnd(s string) string {
if s == "" {
return "end_turn"
}
return chatStopToMessages(s)
}
// ---------------------------------------------------------------------------
// Responses → Messages
type responsesToMessages struct {
sseState
started bool
model string
usage any
nextIndex int // 下一个 content block index(顺序分配)
textIndex int // 文本块 index;-1 = 未开始
toolIdx map[string]int // function_call item_id → messages block index
openBlocks []int // 已开始未停止的 block index,按开始顺序
anyTool bool
}
func newResponsesToMessages() *responsesToMessages {
return &responsesToMessages{textIndex: -1, toolIdx: map[string]int{}}
}
func (t *responsesToMessages) line(line []byte) []byte {
isData, data, done := t.parseLine(line)
if !isData || done {
return nil
}
m := eventData(data)
evt, _ := m["type"].(string)
if resp, ok := m["response"].(map[string]any); ok {
if t.model == "" {
t.model, _ = resp["model"].(string)
}
if u, ok := resp["usage"]; ok {
t.usage = u
}
}
var out [][]byte
// message_start 只在 response.created 时发出;文本/工具块在对应事件到达时再开,
// 避免纯函数调用响应提前开出一个空文本块。
ensureStarted := func() {
if t.started {
return
}
t.started = true
rid := ""
if resp, ok := m["response"].(map[string]any); ok {
rid, _ = resp["id"].(string)
}
out = append(out, eventLine("message_start", map[string]any{
"type": "message_start",
"message": map[string]any{
"id": "msg_" + strings.TrimPrefix(rid, "resp_"), "type": "message", "role": "assistant",
"model": t.model, "content": []any{},
},
}))
}
switch evt {
case "response.created":
ensureStarted()
case "response.output_text.delta":
delta, _ := m["delta"].(string)
if delta == "" {
return nil
}
if t.textIndex < 0 {
t.textIndex = t.nextIndex
t.nextIndex++
ensureStarted()
out = append(out, eventLine("content_block_start", map[string]any{
"type": "content_block_start", "index": t.textIndex, "content_block": map[string]any{"type": "text", "text": ""},
}))
t.openBlocks = append(t.openBlocks, t.textIndex)
}
out = append(out, eventLine("content_block_delta", map[string]any{
"type": "content_block_delta", "index": t.textIndex, "delta": map[string]any{"type": "text_delta", "text": delta},
}))
case "response.output_item.added":
item, _ := m["item"].(map[string]any)
if item == nil || item["type"] != "function_call" {
return nil
}
blockIdx := t.nextIndex
t.nextIndex++
t.anyTool = true
itemID, _ := item["id"].(string)
t.toolIdx[itemID] = blockIdx
toolUseID, _ := item["call_id"].(string)
if toolUseID == "" {
toolUseID = itemID
}
name, _ := item["name"].(string)
ensureStarted()
out = append(out, eventLine("content_block_start", map[string]any{
"type": "content_block_start", "index": blockIdx, "content_block": map[string]any{
"type": "tool_use", "id": toolUseID, "name": name, "input": map[string]any{},
},
}))
t.openBlocks = append(t.openBlocks, blockIdx)
case "response.function_call_arguments.delta":
itemID, _ := m["item_id"].(string)
blockIdx, ok := t.toolIdx[itemID]
if !ok {
return nil
}
delta, _ := m["delta"].(string)
if delta == "" {
return nil
}
out = append(out, eventLine("content_block_delta", map[string]any{
"type": "content_block_delta", "index": blockIdx, "delta": map[string]any{"type": "input_json_delta", "partial_json": delta},
}))
case "response.completed":
for _, idx := range t.openBlocks {
out = append(out, eventLine("content_block_stop", map[string]any{"type": "content_block_stop", "index": idx}))
}
stop := "end_turn"
if t.anyTool {
stop = "tool_use"
}
md := map[string]any{"type": "message_delta", "delta": map[string]any{"stop_reason": stop, "stop_sequence": nil}}
if t.usage != nil {
md["usage"] = t.usage
}
out = append(out, eventLine("message_delta", md))
out = append(out, eventLine("message_stop", map[string]any{"type": "message_stop"}))
}
return joinLines(out)
}
// ---------------------------------------------------------------------------
// Messages → Responses
type messagesToResponses struct {
sseState
model string
usage any
done bool
}
func newMessagesToResponses() *messagesToResponses { return &messagesToResponses{} }
func (t *messagesToResponses) line(line []byte) []byte {
isData, data, done := t.parseLine(line)
if !isData || done {
return nil
}
m := eventData(data)
evt, _ := m["type"].(string)
if msg, ok := m["message"].(map[string]any); ok {
if t.model == "" {
t.model, _ = msg["model"].(string)
}
if u, ok := msg["usage"]; ok {
t.usage = u
}
}
if u, ok := m["usage"]; ok {
t.usage = u
}
var out [][]byte
switch evt {
case "message_start":
id, _ := m["message"].(map[string]any)
rid := ""
if id != nil {
rid, _ = id["id"].(string)
}
out = append(out, eventLine("response.created", map[string]any{
"type": "response.created",
"response": map[string]any{
"id": "resp_" + strings.TrimPrefix(rid, "msg_"), "object": "response", "model": t.model, "status": "in_progress",
},
}))
case "content_block_delta":
delta, _ := m["delta"].(map[string]any)
text, _ := delta["text"].(string)
if text != "" {
out = append(out, eventLine("response.output_text.delta", map[string]any{
"type": "response.output_text.delta", "delta": text, "item_id": "msg_1", "output_index": 0, "content_index": 0,
}))
}
case "message_stop":
if !t.done {
t.done = true
out = append(out, eventLine("response.completed", map[string]any{
"type": "response.completed",
"response": map[string]any{
"id": "resp_stream", "object": "response", "model": t.model, "status": "completed", "usage": t.usage,
},
}))
}
}
return joinLines(out)
}
// ---------------------------------------------------------------------------
// Responses → Chat
type responsesToChat struct {
sseState
id, model string
}
func newResponsesToChat() *responsesToChat { return &responsesToChat{} }
func (t *responsesToChat) line(line []byte) []byte {
isData, data, done := t.parseLine(line)
if !isData {
return nil
}
if done {
return nil
}
m := eventData(data)
evt, _ := m["type"].(string)
if resp, ok := m["response"].(map[string]any); ok {
if t.model == "" {
t.model, _ = resp["model"].(string)
}
if t.id == "" {
t.id, _ = resp["id"].(string)
}
}
var out [][]byte
switch evt {
case "response.created":
out = append(out, dataLine(map[string]any{
"id": "chatcmpl-" + strings.TrimPrefix(t.id, "resp_"), "object": "chat.completion.chunk", "model": t.model,
"choices": []any{map[string]any{"index": 0, "delta": map[string]any{"role": "assistant"}, "finish_reason": nil}},
}))
case "response.output_text.delta":
delta, _ := m["delta"].(string)
if delta != "" {
out = append(out, dataLine(map[string]any{
"id": "chatcmpl-" + strings.TrimPrefix(t.id, "resp_"), "object": "chat.completion.chunk", "model": t.model,
"choices": []any{map[string]any{"index": 0, "delta": map[string]any{"content": delta}, "finish_reason": nil}},
}))
}
case "response.completed":
out = append(out, dataLine(map[string]any{
"id": "chatcmpl-" + strings.TrimPrefix(t.id, "resp_"), "object": "chat.completion.chunk", "model": t.model,
"choices": []any{map[string]any{"index": 0, "delta": map[string]any{}, "finish_reason": "stop"}},
}))
if u, ok := m["response"].(map[string]any); ok {
if usage, ok := u["usage"]; ok {
out = append(out, dataLine(map[string]any{
"id": "chatcmpl-" + strings.TrimPrefix(t.id, "resp_"), "object": "chat.completion.chunk", "model": t.model,
"choices": []any{}, "usage": usage,
}))
}
}
out = append(out, []byte("data: [DONE]\n\n"))
}
return joinLines(out)
}
// ---------------------------------------------------------------------------
// Chat → Responses
type chatToResponses struct {
sseState
model string
usage any
finishSeen bool
done bool
createdSent bool
}
func newChatToResponses() *chatToResponses { return &chatToResponses{} }
func (t *chatToResponses) line(line []byte) []byte {
isData, data, done := t.parseLine(line)
if !isData {
return nil
}
if done {
// 流结束兜底:finish 后 usage 未随块到达时在此补发 completed
if !t.done {
t.done = true
return eventLine("response.completed", map[string]any{
"type": "response.completed",
"response": map[string]any{
"id": "resp_stream", "object": "response", "model": t.model, "status": "completed", "usage": t.usage,
},
})
}
return nil
}
m := eventData(data)
if t.model == "" {
t.model, _ = m["model"].(string)
}
if u, ok := m["usage"]; ok {
t.usage = u
}
delta := map[string]any{}
var finish string
if choices, ok := m["choices"].([]any); ok && len(choices) > 0 {
if c0, ok := choices[0].(map[string]any); ok {
if d, ok := c0["delta"].(map[string]any); ok {
delta = d
}
finish, _ = c0["finish_reason"].(string)
}
}
if finish != "" {
t.finishSeen = true
}
var out [][]byte
// 只发一次 response.created:部分上游(如 OpenRouter 的 reasoning 模型)会在
// 每个 chunk 的 delta 里都带 role:"assistant",不加守卫会刷出数十条 created。
if !t.createdSent && delta["role"] == "assistant" {
t.createdSent = true
out = append(out, eventLine("response.created", map[string]any{
"type": "response.created",
"response": map[string]any{"id": "resp_stream", "object": "response", "model": t.model, "status": "in_progress"},
}))
}
if content, _ := delta["content"].(string); content != "" {
out = append(out, eventLine("response.output_text.delta", map[string]any{
"type": "response.output_text.delta", "delta": content, "item_id": "msg_1", "output_index": 0, "content_index": 0,
}))
}
// 上游 usage 块(choices 为空)通常晚于 finish_reason:此时再发 completed,携带 usage
if _, hasUsage := m["usage"]; hasUsage && t.finishSeen && !t.done {
t.done = true
out = append(out, eventLine("response.completed", map[string]any{
"type": "response.completed",
"response": map[string]any{
"id": "resp_stream", "object": "response", "model": t.model, "status": "completed", "usage": t.usage,
},
}))
}
return joinLines(out)
}
+131
View File
@@ -0,0 +1,131 @@
package convert
import (
"encoding/json"
)
// TokenUsage 从上游响应提取的 token 用量。
// 三种协议的字段名不同,此处统一为:input / output / cache_read / cache_creation,
// 供用量记录与计费使用。
type TokenUsage struct {
InputTokens int
OutputTokens int
CacheReadTokens int
CacheCreationTokens int
}
// has 判断是否真的拿到了非零用量(过滤掉没有 usage 字段的响应)。
func (u *TokenUsage) has() bool {
return u.InputTokens > 0 || u.OutputTokens > 0 ||
u.CacheReadTokens > 0 || u.CacheCreationTokens > 0
}
// mergeJSON 把一张 usage 对象并入累计值。proto 决定字段名(chat/responses 与 messages 不同)。
func (u *TokenUsage) mergeJSON(raw map[string]any, proto string) {
switch proto {
case ProtoChat, ProtoResponses:
in, _ := raw["prompt_tokens"].(float64)
out, _ := raw["completion_tokens"].(float64)
if in == 0 && out == 0 {
in, _ = raw["input_tokens"].(float64)
out, _ = raw["output_tokens"].(float64)
}
u.InputTokens += int(in)
u.OutputTokens += int(out)
if d, ok := raw["prompt_tokens_details"].(map[string]any); ok {
if c, _ := d["cached_tokens"].(float64); c > 0 {
u.CacheReadTokens += int(c)
}
}
if d, ok := raw["input_tokens_details"].(map[string]any); ok {
if c, _ := d["cached_tokens"].(float64); c > 0 {
u.CacheReadTokens += int(c)
}
}
case ProtoMessages:
in, _ := raw["input_tokens"].(float64)
out, _ := raw["output_tokens"].(float64)
u.InputTokens += int(in)
u.OutputTokens += int(out)
if c, _ := raw["cache_read_input_tokens"].(float64); c > 0 {
u.CacheReadTokens += int(c)
}
if c, _ := raw["cache_creation_input_tokens"].(float64); c > 0 {
u.CacheCreationTokens += int(c)
}
}
}
// ExtractUsageJSON 从完整非流式响应体中提取用量。proto 为上游协议。
// 返回 (用量, 是否有效)。
func ExtractUsageJSON(body []byte, proto string) (TokenUsage, bool) {
var top map[string]any
if err := json.Unmarshal(body, &top); err != nil {
return TokenUsage{}, false
}
var u TokenUsage
if usage, ok := top["usage"].(map[string]any); ok {
u.mergeJSON(usage, proto)
}
return u, u.has()
}
// StreamUsageAccum 流式用量累计器。逐行喂入上游 SSE 的 data 载荷,
// 按协议分别取各事件里的 usage 字段(各事件只会携带一部分字段,取最大值合并)。
type StreamUsageAccum struct {
u TokenUsage
}
// NewStreamUsageAccum 创建一个流式用量累计器。
func NewStreamUsageAccum() *StreamUsageAccum {
return &StreamUsageAccum{}
}
// Feed 喂入一行 SSE data 载荷(不含 "data:" 前缀与换行)。
func (a *StreamUsageAccum) Feed(payload []byte, proto string) {
var top map[string]any
if json.Unmarshal(payload, &top) != nil {
return
}
var t TokenUsage
switch proto {
case ProtoChat:
if usage, ok := top["usage"].(map[string]any); ok {
t.mergeJSON(usage, proto)
}
case ProtoResponses:
// response.completed 事件把用量放在 response.usage 下。
if resp, ok := top["response"].(map[string]any); ok {
if usage, ok := resp["usage"].(map[string]any); ok {
t.mergeJSON(usage, proto)
}
}
case ProtoMessages:
// message_start: {message: {usage: {input_tokens, cache_*}}}
// message_delta: {usage: {output_tokens}}
if msg, ok := top["message"].(map[string]any); ok {
if usage, ok := msg["usage"].(map[string]any); ok {
t.mergeJSON(usage, proto)
}
}
if usage, ok := top["usage"].(map[string]any); ok {
var t2 TokenUsage
t2.mergeJSON(usage, proto)
t.InputTokens = max(t.InputTokens, t2.InputTokens)
t.OutputTokens = max(t.OutputTokens, t2.OutputTokens)
t.CacheReadTokens = max(t.CacheReadTokens, t2.CacheReadTokens)
t.CacheCreationTokens = max(t.CacheCreationTokens, t2.CacheCreationTokens)
}
default:
return
}
a.u.InputTokens = max(a.u.InputTokens, t.InputTokens)
a.u.OutputTokens = max(a.u.OutputTokens, t.OutputTokens)
a.u.CacheReadTokens = max(a.u.CacheReadTokens, t.CacheReadTokens)
a.u.CacheCreationTokens = max(a.u.CacheCreationTokens, t.CacheCreationTokens)
}
// Usage 返回当前累计用量。
func (a *StreamUsageAccum) Usage() TokenUsage {
return a.u
}
@@ -0,0 +1,170 @@
package convert
import (
"encoding/json"
"testing"
)
// ---- ExtractUsageJSON: 非流式各协议 ----
func TestExtractUsageJSONChat(t *testing.T) {
body := []byte(`{
"id": "chatcmpl-1",
"object": "chat.completion",
"choices": [{"index": 0, "message": {"role": "assistant", "content": "hi"}}],
"usage": {
"prompt_tokens": 11,
"completion_tokens": 7,
"total_tokens": 18,
"prompt_tokens_details": {"cached_tokens": 4}
}
}`)
u, ok := ExtractUsageJSON(body, ProtoChat)
if !ok {
t.Fatalf("expected ok=true")
}
if u.InputTokens != 11 || u.OutputTokens != 7 {
t.Fatalf("chat usage = %+v, want input=11 output=7", u)
}
if u.CacheReadTokens != 4 {
t.Fatalf("chat cacheRead = %d, want 4", u.CacheReadTokens)
}
}
func TestExtractUsageJSONMessages(t *testing.T) {
body := []byte(`{
"id": "msg_1",
"type": "message",
"role": "assistant",
"content": [{"type": "text", "text": "hi"}],
"usage": {
"input_tokens": 15,
"output_tokens": 8,
"cache_read_input_tokens": 3,
"cache_creation_input_tokens": 2
}
}`)
u, ok := ExtractUsageJSON(body, ProtoMessages)
if !ok {
t.Fatalf("expected ok=true")
}
if u.InputTokens != 15 || u.OutputTokens != 8 || u.CacheReadTokens != 3 || u.CacheCreationTokens != 2 {
t.Fatalf("messages usage = %+v", u)
}
}
func TestExtractUsageJSONResponses(t *testing.T) {
body := []byte(`{
"id": "resp_1",
"object": "response",
"output": [],
"usage": {
"input_tokens": 13,
"output_tokens": 9,
"input_tokens_details": {"cached_tokens": 5}
}
}`)
u, ok := ExtractUsageJSON(body, ProtoResponses)
if !ok {
t.Fatalf("expected ok=true")
}
if u.InputTokens != 13 || u.OutputTokens != 9 || u.CacheReadTokens != 5 {
t.Fatalf("responses usage = %+v", u)
}
}
func TestExtractUsageJSONInvalidAndMissing(t *testing.T) {
if _, ok := ExtractUsageJSON([]byte("not json"), ProtoChat); ok {
t.Fatalf("invalid json should not report ok")
}
if _, ok := ExtractUsageJSON([]byte(`{"id": "x"}`), ProtoChat); ok {
t.Fatalf("missing usage should not report ok")
}
// 空对象 usage:全 0 视为无效
if _, ok := ExtractUsageJSON([]byte(`{"usage": {}}`), ProtoChat); ok {
t.Fatalf("empty usage should not report ok")
}
}
// ---- StreamUsageAccum: 流式各协议 ----
func feedLines(t *testing.T, proto string, lines ...string) TokenUsage {
t.Helper()
acc := NewStreamUsageAccum()
for _, ln := range lines {
acc.Feed([]byte(ln), proto)
}
return acc.Usage()
}
func TestStreamUsageChatFinalChunk(t *testing.T) {
// 前面的 chunk 不带 usage;最后一个 chunk 带完整 usage
u := feedLines(t, ProtoChat,
`{"id":"c1","object":"chat.completion.chunk","choices":[{"delta":{"content":"he"}}]}`,
`{"id":"c1","object":"chat.completion.chunk","choices":[{"delta":{"content":"llo"}}]}`,
`{"id":"c1","object":"chat.completion.chunk","choices":[],"usage":{"prompt_tokens":11,"completion_tokens":7,"prompt_tokens_details":{"cached_tokens":4}}}`,
)
if u.InputTokens != 11 || u.OutputTokens != 7 || u.CacheReadTokens != 4 {
t.Fatalf("chat stream usage = %+v", u)
}
}
func TestStreamUsageMessagesStartAndDelta(t *testing.T) {
// message_start 带 input/cache,message_delta 带 output;逐字段取 max 合并
u := feedLines(t, ProtoMessages,
`{"type":"message_start","message":{"id":"msg_1","usage":{"input_tokens":15,"cache_read_input_tokens":3,"cache_creation_input_tokens":2}}}`,
`{"type":"content_block_delta","delta":{"type":"text_delta","text":"hi"}}`,
`{"type":"message_delta","delta":{"stop_reason":"end_turn"},"usage":{"output_tokens":8}}`,
)
if u.InputTokens != 15 || u.OutputTokens != 8 || u.CacheReadTokens != 3 || u.CacheCreationTokens != 2 {
t.Fatalf("messages stream usage = %+v", u)
}
}
func TestStreamUsageResponsesCompleted(t *testing.T) {
// response.completed 事件的用量嵌在 response.usage 下
u := feedLines(t, ProtoResponses,
`{"type":"response.output_text.delta","delta":"hi"}`,
`{"type":"response.completed","response":{"id":"resp_1","usage":{"input_tokens":13,"output_tokens":9,"input_tokens_details":{"cached_tokens":5}}}}`,
)
if u.InputTokens != 13 || u.OutputTokens != 9 || u.CacheReadTokens != 5 {
t.Fatalf("responses stream usage = %+v", u)
}
}
func TestStreamUsageIgnoresNonDataPayloads(t *testing.T) {
// [DONE]、垃圾行、空对象都不应产生用量
u := feedLines(t, ProtoChat, `[DONE]`, `{`, ``, `{"choices":[]}`)
if u.has() {
t.Fatalf("expected zero usage, got %+v", u)
}
}
func TestStreamUsageFeedKeepsMaxAcrossEvents(t *testing.T) {
// 同一字段在多个事件出现时取较大值(防乱序/重复)
u := feedLines(t, ProtoMessages,
`{"type":"message_start","message":{"usage":{"input_tokens":15}}}`,
`{"type":"message_delta","usage":{"output_tokens":5}}`,
`{"type":"message_delta","usage":{"output_tokens":8}}`,
)
if u.InputTokens != 15 || u.OutputTokens != 8 {
t.Fatalf("max-merge usage = %+v", u)
}
}
// ---- usage JSON 结构合法性(防止手写 struct 漂移)----
func TestUsageJSONRoundTrip(t *testing.T) {
u := TokenUsage{InputTokens: 10, OutputTokens: 5, CacheReadTokens: 2, CacheCreationTokens: 1}
b, err := json.Marshal(u)
if err != nil {
t.Fatalf("marshal: %v", err)
}
var back TokenUsage
if err := json.Unmarshal(b, &back); err != nil {
t.Fatalf("unmarshal: %v", err)
}
if back != u {
t.Fatalf("round trip = %+v, want %+v", back, u)
}
}