From 0939f98fb5a4dc10dae5bf86d12496106eb7fcb4 Mon Sep 17 00:00:00 2001 From: Sakurasan <26715255+Sakurasan@users.noreply.github.com> Date: Wed, 19 Aug 2026 20:59:04 +0800 Subject: [PATCH] =?UTF-8?q?=E4=BF=AE=E5=A4=8D=E6=B5=81=E5=BC=8F=E8=AF=B7?= =?UTF-8?q?=E6=B1=82=E8=AE=B0=E8=B4=A6=E4=B8=BA=200=20=E6=B6=88=E8=80=97?= =?UTF-8?q?=EF=BC=9A=E6=94=AF=E6=8C=81=20SSE=20=E5=A4=9A=E8=A1=8C=E5=9D=97?= =?UTF-8?q?=20usage=20=E6=8F=90=E5=8F=96=20+=20=E6=97=A0=E4=B8=8A=E6=B8=B8?= =?UTF-8?q?=20usage=20=E6=97=B6=20tiktoken=20=E4=BC=B0=E7=AE=97?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - sseContentText/scanUsage 兼容 event:+data: 多行块(转换器 eventLine 产出) - usageFromMap 新增 delta.usage 支持(Anthropic message_delta 标准格式) - finishUsage: 流式成功但上游不返回 usage(如火山方舟)时按内容 tiktoken 估算,in/out 独立估算 - .gitignore 忽略 server/web 本地静态托管软链 --- .gitignore | 1 + server/internal/proxy/passthrough.go | 37 ++++++++++++++++++++++------ 2 files changed, 31 insertions(+), 7 deletions(-) diff --git a/.gitignore b/.gitignore index bde969b..a581ee1 100644 --- a/.gitignore +++ b/.gitignore @@ -7,6 +7,7 @@ dist/ # Go server/bin/ server/data/ +server/web/ # 本地静态托管软链(指向 ../web/dist),不入库 scripts/mockupstream/bin/ # TypeScript 增量构建产物 diff --git a/server/internal/proxy/passthrough.go b/server/internal/proxy/passthrough.go index c6af94d..38fdd87 100644 --- a/server/internal/proxy/passthrough.go +++ b/server/internal/proxy/passthrough.go @@ -101,10 +101,14 @@ func requestText(body []byte) string { return strings.Join(parts, "\n") } -// sseContentText 提取一条 SSE data 行中的内容文本(chat delta.content / responses delta / messages delta.text)。 +// sseContentText 提取一条 SSE 中的内容文本(chat delta.content / responses delta / messages delta.text)。 +// 兼容单 data: 行与 event:+data: 多行块(转换器 eventLine 产出的块)。 func sseContentText(line []byte) string { s := string(line) - if strings.HasPrefix(s, "data: ") { + // 多行块:取最后一个 data: 行(event: 头 + data: 载荷拼在一个 []byte 里) + if idx := strings.LastIndex(s, "\ndata: "); idx >= 0 { + s = s[idx+len("\ndata: "):] + } else if strings.HasPrefix(s, "data: ") { s = strings.TrimPrefix(s, "data: ") } s = strings.TrimSpace(s) @@ -375,7 +379,10 @@ func scanUsage(line []byte) json.RawMessage { if !strings.Contains(s, `"usage"`) { return nil } - if strings.HasPrefix(s, "data: ") { + // 兼容单 data: 行与 event:+data: 多行块(转换器 eventLine 产出的块) + if idx := strings.LastIndex(s, "\ndata: "); idx >= 0 { + s = s[idx+len("\ndata: "):] + } else if strings.HasPrefix(s, "data: ") { s = strings.TrimPrefix(s, "data: ") } s = strings.TrimSpace(s) @@ -400,7 +407,11 @@ func scanUsage(line []byte) json.RawMessage { return nil } -// usageFromMap 从 map 顶层或 message 子对象中取 usage。 +// usageFromMap 从 map 顶层、message 或 delta 子对象中取 usage。 +// 覆盖三种形态: +// - OpenAI 流式末块顶层 usage +// - Anthropic 非流式 / message_start 的 message.usage +// - Anthropic 流式 message_delta 的 delta.usage(真实 token 计数所在) func usageFromMap(m map[string]json.RawMessage) json.RawMessage { if u, ok := m["usage"]; ok && string(u) != "null" { return u @@ -413,6 +424,14 @@ func usageFromMap(m map[string]json.RawMessage) json.RawMessage { } } } + if deltaRaw, ok := m["delta"]; ok { + var delta map[string]json.RawMessage + if json.Unmarshal(deltaRaw, &delta) == nil { + if u, ok := delta["usage"]; ok && string(u) != "null" { + return u + } + } + } return nil } @@ -499,11 +518,15 @@ func (g *Gateway) finishUsage(c *gin.Context, ch *store.Channel, start time.Time modelName, _ := c.Get("model_name") mn, _ := modelName.(string) - // 流式中断(canceled):上游最终 usage 可能未返回,按已生成内容用 tiktoken 估算 - if status == store.UsageStatusCanceled { + // 上游未返回真实 usage 时估算(tiktoken): + // - 流式中断(canceled):上游最终 usage 可能未返回 + // - 流式正常结束(success):部分上游(如火山方舟 Anthropic 流式)message_start.usage 恒为 0、 + // message_delta 不带 usage,只能按已收发内容估算,否则记账为 0 消耗 + // 非流式上游必返回 usage,此处 in/out 非 0 不受影响。 + if status == store.UsageStatusCanceled || status == store.UsageStatusSuccess { if in == 0 { if est, ok := c.Get("est_input_text"); ok { - if v, ok2 := est.(string); ok2 { + if v, ok2 := est.(string); ok2 && v != "" { in = int64(tokenizer.Count(v, mn)) } }