DeepSeek Harness: Grok <think> tags rendered as answer text
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.
- 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. - 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. - 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.
- 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"}]}, thendata: [DONE]. Expected: the current version produces a single text block whose content includes the literal tags (source). - If step 4 reproduces, rule out the model, credentials, and network. Expected: the cause narrows to inbound representation.
- 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:
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.
- Create a plugin, register a
llm/streamlistener, and take the downstream chunk iterator fromnext(). Expected: once loaded, the hook seestext-deltaevents. - Add a model allowlist to the parser, keyed by model id or a
modelOverridesentry, 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:
models:
- id: grok-4.7
parseThinkTags: true # proposed field, not merged; shown for shape only
- 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:
<thiplusnk>split across chunks is still recognized. - 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.
- When one text block becomes a thinking block plus a text block, renumber all following block indices and rewrite both the
textand the block type inblock-end. Expected: the thinking panel shows the content and the body keeps only the text outside the tags. - If stripping the tags leaves the answer empty, report
EMPTY_RESPONSEinstead of success. Expected: an empty answer is never silently marked complete. - Re-run the minimal SSE from step 4. Expected: a thinking block containing
checking the resultand a text block containing42.
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.
- Log in to the gateway admin or change its forwarding rules so upstream reasoning maps to
reasoning_contentand the body keeps only the final answer. Expected: calling the gateway directly withcurlshows a separate reasoning field indelta. - Use the same curl request to confirm the body no longer contains tags. Expected: the whole
contentis answer text. - 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).
- 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. - 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.
- 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.
- 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.
- State explicitly that this is display-layer handling: copy, export, and the next request still contain the tags, and the model itself reads them.
- If you also use Path 1, remove this render patch so the same content is not folded twice.
- 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.
| Path | Where | Who it suits | Main cost |
|---|---|---|---|
| llm/stream parsing | DSH plugin layer | cannot change the gateway but need clean data | must handle block indices and block-end |
| Gateway field mapping | gateway side | you control gateway configuration | depends on that gateway; a new one needs rechecking |
| Frontend folding | render layer | you only need it readable for now | data 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.
- Do not do a global string replacement outside the adapter; legitimate quoted tags in the body would be eaten too.
- 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". - Renumbering block indices is mandatory; a misaligned index shifts everything downstream that associates content by block.
- If the parser looks correct but the tags remain, check the adapter's
block-endpayload path before changing the parser again. - This change does not involve the agent loop and should not rewrite existing sessions.
- 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.
- 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-endpayload 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
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.
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.
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.
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
- [Bug] Grok gateway leading <think> content is rendered as answer text· deepseek-ai/deepseek-harness Discussions
- @deepseek-ai/dsh-llm-pi-ai README· deepseek-ai