M0-M4: 推倒重来基线(基建+用户/密钥/核心代理+前端+管理后台+三协议互转)

- 后端 Go+Gin+GORM: 配置(OT_ env)/SQLite/Postgres 双驱动、用户体系(argon2id+JWT access/refresh)、
  API Key(sk- 48位, 仅存 SHA-256 哈希)
- 代理网关: /v1/chat/completions、/v1/responses、/v1/messages、/v1/models;错误按客户端协议返回
- 三协议互转(convert 包): Chat↔Messages↔Responses 请求/响应 + 流式 SSE 逐事件转换(直通优先)
- 用量计费: 异步批量记账、余额扣减、balance_logs、usage_daily 日聚合
- 管理 API: 用户/渠道 CRUD+测试+模型导入/模型定价+绑定/统计/系统配置
- 前端 Vue3+TS+Tailwind(taste-skill 设计 tokens): Landing/登录注册/控制台/管理后台,
  自建组件+Phosphor 图标+自建 SVG 趋势图, 已过 web-design-guidelines 复查
- mock 上游: OpenAI+Anthropic 双协议模拟(含流式)

Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
Sakurasan
2026-08-15 15:34:06 +08:00
co-authored by Claude
parent b25e9ec8a7
commit ec4de8d913
92 changed files with 6203 additions and 3064 deletions
+157
View File
@@ -0,0 +1,157 @@
// Package convert 三协议互转:OpenAI Chat / OpenAI Responses / Anthropic Messages。
// 网关以 OpenAI Chat 形状作为标准中间模型(PLANNING §5.1.1)。
// 请求与响应(非流式)走 JSON 转换;流式走逐行 SSE 转换(见 stream.go)。
package convert
import (
"encoding/json"
"fmt"
)
// 协议标识。
const (
ProtoChat = "chat"
ProtoMessages = "messages"
ProtoResponses = "responses"
)
// ConvertRequest 转换请求体。from==to 时原样返回。
func ConvertRequest(body []byte, from, to string) ([]byte, error) {
if from == to {
return body, nil
}
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
}
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 i, p := range parts {
if p == "" {
continue
}
if out != "" {
out += sep
}
out += p
_ = i
}
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
}
// decode 把 RawMessage 解到 map。
func decode(raw json.RawMessage) (map[string]any, error) {
var m map[string]any
if err := json.Unmarshal(raw, &m); err != nil {
return nil, err
}
return m, nil
}
@@ -0,0 +1,308 @@
package convert
import (
"encoding/json"
"strings"
"testing"
)
func mustJSON(t *testing.T, v any) string {
t.Helper()
b, err := json.Marshal(v)
if err != nil {
t.Fatalf("marshal: %v", err)
}
return string(b)
}
func TestChatToMessagesReq(t *testing.T) {
in := `{
"model":"claude-sonnet-5",
"messages":[
{"role":"system","content":"你是助手"},
{"role":"user","content":"hi"},
{"role":"assistant","content":"hello","tool_calls":[{"id":"call_1","function":{"name":"get_weather","arguments":"{\"city\":\"sz\"}"}}]},
{"role":"tool","tool_call_id":"call_1","content":"sunny"}
],
"tools":[{"type":"function","function":{"name":"get_weather","description":"查天气","parameters":{"type":"object"}}}],
"max_tokens":100,
"stream":true
}`
out, err := ConvertRequest([]byte(in), ProtoChat, ProtoMessages)
if err != nil {
t.Fatalf("convert: %v", err)
}
var m map[string]any
if err := json.Unmarshal(out, &m); err != nil {
t.Fatalf("unmarshal out: %v\n%s", err, out)
}
if m["system"] != "你是助手" {
t.Fatalf("system = %v", m["system"])
}
if m["max_tokens"] != float64(100) {
t.Fatalf("max_tokens = %v", m["max_tokens"])
}
msgs := m["messages"].([]any)
if len(msgs) != 3 {
t.Fatalf("messages len = %d", len(msgs))
}
// assistant 含 tool_use 块
assistant := msgs[1].(map[string]any)
content := assistant["content"].([]any)
foundToolUse := false
for _, c := range content {
cm := c.(map[string]any)
if cm["type"] == "tool_use" {
foundToolUse = true
if cm["name"] != "get_weather" || cm["id"] != "call_1" {
t.Fatalf("tool_use mismatch: %v", cm)
}
}
}
if !foundToolUse {
t.Fatal("expected tool_use block")
}
// tool 消息 → user 消息的 tool_result 块
tool := msgs[2].(map[string]any)
if tool["role"] != "user" {
t.Fatalf("tool message role = %v", tool["role"])
}
}
func TestMessagesToChatReq(t *testing.T) {
in := `{
"model":"gpt-4o-mini",
"system":"你是助手",
"messages":[
{"role":"user","content":"hi"},
{"role":"assistant","content":[{"type":"text","text":"hello"},{"type":"tool_use","id":"call_1","name":"get_weather","input":{"city":"sz"}}]},
{"role":"user","content":[{"type":"tool_result","tool_use_id":"call_1","content":"sunny"}]}
],
"tools":[{"name":"get_weather","description":"查天气","input_schema":{"type":"object"}}],
"max_tokens":100,
"stream":false
}`
out, err := ConvertRequest([]byte(in), ProtoMessages, ProtoChat)
if err != nil {
t.Fatalf("convert: %v", err)
}
var m map[string]any
_ = json.Unmarshal(out, &m)
msgs := m["messages"].([]any)
// system + user + assistant + tool = 4 条
if len(msgs) != 4 {
t.Fatalf("messages len = %d: %s", len(msgs), out)
}
if msgs[0].(map[string]any)["role"] != "system" {
t.Fatal("expected system message first")
}
assistant := msgs[2].(map[string]any)
if tc := assistant["tool_calls"]; tc == nil {
t.Fatalf("expected tool_calls in assistant: %s", out)
}
tool := msgs[3].(map[string]any)
if tool["role"] != "tool" || tool["tool_call_id"] != "call_1" {
t.Fatalf("tool message mismatch: %v", tool)
}
}
func TestResponsesToChatReq(t *testing.T) {
in := `{
"model":"claude-sonnet-5",
"instructions":"你是助手",
"input":"hello",
"tools":[{"type":"function","name":"get_weather","description":"查天气","parameters":{"type":"object"}}],
"max_output_tokens":200,
"stream":false
}`
out, err := ConvertRequest([]byte(in), ProtoResponses, ProtoChat)
if err != nil {
t.Fatalf("convert: %v", err)
}
var m map[string]any
_ = json.Unmarshal(out, &m)
msgs := m["messages"].([]any)
if len(msgs) != 2 {
t.Fatalf("messages len = %d: %s", len(msgs), out)
}
if msgs[0].(map[string]any)["role"] != "system" {
t.Fatal("expected system from instructions")
}
if m["max_tokens"] != float64(200) {
t.Fatalf("max_tokens = %v", m["max_tokens"])
}
}
func TestChatToResponsesReq(t *testing.T) {
in := mustJSON(t, map[string]any{
"model": "gpt-4o",
"messages": []any{
map[string]any{"role": "system", "content": "sys"},
map[string]any{"role": "user", "content": "hi"},
},
"max_tokens": 300,
})
out, err := ConvertRequest([]byte(in), ProtoChat, ProtoResponses)
if err != nil {
t.Fatalf("convert: %v", err)
}
var m map[string]any
_ = json.Unmarshal(out, &m)
if m["instructions"] != "sys" {
t.Fatalf("instructions = %v", m["instructions"])
}
if m["max_output_tokens"] != float64(300) {
t.Fatalf("max_output_tokens = %v", m["max_output_tokens"])
}
}
func TestMessagesToChatResp(t *testing.T) {
in := `{"id":"msg_abc","type":"message","role":"assistant","model":"claude-sonnet-5",
"content":[{"type":"text","text":"你好"},{"type":"tool_use","id":"call_1","name":"get_weather","input":{"city":"sz"}}],
"stop_reason":"tool_use","usage":{"input_tokens":10,"output_tokens":5}}`
out, err := ConvertResponse([]byte(in), ProtoMessages, ProtoChat)
if err != nil {
t.Fatalf("convert: %v", err)
}
var m map[string]any
_ = json.Unmarshal(out, &m)
choices := m["choices"].([]any)
msg := choices[0].(map[string]any)["message"].(map[string]any)
if msg["content"] != "你好" {
t.Fatalf("content = %v", msg["content"])
}
if msg["tool_calls"] == nil {
t.Fatal("expected tool_calls")
}
if choices[0].(map[string]any)["finish_reason"] != "tool_calls" {
t.Fatalf("finish_reason = %v", choices[0].(map[string]any)["finish_reason"])
}
}
func TestChatToMessagesResp(t *testing.T) {
in := `{"id":"chatcmpl-xyz","object":"chat.completion","model":"gpt-4o",
"choices":[{"index":0,"message":{"role":"assistant","content":"hi"},"finish_reason":"stop"}],
"usage":{"prompt_tokens":3,"completion_tokens":2,"total_tokens":5}}`
out, err := ConvertResponse([]byte(in), ProtoChat, ProtoMessages)
if err != nil {
t.Fatalf("convert: %v", err)
}
var m map[string]any
_ = json.Unmarshal(out, &m)
if m["stop_reason"] != "end_turn" {
t.Fatalf("stop_reason = %v", m["stop_reason"])
}
content := m["content"].([]any)
if content[0].(map[string]any)["text"] != "hi" {
t.Fatalf("content = %v", content)
}
usage := m["usage"].(map[string]any)
if usage["input_tokens"] != float64(3) || usage["output_tokens"] != float64(2) {
t.Fatalf("usage = %v", usage)
}
}
// ---------------------------------------------------------------------------
// 流式转换
func feedLines(t *testing.T, transformer func([]byte) []byte, lines []string) string {
t.Helper()
var sb strings.Builder
for _, l := range lines {
if out := transformer([]byte(l)); out != nil {
sb.Write(out)
}
}
return sb.String()
}
func TestStreamMessagesToChat(t *testing.T) {
tf := newMessagesToChat().line
out := feedLines(t, tf, []string{
"event: message_start\n",
`data: {"type":"message_start","message":{"id":"msg_1","model":"claude-sonnet-5"}}` + "\n\n",
"event: content_block_delta\n",
`data: {"type":"content_block_delta","index":0,"delta":{"type":"text_delta","text":"你好"}}` + "\n\n",
"event: message_delta\n",
`data: {"type":"message_delta","delta":{"stop_reason":"end_turn"},"usage":{"input_tokens":10,"output_tokens":5}}` + "\n\n",
"event: message_stop\n",
`data: {"type":"message_stop"}` + "\n\n",
})
if !strings.Contains(out, `"content":"你好"`) {
t.Fatalf("missing content chunk: %s", out)
}
if !strings.Contains(out, `"finish_reason":"stop"`) {
t.Fatalf("missing finish chunk: %s", out)
}
if !strings.Contains(out, `"usage"`) {
t.Fatalf("missing usage chunk: %s", out)
}
if !strings.Contains(out, "data: [DONE]") {
t.Fatalf("missing [DONE]: %s", out)
}
}
func TestStreamChatToMessages(t *testing.T) {
tf := newChatToMessages().line
out := feedLines(t, tf, []string{
`data: {"id":"chatcmpl-1","object":"chat.completion.chunk","model":"gpt-4o","choices":[{"index":0,"delta":{"role":"assistant"},"finish_reason":null}]}` + "\n\n",
`data: {"id":"chatcmpl-1","object":"chat.completion.chunk","model":"gpt-4o","choices":[{"index":0,"delta":{"content":"你好"},"finish_reason":null}]}` + "\n\n",
`data: {"id":"chatcmpl-1","object":"chat.completion.chunk","model":"gpt-4o","choices":[{"index":0,"delta":{},"finish_reason":"stop"}]}` + "\n\n",
`data: {"id":"chatcmpl-1","object":"chat.completion.chunk","model":"gpt-4o","choices":[],"usage":{"prompt_tokens":12,"completion_tokens":9}}` + "\n\n",
"data: [DONE]\n\n",
})
if !strings.Contains(out, "event: message_start") {
t.Fatalf("missing message_start: %s", out)
}
if !strings.Contains(out, `"text":"你好"`) || !strings.Contains(out, `"type":"text_delta"`) {
t.Fatalf("missing content delta: %s", out)
}
if !strings.Contains(out, `"stop_reason":"end_turn"`) {
t.Fatalf("missing message_delta: %s", out)
}
if !strings.Contains(out, "event: message_stop") {
t.Fatalf("missing message_stop: %s", out)
}
}
func TestStreamResponsesToMessages(t *testing.T) {
tf := newResponsesToMessages().line
out := feedLines(t, tf, []string{
"event: response.created\n",
`data: {"type":"response.created","response":{"id":"resp_1","model":"claude-sonnet-5"}}` + "\n\n",
"event: response.output_text.delta\n",
`data: {"type":"response.output_text.delta","delta":"hi"}` + "\n\n",
"event: response.completed\n",
`data: {"type":"response.completed","response":{"id":"resp_1","status":"completed","usage":{"input_tokens":7,"output_tokens":8}}}` + "\n\n",
})
if !strings.Contains(out, "event: message_start") {
t.Fatalf("missing message_start: %s", out)
}
if !strings.Contains(out, `"text":"hi"`) {
t.Fatalf("missing content: %s", out)
}
if !strings.Contains(out, "event: message_stop") {
t.Fatalf("missing message_stop: %s", out)
}
}
func TestStreamMessagesToResponses(t *testing.T) {
tf := newMessagesToResponses().line
out := feedLines(t, tf, []string{
"event: message_start\n",
`data: {"type":"message_start","message":{"id":"msg_1","model":"claude-sonnet-5"}}` + "\n\n",
"event: content_block_delta\n",
`data: {"type":"content_block_delta","index":0,"delta":{"type":"text_delta","text":"hi"}}` + "\n\n",
"event: message_stop\n",
`data: {"type":"message_stop"}` + "\n\n",
})
if !strings.Contains(out, "event: response.created") {
t.Fatalf("missing response.created: %s", out)
}
if !strings.Contains(out, "event: response.output_text.delta") {
t.Fatalf("missing output_text.delta: %s", out)
}
if !strings.Contains(out, "event: response.completed") {
t.Fatalf("missing response.completed: %s", out)
}
}
+457
View File
@@ -0,0 +1,457 @@
package convert
import (
"encoding/json"
"strings"
)
// ---------------------------------------------------------------------------
// 请求:Chat → Messages
type chatTool struct {
Type string `json:"type"`
Function struct {
Name string `json:"name"`
Description string `json:"description"`
Parameters json.RawMessage `json:"parameters"`
} `json:"function"`
}
type chatMsg struct {
Role string `json:"role"`
Content json.RawMessage `json:"content"`
ToolCallID string `json:"tool_call_id"`
ToolCalls []struct {
ID string `json:"id"`
Function struct {
Name string `json:"name"`
Arguments string `json:"arguments"`
} `json:"function"`
} `json:"tool_calls"`
}
type chatReq struct {
Model string `json:"model"`
Messages []chatMsg `json:"messages"`
Tools []chatTool `json:"tools"`
Temperature *float64 `json:"temperature"`
TopP *float64 `json:"top_p"`
MaxTokens *int `json:"max_tokens"`
Stop []string `json:"stop"`
Stream bool `json:"stream"`
}
// chatToMessagesReq 将 OpenAI Chat 请求转为 Anthropic Messages 请求。
func chatToMessagesReq(body []byte) ([]byte, error) {
var req chatReq
if err := json.Unmarshal(body, &req); err != nil {
return nil, err
}
out := map[string]any{
"model": req.Model,
"max_tokens": intOrNil(req.MaxTokens, 1024), // Anthropic 必填
}
if req.Stream {
out["stream"] = true
}
if req.Temperature != nil {
out["temperature"] = *req.Temperature
}
if req.TopP != nil {
out["top_p"] = *req.TopP
}
if len(req.Stop) > 0 {
out["stop_sequences"] = req.Stop
}
var system []string
msgs := make([]any, 0, len(req.Messages))
for _, m := range req.Messages {
if m.Role == "system" {
if s := str(m.Content); s != "" {
system = append(system, s)
}
continue
}
msgs = append(msgs, chatMsgToAnthropic(m))
}
if len(system) > 0 {
out["system"] = strings.Join(system, "\n")
}
out["messages"] = msgs
if len(req.Tools) > 0 {
tools := make([]any, 0, len(req.Tools))
for _, t := range req.Tools {
var params any
if len(t.Function.Parameters) > 0 && string(t.Function.Parameters) != "null" {
_ = json.Unmarshal(t.Function.Parameters, &params)
}
tools = append(tools, map[string]any{
"name": t.Function.Name,
"description": t.Function.Description,
"input_schema": params,
})
}
out["tools"] = tools
}
return json.Marshal(out)
}
// chatMsgToAnthropic 单条消息转 Anthropic 内容。
func chatMsgToAnthropic(m chatMsg) any {
switch m.Role {
case "assistant":
content := make([]any, 0, 2)
if s := str(m.Content); s != "" {
content = append(content, map[string]any{"type": "text", "text": s})
}
for _, tc := range m.ToolCalls {
var input any
if tc.Function.Arguments != "" {
_ = json.Unmarshal([]byte(tc.Function.Arguments), &input)
}
content = append(content, map[string]any{
"type": "tool_use",
"id": tc.ID,
"name": tc.Function.Name,
"input": input,
})
}
return map[string]any{"role": "assistant", "content": content}
case "tool":
return map[string]any{"role": "user", "content": []any{
map[string]any{"type": "tool_result", "tool_use_id": m.ToolCallID, "content": str(m.Content)},
}}
default: // user
var arr []map[string]any
if json.Unmarshal(m.Content, &arr) == nil && arr != nil {
blocks := make([]any, 0, len(arr))
for _, b := range arr {
switch b["type"] {
case "text", "input_text":
if t, _ := b["text"].(string); t != "" {
blocks = append(blocks, map[string]any{"type": "text", "text": t})
}
case "image_url":
var url string
if iu, ok := b["image_url"].(map[string]any); ok {
url, _ = iu["url"].(string)
}
if url != "" {
blocks = append(blocks, map[string]any{"type": "image", "source": map[string]any{"type": "url", "url": 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 textParts []string
var toolCalls []any
for _, b := range blocks {
switch b["type"] {
case "text":
if t, _ := b["text"].(string); t != "" {
textParts = append(textParts, t)
}
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"])
out = append(out, map[string]any{"role": "tool", "tool_call_id": callID, "content": res})
}
}
if len(textParts) > 0 || len(toolCalls) > 0 {
msg := map[string]any{"role": role}
if len(textParts) > 0 {
msg["content"] = strings.Join(textParts, "")
}
if len(toolCalls) > 0 {
msg["tool_calls"] = toolCalls
}
out = append(out, msg)
}
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,
},
})
}
// ---------------------------------------------------------------------------
// 辅助
func intOrNil(p *int, def int) any {
if p == nil {
return def
}
return *p
}
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{}
}
func strField(v any) string {
if s, ok := v.(string); ok {
return s
}
return ""
}
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,318 @@
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。
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":
var args any
_ = json.Unmarshal([]byte(strField(item["arguments"])), &args)
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
for _, b := range blocks {
if bm, ok := b.(map[string]any); ok {
if t, _ := bm["text"].(string); t != "" {
text = append(text, t)
}
}
}
out = append(out, map[string]any{"role": role, "content": strings.Join(text, "")})
}
}
}
return out
}
// ---------------------------------------------------------------------------
// 请求: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 s := str(m.Content); s != "" {
input = append(input, map[string]any{"type": "message", "role": "user", "content": []any{
map[string]any{"type": "input_text", "text": s},
}})
}
}
}
if len(system) > 0 {
out["instructions"] = strings.Join(system, "\n")
}
if len(input) == 1 {
out["input"] = input[0] // 单条消息项
} else {
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"
if string(rawJSON(m, "status")) == `"incomplete"` {
finish = "length"
}
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"
}
}
status := finish
return json.Marshal(map[string]any{
"id": "resp_" + strings.TrimPrefix(r.ID, "chatcmpl-"),
"object": "response",
"model": r.Model,
"status": status,
"output": output,
"usage": map[string]any{
"input_tokens": r.Usage.PromptTokens,
"output_tokens": r.Usage.CompletionTokens,
"total_tokens": r.Usage.PromptTokens + r.Usage.CompletionTokens,
},
})
}
+478
View File
@@ -0,0 +1,478 @@
package convert
import (
"encoding/json"
"strings"
)
// sseState 记录上一行 event 名与通用状态。
type sseState struct {
event string
}
// parseLine 解析一行 SSE;返回是否 data 行及其内容、是否 [DONE]。
func (s *sseState) parseLine(line []byte) (isData bool, data string, done bool) {
str := strings.TrimRight(string(line), "\r\n")
switch {
case strings.HasPrefix(str, "event: "):
s.event = strings.TrimSpace(strings.TrimPrefix(str, "event: "))
return false, "", false
case str == "data: [DONE]":
return true, "[DONE]", true
case strings.HasPrefix(str, "data: "):
return true, strings.TrimPrefix(str, "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')
}
// ---------------------------------------------------------------------------
// Messages → Chat
type messagesToChat struct {
sseState
id, model string
}
func newMessagesToChat() *messagesToChat { return &messagesToChat{} }
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_delta":
delta, _ := m["delta"].(map[string]any)
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
}
func joinLines(lines [][]byte) []byte {
return []byte(strings.Join(func() []string {
var s []string
for _, l := range lines {
s = append(s, string(l))
}
return s
}(), ""))
}
// ---------------------------------------------------------------------------
// Chat → Messages
type chatToMessages struct {
sseState
started bool
blockStarted bool
model string
stopReason string
usage any
}
func newChatToMessages() *chatToMessages { return &chatToMessages{} }
func (t *chatToMessages) line(line []byte) []byte {
isData, data, done := t.parseLine(line)
if !isData {
return nil
}
if done {
// 汇聚最终 message_delta + content_block_stop + message_stop
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
}
var out [][]byte
out = append(out, eventLine("message_delta", md))
if t.blockStarted {
out = append(out, eventLine("content_block_stop", map[string]any{"type": "content_block_stop", "index": 0}))
}
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 + content_block_start
if !t.started {
role, _ := delta["role"].(string)
content, _ := delta["content"].(string)
if role == "assistant" || content != "" {
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},
},
}))
out = append(out, eventLine("content_block_start", map[string]any{
"type": "content_block_start", "index": 0, "content_block": map[string]any{"type": "text", "text": ""},
}))
t.blockStarted = true
}
}
if content, _ := delta["content"].(string); content != "" {
if !t.started {
t.started = true
t.blockStarted = 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{}},
}))
out = append(out, eventLine("content_block_start", map[string]any{
"type": "content_block_start", "index": 0, "content_block": map[string]any{"type": "text", "text": ""},
}))
}
out = append(out, eventLine("content_block_delta", map[string]any{
"type": "content_block_delta", "index": 0, "delta": map[string]any{"type": "text_delta", "text": content},
}))
}
if u, ok := m["usage"]; ok {
t.usage = u
}
return joinLines(out)
}
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
}
func newResponsesToMessages() *responsesToMessages { return &responsesToMessages{} }
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
switch evt {
case "response.created":
if !t.started {
t.started = true
id, _ := m["response"].(map[string]any)
rid := ""
if id != nil {
rid, _ = id["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{},
},
}))
out = append(out, eventLine("content_block_start", map[string]any{
"type": "content_block_start", "index": 0, "content_block": map[string]any{"type": "text", "text": ""},
}))
}
case "response.output_text.delta":
delta, _ := m["delta"].(string)
if delta != "" {
out = append(out, eventLine("content_block_delta", map[string]any{
"type": "content_block_delta", "index": 0, "delta": map[string]any{"type": "text_delta", "text": delta},
}))
}
case "response.completed":
out = append(out, eventLine("message_delta", map[string]any{
"type": "message_delta", "delta": map[string]any{"stop_reason": "end_turn", "stop_sequence": nil},
}))
out = append(out, eventLine("content_block_stop", map[string]any{"type": "content_block_stop", "index": 0}))
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
done 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 {
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)
}
}
var out [][]byte
if role, _ := delta["role"].(string); role == "assistant" {
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,
}))
}
if finish != "" && !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)
}
+86 -10
View File
@@ -1,5 +1,5 @@
// Package proxy API 网关核心:代理 /v1/chat/completions、/v1/responses、/v1/models。
// M1:对 OpenAI 渠道直通(passthrough),不转格式;M3 起加入协议转换。
// Package proxy API 网关核心:代理 /v1/chat/completions、/v1/responses、/v1/messages、/v1/models。
// M1 直通 OpenAI 渠道;M4 起按客户端协议 × 渠道协议自动转换(见 convert)。
package proxy
import (
@@ -12,6 +12,7 @@ import (
"github.com/openteam/server/internal/channel"
"github.com/openteam/server/internal/pkg/apikey"
"github.com/openteam/server/internal/pkg/crypto"
"github.com/openteam/server/internal/proxy/convert"
"github.com/openteam/server/internal/store"
"github.com/openteam/server/internal/usage"
"gorm.io/gorm"
@@ -43,29 +44,39 @@ func NewGateway(db *gorm.DB, enc *crypto.Encryptor, rec *usage.Recorder) *Gatewa
// Auth 代理鉴权中间件:Bearer sk-xxx → 哈希查表 → 校验状态/过期/模型白名单。
func (g *Gateway) Auth(c *gin.Context) {
// 先按路径确定客户端协议,保证 Auth 阶段错误也按协议格式返回
switch c.Request.URL.Path {
case "/v1/messages":
c.Set("protocol", convert.ProtoMessages)
case "/v1/responses":
c.Set("protocol", convert.ProtoResponses)
default:
c.Set("protocol", convert.ProtoChat)
}
auth := c.GetHeader("Authorization")
key := strings.TrimPrefix(auth, "Bearer ")
key = strings.TrimSpace(key)
if !apikey.Valid(key) {
openAIError(c, http.StatusUnauthorized, "invalid_api_key", "Invalid API key format. Expected: Bearer sk-...")
apiError(c, http.StatusUnauthorized, "invalid_api_key", "Invalid API key format. Expected: Bearer sk-...")
c.Abort()
return
}
hash := apikey.Hash(key)
var k store.APIKey
if err := g.db.Where("key_hash = ? AND status = ?", hash, store.KeyStatusActive).First(&k).Error; err != nil {
openAIError(c, http.StatusUnauthorized, "invalid_api_key", "Invalid API key")
apiError(c, http.StatusUnauthorized, "invalid_api_key", "Invalid API key")
c.Abort()
return
}
var u store.User
if err := g.db.First(&u, k.UserID).Error; err != nil || u.Status != store.UserStatusActive {
openAIError(c, http.StatusForbidden, "user_disabled", "User account is disabled")
apiError(c, http.StatusForbidden, "user_disabled", "User account is disabled")
c.Abort()
return
}
if k.ExpiresAt != nil && time.Now().After(*k.ExpiresAt) {
openAIError(c, http.StatusUnauthorized, "key_expired", "API key has expired")
apiError(c, http.StatusUnauthorized, "key_expired", "API key has expired")
c.Abort()
return
}
@@ -84,10 +95,12 @@ func (g *Gateway) Handle(c *gin.Context) {
g.chatCompletions(c)
case c.Request.URL.Path == "/v1/responses":
g.responses(c)
case c.Request.URL.Path == "/v1/messages":
g.messages(c)
case c.Request.URL.Path == "/v1/models" && c.Request.Method == http.MethodGet:
g.models(c)
default:
openAIError(c, http.StatusNotFound, "not_found", "Unknown endpoint: "+c.Request.URL.Path)
apiError(c, http.StatusNotFound, "not_found", "Unknown endpoint: "+c.Request.URL.Path)
}
}
@@ -95,7 +108,7 @@ func (g *Gateway) Handle(c *gin.Context) {
func (g *Gateway) models(c *gin.Context) {
var ms []store.Model
if err := g.db.Where("enabled = ?", true).Order("sort ASC, id ASC").Find(&ms).Error; err != nil {
openAIError(c, http.StatusInternalServerError, "internal_error", "failed to load models")
apiError(c, http.StatusInternalServerError, "internal_error", "failed to load models")
return
}
data := make([]gin.H, 0, len(ms))
@@ -110,12 +123,22 @@ func (g *Gateway) models(c *gin.Context) {
c.JSON(http.StatusOK, gin.H{"object": "list", "data": data})
}
// selectChannel 选渠道:优先按模型绑定解析,退化为全局选渠道。
func (g *Gateway) selectChannel(c *gin.Context, model string) (*store.Channel, error) {
if model != "" {
if ch, _, err := g.ch.ResolveModel(model); err == nil {
return ch, nil
}
}
return g.ch.Select()
}
// resolveUser 取当前用户(含余额)。
func (g *Gateway) resolveUser(c *gin.Context) (*store.User, bool) {
uid, _ := c.Get(CtxUserID)
var u store.User
if err := g.db.First(&u, uid).Error; err != nil {
openAIError(c, http.StatusUnauthorized, "invalid_api_key", "Invalid API key")
apiError(c, http.StatusUnauthorized, "invalid_api_key", "Invalid API key")
return nil, false
}
return &u, true
@@ -124,10 +147,63 @@ func (g *Gateway) resolveUser(c *gin.Context) (*store.User, bool) {
// checkBalance 余额不足返回 402(PLANNING §4.4.3)。
func (g *Gateway) checkBalance(c *gin.Context, u *store.User) bool {
if u.Balance <= 0 {
openAIError(c, http.StatusPaymentRequired, "insufficient_balance", "Insufficient balance. Please recharge or contact admin.")
apiError(c, http.StatusPaymentRequired, "insufficient_balance", "Insufficient balance. Please recharge or contact admin.")
return false
}
return true
}
// ---------------------------------------------------------------------------
// 协议分派
// upstreamProtoFor 根据渠道 provider 与客户端协议确定上游协议与路径。
func upstreamProtoFor(provider, clientProto string) string {
switch provider {
case store.ChannelProviderAnthropic:
return convert.ProtoMessages
case store.ChannelProviderOpenAI:
if clientProto == convert.ProtoMessages {
return convert.ProtoChat
}
return clientProto
default: // compatible:假定 OpenAI Chat 形状
return convert.ProtoChat
}
}
func upstreamPath(proto string) string {
switch proto {
case convert.ProtoMessages:
return "/v1/messages"
case convert.ProtoResponses:
return "/v1/responses"
default:
return "/v1/chat/completions"
}
}
// upstreamPlan 描述一次代理请求的上游访问方式。
type upstreamPlan struct {
path string // 上游路径
body []byte // 已转换的请求体
lineConv func([]byte) []byte // 流式逐行转换(nil=直通)
bodyConv func([]byte) ([]byte, error) // 非流式响应体转换(nil=直通)
}
// prepareUpstream 计算上游访问计划:协议匹配直通,否则转换。
func prepareUpstream(provider, clientProto string, body []byte) (*upstreamPlan, error) {
up := upstreamProtoFor(provider, clientProto)
plan := &upstreamPlan{path: upstreamPath(up), body: body}
if up != clientProto {
converted, err := convert.ConvertRequest(body, clientProto, up)
if err != nil {
return nil, err
}
plan.body = converted
plan.lineConv = convert.NewStreamTransformer(up, clientProto)
plan.bodyConv = func(b []byte) ([]byte, error) { return convert.ConvertResponse(b, up, clientProto) }
}
return plan, nil
}
var errNoChannel = errors.New("no available channel")
+65 -32
View File
@@ -1,11 +1,10 @@
package proxy
import (
"encoding/json"
"net/http"
"github.com/gin-gonic/gin"
"github.com/openteam/server/internal/store"
"github.com/openteam/server/internal/proxy/convert"
)
// chatCompletions POST /v1/chat/completions
@@ -17,27 +16,28 @@ func (g *Gateway) chatCompletions(c *gin.Context) {
if !g.checkBalance(c, u) {
return
}
br, body, err := parseBody(c)
if err != nil {
openAIError(c, http.StatusBadRequest, "invalid_request_error", "failed to read request body")
apiError(c, http.StatusBadRequest, "invalid_request_error", "failed to read request body")
return
}
c.Set("protocol", "chat")
c.Set("protocol", convert.ProtoChat)
c.Set("model_name", br.Model)
ch, err := g.ch.Select()
ch, err := g.selectChannel(c, br.Model)
if err != nil {
openAIError(c, http.StatusServiceUnavailable, "no_channel", "No available upstream channel")
apiError(c, http.StatusServiceUnavailable, "no_channel", "No available upstream channel")
g.recordError(c, nil, nil, now(), "no_channel")
return
}
plan, err := prepareUpstream(ch.Provider, convert.ProtoChat, body)
if err != nil {
apiError(c, http.StatusInternalServerError, "conversion_error", "Failed to convert request: "+err.Error())
return
}
sink := &usageSink{}
c.Set("usage_raw", &sinkHolder{sink: sink})
g.doPassthrough(c, ch, "/v1/chat/completions", body, br.Stream, func(raw json.RawMessage) {
sink.push(raw)
})
g.doProxy(c, ch, plan, br.Stream, sink)
}
// responses POST /v1/responses(OpenAI Responses API)
@@ -49,33 +49,61 @@ func (g *Gateway) responses(c *gin.Context) {
if !g.checkBalance(c, u) {
return
}
br, body, err := parseBody(c)
if err != nil {
openAIError(c, http.StatusBadRequest, "invalid_request_error", "failed to read request body")
apiError(c, http.StatusBadRequest, "invalid_request_error", "failed to read request body")
return
}
c.Set("protocol", "responses")
c.Set("protocol", convert.ProtoResponses)
c.Set("model_name", br.Model)
ch, err := g.ch.Select()
ch, err := g.selectChannel(c, br.Model)
if err != nil {
openAIError(c, http.StatusServiceUnavailable, "no_channel", "No available upstream channel")
apiError(c, http.StatusServiceUnavailable, "no_channel", "No available upstream channel")
g.recordError(c, nil, nil, now(), "no_channel")
return
}
// M1 仅支持 OpenAI 原生渠道直通;Anthropic 渠道的转换在 M3
if ch.Provider != store.ChannelProviderOpenAI {
openAIError(c, http.StatusNotImplemented, "conversion_pending",
"Responses protocol on this channel requires format conversion (planned in M3)")
plan, err := prepareUpstream(ch.Provider, convert.ProtoResponses, body)
if err != nil {
apiError(c, http.StatusInternalServerError, "conversion_error", "Failed to convert request: "+err.Error())
return
}
sink := &usageSink{}
c.Set("usage_raw", &sinkHolder{sink: sink})
g.doPassthrough(c, ch, "/v1/responses", body, br.Stream, func(raw json.RawMessage) {
sink.push(raw)
})
g.doProxy(c, ch, plan, br.Stream, sink)
}
// messages POST /v1/messages(Anthropic Messages API)
func (g *Gateway) messages(c *gin.Context) {
u, ok := g.resolveUser(c)
if !ok {
return
}
if !g.checkBalance(c, u) {
return
}
br, body, err := parseBody(c)
if err != nil {
apiError(c, http.StatusBadRequest, "invalid_request_error", "failed to read request body")
return
}
c.Set("protocol", convert.ProtoMessages)
c.Set("model_name", br.Model)
ch, err := g.selectChannel(c, br.Model)
if err != nil {
apiError(c, http.StatusServiceUnavailable, "no_channel", "No available upstream channel")
g.recordError(c, nil, nil, now(), "no_channel")
return
}
plan, err := prepareUpstream(ch.Provider, convert.ProtoMessages, body)
if err != nil {
apiError(c, http.StatusInternalServerError, "conversion_error", "Failed to convert request: "+err.Error())
return
}
sink := &usageSink{}
c.Set("usage_raw", &sinkHolder{sink: sink})
g.doProxy(c, ch, plan, br.Stream, sink)
}
// usageSinkHolder 桥接:gin context 里保存 sink 引用,供 finishUsage 读取最终 usage。
@@ -83,8 +111,17 @@ type sinkHolder struct {
sink *usageSink
}
// openAIError 按 OpenAI 错误格式返回(PLANNING §4.1.4)。
func openAIError(c *gin.Context, status int, code, message string) {
// apiError 按客户端协议返回错误体(PLANNING §5.1.4)。
func apiError(c *gin.Context, status int, code, message string) {
if p, _ := c.Get("protocol"); p == convert.ProtoMessages {
// Anthropic 格式
c.AbortWithStatusJSON(status, gin.H{
"type": "error",
"error": gin.H{"type": errorTypeFor(status), "message": message},
})
return
}
// OpenAI 格式
c.AbortWithStatusJSON(status, gin.H{
"error": gin.H{
"message": message,
@@ -99,14 +136,10 @@ func errorTypeFor(status int) string {
switch status {
case http.StatusUnauthorized:
return "authentication_error"
case http.StatusForbidden:
case http.StatusForbidden, http.StatusPaymentRequired:
return "permission_error"
case http.StatusNotFound:
case http.StatusNotFound, http.StatusBadRequest:
return "invalid_request_error"
case http.StatusBadRequest:
return "invalid_request_error"
case http.StatusPaymentRequired:
return "insufficient_quota"
case http.StatusTooManyRequests:
return "rate_limit_error"
default:
+145 -84
View File
@@ -43,24 +43,22 @@ func parseBody(c *gin.Context) (*bodyReq, []byte, error) {
return br, body, nil
}
// upstreamURL 组装上游地址:base_url + 客户端路径(/v1/chat/completions 等)。
// upstreamURL 组装上游地址:base_url + 路径。
func upstreamURL(ch *store.Channel, path string) string {
base := strings.TrimRight(ch.BaseURL, "/")
return base + path
return strings.TrimRight(ch.BaseURL, "/") + path
}
// doPassthrough 通用直通:替换 Authorization 为渠道密钥,转发请求。
// convert 回调用于改写请求体(M1 直通为原样;M3 转换时改写)。
func (g *Gateway) doPassthrough(c *gin.Context, ch *store.Channel, path string, body []byte, stream bool, outUsage func(usageRaw json.RawMessage)) {
// doProxy 通用代理:替换 Authorization 为渠道密钥,转发请求;按 plan 决定路径与转换。
func (g *Gateway) doProxy(c *gin.Context, ch *store.Channel, plan *upstreamPlan, stream bool, sink *usageSink) {
upKey, err := g.ch.UpstreamKey(ch)
if err != nil {
openAIError(c, http.StatusInternalServerError, "channel_error", "failed to decrypt channel key")
apiError(c, http.StatusInternalServerError, "channel_error", "failed to decrypt channel key")
return
}
upBody := body
// 流式 chat:注入 stream_options.include_usage,保证末块带 usage(OpenAI 行为)
if stream && path == "/v1/chat/completions" && !bytes.Contains(upBody, []byte(`"include_usage"`)) {
upBody := plan.body
// 直通 chat 流式:注入 stream_options.include_usage,保证末块带 usage(OpenAI 行为)
if stream && plan.path == "/v1/chat/completions" && plan.lineConv == nil && !bytes.Contains(upBody, []byte(`"include_usage"`)) {
var m map[string]any
if json.Unmarshal(upBody, &m) == nil {
m["stream_options"] = map[string]any{"include_usage": true}
@@ -72,9 +70,9 @@ func (g *Gateway) doPassthrough(c *gin.Context, ch *store.Channel, path string,
ctx, cancel := context.WithTimeout(c.Request.Context(), time.Duration(ch.TimeoutMS)*time.Millisecond)
defer cancel()
req, err := http.NewRequestWithContext(ctx, http.MethodPost, upstreamURL(ch, path), bytes.NewReader(upBody))
req, err := http.NewRequestWithContext(ctx, http.MethodPost, upstreamURL(ch, plan.path), bytes.NewReader(upBody))
if err != nil {
openAIError(c, http.StatusInternalServerError, "internal_error", "failed to build upstream request")
apiError(c, http.StatusInternalServerError, "internal_error", "failed to build upstream request")
return
}
req.Header.Set("Content-Type", "application/json")
@@ -83,6 +81,9 @@ func (g *Gateway) doPassthrough(c *gin.Context, ch *store.Channel, path string,
if ua := c.GetHeader("User-Agent"); ua != "" {
req.Header.Set("User-Agent", ua)
}
if plan.path == "/v1/messages" {
req.Header.Set("anthropic-version", "2023-06-01")
}
// 透传 OpenAI 生态请求头(组织/项目等)
for _, h := range []string{"OpenAI-Organization", "OpenAI-Project", "OpenAI-Beta"} {
if v := c.GetHeader(h); v != "" {
@@ -99,55 +100,57 @@ func (g *Gateway) doPassthrough(c *gin.Context, ch *store.Channel, path string,
status = http.StatusGatewayTimeout
msg = "Upstream request timed out"
}
openAIError(c, status, "upstream_error", msg)
apiError(c, status, "upstream_error", msg)
g.recordError(c, ch, nil, start, "upstream_error")
return
}
defer resp.Body.Close()
// 非 2xx:透传上游错误体(OpenAI 格式),并记录 error 用量
// 非 2xx:透传上游错误体,并记录 error 用量
if resp.StatusCode < 200 || resp.StatusCode >= 300 {
errBody, _ := io.ReadAll(resp.Body)
status := resp.StatusCode
// 上游 5xx → 网关 502/504(重试逻辑 M4)
if status >= 500 {
status = http.StatusBadGateway
}
c.DataFromReader(status, int64(len(errBody)), "application/json", bytes.NewReader(errBody), nil)
c.Header("Content-Type", "application/json")
c.DataFromReader(status, int64(len(errBody)), "application/json", bytes.NewReader(errBody), nil)
g.recordError(c, ch, resp, start, "upstream_http_"+strconv.Itoa(resp.StatusCode))
return
}
// 成功响应
c.Header("Content-Type", resp.Header.Get("Content-Type"))
c.Status(http.StatusOK)
if stream {
g.streamCopy(c, ch, resp.Body, start, outUsage)
g.streamCopy(c, ch, resp.Body, start, plan.lineConv, sink)
} else {
g.copyAndCapture(c, ch, resp.Body, start, outUsage)
g.copyAndCapture(c, ch, resp.Body, start, plan.bodyConv, sink)
}
}
// copyAndCapture 非流式:整体转发 + 解析 usage + 记账。
func (g *Gateway) copyAndCapture(c *gin.Context, ch *store.Channel, r io.Reader, start time.Time, outUsage func(json.RawMessage)) {
// copyAndCapture 非流式:整体转发(可转换)+ 解析 usage + 记账。
func (g *Gateway) copyAndCapture(c *gin.Context, ch *store.Channel, r io.Reader, start time.Time, bodyConv func([]byte) ([]byte, error), sink *usageSink) {
data, err := io.ReadAll(r)
if err != nil {
openAIError(c, http.StatusBadGateway, "upstream_error", "failed reading upstream response")
apiError(c, http.StatusBadGateway, "upstream_error", "failed reading upstream response")
g.recordError(c, ch, nil, start, "read_error")
return
}
// 尝试解析 usage(chat / responses 字段不同)
if usageRaw := extractUsage(data); usageRaw != nil {
outUsage(usageRaw)
if usageRaw := extractUsage(data); usageRaw != nil && sink != nil {
sink.push(usageRaw)
}
_, _ = c.Writer.Write(data)
out := data
if bodyConv != nil {
if converted, cerr := bodyConv(data); cerr == nil {
out = converted
}
}
_, _ = c.Writer.Write(out)
g.finishUsage(c, ch, start, store.UsageStatusSuccess, "")
}
// streamCopy 流式:边读上游 SSE 边写客户端,零缓冲转发;扫描 usage 行记账。
// 客户端断连(ctx cancel)即中止上游读取。
func (g *Gateway) streamCopy(c *gin.Context, ch *store.Channel, r io.Reader, start time.Time, outUsage func(json.RawMessage)) {
// streamCopy 流式:边读上游 SSE 边写客户端,零缓冲转发;按 lineConv 转换;扫描 usage 记账。
func (g *Gateway) streamCopy(c *gin.Context, ch *store.Channel, r io.Reader, start time.Time, lineConv func([]byte) []byte, sink *usageSink) {
w := c.Writer
flusher, ok := w.(http.Flusher)
if !ok {
@@ -158,19 +161,26 @@ func (g *Gateway) streamCopy(c *gin.Context, ch *store.Channel, r io.Reader, sta
for {
line, err := scanner.Next()
if line != nil {
if _, werr := w.Write(line); werr != nil {
// 客户端断开:取消上游(ctx cancel 由 request ctx 处理)
g.recordError(c, ch, nil, start, "client_disconnect")
return
out := line
if lineConv != nil {
out = lineConv(line)
}
flusher.Flush()
if usageRaw := scanUsage(line); usageRaw != nil {
outUsage(usageRaw)
if out != nil {
if _, werr := w.Write(out); werr != nil {
g.recordError(c, ch, nil, start, "client_disconnect")
return
}
flusher.Flush()
}
if usageRaw := scanUsage(line); usageRaw != nil && sink != nil {
sink.push(usageRaw)
}
}
if err != nil {
if err == io.EOF {
g.finishUsage(c, ch, start, store.UsageStatusSuccess, "")
} else if c.Request.Context().Err() != nil {
g.recordError(c, ch, nil, start, "client_disconnect")
} else {
g.recordError(c, ch, nil, start, "stream_read_error")
}
@@ -186,37 +196,34 @@ func (nopFlusher) Flush() {}
// ---------------------------------------------------------------------------
// usage 提取
// usageShape 兼容 chat (prompt/completion) 与 responses (input/output) 两种命名。
// usageShape 兼容 chat (prompt/completion)、responses (input/output)、messages (input/output) 命名。
type usageShape struct {
PromptTokens int64 `json:"prompt_tokens"`
CompletionTokens int64 `json:"completion_tokens"`
InputTokens int64 `json:"input_tokens"`
OutputTokens int64 `json:"output_tokens"`
TotalTokens int64 `json:"total_tokens"`
// Claude 缓存口径(M3 接入)
CacheReadInputTokens int64 `json:"cache_read_input_tokens"`
PromptTokens int64 `json:"prompt_tokens"`
CompletionTokens int64 `json:"completion_tokens"`
InputTokens int64 `json:"input_tokens"`
OutputTokens int64 `json:"output_tokens"`
TotalTokens int64 `json:"total_tokens"`
CacheReadInputTokens int64 `json:"cache_read_input_tokens"`
CacheCreationInputTokens int64 `json:"cache_creation_input_tokens"`
}
// extractUsage 从完整响应体提取 usage 子对象。
// extractUsage 从完整响应体提取 usage 子对象(chat / responses / messages)。
func extractUsage(data []byte) json.RawMessage {
var m map[string]json.RawMessage
if json.Unmarshal(data, &m) != nil {
return nil
}
if u, ok := m["usage"]; ok && string(u) != "null" {
if u := usageFromMap(m); u != nil {
return u
}
// responses 事件/响应:usage 嵌套在 response 对象内
if respRaw, ok := m["response"]; ok {
var resp map[string]json.RawMessage
if json.Unmarshal(respRaw, &resp) == nil {
if u, ok := resp["usage"]; ok && string(u) != "null" {
if u := usageFromMap(resp); u != nil {
return u
}
}
}
// chat 兜底:choices[].message.usage
if choices, ok := m["choices"]; ok {
var cs []map[string]json.RawMessage
if json.Unmarshal(choices, &cs) == nil {
@@ -224,7 +231,7 @@ func extractUsage(data []byte) json.RawMessage {
if msgRaw, ok := ch["message"]; ok {
var msg map[string]json.RawMessage
if json.Unmarshal(msgRaw, &msg) == nil {
if u, ok := msg["usage"]; ok && string(u) != "null" {
if u := usageFromMap(msg); u != nil {
return u
}
}
@@ -235,7 +242,7 @@ func extractUsage(data []byte) json.RawMessage {
return nil
}
// scanUsage 从 SSE 一行中提取 usage(OpenAI 末块 / responses completed 事件)。
// scanUsage 从 SSE 一行中提取 usage(OpenAI 末块 / responses completed / messages message_delta 等)。
func scanUsage(line []byte) json.RawMessage {
s := string(line)
if !strings.Contains(s, `"usage"`) {
@@ -252,14 +259,29 @@ func scanUsage(line []byte) json.RawMessage {
if json.Unmarshal([]byte(s), &m) != nil {
return nil
}
if u, ok := m["usage"]; ok && string(u) != "null" {
if u := usageFromMap(m); u != nil {
return u
}
// responses 流式:usage 在 response 对象内(response.completed 事件)
if respRaw, ok := m["response"]; ok {
var resp map[string]json.RawMessage
if json.Unmarshal(respRaw, &resp) == nil {
if u, ok := resp["usage"]; ok && string(u) != "null" {
if u := usageFromMap(resp); u != nil {
return u
}
}
}
return nil
}
// usageFromMap 从 map 顶层或 message 子对象中取 usage。
func usageFromMap(m map[string]json.RawMessage) json.RawMessage {
if u, ok := m["usage"]; ok && string(u) != "null" {
return u
}
if msgRaw, ok := m["message"]; ok {
var msg map[string]json.RawMessage
if json.Unmarshal(msgRaw, &msg) == nil {
if u, ok := msg["usage"]; ok && string(u) != "null" {
return u
}
}
@@ -268,7 +290,6 @@ func scanUsage(line []byte) json.RawMessage {
}
// sseScanner 按 SSE 行边界读取(兼容 \n 与 \r\n),保留原始行内容。
// 基于 bufio.Reader:行内可含任意内容,跨 chunk 自动拼接。
type sseScanner struct {
r *bufio.Reader
}
@@ -289,17 +310,43 @@ func (s *sseScanner) Next() ([]byte, error) {
// ---------------------------------------------------------------------------
// 记账
// usageSink 累积流式多次 usage(取最后一次,即最终值)。
// usageSink 累积多次 usage:合并各事件字段(message_start 给 input,message_delta 给 output)。
type usageSink struct {
last json.RawMessage
us usageShape
}
func (u *usageSink) push(raw json.RawMessage) {
if len(raw) > 0 {
u.last = raw
if len(raw) == 0 {
return
}
var t usageShape
if json.Unmarshal(raw, &t) != nil {
return
}
// 零值不覆盖:不同事件携带不同字段
if t.PromptTokens > 0 {
u.us.PromptTokens = t.PromptTokens
}
if t.CompletionTokens > 0 {
u.us.CompletionTokens = t.CompletionTokens
}
if t.InputTokens > 0 {
u.us.InputTokens = t.InputTokens
}
if t.OutputTokens > 0 {
u.us.OutputTokens = t.OutputTokens
}
if t.CacheReadInputTokens > 0 {
u.us.CacheReadInputTokens = t.CacheReadInputTokens
}
if t.CacheCreationInputTokens > 0 {
u.us.CacheCreationInputTokens = t.CacheCreationInputTokens
}
}
// Shape 返回合并后的用量。
func (u *usageSink) Shape() usageShape { return u.us }
// finishUsage 落账:计算成本并异步写入。
func (g *Gateway) finishUsage(c *gin.Context, ch *store.Channel, start time.Time, status, errCode string) {
uid, _ := c.Get(CtxUserID)
@@ -308,8 +355,8 @@ func (g *Gateway) finishUsage(c *gin.Context, ch *store.Channel, start time.Time
var us usageShape
if h, ok := c.Get("usage_raw"); ok {
if holder, ok := h.(*sinkHolder); ok && holder.sink != nil && len(holder.sink.last) > 0 {
_ = json.Unmarshal(holder.sink.last, &us)
if holder, ok := h.(*sinkHolder); ok && holder.sink != nil {
us = holder.sink.Shape()
}
}
@@ -340,43 +387,57 @@ func (g *Gateway) finishUsage(c *gin.Context, ch *store.Channel, start time.Time
p = "chat"
}
traceStr, _ := trace.(string)
errMsg := errCode
latency := int(time.Since(start).Milliseconds())
// 已写响应头但流中途出错:记 error
if status == store.UsageStatusSuccess && c.Writer.Status() >= 400 {
status = store.UsageStatusError
}
var errCodePtr *string
if errCode != "" {
errCodePtr = &errCode
}
var uidVal, kidVal uint64
if u, ok := uid.(uint64); ok {
uidVal = u
}
if k, ok := kid.(uint64); ok {
kidVal = k
}
var chID uint64
if ch != nil {
chID = ch.ID
}
g.rec.Record(&store.UsageLog{
RequestID: fmt.Sprintf("trace-%s", traceStr),
TraceID: traceStr,
UserID: uid.(uint64),
KeyID: kid.(uint64),
ChannelID: ch.ID,
ModelID: modelID,
ModelName: mn,
Protocol: p,
InputTokens: in,
OutputTokens: out,
CacheReadTokens: cacheRead,
RequestID: fmt.Sprintf("trace-%s", traceStr),
TraceID: traceStr,
UserID: uidVal,
KeyID: kidVal,
ChannelID: chID,
ModelID: modelID,
ModelName: mn,
Protocol: p,
InputTokens: in,
OutputTokens: out,
CacheReadTokens: cacheRead,
CacheCreationTokens: cacheCreate,
InputPrice: model.InputPrice,
OutputPrice: model.OutputPrice,
CacheReadPrice: model.CacheReadPrice,
Cost: cost,
LatencyMS: latency,
Status: status,
ErrorCode: &errMsg,
CreatedAt: time.Now().UTC(),
InputPrice: model.InputPrice,
OutputPrice: model.OutputPrice,
CacheReadPrice: model.CacheReadPrice,
Cost: cost,
LatencyMS: latency,
Status: status,
ErrorCode: errCodePtr,
CreatedAt: time.Now().UTC(),
})
}
// recordError 失败请求的记账(不产生扣费,status=error)。
func (g *Gateway) recordError(c *gin.Context, ch *store.Channel, resp *http.Response, start time.Time, code string) {
status := store.UsageStatusError
_ = resp
g.finishUsage(c, ch, start, status, code)
g.finishUsage(c, ch, start, store.UsageStatusError, code)
}
func now() time.Time { return time.Now() }
+84 -66
View File
@@ -3,105 +3,123 @@ package proxy
import (
"encoding/json"
"io"
"strings"
"testing"
)
func TestScanUsageChat(t *testing.T) {
line := []byte(`data: {"id":"x","choices":[],"usage":{"prompt_tokens":12,"completion_tokens":9,"total_tokens":21}}`)
raw := scanUsage(line)
func TestSSEScannerSplitsLines(t *testing.T) {
input := "event: message\ndata: {\"a\":1}\n\n" +
"data: {\"b\":2}\r\n\r\n" +
"data: [DONE]\n\n"
s := newSSEScanner(strings.NewReader(input))
var lines []string
for {
line, err := s.Next()
if line != nil {
lines = append(lines, string(line))
}
if err == io.EOF {
break
}
if err != nil {
t.Fatalf("Next: %v", err)
}
}
want := []string{
"event: message\n",
"data: {\"a\":1}\n",
"\n",
"data: {\"b\":2}\r\n",
"\r\n",
"data: [DONE]\n",
"\n",
}
if len(lines) != len(want) {
t.Fatalf("line count = %d, want %d (lines: %q)", len(lines), len(want), lines)
}
for i := range want {
if lines[i] != want[i] {
t.Fatalf("line[%d] = %q, want %q", i, lines[i], want[i])
}
}
}
func TestScanUsageChatStream(t *testing.T) {
chunk := `data: {"id":"x","choices":[],"usage":{"prompt_tokens":12,"completion_tokens":9,"total_tokens":21}}`
raw := scanUsage([]byte(chunk + "\n"))
if raw == nil {
t.Fatal("chat usage not detected")
t.Fatal("expected usage extracted")
}
var us usageShape
if err := json.Unmarshal(raw, &us); err != nil {
t.Fatal(err)
t.Fatalf("unmarshal: %v", err)
}
if us.PromptTokens != 12 || us.CompletionTokens != 9 {
t.Fatalf("usage mismatch: %+v", us)
}
}
func TestScanUsageResponsesNested(t *testing.T) {
line := []byte(`data: {"response":{"id":"r","status":"completed","usage":{"input_tokens":15,"output_tokens":11,"total_tokens":26}},"type":"response.completed"}`)
raw := scanUsage(line)
func TestScanUsageResponsesCompleted(t *testing.T) {
line := `data: {"type":"response.completed","response":{"id":"r1","status":"completed","usage":{"input_tokens":15,"output_tokens":11}}}`
raw := scanUsage([]byte(line + "\n"))
if raw == nil {
t.Fatal("responses nested usage not detected")
t.Fatal("expected usage extracted from response.completed")
}
var us usageShape
if err := json.Unmarshal(raw, &us); err != nil {
t.Fatal(err)
}
_ = json.Unmarshal(raw, &us)
if us.InputTokens != 15 || us.OutputTokens != 11 {
t.Fatalf("usage mismatch: %+v", us)
}
}
func TestScanUsageIgnoresNonData(t *testing.T) {
if scanUsage([]byte("event: response.completed")) != nil {
t.Fatal("event line should be ignored")
func TestScanUsageIgnoresNonUsage(t *testing.T) {
if raw := scanUsage([]byte(`data: {"type":"response.output_text.delta","delta":"hi"}`)); raw != nil {
t.Fatalf("expected nil for non-usage line, got %s", raw)
}
if scanUsage([]byte("data: [DONE]")) != nil {
t.Fatal("[DONE] should be ignored")
}
if scanUsage([]byte("data: {\"choices\":[{\"delta\":{\"content\":\"hi\"}}]}")) != nil {
t.Fatal("content chunk without usage should be ignored")
if raw := scanUsage([]byte(`data: [DONE]`)); raw != nil {
t.Fatal("expected nil for [DONE]")
}
}
func TestExtractUsageFromFullBody(t *testing.T) {
body := []byte(`{"id":"x","choices":[{"message":{"content":"hi"}}],"usage":{"prompt_tokens":1,"completion_tokens":2}}`)
raw := extractUsage(body)
func TestExtractUsageChatBody(t *testing.T) {
body := `{"id":"x","choices":[{"message":{"role":"assistant","content":"hi"}}],"usage":{"prompt_tokens":1,"completion_tokens":2,"total_tokens":3}}`
raw := extractUsage([]byte(body))
if raw == nil {
t.Fatal("usage not extracted from full body")
t.Fatal("expected usage")
}
if !strings.Contains(string(raw), `"prompt_tokens":1`) {
t.Fatalf("unexpected usage: %s", raw)
}
}
func TestExtractUsageResponsesNested(t *testing.T) {
// responses 顶层只有 response 对象,usage 嵌套其中
body := `{"id":"r1","object":"response","status":"completed","response":{"usage":{"input_tokens":7,"output_tokens":8}}}`
raw := extractUsage([]byte(body))
if raw == nil {
t.Fatal("expected nested usage")
}
var us usageShape
_ = json.Unmarshal(raw, &us)
if us.PromptTokens != 1 || us.CompletionTokens != 2 {
if us.InputTokens != 7 || us.OutputTokens != 8 {
t.Fatalf("usage mismatch: %+v", us)
}
}
func TestSSEScannerLines(t *testing.T) {
// 模拟分块写入的 SSE 流
data := "data: {\"a\":1}\n\ndata: {\"usage\":{\"input_tokens\":3}}\n\n"
parts := [][]byte{[]byte(data[:10]), []byte(data[10:20]), []byte(data[20:])}
reader := newChunkReader(parts)
s := newSSEScanner(reader)
var lines [][]byte
for {
line, err := s.Next()
if line != nil {
lines = append(lines, line)
}
if err != nil {
break
}
func TestUsageSinkMergesFields(t *testing.T) {
// message_start 给 input,message_delta 给 output,合并后两者都在
s := &usageSink{}
s.push(json.RawMessage(`{"input_tokens":14,"output_tokens":0}`))
s.push(json.RawMessage(`{"output_tokens":10}`))
got := s.Shape()
if got.InputTokens != 14 || got.OutputTokens != 10 {
t.Fatalf("merge mismatch: %+v", got)
}
if len(lines) != 4 {
t.Fatalf("expected 4 lines, got %d", len(lines))
}
// 合并后应能还原原始数据
joined := ""
for _, l := range lines {
joined += string(l)
}
if joined != string(data) {
t.Fatalf("stream corrupted:\n got: %q\nwant: %q", joined, data)
// chat 末块同时携带两字段
s2 := &usageSink{}
s2.push(json.RawMessage(`{"prompt_tokens":12,"completion_tokens":9}`))
g := s2.Shape()
if g.PromptTokens != 12 || g.CompletionTokens != 9 {
t.Fatalf("chat usage mismatch: %+v", g)
}
}
type chunkReader struct {
parts [][]byte
idx int
}
func newChunkReader(parts [][]byte) *chunkReader { return &chunkReader{parts: parts} }
func (r *chunkReader) Read(p []byte) (int, error) {
if r.idx >= len(r.parts) {
return 0, io.EOF
}
n := copy(p, r.parts[r.idx])
r.idx++
return n, nil
}