diff --git a/internal/adapter/openai/handler_toolcall_test.go b/internal/adapter/openai/handler_toolcall_test.go
index fb942f6..bad8820 100644
--- a/internal/adapter/openai/handler_toolcall_test.go
+++ b/internal/adapter/openai/handler_toolcall_test.go
@@ -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{}
diff --git a/internal/adapter/openai/responses_stream_test.go b/internal/adapter/openai/responses_stream_test.go
index 19c6402..2e139d3 100644
--- a/internal/adapter/openai/responses_stream_test.go
+++ b/internal/adapter/openai/responses_stream_test.go
@@ -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
-}
diff --git a/internal/adapter/openai/tool_sieve_jsonscan.go b/internal/adapter/openai/tool_sieve_jsonscan.go
index 57a808b..6568721 100644
--- a/internal/adapter/openai/tool_sieve_jsonscan.go
+++ b/internal/adapter/openai/tool_sieve_jsonscan.go
@@ -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
-}
diff --git a/internal/sse/parser.go b/internal/sse/parser.go
index 1d8f115..f051225 100644
--- a/internal/sse/parser.go
+++ b/internal/sse/parser.go
@@ -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 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 : 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 or 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"
}
diff --git a/internal/sse/parser_test.go b/internal/sse/parser_test.go
index b036f57..1e494f5 100644
--- a/internal/sse/parser_test.go
+++ b/internal/sse/parser_test.go
@@ -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 thoughtsactual 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": "more thoughts 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 != "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 thoughtstart 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)
+ }
+}
diff --git a/internal/toolcall/toolcalls_markup.go b/internal/toolcall/toolcalls_markup.go
index 23cced1..d17d1ff 100644
--- a/internal/toolcall/toolcalls_markup.go
+++ b/internal/toolcall/toolcalls_markup.go
@@ -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)
}