feat(gateway): pass through upstream finish_reason end-to-end

The gateway hardcoded "stop" on every terminating stream chunk, so
tool-call rounds reported finish_reason=stop and length caps were
invisible to clients. UnifiedChunk now carries finish_reason; adapters
emit it (with empty-string finish reasons like sensenova treated as
non-terminal), standardSSEChunk passes it through for un-adapted
upstreams, [DONE] no longer emits a duplicate reason-less done chunk,
and both streaming paths emit the real reason with "stop" as fallback.

Also vendor sensenova/agentrouter adapters into the repo: they were
WebUI-only uploads and a deploy sync silently removed them while live
AUTO-chain slots still referenced them.
This commit is contained in:
JianFeeeee
2026-08-24 15:05:50 +08:00
parent 9c99ded8c0
commit bb3af3bdb3
17 changed files with 399 additions and 27 deletions

View File

@ -656,6 +656,7 @@ func (g *Gateway) streamChat(w http.ResponseWriter, ctx context.Context, cands [
return return
} }
var lastUsage *types.TokenUsage var lastUsage *types.TokenUsage
lastFinish := ""
for ck := range chunks { for ck := range chunks {
if ck.Usage != nil { if ck.Usage != nil {
lastUsage = mergeUsage(lastUsage, ck.Usage) lastUsage = mergeUsage(lastUsage, ck.Usage)
@ -672,8 +673,12 @@ func (g *Gateway) streamChat(w http.ResponseWriter, ctx context.Context, cands [
} }
choice := ChunkChoice{Index: 0, Delta: delta} choice := ChunkChoice{Index: 0, Delta: delta}
if ck.Done { if ck.Done {
stop := "stop" fin := ck.FinishReason
choice.FinishReason = &stop if fin == "" {
fin = "stop"
}
lastFinish = fin
choice.FinishReason = &fin
} }
chunk.Choices = []ChunkChoice{choice} chunk.Choices = []ChunkChoice{choice}
rec.Compl += int64(len(ck.Content)+len(ck.ReasoningContent)+len(ck.ToolCalls)) / 3 rec.Compl += int64(len(ck.Content)+len(ck.ReasoningContent)+len(ck.ToolCalls)) / 3
@ -681,10 +686,13 @@ func (g *Gateway) streamChat(w http.ResponseWriter, ctx context.Context, cands [
return return
} }
} }
stop := "stop" finalFinish := lastFinish
if finalFinish == "" {
finalFinish = "stop"
}
send(ChatChunk{ send(ChatChunk{
ID: id, Object: "chat.completion.chunk", Created: created, Model: effective, ID: id, Object: "chat.completion.chunk", Created: created, Model: effective,
Choices: []ChunkChoice{{Index: 0, Delta: RespMessage{}, FinishReason: &stop}}, Choices: []ChunkChoice{{Index: 0, Delta: RespMessage{}, FinishReason: &finalFinish}},
}) })
// Final usage chunk (OpenAI standard: empty choices + usage before [DONE]). // Final usage chunk (OpenAI standard: empty choices + usage before [DONE]).
// Prefer the upstream's exact usage if the stream carried it; fall back to // Prefer the upstream's exact usage if the stream carried it; fall back to
@ -826,6 +834,7 @@ func (g *Gateway) streamChatAuto(w http.ResponseWriter, ctx context.Context, cha
return return
} }
var lastUsage *types.TokenUsage var lastUsage *types.TokenUsage
lastFinish := ""
for ck := range chunks { for ck := range chunks {
if ck.Usage != nil { if ck.Usage != nil {
lastUsage = mergeUsage(lastUsage, ck.Usage) lastUsage = mergeUsage(lastUsage, ck.Usage)
@ -842,8 +851,12 @@ func (g *Gateway) streamChatAuto(w http.ResponseWriter, ctx context.Context, cha
} }
choice := ChunkChoice{Index: 0, Delta: delta} choice := ChunkChoice{Index: 0, Delta: delta}
if ck.Done { if ck.Done {
stop := "stop" fin := ck.FinishReason
choice.FinishReason = &stop if fin == "" {
fin = "stop"
}
lastFinish = fin
choice.FinishReason = &fin
} }
chunk.Choices = []ChunkChoice{choice} chunk.Choices = []ChunkChoice{choice}
rec.Compl += int64(len(ck.Content)+len(ck.ReasoningContent)+len(ck.ToolCalls)) / 3 rec.Compl += int64(len(ck.Content)+len(ck.ReasoningContent)+len(ck.ToolCalls)) / 3
@ -851,10 +864,13 @@ func (g *Gateway) streamChatAuto(w http.ResponseWriter, ctx context.Context, cha
return return
} }
} }
stop := "stop" finalFinish := lastFinish
if finalFinish == "" {
finalFinish = "stop"
}
send(ChatChunk{ send(ChatChunk{
ID: id, Object: "chat.completion.chunk", Created: created, Model: rec.Model, ID: id, Object: "chat.completion.chunk", Created: created, Model: rec.Model,
Choices: []ChunkChoice{{Index: 0, Delta: RespMessage{}, FinishReason: &stop}}, Choices: []ChunkChoice{{Index: 0, Delta: RespMessage{}, FinishReason: &finalFinish}},
}) })
// Final usage chunk (OpenAI standard: empty choices + usage before [DONE]). // Final usage chunk (OpenAI standard: empty choices + usage before [DONE]).
// Prefer the upstream's exact usage if the stream carried it; fall back to // Prefer the upstream's exact usage if the stream carried it; fall back to

View File

@ -0,0 +1,111 @@
local adapter = {}
adapter.name = "agentrouter"
adapter.version = "1.0.0"
adapter.endpoint = "/chat/completions"
adapter.headers = {}
-- AgentRouter 的 WAF 会按客户端指纹白名单校验,只放行官方客户端。
-- 这里通过 build_headers 注入官方客户端 User-Agent 以通过校验。
-- 默认 QwenCode 指纹(实测可通);可在 source.meta.user_agent 覆盖成 Claude Code 指纹。
local default_ua = "QwenCode/0.2.0 (linux; x64)"
function adapter.transform_request(raw_body)
local ok, req = pcall(json.decode, raw_body)
if not ok then return raw_body end
req.disable_thinking = nil
req.extra_body = nil
if req.messages then
for _, msg in ipairs(req.messages) do
msg.reasoning_content = nil
end
end
return json.encode(req)
end
-- 注入 Authorization + 官方客户端 User-Agent
function adapter.build_headers(meta)
local ua = default_ua
local meta_ua = (meta.source or {}).meta and (meta.source).meta.user_agent
if meta_ua and meta_ua ~= "" then
ua = meta_ua
end
return {
["Content-Type"] = "application/json",
["Authorization"] = "Bearer " .. meta.api_key,
["User-Agent"] = ua,
}
end
function adapter.transform_response(raw_body)
local ok, resp = pcall(json.decode, raw_body)
if not ok or resp == nil then return raw_body end
local unified = {
content = "",
finish_reason = "",
token_usage = { prompt = 0, completion = 0, total = 0 }
}
if type(resp.usage) == "table" then
unified.token_usage.prompt = resp.usage.prompt_tokens or 0
unified.token_usage.completion = resp.usage.completion_tokens or 0
unified.token_usage.total = resp.usage.total_tokens or 0
end
if type(resp.choices) == "table" and #resp.choices > 0 then
local ch = resp.choices[1]
if type(ch.message) == "table" then
unified.content = ch.message.content or ""
if ch.message.reasoning_content then
unified.reasoning_content = ch.message.reasoning_content
end
if type(ch.message.tool_calls) == "table" then
local tcs = {}
for _, tc in ipairs(ch.message.tool_calls) do
local args_ok, args = pcall(json.decode, tc["function"].arguments)
if not args_ok then args = {} end
table.insert(tcs, {
id = tc.id,
type = tc.type or "function",
name = tc["function"].name,
arguments = args
})
end
unified.tool_calls = tcs
end
end
unified.finish_reason = ch.finish_reason or ""
end
return json.encode(unified)
end
function adapter.transform_stream_chunk(raw_chunk)
local ok, chunk = pcall(json.decode, raw_chunk)
if not ok then return "" end
if not chunk.choices or #chunk.choices == 0 then return "" end
local delta = chunk.choices[1].delta or {}
local fr = chunk.choices[1].finish_reason
local finish = (type(fr) == "string" and fr ~= "") and fr or nil
local unified = {
content = delta.content or "",
done = (finish ~= nil)
}
if finish then
unified.finish_reason = finish
end
if delta.reasoning_content then
unified.reasoning_content = delta.reasoning_content
end
if delta.tool_calls then
unified.tool_calls = delta.tool_calls
end
return json.encode(unified)
end
return adapter

View File

@ -124,12 +124,23 @@ function adapter.transform_stream_chunk(raw_chunk)
uses = { prompt = p, completion = c, total = p + c } uses = { prompt = p, completion = c, total = p + c }
end end
end end
local done = (chunk.delta and chunk.delta.stop_reason ~= nil) local finish = nil
if chunk.delta and chunk.delta.stop_reason ~= nil then
-- Anthropic stop_reason -> OpenAI finish_reason
local sr = chunk.delta.stop_reason
if sr == "max_tokens" then
finish = "length"
elseif sr == "tool_use" then
finish = "tool_calls"
else
finish = "stop"
end
end
if uses ~= nil then if uses ~= nil then
-- completion is final here; prompt is merged from message_start -- completion is final here; prompt is merged from message_start
return json.encode({ content = "", done = done, usage = uses }) return json.encode({ content = "", done = (finish ~= nil), finish_reason = finish, usage = uses })
end end
return json.encode({ content = "", done = done }) return json.encode({ content = "", done = (finish ~= nil), finish_reason = finish })
end end
if chunk.type == "content_block_start" and chunk.content_block if chunk.type == "content_block_start" and chunk.content_block
and chunk.content_block.type == "tool_use" then and chunk.content_block.type == "tool_use" then

View File

@ -106,10 +106,15 @@ function adapter.transform_stream_chunk(raw_chunk)
local delta = chunk.choices[1].delta or {} local delta = chunk.choices[1].delta or {}
local fr = chunk.choices[1].finish_reason local fr = chunk.choices[1].finish_reason
local finish = (type(fr) == "string" and fr ~= "") and fr or nil
local unified = { local unified = {
content = delta.content or "", content = delta.content or "",
done = (fr ~= nil) done = (finish ~= nil)
} }
if finish then
unified.finish_reason = finish
end
if uses ~= nil then if uses ~= nil then
unified.usage = uses unified.usage = uses
end end

View File

@ -110,7 +110,19 @@ function adapter.transform_stream_chunk(raw_chunk)
return "" return ""
end end
local cand = chunk.candidates[1] local cand = chunk.candidates[1]
local unified = { content = "", done = (cand.finishReason ~= nil) } -- Gemini finishReason -> OpenAI finish_reason
local finish = nil
if cand.finishReason ~= nil then
if cand.finishReason == "MAX_TOKENS" then
finish = "length"
elseif cand.finishReason == "SAFETY" or cand.finishReason == "RECITATION"
or cand.finishReason == "BLOCKLIST" then
finish = "content_filter"
else
finish = "stop"
end
end
local unified = { content = "", done = (finish ~= nil), finish_reason = finish }
local reasoning = "" local reasoning = ""
local tools = {} local tools = {}
if cand.content and cand.content.parts then if cand.content and cand.content.parts then

View File

@ -87,10 +87,15 @@ function adapter.transform_stream_chunk(raw_chunk)
local delta = chunk.choices[1].delta or {} local delta = chunk.choices[1].delta or {}
local fr = chunk.choices[1].finish_reason local fr = chunk.choices[1].finish_reason
local finish = (type(fr) == "string" and fr ~= "") and fr or nil
local unified = { local unified = {
content = delta.content or "", content = delta.content or "",
done = (fr ~= nil) done = (finish ~= nil)
} }
if finish then
unified.finish_reason = finish
end
if uses ~= nil then if uses ~= nil then
unified.usage = uses unified.usage = uses
end end

View File

@ -86,10 +86,15 @@ function adapter.transform_stream_chunk(raw_chunk)
local delta = chunk.choices[1].delta or {} local delta = chunk.choices[1].delta or {}
local fr = chunk.choices[1].finish_reason local fr = chunk.choices[1].finish_reason
local finish = (type(fr) == "string" and fr ~= "") and fr or nil
local unified = { local unified = {
content = delta.content or "", content = delta.content or "",
done = (fr ~= nil) done = (finish ~= nil)
} }
if finish then
unified.finish_reason = finish
end
if uses ~= nil then if uses ~= nil then
unified.usage = uses unified.usage = uses
end end

View File

@ -119,10 +119,15 @@ function adapter.transform_stream_chunk(raw_chunk)
local delta = chunk.choices[1].delta or {} local delta = chunk.choices[1].delta or {}
local fr = chunk.choices[1].finish_reason local fr = chunk.choices[1].finish_reason
local finish = (type(fr) == "string" and fr ~= "") and fr or nil
local unified = { local unified = {
content = delta.content or "", content = delta.content or "",
done = (fr ~= nil) done = (finish ~= nil)
} }
if finish then
unified.finish_reason = finish
end
if uses ~= nil then if uses ~= nil then
unified.usage = uses unified.usage = uses
end end

View File

@ -86,10 +86,15 @@ function adapter.transform_stream_chunk(raw_chunk)
local delta = chunk.choices[1].delta or {} local delta = chunk.choices[1].delta or {}
local fr = chunk.choices[1].finish_reason local fr = chunk.choices[1].finish_reason
local finish = (type(fr) == "string" and fr ~= "") and fr or nil
local unified = { local unified = {
content = delta.content or "", content = delta.content or "",
done = (fr ~= nil) done = (finish ~= nil)
} }
if finish then
unified.finish_reason = finish
end
if uses ~= nil then if uses ~= nil then
unified.usage = uses unified.usage = uses
end end

View File

@ -85,16 +85,27 @@ function adapter.transform_stream_chunk(raw_chunk)
end end
end end
-- Ollama done_reason -> OpenAI finish_reason ("length" 透传,其余归一 stop)
local finish = nil
if chunk.done then
if chunk.done_reason == "length" then
finish = "length"
else
finish = "stop"
end
end
if not chunk.message then if not chunk.message then
if uses ~= nil then if uses ~= nil then
return json.encode({ content = "", done = true, usage = uses }) return json.encode({ content = "", done = true, finish_reason = finish, usage = uses })
end end
return "" return json.encode({ content = "", done = true, finish_reason = finish })
end end
local unified = { local unified = {
content = chunk.message.content or "", content = chunk.message.content or "",
done = chunk.done or false done = chunk.done or false,
finish_reason = finish
} }
if uses ~= nil then if uses ~= nil then
unified.usage = uses unified.usage = uses

View File

@ -90,10 +90,15 @@ function adapter.transform_stream_chunk(raw_chunk)
local delta = chunk.choices[1].delta or {} local delta = chunk.choices[1].delta or {}
local fr = chunk.choices[1].finish_reason local fr = chunk.choices[1].finish_reason
local finish = (type(fr) == "string" and fr ~= "") and fr or nil
local unified = { local unified = {
content = delta.content or "", content = delta.content or "",
done = (fr ~= nil) done = (finish ~= nil)
} }
if finish then
unified.finish_reason = finish
end
if uses ~= nil then if uses ~= nil then
unified.usage = uses unified.usage = uses
end end

View File

@ -159,10 +159,15 @@ function adapter.transform_stream_chunk(raw_chunk)
local delta = chunk.choices[1].delta or {} local delta = chunk.choices[1].delta or {}
local fr = chunk.choices[1].finish_reason local fr = chunk.choices[1].finish_reason
local finish = (type(fr) == "string" and fr ~= "") and fr or nil
local unified = { local unified = {
content = delta.content or "", content = delta.content or "",
done = (fr ~= nil) done = (finish ~= nil)
} }
if finish then
unified.finish_reason = finish
end
if uses ~= nil then if uses ~= nil then
unified.usage = uses unified.usage = uses
end end

View File

@ -0,0 +1,108 @@
-- sensenova API format adapter
-- streaming: delta only has reasoning_content, no content field
-- non-streaming: has both content and reasoning_content
local adapter = {}
adapter.name = "sensenova"
adapter.version = "1.0.0"
adapter.endpoint = "/chat/completions"
adapter.headers = {}
-- Same as openai - strip provider-specific fields
function adapter.transform_request(raw_body)
local ok, req = pcall(json.decode, raw_body)
if not ok then return raw_body end
req.disable_thinking = nil
req.extra_body = nil
if req.messages then
for _, msg in ipairs(req.messages) do
msg.reasoning_content = nil
end
end
return json.encode(req)
end
-- Same as openai - extract content from response
function adapter.transform_response(raw_body)
local ok, resp = pcall(json.decode, raw_body)
if not ok or resp == nil then return raw_body end
local unified = {
content = "",
finish_reason = "",
token_usage = { prompt = 0, completion = 0, total = 0 }
}
if type(resp.usage) == "table" then
unified.token_usage.prompt = resp.usage.prompt_tokens or 0
unified.token_usage.completion = resp.usage.completion_tokens or 0
unified.token_usage.total = resp.usage.total_tokens or 0
end
if type(resp.choices) == "table" and #resp.choices > 0 then
local ch = resp.choices[1]
if type(ch.message) == "table" then
unified.content = ch.message.content or ""
if ch.message.reasoning_content then
unified.reasoning_content = ch.message.reasoning_content
end
end
unified.finish_reason = ch.finish_reason or ""
end
return json.encode(unified)
end
-- Sensenova-specific stream handling
-- Upstream puts content in reasoning_content only (no content field)
-- Also sends finish_reason="" (empty string) on every chunk
function adapter.transform_stream_chunk(raw_chunk)
local ok, chunk = pcall(json.decode, raw_chunk)
if not ok then return "" end
local uses = nil
if type(chunk.usage) == "table" then
uses = {
prompt = chunk.usage.prompt_tokens or chunk.usage.prompt or 0,
completion = chunk.usage.completion_tokens or chunk.usage.completion or 0,
total = chunk.usage.total_tokens or chunk.usage.total or 0,
}
end
if not chunk.choices or #chunk.choices == 0 then
if uses ~= nil then
return json.encode({ usage = uses, done = false })
end
return ""
end
local delta = chunk.choices[1].delta or {}
-- Sensenova: delta has reasoning_content but no content
local content = delta.content or ""
if content == "" and delta.reasoning_content then
content = delta.reasoning_content
end
-- Sensenova: finish_reason is "" on every chunk, "stop" on last
local fr = chunk.choices[1].finish_reason
local done = (fr == "stop" or fr == "length")
local unified = {
content = content,
done = done,
}
if chunk.choices[1].finish_reason and chunk.choices[1].finish_reason ~= "" then
unified.finish_reason = chunk.choices[1].finish_reason
end
if delta.tool_calls then
unified.tool_calls = delta.tool_calls
end
if uses ~= nil then
unified.usage = uses
end
return json.encode(unified)
end
return adapter

View File

@ -422,3 +422,35 @@ func TestOpenCodeAdapterKeepsWhitelistedRolesAndDropsMultimodal(t *testing.T) {
t.Fatalf("unexpected roles: %s", out) t.Fatalf("unexpected roles: %s", out)
} }
} }
func TestAdaptersPassFinishReason(t *testing.T) {
vm := NewVM(freshAdapterDir(t))
if err := vm.Start(); err != nil {
t.Fatal(err)
}
defer vm.Stop()
// OpenAI-style chunk with a real finish reason must surface finish_reason
// and done=true; an empty-string finish_reason (sensenova sends "" on
// every chunk) must NOT terminate the stream.
chunk := `{"choices":[{"index":0,"finish_reason":"tool_calls","delta":{"content":""}}]}`
empty := `{"choices":[{"index":0,"finish_reason":"","delta":{"content":"x"}}]}`
for _, name := range []string{"openai", "deepseek", "github", "groq", "kimicode", "mistral", "opencode"} {
out, err := vm.Transform(name, "transform_stream_chunk", chunk)
if err != nil {
t.Fatalf("%s: %v", name, err)
}
if !strings.Contains(out, `"finish_reason":"tool_calls"`) {
t.Fatalf("%s: finish_reason lost: %s", name, out)
}
if !strings.Contains(out, `"done":true`) {
t.Fatalf("%s: done not set on real finish: %s", name, out)
}
out, err = vm.Transform(name, "transform_stream_chunk", empty)
if err != nil {
t.Fatalf("%s: %v", name, err)
}
if strings.Contains(out, `"done":true`) {
t.Fatalf("%s: empty finish_reason must not end stream: %s", name, out)
}
}
}

View File

@ -803,6 +803,7 @@ func (p *Provider) ChatStream(ctx context.Context, req *types.ChatRequest) (<-ch
scanner.Buffer(make([]byte, 0, 64*1024), 1024*1024) scanner.Buffer(make([]byte, 0, 64*1024), 1024*1024)
var chunks int var chunks int
var doneSeen bool var doneSeen bool
var doneSent bool
for scanner.Scan() { for scanner.Scan() {
line := strings.TrimSpace(scanner.Text()) line := strings.TrimSpace(scanner.Text())
if line == "" || !strings.HasPrefix(line, "data:") { if line == "" || !strings.HasPrefix(line, "data:") {
@ -814,9 +815,15 @@ func (p *Provider) ChatStream(ctx context.Context, req *types.ChatRequest) (<-ch
} }
if data == "[DONE]" { if data == "[DONE]" {
doneSeen = true doneSeen = true
select { // adapters that already emitted their terminating done chunk
case ch <- types.UnifiedChunk{Done: true}: // (with the real finish reason) must not get a second,
case <-ctx.Done(): // reason-less done from [DONE] — it would override the true
// finish_reason downstream.
if !doneSent {
select {
case ch <- types.UnifiedChunk{Done: true}:
case <-ctx.Done():
}
} }
continue continue
} }
@ -834,6 +841,9 @@ func (p *Provider) ChatStream(ctx context.Context, req *types.ChatRequest) (<-ch
if err := json.Unmarshal([]byte(unified), &ck); err != nil { if err := json.Unmarshal([]byte(unified), &ck); err != nil {
continue continue
} }
if ck.Done {
doneSent = true
}
chunks++ chunks++
select { select {
case ch <- ck: case ch <- ck:
@ -995,6 +1005,15 @@ func standardSSEChunk(data string) string {
Total: pickFirst(pu.TotalTokens, pu.Total), Total: pickFirst(pu.TotalTokens, pu.Total),
} }
} }
finish := ""
done := false
if len(raw.Choices) > 0 && raw.Choices[0].FinishReason != nil {
// empty-string finish reasons (sensenova sends "" on every chunk)
// are not a finish signal
if fr := *raw.Choices[0].FinishReason; fr != "" {
finish, done = fr, true
}
}
out, _ := json.Marshal(types.UnifiedChunk{ out, _ := json.Marshal(types.UnifiedChunk{
Content: func() string { Content: func() string {
if len(raw.Choices) > 0 { if len(raw.Choices) > 0 {
@ -1002,8 +1021,9 @@ func standardSSEChunk(data string) string {
} }
return "" return ""
}(), }(),
Done: len(raw.Choices) > 0 && raw.Choices[0].FinishReason != nil, Done: done,
Usage: usage, FinishReason: finish,
Usage: usage,
}) })
return string(out) return string(out)
} }

View File

@ -8,6 +8,7 @@ import (
"net/http" "net/http"
"net/http/httptest" "net/http/httptest"
"path/filepath" "path/filepath"
"strings"
"sync/atomic" "sync/atomic"
"testing" "testing"
"time" "time"
@ -472,3 +473,14 @@ func TestImageAutoUsesImageModel(t *testing.T) {
t.Fatalf("AUTO image must use the image-kind model, got %q", gotModel) t.Fatalf("AUTO image must use the image-kind model, got %q", gotModel)
} }
} }
func TestStandardSSEChunkFinishReason(t *testing.T) {
out := standardSSEChunk(`{"choices":[{"index":0,"finish_reason":"tool_calls","delta":{}}]}`)
if !strings.Contains(out, `"done":true`) || !strings.Contains(out, `"finish_reason":"tool_calls"`) {
t.Fatalf("real finish reason must pass through: %s", out)
}
out = standardSSEChunk(`{"choices":[{"index":0,"finish_reason":"","delta":{"content":"hi"}}]}`)
if strings.Contains(out, `"Done":true`) {
t.Fatalf("empty-string finish reason is not a finish signal: %s", out)
}
}

View File

@ -136,6 +136,10 @@ type ImageGenResponse struct {
type UnifiedChunk struct { type UnifiedChunk struct {
Content string `json:"content"` Content string `json:"content"`
Done bool `json:"done"` Done bool `json:"done"`
// FinishReason carries the upstream finish/stop reason ("tool_calls",
// "length", ...) when the adapter provides it; the gateway emits it on
// the terminating chunk instead of the default "stop".
FinishReason string `json:"finish_reason,omitempty"`
ToolCalls json.RawMessage `json:"tool_calls,omitempty"` ToolCalls json.RawMessage `json:"tool_calls,omitempty"`
ReasoningContent string `json:"reasoning_content,omitempty"` ReasoningContent string `json:"reasoning_content,omitempty"`
// Usage carries the upstream token usage when the stream chunk provides // Usage carries the upstream token usage when the stream chunk provides