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

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

Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
Sakurasan
2026-08-15 15:34:06 +08:00
co-authored by Claude
parent b25e9ec8a7
commit ec4de8d913
92 changed files with 6203 additions and 3064 deletions
+84 -66
View File
@@ -3,105 +3,123 @@ package proxy
import (
"encoding/json"
"io"
"strings"
"testing"
)
func TestScanUsageChat(t *testing.T) {
line := []byte(`data: {"id":"x","choices":[],"usage":{"prompt_tokens":12,"completion_tokens":9,"total_tokens":21}}`)
raw := scanUsage(line)
func TestSSEScannerSplitsLines(t *testing.T) {
input := "event: message\ndata: {\"a\":1}\n\n" +
"data: {\"b\":2}\r\n\r\n" +
"data: [DONE]\n\n"
s := newSSEScanner(strings.NewReader(input))
var lines []string
for {
line, err := s.Next()
if line != nil {
lines = append(lines, string(line))
}
if err == io.EOF {
break
}
if err != nil {
t.Fatalf("Next: %v", err)
}
}
want := []string{
"event: message\n",
"data: {\"a\":1}\n",
"\n",
"data: {\"b\":2}\r\n",
"\r\n",
"data: [DONE]\n",
"\n",
}
if len(lines) != len(want) {
t.Fatalf("line count = %d, want %d (lines: %q)", len(lines), len(want), lines)
}
for i := range want {
if lines[i] != want[i] {
t.Fatalf("line[%d] = %q, want %q", i, lines[i], want[i])
}
}
}
func TestScanUsageChatStream(t *testing.T) {
chunk := `data: {"id":"x","choices":[],"usage":{"prompt_tokens":12,"completion_tokens":9,"total_tokens":21}}`
raw := scanUsage([]byte(chunk + "\n"))
if raw == nil {
t.Fatal("chat usage not detected")
t.Fatal("expected usage extracted")
}
var us usageShape
if err := json.Unmarshal(raw, &us); err != nil {
t.Fatal(err)
t.Fatalf("unmarshal: %v", err)
}
if us.PromptTokens != 12 || us.CompletionTokens != 9 {
t.Fatalf("usage mismatch: %+v", us)
}
}
func TestScanUsageResponsesNested(t *testing.T) {
line := []byte(`data: {"response":{"id":"r","status":"completed","usage":{"input_tokens":15,"output_tokens":11,"total_tokens":26}},"type":"response.completed"}`)
raw := scanUsage(line)
func TestScanUsageResponsesCompleted(t *testing.T) {
line := `data: {"type":"response.completed","response":{"id":"r1","status":"completed","usage":{"input_tokens":15,"output_tokens":11}}}`
raw := scanUsage([]byte(line + "\n"))
if raw == nil {
t.Fatal("responses nested usage not detected")
t.Fatal("expected usage extracted from response.completed")
}
var us usageShape
if err := json.Unmarshal(raw, &us); err != nil {
t.Fatal(err)
}
_ = json.Unmarshal(raw, &us)
if us.InputTokens != 15 || us.OutputTokens != 11 {
t.Fatalf("usage mismatch: %+v", us)
}
}
func TestScanUsageIgnoresNonData(t *testing.T) {
if scanUsage([]byte("event: response.completed")) != nil {
t.Fatal("event line should be ignored")
func TestScanUsageIgnoresNonUsage(t *testing.T) {
if raw := scanUsage([]byte(`data: {"type":"response.output_text.delta","delta":"hi"}`)); raw != nil {
t.Fatalf("expected nil for non-usage line, got %s", raw)
}
if scanUsage([]byte("data: [DONE]")) != nil {
t.Fatal("[DONE] should be ignored")
}
if scanUsage([]byte("data: {\"choices\":[{\"delta\":{\"content\":\"hi\"}}]}")) != nil {
t.Fatal("content chunk without usage should be ignored")
if raw := scanUsage([]byte(`data: [DONE]`)); raw != nil {
t.Fatal("expected nil for [DONE]")
}
}
func TestExtractUsageFromFullBody(t *testing.T) {
body := []byte(`{"id":"x","choices":[{"message":{"content":"hi"}}],"usage":{"prompt_tokens":1,"completion_tokens":2}}`)
raw := extractUsage(body)
func TestExtractUsageChatBody(t *testing.T) {
body := `{"id":"x","choices":[{"message":{"role":"assistant","content":"hi"}}],"usage":{"prompt_tokens":1,"completion_tokens":2,"total_tokens":3}}`
raw := extractUsage([]byte(body))
if raw == nil {
t.Fatal("usage not extracted from full body")
t.Fatal("expected usage")
}
if !strings.Contains(string(raw), `"prompt_tokens":1`) {
t.Fatalf("unexpected usage: %s", raw)
}
}
func TestExtractUsageResponsesNested(t *testing.T) {
// responses 顶层只有 response 对象,usage 嵌套其中
body := `{"id":"r1","object":"response","status":"completed","response":{"usage":{"input_tokens":7,"output_tokens":8}}}`
raw := extractUsage([]byte(body))
if raw == nil {
t.Fatal("expected nested usage")
}
var us usageShape
_ = json.Unmarshal(raw, &us)
if us.PromptTokens != 1 || us.CompletionTokens != 2 {
if us.InputTokens != 7 || us.OutputTokens != 8 {
t.Fatalf("usage mismatch: %+v", us)
}
}
func TestSSEScannerLines(t *testing.T) {
// 模拟分块写入的 SSE 流
data := "data: {\"a\":1}\n\ndata: {\"usage\":{\"input_tokens\":3}}\n\n"
parts := [][]byte{[]byte(data[:10]), []byte(data[10:20]), []byte(data[20:])}
reader := newChunkReader(parts)
s := newSSEScanner(reader)
var lines [][]byte
for {
line, err := s.Next()
if line != nil {
lines = append(lines, line)
}
if err != nil {
break
}
func TestUsageSinkMergesFields(t *testing.T) {
// message_start 给 input,message_delta 给 output,合并后两者都在
s := &usageSink{}
s.push(json.RawMessage(`{"input_tokens":14,"output_tokens":0}`))
s.push(json.RawMessage(`{"output_tokens":10}`))
got := s.Shape()
if got.InputTokens != 14 || got.OutputTokens != 10 {
t.Fatalf("merge mismatch: %+v", got)
}
if len(lines) != 4 {
t.Fatalf("expected 4 lines, got %d", len(lines))
}
// 合并后应能还原原始数据
joined := ""
for _, l := range lines {
joined += string(l)
}
if joined != string(data) {
t.Fatalf("stream corrupted:\n got: %q\nwant: %q", joined, data)
// chat 末块同时携带两字段
s2 := &usageSink{}
s2.push(json.RawMessage(`{"prompt_tokens":12,"completion_tokens":9}`))
g := s2.Shape()
if g.PromptTokens != 12 || g.CompletionTokens != 9 {
t.Fatalf("chat usage mismatch: %+v", g)
}
}
type chunkReader struct {
parts [][]byte
idx int
}
func newChunkReader(parts [][]byte) *chunkReader { return &chunkReader{parts: parts} }
func (r *chunkReader) Read(p []byte) (int, error) {
if r.idx >= len(r.parts) {
return 0, io.EOF
}
n := copy(p, r.parts[r.idx])
r.idx++
return n, nil
}