fix: add proper support for DeepSeek V3 models in OpenRouter

- DeepSeek V3 models (v3, 3.2, 3.2-exp) now correctly use standard message format instead of R1 format
- Added explicit model detection to differentiate between DeepSeek R1 and V3 models
- Ensured V3 models have proper tool calling support and reasonable token limits
- Added comprehensive tests to verify correct behavior for both V3 and R1 models

Fixes #9779
This commit is contained in:
Roo Code 2025-12-03 16:22:16 +00:00
parent 822343ccdf
commit d48fb302aa
3 changed files with 151 additions and 1 deletions

View file

@ -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,
)
})
})
})

View file

@ -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
}

View file

@ -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",