From 1d38be54aa45984718077752cc10a39a9015f5a7 Mon Sep 17 00:00:00 2001 From: Wital Date: Thu, 25 Jun 2026 07:57:45 -0300 Subject: [PATCH 01/12] feat: persist provider quota reset windows Add provider_quota_reset_events to record observed weekly quota window transitions from quota refreshes. API-key USD weekly limits now prefer the observed provider window start before falling back to snapshot inference or resetAt minus seven days.\n\nValidation:\n- DISABLE_SQLITE_AUTO_BACKUP=true node --import tsx --import ./open-sse/utils/setupPolyfill.ts --import ./tests/_setup/isolateDataDir.ts --test tests/unit/lib/apiKeyUsageLimits.test.ts\n- npm run check:migration-numbering\n- npm run typecheck:core\n- npx eslint src/lib/db/quotaResetEvents.ts src/domain/quotaCache.ts src/lib/usage/apiKeyUsageLimits.ts tests/unit/lib/apiKeyUsageLimits.test.ts\n- npm run build --- src/domain/quotaCache.ts | 14 ++ src/lib/db/core.ts | 21 ++ .../104_provider_quota_reset_events.sql | 26 +++ src/lib/db/quotaResetEvents.ts | 206 +++++++++++++++++ src/lib/usage/apiKeyUsageLimits.ts | 16 +- tests/unit/lib/apiKeyUsageLimits.test.ts | 211 ++++++++++++++++++ 6 files changed, 492 insertions(+), 2 deletions(-) create mode 100644 src/lib/db/migrations/104_provider_quota_reset_events.sql create mode 100644 src/lib/db/quotaResetEvents.ts create mode 100644 tests/unit/lib/apiKeyUsageLimits.test.ts diff --git a/src/domain/quotaCache.ts b/src/domain/quotaCache.ts index 4a2a5cc4c774..a7d22c1054ed 100644 --- a/src/domain/quotaCache.ts +++ b/src/domain/quotaCache.ts @@ -18,6 +18,7 @@ import { getProviderConnectionById, resolveProxyForConnection } from "@/lib/loca import { runWithProxyContext } from "@omniroute/open-sse/utils/proxyFetch.ts"; import { safePercentage } from "@/shared/utils/formatting"; import { saveQuotaSnapshot, cleanupOldSnapshots } from "@/lib/db/quotaSnapshots"; +import { recordProviderQuotaResetEventIfChanged } from "@/lib/db/quotaResetEvents"; // ─── Types ────────────────────────────────────────────────────────────────── @@ -232,6 +233,19 @@ export function setQuotaCache( (quotaInfo.total > 0 ? Math.round(((quotaInfo.total - (quotaInfo.used || 0)) / quotaInfo.total) * 100) : 0); + recordProviderQuotaResetEventIfChanged({ + provider, + connectionId, + windowKey, + currentResetAt: quotaInfo.resetAt ?? null, + currentRemainingPercentage: remainingPercentage, + previousObservation: prior?.quotas?.[windowKey] + ? { + resetAt: prior.quotas[windowKey].resetAt, + remainingPercentage: prior.quotas[windowKey].remainingPercentage, + } + : null, + }); // #4438 — only persist on the first observation or a real change. if (!quotaSnapshotChanged(prior, windowKey, remainingPercentage, entry.exhausted)) continue; try { diff --git a/src/lib/db/core.ts b/src/lib/db/core.ts index 5ef681615765..6adf83a3a257 100644 --- a/src/lib/db/core.ts +++ b/src/lib/db/core.ts @@ -432,6 +432,27 @@ const SCHEMA_SQL = ` CREATE INDEX IF NOT EXISTS idx_quota_snapshots_provider_time ON quota_snapshots(provider, created_at); CREATE INDEX IF NOT EXISTS idx_quota_snapshots_connection_time ON quota_snapshots(connection_id, created_at); CREATE INDEX IF NOT EXISTS idx_quota_snapshots_created_at ON quota_snapshots(created_at); + + CREATE TABLE IF NOT EXISTS provider_quota_reset_events ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + provider TEXT NOT NULL, + connection_id TEXT NOT NULL, + window_key TEXT NOT NULL, + window_started_at TEXT NOT NULL, + window_resets_at TEXT NOT NULL, + observed_at TEXT NOT NULL, + previous_remaining_percentage REAL, + new_remaining_percentage REAL, + previous_used_percentage REAL, + new_used_percentage REAL, + raw_data TEXT, + created_at TEXT NOT NULL DEFAULT (datetime('now')), + UNIQUE(connection_id, window_key, window_started_at, window_resets_at) + ); + CREATE INDEX IF NOT EXISTS idx_provider_quota_reset_events_connection_window + ON provider_quota_reset_events(connection_id, window_key, window_resets_at); + CREATE INDEX IF NOT EXISTS idx_provider_quota_reset_events_provider_observed + ON provider_quota_reset_events(provider, observed_at); `; // ──────────────── Column Mapping ──────────────── diff --git a/src/lib/db/migrations/104_provider_quota_reset_events.sql b/src/lib/db/migrations/104_provider_quota_reset_events.sql new file mode 100644 index 000000000000..2565cf074a35 --- /dev/null +++ b/src/lib/db/migrations/104_provider_quota_reset_events.sql @@ -0,0 +1,26 @@ +-- Migration: observed provider quota reset windows +-- Records real upstream quota window transitions so API-key USD weekly caps can +-- align with the provider-observed reset instead of assuming resetAt - 7 days. + +CREATE TABLE IF NOT EXISTS provider_quota_reset_events ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + provider TEXT NOT NULL, + connection_id TEXT NOT NULL, + window_key TEXT NOT NULL, + window_started_at TEXT NOT NULL, + window_resets_at TEXT NOT NULL, + observed_at TEXT NOT NULL, + previous_remaining_percentage REAL, + new_remaining_percentage REAL, + previous_used_percentage REAL, + new_used_percentage REAL, + raw_data TEXT, + created_at TEXT NOT NULL DEFAULT (datetime('now')), + UNIQUE(connection_id, window_key, window_started_at, window_resets_at) +); + +CREATE INDEX IF NOT EXISTS idx_provider_quota_reset_events_connection_window + ON provider_quota_reset_events(connection_id, window_key, window_resets_at); + +CREATE INDEX IF NOT EXISTS idx_provider_quota_reset_events_provider_observed + ON provider_quota_reset_events(provider, observed_at); diff --git a/src/lib/db/quotaResetEvents.ts b/src/lib/db/quotaResetEvents.ts new file mode 100644 index 000000000000..86701e65c891 --- /dev/null +++ b/src/lib/db/quotaResetEvents.ts @@ -0,0 +1,206 @@ +import { getDbInstance } from "./core"; + +interface StatementLike { + all: (...params: unknown[]) => TRow[]; + get: (...params: unknown[]) => TRow | undefined; + run: (...params: unknown[]) => { changes?: number }; +} + +interface DbLike { + prepare: (sql: string) => StatementLike; +} + +interface QuotaObservation { + resetAt: string | null; + remainingPercentage: number | null; +} + +interface ResetEventInput { + provider: string; + connectionId: string; + windowKey: string; + currentResetAt: string | null; + currentRemainingPercentage: number | null; + previousObservation?: QuotaObservation | null; + observedAt?: string; +} + +interface ResetEventWindowRow { + windowStartedAt: string; + windowResetsAt: string; + observedAt: string; +} + +interface QuotaSnapshotObservationRow { + nextResetAt: string | null; + remainingPercentage: number | null; +} + +function toNumberOrNull(value: unknown): number | null { + if (typeof value === "number" && Number.isFinite(value)) return value; + if (typeof value === "string" && value.trim()) { + const parsed = Number(value); + return Number.isFinite(parsed) ? parsed : null; + } + return null; +} + +function clampPercent(value: number | null): number | null { + if (value === null || !Number.isFinite(value)) return null; + return Math.max(0, Math.min(100, value)); +} + +function usedPercent(remainingPercentage: number | null): number | null { + const remaining = clampPercent(remainingPercentage); + return remaining === null ? null : Math.max(0, Math.min(100, 100 - remaining)); +} + +function parseResetIso(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(); +} + +function resetDay(value: string | null): string | null { + const iso = parseResetIso(value); + return iso ? iso.slice(0, 10) : null; +} + +function normalizeWindowKey(value: string): string { + return value + .toLowerCase() + .replace(/[^a-z0-9]+/g, " ") + .trim(); +} + +function isPrimaryWeeklyWindow(windowKey: string): boolean { + const normalized = normalizeWindowKey(windowKey); + return ( + (normalized.includes("weekly") || normalized.includes("7d")) && !normalized.includes("sonnet") + ); +} + +function getLatestSnapshotObservation( + connectionId: string, + windowKey: string +): QuotaObservation | null { + const db = getDbInstance() as unknown as DbLike; + try { + const row = db + .prepare( + ` + SELECT + next_reset_at as nextResetAt, + remaining_percentage as remainingPercentage + FROM quota_snapshots + WHERE connection_id = ? + AND LOWER(window_key) = LOWER(?) + AND next_reset_at IS NOT NULL + ORDER BY created_at DESC, id DESC + LIMIT 1 + ` + ) + .get(connectionId, windowKey); + if (!row) return null; + return { + resetAt: row.nextResetAt, + remainingPercentage: toNumberOrNull(row.remainingPercentage), + }; + } catch (error: unknown) { + if (error instanceof Error && error.message.includes("no such table")) return null; + throw error; + } +} + +export function recordProviderQuotaResetEventIfChanged(input: ResetEventInput): void { + if (!input.connectionId || !input.windowKey || !isPrimaryWeeklyWindow(input.windowKey)) return; + + const currentResetIso = parseResetIso(input.currentResetAt); + if (!currentResetIso) return; + + const previous = + input.previousObservation ?? getLatestSnapshotObservation(input.connectionId, input.windowKey); + const previousResetIso = parseResetIso(previous?.resetAt ?? null); + if (!previousResetIso) return; + + const previousResetMs = Date.parse(previousResetIso); + const currentResetMs = Date.parse(currentResetIso); + if (!Number.isFinite(previousResetMs) || !Number.isFinite(currentResetMs)) return; + if (currentResetMs <= previousResetMs) return; + if (resetDay(previousResetIso) === resetDay(currentResetIso)) return; + + const previousRemaining = clampPercent(toNumberOrNull(previous?.remainingPercentage)); + const currentRemaining = clampPercent(toNumberOrNull(input.currentRemainingPercentage)); + const observedAt = parseResetIso(input.observedAt ?? null) ?? new Date().toISOString(); + + try { + const db = getDbInstance() as unknown as DbLike; + db.prepare( + ` + INSERT OR IGNORE INTO provider_quota_reset_events + (provider, connection_id, window_key, window_started_at, window_resets_at, + observed_at, previous_remaining_percentage, new_remaining_percentage, + previous_used_percentage, new_used_percentage, raw_data) + VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) + ` + ).run( + input.provider, + input.connectionId, + input.windowKey, + previousResetIso, + currentResetIso, + observedAt, + previousRemaining, + currentRemaining, + usedPercent(previousRemaining), + usedPercent(currentRemaining), + null + ); + } catch (error: unknown) { + if (error instanceof Error && error.message.includes("no such table")) return; + throw error; + } +} + +export function getProviderQuotaWindowStartIso( + connectionId: string, + targetResetAtIso: string, + nowMs = Date.now() +): string | null { + if (!connectionId || !targetResetAtIso) return null; + const targetDay = resetDay(targetResetAtIso); + if (!targetDay) return null; + + const db = getDbInstance() as unknown as DbLike; + const nowIso = new Date(nowMs).toISOString(); + + try { + const rows = db + .prepare( + ` + SELECT + window_started_at as windowStartedAt, + window_resets_at as windowResetsAt, + observed_at as observedAt + FROM provider_quota_reset_events + WHERE connection_id = @connectionId + AND LOWER(window_key) LIKE '%weekly%' + AND LOWER(window_key) NOT LIKE '%sonnet%' + AND observed_at <= @nowIso + ORDER BY observed_at DESC, id DESC + ` + ) + .all({ connectionId, nowIso }); + + for (const row of rows) { + if (resetDay(row.windowResetsAt) === targetDay) { + return parseResetIso(row.windowStartedAt); + } + } + return null; + } catch (error: unknown) { + if (error instanceof Error && error.message.includes("no such table")) return null; + throw error; + } +} diff --git a/src/lib/usage/apiKeyUsageLimits.ts b/src/lib/usage/apiKeyUsageLimits.ts index c9d2165df67b..627590b6dd6a 100644 --- a/src/lib/usage/apiKeyUsageLimits.ts +++ b/src/lib/usage/apiKeyUsageLimits.ts @@ -1,5 +1,6 @@ import { getDbInstance } from "@/lib/db/core"; import type { ProviderLimitsCacheEntry } from "@/lib/db/providerLimits"; +import { getProviderQuotaWindowStartIso } from "@/lib/db/quotaResetEvents"; import { calculateCost } from "./costCalculator"; import { buildErrorBody, sanitizeErrorMessage } from "@omniroute/open-sse/utils/error.ts"; @@ -258,6 +259,17 @@ function getObservedWeeklyWindowStartIso( } } +function getWeeklyWindowStartIso( + connectionId: string, + targetResetAtIso: string, + nowMs: number +): string | null { + return ( + getProviderQuotaWindowStartIso(connectionId, targetResetAtIso, nowMs) ?? + getObservedWeeklyWindowStartIso(connectionId, targetResetAtIso, nowMs) + ); +} + async function resolveDeps(deps: ApiKeyUsageLimitDeps): Promise> { const providers = deps.getProviderConnectionById && deps.getProviderConnections @@ -301,7 +313,7 @@ async function getProviderWeeklyWindow( resetCandidates.push({ connectionId: connection.id, resetAtIso: resetAt, - observedWindowStartIso: getObservedWeeklyWindowStartIso(connection.id, resetAt, nowMs), + observedWindowStartIso: getWeeklyWindowStartIso(connection.id, resetAt, nowMs), }); } } @@ -316,7 +328,7 @@ async function getProviderWeeklyWindow( resetCandidates.push({ connectionId: connection.id, resetAtIso: resetAt, - observedWindowStartIso: getObservedWeeklyWindowStartIso(connection.id, resetAt, nowMs), + observedWindowStartIso: getWeeklyWindowStartIso(connection.id, resetAt, nowMs), }); } } diff --git a/tests/unit/lib/apiKeyUsageLimits.test.ts b/tests/unit/lib/apiKeyUsageLimits.test.ts new file mode 100644 index 000000000000..00b8b8769ae7 --- /dev/null +++ b/tests/unit/lib/apiKeyUsageLimits.test.ts @@ -0,0 +1,211 @@ +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; + +const core = await import("../../../src/lib/db/core.ts"); +const quotaCache = await import("../../../src/domain/quotaCache.ts"); +const quotaResetEvents = await import("../../../src/lib/db/quotaResetEvents.ts"); +const usageLimits = await import("../../../src/lib/usage/apiKeyUsageLimits.ts"); +const costCalculator = await import("../../../src/lib/usage/costCalculator.ts"); + +async function resetStorage() { + core.resetDbInstance(); + 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(); + fs.rmSync(TEST_DATA_DIR, { recursive: true, force: true }); +}); + +function db() { + return core.getDbInstance() as unknown as { + prepare: (sql: string) => { + get: (...params: unknown[]) => Record | undefined; + all: (...params: unknown[]) => Array>; + run: (...params: unknown[]) => unknown; + }; + }; +} + +function insertUsage(entry: { + apiKeyId: string; + timestamp: string; + input: number; + output: number; +}) { + db() + .prepare( + ` + INSERT INTO usage_history + (provider, model, api_key_id, tokens_input, tokens_output, + tokens_cache_read, tokens_cache_creation, tokens_reasoning, + service_tier, success, timestamp) + VALUES (?, ?, ?, ?, ?, 0, 0, 0, 'standard', 1, ?) + ` + ) + .run("claude", "claude-sonnet-4-6", entry.apiKeyId, entry.input, entry.output, entry.timestamp); +} + +test("setQuotaCache records a weekly reset event when Claude advances to a new reset window", () => { + const connectionId = "conn-reset-event"; + + quotaCache.setQuotaCache(connectionId, "claude", { + "Weekly (7 day)": { + remainingPercentage: 0, + resetAt: "2026-06-25T23:00:00.111Z", + }, + }); + quotaCache.setQuotaCache(connectionId, "claude", { + "Weekly (7 day)": { + remainingPercentage: 96, + resetAt: "2026-07-01T23:00:00.222Z", + }, + }); + + const row = db() + .prepare( + ` + SELECT + provider, + connection_id as connectionId, + window_key as windowKey, + window_started_at as windowStartedAt, + window_resets_at as windowResetsAt, + previous_remaining_percentage as previousRemainingPercentage, + new_remaining_percentage as newRemainingPercentage + FROM provider_quota_reset_events + WHERE connection_id = ? + ` + ) + .get(connectionId); + + assert.deepEqual(row, { + provider: "claude", + connectionId, + windowKey: "Weekly (7 day)", + windowStartedAt: "2026-06-25T23:00:00.111Z", + windowResetsAt: "2026-07-01T23:00:00.222Z", + previousRemainingPercentage: 0, + newRemainingPercentage: 96, + }); +}); + +test("setQuotaCache ignores same-day resetAt drift so milliseconds do not create false windows", () => { + const connectionId = "conn-reset-drift"; + + quotaCache.setQuotaCache(connectionId, "claude", { + "Weekly (7 day)": { + remainingPercentage: 30, + resetAt: "2026-06-25T23:00:00.111Z", + }, + }); + quotaCache.setQuotaCache(connectionId, "claude", { + "Weekly (7 day)": { + remainingPercentage: 29, + resetAt: "2026-06-25T23:00:00.999Z", + }, + }); + + const row = db() + .prepare("SELECT COUNT(*) as count FROM provider_quota_reset_events WHERE connection_id = ?") + .get(connectionId); + + assert.equal(row?.count, 0); +}); + +test("API-key weekly USD limits use the observed reset event instead of resetAt minus seven days", async () => { + const apiKeyId = "key-weekly-window"; + const connectionId = "conn-weekly-window"; + const oldReset = "2026-06-25T23:00:00.000Z"; + const newReset = "2026-07-01T23:00:00.000Z"; + const now = Date.parse("2026-06-26T12:00:00.000Z"); + + quotaResetEvents.recordProviderQuotaResetEventIfChanged({ + provider: "claude", + connectionId, + windowKey: "Weekly (7 day)", + currentResetAt: newReset, + currentRemainingPercentage: 97, + previousObservation: { + resetAt: oldReset, + remainingPercentage: 0, + }, + observedAt: "2026-06-25T23:01:00.000Z", + }); + + insertUsage({ + apiKeyId, + timestamp: "2026-06-25T10:00:00.000Z", + input: 1000, + output: 1000, + }); + insertUsage({ + apiKeyId, + timestamp: "2026-06-26T10:00:00.000Z", + input: 1000, + output: 1000, + }); + + const status = await usageLimits.getApiKeyUsageLimitStatus( + { + id: apiKeyId, + allowedConnections: [connectionId], + usageLimitEnabled: true, + dailyUsageLimitUsd: 100, + weeklyUsageLimitUsd: 100, + }, + { + now: () => now, + getProviderConnectionById: async () => ({ + id: connectionId, + provider: "claude", + isActive: true, + }), + getProviderConnections: async () => [], + getProviderLimitsCache: () => ({ + quotas: { + "Weekly (7 day)": { + remainingPercentage: 97, + resetAt: newReset, + }, + }, + plan: null, + message: null, + fetchedAt: new Date(now).toISOString(), + }), + getAllProviderLimitsCache: () => ({}), + } + ); + + const expectedAfterReset = await costCalculator.calculateCost( + "claude", + "claude-sonnet-4-6", + { + input: 1000, + output: 1000, + cacheRead: 0, + cacheCreation: 0, + reasoning: 0, + }, + { + provider: "claude", + model: "claude-sonnet-4-6", + serviceTier: "standard", + } + ); + + assert.equal(status.weeklyWindowStartIso, oldReset); + assert.equal(status.weeklyResetAtIso, newReset); + assert.equal(status.weeklySpentUsd, Math.round(expectedAfterReset * 1_000_000) / 1_000_000); +}); From ed2270b33d807b72640ce811a9a4d2cc39bac922 Mon Sep 17 00:00:00 2001 From: Wital Date: Thu, 25 Jun 2026 08:10:31 -0300 Subject: [PATCH 02/12] feat: enrich codex playground responses Use the Responses API path for the Codex provider playground so results show usage details, cost headers, resolved/requested models, reasoning effort, response id, and expandable raw JSON. Validation: DISABLE_SQLITE_AUTO_BACKUP=true node --import tsx --import ./open-sse/utils/setupPolyfill.ts --test tests/unit/llm-chat-responses-metadata.test.ts; npm run typecheck:core; npx eslint src/app/(dashboard)/dashboard/media-providers/components/LlmChatCard.tsx src/app/(dashboard)/dashboard/media-providers/components/llmChatResponsesMetadata.ts tests/unit/llm-chat-responses-metadata.test.ts; npm run build --- .../components/LlmChatCard.tsx | 161 ++++++++++++++- .../components/llmChatResponsesMetadata.ts | 184 ++++++++++++++++++ .../unit/llm-chat-responses-metadata.test.ts | 89 +++++++++ 3 files changed, 433 insertions(+), 1 deletion(-) create mode 100644 src/app/(dashboard)/dashboard/media-providers/components/llmChatResponsesMetadata.ts create mode 100644 tests/unit/llm-chat-responses-metadata.test.ts diff --git a/src/app/(dashboard)/dashboard/media-providers/components/LlmChatCard.tsx b/src/app/(dashboard)/dashboard/media-providers/components/LlmChatCard.tsx index f41dbd934b63..e3a491f51a2a 100644 --- a/src/app/(dashboard)/dashboard/media-providers/components/LlmChatCard.tsx +++ b/src/app/(dashboard)/dashboard/media-providers/components/LlmChatCard.tsx @@ -12,8 +12,19 @@ import { useTranslations } from "next-intl"; import { cn } from "@/shared/utils/cn"; import { useApiKey } from "../../providers/hooks/useApiKey"; import { useProviderModels } from "../../providers/hooks/useProviderModels"; +import { + CODEX_PLAYGROUND_REASONING_EFFORT, + type CodexResponseMetadata, + buildCodexResponseMetadata, + buildResponsesInputMessages, + extractResponsesOutputText, + formatCodexUsageValue, + isCodexPlaygroundProvider, + normalizeCodexResponsesModel, +} from "./llmChatResponsesMetadata"; const ENDPOINT = "/api/v1/chat/completions"; +const RESPONSES_ENDPOINT = "/api/v1/responses"; /** Header used to test a specific API key's policy from the dashboard playground * without exposing the key secret to the browser — the gateway resolves the key @@ -57,6 +68,7 @@ interface Message { role: "user" | "assistant"; content: string; model?: string; + metadata?: CodexResponseMetadata; } interface Stats { @@ -118,6 +130,59 @@ function extractUsage(line: string): { prompt_tokens?: number; completion_tokens } } +function CodexResponseMetadataPanel({ + metadata, + rawOpen, + onToggleRaw, +}: { + metadata: CodexResponseMetadata; + rawOpen: boolean; + onToggleRaw: () => void; +}) { + const usageEntries = Object.entries(metadata.usage); + const fields = [ + ["requested", metadata.requestedModel], + ["resolved", metadata.resolvedModel], + ["reasoning", metadata.reasoningEffort], + ["response", metadata.responseId], + ].filter(([, value]) => typeof value === "string" && value.trim().length > 0); + + return ( +
+ {usageEntries.length > 0 && ( +
+ {usageEntries.map(([key, value]) => `${key}=${formatCodexUsageValue(value)}`).join(" · ")} +
+ )} + {fields.length > 0 && ( +
+ {fields.map(([label, value]) => ( +
+ {label}= + {value} +
+ ))} +
+ )} + + {rawOpen && ( +
+          {JSON.stringify(metadata.raw, null, 2)}
+        
+ )} +
+ ); +} + export function LlmChatCard({ providerId, initialModel, @@ -157,6 +222,7 @@ export function LlmChatCard({ const [input, setInput] = useState(""); const [streaming, setStreaming] = useState(false); const [stats, setStats] = useState(null); + const [expandedRawMessages, setExpandedRawMessages] = useState>(() => new Set()); const abortRef = useRef(null); const scrollRef = useRef(null); const textareaRef = useRef(null); @@ -168,6 +234,8 @@ export function LlmChatCard({ // also covers vendor-namespaced ids (e.g. `moonshotai/kimi-k2.6`) that already // contain a slash but still need the provider prefix (#3050). const qualifiedModel = qualifyPlaygroundModel(effectiveModel, providerId); + const isCodexPlayground = isCodexPlaygroundProvider(providerId); + const codexResponsesModel = normalizeCodexResponsesModel(effectiveModel || qualifiedModel); // Autofocus textarea in embedded mode useEffect(() => { @@ -216,6 +284,70 @@ export function LlmChatCard({ }; const playgroundKeyId = resolvePlaygroundKeyId(selectedKey, keys); if (playgroundKeyId) headers[PLAYGROUND_KEY_ID_HEADER] = playgroundKeyId; + if (isCodexPlayground) { + const res = await fetch(RESPONSES_ENDPOINT, { + method: "POST", + signal: controller.signal, + credentials: "same-origin", + headers, + body: JSON.stringify({ + model: codexResponsesModel, + input: buildResponsesInputMessages([...messages, userMsg]), + reasoning: { effort: CODEX_PLAYGROUND_REASONING_EFFORT }, + stream: false, + store: false, + }), + }); + + const payload: unknown = await res.json().catch(() => null); + + if (!res.ok) { + const errData = payload && typeof payload === "object" ? payload : null; + const errMsg = + errData && (errData as Record).error + ? String( + (((errData as Record).error as Record) + ?.message as string | undefined) ?? `HTTP ${res.status}` + ) + : `HTTP ${res.status}`; + setMessages((prev) => { + const next = [...prev]; + const last = next[next.length - 1]; + next[next.length - 1] = { ...last, role: "assistant", content: `[Error: ${errMsg}]` }; + return next; + }); + return; + } + + const metadata = buildCodexResponseMetadata({ + payload, + headers: res.headers, + requestedModel: codexResponsesModel, + reasoningEffort: CODEX_PLAYGROUND_REASONING_EFFORT, + fallbackLatencyMs: performance.now() - t0, + }); + const responseText = extractResponsesOutputText(payload) || "[No text output]"; + + setMessages((prev) => { + const next = [...prev]; + const last = next[next.length - 1]; + next[next.length - 1] = { + ...last, + role: "assistant", + content: responseText, + model: metadata.resolvedModel || codexResponsesModel, + metadata, + }; + return next; + }); + setStats({ + latencyMs: metadata.latencyMs, + tokensIn: metadata.tokensIn, + tokensOut: metadata.tokensOut, + }); + return; + } + const res = await fetch(ENDPOINT, { method: "POST", signal: controller.signal, @@ -325,7 +457,17 @@ export function LlmChatCard({ // Refocus textarea so user can keep typing requestAnimationFrame(() => textareaRef.current?.focus()); } - }, [input, streaming, selectedKey, keys, providerId, qualifiedModel, messages]); + }, [ + input, + streaming, + selectedKey, + keys, + providerId, + qualifiedModel, + isCodexPlayground, + codexResponsesModel, + messages, + ]); const handleKeyDown = (e: React.KeyboardEvent) => { if (e.key === "Enter" && !e.shiftKey) { @@ -342,8 +484,18 @@ export function LlmChatCard({ if (streaming) abortRef.current?.abort(); setMessages([]); setStats(null); + setExpandedRawMessages(new Set()); }, [streaming]); + const toggleRawMessage = useCallback((index: number) => { + setExpandedRawMessages((prev) => { + const next = new Set(prev); + if (next.has(index)) next.delete(index); + else next.add(index); + return next; + }); + }, []); + useImperativeHandle( controlsRef, () => ({ @@ -502,6 +654,13 @@ export function LlmChatCard({ )} + {!isUser && !isError && msg.metadata && ( + toggleRawMessage(i)} + /> + )} ); diff --git a/src/app/(dashboard)/dashboard/media-providers/components/llmChatResponsesMetadata.ts b/src/app/(dashboard)/dashboard/media-providers/components/llmChatResponsesMetadata.ts new file mode 100644 index 000000000000..c4e2def91679 --- /dev/null +++ b/src/app/(dashboard)/dashboard/media-providers/components/llmChatResponsesMetadata.ts @@ -0,0 +1,184 @@ +import { OMNIROUTE_RESPONSE_HEADERS } from "@/shared/constants/headers"; + +export type PlaygroundMessageRole = "user" | "assistant"; + +export interface PlaygroundMessageLike { + role: PlaygroundMessageRole; + content: string; +} + +export interface ResponsesInputMessage { + type: "message"; + role: PlaygroundMessageRole; + content: Array<{ + type: "input_text" | "output_text"; + text: string; + }>; +} + +export interface CodexResponseMetadata { + usage: Record; + requestedModel: string; + resolvedModel: string; + reasoningEffort: string | null; + responseId: string | null; + latencyMs: number; + tokensIn: number; + tokensOut: number; + raw: Record; +} + +export const CODEX_PLAYGROUND_REASONING_EFFORT = "high"; + +function toRecord(value: unknown): Record | null { + return value && typeof value === "object" && !Array.isArray(value) + ? (value as Record) + : 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 readHeader(headers: Headers | null | undefined, name: string): string | null { + try { + return headers?.get(name) ?? null; + } catch { + return null; + } +} + +export function isCodexPlaygroundProvider(providerId: string | null | undefined): boolean { + return (providerId ?? "").trim().toLowerCase() === "codex"; +} + +export function normalizeCodexResponsesModel(model: string | null | undefined): string { + return (model ?? "").trim().replace(/^(codex|cx)\//i, ""); +} + +export function buildResponsesInputMessages( + messages: PlaygroundMessageLike[] +): ResponsesInputMessage[] { + return messages + .filter((message) => message.content.trim().length > 0) + .map((message) => ({ + type: "message", + role: message.role, + content: [ + { + type: message.role === "assistant" ? "output_text" : "input_text", + text: message.content, + }, + ], + })); +} + +export function extractResponsesOutputText(payload: unknown): string { + const root = toRecord(payload); + if (!root) return ""; + + if (typeof root.output_text === "string") { + return root.output_text; + } + + const output = Array.isArray(root.output) ? root.output : []; + const chunks: string[] = []; + + for (const item of output) { + const itemRecord = toRecord(item); + if (!itemRecord) continue; + + if (typeof itemRecord.text === "string") { + chunks.push(itemRecord.text); + } + + const content = Array.isArray(itemRecord.content) ? itemRecord.content : []; + for (const part of content) { + const partRecord = toRecord(part); + if (!partRecord) continue; + + const text = partRecord.text; + if ( + typeof text === "string" && + (partRecord.type === "output_text" || partRecord.type === "text" || !partRecord.type) + ) { + chunks.push(text); + } + } + } + + return chunks.join("\n").trim(); +} + +export function formatCodexUsageValue(value: unknown): string { + if (value == null) return "null"; + if (typeof value === "string") return value; + if (typeof value === "number" || typeof value === "boolean") return String(value); + try { + return JSON.stringify(value); + } catch { + return String(value); + } +} + +export function buildCodexResponseMetadata({ + payload, + headers, + requestedModel, + reasoningEffort, + fallbackLatencyMs, +}: { + payload: unknown; + headers?: Headers | null; + requestedModel: string; + reasoningEffort?: string | null; + fallbackLatencyMs: number; +}): CodexResponseMetadata { + const root = toRecord(payload) ?? {}; + const rawUsage = toRecord(root.usage); + const usage: Record = rawUsage ? { ...rawUsage } : {}; + + const responseCost = readHeader(headers, OMNIROUTE_RESPONSE_HEADERS.responseCost); + if (responseCost != null && !("cost_usd" in usage)) { + const numericCost = toNumber(responseCost); + usage.cost_usd = numericCost > 0 ? numericCost : responseCost; + } + + const latencyHeader = readHeader(headers, OMNIROUTE_RESPONSE_HEADERS.latencyMs); + const latencyMs = toNumber(latencyHeader) || fallbackLatencyMs; + + const headerModel = readHeader(headers, OMNIROUTE_RESPONSE_HEADERS.model); + const resolvedModel = normalizeCodexResponsesModel( + typeof root.model === "string" && root.model.trim() ? root.model : headerModel + ); + + const reasoning = toRecord(root.reasoning); + const responseId = typeof root.id === "string" && root.id.trim() ? root.id : null; + const inputTokens = + toNumber(usage.input_tokens) || + toNumber(usage.prompt_tokens) || + toNumber(readHeader(headers, OMNIROUTE_RESPONSE_HEADERS.tokensIn)); + const outputTokens = + toNumber(usage.output_tokens) || + toNumber(usage.completion_tokens) || + toNumber(readHeader(headers, OMNIROUTE_RESPONSE_HEADERS.tokensOut)); + + return { + usage, + requestedModel: normalizeCodexResponsesModel(requestedModel), + resolvedModel, + reasoningEffort: + (typeof reasoning?.effort === "string" && reasoning.effort.trim()) || + (reasoningEffort ? reasoningEffort : null), + responseId, + latencyMs, + tokensIn: Math.max(0, Math.round(inputTokens)), + tokensOut: Math.max(0, Math.round(outputTokens)), + raw: root, + }; +} diff --git a/tests/unit/llm-chat-responses-metadata.test.ts b/tests/unit/llm-chat-responses-metadata.test.ts new file mode 100644 index 000000000000..06fb5a86a26d --- /dev/null +++ b/tests/unit/llm-chat-responses-metadata.test.ts @@ -0,0 +1,89 @@ +import test from "node:test"; +import assert from "node:assert/strict"; + +const { + buildCodexResponseMetadata, + buildResponsesInputMessages, + extractResponsesOutputText, + normalizeCodexResponsesModel, +} = + await import("../../src/app/(dashboard)/dashboard/media-providers/components/llmChatResponsesMetadata.ts"); + +test("buildResponsesInputMessages maps chat history to Responses API message input", () => { + assert.deepEqual( + buildResponsesInputMessages([ + { role: "user", content: "oi" }, + { role: "assistant", content: "olá" }, + { role: "assistant", content: " " }, + ]), + [ + { + type: "message", + role: "user", + content: [{ type: "input_text", text: "oi" }], + }, + { + type: "message", + role: "assistant", + content: [{ type: "output_text", text: "olá" }], + }, + ] + ); +}); + +test("extractResponsesOutputText supports output_text and nested output content", () => { + assert.equal(extractResponsesOutputText({ output_text: "direto" }), "direto"); + assert.equal( + extractResponsesOutputText({ + output: [ + { + type: "message", + content: [ + { type: "output_text", text: "parte 1" }, + { type: "text", text: "parte 2" }, + ], + }, + ], + }), + "parte 1\nparte 2" + ); +}); + +test("buildCodexResponseMetadata merges OmniRoute headers with Responses API payload", () => { + const metadata = buildCodexResponseMetadata({ + requestedModel: "cx/gpt-5.5", + reasoningEffort: "high", + fallbackLatencyMs: 999, + headers: new Headers({ + "X-OmniRoute-Response-Cost": "0.0064950000", + "X-OmniRoute-Latency-Ms": "4790", + "X-OmniRoute-Tokens-In": "45", + "X-OmniRoute-Tokens-Out": "209", + "X-OmniRoute-Model": "codex/gpt-5.5", + }), + payload: { + id: "resp_123", + model: "gpt-5.5", + usage: { + input_tokens: 45, + output_tokens: 209, + output_tokens_details: { reasoning_tokens: 178 }, + }, + }, + }); + + assert.equal(metadata.requestedModel, "gpt-5.5"); + assert.equal(metadata.resolvedModel, "gpt-5.5"); + assert.equal(metadata.reasoningEffort, "high"); + assert.equal(metadata.responseId, "resp_123"); + assert.equal(metadata.latencyMs, 4790); + assert.equal(metadata.tokensIn, 45); + assert.equal(metadata.tokensOut, 209); + assert.equal(metadata.usage.cost_usd, 0.006495); +}); + +test("normalizeCodexResponsesModel removes codex provider aliases only", () => { + assert.equal(normalizeCodexResponsesModel("cx/gpt-5.5-xhigh"), "gpt-5.5-xhigh"); + assert.equal(normalizeCodexResponsesModel("codex/gpt-5.4"), "gpt-5.4"); + assert.equal(normalizeCodexResponsesModel("openai/gpt-4.1"), "openai/gpt-4.1"); +}); From 3ba8fec223efb20247f247166ebeca90f82656d6 Mon Sep 17 00:00:00 2001 From: Wital Date: Thu, 25 Jun 2026 13:21:27 -0300 Subject: [PATCH 03/12] feat: improve usage quota reporting Add a per-key @@om-usage USD display toggle, default personal quota output to percentages, and surface cached Claude weekly-limit reset messages for exhausted Claude Code accounts.\n\nTests: node --import tsx/esm --test tests/unit/internal-usage-command.test.ts\nTests: node --import tsx/esm --test tests/unit/api-key-usage-limits.test.ts tests/unit/apikeys-usage-command.test.ts\nTests: node --import tsx/esm --test tests/unit/chat-helpers.test.ts\nTests: node --import tsx/esm --test tests/unit/api-manager-page-static.test.ts tests/unit/api-manager-usage-command.test.ts tests/unit/t07-no-log-key-config.test.ts\nTests: npm run check:migration-numbering\nTests: npm run typecheck:core\nTests: npx eslint (warnings only: pre-existing API Manager/search and chat helper any warnings)\nTests: npm run build --- open-sse/utils/error.ts | 26 +++-- .../api-manager/ApiManagerPageClient.tsx | 11 ++ .../components/UsageLimitSettings.tsx | 27 +++++ src/app/api/keys/[id]/route.ts | 3 + src/app/api/keys/route.ts | 6 +- src/lib/db/apiKeyColumnFallbacks.ts | 4 + src/lib/db/apiKeyUsageLimitFields.ts | 12 +- src/lib/db/apiKeys.ts | 10 +- .../migrations/105_usage_command_show_usd.sql | 2 + src/lib/usage/apiKeyUsageLimits.ts | 16 +++ src/lib/usage/internalUsageCommand.ts | 13 ++- src/shared/validation/schemas/keys.ts | 5 +- src/sse/handlers/chatHelpers.ts | 105 ++++++++++++++++-- src/sse/services/auth.ts | 5 + tests/unit/api-key-usage-limits.test.ts | 3 + tests/unit/api-manager-page-static.test.ts | 21 +++- tests/unit/api-manager-usage-command.test.ts | 6 + tests/unit/apikeys-usage-command.test.ts | 18 +++ tests/unit/chat-helpers.test.ts | 45 ++++++++ tests/unit/internal-usage-command.test.ts | 51 ++++++++- tests/unit/t07-no-log-key-config.test.ts | 5 + 21 files changed, 368 insertions(+), 26 deletions(-) create mode 100644 src/lib/db/migrations/105_usage_command_show_usd.sql diff --git a/open-sse/utils/error.ts b/open-sse/utils/error.ts index 980fabcd1486..2a335e9dcc65 100644 --- a/open-sse/utils/error.ts +++ b/open-sse/utils/error.ts @@ -362,13 +362,25 @@ export function unavailableResponse( ) { const retryAfterSec = normalizeRetryAfterSeconds(retryAfter); const msg = retryAfterHuman ? `${message} (${retryAfterHuman})` : message; - return new Response(JSON.stringify({ error: { message: msg } }), { - status: statusCode, - headers: { - "Content-Type": "application/json", - "Retry-After": String(retryAfterSec), - }, - }); + const errorType = statusCode === 429 ? "rate_limit_error" : "server_error"; + const errorCode = statusCode === 429 ? "rate_limited" : "unavailable"; + return new Response( + JSON.stringify({ + type: "error", + error: { + message: msg, + type: errorType, + code: errorCode, + }, + }), + { + status: statusCode, + headers: { + "Content-Type": "application/json", + "Retry-After": String(retryAfterSec), + }, + } + ); } export function providerCircuitOpenResponse( diff --git a/src/app/(dashboard)/dashboard/api-manager/ApiManagerPageClient.tsx b/src/app/(dashboard)/dashboard/api-manager/ApiManagerPageClient.tsx index 120ec6cb0dd5..ba6e1ef8a2cf 100644 --- a/src/app/(dashboard)/dashboard/api-manager/ApiManagerPageClient.tsx +++ b/src/app/(dashboard)/dashboard/api-manager/ApiManagerPageClient.tsx @@ -127,6 +127,7 @@ interface ApiKey { usageLimitEnabled?: boolean; dailyUsageLimitUsd?: number | null; weeklyUsageLimitUsd?: number | null; + usageCommandShowUsd?: boolean; allowedQuotas?: string[] | null; createdAt: string; } @@ -706,6 +707,7 @@ export default function ApiManagerPageClient() { usageLimitEnabled: boolean, dailyUsageLimitUsd: number | null, weeklyUsageLimitUsd: number | null, + usageCommandShowUsd: boolean, blockedModels: string[] ) => { if (!editingKey || !editingKey.id) return; @@ -777,6 +779,7 @@ export default function ApiManagerPageClient() { usageLimitEnabled, dailyUsageLimitUsd, weeklyUsageLimitUsd, + usageCommandShowUsd, }), }); @@ -1544,6 +1547,7 @@ const PermissionsModal = memo(function PermissionsModal({ usageLimitEnabled: boolean, dailyUsageLimitUsd: number | null, weeklyUsageLimitUsd: number | null, + usageCommandShowUsd: boolean, blockedModels: string[] ) => void; }) { @@ -1628,6 +1632,9 @@ const PermissionsModal = memo(function PermissionsModal({ apiKey?.allowUsageCommand === true ); const [usageLimitEnabled, setUsageLimitEnabled] = useState(apiKey?.usageLimitEnabled === true); + const [usageCommandShowUsd, setUsageCommandShowUsd] = useState( + apiKey?.usageCommandShowUsd === true + ); const [dailyUsageLimitUsd, setDailyUsageLimitUsd] = useState( typeof apiKey?.dailyUsageLimitUsd === "number" && apiKey.dailyUsageLimitUsd > 0 ? String(apiKey.dailyUsageLimitUsd) @@ -1830,6 +1837,7 @@ const PermissionsModal = memo(function PermissionsModal({ usageLimitEnabled, parseUsdLimitInput(dailyUsageLimitUsd), parseUsdLimitInput(weeklyUsageLimitUsd), + usageCommandShowUsd, blockedModels ); }, [ @@ -1863,6 +1871,7 @@ const PermissionsModal = memo(function PermissionsModal({ disableNonPublicModels, usageCommandEnabled, usageLimitEnabled, + usageCommandShowUsd, dailyUsageLimitUsd, weeklyUsageLimitUsd, parseUsdLimitInput, @@ -2441,11 +2450,13 @@ const PermissionsModal = memo(function PermissionsModal({

{t("localUsageCommandDesc")}

diff --git a/src/app/(dashboard)/dashboard/api-manager/components/UsageLimitSettings.tsx b/src/app/(dashboard)/dashboard/api-manager/components/UsageLimitSettings.tsx index 1449d7192c13..cb134c62a064 100644 --- a/src/app/(dashboard)/dashboard/api-manager/components/UsageLimitSettings.tsx +++ b/src/app/(dashboard)/dashboard/api-manager/components/UsageLimitSettings.tsx @@ -4,20 +4,24 @@ import { Input } from "@/shared/components"; export function UsageLimitSettings({ enabled, + showUsdInUsageCommand, dailyLimitUsd, weeklyLimitUsd, enabledLabel, disabledLabel, onEnabledChange, + onShowUsdInUsageCommandChange, onDailyLimitUsdChange, onWeeklyLimitUsdChange, }: { enabled: boolean; + showUsdInUsageCommand: boolean; dailyLimitUsd: string; weeklyLimitUsd: string; enabledLabel: string; disabledLabel: string; onEnabledChange: (enabled: boolean) => void; + onShowUsdInUsageCommandChange: (enabled: boolean) => void; onDailyLimitUsdChange: (value: string) => void; onWeeklyLimitUsdChange: (value: string) => void; }) { @@ -70,6 +74,29 @@ export function UsageLimitSettings({ /> +
+
+

@@om-usage USD display

+

+ When disabled, @@om-usage reports this key quota as percentages. +

+
+ +

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. diff --git a/src/app/api/keys/[id]/route.ts b/src/app/api/keys/[id]/route.ts index 8fd10cc3383d..0cdf832c081a 100644 --- a/src/app/api/keys/[id]/route.ts +++ b/src/app/api/keys/[id]/route.ts @@ -86,6 +86,7 @@ export async function PATCH(request, { params }) { usageLimitEnabled, dailyUsageLimitUsd, weeklyUsageLimitUsd, + usageCommandShowUsd, } = validation.data; const payload: Parameters[1] = {}; @@ -112,6 +113,7 @@ export async function PATCH(request, { params }) { if (usageLimitEnabled !== undefined) payload.usageLimitEnabled = usageLimitEnabled; if (dailyUsageLimitUsd !== undefined) payload.dailyUsageLimitUsd = dailyUsageLimitUsd; if (weeklyUsageLimitUsd !== undefined) payload.weeklyUsageLimitUsd = weeklyUsageLimitUsd; + if (usageCommandShowUsd !== undefined) payload.usageCommandShowUsd = usageCommandShowUsd; const updated = await updateApiKeyPermissions(id, payload); if (!updated) { @@ -145,6 +147,7 @@ export async function PATCH(request, { params }) { ...(usageLimitEnabled !== undefined && { usageLimitEnabled }), ...(dailyUsageLimitUsd !== undefined && { dailyUsageLimitUsd }), ...(weeklyUsageLimitUsd !== undefined && { weeklyUsageLimitUsd }), + ...(usageCommandShowUsd !== undefined && { usageCommandShowUsd }), }); } catch (error) { log.error("keys", "Error updating key permissions", error); diff --git a/src/app/api/keys/route.ts b/src/app/api/keys/route.ts index b4611943f6db..00a94e7e92c0 100644 --- a/src/app/api/keys/route.ts +++ b/src/app/api/keys/route.ts @@ -71,6 +71,7 @@ export async function POST(request) { usageLimitEnabled, dailyUsageLimitUsd, weeklyUsageLimitUsd, + usageCommandShowUsd, } = validation.data; // Always get machineId from server @@ -82,7 +83,8 @@ export async function POST(request) { allowUsageCommand === true || usageLimitEnabled === true || dailyUsageLimitUsd !== undefined || - weeklyUsageLimitUsd !== undefined + weeklyUsageLimitUsd !== undefined || + usageCommandShowUsd !== undefined ) { await updateApiKeyPermissions(apiKey.id, { ...(noLog === true && { noLog: true }), @@ -90,6 +92,7 @@ export async function POST(request) { ...(usageLimitEnabled === true && { usageLimitEnabled: true }), ...(dailyUsageLimitUsd !== undefined && { dailyUsageLimitUsd }), ...(weeklyUsageLimitUsd !== undefined && { weeklyUsageLimitUsd }), + ...(usageCommandShowUsd !== undefined && { usageCommandShowUsd }), }); } @@ -107,6 +110,7 @@ export async function POST(request) { usageLimitEnabled: usageLimitEnabled === true, dailyUsageLimitUsd: dailyUsageLimitUsd ?? null, weeklyUsageLimitUsd: weeklyUsageLimitUsd ?? null, + usageCommandShowUsd: usageCommandShowUsd === true, streamDefaultMode: "legacy", }, { status: 201 } diff --git a/src/lib/db/apiKeyColumnFallbacks.ts b/src/lib/db/apiKeyColumnFallbacks.ts index 398672c5fc8b..7984e045e4af 100644 --- a/src/lib/db/apiKeyColumnFallbacks.ts +++ b/src/lib/db/apiKeyColumnFallbacks.ts @@ -44,4 +44,8 @@ export const API_KEY_COLUMN_FALLBACKS = [ name: "weekly_usage_limit_usd", definition: "weekly_usage_limit_usd REAL", }, + { + name: "usage_command_show_usd", + definition: "usage_command_show_usd INTEGER NOT NULL DEFAULT 0", + }, ] as const; diff --git a/src/lib/db/apiKeyUsageLimitFields.ts b/src/lib/db/apiKeyUsageLimitFields.ts index 57f0c60b37ec..4136bb89bc04 100644 --- a/src/lib/db/apiKeyUsageLimitFields.ts +++ b/src/lib/db/apiKeyUsageLimitFields.ts @@ -4,6 +4,7 @@ export interface ApiKeyUsageLimitFields { usageLimitEnabled: boolean; dailyUsageLimitUsd: number | null; weeklyUsageLimitUsd: number | null; + usageCommandShowUsd: boolean; } export function parseUsageLimitEnabled(value: unknown): boolean { @@ -30,6 +31,9 @@ export function parseApiKeyUsageLimitFields(record: UsageLimitRecord): ApiKeyUsa weeklyUsageLimitUsd: parseNullablePositiveNumber( record.weekly_usage_limit_usd ?? record.weeklyUsageLimitUsd ), + usageCommandShowUsd: parseUsageLimitEnabled( + record.usage_command_show_usd ?? record.usageCommandShowUsd + ), }; } @@ -37,7 +41,8 @@ export function hasUsageLimitUpdate(update: UsageLimitRecord): boolean { return ( update.usageLimitEnabled !== undefined || update.dailyUsageLimitUsd !== undefined || - update.weeklyUsageLimitUsd !== undefined + update.weeklyUsageLimitUsd !== undefined || + update.usageCommandShowUsd !== undefined ); } @@ -48,6 +53,7 @@ export function appendUsageLimitUpdates( usageLimitEnabled?: number; dailyUsageLimitUsd?: number | null; weeklyUsageLimitUsd?: number | null; + usageCommandShowUsd?: number; } ) { if (update.usageLimitEnabled !== undefined) { @@ -62,4 +68,8 @@ export function appendUsageLimitUpdates( updates.push("weekly_usage_limit_usd = @weeklyUsageLimitUsd"); params.weeklyUsageLimitUsd = parseNullablePositiveNumber(update.weeklyUsageLimitUsd); } + if (update.usageCommandShowUsd !== undefined) { + updates.push("usage_command_show_usd = @usageCommandShowUsd"); + params.usageCommandShowUsd = update.usageCommandShowUsd === true ? 1 : 0; + } } diff --git a/src/lib/db/apiKeys.ts b/src/lib/db/apiKeys.ts index 810ca53300c8..eac2c250f869 100644 --- a/src/lib/db/apiKeys.ts +++ b/src/lib/db/apiKeys.ts @@ -77,6 +77,7 @@ interface ApiKeyMetadata { usageLimitEnabled: boolean; dailyUsageLimitUsd: number | null; weeklyUsageLimitUsd: number | null; + usageCommandShowUsd: boolean; } interface ApiKeyRow extends JsonRecord { @@ -116,6 +117,8 @@ interface ApiKeyRow extends JsonRecord { dailyUsageLimitUsd?: unknown; weekly_usage_limit_usd?: unknown; weeklyUsageLimitUsd?: unknown; + usage_command_show_usd?: unknown; + usageCommandShowUsd?: unknown; } interface StatementLike { @@ -162,6 +165,7 @@ interface ApiKeyView extends JsonRecord { usageLimitEnabled?: boolean; dailyUsageLimitUsd?: number | null; weeklyUsageLimitUsd?: number | null; + usageCommandShowUsd?: boolean; } // LRU cache for API key validation (valid keys only) @@ -492,7 +496,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, 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 = ?" + "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, usage_command_show_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 (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)" @@ -853,6 +857,7 @@ export async function updateApiKeyPermissions( usageLimitEnabled?: boolean; dailyUsageLimitUsd?: number | null; weeklyUsageLimitUsd?: number | null; + usageCommandShowUsd?: boolean; } ) { const db = getDbInstance() as ApiKeysDbLike; @@ -891,6 +896,7 @@ export async function updateApiKeyPermissions( dailyUsageLimitUsd: (update as { dailyUsageLimitUsd?: number | null }).dailyUsageLimitUsd, weeklyUsageLimitUsd: (update as { weeklyUsageLimitUsd?: number | null }) .weeklyUsageLimitUsd, + usageCommandShowUsd: (update as { usageCommandShowUsd?: boolean }).usageCommandShowUsd, }; if ( @@ -950,6 +956,7 @@ export async function updateApiKeyPermissions( usageLimitEnabled?: number; dailyUsageLimitUsd?: number | null; weeklyUsageLimitUsd?: number | null; + usageCommandShowUsd?: number; } = { id }; if (normalized.name !== undefined) { @@ -1439,6 +1446,7 @@ export async function getApiKeyMetadata( usageLimitEnabled: false, dailyUsageLimitUsd: null, weeklyUsageLimitUsd: null, + usageCommandShowUsd: false, }; } diff --git a/src/lib/db/migrations/105_usage_command_show_usd.sql b/src/lib/db/migrations/105_usage_command_show_usd.sql new file mode 100644 index 000000000000..925daabc7f7b --- /dev/null +++ b/src/lib/db/migrations/105_usage_command_show_usd.sql @@ -0,0 +1,2 @@ +-- Migration: per-key @@om-usage USD display mode +ALTER TABLE api_keys ADD COLUMN usage_command_show_usd INTEGER NOT NULL DEFAULT 0; diff --git a/src/lib/usage/apiKeyUsageLimits.ts b/src/lib/usage/apiKeyUsageLimits.ts index 627590b6dd6a..fc594037559f 100644 --- a/src/lib/usage/apiKeyUsageLimits.ts +++ b/src/lib/usage/apiKeyUsageLimits.ts @@ -457,6 +457,22 @@ export function buildApiKeyUsageLimitText( ].join("\n"); } +export function buildApiKeyUsageLimitPercentText( + status: ApiKeyUsageLimitStatus, + now = Date.now() +): string { + return [ + "Cota pessoal", + "Diaria", + formatUsagePercent(getUsagePercent(status.dailySpentUsd, status.dailyLimitUsd)), + `Resets in ${formatResetIn(status.dailyResetAtIso, now)}`, + "", + "Semanal", + formatUsagePercent(getUsagePercent(status.weeklySpentUsd, status.weeklyLimitUsd)), + `Resets in ${formatResetIn(status.weeklyResetAtIso, now)}`, + ].join("\n"); +} + function buildUsageLimitExceededMessage(status: ApiKeyUsageLimitStatus, now = Date.now()): string { if (status.dailyExceeded && status.dailyLimitUsd !== null) { const percent = formatUsagePercent(getUsagePercent(status.dailySpentUsd, status.dailyLimitUsd)); diff --git a/src/lib/usage/internalUsageCommand.ts b/src/lib/usage/internalUsageCommand.ts index 1f1925898abf..3528a0acd2c4 100644 --- a/src/lib/usage/internalUsageCommand.ts +++ b/src/lib/usage/internalUsageCommand.ts @@ -1,5 +1,6 @@ import type { ProviderLimitsCacheEntry } from "@/lib/db/providerLimits"; import { + buildApiKeyUsageLimitPercentText, buildApiKeyUsageLimitText, type ApiKeyUsageLimitStatus, } from "@/lib/usage/apiKeyUsageLimits"; @@ -19,6 +20,7 @@ interface UsageCommandApiKeyMetadata { usageLimitEnabled?: boolean; dailyUsageLimitUsd?: number | null; weeklyUsageLimitUsd?: number | null; + usageCommandShowUsd?: boolean; } interface ProviderConnectionLike { @@ -375,10 +377,13 @@ export async function buildUsageCommandText( ): Promise { const resolvedDeps = await normalizeDeps(deps); if (metadata.usageLimitEnabled === true) { - return buildApiKeyUsageLimitText( - await resolvedDeps.getApiKeyUsageLimitStatus(metadata, { now: resolvedDeps.now }), - resolvedDeps.now() - ); + const status = await resolvedDeps.getApiKeyUsageLimitStatus(metadata, { + now: resolvedDeps.now, + }); + const now = resolvedDeps.now(); + return metadata.usageCommandShowUsd === true + ? buildApiKeyUsageLimitText(status, now) + : buildApiKeyUsageLimitPercentText(status, now); } const snapshot = selectUsageSnapshot(await collectUsageSnapshots(metadata, resolvedDeps)); diff --git a/src/shared/validation/schemas/keys.ts b/src/shared/validation/schemas/keys.ts index e3db9b792840..e8b8ab2c1a05 100644 --- a/src/shared/validation/schemas/keys.ts +++ b/src/shared/validation/schemas/keys.ts @@ -25,6 +25,7 @@ export const createKeySchema = z.object({ usageLimitEnabled: z.boolean().optional(), dailyUsageLimitUsd: z.coerce.number().min(0).optional().nullable(), weeklyUsageLimitUsd: z.coerce.number().min(0).optional().nullable(), + usageCommandShowUsd: z.boolean().optional(), scopes: z.array(z.string().trim().min(1).max(64)).max(32).optional(), }); @@ -110,6 +111,7 @@ export const updateKeyPermissionsSchema = z usageLimitEnabled: z.boolean().optional(), dailyUsageLimitUsd: z.coerce.number().min(0).optional().nullable(), weeklyUsageLimitUsd: z.coerce.number().min(0).optional().nullable(), + usageCommandShowUsd: z.boolean().optional(), }) .superRefine((value, ctx) => { if ( @@ -133,7 +135,8 @@ export const updateKeyPermissionsSchema = z value.allowUsageCommand === undefined && value.usageLimitEnabled === undefined && value.dailyUsageLimitUsd === undefined && - value.weeklyUsageLimitUsd === undefined + value.weeklyUsageLimitUsd === undefined && + value.usageCommandShowUsd === undefined ) { ctx.addIssue({ code: z.ZodIssueCode.custom, diff --git a/src/sse/handlers/chatHelpers.ts b/src/sse/handlers/chatHelpers.ts index e8abf6533eaf..54b3bf837ddb 100644 --- a/src/sse/handlers/chatHelpers.ts +++ b/src/sse/handlers/chatHelpers.ts @@ -25,6 +25,7 @@ import { runWithTlsTracking, isTlsFingerprintActive, } from "@omniroute/open-sse/utils/proxyFetch.ts"; +import { getProviderLimitsCache } from "@/lib/db/providerLimits"; import { resolveProxyForConnection } from "@/lib/localDb"; import { CircuitBreakerOpenError, getCircuitBreaker } from "../../shared/utils/circuitBreaker"; import { classify429FromError, type FailureKind } from "../../shared/utils/classify429"; @@ -49,6 +50,7 @@ const PREFERRED_BY_FAMILY: Record = { }; const CODEX_NATIVE_RESPONSES_MODELS = new Set(["gpt-5.5"]); +const CLAUDE_LIMIT_TIME_ZONE = "America/Fortaleza"; type TrafficType = "production" | "shadow"; @@ -89,6 +91,95 @@ function isCodexNativeResponsesRequest( return metadataSource.toLowerCase().includes("codex"); } +function isRecord(value: unknown): value is Record { + return value !== null && typeof value === "object" && !Array.isArray(value); +} + +function numericValue(value: unknown): number | null { + if (typeof value === "number" && Number.isFinite(value)) return value; + if (typeof value === "string" && value.trim()) { + const parsed = Number(value); + return Number.isFinite(parsed) ? parsed : null; + } + return null; +} + +function getCredentialConnectionId(credentials: unknown): string | null { + if (!isRecord(credentials)) return null; + const connectionId = credentials.connectionId; + return typeof connectionId === "string" && connectionId.trim() ? connectionId : null; +} + +function isQuotaExhausted(quota: Record): boolean { + const usedPercentage = numericValue(quota.usedPercentage); + if (usedPercentage !== null && usedPercentage >= 100) return true; + + const remainingPercentage = numericValue(quota.remainingPercentage); + if (remainingPercentage !== null && remainingPercentage <= 0) return true; + + const used = numericValue(quota.used); + const total = numericValue(quota.total); + if (used !== null && total !== null && total > 0 && used >= total) return true; + + const remaining = numericValue(quota.remaining); + return remaining !== null && remaining <= 0; +} + +function formatFortalezaResetTime(resetAt: string): string | null { + const resetDate = new Date(resetAt); + if (!Number.isFinite(resetDate.getTime())) return null; + + const parts = new Intl.DateTimeFormat("en-US", { + timeZone: CLAUDE_LIMIT_TIME_ZONE, + hour: "numeric", + minute: "2-digit", + hour12: true, + }).formatToParts(resetDate); + const hour = parts.find((part) => part.type === "hour")?.value; + const minute = parts.find((part) => part.type === "minute")?.value; + const period = parts.find((part) => part.type === "dayPeriod")?.value.toLowerCase(); + if (!hour || !minute || !period) return null; + return `${hour}${minute === "00" ? "" : `:${minute}`}${period}`; +} + +function findClaudeWeeklyResetAt(model: string, connectionId: string | null): string | null { + if (!connectionId) return null; + const cache = getProviderLimitsCache(connectionId); + if (!cache?.quotas || !isRecord(cache.quotas)) return null; + + const modelLower = model.toLowerCase(); + const weeklyEntries = Object.entries(cache.quotas).filter(([key, value]) => { + if (!isRecord(value)) return false; + const normalized = key.toLowerCase(); + return normalized.includes("weekly") || normalized.includes("7d"); + }); + const preferredEntries = modelLower.includes("sonnet") + ? weeklyEntries.filter(([key]) => key.toLowerCase().includes("sonnet")) + : weeklyEntries.filter(([key]) => !key.toLowerCase().includes("sonnet")); + const candidates = preferredEntries.length > 0 ? preferredEntries : weeklyEntries; + + for (const [, quota] of candidates) { + if (!isQuotaExhausted(quota)) continue; + const resetAt = typeof quota.resetAt === "string" && quota.resetAt.trim() ? quota.resetAt : ""; + if (resetAt) return resetAt; + } + + return null; +} + +function buildClaudeWeeklyLimitMessage( + provider: string, + model: string, + credentials: unknown +): string | null { + if (provider !== "claude") return null; + const resetAt = findClaudeWeeklyResetAt(model, getCredentialConnectionId(credentials)); + if (!resetAt) return null; + const resetTime = formatFortalezaResetTime(resetAt); + if (!resetTime) return null; + return `You've hit your weekly limit · resets ${resetTime} (${CLAUDE_LIMIT_TIME_ZONE})`; +} + async function hasOnlyActiveCodexAccount() { try { const { getProviderConnections } = await import("@/lib/db/providers"); @@ -550,12 +641,15 @@ export function handleNoCredentials( }); } - log.warn("CHAT", `[${provider}/${model}] ${errorMsg} (${credentials.retryAfterHuman})`); + const friendlyQuotaMessage = buildClaudeWeeklyLimitMessage(provider, model, credentials); + const responseMessage = friendlyQuotaMessage || `[${provider}/${model}] ${errorMsg}`; + + log.warn("CHAT", `${responseMessage} (${credentials.retryAfterHuman})`); return unavailableResponse( status, - `[${provider}/${model}] ${errorMsg}`, + responseMessage, credentials.retryAfter, - credentials.retryAfterHuman + friendlyQuotaMessage ? undefined : credentials.retryAfterHuman ); } @@ -596,10 +690,7 @@ export function handleNoCredentials( // all disabled. log level is `warn` rather than `error` because zero active // credentials is an expected operator-driven state, not a server fault. log.warn("AUTH", `No active credentials for provider: ${provider}`); - return errorResponse( - HTTP_STATUS.NOT_FOUND, - `No active credentials for provider: ${provider}` - ); + return errorResponse(HTTP_STATUS.NOT_FOUND, `No active credentials for provider: ${provider}`); } log.warn("CHAT", "No more accounts available", { provider }); return errorResponse( diff --git a/src/sse/services/auth.ts b/src/sse/services/auth.ts index ea23f0c95dd7..c0456714b038 100644 --- a/src/sse/services/auth.ts +++ b/src/sse/services/auth.ts @@ -891,6 +891,7 @@ function buildQuotaPreflightRateLimitedResult( allRateLimited: true, retryAfter, retryAfterHuman: formatRetryAfter(retryAfter), + connectionId: blockedByPreflight[0]?.id || undefined, lastError: `All ${provider} accounts blocked by quota preflight`, lastErrorCode: 429, }; @@ -1088,6 +1089,7 @@ export async function getProviderCredentials( allRateLimited: true, retryAfter: earliest, retryAfterHuman: formatRetryAfter(earliest), + connectionId: undefined, }; } log.warn("AUTH", `${provider} | ${allConnections.length} accounts found but none active`); @@ -1288,6 +1290,7 @@ export async function getProviderCredentials( allRateLimited: true, retryAfter: earliest, retryAfterHuman: formatRetryAfter(earliest), + connectionId: earliestConn?.id || undefined, lastError: earliestConn?.lastError || null, lastErrorCode: allBlockedByModelCooldown ? 429 : earliestConn?.errorCode || null, cooldownScope: allBlockedByModelCooldown ? "model" : "connection", @@ -1344,6 +1347,7 @@ export async function getProviderCredentials( allRateLimited: true, retryAfter, retryAfterHuman: formatRetryAfter(retryAfter), + connectionId: blockedByPolicy[0]?.id || undefined, lastError: `All ${provider} accounts reached configured quota threshold`, lastErrorCode: 429, }; @@ -1377,6 +1381,7 @@ export async function getProviderCredentials( allRateLimited: true, retryAfter, retryAfterHuman: formatRetryAfter(retryAfter), + connectionId: exhaustedQuota[0]?.id || undefined, lastError: `All ${provider} accounts have exhausted their quota`, lastErrorCode: 429, }; diff --git a/tests/unit/api-key-usage-limits.test.ts b/tests/unit/api-key-usage-limits.test.ts index b078f1ff5ba5..65726afd488b 100644 --- a/tests/unit/api-key-usage-limits.test.ts +++ b/tests/unit/api-key-usage-limits.test.ts @@ -41,11 +41,13 @@ test("API key USD usage limits persist and default off", async () => { assert.equal(metadata?.usageLimitEnabled, false); assert.equal(metadata?.dailyUsageLimitUsd, null); assert.equal(metadata?.weeklyUsageLimitUsd, null); + assert.equal(metadata?.usageCommandShowUsd, false); await apiKeysDb.updateApiKeyPermissions(created.id, { usageLimitEnabled: true, dailyUsageLimitUsd: 10.5, weeklyUsageLimitUsd: 50, + usageCommandShowUsd: true, }); apiKeysDb.clearApiKeyCaches(); @@ -53,6 +55,7 @@ test("API key USD usage limits persist and default off", async () => { assert.equal(metadata?.usageLimitEnabled, true); assert.equal(metadata?.dailyUsageLimitUsd, 10.5); assert.equal(metadata?.weeklyUsageLimitUsd, 50); + assert.equal(metadata?.usageCommandShowUsd, true); }); test("getApiKeyUsageLimitStatus aligns weekly USD spend with provider resetAt when available", async () => { diff --git a/tests/unit/api-manager-page-static.test.ts b/tests/unit/api-manager-page-static.test.ts index 859be74f6c86..40d96f52394d 100644 --- a/tests/unit/api-manager-page-static.test.ts +++ b/tests/unit/api-manager-page-static.test.ts @@ -9,6 +9,10 @@ const pagePath = path.join( repoRoot, "src/app/(dashboard)/dashboard/api-manager/ApiManagerPageClient.tsx" ); +const usageLimitSettingsPath = path.join( + repoRoot, + "src/app/(dashboard)/dashboard/api-manager/components/UsageLimitSettings.tsx" +); const messagesDir = path.join(repoRoot, "src/i18n/messages"); const selfServiceScopeMessageKeys = [ @@ -61,15 +65,26 @@ test("permissions modal switch buttons declare button type", () => { selfServiceBlock.match(/ { + const source = fs.readFileSync(usageLimitSettingsPath, "utf8"); + const switchButtonCount = (source.match(/role="switch"/g) ?? []).length; + const typedSwitchButtonCount = (source.match(/ { const source = readApiManagerPage(); diff --git a/tests/unit/api-manager-usage-command.test.ts b/tests/unit/api-manager-usage-command.test.ts index 41474616a74a..c53adc7f2284 100644 --- a/tests/unit/api-manager-usage-command.test.ts +++ b/tests/unit/api-manager-usage-command.test.ts @@ -14,7 +14,9 @@ test("api manager exposes allowUsageCommand in create, edit, and list UI", () => assert.ok(src.includes("newKeyAllowUsageCommand"), "create modal must keep command state"); assert.ok(src.includes("setUsageCommandEnabled"), "permissions modal must edit command state"); + assert.ok(src.includes("setUsageCommandShowUsd"), "permissions modal must edit display state"); assert.ok(src.includes("allowUsageCommand"), "API payloads must include allowUsageCommand"); + assert.ok(src.includes("usageCommandShowUsd"), "API payloads must include display mode"); assert.ok(src.includes('t("localUsageCommand")'), "toggle must use i18n title"); assert.ok(src.includes('t("localUsageCommandBadge")'), "key list must show enabled state"); }); @@ -28,6 +30,10 @@ test("api key routes and schemas accept allowUsageCommand", () => { schemas.includes("allowUsageCommand: z.boolean().optional()"), "zod schemas must accept the field" ); + assert.ok( + schemas.includes("usageCommandShowUsd: z.boolean().optional()"), + "zod schemas must accept the display field" + ); assert.ok(createRoute.includes("allowUsageCommand"), "create route must persist the field"); assert.ok(updateRoute.includes("allowUsageCommand"), "update route must persist the field"); }); diff --git a/tests/unit/apikeys-usage-command.test.ts b/tests/unit/apikeys-usage-command.test.ts index 479e9f642471..d0eb699bb2b6 100644 --- a/tests/unit/apikeys-usage-command.test.ts +++ b/tests/unit/apikeys-usage-command.test.ts @@ -36,7 +36,9 @@ test("allowUsageCommand defaults to false for new API keys", async () => { assert.ok(metadata); assert.equal(metadata.allowUsageCommand, false); + assert.equal(metadata.usageCommandShowUsd, false); assert.equal(key?.allowUsageCommand, false); + assert.equal(key?.usageCommandShowUsd, false); }); test("allowUsageCommand can be toggled through updateApiKeyPermissions", async () => { @@ -54,3 +56,19 @@ test("allowUsageCommand can be toggled through updateApiKeyPermissions", async ( const disabled = await apiKeysDb.getApiKeyMetadata(created.key); assert.equal(disabled?.allowUsageCommand, false); }); + +test("usageCommandShowUsd can be toggled through updateApiKeyPermissions", async () => { + const created = await apiKeysDb.createApiKey("Usage Command Display", "machine-usage-03"); + + await apiKeysDb.updateApiKeyPermissions(created.id, { usageCommandShowUsd: true }); + apiKeysDb.clearApiKeyCaches(); + + const enabled = await apiKeysDb.getApiKeyMetadata(created.key); + assert.equal(enabled?.usageCommandShowUsd, true); + + await apiKeysDb.updateApiKeyPermissions(created.id, { usageCommandShowUsd: false }); + apiKeysDb.clearApiKeyCaches(); + + const disabled = await apiKeysDb.getApiKeyMetadata(created.key); + assert.equal(disabled?.usageCommandShowUsd, false); +}); diff --git a/tests/unit/chat-helpers.test.ts b/tests/unit/chat-helpers.test.ts index e27afdb4022c..7ac5f1bd6671 100644 --- a/tests/unit/chat-helpers.test.ts +++ b/tests/unit/chat-helpers.test.ts @@ -9,6 +9,7 @@ process.env.DATA_DIR = TEST_DATA_DIR; const core = await import("../../src/lib/db/core.ts"); const providersDb = await import("../../src/lib/db/providers.ts"); +const providerLimitsDb = await import("../../src/lib/db/providerLimits.ts"); const combosDb = await import("../../src/lib/db/combos.ts"); const { resolveModelOrError, @@ -302,6 +303,50 @@ test("handleNoCredentials returns Retry-After when every account is rate limited assert.match(json.error.message, /\[openai\/gpt-4o-mini\] Quota exceeded/); }); +test("handleNoCredentials uses cached Claude weekly reset for exhausted weekly quota", async () => { + const retryAfter = new Date(Date.now() + 45_000).toISOString(); + providerLimitsDb.setProviderLimitsCache("conn_claude_weekly", { + plan: "Claude Max", + quotas: { + "session (5h)": { + used: 0, + total: 100, + remaining: 100, + resetAt: null, + }, + "weekly (7d)": { + used: 100, + total: 100, + remaining: 0, + resetAt: "2026-06-25T23:00:00.456Z", + }, + }, + message: null, + fetchedAt: "2026-06-25T16:00:00.000Z", + }); + + const response = handleNoCredentials( + { + allRateLimited: true, + retryAfter, + retryAfterHuman: "reset after 45s", + connectionId: "conn_claude_weekly", + lastErrorCode: 429, + lastError: "This request would exceed your account's rate limit.", + }, + "conn_claude_weekly", + "claude", + "claude-opus-4-8", + null, + null + ); + const json = (await response.json()) as { error: { message: string; type?: string } }; + + assert.equal(response.status, 429); + assert.equal(json.error.type, "rate_limit_error"); + assert.equal(json.error.message, "You've hit your weekly limit · resets 8pm (America/Fortaleza)"); +}); + test("handleNoCredentials returns structured model_cooldown when every credential for the model is cooling down", async () => { const retryAfter = new Date(Date.now() + 12_000).toISOString(); const response = handleNoCredentials( diff --git a/tests/unit/internal-usage-command.test.ts b/tests/unit/internal-usage-command.test.ts index 0960336f7f49..3e2edb537a5f 100644 --- a/tests/unit/internal-usage-command.test.ts +++ b/tests/unit/internal-usage-command.test.ts @@ -113,7 +113,7 @@ test("buildUsageCommandText formats cached Claude usage windows exactly", async ); }); -test("buildUsageCommandText formats API key USD limits when fair usage is enabled", async () => { +test("buildUsageCommandText formats API key usage limits as percentages by default", async () => { const text = await buildUsageCommandText( { id: "key-limited", @@ -153,6 +153,55 @@ test("buildUsageCommandText formats API key USD limits when fair usage is enable } ); + assert.equal( + text, + ["Cota pessoal", "Diaria", "20%", "Resets in 15h", "", "Semanal", "11%", "Resets in 7d"].join( + "\n" + ) + ); +}); + +test("buildUsageCommandText keeps USD output when the API key display flag is enabled", async () => { + const text = await buildUsageCommandText( + { + id: "key-limited", + name: "limited", + allowedConnections: ["conn-claude"], + usageLimitEnabled: true, + dailyUsageLimitUsd: 10, + weeklyUsageLimitUsd: 50, + usageCommandShowUsd: true, + }, + { + now: () => NOW, + getApiKeyUsageLimitStatus: async () => ({ + enabled: true, + dailyLimitUsd: 10, + weeklyLimitUsd: 50, + 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-23T12: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, [ diff --git a/tests/unit/t07-no-log-key-config.test.ts b/tests/unit/t07-no-log-key-config.test.ts index 9ce5ded52d76..e365e08aace3 100644 --- a/tests/unit/t07-no-log-key-config.test.ts +++ b/tests/unit/t07-no-log-key-config.test.ts @@ -69,6 +69,11 @@ test("updateKeyPermissionsSchema accepts noLog-only updates and rejects empty pa }); assert.equal(streamDefaultOnly.success, true); + const usageCommandDisplayOnly = schemas.validateBody(schemas.updateKeyPermissionsSchema, { + usageCommandShowUsd: true, + }); + assert.equal(usageCommandDisplayOnly.success, true); + const emptyPayload = schemas.validateBody(schemas.updateKeyPermissionsSchema, {}); assert.equal(emptyPayload.success, false); }); From 79c58ac8e3d286b40e750c42ef7bc055c1e3f9e9 Mon Sep 17 00:00:00 2001 From: Wital Date: Thu, 25 Jun 2026 14:02:25 -0300 Subject: [PATCH 04/12] fix: align usage quota reset messages Respect the per-key @@om-usage USD display mode in API key usage-limit rejections and use the requested provider's cached weekly reset window for Codex usage caps instead of falling back to another provider's reset. Tests: DISABLE_SQLITE_AUTO_BACKUP=true node --import tsx/esm --test tests/unit/api-key-usage-limits.test.ts Tests: npm run typecheck:core Tests: npx eslint src/lib/usage/apiKeyUsageLimits.ts src/shared/utils/apiKeyPolicy.ts tests/unit/api-key-usage-limits.test.ts Tests: npm run check:migration-numbering Tests: npm run build --- src/lib/usage/apiKeyUsageLimits.ts | 72 +++++++++++-- src/shared/utils/apiKeyPolicy.ts | 13 +++ tests/unit/api-key-usage-limits.test.ts | 135 ++++++++++++++++++++++++ 3 files changed, 209 insertions(+), 11 deletions(-) diff --git a/src/lib/usage/apiKeyUsageLimits.ts b/src/lib/usage/apiKeyUsageLimits.ts index fc594037559f..93567b6b239e 100644 --- a/src/lib/usage/apiKeyUsageLimits.ts +++ b/src/lib/usage/apiKeyUsageLimits.ts @@ -11,9 +11,11 @@ const WEEK_MS = 7 * 24 * 60 * 60 * 1000; export interface ApiKeyUsageLimitMetadata { id: string; allowedConnections?: string[] | null; + preferredProvider?: string | null; usageLimitEnabled?: boolean; dailyUsageLimitUsd?: number | null; weeklyUsageLimitUsd?: number | null; + usageCommandShowUsd?: boolean; } export interface ApiKeyUsageLimitStatus { @@ -51,6 +53,7 @@ interface UsageCostRow { interface WeeklyResetCandidate { connectionId: string; + provider: string; resetAtIso: string; observedWindowStartIso: string | null; } @@ -116,9 +119,24 @@ function formatResetIn(resetAt: string | null, now = Date.now()): string { 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`; + const days = Math.floor(deltaMs / dayMs); + const hours = Math.floor((deltaMs % dayMs) / hourMs); + const minutes = Math.ceil((deltaMs % hourMs) / minuteMs); + + if (days > 0) { + const parts = [`${days}d`]; + if (hours > 0) parts.push(`${hours}h`); + if (minutes > 0 && parts.length < 3) parts.push(`${minutes}m`); + return parts.join(" "); + } + + if (hours > 0) { + const parts = [`${hours}h`]; + if (minutes > 0) parts.push(`${minutes}m`); + return parts.join(" "); + } + + return `${Math.max(1, minutes)}m`; } function resetDay(value: string | null): string | null { @@ -158,6 +176,13 @@ function normalizeQuotaName(value: string): string { .trim(); } +function normalizeProvider(value: unknown): string { + if (typeof value !== "string") return ""; + const normalized = value.trim().toLowerCase(); + if (normalized === "cc" || normalized === "claude-code") return "claude"; + return normalized; +} + function findWeeklyQuotaResetAt(quotas: unknown, nowMs: number): string | null { const quotaEntries: Array<[string, Record]> = []; if (Array.isArray(quotas)) { @@ -304,7 +329,7 @@ async function getProviderWeeklyWindow( if (allowedConnections.length > 0) { for (const connectionId of allowedConnections) { const connection = connectionFromValue(await deps.getProviderConnectionById(connectionId)); - if (!connection || connection.provider.toLowerCase() !== "claude") continue; + if (!connection) continue; const resetAt = findWeeklyQuotaResetAt( deps.getProviderLimitsCache(connection.id)?.quotas, nowMs @@ -312,6 +337,7 @@ async function getProviderWeeklyWindow( if (resetAt) { resetCandidates.push({ connectionId: connection.id, + provider: connection.provider, resetAtIso: resetAt, observedWindowStartIso: getWeeklyWindowStartIso(connection.id, resetAt, nowMs), }); @@ -322,11 +348,12 @@ async function getProviderWeeklyWindow( const connections = await deps.getProviderConnections({ isActive: true }); for (const rawConnection of connections) { const connection = connectionFromValue(rawConnection); - if (!connection || connection.provider.toLowerCase() !== "claude") continue; + if (!connection) continue; const resetAt = findWeeklyQuotaResetAt(caches[connection.id]?.quotas, nowMs); if (resetAt) { resetCandidates.push({ connectionId: connection.id, + provider: connection.provider, resetAtIso: resetAt, observedWindowStartIso: getWeeklyWindowStartIso(connection.id, resetAt, nowMs), }); @@ -334,8 +361,15 @@ async function getProviderWeeklyWindow( } } + const preferredProvider = normalizeProvider(metadata.preferredProvider); + const scopedCandidates = preferredProvider + ? resetCandidates.filter( + (candidate) => normalizeProvider(candidate.provider) === preferredProvider + ) + : []; + const candidates = scopedCandidates.length > 0 ? scopedCandidates : resetCandidates; const selected = - resetCandidates + candidates .sort((left, right) => Date.parse(left.resetAtIso) - Date.parse(right.resetAtIso)) .at(0) ?? null; return { @@ -473,18 +507,31 @@ export function buildApiKeyUsageLimitPercentText( ].join("\n"); } -function buildUsageLimitExceededMessage(status: ApiKeyUsageLimitStatus, now = Date.now()): string { +function buildUsageLimitExceededMessage( + status: ApiKeyUsageLimitStatus, + now = Date.now(), + options: { showUsd?: boolean } = {} +): string { + const showUsd = options.showUsd !== false; if (status.dailyExceeded && status.dailyLimitUsd !== null) { const percent = formatUsagePercent(getUsagePercent(status.dailySpentUsd, status.dailyLimitUsd)); + if (!showUsd) { + return `This API key reached its daily usage quota (${percent}). Resets in ${formatResetIn(status.dailyResetAtIso, now)}. Choose another allowed model after reset.`; + } 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) { const percent = formatUsagePercent( getUsagePercent(status.weeklySpentUsd, status.weeklyLimitUsd) ); + if (!showUsd) { + return `This API key reached its weekly usage quota (${percent}). Resets in ${formatResetIn(status.weeklyResetAtIso, now)}. Choose another allowed model after reset.`; + } 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."; + return showUsd + ? "This API key reached its USD usage quota. Choose another allowed model or wait for quota reset." + : "This API key reached its usage quota. Choose another allowed model or wait for quota reset."; } function isAnthropicMessagesRequest(request: Request): boolean { @@ -499,9 +546,10 @@ function isAnthropicMessagesRequest(request: Request): boolean { export function buildApiKeyUsageLimitRejection( request: Request, status: ApiKeyUsageLimitStatus, - now = Date.now() + now = Date.now(), + options: { showUsd?: boolean } = {} ): Response { - const message = sanitizeErrorMessage(buildUsageLimitExceededMessage(status, now)); + const message = sanitizeErrorMessage(buildUsageLimitExceededMessage(status, now, options)); if (isAnthropicMessagesRequest(request)) { return new Response( JSON.stringify({ @@ -530,5 +578,7 @@ export async function buildApiKeyUsageLimitPolicyRejection( ): Promise { const status = await getApiKeyUsageLimitStatus(metadata); if (!status.enabled || (!status.dailyExceeded && !status.weeklyExceeded)) return null; - return buildApiKeyUsageLimitRejection(request, status); + return buildApiKeyUsageLimitRejection(request, status, Date.now(), { + showUsd: metadata.usageCommandShowUsd === true, + }); } diff --git a/src/shared/utils/apiKeyPolicy.ts b/src/shared/utils/apiKeyPolicy.ts index 7e93fb027d14..44717c520caf 100644 --- a/src/shared/utils/apiKeyPolicy.ts +++ b/src/shared/utils/apiKeyPolicy.ts @@ -96,6 +96,7 @@ export interface ApiKeyMetadata { usageLimitEnabled?: boolean; dailyUsageLimitUsd?: number | null; weeklyUsageLimitUsd?: number | null; + usageCommandShowUsd?: boolean; } /** @@ -187,6 +188,15 @@ function matchesComboAccessRule(comboName: string, requestedModel: string, rule: ); } +function inferPreferredProvider(modelStr: string | null): string | null { + if (!modelStr || !modelStr.includes("/")) return null; + const [provider] = modelStr.split("/", 1); + const normalized = provider.trim().toLowerCase(); + if (!normalized || normalized === "combo" || normalized === "auto") return null; + if (normalized === "cc" || normalized === "claude-code") return "claude"; + return normalized; +} + function isAnthropicMessagesRequest(request: Request): boolean { if (request.headers.has("anthropic-version")) return true; @@ -388,9 +398,12 @@ export async function enforceApiKeyPolicy( try { const usageLimitRejection = await buildApiKeyUsageLimitPolicyRejection(request, { id: apiKeyInfo.id, + allowedConnections: apiKeyInfo.allowedConnections, + preferredProvider: inferPreferredProvider(modelStr), usageLimitEnabled: apiKeyInfo.usageLimitEnabled, dailyUsageLimitUsd: apiKeyInfo.dailyUsageLimitUsd, weeklyUsageLimitUsd: apiKeyInfo.weeklyUsageLimitUsd, + usageCommandShowUsd: apiKeyInfo.usageCommandShowUsd, }); if (usageLimitRejection) { return { apiKey, apiKeyInfo, rejection: usageLimitRejection }; diff --git a/tests/unit/api-key-usage-limits.test.ts b/tests/unit/api-key-usage-limits.test.ts index 65726afd488b..f7eb104337b6 100644 --- a/tests/unit/api-key-usage-limits.test.ts +++ b/tests/unit/api-key-usage-limits.test.ts @@ -269,6 +269,111 @@ test("getApiKeyUsageLimitStatus cuts weekly USD spend at observed provider quota assert.equal(status.weeklySpentUsd, 2); }); +test("getApiKeyUsageLimitStatus uses Codex weekly reset for Codex-limited keys", async () => { + const created = await apiKeysDb.createApiKey("Codex Reset Key", "machine-limit-codex"); + await apiKeysDb.updateApiKeyPermissions(created.id, { + usageLimitEnabled: true, + weeklyUsageLimitUsd: 0.5, + }); + + const metadata = await apiKeysDb.getApiKeyMetadata(created.key); + assert.ok(metadata); + + const codexResetAt = "2026-06-26T21:49:00.000Z"; + const claudeResetAt = "2026-06-19T23:00:00.000Z"; + const status = await usageLimits.getApiKeyUsageLimitStatus( + { ...metadata, allowedConnections: ["conn-codex"] }, + { + now: () => Date.parse("2026-06-19T17:00:00.000Z"), + getProviderConnectionById: async (connectionId) => ({ + id: connectionId, + provider: connectionId === "conn-codex" ? "codex" : "claude", + isActive: true, + }), + getProviderConnections: async () => [], + getProviderLimitsCache: (connectionId) => ({ + plan: connectionId === "conn-codex" ? "Prolite" : "Claude Max", + quotas: + connectionId === "conn-codex" + ? { + weekly: { + used: 9, + total: 100, + resetAt: codexResetAt, + }, + } + : { + "weekly (7d)": { + used: 100, + total: 100, + resetAt: claudeResetAt, + }, + }, + message: null, + fetchedAt: "2026-06-19T17:00:00.000Z", + }), + getAllProviderLimitsCache: () => ({}), + } + ); + + assert.equal(status.weeklyResetAtIso, codexResetAt); + assert.equal(status.weeklyWindowStartIso, "2026-06-19T21:49:00.000Z"); +}); + +test("getApiKeyUsageLimitStatus prefers the requested provider reset for unrestricted keys", async () => { + const created = await apiKeysDb.createApiKey("Codex Any Connection Key", "machine-limit-any"); + await apiKeysDb.updateApiKeyPermissions(created.id, { + usageLimitEnabled: true, + weeklyUsageLimitUsd: 0.5, + }); + + const metadata = await apiKeysDb.getApiKeyMetadata(created.key); + assert.ok(metadata); + + const codexResetAt = "2026-06-26T21:49:00.000Z"; + const claudeResetAt = "2026-06-19T23:00:00.000Z"; + const status = await usageLimits.getApiKeyUsageLimitStatus( + { ...metadata, preferredProvider: "codex" }, + { + now: () => Date.parse("2026-06-19T17:00:00.000Z"), + getProviderConnectionById: async () => null, + getProviderConnections: async () => [ + { id: "conn-claude", provider: "claude", isActive: true }, + { id: "conn-codex", provider: "codex", isActive: true }, + ], + getProviderLimitsCache: () => null, + getAllProviderLimitsCache: () => ({ + "conn-claude": { + plan: "Claude Max", + quotas: { + "weekly (7d)": { + used: 100, + total: 100, + resetAt: claudeResetAt, + }, + }, + message: null, + fetchedAt: "2026-06-19T17:00:00.000Z", + }, + "conn-codex": { + plan: "Prolite", + quotas: { + weekly: { + used: 9, + total: 100, + resetAt: codexResetAt, + }, + }, + message: null, + fetchedAt: "2026-06-19T17:00:00.000Z", + }, + }), + } + ); + + assert.equal(status.weeklyResetAtIso, codexResetAt); +}); + test("buildApiKeyUsageLimitText returns API-key quota spend percentage and reset lines", async () => { const text = usageLimits.buildApiKeyUsageLimitText( { @@ -338,6 +443,36 @@ test("buildApiKeyUsageLimitRejection includes over-quota percentage and reset hi ); }); +test("buildApiKeyUsageLimitRejection hides USD amounts when display mode is percentage", async () => { + const response = usageLimits.buildApiKeyUsageLimitRejection( + new Request("http://localhost/v1/responses"), + { + enabled: true, + dailyLimitUsd: 10, + weeklyLimitUsd: 0.5, + dailySpentUsd: 0.01, + weeklySpentUsd: 0.505, + dailyWindowStartIso: "2026-06-19T03:00:00.000Z", + dailyResetAtIso: "2026-06-20T03:00:00.000Z", + weeklyWindowStartIso: "2026-06-19T21:49:00.000Z", + weeklyResetAtIso: "2026-06-26T21:49:00.000Z", + dailyExceeded: false, + weeklyExceeded: true, + }, + Date.parse("2026-06-19T17:00:00.000Z"), + { showUsd: false } + ); + + 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 usage quota (101%). Resets in 7d 4h 49m. Choose another allowed model after reset." + ); + assert.equal(body.error.message.includes("$"), false); + assert.equal(body.error.message.includes("USD"), false); +}); + test("buildApiKeyUsageLimitRejection uses 400 so Claude Code does not trigger login", () => { const response = usageLimits.buildApiKeyUsageLimitRejection( new Request("http://localhost/v1/messages", { From 8189f74bf36fca14158243de18e84da684d52d58 Mon Sep 17 00:00:00 2001 From: Wital Date: Thu, 25 Jun 2026 17:59:08 -0300 Subject: [PATCH 05/12] feat: estimate provider quota USD capacity Add a provider quota USD estimator based on observed usage_history spend and current quota percentages, with Codex and Claude Code regression coverage. Expose the estimate in provider-limits APIs and add a quota dashboard modal with a 0-100 percent slider. Validation: - DISABLE_SQLITE_AUTO_BACKUP=true node --import tsx/esm --test tests/unit/provider-quota-usd-estimator.test.ts tests/unit/provider-quota-usd-estimator-ui.test.ts - DISABLE_SQLITE_AUTO_BACKUP=true node --import tsx/esm --test tests/unit/api-key-usage-limits.test.ts tests/unit/provider-limits-sanitize-scope-3821.test.ts tests/unit/db-provider-limits.test.ts - npx eslint - npm run typecheck:core - npm run build --- .../components/ProviderLimits/QuotaCard.tsx | 8 + .../ProviderLimits/QuotaCardGrid.tsx | 4 + .../ProviderLimits/QuotaUsdEstimateModal.tsx | 282 ++++++++++++ .../usage/components/ProviderLimits/index.tsx | 26 ++ .../parts/QuotaCardExpanded.tsx | 17 + src/app/api/usage/[connectionId]/route.ts | 17 +- src/i18n/messages/en.json | 16 + src/i18n/messages/pt-BR.json | 16 + src/lib/db/providerLimits.ts | 1 + src/lib/db/providers.ts | 30 ++ src/lib/localDb.ts | 1 + src/lib/usage/providerLimits.ts | 50 +- src/lib/usage/providerQuotaUsdEstimator.ts | 433 ++++++++++++++++++ .../provider-quota-usd-estimator-ui.test.ts | 62 +++ .../unit/provider-quota-usd-estimator.test.ts | 174 +++++++ 15 files changed, 1106 insertions(+), 31 deletions(-) create mode 100644 src/app/(dashboard)/dashboard/usage/components/ProviderLimits/QuotaUsdEstimateModal.tsx create mode 100644 src/lib/usage/providerQuotaUsdEstimator.ts create mode 100644 tests/unit/provider-quota-usd-estimator-ui.test.ts create mode 100644 tests/unit/provider-quota-usd-estimator.test.ts diff --git a/src/app/(dashboard)/dashboard/usage/components/ProviderLimits/QuotaCard.tsx b/src/app/(dashboard)/dashboard/usage/components/ProviderLimits/QuotaCard.tsx index 9c42bb064744..17df2f2480a8 100644 --- a/src/app/(dashboard)/dashboard/usage/components/ProviderLimits/QuotaCard.tsx +++ b/src/app/(dashboard)/dashboard/usage/components/ProviderLimits/QuotaCard.tsx @@ -23,6 +23,7 @@ interface QuotaCardProps { plan?: string | null; message?: string | null; stale?: { since?: string; reason?: string } | null; + usdEstimate?: any | null; } | undefined; loading: boolean; @@ -32,6 +33,7 @@ interface QuotaCardProps { providerLabel: string; onRefresh: () => void; onOpenCutoff: () => void; + onOpenUsdEstimate: () => void; onToggleActive: (nextActive: boolean) => void; togglingActive: boolean; } @@ -46,6 +48,7 @@ export default function QuotaCard({ providerLabel, onRefresh, onOpenCutoff, + onOpenUsdEstimate, onToggleActive, togglingActive, }: QuotaCardProps) { @@ -69,6 +72,9 @@ export default function QuotaCard({ const hasStaleData = !!quota?.stale; const displayRefreshedAt = quota?.stale?.since || refreshedAt; const canEditCutoff = quotas.some((q: any) => q && typeof q.name === "string" && !q.isCredits); + const canEstimateUsd = Array.isArray(quota?.usdEstimate?.windows) + ? quota.usdEstimate.windows.length > 0 + : false; return ( ); diff --git a/src/app/(dashboard)/dashboard/usage/components/ProviderLimits/QuotaCardGrid.tsx b/src/app/(dashboard)/dashboard/usage/components/ProviderLimits/QuotaCardGrid.tsx index 2effa011a40e..d79decfbc087 100644 --- a/src/app/(dashboard)/dashboard/usage/components/ProviderLimits/QuotaCardGrid.tsx +++ b/src/app/(dashboard)/dashboard/usage/components/ProviderLimits/QuotaCardGrid.tsx @@ -10,8 +10,10 @@ interface Props { lastRefreshedAt: Record; emailsVisible: boolean; providerLabels: Record; + renderInlineQuotaSummary?: (quota: any) => unknown; onRefresh: (id: string, provider: string) => void; onOpenCutoff: (connection: any) => void; + onOpenUsdEstimate: (connection: any) => void; onToggleActive: (id: string, nextActive: boolean) => void; togglingActiveId: string | null; } @@ -26,6 +28,7 @@ export default function QuotaCardGrid({ providerLabels, onRefresh, onOpenCutoff, + onOpenUsdEstimate, onToggleActive, togglingActiveId, }: Props) { @@ -62,6 +65,7 @@ export default function QuotaCardGrid({ providerLabel={providerLabels[conn.provider] || conn.provider} onRefresh={() => onRefresh(conn.id, conn.provider)} onOpenCutoff={() => onOpenCutoff(conn)} + onOpenUsdEstimate={() => onOpenUsdEstimate(conn)} onToggleActive={(nextActive) => onToggleActive(conn.id, nextActive)} togglingActive={togglingActiveId === conn.id} /> diff --git a/src/app/(dashboard)/dashboard/usage/components/ProviderLimits/QuotaUsdEstimateModal.tsx b/src/app/(dashboard)/dashboard/usage/components/ProviderLimits/QuotaUsdEstimateModal.tsx new file mode 100644 index 000000000000..204f8103bfda --- /dev/null +++ b/src/app/(dashboard)/dashboard/usage/components/ProviderLimits/QuotaUsdEstimateModal.tsx @@ -0,0 +1,282 @@ +"use client"; + +import { useMemo, useState } from "react"; +import { useTranslations } from "next-intl"; +import ProviderIcon from "@/shared/components/ProviderIcon"; +import { translateUsageOrFallback, type UsageTranslationValues } from "./i18nFallback"; +import { formatCountdown, formatQuotaLabel } from "./utils"; + +type EstimateWindow = { + windowName: string; + resetAtIso: string | null; + windowStartIso: string | null; + windowStartSource: string; + remainingPercentage: number | null; + usedPercentage: number | null; + observedSpendUsd: number; + observedRequests: number; + estimatedFullWindowUsd: number | null; + estimatedUsdPerPercent: number | null; + confidence: "high" | "medium" | "low" | "unavailable"; + unavailableReason: string | null; +}; + +type EstimatePayload = { + provider: string; + generatedAtIso: string; + costSource: string; + primaryWindowName: string | null; + windows: EstimateWindow[]; +} | null; + +interface Props { + isOpen: boolean; + estimate: EstimatePayload; + providerLabel: string; + accountName: string; + onClose: () => void; +} + +const CONFIDENCE_CLASS: Record = { + high: "border-emerald-500/25 bg-emerald-500/10 text-emerald-600 dark:text-emerald-300", + medium: "border-sky-500/25 bg-sky-500/10 text-sky-600 dark:text-sky-300", + low: "border-amber-500/30 bg-amber-500/10 text-amber-600 dark:text-amber-300", + unavailable: "border-border bg-bg-subtle text-text-muted", +}; + +function formatUsd(value: number | null | undefined): string { + if (value == null || !Number.isFinite(value)) return "-"; + if (value >= 1) return `$${value.toFixed(2)}`; + if (value >= 0.01) return `$${value.toFixed(4)}`; + return `$${value.toFixed(6)}`; +} + +function formatPercent(value: number | null | undefined): string { + if (value == null || !Number.isFinite(value)) return "-"; + return `${Math.round(value)}%`; +} + +function formatDateTime(value: string | null | undefined): string { + if (!value) return "-"; + const ms = Date.parse(value); + if (!Number.isFinite(ms)) return "-"; + return new Date(ms).toLocaleString([], { + month: "2-digit", + day: "2-digit", + hour: "2-digit", + minute: "2-digit", + }); +} + +function confidenceLabel( + value: EstimateWindow["confidence"], + tr: (key: string, fallback: string, values?: UsageTranslationValues) => string +): string { + if (value === "high") return tr("quotaUsdConfidenceHigh", "High"); + if (value === "medium") return tr("quotaUsdConfidenceMedium", "Medium"); + if (value === "low") return tr("quotaUsdConfidenceLow", "Low"); + return tr("quotaUsdConfidenceUnavailable", "Unavailable"); +} + +export default function QuotaUsdEstimateModal({ + isOpen, + estimate, + providerLabel, + accountName, + onClose, +}: Props) { + const t = useTranslations("usage"); + const tr = (key: string, fallback: string, values?: UsageTranslationValues) => + translateUsageOrFallback(t, key, fallback, values); + const [targetPercent, setTargetPercent] = useState(25); + const windows = useMemo(() => estimate?.windows ?? [], [estimate]); + const initialWindowName = estimate?.primaryWindowName ?? windows[0]?.windowName ?? ""; + const [selectedWindowName, setSelectedWindowName] = useState(initialWindowName); + + const selectedWindow = useMemo(() => { + return windows.find((window) => window.windowName === selectedWindowName) ?? windows[0] ?? null; + }, [selectedWindowName, windows]); + + if (!isOpen) return null; + + const simulatedUsd = + selectedWindow?.estimatedUsdPerPercent != null + ? selectedWindow.estimatedUsdPerPercent * targetPercent + : null; + const resetCountdown = selectedWindow?.resetAtIso + ? formatCountdown(selectedWindow.resetAtIso) + : null; + + return ( +

+
event.stopPropagation()} + > +
+
+
+ +
+
+

+ {tr("quotaUsdEstimate", "USD estimate")} +

+

+ {providerLabel} - {accountName} +

+
+
+ +
+ +
+ {windows.length > 1 && ( +
+ {windows.map((window) => { + const active = window.windowName === selectedWindow?.windowName; + return ( + + ); + })} +
+ )} + + {!selectedWindow ? ( +
+ {tr("quotaUsdNoEstimate", "No USD estimate available for this account.")} +
+ ) : ( + <> +
+
+
+ {tr("quotaUsdUsed", "Used")} +
+
+ {formatPercent(selectedWindow.usedPercentage)} +
+
+
+
+ {tr("quotaUsdObservedSpend", "Spend")} +
+
+ {formatUsd(selectedWindow.observedSpendUsd)} +
+
+
+
+ {tr("quotaUsdFullEstimate", "100% est.")} +
+
+ {formatUsd(selectedWindow.estimatedFullWindowUsd)} +
+
+
+
+ {tr("quotaUsdRequests", "Requests")} +
+
+ {selectedWindow.observedRequests.toLocaleString()} +
+
+
+ +
+
+ + + {targetPercent}% + +
+ setTargetPercent(Number(event.target.value))} + className="mt-3 h-2 w-full cursor-pointer accent-primary" + /> +
+ + {tr("quotaUsdSelectedValue", "Estimated USD")} + + + {formatUsd(simulatedUsd)} + +
+
+ +
+
+ + {tr("quotaUsdWindowStart", "Window start")} + {" "} + {formatDateTime(selectedWindow.windowStartIso)} +
+
+ + {tr("quotaUsdReset", "Reset")} + {" "} + {resetCountdown || formatDateTime(selectedWindow.resetAtIso)} +
+
+ + {tr("quotaUsdSource", "Source")} + {" "} + {selectedWindow.windowStartSource} +
+
+ + {confidenceLabel(selectedWindow.confidence, tr)} + +
+
+ + {selectedWindow.unavailableReason && ( +
+ {selectedWindow.unavailableReason} +
+ )} + + )} +
+
+
+ ); +} diff --git a/src/app/(dashboard)/dashboard/usage/components/ProviderLimits/index.tsx b/src/app/(dashboard)/dashboard/usage/components/ProviderLimits/index.tsx index 7176942e8a8c..6122b144309c 100644 --- a/src/app/(dashboard)/dashboard/usage/components/ProviderLimits/index.tsx +++ b/src/app/(dashboard)/dashboard/usage/components/ProviderLimits/index.tsx @@ -21,6 +21,7 @@ import useEmailPrivacyStore from "@/store/emailPrivacyStore"; import { useNotificationStore } from "@/store/notificationStore"; import QuotaCutoffModal from "./QuotaCutoffModal"; import QuotaCardGrid from "./QuotaCardGrid"; +import QuotaUsdEstimateModal from "./QuotaUsdEstimateModal"; import { useVisibleQuotaData } from "./useVisibleQuotaData"; import { formatAutoRefreshCountdown } from "./formatters"; import { translateUsageOrFallback, type UsageTranslationValues } from "./i18nFallback"; @@ -269,6 +270,7 @@ export default function ProviderLimits({ const autoRefreshIntervalMs = autoRefreshInterval > 0 ? autoRefreshInterval * 1000 : 0; const [autoRefreshClock, setAutoRefreshClock] = useState(() => Date.now()); const [cutoffModalConn, setCutoffModalConn] = useState(null); + const [usdEstimateModalConn, setUsdEstimateModalConn] = useState(null); const [cutoffModalWindows, setCutoffModalWindows] = useState([]); const [providerWindowDefaults, setProviderWindowDefaults] = useState< Record> @@ -368,6 +370,7 @@ export default function ProviderLimits({ quotas: parseQuotaData(conn.provider, cached), plan: cached.plan || null, message: cached.message || null, + usdEstimate: cached.usdEstimate || null, raw: cached, }; @@ -447,6 +450,7 @@ export default function ProviderLimits({ quotas: parsedQuotas, plan: data.plan || null, message: data.message || null, + usdEstimate: data.usdEstimate || null, raw: data, stale: data._stale ? { since: data._staleSince, reason: data._staleReason } : null, }, @@ -1066,6 +1070,7 @@ export default function ProviderLimits({ setCutoffModalWindows(windows); setCutoffModalConn(conn); }} + onOpenUsdEstimate={(conn) => setUsdEstimateModalConn(conn)} onToggleActive={handleToggleActive} togglingActiveId={togglingActiveId} /> @@ -1111,6 +1116,27 @@ export default function ProviderLimits({ }} /> )} + {usdEstimateModalConn && ( + setUsdEstimateModalConn(null)} + estimate={quotaData[usdEstimateModalConn.id]?.usdEstimate ?? null} + providerLabel={ + PROVIDER_LABEL[usdEstimateModalConn.provider] || usdEstimateModalConn.provider + } + accountName={ + pickDisplayValue( + [ + usdEstimateModalConn.name, + usdEstimateModalConn.displayName, + usdEstimateModalConn.email, + ], + emailsVisible, + usdEstimateModalConn.provider + ) || usdEstimateModalConn.provider + } + /> + )} ); } diff --git a/src/app/(dashboard)/dashboard/usage/components/ProviderLimits/parts/QuotaCardExpanded.tsx b/src/app/(dashboard)/dashboard/usage/components/ProviderLimits/parts/QuotaCardExpanded.tsx index 47641ec8968c..60468989f079 100644 --- a/src/app/(dashboard)/dashboard/usage/components/ProviderLimits/parts/QuotaCardExpanded.tsx +++ b/src/app/(dashboard)/dashboard/usage/components/ProviderLimits/parts/QuotaCardExpanded.tsx @@ -30,8 +30,10 @@ interface Props { hasStaleData: boolean; onRefresh: () => void; onOpenCutoff: () => void; + onOpenUsdEstimate: () => void; canEditCutoff: boolean; hasCutoffOverrides: boolean; + canEstimateUsd: boolean; } function QuotaDetailRow({ q }: { q: any }) { @@ -115,8 +117,10 @@ export default function QuotaCardExpanded({ hasStaleData, onRefresh, onOpenCutoff, + onOpenUsdEstimate, canEditCutoff, hasCutoffOverrides, + canEstimateUsd, }: Props) { const t = useTranslations("usage"); const tr = (key: string, fallback: string, values?: UsageTranslationValues) => @@ -189,6 +193,19 @@ export default function QuotaCardExpanded({ tune {tr("editCutoffs", "Edit cutoffs")} +