refactor: implement auto-transition from thinking to text content upon detecting </think> tags and remove unused helper functions

This commit is contained in:
CJACK
2026-04-19 18:05:38 +08:00
parent 6688e0ba35
commit a1ce954ad5
6 changed files with 125 additions and 120 deletions

View File

@@ -58,21 +58,6 @@ func parseSSEDataFrames(t *testing.T, body string) ([]map[string]any, bool) {
return frames, done
}
func streamHasRawToolJSONContent(frames []map[string]any) bool {
for _, frame := range frames {
choices, _ := frame["choices"].([]any)
for _, item := range choices {
choice, _ := item.(map[string]any)
delta, _ := choice["delta"].(map[string]any)
content, _ := delta["content"].(string)
if strings.Contains(content, `"tool_calls"`) {
return true
}
}
}
return false
}
func streamHasToolCallsDelta(frames []map[string]any) bool {
for _, frame := range frames {
choices, _ := frame["choices"].([]any)
@@ -100,26 +85,6 @@ func streamFinishReason(frames []map[string]any) string {
return ""
}
func streamToolCallArgumentChunks(frames []map[string]any) []string {
out := make([]string, 0, 4)
for _, frame := range frames {
choices, _ := frame["choices"].([]any)
for _, item := range choices {
choice, _ := item.(map[string]any)
delta, _ := choice["delta"].(map[string]any)
toolCalls, _ := delta["tool_calls"].([]any)
for _, tc := range toolCalls {
tcm, _ := tc.(map[string]any)
fn, _ := tcm["function"].(map[string]any)
if args, ok := fn["arguments"].(string); ok && args != "" {
out = append(out, args)
}
}
}
}
return out
}
// Backward-compatible alias for historical test name used in CI logs.
func TestHandleNonStreamReturns429WhenUpstreamOutputEmpty(t *testing.T) {
h := &Handler{}

View File

@@ -325,30 +325,3 @@ func extractSSEEventPayload(body, targetEvent string) (map[string]any, bool) {
}
return nil, false
}
func extractAllSSEEventPayloads(body, targetEvent string) []map[string]any {
scanner := bufio.NewScanner(strings.NewReader(body))
matched := false
out := make([]map[string]any, 0, 2)
for scanner.Scan() {
line := strings.TrimSpace(scanner.Text())
if strings.HasPrefix(line, "event: ") {
evt := strings.TrimSpace(strings.TrimPrefix(line, "event: "))
matched = evt == targetEvent
continue
}
if !matched || !strings.HasPrefix(line, "data: ") {
continue
}
raw := strings.TrimSpace(strings.TrimPrefix(line, "data: "))
if raw == "" || raw == "[DONE]" {
continue
}
var payload map[string]any
if err := json.Unmarshal([]byte(raw), &payload); err != nil {
continue
}
out = append(out, payload)
}
return out
}

View File

@@ -2,48 +2,6 @@ package openai
import "strings"
func extractJSONObjectFrom(text string, start int) (string, int, bool) {
if start < 0 || start >= len(text) || text[start] != '{' {
return "", 0, false
}
depth := 0
quote := byte(0)
escaped := false
for i := start; i < len(text); i++ {
ch := text[i]
if quote != 0 {
if escaped {
escaped = false
continue
}
if ch == '\\' {
escaped = true
continue
}
if ch == quote {
quote = 0
}
continue
}
if ch == '"' || ch == '\'' {
quote = ch
continue
}
if ch == '{' {
depth++
continue
}
if ch == '}' {
depth--
if depth == 0 {
end := i + 1
return text[start:end], end, true
}
}
}
return "", 0, false
}
func trimWrappingJSONFence(prefix, suffix string) (string, string) {
trimmedPrefix := strings.TrimRight(prefix, " \t\r\n")
fenceIdx := strings.LastIndex(trimmedPrefix, "```")
@@ -67,18 +25,3 @@ func trimWrappingJSONFence(prefix, suffix string) (string, string) {
consumedLeading := len(suffix) - len(trimmedSuffix)
return trimmedPrefix[:fenceIdx], suffix[consumedLeading+3:]
}
func openFenceStartBefore(s string, pos int) (int, bool) {
if pos <= 0 || pos > len(s) {
return -1, false
}
segment := s[:pos]
lastFence := strings.LastIndex(segment, "```")
if lastFence < 0 {
return -1, false
}
if strings.Count(segment, "```")%2 == 1 {
return lastFence, true
}
return -1, false
}

View File

@@ -3,6 +3,7 @@ package sse
import (
"bytes"
"encoding/json"
"regexp"
"strings"
"ds2api/internal/deepseek"
@@ -93,6 +94,11 @@ func ParseSSEChunkForContent(chunk map[string]any, thinkingEnabled bool, current
if finished {
return nil, true, newType
}
var transitioned bool
parts, transitioned = splitThinkingParts(parts)
if transitioned {
newType = "text"
}
return parts, false, newType
}
@@ -166,6 +172,9 @@ func updateTypeFromNestedResponse(path string, v any, newType *string) {
func resolvePartType(path string, thinkingEnabled bool, newType string) string {
switch {
case path == "response/thinking_content":
if newType == "text" {
return "text"
}
return "thinking"
case path == "response/content":
return "text"
@@ -244,6 +253,59 @@ func appendContentPart(parts *[]ContentPart, content, kind string) {
*parts = append(*parts, ContentPart{Text: content, Type: kind})
}
var thinkClosePattern = regexp.MustCompile(`(?i)</\s*think\s*>`)
var thinkOpenPattern = regexp.MustCompile(`(?i)<\s*think\s*>`)
// splitThinkingParts detects </think> inside thinking content and
// auto-transitions everything after it to text. This handles the
// DeepSeek API bug where the upstream SSE keeps sending
// reasoning_content even though the model has finished thinking.
func splitThinkingParts(parts []ContentPart) ([]ContentPart, bool) {
var out []ContentPart
thinkingDone := false
for _, p := range parts {
if thinkingDone && p.Type == "thinking" {
// Already transitioned — treat remaining thinking as text.
cleaned := stripThinkTags(p.Text)
if cleaned != "" {
out = append(out, ContentPart{Text: cleaned, Type: "text"})
}
continue
}
if p.Type != "thinking" {
out = append(out, p)
continue
}
loc := thinkClosePattern.FindStringIndex(p.Text)
if loc == nil {
out = append(out, p)
continue
}
// Split at </think>: before is still thinking, after becomes text.
thinkingDone = true
before := p.Text[:loc[0]]
after := p.Text[loc[1]:]
if before != "" {
out = append(out, ContentPart{Text: before, Type: "thinking"})
}
after = stripThinkTags(after)
if after != "" {
out = append(out, ContentPart{Text: after, Type: "text"})
}
}
if !thinkingDone {
return parts, false
}
return out, true
}
// stripThinkTags removes any remaining <think> or </think> tags from text.
func stripThinkTags(s string) string {
s = thinkClosePattern.ReplaceAllString(s, "")
s = thinkOpenPattern.ReplaceAllString(s, "")
return s
}
func isStatusPath(path string) bool {
return path == "response/status" || path == "status"
}

View File

@@ -87,3 +87,65 @@ func TestParseSSEChunkForContentAfterAppendUsesUpdatedType(t *testing.T) {
t.Fatalf("unexpected parts: %#v", parts)
}
}
func TestParseSSEChunkForContentAutoTransitionsThinkClose(t *testing.T) {
chunk := map[string]any{
"p": "response/thinking_content",
"v": "deep thoughts</think>actual answer",
}
parts, _, _ := ParseSSEChunkForContent(chunk, true, "thinking")
if len(parts) != 2 {
t.Fatalf("expected 2 parts from split, got %d: %#v", len(parts), parts)
}
if parts[0].Type != "thinking" || parts[0].Text != "deep thoughts" {
t.Fatalf("first part should be thinking: %#v", parts[0])
}
if parts[1].Type != "text" || parts[1].Text != "actual answer" {
t.Fatalf("second part should be text: %#v", parts[1])
}
}
func TestParseSSEChunkForContentStripsLeakedThinkTags(t *testing.T) {
chunk := map[string]any{
"p": "response/thinking_content",
"v": "<think>more thoughts</think> answer",
}
parts, _, _ := ParseSSEChunkForContent(chunk, true, "thinking")
if len(parts) != 2 {
t.Fatalf("expected 2 parts, got %d: %#v", len(parts), parts)
}
if parts[0].Type != "thinking" || parts[0].Text != "<think>more thoughts" {
// note: the open tag is before the split, so it remains in the thinking part.
// that's fine, the output sanitization handles the final string.
t.Fatalf("first part mismatch: %#v", parts[0])
}
if parts[1].Type != "text" || parts[1].Text != " answer" {
t.Fatalf("second part mismatch: %#v", parts[1])
}
}
func TestParseSSEChunkForContentAutoTransitionsState(t *testing.T) {
chunk1 := map[string]any{
"p": "response/thinking_content",
"v": "end of thought</think>start of text",
}
parts1, _, nextType1 := ParseSSEChunkForContent(chunk1, true, "thinking")
if len(parts1) != 2 || parts1[1].Type != "text" {
t.Fatalf("expected split parts, got %#v", parts1)
}
if nextType1 != "text" {
t.Fatalf("expected nextType to transition to text, got %q", nextType1)
}
chunk2 := map[string]any{
"p": "response/thinking_content",
"v": "more actual text sent to thinking path",
}
parts2, _, nextType2 := ParseSSEChunkForContent(chunk2, true, nextType1)
if len(parts2) != 1 || parts2[0].Type != "text" {
t.Fatalf("expected subsequent parts to be text, got %#v", parts2)
}
if nextType2 != "text" {
t.Fatalf("expected nextType2 to remain text, got %q", nextType2)
}
}

View File

@@ -85,7 +85,7 @@ func parseMarkupSingleToolCall(attrs string, inner string) ParsedToolCall {
}
if strings.TrimSpace(name) != "" {
input := parseToolCallInput(obj["input"])
if input == nil || len(input) == 0 {
if len(input) == 0 {
if args, ok := obj["arguments"]; ok {
input = parseToolCallInput(args)
}