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)) } }