diff --git a/src/app/(dashboard)/dashboard/costs/components/ApiKeyUsageLimitCard.tsx b/src/app/(dashboard)/dashboard/costs/components/ApiKeyUsageLimitCard.tsx index 7916ac1633..7b4166ad83 100644 --- a/src/app/(dashboard)/dashboard/costs/components/ApiKeyUsageLimitCard.tsx +++ b/src/app/(dashboard)/dashboard/costs/components/ApiKeyUsageLimitCard.tsx @@ -18,6 +18,7 @@ export interface ApiKeyUsageLimitPayload { dailySpentUsd: number; weeklySpentUsd: number; dailyWindowStartIso: string; + dailyResetAtIso: string; weeklyWindowStartIso: string; weeklyResetAtIso: string | null; dailyExceeded: boolean; diff --git a/src/lib/usage/apiKeyUsageLimits.ts b/src/lib/usage/apiKeyUsageLimits.ts index 166c717bf4..c9d2165df6 100644 --- a/src/lib/usage/apiKeyUsageLimits.ts +++ b/src/lib/usage/apiKeyUsageLimits.ts @@ -4,6 +4,7 @@ import { calculateCost } from "./costCalculator"; import { buildErrorBody, sanitizeErrorMessage } from "@omniroute/open-sse/utils/error.ts"; const FORTALEZA_UTC_OFFSET_MS = 3 * 60 * 60 * 1000; +const DAY_MS = 24 * 60 * 60 * 1000; const WEEK_MS = 7 * 24 * 60 * 60 * 1000; export interface ApiKeyUsageLimitMetadata { @@ -21,6 +22,7 @@ export interface ApiKeyUsageLimitStatus { dailySpentUsd: number; weeklySpentUsd: number; dailyWindowStartIso: string; + dailyResetAtIso: string; weeklyWindowStartIso: string; weeklyResetAtIso: string | null; dailyExceeded: boolean; @@ -46,6 +48,18 @@ interface UsageCostRow { reasoningTokens: number | null; } +interface WeeklyResetCandidate { + connectionId: string; + resetAtIso: string; + observedWindowStartIso: string | null; +} + +interface QuotaSnapshotRow { + remainingPercentage: number | null; + nextResetAt: string | null; + createdAt: string | null; +} + function toNumber(value: unknown): number { if (typeof value === "number" && Number.isFinite(value)) return value; if (typeof value === "string" && value.trim()) { @@ -70,11 +84,49 @@ function roundUsd(value: number): number { return Math.round(value * 1_000_000) / 1_000_000; } +function clampPercent(value: number): number { + return Math.max(0, Math.min(100, value)); +} + function formatUsd(value: number | null): string { if (value === null || !Number.isFinite(value)) return "Not configured"; return `$${value.toFixed(2)}`; } +function getUsagePercent(spentUsd: number, limitUsd: number | null): number | null { + if (limitUsd === null || !Number.isFinite(limitUsd) || limitUsd <= 0) return null; + return (spentUsd / limitUsd) * 100; +} + +function formatUsagePercent(percent: number | null): string { + if (percent === null || !Number.isFinite(percent)) return "Unavailable"; + return `${Math.round(percent)}%`; +} + +function formatResetIn(resetAt: string | null, now = Date.now()): string { + if (!resetAt) return "unknown"; + const resetMs = Date.parse(resetAt); + if (!Number.isFinite(resetMs)) return "unknown"; + + const deltaMs = resetMs - now; + if (deltaMs <= 0) return "now"; + + const minuteMs = 60_000; + const hourMs = 60 * minuteMs; + const dayMs = 24 * hourMs; + + if (deltaMs < hourMs) return `${Math.max(1, Math.ceil(deltaMs / minuteMs))}m`; + if (deltaMs < dayMs) return `${Math.max(1, Math.ceil(deltaMs / hourMs))}h`; + return `${Math.max(1, Math.ceil(deltaMs / dayMs))}d`; +} + +function resetDay(value: string | null): string | null { + if (!value) return null; + const parsed = Date.parse(value); + if (!Number.isFinite(parsed)) return null; + return new Date(parsed).toISOString().slice(0, 10); +} + export function getFortalezaDayStartIso(nowMs = Date.now()): string { const fortalezaLocal = new Date(nowMs - FORTALEZA_UTC_OFFSET_MS); return new Date( @@ -90,6 +142,10 @@ export function getFortalezaDayStartIso(nowMs = Date.now()): string { ).toISOString(); } +export function getFortalezaDayResetIso(nowMs = Date.now()): string { + return new Date(Date.parse(getFortalezaDayStartIso(nowMs)) + DAY_MS).toISOString(); +} + export function getRollingWeekStartIso(nowMs = Date.now()): string { return new Date(nowMs - WEEK_MS).toISOString(); } @@ -144,6 +200,64 @@ function connectionFromValue(value: unknown): { id: string; provider: string } | return { id, provider }; } +function isWeeklyQuotaResetSnapshot(row: QuotaSnapshotRow, targetResetAtIso: string): boolean { + const targetDay = resetDay(targetResetAtIso); + if (!targetDay) return false; + return resetDay(row.nextResetAt) === targetDay; +} + +function getObservedWeeklyWindowStartIso( + connectionId: string, + targetResetAtIso: string, + nowMs: number +): string | null { + if (!connectionId || !targetResetAtIso) return null; + + try { + const rows = getDbInstance() + .prepare( + ` + SELECT + remaining_percentage as remainingPercentage, + next_reset_at as nextResetAt, + created_at as createdAt + FROM quota_snapshots + WHERE connection_id = @connectionId + AND LOWER(window_key) LIKE '%weekly%' + AND LOWER(window_key) NOT LIKE '%sonnet%' + AND created_at <= @nowIso + ORDER BY created_at ASC, id ASC + ` + ) + .all({ connectionId, nowIso: new Date(nowMs).toISOString() }) as QuotaSnapshotRow[]; + + let observedStartIso: string | null = null; + let previousUsedPercent: number | null = null; + + for (const row of rows) { + if (!row.createdAt || !isWeeklyQuotaResetSnapshot(row, targetResetAtIso)) continue; + const remaining = toNumber(row.remainingPercentage); + const usedPercent = clampPercent(100 - remaining); + + if (!observedStartIso) { + observedStartIso = row.createdAt; + } else if (previousUsedPercent !== null) { + const droppedToResetFloor = usedPercent <= 1 && previousUsedPercent > usedPercent; + const significantDrop = previousUsedPercent - usedPercent >= 5; + if (droppedToResetFloor || significantDrop) { + observedStartIso = row.createdAt; + } + } + + previousUsedPercent = usedPercent; + } + + return observedStartIso; + } catch { + return null; + } +} + async function resolveDeps(deps: ApiKeyUsageLimitDeps): Promise> { const providers = deps.getProviderConnectionById && deps.getProviderConnections @@ -165,16 +279,16 @@ async function resolveDeps(deps: ApiKeyUsageLimitDeps): Promise, nowMs: number -): Promise { +): Promise<{ resetAtIso: string | null; windowStartIso: string | null }> { const allowedConnections = Array.isArray(metadata.allowedConnections) ? metadata.allowedConnections.filter((id) => typeof id === "string" && id.trim()) : []; - const resetCandidates: string[] = []; + const resetCandidates: WeeklyResetCandidate[] = []; if (allowedConnections.length > 0) { for (const connectionId of allowedConnections) { const connection = connectionFromValue(await deps.getProviderConnectionById(connectionId)); @@ -183,7 +297,13 @@ async function getProviderWeeklyResetAt( deps.getProviderLimitsCache(connection.id)?.quotas, nowMs ); - if (resetAt) resetCandidates.push(resetAt); + if (resetAt) { + resetCandidates.push({ + connectionId: connection.id, + resetAtIso: resetAt, + observedWindowStartIso: getObservedWeeklyWindowStartIso(connection.id, resetAt, nowMs), + }); + } } } else { const caches = deps.getAllProviderLimitsCache(); @@ -192,11 +312,24 @@ async function getProviderWeeklyResetAt( const connection = connectionFromValue(rawConnection); if (!connection || connection.provider.toLowerCase() !== "claude") continue; const resetAt = findWeeklyQuotaResetAt(caches[connection.id]?.quotas, nowMs); - if (resetAt) resetCandidates.push(resetAt); + if (resetAt) { + resetCandidates.push({ + connectionId: connection.id, + resetAtIso: resetAt, + observedWindowStartIso: getObservedWeeklyWindowStartIso(connection.id, resetAt, nowMs), + }); + } } } - return resetCandidates.sort((left, right) => Date.parse(left) - Date.parse(right)).at(0) ?? null; + const selected = + resetCandidates + .sort((left, right) => Date.parse(left.resetAtIso) - Date.parse(right.resetAtIso)) + .at(0) ?? null; + return { + resetAtIso: selected?.resetAtIso ?? null, + windowStartIso: selected?.observedWindowStartIso ?? null, + }; } async function getApiKeyUsdSpendSince(apiKeyId: string, sinceIso: string): Promise { @@ -257,10 +390,14 @@ export async function getApiKeyUsageLimitStatus( 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 dailyResetAtIso = getFortalezaDayResetIso(now); + const weeklyWindow = await getProviderWeeklyWindow(metadata, resolvedDeps, now); + const weeklyResetAtIso = weeklyWindow.resetAtIso; + const weeklyWindowStartIso = weeklyWindow.windowStartIso + ? weeklyWindow.windowStartIso + : 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; @@ -277,6 +414,7 @@ export async function getApiKeyUsageLimitStatus( dailySpentUsd, weeklySpentUsd, dailyWindowStartIso, + dailyResetAtIso, weeklyWindowStartIso, weeklyResetAtIso, dailyExceeded: enabled && dailyLimitUsd !== null && dailySpentUsd >= dailyLimitUsd, @@ -284,26 +422,39 @@ export async function getApiKeyUsageLimitStatus( }; } -export function buildApiKeyUsageLimitText(status: ApiKeyUsageLimitStatus): string { +export function buildApiKeyUsageLimitText( + status: ApiKeyUsageLimitStatus, + now = Date.now() +): string { return [ "Cota diaria", formatUsd(status.dailyLimitUsd), "Gasto diario", formatUsd(status.dailySpentUsd), + "Uso diario", + formatUsagePercent(getUsagePercent(status.dailySpentUsd, status.dailyLimitUsd)), + `Resets in ${formatResetIn(status.dailyResetAtIso, now)}`, "", "Cota semanal", formatUsd(status.weeklyLimitUsd), "Gasto semanal", formatUsd(status.weeklySpentUsd), + "Uso semanal", + formatUsagePercent(getUsagePercent(status.weeklySpentUsd, status.weeklyLimitUsd)), + `Resets in ${formatResetIn(status.weeklyResetAtIso, now)}`, ].join("\n"); } -function buildUsageLimitExceededMessage(status: ApiKeyUsageLimitStatus): string { +function buildUsageLimitExceededMessage(status: ApiKeyUsageLimitStatus, now = Date.now()): 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.`; + const percent = formatUsagePercent(getUsagePercent(status.dailySpentUsd, status.dailyLimitUsd)); + return `This API key reached its daily USD usage quota (${formatUsd(status.dailySpentUsd)} of ${formatUsd(status.dailyLimitUsd)}, ${percent}). Resets in ${formatResetIn(status.dailyResetAtIso, now)}. Choose another allowed model after 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.`; + const percent = formatUsagePercent( + getUsagePercent(status.weeklySpentUsd, status.weeklyLimitUsd) + ); + return `This API key reached its weekly USD usage quota (${formatUsd(status.weeklySpentUsd)} of ${formatUsd(status.weeklyLimitUsd)}, ${percent}). Resets in ${formatResetIn(status.weeklyResetAtIso, now)}. Choose another allowed model after reset.`; } return "This API key reached its USD usage quota. Choose another allowed model or wait for quota reset."; } @@ -319,9 +470,10 @@ function isAnthropicMessagesRequest(request: Request): boolean { export function buildApiKeyUsageLimitRejection( request: Request, - status: ApiKeyUsageLimitStatus + status: ApiKeyUsageLimitStatus, + now = Date.now() ): Response { - const message = sanitizeErrorMessage(buildUsageLimitExceededMessage(status)); + const message = sanitizeErrorMessage(buildUsageLimitExceededMessage(status, now)); if (isAnthropicMessagesRequest(request)) { return new Response( JSON.stringify({ diff --git a/src/lib/usage/internalUsageCommand.ts b/src/lib/usage/internalUsageCommand.ts index a76b307b49..1f1925898a 100644 --- a/src/lib/usage/internalUsageCommand.ts +++ b/src/lib/usage/internalUsageCommand.ts @@ -376,7 +376,8 @@ export async function buildUsageCommandText( const resolvedDeps = await normalizeDeps(deps); if (metadata.usageLimitEnabled === true) { return buildApiKeyUsageLimitText( - await resolvedDeps.getApiKeyUsageLimitStatus(metadata, { now: resolvedDeps.now }) + await resolvedDeps.getApiKeyUsageLimitStatus(metadata, { now: resolvedDeps.now }), + resolvedDeps.now() ); } diff --git a/tests/unit/api-key-usage-limits.test.ts b/tests/unit/api-key-usage-limits.test.ts index a928179c0a..b078f1ff5b 100644 --- a/tests/unit/api-key-usage-limits.test.ts +++ b/tests/unit/api-key-usage-limits.test.ts @@ -138,26 +138,152 @@ test("getApiKeyUsageLimitStatus aligns weekly USD spend with provider resetAt wh assert.equal(status.weeklySpentUsd, 5); assert.equal(status.dailyLimitUsd, 10); assert.equal(status.weeklyLimitUsd, 20); + assert.equal(status.dailyResetAtIso, "2026-06-20T03:00:00.000Z"); 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, +test("getApiKeyUsageLimitStatus cuts weekly USD spend at observed provider quota reset", 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("Reset Cut Key", "machine-limit-reset"); + 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: "Reset Cut Key", + tokens: { input: 7_000_000, output: 0 }, + success: true, + timestamp: "2026-06-19T23:30:00.000Z", + }); + await usageHistory.saveRequestUsage({ + provider: "claude", + model: "claude-opus-4-8", + apiKeyId: created.id, + apiKeyName: "Reset Cut Key", + tokens: { input: 2_000_000, output: 0 }, + success: true, + timestamp: "2026-06-20T02:00:00.000Z", + }); + + const db = core.getDbInstance(); + const insertSnapshot = db.prepare(` + INSERT INTO quota_snapshots ( + provider, + connection_id, + window_key, + remaining_percentage, + is_exhausted, + next_reset_at, + window_duration_ms, + raw_data, + created_at + ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?) + `); + insertSnapshot.run( + "claude", + "conn-claude", + "weekly (7d)", + 72, + 0, + "2026-06-25T23:00:00.000Z", + null, + null, + "2026-06-19T23:55:00.000Z" + ); + insertSnapshot.run( + "claude", + "conn-claude", + "weekly (7d)", + 100, + 0, + "2026-06-25T23:00:00.000Z", + null, + null, + "2026-06-20T01:42:52.590Z" + ); + insertSnapshot.run( + "claude", + "conn-claude", + "weekly (7d)", + 99, + 0, + "2026-06-25T23:00:00.000Z", + null, + null, + "2026-06-20T02:10:00.000Z" + ); + + const metadata = await apiKeysDb.getApiKeyMetadata(created.key); + assert.ok(metadata); + + const weeklyResetAt = "2026-06-25T23:00:00.000Z"; + const status = await usageLimits.getApiKeyUsageLimitStatus( + { ...metadata, allowedConnections: ["conn-claude"] }, + { + now: () => Date.parse("2026-06-20T14:30:00.000Z"), + getProviderConnectionById: async () => ({ + id: "conn-claude", + provider: "claude", + isActive: true, + }), + getProviderConnections: async () => [], + getProviderLimitsCache: () => ({ + plan: "Claude Max", + quotas: { + "weekly (7d)": { + used: 5, + total: 100, + resetAt: weeklyResetAt, + }, + }, + message: null, + fetchedAt: "2026-06-20T14:30:00.000Z", + }), + getAllProviderLimitsCache: () => ({}), + } + ); + + assert.equal(status.weeklyWindowStartIso, "2026-06-20T01:42:52.590Z"); + assert.equal(status.weeklySpentUsd, 2); +}); + +test("buildApiKeyUsageLimitText returns API-key quota spend percentage and reset lines", async () => { + const text = usageLimits.buildApiKeyUsageLimitText( + { + enabled: true, + dailyLimitUsd: 10, + weeklyLimitUsd: 50, + dailySpentUsd: 2, + weeklySpentUsd: 5.25, + dailyWindowStartIso: "2026-06-19T03:00:00.000Z", + dailyResetAtIso: "2026-06-20T03:00:00.000Z", + weeklyWindowStartIso: "2026-06-12T20:00:00.000Z", + weeklyResetAtIso: "2026-06-25T20:00:00.000Z", + dailyExceeded: false, + weeklyExceeded: false, + }, + Date.parse("2026-06-19T20:00:00.000Z") + ); + assert.equal( text, [ @@ -165,15 +291,50 @@ test("buildApiKeyUsageLimitText returns only the quota and spent USD lines", asy "$10.00", "Gasto diario", "$2.00", + "Uso diario", + "20%", + "Resets in 7h", "", "Cota semanal", "$50.00", "Gasto semanal", "$5.25", + "Uso semanal", + "11%", + "Resets in 6d", ].join("\n") ); }); +test("buildApiKeyUsageLimitRejection includes over-quota percentage and reset hint", async () => { + const response = usageLimits.buildApiKeyUsageLimitRejection( + new Request("http://localhost/v1/messages", { + headers: { "anthropic-version": "2023-06-01" }, + }), + { + enabled: true, + dailyLimitUsd: 10, + weeklyLimitUsd: 1, + dailySpentUsd: 0.25, + weeklySpentUsd: 1.09, + dailyWindowStartIso: "2026-06-19T03:00:00.000Z", + dailyResetAtIso: "2026-06-20T03:00:00.000Z", + weeklyWindowStartIso: "2026-06-12T20:00:00.000Z", + weeklyResetAtIso: "2026-06-25T20:00:00.000Z", + dailyExceeded: false, + weeklyExceeded: true, + }, + Date.parse("2026-06-19T20:00:00.000Z") + ); + + assert.equal(response.status, 400); + const body = (await response.json()) as { error: { message: string } }; + assert.equal( + body.error.message, + "This API key reached its weekly USD usage quota ($1.09 of $1.00, 109%). Resets in 6d. Choose another allowed model after reset." + ); +}); + test("buildApiKeyUsageLimitRejection uses 400 so Claude Code does not trigger login", () => { const response = usageLimits.buildApiKeyUsageLimitRejection( new Request("http://localhost/v1/messages", { @@ -186,6 +347,7 @@ test("buildApiKeyUsageLimitRejection uses 400 so Claude Code does not trigger lo dailySpentUsd: 12, weeklySpentUsd: 20, dailyWindowStartIso: "2026-06-19T03:00:00.000Z", + dailyResetAtIso: "2026-06-20T03:00:00.000Z", weeklyWindowStartIso: "2026-06-12T20:00:00.000Z", weeklyResetAtIso: "2026-06-19T20:00:00.000Z", dailyExceeded: true, diff --git a/tests/unit/internal-usage-command.test.ts b/tests/unit/internal-usage-command.test.ts index 0188f638b8..0960336f7f 100644 --- a/tests/unit/internal-usage-command.test.ts +++ b/tests/unit/internal-usage-command.test.ts @@ -132,8 +132,9 @@ test("buildUsageCommandText formats API key USD limits when fair usage is enable dailySpentUsd: 2, weeklySpentUsd: 5.25, dailyWindowStartIso: "2026-06-16T03:00:00.000Z", + dailyResetAtIso: "2026-06-17T03:00:00.000Z", weeklyWindowStartIso: "2026-06-09T12:00:00.000Z", - weeklyResetAtIso: "2026-06-16T12:00:00.000Z", + weeklyResetAtIso: "2026-06-23T12:00:00.000Z", dailyExceeded: false, weeklyExceeded: false, }), @@ -159,11 +160,17 @@ test("buildUsageCommandText formats API key USD limits when fair usage is enable "$10.00", "Gasto diario", "$2.00", + "Uso diario", + "20%", + "Resets in 15h", "", "Cota semanal", "$50.00", "Gasto semanal", "$5.25", + "Uso semanal", + "11%", + "Resets in 7d", ].join("\n") ); });