mirror of
https://github.com/diegosouzapw/OmniRoute.git
synced 2026-09-04 14:02:12 +03:00
Compare commits
2 Commits
docs/opena
...
feat/11481
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
e51b1661c4 | ||
|
|
ce9e3af633 |
@@ -0,0 +1 @@
|
||||
- feat(api): add an opt-in `modelVisibilityAllowlist`/`modelVisibilityDenylist` settings pair to curate exactly which models `/v1/models` advertises, mirrored into every `auto/*` combo candidate pool so a denied model cannot be routed to via combo selection either (#11481)
|
||||
@@ -477,8 +477,9 @@
|
||||
"_rebaseline_2026_08_23_tip_drift_post_batch0823": "Tip drift after the 2026-08-23 merge wave: chatBodyAdmission.ts 1009->1118 (+109, gate count incl. +1) and auth.ts 3337->3344 (+7), both grown by merges already on origin/release/v3.8.50 (verified identical on the pristine tip) — not by the codex-appserver-hardening PR that carries this bump. Owner pre-authorized baseline bumps 2026-08-22.",
|
||||
"_rebaseline_2026_08_24_11355_cooldown_recovery_guards": "PR #11355 own growth: test/route.ts 1215->1237, +22 (startup crash-recovery guard: clearStaleCrashCooldowns() now parses the persisted rate_limited_until deadline and skips clearing rows still genuinely in the future, instead of clearing every non-terminal cooldown unconditionally). Cohesive fix at the existing test-route dispatch chokepoint alongside the #11141 probe builder. Covered by tests/unit/startup-stale-cooldown-recovery.test.ts + tests/unit/repro-zai-cooldown-cleared-by-connection-test.test.ts.",
|
||||
"_rebaseline_2026_08_24_video_bridge_fu02_fu07_sampler": "PRs #11344 (FU-02 one-frame scene-aware determinism) + #11381 (FU-07 opt-in segment_aware structural sampling) own growth: videoBridgeRuntime.ts <1000->1009, +9 (sum of both boarded together in the same merge-batch). #11344 adds the deterministic one-frame midpoint fallback + policyEffective=uniform report at the existing scene_aware seam; #11381 adds the bounded local-only FFmpeg structural pre-analysis pass (scene/freeze/blur/exposure/SI-TI) and its budget-reallocation logic. Covered by tests/unit/guardrails/videoBridgeSampler.test.ts, tests/unit/guardrails/videoBridgeFu07StructuralSampling.test.ts, tests/integration/video-bridge-sampler-ffmpeg.test.ts. Owner pre-authorized rebaseline for legitimate PR growth (2026-08-19 directive).",
|
||||
"open-sse/services/autoCombo/virtualFactory.ts": 1139,
|
||||
"open-sse/services/autoCombo/virtualFactory.ts": 1145,
|
||||
"_rebaseline_2026_08_29_9133_candidates_inspector_skip_flag": "#9133 own growth: open-sse/services/autoCombo/virtualFactory.ts 1138->1139 (+1, net of extraction). Fix: prepareVirtualAutoComboInputs gained an opt-in `skip` parameter so the read-only #7819 candidate inspector (open-sse/handlers/autoComboCandidates.ts) can build the FULL, unfiltered pool and decorate a resilience-blocked candidate as reachable:false instead of filterResilienceBlockedCandidates silently dropping the row before the inspector ever sees it (routing is unaffected — it never passes `skip`). The connectionsById map-building loop was extracted to buildConnectionResilienceMap() in resilienceCandidateFilter.ts (net 0 there since Prettier still breaks the call over multiple lines) and the now-unused ConnectionResilienceView import was dropped; the sole remaining growth is the new `skip` default parameter itself, which Prettier always places on its own line once the preceding options object parameter already breaks across lines — not further reducible without splitting prepareVirtualAutoComboInputs's signature away from its own body. Covered by tests/unit/auto-combo-candidates-locked-model-visible.test.ts (TDD repro: red before the fix, green after) plus the existing tests/unit/noauth-autocombo-lockout-7623.test.ts and tests/unit/auto-combo-credentialed-model-pool.test.ts (unaffected routing-path behavior).",
|
||||
"_rebaseline_2026_08_29_11481_model_exposure_list": "Feature #11481 (explicit model exposure allow/deny list for /v1/models, mirrored into auto/* combo pools) own growth on top of #9133's +1: open-sse/services/autoCombo/virtualFactory.ts 1139->1145 (measured real line count after both #9133 and #11481 merged together = one import line for filterModelExposureCandidates plus the filter-and-reassign block at the existing buildPreparedPool chokepoint, immediately after the filterPaidOnlyCandidates call it mirrors — the exact pattern #6512 already established for hidePaidModels). The actual predicate (isModelExposureAllowed, glob support via the shared globToRegex matcher) lives in the new src/shared/utils/modelExposureList.ts leaf, and the pool-filter wrapper lives in the new open-sse/services/autoCombo/modelExposureFilter.ts leaf (both well under cap) — this file only carries the minimal call-site wiring plus import, not extractable further without hiding the buildPreparedPool filter chain. Covered by tests/unit/autoCombo/model-exposure-filter-11481.test.ts (pure filter, all branches) and tests/unit/model-exposure-list.test.ts (predicate).",
|
||||
"_rebaseline_2026_08_28_mergebatch_v3851_qwen_retirement": "/merge-batch 2026-08-28 (v3.8.51): #11713 (Qwen Web retirement) own growth: open-sse/services/autoCombo/virtualFactory.ts 1132->1135 (+3, combining the Designer + Runtime retirement-guard filter into the single runtimeConnections predicate at the existing candidate-pool chokepoint, now excluding Qwen Web alongside Felo Web). Irreducible per-mechanism wiring, additive not a new branch. Covered by tests/unit/virtual-auto-combo.test.ts.",
|
||||
"_rebaseline_2026_08_28_mergebatch_v3851_chatgptweb_retirement": "/merge-batch 2026-08-28 (v3.8.51): #11754 (common ChatGPT Web retirement) own growth: open-sse/services/autoCombo/virtualFactory.ts 1135->1138 (+3, an early `available` connection filter for the retired chatgpt-web/cgpt-web ids applied to both the active and disabled-noauth connection lists, ahead of the existing Designer+Runtime runtimeConnections filter). Irreducible per-mechanism wiring, additive not a new branch. Covered by tests/unit/virtual-auto-combo.test.ts.",
|
||||
"src/lib/cloudflaredTunnel.ts": 1078,
|
||||
|
||||
66
docs/routing/MODEL_EXPOSURE_LIST.md
Normal file
66
docs/routing/MODEL_EXPOSURE_LIST.md
Normal file
@@ -0,0 +1,66 @@
|
||||
---
|
||||
title: "Model Exposure Allow/Deny List"
|
||||
version: 3.8.51
|
||||
lastUpdated: 2026-08-29
|
||||
---
|
||||
|
||||
# Model Exposure Allow/Deny List
|
||||
|
||||
> Opt-in, off by default (`modelVisibilityAllowlist`/`modelVisibilityDenylist` both empty leaves
|
||||
> the `/v1/models` catalog AND every `auto/*` candidate pool byte-identical). A sibling of
|
||||
> `hidePaidModels`/`hideAutoCombos` (`src/lib/db/settings.ts`) for operators who want a curated
|
||||
> model list for reasons that have nothing to do with cost.
|
||||
|
||||
## Why this exists
|
||||
|
||||
`hidePaidModels` answers "is this model free?" and `hideAutoCombos` answers "should `auto/*`
|
||||
virtual ids be advertised at all?" — neither lets an operator curate an arbitrary subset of
|
||||
models (e.g. presenting exactly the models a given Claude Code / OpenCode client should see,
|
||||
independent of pricing). #11481 adds that as two independent, opt-in string-array settings.
|
||||
|
||||
## Settings
|
||||
|
||||
| Key | Type | Default | Meaning |
|
||||
| ----------------------------- | ---------- | ------- | ----------------------------------------------------------------- |
|
||||
| `modelVisibilityDenylist` | `string[]` | `[]` | Entries matching a candidate hide it from the catalog/candidate pool. |
|
||||
| `modelVisibilityAllowlist` | `string[]` | `[]` | When non-empty, ONLY entries matching a candidate stay exposed. |
|
||||
|
||||
Both accept up to 500 entries of up to 200 characters each (Zod-validated in
|
||||
`src/shared/validation/settingsSchemas.ts`). An entry is either:
|
||||
|
||||
- an exact catalog id — `"gpt-4o"` (bare model id) or `"openai/gpt-4o"` (provider-prefixed), or
|
||||
- a glob pattern using `*`/`?` — e.g. `"openai/gpt-4*"` or `"anthropic/*"` — resolved via the
|
||||
same shared `globToRegex()` matcher (`src/shared/utils/globPattern.ts`) already used by
|
||||
`ModelRoutingSection`'s per-model combo mappings and `freeModels.ts::matchesOnlyPaidModels`.
|
||||
|
||||
Precedence: the denylist is checked first (a denied entry is always hidden, even if it also
|
||||
matches the allowlist); when the allowlist is non-empty, only entries it matches survive.
|
||||
|
||||
## Two chokepoints, not one
|
||||
|
||||
The lesson from #6512 (a `hidePaidModels`-only catalog filter still let `auto/*` route to a
|
||||
paid model, since the combo candidate pool was built independently) applies identically here.
|
||||
The matching predicate `isModelExposureAllowed()` (`src/shared/utils/modelExposureList.ts`) is
|
||||
called from BOTH:
|
||||
|
||||
- `src/app/api/v1/models/catalog.ts` — the `/v1/models` listing itself, at the same 5 per-source
|
||||
chokepoints `shouldHidePaid()` already gates (static `PROVIDER_MODELS`, synced provider rows,
|
||||
custom rows, alias-backed rows, managed-fallback rows).
|
||||
- `open-sse/services/autoCombo/modelExposureFilter.ts::filterModelExposureCandidates()` — called
|
||||
from `virtualFactory.ts::buildPreparedPool`, immediately after the equivalent
|
||||
`filterPaidOnlyCandidates()` call, so a denied model can never be selected into an `auto/*`
|
||||
candidate pool either.
|
||||
|
||||
## What is NOT filtered
|
||||
|
||||
Mirrors `hideAutoCombos`'s existing behaviour: a model id sent **explicitly** (not via `auto/*`,
|
||||
and not discovered through the catalog listing) is never blocked at dispatch — only
|
||||
advertisement/candidate-pool membership is filtered. This is independent of `hidePaidModels`;
|
||||
an operator may want a curated set for reasons that have nothing to do with cost, so both
|
||||
settings compose as independent AND-ed filters, same as the existing multi-flag composition in
|
||||
`catalog.ts`.
|
||||
|
||||
Settings export (`GET /api/settings/export-json`) includes both arrays verbatim, like any other
|
||||
settings field — unlike `hidePaidModels`'s combo-step export filter, there is no re-hydration
|
||||
risk here: a denied id embedded in an exported combo step is the operator's own explicit routing
|
||||
choice, not something the export boundary needs to strip.
|
||||
51
open-sse/services/autoCombo/modelExposureFilter.ts
Normal file
51
open-sse/services/autoCombo/modelExposureFilter.ts
Normal file
@@ -0,0 +1,51 @@
|
||||
/**
|
||||
* #11481 — mandatory mirror of the `/v1/models` catalog's explicit model
|
||||
* exposure allow/deny list into every `auto/*` combo candidate pool.
|
||||
*
|
||||
* `src/app/api/v1/models/catalog.ts` hides denied models (and, when an
|
||||
* allowlist is set, restricts to it) from the advertised catalog, but
|
||||
* `auto/*` combos build their candidate pool independently — exactly the
|
||||
* trap #6512 already fixed once for `hidePaidModels` (PR #6495 hid paid
|
||||
* models from `GET /v1/models`, but `auto/*` could still route to one,
|
||||
* a 402/403 at request time). This applies the SAME predicate `catalog.ts`
|
||||
* uses to every virtual auto-combo candidate pool.
|
||||
*
|
||||
* Kept as a pure, dependency-light function so the filter is unit-testable
|
||||
* in isolation without seeding the DB-backed virtual factory (mirrors
|
||||
* `paidModelFilter.ts`).
|
||||
*/
|
||||
import {
|
||||
isModelExposureAllowed,
|
||||
type ModelExposureListSettings,
|
||||
} from "@/shared/utils/modelExposureList";
|
||||
|
||||
interface ExposureFilterCandidate {
|
||||
provider: string;
|
||||
model: string;
|
||||
}
|
||||
|
||||
function hasAnyExposureListEntries(settings: ModelExposureListSettings | null | undefined): boolean {
|
||||
return (
|
||||
(Array.isArray(settings?.modelVisibilityDenylist) &&
|
||||
settings.modelVisibilityDenylist.length > 0) ||
|
||||
(Array.isArray(settings?.modelVisibilityAllowlist) &&
|
||||
settings.modelVisibilityAllowlist.length > 0)
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Return the candidate pool filtered by the operator's model exposure
|
||||
* allow/deny lists; otherwise return the pool unchanged (identity — the
|
||||
* default, opt-in-off path when both lists are empty). If the allowlist
|
||||
* empties the pool, the caller's existing graceful empty-pool path handles
|
||||
* it, consistent with the opt-in intent.
|
||||
*/
|
||||
export function filterModelExposureCandidates<T extends ExposureFilterCandidate>(
|
||||
pool: T[],
|
||||
settings: ModelExposureListSettings | null | undefined
|
||||
): T[] {
|
||||
if (!hasAnyExposureListEntries(settings)) return pool;
|
||||
return pool.filter((candidate) =>
|
||||
isModelExposureAllowed(candidate.provider, candidate.model, settings)
|
||||
);
|
||||
}
|
||||
@@ -31,6 +31,7 @@ import { buildFamilyCandidateFilter, type ModelFamily } from "./modelFamily";
|
||||
import { getHiddenModelsByProvider } from "@/models";
|
||||
import { getSyncedAvailableModelsByConnection, getCustomModels } from "@/lib/db/models";
|
||||
import { filterPaidOnlyCandidates } from "./paidModelFilter";
|
||||
import { filterModelExposureCandidates } from "./modelExposureFilter";
|
||||
import {
|
||||
filterSubscriptionOnlyCandidates,
|
||||
orderPoolByRung,
|
||||
@@ -736,6 +737,11 @@ export async function prepareVirtualAutoComboInputs(
|
||||
const paidFilteredPool = filterPaidOnlyCandidates(pool, settings.hidePaidModels === true);
|
||||
if (paidFilteredPool !== pool) pool = paidFilteredPool;
|
||||
|
||||
// #11481: mandatory mirror of the /v1/models exposure allow/deny list —
|
||||
// see src/shared/utils/modelExposureList.ts for why (#6512's lesson).
|
||||
const exposureFilteredPool = filterModelExposureCandidates(pool, settings);
|
||||
if (exposureFilteredPool !== pool) pool = exposureFilteredPool;
|
||||
|
||||
// STRICT_ZERO_COST: opt-in, off by default (`settings.freeAccessPolicy !== "strict"`
|
||||
// leaves `pool` byte-identical, same contract as `hidePaidModels`). See
|
||||
// `strictZeroCostFilter.ts` for why this is stricter than `hidePaidModels` alone —
|
||||
|
||||
@@ -123,6 +123,7 @@ import {
|
||||
import { incrementCcDiscoveryHitCount } from "@/lib/db/ccDiscoveryMetrics";
|
||||
import { isUnifiedChatSourceModelSelectable } from "./catalogModelPolicy";
|
||||
import { isFreeModel } from "@/shared/utils/freeModels";
|
||||
import { isModelExposureAllowed } from "@/shared/utils/modelExposureList";
|
||||
import { isCodexDiscoveryModelExcluded } from "@/shared/services/codexDiscoveryPolicy";
|
||||
import { buildErrorBody } from "@omniroute/open-sse/utils/error";
|
||||
|
||||
@@ -351,6 +352,13 @@ async function buildUnifiedModelsResponseCore(
|
||||
// already returned above.
|
||||
return true;
|
||||
};
|
||||
// #11481: opt-in explicit model exposure allow/deny list — same call sites
|
||||
// as shouldHidePaid above (mirrored into the auto/* combo candidate pool
|
||||
// via open-sse/services/autoCombo/modelExposureFilter.ts, per #6512's
|
||||
// catalog-only-filter-leaks-into-combo-routing lesson). Independent of
|
||||
// hidePaidModels — operator curation, not a cost signal.
|
||||
const shouldHideByExposure = (providerKey: string, modelId: string): boolean =>
|
||||
!isModelExposureAllowed(aliasToProviderId[providerKey] || providerKey, modelId, settings);
|
||||
|
||||
// Get active provider connections
|
||||
let connections = [];
|
||||
@@ -1025,6 +1033,7 @@ async function buildUnifiedModelsResponseCore(
|
||||
if (isExcludedByProviderConnections(canonicalProviderId, model.id)) continue;
|
||||
if (shouldHidePaid(canonicalProviderId, model.id, (model as { pricing?: unknown }).pricing))
|
||||
continue;
|
||||
if (shouldHideByExposure(canonicalProviderId, model.id)) continue;
|
||||
|
||||
const visionFields =
|
||||
getVisionCapabilityFields(aliasId) || getVisionCapabilityFields(model.id);
|
||||
@@ -1184,6 +1193,7 @@ async function buildUnifiedModelsResponseCore(
|
||||
// are hidden when hidePaid is on.
|
||||
if (shouldHidePaid(canonicalProviderId, sm.id, (sm as { pricing?: unknown }).pricing))
|
||||
continue;
|
||||
if (shouldHideByExposure(canonicalProviderId, sm.id)) continue;
|
||||
|
||||
const registryEntry = REGISTRY[providerId];
|
||||
const displayModelId =
|
||||
@@ -1583,6 +1593,7 @@ async function buildUnifiedModelsResponseCore(
|
||||
shouldHidePaid(canonicalProviderId, modelId, (model as { pricing?: unknown }).pricing, (model as any).isFree)
|
||||
)
|
||||
continue;
|
||||
if (shouldHideByExposure(canonicalProviderId, modelId)) continue;
|
||||
// noAuth providers have no connection rows; keep auth providers gated. (#2798/#3200)
|
||||
const isNoAuthProvider = isNoAuthProviderKey(canonicalProviderId, providerId, alias);
|
||||
if (
|
||||
@@ -1760,6 +1771,7 @@ async function buildUnifiedModelsResponseCore(
|
||||
// point at providerKey/modelId with no pricing, so shouldHidePaid()
|
||||
// decides via the FREE_MODEL_IDS_BY_PROVIDER catalog tier.
|
||||
if (shouldHidePaid(canonicalProviderId, modelId)) continue;
|
||||
if (shouldHideByExposure(canonicalProviderId, modelId)) continue;
|
||||
|
||||
const aliasId = `${alias}/${modelId}`;
|
||||
const rawPrefixedId = `${providerKey}/${modelId}`;
|
||||
@@ -1835,6 +1847,7 @@ async function buildUnifiedModelsResponseCore(
|
||||
// FREE_MODEL_IDS_BY_PROVIDER catalog tier.
|
||||
if (shouldHidePaid(canonicalProviderId, modelId, (model as { pricing?: unknown }).pricing))
|
||||
continue;
|
||||
if (shouldHideByExposure(canonicalProviderId, modelId)) continue;
|
||||
if (!hasEligibleConnectionForModel([conn], modelId)) continue;
|
||||
|
||||
const aliasId = `${alias}/${modelId}`;
|
||||
|
||||
@@ -251,6 +251,14 @@ export async function getSettings() {
|
||||
// #9418: Opt-in filter that hides no-think/* gateway variants from the /v1/models catalog.
|
||||
// Routing still works for hidden ids sent explicitly.
|
||||
hideNoThinkVariants: false,
|
||||
// #11481: Opt-in explicit model exposure allow/deny list, mirrored into the
|
||||
// auto/* combo candidate pool (open-sse/services/autoCombo/modelExposureFilter.ts)
|
||||
// so a denied model can't sneak back in via combo routing — the same trap
|
||||
// #6512 already fixed once for hidePaidModels. See
|
||||
// src/shared/utils/modelExposureList.ts for the matching predicate. Empty
|
||||
// arrays preserve prior behaviour; opt-in only.
|
||||
modelVisibilityAllowlist: [],
|
||||
modelVisibilityDenylist: [],
|
||||
// #6977: Opt-in per-connection auto-ping that warms a Codex OAuth connection's
|
||||
// quota window right after it resets, so the first real request doesn't land in
|
||||
// a cold window. `connections` maps connection id -> enabled. Default empty map
|
||||
|
||||
73
src/shared/utils/modelExposureList.ts
Normal file
73
src/shared/utils/modelExposureList.ts
Normal file
@@ -0,0 +1,73 @@
|
||||
/**
|
||||
* #11481 — explicit model exposure allow/deny list for the `/v1/models` catalog.
|
||||
*
|
||||
* Mirrors the opt-in shape of `hidePaidModels`/`hideAutoCombos`
|
||||
* (`src/lib/db/settings.ts`): default-off, two independent string arrays
|
||||
* (`modelVisibilityDenylist` / `modelVisibilityAllowlist`). An entry may be an
|
||||
* exact catalog id ("provider/model" or a bare "model") or a glob pattern
|
||||
* (`*`/`?`) via the shared `globToRegex` matcher already used by
|
||||
* `ModelRoutingSection`'s per-model combo mappings and
|
||||
* `freeModels.ts::matchesOnlyPaidModels` — no new matching logic.
|
||||
*
|
||||
* This predicate is the single chokepoint called from BOTH places the catalog
|
||||
* template requires (#6512's lesson: a catalog-only filter still leaks into
|
||||
* `auto/*` combo routing):
|
||||
* - `src/app/api/v1/models/catalog.ts` (the `/v1/models` listing itself)
|
||||
* - `open-sse/services/autoCombo/modelExposureFilter.ts` (the `auto/*`
|
||||
* candidate-pool mirror)
|
||||
*
|
||||
* A model id sent EXPLICITLY (not via `auto/*`) is never blocked at dispatch —
|
||||
* only catalog advertisement / candidate-pool membership is filtered, exactly
|
||||
* like `hideAutoCombos` already behaves.
|
||||
*/
|
||||
import { globToRegex } from "./globPattern";
|
||||
|
||||
export interface ModelExposureListSettings {
|
||||
modelVisibilityAllowlist?: unknown;
|
||||
modelVisibilityDenylist?: unknown;
|
||||
}
|
||||
|
||||
function normalizeList(value: unknown): string[] {
|
||||
if (!Array.isArray(value)) return [];
|
||||
return value.filter((v): v is string => typeof v === "string" && v.trim() !== "");
|
||||
}
|
||||
|
||||
/** Whether any entry in `list` matches one of `candidates` — exact string match
|
||||
* first (the common case, no regex cost), falling back to a glob match only
|
||||
* when the entry actually contains a wildcard character. */
|
||||
function listMatchesAny(list: string[], candidates: string[]): boolean {
|
||||
return list.some((entry) => {
|
||||
if (candidates.includes(entry)) return true;
|
||||
if (!/[*?]/.test(entry)) return false;
|
||||
let regex: RegExp;
|
||||
try {
|
||||
regex = globToRegex(entry);
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
return candidates.some((candidate) => regex.test(candidate));
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Whether a (provider, model) pair should be exposed given the operator's
|
||||
* allow/deny lists. Empty lists (the default) always expose — opt-in, off by
|
||||
* default, matching Hard Rule #20's "never mutate the operator's behaviour on
|
||||
* their behalf by default" spirit. Denylist is checked first (deny wins over
|
||||
* an overlapping allow entry); when the allowlist is non-empty, only entries
|
||||
* it matches survive.
|
||||
*/
|
||||
export function isModelExposureAllowed(
|
||||
provider: string,
|
||||
modelId: string,
|
||||
settings: ModelExposureListSettings | null | undefined
|
||||
): boolean {
|
||||
const denylist = normalizeList(settings?.modelVisibilityDenylist);
|
||||
const allowlist = normalizeList(settings?.modelVisibilityAllowlist);
|
||||
if (denylist.length === 0 && allowlist.length === 0) return true;
|
||||
|
||||
const candidates = [modelId, `${provider}/${modelId}`];
|
||||
if (listMatchesAny(denylist, candidates)) return false;
|
||||
if (allowlist.length === 0) return true;
|
||||
return listMatchesAny(allowlist, candidates);
|
||||
}
|
||||
@@ -138,6 +138,16 @@ export const updateSettingsSchema = z.object({
|
||||
// curated `tos` verdict is "avoid" (proxy/self-hosted use conflicts with the
|
||||
// provider's own terms) — a contractual concern, not an economic one.
|
||||
excludeTosAvoid: z.boolean().optional(),
|
||||
// #11481: Opt-in explicit model exposure allow/deny list for `/v1/models` AND
|
||||
// the `auto/*` combo candidate pool (mirror in
|
||||
// open-sse/services/autoCombo/modelExposureFilter.ts — #6512 already proved a
|
||||
// catalog-only filter still leaks into combo routing). Entries are exact
|
||||
// "provider/model" (or bare "model") ids, or a glob pattern via the shared
|
||||
// globToRegex matcher (src/shared/utils/modelExposureList.ts). Independent of
|
||||
// hidePaidModels — this is operator curation, not a cost signal. Default
|
||||
// empty arrays = no-op (Hard Rule #20 spirit).
|
||||
modelVisibilityAllowlist: z.array(z.string().max(200)).max(500).optional(),
|
||||
modelVisibilityDenylist: z.array(z.string().max(200)).max(500).optional(),
|
||||
// Subscription-first routing tuning (`auto/subscription`, `auto/thrifty`).
|
||||
// TUNING ONLY — there is deliberately no `enabled` flag: both ids are opt-in
|
||||
// by being requested, and a toggle able to switch them off would leave
|
||||
|
||||
58
tests/unit/autoCombo/model-exposure-filter-11481.test.ts
Normal file
58
tests/unit/autoCombo/model-exposure-filter-11481.test.ts
Normal file
@@ -0,0 +1,58 @@
|
||||
/**
|
||||
* #11481 — mandatory mirror of the `/v1/models` model exposure allow/deny
|
||||
* list into the `auto/*` candidate pool (the same trap #6512 already fixed
|
||||
* once for `hidePaidModels`: a catalog-only filter still leaks into combo
|
||||
* routing).
|
||||
*
|
||||
* Tests the pure `filterModelExposureCandidates` helper wired into
|
||||
* `open-sse/services/autoCombo/virtualFactory.ts::buildPreparedPool`.
|
||||
*/
|
||||
import { test } from "vitest";
|
||||
import assert from "node:assert/strict";
|
||||
|
||||
import { filterModelExposureCandidates } from "../../../open-sse/services/autoCombo/modelExposureFilter.ts";
|
||||
|
||||
const DENIED = { provider: "openai", model: "gpt-4o" };
|
||||
const KEPT = { provider: "anthropic", model: "claude-opus-5" };
|
||||
|
||||
test("no exposure lists configured (default) returns the pool UNCHANGED (identity, regression guard)", () => {
|
||||
const pool = [DENIED, KEPT];
|
||||
const result = filterModelExposureCandidates(pool, {});
|
||||
assert.equal(result, pool, "must return the exact same array reference when both lists are empty");
|
||||
assert.deepEqual(result, [DENIED, KEPT]);
|
||||
});
|
||||
|
||||
test("null/undefined settings also return the pool UNCHANGED", () => {
|
||||
const pool = [DENIED, KEPT];
|
||||
assert.equal(filterModelExposureCandidates(pool, null), pool);
|
||||
assert.equal(filterModelExposureCandidates(pool, undefined), pool);
|
||||
});
|
||||
|
||||
test("denylist drops the denied candidate, keeps the rest", () => {
|
||||
const result = filterModelExposureCandidates([DENIED, KEPT], {
|
||||
modelVisibilityDenylist: ["openai/gpt-4o"],
|
||||
});
|
||||
assert.deepEqual(result, [KEPT], "openai/gpt-4o must be excluded; anthropic candidate kept");
|
||||
});
|
||||
|
||||
test("a denied model is NOT selectable in the candidate pool even with an all-denied pool", () => {
|
||||
const result = filterModelExposureCandidates([DENIED, { provider: "openai", model: "o3" }], {
|
||||
modelVisibilityDenylist: ["openai/*"],
|
||||
});
|
||||
assert.deepEqual(result, [], "an all-denied pool becomes empty — the graceful empty-pool path");
|
||||
});
|
||||
|
||||
test("allowlist restricts the pool to exactly the listed candidates", () => {
|
||||
const result = filterModelExposureCandidates([DENIED, KEPT], {
|
||||
modelVisibilityAllowlist: ["anthropic/claude-opus-5"],
|
||||
});
|
||||
assert.deepEqual(result, [KEPT]);
|
||||
});
|
||||
|
||||
test("preserves extra candidate fields on kept entries", () => {
|
||||
const enriched = { provider: "anthropic", model: "claude-opus-5", connectionId: "abc", extra: 1 };
|
||||
const result = filterModelExposureCandidates([enriched, DENIED], {
|
||||
modelVisibilityDenylist: ["openai/gpt-4o"],
|
||||
});
|
||||
assert.deepEqual(result, [enriched], "generic <T> filter must not strip candidate fields");
|
||||
});
|
||||
63
tests/unit/model-exposure-list-settings-schema.test.ts
Normal file
63
tests/unit/model-exposure-list-settings-schema.test.ts
Normal file
@@ -0,0 +1,63 @@
|
||||
/**
|
||||
* #11481 — `modelVisibilityAllowlist`/`modelVisibilityDenylist` settings PATCH
|
||||
* schema validation. Mirrors hide-paid-models-settings-schema.test.ts.
|
||||
*/
|
||||
import test from "node:test";
|
||||
import assert from "node:assert/strict";
|
||||
|
||||
import { updateSettingsSchema } from "../../src/shared/validation/settingsSchemas.ts";
|
||||
|
||||
test("modelVisibilityAllowlist/Denylist are accepted and preserved by the settings PATCH schema", () => {
|
||||
const validation = updateSettingsSchema.safeParse({
|
||||
modelVisibilityAllowlist: ["openai/gpt-4o-mini"],
|
||||
modelVisibilityDenylist: ["openai/gpt-4o", "anthropic/*"],
|
||||
});
|
||||
|
||||
assert.equal(validation.success, true);
|
||||
if (!validation.success) return;
|
||||
assert.deepEqual(validation.data.modelVisibilityAllowlist, ["openai/gpt-4o-mini"]);
|
||||
assert.deepEqual(validation.data.modelVisibilityDenylist, ["openai/gpt-4o", "anthropic/*"]);
|
||||
});
|
||||
|
||||
test("modelVisibilityAllowlist/Denylist default to undefined when not provided", () => {
|
||||
const validation = updateSettingsSchema.safeParse({});
|
||||
|
||||
assert.equal(validation.success, true);
|
||||
if (!validation.success) return;
|
||||
assert.equal(validation.data.modelVisibilityAllowlist, undefined);
|
||||
assert.equal(validation.data.modelVisibilityDenylist, undefined);
|
||||
});
|
||||
|
||||
test("empty arrays (explicit opt-out / reset) are accepted", () => {
|
||||
const validation = updateSettingsSchema.safeParse({
|
||||
modelVisibilityAllowlist: [],
|
||||
modelVisibilityDenylist: [],
|
||||
});
|
||||
|
||||
assert.equal(validation.success, true);
|
||||
});
|
||||
|
||||
test("rejects non-array values", () => {
|
||||
assert.equal(
|
||||
updateSettingsSchema.safeParse({ modelVisibilityDenylist: "openai/gpt-4o" }).success,
|
||||
false
|
||||
);
|
||||
assert.equal(
|
||||
updateSettingsSchema.safeParse({ modelVisibilityAllowlist: true }).success,
|
||||
false
|
||||
);
|
||||
});
|
||||
|
||||
test("rejects a non-string array entry", () => {
|
||||
assert.equal(
|
||||
updateSettingsSchema.safeParse({ modelVisibilityDenylist: [123] }).success,
|
||||
false
|
||||
);
|
||||
});
|
||||
|
||||
test("rejects an entry longer than the 200-char cap", () => {
|
||||
assert.equal(
|
||||
updateSettingsSchema.safeParse({ modelVisibilityDenylist: ["x".repeat(201)] }).success,
|
||||
false
|
||||
);
|
||||
});
|
||||
79
tests/unit/model-exposure-list.test.ts
Normal file
79
tests/unit/model-exposure-list.test.ts
Normal file
@@ -0,0 +1,79 @@
|
||||
/**
|
||||
* #11481 — explicit model exposure allow/deny list for `/v1/models` (and, via the
|
||||
* mirror in `open-sse/services/autoCombo/modelExposureFilter.ts`, the `auto/*`
|
||||
* candidate pool). Follows the `hidePaidModels`/`hideAutoCombos` opt-in shape
|
||||
* (`src/lib/db/settings.ts`): default-off, two independent string arrays.
|
||||
*
|
||||
* Tests the pure predicate `isModelExposureAllowed()` — the single chokepoint
|
||||
* both `catalog.ts` and the combo-pool mirror call into.
|
||||
*/
|
||||
import { test } from "node:test";
|
||||
import assert from "node:assert/strict";
|
||||
import { isModelExposureAllowed } from "@/shared/utils/modelExposureList";
|
||||
|
||||
test("default off (both lists empty) — every model is exposed", () => {
|
||||
assert.equal(isModelExposureAllowed("openai", "gpt-4o", {}), true);
|
||||
assert.equal(
|
||||
isModelExposureAllowed("openai", "gpt-4o", {
|
||||
modelVisibilityAllowlist: [],
|
||||
modelVisibilityDenylist: [],
|
||||
}),
|
||||
true
|
||||
);
|
||||
assert.equal(isModelExposureAllowed("openai", "gpt-4o", null), true);
|
||||
assert.equal(isModelExposureAllowed("openai", "gpt-4o", undefined), true);
|
||||
});
|
||||
|
||||
test("denylist hides an exact-id match, leaves everything else exposed", () => {
|
||||
const settings = { modelVisibilityDenylist: ["openai/gpt-4o"] };
|
||||
assert.equal(isModelExposureAllowed("openai", "gpt-4o", settings), false);
|
||||
assert.equal(isModelExposureAllowed("openai", "gpt-4o-mini", settings), true);
|
||||
assert.equal(isModelExposureAllowed("anthropic", "claude-opus-5", settings), true);
|
||||
});
|
||||
|
||||
test("denylist also matches the bare model id (no provider prefix required)", () => {
|
||||
const settings = { modelVisibilityDenylist: ["gpt-4o"] };
|
||||
assert.equal(isModelExposureAllowed("openai", "gpt-4o", settings), false);
|
||||
});
|
||||
|
||||
test("allowlist restricts exposure to exactly the listed models", () => {
|
||||
const settings = { modelVisibilityAllowlist: ["openai/gpt-4o-mini", "anthropic/claude-opus-5"] };
|
||||
assert.equal(isModelExposureAllowed("openai", "gpt-4o-mini", settings), true);
|
||||
assert.equal(isModelExposureAllowed("anthropic", "claude-opus-5", settings), true);
|
||||
assert.equal(isModelExposureAllowed("openai", "gpt-4o", settings), false);
|
||||
assert.equal(isModelExposureAllowed("google", "gemini-2.5-pro", settings), false);
|
||||
});
|
||||
|
||||
test("denylist wins over allowlist for the same entry (deny is checked first)", () => {
|
||||
const settings = {
|
||||
modelVisibilityAllowlist: ["openai/gpt-4o"],
|
||||
modelVisibilityDenylist: ["openai/gpt-4o"],
|
||||
};
|
||||
assert.equal(isModelExposureAllowed("openai", "gpt-4o", settings), false);
|
||||
});
|
||||
|
||||
test("glob patterns are supported via the shared globToRegex matcher", () => {
|
||||
const denyGlob = { modelVisibilityDenylist: ["openai/gpt-4*"] };
|
||||
assert.equal(isModelExposureAllowed("openai", "gpt-4o", denyGlob), false);
|
||||
assert.equal(isModelExposureAllowed("openai", "gpt-4.1", denyGlob), false);
|
||||
assert.equal(isModelExposureAllowed("openai", "o1-preview", denyGlob), true);
|
||||
|
||||
const allowGlob = { modelVisibilityAllowlist: ["anthropic/*"] };
|
||||
assert.equal(isModelExposureAllowed("anthropic", "claude-opus-5", allowGlob), true);
|
||||
assert.equal(isModelExposureAllowed("openai", "gpt-4o", allowGlob), false);
|
||||
});
|
||||
|
||||
test("non-array / malformed list values are treated as empty (fail open, never throw)", () => {
|
||||
assert.equal(
|
||||
isModelExposureAllowed("openai", "gpt-4o", {
|
||||
modelVisibilityDenylist: "openai/gpt-4o" as unknown as string[],
|
||||
}),
|
||||
true
|
||||
);
|
||||
assert.equal(
|
||||
isModelExposureAllowed("openai", "gpt-4o", {
|
||||
modelVisibilityDenylist: [123 as unknown as string, " "],
|
||||
}),
|
||||
true
|
||||
);
|
||||
});
|
||||
93
tests/unit/models-catalog-model-exposure-list.test.ts
Normal file
93
tests/unit/models-catalog-model-exposure-list.test.ts
Normal file
@@ -0,0 +1,93 @@
|
||||
/**
|
||||
* #11481 — `modelVisibilityAllowlist`/`modelVisibilityDenylist` filter the
|
||||
* unified `/v1/models` catalog. Mirrors models-catalog-hide-paid.test.ts.
|
||||
* Rule #18 regression guard for the toggle.
|
||||
*/
|
||||
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-model-exposure-"));
|
||||
process.env.DATA_DIR = TEST_DATA_DIR;
|
||||
|
||||
const core = await import("../../src/lib/db/core.ts");
|
||||
const settingsDb = await import("../../src/lib/db/settings.ts");
|
||||
const providersDb = await import("../../src/lib/db/providers.ts");
|
||||
const v1ModelsCatalog = await import("../../src/app/api/v1/models/catalog.ts");
|
||||
|
||||
async function fetchCatalog(): Promise<Array<{ id: string; type?: string }>> {
|
||||
const res = await v1ModelsCatalog.getUnifiedModelsResponse(
|
||||
new Request("http://localhost/api/v1/models", { method: "GET" })
|
||||
);
|
||||
assert.equal(res.status, 200);
|
||||
const body = (await res.json()) as { data: Array<{ id: string; type?: string }> };
|
||||
return body.data;
|
||||
}
|
||||
|
||||
test.after(() => {
|
||||
core.resetDbInstance();
|
||||
try {
|
||||
fs.rmSync(TEST_DATA_DIR, { recursive: true, force: true, maxRetries: 5, retryDelay: 100 });
|
||||
} catch {
|
||||
/* best-effort */
|
||||
}
|
||||
});
|
||||
|
||||
test("default (both lists empty) — catalog is unchanged", async () => {
|
||||
const defaults = await settingsDb.getSettings();
|
||||
assert.deepEqual(defaults.modelVisibilityAllowlist, []);
|
||||
assert.deepEqual(defaults.modelVisibilityDenylist, []);
|
||||
|
||||
await providersDb.createProviderConnection({
|
||||
provider: "openai",
|
||||
authType: "apikey",
|
||||
name: "openai-main-exposure",
|
||||
apiKey: "sk-test",
|
||||
isActive: true,
|
||||
});
|
||||
|
||||
const ids = (await fetchCatalog()).map((m) => m.id);
|
||||
assert.ok(
|
||||
ids.some((id) => /^(openai|oa)\/gpt-4o(-mini)?$/.test(id)),
|
||||
"expected openai chat models present with no exposure list configured"
|
||||
);
|
||||
});
|
||||
|
||||
test("modelVisibilityDenylist hides an exact-id match from /v1/models", async () => {
|
||||
await settingsDb.updateSettings({
|
||||
modelVisibilityAllowlist: [],
|
||||
modelVisibilityDenylist: ["openai/gpt-4o-mini"],
|
||||
});
|
||||
|
||||
const ids = (await fetchCatalog()).map((m) => m.id);
|
||||
assert.ok(!ids.includes("openai/gpt-4o-mini"), "denied model must not appear in the catalog");
|
||||
assert.ok(
|
||||
ids.some((id) => id === "openai/gpt-4o"),
|
||||
"a non-denied sibling model must remain"
|
||||
);
|
||||
|
||||
await settingsDb.updateSettings({ modelVisibilityDenylist: [] });
|
||||
});
|
||||
|
||||
test("modelVisibilityAllowlist restricts the chat catalog to exactly the listed models", async () => {
|
||||
await settingsDb.updateSettings({
|
||||
modelVisibilityDenylist: [],
|
||||
modelVisibilityAllowlist: ["openai/gpt-4o-mini"],
|
||||
});
|
||||
|
||||
const chatIds = (await fetchCatalog())
|
||||
.filter((m) => m.type === undefined || m.type === "chat")
|
||||
.map((m) => m.id)
|
||||
.filter((id) => id.startsWith("openai/") || id.startsWith("oa/"));
|
||||
|
||||
assert.deepEqual(
|
||||
chatIds.filter((id) => id !== "openai/gpt-4o-mini"),
|
||||
[],
|
||||
`only the allow-listed model may remain, found: ${chatIds.join(", ")}`
|
||||
);
|
||||
assert.ok(chatIds.includes("openai/gpt-4o-mini"), "the allow-listed model itself must remain");
|
||||
|
||||
await settingsDb.updateSettings({ modelVisibilityAllowlist: [] });
|
||||
});
|
||||
Reference in New Issue
Block a user