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

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

View File

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