Skip to content

fix: parse Gemma 4 <thought> reasoning tags alongside <think>#324

Merged
edelauna merged 7 commits into
Zoo-Code-Org:mainfrom
sagidM:fix/gemma4-fix-thought-tags
Jun 26, 2026
Merged

fix: parse Gemma 4 <thought> reasoning tags alongside <think>#324
edelauna merged 7 commits into
Zoo-Code-Org:mainfrom
sagidM:fix/gemma4-fix-thought-tags

Conversation

@sagidM

@sagidM sagidM commented May 25, 2026

Copy link
Copy Markdown
Contributor

Related GitHub Issue

Closes #323
Closes a similar issue: #7615 from Roo Code
Similar PR to #7617 from Roo Code

Description

Gemma 4 streams reasoning inside <thought>...</thought> instead of <think>...</think>. Without this the content leaks into chat text and the agent triggers a retry on the first turn.

  • TagMatcher: support multiple tag names - string[], track activeTagName so <think> is never closed by </thought> (and vice-versa).
  • base-openai-compatible-provider and openai handler: match both tags.
  • Tests: <thought> parsing, cross-tag isolation, and invariants.

Test Procedure

To Reproduce
Steps to reproduce the behavior:

  1. Add an OpenAI-Compatible provider pointing to a Gemma 4 model.

  2. Base URL set to https://generativelanguage.googleapis.com/v1beta/openai

  3. Set API Key obtained from https://aistudio.google.com/api-keys

  4. Choose a reasoning Gemma4 model. There are only 2 options: models/gemma-4-26b-a4b-it and models/gemma-4-31b-it. Optionally, you can set Context Window Size to 256000 below.
    ImageExpected behavior

  5. Save. Make sure it is selected down below.

  6. Send any simple prompt (e.g., "2+2? Give only the answer")

  7. Observe raw ... in output

Image

Version: 3.55.0 (d63e7bd)

The tags <thought></thought> wrapped around the reasoning are not expected to show up. Moreover, the whole reasoning should be wrapped into the expandable Thinking item.

Expected

The reasoning/thinking content should be wrapped into an expandable menu option.

Image

Version: 3.55.0 (6966807)

Pre-Submission Checklist

  • Issue Linked: This PR is linked to an approved GitHub Issue (see "Related GitHub Issue" above).
  • Scope: My changes are focused on the linked issue (one major feature/fix per PR).
  • Self-Review: I have performed a thorough self-review of my code.
  • Testing: New and/or updated tests have been added to cover my changes (if applicable).
  • Documentation Impact: I have considered if my changes require documentation updates (see "Documentation Updates" section below).
  • Contribution Guidelines: I have read and agree to the Contributor Guidelines.

Documentation Updates

  • No documentation updates are required.

Additional Notes

I specified some additional notes in the issue but they are not related to this PR.

Get in Touch

Telegram: sagidM
Discord: sagidm

Summary by CodeRabbit

  • New Features

    • Streaming reasoning-tag parsing now recognizes both <think> and <thought> markers, emitting the correct streamed chunks as reasoning vs text.
    • Improved robustness for nested and mixed tags, including mismatched closing tags and correct end-of-stream flushing of partial reasoning.
  • Tests

    • Expanded coverage for streaming tag extraction and chunking behavior, including multi-tag matching, nesting/stack unwinding, missing/no tags, and incomplete tag handling at end-of-stream.

@coderabbitai

coderabbitai Bot commented May 25, 2026

Copy link
Copy Markdown
Contributor

Review Change Stack

Note

Reviews paused

It looks like this branch is under active development. To avoid overwhelming you with review comments due to an influx of new commits, CodeRabbit has automatically paused this review. You can configure this behavior by changing the reviews.auto_review.auto_pause_after_reviewed_commits setting.

Use the following commands to manage reviews:

  • @coderabbitai resume to resume automatic reviews.
  • @coderabbitai review to trigger a single review.

Use the checkboxes below for quick actions:

  • ▶️ Resume reviews
  • 🔍 Trigger review
📝 Walkthrough

Walkthrough

TagMatcher now accepts multiple reasoning tag names, and providers pass both think and thought. Streaming tests now cover parsing, nesting, flushing, mismatched closes, and combined reasoning output.

Changes

Multi-Tag Reasoning Support

Layer / File(s) Summary
TagMatcher multi-tag state machine
src/utils/tag-matcher.ts
Constructor now accepts `string
Provider reasoning tag configuration
src/api/providers/base-openai-compatible-provider.ts, src/api/providers/openai.ts
Providers now initialize TagMatcher with both think and thought, changing which streamed chunks are classified as reasoning.
Streaming tests for reasoning tags
src/api/providers/__tests__/base-openai-compatible-provider.spec.ts, src/api/providers/__tests__/openai.spec.ts
New tests cover <thought> extraction, mismatched closing-tag handling, no-tag and stray-close cases, end-of-stream flushing, single-chunk complete tags, nested mixed tags, and reasoning_content alongside tag-based reasoning.

Estimated code review effort

🎯 3 (Moderate) | ⏱️ ~25 minutes

Suggested reviewers

  • taltas
  • hannesrudolph
  • JamesRobert20

Poem

🐰 I hop through tags from stream to stream,
think and thought now join the team,
Hidden thoughts now stay tucked in,
Reasoning glows beneath the skin,
A tidy stream makes rabbits beam 🥕

🚥 Pre-merge checks | ✅ 5
✅ Passed checks (5 passed)
Check name Status Explanation
Title check ✅ Passed The title accurately summarizes the main change: adding <thought> reasoning-tag support alongside <think>.
Description check ✅ Passed The PR description includes the required issue link, implementation summary, test procedure, checklist, and notes.
Linked Issues check ✅ Passed The changes implement the Gemma 4 <thought> parsing fix, preserve tag isolation, and add tests covering the reported behavior.
Out of Scope Changes check ✅ Passed The PR is focused on reasoning-tag parsing and related tests, with no obvious unrelated code changes.
Docstring Coverage ✅ Passed No functions found in the changed files to evaluate docstring coverage. Skipping docstring coverage check.
✨ Finishing Touches
🧪 Generate unit tests (beta)
  • Create PR with unit tests

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands.

@coderabbitai coderabbitai 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.

Actionable comments posted: 1

🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In `@src/utils/tag-matcher.ts`:
- Around line 75-76: The code currently uses a single activeTagName which is
overwritten by nested opens; change this to a stack (e.g., tagStack) so nested
tags are tracked: when an open tag is matched push its name onto tagStack and
set matched (or matched state) based on the top of the stack; when a close tag
is seen, compare it to the stack top and pop only if it matches (handle
mismatches gracefully), and update activeTagName/matched derived from
tagStack.peek() instead of a single variable; apply this stack pattern in the
logic around activeTagName/matched and the corresponding open/close handling
(the blocks around the current activeTagName usage and the similar code at the
later section referenced) so outer closes are recognized correctly.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro Plus

Run ID: 2023dbbb-6f09-4629-bd89-d0a559731db9

📥 Commits

Reviewing files that changed from the base of the PR and between 9d022d4 and 6966807.

📒 Files selected for processing (4)
  • src/api/providers/__tests__/base-openai-compatible-provider.spec.ts
  • src/api/providers/base-openai-compatible-provider.ts
  • src/api/providers/openai.ts
  • src/utils/tag-matcher.ts

Comment thread src/utils/tag-matcher.ts Outdated
taltas
taltas previously requested changes May 25, 2026

@taltas taltas 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.

Thanks for the PR, I messed around with Gemma 4 in roo and had the same thing, thanks for the fix! Left a couple of comments, we should be good to merge after you address these

Comment thread src/api/providers/__tests__/base-openai-compatible-provider.spec.ts
Comment thread src/utils/tag-matcher.ts Outdated

@sagidM sagidM left a comment

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

Before pushing, I was struggling to decide, how TagMatcher should work.
Consider these AI responses and the reasonings verdicts I implemented:
Gemma4 produced: <think>outer<thought>inner</thought> middle</think>final

"reasoning_content": "outer<thought>inner</thought> middle",
"content": "final"

Gemma4 produced: <think>first</thought>second</think>final

"reasoning_content": "first</thought>second",
"content": "final"

Gemma4 produced: <think>User asks about <think>these</think>tags...</think>final

"reasoning_content": "User asks about <think>these</think>tags...",
"content": "final"

All those tests cases are covered. I am push in a minute.


Backtick problem.

For the prompt: What does </thought> mean? Give me the short answer
Here is the result (replaced \n inside json for readability):

<thought>*   Target phrase: `</thought>`
    *   Goal: Explain what it means.
    *   Constraint: \"Give me the short answer.\"

    *   The user is interacting with an AI.
    *   The AI often uses a `<thought>` block (Chain-of-Thought) to reason before providing the final response.
    *   `</thought>` is the closing tag of that reasoning block.

    *   *Detailed version:* It's a closing XML/HTML-style tag used by AI models to signify the end of their internal reasoning process (Chain-of-Thought) and the beginning of the final answer.
    *   *Short version:* It marks the end of the AI's internal reasoning process.</thought>It is a closing tag used by AI models to signal the **end of their internal reasoning process** (Chain-of-Thought) and the beginning of the final response.

Google wraps both tags into backticks.
However, this is a 1% use case. For the majority of responses, I don't think a user would see this problem. And even if it appears, the chances are that they expand the Thinking.

However, the request to
https://generativelanguage.googleapis.com/v1beta/models/gemma-4-31b-it:generateContent?key=<API_KEY>

Actually returns a normal result and has

`</thought>`

(wrapped into backticks) many times in parts[0].text.
Content is in parts[1].text.

Conclusion. While this PR would solve 99% of the leaking reasoning cases, the complete solution would be to support gemma-4-* models for the Google API Provider.

Backticks Solution.

Regardless, I make another commit but I may wait until you guys confirm that this is really a problem.
Because right now, looking at the code, I have doubts whether it is worth to solve this problem. Because it brings extra complexity layer that TagMatcher should solve for some reason. There are may be other models which have a different way of escaping it and these changes will collide.
So I think it is better to publish this as-is and maybe focus on supporting Gemma for Google provider instead.

Comment thread src/api/providers/__tests__/base-openai-compatible-provider.spec.ts
@sagidM sagidM force-pushed the fix/gemma4-fix-thought-tags branch from 6966807 to b3b3dcc Compare May 26, 2026 13:45

@sagidM sagidM left a comment

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

Sorry, I accidentally send the previous message as Comment instead of Submitting review

@coderabbitai coderabbitai 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.

Actionable comments posted: 1

🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In `@src/utils/tag-matcher.ts`:
- Around line 112-116: Guard the closing-tag handling so we never decrement
this.depth or pop this.activeTagNames when there is no matching opener: change
the condition around the block that checks char === ">" && this.index ===
tagName.length to additionally require this.depth > 0 (and/or
this.activeTagNames.length > 0) before doing this.depth-- and
this.activeTagNames.pop(); if there is no opener, still set this.state = "TEXT"
but skip the decrement/pop to avoid underflow.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro Plus

Run ID: ec8bf8e4-7031-4d56-9521-9b52c582992c

📥 Commits

Reviewing files that changed from the base of the PR and between 6966807 and b3b3dcc.

📒 Files selected for processing (5)
  • src/api/providers/__tests__/base-openai-compatible-provider.spec.ts
  • src/api/providers/__tests__/openai.spec.ts
  • src/api/providers/base-openai-compatible-provider.ts
  • src/api/providers/openai.ts
  • src/utils/tag-matcher.ts

Comment thread src/utils/tag-matcher.ts Outdated
@codecov

codecov Bot commented May 26, 2026

Copy link
Copy Markdown

Codecov Report

❌ Patch coverage is 87.80488% with 5 lines in your changes missing coverage. Please review.

Files with missing lines Patch % Lines
src/utils/tag-matcher.ts 87.80% 2 Missing and 3 partials ⚠️

📢 Thoughts on this report? Let us know!

@sagidM sagidM force-pushed the fix/gemma4-fix-thought-tags branch from b3b3dcc to 80b7dbb Compare May 26, 2026 14:44

@coderabbitai coderabbitai 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.

♻️ Duplicate comments (1)
src/utils/tag-matcher.ts (1)

113-116: ⚠️ Potential issue | 🟠 Major | ⚡ Quick win

Guard closing-tag unwind to prevent depth underflow.

At Line 115, this.depth-- runs even when no opener is active. A leading stray close tag can push depth negative and corrupt subsequent state.

Suggested fix
-				if (char === ">" && this.index === tagName.length) {
+				if (char === ">" && this.index === tagName.length) {
 					this.state = "TEXT"
-					this.depth--
-					this.activeTagNames.pop()
+					if (this.depth > 0 && this.activeTagNames.length > 0) {
+						this.depth--
+						this.activeTagNames.pop()
+					}
 					this.matched = this.depth > 0
 					if (!this.matched) {
 						this.cached = []
 					}
 				}
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@src/utils/tag-matcher.ts` around lines 113 - 116, Guard the closing-tag
unwind so depth can't go negative: when detecting the end of a tag in the block
that checks if (char === ">" && this.index === tagName.length), only decrement
this.depth and pop this.activeTagNames if there is an open tag to close (e.g.,
this.depth > 0 and this.activeTagNames.length > 0); still set this.state =
"TEXT" but avoid running this.depth-- or this.activeTagNames.pop() when no
opener exists to prevent underflow/corruption.
🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Duplicate comments:
In `@src/utils/tag-matcher.ts`:
- Around line 113-116: Guard the closing-tag unwind so depth can't go negative:
when detecting the end of a tag in the block that checks if (char === ">" &&
this.index === tagName.length), only decrement this.depth and pop
this.activeTagNames if there is an open tag to close (e.g., this.depth > 0 and
this.activeTagNames.length > 0); still set this.state = "TEXT" but avoid running
this.depth-- or this.activeTagNames.pop() when no opener exists to prevent
underflow/corruption.

ℹ️ Review info
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro Plus

Run ID: 84d620aa-bc19-4ba7-ae28-849edb1f74b7

📥 Commits

Reviewing files that changed from the base of the PR and between b3b3dcc and 80b7dbb.

📒 Files selected for processing (5)
  • src/api/providers/__tests__/base-openai-compatible-provider.spec.ts
  • src/api/providers/__tests__/openai.spec.ts
  • src/api/providers/base-openai-compatible-provider.ts
  • src/api/providers/openai.ts
  • src/utils/tag-matcher.ts

@sagidM sagidM requested a review from taltas May 28, 2026 07:38
@taltas

taltas commented May 29, 2026

Copy link
Copy Markdown
Contributor

@sagidM I'm making some time to re look at this today, thanks for your patience.

@zakirhossen23

Copy link
Copy Markdown

is it deployed?


const matcher = new TagMatcher(
"think",
["think", "thought"],

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.

LmStudioHandler (lm-studio.ts:107) and NativeOllamaHandler (native-ollama.ts:217) both extend BaseProvider directly with their own createMessage() and TagMatcher instances — they still pass only "think". Should those be updated to ["think", "thought"] too?

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

// class TagMatcher
constructor(
	tagName: string | string[],
	readonly transform?: (chunks: TagMatcherResult) => Result,
	readonly position = 0,
) {
	this.tagNames = Array.isArray(tagName) ? tagName : [tagName]
}

should not matter I guess

Comment thread src/utils/tag-matcher.ts Outdated
@edelauna

edelauna commented Jun 2, 2026

Copy link
Copy Markdown
Contributor

Thanks for putting this together, looks good - just had a question about some other providers, and unused variables.

@github-actions github-actions Bot added the awaiting-review PR changes are ready and waiting for maintainer re-review label Jun 15, 2026
@github-actions github-actions Bot added awaiting-author PR is waiting for the author to address requested changes and removed awaiting-review PR changes are ready and waiting for maintainer re-review labels Jun 17, 2026
@sagidM

sagidM commented Jun 25, 2026

Copy link
Copy Markdown
Contributor Author

Unfortunately, I could not push it due to

Object literal may only specify known properties, and 'allowUnsafeTemplateDir' does not exist in type '{ allowUnsafeCustomBinary?: boolean | undefined; allowUnsafeProtocolOverride?: boolean | undefined; allowUnsafePack?: boolean | undefined; }'.ts(2353)
index.d.ts(107, 5): The expected type comes from property 'unsafe' which is declared here on type 'Partial<SimpleGitOptions>'

unrelated to this PR, fails in the main branch too

Sagid Magomedov and others added 4 commits June 25, 2026 20:43
Gemma 4 streams reasoning inside <thought>...</thought> instead of
<think>...</think>.  Without this the content leaks into chat text and
the agent triggers a retry on the first turn.

- TagMatcher: support multiple tag names - string[], track activeTagName so
    <think> is never closed by </thought> (and vice-versa).
- base-openai-compatible-provider and openai handler: match both tags.
- Tests: <thought> parsing, cross-tag isolation, and invariants.
Add two regression tests that verify depth never goes negative:

1. stray closer with no opener
   "final</think>text" → stays text

2. duplicate closer after a proper close
   "<think>thinking</think>final</think>text" → second </think> stays text

Both cases ensure we only decrement depth and pop activeTagNames
when depth > 0, preventing underflow and treating the extra tag
as plain text.
Co-authored-by: edelauna <54631123+edelauna@users.noreply.github.com>
@edelauna edelauna force-pushed the fix/gemma4-fix-thought-tags branch from aa5c0d7 to f24b964 Compare June 26, 2026 00:43
@edelauna

Copy link
Copy Markdown
Contributor

Unfortunately, I could not push it due to

Object literal may only specify known properties, and 'allowUnsafeTemplateDir' does not exist in type '{ allowUnsafeCustomBinary?: boolean | undefined; allowUnsafeProtocolOverride?: boolean | undefined; allowUnsafePack?: boolean | undefined; }'.ts(2353)
index.d.ts(107, 5): The expected type comes from property 'unsafe' which is declared here on type 'Partial<SimpleGitOptions>'

unrelated to this PR, fails in the main branch too

Running pnpm install should update your node-modules.

I'll help address some of the feedback

@edelauna edelauna 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.

Thanks for addressing this.

@edelauna edelauna enabled auto-merge June 26, 2026 02:59
@edelauna edelauna added this pull request to the merge queue Jun 26, 2026
Merged via the queue into Zoo-Code-Org:main with commit 34898d2 Jun 26, 2026
10 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

awaiting-author PR is waiting for the author to address requested changes

Projects

None yet

Development

Successfully merging this pull request may close these issues.

[BUG] <thought> tag is displayed as plain text instead of hidden reasoning in OpenAI Compatible provider for Gemma4

4 participants