修复流式请求记账为 0 消耗:支持 SSE 多行块 usage 提取 + 无上游 usage 时 tiktoken 估算

- sseContentText/scanUsage 兼容 event:+data: 多行块(转换器 eventLine 产出)
- usageFromMap 新增 delta.usage 支持(Anthropic message_delta 标准格式)
- finishUsage: 流式成功但上游不返回 usage(如火山方舟)时按内容 tiktoken 估算,in/out 独立估算
- .gitignore 忽略 server/web 本地静态托管软链
This commit is contained in:
Sakurasan
2026-08-19 20:59:04 +08:00
parent 30ab9e842c
commit 0939f98fb5
2 changed files with 31 additions and 7 deletions
+1
View File
@@ -7,6 +7,7 @@ dist/
# Go
server/bin/
server/data/
server/web/ # 本地静态托管软链(指向 ../web/dist),不入库
scripts/mockupstream/bin/
# TypeScript 增量构建产物
+30 -7
View File
@@ -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))
}
}