From c84700876a9acabc3f69844e9ab09df2f6089f7f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Krzysztof=20Dre=C5=BCewski?= Date: Sun, 10 May 2026 00:09:54 +0200 Subject: [PATCH] feat(deepseek): wire DeepSeek into dynamic router-model cache MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds DeepSeek to the dynamicProviders tuple and registers a new fetcher that hits https://api.deepseek.com/models, merging the live list with the static deepSeekModels specs. Known model IDs keep their pricing and context-window data; unknown models receive sensible 128K/8K-output defaults so they become selectable in the generic model picker. The webview aggregator pushes a DeepSeek candidate when an API key is present (mirroring the Poe pattern), and useSelectedModel falls back to the static list when the live fetch fails — so existing users keep working on deepseek-chat / deepseek-reasoner without regressions. --- packages/types/src/provider-settings.ts | 1 + src/api/providers/fetchers/deepseek.ts | 91 +++++++++++++++++++ src/api/providers/fetchers/modelCache.ts | 4 + .../webview/__tests__/ClineProvider.spec.ts | 3 + .../__tests__/webviewMessageHandler.spec.ts | 3 + src/core/webview/webviewMessageHandler.ts | 16 ++++ src/shared/api.ts | 1 + .../components/ui/hooks/useSelectedModel.ts | 10 +- .../src/utils/__tests__/validate.spec.ts | 1 + 9 files changed, 127 insertions(+), 3 deletions(-) create mode 100644 src/api/providers/fetchers/deepseek.ts diff --git a/packages/types/src/provider-settings.ts b/packages/types/src/provider-settings.ts index 4df9f1726c..7d97d2600b 100644 --- a/packages/types/src/provider-settings.ts +++ b/packages/types/src/provider-settings.ts @@ -42,6 +42,7 @@ export const dynamicProviders = [ "requesty", "roo", "unbound", + "deepseek", ] as const export type DynamicProvider = (typeof dynamicProviders)[number] diff --git a/src/api/providers/fetchers/deepseek.ts b/src/api/providers/fetchers/deepseek.ts new file mode 100644 index 0000000000..9bcbf877f0 --- /dev/null +++ b/src/api/providers/fetchers/deepseek.ts @@ -0,0 +1,91 @@ +import type { ModelRecord } from "@roo-code/types" +import { deepSeekModels, DEEP_SEEK_DEFAULT_TEMPERATURE } from "@roo-code/types" + +import { DEFAULT_HEADERS } from "../constants" + +/** + * Fetches available models from the DeepSeek API and merges them with known specs. + * + * The DeepSeek /models endpoint only returns basic model IDs without pricing + * or context window info, so we merge the API response with the static + * `deepSeekModels` map for known models. Unknown models get sensible defaults. + */ +export async function getDeepSeekModels(baseUrl?: string, apiKey?: string): Promise { + const normalizedBase = (baseUrl || "https://api.deepseek.com").replace(/\/?v1\/?$/, "") + const url = `${normalizedBase}/models` + + const headers: Record = { + "Content-Type": "application/json", + ...DEFAULT_HEADERS, + } + + if (apiKey) { + headers["Authorization"] = `Bearer ${apiKey}` + } + + const controller = new AbortController() + const timeoutId = setTimeout(() => controller.abort(), 10000) + + try { + const response = await fetch(url, { + headers, + signal: controller.signal, + }) + + if (!response.ok) { + let errorBody = "" + try { + errorBody = await response.text() + } catch { + errorBody = "(unable to read response body)" + } + + console.error(`[getDeepSeekModels] HTTP error:`, { + status: response.status, + statusText: response.statusText, + url, + body: errorBody, + }) + + throw new Error(`HTTP ${response.status}: ${response.statusText}`) + } + + const data = await response.json() + + if (!data?.data || !Array.isArray(data.data)) { + console.error("[getDeepSeekModels] Unexpected response format:", data) + throw new Error("Failed to fetch DeepSeek models: Unexpected response format.") + } + + // Use null-prototype object to prevent prototype pollution + const models: ModelRecord = Object.create(null) + + for (const model of data.data) { + const modelId = typeof model.id === "string" && model.id ? model.id : null + if (!modelId) continue + + const knownSpecs = deepSeekModels[modelId as keyof typeof deepSeekModels] + + if (knownSpecs) { + models[modelId] = { ...knownSpecs } + } else { + models[modelId] = { + maxTokens: 8192, + contextWindow: 128_000, + supportsImages: false, + supportsPromptCache: true, + inputPrice: 0.28, + outputPrice: 0.42, + cacheWritesPrice: 0.28, + cacheReadsPrice: 0.028, + defaultTemperature: DEEP_SEEK_DEFAULT_TEMPERATURE, + description: `DeepSeek model: ${modelId}`, + } + } + } + + return models + } finally { + clearTimeout(timeoutId) + } +} diff --git a/src/api/providers/fetchers/modelCache.ts b/src/api/providers/fetchers/modelCache.ts index 8146e594d1..3d9864042f 100644 --- a/src/api/providers/fetchers/modelCache.ts +++ b/src/api/providers/fetchers/modelCache.ts @@ -26,6 +26,7 @@ import { GetModelsOptions } from "../../../shared/api" import { getOllamaModels } from "./ollama" import { getLMStudioModels } from "./lmstudio" import { getRooModels } from "./roo" +import { getDeepSeekModels } from "./deepseek" const memoryCache = new NodeCache({ stdTTL: 5 * 60, checkperiod: 5 * 60 }) @@ -89,6 +90,9 @@ async function fetchModelsFromProvider(options: GetModelsOptions): Promise { ollama: {}, lmstudio: {}, "vercel-ai-gateway": mockModels, + deepseek: {}, }, values: undefined, }) @@ -2542,6 +2543,7 @@ describe("ClineProvider - Router Models", () => { litellm: {}, poe: {}, "vercel-ai-gateway": mockModels, + deepseek: {}, }, values: undefined, }) @@ -2637,6 +2639,7 @@ describe("ClineProvider - Router Models", () => { ollama: {}, lmstudio: {}, "vercel-ai-gateway": mockModels, + deepseek: {}, }, values: undefined, }) diff --git a/src/core/webview/__tests__/webviewMessageHandler.spec.ts b/src/core/webview/__tests__/webviewMessageHandler.spec.ts index 4fb2e6d4b7..3a3b022c27 100644 --- a/src/core/webview/__tests__/webviewMessageHandler.spec.ts +++ b/src/core/webview/__tests__/webviewMessageHandler.spec.ts @@ -347,6 +347,7 @@ describe("webviewMessageHandler - requestRouterModels", () => { ollama: {}, lmstudio: {}, "vercel-ai-gateway": mockModels, + deepseek: {}, }, values: undefined, }) @@ -433,6 +434,7 @@ describe("webviewMessageHandler - requestRouterModels", () => { ollama: {}, lmstudio: {}, "vercel-ai-gateway": mockModels, + deepseek: {}, }, values: undefined, }) @@ -489,6 +491,7 @@ describe("webviewMessageHandler - requestRouterModels", () => { ollama: {}, lmstudio: {}, "vercel-ai-gateway": mockModels, + deepseek: {}, }, values: undefined, }) diff --git a/src/core/webview/webviewMessageHandler.ts b/src/core/webview/webviewMessageHandler.ts index 4c138b4304..54aa5e7a3f 100644 --- a/src/core/webview/webviewMessageHandler.ts +++ b/src/core/webview/webviewMessageHandler.ts @@ -956,6 +956,7 @@ export const webviewMessageHandler = async ( ollama: {}, lmstudio: {}, roo: {}, + deepseek: {}, } const safeGetModels = async (options: GetModelsOptions): Promise => { @@ -1034,6 +1035,21 @@ export const webviewMessageHandler = async ( }) } + // DeepSeek is conditional on apiKey + const deepSeekApiKey = apiConfiguration.deepSeekApiKey || message?.values?.deepSeekApiKey + const deepSeekBaseUrl = apiConfiguration.deepSeekBaseUrl || message?.values?.deepSeekBaseUrl + + if (deepSeekApiKey) { + if (message?.values?.deepSeekApiKey || message?.values?.deepSeekBaseUrl) { + await flushModels({ provider: "deepseek", apiKey: deepSeekApiKey, baseUrl: deepSeekBaseUrl }, true) + } + + candidates.push({ + key: "deepseek", + options: { provider: "deepseek", apiKey: deepSeekApiKey, baseUrl: deepSeekBaseUrl }, + }) + } + // Apply single provider filter if specified const modelFetchPromises = providerFilter ? candidates.filter(({ key }) => key === providerFilter) diff --git a/src/shared/api.ts b/src/shared/api.ts index 8867c5f4ee..b7e173af73 100644 --- a/src/shared/api.ts +++ b/src/shared/api.ts @@ -178,6 +178,7 @@ const dynamicProviderExtras = { ollama: {} as {}, // eslint-disable-line @typescript-eslint/no-empty-object-type lmstudio: {} as {}, // eslint-disable-line @typescript-eslint/no-empty-object-type roo: {} as { apiKey?: string; baseUrl?: string }, + deepseek: {} as { apiKey?: string; baseUrl?: string }, } as const satisfies Record // Build the dynamic options union from the map, intersected with CommonFetchParams diff --git a/webview-ui/src/components/ui/hooks/useSelectedModel.ts b/webview-ui/src/components/ui/hooks/useSelectedModel.ts index f8b6d33282..2bdc1d1ad6 100644 --- a/webview-ui/src/components/ui/hooks/useSelectedModel.ts +++ b/webview-ui/src/components/ui/hooks/useSelectedModel.ts @@ -237,9 +237,13 @@ function getSelectedModel({ return { id, info } } case "deepseek": { - const id = apiConfiguration.apiModelId ?? defaultModelId - const info = deepSeekModels[id as keyof typeof deepSeekModels] - return { id, info } + const availableModels = routerModels.deepseek + ? { ...deepSeekModels, ...routerModels.deepseek } + : deepSeekModels + const id = getValidatedModelId(apiConfiguration.apiModelId, availableModels, defaultModelId) + const routerInfo = routerModels.deepseek?.[id] + const staticInfo = deepSeekModels[id as keyof typeof deepSeekModels] + return { id, info: routerInfo ?? staticInfo } } case "moonshot": { const id = apiConfiguration.apiModelId ?? defaultModelId diff --git a/webview-ui/src/utils/__tests__/validate.spec.ts b/webview-ui/src/utils/__tests__/validate.spec.ts index 40153a2ad4..de5af86778 100644 --- a/webview-ui/src/utils/__tests__/validate.spec.ts +++ b/webview-ui/src/utils/__tests__/validate.spec.ts @@ -46,6 +46,7 @@ describe("Model Validation Functions", () => { lmstudio: {}, "vercel-ai-gateway": {}, roo: {}, + deepseek: {}, } const allowAllOrganization: OrganizationAllowList = {