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,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)
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user