feat(api-manager): add provider-level model permissions (#9313)

* 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
This commit is contained in:
Xiangzhe
2026-08-11 21:29:28 +08:00
committed by GitHub
parent 3898305df0
commit def5334768
24 changed files with 2227 additions and 358 deletions

View File

@@ -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() {
</span>
)}
{/* Existing badges */}
{isRestricted ? (
{isModelRestricted ? (
<button
onClick={() => handleOpenPermissions(key)}
className="flex items-center gap-1.5 px-2 py-1 rounded-md bg-amber-500/10 text-amber-600 dark:text-amber-400 text-xs font-medium hover:bg-amber-500/20 transition-colors"
>
<span className="material-symbols-outlined text-[14px]">lock</span>
{t("modelsCount", { count: key.allowedModels!.length })}
{formatProviderModelPermissionSummary(providerCount, modelCount, t, tc)}
</button>
) : (
<button
@@ -1657,7 +1673,8 @@ const PermissionsModal = memo(function PermissionsModal({
dailyUsageLimitUsd: number | null,
weeklyUsageLimitUsd: number | null,
blockedModels: string[],
chaosModeEnabled: boolean
chaosModeEnabled: boolean,
modelAccessMode: "all" | "restricted"
) => void;
}) {
const t = useTranslations("apiManager");
@@ -1680,7 +1697,11 @@ const PermissionsModal = memo(function PermissionsModal({
>(() => getBlockedClaudeCodeFamilies(initialBlockedModels));
const [claudeCodeFamiliesExpanded, setClaudeCodeFamiliesExpanded] = useState(false);
const [selectedCombos, setSelectedCombos] = useState<string[]>(initialCombos);
const [allowAll, setAllowAll] = useState(initialModels.length === 0);
// Explicit restricted mode reopens in Restrict even with zero selections
// (restricted + empty = deny-all); legacy absent mode keeps "empty = allow all".
const [allowAll, setAllowAll] = useState(
apiKey?.modelAccessMode === "restricted" ? false : initialModels.length === 0
);
const [allowAllCombos, setAllowAllCombos] = useState(initialCombos.length === 0);
const [noLogEnabled, setNoLogEnabled] = useState(apiKey?.noLog === true);
const [autoResolveEnabled, setAutoResolveEnabled] = useState(apiKey?.autoResolve === true);
@@ -1765,15 +1786,20 @@ const PermissionsModal = memo(function PermissionsModal({
);
// Memoize callbacks to prevent child re-renders
const handleSelectionChange = useCallback((next: string[]) => {
setSelectedModels(next);
}, []);
const handleClaudeCodeDefaultDeselected = useCallback(() => {
setClaudeCodeFamiliesExpanded(false);
}, []);
const handleToggleModel = useCallback(
(modelId: string) => {
if (allowAll) return;
setSelectedModels((prev) => {
if (prev.includes(modelId)) {
if (modelId === CLAUDE_CODE_DEFAULT_MODEL_ID) {
setClaudeCodeFamiliesExpanded(false);
}
if (modelId === CLAUDE_CODE_DEFAULT_MODEL_ID) setClaudeCodeFamiliesExpanded(false);
return prev.filter((m) => m !== modelId);
}
return [...prev, modelId];
@@ -1782,22 +1808,6 @@ const PermissionsModal = memo(function PermissionsModal({
[allowAll]
);
const handleToggleProvider = useCallback(
(provider: string, models: Model[]) => {
if (allowAll) return;
const modelIds = models.map((m) => m.id);
setSelectedModels((prev) => {
const allSelected = modelIds.every((id) => prev.includes(id));
if (allSelected) {
return prev.filter((m) => !modelIds.includes(m));
}
return [...new Set([...prev, ...modelIds])];
});
},
[allowAll]
);
const handleSelectAll = useCallback(() => {
setAllowAll(true);
setSelectedModels([]);
@@ -1924,9 +1934,10 @@ const PermissionsModal = memo(function PermissionsModal({
blockedModels.push(...CLAUDE_CODE_FAMILY_BLOCK_PATTERNS[familyId]);
}
}
const modelAccess = buildModelAccessSavePayload({ allowAll, selectedModels });
onSave(
keyName,
allowAll ? [] : selectedModels,
modelAccess.allowedModels,
allowAllCombos ? [] : selectedCombos,
noLogEnabled,
allowAllConnections ? [] : selectedConnections,
@@ -1953,7 +1964,8 @@ const PermissionsModal = memo(function PermissionsModal({
parseUsdLimitInput(dailyUsageLimitUsd),
parseUsdLimitInput(weeklyUsageLimitUsd),
blockedModels,
chaosModeEnabled
chaosModeEnabled,
modelAccess.modelAccessMode
);
}, [
onSave,
@@ -1998,17 +2010,30 @@ const PermissionsModal = memo(function PermissionsModal({
t,
]);
// Provider wildcards ("ollama-cloud/*") are counted as providers, not models.
// Inherited children render selected via the owner lookup inside the list component.
const { providerWildcards: selectedProviderScopes, exactModels: selectedExactModels } =
restoreProviderScopeSelection(selectedModels);
const selectedProviderCount = selectedProviderScopes.length;
const selectedModelCount = selectedExactModels.length;
const selectedCount = selectedModels.length;
const selectedPermissionSummary = formatProviderModelPermissionSummary(
selectedProviderCount,
selectedModelCount,
t,
tc
);
const totalModels = allModels.length;
const hasClaudeCodeDefaultSelected =
!allowAll && selectedModels.includes(CLAUDE_CODE_DEFAULT_MODEL_ID);
const orderedSelectedModels = useMemo(() => {
if (!hasClaudeCodeDefaultSelected) return selectedModels;
const orderedSelectedProviderScopes = useMemo(() => {
if (!hasClaudeCodeDefaultSelected) return selectedProviderScopes;
return [
CLAUDE_CODE_DEFAULT_MODEL_ID,
...selectedModels.filter((modelId) => modelId !== CLAUDE_CODE_DEFAULT_MODEL_ID),
...selectedProviderScopes.filter((scope) => scope !== CLAUDE_CODE_DEFAULT_MODEL_ID),
];
}, [hasClaudeCodeDefaultSelected, selectedModels]);
}, [hasClaudeCodeDefaultSelected, selectedProviderScopes]);
const visibleClaudeCodeFamilies = useMemo(
() =>
CLAUDE_CODE_DEFAULT_FAMILIES.filter(
@@ -2106,9 +2131,11 @@ const PermissionsModal = memo(function PermissionsModal({
? t("allowAllDesc")
: !modelsLoaded
? t("restrictLoading")
: totalModels === 0
? t("restrictCatalogUnavailable", { selectedCount })
: t("restrictDesc", { selectedCount, totalModels })}
: selectedProviderCount > 0
? selectedPermissionSummary
: totalModels === 0
? t("restrictCatalogUnavailable", { selectedCount })
: t("restrictDesc", { selectedCount, totalModels })}
</p>
</div>
@@ -2626,9 +2653,7 @@ const PermissionsModal = memo(function PermissionsModal({
{!allowAll && selectedCount > 0 && (
<div className="flex flex-col gap-1.5 p-2 bg-primary/5 rounded-lg border border-primary/20">
<div className="flex items-center justify-between">
<span className="text-xs font-medium text-primary">
{t("selectedCount", { count: selectedCount })}
</span>
<span className="text-xs font-medium text-primary">{selectedPermissionSummary}</span>
<div className="flex gap-1">
<button
onClick={handleSelectAllModels}
@@ -2644,230 +2669,157 @@ const PermissionsModal = memo(function PermissionsModal({
</button>
</div>
</div>
<div className="flex flex-wrap gap-1 max-h-28 overflow-y-auto content-start">
{orderedSelectedModels.map((modelId) => {
if (modelId === CLAUDE_CODE_DEFAULT_MODEL_ID) {
return (
<div key={modelId} className="flex flex-col gap-1 basis-full">
<span className="inline-flex w-fit items-center gap-0.5 px-1.5 py-0.5 bg-primary/10 text-text-main text-[10px] rounded border border-primary/35">
{selectedProviderCount > 0 && (
<div className="flex flex-col gap-1">
<span className="text-[10px] font-semibold uppercase tracking-wider text-text-muted">
{tc("providers")}
</span>
<div className="flex flex-wrap gap-1">
{orderedSelectedProviderScopes.map((scope) => {
if (scope === CLAUDE_CODE_DEFAULT_MODEL_ID) {
return (
<div key={scope} className="flex flex-col gap-1 basis-full">
<span className="inline-flex w-fit items-center gap-0.5 px-1.5 py-0.5 bg-primary/10 text-text-main text-[10px] rounded border border-primary/35">
<button
type="button"
onClick={() => setClaudeCodeFamiliesExpanded((prev) => !prev)}
className="inline-flex items-center gap-1 font-mono text-text-main"
title={t("expandClaudeCodeFamilies")}
aria-expanded={claudeCodeFamiliesExpanded}
>
<span className="truncate max-w-[140px]" title={scope}>
{CLAUDE_CODE_DEFAULT_MODEL_NAME}
</span>
<span className="material-symbols-outlined text-[12px] text-primary">
{claudeCodeFamiliesExpanded ? "expand_less" : "expand_more"}
</span>
</button>
<button
type="button"
onClick={() => handleToggleModel(scope)}
className="text-text-muted hover:text-red-500 transition-colors"
title={t("removeClaudeCodeDefault")}
>
<span className="material-symbols-outlined text-[12px]">close</span>
</button>
</span>
{claudeCodeFamiliesExpanded && (
<div className="relative ml-2 flex flex-wrap gap-1 pl-5 animate-in fade-in slide-in-from-top-1 duration-150">
<span
aria-hidden="true"
className="pointer-events-none absolute left-1.5 top-0 bottom-1 w-px bg-primary/25"
/>
<span
aria-hidden="true"
className="pointer-events-none absolute left-1.5 top-3 h-px w-3 bg-primary/25"
/>
{visibleClaudeCodeFamilies.map((family) => {
const canBlock = family.id !== "other";
return (
<span
key={family.id}
className={`inline-flex items-center gap-0.5 px-1.5 py-0.5 text-[10px] rounded border ${
canBlock
? "bg-white dark:bg-surface text-text-main border-border"
: "bg-black/5 dark:bg-white/5 text-text-muted border-border"
}`}
title={
canBlock
? `Allow ${family.label} family through Claude Code default`
: "Catch-all for other Claude Code models"
}
>
<span className="font-mono">{family.label}</span>
{canBlock && (
<button
type="button"
onClick={() =>
handleBlockClaudeCodeFamily(
family.id as ClaudeCodeBlockableFamilyId
)
}
className="text-text-muted hover:text-red-500 transition-colors"
title={`Block ${family.label} family`}
>
<span className="material-symbols-outlined text-[12px]">
close
</span>
</button>
)}
</span>
);
})}
</div>
)}
</div>
);
}
const provider = scope.slice(0, -2);
return (
<span
key={scope}
className="inline-flex items-center gap-0.5 px-1.5 py-0.5 bg-primary/10 text-text-main text-[10px] rounded border border-primary/35"
title={scope}
>
<span className="font-mono truncate max-w-[120px]">{provider}</span>
<button
type="button"
onClick={() => setClaudeCodeFamiliesExpanded((prev) => !prev)}
className="inline-flex items-center gap-1 font-mono text-text-main"
title={t("expandClaudeCodeFamilies")}
aria-expanded={claudeCodeFamiliesExpanded}
>
<span className="truncate max-w-[140px]" title={modelId}>
{getModelDisplayName(modelId)}
</span>
<span className="material-symbols-outlined text-[12px] text-primary">
{claudeCodeFamiliesExpanded ? "expand_less" : "expand_more"}
</span>
</button>
<button
type="button"
onClick={() => handleToggleModel(modelId)}
onClick={() => handleToggleModel(scope)}
className="text-text-muted hover:text-red-500 transition-colors"
title={t("removeClaudeCodeDefault")}
>
<span className="material-symbols-outlined text-[12px]">close</span>
</button>
</span>
{claudeCodeFamiliesExpanded && (
<div className="relative ml-2 flex flex-wrap gap-1 pl-5 animate-in fade-in slide-in-from-top-1 duration-150">
<span
aria-hidden="true"
className="pointer-events-none absolute left-1.5 top-0 bottom-1 w-px bg-primary/25"
/>
<span
aria-hidden="true"
className="pointer-events-none absolute left-1.5 top-3 h-px w-3 bg-primary/25"
/>
{visibleClaudeCodeFamilies.map((family) => {
const canBlock = family.id !== "other";
return (
<span
key={family.id}
className={`inline-flex items-center gap-0.5 px-1.5 py-0.5 text-[10px] rounded border ${
canBlock
? "bg-white dark:bg-surface text-text-main border-border"
: "bg-black/5 dark:bg-white/5 text-text-muted border-border"
}`}
title={
canBlock
? `Allow ${family.label} family through Claude Code default`
: "Catch-all for other Claude Code models"
}
>
<span className="font-mono">{family.label}</span>
{canBlock && (
<button
type="button"
onClick={() =>
handleBlockClaudeCodeFamily(
family.id as ClaudeCodeBlockableFamilyId
)
}
className="text-text-muted hover:text-red-500 transition-colors"
title={`Block ${family.label} family`}
>
<span className="material-symbols-outlined text-[12px]">
close
</span>
</button>
)}
</span>
);
})}
</div>
)}
</div>
);
}
return (
<span
key={modelId}
className="inline-flex items-center gap-0.5 px-1.5 py-0.5 bg-white dark:bg-surface text-text-main text-[10px] rounded border border-border"
>
<span className="font-mono truncate max-w-[120px]" title={modelId}>
{getModelDisplayName(modelId)}
</span>
<button
type="button"
onClick={() => handleToggleModel(modelId)}
className="text-text-muted hover:text-red-500 transition-colors"
);
})}
</div>
</div>
)}
{selectedModelCount > 0 && (
<div className="flex flex-col gap-1">
<span className="text-[10px] font-semibold uppercase tracking-wider text-text-muted">
{tc("models")}
</span>
<div className="flex flex-wrap gap-1 max-h-28 overflow-y-auto content-start">
{selectedExactModels.map((modelId) => (
<span
key={modelId}
className="inline-flex items-center gap-0.5 px-1.5 py-0.5 bg-white dark:bg-surface text-text-main text-[10px] rounded border border-border"
>
<span className="material-symbols-outlined text-[12px]">close</span>
</button>
</span>
);
})}
</div>
<span className="font-mono truncate max-w-[120px]" title={modelId}>
{getModelDisplayName(modelId)}
</span>
<button
type="button"
onClick={() => handleToggleModel(modelId)}
className="text-text-muted hover:text-red-500 transition-colors"
>
<span className="material-symbols-outlined text-[12px]">close</span>
</button>
</span>
))}
</div>
</div>
)}
</div>
)}
{/* Search and Model Selection (only in restrict mode) */}
{!allowAll && (
<>
<div className="relative">
<Input
value={searchModel}
onChange={(e) => onSearchChange(e.target.value)}
placeholder={t("searchModels")}
icon="search"
/>
{searchModel && (
<button
onClick={() => onSearchChange("")}
className="absolute right-3 top-1/2 -translate-y-1/2 text-text-muted hover:text-text-main"
>
<span className="material-symbols-outlined text-[18px]">close</span>
</button>
)}
</div>
<div className="max-h-[280px] overflow-y-auto border border-border rounded-lg divide-y divide-border">
{modelsByProvider.length === 0 ? (
<div className="flex flex-col items-center justify-center py-6 text-text-muted">
<span className="material-symbols-outlined text-2xl mb-1">search_off</span>
<p className="text-xs">{t("noModelsFound")}</p>
</div>
) : (
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 (
<div key={provider} className="group">
<button
onClick={() => handleToggleExpand(provider)}
className="w-full flex items-center gap-2 px-3 py-2 hover:bg-surface/50 transition-colors text-left"
>
<span
className={`material-symbols-outlined text-base transition-transform duration-200 ${
expandedProviders.has(provider) ? "rotate-90" : ""
}`}
>
chevron_right
</span>
<div className="flex items-center gap-2 flex-1 min-w-0">
<div
className="relative flex items-center cursor-pointer shrink-0"
onClick={(e) => {
e.stopPropagation();
handleToggleProvider(provider, models);
}}
>
<div
className={`w-4 h-4 rounded border-2 transition-colors flex items-center justify-center ${
allSelected
? "bg-primary border-primary"
: someSelected
? "bg-primary/20 border-primary"
: "border-border hover:border-primary/50"
}`}
>
{allSelected && (
<span className="material-symbols-outlined text-white text-[12px]">
check
</span>
)}
{someSelected && !allSelected && (
<span className="material-symbols-outlined text-primary text-[12px]">
remove
</span>
)}
</div>
</div>
<span className="text-xs font-semibold text-text-main truncate">
{provider}
</span>
<span className="text-[10px] text-text-muted bg-surface px-1 py-0.5 rounded shrink-0">
{models.length}
</span>
</div>
{selectedInProvider > 0 && (
<span className="text-[10px] font-medium text-primary bg-primary/10 px-1.5 py-0.5 rounded-full shrink-0">
{selectedInProvider}
</span>
)}
</button>
{/* Expandable model list */}
{expandedProviders.has(provider) && (
<div className="px-3 pb-2 pl-9">
<div className="flex flex-wrap gap-1">
{models.map((model) => {
const isSelected = selectedModels.includes(model.id);
return (
<button
key={model.id}
onClick={() => handleToggleModel(model.id)}
className={`inline-flex items-center gap-1 px-2 py-1 rounded-full text-[10px] font-mono transition-all ${
isSelected
? "bg-primary text-white"
: "bg-surface border border-border text-text-muted hover:border-primary/50 hover:text-text-main"
}`}
title={model.id}
>
{getModelDisplayName(model.id)}
</button>
);
})}
</div>
</div>
)}
</div>
);
})
)}
</div>
</>
<ProviderModelPermissionList
modelsByProvider={modelsByProvider}
allModels={allModels}
selectedModels={selectedModels}
expandedProviders={expandedProviders}
searchModel={searchModel}
onSearchChange={onSearchChange}
onToggleExpand={handleToggleExpand}
onSelectionChange={handleSelectionChange}
getModelDisplayName={getModelDisplayName}
onClaudeCodeDefaultDeselected={handleClaudeCodeDefaultDeselected}
/>
)}
{/* Allowed Connections Section */}

View File

@@ -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<string>, keyId: string): Set<strin
else next.add(keyId);
return next;
}
// ──────────────── Provider-scope (dynamic provider/*) model permissions ────────────────
/** Reference to a catalog model used to resolve provider ownership. */
export interface ProviderModelRef {
id: string;
owned_by?: string;
providerId?: string;
}
export interface ProviderScopeOptions {
/** Canonical provider id (fallback owner for alias-prefixed catalog ids). */
providerId?: string;
/** Canonical owner id (fallback owner when providerId is absent). */
ownedBy?: string;
/** Catalog models used to map alias-prefixed ids to their canonical owner. */
providerModels?: ProviderModelRef[];
}
/** Canonical provider id segment of a wildcard entry ("ollama-cloud/*" → "ollama-cloud"). */
function providerWildcardId(entry: string): string | null {
return entry.endsWith("/*") ? entry.slice(0, -2) : null;
}
/**
* Resolve the canonical owner id of a single entry. Exact model ids resolve
* through the catalog (alias-prefixed ids like `ollamacloud/llama3` map to
* their canonical `owned_by`), falling back to the id's prefix segment and
* then to the provided canonical provider fallback. Wildcards resolve to
* their own provider segment (already canonical).
*/
function resolveEntryProvider(entry: string, options?: ProviderScopeOptions): string {
const wildcard = providerWildcardId(entry);
if (wildcard !== null) return wildcard;
const model = options?.providerModels?.find((m) => 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, unknown>) => 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 };
}

View File

@@ -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<string>;
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<HTMLInputElement>(null);
useEffect(() => {
if (inputRef.current) inputRef.current.indeterminate = indeterminate;
}, [indeterminate]);
return (
<input
ref={inputRef}
type="checkbox"
aria-label={`${provider} model access`}
checked={checked}
onChange={onChange}
className="w-4 h-4 rounded border-border accent-[var(--color-primary)] cursor-pointer shrink-0"
/>
);
}
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<string, ProviderModelRef[]>();
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<string, string | null>(
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 (
<>
<div className="relative">
<Input
value={searchModel}
onChange={(e) => onSearchChange(e.target.value)}
placeholder={t("searchModels")}
icon="search"
/>
{searchModel && (
<button
type="button"
onClick={() => onSearchChange("")}
className="absolute right-3 top-1/2 -translate-y-1/2 text-text-muted hover:text-text-main"
>
<span className="material-symbols-outlined text-[18px]">close</span>
</button>
)}
</div>
<div className="max-h-[280px] overflow-y-auto border border-border rounded-lg divide-y divide-border">
{modelsByProvider.length === 0 ? (
<div className="flex flex-col items-center justify-center py-6 text-text-muted">
<span className="material-symbols-outlined text-2xl mb-1">search_off</span>
<p className="text-xs">{t("noModelsFound")}</p>
</div>
) : (
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 (
<div key={provider} className="group">
<div className="w-full flex items-center gap-2 px-3 py-2 hover:bg-surface/50 transition-colors text-left">
<button
type="button"
onClick={() => onToggleExpand(provider)}
className="flex items-center shrink-0"
aria-label={provider}
>
<span
className={`material-symbols-outlined text-base transition-transform duration-200 ${
expandedProviders.has(provider) ? "rotate-90" : ""
}`}
>
chevron_right
</span>
</button>
<div className="flex items-center gap-2 flex-1 min-w-0">
<ProviderSelectionCheckbox
provider={provider}
checked={allSelected}
indeterminate={someSelected}
onChange={() => {
if (useProviderScope) handleProviderCheckedChange(provider);
else handleSnapshotToggleProvider(provider, models);
}}
/>
<button
type="button"
onClick={() => onToggleExpand(provider)}
className="text-xs font-semibold text-text-main truncate"
>
{provider}
</button>
<span className="text-[10px] text-text-muted bg-surface px-1 py-0.5 rounded shrink-0">
{models.length}
</span>
</div>
{selectedCount > 0 && (
<span className="text-[10px] font-medium text-primary bg-primary/10 px-1.5 py-0.5 rounded-full shrink-0">
{selectedCount}
</span>
)}
</div>
{/* Expandable model list */}
{expandedProviders.has(provider) && (
<div className="px-3 pb-2 pl-9">
<div className="flex flex-wrap gap-1">
{models.map((model) => {
const isSelected =
providerScopeSelected || selectedModels.includes(model.id);
const isToggleable = isModelIndividuallyToggleable(
selectedModels,
model.id,
{ ownedBy: model.owned_by }
);
return (
<button
key={model.id}
type="button"
disabled={!isToggleable}
onClick={() => handleToggleModel(model)}
className={`inline-flex items-center gap-1 px-2 py-1 rounded-full text-[10px] font-mono transition-all ${
isSelected
? "bg-primary text-white"
: "bg-surface border border-border text-text-muted hover:border-primary/50 hover:text-text-main"
} ${isToggleable ? "" : "opacity-70 cursor-not-allowed"}`}
title={model.id}
>
{getModelDisplayName(model.id)}
</button>
);
})}
</div>
</div>
)}
</div>
);
})
)}
</div>
</>
);
});
export default ProviderModelPermissionList;

View File

@@ -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<typeof updateApiKeyPermissions>[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 }),

View File

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

View File

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

View File

@@ -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<ApiKeyRow>(
"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 ||

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -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(),
});

View File

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

View File

@@ -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();
}

View File

@@ -118,6 +118,7 @@ function sanitizeApiKeyForSync(apiKey: unknown): JsonRecord {
"name",
"key",
"machineId",
"modelAccessMode",
"allowedModels",
"allowedCombos",
"allowedConnections",

View File

@@ -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<Response | n
let requestedComboName = comboAccess.comboName;
const hasModelRestrictions =
Boolean(apiKeyInfo.allowedModels?.length) || apiKeyInfo.disableNonPublicModels === true;
apiKeyInfo.modelAccessMode === "restricted" ||
Boolean(apiKeyInfo.allowedModels?.length) ||
apiKeyInfo.disableNonPublicModels === true;
if (!requestedComboName && hasModelRestrictions) {
if (modelStr.startsWith("auto/") || modelStr.startsWith("qtSd/")) {
requestedComboName = modelStr;

View File

@@ -82,6 +82,7 @@ export const setTokenLimitSchema = z
export const updateKeyPermissionsSchema = z
.object({
name: z.string().trim().min(1).max(200).optional(),
modelAccessMode: z.enum(["all", "restricted"]).optional(),
allowedModels: z.array(z.string().trim().min(1)).max(1000).optional(),
allowedCombos: z.array(z.string().trim().min(1).max(200)).max(500).optional(),
allowedConnections: z.array(z.string().uuid()).max(100).optional(),
@@ -116,8 +117,16 @@ export const updateKeyPermissionsSchema = z
chaosModeEnabled: z.boolean().optional(),
})
.superRefine((value, ctx) => {
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 &&

View File

@@ -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<Record<string, unknown>>;
} = {
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/
);
});
});

View File

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

View File

@@ -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<typeof apiKeys.updateApiKeyPermissions>[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<typeof apiKeys.updateApiKeyPermissions>[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<typeof apiKeys.updateApiKeyPermissions>[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<typeof apiKeys.updateApiKeyPermissions>[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<typeof apiKeys.updateApiKeyPermissions>[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<typeof payload>) => void;
let oldCalls = 0;
const oldGate = new Promise<ReturnType<typeof payload>>((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<typeof pageUtils.isRestricted>[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, unknown>) => 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<string, unknown>) => {
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"],
}
);
});

View File

@@ -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<string, unknown> | undefined;
all: (...p: unknown[]) => Record<string, unknown>[];
};
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();
});

View File

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

View File

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