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)`) +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) }