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