Repository navigation
Description
Summary
OpenCode currently treats a standalone delta.content: "</think>" chunk after OpenAI-compatible reasoning_content and before tool_calls as normal assistant text. That text is then persisted into the session history before the tool call context.
This showed up with OmniRoute + Kimi Coding, but the stream shape is generic OpenAI Chat-compatible input.
Related OmniRoute issue: cheaper-inference/OmniRoute#5123
Minimal stream shape
data: {"choices":[{"delta":{"reasoning_content":"thinking"},"finish_reason":null}]}
data: {"choices":[{"delta":{"content":"</think>"},"finish_reason":null}]}
data: {"choices":[{"delta":{"tool_calls":[{"index":0,"id":"call_1","type":"function","function":{"name":"lookup","arguments":"{\"query\":\"weather\"}"}}]},"finish_reason":null}]}
data: {"choices":[{"delta":{},"finish_reason":"tool_calls"}]}
Current behavior
In packages/llm/src/protocols/openai-chat.ts, commit 11d2f3e5f (fix(llm): end reasoning before responses) changed the parser to close active reasoning before delta.content and before tool_calls:
if (delta?.content) {
lifecycle = Lifecycle.reasoningEnd(lifecycle, events, "reasoning-0")
lifecycle = Lifecycle.textDelta(lifecycle, events, "text-0", delta.content)
}
if (toolDeltas.length) lifecycle = Lifecycle.reasoningEnd(lifecycle, events, "reasoning-0")That means the minimal stream above becomes:
reasoning-delta "thinking"
reasoning-end
text-delta "</think>"
tool-input-start / tool-call ...
finish tool-calls
Then packages/opencode/src/session/processor.ts persists that text delta as a normal text part, and packages/opencode/src/session/message-v2.ts can replay it into the next model request as assistant content.
Expected behavior
A standalone closing thought marker immediately after reasoning_content and before tool_calls should not become normal assistant text.
Possible defensive behavior:
- Ignore a lone
</think>/</thinking>/ equivalent closing marker when it appears immediately after active reasoning and before a tool call. - Or treat it as a reasoning boundary marker, not a
text-delta.
This should not suppress ordinary user-visible text; the special case should be limited to a standalone closing marker in the reasoning-to-tool boundary.
Why client-side handling is useful
The upstream/proxy should ideally not emit this marker as OpenAI content, and I opened the OmniRoute issue above for that. However, OpenCode can still be robust against this malformed-but-plausible OpenAI-compatible stream shape, especially because different proxies/providers expose reasoning markers differently.
Local observations
- Local OpenCode binary:
1.17.11 - Local OpenCode HEAD contains
11d2f3e5f - Running OmniRoute container was verified as
diegosouzapw/omniroute:3.8.35, so the symptom was not caused only by a failed OmniRoute rollback.
Activity
github-actions commentedon Jun 26, 2026
This issue might be a duplicate of or closely related to existing issues. Please check:
<think>and<thinking>tags as reasoning blocks #11439: Support parsing<think>and<thinking>tags as reasoning blocks — that issue also covers</think>being treated as plain text content rather than a reasoning boundary marker, which is the same core symptom reported here.If your issue is distinct from those (e.g. it is specifically about the OpenAI Chat stream parser emitting a
text-deltafor a lone</think>closing marker at the reasoning-to-tool boundary, rather than the tag-extraction middleware removal), please clarify so maintainers can track it separately.hertznsk commentedon Jun 26, 2026
Thanks for the duplicate check. I think this is related to both, but distinct enough to track separately unless maintainers prefer folding it into #11439.
The distinction from #11439 is that this is not asking for general tag-based reasoning extraction from normal text like:
In this repro the provider/proxy is already using the OpenAI-compatible native reasoning field for the reasoning text:
The only malformed textual piece is a standalone closing marker emitted as a separate OpenAI Chat content chunk at the boundary between native reasoning and tool calls:
So the proposed defensive handling is narrower than restoring the removed
extractReasoningMiddleware: ignore or treat as a boundary only when a lone closing marker appears immediately after activereasoning_contentand beforetool_calls. It should not parse arbitrary<think>...</think>text in regular responses.The code path I observed locally is:
packages/llm/src/protocols/openai-chat.tsdelta.reasoning_contentemitsreasoning-delta.delta.contentfirst closes reasoning and then emitstext-delta.tool_callscloses reasoning again and emits tool input/tool call events.packages/opencode/src/session/processor.tstext-startcreates a normal text part.text-deltaappends the raw text to that part.</think>becomes persisted assistant text.packages/opencode/src/session/message-v2.ts</think>) and the tool call/result context.That is where it overlaps with #19548: the bad text can be replayed into later prompts. But unlike #19548, the source is not generic stale progress text from an older turn; it is a synthetic closing marker from the current provider stream being persisted as ordinary assistant content.
A minimal regression assertion would be something like:
I could not run this exact test locally because the checked-out repo has no
node_modules, andbun install --frozen-lockfiledid not complete within 10 minutes in my environment. The code-path analysis above is from the local OpenCode checkout at1.17.11/ HEAD containing11d2f3e5f.If maintainers prefer, I can also restate this as a narrower acceptance criterion under #11439: “when native
reasoning_contentis active, suppress a standalone closing thought marker at the reasoning-to-tool boundary.”lexlian commentedon Jul 1, 2026
@hertznsk @kitlangton , I fixed the issue with PR #34698.
The bug was in
packages/llm/src/protocols/openai-chat.tsstep(). The parser emitted atext-deltaevent unconditionally for anydelta.content, including a lone closing marker (</think>/</thinking>) emitted as a separate content chunk betweenreasoning_contentandtool_calls. The straytext-deltawas then persisted as a normal text part bysession/processor.tsand replayed into the next model request — exactly the harm you described in your second comment.The fix adds a narrow conditional: when
delta.contentmatches/^\s*<\/(?:think|thinking)>\s*$/(a lone closing marker, allowing surrounding whitespace) AND a reasoning block is currently active (thestate.lifecycle.reasoning.has("reasoning-0")check), skip thetext-deltaemission while still emittingreasoningEnd. The regex does not match</think>embedded in larger content like"ok here's the answer </think> done", and the reasoning-active guard ensures the marker remains user-visible text when it appears without preceding reasoning.This is precisely the boundary-only behavior you proposed — narrower than #11439 (general tag extraction) and unrelated to #19548 (stale progress replay). The fix is local to
step()and transitively covers all OpenAI-Chat-shaped routes:openai-compatible-chat(DeepSeek, Groq, Cerebras, Fireworks, Baseten, DeepInfra, TogetherAI, xAI, Zhipu/ZAI, MiniMax, OmniRoute + Kimi) andopenrouter.ts(which doesstream: OpenAIChat.protocol.stream— covers GLM-5.2 and other OpenRouter-proxied models).Four new regression tests in
packages/llm/test/provider/openai-chat.test.tscover the drop (</think>,</thinking>) and the two non-regression cases (</think>without preceding reasoning,</think>embedded in larger content). Full llm package: 300 pass / 0 fail across 29 test files.bun typecheck: 29/29 packages successful.github-actions commentedon Aug 31, 2026
To stay organized issues are automatically closed after 60 days of no activity. If the issue is still relevant please open a new one.