mirror of
https://github.com/diegosouzapw/OmniRoute.git
synced 2026-08-13 10:43:43 +03:00
feat(providers): add DeepSeek V4 thinking effort aliases
This commit is contained in:
@@ -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,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",
|
||||
|
||||
@@ -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,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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 `<prefix>/<model>-<tier>`
|
||||
// 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 {
|
||||
|
||||
105
tests/unit/deepseek-thinking-efforts.test.ts
Normal file
105
tests/unit/deepseek-thinking-efforts.test.ts
Normal file
@@ -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<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");
|
||||
});
|
||||
Reference in New Issue
Block a user