Skip to content

fix: strip inline <thought>/<think> tags from OpenAI-compatible provider content#4037

Open
WizisCool wants to merge 1 commit into
router-for-me:devfrom
WizisCool:fix/minimax-thought-tag-leak
Open

fix: strip inline <thought>/<think> tags from OpenAI-compatible provider content#4037
WizisCool wants to merge 1 commit into
router-for-me:devfrom
WizisCool:fix/minimax-thought-tag-leak

Conversation

@WizisCool

Copy link
Copy Markdown

Problem

Some OpenAI-compatible providers (e.g., MiniMax M3 via OpenCode Go) embed reasoning/thinking content inside <thought> or <think> XML tags within the regular content field instead of using the standard reasoning_content field.

When CLIProxyAPI converts these responses to Anthropic format for use with Claude Code and other clients, the thinking content leaks as visible text in the output — it is emitted as regular text content blocks instead of being wrapped in proper thinking blocks.

This is the same class of bug reported in:

Root Cause

The MiniMax M3 API (and similar reasoning models behind proxies like OpenCode Go) returns thinking content inline via XML tags in the content delta:

{"choices":[{"delta":{"content":"<thought>let me think about this...</thought>final answer"}}]}

CLIProxyAPI's OpenAI→Claude translator already correctly maps the reasoning_content field to Anthropic thinking blocks (lines 174-197 of openai_claude_response.go), but does not handle the tag-embedded variant.

Fix

New: internal/thinking/text.go

  • StripThoughtTags(text) — Stateless function for non-streaming: regex-matches <thought>...</thought> and <think>...</think> tags, extracts tagged content as thinking text, returns cleaned visible text
  • ThoughtTagStripper — Stateful stream processor for the streaming path: handles tags that span multiple SSE delta chunks (e.g., <thou in chunk 1, ght>reasoning</thought> in chunk 2). Emits accumulated thinking text when closing tag is seen. Buffers partial tag prefixes between chunks
  • Comprehensive test suite (19 tests) covering: single/multi-chunk, span-across-chunks, flush, reset, mixed tag types, Chinese content, code blocks with angle brackets (not false-matched)

Modified: internal/translator/openai/claude/openai_claude_response.go

  • Added ThoughtTagStripper field to ConvertOpenAIResponseToAnthropicParams
  • Streaming path (convertOpenAIStreamingChunkToAnthropic): content delta runs through ThoughtTagStripper.Feed(); extracted thinking emitted as content_block_start(type:thinking) + content_block_delta(type:thinking_delta); cleaned text emitted as normal text blocks
  • Non-streaming paths (convertOpenAINonStreamingToAnthropic and ConvertOpenAIResponseToClaudeNonStream): apply StripThoughtTags to message.content strings and array-based text items
  • [DONE] handler (convertOpenAIDoneToAnthropic): calls ThoughtTagStripper.Flush() to emit any remaining buffered tagged thinking content

Verification

  • go build ./cmd/server — compiles cleanly
  • go test ./internal/thinking/... — 19/19 tests pass
  • go test ./internal/translator/openai/claude/... — all 26 existing tests pass (no regressions)
  • go vet ./internal/thinking/... ./internal/translator/openai/claude/... — clean
  • gofmt -w applied

Non-Goals

This fix handles the specific case of XML-tag-embedded thinking content. It does not implement:

  • Heuristic detection of untagged reasoning patterns ("Let me think..." style) — that would require model-specific configuration
  • Per-model enable/disable toggle — can be added later via OpenAICompatibility config if needed

@
fix: strip inline <thought>/<think> tags from OpenAI-compatible provider content

Some OpenAI-compatible providers (e.g., MiniMax M3 via OpenCode Go) embed reasoning/thinking content inside `<thought>` or `<think>` XML tags within the regular `content` field instead of using the standard `reasoning_content` field. When converted to Anthropic format, this thinking content leaked as visible text to the client.

Changes:
- Add `StripThoughtTags()` to `internal/thinking/text.go` for non-streaming: extracts tagged thinking text and returns it separately from cleaned visible text
- Add `ThoughtTagStripper` stateful stream processor for streaming: handles tags that span multiple SSE delta chunks, emitting thinking content as proper Anthropic `thinking` blocks
- Wire `ThoughtTagStripper` into `openai_claude_response.go` streaming path via `ConvertOpenAIResponseToAnthropicParams`
- Apply `StripThoughtTags` in non-streaming `convertOpenAINonStreamingToAnthropic` and `ConvertOpenAIResponseToClaudeNonStream`
- Flush buffered tagged content on `[DONE]` marker to ensure no thinking text is dropped

Fixes the issue where MiniMax M3 internal reasoning appears as visible text in Claude Code and other Anthropic-protocol clients when routed through OpenCode Go subscriptions.
@
@github-actions github-actions Bot changed the base branch from main to dev June 28, 2026 15:38
@github-actions

Copy link
Copy Markdown

This pull request targeted main.

The base branch has been automatically changed to dev.

@gemini-code-assist gemini-code-assist Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Code Review

This pull request introduces ThoughtTagStripper and StripThoughtTags to extract inline <thought> and <think> tags from OpenAI-compatible provider responses and convert them into Anthropic thinking blocks. The review comments identify critical issues in the streaming implementation: Feed destroys whitespace formatting and fails to handle split closing tags across chunks, convertOpenAIDoneToAnthropic starts new content blocks without stopping active ones during flushing, and Flush discards partial closing tags and trims trailing whitespace. Correcting these issues with the provided suggestions will ensure robust streaming behavior and protocol compliance.

Important

The consumer version of Gemini Code Assist on GitHub is being sunset. Starting June 18, 2026, new organization installations will be blocked, and all code review activity will officially cease on July 17, 2026.
For more details on the timeline and next steps, please review the Help Documentation.

Comment thread internal/thinking/text.go
Comment on lines +110 to +211
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:], "<thought>")
thinkIdx := strings.Index(full[i:], "<think>")

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()
}

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

critical

There are two critical issues in the streaming Feed implementation:

  1. Whitespace and Newline Destruction: Calling strings.TrimSpace(text) on every chunk and joining them with spaces completely destroys formatting, newlines, and spaces in the streaming output. For example, if a chunk is just "\n", it is ignored. If a chunk ends with a space, that space is lost, causing words to run together (e.g., "hello " and "world" becomes "helloworld").
  2. Split Closing Tags: If a closing tag (e.g., </thought>) is split across streaming chunks (e.g., Chunk 1 ends with "</th" and Chunk 2 starts with "ought>"), the stripper fails to detect it. It writes "</th" to s.thinkingBuf and doesn't buffer it in s.buf. In the next chunk, it doesn't find "</thought>" because the prefix is missing. As a result, the tag is never closed, and the entire rest of the stream is incorrectly treated as thinking content.

We should rewrite Feed to write directly to visibleOut and thinkingOut without trimming or joining with spaces, and check for partial closing tag prefixes at the end of the remaining string using lastPotentialTagPrefix when s.inThought is true. Note that the tests in internal/thinking/text_test.go (specifically TestThoughtTagStripper_ComplexStream and TestThoughtTagStripper_VisibleBeforeTag) will also need to be updated to expect the correct, non-destructive streaming behavior.

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) {
		visibleOut.WriteString(text)
	}

	emitThinking := func(text string) {
		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 {
				remaining := full[i:]
				partialStart := lastPotentialTagPrefix(remaining)
				if partialStart >= 0 {
					s.thinkingBuf.WriteString(remaining[:partialStart])
					s.buf.WriteString(remaining[partialStart:])
				} else {
					s.thinkingBuf.WriteString(remaining)
				}
				i = len(full)
			}
			continue
		}

		// Not in a thought — look for an opening tag.
		thoughtIdx := strings.Index(full[i:], "<thought>")
		thinkIdx := strings.Index(full[i:], "<think>")

		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 {
			remaining := full[i:]
			partialStart := lastPotentialTagPrefix(remaining)
			if partialStart >= 0 {
				s.thinkingBuf.WriteString(remaining[:partialStart])
				s.buf.WriteString(remaining[partialStart:])
			} else {
				s.thinkingBuf.WriteString(remaining)
			}
			i = len(full)
		}
	}

	return thinkingOut.String(), visibleOut.String()
}

Comment on lines +402 to +441
// 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))
}
}

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

high

In convertOpenAIDoneToAnthropic, when flushing remaining buffered thinking or visible text, the code does not call stopTextContentBlock or stopThinkingContentBlock before starting the other block. This violates the Anthropic protocol because you cannot start a new content block without stopping the active one first. We should call stopTextContentBlock before starting the thinking block, and stopThinkingContentBlock before starting the text block.

	// Flush any remaining buffered tagged thinking content
	if param.ThoughtTagStripper != nil {
		thinkingText, visibleText := param.ThoughtTagStripper.Flush()
		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
			}
			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 {
				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
			}
			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))
		}
	}

Comment thread internal/thinking/text.go
Comment on lines +236 to +250
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
}

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

medium

In Flush(), if s.inThought is true, any partial closing tag buffered in s.buf is discarded instead of being appended to the thinking content. We should append s.buf.String() to s.thinkingBuf.String() when treating unterminated thought tags as thinking, and avoid trimming visible to preserve trailing whitespace of the stream.

Suggested change
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
}
func (s *ThoughtTagStripper) Flush() (thinking, visible string) {
if s.inThought {
// Unterminated thought tag — treat buffered content as thinking
thinking = s.thinkingBuf.String() + s.buf.String()
}
// Any remaining non-tag content
if !s.inThought {
visible = s.buf.String()
}
s.buf.Reset()
s.thinkingBuf.Reset()
s.inThought = false
s.currentTag = ""
return thinking, visible
}

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: 9d60fda2c1

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

Comment thread internal/thinking/text.go
var visibleOut strings.Builder

emitVisible := func(text string) {
text = strings.TrimSpace(text)

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Badge Preserve whitespace in streaming text deltas

When normal streaming content is passed through Feed, this TrimSpace strips leading/trailing whitespace from every delta even when there are no thought tags. OpenAI-style streams commonly send tokens like "Hello" followed by " world"; after this change the emitted Anthropic text deltas become "Hello" and "world", so clients reconstruct "Helloworld" instead of the original text. The stripper should preserve visible bytes outside tags rather than trimming each chunk.

Useful? React with 👍 / 👎.

Comment thread internal/thinking/text.go
Comment on lines +152 to +154
} else {
s.thinkingBuf.WriteString(full[i:])
i = len(full)

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Badge Buffer partial closing tags inside thought blocks

When the closing tag is split across SSE chunks, for example "<thought>why</thou" followed by "ght>answer", this branch appends the partial </thou to thinkingBuf and discards the chunk boundary. The next feed can no longer match </thought>, so ght>answer is treated as thinking and the visible answer is swallowed until flush. This is the same split-chunk case the stateful stripper is meant to handle, but it only buffers partial opening tags outside a thought block.

Useful? React with 👍 / 👎.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant