Compare commits

...

2 Commits

Author SHA1 Message Date
Markus Hartung
e51b1661c4 merge #11997 onto updated tip 2026-08-29 15:36:55 -03:00
Markus Hartung
ce9e3af633 feat(api): add explicit model exposure allow/deny list for /v1/models (#11481)
Operators can now curate exactly which models `/v1/models` advertises via two
opt-in string-array settings, `modelVisibilityAllowlist`/`modelVisibilityDenylist`
(default empty = zero behavior change). Entries are exact "provider/model" (or
bare "model") ids, or a glob pattern via the existing shared globToRegex
matcher (src/shared/utils/globPattern.ts) already used by ModelRoutingSection
and freeModels.ts.

Follows the hidePaidModels/hideAutoCombos template (src/lib/db/settings.ts):
- src/shared/utils/modelExposureList.ts: the pure isModelExposureAllowed()
  predicate (deny wins over allow; allow, when non-empty, restricts to it).
- src/app/api/v1/models/catalog.ts: wired at the same 5 per-source chokepoints
  shouldHidePaid() already gates.
- open-sse/services/autoCombo/modelExposureFilter.ts +
  virtualFactory.ts::buildPreparedPool: mandatory mirror into the auto/*
  combo candidate pool, so a denied model can't be routed to via combo
  selection either -- the exact trap #6512 already fixed once for
  hidePaidModels.
- settingsSchemas.ts: Zod-validated (max 500 entries, 200 chars each).

config/quality/file-size-baseline.json: virtualFactory.ts is frozen with zero
headroom; bumped 1138->1144 for the minimal 2-line call-site wiring + import
(all real logic lives in the two new, unfrozen leaf modules).
2026-08-29 05:56:51 -03:00
13 changed files with 523 additions and 1 deletions

View File

@@ -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)

View File

@@ -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,

View 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.

View 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)
);
}

View File

@@ -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 —

View File

@@ -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}`;

View File

@@ -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

View 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);
}

View File

@@ -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

View 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");
});

View 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
);
});

View 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
);
});

View 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: [] });
});