From bb3af3bdb3d15554e812a386caa36abed2cfc632 Mon Sep 17 00:00:00 2001 From: JianFeeeee Date: Mon, 24 Aug 2026 15:05:50 +0800 Subject: [PATCH] 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. --- internal/gateway/chat.go | 32 ++++++-- internal/lua/adapters/agentrouter.lua | 111 ++++++++++++++++++++++++++ internal/lua/adapters/anthropic.lua | 17 +++- internal/lua/adapters/deepseek.lua | 7 +- internal/lua/adapters/gemini.lua | 14 +++- internal/lua/adapters/github.lua | 7 +- internal/lua/adapters/groq.lua | 7 +- internal/lua/adapters/kimicode.lua | 7 +- internal/lua/adapters/mistral.lua | 7 +- internal/lua/adapters/ollama.lua | 17 +++- internal/lua/adapters/openai.lua | 7 +- internal/lua/adapters/opencode.lua | 7 +- internal/lua/adapters/sensenova.lua | 108 +++++++++++++++++++++++++ internal/lua/vm_test.go | 32 ++++++++ internal/provider/provider.go | 30 +++++-- internal/provider/provider_test.go | 12 +++ internal/types/types.go | 4 + 17 files changed, 399 insertions(+), 27 deletions(-) create mode 100644 internal/lua/adapters/agentrouter.lua create mode 100644 internal/lua/adapters/sensenova.lua diff --git a/internal/gateway/chat.go b/internal/gateway/chat.go index ba665cc..b2e447e 100644 --- a/internal/gateway/chat.go +++ b/internal/gateway/chat.go @@ -656,6 +656,7 @@ func (g *Gateway) streamChat(w http.ResponseWriter, ctx context.Context, cands [ return } var lastUsage *types.TokenUsage + lastFinish := "" for ck := range chunks { if ck.Usage != nil { 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} if ck.Done { - stop := "stop" - choice.FinishReason = &stop + fin := ck.FinishReason + if fin == "" { + fin = "stop" + } + lastFinish = fin + choice.FinishReason = &fin } chunk.Choices = []ChunkChoice{choice} 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 } } - stop := "stop" + finalFinish := lastFinish + if finalFinish == "" { + finalFinish = "stop" + } send(ChatChunk{ 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]). // 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 } var lastUsage *types.TokenUsage + lastFinish := "" for ck := range chunks { if ck.Usage != nil { 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} if ck.Done { - stop := "stop" - choice.FinishReason = &stop + fin := ck.FinishReason + if fin == "" { + fin = "stop" + } + lastFinish = fin + choice.FinishReason = &fin } chunk.Choices = []ChunkChoice{choice} 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 } } - stop := "stop" + finalFinish := lastFinish + if finalFinish == "" { + finalFinish = "stop" + } send(ChatChunk{ 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]). // Prefer the upstream's exact usage if the stream carried it; fall back to diff --git a/internal/lua/adapters/agentrouter.lua b/internal/lua/adapters/agentrouter.lua new file mode 100644 index 0000000..a6193a8 --- /dev/null +++ b/internal/lua/adapters/agentrouter.lua @@ -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 \ No newline at end of file diff --git a/internal/lua/adapters/anthropic.lua b/internal/lua/adapters/anthropic.lua index 34cb75e..c696556 100644 --- a/internal/lua/adapters/anthropic.lua +++ b/internal/lua/adapters/anthropic.lua @@ -124,12 +124,23 @@ function adapter.transform_stream_chunk(raw_chunk) uses = { prompt = p, completion = c, total = p + c } 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 -- 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 - return json.encode({ content = "", done = done }) + return json.encode({ content = "", done = (finish ~= nil), finish_reason = finish }) end if chunk.type == "content_block_start" and chunk.content_block and chunk.content_block.type == "tool_use" then diff --git a/internal/lua/adapters/deepseek.lua b/internal/lua/adapters/deepseek.lua index 12e53be..a8a105d 100644 --- a/internal/lua/adapters/deepseek.lua +++ b/internal/lua/adapters/deepseek.lua @@ -106,10 +106,15 @@ function adapter.transform_stream_chunk(raw_chunk) 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 = (fr ~= nil) + done = (finish ~= nil) } + if finish then + unified.finish_reason = finish + end if uses ~= nil then unified.usage = uses end diff --git a/internal/lua/adapters/gemini.lua b/internal/lua/adapters/gemini.lua index c54d8b2..2f740c4 100644 --- a/internal/lua/adapters/gemini.lua +++ b/internal/lua/adapters/gemini.lua @@ -110,7 +110,19 @@ function adapter.transform_stream_chunk(raw_chunk) return "" end 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 tools = {} if cand.content and cand.content.parts then diff --git a/internal/lua/adapters/github.lua b/internal/lua/adapters/github.lua index 6a35097..a6f2652 100644 --- a/internal/lua/adapters/github.lua +++ b/internal/lua/adapters/github.lua @@ -87,10 +87,15 @@ function adapter.transform_stream_chunk(raw_chunk) 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 = (fr ~= nil) + done = (finish ~= nil) } + if finish then + unified.finish_reason = finish + end if uses ~= nil then unified.usage = uses end diff --git a/internal/lua/adapters/groq.lua b/internal/lua/adapters/groq.lua index 4a4deb0..9b3f195 100644 --- a/internal/lua/adapters/groq.lua +++ b/internal/lua/adapters/groq.lua @@ -86,10 +86,15 @@ function adapter.transform_stream_chunk(raw_chunk) 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 = (fr ~= nil) + done = (finish ~= nil) } + if finish then + unified.finish_reason = finish + end if uses ~= nil then unified.usage = uses end diff --git a/internal/lua/adapters/kimicode.lua b/internal/lua/adapters/kimicode.lua index 2eed888..b4f7c09 100644 --- a/internal/lua/adapters/kimicode.lua +++ b/internal/lua/adapters/kimicode.lua @@ -119,10 +119,15 @@ function adapter.transform_stream_chunk(raw_chunk) 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 = (fr ~= nil) + done = (finish ~= nil) } + if finish then + unified.finish_reason = finish + end if uses ~= nil then unified.usage = uses end diff --git a/internal/lua/adapters/mistral.lua b/internal/lua/adapters/mistral.lua index 2fae9ce..9a2f58e 100644 --- a/internal/lua/adapters/mistral.lua +++ b/internal/lua/adapters/mistral.lua @@ -86,10 +86,15 @@ function adapter.transform_stream_chunk(raw_chunk) 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 = (fr ~= nil) + done = (finish ~= nil) } + if finish then + unified.finish_reason = finish + end if uses ~= nil then unified.usage = uses end diff --git a/internal/lua/adapters/ollama.lua b/internal/lua/adapters/ollama.lua index 90b3121..1006b88 100644 --- a/internal/lua/adapters/ollama.lua +++ b/internal/lua/adapters/ollama.lua @@ -85,16 +85,27 @@ function adapter.transform_stream_chunk(raw_chunk) 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 uses ~= nil then - return json.encode({ content = "", done = true, usage = uses }) + return json.encode({ content = "", done = true, finish_reason = finish, usage = uses }) end - return "" + return json.encode({ content = "", done = true, finish_reason = finish }) end local unified = { content = chunk.message.content or "", - done = chunk.done or false + done = chunk.done or false, + finish_reason = finish } if uses ~= nil then unified.usage = uses diff --git a/internal/lua/adapters/openai.lua b/internal/lua/adapters/openai.lua index a987a6f..a755dc7 100644 --- a/internal/lua/adapters/openai.lua +++ b/internal/lua/adapters/openai.lua @@ -90,10 +90,15 @@ function adapter.transform_stream_chunk(raw_chunk) 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 = (fr ~= nil) + done = (finish ~= nil) } + if finish then + unified.finish_reason = finish + end if uses ~= nil then unified.usage = uses end diff --git a/internal/lua/adapters/opencode.lua b/internal/lua/adapters/opencode.lua index aa549a6..1d5b516 100644 --- a/internal/lua/adapters/opencode.lua +++ b/internal/lua/adapters/opencode.lua @@ -159,10 +159,15 @@ function adapter.transform_stream_chunk(raw_chunk) 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 = (fr ~= nil) + done = (finish ~= nil) } + if finish then + unified.finish_reason = finish + end if uses ~= nil then unified.usage = uses end diff --git a/internal/lua/adapters/sensenova.lua b/internal/lua/adapters/sensenova.lua new file mode 100644 index 0000000..980d220 --- /dev/null +++ b/internal/lua/adapters/sensenova.lua @@ -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 diff --git a/internal/lua/vm_test.go b/internal/lua/vm_test.go index 6cd3a14..c41d7db 100644 --- a/internal/lua/vm_test.go +++ b/internal/lua/vm_test.go @@ -422,3 +422,35 @@ func TestOpenCodeAdapterKeepsWhitelistedRolesAndDropsMultimodal(t *testing.T) { 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) + } + } +} diff --git a/internal/provider/provider.go b/internal/provider/provider.go index f4e2d94..6ac2be8 100644 --- a/internal/provider/provider.go +++ b/internal/provider/provider.go @@ -803,6 +803,7 @@ func (p *Provider) ChatStream(ctx context.Context, req *types.ChatRequest) (<-ch scanner.Buffer(make([]byte, 0, 64*1024), 1024*1024) var chunks int var doneSeen bool + var doneSent bool for scanner.Scan() { line := strings.TrimSpace(scanner.Text()) if line == "" || !strings.HasPrefix(line, "data:") { @@ -814,9 +815,15 @@ func (p *Provider) ChatStream(ctx context.Context, req *types.ChatRequest) (<-ch } if data == "[DONE]" { doneSeen = true - select { - case ch <- types.UnifiedChunk{Done: true}: - case <-ctx.Done(): + // adapters that already emitted their terminating done chunk + // (with the real finish reason) must not get a second, + // 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 } @@ -834,6 +841,9 @@ func (p *Provider) ChatStream(ctx context.Context, req *types.ChatRequest) (<-ch if err := json.Unmarshal([]byte(unified), &ck); err != nil { continue } + if ck.Done { + doneSent = true + } chunks++ select { case ch <- ck: @@ -995,6 +1005,15 @@ func standardSSEChunk(data string) string { 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{ Content: func() string { if len(raw.Choices) > 0 { @@ -1002,8 +1021,9 @@ func standardSSEChunk(data string) string { } return "" }(), - Done: len(raw.Choices) > 0 && raw.Choices[0].FinishReason != nil, - Usage: usage, + Done: done, + FinishReason: finish, + Usage: usage, }) return string(out) } diff --git a/internal/provider/provider_test.go b/internal/provider/provider_test.go index f84dad0..c36e0de 100644 --- a/internal/provider/provider_test.go +++ b/internal/provider/provider_test.go @@ -8,6 +8,7 @@ import ( "net/http" "net/http/httptest" "path/filepath" + "strings" "sync/atomic" "testing" "time" @@ -472,3 +473,14 @@ func TestImageAutoUsesImageModel(t *testing.T) { 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) + } +} diff --git a/internal/types/types.go b/internal/types/types.go index 0807ec2..a23665d 100644 --- a/internal/types/types.go +++ b/internal/types/types.go @@ -136,6 +136,10 @@ type ImageGenResponse struct { type UnifiedChunk struct { Content string `json:"content"` 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"` ReasoningContent string `json:"reasoning_content,omitempty"` // Usage carries the upstream token usage when the stream chunk provides