Skip to content

OpenAI Chat parser treats standalone </think> before tool_calls as assistant text #34126

Closed as not planned

Description

@hertznsk

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

  1. github-actions commented on Jun 26, 2026

    @github-actions
    Contributor

    This issue might be a duplicate of or closely related to existing issues. Please check:

    If your issue is distinct from those (e.g. it is specifically about the OpenAI Chat stream parser emitting a text-delta for 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.

  2. hertznsk commented on Jun 26, 2026

    @hertznsk
    Author

    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:

    content: "<think>hidden reasoning</think>visible answer"
    

    In this repro the provider/proxy is already using the OpenAI-compatible native reasoning field for the reasoning text:

    delta.reasoning_content: "thinking"
    

    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:

    delta.reasoning_content: "thinking"
    delta.content: "</think>"
    delta.tool_calls: [{ ... }]
    finish_reason: "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 active reasoning_content and before tool_calls. It should not parse arbitrary <think>...</think> text in regular responses.

    The code path I observed locally is:

    1. packages/llm/src/protocols/openai-chat.ts

      • delta.reasoning_content emits reasoning-delta.
      • delta.content first closes reasoning and then emits text-delta.
      • tool_calls closes reasoning again and emits tool input/tool call events.
    2. packages/opencode/src/session/processor.ts

      • text-start creates a normal text part.
      • text-delta appends the raw text to that part.
      • Therefore the standalone </think> becomes persisted assistant text.
    3. packages/opencode/src/session/message-v2.ts

      • persisted text parts are replayed into future model messages as assistant text.
      • tool parts are also replayed, so the next request can contain both a stray assistant text part (</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:

    const body = sseEvents(
      { choices: [{ delta: { reasoning_content: "thinking" }, finish_reason: null }] },
      { choices: [{ delta: { content: "</think>" }, finish_reason: null }] },
      {
        choices: [
          {
            delta: {
              tool_calls: [
                {
                  index: 0,
                  id: "call_1",
                  type: "function",
                  function: { name: "lookup", arguments: "{\"query\":\"weather\"}" },
                },
              ],
            },
            finish_reason: null,
          },
        ],
      },
      { choices: [{ delta: {}, finish_reason: "tool_calls" }] },
    )
    
    const response = await generate(...)
    expect(response.reasoning).toBe("thinking")
    expect(response.text).toBe("")
    expect(response.toolCalls).toHaveLength(1)

    I could not run this exact test locally because the checked-out repo has no node_modules, and bun install --frozen-lockfile did not complete within 10 minutes in my environment. The code-path analysis above is from the local OpenCode checkout at 1.17.11 / HEAD containing 11d2f3e5f.

    If maintainers prefer, I can also restate this as a narrower acceptance criterion under #11439: “when native reasoning_content is active, suppress a standalone closing thought marker at the reasoning-to-tool boundary.”

  3. lexlian commented on Jul 1, 2026

    @lexlian

    @hertznsk @kitlangton , I fixed the issue with PR #34698.

    The bug was in packages/llm/src/protocols/openai-chat.ts step(). The parser emitted a text-delta event unconditionally for any delta.content, including a lone closing marker (</think> / </thinking>) emitted as a separate content chunk between reasoning_content and tool_calls. The stray text-delta was then persisted as a normal text part by session/processor.ts and replayed into the next model request — exactly the harm you described in your second comment.

    The fix adds a narrow conditional: when delta.content matches /^\s*<\/(?:think|thinking)>\s*$/ (a lone closing marker, allowing surrounding whitespace) AND a reasoning block is currently active (the state.lifecycle.reasoning.has("reasoning-0") check), skip the text-delta emission while still emitting reasoningEnd. 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) and openrouter.ts (which does stream: OpenAIChat.protocol.stream — covers GLM-5.2 and other OpenRouter-proxied models).

    Four new regression tests in packages/llm/test/provider/openai-chat.test.ts cover 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.

  4. github-actions commented on Aug 31, 2026

    @github-actions
    Contributor

    To stay organized issues are automatically closed after 60 days of no activity. If the issue is still relevant please open a new one.

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

Metadata

Metadata

Assignees

Labels

No labels
No labels

Type

No type

Projects

No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions