From 146d867db1e7ff6795000d1e69cbad4f40f7e36a Mon Sep 17 00:00:00 2001 From: Roo Code Date: Fri, 17 Oct 2025 13:37:20 +0000 Subject: [PATCH 1/3] fix: add extra_body support for DeepSeek V3.1 Terminus reasoning control - Use chat_template_kwargs with thinking parameter instead of reasoning param - Default to reasoning OFF for DeepSeek V3.1 Terminus - Enable reasoning only when explicitly requested via reasoning settings - Add comprehensive tests for the new functionality Fixes #8270 --- .../providers/__tests__/openrouter.spec.ts | 153 ++++++++++++++++++ src/api/providers/openrouter.ts | 46 +++++- 2 files changed, 197 insertions(+), 2 deletions(-) diff --git a/src/api/providers/__tests__/openrouter.spec.ts b/src/api/providers/__tests__/openrouter.spec.ts index ae36fc1399d6..af598d71037e 100644 --- a/src/api/providers/__tests__/openrouter.spec.ts +++ b/src/api/providers/__tests__/openrouter.spec.ts @@ -320,4 +320,157 @@ describe("OpenRouterHandler", () => { await expect(handler.completePrompt("test prompt")).rejects.toThrow("Unexpected error") }) }) + + describe("DeepSeek V3.1 Terminus", () => { + it("uses extra_body for reasoning control in createMessage", async () => { + const handler = new OpenRouterHandler({ + ...mockOptions, + openRouterModelId: "deepseek/deepseek-v3.1-terminus", + }) + + const mockStream = { + async *[Symbol.asyncIterator]() { + yield { + id: "test-id", + choices: [{ delta: { content: "test response" } }], + } + }, + } + + const mockCreate = vitest.fn().mockResolvedValue(mockStream) + ;(OpenAI as any).prototype.chat = { + completions: { create: mockCreate }, + } as any + + // Test with reasoning disabled (default) + await handler.createMessage("test", []).next() + + expect(mockCreate).toHaveBeenCalledWith( + expect.objectContaining({ + model: "deepseek/deepseek-v3.1-terminus", + extra_body: { + chat_template_kwargs: { + thinking: false, + }, + }, + }), + ) + expect(mockCreate).not.toHaveBeenCalledWith( + expect.objectContaining({ + reasoning: expect.anything(), + }), + ) + }) + + it("enables thinking when reasoning is requested", async () => { + // Mock getModels to return a model with reasoning capability + const { getModels } = await import("../fetchers/modelCache") + vitest.mocked(getModels).mockResolvedValueOnce({ + "deepseek/deepseek-v3.1-terminus": { + maxTokens: 8192, + contextWindow: 128000, + supportsImages: false, + supportsPromptCache: false, + inputPrice: 0.5, + outputPrice: 1.5, + description: "DeepSeek V3.1 Terminus", + reasoningEffort: "high", + }, + }) + + const handler = new OpenRouterHandler({ + ...mockOptions, + openRouterModelId: "deepseek/deepseek-v3.1-terminus", + reasoningEffort: "high", + }) + + const mockStream = { + async *[Symbol.asyncIterator]() { + yield { + id: "test-id", + choices: [{ delta: { content: "test response" } }], + } + }, + } + + const mockCreate = vitest.fn().mockResolvedValue(mockStream) + ;(OpenAI as any).prototype.chat = { + completions: { create: mockCreate }, + } as any + + await handler.createMessage("test", []).next() + + expect(mockCreate).toHaveBeenCalledWith( + expect.objectContaining({ + model: "deepseek/deepseek-v3.1-terminus", + extra_body: { + chat_template_kwargs: { + thinking: true, + }, + }, + }), + ) + }) + + it("uses extra_body for reasoning control in completePrompt", async () => { + const handler = new OpenRouterHandler({ + ...mockOptions, + openRouterModelId: "deepseek/deepseek-v3.1-terminus", + }) + + const mockResponse = { choices: [{ message: { content: "test completion" } }] } + + const mockCreate = vitest.fn().mockResolvedValue(mockResponse) + ;(OpenAI as any).prototype.chat = { + completions: { create: mockCreate }, + } as any + + await handler.completePrompt("test prompt") + + expect(mockCreate).toHaveBeenCalledWith( + expect.objectContaining({ + model: "deepseek/deepseek-v3.1-terminus", + extra_body: { + chat_template_kwargs: { + thinking: false, + }, + }, + }), + ) + expect(mockCreate).not.toHaveBeenCalledWith( + expect.objectContaining({ + reasoning: expect.anything(), + }), + ) + }) + + it("does not use extra_body for other models", async () => { + const handler = new OpenRouterHandler({ + ...mockOptions, + openRouterModelId: "anthropic/claude-sonnet-4", + }) + + const mockStream = { + async *[Symbol.asyncIterator]() { + yield { + id: "test-id", + choices: [{ delta: { content: "test response" } }], + } + }, + } + + const mockCreate = vitest.fn().mockResolvedValue(mockStream) + ;(OpenAI as any).prototype.chat = { + completions: { create: mockCreate }, + } as any + + await handler.createMessage("test", []).next() + + expect(mockCreate).not.toHaveBeenCalledWith( + expect.objectContaining({ + extra_body: expect.anything(), + }), + ) + }) + }) }) diff --git a/src/api/providers/openrouter.ts b/src/api/providers/openrouter.ts index 580b17331194..e55be912c4fa 100644 --- a/src/api/providers/openrouter.ts +++ b/src/api/providers/openrouter.ts @@ -60,6 +60,12 @@ type OpenRouterChatCompletionParams = OpenAI.Chat.ChatCompletionCreateParams & { include_reasoning?: boolean // https://openrouter.ai/docs/use-cases/reasoning-tokens reasoning?: OpenRouterReasoningParams + // For DeepSeek models that require extra_body + extra_body?: { + chat_template_kwargs?: { + thinking?: boolean + } + } } // See `OpenAI.Chat.Completions.ChatCompletionChunk["usage"]` @@ -141,6 +147,23 @@ export class OpenRouterHandler extends BaseProvider implements SingleCompletionH const transforms = (this.options.openRouterUseMiddleOutTransform ?? true) ? ["middle-out"] : undefined + // For DeepSeek V3.1 Terminus, use extra_body to control reasoning + const isDeepSeekV3Terminus = modelId === "deepseek/deepseek-v3.1-terminus" + let extraBody: OpenRouterChatCompletionParams["extra_body"] = undefined + + if (isDeepSeekV3Terminus) { + // Default to reasoning OFF for DeepSeek V3.1 Terminus + // Enable only if reasoning is explicitly requested + const enableThinking = Boolean( + reasoning && !reasoning.exclude && (reasoning.max_tokens || reasoning.effort), + ) + extraBody = { + chat_template_kwargs: { + thinking: enableThinking, + }, + } + } + // https://openrouter.ai/docs/transforms const completionParams: OpenRouterChatCompletionParams = { model: modelId, @@ -160,7 +183,8 @@ export class OpenRouterHandler extends BaseProvider implements SingleCompletionH }, }), ...(transforms && { transforms }), - ...(reasoning && { reasoning }), + // For DeepSeek V3.1 Terminus, use extra_body instead of reasoning param + ...(isDeepSeekV3Terminus ? { extra_body: extraBody } : reasoning && { reasoning }), } let stream @@ -248,6 +272,23 @@ export class OpenRouterHandler extends BaseProvider implements SingleCompletionH async completePrompt(prompt: string) { let { id: modelId, maxTokens, temperature, reasoning } = await this.fetchModel() + // For DeepSeek V3.1 Terminus, use extra_body to control reasoning + const isDeepSeekV3Terminus = modelId === "deepseek/deepseek-v3.1-terminus" + let extraBody: OpenRouterChatCompletionParams["extra_body"] = undefined + + if (isDeepSeekV3Terminus) { + // Default to reasoning OFF for DeepSeek V3.1 Terminus + // Enable only if reasoning is explicitly requested + const enableThinking = Boolean( + reasoning && !reasoning.exclude && (reasoning.max_tokens || reasoning.effort), + ) + extraBody = { + chat_template_kwargs: { + thinking: enableThinking, + }, + } + } + const completionParams: OpenRouterChatCompletionParams = { model: modelId, max_tokens: maxTokens, @@ -263,7 +304,8 @@ export class OpenRouterHandler extends BaseProvider implements SingleCompletionH allow_fallbacks: false, }, }), - ...(reasoning && { reasoning }), + // For DeepSeek V3.1 Terminus, use extra_body instead of reasoning param + ...(isDeepSeekV3Terminus ? { extra_body: extraBody } : reasoning && { reasoning }), } let response From 6aed66930b4eb0ce3ae7880af2c307f5aff7f1be Mon Sep 17 00:00:00 2001 From: Roo Code Date: Fri, 17 Oct 2025 14:07:55 +0000 Subject: [PATCH 2/3] test(e2e): load .env.local inside VS Code test runner to ensure OPENROUTER_API_KEY --- apps/vscode-e2e/src/suite/index.ts | 25 +++++++++++++++++++++++++ 1 file changed, 25 insertions(+) diff --git a/apps/vscode-e2e/src/suite/index.ts b/apps/vscode-e2e/src/suite/index.ts index ab0be6e5dffb..2ef827e13275 100644 --- a/apps/vscode-e2e/src/suite/index.ts +++ b/apps/vscode-e2e/src/suite/index.ts @@ -6,6 +6,7 @@ import * as vscode from "vscode" import type { RooCodeAPI } from "@roo-code/types" import { waitFor } from "./utils" +import * as fs from "fs" export async function run() { const extension = vscode.extensions.getExtension("RooVeterinaryInc.roo-cline") @@ -16,6 +17,30 @@ export async function run() { const api = extension.isActive ? extension.exports : await extension.activate() + // Ensure OPENROUTER_API_KEY is loaded from .env.local in CI (written by workflow) + // __dirname at runtime is apps/vscode-e2e/out/suite, so go up two levels + const envPath = path.resolve(__dirname, "..", "..", ".env.local") + if (!process.env.OPENROUTER_API_KEY && fs.existsSync(envPath)) { + try { + const content = fs.readFileSync(envPath, "utf8") + for (const rawLine of content.split("\n")) { + const line = rawLine.trim() + if (!line || line.startsWith("#")) continue + const match = line.match(/^\s*([A-Za-z_][A-Za-z0-9_]*)\s*=\s*(.*)\s*$/) + if (!match) continue + const key = match[1] + let val = match[2] + // Strip surrounding quotes if present + if ((val.startsWith('"') && val.endsWith('"')) || (val.startsWith("'") && val.endsWith("'"))) { + val = val.slice(1, -1) + } + if (!process.env[key]) process.env[key] = val + } + } catch { + // ignore env load errors; tests may still pass without API calls + } + } + await api.setConfiguration({ apiProvider: "openrouter" as const, openRouterApiKey: process.env.OPENROUTER_API_KEY!, From 21da7b5751f826ffeaaaa6aa17472cb55499c4fa Mon Sep 17 00:00:00 2001 From: Roo Code Date: Fri, 17 Oct 2025 14:11:56 +0000 Subject: [PATCH 3/3] test(e2e): robust .env.local loader for VS Code extension host tests --- apps/vscode-e2e/src/suite/index.ts | 12 +++++++----- 1 file changed, 7 insertions(+), 5 deletions(-) diff --git a/apps/vscode-e2e/src/suite/index.ts b/apps/vscode-e2e/src/suite/index.ts index 2ef827e13275..be9f020ad9b8 100644 --- a/apps/vscode-e2e/src/suite/index.ts +++ b/apps/vscode-e2e/src/suite/index.ts @@ -26,15 +26,17 @@ export async function run() { for (const rawLine of content.split("\n")) { const line = rawLine.trim() if (!line || line.startsWith("#")) continue - const match = line.match(/^\s*([A-Za-z_][A-Za-z0-9_]*)\s*=\s*(.*)\s*$/) - if (!match) continue - const key = match[1] - let val = match[2] + const m = /^\s*([A-Za-z_][A-Za-z0-9_]*)\s*=\s*(.*)\s*$/.exec(line) + if (!m) continue + const key: string = m[1] ?? "" + let val: string = m[2] ?? "" // Strip surrounding quotes if present if ((val.startsWith('"') && val.endsWith('"')) || (val.startsWith("'") && val.endsWith("'"))) { val = val.slice(1, -1) } - if (!process.env[key]) process.env[key] = val + if (key && !(key in process.env)) { + ;(process.env as Record)[key] = val + } } } catch { // ignore env load errors; tests may still pass without API calls