From def5334768dfa827e3b88a1814b231b4499d596a Mon Sep 17 00:00:00 2001
From: Xiangzhe <32761048+xz-dev@users.noreply.github.com>
Date: Tue, 11 Aug 2026 21:29:28 +0800
Subject: [PATCH] feat(api-manager): add provider-level model permissions
(#9313)
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
* feat(api-manager): add provider-level model permissions
Persist canonical provider wildcards alongside exact model grants and
preserve explicit restricted-empty deny-all semantics across API, SQLite,
JSON import, sync, runtime policy, and the dashboard.
Invalidate filtered model catalogs on permission changes and guard against
stale in-flight catalog builders repopulating invalidated cache entries.
* fix(api-manager): show provider and model counts separately in summary
Provider wildcard selections (provider/*) are no longer counted as
individual models in the Selected Models Summary. The header now shows
"N providers · M models" when both are present, or just the non-empty
category when only one type is selected.
* fix(api-manager): separate provider and model permission displays
* fix(api-manager): separate provider wildcard permissions in UI
---
.../api-manager/ApiManagerPageClient.tsx | 450 +++++-----
.../api-manager/apiManagerPageUtils.ts | 161 ++++
.../ProviderModelPermissionList.tsx | 301 +++++++
src/app/api/keys/[id]/route.ts | 3 +
src/app/api/settings/import-json/route.ts | 4 +
src/lib/db/apiKeyColumnFallbacks.ts | 5 +
src/lib/db/apiKeys.ts | 143 ++--
src/lib/db/apiKeys/modelAccessMode.ts | 44 +
src/lib/db/apiKeys/modelPermissions.ts | 5 +-
src/lib/db/apiKeys/permissionsUpdate.ts | 77 ++
src/lib/db/apiKeys/rowParsers.ts | 2 +
src/lib/db/core.ts | 8 +-
src/lib/db/jsonMigration.ts | 12 +-
.../147_api_keys_model_access_mode.sql | 13 +
src/lib/db/readCache.ts | 2 +-
src/lib/sync/bundle.ts | 1 +
src/shared/utils/apiKeyPolicy.ts | 8 +-
src/shared/validation/schemas/keys.ts | 9 +
tests/e2e/api-keys-flow.spec.ts | 323 +++++++
tests/unit/api-manager-page-static.test.ts | 50 +-
.../api-manager-provider-permissions.test.ts | 795 ++++++++++++++++++
...ion-147-api-keys-model-access-mode.test.ts | 134 +++
tests/unit/pick-internal-api-key-6372.test.ts | 27 +-
tests/unit/sync-bundle.test.ts | 8 +-
24 files changed, 2227 insertions(+), 358 deletions(-)
create mode 100644 src/app/(dashboard)/dashboard/api-manager/components/ProviderModelPermissionList.tsx
create mode 100644 src/lib/db/apiKeys/modelAccessMode.ts
create mode 100644 src/lib/db/apiKeys/permissionsUpdate.ts
create mode 100644 src/lib/db/migrations/147_api_keys_model_access_mode.sql
create mode 100644 tests/unit/api-manager-provider-permissions.test.ts
create mode 100644 tests/unit/migration-147-api-keys-model-access-mode.test.ts
diff --git a/src/app/(dashboard)/dashboard/api-manager/ApiManagerPageClient.tsx b/src/app/(dashboard)/dashboard/api-manager/ApiManagerPageClient.tsx
index 4ecc840ecf..ddcb22f911 100644
--- a/src/app/(dashboard)/dashboard/api-manager/ApiManagerPageClient.tsx
+++ b/src/app/(dashboard)/dashboard/api-manager/ApiManagerPageClient.tsx
@@ -12,9 +12,12 @@ import {
isKeyActive,
isExpired,
isRestricted as isKeyRestricted,
+ buildModelAccessSavePayload,
classifyKeyStatus,
computeApiKeyCounts,
+ formatProviderModelPermissionSummary,
formatUsdCost,
+ restoreProviderScopeSelection,
toLocalDateTimeInputValue,
toggleKeyVisibility,
} from "./apiManagerPageUtils";
@@ -28,6 +31,7 @@ import { UsageLimitSettings } from "./components/UsageLimitSettings";
import { ChaosModeAccessToggle } from "./components/ChaosModeAccessToggle";
import { BypassProviderQuotaToggle } from "./components/BypassProviderQuotaToggle";
import { ApiKeyCompressionToggle } from "./components/ApiKeyCompressionToggle";
+import ProviderModelPermissionList from "./components/ProviderModelPermissionList";
import ReasoningRoutingRules from "@/shared/components/ReasoningRoutingRules";
// Constants for validation
@@ -112,6 +116,8 @@ interface ApiKey {
name: string;
key: string;
allowedModels: string[] | null;
+ /** Public shape: "all" | "restricted". Absent on legacy keys. */
+ modelAccessMode?: "all" | "restricted" | null;
blockedModels?: string[] | null;
allowedCombos: string[] | null;
allowedConnections: string[] | null;
@@ -799,7 +805,8 @@ export default function ApiManagerPageClient() {
dailyUsageLimitUsd: number | null,
weeklyUsageLimitUsd: number | null,
blockedModels: string[],
- chaosModeEnabled: boolean
+ chaosModeEnabled: boolean,
+ modelAccessMode: "all" | "restricted"
) => {
if (!editingKey || !editingKey.id) return;
@@ -849,6 +856,7 @@ export default function ApiManagerPageClient() {
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
name: sanitizedName,
+ modelAccessMode,
allowedModels: validModels,
blockedModels: validBlockedModels,
allowedCombos: validCombos,
@@ -1038,7 +1046,15 @@ export default function ApiManagerPageClient() {
(() => {
const renderKeyRow = (key: ApiKey) => {
const stats = usageStats[key.id];
- const isRestricted = Array.isArray(key.allowedModels) && key.allowedModels.length > 0;
+ const isRestricted = isKeyRestricted(key);
+ const isModelRestricted =
+ key.modelAccessMode === "restricted" ||
+ (Array.isArray(key.allowedModels) && key.allowedModels.length > 0);
+ const { providerWildcards, exactModels } = restoreProviderScopeSelection(
+ Array.isArray(key.allowedModels) ? key.allowedModels : []
+ );
+ const providerCount = providerWildcards.length;
+ const modelCount = exactModels.length;
const hasComboRestrictions =
Array.isArray(key.allowedCombos) && key.allowedCombos.length > 0;
const hasConnectionRestrictions =
@@ -1138,13 +1154,13 @@ export default function ApiManagerPageClient() {
)}
{/* Existing badges */}
- {isRestricted ? (
+ {isModelRestricted ? (
) : (
@@ -2626,9 +2653,7 @@ const PermissionsModal = memo(function PermissionsModal({
{!allowAll && selectedCount > 0 && (
-
- {t("selectedCount", { count: selectedCount })}
-
+
{selectedPermissionSummary}
-
- {orderedSelectedModels.map((modelId) => {
- if (modelId === CLAUDE_CODE_DEFAULT_MODEL_ID) {
- return (
-
-
+ {selectedProviderCount > 0 && (
+
+
+ {tc("providers")}
+
+
+ {orderedSelectedProviderScopes.map((scope) => {
+ if (scope === CLAUDE_CODE_DEFAULT_MODEL_ID) {
+ return (
+
+
+
+
+
+
+ {claudeCodeFamiliesExpanded && (
+
+
+
+ {visibleClaudeCodeFamilies.map((family) => {
+ const canBlock = family.id !== "other";
+ return (
+
+ {family.label}
+ {canBlock && (
+
+ )}
+
+ );
+ })}
+
+ )}
+
+ );
+ }
+
+ const provider = scope.slice(0, -2);
+ return (
+
+ {provider}
-
-
- {claudeCodeFamiliesExpanded && (
-
-
-
- {visibleClaudeCodeFamilies.map((family) => {
- const canBlock = family.id !== "other";
- return (
-
- {family.label}
- {canBlock && (
-
- )}
-
- );
- })}
-
- )}
-
- );
- }
-
- return (
-
-
- {getModelDisplayName(modelId)}
-
-
+
+ )}
+ {selectedModelCount > 0 && (
+
+
+ {tc("models")}
+
+
+ {selectedExactModels.map((modelId) => (
+
- close
-
-
- );
- })}
-
+
+ {getModelDisplayName(modelId)}
+
+
+
+ ))}
+
+
+ )}
)}
{/* Search and Model Selection (only in restrict mode) */}
{!allowAll && (
- <>
-
- onSearchChange(e.target.value)}
- placeholder={t("searchModels")}
- icon="search"
- />
- {searchModel && (
-
- )}
-
-
-
- {modelsByProvider.length === 0 ? (
-
-
search_off
-
{t("noModelsFound")}
-
- ) : (
- modelsByProvider.map(([provider, models]) => {
- const selectedInProvider = selectedModels.filter((m) =>
- models.some((model) => model.id === m)
- ).length;
- const allSelected = models.every((m) => selectedModels.includes(m.id));
- const someSelected = selectedInProvider > 0 && !allSelected;
-
- return (
-
-
-
- {/* Expandable model list */}
- {expandedProviders.has(provider) && (
-
-
- {models.map((model) => {
- const isSelected = selectedModels.includes(model.id);
- return (
-
- );
- })}
-
-
- )}
-
- );
- })
- )}
-
- >
+
)}
{/* Allowed Connections Section */}
diff --git a/src/app/(dashboard)/dashboard/api-manager/apiManagerPageUtils.ts b/src/app/(dashboard)/dashboard/api-manager/apiManagerPageUtils.ts
index 39474592f0..7c89d9460b 100644
--- a/src/app/(dashboard)/dashboard/api-manager/apiManagerPageUtils.ts
+++ b/src/app/(dashboard)/dashboard/api-manager/apiManagerPageUtils.ts
@@ -12,6 +12,8 @@ export interface ApiKeyShape {
scopes?: string[];
allowedModels?: string[] | null;
allowedConnections?: string[] | null;
+ /** Public shape: "all" | "restricted". Absent on legacy keys. */
+ modelAccessMode?: "all" | "restricted" | null;
}
export function isKeyActive(k: ApiKeyShape): boolean {
@@ -31,6 +33,10 @@ export function isExpired(k: ApiKeyShape): boolean {
}
export function isRestricted(k: ApiKeyShape): boolean {
+ // Explicit restricted mode classifies as restricted even with an empty
+ // allow-list (restricted + zero selections means deny-all). Legacy keys
+ // without a mode keep the historical "empty means allow all" semantics.
+ if (k.modelAccessMode === "restricted") return true;
const hasModelRestrictions = Array.isArray(k.allowedModels) && k.allowedModels.length > 0;
const hasConnectionRestrictions =
Array.isArray(k.allowedConnections) && k.allowedConnections.length > 0;
@@ -125,3 +131,158 @@ export function toggleKeyVisibility(prev: Set, keyId: string): Set m.id === entry);
+ const fallback = options?.providerId ?? options?.ownedBy;
+ return model?.owned_by ?? model?.providerId ?? entry.split("/")[0] ?? fallback ?? entry;
+}
+
+/**
+ * Resolve the canonical owner id of a catalog model. Prefers the catalog
+ * `owned_by` (alias-prefixed ids map to their canonical owner), then the
+ * provided provider fallback, then the model-id prefix.
+ */
+function resolveModelProvider(modelId: string, options?: ProviderScopeOptions): string {
+ const model = options?.providerModels?.find((m) => m.id === modelId);
+ return (
+ model?.owned_by ??
+ model?.providerId ??
+ options?.ownedBy ??
+ options?.providerId ??
+ modelId.split("/")[0]
+ );
+}
+
+/**
+ * Toggle a canonical `provider/*` scope on/off inside a selection list.
+ *
+ * Selecting: removes every currently known exact entry owned by the provider
+ * (exact id-prefix children, alias-prefixed catalog children and the raw
+ * wildcard alike), then appends the canonical wildcard. Unknown/offline rules
+ * and exact selections from other providers are preserved.
+ * Deselecting: removes only that provider's wildcard, preserving everything else.
+ */
+export function toggleProviderWildcardSelection(
+ selected: string[],
+ providerId: string,
+ options?: ProviderScopeOptions
+): string[] {
+ const wildcard = `${providerId}/*`;
+ if (selected.includes(wildcard)) {
+ return selected.filter((entry) => entry !== wildcard);
+ }
+ const kept = selected.filter(
+ (entry) =>
+ providerWildcardId(entry) !== null || resolveEntryProvider(entry, options) !== providerId
+ );
+ return [...kept, wildcard];
+}
+
+/**
+ * Whether a model is covered by a selected `provider/*` scope. Ownership is
+ * resolved via the canonical owner (catalog `owned_by` / provided fallback),
+ * not the model-id prefix, so alias-prefixed catalog ids still inherit.
+ */
+export function isModelInheritedByProviderScope(
+ selected: string[],
+ modelId: string,
+ options?: ProviderScopeOptions
+): boolean {
+ const owner = resolveModelProvider(modelId, options);
+ return owner != null && selected.includes(`${owner}/*`);
+}
+
+/**
+ * Whether a model may be toggled individually. Models inherited through a
+ * selected provider scope are disabled/non-toggleable; manual exact models
+ * from other providers stay toggleable.
+ */
+export function isModelIndividuallyToggleable(
+ selected: string[],
+ modelId: string,
+ options?: ProviderScopeOptions
+): boolean {
+ return !isModelInheritedByProviderScope(selected, modelId, options);
+}
+
+/**
+ * Split persisted allowedModels back into provider wildcards and exact model
+ * entries so reopening the modal restores provider-level selections.
+ * `providerModels` is accepted for forward-compatible owner normalization but
+ * is not required: wildcard entries are already canonical.
+ */
+export function restoreProviderScopeSelection(
+ allowedModels: string[],
+ _options?: { providerModels?: ProviderModelRef[] }
+): { providerWildcards: string[]; exactModels: string[] } {
+ void _options;
+ const providerWildcards: string[] = [];
+ const exactModels: string[] = [];
+ for (const entry of allowedModels) {
+ if (providerWildcardId(entry) !== null) providerWildcards.push(entry);
+ else exactModels.push(entry);
+ }
+ return { providerWildcards, exactModels };
+}
+
+export function formatProviderModelPermissionSummary(
+ providerCount: number,
+ modelCount: number,
+ t: (key: string, values?: Record) => string,
+ tc: (key: string) => string
+): string {
+ const providerLabel =
+ providerCount > 0
+ ? `${providerCount} ${tc(providerCount === 1 ? "provider" : "providers")}`
+ : "";
+ const modelLabel = modelCount > 0 ? t("modelsCount", { count: modelCount }) : "";
+ return (
+ [providerLabel, modelLabel].filter(Boolean).join(" · ") || t("selectedCount", { count: 0 })
+ );
+}
+
+/**
+ * Persisted payload for the model access section. Allow All always saves an
+ * empty allow-list with mode "all"; Restrict (including zero selections)
+ * saves the exact selection with mode "restricted".
+ */
+export function buildModelAccessSavePayload(input: {
+ allowAll: boolean;
+ selectedModels: string[];
+}): { modelAccessMode: "all" | "restricted"; allowedModels: string[] } {
+ if (input.allowAll) return { modelAccessMode: "all", allowedModels: [] };
+ return { modelAccessMode: "restricted", allowedModels: input.selectedModels };
+}
diff --git a/src/app/(dashboard)/dashboard/api-manager/components/ProviderModelPermissionList.tsx b/src/app/(dashboard)/dashboard/api-manager/components/ProviderModelPermissionList.tsx
new file mode 100644
index 0000000000..0924238c15
--- /dev/null
+++ b/src/app/(dashboard)/dashboard/api-manager/components/ProviderModelPermissionList.tsx
@@ -0,0 +1,301 @@
+"use client";
+
+import { memo, useCallback, useEffect, useMemo, useRef } from "react";
+import { useTranslations } from "next-intl";
+import { getProviderDisplayName } from "@/lib/display/names";
+import { Input } from "@/shared/components";
+import {
+ isModelIndividuallyToggleable,
+ restoreProviderScopeSelection,
+ toggleProviderWildcardSelection,
+} from "../apiManagerPageUtils";
+import type { ProviderModelRef } from "../apiManagerPageUtils";
+
+export interface ProviderPermissionModel {
+ id: string;
+ owned_by: string;
+ name?: string;
+}
+
+/** Tuple type for models grouped by provider display label. */
+export type ProviderGroup = [provider: string, models: ProviderPermissionModel[]];
+
+interface ProviderModelPermissionListProps {
+ /** Search-filtered groups to render. */
+ modelsByProvider: ProviderGroup[];
+ /** Unfiltered catalog (provider selection spans the full provider, not just filtered rows). */
+ allModels: ProviderPermissionModel[];
+ selectedModels: string[];
+ expandedProviders: Set;
+ searchModel: string;
+ onSearchChange: (value: string) => void;
+ onToggleExpand: (provider: string) => void;
+ onSelectionChange: (next: string[]) => void;
+ getModelDisplayName: (modelId: string) => string;
+ onClaudeCodeDefaultDeselected?: () => void;
+}
+
+/**
+ * Resolve the single canonical provider owner for a visible group, or null
+ * when the group merges multiple owners (e.g. shared display label) or is
+ * combo-owned — those groups keep the legacy snapshot-selection behavior.
+ */
+function resolveGroupProviderOwner(models: ProviderPermissionModel[]): string | null {
+ let owner: string | null = null;
+ for (const model of models) {
+ const modelOwner = model.owned_by;
+ if (!modelOwner || modelOwner === "combo") return null;
+ if (owner === null) owner = modelOwner;
+ else if (modelOwner !== owner) return null;
+ }
+ return owner;
+}
+
+function getModelRef(model: ProviderPermissionModel): ProviderModelRef {
+ return { id: model.id, owned_by: model.owned_by };
+}
+
+function ProviderSelectionCheckbox({
+ provider,
+ checked,
+ indeterminate,
+ onChange,
+}: {
+ provider: string;
+ checked: boolean;
+ indeterminate: boolean;
+ onChange: () => void;
+}) {
+ const inputRef = useRef(null);
+ useEffect(() => {
+ if (inputRef.current) inputRef.current.indeterminate = indeterminate;
+ }, [indeterminate]);
+
+ return (
+
+ );
+}
+
+const ProviderModelPermissionList = memo(function ProviderModelPermissionList({
+ modelsByProvider,
+ allModels,
+ selectedModels,
+ expandedProviders,
+ searchModel,
+ onSearchChange,
+ onToggleExpand,
+ onSelectionChange,
+ getModelDisplayName,
+ onClaudeCodeDefaultDeselected,
+}: ProviderModelPermissionListProps) {
+ const t = useTranslations("apiManager");
+
+ const providerModelRefs = useMemo(() => allModels.map(getModelRef), [allModels]);
+
+ const allModelsByOwner = useMemo(() => {
+ const grouped = new Map();
+ for (const model of allModels) {
+ const list = grouped.get(model.owned_by);
+ const ref = getModelRef(model);
+ if (list) list.push(ref);
+ else grouped.set(model.owned_by, [ref]);
+ }
+ return grouped;
+ }, [allModels]);
+
+ const { providerWildcards } = useMemo(
+ () => restoreProviderScopeSelection(selectedModels),
+ [selectedModels]
+ );
+ const selectedProviderScopes = useMemo(() => new Set(providerWildcards), [providerWildcards]);
+
+ const groupOwners = useMemo(
+ () =>
+ new Map(
+ modelsByProvider.map(([provider, models]) => [provider, resolveGroupProviderOwner(models)])
+ ),
+ [modelsByProvider]
+ );
+
+ const handleProviderCheckedChange = useCallback(
+ (provider: string) => {
+ const owner = groupOwners.get(provider);
+ if (!owner) return;
+ const next = toggleProviderWildcardSelection(selectedModels, owner, {
+ providerId: owner,
+ providerModels: allModelsByOwner.get(owner) ?? providerModelRefs,
+ });
+ onSelectionChange(next);
+ },
+ [groupOwners, selectedModels, allModelsByOwner, providerModelRefs, onSelectionChange]
+ );
+
+ const handleSnapshotToggleProvider = useCallback(
+ (provider: string, models: ProviderPermissionModel[]) => {
+ // Legacy behavior for groups without a single real owner: toggle the
+ // exact ids across the full unfiltered catalog for that display label,
+ // not just the currently search-filtered subset.
+ const ownerModels = allModels.filter(
+ (model) => (getProviderDisplayName(model.owned_by) || model.owned_by) === provider
+ );
+ const modelIds = (ownerModels.length > 0 ? ownerModels : models).map((model) => model.id);
+ const allSelected = modelIds.every((id) => selectedModels.includes(id));
+ if (allSelected) {
+ onSelectionChange(selectedModels.filter((id) => !modelIds.includes(id)));
+ } else {
+ onSelectionChange([...new Set([...selectedModels, ...modelIds])]);
+ }
+ },
+ [allModels, selectedModels, onSelectionChange]
+ );
+
+ const handleToggleModel = useCallback(
+ (model: ProviderPermissionModel) => {
+ if (!isModelIndividuallyToggleable(selectedModels, model.id, { ownedBy: model.owned_by }))
+ return;
+ if (selectedModels.includes(model.id)) {
+ if (onClaudeCodeDefaultDeselected) onClaudeCodeDefaultDeselected();
+ onSelectionChange(selectedModels.filter((id) => id !== model.id));
+ } else {
+ onSelectionChange([...selectedModels, model.id]);
+ }
+ },
+ [selectedModels, onSelectionChange, onClaudeCodeDefaultDeselected]
+ );
+
+ return (
+ <>
+
+ onSearchChange(e.target.value)}
+ placeholder={t("searchModels")}
+ icon="search"
+ />
+ {searchModel && (
+
+ )}
+
+
+
+ {modelsByProvider.length === 0 ? (
+
+
search_off
+
{t("noModelsFound")}
+
+ ) : (
+ modelsByProvider.map(([provider, models]) => {
+ const owner = groupOwners.get(provider) ?? null;
+ const useProviderScope = owner !== null;
+ const providerScopeSelected = useProviderScope
+ ? selectedProviderScopes.has(`${owner}/*`)
+ : false;
+ const selectedInProvider = selectedModels.filter((m) =>
+ models.some((model) => model.id === m)
+ ).length;
+ const allSelected = useProviderScope
+ ? providerScopeSelected
+ : models.every((m) => selectedModels.includes(m.id));
+ const someSelected = !allSelected && (selectedInProvider > 0 || providerScopeSelected);
+ const selectedCount = useProviderScope ? models.length : selectedInProvider;
+
+ return (
+
+
+
+
+
{
+ if (useProviderScope) handleProviderCheckedChange(provider);
+ else handleSnapshotToggleProvider(provider, models);
+ }}
+ />
+
+
+ {models.length}
+
+
+ {selectedCount > 0 && (
+
+ {selectedCount}
+
+ )}
+
+
+ {/* Expandable model list */}
+ {expandedProviders.has(provider) && (
+
+
+ {models.map((model) => {
+ const isSelected =
+ providerScopeSelected || selectedModels.includes(model.id);
+ const isToggleable = isModelIndividuallyToggleable(
+ selectedModels,
+ model.id,
+ { ownedBy: model.owned_by }
+ );
+ return (
+
+ );
+ })}
+
+
+ )}
+
+ );
+ })
+ )}
+
+ >
+ );
+});
+
+export default ProviderModelPermissionList;
diff --git a/src/app/api/keys/[id]/route.ts b/src/app/api/keys/[id]/route.ts
index 5b8283553d..541a294f04 100644
--- a/src/app/api/keys/[id]/route.ts
+++ b/src/app/api/keys/[id]/route.ts
@@ -65,6 +65,7 @@ export async function PATCH(request, { params }) {
}
const {
name,
+ modelAccessMode,
allowedModels,
blockedModels,
allowedCombos,
@@ -93,6 +94,7 @@ export async function PATCH(request, { params }) {
const payload: Parameters[1] = {};
if (name !== undefined) payload.name = name;
+ if (modelAccessMode !== undefined) payload.modelAccessMode = modelAccessMode;
if (allowedModels !== undefined) payload.allowedModels = allowedModels;
if (blockedModels !== undefined) payload.blockedModels = blockedModels;
if (allowedCombos !== undefined) payload.allowedCombos = allowedCombos;
@@ -130,6 +132,7 @@ export async function PATCH(request, { params }) {
return NextResponse.json({
message: "API key settings updated successfully",
...(name !== undefined && { name }),
+ ...(modelAccessMode !== undefined && { modelAccessMode }),
...(allowedModels !== undefined && { allowedModels }),
...(blockedModels !== undefined && { blockedModels }),
...(allowedCombos !== undefined && { allowedCombos }),
diff --git a/src/app/api/settings/import-json/route.ts b/src/app/api/settings/import-json/route.ts
index da32da8ddd..a08423dc8a 100644
--- a/src/app/api/settings/import-json/route.ts
+++ b/src/app/api/settings/import-json/route.ts
@@ -1,6 +1,8 @@
import { NextResponse } from "next/server";
import { getDbInstance } from "@/lib/db/core";
import { backupDbFile } from "@/lib/db/backup";
+import { clearApiKeyCaches } from "@/lib/db/apiKeys";
+import { invalidateDbCache } from "@/lib/db/readCache";
import { isAuthRequired, isAuthenticated } from "@/shared/utils/apiAuth";
import { runJsonMigration, type LegacyJsonData } from "@/lib/db/jsonMigration";
import { getSettings } from "@/lib/db/settings";
@@ -67,6 +69,8 @@ export async function POST(request: Request) {
// Delegate the actual migration to the shared helper (avoids duplication with core.ts)
const counts = runJsonMigration(db, data);
+ clearApiKeyCaches();
+ invalidateDbCache();
// Re-hydrate the in-memory Global System Prompt config — the migration writes it to
// the DB but the in-memory state would stay stale until a restart otherwise (#2470).
diff --git a/src/lib/db/apiKeyColumnFallbacks.ts b/src/lib/db/apiKeyColumnFallbacks.ts
index af23f89908..a550198322 100644
--- a/src/lib/db/apiKeyColumnFallbacks.ts
+++ b/src/lib/db/apiKeyColumnFallbacks.ts
@@ -1,5 +1,10 @@
export const API_KEY_COLUMN_FALLBACKS = [
{ name: "allowed_models", definition: "allowed_models TEXT" },
+ {
+ name: "model_access_mode",
+ definition:
+ "model_access_mode TEXT NOT NULL DEFAULT 'all' CHECK (model_access_mode IN ('all', 'restricted'))",
+ },
{ name: "blocked_models", definition: "blocked_models TEXT" },
{ name: "allowed_combos", definition: "allowed_combos TEXT" },
{ name: "no_log", definition: "no_log INTEGER NOT NULL DEFAULT 0" },
diff --git a/src/lib/db/apiKeys.ts b/src/lib/db/apiKeys.ts
index 5869216933..5129df0a7e 100644
--- a/src/lib/db/apiKeys.ts
+++ b/src/lib/db/apiKeys.ts
@@ -49,6 +49,7 @@ import {
parseCacheDefaultMode,
parseChaosModeEnabled,
parseCompressionEnabled,
+ parseModelAccessMode,
} from "./apiKeys/rowParsers";
import {
clearModelPermissionCache,
@@ -56,6 +57,11 @@ import {
setCachedModelPermission,
evictModelPermissionCache,
} from "./apiKeys/modelPermissionCache";
+import type { ModelAccessMode } from "./apiKeys/modelAccessMode";
+import {
+ normalizeApiKeyPermissionsUpdate,
+ type ApiKeyPermissionsUpdate,
+} from "./apiKeys/permissionsUpdate";
import { getModelCatalogCacheVersion, invalidateModelCatalogCache } from "./readCache";
import type { AccessSchedule, RateLimitRule } from "./apiKeys/types";
@@ -77,6 +83,7 @@ interface ApiKeyMetadata {
id: string;
name: string;
machineId: string | null;
+ modelAccessMode: ModelAccessMode;
allowedModels: string[];
blockedModels: string[];
allowedCombos: string[];
@@ -118,6 +125,8 @@ interface ApiKeyRow extends JsonRecord {
machineId?: unknown;
allowed_models?: unknown;
allowedModels?: unknown;
+ model_access_mode?: unknown;
+ modelAccessMode?: unknown;
blocked_models?: unknown;
blockedModels?: unknown;
allowed_combos?: unknown;
@@ -177,6 +186,7 @@ interface ApiKeysStatements {
interface ApiKeyView extends JsonRecord {
id?: string;
+ modelAccessMode: ModelAccessMode;
allowedModels: string[];
blockedModels: string[];
allowedCombos: string[];
@@ -409,7 +419,7 @@ function getPreparedStatements(db: ApiKeysDbLike): ApiKeysStatements {
"SELECT id, expires_at, revoked_at, is_active, is_banned FROM api_keys WHERE key = ? OR key_hash = ?",
);
_stmtGetKeyMetadata = db.prepare(
- "SELECT id, name, machine_id, allowed_models, blocked_models, allowed_combos, allowed_connections, allowed_quotas, no_log, auto_resolve, is_active, access_schedule, max_requests_per_day, max_requests_per_minute, throttle_delay_ms, max_sessions, revoked_at, expires_at, ip_allowlist, scopes, rate_limits, is_banned, key_hash, allowed_endpoints, stream_default_mode, cache_default_mode, disable_non_public_models, allow_usage_command, usage_limit_enabled, daily_usage_limit_usd, weekly_usage_limit_usd, chaos_mode_enabled, compression_enabled, proxy_id FROM api_keys WHERE key = ? OR key_hash = ?",
+ "SELECT id, name, machine_id, model_access_mode, allowed_models, blocked_models, allowed_combos, allowed_connections, allowed_quotas, no_log, auto_resolve, is_active, access_schedule, max_requests_per_day, max_requests_per_minute, throttle_delay_ms, max_sessions, revoked_at, expires_at, ip_allowlist, scopes, rate_limits, is_banned, key_hash, allowed_endpoints, stream_default_mode, cache_default_mode, disable_non_public_models, allow_usage_command, usage_limit_enabled, daily_usage_limit_usd, weekly_usage_limit_usd, chaos_mode_enabled, compression_enabled, proxy_id FROM api_keys WHERE key = ? OR key_hash = ?",
);
_stmtInsertKey = db.prepare(
"INSERT INTO api_keys (id, name, key, machine_id, allowed_models, no_log, created_at, key_prefix, key_hash, scopes) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)",
@@ -450,6 +460,10 @@ export async function getApiKeys(limit?: number, offset?: number) {
}
return rows.map((row) => {
const camelRow = toRecord(rowToCamel(row)) as ApiKeyView;
+ camelRow.modelAccessMode = parseModelAccessMode(
+ camelRow.modelAccessMode,
+ camelRow.allowedModels
+ );
camelRow.allowedModels = parseAllowedModels(camelRow.allowedModels);
camelRow.blockedModels = parseAllowedModels(camelRow.blockedModels);
camelRow.allowedCombos = parseAllowedCombos(camelRow.allowedCombos);
@@ -521,6 +535,7 @@ export async function pickApiKeyForInternalUse(
revokedAt?: string | null;
isBanned?: boolean;
scopes?: string[];
+ modelAccessMode?: ModelAccessMode;
allowedModels?: string[];
lastUsedAt?: string | number | null;
}>;
@@ -536,7 +551,11 @@ export async function pickApiKeyForInternalUse(
// 2. Allow-all key (empty allowedModels means no model restrictions).
const allowAllKey = keys.find(
- (k) => isUsable(k) && Array.isArray(k.allowedModels) && k.allowedModels.length === 0,
+ (k) =>
+ isUsable(k) &&
+ k.modelAccessMode !== "restricted" &&
+ Array.isArray(k.allowedModels) &&
+ k.allowedModels.length === 0
);
if (allowAllKey?.key) return allowAllKey.key;
@@ -564,6 +583,7 @@ export async function getApiKeyById(id: string) {
const row = stmt.getKeyById.get(id);
if (!row) return null;
const camelRow = toRecord(rowToCamel(row)) as ApiKeyView;
+ camelRow.modelAccessMode = parseModelAccessMode(camelRow.modelAccessMode, camelRow.allowedModels);
camelRow.allowedModels = parseAllowedModels(camelRow.allowedModels);
camelRow.blockedModels = parseAllowedModels(camelRow.blockedModels);
camelRow.allowedCombos = parseAllowedCombos(camelRow.allowedCombos);
@@ -620,6 +640,7 @@ export async function createApiKey(name: string, machineId: string, scopes: stri
name: name,
key: result.key,
machineId: machineId,
+ modelAccessMode: "all" as const,
allowedModels: [], // Empty array means all models allowed
allowedCombos: [], // Empty array means no explicit combo restriction
allowedConnections: [], // Empty array means all connections allowed
@@ -683,85 +704,24 @@ export async function regenerateApiKey(id: string) {
export async function updateApiKeyPermissions(
id: string,
- update:
- | string[]
- | {
- name?: string;
- allowedModels?: string[];
- blockedModels?: string[];
- allowedCombos?: string[];
- allowedConnections?: string[];
- allowedQuotas?: string[];
- noLog?: boolean;
- autoResolve?: boolean;
- isActive?: boolean;
- accessSchedule?: AccessSchedule | null;
- maxRequestsPerDay?: number | null;
- maxRequestsPerMinute?: number | null;
- throttleDelayMs?: number | null;
- rateLimits?: RateLimitRule[] | null;
- isBanned?: boolean;
- expiresAt?: string | null;
- // T08: max concurrent sessions for this key (0 = unlimited)
- maxSessions?: number | null;
- scopes?: string[] | null;
- proxyId?: string | null;
- allowedEndpoints?: string[] | null;
- streamDefaultMode?: "legacy" | "json" | null;
- cacheDefaultMode?: "legacy" | "bypass" | null;
- disableNonPublicModels?: boolean;
- allowUsageCommand?: boolean;
- usageLimitEnabled?: boolean;
- dailyUsageLimitUsd?: number | null;
- weeklyUsageLimitUsd?: number | null;
- chaosModeEnabled?: boolean;
- compressionEnabled?: boolean;
- },
+ update: string[] | ApiKeyPermissionsUpdate,
) {
const db = getDbInstance() as ApiKeysDbLike;
getPreparedStatements(db);
- const normalized =
- Array.isArray(update) || update === undefined
- ? { allowedModels: update || [] }
- : {
- name: update.name,
- allowedModels: update.allowedModels,
- blockedModels: update.blockedModels,
- allowedCombos: update.allowedCombos,
- allowedConnections: update.allowedConnections,
- allowedQuotas: (update as { allowedQuotas?: string[] }).allowedQuotas,
- noLog: update.noLog,
- autoResolve: update.autoResolve,
- isActive: update.isActive,
- accessSchedule: update.accessSchedule,
- maxRequestsPerDay: update.maxRequestsPerDay,
- maxRequestsPerMinute: update.maxRequestsPerMinute,
- throttleDelayMs: update.throttleDelayMs,
- rateLimits: update.rateLimits,
- isBanned: update.isBanned,
- expiresAt: update.expiresAt,
- maxSessions: (update as { maxSessions?: number | null }).maxSessions,
- scopes: (update as { scopes?: string[] | null }).scopes,
- proxyId: (update as { proxyId?: string | null }).proxyId,
- allowedEndpoints: (update as { allowedEndpoints?: string[] | null }).allowedEndpoints,
- streamDefaultMode: (update as { streamDefaultMode?: "legacy" | "json" | null })
- .streamDefaultMode,
- cacheDefaultMode: (update as { cacheDefaultMode?: "legacy" | "bypass" | null })
- .cacheDefaultMode,
- disableNonPublicModels: (update as { disableNonPublicModels?: boolean })
- .disableNonPublicModels,
- allowUsageCommand: (update as { allowUsageCommand?: boolean }).allowUsageCommand,
- usageLimitEnabled: (update as { usageLimitEnabled?: boolean }).usageLimitEnabled,
- dailyUsageLimitUsd: (update as { dailyUsageLimitUsd?: number | null }).dailyUsageLimitUsd,
- weeklyUsageLimitUsd: (update as { weeklyUsageLimitUsd?: number | null })
- .weeklyUsageLimitUsd,
- chaosModeEnabled: (update as { chaosModeEnabled?: boolean }).chaosModeEnabled,
- compressionEnabled: (update as { compressionEnabled?: boolean }).compressionEnabled,
- };
+ const normalized = normalizeApiKeyPermissionsUpdate(update);
+ const shouldInvalidateModelCatalog =
+ normalized.modelAccessMode !== undefined ||
+ normalized.allowedModels !== undefined ||
+ normalized.blockedModels !== undefined ||
+ normalized.allowedCombos !== undefined ||
+ normalized.allowedConnections !== undefined ||
+ normalized.allowedQuotas !== undefined ||
+ normalized.disableNonPublicModels !== undefined;
if (
normalized.name === undefined &&
+ normalized.modelAccessMode === undefined &&
normalized.allowedModels === undefined &&
normalized.blockedModels === undefined &&
normalized.allowedCombos === undefined &&
@@ -796,6 +756,7 @@ export async function updateApiKeyPermissions(
const params: {
id: string;
name?: string;
+ modelAccessMode?: ModelAccessMode;
allowedModels?: string;
blockedModels?: string;
allowedCombos?: string;
@@ -830,10 +791,13 @@ export async function updateApiKeyPermissions(
params.name = normalized.name;
}
+ if (normalized.modelAccessMode !== undefined) {
+ updates.push("model_access_mode = @modelAccessMode");
+ params.modelAccessMode = normalized.modelAccessMode;
+ }
if (normalized.allowedModels !== undefined) {
- // Empty array means all models are allowed
updates.push("allowed_models = @allowedModels");
- params.allowedModels = JSON.stringify(normalized.allowedModels || []);
+ params.allowedModels = JSON.stringify(normalized.allowedModels);
}
if (normalized.blockedModels !== undefined) {
@@ -1028,16 +992,6 @@ export async function updateApiKeyPermissions(
if (changedRows === 0) return false;
- const invalidatesModelCatalogCache =
- normalized.allowedModels !== undefined ||
- normalized.blockedModels !== undefined ||
- allowedQuotasUpdate !== undefined ||
- normalized.disableNonPublicModels !== undefined;
-
- if (invalidatesModelCatalogCache) {
- invalidateModelCatalogCache();
- }
-
const { logAuditEvent } = await import("@/lib/compliance");
if (normalized.isBanned !== undefined) {
@@ -1090,8 +1044,9 @@ export async function updateApiKeyPermissions(
setNoLog(id, normalized.noLog);
}
- // Invalidate caches since permissions changed
+ // Invalidate per-key policy and filtered model-catalog caches after the atomic write.
invalidateCaches();
+ if (shouldInvalidateModelCatalog) invalidateModelCatalogCache();
await deleteRedisAuthCacheForKeyId(db, id);
@@ -1311,6 +1266,7 @@ export async function getApiKeyMetadata(
id: "env-key",
name: "Environment Key",
machineId: "server-env",
+ modelAccessMode: "all",
allowedModels: [],
blockedModels: [],
allowedCombos: [],
@@ -1370,11 +1326,16 @@ export async function getApiKeyMetadata(
const rawMaxSessions = record.max_sessions ?? record.maxSessions;
+ const rawAllowedModels = record.allowed_models ?? record.allowedModels;
const metadata: ApiKeyMetadata = {
id: metadataId,
name: metadataName,
machineId: metadataMachineId,
- allowedModels: parseAllowedModels(record.allowed_models ?? record.allowedModels),
+ modelAccessMode: parseModelAccessMode(
+ record.model_access_mode ?? record.modelAccessMode,
+ rawAllowedModels
+ ),
+ allowedModels: parseAllowedModels(rawAllowedModels),
blockedModels: parseAllowedModels(record.blocked_models ?? record.blockedModels),
allowedCombos: parseAllowedCombos(record.allowed_combos ?? record.allowedCombos),
allowedConnections: parseAllowedConnections(
@@ -1471,7 +1432,7 @@ export async function isModelAllowedForKey(
// SECURITY: Key not found in database = deny access (invalid/non-existent key)
if (!metadata) return false;
- const { allowedModels, blockedModels, disableNonPublicModels } = metadata;
+ const { modelAccessMode, allowedModels, blockedModels, disableNonPublicModels } = metadata;
const modelPermissionCandidates = await getModelPermissionCandidates(modelId);
// Deny-list patterns win over any allow-list entry. This lets operators keep
@@ -1506,6 +1467,10 @@ export async function isModelAllowedForKey(
}
}
+ // Only explicit allow-all permits an empty list; restricted + [] is deny-all.
+ if (!allowedModels || allowedModels.length === 0) {
+ return modelAccessMode !== "restricted";
+ }
// Support exact match and prefix match (e.g., "openai/*" allows all OpenAI models)
let allowed =
!allowedModels ||
diff --git a/src/lib/db/apiKeys/modelAccessMode.ts b/src/lib/db/apiKeys/modelAccessMode.ts
new file mode 100644
index 0000000000..7b982f751a
--- /dev/null
+++ b/src/lib/db/apiKeys/modelAccessMode.ts
@@ -0,0 +1,44 @@
+export type ModelAccessMode = "all" | "restricted";
+
+function hasLegacyModelRestriction(value: unknown): boolean {
+ if (Array.isArray(value)) return value.length > 0;
+ if (typeof value !== "string") return false;
+
+ const trimmed = value.trim();
+ if (trimmed === "" || trimmed === "[]" || trimmed === "null") return false;
+
+ try {
+ const parsed: unknown = JSON.parse(trimmed);
+ return Array.isArray(parsed) ? parsed.length > 0 : parsed !== null;
+ } catch {
+ return true;
+ }
+}
+
+/** Non-empty legacy allow-lists always remain restrictive, even beside a bad `all` mode. */
+export function parseModelAccessMode(value: unknown, rawAllowedModels?: unknown): ModelAccessMode {
+ if (hasLegacyModelRestriction(rawAllowedModels)) return "restricted";
+ return value === "restricted" ? "restricted" : "all";
+}
+
+export function normalizeModelAccessUpdate(
+ modelAccessMode: ModelAccessMode | undefined,
+ allowedModels: string[] | undefined
+): { modelAccessMode?: ModelAccessMode; allowedModels?: string[] } {
+ if (modelAccessMode === "all") {
+ return { modelAccessMode: "all", allowedModels: [] };
+ }
+ if (modelAccessMode === "restricted") {
+ return {
+ modelAccessMode: "restricted",
+ ...(allowedModels !== undefined && { allowedModels }),
+ };
+ }
+ if (allowedModels !== undefined) {
+ return {
+ modelAccessMode: allowedModels.length > 0 ? "restricted" : "all",
+ allowedModels,
+ };
+ }
+ return {};
+}
diff --git a/src/lib/db/apiKeys/modelPermissions.ts b/src/lib/db/apiKeys/modelPermissions.ts
index 9d5605a92a..ee1780fbd7 100644
--- a/src/lib/db/apiKeys/modelPermissions.ts
+++ b/src/lib/db/apiKeys/modelPermissions.ts
@@ -67,9 +67,8 @@ export function modelPatternMatches(pattern: string, candidates: string[]): bool
if (pattern === candidate) return true;
if (pattern.endsWith("/*")) {
const prefix = pattern.slice(0, -2);
- if (candidate.startsWith(prefix + "/") || candidate.startsWith(prefix)) {
- return true;
- }
+ if (candidate.startsWith(prefix + "/")) return true;
+ continue;
}
if (pattern.includes("*") && matchesWildcardPattern(pattern, candidate)) {
return true;
diff --git a/src/lib/db/apiKeys/permissionsUpdate.ts b/src/lib/db/apiKeys/permissionsUpdate.ts
new file mode 100644
index 0000000000..98e4799aa0
--- /dev/null
+++ b/src/lib/db/apiKeys/permissionsUpdate.ts
@@ -0,0 +1,77 @@
+import type { AccessSchedule, RateLimitRule } from "./types";
+import { normalizeModelAccessUpdate, type ModelAccessMode } from "./modelAccessMode";
+
+export interface ApiKeyPermissionsUpdate {
+ name?: string;
+ modelAccessMode?: ModelAccessMode;
+ allowedModels?: string[];
+ blockedModels?: string[];
+ allowedCombos?: string[];
+ allowedConnections?: string[];
+ allowedQuotas?: string[];
+ noLog?: boolean;
+ autoResolve?: boolean;
+ isActive?: boolean;
+ accessSchedule?: AccessSchedule | null;
+ maxRequestsPerDay?: number | null;
+ maxRequestsPerMinute?: number | null;
+ throttleDelayMs?: number | null;
+ rateLimits?: RateLimitRule[] | null;
+ isBanned?: boolean;
+ expiresAt?: string | null;
+ maxSessions?: number | null;
+ scopes?: string[] | null;
+ proxyId?: string | null;
+ allowedEndpoints?: string[] | null;
+ streamDefaultMode?: "legacy" | "json" | null;
+ cacheDefaultMode?: "legacy" | "bypass" | null;
+ disableNonPublicModels?: boolean;
+ allowUsageCommand?: boolean;
+ usageLimitEnabled?: boolean;
+ dailyUsageLimitUsd?: number | null;
+ weeklyUsageLimitUsd?: number | null;
+ chaosModeEnabled?: boolean;
+ compressionEnabled?: boolean;
+}
+
+export function normalizeApiKeyPermissionsUpdate(
+ update: string[] | ApiKeyPermissionsUpdate | undefined
+): ApiKeyPermissionsUpdate {
+ if (update === undefined) {
+ return normalizeModelAccessUpdate(undefined, []);
+ }
+ if (Array.isArray(update)) {
+ return normalizeModelAccessUpdate(undefined, update);
+ }
+ return {
+ name: update.name,
+ ...normalizeModelAccessUpdate(update.modelAccessMode, update.allowedModels),
+ blockedModels: update.blockedModels,
+ allowedCombos: update.allowedCombos,
+ allowedConnections: update.allowedConnections,
+ allowedQuotas: update.allowedQuotas,
+ noLog: update.noLog,
+ autoResolve: update.autoResolve,
+ isActive: update.isActive,
+ accessSchedule: update.accessSchedule,
+ maxRequestsPerDay: update.maxRequestsPerDay,
+ maxRequestsPerMinute: update.maxRequestsPerMinute,
+ throttleDelayMs: update.throttleDelayMs,
+ rateLimits: update.rateLimits,
+ isBanned: update.isBanned,
+ expiresAt: update.expiresAt,
+ maxSessions: update.maxSessions,
+ scopes: update.scopes,
+ proxyId: update.proxyId,
+ allowedEndpoints: update.allowedEndpoints,
+ streamDefaultMode: update.streamDefaultMode,
+ cacheDefaultMode: update.cacheDefaultMode,
+ disableNonPublicModels: update.disableNonPublicModels,
+ allowUsageCommand: update.allowUsageCommand,
+ usageLimitEnabled: update.usageLimitEnabled,
+ dailyUsageLimitUsd: update.dailyUsageLimitUsd,
+ weeklyUsageLimitUsd: update.weeklyUsageLimitUsd,
+ chaosModeEnabled: update.chaosModeEnabled,
+ compressionEnabled: update.compressionEnabled,
+ };
+}
diff --git a/src/lib/db/apiKeys/rowParsers.ts b/src/lib/db/apiKeys/rowParsers.ts
index 9f43787421..86be2bd155 100644
--- a/src/lib/db/apiKeys/rowParsers.ts
+++ b/src/lib/db/apiKeys/rowParsers.ts
@@ -9,6 +9,8 @@
*/
import type { AccessSchedule, RateLimitRule } from "./types";
+export { parseModelAccessMode } from "./modelAccessMode";
+export type { ModelAccessMode } from "./modelAccessMode";
/**
* Helper function to safely parse allowed_models JSON
diff --git a/src/lib/db/core.ts b/src/lib/db/core.ts
index e2c29be2dd..202d76be9f 100644
--- a/src/lib/db/core.ts
+++ b/src/lib/db/core.ts
@@ -38,6 +38,7 @@ import { migrateLegacyEncryptedString } from "./encryption";
import { invalidateDbCache } from "./readCache";
import { rowToCamel } from "./caseMapping";
import { isAutomatedTestProcess } from "@/shared/utils/testProcess";
+import { parseModelAccessMode } from "./apiKeys/modelAccessMode";
// Re-exported so existing call sites that pull these helpers off the core module keep working.
export { toSnakeCase, toCamelCase, objToSnake, rowToCamel, cleanNulls } from "./caseMapping";
import {
@@ -1580,11 +1581,9 @@ function migrateFromJson(db: SqliteDatabase, jsonPath: string) {
updatedAt: normalizedCombo.updatedAt || new Date().toISOString(),
});
}
-
- // 5. API Keys
const insertKey = db.prepare(`
- INSERT OR REPLACE INTO api_keys (id, name, key, machine_id, allowed_models, no_log, created_at)
- VALUES (@id, @name, @key, @machineId, @allowedModels, @noLog, @createdAt)
+ INSERT OR REPLACE INTO api_keys (id, name, key, machine_id, model_access_mode, allowed_models, no_log, created_at)
+ VALUES (@id, @name, @key, @machineId, @modelAccessMode, @allowedModels, @noLog, @createdAt)
`);
for (const apiKey of data.apiKeys || []) {
insertKey.run({
@@ -1592,6 +1591,7 @@ function migrateFromJson(db: SqliteDatabase, jsonPath: string) {
name: apiKey.name,
key: apiKey.key,
machineId: apiKey.machineId || null,
+ modelAccessMode: parseModelAccessMode(apiKey.modelAccessMode, apiKey.allowedModels),
allowedModels: JSON.stringify(apiKey.allowedModels || []),
noLog: apiKey.noLog ? 1 : 0,
createdAt: apiKey.createdAt || new Date().toISOString(),
diff --git a/src/lib/db/jsonMigration.ts b/src/lib/db/jsonMigration.ts
index 89bad747ed..320de5f6b8 100644
--- a/src/lib/db/jsonMigration.ts
+++ b/src/lib/db/jsonMigration.ts
@@ -15,6 +15,7 @@ import type { SqliteAdapter } from "./adapters/types";
import { normalizeRoutingStrategy } from "@/shared/constants/routingStrategies";
import { normalizeComboRecord } from "@/lib/combos/steps";
import { validateComboInvariant } from "@/lib/combos/invariants";
+import { parseModelAccessMode } from "./apiKeys/modelAccessMode";
import {
resolveImportedUsageAccountIdentity,
resolveOrphanedUsageAccountIdentity,
@@ -99,8 +100,11 @@ export function runJsonMigration(
`);
const insertKey = db.prepare(`
- INSERT OR REPLACE INTO api_keys (id, name, key, machine_id, allowed_models, no_log, created_at)
- VALUES (@id, @name, @key, @machineId, @allowedModels, @noLog, @createdAt)
+ INSERT OR REPLACE INTO api_keys (
+ id, name, key, machine_id, model_access_mode, allowed_models, no_log, created_at
+ ) VALUES (
+ @id, @name, @key, @machineId, @modelAccessMode, @allowedModels, @noLog, @createdAt
+ )
`);
const migrate = db.transaction(() => {
@@ -219,12 +223,14 @@ export function runJsonMigration(
// 6. API Keys
for (const apiKey of data.apiKeys ?? []) {
+ const allowedModels = Array.isArray(apiKey.allowedModels) ? apiKey.allowedModels : [];
insertKey.run({
id: apiKey.id,
name: apiKey.name,
key: apiKey.key,
machineId: apiKey.machineId ?? null,
- allowedModels: JSON.stringify(apiKey.allowedModels ?? []),
+ modelAccessMode: parseModelAccessMode(apiKey.modelAccessMode, allowedModels),
+ allowedModels: JSON.stringify(allowedModels),
noLog: apiKey.noLog ? 1 : 0,
createdAt: apiKey.createdAt ?? new Date().toISOString(),
});
diff --git a/src/lib/db/migrations/147_api_keys_model_access_mode.sql b/src/lib/db/migrations/147_api_keys_model_access_mode.sql
new file mode 100644
index 0000000000..af86ca5a73
--- /dev/null
+++ b/src/lib/db/migrations/147_api_keys_model_access_mode.sql
@@ -0,0 +1,13 @@
+ALTER TABLE api_keys
+ADD COLUMN model_access_mode TEXT NOT NULL DEFAULT 'all'
+CHECK (model_access_mode IN ('all', 'restricted'));
+
+UPDATE api_keys
+SET model_access_mode = CASE
+ WHEN allowed_models IS NULL OR trim(allowed_models) = '' THEN 'all'
+ WHEN json_valid(allowed_models) = 1 AND (
+ json_type(allowed_models) = 'null'
+ OR (json_type(allowed_models) = 'array' AND json_array_length(allowed_models) = 0)
+ ) THEN 'all'
+ ELSE 'restricted'
+END;
diff --git a/src/lib/db/readCache.ts b/src/lib/db/readCache.ts
index 936428c5d2..0c87392da1 100644
--- a/src/lib/db/readCache.ts
+++ b/src/lib/db/readCache.ts
@@ -294,5 +294,5 @@ export function invalidateDbCache(
// Settings/connections/combos all feed the unified model catalog builder
// (blockedProviders + hidePaidModels, provider connections + excludedModels,
// combo definitions, respectively) — pricing does too, via isFreeModel().
- modelCatalogCacheVersion++;
+ invalidateModelCatalogCache();
}
diff --git a/src/lib/sync/bundle.ts b/src/lib/sync/bundle.ts
index a9220e7637..3f13edf8fa 100644
--- a/src/lib/sync/bundle.ts
+++ b/src/lib/sync/bundle.ts
@@ -118,6 +118,7 @@ function sanitizeApiKeyForSync(apiKey: unknown): JsonRecord {
"name",
"key",
"machineId",
+ "modelAccessMode",
"allowedModels",
"allowedCombos",
"allowedConnections",
diff --git a/src/shared/utils/apiKeyPolicy.ts b/src/shared/utils/apiKeyPolicy.ts
index c1183452a0..99ca41c192 100644
--- a/src/shared/utils/apiKeyPolicy.ts
+++ b/src/shared/utils/apiKeyPolicy.ts
@@ -73,6 +73,7 @@ interface AccessSchedule {
export interface ApiKeyMetadata {
id: string;
name?: string;
+ modelAccessMode?: "all" | "restricted";
allowedModels?: string[];
allowedCombos?: string[];
allowedConnections?: string[];
@@ -319,7 +320,8 @@ async function validateStandardRoutingTarget(
}
const hasModelRestrictions =
- (apiKeyInfo.allowedModels && apiKeyInfo.allowedModels.length > 0) ||
+ apiKeyInfo.modelAccessMode === "restricted" ||
+ Boolean(apiKeyInfo.allowedModels?.length) ||
apiKeyInfo.disableNonPublicModels === true;
if (!requestedComboName && hasModelRestrictions && modelStr.startsWith("auto/")) {
requestedComboName = modelStr;
@@ -525,7 +527,9 @@ async function validateModelAccess(context: PolicyContext): Promise {
+ if (value.modelAccessMode === "all" && value.allowedModels && value.allowedModels.length > 0) {
+ ctx.addIssue({
+ code: z.ZodIssueCode.custom,
+ message: "allowedModels must be empty when modelAccessMode is 'all'",
+ path: ["allowedModels"],
+ });
+ }
if (
value.name === undefined &&
+ value.modelAccessMode === undefined &&
value.allowedModels === undefined &&
value.allowedCombos === undefined &&
value.allowedConnections === undefined &&
diff --git a/tests/e2e/api-keys-flow.spec.ts b/tests/e2e/api-keys-flow.spec.ts
index 9ec5c5bcfc..d69863520f 100644
--- a/tests/e2e/api-keys-flow.spec.ts
+++ b/tests/e2e/api-keys-flow.spec.ts
@@ -11,6 +11,8 @@ type ApiKeyRecord = {
fullKey: string;
allowedModels: string[] | null;
allowedConnections: string[] | null;
+ /** Public shape: "all" | "restricted". Absent on legacy keys. */
+ modelAccessMode?: "all" | "restricted" | null;
createdAt: string;
};
@@ -640,4 +642,325 @@ test.describe("API keys flow", () => {
await expect(permissionsDialog).not.toBeVisible({ timeout: UI_STABILITY_TIMEOUT_MS });
await expect(page.getByText("Renamed Key")).toBeVisible();
});
+
+ test("Restrict mode provider scope: selects ollama-cloud/* dynamically plus exact OpenAI model", async ({
+ page,
+ }) => {
+ // R4 acceptance: selecting the Ollama Cloud provider must PATCH canonical
+ // ollama-cloud/* (not alias-prefixed snapshot IDs), with modelAccessMode restricted;
+ // reopening restores the provider checkbox and leaves children non-toggleable.
+ const state: {
+ keys: ApiKeyRecord[];
+ nextId: number;
+ patchPayloads: Array>;
+ } = {
+ keys: [],
+ nextId: 1,
+ patchPayloads: [],
+ };
+
+ const catalogModels = [
+ {
+ id: "ollamacloud/llama3",
+ owned_by: "ollama-cloud",
+ name: "llama3",
+ },
+ {
+ id: "ollamacloud/qwen",
+ owned_by: "ollama-cloud",
+ name: "qwen",
+ },
+ {
+ id: "openai/gpt-4.1",
+ owned_by: "openai",
+ name: "gpt-4.1",
+ },
+ ];
+
+ // Override catalog fetches in-page. page.route alone can miss the early client
+ // catalog load against a reused Playwright server; this keeps the fixture deterministic.
+ // Note: init-script body is browser-serialized — keep it plain JS (no TS types).
+ await page.addInitScript((models) => {
+ const originalFetch = window.fetch.bind(window);
+ window.fetch = async (input, init) => {
+ const raw =
+ typeof input === "string" ? input : input instanceof URL ? input.href : input.url;
+ const path = String(raw).replace(/^https?:\/\/[^/]+/, "");
+
+ if (path.startsWith("/v1/models")) {
+ return new Response(JSON.stringify({ data: models }), {
+ status: 200,
+ headers: { "Content-Type": "application/json" },
+ });
+ }
+
+ if (path.startsWith("/api/models")) {
+ return new Response(
+ JSON.stringify({
+ models: models.map((m) => ({
+ provider: m.owned_by,
+ model: m.id.split("/").slice(1).join("/"),
+ fullModel: m.id,
+ alias: m.name,
+ })),
+ }),
+ {
+ status: 200,
+ headers: { "Content-Type": "application/json" },
+ }
+ );
+ }
+
+ if (path.startsWith("/api/combos")) {
+ return new Response(JSON.stringify({ combos: [] }), {
+ status: 200,
+ headers: { "Content-Type": "application/json" },
+ });
+ }
+
+ return originalFetch(input, init);
+ };
+ }, catalogModels);
+
+ // Defense in depth: also stub network for non-init-script navigations.
+ await page.route(/\/v1\/models(?:\?|$)/, async (route) => {
+ await fulfillJson(route, { data: catalogModels });
+ });
+ await page.route(/\/api\/models(?:\?|$)/, async (route) => {
+ await fulfillJson(route, {
+ models: catalogModels.map((m) => ({
+ provider: m.owned_by,
+ model: m.id.split("/").slice(1).join("/"),
+ fullModel: m.id,
+ alias: m.name,
+ })),
+ });
+ });
+ await page.route(/\/api\/combos(?:\?|$)/, async (route) => {
+ await fulfillJson(route, { combos: [] });
+ });
+
+ await page.route("**/api/settings", async (route) => {
+ await fulfillJson(route, {});
+ });
+
+ await page.route("**/api/providers", async (route) => {
+ await fulfillJson(route, {
+ connections: [
+ {
+ id: "conn-ollama-cloud",
+ name: "Ollama Cloud",
+ provider: "ollama-cloud",
+ isActive: true,
+ },
+ { id: "conn-openai", name: "OpenAI Main", provider: "openai", isActive: true },
+ ],
+ });
+ });
+
+ await page.route(/\/api\/usage\/call-logs(?:\?.*)?$/, async (route) => {
+ await fulfillJson(route, []);
+ });
+
+ await page.route("**/api/sessions", async (route) => {
+ await fulfillJson(route, { byApiKey: {} });
+ });
+
+ await page.route(/\/api\/keys\/[^/]+$/, async (route) => {
+ if (route.request().method() === "PATCH") {
+ const keyId = route.request().url().split("/").pop() || "";
+ const payload = (await route.request().postDataJSON()) as {
+ name?: string;
+ allowedModels?: string[];
+ modelAccessMode?: "all" | "restricted";
+ };
+ state.patchPayloads.push(payload);
+ const record = state.keys.find((key) => key.id === keyId);
+ if (!record) {
+ await fulfillJson(route, { error: "Key not found" }, 404);
+ return;
+ }
+ if (payload.name) record.name = payload.name;
+ if (Array.isArray(payload.allowedModels)) {
+ record.allowedModels = payload.allowedModels;
+ }
+ if (payload.modelAccessMode === "all" || payload.modelAccessMode === "restricted") {
+ record.modelAccessMode = payload.modelAccessMode;
+ }
+ await fulfillJson(route, {
+ message: "API key settings updated successfully",
+ ...payload,
+ });
+ return;
+ }
+
+ await fulfillJson(route, { error: "Method not allowed" }, 405);
+ });
+
+ await page.route("**/api/keys", async (route) => {
+ const method = route.request().method();
+
+ if (method === "GET") {
+ await fulfillJson(route, {
+ keys: state.keys.map(({ fullKey, ...record }) => record),
+ allowKeyReveal: true,
+ });
+ return;
+ }
+
+ if (method === "POST") {
+ const payload = (route.request().postDataJSON() as { name?: string }) || {};
+ const id = `key-${state.nextId++}`;
+ const suffix = String(1000 + state.nextId);
+ const fullKey = `sk-live-${suffix}-demo-secret`;
+ const maskedKey = `sk-live-****${suffix}`;
+ state.keys.push({
+ id,
+ name: payload.name || "New Key",
+ key: maskedKey,
+ fullKey,
+ allowedModels: null,
+ allowedConnections: null,
+ modelAccessMode: null,
+ createdAt: new Date("2026-04-05T20:00:00.000Z").toISOString(),
+ });
+ await fulfillJson(route, { key: fullKey, id });
+ return;
+ }
+
+ await fulfillJson(route, { error: "Method not allowed" }, 405);
+ });
+
+ await gotoDashboardRoute(page, "/dashboard/api-manager", {
+ timeoutMs: NAVIGATION_TIMEOUT_MS,
+ });
+ await waitForPageToSettle(page);
+ await waitForNextDevCompileToFinish(page);
+
+ const createFirstKeyButton = page.getByRole("button", {
+ name: /create (your )?first key/i,
+ });
+ await expect(createFirstKeyButton).toBeVisible({ timeout: UI_STABILITY_TIMEOUT_MS });
+ await createFirstKeyButton.click();
+
+ const createDialog = page.getByRole("dialog", { name: /create api key/i });
+ await expect(createDialog).toBeVisible({ timeout: UI_STABILITY_TIMEOUT_MS });
+ await createDialog.locator("input").first().fill("Provider Scope Key");
+ await createDialog.getByRole("button", { name: /create api key/i }).click({ force: true });
+
+ const createdDialog = page.getByRole("dialog", { name: /api key created/i });
+ await createdDialog.getByRole("button", { name: /done/i }).click();
+ await waitForPageToSettle(page);
+ await waitForNextDevCompileToFinish(page);
+ await expect(page.getByText("Provider Scope Key")).toBeVisible({
+ timeout: UI_STABILITY_TIMEOUT_MS,
+ });
+
+ const keyRow = page
+ .locator("div")
+ .filter({ has: page.getByText("Provider Scope Key", { exact: true }) })
+ .first();
+ await keyRow.locator('button[title="Edit permissions"]').click({ force: true });
+
+ const permissionsDialog = page.getByRole("dialog", {
+ name: /permissions: provider scope key/i,
+ });
+ await expect(permissionsDialog).toBeVisible({ timeout: UI_STABILITY_TIMEOUT_MS });
+
+ // Enter Restrict mode via the primary Access Mode toggle (accessible name includes the lock icon text).
+ const accessModeRestrict = permissionsDialog.getByRole("button", {
+ name: /^lock\s+restrict$/i,
+ });
+ await expect(accessModeRestrict).toBeVisible({ timeout: UI_STABILITY_TIMEOUT_MS });
+ await accessModeRestrict.click();
+
+ // Wait for the mocked catalog to render (search box appears only in Restrict mode).
+ const modelSearch = permissionsDialog.getByPlaceholder(/search models/i);
+ await expect(modelSearch).toBeVisible({ timeout: UI_STABILITY_TIMEOUT_MS });
+ await expect(permissionsDialog.getByText(/ollama[- ]?cloud/i).first()).toBeVisible({
+ timeout: UI_STABILITY_TIMEOUT_MS,
+ });
+
+ const ollamaProviderCheckbox = permissionsDialog.getByRole("checkbox", {
+ name: /ollama[- ]?cloud/i,
+ });
+ await expect(ollamaProviderCheckbox).toBeVisible({ timeout: UI_STABILITY_TIMEOUT_MS });
+ await ollamaProviderCheckbox.check();
+
+ // Exact OpenAI model selection coexists with the provider scope.
+ await modelSearch.fill("openai/gpt-4.1");
+ const openaiModelButton = permissionsDialog.getByRole("button", {
+ name: "openai/gpt-4.1",
+ });
+ await expect(openaiModelButton).toBeVisible({ timeout: UI_STABILITY_TIMEOUT_MS });
+ await openaiModelButton.click();
+ await modelSearch.fill("");
+
+ const saveButton = permissionsDialog.getByRole("button", { name: /save permissions/i });
+ await saveButton.click();
+
+ await expect.poll(() => state.patchPayloads.length).toBeGreaterThan(0);
+ const patch = state.patchPayloads[state.patchPayloads.length - 1]!;
+ const allowedModels = Array.isArray(patch.allowedModels)
+ ? (patch.allowedModels as string[])
+ : [];
+
+ expect(patch.modelAccessMode).toBe("restricted");
+ expect(allowedModels).toEqual(expect.arrayContaining(["ollama-cloud/*", "openai/gpt-4.1"]));
+ expect(allowedModels).toContain("ollama-cloud/*");
+ expect(allowedModels).toContain("openai/gpt-4.1");
+ // Must not snapshot current Ollama catalog IDs (including alias-prefixed ones).
+ expect(allowedModels).not.toContain("ollamacloud/llama3");
+ expect(allowedModels).not.toContain("ollamacloud/qwen");
+ expect(allowedModels).not.toContain("ollama-cloud/llama3");
+ expect(allowedModels).not.toContain("ollama-cloud/qwen");
+
+ await expect(permissionsDialog).not.toBeVisible({ timeout: UI_STABILITY_TIMEOUT_MS });
+
+ // Reopen after state/refetch: provider scope restored; children inherited/non-toggleable.
+ await keyRow.locator('button[title="Edit permissions"]').click({ force: true });
+ const reopened = page.getByRole("dialog", {
+ name: /permissions: provider scope key/i,
+ });
+ await expect(reopened).toBeVisible({ timeout: UI_STABILITY_TIMEOUT_MS });
+
+ // Restrict mode should remain selected after reopen.
+ await expect(reopened.getByRole("button", { name: /^lock\s+restrict$/i })).toHaveClass(
+ /bg-primary/
+ );
+
+ const reopenedProviderCheckbox = reopened.getByRole("checkbox", {
+ name: /ollama[- ]?cloud/i,
+ });
+ await expect(reopenedProviderCheckbox).toBeChecked();
+
+ // Child models under the provider scope are inherited and not individually toggleable.
+ for (const childId of ["ollamacloud/llama3", "ollamacloud/qwen"]) {
+ const child = reopened.getByRole("button", { name: childId });
+ await expect(child).toBeVisible({ timeout: UI_STABILITY_TIMEOUT_MS });
+ await expect(child).toBeDisabled();
+ }
+
+ // Exact OpenAI selection remains individually selected/toggleable.
+ const reopenedOpenAiModel = reopened.getByRole("button", { name: "openai/gpt-4.1" });
+ await expect(reopenedOpenAiModel).toBeEnabled();
+
+ // Explicit Restrict with no provider/model selections is a persistent deny-all state.
+ await reopenedProviderCheckbox.uncheck();
+ await reopenedOpenAiModel.click();
+ await reopened.getByRole("button", { name: /save permissions/i }).click();
+ await expect.poll(() => state.patchPayloads.length).toBeGreaterThan(1);
+ const denyAllPatch = state.patchPayloads[state.patchPayloads.length - 1]!;
+ expect(denyAllPatch.modelAccessMode).toBe("restricted");
+ expect(denyAllPatch.allowedModels).toEqual([]);
+
+ await keyRow.locator('button[title="Edit permissions"]').click({ force: true });
+ const denyAllReopened = page.getByRole("dialog", {
+ name: /permissions: provider scope key/i,
+ });
+ await expect(denyAllReopened).toBeVisible({ timeout: UI_STABILITY_TIMEOUT_MS });
+ await expect(denyAllReopened.getByRole("button", { name: /^lock\s+restrict$/i })).toHaveClass(
+ /bg-primary/
+ );
+ });
});
diff --git a/tests/unit/api-manager-page-static.test.ts b/tests/unit/api-manager-page-static.test.ts
index 166e2e42cc..005bf3c6e4 100644
--- a/tests/unit/api-manager-page-static.test.ts
+++ b/tests/unit/api-manager-page-static.test.ts
@@ -9,6 +9,10 @@ const pagePath = path.join(
repoRoot,
"src/app/(dashboard)/dashboard/api-manager/ApiManagerPageClient.tsx"
);
+const providerModelPermissionListPath = path.join(
+ repoRoot,
+ "src/app/(dashboard)/dashboard/api-manager/components/ProviderModelPermissionList.tsx"
+);
const messagesDir = path.join(repoRoot, "src/i18n/messages");
const selfServiceScopeMessageKeys = [
@@ -107,11 +111,12 @@ test("permissions modal persists the per-key prompt-compression switch", () => {
test("permissions modal exposes Claude Code default wildcard model", () => {
const source = readApiManagerPage();
+ const modelListSource = fs.readFileSync(providerModelPermissionListPath, "utf8");
assert.match(source, /const CLAUDE_CODE_DEFAULT_MODEL_ID = "cc\/\*";/);
assert.match(source, /const CLAUDE_CODE_DEFAULT_MODEL_NAME = "Claude Code default";/);
assert.match(source, /withClaudeCodeDefaultModel\(allModels\)/);
- assert.match(source, /getModelDisplayName\(model\.id\)/);
+ assert.match(modelListSource, /getModelDisplayName\(model\.id\)/);
assert.match(
source,
/modelId === CLAUDE_CODE_DEFAULT_MODEL_ID\s+\?\s+CLAUDE_CODE_DEFAULT_MODEL_NAME\s+:\s+modelId/
@@ -128,7 +133,7 @@ test("permissions modal expands Claude Code default families in selected models
assert.match(source, /id: "opus",\s+label: "opus"/);
assert.match(source, /id: "sonnet",\s+label: "sonnet"/);
assert.match(source, /id: "haiku",\s+label: "haiku"/);
- assert.match(source, /const orderedSelectedModels = useMemo/);
+ assert.match(source, /const orderedSelectedProviderScopes = useMemo/);
assert.match(source, /modelId === CLAUDE_CODE_DEFAULT_MODEL_ID/);
assert.match(source, /setClaudeCodeFamiliesExpanded/);
assert.match(
@@ -151,7 +156,10 @@ test("API-key model fallback preserves combo pseudo-models", () => {
const source = readApiManagerPage();
const fallbackBlock = source.slice(
source.indexOf("const [fallbackRes, combosRes] = await Promise.all"),
- source.indexOf("} catch (error)", source.indexOf("const [fallbackRes, combosRes] = await Promise.all"))
+ source.indexOf(
+ "} catch (error)",
+ source.indexOf("const [fallbackRes, combosRes] = await Promise.all")
+ )
);
assert.match(fallbackBlock, /fetch\("\/api\/models\?all=true"\)/);
@@ -161,6 +169,42 @@ test("API-key model fallback preserves combo pseudo-models", () => {
assert.match(fallbackBlock, /seen\.has\(m\.id\)/);
});
+test("provider wildcard permissions render separately from exact models", () => {
+ const source = readApiManagerPage();
+
+ assert.match(
+ source,
+ /const \{ providerWildcards, exactModels \} = restoreProviderScopeSelection\(/,
+ "the API-key row must split provider wildcards from exact model selections"
+ );
+ assert.match(source, /const isModelRestricted =/);
+ assert.match(source, /const providerCount = providerWildcards\.length;/);
+ assert.match(source, /const modelCount = exactModels\.length;/);
+ assert.match(source, /formatProviderModelPermissionSummary\(\s*providerCount,/);
+ assert.doesNotMatch(source, /modelsCount\", \{ count: key\.allowedModels!\.length \}/);
+
+ const summaryStart = source.indexOf("{/* Selected Models Summary");
+ const summaryEnd = source.indexOf("{/* Search and Model Selection", summaryStart);
+ const summary = source.slice(summaryStart, summaryEnd);
+ assert.match(summary, /\{tc\("providers"\)\}/);
+ assert.match(summary, /\{tc\("models"\)\}/);
+ assert.match(summary, /\{selectedPermissionSummary\}/);
+ assert.match(summary, /orderedSelectedProviderScopes\.map/);
+ assert.match(summary, /selectedExactModels\.map/);
+ assert.doesNotMatch(summary, /orderedSelectedModels\.map/);
+
+ const infoBannerStart = source.indexOf(
+ "{/* Info Banner */}",
+ source.indexOf("const PermissionsModal")
+ );
+ const infoBannerEnd = source.indexOf("{/* Key Active Toggle */}", infoBannerStart);
+ const infoBanner = source.slice(infoBannerStart, infoBannerEnd);
+ assert.match(
+ infoBanner,
+ /selectedProviderCount > 0\s*\? selectedPermissionSummary\s*:\s*totalModels === 0/
+ );
+});
+
test("self-service API key scope labels do not expose missing placeholders", () => {
const messageFiles = fs.readdirSync(messagesDir).filter((file) => file.endsWith(".json"));
diff --git a/tests/unit/api-manager-provider-permissions.test.ts b/tests/unit/api-manager-provider-permissions.test.ts
new file mode 100644
index 0000000000..5169219a24
--- /dev/null
+++ b/tests/unit/api-manager-provider-permissions.test.ts
@@ -0,0 +1,795 @@
+/**
+ * Acceptance: API Manager dynamic provider model permissions
+ *
+ * Confirmed examples (traceable rules):
+ * R1 Legacy empty allowedModels remains allow-all unless explicitly marked restricted.
+ * R2 Explicit restricted mode with zero provider/model selections is deny-all.
+ * R3 allowedModels ["ollama-cloud/*", "openai/gpt-4.1"] allows every current/future
+ * ollama-cloud/... model + that exact OpenAI model; denies unselected providers.
+ * R3b Namespace boundary: ollama-cloud/* must NOT match ollama-cloudx/model.
+ * R4 Selecting provider ollama-cloud saves canonical ollama-cloud/* (not snapshot IDs);
+ * children are inherited/non-individually-toggleable; reopening restores provider scope;
+ * manual exact selections from other providers coexist; alias-prefixed catalog IDs
+ * (e.g. ollamacloud/llama3 with owned_by ollama-cloud) map via canonical provider id.
+ * R5 Management PATCH persists explicit modelAccessMode ("all" | "restricted").
+ *
+ * Public shape under test: modelAccessMode: "all" | "restricted".
+ */
+
+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-provider-perms-"));
+process.env.DATA_DIR = TEST_DATA_DIR;
+process.env.API_KEY_SECRET = "test-api-key-secret-provider-perms";
+delete process.env.INITIAL_PASSWORD;
+delete process.env.JWT_SECRET;
+
+const core = await import("../../src/lib/db/core.ts");
+const apiKeys = await import("../../src/lib/db/apiKeys.ts");
+const schemas = await import("../../src/shared/validation/schemas.ts");
+const keysRoute = await import("../../src/app/api/keys/[id]/route.ts");
+const jsonImportRoute = await import("../../src/app/api/settings/import-json/route.ts");
+const jsonMigration = await import("../../src/lib/db/jsonMigration.ts");
+const catalogCache = await import("../../src/app/api/v1/models/catalogCache.ts");
+const apiKeyPolicy = await import("../../src/shared/utils/apiKeyPolicy.ts");
+const pageUtils =
+ await import("../../src/app/(dashboard)/dashboard/api-manager/apiManagerPageUtils.ts");
+
+async function resetStorage() {
+ apiKeys.resetApiKeyState();
+ core.resetDbInstance();
+ for (let attempt = 0; attempt < 10; attempt++) {
+ try {
+ if (fs.existsSync(TEST_DATA_DIR)) {
+ fs.rmSync(TEST_DATA_DIR, { recursive: true, force: true });
+ }
+ break;
+ } catch {
+ await new Promise((r) => setTimeout(r, 50 * (attempt + 1)));
+ }
+ }
+ fs.mkdirSync(TEST_DATA_DIR, { recursive: true });
+ core.getDbInstance();
+ catalogCache.__resetCatalogBuilderRunsForTest();
+}
+
+await resetStorage();
+
+test.after(async () => {
+ apiKeys.resetApiKeyState();
+ core.resetDbInstance();
+ try {
+ fs.rmSync(TEST_DATA_DIR, { recursive: true, force: true });
+ } catch {
+ // best-effort cleanup
+ }
+});
+
+// ──────────────── R1 / R2 / R3 — DB policy via isModelAllowedForKey ────────────────
+
+test("R1: legacy empty allowedModels without restricted mode remains allow-all", async () => {
+ await resetStorage();
+ const created = await apiKeys.createApiKey("Legacy Allow All", "ma-provider-r1");
+
+ // Default create: empty allow-list, no explicit restricted mode.
+ const meta = await apiKeys.getApiKeyMetadata(created.key);
+ assert.ok(meta);
+ assert.deepEqual(meta!.allowedModels, []);
+ // Absent / non-restricted mode must keep the historical allow-all semantics.
+ assert.notEqual(
+ (meta as { modelAccessMode?: string | null }).modelAccessMode,
+ "restricted",
+ "legacy keys must not be treated as restricted"
+ );
+
+ assert.equal(await apiKeys.isModelAllowedForKey(created.key, "ollama-cloud/llama3"), true);
+ assert.equal(await apiKeys.isModelAllowedForKey(created.key, "openai/gpt-4.1"), true);
+ assert.equal(await apiKeys.isModelAllowedForKey(created.key, "anthropic/claude-3"), true);
+});
+
+test("R2: explicit restricted mode with zero selections is deny-all", async () => {
+ await resetStorage();
+ const created = await apiKeys.createApiKey("Restricted Empty", "ma-provider-r2");
+
+ const updated = await apiKeys.updateApiKeyPermissions(created.id, {
+ modelAccessMode: "restricted",
+ allowedModels: [],
+ } as Parameters[1] & {
+ modelAccessMode: "restricted";
+ });
+ assert.equal(updated, true);
+ apiKeys.resetApiKeyState();
+
+ // Core policy: restricted + empty selection denies every model (not legacy allow-all).
+ assert.equal(await apiKeys.isModelAllowedForKey(created.key, "ollama-cloud/llama3"), false);
+ assert.equal(await apiKeys.isModelAllowedForKey(created.key, "openai/gpt-4.1"), false);
+ assert.equal(await apiKeys.isModelAllowedForKey(created.key, "any/model"), false);
+
+ const meta = await apiKeys.getApiKeyMetadata(created.key);
+ assert.ok(meta);
+ assert.deepEqual(meta!.allowedModels, []);
+ assert.equal(
+ (meta as { modelAccessMode?: string }).modelAccessMode,
+ "restricted",
+ "modelAccessMode must round-trip on metadata"
+ );
+ const policyRejection = await apiKeyPolicy.validateApiKeyRoutingTarget(
+ new Request("http://localhost/v1/chat/completions"),
+ created.key,
+ meta,
+ "openai/gpt-4.1"
+ );
+ assert.equal(policyRejection?.status, 403, "the request-policy seam must also deny every model");
+});
+
+test("R3: provider wildcard + exact model are OR-combined; unselected providers denied", async () => {
+ await resetStorage();
+ const created = await apiKeys.createApiKey("Provider Union", "ma-provider-r3");
+
+ await apiKeys.updateApiKeyPermissions(created.id, {
+ modelAccessMode: "restricted",
+ allowedModels: ["ollama-cloud/*", "openai/gpt-4.1"],
+ } as Parameters[1] & {
+ modelAccessMode: "restricted";
+ });
+ apiKeys.resetApiKeyState();
+
+ // Current and future ollama-cloud models inherit the provider scope.
+ assert.equal(await apiKeys.isModelAllowedForKey(created.key, "ollama-cloud/llama3"), true);
+ assert.equal(
+ await apiKeys.isModelAllowedForKey(created.key, "ollamacloud/llama3"),
+ true,
+ "canonical provider scope must authorize an alias-prefixed runtime model id"
+ );
+ const metadata = await apiKeys.getApiKeyMetadata(created.key);
+ const policyRejection = await apiKeyPolicy.validateApiKeyRoutingTarget(
+ new Request("http://localhost/v1/chat/completions"),
+ created.key,
+ metadata,
+ "ollamacloud/llama3"
+ );
+ assert.equal(policyRejection, null, "the request-policy seam must accept provider inheritance");
+ assert.equal(
+ await apiKeys.isModelAllowedForKey(created.key, "ollama-cloud/new-model-after-import"),
+ true
+ );
+ // Exact OpenAI selection remains allowed.
+ assert.equal(await apiKeys.isModelAllowedForKey(created.key, "openai/gpt-4.1"), true);
+ // Other OpenAI models and other providers stay denied.
+ assert.equal(await apiKeys.isModelAllowedForKey(created.key, "openai/gpt-4o"), false);
+ assert.equal(await apiKeys.isModelAllowedForKey(created.key, "anthropic/claude-3"), false);
+
+ const legacyAlias = await apiKeys.createApiKey("Legacy Alias Scope", "ma-provider-r3-alias");
+ await apiKeys.updateApiKeyPermissions(legacyAlias.id, ["ollamacloud/*"]);
+ assert.equal(
+ await apiKeys.isModelAllowedForKey(legacyAlias.key, "ollama-cloud/llama3"),
+ true,
+ "legacy alias wildcard must continue authorizing the canonical runtime model id"
+ );
+});
+
+test("R3b: provider namespace boundary — ollama-cloud/* denies ollama-cloudx/model", async () => {
+ await resetStorage();
+ const created = await apiKeys.createApiKey("Namespace Boundary", "ma-provider-r3b");
+
+ await apiKeys.updateApiKeyPermissions(created.id, {
+ modelAccessMode: "restricted",
+ allowedModels: ["ollama-cloud/*"],
+ } as Parameters[1] & {
+ modelAccessMode: "restricted";
+ });
+ apiKeys.resetApiKeyState();
+
+ // Positive control: true children of the provider scope remain allowed.
+ assert.equal(await apiKeys.isModelAllowedForKey(created.key, "ollama-cloud/llama3"), true);
+ // Security: prefix must not leak across provider id boundaries.
+ assert.equal(
+ await apiKeys.isModelAllowedForKey(created.key, "ollama-cloudx/model"),
+ false,
+ "ollama-cloud/* must not authorize ollama-cloudx/model"
+ );
+ assert.equal(await apiKeys.isModelAllowedForKey(created.key, "ollama-cloud-extra/foo"), false);
+
+ // Pure matcher seam (same rule, no DB).
+ const modelPermissions = await import("../../src/lib/db/apiKeys/modelPermissions.ts");
+ assert.equal(
+ modelPermissions.modelPatternMatches("ollama-cloud/*", ["ollama-cloudx/model"]),
+ false,
+ "modelPatternMatches must enforce provider segment boundary"
+ );
+ assert.equal(
+ modelPermissions.modelPatternMatches("ollama-cloud/*", ["ollama-cloud/llama3"]),
+ true
+ );
+});
+
+test("R2/R5: JSON import preserves explicit restricted + empty and infers legacy mode", async () => {
+ await resetStorage();
+
+ jsonMigration.runJsonMigration(core.getDbInstance(), {
+ apiKeys: [
+ {
+ id: "json-restricted-empty",
+ name: "JSON Restricted Empty",
+ key: "omni_json_restricted_empty",
+ modelAccessMode: "restricted",
+ allowedModels: [],
+ },
+ {
+ id: "json-legacy-restricted",
+ name: "JSON Legacy Restricted",
+ key: "omni_json_legacy_restricted",
+ allowedModels: ["ollama-cloud/*"],
+ },
+ {
+ id: "json-legacy-all",
+ name: "JSON Legacy All",
+ key: "omni_json_legacy_all",
+ allowedModels: [],
+ },
+ ],
+ });
+ apiKeys.resetApiKeyState();
+
+ const restrictedEmpty = await apiKeys.getApiKeyMetadata("omni_json_restricted_empty");
+ assert.equal(restrictedEmpty?.modelAccessMode, "restricted");
+ assert.deepEqual(restrictedEmpty?.allowedModels, []);
+ assert.equal(
+ await apiKeys.isModelAllowedForKey("omni_json_restricted_empty", "openai/gpt-4.1"),
+ false
+ );
+
+ const legacyRestricted = await apiKeys.getApiKeyMetadata("omni_json_legacy_restricted");
+ assert.equal(legacyRestricted?.modelAccessMode, "restricted");
+ assert.equal(
+ await apiKeys.isModelAllowedForKey("omni_json_legacy_restricted", "ollama-cloud/new-model"),
+ true
+ );
+
+ const legacyAll = await apiKeys.getApiKeyMetadata("omni_json_legacy_all");
+ assert.equal(legacyAll?.modelAccessMode, "all");
+ assert.equal(await apiKeys.isModelAllowedForKey("omni_json_legacy_all", "any/model"), true);
+});
+
+test("R2/R5: startup db.json migration preserves explicit restricted-empty mode", async () => {
+ apiKeys.resetApiKeyState();
+ core.resetDbInstance();
+ fs.rmSync(TEST_DATA_DIR, { recursive: true, force: true });
+ fs.mkdirSync(TEST_DATA_DIR, { recursive: true });
+ fs.writeFileSync(
+ path.join(TEST_DATA_DIR, "db.json"),
+ JSON.stringify({
+ apiKeys: [
+ {
+ id: "startup-restricted-empty",
+ name: "Startup Restricted Empty",
+ key: "omni_startup_restricted_empty",
+ modelAccessMode: "restricted",
+ allowedModels: [],
+ },
+ ],
+ })
+ );
+
+ core.getDbInstance();
+ const metadata = await apiKeys.getApiKeyMetadata("omni_startup_restricted_empty");
+ assert.equal(metadata?.modelAccessMode, "restricted");
+ assert.deepEqual(metadata?.allowedModels, []);
+ assert.equal(
+ await apiKeys.isModelAllowedForKey("omni_startup_restricted_empty", "openai/gpt-4.1"),
+ false
+ );
+});
+
+test("R2/R5: dashboard JSON import invalidates warm permission and catalog caches", async () => {
+ await resetStorage();
+ const created = await apiKeys.createApiKey("Import Cache", "ma-provider-import-cache");
+ assert.equal(await apiKeys.isModelAllowedForKey(created.key, "openai/gpt-4.1"), true);
+
+ const catalogRequest = () =>
+ new Request("http://localhost/v1/models", { headers: { Authorization: created.key } });
+ const headers = { corsHeaders: {}, diagnosticHeaders: {} };
+ const catalogPayload = (body: string) => ({
+ body,
+ headers: { "content-type": "application/json" },
+ status: 200,
+ cacheTTL: 60_000,
+ });
+ assert.equal(
+ await (
+ await catalogCache.resolveCachedCatalogResponse(catalogRequest(), headers, async () =>
+ catalogPayload("OLD")
+ )
+ ).text(),
+ "OLD"
+ );
+
+ const response = await jsonImportRoute.POST(
+ new Request("http://localhost/api/settings/import-json", {
+ method: "POST",
+ headers: { "Content-Type": "application/json" },
+ body: JSON.stringify({
+ apiKeys: [
+ {
+ id: created.id,
+ name: "Import Cache",
+ key: created.key,
+ modelAccessMode: "restricted",
+ allowedModels: [],
+ },
+ ],
+ }),
+ })
+ );
+ assert.equal(response.status, 200);
+ assert.equal(await apiKeys.isModelAllowedForKey(created.key, "openai/gpt-4.1"), false);
+
+ let freshBuilds = 0;
+ const catalogAfterImport = await catalogCache.resolveCachedCatalogResponse(
+ catalogRequest(),
+ headers,
+ async () => {
+ freshBuilds++;
+ return catalogPayload("NEW");
+ }
+ );
+ assert.equal(await catalogAfterImport.text(), "NEW");
+ assert.equal(freshBuilds, 1);
+});
+
+test("R2/R5: updateApiKeyPermissions persists modelAccessMode on getApiKeyById", async () => {
+ await resetStorage();
+ const created = await apiKeys.createApiKey("Mode Persist", "ma-provider-r5-db");
+
+ await apiKeys.updateApiKeyPermissions(created.id, {
+ modelAccessMode: "restricted",
+ allowedModels: ["ollama-cloud/*"],
+ } as Parameters[1] & {
+ modelAccessMode: "restricted";
+ });
+ apiKeys.resetApiKeyState();
+
+ const row = await apiKeys.getApiKeyById(created.id);
+ assert.ok(row);
+ assert.equal(
+ (row as { modelAccessMode?: string }).modelAccessMode,
+ "restricted",
+ "getApiKeyById must expose persisted modelAccessMode"
+ );
+ assert.deepEqual(row!.allowedModels, ["ollama-cloud/*"]);
+
+ await apiKeys.updateApiKeyPermissions(created.id, {
+ modelAccessMode: "all",
+ allowedModels: [],
+ } as Parameters[1] & {
+ modelAccessMode: "all";
+ });
+ apiKeys.resetApiKeyState();
+
+ const allRow = await apiKeys.getApiKeyById(created.id);
+ assert.equal((allRow as { modelAccessMode?: string }).modelAccessMode, "all");
+ assert.deepEqual(allRow!.allowedModels, []);
+});
+
+test("R5: unrelated API key writes retain the filtered model catalog cache", async () => {
+ await resetStorage();
+ const created = await apiKeys.createApiKey("Catalog Stable", "ma-provider-catalog-stable");
+ const request = () =>
+ new Request("http://localhost/v1/models", { headers: { Authorization: created.key } });
+ const headers = { corsHeaders: {}, diagnosticHeaders: {} };
+ const payload = {
+ body: "CACHED",
+ headers: { "content-type": "application/json" },
+ status: 200,
+ cacheTTL: 60_000,
+ };
+
+ await catalogCache.resolveCachedCatalogResponse(request(), headers, async () => payload);
+ await apiKeys.updateApiKeyPermissions(created.id, { name: "Catalog Stable Renamed" });
+
+ let rebuilds = 0;
+ const response = await catalogCache.resolveCachedCatalogResponse(request(), headers, async () => {
+ rebuilds++;
+ return { ...payload, body: "UNEXPECTED" };
+ });
+ assert.equal(await response.text(), "CACHED");
+ assert.equal(rebuilds, 0);
+});
+
+test("R4/R5: permission writes detach stale in-flight model catalog builds", async () => {
+ await resetStorage();
+ const created = await apiKeys.createApiKey("Catalog Invalidation", "ma-provider-catalog");
+ const request = () =>
+ new Request("http://localhost/v1/models", { headers: { Authorization: created.key } });
+ const headers = { corsHeaders: {}, diagnosticHeaders: {} };
+ const payload = (body: string) => ({
+ body,
+ headers: { "content-type": "application/json" },
+ status: 200,
+ cacheTTL: 60_000,
+ });
+
+ let releaseOld!: (value: ReturnType) => void;
+ let oldCalls = 0;
+ const oldGate = new Promise>((resolve) => {
+ releaseOld = resolve;
+ });
+ const oldResponsePromise = catalogCache.resolveCachedCatalogResponse(request(), headers, () => {
+ oldCalls++;
+ return oldGate;
+ });
+ await Promise.resolve();
+ assert.equal(oldCalls, 1);
+
+ await apiKeys.updateApiKeyPermissions(created.id, {
+ modelAccessMode: "restricted",
+ allowedModels: ["ollama-cloud/*"],
+ });
+
+ let freshCalls = 0;
+ const freshResponse = await catalogCache.resolveCachedCatalogResponse(
+ request(),
+ headers,
+ async () => {
+ freshCalls++;
+ return payload("NEW");
+ }
+ );
+ assert.equal(freshCalls, 1, "post-write request must not join the stale pre-write build");
+ assert.equal(await freshResponse.text(), "NEW");
+
+ releaseOld(payload("OLD"));
+ assert.equal(await (await oldResponsePromise).text(), "OLD");
+
+ let unexpectedCalls = 0;
+ const cachedResponse = await catalogCache.resolveCachedCatalogResponse(
+ request(),
+ headers,
+ async () => {
+ unexpectedCalls++;
+ return payload("UNEXPECTED");
+ }
+ );
+ assert.equal(await cachedResponse.text(), "NEW");
+ assert.equal(unexpectedCalls, 0, "stale completion must not overwrite the current catalog cache");
+});
+
+// ──────────────── R5 — schema + management PATCH ────────────────
+
+test("R5: updateKeyPermissionsSchema accepts and retains modelAccessMode", () => {
+ const restrictedEmpty = schemas.validateBody(schemas.updateKeyPermissionsSchema, {
+ modelAccessMode: "restricted",
+ allowedModels: [],
+ });
+ assert.equal(restrictedEmpty.success, true, "restricted + empty allowedModels must be valid");
+ assert.equal(
+ (restrictedEmpty as { success: true; data: { modelAccessMode?: string } }).data.modelAccessMode,
+ "restricted",
+ "schema must retain modelAccessMode (not strip it)"
+ );
+
+ const allMode = schemas.validateBody(schemas.updateKeyPermissionsSchema, {
+ modelAccessMode: "all",
+ });
+ assert.equal(allMode.success, true, "modelAccessMode-only update must be a valid payload");
+ assert.equal(
+ (allMode as { success: true; data: { modelAccessMode?: string } }).data.modelAccessMode,
+ "all"
+ );
+
+ const invalidMode = schemas.validateBody(schemas.updateKeyPermissionsSchema, {
+ modelAccessMode: "maybe",
+ });
+ assert.equal(invalidMode.success, false, "unknown modelAccessMode values must be rejected");
+
+ const contradictoryAll = schemas.validateBody(schemas.updateKeyPermissionsSchema, {
+ modelAccessMode: "all",
+ allowedModels: ["ollama-cloud/*"],
+ });
+ assert.equal(contradictoryAll.success, false, "all mode with a non-empty allow-list is invalid");
+});
+
+test("R1/R5: legacy PATCH payloads infer mode from allowedModels", async () => {
+ await resetStorage();
+ const created = await apiKeys.createApiKey("Legacy Patch", "ma-provider-legacy-patch");
+
+ const restrictResponse = await keysRoute.PATCH(
+ new Request(`http://localhost/api/keys/${created.id}`, {
+ method: "PATCH",
+ headers: { "Content-Type": "application/json" },
+ body: JSON.stringify({ allowedModels: ["ollama-cloud/*"] }),
+ }),
+ { params: Promise.resolve({ id: created.id }) }
+ );
+ assert.equal(restrictResponse.status, 200);
+ apiKeys.resetApiKeyState();
+ assert.equal((await apiKeys.getApiKeyById(created.id))?.modelAccessMode, "restricted");
+
+ const unrelatedResponse = await keysRoute.PATCH(
+ new Request(`http://localhost/api/keys/${created.id}`, {
+ method: "PATCH",
+ headers: { "Content-Type": "application/json" },
+ body: JSON.stringify({ name: "Legacy Patch Renamed" }),
+ }),
+ { params: Promise.resolve({ id: created.id }) }
+ );
+ assert.equal(unrelatedResponse.status, 200);
+ apiKeys.resetApiKeyState();
+ const unchanged = await apiKeys.getApiKeyById(created.id);
+ assert.equal(unchanged?.modelAccessMode, "restricted");
+ assert.deepEqual(unchanged?.allowedModels, ["ollama-cloud/*"]);
+
+ const allowAllResponse = await keysRoute.PATCH(
+ new Request(`http://localhost/api/keys/${created.id}`, {
+ method: "PATCH",
+ headers: { "Content-Type": "application/json" },
+ body: JSON.stringify({ allowedModels: [] }),
+ }),
+ { params: Promise.resolve({ id: created.id }) }
+ );
+ assert.equal(allowAllResponse.status, 200);
+ apiKeys.resetApiKeyState();
+ const allowAll = await apiKeys.getApiKeyById(created.id);
+ assert.equal(allowAll?.modelAccessMode, "all");
+ assert.deepEqual(allowAll?.allowedModels, []);
+});
+
+test("R5: PATCH /api/keys/[id] persists modelAccessMode restricted + empty allow-list", async () => {
+ await resetStorage();
+ const created = await apiKeys.createApiKey("Patch Mode", "ma-provider-r5-patch");
+
+ const response = await keysRoute.PATCH(
+ new Request(`http://localhost/api/keys/${created.id}`, {
+ method: "PATCH",
+ headers: { "Content-Type": "application/json" },
+ body: JSON.stringify({
+ modelAccessMode: "restricted",
+ allowedModels: [],
+ }),
+ }),
+ { params: Promise.resolve({ id: created.id }) }
+ );
+ const body = (await response.json()) as {
+ modelAccessMode?: string;
+ allowedModels?: string[];
+ error?: unknown;
+ };
+
+ assert.equal(
+ response.status,
+ 200,
+ `expected 200, got ${response.status}: ${JSON.stringify(body)}`
+ );
+ assert.equal(body.modelAccessMode, "restricted");
+ assert.deepEqual(body.allowedModels, []);
+
+ apiKeys.resetApiKeyState();
+ const meta = await apiKeys.getApiKeyMetadata(created.key);
+ assert.equal((meta as { modelAccessMode?: string } | null)?.modelAccessMode, "restricted");
+ assert.deepEqual(meta?.allowedModels, []);
+ assert.equal(await apiKeys.isModelAllowedForKey(created.key, "openai/gpt-4.1"), false);
+});
+
+// ──────────────── R2 / R4 — UI classification + provider-scope helpers ────────────────
+
+test("R2: isRestricted treats explicit restricted mode with empty allowedModels as restricted", () => {
+ // Existing helper must honor the new dual empty-array semantics.
+ const restrictedEmpty = {
+ allowedModels: [] as string[],
+ allowedConnections: null,
+ modelAccessMode: "restricted" as const,
+ };
+ assert.equal(
+ pageUtils.isRestricted(restrictedEmpty as Parameters[0]),
+ true,
+ "restricted + empty selections must classify as restricted (not standard allow-all)"
+ );
+
+ const legacyEmpty = {
+ allowedModels: [] as string[],
+ allowedConnections: null,
+ };
+ assert.equal(
+ pageUtils.isRestricted(legacyEmpty),
+ false,
+ "legacy empty without restricted mode stays unrestricted"
+ );
+});
+
+test("R4: provider selection helpers emit canonical provider/* and keep other exact selections", () => {
+ type ProviderModelRef = { id: string; owned_by?: string; providerId?: string };
+ type ProviderScopeOptions = {
+ providerId?: string;
+ ownedBy?: string;
+ providerModels?: ProviderModelRef[];
+ };
+
+ const utils = pageUtils as typeof pageUtils & {
+ toggleProviderWildcardSelection?: (
+ selected: string[],
+ providerId: string,
+ options?: ProviderScopeOptions
+ ) => string[];
+ isModelInheritedByProviderScope?: (
+ selected: string[],
+ modelId: string,
+ options?: ProviderScopeOptions
+ ) => boolean;
+ isModelIndividuallyToggleable?: (
+ selected: string[],
+ modelId: string,
+ options?: ProviderScopeOptions
+ ) => boolean;
+ restoreProviderScopeSelection?: (
+ allowedModels: string[],
+ options?: { providerModels?: ProviderModelRef[] }
+ ) => {
+ providerWildcards: string[];
+ exactModels: string[];
+ };
+ buildModelAccessSavePayload?: (input: { allowAll: boolean; selectedModels: string[] }) => {
+ modelAccessMode: "all" | "restricted";
+ allowedModels: string[];
+ };
+ formatProviderModelPermissionSummary?: (
+ providerCount: number,
+ modelCount: number,
+ t: (key: string, values?: Record) => string,
+ tc: (key: string) => string
+ ) => string;
+ };
+
+ assert.equal(
+ typeof utils.toggleProviderWildcardSelection,
+ "function",
+ "UI must expose toggleProviderWildcardSelection for provider-scope selection"
+ );
+ assert.equal(
+ typeof utils.isModelInheritedByProviderScope,
+ "function",
+ "UI must expose isModelInheritedByProviderScope for inherited children"
+ );
+ assert.equal(
+ typeof utils.isModelIndividuallyToggleable,
+ "function",
+ "UI must expose isModelIndividuallyToggleable so provider children are non-toggleable"
+ );
+ assert.equal(
+ typeof utils.restoreProviderScopeSelection,
+ "function",
+ "UI must expose restoreProviderScopeSelection so reopening restores provider/*"
+ );
+ assert.equal(
+ typeof utils.buildModelAccessSavePayload,
+ "function",
+ "UI must expose buildModelAccessSavePayload for modelAccessMode + allowedModels"
+ );
+ assert.equal(
+ typeof utils.formatProviderModelPermissionSummary,
+ "function",
+ "UI must expose one provider/model summary formatter for every display surface"
+ );
+
+ const t = (key: string, values?: Record) => {
+ if (key === "modelsCount") return `${values?.count} model${values?.count === 1 ? "" : "s"}`;
+ if (key === "selectedCount") return `${values?.count} selected`;
+ return key;
+ };
+ const tc = (key: string) => key;
+ assert.deepEqual(pageUtils.restoreProviderScopeSelection(["codex/*", "openai/gpt-4.1"]), {
+ providerWildcards: ["codex/*"],
+ exactModels: ["openai/gpt-4.1"],
+ });
+ assert.equal(utils.formatProviderModelPermissionSummary!(1, 1, t, tc), "1 provider · 1 model");
+ assert.equal(utils.formatProviderModelPermissionSummary!(1, 0, t, tc), "1 provider");
+ assert.equal(utils.formatProviderModelPermissionSummary!(0, 2, t, tc), "2 models");
+
+ // Selecting ollama-cloud adds canonical wildcard; coexists with openai exact.
+ assert.deepEqual(utils.toggleProviderWildcardSelection!(["openai/gpt-4.1"], "ollama-cloud"), [
+ "openai/gpt-4.1",
+ "ollama-cloud/*",
+ ]);
+
+ // Selecting again removes only that provider wildcard.
+ assert.deepEqual(
+ utils.toggleProviderWildcardSelection!(["openai/gpt-4.1", "ollama-cloud/*"], "ollama-cloud"),
+ ["openai/gpt-4.1"]
+ );
+
+ // Selecting a provider replaces any previously expanded exact child IDs for that provider.
+ assert.deepEqual(
+ utils.toggleProviderWildcardSelection!(
+ ["ollama-cloud/llama3", "ollama-cloud/qwen", "openai/gpt-4.1"],
+ "ollama-cloud"
+ ),
+ ["openai/gpt-4.1", "ollama-cloud/*"]
+ );
+
+ // Alias-prefixed catalog IDs (id prefix ≠ canonical provider) still collapse via owned_by.
+ // Example: catalog model id `ollamacloud/llama3` with owned_by/providerId `ollama-cloud`.
+ const aliasPrefixedCatalog: ProviderModelRef[] = [
+ { id: "ollamacloud/llama3", owned_by: "ollama-cloud", providerId: "ollama-cloud" },
+ { id: "ollamacloud/qwen", owned_by: "ollama-cloud", providerId: "ollama-cloud" },
+ ];
+ assert.deepEqual(
+ utils.toggleProviderWildcardSelection!(
+ ["ollamacloud/llama3", "ollamacloud/qwen", "openai/gpt-4.1"],
+ "ollama-cloud",
+ { providerId: "ollama-cloud", providerModels: aliasPrefixedCatalog }
+ ),
+ ["openai/gpt-4.1", "ollama-cloud/*"],
+ "toggle must remove alias-prefixed children and emit canonical ollama-cloud/*"
+ );
+
+ // Children under a selected provider are inherited and not individually toggleable.
+ assert.equal(
+ utils.isModelInheritedByProviderScope!(
+ ["ollama-cloud/*", "openai/gpt-4.1"],
+ "ollama-cloud/llama3"
+ ),
+ true
+ );
+ assert.equal(
+ utils.isModelInheritedByProviderScope!(["ollama-cloud/*"], "ollamacloud/llama3", {
+ providerId: "ollama-cloud",
+ ownedBy: "ollama-cloud",
+ }),
+ true,
+ "alias-prefixed id must still be recognized as inherited under canonical provider scope"
+ );
+ assert.equal(
+ utils.isModelIndividuallyToggleable!(
+ ["ollama-cloud/*", "openai/gpt-4.1"],
+ "ollama-cloud/llama3"
+ ),
+ false
+ );
+ assert.equal(
+ utils.isModelIndividuallyToggleable!(["ollama-cloud/*"], "ollamacloud/llama3", {
+ providerId: "ollama-cloud",
+ }),
+ false,
+ "alias-prefixed children of a selected provider must not be individually toggleable"
+ );
+ assert.equal(
+ utils.isModelIndividuallyToggleable!(["ollama-cloud/*", "openai/gpt-4.1"], "openai/gpt-4.1"),
+ true
+ );
+
+ // Reopening restores provider-level scope from stored allowedModels.
+ assert.deepEqual(utils.restoreProviderScopeSelection!(["ollama-cloud/*", "openai/gpt-4.1"]), {
+ providerWildcards: ["ollama-cloud/*"],
+ exactModels: ["openai/gpt-4.1"],
+ });
+
+ // Restrict with zero selections must save restricted + [].
+ assert.deepEqual(utils.buildModelAccessSavePayload!({ allowAll: false, selectedModels: [] }), {
+ modelAccessMode: "restricted",
+ allowedModels: [],
+ });
+
+ // Allow-all mode.
+ assert.deepEqual(
+ utils.buildModelAccessSavePayload!({
+ allowAll: true,
+ selectedModels: ["ollama-cloud/*"],
+ }),
+ { modelAccessMode: "all", allowedModels: [] }
+ );
+
+ // Restrict with provider + exact selections.
+ assert.deepEqual(
+ utils.buildModelAccessSavePayload!({
+ allowAll: false,
+ selectedModels: ["ollama-cloud/*", "openai/gpt-4.1"],
+ }),
+ {
+ modelAccessMode: "restricted",
+ allowedModels: ["ollama-cloud/*", "openai/gpt-4.1"],
+ }
+ );
+});
diff --git a/tests/unit/migration-147-api-keys-model-access-mode.test.ts b/tests/unit/migration-147-api-keys-model-access-mode.test.ts
new file mode 100644
index 0000000000..54d95b550f
--- /dev/null
+++ b/tests/unit/migration-147-api-keys-model-access-mode.test.ts
@@ -0,0 +1,134 @@
+/**
+ * Acceptance: migration 147 — api_keys.model_access_mode
+ *
+ * Confirmed backfill:
+ * - Adds model_access_mode column (public shape: "all" | "restricted")
+ * - Legacy empty allowed_models rows → "all"
+ * - Legacy non-empty allowed_models rows → "restricted"
+ *
+ * The existence assertion keeps the migration artifact part of the accepted contract.
+ */
+
+import test from "node:test";
+import assert from "node:assert/strict";
+import fs from "node:fs";
+import path from "node:path";
+import { fileURLToPath } from "node:url";
+import Database from "better-sqlite3";
+
+const __dirname = path.dirname(fileURLToPath(import.meta.url));
+const MIGRATION_147_PATH = path.join(
+ __dirname,
+ "../../src/lib/db/migrations/147_api_keys_model_access_mode.sql"
+);
+
+interface TestDb {
+ exec: (sql: string) => unknown;
+ prepare: (sql: string) => {
+ run: (...p: unknown[]) => unknown;
+ get: (...p: unknown[]) => Record | undefined;
+ all: (...p: unknown[]) => Record[];
+ };
+ close: () => void;
+}
+
+function makeLegacyApiKeysDb(): TestDb {
+ const db = new Database(":memory:") as unknown as TestDb;
+ db.exec(`
+ CREATE TABLE api_keys (
+ id TEXT PRIMARY KEY,
+ name TEXT NOT NULL,
+ key TEXT NOT NULL,
+ allowed_models TEXT DEFAULT '[]'
+ );
+ `);
+ return db;
+}
+
+function insertKey(db: TestDb, id: string, name: string, allowedModelsJson: string | null): void {
+ db.prepare("INSERT INTO api_keys (id, name, key, allowed_models) VALUES (?, ?, ?, ?)").run(
+ id,
+ name,
+ `omni_${id}`,
+ allowedModelsJson
+ );
+}
+
+function modeOf(db: TestDb, id: string): string | null {
+ const row = db.prepare("SELECT model_access_mode AS mode FROM api_keys WHERE id = ?").get(id);
+ return (row?.mode as string | undefined) ?? null;
+}
+
+function hasModelAccessModeColumn(db: TestDb): boolean {
+ const cols = db.prepare("PRAGMA table_info(api_keys)").all();
+ return cols.some((col) => col.name === "model_access_mode");
+}
+
+test("R-migration: 147_api_keys_model_access_mode.sql must exist", () => {
+ assert.ok(
+ fs.existsSync(MIGRATION_147_PATH),
+ "expected src/lib/db/migrations/147_api_keys_model_access_mode.sql"
+ );
+});
+
+test("R-migration: 147 adds model_access_mode and backfills all/restricted from allowed_models", () => {
+ assert.ok(
+ fs.existsSync(MIGRATION_147_PATH),
+ "expected src/lib/db/migrations/147_api_keys_model_access_mode.sql"
+ );
+
+ const sql = fs.readFileSync(MIGRATION_147_PATH, "utf-8");
+ const db = makeLegacyApiKeysDb();
+
+ insertKey(db, "legacy-all", "Legacy Allow All", "[]");
+ insertKey(db, "legacy-spaced-all", "Legacy Spaced Allow All", "[ ]");
+ insertKey(db, "legacy-nullish", "Legacy Nullish", "null");
+ insertKey(db, "legacy-sql-null", "Legacy SQL Null", null);
+ insertKey(db, "legacy-restricted", "Legacy Restricted", '["ollama-cloud/*","openai/gpt-4.1"]');
+ insertKey(db, "legacy-exact", "Legacy Exact", '["openai/gpt-4.1"]');
+ insertKey(db, "legacy-scalar", "Legacy Scalar", "true");
+ insertKey(db, "legacy-malformed", "Legacy Malformed", "not-json");
+
+ db.exec(sql);
+
+ assert.equal(hasModelAccessModeColumn(db), true, "must add model_access_mode column");
+
+ assert.equal(
+ modeOf(db, "legacy-all"),
+ "all",
+ "legacy empty allowed_models must backfill to model_access_mode=all"
+ );
+ assert.equal(
+ modeOf(db, "legacy-spaced-all"),
+ "all",
+ "valid empty JSON arrays remain allow-all regardless of whitespace"
+ );
+ assert.equal(
+ modeOf(db, "legacy-nullish"),
+ "all",
+ "JSON null allowed_models must backfill to all"
+ );
+ assert.equal(modeOf(db, "legacy-sql-null"), "all", "SQL NULL must backfill to all");
+ assert.equal(
+ modeOf(db, "legacy-restricted"),
+ "restricted",
+ "legacy non-empty allowed_models must backfill to restricted"
+ );
+ assert.equal(
+ modeOf(db, "legacy-exact"),
+ "restricted",
+ "legacy exact-model allow-list must backfill to restricted"
+ );
+ assert.equal(
+ modeOf(db, "legacy-scalar"),
+ "restricted",
+ "non-array JSON values other than null must fail closed"
+ );
+ assert.equal(
+ modeOf(db, "legacy-malformed"),
+ "restricted",
+ "malformed legacy values must fail closed"
+ );
+
+ db.close();
+});
diff --git a/tests/unit/pick-internal-api-key-6372.test.ts b/tests/unit/pick-internal-api-key-6372.test.ts
index ab70247d31..927d332ae0 100644
--- a/tests/unit/pick-internal-api-key-6372.test.ts
+++ b/tests/unit/pick-internal-api-key-6372.test.ts
@@ -43,8 +43,29 @@ test("#6372: prefers a management-scoped key over a plain self:usage key", async
assert.equal(picked, mgr.key, "should pick the management-scoped key, not the first row");
});
-test("#6372: falls back to an active key when none is management-scoped", async () => {
- const only = await apiKeysDb.createApiKey("usage-key", "machine-a", ["self:usage"]);
+test("#6372: a restricted-empty key does not outrank a real allow-all key", async () => {
+ const restricted = await apiKeysDb.createApiKey("restricted-empty", "machine-a", ["self:usage"]);
+ await apiKeysDb.updateApiKeyPermissions(restricted.id, {
+ modelAccessMode: "restricted",
+ allowedModels: [],
+ });
+ const allowAll = await apiKeysDb.createApiKey("allow-all", "machine-a", ["self:usage"]);
+
const picked = await apiKeysDb.pickApiKeyForInternalUse("internal-probe");
- assert.equal(picked, only.key, "should still return a usable active key via fallback rules");
+ assert.equal(picked, allowAll.key);
+});
+
+test("#6372: falls back to an active key when none is management-scoped or allow-all", async () => {
+ const only = await apiKeysDb.createApiKey("restricted-empty", "machine-a", ["self:usage"]);
+ await apiKeysDb.updateApiKeyPermissions(only.id, {
+ modelAccessMode: "restricted",
+ allowedModels: [],
+ });
+
+ const picked = await apiKeysDb.pickApiKeyForInternalUse("internal-probe");
+ assert.equal(
+ picked,
+ only.key,
+ "last-resort fallback stays best-effort and does not bypass policy"
+ );
});
diff --git a/tests/unit/sync-bundle.test.ts b/tests/unit/sync-bundle.test.ts
index fd830b6262..24215d0226 100644
--- a/tests/unit/sync-bundle.test.ts
+++ b/tests/unit/sync-bundle.test.ts
@@ -69,7 +69,11 @@ test("config sync bundle is deterministic, strips auth settings, and ignores vol
models: ["openai/gpt-4o-mini"],
strategy: "priority",
});
- await apiKeysDb.createApiKey("Desktop", "machine-sync-1");
+ const apiKey = await apiKeysDb.createApiKey("Desktop", "machine-sync-1");
+ await apiKeysDb.updateApiKeyPermissions(apiKey.id, {
+ modelAccessMode: "restricted",
+ allowedModels: [],
+ });
const first = await syncBundle.buildConfigSyncEnvelope();
const second = await syncBundle.buildConfigSyncEnvelope();
@@ -81,6 +85,8 @@ test("config sync bundle is deterministic, strips auth settings, and ignores vol
assert.equal(first.bundle.settings.cloudEnabled, undefined);
assert.equal(first.bundle.providerConnections[0].apiKey, "sk-live-secret");
assert.equal(first.bundle.modelAliases["smart-default"], "openai/gpt-4o-mini");
+ assert.equal(first.bundle.apiKeys[0].modelAccessMode, "restricted");
+ assert.deepEqual(first.bundle.apiKeys[0].allowedModels, []);
assert.deepEqual(first.bundle.reasoningRoutingRules, []);
await providersDb.updateProviderConnection((connection as any).id, {