Compare commits

..

5 Commits

Author SHA1 Message Date
diegosouzapw
f52b60d846 fix(sse): re-align stream-readiness-policy tests with minimax's openai format
PR #9463 switched minimax/minimax-cn from claude to openai format so images
work. The stream-readiness bump for Claude-format replicas is keyed off the
registry's format field (single source of truth), so minimax legitimately
falls out of that group now. Swap the "Claude-format replica" test fixtures
to agentrouter (still format: "claude") and add explicit coverage that
minimax no longer gets the claude_format_heavy_reasoning bump.
2026-08-05 19:59:32 -03:00
Diego Rodrigues de Sa e Souza
aebd481607 Merge branch 'release/v3.8.50' into fix/minimax-openai-vision 2026-08-05 16:23:59 -03:00
Diego Rodrigues de Sa e Souza
12c64667a9 Merge branch 'release/v3.8.50' into fix/minimax-openai-vision 2026-08-05 13:21:17 -03:00
Diego Rodrigues de Sa e Souza
a316c8db52 Merge branch 'release/v3.8.50' into fix/minimax-openai-vision 2026-08-05 12:00:19 -03:00
diegosouzapw
25cf9d9065 fix(providers): switch minimax from claude to openai format so images work
The Anthropic-compatible /anthropic/v1/messages endpoint rejects image
input with 403. MiniMax's OpenAI-compatible /v1/chat/completions endpoint
supports image_url natively for MiniMax-M3.

- minimax + minimax-cn: format claude→openai, baseUrl→/v1/chat/completions
- Remove Anthropic-Version header + ?beta=true suffix (not needed for openai)
- Remove minimax/minimax-cn from ?beta=true executor case
- Update cache-control tests (openai format uses different caching path)
- Fix reasoning-split test names (no longer claude format)

TDD: 2 registry tests assert format=openai (red→green).
Refs: Hermes Agent #15715, MiniMax OpenAI-compatible API docs.
2026-08-04 18:25:58 -03:00
15 changed files with 83 additions and 355 deletions

View File

@@ -1 +0,0 @@
- **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)).

View File

@@ -1,5 +1,4 @@
{
"_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).",
@@ -389,7 +388,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": 1555,
"src/app/api/v1/models/catalog.ts": 1549,
"src/lib/tokenHealthCheck.ts": 1021,
"src/lib/db/apiKeys.ts": 1529,
"src/lib/db/core.ts": 1637,

View File

@@ -9,17 +9,7 @@ export const deepseekProvider: RegistryEntry = {
authType: "apikey",
authHeader: "bearer",
models: [
{
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"],
},
{ id: "deepseek-v4-pro", name: "DeepSeek V4 Pro", supportsReasoning: true },
{ id: "deepseek-v4-flash", name: "DeepSeek V4 Flash", supportsReasoning: true },
],
};

View File

@@ -1,17 +1,14 @@
import type { RegistryEntry } from "../../../shared.ts";
import { getAnthropicCompatHeaders, ANTHROPIC_VERSION_HEADER } from "../../../shared.ts";
export const minimax_cnProvider: RegistryEntry = {
id: "minimax-cn",
alias: "minimax-cn", // unique alias (was colliding with minimax)
format: "claude",
format: "openai",
executor: "default",
baseUrl: "https://api.minimaxi.com/anthropic/v1/messages",
baseUrl: "https://api.minimaxi.com/v1/chat/completions",
modelsUrl: "https://api.minimaxi.com/v1/models",
urlSuffix: "?beta=true",
authType: "apikey",
authHeader: "bearer",
headers: getAnthropicCompatHeaders(),
models: [
// Keep parity with minimax to ensure model discovery works for minimax-cn connections.
// #3110: MiniMax M3 — frontier coding model with 1M context

View File

@@ -1,17 +1,14 @@
import type { RegistryEntry } from "../../shared.ts";
import { getAnthropicCompatHeaders, ANTHROPIC_VERSION_HEADER } from "../../shared.ts";
export const minimaxProvider: RegistryEntry = {
id: "minimax",
alias: "minimax",
format: "claude",
format: "openai",
executor: "default",
baseUrl: "https://api.minimax.io/anthropic/v1/messages",
baseUrl: "https://api.minimax.io/v1/chat/completions",
modelsUrl: "https://api.minimax.io/v1/models",
urlSuffix: "?beta=true",
authType: "apikey",
authHeader: "bearer",
headers: getAnthropicCompatHeaders(),
models: [
// T12/T28: MiniMax default upgraded from M2.5 to M2.7
// #3110: MiniMax M3 — frontier coding model with 1M context

View File

@@ -265,25 +265,17 @@ export function sanitizeReasoningEffortForProvider(
return stripEffortValue(b, c);
}
// 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.
// 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).
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 === "medium" || (effortStr === "low" && !isFlash)
? "high"
: null;
effortStr === "xhigh" ? "max" : effortStr === "low" || effortStr === "medium" ? "high" : null;
if (mapped && mapped !== effortStr) {
log?.info?.(
"REASONING_SANITIZE",

View File

@@ -289,8 +289,6 @@ export class DefaultExecutor extends BaseExecutor {
case "glm":
case "glmt":
case "kimi-coding":
case "minimax":
case "minimax-cn":
return `${this.config.baseUrl}?beta=true`;
case "agentrouter":
return this.usesClaudeCodeProtocol(credentials)

View File

@@ -728,17 +728,13 @@ 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) &&
!hasDeclaredEffortTiers
!isRegisteredEffortVariant(providerModels, model.id)
)
continue;
if (!providerSupportsModel(canonicalProviderId, model.id)) continue;
@@ -749,18 +745,6 @@ 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,
@@ -771,8 +755,6 @@ async function buildUnifiedModelsResponseCore(
root: model.id,
parent: null,
...(visionFields || {}),
...thinkingFields,
...thinkingCapabilities,
});
}
if (
@@ -793,8 +775,6 @@ async function buildUnifiedModelsResponseCore(
root: model.id,
parent: includeAlias ? aliasId : null,
...(providerVisionFields || {}),
...thinkingFields,
...thinkingCapabilities,
});
}
}

View File

@@ -84,24 +84,19 @@ export function getThinkingCapabilityFields(
providerId: string,
modelId: string,
resolvedThinking?: boolean | null,
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
supportedThinkingEfforts?: readonly string[]
): Record<string, boolean | string[]> {
const supportsThinking = resolvedThinking;
if (typeof supportsThinking !== "boolean") return {};
const hasDeclaredTiers =
supportedThinkingEfforts && supportedThinkingEfforts.length > 0;
return {
thinking: supportsThinking,
supportsThinking,
...(supportsThinking && (hasDeclaredTiers || !skipCanonicalEffortFallback)
...(supportsThinking
? {
effort_tiers: hasDeclaredTiers
? [...supportedThinkingEfforts!]
: extendCodexGpt56EffortValues(providerId, modelId, CANONICAL_EFFORT_VALUES),
effort_tiers:
supportedThinkingEfforts && supportedThinkingEfforts.length > 0
? [...supportedThinkingEfforts]
: extendCodexGpt56EffortValues(providerId, modelId, CANONICAL_EFFORT_VALUES),
}
: {}),
};

View File

@@ -121,37 +121,6 @@ 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
@@ -220,11 +189,7 @@ function copySyncedThinkingMetadata(metadata: RuntimeModelMeta, syncedMatch: any
metadata.supportsThinking = syncedMatch.supportsThinking;
}
if (syncedMatch?.alwaysThinking === true) metadata.alwaysThinking = true;
// 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) {
if (Array.isArray(syncedMatch?.supportedThinkingEfforts)) {
metadata.supportedThinkingEfforts = syncedMatch.supportedThinkingEfforts;
}
if (typeof syncedMatch?.defaultThinkingEffort === "string") {
@@ -232,22 +197,8 @@ function copySyncedThinkingMetadata(metadata: RuntimeModelMeta, syncedMatch: any
}
}
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 {
function buildRuntimeModelMeta(customMatch: any, syncedMatch: any): RuntimeModelMeta {
const metadata = resolveRuntimeFormats(customMatch, syncedMatch);
copyRegistryThinkingMetadata(metadata, registryMatch);
copySyncedThinkingMetadata(metadata, syncedMatch);
return metadata;
}
@@ -264,34 +215,16 @@ 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.
// #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(
const { 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 registryMatch = findRegistryModel(providerId, resolvedModelId);
const metadata = buildRuntimeModelMeta(customMatch, syncedMatch, registryMatch);
const metadata = buildRuntimeModelMeta(customMatch, syncedMatch);
if (effort) metadata.resolvedThinkingEffort = effort;
return { modelId: resolvedModelId, metadata };
} catch {

View File

@@ -17,8 +17,10 @@ describe("Cache Control Policy - Claude Protocol Providers", () => {
// These should be detected via targetFormat
assert.equal(providerSupportsCaching("bailian-coding-plan", "claude"), true);
assert.equal(providerSupportsCaching("glm", "claude"), true);
assert.equal(providerSupportsCaching("minimax", "claude"), true);
assert.equal(providerSupportsCaching("minimax-cn", "claude"), true);
// minimax/minimax-cn use openai format (#3110 / image 403 fix);
// caching support for their OpenAI-compatible endpoint is TBD
assert.equal(providerSupportsCaching("minimax", "openai"), false);
assert.equal(providerSupportsCaching("minimax-cn", "openai"), false);
assert.equal(providerSupportsCaching("kimi-coding", "claude"), true);
// #3955 — OpenAI / Codex use automatic prefix caching (no cache_control needed).
@@ -66,15 +68,17 @@ describe("Cache Control Policy - Claude Protocol Providers", () => {
true
);
// minimax now uses openai format — caching behavior may differ;
// cache_control preservation depends on whether it joins CACHING_PROVIDERS
assert.equal(
shouldPreserveCacheControl({
userAgent: claudeCodeUA,
isCombo: false,
targetProvider: "minimax",
targetFormat: "claude",
targetFormat: "openai",
settings: { alwaysPreserveClientCache: "auto" },
}),
true
false
);
});

View File

@@ -1,193 +0,0 @@
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");
});

View File

@@ -73,6 +73,26 @@ describe("MiniMax M3 model registration (#3110)", () => {
assert.equal(m3.contextLength, 1_048_576);
});
it("minimax uses openai format (not claude) so images work via /v1/chat/completions", () => {
const entry = REGISTRY.minimax;
assert.ok(entry, "minimax registry entry must exist");
assert.equal(
entry.format,
"openai",
"minimax must use openai format — the Anthropic-compatible /anthropic/v1/messages endpoint rejects images with 403; images work on the OpenAI-compatible /v1/chat/completions endpoint. See Hermes Agent #15715."
);
});
it("minimax-cn uses openai format (not claude) so images work via /v1/chat/completions", () => {
const entry = REGISTRY["minimax-cn"];
assert.ok(entry, "minimax-cn registry entry must exist");
assert.equal(
entry.format,
"openai",
"minimax-cn must use openai format — parity with minimax; the Anthropic endpoint on api.minimaxi.com also rejects images."
);
});
it("nvidia provider does NOT list minimaxai/minimax-m3 (removed in #3329 — 404 upstream)", () => {
const entry = REGISTRY.nvidia;
assert.ok(entry, "nvidia registry entry must exist");

View File

@@ -137,12 +137,12 @@ describe("responseSanitizer/reasoning — MiniMax M3 textual reasoning-tag route
});
describe("responseSanitizer/reasoning — MiniMax M3 fix regression guards", () => {
it("direct minimax tier (claude format) stays unaffected", () => {
it("direct minimax tier (openai format) stays unaffected for textual reasoning tags", () => {
assert.equal(isTextualReasoningTagNativeRoute("minimax", "minimax-m3"), false);
assert.equal(shouldParseTextualReasoningTags("minimax", "MiniMax-M3"), false);
});
it("direct minimax-cn tier (claude format) stays unaffected", () => {
it("direct minimax-cn tier (openai format) stays unaffected for textual reasoning tags", () => {
assert.equal(isTextualReasoningTagNativeRoute("minimax-cn", "minimax-m3"), false);
assert.equal(shouldParseTextualReasoningTags("minimax-cn", "MiniMax-M3"), false);
});

View File

@@ -145,14 +145,14 @@ test("preserves zero timeout so readiness checks can be disabled", () => {
assert.deepEqual(result.reasons, ["disabled"]);
});
test("bumps small requests to third-party Claude-format replicas (Minimax M3, ZAI, bailian, agentrouter) — guards against #3825-class false 504s on long reasoning warm-ups", () => {
// Provider registry lists Minimax with `format: "claude"` — the readiness budget
test("bumps small requests to third-party Claude-format replicas (agentrouter, ZAI, bailian) — guards against #3825-class false 504s on long reasoning warm-ups", () => {
// Provider registry lists agentrouter with `format: "claude"` — the readiness budget
// must fire UNCONDITIONALLY for those replicas, like the codex_gpt_5_5_high
// bump, because their reasoning warm-ups routinely exceed the default 80s window.
const result = resolveStreamReadinessTimeout({
baseTimeoutMs: 80_000,
provider: "minimax",
model: "MiniMax-M3",
provider: "agentrouter",
model: "claude-opus-4-8",
body: { messages: items(3), tools: tools(2) },
});
@@ -163,6 +163,23 @@ test("bumps small requests to third-party Claude-format replicas (Minimax M3, ZA
);
});
test("does NOT bump Minimax (M3) — #3110 moved it from claude to openai format so images work, and the readiness bump is keyed off the registry's `format: \"claude\"` field", () => {
// Minimax's replica quirk (long reasoning warm-up) hasn't changed, but this
// policy intentionally keys off the translator format, not the provider
// name — the registry is the single source of truth (see isClaudeFormatReasoningProvider
// doc comment). Now that minimax routes through the OpenAI translator, it no
// longer matches, mirroring the OpenAI/non-Claude exclusion below.
const result = resolveStreamReadinessTimeout({
baseTimeoutMs: 80_000,
provider: "minimax",
model: "MiniMax-M3",
body: { messages: items(3), tools: tools(2) },
});
assert.equal(result.timeoutMs, 80_000);
assert.ok(!result.reasons.includes("claude_format_heavy_reasoning"));
});
test("bumps ZAI (claude-format replica) readiness budget the same way", () => {
const result = resolveStreamReadinessTimeout({
baseTimeoutMs: 80_000,
@@ -213,13 +230,13 @@ test("does NOT double-bump when codex-high reasoning and Claude-format replica b
// Claude-format providers later, the readiness bump must not stack.
const result = resolveStreamReadinessTimeout({
baseTimeoutMs: 80_000,
provider: "minimax",
model: "MiniMax-M3-high",
provider: "agentrouter",
model: "claude-opus-4-8-high",
body: { messages: items(3), tools: tools(2), reasoning_effort: "high" },
});
// Should be bumped by exactly one reason — claude_format_heavy_reasoning —
// because minimax is not a codex provider, the codex_* path never fires.
// because agentrouter is not a codex provider, the codex_* path never fires.
assert.equal(result.timeoutMs, 110_000);
assert.ok(result.reasons.includes("claude_format_heavy_reasoning"));
assert.ok(!result.reasons.includes("codex_gpt_5_5_high_reasoning"));
@@ -229,8 +246,8 @@ test("caps Claude-format replica bump at the configured maxTimeoutMs", () => {
const result = resolveStreamReadinessTimeout({
baseTimeoutMs: 80_000,
maxTimeoutMs: 100_000,
provider: "minimax",
model: "MiniMax-M3",
provider: "agentrouter",
model: "claude-opus-4-8",
body: { messages: items(500), tools: tools(20), instructions: "x".repeat(800_000) },
});