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 := "" + s.currentTag + ">"
+ 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 := "" + tagName + ">"
+ 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)
+ }
}
}
}