Repository navigation
fix(model): preserve reasoning streams and final results - #382
Conversation
The langgenius#277 fix in _handle_generate_stream_response only bypassed the thinking wrapper when reasoning_content and reasoning keys were *present* with explicit empty-string values: if ( is_reasoning_started and "" in reasoning_parts and not any(reasoning_parts) ... ) Runtimes like MTPLX (the Qwen3 local-inference server) emit heartbeat deltas mid-reasoning that omit BOTH reasoning_content and reasoning entirely, instead of emitting empty strings. The old check did not match that shape, so the wrapper fired on every heartbeat and emitted '\n</think>', which prematurely closed the think block. The next reasoning chunk then opened a new one, fragmenting the chat UI into many small 'Thoughts (x.x s)' entries. This change replaces the present-but-empty check with one that treats both 'key absent' (None) and 'key present with empty string' ("") as 'no new reasoning'. A delta with no visible payload (content / tool_calls / function_call) and no new reasoning now stays out of the wrapper entirely, so reasoning flows through heartbeats into a single coherent <think> block. Reproduction (real stream from a local MTPLX/Qwen3 server, ~500 chunks, ~40 mid-reasoning heartbeats): before: <think>...</think> x ~40 fragments after: <think>...</think> x 1 coherent block Updates test_stream_reasoning_is_closed_at_end with three new parameterised cases covering: * absent-key heartbeats during reasoning (the MTPLX / Qwen3 pattern) * heartbeats with the alternate "reasoning" key (LiteLLM-style) * heartbeats that arrive after a legitimate content close (must not reopen a second think block) Also tightens the lines3 expectation to match the correct (now-fixed) behaviour: previously the test asserted the fragmented output as 'expected' even though that was the bug being demonstrated. Fixes langgenius#277 follow-up.
Follow-up: deployment note for plugin authorsWhile validating this fix end-to-end with the When patching in-place, you must patch both:
For the bundled SDK, also delete the matching This is a deployment-side caveat, not an SDK bug — the PR fix itself is correct. Filing here for visibility because the symptom would look like the patch didn't take if you only patch #1. |
WH-2099
left a comment
There was a problem hiding this comment.
Reviewed the complete six-file diff from e6daca8 to 4674b90 with three independent reviews of correctness, stream handling, and caller compatibility.
No blocking regressions found. The changes preserve reasoning through heartbeats, keep terminal metadata and tool arguments, close responses on completion or cancellation, and map errors raised while consuming lazy streams. Checked the actual SDK and downstream consumers of the combined tool/usage final chunk, plus the 66 official LLM override signatures affected by keyword invocation.
Validation: 151 model tests and 8 subtests passed; lockfile, Ruff formatting/lint, and diff checks passed. Additional local checks used real requests.Response iteration and the tokenizer, public invoke/executor paths, synchronous and lazy errors, structured output, and cancellation cleanup.
Existing limits remain: fallback prompt usage omits tool definitions, and fragmented tool names can replace an earlier name fragment. Both were reproduced against the base revision as well; neither is introduced by this diff. Fallback usage is still an approximation.
Remote CI for this commit is awaiting workflow approval. No live provider calls or production load tests were performed.
4674b90 to
bac5f0c
Compare
Delivery status — 2026-11-02
Merged on 2026-10-12.
The fix is included in the planned SDK 0.11.0 release for Dify 1.19.0, tracked in #413.
The public SDK remains 0.10.2; plugin delivery follows publication.
Summary
OpenAI-compatible reasoning streams could split one thinking block at empty heartbeats and lose their final result at nearby stream boundaries.
Keep an active thinking block across absent, null, or empty reasoning fields until content, tool calls, or stream completion closes it.
For example, reasoning
A, an empty delta, reasoningB, and answer text now produce one<think>AB</think>block.The same response path now preserves the rest of the result:
[DONE]and close the HTTP response on completion, cancellation, parse failures, and read errors.InvokeErrorfor invalid JSON and provider error envelopes instead of returning a successful partial result.Refs #277, #366, and #391.
Validation
just check: passed.just test: 276 passed, 1 skipped because the Dify plugin CLI is unavailable.just build: wheel and source distribution built successfully.requests.Responseiteration, stream parsing, final chunks, and usage calculation with in-memory network data.They cover heartbeats, terminal frames, invalid JSON, provider errors, response cleanup, partial/zero usage, parallel tool arguments, missing usage, and lazy stream errors.
These checks used local data and simulated sockets, not deployed model servers.
Call-chain audit
The audit followed
_generate→_handle_generate_stream_response→ thinking/tool aggregation →_create_final_llm_result_chunk→LargeLanguageModel.invoke→ executor/writers → daemon/Dify/Graphon consumers.OAICompatLargeLanguageModelsubclasses atdify-official-plugins@7584b133.Twenty-nine inherit the SDK stream handler; OpenRouter and Stepfun implement their own.
The heartbeat guard stays before provider wrapper dispatch so existing wrapper overrides receive the fix.
_increase_tool_callinterface because OpenRouter imports it directly.Finalizer signatures and wire schemas remain compatible with actual callers.
689f331f, Dify1a817b99, and Graphon11e2dee8(0.7.0, the version pinned by Dify), as well as current Graphon main.Their consumers accept tool calls, usage, and finish reason in the same final chunk and do not deduplicate chunks by index.
No consumer changes are required for these SDK fixes.
OpenRouter's independent stream handler also needs matching terminal-state, tool-aggregation, and response-cleanup fixes.
These plugin-specific changes require a companion PR in the official plugin repository.
SDK publication also requires downstream lock updates and plugin repackaging before installed plugins receive the fixes.
Compatibility
Malformed response data now fails the invocation instead of being reported as a completed partial answer.
Empty reasoning fields alone no longer terminate an active thinking block.
Usage estimates remain local approximations when the provider omits counts.
No public schema or package version is changed by this PR.
General SSE framing expansion, transport keepalive/EOF policy, standalone JSON/XML fence parsing, and unrelated non-stream response behavior are outside this change.
Available checks
just check,just test, andjust buildcompleted.