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:
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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, ¶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)
|
||||
}
|
||||
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,
|
||||
},
|
||||
})
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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")
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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() }
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user