feat(keys): add per-key USD usage quota controls (#4327)

* feat(keys): add per-key USD usage quotas

Adds daily and weekly API key USD caps with reset-aware weekly windows, exposes quota controls in API key permissions and costs views, and returns Claude Code-safe 400 responses when caps are exceeded.

Validations:

- node --import tsx/esm --test tests/unit/api-key-usage-limits.test.ts tests/unit/internal-usage-command.test.ts

- npm run typecheck:core

- npm run check:file-size

- npm run check:migration-numbering

- npm run lint

- Docker image build/deploy smoke test on 100.64.0.1:20128

* fix(db): renumber api_key_usage_limits migration 100->101 (avoid cli_access_tokens collision)

Migration version 100 is taken by 100_cli_access_tokens.sql on release; the
migrationRunner version-collision guard would otherwise skip one. Renumber to 101.

Co-authored-by: diegosouzapw <diegosouza.pw@gmail.com>

* chore(quality): bump apiKeys.ts file-size baseline 1661->1662 (USD quota fields)

Co-authored-by: diegosouzapw <diegosouza.pw@gmail.com>

---------

Co-authored-by: Wital <wital@example.com>
Co-authored-by: diegosouzapw <diegosouza.pw@gmail.com>
This commit is contained in:
Witroch4
2026-06-19 22:42:54 -03:00
committed by GitHub
parent fbb423f32b
commit 70d89d2f68
21 changed files with 1360 additions and 86 deletions

View File

@@ -149,7 +149,7 @@
"src/app/api/usage/analytics/route.ts": 941,
"src/app/api/v1/models/catalog.ts": 1478,
"src/lib/cloudflaredTunnel.ts": 934,
"src/lib/db/apiKeys.ts": 1661,
"src/lib/db/apiKeys.ts": 1662,
"src/lib/db/core.ts": 1820,
"src/lib/db/migrationRunner.ts": 1125,
"src/lib/db/models.ts": 1184,

View File

@@ -14,11 +14,14 @@ import {
isRestricted as isKeyRestricted,
classifyKeyStatus,
computeApiKeyCounts,
formatUsdCost,
toLocalDateTimeInputValue,
} from "./apiManagerPageUtils";
import type { KeyStatus, KeyType } from "./apiManagerPageUtils";
import { readActiveOnlyPreference, writeActiveOnlyPreference } from "./apiManagerPageStorage";
import { buildApiKeyCreateScopes, mergeApiKeyPermissionScopes } from "./apiManagerScopes";
import { SELF_ACCOUNT_QUOTA_SCOPE, SELF_USAGE_SCOPE } from "@/shared/constants/selfServiceScopes";
import { UsageLimitSettings } from "./components/UsageLimitSettings";
// Constants for validation
const MAX_KEY_NAME_LENGTH = 200;
@@ -44,16 +47,6 @@ 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 "";
const date = new Date(value);
if (Number.isNaN(date.getTime())) return "";
const pad = (n: number) => String(n).padStart(2, "0");
return `${date.getFullYear()}-${pad(date.getMonth() + 1)}-${pad(date.getDate())}T${pad(
date.getHours()
)}:${pad(date.getMinutes())}`;
}
// Debounce hook for search optimization
function useDebouncedValue<T>(value: T, delay: number): T {
const [debouncedValue, setDebouncedValue] = useState(value);
@@ -129,6 +122,9 @@ interface ApiKey {
streamDefaultMode?: StreamDefaultMode;
disableNonPublicModels?: boolean;
allowUsageCommand?: boolean;
usageLimitEnabled?: boolean;
dailyUsageLimitUsd?: number | null;
weeklyUsageLimitUsd?: number | null;
allowedQuotas?: string[] | null;
createdAt: string;
}
@@ -146,16 +142,6 @@ interface KeyUsageStats {
lastUsed: string | null;
}
function formatUsdCost(value: number, locale: string): string {
const amount = Number.isFinite(value) ? value : 0;
return new Intl.NumberFormat(locale, {
style: "currency",
currency: "USD",
minimumFractionDigits: amount > 0 && amount < 1 ? 4 : 2,
maximumFractionDigits: amount > 0 && amount < 1 ? 4 : 2,
}).format(amount);
}
interface Model {
id: string;
owned_by: string;
@@ -664,6 +650,9 @@ export default function ApiManagerPageClient() {
streamDefaultMode: StreamDefaultMode,
disableNonPublicModels: boolean,
allowUsageCommand: boolean,
usageLimitEnabled: boolean,
dailyUsageLimitUsd: number | null,
weeklyUsageLimitUsd: number | null,
blockedModels: string[]
) => {
if (!editingKey || !editingKey.id) return;
@@ -732,6 +721,9 @@ export default function ApiManagerPageClient() {
streamDefaultMode,
disableNonPublicModels,
allowUsageCommand,
usageLimitEnabled,
dailyUsageLimitUsd,
weeklyUsageLimitUsd,
}),
});
@@ -1046,6 +1038,12 @@ export default function ApiManagerPageClient() {
{t("localUsageCommandBadge")}
</span>
)}
{key.usageLimitEnabled === true && (
<span className="inline-flex items-center gap-1 px-2 py-0.5 rounded-md bg-emerald-500/10 text-emerald-700 dark:text-emerald-300 text-[11px] font-medium">
<span className="material-symbols-outlined text-[12px]">paid</span>
USD quota
</span>
)}
{hasSessionLimit && (
<span className="inline-flex items-center gap-1 px-2 py-0.5 rounded-md bg-indigo-500/10 text-indigo-600 dark:text-indigo-400 text-[11px] font-medium">
<span className="material-symbols-outlined text-[12px]">group</span>
@@ -1469,6 +1467,9 @@ const PermissionsModal = memo(function PermissionsModal({
streamDefaultMode: StreamDefaultMode,
disableNonPublicModels: boolean,
allowUsageCommand: boolean,
usageLimitEnabled: boolean,
dailyUsageLimitUsd: number | null,
weeklyUsageLimitUsd: number | null,
blockedModels: string[]
) => void;
}) {
@@ -1552,6 +1553,17 @@ const PermissionsModal = memo(function PermissionsModal({
const [usageCommandEnabled, setUsageCommandEnabled] = useState(
apiKey?.allowUsageCommand === true
);
const [usageLimitEnabled, setUsageLimitEnabled] = useState(apiKey?.usageLimitEnabled === true);
const [dailyUsageLimitUsd, setDailyUsageLimitUsd] = useState(
typeof apiKey?.dailyUsageLimitUsd === "number" && apiKey.dailyUsageLimitUsd > 0
? String(apiKey.dailyUsageLimitUsd)
: ""
);
const [weeklyUsageLimitUsd, setWeeklyUsageLimitUsd] = useState(
typeof apiKey?.weeklyUsageLimitUsd === "number" && apiKey.weeklyUsageLimitUsd > 0
? String(apiKey.weeklyUsageLimitUsd)
: ""
);
const getModelDisplayName = useCallback(
(modelId: string) =>
modelId === CLAUDE_CODE_DEFAULT_MODEL_ID ? CLAUDE_CODE_DEFAULT_MODEL_NAME : modelId,
@@ -1670,6 +1682,11 @@ const PermissionsModal = memo(function PermissionsModal({
[allowAllEndpoints]
);
const parseUsdLimitInput = useCallback((value: string): number | null => {
const parsed = Number(value);
return Number.isFinite(parsed) && parsed > 0 ? parsed : null;
}, []);
const handleSave = useCallback(() => {
// Clear previous inline errors
setNameError(null);
@@ -1736,6 +1753,9 @@ const PermissionsModal = memo(function PermissionsModal({
streamDefaultMode,
disableNonPublicModels,
usageCommandEnabled,
usageLimitEnabled,
parseUsdLimitInput(dailyUsageLimitUsd),
parseUsdLimitInput(weeklyUsageLimitUsd),
blockedModels
);
}, [
@@ -1768,6 +1788,10 @@ const PermissionsModal = memo(function PermissionsModal({
streamDefaultMode,
disableNonPublicModels,
usageCommandEnabled,
usageLimitEnabled,
dailyUsageLimitUsd,
weeklyUsageLimitUsd,
parseUsdLimitInput,
blockedClaudeCodeFamilies,
initialBlockedModels,
apiKey?.scopes,
@@ -2341,6 +2365,16 @@ const PermissionsModal = memo(function PermissionsModal({
{t("localUsageCommand")} - {usageCommandEnabled ? tc("enabled") : tc("disabled")}
</button>
<p className="text-xs text-text-muted">{t("localUsageCommandDesc")}</p>
<UsageLimitSettings
enabled={usageLimitEnabled}
dailyLimitUsd={dailyUsageLimitUsd}
weeklyLimitUsd={weeklyUsageLimitUsd}
enabledLabel={tc("enabled")}
disabledLabel={tc("disabled")}
onEnabledChange={setUsageLimitEnabled}
onDailyLimitUsdChange={setDailyUsageLimitUsd}
onWeeklyLimitUsdChange={setWeeklyUsageLimitUsd}
/>
</div>
{/* Disable Non-Public Models Toggle */}

View File

@@ -83,3 +83,23 @@ export function computeApiKeyCounts(keys: ApiKeyShape[]): ApiKeyCounts {
return counts;
}
export function toLocalDateTimeInputValue(value: string | null | undefined): string {
if (!value) return "";
const date = new Date(value);
if (Number.isNaN(date.getTime())) return "";
const pad = (n: number) => String(n).padStart(2, "0");
return `${date.getFullYear()}-${pad(date.getMonth() + 1)}-${pad(date.getDate())}T${pad(
date.getHours()
)}:${pad(date.getMinutes())}`;
}
export function formatUsdCost(value: number, locale: string): string {
const amount = Number.isFinite(value) ? value : 0;
return new Intl.NumberFormat(locale, {
style: "currency",
currency: "USD",
minimumFractionDigits: amount > 0 && amount < 1 ? 4 : 2,
maximumFractionDigits: amount > 0 && amount < 1 ? 4 : 2,
}).format(amount);
}

View File

@@ -0,0 +1,79 @@
"use client";
import { Input } from "@/shared/components";
export function UsageLimitSettings({
enabled,
dailyLimitUsd,
weeklyLimitUsd,
enabledLabel,
disabledLabel,
onEnabledChange,
onDailyLimitUsdChange,
onWeeklyLimitUsdChange,
}: {
enabled: boolean;
dailyLimitUsd: string;
weeklyLimitUsd: string;
enabledLabel: string;
disabledLabel: string;
onEnabledChange: (enabled: boolean) => void;
onDailyLimitUsdChange: (value: string) => void;
onWeeklyLimitUsdChange: (value: string) => void;
}) {
return (
<div className="mt-1 rounded-lg border border-emerald-500/20 bg-emerald-500/5 p-3">
<div className="flex items-start justify-between gap-3">
<div className="flex flex-col gap-1">
<p className="text-sm font-medium text-text-main">USD usage quota</p>
<p className="text-xs text-text-muted">
Blocks this key with a 400 API error after its local USD spend reaches the configured
daily or weekly quota.
</p>
</div>
<button
type="button"
role="switch"
aria-checked={enabled}
onClick={() => onEnabledChange(!enabled)}
className={`inline-flex shrink-0 items-center gap-1.5 px-2.5 py-1.5 rounded-md text-xs font-semibold transition-colors ${
enabled
? "bg-emerald-500/15 text-emerald-700 dark:text-emerald-300 border border-emerald-500/30"
: "bg-black/5 dark:bg-white/5 text-text-muted border border-border"
}`}
>
<span className="material-symbols-outlined text-[14px]">paid</span>
{enabled ? enabledLabel : disabledLabel}
</button>
</div>
<div className="mt-3 grid grid-cols-2 gap-2">
<div>
<label className="text-xs text-text-muted mb-1 block">Daily quota (USD)</label>
<Input
type="number"
min={0}
step="0.01"
value={dailyLimitUsd}
onChange={(event) => onDailyLimitUsdChange(event.target.value)}
placeholder="0.00"
/>
</div>
<div>
<label className="text-xs text-text-muted mb-1 block">Weekly quota (USD)</label>
<Input
type="number"
min={0}
step="0.01"
value={weeklyLimitUsd}
onChange={(event) => onWeeklyLimitUsdChange(event.target.value)}
placeholder="0.00"
/>
</div>
</div>
<p className="mt-2 text-[11px] text-text-muted">
Weekly quota follows the cached Claude weekly reset when available; otherwise it falls back
to a rolling 7 day window. Daily quota uses the Fortaleza calendar day.
</p>
</div>
);
}

View File

@@ -37,6 +37,9 @@ import {
parseExplorerGroupBy,
type CostRange,
} from "./costExplorerParams";
import { ApiKeyUsageLimitCard } from "./components/ApiKeyUsageLimitCard";
import { MetricCard } from "./components/MetricCard";
import { useApiKeyUsageLimits } from "./useApiKeyUsageLimits";
interface UsageAnalyticsSummary {
totalCost: number;
@@ -281,7 +284,9 @@ export default function CostOverviewTab() {
const locale = useLocale();
const searchParams = useSearchParams();
const apiKeyIdsParam = searchParams.get("apiKeyIds");
const apiKeyFilter = useMemo(() => parseApiKeyIds(apiKeyIdsParam).join(","), [apiKeyIdsParam]);
const selectedApiKeyIds = useMemo(() => parseApiKeyIds(apiKeyIdsParam), [apiKeyIdsParam]);
const selectedApiKeyId = selectedApiKeyIds.length === 1 ? selectedApiKeyIds[0] : null;
const apiKeyFilter = useMemo(() => selectedApiKeyIds.join(","), [selectedApiKeyIds]);
const currencyFormatter = useMemo(() => createCurrencyFormatter(locale), [locale]);
const [range, setRange] = useState<CostRange>(() => parseCostRange(searchParams.get("range")));
const [analytics, setAnalytics] = useState<UsageAnalyticsPayload | null>(null);
@@ -300,6 +305,11 @@ export default function CostOverviewTab() {
const [explorerSortKey, setExplorerSortKey] = useState<CostExplorerSortKey>("cost");
const [explorerSortDirection, setExplorerSortDirection] =
useState<CostExplorerSortDirection>("desc");
const {
payload: apiKeyUsageLimits,
loading: apiKeyUsageLimitsLoading,
save: saveApiKeyUsageLimits,
} = useApiKeyUsageLimits(selectedApiKeyId);
useEffect(() => {
let active = true;
@@ -536,6 +546,15 @@ export default function CostOverviewTab() {
/>
</div>
{selectedApiKeyId && (
<ApiKeyUsageLimitCard
payload={apiKeyUsageLimits}
loading={apiKeyUsageLimitsLoading}
locale={locale}
onSave={saveApiKeyUsageLimits}
/>
)}
<Card className="p-5">
<div className="grid grid-cols-2 xl:grid-cols-4 gap-4">
<CompactMetric
@@ -866,28 +885,6 @@ export default function CostOverviewTab() {
);
}
function MetricCard({
label,
value,
subValue,
color = "text-text-main",
loading = false,
}: {
label: string;
value: string;
subValue?: string;
color?: string;
loading?: boolean;
}) {
return (
<Card className="px-4 py-3">
<p className="text-xs uppercase tracking-wide text-text-muted font-semibold">{label}</p>
<p className={`text-2xl font-bold mt-1 ${color}`}>{loading ? "…" : value}</p>
{subValue ? <p className="text-xs text-text-muted mt-1">{subValue}</p> : null}
</Card>
);
}
function CostExplorerCard({
rows,
totalRows,

View File

@@ -0,0 +1,214 @@
"use client";
import { useEffect, useMemo, useState } from "react";
import { Card } from "@/shared/components";
export interface ApiKeyUsageLimitPayload {
key: {
id: string;
name: string;
usageLimitEnabled: boolean;
dailyUsageLimitUsd: number | null;
weeklyUsageLimitUsd: number | null;
};
status: {
enabled: boolean;
dailyLimitUsd: number | null;
weeklyLimitUsd: number | null;
dailySpentUsd: number;
weeklySpentUsd: number;
dailyWindowStartIso: string;
weeklyWindowStartIso: string;
weeklyResetAtIso: string | null;
dailyExceeded: boolean;
weeklyExceeded: boolean;
};
}
export interface ApiKeyUsageLimitSavePayload {
usageLimitEnabled: boolean;
dailyUsageLimitUsd: number | null;
weeklyUsageLimitUsd: number | null;
}
function createCurrencyFormatter(locale: string) {
return new Intl.NumberFormat(locale, {
style: "currency",
currency: "USD",
minimumFractionDigits: 2,
maximumFractionDigits: 2,
});
}
function parseUsdInput(value: string): number | null {
const parsed = Number(value);
return Number.isFinite(parsed) && parsed > 0 ? parsed : null;
}
function formatResetHint(resetAtIso: string | null): string {
if (!resetAtIso) return "fallback: rolling 7 days";
const resetMs = Date.parse(resetAtIso);
if (!Number.isFinite(resetMs)) return "fallback: rolling 7 days";
const deltaMs = resetMs - Date.now();
if (deltaMs <= 0) return "reset due now";
const hourMs = 60 * 60 * 1000;
const dayMs = 24 * hourMs;
if (deltaMs < dayMs) return `resets in ${Math.max(1, Math.ceil(deltaMs / hourMs))}h`;
return `resets in ${Math.max(1, Math.ceil(deltaMs / dayMs))}d`;
}
function UsageQuotaMetric({ label, value }: { label: string; value: string }) {
return (
<div className="rounded-lg border border-border/20 bg-surface/20 px-4 py-3">
<p className="text-xs uppercase tracking-wide text-text-muted font-semibold">{label}</p>
<p className="text-lg font-semibold text-text-main mt-1">{value}</p>
</div>
);
}
export function ApiKeyUsageLimitCard({
payload,
loading,
locale,
onSave,
}: {
payload: ApiKeyUsageLimitPayload | null;
loading: boolean;
locale: string;
onSave: (next: ApiKeyUsageLimitSavePayload) => Promise<void>;
}) {
const [enabled, setEnabled] = useState(false);
const [dailyLimit, setDailyLimit] = useState("");
const [weeklyLimit, setWeeklyLimit] = useState("");
const [saving, setSaving] = useState(false);
const [error, setError] = useState<string | null>(null);
useEffect(() => {
if (!payload) return;
setEnabled(payload.key.usageLimitEnabled);
setDailyLimit(
typeof payload.key.dailyUsageLimitUsd === "number"
? String(payload.key.dailyUsageLimitUsd)
: ""
);
setWeeklyLimit(
typeof payload.key.weeklyUsageLimitUsd === "number"
? String(payload.key.weeklyUsageLimitUsd)
: ""
);
setError(null);
}, [payload]);
const formatter = useMemo(() => createCurrencyFormatter(locale), [locale]);
const status = payload?.status;
const handleSave = async () => {
setSaving(true);
setError(null);
try {
await onSave({
usageLimitEnabled: enabled,
dailyUsageLimitUsd: parseUsdInput(dailyLimit),
weeklyUsageLimitUsd: parseUsdInput(weeklyLimit),
});
} catch (saveError) {
setError(saveError instanceof Error ? saveError.message : "Failed to save usage limits");
} finally {
setSaving(false);
}
};
return (
<Card className="p-5 border-emerald-500/20">
<div className="flex flex-col gap-4 lg:flex-row lg:items-start lg:justify-between">
<div className="min-w-0">
<div className="flex items-center gap-2">
<span className="material-symbols-outlined text-emerald-400 text-lg">paid</span>
<h3 className="text-sm font-semibold text-text-main">API key USD quota</h3>
{payload?.key.name && (
<span className="truncate rounded bg-surface px-2 py-0.5 text-xs text-text-muted">
{payload.key.name}
</span>
)}
</div>
<p className="mt-1 text-xs text-text-muted">
When enabled, @@om-usage returns daily quota, weekly quota, daily spend, and weekly
spend in USD. Weekly follows the cached Claude reset when available.
</p>
</div>
<button
type="button"
role="switch"
aria-checked={enabled}
disabled={loading || !payload}
onClick={() => setEnabled((prev) => !prev)}
className={`inline-flex shrink-0 items-center justify-center gap-1.5 rounded-md border px-3 py-2 text-xs font-semibold transition-colors ${
enabled
? "border-emerald-500/30 bg-emerald-500/15 text-emerald-700 dark:text-emerald-300"
: "border-border bg-black/5 text-text-muted dark:bg-white/5"
} ${loading || !payload ? "opacity-50" : ""}`}
>
<span className="material-symbols-outlined text-[14px]">paid</span>
{enabled ? "Enabled" : "Disabled"}
</button>
</div>
<div className="mt-4 grid gap-3 md:grid-cols-2 xl:grid-cols-4">
<UsageQuotaMetric
label="Daily spend"
value={loading || !status ? "..." : formatter.format(status.dailySpentUsd)}
/>
<UsageQuotaMetric
label="Weekly spend"
value={loading || !status ? "..." : formatter.format(status.weeklySpentUsd)}
/>
<div className="rounded-lg border border-border/20 bg-surface/20 px-4 py-3">
<label className="text-xs uppercase tracking-wide text-text-muted font-semibold">
Daily quota
</label>
<input
type="number"
min={0}
step="0.01"
value={dailyLimit}
onChange={(event) => setDailyLimit(event.target.value)}
className="mt-2 w-full rounded-md border border-border bg-background px-2 py-1.5 text-sm text-text-main"
placeholder="0.00"
/>
</div>
<div className="rounded-lg border border-border/20 bg-surface/20 px-4 py-3">
<label className="text-xs uppercase tracking-wide text-text-muted font-semibold">
Weekly quota
</label>
<input
type="number"
min={0}
step="0.01"
value={weeklyLimit}
onChange={(event) => setWeeklyLimit(event.target.value)}
className="mt-2 w-full rounded-md border border-border bg-background px-2 py-1.5 text-sm text-text-main"
placeholder="0.00"
/>
<p className="mt-1 text-[10px] text-text-muted">
{formatResetHint(status?.weeklyResetAtIso ?? null)}
</p>
</div>
</div>
{error && <p className="mt-3 text-xs text-red-500">{error}</p>}
<div className="mt-4 flex justify-end">
<button
type="button"
onClick={handleSave}
disabled={saving || loading || !payload}
className="inline-flex items-center gap-1.5 rounded-md bg-primary px-3 py-2 text-xs font-semibold text-white transition-colors hover:bg-primary/90 disabled:opacity-50"
>
<span className="material-symbols-outlined text-[14px]">
{saving ? "hourglass_empty" : "save"}
</span>
{saving ? "Saving..." : "Save quota"}
</button>
</div>
</Card>
);
}

View File

@@ -0,0 +1,23 @@
import { Card } from "@/shared/components";
export function MetricCard({
label,
value,
subValue,
color = "text-text-main",
loading = false,
}: {
label: string;
value: string;
subValue?: string;
color?: string;
loading?: boolean;
}) {
return (
<Card className="px-4 py-3">
<p className="text-xs uppercase tracking-wide text-text-muted font-semibold">{label}</p>
<p className={`text-2xl font-bold mt-1 ${color}`}>{loading ? "…" : value}</p>
{subValue ? <p className="text-xs text-text-muted mt-1">{subValue}</p> : null}
</Card>
);
}

View File

@@ -0,0 +1,51 @@
"use client";
import { useCallback, useEffect, useState } from "react";
import type {
ApiKeyUsageLimitPayload,
ApiKeyUsageLimitSavePayload,
} from "./components/ApiKeyUsageLimitCard";
export function useApiKeyUsageLimits(selectedApiKeyId: string | null) {
const [payload, setPayload] = useState<ApiKeyUsageLimitPayload | null>(null);
const [loading, setLoading] = useState(false);
const load = useCallback(async () => {
if (!selectedApiKeyId) {
setPayload(null);
return;
}
setLoading(true);
try {
const response = await fetch(
`/api/keys/${encodeURIComponent(selectedApiKeyId)}/usage-limits`
);
if (!response.ok) throw new Error("Failed to load API key usage limits");
setPayload((await response.json()) as ApiKeyUsageLimitPayload);
} catch {
setPayload(null);
} finally {
setLoading(false);
}
}, [selectedApiKeyId]);
const save = useCallback(
async (next: ApiKeyUsageLimitSavePayload) => {
if (!selectedApiKeyId) return;
const response = await fetch(`/api/keys/${encodeURIComponent(selectedApiKeyId)}`, {
method: "PATCH",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(next),
});
if (!response.ok) throw new Error("Failed to save API key usage limits");
await load();
},
[load, selectedApiKeyId]
);
useEffect(() => {
void load();
}, [load]);
return { payload, loading, save };
}

View File

@@ -83,6 +83,9 @@ export async function PATCH(request, { params }) {
streamDefaultMode,
disableNonPublicModels,
allowUsageCommand,
usageLimitEnabled,
dailyUsageLimitUsd,
weeklyUsageLimitUsd,
} = validation.data;
const payload: Parameters<typeof updateApiKeyPermissions>[1] = {};
@@ -106,6 +109,9 @@ export async function PATCH(request, { params }) {
if (disableNonPublicModels !== undefined)
payload.disableNonPublicModels = disableNonPublicModels;
if (allowUsageCommand !== undefined) payload.allowUsageCommand = allowUsageCommand;
if (usageLimitEnabled !== undefined) payload.usageLimitEnabled = usageLimitEnabled;
if (dailyUsageLimitUsd !== undefined) payload.dailyUsageLimitUsd = dailyUsageLimitUsd;
if (weeklyUsageLimitUsd !== undefined) payload.weeklyUsageLimitUsd = weeklyUsageLimitUsd;
const updated = await updateApiKeyPermissions(id, payload);
if (!updated) {
@@ -136,6 +142,9 @@ export async function PATCH(request, { params }) {
...(streamDefaultMode !== undefined && { streamDefaultMode }),
...(disableNonPublicModels !== undefined && { disableNonPublicModels }),
...(allowUsageCommand !== undefined && { allowUsageCommand }),
...(usageLimitEnabled !== undefined && { usageLimitEnabled }),
...(dailyUsageLimitUsd !== undefined && { dailyUsageLimitUsd }),
...(weeklyUsageLimitUsd !== undefined && { weeklyUsageLimitUsd }),
});
} catch (error) {
log.error("keys", "Error updating key permissions", error);

View File

@@ -0,0 +1,44 @@
import { NextResponse } from "next/server";
import { requireManagementAuth } from "@/lib/api/requireManagementAuth";
import { getApiKeyById } from "@/lib/db/apiKeys";
import { getApiKeyUsageLimitStatus } from "@/lib/usage/apiKeyUsageLimits";
import * as log from "@/sse/utils/logger";
export async function GET(request: Request, { params }: { params: Promise<{ id: string }> }) {
const authError = await requireManagementAuth(request);
if (authError) return authError;
try {
const { id } = await params;
const key = await getApiKeyById(id);
if (!key || typeof key.id !== "string") {
return NextResponse.json({ error: "Key not found" }, { status: 404 });
}
const status = await getApiKeyUsageLimitStatus({
id: key.id,
allowedConnections: Array.isArray(key.allowedConnections) ? key.allowedConnections : [],
usageLimitEnabled: key.usageLimitEnabled === true,
dailyUsageLimitUsd:
typeof key.dailyUsageLimitUsd === "number" ? key.dailyUsageLimitUsd : null,
weeklyUsageLimitUsd:
typeof key.weeklyUsageLimitUsd === "number" ? key.weeklyUsageLimitUsd : null,
});
return NextResponse.json({
key: {
id: key.id,
name: typeof key.name === "string" ? key.name : "",
usageLimitEnabled: key.usageLimitEnabled === true,
dailyUsageLimitUsd:
typeof key.dailyUsageLimitUsd === "number" ? key.dailyUsageLimitUsd : null,
weeklyUsageLimitUsd:
typeof key.weeklyUsageLimitUsd === "number" ? key.weeklyUsageLimitUsd : null,
},
status,
});
} catch (error) {
log.error("keys", "Error fetching API key usage limits", error);
return NextResponse.json({ error: "Failed to fetch usage limits" }, { status: 500 });
}
}

View File

@@ -63,16 +63,33 @@ export async function POST(request) {
if (isValidationFailure(validation)) {
return NextResponse.json({ error: validation.error }, { status: 400 });
}
const { name, noLog, scopes, allowUsageCommand } = validation.data;
const {
name,
noLog,
scopes,
allowUsageCommand,
usageLimitEnabled,
dailyUsageLimitUsd,
weeklyUsageLimitUsd,
} = validation.data;
// Always get machineId from server
const machineId = await getConsistentMachineId();
const normalizedScopes = normalizeSelfServiceScopesForCreate(scopes);
const apiKey = await createApiKey(name, machineId, normalizedScopes);
if (noLog === true || allowUsageCommand === true) {
if (
noLog === true ||
allowUsageCommand === true ||
usageLimitEnabled === true ||
dailyUsageLimitUsd !== undefined ||
weeklyUsageLimitUsd !== undefined
) {
await updateApiKeyPermissions(apiKey.id, {
...(noLog === true && { noLog: true }),
...(allowUsageCommand === true && { allowUsageCommand: true }),
...(usageLimitEnabled === true && { usageLimitEnabled: true }),
...(dailyUsageLimitUsd !== undefined && { dailyUsageLimitUsd }),
...(weeklyUsageLimitUsd !== undefined && { weeklyUsageLimitUsd }),
});
}
@@ -87,6 +104,9 @@ export async function POST(request) {
machineId: apiKey.machineId,
noLog: noLog === true,
allowUsageCommand: allowUsageCommand === true,
usageLimitEnabled: usageLimitEnabled === true,
dailyUsageLimitUsd: dailyUsageLimitUsd ?? null,
weeklyUsageLimitUsd: weeklyUsageLimitUsd ?? null,
streamDefaultMode: "legacy",
},
{ status: 201 }

View File

@@ -0,0 +1,47 @@
export 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" },
{ name: "auto_resolve", definition: "auto_resolve INTEGER NOT NULL DEFAULT 0" },
{ name: "is_active", definition: "is_active INTEGER NOT NULL DEFAULT 1" },
{ name: "access_schedule", definition: "access_schedule TEXT" },
{ name: "max_requests_per_day", definition: "max_requests_per_day INTEGER" },
{ name: "max_requests_per_minute", definition: "max_requests_per_minute INTEGER" },
{ name: "throttle_delay_ms", definition: "throttle_delay_ms INTEGER" },
{ name: "max_sessions", definition: "max_sessions INTEGER NOT NULL DEFAULT 0" },
{ name: "revoked_at", definition: "revoked_at TEXT" },
{ name: "expires_at", definition: "expires_at TEXT" },
{ name: "last_used_at", definition: "last_used_at TEXT" },
{ name: "key_prefix", definition: "key_prefix TEXT" },
{ name: "ip_allowlist", definition: "ip_allowlist TEXT" },
{ name: "scopes", definition: "scopes TEXT" },
{ name: "rate_limits", definition: "rate_limits TEXT" },
{ name: "is_banned", definition: "is_banned INTEGER NOT NULL DEFAULT 0" },
{ name: "key_hash", definition: "key_hash TEXT" },
{ name: "proxy_id", definition: "proxy_id TEXT" },
{ 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: "allow_usage_command",
definition: "allow_usage_command INTEGER NOT NULL DEFAULT 0",
},
{
name: "usage_limit_enabled",
definition: "usage_limit_enabled INTEGER NOT NULL DEFAULT 0",
},
{
name: "daily_usage_limit_usd",
definition: "daily_usage_limit_usd REAL",
},
{
name: "weekly_usage_limit_usd",
definition: "weekly_usage_limit_usd REAL",
},
] as const;

View File

@@ -0,0 +1,65 @@
type UsageLimitRecord = Record<string, unknown>;
export interface ApiKeyUsageLimitFields {
usageLimitEnabled: boolean;
dailyUsageLimitUsd: number | null;
weeklyUsageLimitUsd: number | null;
}
export function parseUsageLimitEnabled(value: unknown): boolean {
return value === true || value === 1 || value === "1";
}
export function parseNullablePositiveNumber(value: unknown): number | null {
if (typeof value === "number" && Number.isFinite(value) && value > 0) return value;
if (typeof value === "string" && value.trim()) {
const parsed = Number(value);
return Number.isFinite(parsed) && parsed > 0 ? parsed : null;
}
return null;
}
export function parseApiKeyUsageLimitFields(record: UsageLimitRecord): ApiKeyUsageLimitFields {
return {
usageLimitEnabled: parseUsageLimitEnabled(
record.usage_limit_enabled ?? record.usageLimitEnabled
),
dailyUsageLimitUsd: parseNullablePositiveNumber(
record.daily_usage_limit_usd ?? record.dailyUsageLimitUsd
),
weeklyUsageLimitUsd: parseNullablePositiveNumber(
record.weekly_usage_limit_usd ?? record.weeklyUsageLimitUsd
),
};
}
export function hasUsageLimitUpdate(update: UsageLimitRecord): boolean {
return (
update.usageLimitEnabled !== undefined ||
update.dailyUsageLimitUsd !== undefined ||
update.weeklyUsageLimitUsd !== undefined
);
}
export function appendUsageLimitUpdates(
update: UsageLimitRecord,
updates: string[],
params: {
usageLimitEnabled?: number;
dailyUsageLimitUsd?: number | null;
weeklyUsageLimitUsd?: number | null;
}
) {
if (update.usageLimitEnabled !== undefined) {
updates.push("usage_limit_enabled = @usageLimitEnabled");
params.usageLimitEnabled = update.usageLimitEnabled === true ? 1 : 0;
}
if (update.dailyUsageLimitUsd !== undefined) {
updates.push("daily_usage_limit_usd = @dailyUsageLimitUsd");
params.dailyUsageLimitUsd = parseNullablePositiveNumber(update.dailyUsageLimitUsd);
}
if (update.weeklyUsageLimitUsd !== undefined) {
updates.push("weekly_usage_limit_usd = @weeklyUsageLimitUsd");
params.weeklyUsageLimitUsd = parseNullablePositiveNumber(update.weeklyUsageLimitUsd);
}
}

View File

@@ -8,6 +8,12 @@ import { getDbInstance, rowToCamel } from "./core";
import { backupDbFile } from "./backup";
import { registerDbStateResetter } from "./stateReset";
import { getKeyGroupsForApiKey, checkKeyModelAccess } from "./apiKeyGroups";
import { API_KEY_COLUMN_FALLBACKS } from "./apiKeyColumnFallbacks";
import {
appendUsageLimitUpdates,
hasUsageLimitUpdate,
parseApiKeyUsageLimitFields,
} from "./apiKeyUsageLimitFields";
import { setNoLog } from "../compliance/noLog";
import { resolveModelAlias } from "@omniroute/open-sse/services/modelDeprecation.ts";
import { getSyncedAvailableModelsByConnection, getCustomModels, getModelIsHidden } from "./models";
@@ -68,6 +74,9 @@ interface ApiKeyMetadata {
streamDefaultMode: "legacy" | "json";
disableNonPublicModels: boolean;
allowUsageCommand: boolean;
usageLimitEnabled: boolean;
dailyUsageLimitUsd: number | null;
weeklyUsageLimitUsd: number | null;
}
interface ApiKeyRow extends JsonRecord {
@@ -101,6 +110,12 @@ interface ApiKeyRow extends JsonRecord {
streamDefaultMode?: unknown;
allow_usage_command?: unknown;
allowUsageCommand?: unknown;
usage_limit_enabled?: unknown;
usageLimitEnabled?: unknown;
daily_usage_limit_usd?: unknown;
dailyUsageLimitUsd?: unknown;
weekly_usage_limit_usd?: unknown;
weeklyUsageLimitUsd?: unknown;
}
interface StatementLike<TRow = unknown> {
@@ -144,6 +159,9 @@ interface ApiKeyView extends JsonRecord {
streamDefaultMode: "legacy" | "json";
disableNonPublicModels?: boolean;
allowUsageCommand?: boolean;
usageLimitEnabled?: boolean;
dailyUsageLimitUsd?: number | null;
weeklyUsageLimitUsd?: number | null;
}
// LRU cache for API key validation (valid keys only)
@@ -159,42 +177,6 @@ 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" },
{ name: "auto_resolve", definition: "auto_resolve INTEGER NOT NULL DEFAULT 0" },
{ name: "is_active", definition: "is_active INTEGER NOT NULL DEFAULT 1" },
{ name: "access_schedule", definition: "access_schedule TEXT" },
{ name: "max_requests_per_day", definition: "max_requests_per_day INTEGER" },
{ name: "max_requests_per_minute", definition: "max_requests_per_minute INTEGER" },
{ name: "throttle_delay_ms", definition: "throttle_delay_ms INTEGER" },
{ name: "max_sessions", definition: "max_sessions INTEGER NOT NULL DEFAULT 0" },
{ name: "revoked_at", definition: "revoked_at TEXT" },
{ name: "expires_at", definition: "expires_at TEXT" },
{ name: "last_used_at", definition: "last_used_at TEXT" },
{ name: "key_prefix", definition: "key_prefix TEXT" },
{ name: "ip_allowlist", definition: "ip_allowlist TEXT" },
{ name: "scopes", definition: "scopes TEXT" },
{ name: "rate_limits", definition: "rate_limits TEXT" },
{ name: "is_banned", definition: "is_banned INTEGER NOT NULL DEFAULT 0" },
{ name: "key_hash", definition: "key_hash TEXT" },
{ name: "proxy_id", definition: "proxy_id TEXT" },
{ 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: "allow_usage_command",
definition: "allow_usage_command INTEGER NOT NULL DEFAULT 0",
},
] as const;
// Cache for model permission checks
const _modelPermissionCache = new Map<string, { allowed: boolean; timestamp: number }>();
@@ -510,7 +492,7 @@ function getPreparedStatements(db: ApiKeysDbLike): ApiKeysStatements {
"SELECT id, expires_at, revoked_at, is_active, is_banned FROM api_keys WHERE key = ? OR key_hash = ?"
);
_stmtGetKeyMetadata = db.prepare<ApiKeyRow>(
"SELECT id, name, machine_id, allowed_models, blocked_models, allowed_combos, allowed_connections, allowed_quotas, no_log, auto_resolve, is_active, access_schedule, max_requests_per_day, max_requests_per_minute, throttle_delay_ms, max_sessions, revoked_at, expires_at, ip_allowlist, scopes, rate_limits, is_banned, key_hash, allowed_endpoints, stream_default_mode, disable_non_public_models, allow_usage_command, 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, allow_usage_command, usage_limit_enabled, daily_usage_limit_usd, weekly_usage_limit_usd, 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 (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)"
@@ -563,6 +545,7 @@ export async function getApiKeys() {
(camelRow as JsonRecord).disableNonPublicModels
);
camelRow.allowUsageCommand = parseAllowUsageCommand((camelRow as JsonRecord).allowUsageCommand);
Object.assign(camelRow, parseApiKeyUsageLimitFields(camelRow));
if (typeof camelRow.id === "string" && camelRow.id.length > 0) {
setNoLog(camelRow.id, camelRow.noLog === true);
}
@@ -594,6 +577,7 @@ export async function getApiKeyById(id: string) {
(camelRow as JsonRecord).disableNonPublicModels
);
camelRow.allowUsageCommand = parseAllowUsageCommand((camelRow as JsonRecord).allowUsageCommand);
Object.assign(camelRow, parseApiKeyUsageLimitFields(camelRow));
if (typeof camelRow.id === "string" && camelRow.id.length > 0) {
setNoLog(camelRow.id, camelRow.noLog === true);
}
@@ -866,6 +850,9 @@ export async function updateApiKeyPermissions(
streamDefaultMode?: "legacy" | "json" | null;
disableNonPublicModels?: boolean;
allowUsageCommand?: boolean;
usageLimitEnabled?: boolean;
dailyUsageLimitUsd?: number | null;
weeklyUsageLimitUsd?: number | null;
}
) {
const db = getDbInstance() as ApiKeysDbLike;
@@ -900,6 +887,10 @@ export async function updateApiKeyPermissions(
disableNonPublicModels: (update as { disableNonPublicModels?: boolean })
.disableNonPublicModels,
allowUsageCommand: (update as { allowUsageCommand?: boolean }).allowUsageCommand,
usageLimitEnabled: (update as { usageLimitEnabled?: boolean }).usageLimitEnabled,
dailyUsageLimitUsd: (update as { dailyUsageLimitUsd?: number | null }).dailyUsageLimitUsd,
weeklyUsageLimitUsd: (update as { weeklyUsageLimitUsd?: number | null })
.weeklyUsageLimitUsd,
};
if (
@@ -925,7 +916,8 @@ export async function updateApiKeyPermissions(
(normalized as Record<string, unknown>).allowedEndpoints === undefined &&
(normalized as Record<string, unknown>).streamDefaultMode === undefined &&
normalized.disableNonPublicModels === undefined &&
normalized.allowUsageCommand === undefined
normalized.allowUsageCommand === undefined &&
!hasUsageLimitUpdate(normalized as Record<string, unknown>)
) {
return false;
}
@@ -955,6 +947,9 @@ export async function updateApiKeyPermissions(
streamDefaultMode?: "legacy" | "json";
disableNonPublicModels?: number;
allowUsageCommand?: number;
usageLimitEnabled?: number;
dailyUsageLimitUsd?: number | null;
weeklyUsageLimitUsd?: number | null;
} = { id };
if (normalized.name !== undefined) {
@@ -1058,6 +1053,8 @@ export async function updateApiKeyPermissions(
params.allowUsageCommand = normalized.allowUsageCommand ? 1 : 0;
}
appendUsageLimitUpdates(normalized as Record<string, unknown>, updates, params);
const maxSessionsUpdate = (normalized as Record<string, unknown>).maxSessions;
if (maxSessionsUpdate !== undefined) {
updates.push("max_sessions = @maxSessions");
@@ -1439,6 +1436,9 @@ export async function getApiKeyMetadata(
streamDefaultMode: "legacy",
disableNonPublicModels: false,
allowUsageCommand: false,
usageLimitEnabled: false,
dailyUsageLimitUsd: null,
weeklyUsageLimitUsd: null,
};
}
@@ -1512,6 +1512,7 @@ export async function getApiKeyMetadata(
allowUsageCommand: parseAllowUsageCommand(
(record as JsonRecord).allow_usage_command ?? (record as JsonRecord).allowUsageCommand
),
...parseApiKeyUsageLimitFields(record as JsonRecord),
};
if (!metadata.id) {

View File

@@ -0,0 +1,4 @@
-- Migration: per-API-key USD usage limits
ALTER TABLE api_keys ADD COLUMN usage_limit_enabled INTEGER NOT NULL DEFAULT 0;
ALTER TABLE api_keys ADD COLUMN daily_usage_limit_usd REAL;
ALTER TABLE api_keys ADD COLUMN weekly_usage_limit_usd REAL;

View File

@@ -0,0 +1,354 @@
import { getDbInstance } from "@/lib/db/core";
import type { ProviderLimitsCacheEntry } from "@/lib/db/providerLimits";
import { calculateCost } from "./costCalculator";
import { buildErrorBody, sanitizeErrorMessage } from "@omniroute/open-sse/utils/error.ts";
const FORTALEZA_UTC_OFFSET_MS = 3 * 60 * 60 * 1000;
const WEEK_MS = 7 * 24 * 60 * 60 * 1000;
export interface ApiKeyUsageLimitMetadata {
id: string;
allowedConnections?: string[] | null;
usageLimitEnabled?: boolean;
dailyUsageLimitUsd?: number | null;
weeklyUsageLimitUsd?: number | null;
}
export interface ApiKeyUsageLimitStatus {
enabled: boolean;
dailyLimitUsd: number | null;
weeklyLimitUsd: number | null;
dailySpentUsd: number;
weeklySpentUsd: number;
dailyWindowStartIso: string;
weeklyWindowStartIso: string;
weeklyResetAtIso: string | null;
dailyExceeded: boolean;
weeklyExceeded: boolean;
}
export interface ApiKeyUsageLimitDeps {
now?: () => number;
getProviderConnectionById?: (connectionId: string) => Promise<unknown>;
getProviderConnections?: (filter?: Record<string, unknown>) => Promise<unknown[]>;
getProviderLimitsCache?: (connectionId: string) => ProviderLimitsCacheEntry | null;
getAllProviderLimitsCache?: () => Record<string, ProviderLimitsCacheEntry>;
}
interface UsageCostRow {
provider: string | null;
model: string | null;
serviceTier: string | null;
promptTokens: number | null;
completionTokens: number | null;
cacheReadTokens: number | null;
cacheCreationTokens: number | null;
reasoningTokens: number | null;
}
function toNumber(value: unknown): number {
if (typeof value === "number" && Number.isFinite(value)) return value;
if (typeof value === "string" && value.trim()) {
const parsed = Number(value);
return Number.isFinite(parsed) ? parsed : 0;
}
return 0;
}
function asRecord(value: unknown): Record<string, unknown> | null {
return value && typeof value === "object" && !Array.isArray(value)
? (value as Record<string, unknown>)
: null;
}
function normalizeLimitUsd(value: unknown): number | null {
const numeric = toNumber(value);
return numeric > 0 ? numeric : null;
}
function roundUsd(value: number): number {
return Math.round(value * 1_000_000) / 1_000_000;
}
function formatUsd(value: number | null): string {
if (value === null || !Number.isFinite(value)) return "Not configured";
return `$${value.toFixed(2)}`;
}
export function getFortalezaDayStartIso(nowMs = Date.now()): string {
const fortalezaLocal = new Date(nowMs - FORTALEZA_UTC_OFFSET_MS);
return new Date(
Date.UTC(
fortalezaLocal.getUTCFullYear(),
fortalezaLocal.getUTCMonth(),
fortalezaLocal.getUTCDate(),
3,
0,
0,
0
)
).toISOString();
}
export function getRollingWeekStartIso(nowMs = Date.now()): string {
return new Date(nowMs - WEEK_MS).toISOString();
}
function normalizeQuotaName(value: string): string {
return value
.toLowerCase()
.replace(/[^a-z0-9]+/g, " ")
.trim();
}
function findWeeklyQuotaResetAt(quotas: unknown, nowMs: number): string | null {
const quotaEntries: Array<[string, Record<string, unknown>]> = [];
if (Array.isArray(quotas)) {
for (const item of quotas) {
const quota = asRecord(item);
if (!quota) continue;
const name = typeof quota.name === "string" ? quota.name : "";
quotaEntries.push([name, quota]);
}
} else {
const quotaMap = asRecord(quotas);
if (quotaMap) {
for (const [name, value] of Object.entries(quotaMap)) {
const quota = asRecord(value);
if (quota) quotaEntries.push([name, quota]);
}
}
}
for (const [name, quota] of quotaEntries) {
const label = normalizeQuotaName(`${name} ${typeof quota.name === "string" ? quota.name : ""}`);
if (!label) continue;
const isWeekly = label.includes("weekly") || label.includes("7d");
if (!isWeekly || label.includes("sonnet")) continue;
const resetAt = typeof quota.resetAt === "string" && quota.resetAt.trim() ? quota.resetAt : "";
const resetMs = Date.parse(resetAt);
if (Number.isFinite(resetMs) && resetMs > nowMs) {
return new Date(resetMs).toISOString();
}
}
return null;
}
function connectionFromValue(value: unknown): { id: string; provider: string } | null {
const record = asRecord(value);
if (!record) return null;
const id = typeof record.id === "string" ? record.id : "";
const provider = typeof record.provider === "string" ? record.provider : "";
if (!id || !provider || record.isActive === false) return null;
return { id, provider };
}
async function resolveDeps(deps: ApiKeyUsageLimitDeps): Promise<Required<ApiKeyUsageLimitDeps>> {
const providers =
deps.getProviderConnectionById && deps.getProviderConnections
? null
: await import("@/lib/db/providers");
const providerLimits =
deps.getProviderLimitsCache && deps.getAllProviderLimitsCache
? null
: await import("@/lib/db/providerLimits");
return {
now: deps.now ?? Date.now,
getProviderConnectionById:
deps.getProviderConnectionById ?? providers!.getProviderConnectionById,
getProviderConnections: deps.getProviderConnections ?? providers!.getProviderConnections,
getProviderLimitsCache: deps.getProviderLimitsCache ?? providerLimits!.getProviderLimitsCache,
getAllProviderLimitsCache:
deps.getAllProviderLimitsCache ?? providerLimits!.getAllProviderLimitsCache,
};
}
async function getProviderWeeklyResetAt(
metadata: ApiKeyUsageLimitMetadata,
deps: Required<ApiKeyUsageLimitDeps>,
nowMs: number
): Promise<string | null> {
const allowedConnections = Array.isArray(metadata.allowedConnections)
? metadata.allowedConnections.filter((id) => typeof id === "string" && id.trim())
: [];
const resetCandidates: string[] = [];
if (allowedConnections.length > 0) {
for (const connectionId of allowedConnections) {
const connection = connectionFromValue(await deps.getProviderConnectionById(connectionId));
if (!connection || connection.provider.toLowerCase() !== "claude") continue;
const resetAt = findWeeklyQuotaResetAt(
deps.getProviderLimitsCache(connection.id)?.quotas,
nowMs
);
if (resetAt) resetCandidates.push(resetAt);
}
} else {
const caches = deps.getAllProviderLimitsCache();
const connections = await deps.getProviderConnections({ isActive: true });
for (const rawConnection of connections) {
const connection = connectionFromValue(rawConnection);
if (!connection || connection.provider.toLowerCase() !== "claude") continue;
const resetAt = findWeeklyQuotaResetAt(caches[connection.id]?.quotas, nowMs);
if (resetAt) resetCandidates.push(resetAt);
}
}
return resetCandidates.sort((left, right) => Date.parse(left) - Date.parse(right)).at(0) ?? null;
}
async function getApiKeyUsdSpendSince(apiKeyId: string, sinceIso: string): Promise<number> {
if (!apiKeyId) return 0;
const db = getDbInstance();
const rows = db
.prepare(
`
SELECT
LOWER(provider) as provider,
LOWER(model) as model,
COALESCE(NULLIF(service_tier, ''), 'standard') as serviceTier,
COALESCE(SUM(tokens_input), 0) as promptTokens,
COALESCE(SUM(tokens_output), 0) as completionTokens,
COALESCE(SUM(tokens_cache_read), 0) as cacheReadTokens,
COALESCE(SUM(tokens_cache_creation), 0) as cacheCreationTokens,
COALESCE(SUM(tokens_reasoning), 0) as reasoningTokens
FROM usage_history
WHERE api_key_id = @apiKeyId
AND timestamp >= @sinceIso
AND success = 1
GROUP BY LOWER(provider), LOWER(model), serviceTier
`
)
.all({ apiKeyId, sinceIso }) as UsageCostRow[];
let total = 0;
for (const row of rows) {
const provider = typeof row.provider === "string" ? row.provider : "";
const model = typeof row.model === "string" ? row.model : "";
if (!provider || !model) continue;
total += await calculateCost(
provider,
model,
{
input: toNumber(row.promptTokens),
output: toNumber(row.completionTokens),
cacheRead: toNumber(row.cacheReadTokens),
cacheCreation: toNumber(row.cacheCreationTokens),
reasoning: toNumber(row.reasoningTokens),
},
{
provider,
model,
serviceTier: row.serviceTier || "standard",
}
);
}
return roundUsd(total);
}
export async function getApiKeyUsageLimitStatus(
metadata: ApiKeyUsageLimitMetadata,
deps: ApiKeyUsageLimitDeps = {}
): Promise<ApiKeyUsageLimitStatus> {
const resolvedDeps = await resolveDeps(deps);
const now = resolvedDeps.now();
const dailyWindowStartIso = getFortalezaDayStartIso(now);
const weeklyResetAtIso = await getProviderWeeklyResetAt(metadata, resolvedDeps, now);
const weeklyWindowStartIso = weeklyResetAtIso
? new Date(Date.parse(weeklyResetAtIso) - WEEK_MS).toISOString()
: getRollingWeekStartIso(now);
const dailyLimitUsd = normalizeLimitUsd(metadata.dailyUsageLimitUsd);
const weeklyLimitUsd = normalizeLimitUsd(metadata.weeklyUsageLimitUsd);
const enabled = metadata.usageLimitEnabled === true;
const [dailySpentUsd, weeklySpentUsd] = await Promise.all([
getApiKeyUsdSpendSince(metadata.id, dailyWindowStartIso),
getApiKeyUsdSpendSince(metadata.id, weeklyWindowStartIso),
]);
return {
enabled,
dailyLimitUsd,
weeklyLimitUsd,
dailySpentUsd,
weeklySpentUsd,
dailyWindowStartIso,
weeklyWindowStartIso,
weeklyResetAtIso,
dailyExceeded: enabled && dailyLimitUsd !== null && dailySpentUsd >= dailyLimitUsd,
weeklyExceeded: enabled && weeklyLimitUsd !== null && weeklySpentUsd >= weeklyLimitUsd,
};
}
export function buildApiKeyUsageLimitText(status: ApiKeyUsageLimitStatus): string {
return [
"Cota diaria",
formatUsd(status.dailyLimitUsd),
"Gasto diario",
formatUsd(status.dailySpentUsd),
"",
"Cota semanal",
formatUsd(status.weeklyLimitUsd),
"Gasto semanal",
formatUsd(status.weeklySpentUsd),
].join("\n");
}
function buildUsageLimitExceededMessage(status: ApiKeyUsageLimitStatus): string {
if (status.dailyExceeded && status.dailyLimitUsd !== null) {
return `This API key reached its daily USD usage quota (${formatUsd(status.dailySpentUsd)} of ${formatUsd(status.dailyLimitUsd)}). Choose another allowed model or wait for quota reset.`;
}
if (status.weeklyExceeded && status.weeklyLimitUsd !== null) {
return `This API key reached its weekly USD usage quota (${formatUsd(status.weeklySpentUsd)} of ${formatUsd(status.weeklyLimitUsd)}). Choose another allowed model or wait for quota reset.`;
}
return "This API key reached its USD usage quota. Choose another allowed model or wait for quota reset.";
}
function isAnthropicMessagesRequest(request: Request): boolean {
if (request.headers.has("anthropic-version")) return true;
try {
return new URL(request.url).pathname.endsWith("/v1/messages");
} catch {
return false;
}
}
export function buildApiKeyUsageLimitRejection(
request: Request,
status: ApiKeyUsageLimitStatus
): Response {
const message = sanitizeErrorMessage(buildUsageLimitExceededMessage(status));
if (isAnthropicMessagesRequest(request)) {
return new Response(
JSON.stringify({
type: "error",
error: {
type: "invalid_request_error",
message,
},
}),
{
status: 400,
headers: { "Content-Type": "application/json" },
}
);
}
return new Response(JSON.stringify(buildErrorBody(400, message)), {
status: 400,
headers: { "Content-Type": "application/json" },
});
}
export async function buildApiKeyUsageLimitPolicyRejection(
request: Request,
metadata: ApiKeyUsageLimitMetadata
): Promise<Response | null> {
const status = await getApiKeyUsageLimitStatus(metadata);
if (!status.enabled || (!status.dailyExceeded && !status.weeklyExceeded)) return null;
return buildApiKeyUsageLimitRejection(request, status);
}

View File

@@ -1,4 +1,8 @@
import type { ProviderLimitsCacheEntry } from "@/lib/db/providerLimits";
import {
buildApiKeyUsageLimitText,
type ApiKeyUsageLimitStatus,
} from "@/lib/usage/apiKeyUsageLimits";
export const INTERNAL_USAGE_COMMAND = "@@om-usage";
export const USAGE_COMMAND_DISABLED_MESSAGE = "Usage command is disabled for this API key.";
@@ -12,6 +16,9 @@ interface UsageCommandApiKeyMetadata {
name?: string;
allowedConnections?: string[] | null;
allowUsageCommand?: boolean;
usageLimitEnabled?: boolean;
dailyUsageLimitUsd?: number | null;
weeklyUsageLimitUsd?: number | null;
}
interface ProviderConnectionLike {
@@ -35,6 +42,10 @@ export interface InternalUsageCommandDeps {
getProviderConnections?: (filter?: JsonRecord) => Promise<unknown[]>;
getProviderLimitsCache?: (connectionId: string) => ProviderLimitsCacheEntry | null;
getAllProviderLimitsCache?: () => Record<string, ProviderLimitsCacheEntry>;
getApiKeyUsageLimitStatus?: (
metadata: UsageCommandApiKeyMetadata,
deps?: { now?: () => number }
) => Promise<ApiKeyUsageLimitStatus>;
}
type RequiredDeps = Required<InternalUsageCommandDeps>;
@@ -50,6 +61,9 @@ async function normalizeDeps(deps: InternalUsageCommandDeps = {}): Promise<Requi
deps.getProviderLimitsCache && deps.getAllProviderLimitsCache
? null
: await import("@/lib/db/providerLimits");
const usageLimits = deps.getApiKeyUsageLimitStatus
? null
: await import("@/lib/usage/apiKeyUsageLimits");
return {
now: deps.now ?? Date.now,
@@ -61,6 +75,8 @@ async function normalizeDeps(deps: InternalUsageCommandDeps = {}): Promise<Requi
getProviderLimitsCache: deps.getProviderLimitsCache ?? providerLimits!.getProviderLimitsCache,
getAllProviderLimitsCache:
deps.getAllProviderLimitsCache ?? providerLimits!.getAllProviderLimitsCache,
getApiKeyUsageLimitStatus:
deps.getApiKeyUsageLimitStatus ?? usageLimits!.getApiKeyUsageLimitStatus,
};
}
@@ -358,6 +374,12 @@ export async function buildUsageCommandText(
deps: InternalUsageCommandDeps = {}
): Promise<string> {
const resolvedDeps = await normalizeDeps(deps);
if (metadata.usageLimitEnabled === true) {
return buildApiKeyUsageLimitText(
await resolvedDeps.getApiKeyUsageLimitStatus(metadata, { now: resolvedDeps.now })
);
}
const snapshot = selectUsageSnapshot(await collectUsageSnapshots(metadata, resolvedDeps));
if (!snapshot) {

View File

@@ -30,6 +30,7 @@ import { checkRateLimit, RateLimitRule } from "./rateLimiter";
import { resolveEndpointCategory } from "@/shared/constants/endpointCategories";
import { resolveQuotaKeyScope } from "@/lib/quota/quotaKey";
import { isQuotaModelName, parseQuotaModelName } from "@/lib/quota/quotaModelNaming";
import { buildApiKeyUsageLimitPolicyRejection } from "@/lib/usage/apiKeyUsageLimits";
// Default to no per-key request cap. API keys can still opt into explicit
// limits via Settings/API Keys, while provider/account quota controls remain
@@ -92,6 +93,9 @@ export interface ApiKeyMetadata {
allowedEndpoints?: string[];
disableNonPublicModels?: boolean;
allowUsageCommand?: boolean;
usageLimitEnabled?: boolean;
dailyUsageLimitUsd?: number | null;
weeklyUsageLimitUsd?: number | null;
}
/**
@@ -379,6 +383,31 @@ export async function enforceApiKeyPolicy(
}
}
// ── Check 2.1: per-key USD fair usage cap ──
if (apiKeyInfo.usageLimitEnabled === true) {
try {
const usageLimitRejection = await buildApiKeyUsageLimitPolicyRejection(request, {
id: apiKeyInfo.id,
usageLimitEnabled: apiKeyInfo.usageLimitEnabled,
dailyUsageLimitUsd: apiKeyInfo.dailyUsageLimitUsd,
weeklyUsageLimitUsd: apiKeyInfo.weeklyUsageLimitUsd,
});
if (usageLimitRejection) {
return { apiKey, apiKeyInfo, rejection: usageLimitRejection };
}
} catch (error) {
log.error("API_POLICY", "API key USD usage limit check failed. Request blocked.", { error });
return {
apiKey,
apiKeyInfo,
rejection: errorResponse(
HTTP_STATUS.SERVICE_UNAVAILABLE,
"API key usage limit unavailable"
),
};
}
}
// ── Check 2.5: Endpoint restriction ──
if (apiKeyInfo.allowedEndpoints && apiKeyInfo.allowedEndpoints.length > 0) {
try {

View File

@@ -22,6 +22,9 @@ export const createKeySchema = z.object({
name: z.string().min(1, "Name is required").max(200),
noLog: z.boolean().optional(),
allowUsageCommand: z.boolean().optional(),
usageLimitEnabled: z.boolean().optional(),
dailyUsageLimitUsd: z.coerce.number().min(0).optional().nullable(),
weeklyUsageLimitUsd: z.coerce.number().min(0).optional().nullable(),
scopes: z.array(z.string().trim().min(1).max(64)).max(32).optional(),
});
@@ -104,6 +107,9 @@ export const updateKeyPermissionsSchema = z
streamDefaultMode: z.enum(["legacy", "json"]).optional(),
disableNonPublicModels: z.boolean().optional(),
allowUsageCommand: z.boolean().optional(),
usageLimitEnabled: z.boolean().optional(),
dailyUsageLimitUsd: z.coerce.number().min(0).optional().nullable(),
weeklyUsageLimitUsd: z.coerce.number().min(0).optional().nullable(),
})
.superRefine((value, ctx) => {
if (
@@ -124,7 +130,10 @@ export const updateKeyPermissionsSchema = z
value.allowedEndpoints === undefined &&
value.streamDefaultMode === undefined &&
value.disableNonPublicModels === undefined &&
value.allowUsageCommand === undefined
value.allowUsageCommand === undefined &&
value.usageLimitEnabled === undefined &&
value.dailyUsageLimitUsd === undefined &&
value.weeklyUsageLimitUsd === undefined
) {
ctx.addIssue({
code: z.ZodIssueCode.custom,
@@ -132,4 +141,4 @@ export const updateKeyPermissionsSchema = z
path: [],
});
}
});
});

View File

@@ -0,0 +1,197 @@
import test from "node:test";
import assert from "node:assert/strict";
import fs from "node:fs";
import os from "node:os";
import path from "node:path";
const TEST_DATA_DIR = fs.mkdtempSync(path.join(os.tmpdir(), "omniroute-api-key-usage-limits-"));
process.env.DATA_DIR = TEST_DATA_DIR;
process.env.API_KEY_SECRET = process.env.API_KEY_SECRET || "usage-limit-test-secret";
const core = await import("../../src/lib/db/core.ts");
const localDb = await import("../../src/lib/localDb.ts");
const apiKeysDb = await import("../../src/lib/db/apiKeys.ts");
const usageHistory = await import("../../src/lib/usage/usageHistory.ts");
const usageLimits = await import("../../src/lib/usage/apiKeyUsageLimits.ts");
const NOW = Date.parse("2026-06-19T20:00:00.000Z");
async function resetStorage() {
core.resetDbInstance();
apiKeysDb.resetApiKeyState();
usageHistory.clearPendingRequests();
fs.rmSync(TEST_DATA_DIR, { recursive: true, force: true });
fs.mkdirSync(TEST_DATA_DIR, { recursive: true });
}
test.beforeEach(async () => {
await resetStorage();
});
test.after(() => {
core.resetDbInstance();
apiKeysDb.resetApiKeyState();
fs.rmSync(TEST_DATA_DIR, { recursive: true, force: true });
});
test("API key USD usage limits persist and default off", async () => {
const created = await apiKeysDb.createApiKey("Usage Limit Key", "machine-limit-01");
let metadata = await apiKeysDb.getApiKeyMetadata(created.key);
assert.equal(metadata?.usageLimitEnabled, false);
assert.equal(metadata?.dailyUsageLimitUsd, null);
assert.equal(metadata?.weeklyUsageLimitUsd, null);
await apiKeysDb.updateApiKeyPermissions(created.id, {
usageLimitEnabled: true,
dailyUsageLimitUsd: 10.5,
weeklyUsageLimitUsd: 50,
});
apiKeysDb.clearApiKeyCaches();
metadata = await apiKeysDb.getApiKeyMetadata(created.key);
assert.equal(metadata?.usageLimitEnabled, true);
assert.equal(metadata?.dailyUsageLimitUsd, 10.5);
assert.equal(metadata?.weeklyUsageLimitUsd, 50);
});
test("getApiKeyUsageLimitStatus aligns weekly USD spend with provider resetAt when available", async () => {
await localDb.updatePricing({
claude: {
"claude-opus-4-8": {
input: 1,
cached: 1,
output: 1,
reasoning: 1,
cache_creation: 1,
},
},
});
const created = await apiKeysDb.createApiKey("Metered Key", "machine-limit-02");
await apiKeysDb.updateApiKeyPermissions(created.id, {
usageLimitEnabled: true,
dailyUsageLimitUsd: 10,
weeklyUsageLimitUsd: 20,
});
await usageHistory.saveRequestUsage({
provider: "claude",
model: "claude-opus-4-8",
apiKeyId: created.id,
apiKeyName: "Metered Key",
tokens: { input: 2_000_000, output: 0 },
success: true,
timestamp: "2026-06-19T12:00:00.000Z",
});
await usageHistory.saveRequestUsage({
provider: "claude",
model: "claude-opus-4-8",
apiKeyId: created.id,
apiKeyName: "Metered Key",
tokens: { input: 3_000_000, output: 0 },
success: true,
timestamp: "2026-06-18T21:00:00.000Z",
});
await usageHistory.saveRequestUsage({
provider: "claude",
model: "claude-opus-4-8",
apiKeyId: created.id,
apiKeyName: "Metered Key",
tokens: { input: 7_000_000, output: 0 },
success: true,
timestamp: "2026-06-18T12:00:00.000Z",
});
const metadata = await apiKeysDb.getApiKeyMetadata(created.key);
assert.ok(metadata);
const weeklyResetAt = "2026-06-25T20:00:00.000Z";
const status = await usageLimits.getApiKeyUsageLimitStatus(
{ ...metadata, allowedConnections: ["conn-claude"] },
{
now: () => NOW,
getProviderConnectionById: async () => ({
id: "conn-claude",
provider: "claude",
isActive: true,
}),
getProviderConnections: async () => [],
getProviderLimitsCache: () => ({
plan: "Claude Max",
quotas: {
"weekly (7d)": {
used: 27,
total: 100,
resetAt: weeklyResetAt,
},
},
message: null,
fetchedAt: new Date(NOW).toISOString(),
}),
getAllProviderLimitsCache: () => ({}),
}
);
assert.equal(status.enabled, true);
assert.equal(status.dailySpentUsd, 2);
assert.equal(status.weeklySpentUsd, 5);
assert.equal(status.dailyLimitUsd, 10);
assert.equal(status.weeklyLimitUsd, 20);
assert.equal(status.weeklyWindowStartIso, "2026-06-18T20:00:00.000Z");
assert.equal(status.weeklyResetAtIso, weeklyResetAt);
assert.equal(status.dailyExceeded, false);
assert.equal(status.weeklyExceeded, false);
});
test("buildApiKeyUsageLimitText returns only the quota and spent USD lines", async () => {
const text = usageLimits.buildApiKeyUsageLimitText({
enabled: true,
dailyLimitUsd: 10,
weeklyLimitUsd: 50,
dailySpentUsd: 2,
weeklySpentUsd: 5.25,
dailyWindowStartIso: "2026-06-19T03:00:00.000Z",
weeklyWindowStartIso: "2026-06-12T20:00:00.000Z",
weeklyResetAtIso: "2026-06-19T20:00:00.000Z",
dailyExceeded: false,
weeklyExceeded: false,
});
assert.equal(
text,
[
"Cota diaria",
"$10.00",
"Gasto diario",
"$2.00",
"",
"Cota semanal",
"$50.00",
"Gasto semanal",
"$5.25",
].join("\n")
);
});
test("buildApiKeyUsageLimitRejection uses 400 so Claude Code does not trigger login", () => {
const response = usageLimits.buildApiKeyUsageLimitRejection(
new Request("http://localhost/v1/messages", {
headers: { "anthropic-version": "2023-06-01" },
}),
{
enabled: true,
dailyLimitUsd: 10,
weeklyLimitUsd: 50,
dailySpentUsd: 12,
weeklySpentUsd: 20,
dailyWindowStartIso: "2026-06-19T03:00:00.000Z",
weeklyWindowStartIso: "2026-06-12T20:00:00.000Z",
weeklyResetAtIso: "2026-06-19T20:00:00.000Z",
dailyExceeded: true,
weeklyExceeded: false,
}
);
assert.equal(response.status, 400);
});

View File

@@ -113,6 +113,61 @@ test("buildUsageCommandText formats cached Claude usage windows exactly", async
);
});
test("buildUsageCommandText formats API key USD limits when fair usage is enabled", async () => {
const text = await buildUsageCommandText(
{
id: "key-limited",
name: "limited",
allowedConnections: ["conn-claude"],
usageLimitEnabled: true,
dailyUsageLimitUsd: 10,
weeklyUsageLimitUsd: 50,
},
{
now: () => NOW,
getApiKeyUsageLimitStatus: async () => ({
enabled: true,
dailyLimitUsd: 10,
weeklyLimitUsd: 50,
dailySpentUsd: 2,
weeklySpentUsd: 5.25,
dailyWindowStartIso: "2026-06-16T03:00:00.000Z",
weeklyWindowStartIso: "2026-06-09T12:00:00.000Z",
weeklyResetAtIso: "2026-06-16T12:00:00.000Z",
dailyExceeded: false,
weeklyExceeded: false,
}),
getProviderConnectionById: async () => {
throw new Error("provider connection lookup must not run for fair usage output");
},
getProviderConnections: async () => {
throw new Error("provider connection lookup must not run for fair usage output");
},
getProviderLimitsCache: () => null,
getAllProviderLimitsCache: () => {
throw new Error("provider cache lookup must not run for fair usage output");
},
isValidApiKey: async () => true,
getApiKeyMetadata: async () => null,
}
);
assert.equal(
text,
[
"Cota diaria",
"$10.00",
"Gasto diario",
"$2.00",
"",
"Cota semanal",
"$50.00",
"Gasto semanal",
"$5.25",
].join("\n")
);
});
test("handleInternalUsageCommand returns disabled response locally without provider routing", async () => {
const response = await handleInternalUsageCommand(
new Request("http://localhost/v1/chat/completions", {