Repository navigation
fix(openai): preserve reasoning for local OpenAI-compatible models via R1 toggle - #1119
ch405canova-sudo wants to merge 6 commits into
Conversation
…a R1 toggle The openAiR1FormatEnabled toggle (UI + provider-settings) only forced the R1 request format, but getModel() never set info.preserveReasoning. As a result Task.ts (shouldPreserveForApi = info.preserveReasoning === true) stripped reasoning_content from follow-up context for every local OpenAI-compatible reasoning model (llama.cpp, LM Studio, Ollama) — there was no way to feed the chain back. Enable the toggle and getModel() now sets preserveReasoning: true so the reasoning chain is preserved in the next-turn context. Default behaviour unchanged.
|
Navigate logical layers of code changes, visualize relationships, and explore their blast radius. No actionable comments were generated in the recent review. 🎉 ℹ️ Recent review info⚙️ Run configuration
📒 Files selected for processing (3)
Included review availability: This review used your included allowance. Your plan provides up to 4 included reviews per hour; 3 remain after this review. 📜 Recent review details🧰 Additional context used📓 Path-based instructions (6)Check persistence and lifecycle invariants: awaited atomic writes, rollback or explicit partial-failure behavior, cross-window state consistency, stale listeners/watchers, cancellation, idempotency, and safe restart/resume without lost or d...⚙️ CodeRabbit configuration file Files:
Treat model, provider, MCP, path, command, and tool data as untrusted.⚙️ CodeRabbit configuration file Files:
Require regression coverage at the lowest valid harness with behavior-focused assertions, including relevant negative, error, false/unset, and boundary cases.⚙️ CodeRabbit configuration file Files:
Check strict typing and exhaustive behavior across normal, boundary, error, cancellation, retry, and compatibility paths.⚙️ CodeRabbit configuration file Files:
Verify extension/webview contracts, cancellation and error propagation, VS Code lifecycle correctness, and behavior under retries and partial failure.⚙️ CodeRabbit configuration file Files:
Act as an adversarial second-opinion reviewer.⚙️ CodeRabbit configuration file Files:
🔇 Additional comments (3)
📝 SummarySummary by CodeRabbit
WalkthroughThe OpenAI provider now uses a shared predicate for R1 format detection. When the predicate is true, ChangesOpenAI reasoning configuration
Priority: ➖ Normal Estimated code review effort: 2 (Simple) | ~10 minutes Change: Bug fix · Severity of issue fixed: Medium Merge Risk: ⚪ Minimal · up to The R1 setting and DeepSeek Reasoner model IDs now enable reasoning preservation, with the default behavior unchanged. No merge-blocking risk is established. Security Architecture ReviewSecurity architecture risk: 🔵 Low · up to The change is narrowly scoped, but switching configurations while a request is being prepared can leave reasoning included when the new configuration would normally omit it. Exposure depends on timing and is limited to the active conversation and selected destination. Retained concerns
Security review detailsSecurity Blast Radius
Security Findings and Attack Paths
Trust Boundaries and Controls
Resilience and Maintainability Implications
Hardening Proposals
Important Pre-merge checks failedPlease resolve all errors before merging. Addressing warnings is optional. ❌ Failed checks (1 inconclusive)
✅ Passed checks (7 passed)
Full details: Linked Issues checkExplanation
✨ Finishing Touches🧪 Generate unit tests (beta)
Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out. Comment |
|
This work is a joint effort by chaos (@ch405canova-sudo) and opencode — the bug was found and the fix developed together on a local llama.cpp stack (August 2026). |
Codecov Report✅ All modified and coverable lines are covered by tests. 📢 Thoughts on this report? Let us know! |
|
This PR addresses the same root cause as #1096 (local OpenAI-compatible reasoning is discarded because Note on approach: #1096 proposes a separate |
| // toggle, treat the model as preserving reasoning so the chain is fed back. | ||
| return { | ||
| id, | ||
| info: this.options.openAiR1FormatEnabled ? { ...info, preserveReasoning: true } : info, |
There was a problem hiding this comment.
createMessage() treats a model as R1-format via modelId.includes("deepseek-reasoner") || enabledR1Format (line 90), but this gate only checks the toggle. If someone points openAiModelId at a custom endpoint whose id contains deepseek-reasoner without flipping the toggle, wouldn't createMessage still use R1 conversion while preserveReasoning never gets set here — leaving the original bug open for that case?
| // toggle, treat the model as preserving reasoning so the chain is fed back. | ||
| return { | ||
| id, | ||
| info: this.options.openAiR1FormatEnabled ? { ...info, preserveReasoning: true } : info, |
There was a problem hiding this comment.
This forces preserveReasoning: true whenever the toggle is on, even if openAiCustomModelInfo.preserveReasoning was explicitly set to false. Should an explicit value win here, e.g. info.preserveReasoning ?? true?
Co-authored-by: edelauna <54631123+edelauna@users.noreply.github.com>
|
This PR has been awaiting author changes for 14 days and will be automatically closed in 7 days. Please address the review comments or leave a comment if you need more time. |
Review statusThanks for contributing. This comment tracks the review sequence and the next action. Current step: Awaiting fresh human maintainer or CODEOWNER approval. Automated review is complete for the latest commit but does not replace human approval. Review-state labels are managed by this workflow; do not edit them manually. |
edelauna
left a comment
There was a problem hiding this comment.
Thanks for the update - had one comment on how to handle deepseek
| // toggle, treat the model as preserving reasoning so the chain is fed back. | ||
| return { | ||
| id, | ||
| info: this.options.openAiR1FormatEnabled ? { ...info, preserveReasoning: true } : info, |
There was a problem hiding this comment.
Line 95 derives the R1 path from modelId.includes("deepseek-reasoner") || enabledR1Format, so a model id containing deepseek-reasoner goes through convertToR1Format while Task.ts still strips its reasoning (preserveReasoning stays unset here). Should both gates share one predicate?
| info: this.options.openAiR1FormatEnabled ? { ...info, preserveReasoning: true } : info, | |
| info: (id.includes("deepseek-reasoner") || this.options.openAiR1FormatEnabled) ? { ...info, preserveReasoning: true } : info, |
(id is already in scope in getModel; createMessage could reuse the same condition.)
There was a problem hiding this comment.
Done in 1a47887 — both gates now share a single predicate, OpenAiHandler.usesR1Format(modelId) (modelId.includes("deepseek-reasoner") || (this.options.openAiR1FormatEnabled ?? false)), so createMessage() and getModel() can no longer drift apart. Added a test covering a deepseek-reasoner model id with the toggle off.
| openAiR1FormatEnabled: true, | ||
| }) | ||
| const model = r1Handler.getModel() | ||
| expect(model.info).toEqual({ ...openAiModelInfoSaneDefaults, preserveReasoning: true }) |
There was a problem hiding this comment.
Both new tests leave openAiCustomModelInfo unset, so info is value-identical to the defaults here — the { ...info } merge only gets exercised on the defaults path. A case combining custom model info (e.g. a distinct contextWindow) with the toggle would pin the merge for the llama.cpp / LM Studio / Ollama setups this PR targets. Worth adding?
There was a problem hiding this comment.
Added in 1a47887: should merge preserveReasoning into custom model info when openAiR1FormatEnabled is on builds openAiCustomModelInfo with a distinct contextWindow (32_768) and supportsImages: false, and asserts the full merged object { ...customInfo, preserveReasoning: true }, so the { ...info } merge path is actually exercised.
|
|
||
| it("should not set preserveReasoning by default", () => { | ||
| const model = handler.getModel() | ||
| expect(model.info.preserveReasoning).toBeUndefined() |
There was a problem hiding this comment.
This pins the flag, but the consumer it feeds — buildCleanConversationHistory in Task.ts (preserveReasoning === true at Task.ts:5020) — is only covered by tests that re-implement the branch (reasoning-preservation.test.ts:223, 290, 347, 398), so deleting the production gate would still pass everything. Would a regression test calling the real method be worth adding here?
There was a problem hiding this comment.
Added in 1a47887: two tests in reasoning-preservation.test.ts store a plain-text reasoning block through the real addToApiConversationHistory and then call the real Task.buildCleanConversationHistory for both the preserve and the strip path. I mutation-checked them: removing the production gate fails the preserve test, inverting it fails the strip test. Note that on current main the method takes requestModelInfo as a second argument (the gate now reads requestModelInfo.preserveReasoning === true, Task.ts:5800), so the tests pass the ModelInfo through the real signature instead of stubbing api.getModel().
|
Hello, I'm very interested in this fix but it seems nothing has really moved on this PR for several weeks. Could we try to finish it? I can help if needed. |
createMessage() treats a model as R1-format when the id contains deepseek-reasoner or the R1 toggle is on, but getModel() only set preserveReasoning for the toggle. A deepseek-reasoner model id without the toggle therefore went through convertToR1Format while Task.ts still stripped its reasoning from the follow-up context. - extract OpenAiHandler.usesR1Format() as the single predicate used by both gates - tests: deepseek-reasoner id without the toggle, preserveReasoning merged into openAiCustomModelInfo (contextWindow etc. preserved) - regression tests calling the real Task.buildCleanConversationHistory for both the preserve and the strip path
|
Update: the three review comments from Sep 11 are addressed and the branch is rebased onto the current What changed
Verification
@edelauna — re-requesting your review. @LouisClt — thanks for the ping, and for filing #1665. That one (Bedrock |
Summary
Local OpenAI-compatible reasoning models (llama.cpp
llama-server, LM Studio, Ollama's OpenAI endpoint, vLLM, etc.) stream areasoning_contentfield, but Zoo Code strips it from the follow-up context for these providers — so the model can never see its own reasoning chain on the next turn.Root cause
openAiR1FormatEnabled(exposed as a UI checkbox viaR1FormatSettingand declared inprovider-settings.ts) only forces the R1 request format inopenai.ts. It never propagates toinfo.preserveReasoning, which is what gates reasoning retention inTask.ts:OpenAiHandler.getModel()builds itsModelInfofromopenAiCustomModelInfo ?? openAiModelInfoSaneDefaults— neither setspreserveReasoning. For the built-in OpenAI-compatible provider the value is therefore alwaysundefined, and reasoning is stripped from messages sent back to the API.Fix
When the user enables the R1 format toggle,
getModel()now setspreserveReasoning: trueon the returned model info:This preserves
reasoning_contentin the assistant history sent to the model on follow-up turns. Default behaviour (toggle off) is unchanged.Tests
Added two
getModelcases tosrc/api/providers/__tests__/openai.spec.ts:preserveReasoningistruewhenopenAiR1FormatEnabledis onpreserveReasoningstaysundefinedby defaultRelated
Fixes #1118