diff --git a/internal/thinking/text.go b/internal/thinking/text.go index eed1ba2879a..02a1e03275d 100644 --- a/internal/thinking/text.go +++ b/internal/thinking/text.go @@ -1,6 +1,9 @@ package thinking import ( + "regexp" + "strings" + "github.com/tidwall/gjson" ) @@ -39,3 +42,217 @@ func GetThinkingText(part gjson.Result) string { return "" } + +// thoughtTagPattern matches ... and ... tags, including +// multiline content. It is used to extract thinking content that providers like MiniMax +// embed inside XML tags within the regular content field. +var thoughtTagPattern = regexp.MustCompile(`([\s\S]*?)|([\s\S]*?)`) + +// StripThoughtTags removes ... and ... tags from text, +// returning the extracted thinking content and the cleaned visible text. +// +// Some OpenAI-compatible providers (e.g., MiniMax M3 via OpenCode Go) embed reasoning +// content inside XML tags within the regular content field instead of using the standard +// reasoning_content field. This function separates the tagged thinking content so it can +// be placed into proper Anthropic thinking blocks. +// +// If no tags are found, thinking returns empty string and clean is the original text. +func StripThoughtTags(text string) (thinking, clean string) { + if !strings.Contains(text, "") { + inner = match[len("") : len(match)-len("")] + } else if strings.HasPrefix(match, "") { + inner = match[len("") : len(match)-len("")] + } + if trimmed := strings.TrimSpace(inner); trimmed != "" { + thinkingParts = append(thinkingParts, trimmed) + } + return " " + }) + + if len(thinkingParts) > 0 { + thinking = strings.Join(thinkingParts, "\n") + } + // Collapse multiple spaces introduced by tag removal. + clean = strings.TrimSpace(clean) + clean = multiSpace.ReplaceAllString(clean, " ") + return thinking, clean +} + +// multiSpace matches two or more consecutive whitespace characters. +var multiSpace = regexp.MustCompile(`\s{2,}`) + +// ThoughtTagStripper maintains state for stripping thought tags across streaming chunks. +// Some providers emit tags that span multiple SSE delta chunks, requiring +// stateful processing. +type ThoughtTagStripper struct { + buf strings.Builder + inThought bool + currentTag string // "thought" or "think" + thinkingBuf strings.Builder +} + +// NewThoughtTagStripper creates a new ThoughtTagStripper instance. +func NewThoughtTagStripper() *ThoughtTagStripper { + return &ThoughtTagStripper{} +} + +// Feed processes a streaming content chunk and returns any extracted thinking text +// and the cleaned visible text. The thinking and visible outputs may each be empty +// if the chunk is entirely consumed by tag processing. +func (s *ThoughtTagStripper) Feed(chunk string) (thinking, visible string) { + s.buf.WriteString(chunk) + full := s.buf.String() + s.buf.Reset() + + var thinkingOut strings.Builder + var visibleOut strings.Builder + + emitVisible := func(text string) { + text = strings.TrimSpace(text) + if text == "" { + return + } + if visibleOut.Len() > 0 { + visibleOut.WriteByte(' ') + } + visibleOut.WriteString(text) + } + + emitThinking := func(text string) { + text = strings.TrimSpace(text) + if text == "" { + return + } + if thinkingOut.Len() > 0 { + thinkingOut.WriteByte('\n') + } + thinkingOut.WriteString(text) + } + + i := 0 + for i < len(full) { + if s.inThought { + closeTag := "" + closeIdx := strings.Index(full[i:], closeTag) + if closeIdx >= 0 { + s.thinkingBuf.WriteString(full[i : i+closeIdx]) + emitThinking(s.thinkingBuf.String()) + s.thinkingBuf.Reset() + i += closeIdx + len(closeTag) + s.inThought = false + s.currentTag = "" + } else { + s.thinkingBuf.WriteString(full[i:]) + i = len(full) + } + continue + } + + // Not in a thought — look for an opening tag. + thoughtIdx := strings.Index(full[i:], "") + thinkIdx := strings.Index(full[i:], "") + + openIdx := -1 + tagName := "" + if thoughtIdx >= 0 && (thinkIdx < 0 || thoughtIdx <= thinkIdx) { + openIdx = thoughtIdx + tagName = "thought" + } else if thinkIdx >= 0 { + openIdx = thinkIdx + tagName = "think" + } + + if openIdx < 0 { + // No opening tag in buffer. Check for a partial tag at the very end + // that could complete on the next chunk. + remaining := full[i:] + partialStart := lastPotentialTagPrefix(remaining) + if partialStart >= 0 { + emitVisible(remaining[:partialStart]) + // Keep the partial prefix in buffer for next chunk. + s.buf.WriteString(remaining[partialStart:]) + } else { + emitVisible(remaining) + } + i = len(full) + continue + } + + // Emit text before the tag. + emitVisible(full[i : i+openIdx]) + + // Enter thought. + i += openIdx + len("<"+tagName+">") + s.inThought = true + s.currentTag = tagName + + // Check if closing tag is in the same buffer. + closeTag := "" + if closeIdx := strings.Index(full[i:], closeTag); closeIdx >= 0 { + emitThinking(full[i : i+closeIdx]) + i += closeIdx + len(closeTag) + s.inThought = false + s.currentTag = "" + } else { + s.thinkingBuf.WriteString(full[i:]) + i = len(full) + } + } + + return thinkingOut.String(), visibleOut.String() +} + +// lastPotentialTagPrefix checks if the trailing portion of s looks like it could be +// the start of , , , or . Returns the index within s +// where the potential tag starts, or -1 if no partial tag prefix is detected. +func lastPotentialTagPrefix(s string) int { + lastLT := strings.LastIndex(s, "<") + if lastLT < 0 { + return -1 + } + candidate := s[lastLT:] + + for _, tag := range []string{ + "", "", "", "", + } { + if strings.HasPrefix(tag, candidate) && len(candidate) < len(tag) { + return lastLT + } + } + return -1 +} + +// Flush returns any remaining buffered thinking text. Call this when the stream +// ends to ensure no thinking content is lost. Also returns any remaining visible +// text in the buffer. +func (s *ThoughtTagStripper) Flush() (thinking, visible string) { + if s.inThought { + // Unterminated thought tag — treat buffered content as thinking + thinking = strings.TrimSpace(s.thinkingBuf.String()) + } + // Any remaining non-tag content + if !s.inThought { + visible = strings.TrimSpace(s.buf.String()) + } + s.buf.Reset() + s.thinkingBuf.Reset() + s.inThought = false + s.currentTag = "" + return thinking, visible +} + +// Reset clears all internal state for reuse. +func (s *ThoughtTagStripper) Reset() { + s.buf.Reset() + s.thinkingBuf.Reset() + s.inThought = false + s.currentTag = "" +} diff --git a/internal/thinking/text_test.go b/internal/thinking/text_test.go new file mode 100644 index 00000000000..811fedb9952 --- /dev/null +++ b/internal/thinking/text_test.go @@ -0,0 +1,277 @@ +package thinking + +import ( + "testing" +) + +func TestStripThoughtTags(t *testing.T) { + tests := []struct { + name string + input string + wantThinking string + wantClean string + }{ + { + name: "no tags", + input: "Hello, world!", + wantThinking: "", + wantClean: "Hello, world!", + }, + { + name: "single thought tag", + input: "let me think about thisHere is the answer", + wantThinking: "let me think about this", + wantClean: "Here is the answer", + }, + { + name: "only thought content", + input: "only reasoning", + wantThinking: "only reasoning", + wantClean: "", + }, + { + name: "think tag variant", + input: "reasoning with think tagThe answer", + wantThinking: "reasoning with think tag", + wantClean: "The answer", + }, + { + name: "multiple tags", + input: "first reasoningtext betweensecond reasoningfinal answer", + wantThinking: "first reasoning\nsecond reasoning", + wantClean: "text between final answer", + }, + { + name: "multiline thought", + input: "\nmulti\nline\nreasoning\n\nanswer", + wantThinking: "multi\nline\nreasoning", + wantClean: "answer", + }, + { + name: "mixed tag types", + input: "some thoughtvisiblesome thinkmore visible", + wantThinking: "some thought\nsome think", + wantClean: "visible more visible", + }, + { + name: "empty thought tag", + input: "clean text", + wantThinking: "", + wantClean: "clean text", + }, + { + name: "Chinese content", + input: "让我想一想这是回答", + wantThinking: "让我想一想", + wantClean: "这是回答", + }, + { + name: "code blocks with angle brackets", + input: "Here is code: `
` and `
` in HTML", + wantThinking: "", + wantClean: "Here is code: `
` and `
` in HTML", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + gotThinking, gotClean := StripThoughtTags(tt.input) + if gotThinking != tt.wantThinking { + t.Errorf("StripThoughtTags() thinking = %q, want %q", gotThinking, tt.wantThinking) + } + if gotClean != tt.wantClean { + t.Errorf("StripThoughtTags() clean = %q, want %q", gotClean, tt.wantClean) + } + }) + } +} + +func TestThoughtTagStripper_SingleChunk(t *testing.T) { + s := NewThoughtTagStripper() + + thinking, visible := s.Feed("reasoninganswer") + if thinking != "reasoning" { + t.Errorf("thinking = %q, want %q", thinking, "reasoning") + } + if visible != "answer" { + t.Errorf("visible = %q, want %q", visible, "answer") + } +} + +func TestThoughtTagStripper_MultiChunk(t *testing.T) { + s := NewThoughtTagStripper() + + // Chunk 1: Opening tag starts, partial + t1, v1 := s.Feed("reason") + if t2 != "" || v2 != "" { + t.Errorf("chunk 2: thinking=%q visible=%q, want both empty", t2, v2) + } + + // Chunk 3: More content + t3, v3 := s.Feed("ing") + if t3 != "" || v3 != "" { + t.Errorf("chunk 3: thinking=%q visible=%q, want both empty", t3, v3) + } + + // Chunk 4: Close tag + visible text + t4, v4 := s.Feed("
visible") + if t4 != "reasoning" { + t.Errorf("chunk 4 thinking = %q, want %q", t4, "reasoning") + } + if v4 != "visible" { + t.Errorf("chunk 4 visible = %q, want %q", v4, "visible") + } +} + +func TestThoughtTagStripper_Flush(t *testing.T) { + s := NewThoughtTagStripper() + + // Feed incomplete thought tag + s.Feed("unterminated thinking") + thinking, visible := s.Flush() + + if thinking != "unterminated thinking" { + t.Errorf("flush thinking = %q, want %q", thinking, "unterminated thinking") + } + if visible != "" { + t.Errorf("flush visible = %q, want empty", visible) + } +} + +func TestThoughtTagStripper_Reset(t *testing.T) { + s := NewThoughtTagStripper() + + s.Feed("some reasoning") + s.Reset() + + thinking, visible := s.Feed("clean text") + if thinking != "" { + t.Errorf("after reset: thinking = %q, want empty", thinking) + } + if visible != "clean text" { + t.Errorf("after reset: visible = %q, want %q", visible, "clean text") + } +} + +func TestThoughtTagStripper_ComplexStream(t *testing.T) { + s := NewThoughtTagStripper() + + chunks := []string{ + "Hello ", + "", + "let me ", + "think", + " about ", + "this", + " world", + } + + var allThinking, allVisible string + for _, chunk := range chunks { + tv, vs := s.Feed(chunk) + if tv != "" { + if allThinking != "" { + allThinking += "\n" + } + allThinking += tv + } + if vs != "" { + if allVisible != "" { + allVisible += " " + } + allVisible += vs + } + } + flushT, flushV := s.Flush() + if flushT != "" { + if allThinking != "" { + allThinking += "\n" + } + allThinking += flushT + } + if flushV != "" { + if allVisible != "" { + allVisible += " " + } + allVisible += flushV + } + + if allThinking != "let me think about this" { + t.Errorf("allThinking = %q, want %q", allThinking, "let me think about this") + } + if allVisible != "Hello world" { + t.Errorf("allVisible = %q, want %q", allVisible, "Hello world") + } +} + +func TestThoughtTagStripper_VisibleBeforeTag(t *testing.T) { + s := NewThoughtTagStripper() + + thinking, visible := s.Feed("visible beforehiddenvisible after") + if thinking != "hidden" { + t.Errorf("thinking = %q, want %q", thinking, "hidden") + } + if visible != "visible before visible after" { + t.Errorf("visible = %q, want %q", visible, "visible before visible after") + } +} + +func TestThoughtTagStripper_SpanAcrossChunks(t *testing.T) { + s := NewThoughtTagStripper() + + // Partial text, then opening tag starts + t1, v1 := s.Feed("visible1hiddenvisible2") + if t2 != "hidden" { + t.Errorf("chunk 2 thinking = %q, want %q", t2, "hidden") + } + if v2 != "visible2" { + t.Errorf("chunk 2 visible = %q, want %q", v2, "visible2") + } +} + +func TestThoughtTagStripper_NoTags(t *testing.T) { + s := NewThoughtTagStripper() + + // Plain text, no tags at all + thinking, visible := s.Feed("hello world") + if thinking != "" { + t.Errorf("thinking = %q, want empty", thinking) + } + if visible != "hello world" { + t.Errorf("visible = %q, want %q", visible, "hello world") + } +} + +func TestThoughtTagStripper_TagThenCloseInNextChunk(t *testing.T) { + s := NewThoughtTagStripper() + + // Full opening tag, no close + t1, v1 := s.Feed("reason") + if t1 != "" || v1 != "" { + t.Errorf("chunk 1: thinking=%q visible=%q, want both empty", t1, v1) + } + + // Close tag + t2, v2 := s.Feed("ing") + if t2 != "reasoning" { + t.Errorf("chunk 2 thinking = %q, want %q", t2, "reasoning") + } + if v2 != "" { + t.Errorf("chunk 2 visible = %q, want empty", v2) + } +} diff --git a/internal/translator/openai/claude/openai_claude_response.go b/internal/translator/openai/claude/openai_claude_response.go index 47f3f3897a2..2bb8a7972f4 100644 --- a/internal/translator/openai/claude/openai_claude_response.go +++ b/internal/translator/openai/claude/openai_claude_response.go @@ -11,6 +11,7 @@ import ( "sort" "strings" + "github.com/router-for-me/CLIProxyAPI/v7/internal/thinking" translatorcommon "github.com/router-for-me/CLIProxyAPI/v7/internal/translator/common" "github.com/router-for-me/CLIProxyAPI/v7/internal/util" "github.com/tidwall/gjson" @@ -57,6 +58,9 @@ type ConvertOpenAIResponseToAnthropicParams struct { ThinkingContentBlockIndex int // Next available content block index NextContentBlockIndex int + // ThoughtTagStripper strips inline / tags from content, extracting + // thinking text that must be emitted as proper Anthropic thinking blocks. + ThoughtTagStripper *thinking.ThoughtTagStripper } // ToolCallAccumulator holds the state for accumulating tool call data @@ -100,6 +104,7 @@ func ConvertOpenAIResponseToClaude(_ context.Context, _ string, originalRequestR TextContentBlockIndex: -1, ThinkingContentBlockIndex: -1, NextContentBlockIndex: 0, + ThoughtTagStripper: thinking.NewThoughtTagStripper(), } } @@ -194,28 +199,59 @@ func convertOpenAIStreamingChunkToAnthropic(rawJSON []byte, param *ConvertOpenAI // Handle content delta if content := delta.Get("content"); content.Exists() && content.String() != "" { - // Send content_block_start for text if not already sent - if !param.TextContentBlockStarted { - stopThinkingContentBlock(param, &results) - if param.TextContentBlockIndex == -1 { - param.TextContentBlockIndex = param.NextContentBlockIndex - param.NextContentBlockIndex++ + contentText := content.String() + + // Strip inline / tags that some providers (e.g., MiniMax M3 + // via OpenCode Go) embed inside the content field instead of using the + // standard reasoning_content field. + thinkingText, visibleText := param.ThoughtTagStripper.Feed(contentText) + + // Emit extracted thinking content as proper thinking blocks + if thinkingText != "" { + stopTextContentBlock(param, &results) + if !param.ThinkingContentBlockStarted { + if param.ThinkingContentBlockIndex == -1 { + param.ThinkingContentBlockIndex = param.NextContentBlockIndex + param.NextContentBlockIndex++ + } + contentBlockStartJSON := `{"type":"content_block_start","index":0,"content_block":{"type":"thinking","thinking":""}}` + contentBlockStartJSONBytes := []byte(contentBlockStartJSON) + contentBlockStartJSONBytes, _ = sjson.SetBytes(contentBlockStartJSONBytes, "index", param.ThinkingContentBlockIndex) + results = append(results, translatorcommon.AppendSSEEventBytes(nil, "content_block_start", contentBlockStartJSONBytes, 2)) + param.ThinkingContentBlockStarted = true } - contentBlockStartJSON := `{"type":"content_block_start","index":0,"content_block":{"type":"text","text":""}}` - contentBlockStartJSONBytes := []byte(contentBlockStartJSON) - contentBlockStartJSONBytes, _ = sjson.SetBytes(contentBlockStartJSONBytes, "index", param.TextContentBlockIndex) - results = append(results, translatorcommon.AppendSSEEventBytes(nil, "content_block_start", contentBlockStartJSONBytes, 2)) - param.TextContentBlockStarted = true + + thinkingDeltaJSON := `{"type":"content_block_delta","index":0,"delta":{"type":"thinking_delta","thinking":""}}` + thinkingDeltaJSONBytes := []byte(thinkingDeltaJSON) + thinkingDeltaJSONBytes, _ = sjson.SetBytes(thinkingDeltaJSONBytes, "index", param.ThinkingContentBlockIndex) + thinkingDeltaJSONBytes, _ = sjson.SetBytes(thinkingDeltaJSONBytes, "delta.thinking", thinkingText) + results = append(results, translatorcommon.AppendSSEEventBytes(nil, "content_block_delta", thinkingDeltaJSONBytes, 2)) } - contentDeltaJSON := `{"type":"content_block_delta","index":0,"delta":{"type":"text_delta","text":""}}` - contentDeltaJSONBytes := []byte(contentDeltaJSON) - contentDeltaJSONBytes, _ = sjson.SetBytes(contentDeltaJSONBytes, "index", param.TextContentBlockIndex) - contentDeltaJSONBytes, _ = sjson.SetBytes(contentDeltaJSONBytes, "delta.text", content.String()) - results = append(results, translatorcommon.AppendSSEEventBytes(nil, "content_block_delta", contentDeltaJSONBytes, 2)) + // Emit cleaned visible text (if any) + if visibleText != "" { + if !param.TextContentBlockStarted { + stopThinkingContentBlock(param, &results) + if param.TextContentBlockIndex == -1 { + param.TextContentBlockIndex = param.NextContentBlockIndex + param.NextContentBlockIndex++ + } + contentBlockStartJSON := `{"type":"content_block_start","index":0,"content_block":{"type":"text","text":""}}` + contentBlockStartJSONBytes := []byte(contentBlockStartJSON) + contentBlockStartJSONBytes, _ = sjson.SetBytes(contentBlockStartJSONBytes, "index", param.TextContentBlockIndex) + results = append(results, translatorcommon.AppendSSEEventBytes(nil, "content_block_start", contentBlockStartJSONBytes, 2)) + param.TextContentBlockStarted = true + } - // Accumulate content - param.ContentAccumulator.WriteString(content.String()) + contentDeltaJSON := `{"type":"content_block_delta","index":0,"delta":{"type":"text_delta","text":""}}` + contentDeltaJSONBytes := []byte(contentDeltaJSON) + contentDeltaJSONBytes, _ = sjson.SetBytes(contentDeltaJSONBytes, "index", param.TextContentBlockIndex) + contentDeltaJSONBytes, _ = sjson.SetBytes(contentDeltaJSONBytes, "delta.text", visibleText) + results = append(results, translatorcommon.AppendSSEEventBytes(nil, "content_block_delta", contentDeltaJSONBytes, 2)) + + // Accumulate content + param.ContentAccumulator.WriteString(visibleText) + } } // Handle tool calls @@ -363,6 +399,47 @@ func convertOpenAIStreamingChunkToAnthropic(rawJSON []byte, param *ConvertOpenAI func convertOpenAIDoneToAnthropic(param *ConvertOpenAIResponseToAnthropicParams) [][]byte { var results [][]byte + // Flush any remaining buffered tagged thinking content + if param.ThoughtTagStripper != nil { + thinkingText, visibleText := param.ThoughtTagStripper.Flush() + if thinkingText != "" { + if !param.ThinkingContentBlockStarted { + if param.ThinkingContentBlockIndex == -1 { + param.ThinkingContentBlockIndex = param.NextContentBlockIndex + param.NextContentBlockIndex++ + } + contentBlockStartJSON := `{"type":"content_block_start","index":0,"content_block":{"type":"thinking","thinking":""}}` + contentBlockStartJSONBytes := []byte(contentBlockStartJSON) + contentBlockStartJSONBytes, _ = sjson.SetBytes(contentBlockStartJSONBytes, "index", param.ThinkingContentBlockIndex) + results = append(results, translatorcommon.AppendSSEEventBytes(nil, "content_block_start", contentBlockStartJSONBytes, 2)) + param.ThinkingContentBlockStarted = true + } + thinkingDeltaJSON := `{"type":"content_block_delta","index":0,"delta":{"type":"thinking_delta","thinking":""}}` + thinkingDeltaJSONBytes := []byte(thinkingDeltaJSON) + thinkingDeltaJSONBytes, _ = sjson.SetBytes(thinkingDeltaJSONBytes, "index", param.ThinkingContentBlockIndex) + thinkingDeltaJSONBytes, _ = sjson.SetBytes(thinkingDeltaJSONBytes, "delta.thinking", thinkingText) + results = append(results, translatorcommon.AppendSSEEventBytes(nil, "content_block_delta", thinkingDeltaJSONBytes, 2)) + } + if visibleText != "" { + if !param.TextContentBlockStarted { + if param.TextContentBlockIndex == -1 { + param.TextContentBlockIndex = param.NextContentBlockIndex + param.NextContentBlockIndex++ + } + contentBlockStartJSON := `{"type":"content_block_start","index":0,"content_block":{"type":"text","text":""}}` + contentBlockStartJSONBytes := []byte(contentBlockStartJSON) + contentBlockStartJSONBytes, _ = sjson.SetBytes(contentBlockStartJSONBytes, "index", param.TextContentBlockIndex) + results = append(results, translatorcommon.AppendSSEEventBytes(nil, "content_block_start", contentBlockStartJSONBytes, 2)) + param.TextContentBlockStarted = true + } + contentDeltaJSON := `{"type":"content_block_delta","index":0,"delta":{"type":"text_delta","text":""}}` + contentDeltaJSONBytes := []byte(contentDeltaJSON) + contentDeltaJSONBytes, _ = sjson.SetBytes(contentDeltaJSONBytes, "index", param.TextContentBlockIndex) + contentDeltaJSONBytes, _ = sjson.SetBytes(contentDeltaJSONBytes, "delta.text", visibleText) + results = append(results, translatorcommon.AppendSSEEventBytes(nil, "content_block_delta", contentDeltaJSONBytes, 2)) + } + } + // Ensure all content blocks are stopped before final events if param.ThinkingContentBlockStarted { contentBlockStopJSON := []byte(`{"type":"content_block_stop","index":0}`) @@ -437,11 +514,23 @@ func convertOpenAINonStreamingToAnthropic(rawJSON []byte) [][]byte { out, _ = sjson.SetRawBytes(out, "content.-1", block) } - // Handle text content + // Handle text content — strip inline / tags that some + // providers (e.g., MiniMax M3 via OpenCode Go) embed inside the content + // field instead of using the standard reasoning_content field. if content := choice.Get("message.content"); content.Exists() && content.String() != "" { - block := []byte(`{"type":"text","text":""}`) - block, _ = sjson.SetBytes(block, "text", content.String()) - out, _ = sjson.SetRawBytes(out, "content.-1", block) + contentText := content.String() + thinkingText, visibleText := thinking.StripThoughtTags(contentText) + + if thinkingText != "" { + block := []byte(`{"type":"thinking","thinking":""}`) + block, _ = sjson.SetBytes(block, "thinking", thinkingText) + out, _ = sjson.SetRawBytes(out, "content.-1", block) + } + if visibleText != "" { + block := []byte(`{"type":"text","text":""}`) + block, _ = sjson.SetBytes(block, "text", visibleText) + out, _ = sjson.SetRawBytes(out, "content.-1", block) + } } // Handle tool calls @@ -660,7 +749,16 @@ func ConvertOpenAIResponseToClaudeNonStream(_ context.Context, _ string, origina switch item.Get("type").String() { case "text": flushThinking() - textBuilder.WriteString(item.Get("text").String()) + { + textVal := item.Get("text").String() + thoughtText, visibleText := thinking.StripThoughtTags(textVal) + if thoughtText != "" { + thinkingBuilder.WriteString(thoughtText) + } + if visibleText != "" { + textBuilder.WriteString(visibleText) + } + } case "tool_calls": flushThinking() flushText() @@ -704,9 +802,17 @@ func ConvertOpenAIResponseToClaudeNonStream(_ context.Context, _ string, origina } else if contentResult.Type == gjson.String { textContent := contentResult.String() if textContent != "" { - block := []byte(`{"type":"text","text":""}`) - block, _ = sjson.SetBytes(block, "text", textContent) - out, _ = sjson.SetRawBytes(out, "content.-1", block) + thoughtText, visibleText := thinking.StripThoughtTags(textContent) + if thoughtText != "" { + block := []byte(`{"type":"thinking","thinking":""}`) + block, _ = sjson.SetBytes(block, "thinking", thoughtText) + out, _ = sjson.SetRawBytes(out, "content.-1", block) + } + if visibleText != "" { + block := []byte(`{"type":"text","text":""}`) + block, _ = sjson.SetBytes(block, "text", visibleText) + out, _ = sjson.SetRawBytes(out, "content.-1", block) + } } } }