From e9c0d561da192946dcf1f8dee8a9d5fef67b3133 Mon Sep 17 00:00:00 2001 From: excessivechaos Date: Tue, 4 Aug 2026 20:43:54 -0700 Subject: [PATCH] feat(providers): add DeepSeek V4 thinking effort aliases --- .../providers/registry/deepseek/index.ts | 14 ++- open-sse/executors/base/reasoningEffort.ts | 22 ++-- src/app/api/v1/models/catalog.ts | 12 ++ src/sse/services/model.ts | 57 +++++++++- tests/unit/deepseek-thinking-efforts.test.ts | 105 ++++++++++++++++++ 5 files changed, 196 insertions(+), 14 deletions(-) create mode 100644 tests/unit/deepseek-thinking-efforts.test.ts diff --git a/open-sse/config/providers/registry/deepseek/index.ts b/open-sse/config/providers/registry/deepseek/index.ts index 348a0bf81a..6825b23078 100644 --- a/open-sse/config/providers/registry/deepseek/index.ts +++ b/open-sse/config/providers/registry/deepseek/index.ts @@ -9,7 +9,17 @@ export const deepseekProvider: RegistryEntry = { authType: "apikey", authHeader: "bearer", models: [ - { id: "deepseek-v4-pro", name: "DeepSeek V4 Pro", supportsReasoning: true }, - { id: "deepseek-v4-flash", name: "DeepSeek V4 Flash", supportsReasoning: true }, + { + id: "deepseek-v4-pro", + name: "DeepSeek V4 Pro", + supportsReasoning: true, + supportedThinkingEfforts: ["none", "high", "max"], + }, + { + id: "deepseek-v4-flash", + name: "DeepSeek V4 Flash", + supportsReasoning: true, + supportedThinkingEfforts: ["none", "low", "high", "max"], + }, ], }; diff --git a/open-sse/executors/base/reasoningEffort.ts b/open-sse/executors/base/reasoningEffort.ts index 6c87a25ce7..1b341e95e7 100644 --- a/open-sse/executors/base/reasoningEffort.ts +++ b/open-sse/executors/base/reasoningEffort.ts @@ -265,17 +265,21 @@ export function sanitizeReasoningEffortForProvider( return stripEffortValue(b, c); } - // Native DeepSeek (api.deepseek.com) — V4 thinking mode accepts reasoning_effort - // ONLY as {high, max} (its own top tier is literally "max"). OmniRoute's internal - // scale is low|medium|high|xhigh where xhigh is the top, so map onto DeepSeek's - // vocabulary: xhigh → max (top→top), low|medium → high (below the enum floor). - // high/max pass through unchanged. Without this, the claude→openai translator's - // xhigh (and max-normalized-to-xhigh below) reaches DeepSeek as an unknown value, - // silently dropping the client's requested effort. This is the INVERSE of the - // OpenRouter-DeepSeek path, whose normalized API expects xhigh, not max (pi#4055). + // Native DeepSeek (api.deepseek.com) — V4 thinking mode uses the native + // {low, high, max} vocabulary on Flash and {high, max} on Pro. OmniRoute's + // internal top tier xhigh maps to DeepSeek's literal max. Pro's unsupported + // low/medium values still clamp to high; Flash's documented low tier passes + // through. This is the INVERSE of the OpenRouter-DeepSeek path, whose + // normalized API expects xhigh, not max (pi#4055). `none` is already the + // OpenAI no-thinking carrier and passes through unchanged. if (provider === "deepseek") { + const isFlash = modelStr.toLowerCase() === "deepseek-v4-flash"; const mapped = - effortStr === "xhigh" ? "max" : effortStr === "low" || effortStr === "medium" ? "high" : null; + effortStr === "xhigh" + ? "max" + : effortStr === "medium" || (effortStr === "low" && !isFlash) + ? "high" + : null; if (mapped && mapped !== effortStr) { log?.info?.( "REASONING_SANITIZE", diff --git a/src/app/api/v1/models/catalog.ts b/src/app/api/v1/models/catalog.ts index d4229cf64d..ef67681578 100644 --- a/src/app/api/v1/models/catalog.ts +++ b/src/app/api/v1/models/catalog.ts @@ -737,6 +737,14 @@ async function buildUnifiedModelsResponseCore( const visionFields = getVisionCapabilityFields(aliasId) || getVisionCapabilityFields(model.id); + const thinkingFields = getThinkingCapabilityFields( + canonicalProviderId, + model.id, + model.supportsReasoning, + model.supportedThinkingEfforts + ); + const thinkingCapabilities = + Object.keys(thinkingFields).length > 0 ? { capabilities: thinkingFields } : {}; if (includeAlias) { models.push({ id: aliasId, @@ -747,6 +755,8 @@ async function buildUnifiedModelsResponseCore( root: model.id, parent: null, ...(visionFields || {}), + ...thinkingFields, + ...thinkingCapabilities, }); } if ( @@ -767,6 +777,8 @@ async function buildUnifiedModelsResponseCore( root: model.id, parent: includeAlias ? aliasId : null, ...(providerVisionFields || {}), + ...thinkingFields, + ...thinkingCapabilities, }); } } diff --git a/src/sse/services/model.ts b/src/sse/services/model.ts index 590d0e9257..959a9d18fe 100644 --- a/src/sse/services/model.ts +++ b/src/sse/services/model.ts @@ -121,6 +121,37 @@ function isSyncedEffortSkippedProvider(providerId: string): boolean { return SYNCED_EFFORT_SKIP_PROVIDER_PREFIXES.some((prefix) => providerId.startsWith(prefix)); } +/** Resolve a suffix against an explicitly tiered static registry model. */ +function resolveRegistryModelIdAndEffort( + providerId: string, + modelId: string +): { modelId: string; effort: string | null } { + if (isSyncedEffortSkippedProvider(providerId)) return { modelId, effort: null }; + + const registryModels = REGISTRY[providerId]?.models; + if (!Array.isArray(registryModels)) return { modelId, effort: null }; + if (registryModels.some((candidate) => candidate?.id === modelId)) { + return { modelId, effort: null }; + } + + for (const candidate of registryModels) { + if (!Array.isArray(candidate?.supportedThinkingEfforts)) continue; + const attempt = splitSyncedEffortSuffix(modelId, candidate.supportedThinkingEfforts); + if (attempt.effort && attempt.baseModel === candidate.id) { + return { modelId: attempt.baseModel, effort: attempt.effort }; + } + } + + return { modelId, effort: null }; +} + +function findRegistryModel(providerId: string, modelId: string): any { + const registryModels = REGISTRY[providerId]?.models; + return Array.isArray(registryModels) + ? registryModels.find((candidate) => candidate?.id === modelId) + : undefined; +} + /** * #7694: when `modelId` has no direct synced-model match, try stripping a trailing * `-{effort}` token by testing it against each candidate synced model's OWN declared @@ -197,8 +228,22 @@ function copySyncedThinkingMetadata(metadata: RuntimeModelMeta, syncedMatch: any } } -function buildRuntimeModelMeta(customMatch: any, syncedMatch: any): RuntimeModelMeta { +function copyRegistryThinkingMetadata(metadata: RuntimeModelMeta, registryMatch: any): void { + if (typeof registryMatch?.supportsReasoning === "boolean") { + metadata.supportsThinking = registryMatch.supportsReasoning; + } + if (Array.isArray(registryMatch?.supportedThinkingEfforts)) { + metadata.supportedThinkingEfforts = [...registryMatch.supportedThinkingEfforts]; + } +} + +function buildRuntimeModelMeta( + customMatch: any, + syncedMatch: any, + registryMatch: any +): RuntimeModelMeta { const metadata = resolveRuntimeFormats(customMatch, syncedMatch); + copyRegistryThinkingMetadata(metadata, registryMatch); copySyncedThinkingMetadata(metadata, syncedMatch); return metadata; } @@ -215,16 +260,22 @@ async function lookupModelMeta( // #7694: no direct match on the raw modelId? try a synced-declared `-{effort}` // suffix before falling back to the literal id, so `/-` // resolves to the real base model + a resolved effort. - const { modelId: resolvedModelId, effort } = resolveSyncedModelIdAndEffort( + let { modelId: resolvedModelId, effort } = resolveSyncedModelIdAndEffort( providerId, modelId, syncedModels ); + if (!effort && resolvedModelId === modelId) { + const registryResolution = resolveRegistryModelIdAndEffort(providerId, modelId); + resolvedModelId = registryResolution.modelId; + effort = registryResolution.effort; + } // #7364: exact match first; retain the case-insensitive custom-model fallback // while also consulting the API-synced catalog for Kimi runtime metadata. const customMatch = findCustomModelMeta(customModels, resolvedModelId); const syncedMatch = findSyncedModelMeta(syncedModels, resolvedModelId); - const metadata = buildRuntimeModelMeta(customMatch, syncedMatch); + const registryMatch = findRegistryModel(providerId, resolvedModelId); + const metadata = buildRuntimeModelMeta(customMatch, syncedMatch, registryMatch); if (effort) metadata.resolvedThinkingEffort = effort; return { modelId: resolvedModelId, metadata }; } catch { diff --git a/tests/unit/deepseek-thinking-efforts.test.ts b/tests/unit/deepseek-thinking-efforts.test.ts new file mode 100644 index 0000000000..dc904d19b0 --- /dev/null +++ b/tests/unit/deepseek-thinking-efforts.test.ts @@ -0,0 +1,105 @@ +import test from "node:test"; +import assert from "node:assert/strict"; +import fs from "node:fs"; +import os from "node:os"; +import path from "node:path"; + +const TEST_DATA_DIR = fs.mkdtempSync(path.join(os.tmpdir(), "omniroute-deepseek-efforts-")); +process.env.DATA_DIR = TEST_DATA_DIR; +process.env.API_KEY_SECRET = process.env.API_KEY_SECRET || "deepseek-efforts-test-secret"; + +const core = await import("../../src/lib/db/core.ts"); +const providersDb = await import("../../src/lib/db/providers.ts"); +const { getModelInfo } = await import("../../src/sse/services/model.ts"); +const v1ModelsCatalog = await import("../../src/app/api/v1/models/catalog.ts"); +const { REGISTRY } = await import("../../open-sse/config/providerRegistry.ts"); +const { sanitizeReasoningEffortForProvider } = await import("../../open-sse/executors/base.ts"); + +test.beforeEach(() => { + core.resetDbInstance(); + fs.rmSync(TEST_DATA_DIR, { recursive: true, force: true }); + fs.mkdirSync(TEST_DATA_DIR, { recursive: true }); + v1ModelsCatalog.__resetCatalogBuilderRunsForTest(); +}); + +test.after(() => { + core.resetDbInstance(); + fs.rmSync(TEST_DATA_DIR, { recursive: true, force: true }); +}); + +test("DeepSeek registry declares the documented per-model thinking efforts", () => { + const models = new Map((REGISTRY.deepseek?.models || []).map((model) => [model.id, model])); + + assert.deepEqual(models.get("deepseek-v4-flash")?.supportedThinkingEfforts, [ + "none", + "low", + "high", + "max", + ]); + assert.deepEqual(models.get("deepseek-v4-pro")?.supportedThinkingEfforts, [ + "none", + "high", + "max", + ]); +}); + +test("DeepSeek catalog exposes only the declared effort aliases", async () => { + await providersDb.createProviderConnection({ + provider: "deepseek", + authType: "apikey", + name: "deepseek-efforts", + apiKey: "deepseek-test-key", + isActive: true, + testStatus: "active", + }); + + const response = await v1ModelsCatalog.getUnifiedModelsResponse( + new Request("http://localhost/api/v1/models") + ); + const body = (await response.json()) as { data: Array<{ id: string }> }; + const ids = new Set(body.data.map((model) => model.id)); + + assert.ok([...ids].some((id) => id.endsWith("deepseek-v4-flash-none"))); + assert.ok([...ids].some((id) => id.endsWith("deepseek-v4-flash-low"))); + assert.ok([...ids].some((id) => id.endsWith("deepseek-v4-flash-high"))); + assert.ok([...ids].some((id) => id.endsWith("deepseek-v4-flash-max"))); + assert.ok([...ids].some((id) => id.endsWith("deepseek-v4-pro-none"))); + assert.ok([...ids].some((id) => id.endsWith("deepseek-v4-pro-high"))); + assert.ok([...ids].some((id) => id.endsWith("deepseek-v4-pro-max"))); + assert.equal( + [...ids].some((id) => id.endsWith("deepseek-v4-pro-low")), + false, + "Pro does not advertise low" + ); +}); + +test("hardcoded DeepSeek effort suffixes resolve through the static registry", async () => { + const flashLow = await getModelInfo("ds/deepseek-v4-flash-low"); + assert.equal(flashLow.provider, "deepseek"); + assert.equal(flashLow.model, "deepseek-v4-flash"); + assert.equal(flashLow.resolvedThinkingEffort, "low"); + + const flashNone = await getModelInfo("deepseek/deepseek-v4-flash-none"); + assert.equal(flashNone.model, "deepseek-v4-flash"); + assert.equal(flashNone.resolvedThinkingEffort, "none"); + + const unsupportedProLow = await getModelInfo("ds/deepseek-v4-pro-low"); + assert.equal(unsupportedProLow.model, "deepseek-v4-pro-low"); + assert.equal(unsupportedProLow.resolvedThinkingEffort, undefined); +}); + +test("native DeepSeek preserves Flash low while clamping unsupported Pro low", () => { + const flash = sanitizeReasoningEffortForProvider( + { model: "deepseek-v4-flash", reasoning_effort: "low" }, + "deepseek", + "deepseek-v4-flash" + ) as Record; + assert.equal(flash.reasoning_effort, "low"); + + const pro = sanitizeReasoningEffortForProvider( + { model: "deepseek-v4-pro", reasoning_effort: "low" }, + "deepseek", + "deepseek-v4-pro" + ) as Record; + assert.equal(pro.reasoning_effort, "high"); +});