feat(api-keys): strict-mode controls for Claude Code default models (#3776)

Adds per-API-key strict-mode controls for the Claude Code default model surface.

- blocked_models deny-list on API keys (deny-list takes precedence over allow-list), so operators can keep a broad dynamic scope like cc/* while excluding expensive families (opus/sonnet/haiku/fable).
- Claude Code default model (cc/*) UI in the API manager: a collapsible families chip group to block individual families through the default model.
- Permission matching expanded with claude-code candidates (cc/, claude/, short aliases, [1m] suffix) so allow and deny match the same candidate set — a blocked family cannot be bypassed via an alias. Setting-dependent claude routing bypasses the permission cache to avoid stale results.
- Model denials on the Anthropic /v1/messages path return a Claude-shaped error (invalid_request_error) via sanitizeErrorMessage instead of the generic 403.

Synced from the v3.8.23 fork point to release/v3.8.24; restored reasoningTokenBufferEnabled (out-of-scope deletion) and re-baselined file-size for the feature growth. Fast Quality Gates + semgrep green; PR unit tests 74/74; typecheck:core + eslint clean.

Integrated into release/v3.8.24.
This commit is contained in:
Witroch4
2026-06-13 14:41:16 -03:00
committed by GitHub
parent 7219f2b38d
commit d4f253bbcd
10 changed files with 684 additions and 121 deletions

View File

@@ -4,7 +4,7 @@
"frozen": {
"open-sse/config/providerRegistry.ts": 4703,
"open-sse/executors/antigravity.ts": 1572,
"open-sse/executors/base.ts": 1205,
"open-sse/executors/base.ts": 1218,
"open-sse/executors/chatgpt-web.ts": 2870,
"open-sse/executors/claude-web.ts": 1057,
"open-sse/executors/codex.ts": 1439,
@@ -37,7 +37,7 @@
"open-sse/utils/stream.ts": 2710,
"src/app/(dashboard)/dashboard/HomePageClient.tsx": 1385,
"src/app/(dashboard)/dashboard/analytics/ComboHealthTab.tsx": 1020,
"src/app/(dashboard)/dashboard/api-manager/ApiManagerPageClient.tsx": 2701,
"src/app/(dashboard)/dashboard/api-manager/ApiManagerPageClient.tsx": 2909,
"src/app/(dashboard)/dashboard/cache/media/MediaPageClient.tsx": 1105,
"src/app/(dashboard)/dashboard/cache/page.tsx": 841,
"src/app/(dashboard)/dashboard/cli-code/components/CodexToolCard.tsx": 894,
@@ -76,7 +76,7 @@
"src/app/api/usage/analytics/route.ts": 941,
"src/app/api/v1/models/catalog.ts": 1435,
"src/lib/cloudflaredTunnel.ts": 934,
"src/lib/db/apiKeys.ts": 1490,
"src/lib/db/apiKeys.ts": 1633,
"src/lib/db/core.ts": 1820,
"src/lib/db/migrationRunner.ts": 1100,
"src/lib/db/models.ts": 1132,
@@ -100,7 +100,7 @@
"src/shared/constants/providers.ts": 3146,
"src/shared/constants/sidebarVisibility.ts": 990,
"src/shared/services/cliRuntime.ts": 1090,
"src/shared/validation/schemas.ts": 2515,
"src/shared/validation/schemas.ts": 2519,
"src/sse/handlers/chat.ts": 1392,
"src/sse/services/auth.ts": 2207
},
@@ -110,5 +110,6 @@
"_rebaseline_2026_06_12_phase1g1j": "Phase 1g-1j (#3501): ProviderDetailPageClient.tsx 4063→3409 (extraídos ProviderPlaygroundPanel, useCommandCodeAuth, useExternalLinkFlow+ExternalLinkModal, useAuthFileHandlers — zero lógica nova). models/route.ts 2344→2426: drift do #3712 (vertex dynamic model discovery) reconciliado aqui.",
"_rebaseline_2026_06_12_phase1n1s": "Phase 1n-1s (#3501): ProviderDetailPageClient.tsx 2554→1376 (extraídos ConnectionsListPanel, ConnectionsHeaderToolbar, ZedImportCard, BatchTestResultsModal, AdaptaTutorialModal + hooks/useApiKeySave + 4 helper closures→providerPageHelpers.ts). providerPageHelpers.ts 822→897 justificado: recebe 4 closures do god-component (getApiLabel/getApiDefaultPath/getApiPath/getHeaderIconProviderId), zero lógica nova, cliente encolhe mais do que helpers crescem.",
"_rebaseline_2026_06_12_phase1t": "Phase 1t (#3501): ProviderDetailPageClient.tsx 1377→782 — META ≤800 ATINGIDA (extraídos ProviderPageHeader, CompatibleNodeCard, ProviderModalsPanel, EmptyConnectionsPlaceholder, UpstreamProxyCard, SearchProviderCard + hooks useConnectionGate/useProviderNodeActions). Drift concorrente reconciliado: ResilienceTab/sse-chat/sse-auth/accountFallback/combo (merges #3629 model-lockout etc.).",
"_rebaseline_2026_06_12_v3823_new_features": "Re-baseline v3.8.23 pós-merge de #3742 (cost drilldown: ApiManagerPageClient.tsx +21, CostOverviewTab.tsx +14, providerLimits.ts +2, usage.ts +53) + #3743 (provider display modes: ProviderDetailPageClient.tsx +2, providerPageHelpers.ts +42, providers.ts +2) + #3740 (semantic cache key isolation: chat.ts +3). Crescimento justificado por features novas mergeadas no ciclo."
"_rebaseline_2026_06_12_v3823_new_features": "Re-baseline v3.8.23 pós-merge de #3742 (cost drilldown: ApiManagerPageClient.tsx +21, CostOverviewTab.tsx +14, providerLimits.ts +2, usage.ts +53) + #3743 (provider display modes: ProviderDetailPageClient.tsx +2, providerPageHelpers.ts +42, providers.ts +2) + #3740 (semantic cache key isolation: chat.ts +3). Crescimento justificado por features novas mergeadas no ciclo.",
"_rebaseline_2026_06_13_v3824_3776": "Re-baseline v3.8.24 pós #3776 (strict-mode controls Claude Code default models: ApiManagerPageClient.tsx 2701→2909 = UI de famílias bloqueáveis cc/* + chips; apiKeys.ts 1490→1633 = blocked_models deny-list + candidatos de permissão claude-code; schemas.ts 2515→2519 = reformatação Prettier + reasoningTokenBufferEnabled restaurado) + carry-over base.ts 1205→1218 do #3780 (enforceThinkingTemperature no chokepoint, drift de baseline não bumpado no merge). Crescimento de feature; sem god-component novo."
}

View File

@@ -23,6 +23,26 @@ import { SELF_ACCOUNT_QUOTA_SCOPE, SELF_USAGE_SCOPE } from "@/shared/constants/s
// Constants for validation
const MAX_KEY_NAME_LENGTH = 200;
const MAX_SELECTED_MODELS = 500;
const CLAUDE_CODE_DEFAULT_MODEL_ID = "cc/*";
const CLAUDE_CODE_DEFAULT_MODEL_NAME = "Claude Code default";
const CLAUDE_CODE_DEFAULT_FAMILIES = [
{ id: "other", label: "other" },
{ id: "fable", label: "fable" },
{ id: "opus", label: "opus" },
{ id: "sonnet", label: "sonnet" },
{ id: "haiku", label: "haiku" },
] as const;
type ClaudeCodeFamilyId = (typeof CLAUDE_CODE_DEFAULT_FAMILIES)[number]["id"];
type ClaudeCodeBlockableFamilyId = Exclude<ClaudeCodeFamilyId, "other">;
const CLAUDE_CODE_FAMILY_BLOCK_PATTERNS: Record<ClaudeCodeBlockableFamilyId, string[]> = {
fable: ["claude-fable*", "fable"],
opus: ["claude-opus*", "opus"],
sonnet: ["claude-sonnet*", "sonnet"],
haiku: ["claude-haiku*", "haiku"],
};
const CLAUDE_CODE_BLOCK_PATTERN_SET = new Set(
Object.values(CLAUDE_CODE_FAMILY_BLOCK_PATTERNS).flat()
);
function toLocalDateTimeInputValue(value: string | null | undefined): string {
if (!value) return "";
@@ -92,6 +112,7 @@ interface ApiKey {
name: string;
key: string;
allowedModels: string[] | null;
blockedModels?: string[] | null;
allowedCombos: string[] | null;
allowedConnections: string[] | null;
noLog?: boolean;
@@ -137,6 +158,7 @@ function formatUsdCost(value: number, locale: string): string {
interface Model {
id: string;
owned_by: string;
name?: string;
}
interface ComboOption {
@@ -148,6 +170,41 @@ interface ComboOption {
/** Tuple type for models grouped by provider: [providerName, models[]] */
type ProviderGroup = [provider: string, models: Model[]];
function isClaudeCodeModel(model: Model): boolean {
return (
model.owned_by === "claude" || model.id.startsWith("cc/") || model.id.startsWith("claude/")
);
}
function withClaudeCodeDefaultModel(models: Model[]): Model[] {
if (!models.some(isClaudeCodeModel)) return models;
if (models.some((model) => model.id === CLAUDE_CODE_DEFAULT_MODEL_ID)) return models;
return [
{
id: CLAUDE_CODE_DEFAULT_MODEL_ID,
name: CLAUDE_CODE_DEFAULT_MODEL_NAME,
owned_by: "claude",
},
...models,
];
}
function getBlockedClaudeCodeFamilies(blockedModels: string[]): ClaudeCodeBlockableFamilyId[] {
return (Object.keys(CLAUDE_CODE_FAMILY_BLOCK_PATTERNS) as ClaudeCodeBlockableFamilyId[]).filter(
(familyId) =>
CLAUDE_CODE_FAMILY_BLOCK_PATTERNS[familyId].some((pattern) => blockedModels.includes(pattern))
);
}
function isClaudeCodeFamilyModel(modelId: string, familyId: ClaudeCodeBlockableFamilyId): boolean {
const normalized = modelId.toLowerCase();
return (
normalized === familyId ||
normalized.includes(`/${familyId}`) ||
normalized.includes(`-${familyId}`)
);
}
export default function ApiManagerPageClient() {
const t = useTranslations("apiManager");
const tc = useTranslations("common");
@@ -437,6 +494,7 @@ export default function ApiManagerPageClient() {
const quotaKeys = filteredKeys.filter(isQuotaKey);
const normalKeys = filteredKeys.filter((k) => !isQuotaKey(k));
const permissionModels = useMemo(() => withClaudeCodeDefaultModel(allModels), [allModels]);
const quotaGroupsForKey = (k: ApiKey): string[] => {
if (!Array.isArray(k.allowedQuotas)) return [];
@@ -600,7 +658,8 @@ export default function ApiManagerPageClient() {
scopes: string[],
allowedEndpoints: string[],
streamDefaultMode: StreamDefaultMode,
disableNonPublicModels: boolean
disableNonPublicModels: boolean,
blockedModels: string[]
) => {
if (!editingKey || !editingKey.id) return;
@@ -620,6 +679,9 @@ export default function ApiManagerPageClient() {
const validModels = allowedModels.filter(
(id) => typeof id === "string" && id.length > 0 && id.length < 200
);
const validBlockedModels = blockedModels.filter(
(id) => typeof id === "string" && id.length > 0 && id.length < 200
);
const validCombos = allowedCombos.filter(
(name) => typeof name === "string" && name.trim().length > 0 && name.length < 200
@@ -648,6 +710,7 @@ export default function ApiManagerPageClient() {
body: JSON.stringify({
name: sanitizedName,
allowedModels: validModels,
blockedModels: validBlockedModels,
allowedCombos: validCombos,
allowedConnections: validConnections,
noLog,
@@ -690,14 +753,14 @@ export default function ApiManagerPageClient() {
// ids like "openai-compatible-chat-<uuid>" into the grouping label)
const modelsByProvider = useMemo((): ProviderGroup[] => {
const grouped: Record<string, Model[]> = {};
for (const model of allModels) {
for (const model of permissionModels) {
const provider =
getProviderDisplayName(model.owned_by) || model.owned_by || t("unknownProvider");
if (!grouped[provider]) grouped[provider] = [];
grouped[provider].push(model);
}
return Object.entries(grouped).sort((a, b) => compareTr(a[0], b[0]));
}, [allModels, t]);
}, [permissionModels, t]);
// Filter models based on debounced search
const filteredModelsByProvider = useMemo((): ProviderGroup[] => {
@@ -710,6 +773,7 @@ export default function ApiManagerPageClient() {
models.filter(
(m) =>
matchesSearch(m.id, debouncedSearchModel) ||
matchesSearch(m.name || "", debouncedSearchModel) ||
matchesSearch(provider, debouncedSearchModel)
),
]
@@ -1100,8 +1164,8 @@ export default function ApiManagerPageClient() {
<a
href={`/dashboard/costs?range=all&apiKeyIds=${encodeURIComponent(key.id)}&groupBy=model`}
className="p-2 hover:bg-emerald-500/10 rounded text-text-muted hover:text-emerald-500 opacity-0 group-hover:opacity-100 transition-all"
title={t("viewCostsFor", { name: key.name })}
aria-label={t("viewCostsFor", { name: key.name })}
title={`View costs for ${key.name}`}
aria-label={`View costs for ${key.name}`}
>
<span className="material-symbols-outlined text-[18px]">payments</span>
</a>
@@ -1406,7 +1470,7 @@ export default function ApiManagerPageClient() {
}}
apiKey={editingKey}
modelsByProvider={filteredModelsByProvider}
allModels={allModels}
allModels={permissionModels}
allCombos={allCombos}
allConnections={allConnections}
searchModel={searchModel}
@@ -1458,7 +1522,8 @@ const PermissionsModal = memo(function PermissionsModal({
scopes: string[],
allowedEndpoints: string[],
streamDefaultMode: StreamDefaultMode,
disableNonPublicModels: boolean
disableNonPublicModels: boolean,
blockedModels: string[]
) => void;
}) {
const t = useTranslations("apiManager");
@@ -1466,12 +1531,20 @@ const PermissionsModal = memo(function PermissionsModal({
// Initialize state from props - component remounts when key prop changes
const initialModels = Array.isArray(apiKey?.allowedModels) ? apiKey.allowedModels : [];
const initialBlockedModels = useMemo(
() => (Array.isArray(apiKey?.blockedModels) ? apiKey.blockedModels : []),
[apiKey?.blockedModels]
);
const initialCombos = Array.isArray(apiKey?.allowedCombos) ? apiKey.allowedCombos : [];
const initialConnections = Array.isArray(apiKey?.allowedConnections)
? apiKey.allowedConnections
: [];
const [keyName, setKeyName] = useState(apiKey?.name ?? "");
const [selectedModels, setSelectedModels] = useState<string[]>(initialModels);
const [blockedClaudeCodeFamilies, setBlockedClaudeCodeFamilies] = useState<
ClaudeCodeBlockableFamilyId[]
>(() => getBlockedClaudeCodeFamilies(initialBlockedModels));
const [claudeCodeFamiliesExpanded, setClaudeCodeFamiliesExpanded] = useState(false);
const [selectedCombos, setSelectedCombos] = useState<string[]>(initialCombos);
const [allowAll, setAllowAll] = useState(initialModels.length === 0);
const [allowAllCombos, setAllowAllCombos] = useState(initialCombos.length === 0);
@@ -1530,6 +1603,11 @@ const PermissionsModal = memo(function PermissionsModal({
const [disableNonPublicModels, setDisableNonPublicModels] = useState(
apiKey?.disableNonPublicModels === true
);
const getModelDisplayName = useCallback(
(modelId: string) =>
modelId === CLAUDE_CODE_DEFAULT_MODEL_ID ? CLAUDE_CODE_DEFAULT_MODEL_NAME : modelId,
[]
);
// Memoize callbacks to prevent child re-renders
const handleToggleModel = useCallback(
@@ -1538,6 +1616,9 @@ const PermissionsModal = memo(function PermissionsModal({
setSelectedModels((prev) => {
if (prev.includes(modelId)) {
if (modelId === CLAUDE_CODE_DEFAULT_MODEL_ID) {
setClaudeCodeFamiliesExpanded(false);
}
return prev.filter((m) => m !== modelId);
}
return [...prev, modelId];
@@ -1565,6 +1646,8 @@ const PermissionsModal = memo(function PermissionsModal({
const handleSelectAll = useCallback(() => {
setAllowAll(true);
setSelectedModels([]);
setBlockedClaudeCodeFamilies([]);
setClaudeCodeFamiliesExpanded(false);
}, []);
const handleRestrictMode = useCallback(() => {
@@ -1589,10 +1672,21 @@ const PermissionsModal = memo(function PermissionsModal({
const handleSelectAllModels = useCallback(() => {
const allModelIds = allModels.map((m) => m.id);
setSelectedModels(allModelIds);
setBlockedClaudeCodeFamilies([]);
setClaudeCodeFamiliesExpanded(false);
}, [allModels]);
const handleDeselectAllModels = useCallback(() => {
setSelectedModels([]);
setBlockedClaudeCodeFamilies([]);
setClaudeCodeFamiliesExpanded(false);
}, []);
const handleBlockClaudeCodeFamily = useCallback((familyId: ClaudeCodeBlockableFamilyId) => {
setBlockedClaudeCodeFamilies((prev) => (prev.includes(familyId) ? prev : [...prev, familyId]));
setSelectedModels((prev) =>
prev.filter((modelId) => !isClaudeCodeFamilyModel(modelId, familyId))
);
}, []);
const handleToggleCombo = useCallback(
@@ -1660,6 +1754,16 @@ const PermissionsModal = memo(function PermissionsModal({
tz: scheduleTz,
}
: null;
const hasClaudeCodeDefaultSelected =
!allowAll && selectedModels.includes(CLAUDE_CODE_DEFAULT_MODEL_ID);
const blockedModels = initialBlockedModels.filter(
(pattern) => !CLAUDE_CODE_BLOCK_PATTERN_SET.has(pattern)
);
if (hasClaudeCodeDefaultSelected) {
for (const familyId of blockedClaudeCodeFamilies) {
blockedModels.push(...CLAUDE_CODE_FAMILY_BLOCK_PATTERNS[familyId]);
}
}
onSave(
keyName,
allowAll ? [] : selectedModels,
@@ -1681,7 +1785,8 @@ const PermissionsModal = memo(function PermissionsModal({
}),
allowAllEndpoints ? [] : selectedEndpoints,
streamDefaultMode,
disableNonPublicModels
disableNonPublicModels,
blockedModels
);
}, [
onSave,
@@ -1712,12 +1817,32 @@ const PermissionsModal = memo(function PermissionsModal({
selectedEndpoints,
streamDefaultMode,
disableNonPublicModels,
blockedClaudeCodeFamilies,
initialBlockedModels,
apiKey?.scopes,
t,
]);
const selectedCount = selectedModels.length;
const totalModels = allModels.length;
const hasClaudeCodeDefaultSelected =
!allowAll && selectedModels.includes(CLAUDE_CODE_DEFAULT_MODEL_ID);
const orderedSelectedModels = useMemo(() => {
if (!hasClaudeCodeDefaultSelected) return selectedModels;
return [
CLAUDE_CODE_DEFAULT_MODEL_ID,
...selectedModels.filter((modelId) => modelId !== CLAUDE_CODE_DEFAULT_MODEL_ID),
];
}, [hasClaudeCodeDefaultSelected, selectedModels]);
const visibleClaudeCodeFamilies = useMemo(
() =>
CLAUDE_CODE_DEFAULT_FAMILIES.filter(
(family) =>
family.id === "other" ||
!blockedClaudeCodeFamilies.includes(family.id as ClaudeCodeBlockableFamilyId)
),
[blockedClaudeCodeFamilies]
);
return (
<Modal
@@ -2298,23 +2423,106 @@ const PermissionsModal = memo(function PermissionsModal({
</button>
</div>
</div>
<div className="flex flex-wrap gap-1 max-h-16 overflow-y-auto content-start">
{selectedModels.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="font-mono truncate max-w-[120px]" title={modelId}>
{modelId}
</span>
<button
onClick={() => handleToggleModel(modelId)}
className="text-text-muted hover:text-red-500 transition-colors"
<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">
<button
type="button"
onClick={() => setClaudeCodeFamiliesExpanded((prev) => !prev)}
className="inline-flex items-center gap-1 font-mono text-text-main"
title="Expand Claude Code families"
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)}
className="text-text-muted hover:text-red-500 transition-colors"
title="Remove Claude Code default"
>
<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="material-symbols-outlined text-[12px]">close</span>
</button>
</span>
))}
<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>
)}
@@ -2426,7 +2634,7 @@ const PermissionsModal = memo(function PermissionsModal({
}`}
title={model.id}
>
{model.id}
{getModelDisplayName(model.id)}
</button>
);
})}

View File

@@ -66,6 +66,7 @@ export async function PATCH(request, { params }) {
const {
name,
allowedModels,
blockedModels,
allowedCombos,
allowedConnections,
noLog,
@@ -86,6 +87,7 @@ export async function PATCH(request, { params }) {
const payload: Parameters<typeof updateApiKeyPermissions>[1] = {};
if (name !== undefined) payload.name = name;
if (allowedModels !== undefined) payload.allowedModels = allowedModels;
if (blockedModels !== undefined) payload.blockedModels = blockedModels;
if (allowedCombos !== undefined) payload.allowedCombos = allowedCombos;
if (allowedConnections !== undefined) payload.allowedConnections = allowedConnections;
if (noLog !== undefined) payload.noLog = noLog;
@@ -100,7 +102,8 @@ export async function PATCH(request, { params }) {
if (scopes !== undefined) payload.scopes = scopes;
if (allowedEndpoints !== undefined) payload.allowedEndpoints = allowedEndpoints;
if (streamDefaultMode !== undefined) payload.streamDefaultMode = streamDefaultMode;
if (disableNonPublicModels !== undefined) payload.disableNonPublicModels = disableNonPublicModels;
if (disableNonPublicModels !== undefined)
payload.disableNonPublicModels = disableNonPublicModels;
const updated = await updateApiKeyPermissions(id, payload);
if (!updated) {
@@ -114,6 +117,7 @@ export async function PATCH(request, { params }) {
message: "API key settings updated successfully",
...(name !== undefined && { name }),
...(allowedModels !== undefined && { allowedModels }),
...(blockedModels !== undefined && { blockedModels }),
...(allowedCombos !== undefined && { allowedCombos }),
...(allowedConnections !== undefined && { allowedConnections }),
...(noLog !== undefined && { noLog }),

View File

@@ -10,11 +10,7 @@ import { registerDbStateResetter } from "./stateReset";
import { getKeyGroupsForApiKey, checkKeyModelAccess } from "./apiKeyGroups";
import { setNoLog } from "../compliance/noLog";
import { resolveModelAlias } from "@omniroute/open-sse/services/modelDeprecation.ts";
import {
getSyncedAvailableModelsByConnection,
getCustomModels,
getModelIsHidden,
} from "./models";
import { getSyncedAvailableModelsByConnection, getCustomModels, getModelIsHidden } from "./models";
// ──────────────── Performance Optimizations ────────────────
@@ -46,6 +42,7 @@ interface ApiKeyMetadata {
name: string;
machineId: string | null;
allowedModels: string[];
blockedModels: string[];
allowedCombos: string[];
allowedConnections: string[];
allowedQuotas: string[];
@@ -80,6 +77,8 @@ interface ApiKeyRow extends JsonRecord {
machineId?: unknown;
allowed_models?: unknown;
allowedModels?: unknown;
blocked_models?: unknown;
blockedModels?: unknown;
allowed_combos?: unknown;
allowedCombos?: unknown;
allowed_connections?: unknown;
@@ -124,6 +123,7 @@ interface ApiKeysStatements {
interface ApiKeyView extends JsonRecord {
id?: string;
allowedModels: string[];
blockedModels: string[];
allowedCombos: string[];
allowedConnections: string[];
allowedQuotas: string[];
@@ -149,12 +149,15 @@ const _lastUsedUpdateCache = new Map<string, number>();
const CACHE_TTL = 60 * 1000; // 1 minute TTL
const LAST_USED_UPDATE_TTL = 5 * 60 * 1000;
const MAX_CACHE_SIZE = 1000;
const CLAUDE_CODE_PROVIDER_PREFIXES = new Set(["cc", "claude"]);
const CLAUDE_CODE_SHORT_ALIASES = new Set(["sonnet", "opus", "haiku", "fable"]);
// Wildcard scope matching is now handled by `matchesWildcardPattern`
// (deterministic, no RegExp from dynamic strings).
const API_KEY_COLUMN_FALLBACKS = [
{ name: "allowed_models", definition: "allowed_models TEXT" },
{ 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" },
{ name: "allowed_connections", definition: "allowed_connections TEXT" },
@@ -178,7 +181,10 @@ const API_KEY_COLUMN_FALLBACKS = [
{ name: "allowed_endpoints", definition: "allowed_endpoints TEXT" },
{ name: "allowed_quotas", definition: "allowed_quotas TEXT NOT NULL DEFAULT '[]'" },
{ name: "stream_default_mode", definition: "stream_default_mode TEXT NOT NULL DEFAULT 'legacy'" },
{ name: "disable_non_public_models", definition: "disable_non_public_models INTEGER NOT NULL DEFAULT 0" },
{
name: "disable_non_public_models",
definition: "disable_non_public_models INTEGER NOT NULL DEFAULT 0",
},
] as const;
// Cache for model permission checks
@@ -273,6 +279,124 @@ function evictIfNeeded<TKey, TValue>(cache: Map<TKey, TValue>) {
}
}
function isTruthyEnvFlag(value: string | undefined): boolean {
return typeof value === "string" && /^(1|true|yes|on)$/i.test(value.trim());
}
async function preferClaudeCodeForUnprefixedClaudeModels(): Promise<boolean> {
try {
const { getCachedSettings } = await import("./readCache");
const settings = await getCachedSettings();
if (typeof settings.preferClaudeCodeForUnprefixedClaudeModels === "boolean") {
return settings.preferClaudeCodeForUnprefixedClaudeModels;
}
} catch {
// Standalone DB usage may not have the settings cache ready.
}
return isTruthyEnvFlag(process.env.OMNIROUTE_PREFER_CLAUDE_CODE_FOR_UNPREFIXED_CLAUDE_MODELS);
}
function stripExtendedContextSuffix(modelId: string): string {
return modelId.endsWith("[1m]") ? modelId.slice(0, -4) : modelId;
}
function isPotentialUnprefixedClaudeCodeModel(modelId: string): boolean {
const clean = stripExtendedContextSuffix(modelId.trim());
return /^claude-/i.test(clean) || CLAUDE_CODE_SHORT_ALIASES.has(clean.toLowerCase());
}
function addModelCandidate(candidates: Set<string>, modelId: string): void {
const clean = modelId.trim();
if (!clean) return;
candidates.add(clean);
candidates.add(stripExtendedContextSuffix(clean));
}
async function getModelPermissionCandidates(modelId: string): Promise<string[]> {
const candidates = new Set<string>();
addModelCandidate(candidates, modelId);
const cleanModelId = stripExtendedContextSuffix(modelId.trim());
if (!cleanModelId) return Array.from(candidates);
if (cleanModelId.includes("/")) {
const firstSlash = cleanModelId.indexOf("/");
const providerOrAlias = cleanModelId.slice(0, firstSlash);
const providerScopedModel = cleanModelId.slice(firstSlash + 1);
if (CLAUDE_CODE_PROVIDER_PREFIXES.has(providerOrAlias) && providerScopedModel) {
addModelCandidate(candidates, providerScopedModel);
addModelCandidate(candidates, `cc/${providerScopedModel}`);
addModelCandidate(candidates, `claude/${providerScopedModel}`);
}
return Array.from(candidates);
}
if (
isPotentialUnprefixedClaudeCodeModel(cleanModelId) &&
(await preferClaudeCodeForUnprefixedClaudeModels())
) {
addModelCandidate(candidates, `cc/${cleanModelId}`);
addModelCandidate(candidates, `claude/${cleanModelId}`);
}
return Array.from(candidates);
}
function modelPatternMatches(pattern: string, candidates: string[]): boolean {
for (const candidate of candidates) {
if (pattern === candidate) return true;
if (pattern.endsWith("/*")) {
const prefix = pattern.slice(0, -2);
if (candidate.startsWith(prefix + "/") || candidate.startsWith(prefix)) {
return true;
}
}
if (pattern.includes("*") && matchesWildcardPattern(pattern, candidate)) {
return true;
}
}
return false;
}
function hasClaudeCodeWildcardPermission(
allowedModels: string[] | undefined,
candidates: string[]
): boolean {
if (!allowedModels || allowedModels.length === 0) return false;
return allowedModels.some(
(pattern) =>
(pattern === "cc/*" || pattern === "claude/*") &&
candidates.some((candidate) => modelPatternMatches(pattern, [candidate]))
);
}
async function getPublishedModelLookupTarget(
modelId: string
): Promise<{ providerId: string; modelId: string } | null> {
const cleanModelId = stripExtendedContextSuffix(modelId.trim());
if (!cleanModelId) return null;
if (cleanModelId.includes("/")) {
const firstSlash = cleanModelId.indexOf("/");
const providerOrAlias = cleanModelId.slice(0, firstSlash);
const providerScopedModel = cleanModelId.slice(firstSlash + 1);
if (!providerScopedModel) return null;
const providerId = CLAUDE_CODE_PROVIDER_PREFIXES.has(providerOrAlias)
? "claude"
: providerOrAlias;
return { providerId, modelId: providerScopedModel };
}
if (
isPotentialUnprefixedClaudeCodeModel(cleanModelId) &&
(await preferClaudeCodeForUnprefixedClaudeModels())
) {
return { providerId: "claude", modelId: cleanModelId };
}
return null;
}
/**
* Match an API-key wildcard scope pattern against a model id without
* compiling a RegExp from string concatenation (avoid ReDoS exposure on
@@ -378,7 +502,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, 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, disable_non_public_models, proxy_id FROM api_keys WHERE key = ? OR key_hash = ?"
"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, disable_non_public_models, 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 (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)"
@@ -414,6 +538,7 @@ export async function getApiKeys() {
return rows.map((row) => {
const camelRow = toRecord(rowToCamel(row)) as ApiKeyView;
camelRow.allowedModels = parseAllowedModels(camelRow.allowedModels);
camelRow.blockedModels = parseAllowedModels(camelRow.blockedModels);
camelRow.allowedCombos = parseAllowedCombos(camelRow.allowedCombos);
camelRow.allowedConnections = parseAllowedConnections(camelRow.allowedConnections);
camelRow.allowedQuotas = parseAllowedQuotas((camelRow as JsonRecord).allowedQuotas);
@@ -426,7 +551,9 @@ export async function getApiKeys() {
camelRow.scopes = parseStringList((camelRow as JsonRecord).scopes);
camelRow.allowedEndpoints = parseStringList((camelRow as JsonRecord).allowedEndpoints);
camelRow.streamDefaultMode = parseStreamDefaultMode((camelRow as JsonRecord).streamDefaultMode);
camelRow.disableNonPublicModels = parseDisableNonPublicModels((camelRow as JsonRecord).disableNonPublicModels);
camelRow.disableNonPublicModels = parseDisableNonPublicModels(
(camelRow as JsonRecord).disableNonPublicModels
);
if (typeof camelRow.id === "string" && camelRow.id.length > 0) {
setNoLog(camelRow.id, camelRow.noLog === true);
}
@@ -441,6 +568,7 @@ export async function getApiKeyById(id: string) {
if (!row) return null;
const camelRow = toRecord(rowToCamel(row)) as ApiKeyView;
camelRow.allowedModels = parseAllowedModels(camelRow.allowedModels);
camelRow.blockedModels = parseAllowedModels(camelRow.blockedModels);
camelRow.allowedCombos = parseAllowedCombos(camelRow.allowedCombos);
camelRow.allowedConnections = parseAllowedConnections(camelRow.allowedConnections);
camelRow.allowedQuotas = parseAllowedQuotas((camelRow as JsonRecord).allowedQuotas);
@@ -453,7 +581,9 @@ export async function getApiKeyById(id: string) {
camelRow.scopes = parseStringList((camelRow as JsonRecord).scopes);
camelRow.allowedEndpoints = parseStringList((camelRow as JsonRecord).allowedEndpoints);
camelRow.streamDefaultMode = parseStreamDefaultMode((camelRow as JsonRecord).streamDefaultMode);
camelRow.disableNonPublicModels = parseDisableNonPublicModels((camelRow as JsonRecord).disableNonPublicModels);
camelRow.disableNonPublicModels = parseDisableNonPublicModels(
(camelRow as JsonRecord).disableNonPublicModels
);
if (typeof camelRow.id === "string" && camelRow.id.length > 0) {
setNoLog(camelRow.id, camelRow.noLog === true);
}
@@ -699,6 +829,7 @@ export async function updateApiKeyPermissions(
| {
name?: string;
allowedModels?: string[];
blockedModels?: string[];
allowedCombos?: string[];
allowedConnections?: string[];
allowedQuotas?: string[];
@@ -730,6 +861,7 @@ export async function updateApiKeyPermissions(
: {
name: update.name,
allowedModels: update.allowedModels,
blockedModels: update.blockedModels,
allowedCombos: update.allowedCombos,
allowedConnections: update.allowedConnections,
allowedQuotas: (update as { allowedQuotas?: string[] }).allowedQuotas,
@@ -756,6 +888,7 @@ export async function updateApiKeyPermissions(
if (
normalized.name === undefined &&
normalized.allowedModels === undefined &&
normalized.blockedModels === undefined &&
normalized.allowedCombos === undefined &&
normalized.allowedConnections === undefined &&
(normalized as Record<string, unknown>).allowedQuotas === undefined &&
@@ -784,6 +917,7 @@ export async function updateApiKeyPermissions(
id: string;
name?: string;
allowedModels?: string;
blockedModels?: string;
allowedCombos?: string;
allowedConnections?: string;
allowedQuotas?: string;
@@ -815,6 +949,12 @@ export async function updateApiKeyPermissions(
params.allowedModels = JSON.stringify(normalized.allowedModels || []);
}
if (normalized.blockedModels !== undefined) {
// Deny-list patterns always take precedence over allowed_models.
updates.push("blocked_models = @blockedModels");
params.blockedModels = JSON.stringify(normalized.blockedModels || []);
}
if (normalized.allowedCombos !== undefined) {
// Empty array means no explicit combo restriction; legacy allowed_models rules still apply.
updates.push("allowed_combos = @allowedCombos");
@@ -1251,6 +1391,7 @@ export async function getApiKeyMetadata(
name: "Environment Key",
machineId: "server-env",
allowedModels: [],
blockedModels: [],
allowedCombos: [],
allowedConnections: [],
allowedQuotas: [],
@@ -1306,6 +1447,7 @@ export async function getApiKeyMetadata(
name: metadataName,
machineId: metadataMachineId,
allowedModels: parseAllowedModels(record.allowed_models ?? record.allowedModels),
blockedModels: parseAllowedModels(record.blocked_models ?? record.blockedModels),
allowedCombos: parseAllowedCombos(record.allowed_combos ?? record.allowedCombos),
allowedConnections: parseAllowedConnections(
record.allowed_connections ?? record.allowedConnections
@@ -1331,9 +1473,7 @@ export async function getApiKeyMetadata(
isBanned: parseIsBanned(record.is_banned ?? (record as JsonRecord).isBanned),
keyHash: (record.key_hash ?? (record as JsonRecord).keyHash) as string | null,
proxyId:
typeof record.proxy_id === "string" && record.proxy_id.trim() !== ""
? record.proxy_id
: null,
typeof record.proxy_id === "string" && record.proxy_id.trim() !== "" ? record.proxy_id : null,
allowedEndpoints: parseStringList(
(record as JsonRecord).allowed_endpoints ?? (record as JsonRecord).allowedEndpoints
),
@@ -1341,7 +1481,8 @@ export async function getApiKeyMetadata(
(record as JsonRecord).stream_default_mode ?? (record as JsonRecord).streamDefaultMode
),
disableNonPublicModels: parseDisableNonPublicModels(
(record as JsonRecord).disable_non_public_models ?? (record as JsonRecord).disableNonPublicModels
(record as JsonRecord).disable_non_public_models ??
(record as JsonRecord).disableNonPublicModels
),
};
@@ -1376,10 +1517,11 @@ export async function isModelAllowedForKey(
// Create cache key
const cacheKey = `${key}:${modelId}`;
const now = Date.now();
const usesSettingDependentClaudeRouting = isPotentialUnprefixedClaudeCodeModel(modelId);
// Check permission cache
const cached = _modelPermissionCache.get(cacheKey);
if (cached && now - cached.timestamp < CACHE_TTL) {
if (!usesSettingDependentClaudeRouting && cached && now - cached.timestamp < CACHE_TTL) {
return cached.allowed;
}
@@ -1387,25 +1529,39 @@ export async function isModelAllowedForKey(
// SECURITY: Key not found in database = deny access (invalid/non-existent key)
if (!metadata) return false;
const { allowedModels, disableNonPublicModels } = metadata;
const { allowedModels, blockedModels, disableNonPublicModels } = metadata;
const modelPermissionCandidates = await getModelPermissionCandidates(modelId);
// Deny-list patterns win over any allow-list entry. This lets operators keep
// broad dynamic scopes like cc/* while excluding expensive families.
if (blockedModels?.some((pattern) => modelPatternMatches(pattern, modelPermissionCandidates))) {
return false;
}
// Check disableNonPublicModels flag
if (disableNonPublicModels) {
const resolvedModelId = resolveModelAlias(modelId);
const effectiveModelId = resolvedModelId || modelId;
const providerId = effectiveModelId.split("/")[0];
const shortModelId = effectiveModelId.split("/").slice(1).join("/");
const syncedModelsByConnection = await getSyncedAvailableModelsByConnection(providerId);
const customModels = await getCustomModels(providerId);
// Combine synced and custom models
const allDiscoveredModels = Object.values(syncedModelsByConnection).flat().concat(customModels);
const discovered = allDiscoveredModels.some((m) => m.id === shortModelId);
if (!discovered) return false;
const isPublic = !getModelIsHidden(providerId, shortModelId);
if (!isPublic) return false;
if (!hasClaudeCodeWildcardPermission(allowedModels, modelPermissionCandidates)) {
const lookupTarget = await getPublishedModelLookupTarget(effectiveModelId);
const providerId = lookupTarget?.providerId || effectiveModelId.split("/")[0];
const shortModelId = lookupTarget?.modelId || effectiveModelId.split("/").slice(1).join("/");
if (!providerId || !shortModelId) return false;
const syncedModelsByConnection = await getSyncedAvailableModelsByConnection(providerId);
const customModels = await getCustomModels(providerId);
// Combine synced and custom models
const allDiscoveredModels = Object.values(syncedModelsByConnection)
.flat()
.concat(customModels);
const discovered = allDiscoveredModels.some((m) => m.id === shortModelId);
if (!discovered) return false;
const isPublic = !getModelIsHidden(providerId, shortModelId);
if (!isPublic) return false;
}
}
// Empty array means all models allowed
@@ -1418,25 +1574,10 @@ export async function isModelAllowedForKey(
// Check if model matches each allowed pattern
// Support exact match and prefix match (e.g., "openai/*" allows all OpenAI models)
for (const pattern of allowedModels) {
if (pattern === modelId) {
if (modelPatternMatches(pattern, modelPermissionCandidates)) {
allowed = true;
break;
}
if (pattern.endsWith("/*")) {
const prefix = pattern.slice(0, -2); // Remove "/*"
if (modelId.startsWith(prefix + "/") || modelId.startsWith(prefix)) {
allowed = true;
break;
}
}
// Support wildcard patterns via deterministic matcher (no RegExp
// compilation from operator input — avoids ReDoS exposure).
if (pattern.includes("*")) {
if (matchesWildcardPattern(pattern, modelId)) {
allowed = true;
break;
}
}
}
// If key belongs to groups, also check group-level permissions
@@ -1447,8 +1588,10 @@ export async function isModelAllowedForKey(
}
}
// Cache the result
evictIfNeeded(_modelPermissionCache);
_modelPermissionCache.set(cacheKey, { allowed, timestamp: now });
if (!usesSettingDependentClaudeRouting) {
evictIfNeeded(_modelPermissionCache);
_modelPermissionCache.set(cacheKey, { allowed, timestamp: now });
}
return allowed;
}

View File

@@ -9,12 +9,21 @@
*/
import { extractApiKey } from "@/sse/services/auth";
import { getApiKeyMetadata, getComboByName, isModelAllowedForKey, getApiKeyById } from "@/lib/localDb";
import {
getApiKeyMetadata,
getComboByName,
isModelAllowedForKey,
getApiKeyById,
} from "@/lib/localDb";
import { isDashboardSessionAuthenticated } from "./apiAuth";
import { resolveComboForModel } from "@/lib/db/modelComboMappings";
import { checkBudget } from "@/domain/costRules";
import { checkTokenLimits } from "@omniroute/open-sse/services/tokenLimitCounter.ts";
import { errorResponse, buildErrorBody } from "@omniroute/open-sse/utils/error.ts";
import {
errorResponse,
buildErrorBody,
sanitizeErrorMessage,
} from "@omniroute/open-sse/utils/error.ts";
import { HTTP_STATUS } from "@omniroute/open-sse/config/constants.ts";
import * as log from "@/sse/utils/logger";
import { checkRateLimit, RateLimitRule } from "./rateLimiter";
@@ -173,6 +182,45 @@ function matchesComboAccessRule(comboName: string, requestedModel: string, rule:
);
}
function isAnthropicMessagesRequest(request: Request): boolean {
if (request.headers.has("anthropic-version")) return true;
try {
const url = new URL(request.url);
return url.pathname.endsWith("/v1/messages");
} catch {
return false;
}
}
function policyErrorResponse(
request: Request,
statusCode: number,
message: string,
anthropicMessage = message,
anthropicErrorType = "permission_error",
anthropicStatusCode = statusCode
): Response {
if (!isAnthropicMessagesRequest(request)) {
return errorResponse(statusCode, message);
}
const safeMessage = sanitizeErrorMessage(anthropicMessage);
return new Response(
JSON.stringify({
type: "error",
error: {
type: anthropicErrorType,
message: safeMessage,
},
}),
{
status: anthropicStatusCode,
headers: { "Content-Type": "application/json" },
}
);
}
async function resolveRequestedComboName(modelStr: string): Promise<string | null> {
const exact = await getComboByName(modelStr);
if (exact && typeof exact.name === "string") return exact.name;
@@ -435,7 +483,12 @@ export async function enforceApiKeyPolicy(
let requestedComboName: string | null = null;
const isQuotaExclusive =
Boolean(apiKeyInfo.allowedQuotas) && (apiKeyInfo.allowedQuotas as string[]).length > 0;
if (!isQuotaExclusive && modelStr && apiKeyInfo.allowedCombos && apiKeyInfo.allowedCombos.length > 0) {
if (
!isQuotaExclusive &&
modelStr &&
apiKeyInfo.allowedCombos &&
apiKeyInfo.allowedCombos.length > 0
) {
try {
const comboAccess = await isComboAllowedForKey(apiKeyInfo.allowedCombos, modelStr);
requestedComboName = comboAccess.comboName;
@@ -487,9 +540,13 @@ export async function enforceApiKeyPolicy(
return {
apiKey,
apiKeyInfo,
rejection: errorResponse(
rejection: policyErrorResponse(
request,
HTTP_STATUS.FORBIDDEN,
`Model "${modelStr}" is not allowed for this API key`
`Model "${modelStr}" is not allowed for this API key`,
`Model "${modelStr}" is not enabled or quota is insufficient. Choose another allowed model.`,
"invalid_request_error",
HTTP_STATUS.BAD_REQUEST
),
};
}
@@ -526,9 +583,7 @@ export async function enforceApiKeyPolicy(
const breach = checkTokenLimits(apiKeyInfo.id, undefined, modelStr ?? undefined);
if (breach) {
const scopeLabel =
breach.scopeType === "global"
? "account"
: `${breach.scopeType} "${breach.scopeValue}"`;
breach.scopeType === "global" ? "account" : `${breach.scopeType} "${breach.scopeValue}"`;
return {
apiKey,
apiKeyInfo,
@@ -545,10 +600,7 @@ export async function enforceApiKeyPolicy(
return {
apiKey,
apiKeyInfo,
rejection: errorResponse(
HTTP_STATUS.SERVICE_UNAVAILABLE,
"Token limit policy unavailable"
),
rejection: errorResponse(HTTP_STATUS.SERVICE_UNAVAILABLE, "Token limit policy unavailable"),
};
}
}

View File

@@ -363,7 +363,10 @@ export const bulkWebSessionImportSchema = z.object({
.array(
z.object({
name: z.string().min(1).max(200),
credential: z.string().min(1).max(64 * 1024, "Credential must be under 64 KB"),
credential: z
.string()
.min(1)
.max(64 * 1024, "Credential must be under 64 KB"),
})
)
.min(1, "entries must contain at least 1 item")
@@ -912,24 +915,23 @@ export const v1CountTokensSchema = z
})
.catchall(z.unknown());
export const setBudgetSchema = z
.object({
apiKeyId: z.string().trim().min(1, "apiKeyId is required"),
// #3537: a limit of 0 means "no limit for this period" (checkBudget only enforces when
// activeLimitUsd > 0). The dashboard sends 0 for unfilled fields, so 0 must be accepted —
// `.positive()` (rejects 0) used to 400 any save that left a field blank. Negatives are
// still rejected by `.min(0)`.
dailyLimitUsd: z.coerce.number().min(0, "dailyLimitUsd must be zero or greater").optional(),
weeklyLimitUsd: z.coerce.number().min(0, "weeklyLimitUsd must be zero or greater").optional(),
monthlyLimitUsd: z.coerce.number().min(0, "monthlyLimitUsd must be zero or greater").optional(),
warningThreshold: z.coerce.number().min(0).max(1).optional(),
resetInterval: z.enum(["daily", "weekly", "monthly"]).optional(),
resetTime: z
.string()
.trim()
.regex(/^\d{2}:\d{2}$/, "resetTime must be in HH:MM format")
.optional(),
});
export const setBudgetSchema = z.object({
apiKeyId: z.string().trim().min(1, "apiKeyId is required"),
// #3537: a limit of 0 means "no limit for this period" (checkBudget only enforces when
// activeLimitUsd > 0). The dashboard sends 0 for unfilled fields, so 0 must be accepted —
// `.positive()` (rejects 0) used to 400 any save that left a field blank. Negatives are
// still rejected by `.min(0)`.
dailyLimitUsd: z.coerce.number().min(0, "dailyLimitUsd must be zero or greater").optional(),
weeklyLimitUsd: z.coerce.number().min(0, "weeklyLimitUsd must be zero or greater").optional(),
monthlyLimitUsd: z.coerce.number().min(0, "monthlyLimitUsd must be zero or greater").optional(),
warningThreshold: z.coerce.number().min(0).max(1).optional(),
resetInterval: z.enum(["daily", "weekly", "monthly"]).optional(),
resetTime: z
.string()
.trim()
.regex(/^\d{2}:\d{2}$/, "resetTime must be in HH:MM format")
.optional(),
});
// #3537: the previous superRefine required at least one limit > 0, which made it impossible to
// clear all limits (save 0/0/0). Setting all limits to 0 is a valid "disable enforcement"
// operation, so no cross-field minimum is imposed.
@@ -1908,6 +1910,7 @@ export const updateKeyPermissionsSchema = z
.object({
name: z.string().trim().min(1).max(200).optional(),
allowedModels: z.array(z.string().trim().min(1)).max(1000).optional(),
blockedModels: 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(),
noLog: z.boolean().optional(),
@@ -1937,6 +1940,7 @@ export const updateKeyPermissionsSchema = z
if (
value.name === undefined &&
value.allowedModels === undefined &&
value.blockedModels === undefined &&
value.allowedCombos === undefined &&
value.allowedConnections === undefined &&
value.noLog === undefined &&

View File

@@ -34,6 +34,12 @@ const rateLimiter = await import("../../src/shared/utils/rateLimiter.ts");
rateLimiter.setRateLimiterTestMode(true);
function getFsErrorCode(error: unknown): string | undefined {
if (typeof error !== "object" || error === null || !("code" in error)) return undefined;
const { code } = error as { code?: unknown };
return typeof code === "string" ? code : undefined;
}
async function resetStorage() {
apiKeysDb.resetApiKeyState();
costRules.resetCostData();
@@ -45,8 +51,9 @@ async function resetStorage() {
fs.rmSync(TEST_DATA_DIR, { recursive: true, force: true });
}
break;
} catch (error: any) {
if ((error?.code === "EBUSY" || error?.code === "EPERM") && attempt < 9) {
} catch (error: unknown) {
const code = getFsErrorCode(error);
if ((code === "EBUSY" || code === "EPERM") && attempt < 9) {
await new Promise((resolve) => setTimeout(resolve, 50 * (attempt + 1)));
} else {
throw error;
@@ -77,9 +84,21 @@ function makePolicyRequest(apiKey) {
});
}
function makeAnthropicPolicyRequest(apiKey) {
return new Request("http://localhost/v1/messages", {
method: "POST",
headers: apiKey
? {
"x-api-key": apiKey,
"anthropic-version": "2023-06-01",
}
: { "anthropic-version": "2023-06-01" },
});
}
async function readErrorMessage(response) {
const body = (await response.json()) as any;
return body.error.message;
const body = (await response.json()) as { error?: { message?: unknown } };
return typeof body.error?.message === "string" ? body.error.message : "";
}
function getCurrentUtcDay() {
@@ -464,6 +483,31 @@ test("enforceApiKeyPolicy rejects disallowed models and exhausted budgets", asyn
assert.match(await readErrorMessage(overBudget.rejection), /Daily budget exceeded/);
});
test("enforceApiKeyPolicy returns Anthropic error envelope for /v1/messages model denials", async () => {
const restrictedKey = await createKeyWithPolicy({
allowedModels: ["cc/*"],
blockedModels: ["claude-fable*", "fable"],
});
const policy = await loadPolicy("anthropic-model-denial");
const denied = await policy.enforceApiKeyPolicy(
makeAnthropicPolicyRequest(restrictedKey.key),
"claude-fable-5"
);
assert.equal(denied.rejection.status, 400);
const body = await denied.rejection.json();
assert.equal(body.type, "error");
assert.equal(body.error.type, "invalid_request_error");
assert.match(body.error.message, /claude-fable-5/);
assert.equal(
body.error.message,
'Model "claude-fable-5" is not enabled or quota is insufficient. Choose another allowed model.'
);
assert.doesNotMatch(body.error.message, /login|authenticate|api key|credential|omniroute/i);
assert.equal(body.error.code, undefined);
});
test("enforceApiKeyPolicy does not rate-limit unrestricted keys by default", async () => {
const unrestrictedKey = await createKeyWithPolicy({ allowedModels: ["openai/*"] });
const policy = await loadPolicy("default-no-request-limit");

View File

@@ -69,6 +69,48 @@ test("permissions modal switch buttons declare button type", () => {
assert.equal(typedSwitchButtonCount, 3);
});
test("permissions modal exposes Claude Code default wildcard model", () => {
const source = readApiManagerPage();
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(
source,
/modelId === CLAUDE_CODE_DEFAULT_MODEL_ID\s+\?\s+CLAUDE_CODE_DEFAULT_MODEL_NAME\s+:\s+modelId/
);
assert.doesNotMatch(source, /modelById\.get\(modelId\)\?\.name/);
});
test("permissions modal expands Claude Code default families in selected models summary", () => {
const source = readApiManagerPage();
assert.match(source, /const CLAUDE_CODE_DEFAULT_FAMILIES = \[/);
assert.match(source, /id: "other",\s+label: "other"/);
assert.match(source, /id: "fable",\s+label: "fable"/);
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, /modelId === CLAUDE_CODE_DEFAULT_MODEL_ID/);
assert.match(source, /setClaudeCodeFamiliesExpanded/);
assert.match(
source,
/const \[claudeCodeFamiliesExpanded,\s*setClaudeCodeFamiliesExpanded\] = useState\(false\)/
);
assert.doesNotMatch(source, /setClaudeCodeFamiliesExpanded\(true\)/);
assert.match(source, /aria-expanded=\{claudeCodeFamiliesExpanded\}/);
assert.match(source, /bg-primary\/25/);
assert.match(source, /handleBlockClaudeCodeFamily/);
assert.match(source, /blockedModels: validBlockedModels/);
assert.match(
source,
/blockedModels\.push\(\.\.\.CLAUDE_CODE_FAMILY_BLOCK_PATTERNS\[familyId\]\)/
);
assert.doesNotMatch(source, /Block Fable family/);
});
test("self-service API key scope labels do not expose missing placeholders", () => {
const messageFiles = fs.readdirSync(messagesDir).filter((file) => file.endsWith(".json"));

View File

@@ -22,9 +22,7 @@ import os from "node:os";
import path from "node:path";
import { pathToFileURL } from "node:url";
const TEST_DATA_DIR = fs.mkdtempSync(
path.join(os.tmpdir(), "omniroute-apikeypolicy-dnp-")
);
const TEST_DATA_DIR = fs.mkdtempSync(path.join(os.tmpdir(), "omniroute-apikeypolicy-dnp-"));
process.env.DATA_DIR = TEST_DATA_DIR;
process.env.API_KEY_SECRET = process.env.API_KEY_SECRET || "disable-non-public-policy-secret";
@@ -32,6 +30,7 @@ process.env.API_KEY_SECRET = process.env.API_KEY_SECRET || "disable-non-public-p
const coreDb = await import("../../src/lib/db/core.ts");
const apiKeysDb = await import("../../src/lib/db/apiKeys.ts");
const rateLimiter = await import("../../src/shared/utils/rateLimiter.ts");
const settingsDb = await import("../../src/lib/db/settings.ts");
rateLimiter.setRateLimiterTestMode(true);
@@ -168,6 +167,56 @@ test("disableNonPublicModels=true + qtSd/ virtual model → not rejected by publ
}
});
test("disableNonPublicModels=true + cc wildcard allows unprefixed Claude Code models", async () => {
await settingsDb.updateSettings({ preferClaudeCodeForUnprefixedClaudeModels: true });
const created = await apiKeysDb.createApiKey(
"DNP Claude Code Wildcard",
"machine-dnp-cc-wildcard"
);
await apiKeysDb.updateApiKeyPermissions(created.id, {
allowedModels: ["cc/*"],
disableNonPublicModels: true,
});
apiKeysDb.clearApiKeyCaches();
const policy = await loadPolicy("dnp-cc-wildcard");
for (const modelId of ["claude-sonnet-4-99", "claude-opus-4-8", "sonnet", "opus"]) {
const result = await policy.enforceApiKeyPolicy(makeRequest(created.key), modelId);
assert.equal(
result.rejection,
null,
`cc/* should act as Claude Code default for dynamically routed unprefixed Claude model ${modelId}`
);
}
});
test("cc wildcard can deny the Fable family while allowing other Claude Code default models", async () => {
await settingsDb.updateSettings({ preferClaudeCodeForUnprefixedClaudeModels: true });
const created = await apiKeysDb.createApiKey(
"Claude Code Default No Fable",
"machine-cc-no-fable"
);
await apiKeysDb.updateApiKeyPermissions(created.id, {
allowedModels: ["cc/*"],
blockedModels: ["claude-fable*", "fable"],
});
apiKeysDb.clearApiKeyCaches();
const policy = await loadPolicy("cc-wildcard-block-fable");
for (const modelId of ["sonnet", "claude-sonnet-4-6[1m]", "claude-opus-4-8[1m]"]) {
const result = await policy.enforceApiKeyPolicy(makeRequest(created.key), modelId);
assert.equal(result.rejection, null, `${modelId} should remain allowed by cc/*`);
}
for (const modelId of ["fable", "claude-fable-5", "claude-fable-5[1m]"]) {
const result = await policy.enforceApiKeyPolicy(makeRequest(created.key), modelId);
assert.ok(result.rejection, `${modelId} should be denied by the Fable blocklist`);
assert.equal(result.rejection.status, 403);
}
});
test("disableNonPublicModels=true + hidden model → REJECTED 403 (not in discovered+public set)", async () => {
const created = await apiKeysDb.createApiKey("DNP Hidden Key", "machine-dnp-hidden");
await apiKeysDb.updateApiKeyPermissions(created.id, { disableNonPublicModels: true });
@@ -180,7 +229,10 @@ test("disableNonPublicModels=true + hidden model → REJECTED 403 (not in discov
makeRequest(created.key),
"openai/gpt-totally-undiscovered-xyz"
);
assert.ok(result.rejection, "non-discovered model should be rejected for disableNonPublicModels key");
assert.ok(
result.rejection,
"non-discovered model should be rejected for disableNonPublicModels key"
);
assert.equal(result.rejection.status, 403);
const body = (await result.rejection.json()) as { error: { message: string } };
assert.match(body.error.message, /not allowed for this API key/);
@@ -204,7 +256,7 @@ test("disableNonPublicModels=true + existing combo name → not rejected by publ
// Create a combo via the DB helper if available:
let comboDb: { createCombo?: (input: Record<string, unknown>) => { name: string } } | null = null;
try {
comboDb = await import("../../src/lib/db/combos.ts") as typeof comboDb;
comboDb = (await import("../../src/lib/db/combos.ts")) as typeof comboDb;
} catch {
comboDb = null;
}
@@ -213,10 +265,7 @@ test("disableNonPublicModels=true + existing combo name → not rejected by publ
comboDb.createCombo({ name: "test-combo-dnp", targets: [] });
apiKeysDb.clearApiKeyCaches();
const result2 = await policy.enforceApiKeyPolicy(
makeRequest(created.key),
"test-combo-dnp"
);
const result2 = await policy.enforceApiKeyPolicy(makeRequest(created.key), "test-combo-dnp");
if (result2.rejection) {
const body = (await result2.rejection.clone().json()) as { error: { message: string } };
assert.ok(

View File

@@ -9,6 +9,7 @@ import {
v1EmbeddingsSchema,
providerChatCompletionSchema,
v1CountTokensSchema,
intelligenceSyncRequestSchema,
pricingSyncRequestSchema,
updateTaskRoutingSchema,
taskRoutingActionSchema,
@@ -132,6 +133,21 @@ test("pricingSyncRequestSchema accepts dryRun-only requests", () => {
assert.equal(validation.success, true);
});
test("intelligenceSyncRequestSchema accepts dryRun-only requests", () => {
const validation = validateBody(intelligenceSyncRequestSchema, {
dryRun: true,
});
assert.equal(validation.success, true);
});
test("intelligenceSyncRequestSchema rejects unknown properties", () => {
const validation = validateBody(intelligenceSyncRequestSchema, {
dryRun: true,
source: "unexpected",
});
assert.equal(validation.success, false);
});
test("updateTaskRoutingSchema rejects empty payloads", () => {
const validation = validateBody(updateTaskRoutingSchema, {});
assert.equal(validation.success, false);