mirror of
https://github.com/diegosouzapw/OmniRoute.git
synced 2026-08-26 17:12:27 +03:00
Validated in a combined 3-PR batch worktree off release/v3.8.51 tip. This PR conflicted against today's accumulated merges (mostly pure provider-count drift: 353 vs its 352 snapshot across 51 docs/i18n/SVG files — resolved to the release's current 353, confirmed byte-identical besides the count on diff). Two real code conflicts: - src/lib/usage/providerLimits.ts: this PR's `syntheticCooldownOutlivedByRealWindows()` is genuinely new (didn't exist on the tip; a caller already referencing it elsewhere in the file confirmed it was required) — kept in full. - tests/unit/providers-constants-split.test.ts: both sides' running-count comments land at the same 233 via different additions (this PR's volcengine-agent/coding-plan vs the v3.8.50 back-merge's Synthetic + Kilo Gateway, both already present in providers.ts) — combined as sequential history, no functional change. Resolution pushed to the PR branch and re-validated: - Focused tests: 8134-github-t5-fallback-filter, cc-compatible-provider, cli-oneproxy-commands, hard-session-lease-bypass-inventory, llm-selector-custom-vision-models, model-capabilities-registry, openapi-coverage, provider-limits-recovery, providers-constants-split, repro-glm-iso-reset-24h-cap, startup-stale-cooldown-recovery, memory-pipeline, security-hardening, skills-pipeline — part of batch's 165/165 node:test run; glmCodingProviderConfig.test.ts (vitest) 10/10 - typecheck:core, file-size, changelog-integrity, complexity, cognitive-complexity, check:docs-counts-sync — all OK - Full-repo lint: 228 pre-existing dashboard react-hooks/* findings, unrelated to this diff Thanks for this — root-causing all 18 failed jobs from a single CI run with gate-by-gate evidence (including the harder-to-spot ones like the antigravity BYOP legacy-ack misread and the reserved-alias `cc` guard) is exactly the kind of base-red drain this release needs.
127 lines
5.7 KiB
TypeScript
127 lines
5.7 KiB
TypeScript
/**
|
|
* Custom vision models in the LLM selector / `/v1/models` catalog
|
|
* (port of upstream decolua/9router 5e5e78d3 — "fix: show custom vision
|
|
* models in LLM selector and model list").
|
|
*
|
|
* Upstream stored an `imageToText` kind on user-added custom models and the
|
|
* selector hid them from the default LLM list. OmniRoute already keeps custom
|
|
* chat models in the LLM list, but the equivalent gap is the `supportsVision`
|
|
* flag on a custom model: the catalog used to only set `capabilities.vision`
|
|
* when the model id matched the conservative `isVisionModelId` heuristic
|
|
* (`pixtral`, `llava`, `qwen-vl`, …). A user who registered a custom chat
|
|
* model called e.g. `my-vision-llm` and explicitly checked "vision-capable"
|
|
* still saw no `capabilities.vision` in `/v1/models`, so the LLM selector and
|
|
* downstream routing treated it as text-only.
|
|
*
|
|
* This file pins the OmniRoute-flavoured behaviour:
|
|
* • a custom chat model with `supportsVision: true` MUST surface
|
|
* `capabilities: { vision: true }` via the catalog helper, regardless of
|
|
* whether the model id matches the static vision heuristic.
|
|
* • the existing id-based heuristic (`isVisionModelId`) still works.
|
|
* • when neither signal is present, no vision fields are emitted (no false
|
|
* positives — same conservative guard #4071 / #4072).
|
|
*/
|
|
import test from "node:test";
|
|
import assert from "node:assert/strict";
|
|
|
|
const catalog = await import("../../src/app/api/v1/models/catalog.ts");
|
|
|
|
type VisionFields = { capabilities: { vision: true } } | null;
|
|
type Helper = (entry: unknown, ...candidateIds: Array<string | null | undefined>) => VisionFields;
|
|
|
|
const getCustomVisionCapabilityFields = (
|
|
catalog as unknown as { getCustomVisionCapabilityFields?: Helper }
|
|
).getCustomVisionCapabilityFields;
|
|
|
|
test("custom vision helper is exported from the catalog module", () => {
|
|
assert.equal(
|
|
typeof getCustomVisionCapabilityFields,
|
|
"function",
|
|
"expected getCustomVisionCapabilityFields to be exported from src/app/api/v1/models/catalog.ts"
|
|
);
|
|
});
|
|
|
|
test("supportsVision flag on a custom model surfaces capabilities.vision (LLM selector unblock)", () => {
|
|
const helper = getCustomVisionCapabilityFields as Helper;
|
|
const entry = { id: "my-vision-llm", supportsVision: true };
|
|
const fields = helper(entry, "openai-compat/my-vision-llm", "my-vision-llm");
|
|
assert.ok(fields, "supportsVision=true must yield vision fields");
|
|
assert.equal(fields?.capabilities.vision, true);
|
|
});
|
|
|
|
test("id-based isVisionModelId heuristic still works when supportsVision is unset", () => {
|
|
const helper = getCustomVisionCapabilityFields as Helper;
|
|
const entry = { id: "pixtral-12b" };
|
|
const fields = helper(entry, "openai-compat/pixtral-12b", "pixtral-12b");
|
|
assert.ok(fields, "well-known vision model id must yield vision fields");
|
|
assert.equal(fields?.capabilities.vision, true);
|
|
});
|
|
|
|
test("no signal → no vision fields (avoids #4071-style false positives)", () => {
|
|
const helper = getCustomVisionCapabilityFields as Helper;
|
|
const entry = { id: "plain-text-llm" };
|
|
const fields = helper(entry, "openai-compat/plain-text-llm", "plain-text-llm");
|
|
assert.equal(fields, null);
|
|
});
|
|
|
|
test("explicit supportsVision=false is respected (text-only override)", () => {
|
|
const helper = getCustomVisionCapabilityFields as Helper;
|
|
// Even if the id matches the heuristic, an explicit false flag wins so the
|
|
// user can downgrade a mis-classified model.
|
|
const entry = { id: "pixtral-12b", supportsVision: false };
|
|
const fields = helper(entry, "alias/pixtral-12b", "pixtral-12b");
|
|
assert.equal(fields, null);
|
|
});
|
|
|
|
// ─── catalog.ts:1613/1569 vision-branch narrowing (TS2367 fix) ──────────────
|
|
// The production expression `!modelType || modelType === "chat"` was reduced to
|
|
// `!modelType` because classifyModelSupportedEndpoints() can never return
|
|
// "chat". These asserts pin that invariant so a future union widening forces a
|
|
// revisit of the call sites instead of silently changing /v1/models output.
|
|
|
|
test("classifyModelSupportedEndpoints never yields type 'chat'", async () => {
|
|
const { classifyModelSupportedEndpoints } =
|
|
await import("../../src/shared/constants/modelSupportedEndpoints.ts");
|
|
const endpointSets = [
|
|
[],
|
|
["chat"],
|
|
["chat", "completions"],
|
|
["responses"],
|
|
["embeddings"],
|
|
["rerank"],
|
|
["images"],
|
|
["videos"],
|
|
["video"],
|
|
["audio-speech"],
|
|
["audio-transcriptions"],
|
|
["audio"],
|
|
["chat", "images"],
|
|
["fim"],
|
|
["totally-unknown-endpoint"],
|
|
];
|
|
for (const endpoints of endpointSets) {
|
|
const { type } = classifyModelSupportedEndpoints(endpoints);
|
|
assert.notEqual(
|
|
type,
|
|
"chat" as never,
|
|
`type must never be "chat" for [${endpoints.join(", ")}] — the catalog vision branch relies on !modelType covering chat-type models`
|
|
);
|
|
}
|
|
});
|
|
|
|
test("custom chat-type models keep their vision fields after the TS2367 narrowing", async () => {
|
|
const { classifyModelSupportedEndpoints } =
|
|
await import("../../src/shared/constants/modelSupportedEndpoints.ts");
|
|
const helper = getCustomVisionCapabilityFields as Helper;
|
|
// A user-registered custom model with no specialty endpoints classifies as
|
|
// plain chat (modelType === undefined) and checked "vision-capable".
|
|
const classification = classifyModelSupportedEndpoints(["chat"]);
|
|
const modelType = classification.type; // undefined by the invariant above
|
|
const fields = helper({ supportsVision: true }, "my-vision-llm");
|
|
assert.ok(fields, "supportsVision:true custom model must surface vision fields");
|
|
// The exact production expression after the narrowing:
|
|
const visionFields = !modelType ? fields : null;
|
|
assert.ok(visionFields, "chat-type (modelType=undefined) keeps vision fields");
|
|
assert.equal(visionFields?.capabilities.vision, true);
|
|
});
|