diff --git a/file-size-baseline.json b/file-size-baseline.json index bf4a3de655..c0055d24ab 100644 --- a/file-size-baseline.json +++ b/file-size-baseline.json @@ -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." } diff --git a/src/app/(dashboard)/dashboard/api-manager/ApiManagerPageClient.tsx b/src/app/(dashboard)/dashboard/api-manager/ApiManagerPageClient.tsx index 961eb75a92..615c7d58d8 100644 --- a/src/app/(dashboard)/dashboard/api-manager/ApiManagerPageClient.tsx +++ b/src/app/(dashboard)/dashboard/api-manager/ApiManagerPageClient.tsx @@ -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; +const CLAUDE_CODE_FAMILY_BLOCK_PATTERNS: Record = { + 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-" into the grouping label) const modelsByProvider = useMemo((): ProviderGroup[] => { const grouped: Record = {}; - 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() { payments @@ -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(initialModels); + const [blockedClaudeCodeFamilies, setBlockedClaudeCodeFamilies] = useState< + ClaudeCodeBlockableFamilyId[] + >(() => getBlockedClaudeCodeFamilies(initialBlockedModels)); + const [claudeCodeFamiliesExpanded, setClaudeCodeFamiliesExpanded] = useState(false); const [selectedCombos, setSelectedCombos] = useState(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 ( -
- {selectedModels.map((modelId) => ( - - - {modelId} - - + + + + {claudeCodeFamiliesExpanded && ( +
+
+ )} +
+ ); + } + + return ( + - close - - - ))} + + {getModelDisplayName(modelId)} + + + + ); + })} )} @@ -2426,7 +2634,7 @@ const PermissionsModal = memo(function PermissionsModal({ }`} title={model.id} > - {model.id} + {getModelDisplayName(model.id)} ); })} diff --git a/src/app/api/keys/[id]/route.ts b/src/app/api/keys/[id]/route.ts index 13934d94d4..08a6940751 100644 --- a/src/app/api/keys/[id]/route.ts +++ b/src/app/api/keys/[id]/route.ts @@ -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[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 }), diff --git a/src/lib/db/apiKeys.ts b/src/lib/db/apiKeys.ts index 559b8e8c6a..090dd48f77 100644 --- a/src/lib/db/apiKeys.ts +++ b/src/lib/db/apiKeys.ts @@ -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(); 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(cache: Map) { } } +function isTruthyEnvFlag(value: string | undefined): boolean { + return typeof value === "string" && /^(1|true|yes|on)$/i.test(value.trim()); +} + +async function preferClaudeCodeForUnprefixedClaudeModels(): Promise { + 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, modelId: string): void { + const clean = modelId.trim(); + if (!clean) return; + candidates.add(clean); + candidates.add(stripExtendedContextSuffix(clean)); +} + +async function getModelPermissionCandidates(modelId: string): Promise { + const candidates = new Set(); + 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( - "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).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; } diff --git a/src/shared/utils/apiKeyPolicy.ts b/src/shared/utils/apiKeyPolicy.ts index 2ae2e9a040..33d6365449 100644 --- a/src/shared/utils/apiKeyPolicy.ts +++ b/src/shared/utils/apiKeyPolicy.ts @@ -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 { 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"), }; } } diff --git a/src/shared/validation/schemas.ts b/src/shared/validation/schemas.ts index e855296431..fd248834be 100644 --- a/src/shared/validation/schemas.ts +++ b/src/shared/validation/schemas.ts @@ -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 && diff --git a/tests/unit/api-key-policy.test.ts b/tests/unit/api-key-policy.test.ts index 34a00412de..9f5c304006 100644 --- a/tests/unit/api-key-policy.test.ts +++ b/tests/unit/api-key-policy.test.ts @@ -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"); diff --git a/tests/unit/api-manager-page-static.test.ts b/tests/unit/api-manager-page-static.test.ts index 29d920e82f..c7bc948406 100644 --- a/tests/unit/api-manager-page-static.test.ts +++ b/tests/unit/api-manager-page-static.test.ts @@ -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")); diff --git a/tests/unit/apikeypolicy-disable-non-public.test.ts b/tests/unit/apikeypolicy-disable-non-public.test.ts index eb058383fb..7c0e562298 100644 --- a/tests/unit/apikeypolicy-disable-non-public.test.ts +++ b/tests/unit/apikeypolicy-disable-non-public.test.ts @@ -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) => { 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( diff --git a/tests/unit/t06-schema-hardening.test.ts b/tests/unit/t06-schema-hardening.test.ts index 68615abdb9..87fdbb6c6b 100644 --- a/tests/unit/t06-schema-hardening.test.ts +++ b/tests/unit/t06-schema-hardening.test.ts @@ -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);