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:
@@ -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