From beef72ead7dc8860cd35f210badec0f05f3eaf75 Mon Sep 17 00:00:00 2001 From: Chaos Date: Tue, 4 Aug 2026 11:08:38 +0200 Subject: [PATCH 1/3] fix(openai): preserve reasoning for local OpenAI-compatible models via R1 toggle MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. --- src/api/providers/__tests__/openai.spec.ts | 14 ++++++++++++++ src/api/providers/openai.ts | 10 +++++++++- 2 files changed, 23 insertions(+), 1 deletion(-) diff --git a/src/api/providers/__tests__/openai.spec.ts b/src/api/providers/__tests__/openai.spec.ts index 3e18f03a4c..ec4daef5d9 100644 --- a/src/api/providers/__tests__/openai.spec.ts +++ b/src/api/providers/__tests__/openai.spec.ts @@ -837,6 +837,20 @@ describe("OpenAiHandler", () => { expect(model.id).toBe("") expect(model.info).toBeDefined() }) + + it("should set preserveReasoning when openAiR1FormatEnabled is on", () => { + const r1Handler = new OpenAiHandler({ + ...mockOptions, + openAiR1FormatEnabled: true, + }) + const model = r1Handler.getModel() + expect(model.info.preserveReasoning).toBe(true) + }) + + it("should not set preserveReasoning by default", () => { + const model = handler.getModel() + expect(model.info.preserveReasoning).toBeUndefined() + }) }) describe("Azure AI Inference Service", () => { diff --git a/src/api/providers/openai.ts b/src/api/providers/openai.ts index 9545068794..0774642bcf 100644 --- a/src/api/providers/openai.ts +++ b/src/api/providers/openai.ts @@ -293,7 +293,15 @@ export class OpenAiHandler extends BaseProvider implements SingleCompletionHandl settings: this.options, defaultTemperature: 0, }) - return { id, info, ...params } + // Local OpenAI-compatible reasoning models (llama.cpp, LM Studio, Ollama) + // stream reasoning_content, but Roo strips it from the follow-up context + // unless info.preserveReasoning is set. When the user enables the R1 format + // toggle, treat the model as preserving reasoning so the chain is fed back. + return { + id, + info: this.options.openAiR1FormatEnabled ? { ...info, preserveReasoning: true } : info, + ...params, + } } async completePrompt(prompt: string, options?: CompletePromptOptions): Promise { From f4a69cc880d6c9070221744d7b8997fa0a8b7454 Mon Sep 17 00:00:00 2001 From: MahooCoder Date: Sun, 9 Aug 2026 11:36:18 +0200 Subject: [PATCH 2/3] Update src/api/providers/__tests__/openai.spec.ts Co-authored-by: edelauna <54631123+edelauna@users.noreply.github.com> --- src/api/providers/__tests__/openai.spec.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/api/providers/__tests__/openai.spec.ts b/src/api/providers/__tests__/openai.spec.ts index ec4daef5d9..65534da8d6 100644 --- a/src/api/providers/__tests__/openai.spec.ts +++ b/src/api/providers/__tests__/openai.spec.ts @@ -844,7 +844,7 @@ describe("OpenAiHandler", () => { openAiR1FormatEnabled: true, }) const model = r1Handler.getModel() - expect(model.info.preserveReasoning).toBe(true) + expect(model.info).toEqual({ ...openAiModelInfoSaneDefaults, preserveReasoning: true }) }) it("should not set preserveReasoning by default", () => { From 1a47887d772bb855c917275eaa6ae733b3804baa Mon Sep 17 00:00:00 2001 From: Chaos Date: Mon, 5 Oct 2026 09:30:10 +0200 Subject: [PATCH 3/3] fix(openai): share the R1 gate between createMessage and getModel 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 --- src/api/providers/__tests__/openai.spec.ts | 26 ++++++++ src/api/providers/openai.ts | 20 ++++-- .../__tests__/reasoning-preservation.test.ts | 66 +++++++++++++++++++ 3 files changed, 106 insertions(+), 6 deletions(-) diff --git a/src/api/providers/__tests__/openai.spec.ts b/src/api/providers/__tests__/openai.spec.ts index 6c44cfee3d..8e2728474b 100644 --- a/src/api/providers/__tests__/openai.spec.ts +++ b/src/api/providers/__tests__/openai.spec.ts @@ -1128,6 +1128,32 @@ describe("OpenAiHandler", () => { expect(model.info).toEqual({ ...openAiModelInfoSaneDefaults, preserveReasoning: true }) }) + it("should set preserveReasoning for deepseek-reasoner model ids without the toggle", () => { + const deepseekHandler = new OpenAiHandler({ + ...mockOptions, + openAiModelId: "deepseek-reasoner", + }) + const model = deepseekHandler.getModel() + expect(model.info.preserveReasoning).toBe(true) + }) + + it("should merge preserveReasoning into custom model info when openAiR1FormatEnabled is on", () => { + const customInfo: ModelInfo = { + ...openAiModelInfoSaneDefaults, + contextWindow: 32_768, + supportsImages: false, + } + const r1Handler = new OpenAiHandler({ + ...mockOptions, + openAiCustomModelInfo: customInfo, + openAiR1FormatEnabled: true, + }) + const model = r1Handler.getModel() + expect(model.info).toEqual({ ...customInfo, preserveReasoning: true }) + expect(model.info.contextWindow).toBe(32_768) + expect(model.info.supportsImages).toBe(false) + }) + it("should not set preserveReasoning by default", () => { const model = handler.getModel() expect(model.info.preserveReasoning).toBeUndefined() diff --git a/src/api/providers/openai.ts b/src/api/providers/openai.ts index 525f6a1fa2..a208e59fa7 100644 --- a/src/api/providers/openai.ts +++ b/src/api/providers/openai.ts @@ -91,9 +91,8 @@ export class OpenAiHandler extends BaseProvider implements SingleCompletionHandl const { info: modelInfo, reasoning } = this.getModel() const modelUrl = this.options.openAiBaseUrl ?? "" const modelId = this.options.openAiModelId ?? "" - const enabledR1Format = this.options.openAiR1FormatEnabled ?? false const isAzureAiInference = this._isAzureAiInference(modelUrl) - const deepseekReasoner = modelId.includes("deepseek-reasoner") || enabledR1Format + const deepseekReasoner = this.usesR1Format(modelId) if (modelId.includes("o1") || modelId.includes("o3") || modelId.includes("o4")) { yield* this.handleO3FamilyMessage(modelId, systemPrompt, messages, metadata) @@ -294,6 +293,14 @@ export class OpenAiHandler extends BaseProvider implements SingleCompletionHandl } } + // Single gate for everything that depends on the R1 request format: message + // conversion in createMessage() and reasoning retention in getModel(). Both + // must agree, otherwise reasoning is converted for the request but stripped + // from the follow-up context (or vice versa). + private usesR1Format(modelId: string): boolean { + return modelId.includes("deepseek-reasoner") || (this.options.openAiR1FormatEnabled ?? false) + } + override getModel() { const id = this.options.openAiModelId ?? "" const info: ModelInfo = this.options.openAiCustomModelInfo ?? openAiModelInfoSaneDefaults @@ -307,12 +314,13 @@ export class OpenAiHandler extends BaseProvider implements SingleCompletionHandl defaultTemperature: 0, }) // Local OpenAI-compatible reasoning models (llama.cpp, LM Studio, Ollama) - // stream reasoning_content, but Roo strips it from the follow-up context - // unless info.preserveReasoning is set. When the user enables the R1 format - // toggle, treat the model as preserving reasoning so the chain is fed back. + // stream reasoning_content, but Zoo Code strips it from the follow-up context + // unless info.preserveReasoning is set. Whenever createMessage() treats the + // model as R1 (deepseek-reasoner id or the R1 format toggle), also treat it + // as preserving reasoning so the chain is fed back. return { id, - info: this.options.openAiR1FormatEnabled ? { ...info, preserveReasoning: true } : info, + info: this.usesR1Format(id) ? { ...info, preserveReasoning: true } : info, ...params, } } diff --git a/src/core/task/__tests__/reasoning-preservation.test.ts b/src/core/task/__tests__/reasoning-preservation.test.ts index f34cea1bdc..16e959fcc3 100644 --- a/src/core/task/__tests__/reasoning-preservation.test.ts +++ b/src/core/task/__tests__/reasoning-preservation.test.ts @@ -512,4 +512,70 @@ describe("Task reasoning preservation", () => { text: assistantText, }) }) + + it("should keep plain text reasoning in buildCleanConversationHistory when preserveReasoning is true", async () => { + const task = new Task({ + provider: mockProvider as ClineProvider, + apiConfiguration: mockApiConfiguration, + task: "Test task", + startTask: false, + }) + + const requestModelInfo: ModelInfo = { + contextWindow: 16000, + supportsPromptCache: true, + preserveReasoning: true, + } + + await task["addToApiConversationHistory"]( + { + role: "assistant", + content: [{ type: "text", text: "Reading the file." }], + }, + "I should read the file first.", + ) + + const history = task["buildCleanConversationHistory"](task.apiConversationHistory, requestModelInfo) + + expect(history).toHaveLength(1) + expect(history[0]).toMatchObject({ + role: "assistant", + content: [ + { type: "reasoning", text: "I should read the file first.", summary: [] }, + { type: "text", text: "Reading the file." }, + ], + }) + }) + + it("should strip plain text reasoning in buildCleanConversationHistory when preserveReasoning is not set", async () => { + const task = new Task({ + provider: mockProvider as ClineProvider, + apiConfiguration: mockApiConfiguration, + task: "Test task", + startTask: false, + }) + + // preserveReasoning is undefined on this model info + const requestModelInfo: ModelInfo = { + contextWindow: 16000, + supportsPromptCache: true, + } + + await task["addToApiConversationHistory"]( + { + role: "assistant", + content: [{ type: "text", text: "Reading the file." }], + }, + "I should read the file first.", + ) + + const history = task["buildCleanConversationHistory"](task.apiConversationHistory, requestModelInfo) + + expect(history).toHaveLength(1) + expect(history[0]).toMatchObject({ + role: "assistant", + content: "Reading the file.", + }) + expect(JSON.stringify(history)).not.toContain("reasoning") + }) })