Skip to content

feat(api_server): stream inline <think> as delta.reasoning_content (unify A/B-class reasoning) - #23638

Open
lld1995 wants to merge 2 commits into
NousResearch:mainfrom
lld1995:feat/stream-inline-think-as-reasoning-content
Open

feat(api_server): stream inline <think> as delta.reasoning_content (unify A/B-class reasoning)#23638
lld1995 wants to merge 2 commits into
NousResearch:mainfrom
lld1995:feat/stream-inline-think-as-reasoning-content

Conversation

@lld1995

@lld1995 lld1995 commented May 11, 2026

Copy link
Copy Markdown

Summary

Surface the model's chain-of-thought on the de-facto OpenAI-compatible delta.reasoning_content field in /v1/chat/completions (streaming branch only), and unify two previously-divergent reasoning sources onto this single wire format:

  • A-class — structured delta.reasoning_content from DeepSeek / Moonshot / Kimi / GLM / MiniMax / Tencent thinking modes; already arrives on the model's dedicated reasoning channel via run_agent._fire_reasoning_delta.
  • B-class — inline <think> / <thinking> / <reasoning> tags emitted on the normal content channel by open-weights models (Qwen3 thinking, DeepSeek-R1 finetunes following that prompt template, MiniMax-M2.7, etc.).

The mechanism: StreamingThinkScrubber.feed() / flush() now return (visible, reasoning) instead of silently dropping scrubbed-out <think>…</think> block contents on the floor, so the same SSE writer can route both sources through one path.

delta.content / message.content / stored conversation history remain unchanged.

Output (both A-class and B-class models)

data: {"choices":[{"delta":{"role":"assistant"},"finish_reason":null}]}

data: {"choices":[{"delta":{"reasoning_content":"先分析一下问题..."},"finish_reason":null}]}

data: {"choices":[{"delta":{"reasoning_content":"应该用方案 A..."},"finish_reason":null}]}

data: {"choices":[{"delta":{"content":"结论是..."},"finish_reason":null}]}

data: {"choices":[{"delta":{},"finish_reason":"stop"}],"usage":{...}}

data: [DONE]

Why delta.reasoning_content (not a custom SSE event)

delta.reasoning_content is the field popularised by DeepSeek's API and is already consumed natively by:

  • Open WebUI
  • Lobe Chat
  • ChatGPT-Next-Web
  • Cline
  • Cursor
  • Continue

No client-side adapter needed — existing DeepSeek-compatible frontends just work against Hermes now, including for open-weights models that only emit <think> inline.

Design

  • agent/think_scrubber.pyfeed() and flush() return (visible, reasoning). Inside a <think> block, bytes that previously fell on the floor are now surfaced on the second channel. Partial close-tag held-back prefixes on stream termination are surfaced as reasoning (not discarded or leaked into visible).
  • run_agent.py_fire_stream_delta and the end-of-stream flush route the reasoning half through the existing _fire_reasoning_delta (same path A-class already uses). Visible half still goes through context scrubber + stream_delta_callback.
  • gateway/platforms/api_server.py_create_agent / _run_agent thread a new reasoning_callback through to AIAgent. _handle_chat_completions streaming branch registers _on_reasoning that enqueues ("__reasoning__", text) tuples onto the SSE queue. The SSE writer emits them as chat.completion.chunk frames carrying delta.reasoning_content.

Scope

Streaming /v1/chat/completions only. Non-streaming, /v1/responses, /v1/runs, and every non-api_server gateway platform are untouched.

Acceptance

  • Non-reasoning model — SSE bytes, ordering, tokens, headers byte-identical to previous behaviour (new branch is never triggered, scrubber returns (text, "")).
  • A-class model (DeepSeek-style) — structured reasoning arrives on delta.reasoning_content, body arrives on delta.content.
  • B-class model (Qwen3-thinking-style)<think> block contents arrive on delta.reasoning_content, post-</think> body on delta.content. delta.content never contains <think> / </think> markers. Memory / conversation-history paths still see the fully-scrubbed visible text.
  • hermes.tool.progress, finish_reason, usage, X-Hermes-Session-Id, X-Hermes-Session-Key, [DONE], and client-disconnect interrupt behaviour are unaffected.

Tests

  • tests/agent/test_think_scrubber.py — rewritten against the (visible, reasoning) return shape; adds coverage for recovered B-class content, partial close-tag tails surfaced on flush, multi-block streams, and nested/malformed tags.

Risk / rollback

Isolated to the api_server streaming branch and the think-scrubber return contract. All internal callers of the scrubber updated in-tree. Revert = single-commit revert.

fengbin and others added 2 commits May 11, 2026 14:42
The scrubber now returns `(visible, reasoning)` from both `feed()` and
`flush()` instead of discarding `<think>…</think>` block contents on the
floor. Unifies A-class (structured `delta.reasoning_content` from
DeepSeek/Moonshot/Kimi thinking modes) and B-class (inline `<think>` tags
from open-weights models) reasoning on a single downstream channel.

Changes:

- `agent/think_scrubber.py`: `feed()` and `flush()` return
  `
@alt-glitch alt-glitch added type/feature New feature or request P2 Medium — degraded but workaround exists comp/gateway Gateway runner, session dispatch, delivery comp/agent Core agent runtime: loop, agent_init, prompt builder, context-compression, responses endpoint labels May 11, 2026

@teknium1 teknium1 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 focused extension of the existing reasoning callback path. Current main still drops this API-server surface: gateway/platforms/api_server.py:2271-2282 passes visible and tool callbacks only, while _write_sse_chat_completion() at gateway/platforms/api_server.py:2449-2470 has no delta.reasoning_content branch. run_agent.py:4701-4706 also only receives the scrubber's visible string today, so the inline-tag part of the premise is current.

Problems

  • The PR adds only scrubber-unit coverage. tests/gateway/test_api_server.py:1097-1123 validates ordinary content streaming but does not exercise a reasoning callback or assert a delta.reasoning_content SSE frame. This leaves the public HTTP callback threading and queue discrimination unguarded.

Suggested changes

  • Add an aiohttp chat-completions streaming test that invokes both callbacks and asserts ordered reasoning/content chunks, with no reasoning in delta.content.
  • Add an agent streaming-path test for split inline `` tags to verify _fire_stream_delta routes recovered text to `reasoning_callback`.

Automated hermes-sweeper review.

@@ -14,23 +23,37 @@
from agent.think_scrubber import StreamingThinkScrubber

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.

These tests validate tuple recovery only. Please also add an API-server SSE test: current tests/gateway/test_api_server.py:1097-1123 drives only stream_delta_callback, so it would not catch a lost reasoning_callback thread or a __reasoning__ tuple being serialized as delta.content.

@teknium1 teknium1 added sweeper:risk-message-delivery Sweeper risk: may drop, duplicate, misroute, or suppress messages sweeper:risk-compatibility Sweeper risk: may break existing users, config, migrations, defaults, or upgrades sweeper:risk-security-boundary Sweeper risk: may affect sandboxing, auth, credentials, or sensitive data sweeper:blast-moderate Sweeper blast radius: moderate — a subsystem or single platform area/streaming Streaming responses: gateway delivery, provider wire labels Jul 13, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

area/streaming Streaming responses: gateway delivery, provider wire comp/agent Core agent runtime: loop, agent_init, prompt builder, context-compression, responses endpoint comp/gateway Gateway runner, session dispatch, delivery P2 Medium — degraded but workaround exists sweeper:blast-moderate Sweeper blast radius: moderate — a subsystem or single platform sweeper:risk-compatibility Sweeper risk: may break existing users, config, migrations, defaults, or upgrades sweeper:risk-message-delivery Sweeper risk: may drop, duplicate, misroute, or suppress messages sweeper:risk-security-boundary Sweeper risk: may affect sandboxing, auth, credentials, or sensitive data type/feature New feature or request

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants