{{ message }}
Repository navigation
Conversation
A thinking-mode provider treats its reasoning as part of the turn, not as decoration. DeepSeek answers 400 to every follow-up request once a turn called a tool and the assistant message came back without `reasoning_content`, and Anthropic rejects a thinking block whose signature was not returned. eko streamed reasoning to the UI as `thinking` events and then threw it away: `callLLM` never put a reasoning part in the array it returns, and `convertAssistantContent` filtered the prompt down to text and tool calls. The provider layer is ready for it — `@ai-sdk/openai-compatible` maps an assistant `reasoning` part onto `reasoning_content` — so the part only had to survive eko's own assembly of the turn. `reasoning-end` now records the block with the `providerMetadata` that carries the signature, `thinkText` resets so several blocks stay separate, and one `collected()` builds the turn for both the normal and the abort path with reasoning ahead of text and tool calls. `LLMAssistantPart` names the widened union once. The consumers that assumed text-or-tool-call are adjusted rather than left to misread a reasoning part: the agent's "text-only turn" check folds reasoning out before deciding no tool was called, the chat agent skips it instead of executing it as a call, and the persisted `EkoMessage` transcript drops it, since that format has no reasoning variant and extending it is a separate change.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.

Fixes #281.
The bug
DeepSeek's thinking mode is on by default, and once a turn has called a tool the API requires that turn's
reasoning_contentback on every subsequent request. Without it the next call is a 400, which is what the issue reports.eko never sends it — not because of a provider gap, but because the reasoning is discarded before the provider ever sees it:
callLLMhandlesreasoning-start/reasoning-delta/reasoning-endonly to emitthinkingstream events for the UI. The array it returns is built fromstreamTextandtoolPartsalone, so the reasoning is gone the moment the stream ends.convertAssistantContent, which turns that array into{ role: "assistant", content }, then filters explicitly totextandtool-call.The provider layer was ready the whole time.
@ai-sdk/openai-compatiblewalks an assistant message's parts and does exactly what DeepSeek needs:So the part only had to survive eko's own assembly of the turn.
The fix
reasoning-endrecords the block, carryingproviderMetadatathrough asproviderOptions— Anthropic's signature rides in there, and a thinking block replayed without its signature is rejected too, so the same fix covers extended thinking.thinkTextresets afterwards so a turn with several reasoning blocks keeps them apart instead of concatenating them into the second one.Both return paths now go through a single
collected(), which puts reasoning ahead of text and tool calls. That also means the abort path returns the same shape rather than a second hand-built array that could drift.LLMAssistantPartnames the widened union once instead of repeating it at a dozen signatures.What the widened union means for existing consumers
Adding a third part type to an array others already read is the risky half, so each consumer was checked rather than assumed:
agent/base.tsresults.every(s => s.type == "text")decided "no tool was called", then mappeds.textchat/chat-agent.tsundefined tool does not existchat/chat-llm.tsconvertAssistantToolResultsmapped parts intoEkoMessageAssistantPartmemory/index.tsVerification
npx jest test/llm/reasoning-round-trip.test.ts— 6 passed. The suite is offline: it drivescallLLMwith a hand-builtReadableStreamof provider chunks, so it needs no API key.Each test was checked against a mutation, so none of them passes for an unrelated reason:
reasoning-endcollected()omitsreasoningPartscollected()emits reasoning lastthinkTextbetween blocks["first", "firstsecond"])convertAssistantContentfilters reasoning out againnpx tsc --noEmitreports only the pre-existingagent-llm.ts:47providerOptionserror, which is present on a clean checkout ofmainas well (verified by stashing).npx rollup -cbuilds.Not covered
The alternative the issue also mentions — disabling thinking mode for DeepSeek — is not done here. Sending the reasoning back is the behaviour-preserving half: it keeps the model's thinking available to both the user and the model, and it fixes Anthropic's signature case at the same time. A switch to turn thinking off is a configuration feature, not a bug fix, and belongs in its own change.
Not verified against the live DeepSeek API — I have no key for it. What is verified is that the reasoning part now reaches the provider layer, and that
@ai-sdk/openai-compatibleturns that part intoreasoning_content.