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:
@@ -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 ""
|
||||
}
|
||||
@@ -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, ¶ms)
|
||||
}
|
||||
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)
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
+103
-108
@@ -1,6 +1,7 @@
|
||||
package proxy
|
||||
|
||||
import (
|
||||
"bufio"
|
||||
"bytes"
|
||||
"context"
|
||||
"encoding/json"
|
||||
@@ -13,6 +14,7 @@ import (
|
||||
"opencatd-open/internal/dao"
|
||||
"opencatd-open/internal/proxy/convert"
|
||||
"opencatd-open/internal/store"
|
||||
"opencatd-open/internal/usage"
|
||||
"opencatd-open/pkg/config"
|
||||
"os"
|
||||
"strings"
|
||||
@@ -35,6 +37,7 @@ type Gateway struct {
|
||||
usageDAO *dao.UsageDAO
|
||||
dailyDAO *dao.DailyUsageDAO
|
||||
channelSvc *channel.Service
|
||||
usageRec *usage.Recorder
|
||||
}
|
||||
|
||||
func NewGateway(ctx context.Context, cfg *config.Config, db *gorm.DB, wg *sync.WaitGroup, userDAO *dao.UserDAO, apiKeyDAO *dao.ApiKeyDAO, usageDAO *dao.UsageDAO, dailyDAO *dao.DailyUsageDAO) *Gateway {
|
||||
@@ -67,6 +70,11 @@ func (g *Gateway) SetChannelService(svc *channel.Service) {
|
||||
g.channelSvc = svc
|
||||
}
|
||||
|
||||
// SetUsageRecorder 注入异步用量记录器;nil 时网关跳过用量上报。
|
||||
func (g *Gateway) SetUsageRecorder(r *usage.Recorder) {
|
||||
g.usageRec = r
|
||||
}
|
||||
|
||||
// Request represents a parsed incoming request
|
||||
type Request struct {
|
||||
Model string
|
||||
@@ -144,26 +152,24 @@ func (g *Gateway) Dispatch(c *gin.Context, req *Request) {
|
||||
return
|
||||
}
|
||||
|
||||
// Determine target format and convert if needed
|
||||
targetFormat := req.Protocol
|
||||
if len(ch.FormatsEffective()) > 0 {
|
||||
// Prefer the channel's native format
|
||||
for _, f := range ch.FormatsEffective() {
|
||||
if f == req.Protocol {
|
||||
targetFormat = f
|
||||
break
|
||||
}
|
||||
}
|
||||
// Determine target format: channel declares support for the client protocol
|
||||
// then passthrough, otherwise convert to its first supported protocol
|
||||
// (chat > messages > responses).
|
||||
targetFormat := g.conversionTarget(ch, req.Protocol)
|
||||
if targetFormat == "" {
|
||||
g.writeError(c, http.StatusBadGateway, fmt.Sprintf("channel %q declares no supported protocol format", ch.Name))
|
||||
return
|
||||
}
|
||||
|
||||
// Build upstream URL
|
||||
upstreamPath := g.getUpstreamPath(req.Protocol)
|
||||
upstreamURL := ch.UpstreamURL(req.Protocol, upstreamPath)
|
||||
upstreamPath := g.getUpstreamPath(targetFormat)
|
||||
upstreamURL := ch.UpstreamURL(targetFormat, upstreamPath)
|
||||
|
||||
// Convert request if needed
|
||||
var requestBody []byte
|
||||
if targetFormat != req.Protocol {
|
||||
requestBody, err = g.convertRequest(req.Body, req.Protocol, targetFormat)
|
||||
var err error
|
||||
requestBody, err = convert.ConvertRequest(req.Body, req.Protocol, targetFormat)
|
||||
if err != nil {
|
||||
g.writeError(c, http.StatusBadRequest, "conversion failed: "+err.Error())
|
||||
return
|
||||
@@ -206,12 +212,31 @@ func (g *Gateway) Dispatch(c *gin.Context, req *Request) {
|
||||
|
||||
// Stream or buffer response
|
||||
if req.Stream {
|
||||
g.streamResponse(c, resp, req.Protocol, ch)
|
||||
g.streamResponse(c, resp, req.Protocol, targetFormat)
|
||||
} else {
|
||||
g.bufferResponse(c, resp, req.Protocol, ch)
|
||||
g.bufferResponse(c, resp, req.Protocol, targetFormat)
|
||||
}
|
||||
}
|
||||
|
||||
// conversionTarget 决定客户端协议在渠道上的处理方式:
|
||||
// 渠道声明支持该协议则直通;否则转为其首选支持协议(chat > messages > responses)。
|
||||
func (g *Gateway) conversionTarget(ch *store.Channel, clientProto string) string {
|
||||
formats := ch.FormatsEffective()
|
||||
for _, f := range formats {
|
||||
if f == clientProto {
|
||||
return clientProto
|
||||
}
|
||||
}
|
||||
for _, p := range []string{convert.ProtoChat, convert.ProtoMessages, convert.ProtoResponses} {
|
||||
for _, f := range formats {
|
||||
if f == p {
|
||||
return p
|
||||
}
|
||||
}
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
func (g *Gateway) getUpstreamPath(protocol string) string {
|
||||
switch protocol {
|
||||
case "chat":
|
||||
@@ -237,116 +262,86 @@ func (g *Gateway) setHeaders(req *http.Request, ch *store.Channel, apiKey string
|
||||
}
|
||||
}
|
||||
|
||||
func (g *Gateway) convertRequest(body []byte, from, to string) ([]byte, error) {
|
||||
switch {
|
||||
case from == "chat" && to == "messages":
|
||||
var req convert.ChatCompletionRequest
|
||||
if err := json.Unmarshal(body, &req); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
msgReq, err := convert.ChatToMessages(&req)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return json.Marshal(msgReq)
|
||||
|
||||
case from == "chat" && to == "responses":
|
||||
var req convert.ChatCompletionRequest
|
||||
if err := json.Unmarshal(body, &req); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
respReq, err := convert.ChatToResponses(&req)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return json.Marshal(respReq)
|
||||
|
||||
case from == "messages" && to == "chat":
|
||||
var req convert.MessagesRequest
|
||||
if err := json.Unmarshal(body, &req); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
// Messages -> Chat: we need to construct a ChatCompletionRequest
|
||||
chatReq := &convert.ChatCompletionRequest{
|
||||
Model: req.Model,
|
||||
}
|
||||
for _, m := range req.Messages {
|
||||
chatReq.Messages = append(chatReq.Messages, m)
|
||||
}
|
||||
if req.Temperature != nil {
|
||||
chatReq.Temperature = req.Temperature
|
||||
}
|
||||
if req.TopP != nil {
|
||||
chatReq.TopP = req.TopP
|
||||
}
|
||||
chatReq.Tools = req.Tools
|
||||
chatReq.Stream = req.Stream
|
||||
return json.Marshal(chatReq)
|
||||
|
||||
case from == "responses" && to == "chat":
|
||||
var req convert.ResponsesRequest
|
||||
if err := json.Unmarshal(body, &req); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
chatReq := &convert.ChatCompletionRequest{
|
||||
Model: req.Model,
|
||||
}
|
||||
for _, item := range req.Input {
|
||||
chatReq.Messages = append(chatReq.Messages, convert.Message{
|
||||
Role: item.Role,
|
||||
Content: item.Content,
|
||||
})
|
||||
}
|
||||
chatReq.Tools = req.Tools
|
||||
chatReq.Stream = req.Stream
|
||||
return json.Marshal(chatReq)
|
||||
|
||||
default:
|
||||
return body, nil
|
||||
}
|
||||
}
|
||||
|
||||
func (g *Gateway) streamResponse(c *gin.Context, resp *http.Response, protocol string, ch *store.Channel) {
|
||||
// streamResponse 流式响应:按 \n\n 分块零缓冲转发;跨协议时逐行转换。
|
||||
func (g *Gateway) streamResponse(c *gin.Context, resp *http.Response, clientProto, upstreamProto string) {
|
||||
w := c.Writer
|
||||
c.Header("Content-Type", "text/event-stream")
|
||||
c.Header("Cache-Control", "no-cache")
|
||||
c.Header("Connection", "keep-alive")
|
||||
c.Status(http.StatusOK)
|
||||
|
||||
writer := convert.NewSSEWriter(c.Writer)
|
||||
parser := convert.NewSSEParser(resp.Body)
|
||||
flusher, _ := w.(http.Flusher)
|
||||
|
||||
for {
|
||||
event, err := parser.ReadEvent()
|
||||
if err != nil {
|
||||
if err == io.EOF {
|
||||
break
|
||||
}
|
||||
log.Printf("Stream parse error: %v", err)
|
||||
break
|
||||
}
|
||||
|
||||
if event.Event == "error" {
|
||||
log.Printf("Upstream stream error: %s", event.Data)
|
||||
break
|
||||
}
|
||||
|
||||
// Write raw SSE event based on protocol
|
||||
if err := writer.WriteEvent("chat CompletionChunk", event.Data); err != nil {
|
||||
break
|
||||
}
|
||||
// 跨协议时按行转换;同协议直通(lineConv 为 nil)。
|
||||
var lineConv func([]byte) []byte
|
||||
if upstreamProto != clientProto {
|
||||
lineConv = convert.NewStreamTransformer(upstreamProto, clientProto)
|
||||
}
|
||||
|
||||
writer.WriteDone()
|
||||
// 上游原始行按 \n\n 分块,避免把 data 行内的转义换行当成事件边界。
|
||||
r := bufio.NewReaderSize(resp.Body, 32*1024)
|
||||
for {
|
||||
buf := []byte{}
|
||||
for {
|
||||
line, err := r.ReadSlice('\n')
|
||||
if err == bufio.ErrBufferFull {
|
||||
buf = append(buf, line...)
|
||||
continue
|
||||
}
|
||||
buf = append(buf, line...)
|
||||
if err == io.EOF {
|
||||
if len(buf) == 0 {
|
||||
return
|
||||
}
|
||||
if !bytes.HasSuffix(buf, []byte("\n")) {
|
||||
buf = append(buf, '\n')
|
||||
}
|
||||
} else if err != nil {
|
||||
log.Printf("stream read error: %v", err)
|
||||
return
|
||||
}
|
||||
if len(buf) >= 2 && bytes.HasSuffix(buf, []byte("\n\n")) {
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
out := buf
|
||||
if lineConv != nil {
|
||||
out = lineConv(buf)
|
||||
}
|
||||
if len(out) == 0 {
|
||||
continue
|
||||
}
|
||||
if _, err := w.Write(out); err != nil {
|
||||
return // 客户端已断开
|
||||
}
|
||||
if flusher != nil {
|
||||
flusher.Flush()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func (g *Gateway) bufferResponse(c *gin.Context, resp *http.Response, protocol string, ch *store.Channel) {
|
||||
func (g *Gateway) bufferResponse(c *gin.Context, resp *http.Response, clientProto, upstreamProto string) {
|
||||
body, err := io.ReadAll(resp.Body)
|
||||
if err != nil {
|
||||
g.writeError(c, http.StatusBadGateway, "failed to read response")
|
||||
return
|
||||
}
|
||||
|
||||
c.Data(resp.StatusCode, "application/json", body)
|
||||
out := body
|
||||
if upstreamProto != clientProto {
|
||||
if converted, cerr := convert.ConvertResponse(body, upstreamProto, clientProto); cerr == nil {
|
||||
out = converted
|
||||
} else {
|
||||
// 转换失败时至少剥掉非 JSON 前缀,让客户端能解析出正文
|
||||
out = convert.CleanJSON(body)
|
||||
}
|
||||
} else {
|
||||
// 直通:部分上游(如 OpenRouter)的 non-stream 响应在 JSON 前夹带空白/注释
|
||||
out = convert.CleanJSON(body)
|
||||
}
|
||||
c.Data(resp.StatusCode, "application/json", out)
|
||||
}
|
||||
|
||||
func (g *Gateway) writeError(c *gin.Context, status int, message string) {
|
||||
|
||||
Reference in New Issue
Block a user