mirror of
https://github.com/diegosouzapw/OmniRoute.git
synced 2026-08-09 00:32:13 +03:00
Compare commits
7 Commits
feat/9490-
...
fix/deepse
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
a6e5d47e97 | ||
|
|
d4d2cd28c6 | ||
|
|
52603fcffb | ||
|
|
dd44abf28a | ||
|
|
0334695bff | ||
|
|
4da2dbe019 | ||
|
|
e9c0d561da |
1
changelog.d/features/9485-deepseek-thinking-efforts.md
Normal file
1
changelog.d/features/9485-deepseek-thinking-efforts.md
Normal file
@@ -0,0 +1 @@
|
||||
- **feat(providers):** add native DeepSeek V4 Flash and Pro thinking-effort aliases for their documented per-model tiers ([#9485](https://github.com/diegosouzapw/OmniRoute/pull/9485)).
|
||||
@@ -1,4 +1,5 @@
|
||||
{
|
||||
"_rebaseline_2026_08_05_9485_deepseek_v4_effort_aliases": "PR #9485 (excessivechaos, fix/deepseek-thinking-efforts) own growth: src/app/api/v1/models/catalog.ts 1549->1555 (+6, check-file-size.mjs counts via split(\"\\n\").length) at the existing static-model emission chokepoint in buildUnifiedModelsResponseCore. Adds a `hasDeclaredEffortTiers` gate (skip the synced-coverage suppression when the static model declares its own supportedThinkingEfforts list) and calls the existing getThinkingCapabilityFields(...) helper — now with a new skipCanonicalEffortFallback flag — to spread thinking/effort_tiers fields onto both the alias and provider-prefixed model entries. DeepSeek V4's thinking-effort tiers (minimal/low/medium/high) were being silently suppressed by the synced-model-coverage guard added for #7786, and any effort variant not explicitly declared fell back to synthesizing unresolvable `<prefix>/<model>-{tier}` ids for every static reasoning model. All the actual field-computation logic (hasDeclaredTiers, effort_tiers selection, extendCodexGpt56EffortValues fallback) lives in the non-frozen leaf src/app/api/v1/models/catalogHelpers.ts::getThinkingCapabilityFields; catalog.ts only adds the 5-line gate + destructure + spread at its single static-model push site. Cohesive wiring at the existing chokepoint, mirroring prior thinking/vision-field rebaselines (#4264 supportsVision, #6218-era supportsThinking) at the same site; not extractable further without hiding the per-model field-merge boundary. Covered by tests/unit/deepseek-thinking-efforts.test.ts.",
|
||||
"_rebaseline_2026_07_24_8470_hyperagent_sticky_thread": "PR #8470 (artickc, fix/hyperagent-tool-loop-thread-sticky) own growth: open-sse/executors/hyperagent.ts 936->1025 (wc -l; check-file-size.mjs counts via split(\"\\n\").length so the gate sees 937->1026, +89, crosses the 1000 cap). Fixes a real bug where a reverse-conversion proxy (text-Intent/JSON to Claude Code native tool_calls) rewrites assistant messages between agentic tool-loop turns, breaking HyperAgent's conversation-prefix fingerprint and cold-starting the thread mid tool-loop. Adds Anthropic tool_use/tool_result flattening to extractMessageText() plus a new rootUserFingerprint()/root-key lookup tier in resolveHyperAgentThreadBinding()/storeHyperAgentThreadAfterTurn() so the thread stays sticky across the tool loop. Cohesive additions inside the existing single-file executor; not extractable without splitting the executor mid-request-flow. Covered by tests/unit/executor-hyperagent.test.ts (19/19, +5 new cases for tool_result/tool_use flattening + root-key stickiness). Pre-merge review flagged a cross-conversation root-key collision risk (tracked in the PR's own mandatory pre-merge checklist, not yet addressed) — unrelated to this file-size ratchet, tracked separately by /fix-prs.",
|
||||
"_rebaseline_2026_07_25_8494_capability_filter_fail_closed": "PR #8494 (fix/capability-filters-fail-closed, #8488) own growth: open-sse/services/combo.ts 3640->3693 (+53) adds a fail-closed guard after filterTargetsByRequestCompatibility() — when every eligible target is excluded by request-capability filtering (vision/tools/etc) instead of quota/health, the combo now returns an explicit `capability_mismatch` 400 (describeCapabilityFilterExhaustion, imported from combo/comboStructure.ts) rather than silently falling through to a generic no-targets error, plus a `compatFilterFailOpen` escape hatch (combo config OR settings) mirrored at both the main/auto and round-robin call sites for symmetry. combo/comboStructure.ts (previously under cap, un-frozen) grows 794->918 (+124) — new home for describeCapabilityFilterExhaustion + providerSupportsEmulatedToolCalling (#5240 emulated tool-calling exemption so fail-closed does not regress prompt-emulation-only combos like all-chatgpt-web). Irreducible orchestration wiring at the existing filter chokepoint (same precedent as #7301's universal-cooldown-retry generalization). Companion test tests/unit/combo-routing-engine.test.ts 3409->3449 (+40, fail-closed/fail-open coverage across both call sites) also rebaselined. Covered by tests/unit/8488-capability-filter-fail-closed.test.ts (new) + 95/95 passing across both files. Structural shrink of combo.ts tracked in #3501.",
|
||||
"_rebaseline_2026_07_25_8499_ts7_result_union_predicates": "PR #8499 (backryun, chore/ts7-types-executor-scattered) own growth: muse-spark-web.ts 1396->1405 (+9, irreducible). Under this workspace's `strictNullChecks: false`, the boolean-literal discriminant on `GraphqlResult` (`{ ok: true } | { ok: false; error: string }`) narrows the positive `.ok===true` branch but leaves `!result.ok` at the full union under TS7, making `.error` unreachable to the checker at the two call sites (warmup, mode-switch). Fixed by adding a single `isGraphqlFailure()` type-predicate helper (doc comment + 3-line body) reused at both call sites instead of duplicating the predicate inline — not extractable to a shared module without splitting a single-file executor's local narrowing helper out of its own file. Covered by the existing muse-spark-web executor test suite (no behavior change, pure narrowing fix).",
|
||||
@@ -388,7 +389,7 @@
|
||||
"src/app/(dashboard)/dashboard/usage/components/EvalsTab.tsx": 2148,
|
||||
"src/app/(dashboard)/dashboard/usage/components/ProviderLimits/index.tsx": 1109,
|
||||
"src/app/api/providers/[id]/models/route.ts": 2250,
|
||||
"src/app/api/v1/models/catalog.ts": 1549,
|
||||
"src/app/api/v1/models/catalog.ts": 1555,
|
||||
"src/lib/tokenHealthCheck.ts": 1021,
|
||||
"src/lib/db/apiKeys.ts": 1529,
|
||||
"src/lib/db/core.ts": 1637,
|
||||
|
||||
@@ -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"],
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
@@ -265,17 +265,25 @@ 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") {
|
||||
// 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",
|
||||
|
||||
@@ -728,13 +728,17 @@ async function buildUnifiedModelsResponseCore(
|
||||
// the fix, a provider with any synced model silently dropped ALL its
|
||||
// static models.
|
||||
const syncedForProvider = syncedModelIdsByCanonicalProvider.get(canonicalProviderId);
|
||||
const hasDeclaredEffortTiers =
|
||||
Array.isArray(model.supportedThinkingEfforts) &&
|
||||
model.supportedThinkingEfforts.length > 0;
|
||||
if (
|
||||
shouldSuppressStaticModelBySyncedCoverage({
|
||||
providerHasSynced: syncedForProvider !== undefined && syncedForProvider.size > 0,
|
||||
staticModelId: model.id,
|
||||
syncedModelIds: syncedForProvider ? [...syncedForProvider] : [],
|
||||
}) &&
|
||||
!isRegisteredEffortVariant(providerModels, model.id)
|
||||
!isRegisteredEffortVariant(providerModels, model.id) &&
|
||||
!hasDeclaredEffortTiers
|
||||
)
|
||||
continue;
|
||||
if (!providerSupportsModel(canonicalProviderId, model.id)) continue;
|
||||
@@ -745,6 +749,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 `<prefix>/<model>-{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,
|
||||
@@ -755,6 +771,8 @@ async function buildUnifiedModelsResponseCore(
|
||||
root: model.id,
|
||||
parent: null,
|
||||
...(visionFields || {}),
|
||||
...thinkingFields,
|
||||
...thinkingCapabilities,
|
||||
});
|
||||
}
|
||||
if (
|
||||
@@ -775,6 +793,8 @@ async function buildUnifiedModelsResponseCore(
|
||||
root: model.id,
|
||||
parent: includeAlias ? aliasId : null,
|
||||
...(providerVisionFields || {}),
|
||||
...thinkingFields,
|
||||
...thinkingCapabilities,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
@@ -84,19 +84,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 `<prefix>/<model>-{tier}` ids. */
|
||||
skipCanonicalEffortFallback = false
|
||||
): Record<string, boolean | string[]> {
|
||||
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),
|
||||
}
|
||||
: {}),
|
||||
};
|
||||
|
||||
@@ -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
|
||||
@@ -189,7 +220,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") {
|
||||
@@ -197,8 +232,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 +264,34 @@ 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 `<prefix>/<model>-<tier>`
|
||||
// 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 `<prefix>/<model>-<tier>`
|
||||
// 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;
|
||||
}
|
||||
// #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 {
|
||||
|
||||
193
tests/unit/deepseek-thinking-efforts.test.ts
Normal file
193
tests/unit/deepseek-thinking-efforts.test.ts
Normal file
@@ -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<string, unknown>;
|
||||
assert.equal(flash.reasoning_effort, "low");
|
||||
|
||||
const pro = sanitizeReasoningEffortForProvider(
|
||||
{ model: "deepseek-v4-pro", reasoning_effort: "low" },
|
||||
"deepseek",
|
||||
"deepseek-v4-pro"
|
||||
) as Record<string, unknown>;
|
||||
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<string, unknown>;
|
||||
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<string, unknown>;
|
||||
assert.equal(sanitizedSuffixed.reasoning_effort, "low");
|
||||
});
|
||||
Reference in New Issue
Block a user