mirror of
https://github.com/diegosouzapw/OmniRoute.git
synced 2026-08-05 06:42:12 +03:00
feat(api/v1): include alias-backed models in /v1/models listing (#4630)
Integrated into release/v3.8.36 — port rebuilt clean over release tip, release-green validated
This commit is contained in:
committed by
GitHub
parent
be2ab9e419
commit
69703ee7db
@@ -8,6 +8,7 @@
|
||||
"_rebaseline_2026_06_21_4481_websearch_routing": "Feature #4481 layer 2 own growth: src/sse/handlers/chat.ts 1491->1513 (+22 = the CCR-style web-search routing hook at the request entrypoint, right after the T05 task-aware-routing block — a 5-line comment + one `if (hasNativeWebSearchTool(body)) { ... }` guard that reads getCachedSettings only when a web_search tool is present and overrides resolvedModelStr/body.model via the pure helper, plus a 4-line import). When a request carries a native web_search server tool and the operator set `webSearchRouteModel`, the whole request routes to that model instead of the default (some providers, e.g. MiniMax, don't implement Anthropic's web_search_20250305 server tool). The detection + override logic lives in the new pure leaf open-sse/services/webSearchRouting.ts (no DB, unit-testable); chat.ts is thin wiring mirroring the adjacent T05 override. Lands BEFORE auto/combo resolution + the layer-1 webSearchFallback so the target's own format/fallback handling applies. Not extractable further (it IS the entrypoint wiring). Covered by tests/unit/web-search-tool-routing-4481.test.ts. Structural shrink of this handler tracked separately.",
|
||||
"_rebaseline_2026_06_21_4483_auto_quota_cutoff": "PR #4483 (megamen32) own growth + review fix: open-sse/services/combo.ts 2611->2623 (+12). The PR adds an auto-routing hard quota cutoff in buildAutoCandidates (evaluateQuotaCutoff + buildAutoQuotaThresholds/clampPercent/asThresholdMap/quotaWindowLookupNames helpers) that drops low-quota candidates before scoring, plus a 429 guard when all candidates are below cutoff. On review (owner decision) the cutoff was made OPT-IN behind a new QuotaPreflightSettings.enabled flag (default OFF via QUOTA_PREFLIGHT_CUTOFF_ENABLED) so default behavior is unchanged, and the `...eligibleTargets` last-resort fallback the PR removed was restored (dedupe makes it identical to pre-cutoff when OFF; when ON a blocked target survives as final fallback rather than vanishing). Cohesive at the existing candidate-build/select chokepoints; not extractable. Covered by tests/unit/combo/auto-quota-cutoff.test.ts + tests/unit/resilience-settings-quota-preflight.test.ts (default-off + opt-in round-trip).",
|
||||
"_rebaseline_2026_06_21_4475_target_format_badge": "PR #4475 (adivekar-utexas) review: src/app/(dashboard)/dashboard/providers/[id]/providerPageHelpers.ts 955->974 (+19). Extracted the pure targetFormatBadgeI18nKey (the 6 targetFormat value->i18n-key mapping) out of the CustomModelsSection.tsx badge so it is unit-testable outside the .tsx (Rule #18 gap — the PR had no UI test). The .tsx now calls the helper instead of an inline if-chain. This leaf is the strangler-fig home for pure provider-page helpers (#3501), so receiving the extraction is on-purpose. Covered by tests/unit/provider-target-format-badge-4475.test.ts.",
|
||||
"_rebaseline_2026_06_22_port_730_alias_backed_models": "Port of decolua/9router#730 own growth: src/app/api/v1/models/catalog.ts 1493->1574 (+81 = one import + one alias-backed walk after the customModels block, mirroring the existing static/custom emission path: resolve canonical providerId, gate on blocked/active/hidden, push aliasId + optional canonical providerPrefixedId with the same vision-fields enrichment). Surfaces models registered ONLY through the modelAliases key_value namespace (value = \"<providerKey>/<modelId>\", e.g. setModelAlias(\"kimi-k2.6\", \"custom/kimi-k2.6\")) so compatible-provider entries that resolve at request time but were silently dropped from /v1/models are now discoverable by OpenAI clients. Pair-extract is in the new pure leaf src/app/api/v1/models/aliasBackedModels.ts (no DB import, unit-testable via tests/unit/v1-models-aliases-included.test.ts). Cohesive wiring next to the other per-source push blocks; not extractable further without hiding the per-source dedupe boundary. Structural shrink of this god-file tracked in #3789.",
|
||||
"_rebaseline_2026_06_21_4424_exact_dup_dedupe": "Feature #4424 follow-up own growth: src/app/api/v1/models/catalog.ts 1486->1493 (+7 = the final exact-duplicate-id guard at the existing finalModels chokepoint — one import + a 4-line comment + one `finalModels = dedupeExactCatalogIds(finalModels)` call). #4427 added the opt-in prefix mode but the catalog still emitted 3 exact-duplicate ids (codex/gpt-5.5, veo-free/seedance, veo-free/veo each listed twice) because they originate from different push sources whose local guards don't see each other. The collapse logic lives in the new pure leaf src/app/api/v1/models/catalogDedupe.ts (no DB import, unit-testable). Keyed by listing identity (id, type, subtype) so the intentional same-id audio transcription/speech pair is preserved; keep-first, order-preserving, independent of MODELS_CATALOG_PREFIX_MODE. Thin cohesive wiring at the single serialization boundary; not extractable further. Covered by tests/unit/models-catalog-exact-dup-4424.test.ts. Structural shrink of this god-file tracked in #3789.",
|
||||
"_rebaseline_2026_06_21_4427_low_noise_catalog": "PR #4427 (Rahulsharma0810) own growth: src/app/api/v1/models/catalog.ts 1478->1486 (+8). The opt-in MODELS_CATALOG_PREFIX_MODE (dual default | alias | canonical, with ?prefix= per-request override) gates the dual alias+canonical model emission at the three /v1/models push sites (static, synced, custom) behind includeAlias/includeCanonical, suppressing the duplicate cross-prefix entries (net +3 from the feature). On review, 4 incidental explanatory comments removed by the PR were restored (synced-models resolve, skip-static, try-block intent, strip-modelIdPrefix; +5) since their code is unchanged — useful docs on a non-trivial catalog function. Default `dual` keeps byte-identical output; request-side alias resolution unchanged. Structural shrink of this route tracked in #3789. Covered by tests/unit/models-catalog-low-noise-flag.test.ts.",
|
||||
"_rebaseline_2026_06_21_phase2_active_selector": "Compression Phase 2 (named profiles + active selector) own growth: chatCore.ts 5110->5125 (+15 at the existing compression-dispatch chokepoint, just before the selectCompressionStrategy call). chatCore now loads the operator's named compression combos once into a `namedCombos` map (a best-effort try/catch dynamic-import of listCompressionCombos, debug-logged on failure) and passes it as the new `combos` arg to selectCompressionStrategy + selectCompressionPlan, plus an `&& !activeComboResolves(config, namedCombos)` guard term on the legacy default-combo block so the seeded default cannot shadow the operator's active profile. The resolver itself stays pure (the `combos` threading + activeComboResolves live in open-sse/services/compression/strategySelector.ts, <cap). The two UI files net SHRINK or stay flat: CompressionHub.tsx 521->277 (master toggle/mode/reorder removed, replaced by the thin active-profile selector + preview) and CompressionCombosPageClient.tsx grows a few lines under its frozen for the active badge. Cohesive wiring at the existing compression chokepoint, mirroring the prior compression rebaselines (#4217/#4210/#3890); not extractable without hiding the dispatch boundary. Structural shrink of chatCore.ts tracked in #3501. Covered by tests/unit/compression/active-combo-dispatch.test.ts + active-combo-integration.test.ts + tests/unit/ui/compressionHub-active-selector.test.tsx + namedCombos-active-badge.test.tsx.",
|
||||
@@ -188,7 +189,7 @@
|
||||
"src/app/api/providers/[id]/models/route.ts": 2590,
|
||||
"src/app/api/providers/[id]/test/route.ts": 917,
|
||||
"src/app/api/usage/analytics/route.ts": 941,
|
||||
"src/app/api/v1/models/catalog.ts": 1493,
|
||||
"src/app/api/v1/models/catalog.ts": 1574,
|
||||
"src/lib/cloudflaredTunnel.ts": 934,
|
||||
"src/lib/db/apiKeys.ts": 1662,
|
||||
"src/lib/db/core.ts": 1825,
|
||||
|
||||
50
src/app/api/v1/models/aliasBackedModels.ts
Normal file
50
src/app/api/v1/models/aliasBackedModels.ts
Normal file
@@ -0,0 +1,50 @@
|
||||
/**
|
||||
* Extract `{providerKey, modelId}` pairs from the `modelAliases` key_value namespace
|
||||
* so `/v1/models` can surface models that were registered ONLY through a model alias
|
||||
* (e.g. `setModelAlias("kimi-k2.6", "custom/kimi-k2.6")`).
|
||||
*
|
||||
* Port of upstream decolua/9router PR #730 — kept as a PURE helper so the route can
|
||||
* walk the result and merge it into the per-provider catalog without dragging DB or
|
||||
* Next-runtime imports into a unit test.
|
||||
*
|
||||
* Each stored alias value is `"<providerKey>/<modelId>"`. `providerKey` may be either
|
||||
* a provider id (e.g. `openai`) or a provider alias / prefix (e.g. `cu`, `custom`);
|
||||
* the caller is responsible for resolving that against the active connection map.
|
||||
*
|
||||
* The split is on the FIRST `/` only — OpenRouter-style ids contain an internal slash
|
||||
* (`openrouter/anthropic/claude-3.5-sonnet`) and must keep their full sub-path as the
|
||||
* model id.
|
||||
*/
|
||||
export interface AliasBackedModel {
|
||||
providerKey: string;
|
||||
modelId: string;
|
||||
}
|
||||
|
||||
export function extractAliasBackedModels(
|
||||
aliases: Record<string, unknown> | null | undefined
|
||||
): AliasBackedModel[] {
|
||||
if (!aliases || typeof aliases !== "object") return [];
|
||||
|
||||
const seen = new Set<string>();
|
||||
const out: AliasBackedModel[] = [];
|
||||
|
||||
for (const value of Object.values(aliases)) {
|
||||
if (typeof value !== "string") continue;
|
||||
const trimmed = value.trim();
|
||||
if (trimmed.length === 0) continue;
|
||||
|
||||
const slash = trimmed.indexOf("/");
|
||||
if (slash <= 0 || slash === trimmed.length - 1) continue;
|
||||
|
||||
const providerKey = trimmed.slice(0, slash);
|
||||
const modelId = trimmed.slice(slash + 1);
|
||||
if (!providerKey || !modelId) continue;
|
||||
|
||||
const dedupeKey = `${providerKey}/${modelId}`;
|
||||
if (seen.has(dedupeKey)) continue;
|
||||
seen.add(dedupeKey);
|
||||
out.push({ providerKey, modelId });
|
||||
}
|
||||
|
||||
return out;
|
||||
}
|
||||
@@ -8,7 +8,9 @@ import {
|
||||
getSettings,
|
||||
getProviderNodes,
|
||||
getModelIsHidden,
|
||||
getModelAliases,
|
||||
} from "@/lib/localDb";
|
||||
import { extractAliasBackedModels } from "./aliasBackedModels";
|
||||
import { appendNoThinkingVariants } from "@omniroute/open-sse/utils/noThinkingAlias";
|
||||
import { getAllEmbeddingModels } from "@omniroute/open-sse/config/embeddingRegistry";
|
||||
import { getAllImageModels } from "@omniroute/open-sse/config/imageRegistry";
|
||||
@@ -1323,6 +1325,88 @@ export async function getUnifiedModelsResponse(
|
||||
console.log("Could not fetch custom models");
|
||||
}
|
||||
|
||||
// Port of decolua/9router#730 — surface models registered ONLY through a model
|
||||
// alias (`key_value` namespace `modelAliases`, value `"<providerKey>/<modelId>"`).
|
||||
// Without this walk, a compatible-provider entry like `setModelAlias("kimi-k2.6",
|
||||
// "custom/kimi-k2.6")` resolves at request time but never shows up in `/v1/models`.
|
||||
// We respect the same gating as the static/custom listing path: provider must be
|
||||
// active (or noAuth+unblocked), model must not be hidden, and the canonical alias
|
||||
// entry must not already exist (so we don't shadow combo / synced / custom rows).
|
||||
try {
|
||||
const modelAliases = await getModelAliases();
|
||||
const aliasBacked = extractAliasBackedModels(modelAliases);
|
||||
for (const { providerKey, modelId } of aliasBacked) {
|
||||
const canonicalProviderId = resolveCanonicalProviderId(providerKey);
|
||||
if (!canonicalProviderId) continue;
|
||||
if (
|
||||
blockedProviders.has(providerKey) ||
|
||||
blockedProviders.has(canonicalProviderId) ||
|
||||
isNoAuthProviderBlocked(blockedProviders, canonicalProviderId, providerKey)
|
||||
) {
|
||||
continue;
|
||||
}
|
||||
|
||||
const alias = providerIdToAlias[canonicalProviderId] || providerKey;
|
||||
if (
|
||||
!activeAliases.has(alias) &&
|
||||
!activeAliases.has(canonicalProviderId) &&
|
||||
!activeAliases.has(providerKey)
|
||||
) {
|
||||
continue;
|
||||
}
|
||||
|
||||
if (getModelIsHidden(canonicalProviderId, modelId)) continue;
|
||||
|
||||
const aliasId = `${alias}/${modelId}`;
|
||||
const rawPrefixedId = `${providerKey}/${modelId}`;
|
||||
if (
|
||||
models.some((m: any) => m?.id === aliasId) ||
|
||||
models.some((m: any) => m?.id === rawPrefixedId)
|
||||
) {
|
||||
continue;
|
||||
}
|
||||
|
||||
const visionFields =
|
||||
getVisionCapabilityFields(aliasId) || getVisionCapabilityFields(modelId);
|
||||
|
||||
if (includeAlias) {
|
||||
models.push({
|
||||
id: aliasId,
|
||||
object: "model",
|
||||
created: timestamp,
|
||||
owned_by: canonicalProviderId,
|
||||
permission: [],
|
||||
root: modelId,
|
||||
parent: null,
|
||||
...(visionFields || {}),
|
||||
});
|
||||
}
|
||||
if (
|
||||
includeCanonical &&
|
||||
canonicalProviderId !== alias &&
|
||||
!isNoAuthProviderKey(canonicalProviderId) &&
|
||||
prefixRoutesToProvider(canonicalProviderId, canonicalProviderId)
|
||||
) {
|
||||
const providerPrefixedId = `${canonicalProviderId}/${modelId}`;
|
||||
if (models.some((m: any) => m?.id === providerPrefixedId)) continue;
|
||||
const providerVisionFields =
|
||||
getVisionCapabilityFields(providerPrefixedId) || getVisionCapabilityFields(modelId);
|
||||
models.push({
|
||||
id: providerPrefixedId,
|
||||
object: "model",
|
||||
created: timestamp,
|
||||
owned_by: canonicalProviderId,
|
||||
permission: [],
|
||||
root: modelId,
|
||||
parent: includeAlias ? aliasId : null,
|
||||
...(providerVisionFields || {}),
|
||||
});
|
||||
}
|
||||
}
|
||||
} catch (e) {
|
||||
console.log("Could not fetch model aliases");
|
||||
}
|
||||
|
||||
// Add managed fallback models for compatible providers that don't import a model list.
|
||||
for (const conn of connections) {
|
||||
const providerId = typeof conn.provider === "string" ? conn.provider : null;
|
||||
|
||||
79
tests/unit/v1-models-aliases-included.test.ts
Normal file
79
tests/unit/v1-models-aliases-included.test.ts
Normal file
@@ -0,0 +1,79 @@
|
||||
import test from "node:test";
|
||||
import assert from "node:assert/strict";
|
||||
|
||||
// Port of upstream decolua/9router PR #730 — `/v1/models` must surface models added
|
||||
// through alias mappings (key_value namespace `modelAliases`) so compatible-provider
|
||||
// entries like `custom/kimi-k2.6` registered only via `setModelAlias("kimi-k2.6",
|
||||
// "custom/kimi-k2.6")` are still discoverable by OpenAI clients.
|
||||
//
|
||||
// The catalog already iterates `PROVIDER_MODELS`, synced available models, and
|
||||
// `customModels`. It never iterated `modelAliases`, so values pointing to bare
|
||||
// `<providerAlias>/<modelId>` entries were silently dropped from the listing even
|
||||
// though the model resolves at request time. This helper exposes the alias-backed
|
||||
// pairs as a pure function so the route can merge them into the per-provider model
|
||||
// list without changing the rest of the pipeline.
|
||||
|
||||
import { extractAliasBackedModels } from "../../src/app/api/v1/models/aliasBackedModels.ts";
|
||||
|
||||
test("returns the {providerKey, modelId} pair for each alias-backed entry", () => {
|
||||
const aliases = {
|
||||
"kimi-k2.6": "custom/kimi-k2.6",
|
||||
"gpt-4o-mini-fast": "openai/gpt-4o-mini",
|
||||
};
|
||||
const out = extractAliasBackedModels(aliases);
|
||||
assert.deepEqual(out.sort((a, b) => a.providerKey.localeCompare(b.providerKey)), [
|
||||
{ providerKey: "custom", modelId: "kimi-k2.6" },
|
||||
{ providerKey: "openai", modelId: "gpt-4o-mini" },
|
||||
]);
|
||||
});
|
||||
|
||||
test("ignores alias values that are not strings", () => {
|
||||
const aliases = {
|
||||
bad1: 42,
|
||||
bad2: null,
|
||||
bad3: { foo: "openai/gpt-4o" },
|
||||
good: "anthropic/claude-3-5-sonnet",
|
||||
};
|
||||
const out = extractAliasBackedModels(aliases);
|
||||
assert.deepEqual(out, [{ providerKey: "anthropic", modelId: "claude-3-5-sonnet" }]);
|
||||
});
|
||||
|
||||
test("ignores alias values without a provider/model split", () => {
|
||||
const aliases = {
|
||||
no_slash: "just-a-model-id",
|
||||
empty: "",
|
||||
only_prefix: "openai/",
|
||||
only_suffix: "/model",
|
||||
};
|
||||
const out = extractAliasBackedModels(aliases);
|
||||
assert.deepEqual(out, []);
|
||||
});
|
||||
|
||||
test("preserves the full sub-path when the model id itself contains a slash", () => {
|
||||
// e.g. openrouter-style "owner/model" identifiers
|
||||
const aliases = {
|
||||
or_alias: "openrouter/anthropic/claude-3.5-sonnet",
|
||||
};
|
||||
const out = extractAliasBackedModels(aliases);
|
||||
assert.deepEqual(out, [
|
||||
{ providerKey: "openrouter", modelId: "anthropic/claude-3.5-sonnet" },
|
||||
]);
|
||||
});
|
||||
|
||||
test("returns an empty list for an empty / null / undefined input", () => {
|
||||
assert.deepEqual(extractAliasBackedModels({}), []);
|
||||
assert.deepEqual(extractAliasBackedModels(null as unknown as Record<string, unknown>), []);
|
||||
assert.deepEqual(
|
||||
extractAliasBackedModels(undefined as unknown as Record<string, unknown>),
|
||||
[]
|
||||
);
|
||||
});
|
||||
|
||||
test("de-duplicates entries that resolve to the same {providerKey, modelId}", () => {
|
||||
const aliases = {
|
||||
primary: "custom/kimi-k2.6",
|
||||
secondary: "custom/kimi-k2.6",
|
||||
};
|
||||
const out = extractAliasBackedModels(aliases);
|
||||
assert.deepEqual(out, [{ providerKey: "custom", modelId: "kimi-k2.6" }]);
|
||||
});
|
||||
Reference in New Issue
Block a user