diff --git a/src/api/providers/__tests__/openrouter.spec.ts b/src/api/providers/__tests__/openrouter.spec.ts index ae36fc1399..af598d7103 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 580b173311..e55be912c4 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