mirror of
https://github.com/CJackHwang/ds2api.git
synced 2026-05-08 02:15:27 +08:00
refactor: implement auto-transition from thinking to text content upon detecting </think> tags and remove unused helper functions
This commit is contained in:
@@ -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{}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user