fix: strip inline <thought>/<think> tags from OpenAI-compatible provider content#4037
fix: strip inline <thought>/<think> tags from OpenAI-compatible provider content#4037WizisCool wants to merge 1 commit into
Conversation
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. @
|
This pull request targeted The base branch has been automatically changed to |
There was a problem hiding this comment.
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.
| 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() | ||
| } |
There was a problem hiding this comment.
There are two critical issues in the streaming Feed implementation:
- 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"). - 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"tos.thinkingBufand doesn't buffer it ins.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()
}| // 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)) | ||
| } | ||
| } |
There was a problem hiding this comment.
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))
}
}| 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 | ||
| } |
There was a problem hiding this comment.
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.
| 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 | |
| } |
There was a problem hiding this comment.
💡 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".
| var visibleOut strings.Builder | ||
|
|
||
| emitVisible := func(text string) { | ||
| text = strings.TrimSpace(text) |
There was a problem hiding this comment.
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 👍 / 👎.
| } else { | ||
| s.thinkingBuf.WriteString(full[i:]) | ||
| i = len(full) |
There was a problem hiding this comment.
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 👍 / 👎.
Problem
Some OpenAI-compatible providers (e.g., MiniMax M3 via OpenCode Go) embed reasoning/thinking content inside
<thought>or<think>XML tags within the regularcontentfield instead of using the standardreasoning_contentfield.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
textcontent blocks instead of being wrapped in properthinkingblocks.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
contentdelta:{"choices":[{"delta":{"content":"<thought>let me think about this...</thought>final answer"}}]}CLIProxyAPI's OpenAI→Claude translator already correctly maps the
reasoning_contentfield to Anthropicthinkingblocks (lines 174-197 ofopenai_claude_response.go), but does not handle the tag-embedded variant.Fix
New:
internal/thinking/text.goStripThoughtTags(text)— Stateless function for non-streaming: regex-matches<thought>...</thought>and<think>...</think>tags, extracts tagged content as thinking text, returns cleaned visible textThoughtTagStripper— Stateful stream processor for the streaming path: handles tags that span multiple SSE delta chunks (e.g.,<thouin chunk 1,ght>reasoning</thought>in chunk 2). Emits accumulated thinking text when closing tag is seen. Buffers partial tag prefixes between chunksModified:
internal/translator/openai/claude/openai_claude_response.goThoughtTagStripperfield toConvertOpenAIResponseToAnthropicParamsconvertOpenAIStreamingChunkToAnthropic):contentdelta runs throughThoughtTagStripper.Feed(); extracted thinking emitted ascontent_block_start(type:thinking)+content_block_delta(type:thinking_delta); cleaned text emitted as normal text blocksconvertOpenAINonStreamingToAnthropicandConvertOpenAIResponseToClaudeNonStream): applyStripThoughtTagstomessage.contentstrings and array-based text items[DONE]handler (convertOpenAIDoneToAnthropic): callsThoughtTagStripper.Flush()to emit any remaining buffered tagged thinking contentVerification
go build ./cmd/server— compiles cleanlygo test ./internal/thinking/...— 19/19 tests passgo test ./internal/translator/openai/claude/...— all 26 existing tests pass (no regressions)go vet ./internal/thinking/... ./internal/translator/openai/claude/...— cleangofmt -wappliedNon-Goals
This fix handles the specific case of XML-tag-embedded thinking content. It does not implement:
OpenAICompatibilityconfig if needed