mirror of
https://github.com/diegosouzapw/OmniRoute.git
synced 2026-08-06 07:12:12 +03:00
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:
@@ -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>
|
||||
);
|
||||
})}
|
||||
|
||||
@@ -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 }),
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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"),
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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 &&
|
||||
|
||||
Reference in New Issue
Block a user