diff --git a/changelog.d/features/9485-deepseek-thinking-efforts.md b/changelog.d/features/9485-deepseek-thinking-efforts.md new file mode 100644 index 0000000000..a2b2bbcf95 --- /dev/null +++ b/changelog.d/features/9485-deepseek-thinking-efforts.md @@ -0,0 +1 @@ +- **feat(providers):** add native DeepSeek V4 Flash and Pro thinking-effort aliases for their documented per-model tiers, including Combo Builder exposure ([#9485](https://github.com/diegosouzapw/OmniRoute/pull/9485)). 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 005afd3375..55b328965e 100644 --- a/open-sse/executors/base/reasoningEffort.ts +++ b/open-sse/executors/base/reasoningEffort.ts @@ -294,17 +294,25 @@ export function sanitizeReasoningEffortForProvider( return writeEffortValue(b, "max", 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") { + // Match the Flash family even when the sanitizer sees a suffixed or prefixed + // id — exact-match would silently clamp Flash `low → high` if a future route + // forwards the raw catalog id (`deepseek-v4-flash-low`) before resolution + // (#9485 review). + const isFlash = modelStr.toLowerCase().startsWith("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 1feee24501..b2c3f5369c 100644 --- a/src/app/api/v1/models/catalog.ts +++ b/src/app/api/v1/models/catalog.ts @@ -763,9 +763,13 @@ async function buildUnifiedModelsResponseCore( staticModelId: model.id, syncedModelIds: syncedForProvider ? [...syncedForProvider] : [], }); + const hasDeclaredEffortTiers = + Array.isArray(model.supportedThinkingEfforts) && + model.supportedThinkingEfforts.length > 0; if ( coveredBySynced && - (exclusiveListing || !isRegisteredEffortVariant(providerModels, model.id)) + (exclusiveListing || + (!isRegisteredEffortVariant(providerModels, model.id) && !hasDeclaredEffortTiers)) ) continue; if (!providerSupportsModel(canonicalProviderId, model.id)) continue; @@ -776,6 +780,18 @@ async function buildUnifiedModelsResponseCore( const visionFields = getVisionCapabilityFields(aliasId) || getVisionCapabilityFields(model.id); + const thinkingFields = getThinkingCapabilityFields( + canonicalProviderId, + model.id, + model.supportsReasoning, + model.supportedThinkingEfforts, + // Skip the canonical fallback for static models without declared tiers — + // otherwise the catalog synthesizes unresolvable `/-{tier}` + // ids for every static reasoning model across all providers (#9485 review). + !hasDeclaredEffortTiers + ); + const thinkingCapabilities = + Object.keys(thinkingFields).length > 0 ? { capabilities: thinkingFields } : {}; if (includeAlias) { models.push({ id: aliasId, @@ -786,6 +802,8 @@ async function buildUnifiedModelsResponseCore( root: model.id, parent: null, ...(visionFields || {}), + ...thinkingFields, + ...thinkingCapabilities, }); } if ( @@ -806,6 +824,8 @@ async function buildUnifiedModelsResponseCore( root: model.id, parent: includeAlias ? aliasId : null, ...(providerVisionFields || {}), + ...thinkingFields, + ...thinkingCapabilities, }); } } diff --git a/src/app/api/v1/models/catalogHelpers.ts b/src/app/api/v1/models/catalogHelpers.ts index 20ca9ffe55..151d5ce733 100644 --- a/src/app/api/v1/models/catalogHelpers.ts +++ b/src/app/api/v1/models/catalogHelpers.ts @@ -85,19 +85,24 @@ export function getThinkingCapabilityFields( providerId: string, modelId: string, resolvedThinking?: boolean | null, - supportedThinkingEfforts?: readonly string[] + supportedThinkingEfforts?: readonly string[], + /** When true, skip the canonical effort-tier fallback — used for static registry + * models that declare `supportsReasoning` but no explicit tier list, so the + * catalog does not synthesize unresolvable `/-{tier}` ids. */ + skipCanonicalEffortFallback = false ): Record { const supportsThinking = resolvedThinking; if (typeof supportsThinking !== "boolean") return {}; + const hasDeclaredTiers = + supportedThinkingEfforts && supportedThinkingEfforts.length > 0; return { thinking: supportsThinking, supportsThinking, - ...(supportsThinking + ...(supportsThinking && (hasDeclaredTiers || !skipCanonicalEffortFallback) ? { - effort_tiers: - supportedThinkingEfforts && supportedThinkingEfforts.length > 0 - ? [...supportedThinkingEfforts] - : extendCodexGpt56EffortValues(providerId, modelId, CANONICAL_EFFORT_VALUES), + effort_tiers: hasDeclaredTiers + ? [...supportedThinkingEfforts!] + : extendCodexGpt56EffortValues(providerId, modelId, CANONICAL_EFFORT_VALUES), } : {}), }; diff --git a/src/lib/combos/builderOptions.ts b/src/lib/combos/builderOptions.ts index 8d73047e99..7761ca106e 100644 --- a/src/lib/combos/builderOptions.ts +++ b/src/lib/combos/builderOptions.ts @@ -457,6 +457,55 @@ function buildModelOptions( }); } + // #9485: static registry models can declare provider-specific effort tiers even + // when a connection's synced row does not include supportedThinkingEfforts. + // Feed those declarations through the same catalog variant utility, while + // copying the merged base option so aliases retain its metadata and source. + const staticCatalogShaped = builtInModels + .filter( + (m): m is RegistryModel & { supportedThinkingEfforts: readonly string[] } => + typeof m.id === "string" && + Array.isArray(m.supportedThinkingEfforts) && + m.supportedThinkingEfforts.length > 0 + ) + .map((m) => ({ + id: `${providerId}/${m.id}`, + owned_by: providerId, + root: m.id, + name: m.name, + capabilities: { effort_tiers: m.supportedThinkingEfforts }, + })); + if (staticCatalogShaped.length > 0) { + const baseRawIdByVariantId = new Map(); + for (const shaped of staticCatalogShaped) { + for (const tier of shaped.capabilities.effort_tiers) { + if (typeof tier === "string" && tier.length > 0) { + baseRawIdByVariantId.set(`${shaped.id}-${tier}`, shaped.root); + } + } + } + + const withVariants = appendSyncedEffortVariants(staticCatalogShaped); + for (const variant of withVariants) { + if (typeof variant.id !== "string") continue; + const rawId = variant.id.startsWith(`${providerId}/`) + ? variant.id.slice(providerId.length + 1) + : variant.id; + if (modelMap.has(rawId)) continue; + const baseId = baseRawIdByVariantId.get(variant.id) ?? rawId; + const base = modelMap.get(baseId); + addModelOption(modelMap, providerId, { + id: rawId, + name: base ? `${base.name} (${rawId.slice(baseId.length + 1)})` : rawId, + source: base?.source ?? "system", + supportedEndpoints: base?.supportedEndpoints, + contextLength: base?.contextLength ?? null, + outputTokenLimit: base?.outputTokenLimit ?? null, + supportsThinking: base?.supportsThinking, + }); + } + } + for (const model of customModels) { if (model.isHidden === true) continue; const source = ["api-sync", "auto-sync", "imported"].includes( diff --git a/src/sse/services/model.ts b/src/sse/services/model.ts index 8fa7a57b3d..53e065d8ef 100644 --- a/src/sse/services/model.ts +++ b/src/sse/services/model.ts @@ -123,6 +123,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 @@ -194,7 +225,11 @@ function copySyncedThinkingMetadata(metadata: RuntimeModelMeta, syncedMatch: any metadata.supportsThinking = syncedMatch.supportsThinking; } if (syncedMatch?.alwaysThinking === true) metadata.alwaysThinking = true; - if (Array.isArray(syncedMatch?.supportedThinkingEfforts)) { + // Only let a non-empty synced effort list override the static registry fallback; + // an empty array from an incomplete synced discovery must not erase registry-declared + // tiers (#9485 review). + if (Array.isArray(syncedMatch?.supportedThinkingEfforts) && + syncedMatch.supportedThinkingEfforts.length > 0) { metadata.supportedThinkingEfforts = syncedMatch.supportedThinkingEfforts; } if (typeof syncedMatch?.defaultThinkingEffort === "string") { @@ -202,8 +237,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; } @@ -226,16 +275,33 @@ 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( + // #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. + let { modelId: resolvedModelId, effort } = resolveSyncedModelIdAndEffort( providerId, modelId, syncedModels ); - + // Short-circuit registry suffix resolution when the raw id is already a direct + // custom or synced model — otherwise a model literally named + // `deepseek-v4-flash-low` gets rewritten to `deepseek-v4-flash` + effort `low` + // and its custom/synced metadata (apiFormat/targetFormat) is dropped (#9485 review). + if ( + !effort && + resolvedModelId === modelId && + !findCustomModelMeta(customModels, modelId) && + !findSyncedModelMeta(syncedModels, modelId) + ) { + const registryResolution = resolveRegistryModelIdAndEffort(providerId, modelId); + resolvedModelId = registryResolution.modelId; + effort = registryResolution.effort; + } // Custom models remain explicit operator overrides even when live discovery // is authoritative for the provider. const customMatch = findCustomModelMeta(customModels, resolvedModelId); const syncedMatch = findSyncedModelMeta(syncedModels, resolvedModelId); + const registryMatch = findRegistryModel(providerId, resolvedModelId); const effortBaseModelId = getRegisteredProviderEffortBaseModelId(providerId, modelId); const liveBackedEffortVariant = @@ -244,7 +310,7 @@ async function lookupModelMeta( const available = !liveCatalog.authoritative || Boolean(customMatch || syncedMatch || liveBackedEffortVariant); - const metadata = buildRuntimeModelMeta(customMatch, syncedMatch); + const metadata = buildRuntimeModelMeta(customMatch, syncedMatch, registryMatch); if (effort) metadata.resolvedThinkingEffort = effort; return { modelId: resolvedModelId, metadata, available }; diff --git a/tests/unit/combo-builder-effort-variants-8072.test.ts b/tests/unit/combo-builder-effort-variants-8072.test.ts index 166a00a176..05ee3204bd 100644 --- a/tests/unit/combo-builder-effort-variants-8072.test.ts +++ b/tests/unit/combo-builder-effort-variants-8072.test.ts @@ -97,3 +97,76 @@ test("#8072 buildModelOptions: synced - effort variants appear in t ); } }); + +test("#9485 static DeepSeek effort aliases appear when synced rows omit supportedThinkingEfforts", async () => { + const connection = await providersDb.createProviderConnection({ + provider: "deepseek", + authType: "apikey", + name: "deepseek-9485-effort", + apiKey: "deepseek-key-9485", + isActive: true, + testStatus: "active", + }); + + const flashId = "deepseek-v4-flash"; + const proId = "deepseek-v4-pro"; + const syncedMetadata = { + supportedEndpoints: ["chat"], + inputTokenLimit: 65536, + outputTokenLimit: 16384, + supportsThinking: true, + }; + + await modelsDb.replaceSyncedAvailableModelsForConnection("deepseek", connection.id, [ + { id: flashId, name: "Synced DeepSeek V4 Flash", ...syncedMetadata }, + { id: proId, name: "Synced DeepSeek V4 Pro", ...syncedMetadata }, + ]); + + const payload = await getComboBuilderOptions(); + const provider = payload.providers.find((p) => p.providerId === "deepseek"); + assert.ok(provider, "deepseek provider must appear in the combo builder output"); + + const baseModels = new Map( + [flashId, proId].map((id) => { + const base = provider!.models.find((model) => model.id === id); + assert.ok(base, `${id} base model must appear in the provider's models list`); + return [id, base!]; + }) + ); + + const expectedAliases = new Set([ + `${flashId}-none`, + `${flashId}-low`, + `${flashId}-high`, + `${flashId}-max`, + `${proId}-none`, + `${proId}-high`, + `${proId}-max`, + ]); + const deepSeekAliases = new Set( + provider!.models + .map((model) => model.id) + .filter((id) => id.startsWith(`${flashId}-`) || id.startsWith(`${proId}-`)) + ); + assert.deepEqual(deepSeekAliases, expectedAliases); + assert.equal( + provider!.models.some((model) => model.id === `${proId}-low`), + false + ); + assert.equal( + provider!.models.some((model) => model.id === `${proId}-medium`), + false + ); + + for (const aliasId of expectedAliases) { + const baseId = aliasId.startsWith(`${flashId}-`) ? flashId : proId; + const base = baseModels.get(baseId)!; + const alias = provider!.models.find((model) => model.id === aliasId); + assert.ok(alias, `${aliasId} effort alias must appear in the model picker`); + assert.equal(alias!.source, base.source, `${aliasId} must preserve the base source`); + assert.equal(alias!.contextLength, base.contextLength); + assert.equal(alias!.outputTokenLimit, base.outputTokenLimit); + assert.deepEqual(alias!.supportedEndpoints, base.supportedEndpoints); + assert.equal(alias!.supportsThinking, base.supportsThinking); + } +}); diff --git a/tests/unit/deepseek-thinking-efforts.test.ts b/tests/unit/deepseek-thinking-efforts.test.ts new file mode 100644 index 0000000000..832bc9e10e --- /dev/null +++ b/tests/unit/deepseek-thinking-efforts.test.ts @@ -0,0 +1,193 @@ +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 modelsDb = await import("../../src/lib/db/models.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"); +}); + + +test("non-DeepSeek static reasoning models do not advertise unresolvable effort aliases", async () => { + // cheaperinference declares deepseek-v4-flash/pro with supportsReasoning: true + // but no supportedThinkingEfforts — the catalog must NOT synthesize + // cheaperinference/deepseek-v4-flash-{low,high,...} ids for them (#9485 review #1). + await providersDb.createProviderConnection({ + provider: "cheaperinference", + authType: "apikey", + name: "cheaperinference-blast-radius", + apiKey: "cheaperinference-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 = body.data.map((model) => model.id); + + // Static base models for cheaperinference should still be present + assert.ok( + ids.some((id) => id.endsWith("cheaperinference/deepseek-v4-flash")), + "cheaperinference/deepseek-v4-flash base entry should still be present" + ); + // But NO effort-suffixed aliases should be synthesized + assert.equal( + ids.some((id) => /cheaperinference\/deepseek-v4-flash-(none|low|medium|high|max|xhigh)$/.test(id)), + false, + "cheaperinference static reasoning models must not advertise unresolvable effort aliases" + ); + assert.equal( + ids.some((id) => /cheaperinference\/deepseek-v4-pro-(none|low|medium|high|max|xhigh)$/.test(id)), + false, + "cheaperinference static reasoning models must not advertise unresolvable effort aliases" + ); +}); + +test("custom model named deepseek-v4-flash-low is not rewritten by registry suffix resolution", async () => { + // A custom (DB) model literally named deepseek-v4-flash-low on the deepseek + // provider must not be silently rewritten to deepseek-v4-flash + effort low, + // which would drop its custom apiFormat/targetFormat metadata (#9485 review #3). + await modelsDb.addCustomModel( + "deepseek", + "deepseek-v4-flash-low", + "deepseek-v4-flash-low", + "manual", + "responses", + ["chat"], + "responses" + ); + + const info = await getModelInfo("ds/deepseek-v4-flash-low"); + // The model id should be preserved as the literal custom id, not rewritten + assert.equal(info.model, "deepseek-v4-flash-low"); + // The custom apiFormat must survive (not dropped by registry rewriting) + assert.equal(info.apiFormat, "responses"); + // No resolved effort should be injected — this is a distinct custom model + assert.equal(info.resolvedThinkingEffort, undefined); +}); + +test("none effort resolves and passes through the native DeepSeek sanitizer unchanged", async () => { + // The -none suffix resolves to base + effort "none", which reaches the native + // DeepSeek endpoint as reasoning_effort: "none" unchanged (#9485 review #8). + const flashNone = await getModelInfo("ds/deepseek-v4-flash-none"); + assert.equal(flashNone.model, "deepseek-v4-flash"); + assert.equal(flashNone.resolvedThinkingEffort, "none"); + + const sanitized = sanitizeReasoningEffortForProvider( + { model: "deepseek-v4-flash", reasoning_effort: "none" }, + "deepseek", + "deepseek-v4-flash" + ) as Record; + assert.equal(sanitized.reasoning_effort, "none"); +}); + +test("isFlash check is robust to suffixed model ids", () => { + // A suffixed id like deepseek-v4-flash-low must still be recognized as Flash + // so its low effort is preserved, not clamped to high (#9485 review #5). + const sanitizedSuffixed = sanitizeReasoningEffortForProvider( + { model: "deepseek-v4-flash-low", reasoning_effort: "low" }, + "deepseek", + "deepseek-v4-flash-low" + ) as Record; + assert.equal(sanitizedSuffixed.reasoning_effort, "low"); +});