后端 - 新增 proxy/convert 三协议(chat/messages/responses)请求、响应与 SSE 流式互转, 以 Chat 为中间模型;usage.go 统一提取三协议 token 用量(含单测) - gateway: 跨协议调度(渠道未声明客户端协议时转为渠道首选格式), streamResponse 按 \n\n 分块逐行转换直通,bufferResponse 转换失败时剥非 JSON 前缀 - gateway: 新增 SetUsageRecorder 注入异步用量记录器 - auth_llm: 修复 key_prefix 查询长度错配([:8] vs 存储的 [:12])导致全部 401; 修复长度 8-11 的 key 切片越界 panic;统一 unauthorized 响应 - usage: 日报表改为增量累加 upsert,避免多次 flush 互相清零;记录协议/错误码/时延等字段 - channel: 新增渠道并发槽 TryAcquire;健康检查支持可配置参数 - api: 新增 admin 渠道/模型/系统配置管理端点(旧端点保留兼容) 前端 - 新增渠道管理、模型管理、系统配置视图与 ChannelModelsDrawer - 新增 ui 基础组件(Button/Badge/Input/Modal)与 protocol.ts - 调整 Toast 样式、密钥页、路由菜单;dev 代理默认指向 3000 端口
171 lines
5.3 KiB
Go
171 lines
5.3 KiB
Go
package convert
|
||
|
||
import (
|
||
"encoding/json"
|
||
"testing"
|
||
)
|
||
|
||
// ---- ExtractUsageJSON: 非流式各协议 ----
|
||
|
||
func TestExtractUsageJSONChat(t *testing.T) {
|
||
body := []byte(`{
|
||
"id": "chatcmpl-1",
|
||
"object": "chat.completion",
|
||
"choices": [{"index": 0, "message": {"role": "assistant", "content": "hi"}}],
|
||
"usage": {
|
||
"prompt_tokens": 11,
|
||
"completion_tokens": 7,
|
||
"total_tokens": 18,
|
||
"prompt_tokens_details": {"cached_tokens": 4}
|
||
}
|
||
}`)
|
||
u, ok := ExtractUsageJSON(body, ProtoChat)
|
||
if !ok {
|
||
t.Fatalf("expected ok=true")
|
||
}
|
||
if u.InputTokens != 11 || u.OutputTokens != 7 {
|
||
t.Fatalf("chat usage = %+v, want input=11 output=7", u)
|
||
}
|
||
if u.CacheReadTokens != 4 {
|
||
t.Fatalf("chat cacheRead = %d, want 4", u.CacheReadTokens)
|
||
}
|
||
}
|
||
|
||
func TestExtractUsageJSONMessages(t *testing.T) {
|
||
body := []byte(`{
|
||
"id": "msg_1",
|
||
"type": "message",
|
||
"role": "assistant",
|
||
"content": [{"type": "text", "text": "hi"}],
|
||
"usage": {
|
||
"input_tokens": 15,
|
||
"output_tokens": 8,
|
||
"cache_read_input_tokens": 3,
|
||
"cache_creation_input_tokens": 2
|
||
}
|
||
}`)
|
||
u, ok := ExtractUsageJSON(body, ProtoMessages)
|
||
if !ok {
|
||
t.Fatalf("expected ok=true")
|
||
}
|
||
if u.InputTokens != 15 || u.OutputTokens != 8 || u.CacheReadTokens != 3 || u.CacheCreationTokens != 2 {
|
||
t.Fatalf("messages usage = %+v", u)
|
||
}
|
||
}
|
||
|
||
func TestExtractUsageJSONResponses(t *testing.T) {
|
||
body := []byte(`{
|
||
"id": "resp_1",
|
||
"object": "response",
|
||
"output": [],
|
||
"usage": {
|
||
"input_tokens": 13,
|
||
"output_tokens": 9,
|
||
"input_tokens_details": {"cached_tokens": 5}
|
||
}
|
||
}`)
|
||
u, ok := ExtractUsageJSON(body, ProtoResponses)
|
||
if !ok {
|
||
t.Fatalf("expected ok=true")
|
||
}
|
||
if u.InputTokens != 13 || u.OutputTokens != 9 || u.CacheReadTokens != 5 {
|
||
t.Fatalf("responses usage = %+v", u)
|
||
}
|
||
}
|
||
|
||
func TestExtractUsageJSONInvalidAndMissing(t *testing.T) {
|
||
if _, ok := ExtractUsageJSON([]byte("not json"), ProtoChat); ok {
|
||
t.Fatalf("invalid json should not report ok")
|
||
}
|
||
if _, ok := ExtractUsageJSON([]byte(`{"id": "x"}`), ProtoChat); ok {
|
||
t.Fatalf("missing usage should not report ok")
|
||
}
|
||
// 空对象 usage:全 0 视为无效
|
||
if _, ok := ExtractUsageJSON([]byte(`{"usage": {}}`), ProtoChat); ok {
|
||
t.Fatalf("empty usage should not report ok")
|
||
}
|
||
}
|
||
|
||
// ---- StreamUsageAccum: 流式各协议 ----
|
||
|
||
func feedLines(t *testing.T, proto string, lines ...string) TokenUsage {
|
||
t.Helper()
|
||
acc := NewStreamUsageAccum()
|
||
for _, ln := range lines {
|
||
acc.Feed([]byte(ln), proto)
|
||
}
|
||
return acc.Usage()
|
||
}
|
||
|
||
func TestStreamUsageChatFinalChunk(t *testing.T) {
|
||
// 前面的 chunk 不带 usage;最后一个 chunk 带完整 usage
|
||
u := feedLines(t, ProtoChat,
|
||
`{"id":"c1","object":"chat.completion.chunk","choices":[{"delta":{"content":"he"}}]}`,
|
||
`{"id":"c1","object":"chat.completion.chunk","choices":[{"delta":{"content":"llo"}}]}`,
|
||
`{"id":"c1","object":"chat.completion.chunk","choices":[],"usage":{"prompt_tokens":11,"completion_tokens":7,"prompt_tokens_details":{"cached_tokens":4}}}`,
|
||
)
|
||
if u.InputTokens != 11 || u.OutputTokens != 7 || u.CacheReadTokens != 4 {
|
||
t.Fatalf("chat stream usage = %+v", u)
|
||
}
|
||
}
|
||
|
||
func TestStreamUsageMessagesStartAndDelta(t *testing.T) {
|
||
// message_start 带 input/cache,message_delta 带 output;逐字段取 max 合并
|
||
u := feedLines(t, ProtoMessages,
|
||
`{"type":"message_start","message":{"id":"msg_1","usage":{"input_tokens":15,"cache_read_input_tokens":3,"cache_creation_input_tokens":2}}}`,
|
||
`{"type":"content_block_delta","delta":{"type":"text_delta","text":"hi"}}`,
|
||
`{"type":"message_delta","delta":{"stop_reason":"end_turn"},"usage":{"output_tokens":8}}`,
|
||
)
|
||
if u.InputTokens != 15 || u.OutputTokens != 8 || u.CacheReadTokens != 3 || u.CacheCreationTokens != 2 {
|
||
t.Fatalf("messages stream usage = %+v", u)
|
||
}
|
||
}
|
||
|
||
func TestStreamUsageResponsesCompleted(t *testing.T) {
|
||
// response.completed 事件的用量嵌在 response.usage 下
|
||
u := feedLines(t, ProtoResponses,
|
||
`{"type":"response.output_text.delta","delta":"hi"}`,
|
||
`{"type":"response.completed","response":{"id":"resp_1","usage":{"input_tokens":13,"output_tokens":9,"input_tokens_details":{"cached_tokens":5}}}}`,
|
||
)
|
||
if u.InputTokens != 13 || u.OutputTokens != 9 || u.CacheReadTokens != 5 {
|
||
t.Fatalf("responses stream usage = %+v", u)
|
||
}
|
||
}
|
||
|
||
func TestStreamUsageIgnoresNonDataPayloads(t *testing.T) {
|
||
// [DONE]、垃圾行、空对象都不应产生用量
|
||
u := feedLines(t, ProtoChat, `[DONE]`, `{`, ``, `{"choices":[]}`)
|
||
if u.has() {
|
||
t.Fatalf("expected zero usage, got %+v", u)
|
||
}
|
||
}
|
||
|
||
func TestStreamUsageFeedKeepsMaxAcrossEvents(t *testing.T) {
|
||
// 同一字段在多个事件出现时取较大值(防乱序/重复)
|
||
u := feedLines(t, ProtoMessages,
|
||
`{"type":"message_start","message":{"usage":{"input_tokens":15}}}`,
|
||
`{"type":"message_delta","usage":{"output_tokens":5}}`,
|
||
`{"type":"message_delta","usage":{"output_tokens":8}}`,
|
||
)
|
||
if u.InputTokens != 15 || u.OutputTokens != 8 {
|
||
t.Fatalf("max-merge usage = %+v", u)
|
||
}
|
||
}
|
||
|
||
// ---- usage JSON 结构合法性(防止手写 struct 漂移)----
|
||
|
||
func TestUsageJSONRoundTrip(t *testing.T) {
|
||
u := TokenUsage{InputTokens: 10, OutputTokens: 5, CacheReadTokens: 2, CacheCreationTokens: 1}
|
||
b, err := json.Marshal(u)
|
||
if err != nil {
|
||
t.Fatalf("marshal: %v", err)
|
||
}
|
||
var back TokenUsage
|
||
if err := json.Unmarshal(b, &back); err != nil {
|
||
t.Fatalf("unmarshal: %v", err)
|
||
}
|
||
if back != u {
|
||
t.Fatalf("round trip = %+v, want %+v", back, u)
|
||
}
|
||
}
|