mirror of
https://github.com/CJackHwang/ds2api.git
synced 2026-05-16 14:15:20 +08:00
Merge origin/dev into PR 406
This commit is contained in:
104
internal/httpapi/openai/shared/stream_accumulator.go
Normal file
104
internal/httpapi/openai/shared/stream_accumulator.go
Normal file
@@ -0,0 +1,104 @@
|
||||
package shared
|
||||
|
||||
import (
|
||||
"strings"
|
||||
|
||||
"ds2api/internal/sse"
|
||||
)
|
||||
|
||||
type StreamAccumulator struct {
|
||||
ThinkingEnabled bool
|
||||
SearchEnabled bool
|
||||
StripReferenceMarkers bool
|
||||
|
||||
RawThinking strings.Builder
|
||||
Thinking strings.Builder
|
||||
ToolDetectionThinking strings.Builder
|
||||
RawText strings.Builder
|
||||
Text strings.Builder
|
||||
}
|
||||
|
||||
type StreamPartDelta struct {
|
||||
Type string
|
||||
RawText string
|
||||
VisibleText string
|
||||
CitationOnly bool
|
||||
}
|
||||
|
||||
type StreamAccumulatorResult struct {
|
||||
ContentSeen bool
|
||||
Parts []StreamPartDelta
|
||||
}
|
||||
|
||||
func (a *StreamAccumulator) Apply(parsed sse.LineResult) StreamAccumulatorResult {
|
||||
out := StreamAccumulatorResult{}
|
||||
for _, p := range parsed.ToolDetectionThinkingParts {
|
||||
trimmed := sse.TrimContinuationOverlapFromBuilder(&a.ToolDetectionThinking, p.Text)
|
||||
if trimmed != "" {
|
||||
a.ToolDetectionThinking.WriteString(trimmed)
|
||||
}
|
||||
}
|
||||
for _, p := range parsed.Parts {
|
||||
if p.Type == "thinking" {
|
||||
delta := a.applyThinkingPart(p.Text)
|
||||
if delta.RawText != "" {
|
||||
out.ContentSeen = true
|
||||
}
|
||||
if delta.RawText != "" || delta.VisibleText != "" {
|
||||
out.Parts = append(out.Parts, delta)
|
||||
}
|
||||
continue
|
||||
}
|
||||
delta := a.applyTextPart(p.Text)
|
||||
if delta.RawText != "" {
|
||||
out.ContentSeen = true
|
||||
}
|
||||
if delta.RawText != "" || delta.VisibleText != "" || delta.CitationOnly {
|
||||
out.Parts = append(out.Parts, delta)
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func (a *StreamAccumulator) applyThinkingPart(text string) StreamPartDelta {
|
||||
rawTrimmed := sse.TrimContinuationOverlapFromBuilder(&a.RawThinking, text)
|
||||
if rawTrimmed != "" {
|
||||
a.RawThinking.WriteString(rawTrimmed)
|
||||
}
|
||||
delta := StreamPartDelta{Type: "thinking", RawText: rawTrimmed}
|
||||
if !a.ThinkingEnabled || rawTrimmed == "" {
|
||||
return delta
|
||||
}
|
||||
cleanedText := CleanVisibleOutput(rawTrimmed, a.StripReferenceMarkers)
|
||||
if cleanedText == "" {
|
||||
return delta
|
||||
}
|
||||
trimmed := sse.TrimContinuationOverlapFromBuilder(&a.Thinking, cleanedText)
|
||||
if trimmed == "" {
|
||||
return delta
|
||||
}
|
||||
a.Thinking.WriteString(trimmed)
|
||||
delta.VisibleText = trimmed
|
||||
return delta
|
||||
}
|
||||
|
||||
func (a *StreamAccumulator) applyTextPart(text string) StreamPartDelta {
|
||||
rawTrimmed := sse.TrimContinuationOverlapFromBuilder(&a.RawText, text)
|
||||
if rawTrimmed == "" {
|
||||
return StreamPartDelta{Type: "text"}
|
||||
}
|
||||
a.RawText.WriteString(rawTrimmed)
|
||||
delta := StreamPartDelta{Type: "text", RawText: rawTrimmed}
|
||||
cleanedText := CleanVisibleOutput(rawTrimmed, a.StripReferenceMarkers)
|
||||
if a.SearchEnabled && sse.IsCitation(cleanedText) {
|
||||
delta.CitationOnly = true
|
||||
return delta
|
||||
}
|
||||
trimmed := sse.TrimContinuationOverlapFromBuilder(&a.Text, cleanedText)
|
||||
if trimmed == "" {
|
||||
return delta
|
||||
}
|
||||
a.Text.WriteString(trimmed)
|
||||
delta.VisibleText = trimmed
|
||||
return delta
|
||||
}
|
||||
97
internal/httpapi/openai/shared/stream_accumulator_test.go
Normal file
97
internal/httpapi/openai/shared/stream_accumulator_test.go
Normal file
@@ -0,0 +1,97 @@
|
||||
package shared
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"ds2api/internal/sse"
|
||||
)
|
||||
|
||||
func TestStreamAccumulatorAppliesThinkingAndTextDedupe(t *testing.T) {
|
||||
acc := StreamAccumulator{ThinkingEnabled: true, StripReferenceMarkers: true}
|
||||
thinkingPrefix := "this is a long thinking snapshot prefix used by DeepSeek continue replay"
|
||||
textPrefix := "this is a long visible answer snapshot prefix used by DeepSeek continue replay"
|
||||
first := acc.Apply(sse.LineResult{
|
||||
Parsed: true,
|
||||
Parts: []sse.ContentPart{
|
||||
{Type: "thinking", Text: thinkingPrefix},
|
||||
{Type: "text", Text: textPrefix},
|
||||
},
|
||||
})
|
||||
second := acc.Apply(sse.LineResult{
|
||||
Parsed: true,
|
||||
Parts: []sse.ContentPart{
|
||||
{Type: "thinking", Text: thinkingPrefix + " next"},
|
||||
{Type: "text", Text: textPrefix + " world"},
|
||||
},
|
||||
})
|
||||
|
||||
if !first.ContentSeen || !second.ContentSeen {
|
||||
t.Fatalf("expected both chunks to mark content seen")
|
||||
}
|
||||
if got := acc.RawThinking.String(); got != thinkingPrefix+" next" {
|
||||
t.Fatalf("raw thinking = %q", got)
|
||||
}
|
||||
if got := acc.Thinking.String(); got != thinkingPrefix+" next" {
|
||||
t.Fatalf("thinking = %q", got)
|
||||
}
|
||||
if got := acc.RawText.String(); got != textPrefix+" world" {
|
||||
t.Fatalf("raw text = %q", got)
|
||||
}
|
||||
if got := acc.Text.String(); got != textPrefix+" world" {
|
||||
t.Fatalf("text = %q", got)
|
||||
}
|
||||
if got := second.Parts[0].VisibleText; got != " next" {
|
||||
t.Fatalf("thinking delta = %q", got)
|
||||
}
|
||||
if got := second.Parts[1].VisibleText; got != " world" {
|
||||
t.Fatalf("text delta = %q", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestStreamAccumulatorKeepsHiddenThinkingForToolDetection(t *testing.T) {
|
||||
acc := StreamAccumulator{ThinkingEnabled: false, StripReferenceMarkers: true}
|
||||
result := acc.Apply(sse.LineResult{
|
||||
Parsed: true,
|
||||
Parts: []sse.ContentPart{
|
||||
{Type: "thinking", Text: "<tool_calls></tool_calls>"},
|
||||
},
|
||||
ToolDetectionThinkingParts: []sse.ContentPart{
|
||||
{Type: "thinking", Text: "detect"},
|
||||
{Type: "thinking", Text: " tools"},
|
||||
},
|
||||
})
|
||||
|
||||
if !result.ContentSeen {
|
||||
t.Fatalf("expected hidden thinking to count as upstream content")
|
||||
}
|
||||
if got := acc.RawThinking.String(); got != "<tool_calls></tool_calls>" {
|
||||
t.Fatalf("raw thinking = %q", got)
|
||||
}
|
||||
if got := acc.Thinking.String(); got != "" {
|
||||
t.Fatalf("visible thinking = %q", got)
|
||||
}
|
||||
if got := acc.ToolDetectionThinking.String(); got != "detect tools" {
|
||||
t.Fatalf("tool detection thinking = %q", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestStreamAccumulatorSuppressesCitationTextWhenSearchEnabled(t *testing.T) {
|
||||
acc := StreamAccumulator{SearchEnabled: true, StripReferenceMarkers: true}
|
||||
result := acc.Apply(sse.LineResult{
|
||||
Parsed: true,
|
||||
Parts: []sse.ContentPart{{Type: "text", Text: "[citation:1]"}},
|
||||
})
|
||||
|
||||
if !result.ContentSeen {
|
||||
t.Fatalf("expected citation chunk to mark upstream content")
|
||||
}
|
||||
if len(result.Parts) != 1 || !result.Parts[0].CitationOnly {
|
||||
t.Fatalf("expected citation-only delta, got %#v", result.Parts)
|
||||
}
|
||||
if got := acc.RawText.String(); got != "[citation:1]" {
|
||||
t.Fatalf("raw text = %q", got)
|
||||
}
|
||||
if got := acc.Text.String(); got != "" {
|
||||
t.Fatalf("visible text = %q", got)
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user