diff --git a/internal/gateway/chat.go b/internal/gateway/chat.go index 489fc68..cb018ee 100644 --- a/internal/gateway/chat.go +++ b/internal/gateway/chat.go @@ -42,6 +42,10 @@ type ChatCompletion struct { Model string `json:"model"` Choices []ChatChoice `json:"choices"` Usage *types.TokenUsage `json:"usage,omitempty"` + // Cost is the upstream-reported charge for this request, passed through + // verbatim (OpenCode reports it as a decimal string, always "0" on the + // flat-rate Go subscription). Absent when the upstream reports nothing. + Cost string `json:"cost,omitempty"` } type ChatChoice struct { @@ -66,6 +70,10 @@ type ChatChunk struct { // Usage is sent in the final chunk of a stream (empty choices) so // OpenAI-compatible clients can read token usage. Usage *types.TokenUsage `json:"usage,omitempty"` + // Cost is the upstream-reported charge for this request, passed through + // verbatim (OpenCode reports it as a decimal string). Emitted on the + // terminal chunk, mirroring OpenCode's own {"choices":[],"cost":"0"}. + Cost string `json:"cost,omitempty"` } type ChunkChoice struct { @@ -703,6 +711,7 @@ func writeChatCompletion(w http.ResponseWriter, resp *types.UnifiedResponse, mod Created: time.Now().Unix(), Model: modelName, Choices: []ChatChoice{{Index: 0, Message: msg, FinishReason: resp.FinishReason}}, + Cost: resp.Cost, } if resp.TokenUsage.Total > 0 || resp.TokenUsage.Prompt > 0 || resp.TokenUsage.Completion > 0 { out.Usage = &resp.TokenUsage @@ -822,11 +831,15 @@ func (g *Gateway) pumpStream(w http.ResponseWriter, rec *Req, chunks <-chan type // source's status-page latency average. rec.FirstByteMs = time.Since(t0).Milliseconds() var lastUsage *types.TokenUsage + var lastCost string lastFinish := "" for ck := range chunks { if ck.Usage != nil { lastUsage = mergeUsage(lastUsage, ck.Usage) } + if ck.Cost != "" { + lastCost = ck.Cost + } chunk := ChatChunk{ ID: id, Object: "chat.completion.chunk", Created: created, Model: modelName, } @@ -893,6 +906,14 @@ func (g *Gateway) pumpStream(w http.ResponseWriter, rec *Req, chunks <-chan type ID: id, Object: "chat.completion.chunk", Created: created, Model: modelName, Choices: []ChunkChoice{}, Usage: tut, + Cost: lastCost, + }) + } else if lastCost != "" { + // Cost arrived without any usage (OpenCode sends it on its own frame). + send(ChatChunk{ + ID: id, Object: "chat.completion.chunk", Created: created, Model: modelName, + Choices: []ChunkChoice{}, + Cost: lastCost, }) } fmt.Fprintf(w, "data: [DONE]\n\n") diff --git a/internal/lua/adapters/opencodego.lua b/internal/lua/adapters/opencodego.lua index c2a9038..f1e094c 100644 --- a/internal/lua/adapters/opencodego.lua +++ b/internal/lua/adapters/opencodego.lua @@ -221,6 +221,19 @@ function adapter.transform_response(raw_body) unified.token_usage.prompt_tokens_details = { cached_tokens = resp.usage.prompt_cache_hit_tokens } end end + -- 上游报告输出里有多少是思考 token。不给客户端的话,无法判断 + -- completion_tokens 里多少是「可见回答」、多少是「思考」(都按输出计费)。 + local ctd = resp.usage.completion_tokens_details + if type(ctd) == "table" and ctd.reasoning_tokens ~= nil then + unified.token_usage.completion_tokens_details = { reasoning_tokens = ctd.reasoning_tokens } + end + end + + -- 本次调用的费用,上游放在**顶层**且是**字符串**(如 "0"、"0.0012")。 + -- Go 订阅是包月制,恒为 "0";只有 Zen 按量付费模型才有信息量。 + -- 原样透传:网关不解析、不换算、不汇总 —— 它只是上游事实的搬运者。 + if resp.cost ~= nil then + unified.cost = tostring(resp.cost) end if type(resp.choices) == "table" and #resp.choices > 0 then @@ -267,22 +280,36 @@ function adapter.transform_stream_chunk(raw_chunk) completion = chunk.usage.completion_tokens or chunk.usage.completion or 0, total = chunk.usage.total_tokens or chunk.usage.total or 0, } + -- 独立字段与 prompt_tokens_details **并存**透传,不要写成 elseif: + -- 上游命中缓存时两者都发,非流式路径也是两个都带。写成 elseif 会让 + -- 流式丢掉 prompt_cache_hit_tokens/miss,与同一源的**非流式**产出不一致 + -- (dsh 优先读 details,但只认独立字段的老客户端会看不到缓存)。 if type(chunk.usage.prompt_tokens_details) == "table" and chunk.usage.prompt_tokens_details.cached_tokens ~= nil then uses.prompt_tokens_details = { cached_tokens = chunk.usage.prompt_tokens_details.cached_tokens } - elseif (chunk.usage.prompt_cache_hit_tokens or 0) > 0 then + end + if (chunk.usage.prompt_cache_hit_tokens or 0) > 0 then uses.prompt_cache_hit_tokens = chunk.usage.prompt_cache_hit_tokens uses.prompt_cache_miss_tokens = chunk.usage.prompt_cache_miss_tokens or 0 - uses.prompt_tokens_details = { cached_tokens = chunk.usage.prompt_cache_hit_tokens } + if uses.prompt_tokens_details == nil then + uses.prompt_tokens_details = { cached_tokens = chunk.usage.prompt_cache_hit_tokens } + end + end + local ctd = chunk.usage.completion_tokens_details + if type(ctd) == "table" and ctd.reasoning_tokens ~= nil then + uses.completion_tokens_details = { reasoning_tokens = ctd.reasoning_tokens } end end if not chunk.choices or #chunk.choices == 0 then - if uses ~= nil then - -- usage-only chunk is not a content/finish signal; the gateway - -- emits its own terminal stop chunk and merges this usage. - return json.encode({ usage = uses, done = false }) - end - return "" + -- 空 choices 的帧既不是内容也不是结束信号。上游发两种: + -- {"choices":[],"usage":{...}} —— 终帧用量 + -- {"choices":[],"cost":"0"} —— 单独的费用帧 + -- 两者可能落在同一帧上,必须都转出去;早退只带 usage 会把费用丢干净。 + local out = { done = false } + if uses ~= nil then out.usage = uses end + if chunk.cost ~= nil then out.cost = tostring(chunk.cost) end + if uses == nil and chunk.cost == nil then return "" end + return json.encode(out) end local delta = chunk.choices[1].delta or {} local fr = chunk.choices[1].finish_reason diff --git a/internal/lua/adapters/opencodezen.lua b/internal/lua/adapters/opencodezen.lua index de0a540..43097b7 100644 --- a/internal/lua/adapters/opencodezen.lua +++ b/internal/lua/adapters/opencodezen.lua @@ -209,6 +209,19 @@ function adapter.transform_response(raw_body) unified.token_usage.prompt_tokens_details = { cached_tokens = resp.usage.prompt_cache_hit_tokens } end end + -- 上游报告输出里有多少是思考 token。不给客户端的话,无法判断 + -- completion_tokens 里多少是「可见回答」、多少是「思考」(都按输出计费)。 + local ctd = resp.usage.completion_tokens_details + if type(ctd) == "table" and ctd.reasoning_tokens ~= nil then + unified.token_usage.completion_tokens_details = { reasoning_tokens = ctd.reasoning_tokens } + end + end + + -- 本次调用的费用,上游放在**顶层**且是**字符串**(如 "0"、"0.0012")。 + -- Go 订阅是包月制,恒为 "0";只有 Zen 按量付费模型才有信息量。 + -- 原样透传:网关不解析、不换算、不汇总 —— 它只是上游事实的搬运者。 + if resp.cost ~= nil then + unified.cost = tostring(resp.cost) end if type(resp.choices) == "table" and #resp.choices > 0 then @@ -255,22 +268,36 @@ function adapter.transform_stream_chunk(raw_chunk) completion = chunk.usage.completion_tokens or chunk.usage.completion or 0, total = chunk.usage.total_tokens or chunk.usage.total or 0, } + -- 独立字段与 prompt_tokens_details **并存**透传,不要写成 elseif: + -- 上游命中缓存时两者都发,非流式路径也是两个都带。写成 elseif 会让 + -- 流式丢掉 prompt_cache_hit_tokens/miss,与同一源的**非流式**产出不一致 + -- (dsh 优先读 details,但只认独立字段的老客户端会看不到缓存)。 if type(chunk.usage.prompt_tokens_details) == "table" and chunk.usage.prompt_tokens_details.cached_tokens ~= nil then uses.prompt_tokens_details = { cached_tokens = chunk.usage.prompt_tokens_details.cached_tokens } - elseif (chunk.usage.prompt_cache_hit_tokens or 0) > 0 then + end + if (chunk.usage.prompt_cache_hit_tokens or 0) > 0 then uses.prompt_cache_hit_tokens = chunk.usage.prompt_cache_hit_tokens uses.prompt_cache_miss_tokens = chunk.usage.prompt_cache_miss_tokens or 0 - uses.prompt_tokens_details = { cached_tokens = chunk.usage.prompt_cache_hit_tokens } + if uses.prompt_tokens_details == nil then + uses.prompt_tokens_details = { cached_tokens = chunk.usage.prompt_cache_hit_tokens } + end + end + local ctd = chunk.usage.completion_tokens_details + if type(ctd) == "table" and ctd.reasoning_tokens ~= nil then + uses.completion_tokens_details = { reasoning_tokens = ctd.reasoning_tokens } end end if not chunk.choices or #chunk.choices == 0 then - if uses ~= nil then - -- usage-only chunk is not a content/finish signal; the gateway - -- emits its own terminal stop chunk and merges this usage. - return json.encode({ usage = uses, done = false }) - end - return "" + -- 空 choices 的帧既不是内容也不是结束信号。上游发两种: + -- {"choices":[],"usage":{...}} —— 终帧用量 + -- {"choices":[],"cost":"0"} —— 单独的费用帧 + -- 两者可能落在同一帧上,必须都转出去;早退只带 usage 会把费用丢干净。 + local out = { done = false } + if uses ~= nil then out.usage = uses end + if chunk.cost ~= nil then out.cost = tostring(chunk.cost) end + if uses == nil and chunk.cost == nil then return "" end + return json.encode(out) end local delta = chunk.choices[1].delta or {} local fr = chunk.choices[1].finish_reason diff --git a/internal/lua/usage_cost_test.go b/internal/lua/usage_cost_test.go new file mode 100644 index 0000000..dd36ffa --- /dev/null +++ b/internal/lua/usage_cost_test.go @@ -0,0 +1,177 @@ +package lua + +import ( + "encoding/json" + "testing" +) + +// OpenCode Zen/Go 在 usage 里报告输出有多少是思考 token,并在**顶层**用一个 +// **字符串**给出本次费用。前者决定「completion_tokens 里多少是可见回答」, +// 后者是唯一的费用信号。两者都曾被适配器丢掉。 +func TestOpenCodeCostAndReasoningPassthrough(t *testing.T) { + for _, name := range []string{"opencodego", "opencodezen"} { + vm := NewVM(freshAdapterDir(t)) + if err := vm.Start(); err != nil { + t.Fatal(err) + } + + // ---- 非流式 ---- + resp := `{"id":"x","object":"chat.completion","model":"deepseek-v4.1-flash", + "choices":[{"index":0,"message":{"role":"assistant","content":"hi","reasoning_content":"think"}, + "finish_reason":"stop"}], + "usage":{"prompt_tokens":37,"completion_tokens":40,"total_tokens":77, + "prompt_cache_hit_tokens":0,"prompt_cache_miss_tokens":37, + "prompt_tokens_details":{"cached_tokens":0}, + "completion_tokens_details":{"reasoning_tokens":40}}, + "cost":"0"}` + out, err := vm.Transform(name, "transform_response", resp) + if err != nil { + t.Fatalf("%s transform_response: %v", name, err) + } + var r struct { + TokenUsage struct { + Completion int `json:"completion"` + Details *struct { + ReasoningTokens int `json:"reasoning_tokens"` + } `json:"completion_tokens_details"` + } `json:"token_usage"` + Cost string `json:"cost"` + } + if err := json.Unmarshal([]byte(out), &r); err != nil { + t.Fatalf("%s unmarshal: %v (%s)", name, err, out) + } + if r.Cost != "0" { + t.Errorf("%s: cost 未透传(上游给的是字符串 \"0\"): %q", name, r.Cost) + } + if r.TokenUsage.Details == nil || r.TokenUsage.Details.ReasoningTokens != 40 { + t.Errorf("%s: completion_tokens_details.reasoning_tokens 未透传: %s", name, out) + } + // 前提校验:reasoning_tokens 确实 ≤ completion_tokens(全为思考)。 + if r.TokenUsage.Details != nil && r.TokenUsage.Details.ReasoningTokens > r.TokenUsage.Completion { + t.Errorf("%s: reasoning 不应超过 completion", name) + } + + // ---- 流式:非零费用也要透传 ---- + chunk := `{"id":"x","object":"chat.completion.chunk","choices":[], + "usage":{"prompt_tokens":10,"completion_tokens":5,"total_tokens":15, + "completion_tokens_details":{"reasoning_tokens":4}},"cost":"0.0012"}` + co, err := vm.Transform(name, "transform_stream_chunk", chunk) + if err != nil { + t.Fatalf("%s transform_stream_chunk: %v", name, err) + } + var c struct { + Cost string `json:"cost"` + Usage *struct { + Details *struct { + ReasoningTokens int `json:"reasoning_tokens"` + } `json:"completion_tokens_details"` + } `json:"usage"` + } + if err := json.Unmarshal([]byte(co), &c); err != nil { + t.Fatalf("%s unmarshal chunk: %v (%s)", name, err, co) + } + if c.Usage == nil || c.Usage.Details == nil || c.Usage.Details.ReasoningTokens != 4 { + t.Errorf("%s: 流式 reasoning_tokens 未透传: %s", name, co) + } + if c.Cost != "0.0012" { + t.Errorf("%s: 流式 cost 未透传: %s", name, co) + } + + // ---- 独立的 cost 帧(上游就这么发:choices 为空、只有 cost)---- + only := `{"choices":[],"cost":"0"}` + oo, err := vm.Transform(name, "transform_stream_chunk", only) + if err != nil { + t.Fatalf("%s cost-only chunk: %v", name, err) + } + if oo == "" { + t.Fatalf("%s: 纯 cost 帧被整个丢弃(客户端将看不到费用)", name) + } + var oc struct { + Cost string `json:"cost"` + } + if err := json.Unmarshal([]byte(oo), &oc); err != nil { + t.Fatalf("%s unmarshal cost chunk: %v (%s)", name, err, oo) + } + if oc.Cost != "0" { + t.Errorf("%s: 纯 cost 帧内容丢失: %s", name, oo) + } + + // ---- 回归:没有 cost / 没有 details 时不得凭空造字段 ---- + bare := `{"choices":[{"index":0,"delta":{"content":"hi"},"finish_reason":"stop"}], + "usage":{"prompt_tokens":3,"completion_tokens":1,"total_tokens":4}}` + bo, _ := vm.Transform(name, "transform_response", bare) + if got := bo; len(got) > 0 && containsAll(got, `"cost"`) { + t.Errorf("%s: 上游没给 cost 却透传了该字段: %s", name, got) + } + + vm.Stop() + } +} + +func containsAll(s string, subs ...string) bool { + for _, sub := range subs { + found := false + for i := 0; i+len(sub) <= len(s); i++ { + if s[i:i+len(sub)] == sub { + found = true + break + } + } + if !found { + return false + } + } + return true +} + +// 流式与非流式对同一上游切片的产出必须一致:命中缓存时上游同时给 +// prompt_tokens_details.cached_tokens 和独立的 hit/miss 字段,两边都要透传。 +func TestOpenCodeStreamCacheFieldsMatchNonStream(t *testing.T) { + for _, name := range []string{"opencodego", "opencodezen"} { + vm := NewVM(freshAdapterDir(t)) + if err := vm.Start(); err != nil { + t.Fatal(err) + } + type cache struct { + Details *struct { + CachedTokens int `json:"cached_tokens"` + } `json:"prompt_tokens_details"` + Hit int `json:"prompt_cache_hit_tokens"` + Miss int `json:"prompt_cache_miss_tokens"` + } + usage := `"usage":{"prompt_tokens":5732,"completion_tokens":20,"total_tokens":5752, + "prompt_cache_hit_tokens":5504,"prompt_cache_miss_tokens":228, + "prompt_tokens_details":{"cached_tokens":5504}}` + + // 非流式 + nout, _ := vm.Transform(name, "transform_response", + `{"choices":[{"index":0,"message":{"role":"assistant","content":"hi"},"finish_reason":"stop"}],`+usage+`}`) + var nr struct { + U cache `json:"token_usage"` + } + if err := json.Unmarshal([]byte(nout), &nr); err != nil { + t.Fatalf("%s: %v", name, err) + } + // 流式 + sout, _ := vm.Transform(name, "transform_stream_chunk", + `{"choices":[],`+usage+`}`) + var sr struct { + U cache `json:"usage"` + } + if err := json.Unmarshal([]byte(sout), &sr); err != nil { + t.Fatalf("%s chunk: %v (%s)", name, err, sout) + } + + if nr.U.Hit != 5504 || nr.U.Miss != 228 { + t.Errorf("%s: 非流式 hit/miss 异常: %+v", name, nr.U) + } + if sr.U.Hit != nr.U.Hit || sr.U.Miss != nr.U.Miss { + t.Errorf("%s: 流式 hit/miss 与非流式不一致 (stream %d/%d vs non-stream %d/%d): %s", + name, sr.U.Hit, sr.U.Miss, nr.U.Hit, nr.U.Miss, sout) + } + if sr.U.Details == nil || sr.U.Details.CachedTokens != 5504 { + t.Errorf("%s: 流式 cached_tokens 丢失: %s", name, sout) + } + vm.Stop() + } +} diff --git a/internal/provider/provider.go b/internal/provider/provider.go index cd9f068..eb1564b 100644 --- a/internal/provider/provider.go +++ b/internal/provider/provider.go @@ -1518,12 +1518,24 @@ func standardSSEChunk(data string) string { PromptTokensDetails *struct { CachedTokens int `json:"cached_tokens"` } `json:"prompt_tokens_details"` + CompletionTokensDetails *struct { + ReasoningTokens int `json:"reasoning_tokens"` + } `json:"completion_tokens_details"` } `json:"usage"` + // Cost is a top-level decimal string on OpenCode Zen/Go responses. + Cost json.RawMessage `json:"cost"` } if err := json.Unmarshal([]byte(data), &raw); err != nil { return "" } if len(raw.Choices) == 0 && raw.UpstreamUsage.Total == 0 && raw.UpstreamUsage.TotalTokens == 0 { + // Cost-only chunk: OpenCode sends the charge on its own frame + // {"choices":[],"cost":"0"} with no usage at all. Dropping it here + // would lose the only cost signal the client can see. + if c := rawCostString(raw.Cost); c != "" { + out, _ := json.Marshal(types.UnifiedChunk{Cost: c}) + return string(out) + } return "" } var usage *types.TokenUsage @@ -1541,6 +1553,11 @@ func standardSSEChunk(data string) string { CachedTokens: pu.PromptTokensDetails.CachedTokens, } } + if pu.CompletionTokensDetails != nil { + usage.CompletionTokensDetails = &types.CompletionTokensDetails{ + ReasoningTokens: pu.CompletionTokensDetails.ReasoningTokens, + } + } } finish := "" done := false @@ -1565,6 +1582,24 @@ func standardSSEChunk(data string) string { return string(out) } +// rawCostString normalizes an upstream cost field into a plain string. The +// value is a decimal **string** on OpenCode Zen/Go ("0", "0.0012"), but other +// upstreams have been seen to send a bare number, so both are accepted. +// Returns "" when absent, null, or empty. +func rawCostString(raw json.RawMessage) string { + s := strings.TrimSpace(string(raw)) + if s == "" || s == "null" { + return "" + } + if s[0] == '"' { + var unq string + if json.Unmarshal(raw, &unq) == nil { + return strings.TrimSpace(unq) + } + } + return s +} + // pickFirst returns a if non-zero, else b (for usage keys that may appear in // either standard *_tokens or legacy short form). func pickFirst(a, b int) int { diff --git a/internal/types/types.go b/internal/types/types.go index 94b94c8..51a5f16 100644 --- a/internal/types/types.go +++ b/internal/types/types.go @@ -117,6 +117,11 @@ type UnifiedResponse struct { ToolCalls []ToolCall `json:"tool_calls,omitempty"` // ImageData used by image-generation adapters. ImageData []ImageData `json:"image_data,omitempty"` + // Cost is the upstream-reported charge for this request, passed through + // verbatim (OpenCode Zen/Go put a decimal **string** at the response top + // level). Deliberately not parsed or summed by the gateway: it is an + // upstream fact, and only some upstreams report it at all. + Cost string `json:"cost,omitempty"` } type TokenUsage struct { @@ -133,6 +138,16 @@ type TokenUsage struct { // prompt_tokens_details.cached_tokens is absent. PromptCacheHit int `json:"prompt_cache_hit_tokens,omitempty"` PromptCacheMiss int `json:"prompt_cache_miss_tokens,omitempty"` + // CompletionTokensDetails mirrors the OpenAI v2 usage.completion_tokens_details + // object. OpenCode Zen/Go report how much of the completion was thinking + // tokens here; without it clients cannot tell how much of the billed output + // was reasoning rather than visible answer. + CompletionTokensDetails *CompletionTokensDetails `json:"completion_tokens_details,omitempty"` +} + +// CompletionTokensDetails is the OpenAI v2 completion_tokens_details object. +type CompletionTokensDetails struct { + ReasoningTokens int `json:"reasoning_tokens"` } // PromptTokensDetails is the OpenAI v2 prompt_tokens_details object. Only @@ -157,15 +172,16 @@ func (t TokenUsage) MarshalJSON() ([]byte, error) { pdetails = &PromptTokensDetails{CachedTokens: t.PromptCacheHit} } return json.Marshal(struct { - PromptTokens int `json:"prompt_tokens"` - CompletionTokens int `json:"completion_tokens"` - TotalTokens int `json:"total_tokens"` - Prompt int `json:"prompt"` - Completion int `json:"completion"` - Total int `json:"total"` - PromptTokensDetails *PromptTokensDetails `json:"prompt_tokens_details,omitempty"` - PromptCacheHitTokens int `json:"prompt_cache_hit_tokens,omitempty"` - PromptCacheMissTokens int `json:"prompt_cache_miss_tokens,omitempty"` + PromptTokens int `json:"prompt_tokens"` + CompletionTokens int `json:"completion_tokens"` + TotalTokens int `json:"total_tokens"` + Prompt int `json:"prompt"` + Completion int `json:"completion"` + Total int `json:"total"` + PromptTokensDetails *PromptTokensDetails `json:"prompt_tokens_details,omitempty"` + PromptCacheHitTokens int `json:"prompt_cache_hit_tokens,omitempty"` + PromptCacheMissTokens int `json:"prompt_cache_miss_tokens,omitempty"` + CompletionDetails *CompletionTokensDetails `json:"completion_tokens_details,omitempty"` }{ PromptTokens: t.Prompt, CompletionTokens: t.Completion, @@ -176,6 +192,7 @@ func (t TokenUsage) MarshalJSON() ([]byte, error) { PromptTokensDetails: pdetails, PromptCacheHitTokens: t.PromptCacheHit, PromptCacheMissTokens: t.PromptCacheMiss, + CompletionDetails: t.CompletionTokensDetails, }) } @@ -219,6 +236,9 @@ type UnifiedChunk struct { // often has empty choices). Gateway uses it to emit exact usage in the // final stream chunk instead of estimates. Usage *TokenUsage `json:"usage,omitempty"` + // Cost is upstream-reported charge for this request (decimal string), + // passed through verbatim; OpenCode sends it on its own stream chunk. + Cost string `json:"cost,omitempty"` } // Meta passed to Lua build_headers hook diff --git a/internal/types/types_test.go b/internal/types/types_test.go index 4c758dc..9762f65 100644 --- a/internal/types/types_test.go +++ b/internal/types/types_test.go @@ -2,6 +2,7 @@ package types import ( "encoding/json" + "strings" "testing" ) @@ -117,3 +118,37 @@ func TestChatMessageNormalizesEmptyArrayContent(t *testing.T) { t.Fatalf("empty tool_calls array must be dropped, got %s", om.ToolCalls) } } + +// 上游报告输出里有多少是思考 token;缺少这个字段,客户端无法判断 +// completion_tokens 里多少是可见回答、多少是思考(两者都按输出计费)。 +func TestTokenUsageMarshalsCompletionTokensDetails(t *testing.T) { + u := TokenUsage{ + Prompt: 37, Completion: 40, Total: 77, + CompletionTokensDetails: &CompletionTokensDetails{ReasoningTokens: 40}, + } + b, err := json.Marshal(u) + if err != nil { + t.Fatal(err) + } + var got map[string]interface{} + if err := json.Unmarshal(b, &got); err != nil { + t.Fatal(err) + } + ctd, ok := got["completion_tokens_details"].(map[string]interface{}) + if !ok { + t.Fatalf("completion_tokens_details 未输出: %s", b) + } + if n, _ := ctd["reasoning_tokens"].(float64); int(n) != 40 { + t.Errorf("reasoning_tokens = %v, want 40 (%s)", ctd["reasoning_tokens"], b) + } + // 标准 OpenAI 字段同时存在,老客户端不受影响。 + if _, ok := got["completion_tokens"]; !ok { + t.Errorf("标准 completion_tokens 丢失: %s", b) + } + + // 未设置时不得凭空造字段。 + b2, _ := json.Marshal(TokenUsage{Prompt: 1, Completion: 2, Total: 3}) + if strings.Contains(string(b2), "completion_tokens_details") { + t.Errorf("无数据时不应输出 completion_tokens_details: %s", b2) + } +}