后端 - 新增 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 端口
505 lines
14 KiB
Go
505 lines
14 KiB
Go
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"
|
||
}
|
||
}
|