DeepSeek Harness: Grok <think> tags rendered as answer text

TroubleshootingPublished 2026-10-20Author: DeepSeek Plugin Market
DeepSeek HarnessGrok gatewaythink tagsstream parsingllm-pi-ai
DeepSeek Harness with a Grok gateway streams <think> tags as answer text. Parse in llm/stream, map reasoning_content, or fold in the UI.

The model did not put its reasoning into the answer; the gateway injected <think>…</think> into delta.content, and the DSH adapter forwards every text segment as answer text verbatim. This is not a configuration error and there is no existing switch to turn off. The sections below cover how to confirm it and three remediation paths that do not depend on each other (source).

DeepSeek Harness symptom: <think> tags in the body, an empty thinking panel

The key test is inconsistent representation on the same route: one response carries tags in the body while another returns native reasoning_content. That makes it an inbound representation problem, not the model.

  1. Open the failing answer and check whether <think> and a closing tag appear directly in the body, then expand the thinking fold. Expected: the fold is empty or contains different content.
  2. Send a short question again on the same route. Expected: one request returns native reasoning_content, with a normal thinking panel and a clean body.
  3. If both requests mix the tags into the body, the gateway may simply behave that way; do not conclude yet. Expected: step 4 decides with a deterministic stream.
  4. Reproduce with a deterministic Chat Completions SSE stream: send {"choices":[{"index":0,"delta":{"role":"assistant","content":"<think>checking the result</think>42"},"finish_reason":null}]}, then {"choices":[{"index":0,"delta":{},"finish_reason":"stop"}]}, then data: [DONE]. Expected: the current version produces a single text block whose content includes the literal tags (source).
  5. If step 4 reproduces, rule out the model, credentials, and network. Expected: the cause narrows to inbound representation.
  6. Also check whether the answer ended with a tool call. Expected: a tool turn that ended the text block before </think> arrived leaves the tag half-written, a shape the thread calls out.

DeepSeek Harness root cause: compat has only an outbound switch

The compat table has requiresThinkingAsText, which means "this endpoint requires thinking to be sent as text tags"; there is no inbound switch of the same kind, so nothing is responsible for splitting the tags back out.

The community source review in the thread gives two parts of the chain. The first is the compat definition in llm-pi-ai, which declares the outbound semantics only; the second is the stream mapping, where both branches forward verbatim:

ts
case 'text_delta':
  yield { type: 'text-delta', index: event.contentIndex, text: event.delta }
case 'text_end':
  yield { type: 'block-end', index: event.contentIndex, block: { type: 'text', text: event.content } }

That is why <think> split across two SSE records goes unnoticed: both increments land on the same block index and are meant to be concatenated.

One more trap: text_end carries the whole accumulated text, not an increment. A parser that rewrites text-delta without also rewriting block-end's text leaves a final block that contradicts the stream, so the UI may fold during streaming and then snap the content back into the body at the end.

DSH plugin path 1: opt-in parsing in the llm/stream waterfall

llm/stream is a waterfall hook whose next() returns an iterable chunk stream, so a plugin can wrap it and restructure events; the parser can be enabled only on deployments with this kind of gateway and leave everything else untouched. Check peer plugins on DSH Plugin Hub before publishing.

  1. Create a plugin, register a llm/stream listener, and take the downstream chunk iterator from next(). Expected: once loaded, the hook sees text-delta events.
  2. Add a model allowlist to the parser, keyed by model id or a modelOverrides entry, disabled by default. Expected: output for models that are not enabled is byte-for-byte unchanged. The proposal in the thread adds a switch on the model entry:
yaml
models:
  - id: grok-4.7
    parseThinkTags: true   # proposed field, not merged; shown for shape only
  1. Implement a cross-chunk state machine that recognizes only the tag segment at the start of a block; the tag may arrive across several chunks, and an unclosed tag is treated as thinking until the block ends. Expected: <thi plus nk> split across chunks is still recognized.
  2. Pass through ordinary prose, tags quoted inside code fences, and any tag after the first closed segment unchanged. Expected: the thread's requirement that "body text quoting a tag is not rewritten" holds.
  3. When one text block becomes a thinking block plus a text block, renumber all following block indices and rewrite both the text and the block type in block-end. Expected: the thinking panel shows the content and the body keeps only the text outside the tags.
  4. If stripping the tags leaves the answer empty, report EMPTY_RESPONSE instead of success. Expected: an empty answer is never silently marked complete.
  5. Re-run the minimal SSE from step 4. Expected: a thinking block containing checking the result and a text block containing 42.

DeepSeek Harness path 2: have the gateway use reasoning_content

This is the smallest change on the DSH side: if the gateway writes reasoning into reasoning_content or an equivalent separate field, the adapter's existing reasoning channel accepts it and neither the UI nor history replay changes.

  1. Log in to the gateway admin or change its forwarding rules so upstream reasoning maps to reasoning_content and the body keeps only the final answer. Expected: calling the gateway directly with curl shows a separate reasoning field in delta.
  2. Use the same curl request to confirm the body no longer contains tags. Expected: the whole content is answer text.
  3. Return to DSH, resend the same question, and expand the thinking fold. Expected: the content appears in the fold and the body is clean (source).
  4. Check whether the route declares requiresThinkingAsText. That field governs the outbound direction; do not assume it means inbound is handled. Expected: you confirm it is not an inbound switch.
  5. Record the gateway's field names and version, and retest after a gateway change or upgrade. Expected: behavior stays the same.

DeepSeek Harness path 3: fold in the frontend without touching data

This fixes "it reads badly", not "the data carries tags": the render layer recognizes the leading segment before Markdown parsing and folds it, while history and exports remain the original text.

  1. In the message renderer, run a regex or state machine on the body that matches only the leading segment. Expected: only the block-leading tag is hit and inline quotations are not damaged.
  2. Render the matched segment as a collapsed block by default and render only the remainder as the body. Expected: it looks like the native reasoning fold.
  3. State explicitly that this is display-layer handling: copy, export, and the next request still contain the tags, and the model itself reads them.
  4. If you also use Path 1, remove this render patch so the same content is not folded twice.
  5. Watch a tool-turn session where the tag never closes: the fold extends to the end of the block. Expected: the latter half of the answer is not hidden; if it is, your recognition radius is too wide.

Change the gateway when you can, because it splits the data at the source; use the other two paths only when you cannot.

PathWhereWho it suitsMain cost
llm/stream parsingDSH plugin layercannot change the gateway but need clean datamust handle block indices and block-end
Gateway field mappinggateway sideyou control gateway configurationdepends on that gateway; a new one needs rechecking
Frontend foldingrender layeryou only need it readable for nowdata still carries tags; not a root fix

The default recommendation is Path 2, then Path 1 when the gateway cannot be changed, with Path 3 only as a transition.

One metric tells you whether you chose correctly: export the session as plain text and look for the tags. If they are still there, you only did Path 3.

DeepSeek Harness: seven details that are easy to miss

Most failures here come from fixing only one half of the stream mapping; keep increments and closing blocks in sync.

  1. Do not do a global string replacement outside the adapter; legitimate quoted tags in the body would be eaten too.
  2. A tag may never close, because a tool turn can end the text block before </think>. The parser needs a flush path, or "tags leak" becomes "the whole answer is swallowed".
  3. Renumbering block indices is mandatory; a misaligned index shifts everything downstream that associates content by block.
  4. If the parser looks correct but the tags remain, check the adapter's block-end payload path before changing the parser again.
  5. This change does not involve the agent loop and should not rewrite existing sessions.
  6. Attribution: the line references and mechanism above come from the community source review in the thread, not an official conclusion, so recheck them after an upgrade.
  7. The parser only affects the response side and cannot see content the adapter already folded into a completed block; if tags still leak, inspect the block-end payload rather than widening the regex.

Source: Discussion #9877, @deepseek-ai/dsh-llm-pi-ai README. For other gateway and model entry points, see DSH troubleshooting.

FAQ

Why do <think> tags appear in a DeepSeek Harness answer from a Grok gateway?

A Grok gateway writes reasoning into delta.content, and the DeepSeek Harness llm-pi-ai stream mapping turns each text delta into answer text verbatim. The compat table has only the outbound requiresThinkingAsText flag and no inbound parsing switch, so nothing splits the tags.

Can a plugin return Grok's <think> content to the thinking panel without changing DeepSeek Harness core?

DeepSeek Harness can wrap the chunk iterator in the llm/stream waterfall for opt-in parsing. The parser must keep state across chunks, renumber block indices, and rewrite the accumulated text in block-end, or the final block will contradict the streamed content.

Is it more reliable to move reasoning to reasoning_content than to parse in DeepSeek Harness?

For a single route, yes, because DeepSeek Harness receives data that is classified correctly from the start and neither the UI nor history replay needs changes. The cost is that you depend on gateway configuration; another gateway may put reasoning back into content and force you to confirm the field names again.

Can folding DeepSeek Harness <think> tags in the frontend be treated as the real fix?

DeepSeek Harness frontend folding changes display only, not data. Session history, exports, and the next request still contain the tags, and the model itself reads them. It is a reasonable temporary readability patch but not a replacement for inbound parsing.

Related Terms

compat table (llm-pi-ai)
The compat table is the set of switches llm-pi-ai uses to describe dialect differences of an OpenAI-compatible endpoint. It distinguishes direction: outbound requiresThinkingAsText means thinking must be sent as text tags, and there is currently no inbound counterpart.— https://github.com/deepseek-ai/deepseek-harness/discussions/9877
text-delta / block-end
text-delta is the incremental text event in a streaming response, and block-end is the closing event that carries the complete text of a content block. In the llm-pi-ai stream mapping they correspond one to one, so rewriting the increments requires rewriting the closing text as well.— https://github.com/deepseek-ai/deepseek-harness/discussions/9877
llm/stream waterfall
llm/stream is a waterfall hook on the LLM request path whose next() returns an iterable stream of response chunks. A plugin can wrap that iterator and restructure the event sequence without changing the core adapter.— https://github.com/deepseek-ai/deepseek-harness/discussions/9877

Sources