mirror of
https://gitcode.com/JianFeeeee/ModelRouter.git
synced 2026-09-19 16:39:15 +00:00
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:
@ -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)
|
||||
}
|
||||
|
||||
@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user