diff --git a/src/api/providers/__tests__/openrouter.spec.ts b/src/api/providers/__tests__/openrouter.spec.ts index 37b839f303..8607976aff 100644 --- a/src/api/providers/__tests__/openrouter.spec.ts +++ b/src/api/providers/__tests__/openrouter.spec.ts @@ -338,4 +338,129 @@ describe("OpenRouterHandler", () => { await expect(handler.completePrompt("test prompt")).rejects.toThrow("Unexpected error") }) }) + + describe("DeepSeek V3 Model Handling", () => { + it("should NOT use R1 format for DeepSeek V3 models", async () => { + const deepseekV3Handler = new OpenRouterHandler({ + ...mockOptions, + openRouterModelId: "deepseek/deepseek-v3", + }) + + const mockStream = { + [Symbol.asyncIterator]: async function* () { + yield { + choices: [{ delta: { content: "test" }, finish_reason: null }], + usage: null, + } + yield { + choices: [{ delta: {}, finish_reason: "stop" }], + usage: { prompt_tokens: 10, completion_tokens: 5 }, + } + }, + } + + const mockCreate = vitest.fn().mockResolvedValue(mockStream) + ;(OpenAI as any).prototype.chat = { + completions: { create: mockCreate }, + } as any + + const generator = deepseekV3Handler.createMessage("system prompt", []) + const chunks = [] + for await (const chunk of generator) { + chunks.push(chunk) + } + + // Verify that the messages were NOT converted to R1 format + expect(mockCreate).toHaveBeenCalledWith( + expect.objectContaining({ + messages: expect.arrayContaining([ + expect.objectContaining({ role: "system", content: expect.anything() }), + ]), + }), + undefined, + ) + }) + + it("should NOT use R1 format for DeepSeek 3.2 models", async () => { + const deepseek32Handler = new OpenRouterHandler({ + ...mockOptions, + openRouterModelId: "deepseek/deepseek-3.2", + }) + + const mockStream = { + [Symbol.asyncIterator]: async function* () { + yield { + choices: [{ delta: { content: "test" }, finish_reason: null }], + usage: null, + } + yield { + choices: [{ delta: {}, finish_reason: "stop" }], + usage: { prompt_tokens: 10, completion_tokens: 5 }, + } + }, + } + + const mockCreate = vitest.fn().mockResolvedValue(mockStream) + ;(OpenAI as any).prototype.chat = { + completions: { create: mockCreate }, + } as any + + const generator = deepseek32Handler.createMessage("system prompt", []) + const chunks = [] + for await (const chunk of generator) { + chunks.push(chunk) + } + + // Verify that the messages were NOT converted to R1 format + expect(mockCreate).toHaveBeenCalledWith( + expect.objectContaining({ + messages: expect.arrayContaining([ + expect.objectContaining({ role: "system", content: expect.anything() }), + ]), + }), + undefined, + ) + }) + + it("should still use R1 format for DeepSeek R1 models", async () => { + const deepseekR1Handler = new OpenRouterHandler({ + ...mockOptions, + openRouterModelId: "deepseek/deepseek-r1", + }) + + const mockStream = { + [Symbol.asyncIterator]: async function* () { + yield { + choices: [{ delta: { content: "test" }, finish_reason: null }], + usage: null, + } + yield { + choices: [{ delta: {}, finish_reason: "stop" }], + usage: { prompt_tokens: 10, completion_tokens: 5 }, + } + }, + } + + const mockCreate = vitest.fn().mockResolvedValue(mockStream) + ;(OpenAI as any).prototype.chat = { + completions: { create: mockCreate }, + } as any + + const generator = deepseekR1Handler.createMessage("system prompt", []) + const chunks = [] + for await (const chunk of generator) { + chunks.push(chunk) + } + + // Verify that the messages WERE converted to R1 format (user role instead of system) + expect(mockCreate).toHaveBeenCalledWith( + expect.objectContaining({ + messages: expect.arrayContaining([ + expect.objectContaining({ role: "user", content: expect.anything() }), + ]), + }), + undefined, + ) + }) + }) }) diff --git a/src/api/providers/fetchers/openrouter.ts b/src/api/providers/fetchers/openrouter.ts index 38d3c52fa9..1882e280f3 100644 --- a/src/api/providers/fetchers/openrouter.ts +++ b/src/api/providers/fetchers/openrouter.ts @@ -266,5 +266,23 @@ export const parseOpenRouterModel = ({ modelInfo.maxTokens = 32768 } + // Configure DeepSeek V3 models properly + // These models support standard tool calling but NOT R1 format + if ( + id.startsWith("deepseek/deepseek-v3") || + id.startsWith("deepseek/deepseek-3") || + id === "deepseek/deepseek-chat" + ) { + // Ensure these models are marked as supporting native tools + // but NOT reasoning format (they're not R1 models) + if (modelInfo.supportsNativeTools === undefined) { + modelInfo.supportsNativeTools = true + } + // Ensure reasonable max tokens if not set + if (!modelInfo.maxTokens || modelInfo.maxTokens < 8192) { + modelInfo.maxTokens = 8192 + } + } + return modelInfo } diff --git a/src/api/providers/openrouter.ts b/src/api/providers/openrouter.ts index e8e95ad58d..eb40e241c5 100644 --- a/src/api/providers/openrouter.ts +++ b/src/api/providers/openrouter.ts @@ -135,7 +135,8 @@ export class OpenRouterHandler extends BaseProvider implements SingleCompletionH ...convertToOpenAiMessages(messages), ] - // DeepSeek highly recommends using user instead of system role. + // DeepSeek R1 models require user instead of system role. + // Note: DeepSeek V3 models (deepseek-v3, deepseek-3.2, etc.) do NOT use R1 format if (modelId.startsWith("deepseek/deepseek-r1") || modelId === "perplexity/sonar-reasoning") { openAiMessages = convertToR1Format([{ role: "user", content: systemPrompt }, ...messages]) } @@ -388,7 +389,13 @@ export class OpenRouterHandler extends BaseProvider implements SingleCompletionH info = this.endpoints[this.options.openRouterSpecificProvider] } + // Only DeepSeek R1 models use special temperature and topP settings + // DeepSeek V3 models (v3, 3.2, 3.2-exp) use standard settings const isDeepSeekR1 = id.startsWith("deepseek/deepseek-r1") || id === "perplexity/sonar-reasoning" + const isDeepSeekV3 = + id.startsWith("deepseek/deepseek-v3") || + id.startsWith("deepseek/deepseek-3") || + id === "deepseek/deepseek-chat" const params = getModelParams({ format: "openrouter",