From 7ac07119858a2f4adb23e0f84192241b1f32b59b Mon Sep 17 00:00:00 2001 From: Alex Schmitt Date: Thu, 20 Aug 2026 15:07:00 -0300 Subject: [PATCH] feat: add provider quota tracker --- app/(app)/dashboard/quotas/page.tsx | 5 + components/app-shell.tsx | 23 +- components/dashboard/dashboard-page.tsx | 6 +- components/dashboard/quota-tracker.tsx | 532 ++++++++++++++++++ components/ui/card.tsx | 28 + components/ui/dialog.tsx | 11 + components/ui/progress.tsx | 31 + lib/contracts.ts | 57 ++ lib/provider-quota.test.ts | 100 ++++ lib/provider-quota.ts | 164 ++++++ .../migration.sql | 25 + prisma/schema.prisma | 20 + server/routes/user.ts | 98 +++- server/tests/user-routes.test.ts | 62 ++ 14 files changed, 1159 insertions(+), 3 deletions(-) create mode 100644 app/(app)/dashboard/quotas/page.tsx create mode 100644 components/dashboard/quota-tracker.tsx create mode 100644 components/ui/progress.tsx create mode 100644 lib/provider-quota.test.ts create mode 100644 lib/provider-quota.ts create mode 100644 prisma/migrations/20260820130000_provider_quota/migration.sql diff --git a/app/(app)/dashboard/quotas/page.tsx b/app/(app)/dashboard/quotas/page.tsx new file mode 100644 index 0000000..f79bf91 --- /dev/null +++ b/app/(app)/dashboard/quotas/page.tsx @@ -0,0 +1,5 @@ +import { DashboardPage } from "@/components/dashboard/dashboard-page" + +export default function DashboardQuotasPage() { + return +} diff --git a/components/app-shell.tsx b/components/app-shell.tsx index e2969ce..430b06e 100644 --- a/components/app-shell.tsx +++ b/components/app-shell.tsx @@ -9,6 +9,7 @@ import { CloudIcon, FileTextIcon, FlaskConicalIcon, + GaugeIcon, LayoutDashboardIcon, Loader2Icon, LogOutIcon, @@ -91,6 +92,13 @@ function getPageCopy(pathname: string) { }; } + if (pathname.startsWith("/dashboard/quotas")) { + return { + description: "Monitore consumo, limites configurados e janelas de renovação por provedor.", + title: "Quota Tracker", + }; + } + if (pathname.startsWith("/dashboard/logs")) { return { description: "Acompanhe requests recentes, status e detalhes de erro.", @@ -198,7 +206,8 @@ export function AppShell({ children }: { children: React.ReactNode }) { asChild isActive={ pathname.startsWith("/dashboard") && - !pathname.startsWith("/dashboard/credentials") + !pathname.startsWith("/dashboard/credentials") && + !pathname.startsWith("/dashboard/quotas") } tooltip="Dashboard" > @@ -208,6 +217,18 @@ export function AppShell({ children }: { children: React.ReactNode }) { + + + + + Quotas + + + diff --git a/components/dashboard/dashboard-page.tsx b/components/dashboard/dashboard-page.tsx index 2918775..2990d4b 100644 --- a/components/dashboard/dashboard-page.tsx +++ b/components/dashboard/dashboard-page.tsx @@ -29,6 +29,7 @@ import { AnalyticsSection } from "@/components/dashboard/analytics-section"; import { ApiQuickStartCard } from "@/components/dashboard/api-quick-start-card"; import { RoutingSection } from "@/components/dashboard/routing-section"; import { ProviderCatalog } from "@/components/dashboard/providers/provider-catalog"; +import { QuotaTracker } from "@/components/dashboard/quota-tracker"; import { useAppState } from "@/components/app-state-provider"; import { AlertDialog, @@ -62,7 +63,7 @@ import { import { apiJson, apiJsonRequest } from "@/lib/api"; import { providerHasRequiredCredentials } from "@/lib/provider-credentials"; -export type DashboardSection = "overview" | "keys" | "credentials" | "logs" | "analytics" | "routing" | "budget"; +export type DashboardSection = "overview" | "keys" | "credentials" | "quotas" | "logs" | "analytics" | "routing" | "budget"; function sectionNeedsDashboardData(section: DashboardSection) { return section === "overview" || section === "keys" || section === "logs"; @@ -237,6 +238,7 @@ export function DashboardPage({ section = "overview" }: { section?: DashboardSec { href: "/dashboard", id: "overview", label: "Visão geral" }, { count: shouldLoadDashboardData ? apiKeys.length : undefined, href: "/dashboard/api-keys", id: "keys", label: "API Keys" }, { count: providers.length, href: "/dashboard/credentials", id: "credentials", label: "Provedores" }, + { href: "/dashboard/quotas", id: "quotas", label: "Quotas" }, { count: shouldLoadDashboardData ? logs.length : undefined, href: "/dashboard/logs", id: "logs", label: "Logs de uso" }, { href: "/dashboard/analytics", id: "analytics", label: "Analytics" }, { href: "/dashboard/routing", id: "routing", label: "Roteamento" }, @@ -636,6 +638,8 @@ export function DashboardPage({ section = "overview" }: { section?: DashboardSec ) : null} + {section === "quotas" ? : null} + {section === "logs" ? ( diff --git a/components/dashboard/quota-tracker.tsx b/components/dashboard/quota-tracker.tsx new file mode 100644 index 0000000..e01294d --- /dev/null +++ b/components/dashboard/quota-tracker.tsx @@ -0,0 +1,532 @@ +"use client" + +import { useCallback, useEffect, useMemo, useState } from "react" +import { + ActivityIcon, + AlertTriangleIcon, + CheckCircle2Icon, + Clock3Icon, + GaugeIcon, + Loader2Icon, + PencilIcon, + RefreshCwIcon, + SearchXIcon, + ShieldQuestionIcon, + Trash2Icon, +} from "lucide-react" +import { toast } from "sonner" + +import { useAppState } from "@/components/app-state-provider" +import { ProviderAvatar } from "@/components/dashboard/routing/provider-avatar" +import { Badge } from "@/components/ui/badge" +import { Button } from "@/components/ui/button" +import { + Card, + CardAction, + CardContent, + CardDescription, + CardFooter, + CardHeader, + CardTitle, +} from "@/components/ui/card" +import { + Dialog, + DialogContent, + DialogDescription, + DialogFooter, + DialogHeader, + DialogTitle, +} from "@/components/ui/dialog" +import { Empty, EmptyDescription, EmptyHeader, EmptyMedia, EmptyTitle } from "@/components/ui/empty" +import { Field, FieldDescription, FieldGroup, FieldLabel } from "@/components/ui/field" +import { Input } from "@/components/ui/input" +import { Progress } from "@/components/ui/progress" +import { + Select, + SelectContent, + SelectGroup, + SelectItem, + SelectTrigger, + SelectValue, +} from "@/components/ui/select" +import { Skeleton } from "@/components/ui/skeleton" +import { Switch } from "@/components/ui/switch" +import type { + ProviderQuotaAccount, + ProviderQuotaResponse, +} from "@/lib/contracts" +import { apiJson, apiJsonRequest } from "@/lib/api" + +type QuotaStatusFilter = "all" | "available" | "attention" | "disabled" +type QuotaSort = "risk" | "usage" | "provider" + +const WINDOW_LABELS: Record = { + 1: "1 hora", + 6: "6 horas", + 24: "24 horas", + 168: "7 dias", + 720: "30 dias", +} + +function formatCompact(value: number) { + return new Intl.NumberFormat("pt-BR", { notation: "compact", maximumFractionDigits: 1 }).format(value) +} + +function formatCurrency(value: number) { + return new Intl.NumberFormat("pt-BR", { + style: "currency", + currency: "USD", + minimumFractionDigits: value < 1 ? 3 : 2, + maximumFractionDigits: value < 1 ? 3 : 2, + }).format(value) +} + +function formatReset(resetAt: string | null, windowHours: number) { + if (!resetAt) return `Janela móvel de ${WINDOW_LABELS[windowHours] ?? `${windowHours}h`}` + const milliseconds = new Date(resetAt).getTime() - Date.now() + if (milliseconds <= 0) return "Renovando agora" + const minutes = Math.ceil(milliseconds / 60_000) + if (minutes < 60) return `Próxima liberação em ${minutes} min` + const hours = Math.ceil(minutes / 60) + if (hours < 48) return `Próxima liberação em ${hours} h` + return `Próxima liberação em ${Math.ceil(hours / 24)} dias` +} + +function statusCopy(status: ProviderQuotaAccount["status"]) { + switch (status) { + case "available": + return { label: "Disponível", variant: "secondary" as const, icon: CheckCircle2Icon } + case "warning": + return { label: "Atenção", variant: "outline" as const, icon: AlertTriangleIcon } + case "exhausted": + return { label: "Esgotado", variant: "destructive" as const, icon: AlertTriangleIcon } + case "disabled": + return { label: "Pausado", variant: "outline" as const, icon: Clock3Icon } + default: + return { label: "Monitorando", variant: "outline" as const, icon: ShieldQuestionIcon } + } +} + +function QuotaMetric({ + label, + limit, + value, + formatter = formatCompact, +}: { + formatter?: (value: number) => string + label: string + limit: number | null + value: number +}) { + const percentage = limit == null ? null : Math.min(100, Math.round((value / limit) * 100)) + + return ( +
+
+ {label} + + {formatter(value)} / {limit == null ? "limite não informado" : formatter(limit)} + +
+ +
+ {percentage == null ? "—" : `${percentage}% utilizado`} +
+
+ ) +} + +function QuotaSkeleton() { + return ( +
+ + +
+ {Array.from({ length: 4 }).map((_, index) => ( + + ))} +
+
+ ) +} + +type EditorState = { + providerId: string + label: string + isEnabled: boolean + windowHours: string + requestLimit: string + tokenLimit: string + costLimitUsd: string +} + +function optionalNumber(value: string) { + const normalized = value.trim().replace(",", ".") + return normalized ? Number(normalized) : null +} + +export function QuotaTracker() { + const { providers } = useAppState() + const [payload, setPayload] = useState(null) + const [loading, setLoading] = useState(true) + const [refreshing, setRefreshing] = useState(false) + const [saving, setSaving] = useState(false) + const [autoRefresh, setAutoRefresh] = useState(true) + const [hideEmpty, setHideEmpty] = useState(false) + const [providerFilter, setProviderFilter] = useState("all") + const [statusFilter, setStatusFilter] = useState("all") + const [sort, setSort] = useState("risk") + const [editor, setEditor] = useState(null) + + const providerById = useMemo( + () => new Map(providers.map((provider) => [provider.id, provider])), + [providers], + ) + + const load = useCallback(async (background = false) => { + if (background) setRefreshing(true) + else setLoading(true) + try { + setPayload(await apiJson("/user/quotas")) + } catch (error) { + toast.error(error instanceof Error ? error.message : "Falha ao carregar quotas.") + } finally { + setLoading(false) + setRefreshing(false) + } + }, []) + + useEffect(() => { + void load() + }, [load]) + + useEffect(() => { + if (!autoRefresh) return + const interval = window.setInterval(() => void load(true), 60_000) + return () => window.clearInterval(interval) + }, [autoRefresh, load]) + + const accounts = useMemo(() => { + const filtered = (payload?.accounts ?? []).filter((account) => { + if (providerFilter !== "all" && account.providerId !== providerFilter) return false + if (hideEmpty && account.requests === 0 && account.tokens === 0 && account.costUsd === 0) return false + if (statusFilter === "available" && !["available", "monitoring"].includes(account.status)) return false + if (statusFilter === "attention" && !["warning", "exhausted"].includes(account.status)) return false + if (statusFilter === "disabled" && account.status !== "disabled") return false + return true + }) + + return [...filtered].sort((a, b) => { + if (sort === "provider") { + const aLabel = providerById.get(a.providerId)?.label ?? a.providerId + const bLabel = providerById.get(b.providerId)?.label ?? b.providerId + return aLabel.localeCompare(bLabel) + } + if (sort === "usage") return b.requests - a.requests || b.tokens - a.tokens + const order = { exhausted: 0, warning: 1, available: 2, monitoring: 3, disabled: 4 } + return order[a.status] - order[b.status] || (b.percentage ?? -1) - (a.percentage ?? -1) + }) + }, [hideEmpty, payload, providerById, providerFilter, sort, statusFilter]) + + const totals = useMemo(() => { + const all = payload?.accounts ?? [] + return { + active: all.filter((account) => account.status !== "disabled").length, + attention: all.filter((account) => ["warning", "exhausted"].includes(account.status)).length, + requests: all.reduce((total, account) => total + account.requests, 0), + tokens: all.reduce((total, account) => total + account.tokens, 0), + } + }, [payload]) + + function openEditor(account: ProviderQuotaAccount) { + setEditor({ + providerId: account.providerId, + label: account.profile.label ?? "", + isEnabled: account.profile.isEnabled, + windowHours: String(account.profile.windowHours), + requestLimit: account.profile.requestLimit == null ? "" : String(account.profile.requestLimit), + tokenLimit: account.profile.tokenLimit == null ? "" : String(account.profile.tokenLimit), + costLimitUsd: account.profile.costLimitUsd == null ? "" : String(account.profile.costLimitUsd), + }) + } + + async function saveEditor() { + if (!editor) return + const requestLimit = optionalNumber(editor.requestLimit) + const tokenLimit = optionalNumber(editor.tokenLimit) + const costLimitUsd = optionalNumber(editor.costLimitUsd) + if ([requestLimit, tokenLimit, costLimitUsd].some((value) => value != null && (!Number.isFinite(value) || value <= 0))) { + toast.error("Os limites devem ser números maiores que zero.") + return + } + + setSaving(true) + try { + await apiJsonRequest(`/user/quotas/${encodeURIComponent(editor.providerId)}`, "PATCH", { + label: editor.label.trim() || null, + isEnabled: editor.isEnabled, + windowHours: Number(editor.windowHours), + requestLimit, + tokenLimit, + costLimitUsd, + }) + setEditor(null) + await load(true) + toast.success("Limites de quota atualizados.") + } catch (error) { + toast.error(error instanceof Error ? error.message : "Falha ao salvar quota.") + } finally { + setSaving(false) + } + } + + async function updateEnabled(account: ProviderQuotaAccount, isEnabled: boolean) { + try { + await apiJsonRequest(`/user/quotas/${encodeURIComponent(account.providerId)}`, "PATCH", { isEnabled }) + await load(true) + toast.success(isEnabled ? "Monitoramento ativado." : "Monitoramento pausado.") + } catch (error) { + toast.error(error instanceof Error ? error.message : "Falha ao atualizar monitoramento.") + } + } + + async function removeLimits() { + if (!editor) return + setSaving(true) + try { + await apiJsonRequest(`/user/quotas/${encodeURIComponent(editor.providerId)}`, "DELETE") + setEditor(null) + await load(true) + toast.success("Limites personalizados removidos.") + } catch (error) { + toast.error(error instanceof Error ? error.message : "Falha ao remover limites.") + } finally { + setSaving(false) + } + } + + if (loading) return + + return ( +
+
+
+
+ +
+
+
+

Quota Tracker

+ {totals.active} monitorados +
+

+ Acompanhe o consumo observado e compare com os limites reais do plano de cada provedor. +

+
+
+ +
+ +
+ + Contas monitoradas{totals.active} + + + Precisam de atenção{totals.attention} + + + Requests nas janelas{formatCompact(totals.requests)} + + + Tokens nas janelas{formatCompact(totals.tokens)} + +
+ + + + + + +
+ + +
+
+
+ + {accounts.length === 0 ? ( + + + + Nenhuma quota encontrada + Conecte um provedor, faça uma chamada ou ajuste os filtros para começar a monitorar. + + + ) : ( +
+ {accounts.map((account) => { + const provider = providerById.get(account.providerId) + const status = statusCopy(account.status) + const StatusIcon = status.icon + const hasLimits = account.profile.requestLimit != null || account.profile.tokenLimit != null || account.profile.costLimitUsd != null + return ( + + +
+ +
+ + {provider?.label ?? account.providerId} + {status.label} + + + {account.profile.label || "Conta principal"} · {WINDOW_LABELS[account.profile.windowHours] ?? `${account.profile.windowHours}h`} + +
+
+ + + void updateEnabled(account, checked)} aria-label={`Monitorar ${provider?.label ?? account.providerId}`} /> + +
+ +
+ {account.models.length} {account.models.length === 1 ? "modelo observado" : "modelos observados"} + {formatReset(account.resetAt, account.profile.windowHours)} +
+
+ + + +
+ {account.models.length > 0 ? ( +
+

Uso por modelo

+ {account.models.slice(0, 5).map((model) => ( +
+ {model.modelId} + {formatCompact(model.requests)} req. + {formatCompact(model.tokens)} tok. +
+ ))} +
+ ) : null} +
+ + + {hasLimits ? `${account.percentage ?? 0}% do maior limite utilizado` : "Defina os limites exibidos no painel do provedor"} + + 0 ? "destructive" : "outline"}> + {account.errors} erros + + +
+ ) + })} +
+ )} + + { if (!open) setEditor(null) }}> + + + Configurar limites de quota + + Informe os limites do plano exibidos pelo provedor. O ModelHub compara esses valores com o consumo observado. + + + {editor ? ( + + + Nome da conta + setEditor({ ...editor, label: event.target.value })} placeholder="Ex.: Produção" /> + + + Janela de medição + + Use a mesma janela de renovação informada pelo provedor. + +
+ + Requests + setEditor({ ...editor, requestLimit: event.target.value })} placeholder="Sem limite" /> + + + Tokens + setEditor({ ...editor, tokenLimit: event.target.value })} placeholder="Sem limite" /> + + + Custo (USD) + setEditor({ ...editor, costLimitUsd: event.target.value })} placeholder="Sem limite" /> + +
+ +
+ Monitoramento ativo + Inclui esta conta na atualização automática e nos alertas. +
+ setEditor({ ...editor, isEnabled: checked })} /> +
+
+ ) : null} + + +
+ + +
+
+
+
+
+ ) +} diff --git a/components/ui/card.tsx b/components/ui/card.tsx index a9ff08f..8b06849 100644 --- a/components/ui/card.tsx +++ b/components/ui/card.tsx @@ -56,6 +56,19 @@ function CardDescription({ className, ...props }: React.ComponentProps<"div">) { ) } +function CardAction({ className, ...props }: React.ComponentProps<"div">) { + return ( +
+ ) +} + function CardContent({ className, ...props }: React.ComponentProps<"div">) { return (
) { ) } +function CardFooter({ className, ...props }: React.ComponentProps<"div">) { + return ( +
+ ) +} + export { Card, CardHeader, CardTitle, + CardAction, CardDescription, CardContent, + CardFooter, } diff --git a/components/ui/dialog.tsx b/components/ui/dialog.tsx index f1d1d79..f8b3c57 100644 --- a/components/ui/dialog.tsx +++ b/components/ui/dialog.tsx @@ -83,6 +83,16 @@ function DialogHeader({ className, ...props }: React.ComponentProps<"div">) { ) } +function DialogFooter({ className, ...props }: React.ComponentProps<"div">) { + return ( +
+ ) +} + function DialogTitle({ className, ...props @@ -119,6 +129,7 @@ export { Dialog, DialogContent, DialogDescription, + DialogFooter, DialogHeader, DialogTitle, } diff --git a/components/ui/progress.tsx b/components/ui/progress.tsx new file mode 100644 index 0000000..5a0a5a6 --- /dev/null +++ b/components/ui/progress.tsx @@ -0,0 +1,31 @@ +"use client" + +import * as React from "react" +import { Progress as ProgressPrimitive } from "radix-ui" + +import { cn } from "@/lib/utils" + +function Progress({ + className, + value, + ...props +}: React.ComponentProps) { + return ( + + + + ) +} + +export { Progress } diff --git a/lib/contracts.ts b/lib/contracts.ts index 05b8c47..1d87a16 100644 --- a/lib/contracts.ts +++ b/lib/contracts.ts @@ -199,6 +199,45 @@ export type ProviderCredentialSummary = { updatedAt: string } +export type ProviderQuotaProfile = { + providerId: string + label: string | null + isEnabled: boolean + windowHours: number + requestLimit: number | null + tokenLimit: number | null + costLimitUsd: number | null + updatedAt: string | null +} + +export type ProviderQuotaModelUsage = { + modelId: string + requests: number + tokens: number + costUsd: number + errors: number +} + +export type ProviderQuotaAccount = { + providerId: string + connectedAt: string | null + lastActivityAt: string | null + resetAt: string | null + requests: number + tokens: number + costUsd: number + errors: number + percentage: number | null + status: "available" | "warning" | "exhausted" | "monitoring" | "disabled" + profile: ProviderQuotaProfile + models: ProviderQuotaModelUsage[] +} + +export type ProviderQuotaResponse = { + accounts: ProviderQuotaAccount[] + generatedAt: string +} + export const cloudDeploymentStatusSchema = z.enum([ "provisioning", "healthy", @@ -275,6 +314,24 @@ export const providerCredentialSchema = z.object({ credentialValue: z.string().min(1).max(4096), }) +const nullablePositiveInteger = z.number().int().positive().max(1_000_000_000).nullable().optional() +const nullablePositiveAmount = z.number().positive().max(1_000_000).nullable().optional() + +export const providerQuotaSchema = z.object({ + label: z.string().trim().max(100).nullable().optional(), + isEnabled: z.boolean().optional(), + windowHours: z.union([ + z.literal(1), + z.literal(6), + z.literal(24), + z.literal(168), + z.literal(720), + ]).optional(), + requestLimit: nullablePositiveInteger, + tokenLimit: nullablePositiveInteger, + costLimitUsd: nullablePositiveAmount, +}) + export const cloudRenderConnectionSchema = z.object({ label: z.string().trim().min(1).max(100).optional(), token: z.string().trim().min(1).max(4096), diff --git a/lib/provider-quota.test.ts b/lib/provider-quota.test.ts new file mode 100644 index 0000000..ecbd124 --- /dev/null +++ b/lib/provider-quota.test.ts @@ -0,0 +1,100 @@ +import { describe, expect, it } from "vitest" + +import { buildProviderQuotaAccounts } from "./provider-quota" + +const NOW = new Date("2026-08-20T12:00:00.000Z") + +describe("buildProviderQuotaAccounts", () => { + it("combina limites configurados com uso real dentro da janela", () => { + const [account] = buildProviderQuotaAccounts({ + credentials: [{ providerId: "groq", updatedAt: NOW }], + profiles: [{ + providerId: "groq", + label: "Produção", + isEnabled: true, + windowHours: 24, + requestLimit: 10, + tokenLimit: 1_000, + costLimitUsd: null, + updatedAt: NOW, + }], + logs: [ + { + providerId: "groq", + modelId: "llama", + statusCode: 200, + requests: 1, + tokens: 400, + costUsd: 0.01, + oldestAt: new Date("2026-08-20T10:00:00.000Z"), + lastAt: new Date("2026-08-20T10:00:00.000Z"), + windowHours: 24, + }, + { + providerId: "groq", + modelId: "llama", + statusCode: 429, + requests: 1, + tokens: 500, + costUsd: 0, + oldestAt: new Date("2026-08-20T11:00:00.000Z"), + lastAt: new Date("2026-08-20T11:00:00.000Z"), + windowHours: 24, + }, + ], + }) + + expect(account).toMatchObject({ + providerId: "groq", + requests: 2, + tokens: 900, + errors: 1, + percentage: 90, + status: "warning", + }) + expect(account.models[0]).toMatchObject({ modelId: "llama", requests: 2 }) + expect(account.resetAt).toBe("2026-08-21T10:00:00.000Z") + }) + + it("não inventa percentual quando nenhum limite foi informado", () => { + const [account] = buildProviderQuotaAccounts({ + credentials: [{ providerId: "openrouter", updatedAt: NOW }], + profiles: [], + logs: [], + }) + + expect(account.percentage).toBeNull() + expect(account.status).toBe("monitoring") + expect(account.profile.windowHours).toBe(24) + }) + + it("marca a conta como esgotada quando qualquer limite chega a cem por cento", () => { + const [account] = buildProviderQuotaAccounts({ + credentials: [], + profiles: [{ + providerId: "moonshot", + label: null, + isEnabled: true, + windowHours: 1, + requestLimit: 1, + tokenLimit: null, + costLimitUsd: null, + updatedAt: NOW, + }], + logs: [{ + providerId: "moonshot", + modelId: "kimi", + statusCode: 200, + requests: 1, + tokens: 0, + costUsd: 0, + oldestAt: NOW, + lastAt: NOW, + windowHours: 1, + }], + }) + + expect(account.status).toBe("exhausted") + expect(account.percentage).toBe(100) + }) +}) diff --git a/lib/provider-quota.ts b/lib/provider-quota.ts new file mode 100644 index 0000000..5464265 --- /dev/null +++ b/lib/provider-quota.ts @@ -0,0 +1,164 @@ +import type { + ProviderQuotaAccount, + ProviderQuotaProfile, +} from "@/lib/contracts" + +export type QuotaProfileRow = { + providerId: string + label: string | null + isEnabled: boolean + windowHours: number + requestLimit: number | null + tokenLimit: number | null + costLimitUsd: number | null + updatedAt: Date +} + +export type QuotaCredentialRow = { + providerId: string + updatedAt: Date +} + +export type QuotaUsageRow = { + providerId: string + modelId: string | null + statusCode: number + requests: number + tokens: number + costUsd: number + oldestAt: Date | null + lastAt: Date | null + windowHours: number +} + +const STATUS_ORDER: Record = { + exhausted: 0, + warning: 1, + available: 2, + monitoring: 3, + disabled: 4, +} + +function defaultProfile(providerId: string): ProviderQuotaProfile { + return { + providerId, + label: null, + isEnabled: true, + windowHours: 24, + requestLimit: null, + tokenLimit: null, + costLimitUsd: null, + updatedAt: null, + } +} + +function percentOf(value: number, limit: number | null) { + if (limit == null || limit <= 0) return null + return (value / limit) * 100 +} + +export function buildProviderQuotaAccounts(input: { + credentials: QuotaCredentialRow[] + logs: QuotaUsageRow[] + profiles: QuotaProfileRow[] +}): ProviderQuotaAccount[] { + const profileByProvider = new Map(input.profiles.map((profile) => [profile.providerId, profile])) + const providerIds = new Set() + + input.credentials.forEach((credential) => providerIds.add(credential.providerId)) + input.logs.forEach((log) => providerIds.add(log.providerId)) + input.profiles.forEach((profile) => providerIds.add(profile.providerId)) + + return Array.from(providerIds, (providerId): ProviderQuotaAccount => { + const storedProfile = profileByProvider.get(providerId) + const profile: ProviderQuotaProfile = storedProfile + ? { + providerId, + label: storedProfile.label, + isEnabled: storedProfile.isEnabled, + windowHours: storedProfile.windowHours, + requestLimit: storedProfile.requestLimit, + tokenLimit: storedProfile.tokenLimit, + costLimitUsd: storedProfile.costLimitUsd, + updatedAt: storedProfile.updatedAt.toISOString(), + } + : defaultProfile(providerId) + + const logs = input.logs.filter( + (log) => log.providerId === providerId && log.windowHours === profile.windowHours, + ) + const credentials = input.credentials.filter((credential) => credential.providerId === providerId) + const requests = logs.reduce((total, log) => total + log.requests, 0) + const tokens = logs.reduce((total, log) => total + log.tokens, 0) + const costUsd = logs.reduce((total, log) => total + log.costUsd, 0) + const errors = logs.reduce( + (total, log) => total + (log.statusCode >= 400 ? log.requests : 0), + 0, + ) + const modelMap = new Map() + + for (const log of logs) { + const modelId = log.modelId?.trim() || "Modelo não informado" + const current = modelMap.get(modelId) ?? { + modelId, + requests: 0, + tokens: 0, + costUsd: 0, + errors: 0, + } + current.requests += log.requests + current.tokens += log.tokens + current.costUsd += log.costUsd + current.errors += log.statusCode >= 400 ? log.requests : 0 + modelMap.set(modelId, current) + } + + const percentages = [ + percentOf(requests, profile.requestLimit), + percentOf(tokens, profile.tokenLimit), + percentOf(costUsd, profile.costLimitUsd), + ].filter((value): value is number => value != null) + const rawPercentage = percentages.length > 0 ? Math.max(...percentages) : null + const percentage = rawPercentage == null ? null : Math.min(100, Math.round(rawPercentage)) + const status: ProviderQuotaAccount["status"] = !profile.isEnabled + ? "disabled" + : rawPercentage == null + ? "monitoring" + : rawPercentage >= 100 + ? "exhausted" + : rawPercentage >= 80 + ? "warning" + : "available" + const oldestLog = logs.reduce( + (oldest, log) => (!log.oldestAt || (oldest && oldest <= log.oldestAt) ? oldest : log.oldestAt), + null, + ) + const lastLog = logs.reduce( + (latest, log) => (!log.lastAt || (latest && latest >= log.lastAt) ? latest : log.lastAt), + null, + ) + + return { + providerId, + connectedAt: credentials.length > 0 + ? new Date(Math.max(...credentials.map((credential) => credential.updatedAt.getTime()))).toISOString() + : null, + lastActivityAt: lastLog?.toISOString() ?? null, + resetAt: oldestLog + ? new Date(oldestLog.getTime() + profile.windowHours * 60 * 60 * 1000).toISOString() + : null, + requests, + tokens, + costUsd, + errors, + percentage, + status, + profile, + models: Array.from(modelMap.values()).sort((a, b) => b.requests - a.requests), + } + }).sort((a, b) => { + const statusDifference = STATUS_ORDER[a.status] - STATUS_ORDER[b.status] + if (statusDifference !== 0) return statusDifference + return (b.percentage ?? -1) - (a.percentage ?? -1) || a.providerId.localeCompare(b.providerId) + }) +} diff --git a/prisma/migrations/20260820130000_provider_quota/migration.sql b/prisma/migrations/20260820130000_provider_quota/migration.sql new file mode 100644 index 0000000..d5c5eaf --- /dev/null +++ b/prisma/migrations/20260820130000_provider_quota/migration.sql @@ -0,0 +1,25 @@ +CREATE TABLE "ProviderQuota" ( + "id" TEXT NOT NULL, + "userId" TEXT NOT NULL, + "providerId" TEXT NOT NULL, + "label" TEXT, + "isEnabled" BOOLEAN NOT NULL DEFAULT true, + "windowHours" INTEGER NOT NULL DEFAULT 24, + "requestLimit" INTEGER, + "tokenLimit" INTEGER, + "costLimitUsd" DOUBLE PRECISION, + "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + "updatedAt" TIMESTAMP(3) NOT NULL, + + CONSTRAINT "ProviderQuota_pkey" PRIMARY KEY ("id") +); + +CREATE UNIQUE INDEX "ProviderQuota_userId_providerId_key" +ON "ProviderQuota"("userId", "providerId"); + +CREATE INDEX "ProviderQuota_userId_isEnabled_idx" +ON "ProviderQuota"("userId", "isEnabled"); + +ALTER TABLE "ProviderQuota" +ADD CONSTRAINT "ProviderQuota_userId_fkey" +FOREIGN KEY ("userId") REFERENCES "User"("id") ON DELETE CASCADE ON UPDATE CASCADE; diff --git a/prisma/schema.prisma b/prisma/schema.prisma index 97c5fd6..6f65cc1 100644 --- a/prisma/schema.prisma +++ b/prisma/schema.prisma @@ -32,6 +32,7 @@ model User { memories UserMemory[] routingConfig RoutingConfig? budget UserBudget? + providerQuotas ProviderQuota[] } model Project { @@ -527,6 +528,25 @@ model UserBudget { user User @relation(fields: [userId], references: [id], onDelete: Cascade) } +model ProviderQuota { + id String @id @default(cuid()) + userId String + providerId String + label String? + isEnabled Boolean @default(true) + windowHours Int @default(24) + requestLimit Int? + tokenLimit Int? + costLimitUsd Float? + createdAt DateTime @default(now()) + updatedAt DateTime @updatedAt + + user User @relation(fields: [userId], references: [id], onDelete: Cascade) + + @@unique([userId, providerId]) + @@index([userId, isEnabled]) +} + model ConversationAttachment { id String @id @default(cuid()) conversationId String diff --git a/server/routes/user.ts b/server/routes/user.ts index abc6d16..70c3119 100644 --- a/server/routes/user.ts +++ b/server/routes/user.ts @@ -1,7 +1,12 @@ import { Hono } from "hono" import { z } from "zod" -import { apiKeyLabelSchema, providerCredentialSchema } from "@/lib/contracts" +import { + apiKeyLabelSchema, + providerCredentialSchema, + providerQuotaSchema, +} from "@/lib/contracts" import { isValidAccentColor } from "@/lib/accent-colors" +import { buildProviderQuotaAccounts } from "@/lib/provider-quota" import { encryptCredential, generateApiKey } from "../lib/crypto" import { prisma } from "../lib/db" @@ -321,6 +326,97 @@ app.get("/usage/recent", async (c) => { return c.json({ logs }) }) +app.get("/quotas", async (c) => { + const userId = requireAuth(c) + if (typeof userId !== "string") return userId + + const profiles = await prisma.providerQuota.findMany({ + where: { userId }, + orderBy: { providerId: "asc" }, + }) + const windowHours = Array.from(new Set([ + 24, + ...profiles.map((profile) => Math.min(profile.windowHours, 720)), + ])) + const generatedAt = new Date() + const [credentials, groupedUsage] = await Promise.all([ + prisma.providerCredential.findMany({ + where: { userId }, + select: { providerId: true, updatedAt: true }, + }), + Promise.all(windowHours.map(async (hours) => { + const rows = await prisma.usageLog.groupBy({ + by: ["providerId", "modelId", "statusCode"], + where: { + createdAt: { gte: new Date(generatedAt.getTime() - hours * 60 * 60 * 1000) }, + userId, + }, + _count: { id: true }, + _sum: { costUsd: true, inputTokens: true, outputTokens: true }, + _min: { createdAt: true }, + _max: { createdAt: true }, + }) + return rows.map((row) => ({ + providerId: row.providerId, + modelId: row.modelId, + statusCode: row.statusCode, + requests: row._count.id, + tokens: (row._sum.inputTokens ?? 0) + (row._sum.outputTokens ?? 0), + costUsd: row._sum.costUsd ?? 0, + oldestAt: row._min.createdAt, + lastAt: row._max.createdAt, + windowHours: hours, + })) + })), + ]) + const logs = groupedUsage.flat() + + return c.json({ + accounts: buildProviderQuotaAccounts({ + credentials, + logs, + profiles, + }), + generatedAt: generatedAt.toISOString(), + }) +}) + +app.patch("/quotas/:providerId", async (c) => { + const userId = requireAuth(c) + if (typeof userId !== "string") return userId + + const providerId = c.req.param("providerId").trim() + if (!/^[a-zA-Z0-9._-]{1,64}$/.test(providerId)) { + return jsonErrorResponse(400, "Invalid provider id") + } + const body = await c.req.json().catch(() => ({})) + const parsed = providerQuotaSchema.safeParse(body) + if (!parsed.success) return jsonErrorResponse(400, "Invalid quota profile") + + const profile = await prisma.providerQuota.upsert({ + where: { userId_providerId: { providerId, userId } }, + create: { ...parsed.data, providerId, userId }, + update: parsed.data, + }) + + return c.json({ + profile: { + ...profile, + createdAt: profile.createdAt.toISOString(), + updatedAt: profile.updatedAt.toISOString(), + }, + }) +}) + +app.delete("/quotas/:providerId", async (c) => { + const userId = requireAuth(c) + if (typeof userId !== "string") return userId + + const providerId = c.req.param("providerId").trim() + await prisma.providerQuota.deleteMany({ where: { providerId, userId } }) + return c.json({ success: true }) +}) + // GET /user/routing-config app.get("/routing-config", async (c) => { const userId = requireAuth(c) diff --git a/server/tests/user-routes.test.ts b/server/tests/user-routes.test.ts index 61c26d6..400fb94 100644 --- a/server/tests/user-routes.test.ts +++ b/server/tests/user-routes.test.ts @@ -3,6 +3,7 @@ const mockPrisma = { apiKey: { findMany: vi.fn(), findFirst: vi.fn(), create: vi.fn(), update: vi.fn(), count: vi.fn() }, providerCredential: { findMany: vi.fn(), findFirst: vi.fn(), upsert: vi.fn(), delete: vi.fn() }, + providerQuota: { findMany: vi.fn(), upsert: vi.fn(), deleteMany: vi.fn() }, usageLog: { count: vi.fn(), groupBy: vi.fn(), findMany: vi.fn() }, user: { findUnique: vi.fn() }, userSettings: { findUnique: vi.fn(), upsert: vi.fn() }, @@ -155,6 +156,67 @@ describe("GET /user/usage/recent", () => { }); }); +describe("provider quota routes", () => { + it("returns configured accounts and observed usage", async () => { + mockPrisma.providerQuota.findMany.mockResolvedValue([]); + mockPrisma.providerCredential.findMany.mockResolvedValue([ + { providerId: "groq", updatedAt: new Date("2026-08-20T10:00:00.000Z") }, + ]); + mockPrisma.usageLog.groupBy.mockResolvedValue([]); + + const res = await mkApp().request("/user/quotas", { headers: AUTH }); + const body = await res.json(); + + expect(res.status).toBe(200); + expect(body.accounts[0]).toMatchObject({ + providerId: "groq", + percentage: null, + status: "monitoring", + }); + }); + + it("validates and persists a provider quota profile", async () => { + const now = new Date("2026-08-20T10:00:00.000Z"); + mockPrisma.providerQuota.upsert.mockResolvedValue({ + id: "quota-1", + userId: UID, + providerId: "groq", + label: "Conta principal", + isEnabled: true, + windowHours: 24, + requestLimit: 1000, + tokenLimit: null, + costLimitUsd: null, + createdAt: now, + updatedAt: now, + }); + + const res = await mkApp().request("/user/quotas/groq", { + method: "PATCH", + headers: { ...AUTH, "Content-Type": "application/json" }, + body: JSON.stringify({ label: "Conta principal", requestLimit: 1000, windowHours: 24 }), + }); + + expect(res.status).toBe(200); + expect(mockPrisma.providerQuota.upsert).toHaveBeenCalledWith( + expect.objectContaining({ + where: { userId_providerId: { providerId: "groq", userId: UID } }, + }), + ); + }); + + it("rejects invalid quota limits", async () => { + const res = await mkApp().request("/user/quotas/groq", { + method: "PATCH", + headers: { ...AUTH, "Content-Type": "application/json" }, + body: JSON.stringify({ requestLimit: -1 }), + }); + + expect(res.status).toBe(400); + expect(mockPrisma.providerQuota.upsert).not.toHaveBeenCalled(); + }); +}); + describe("PATCH /user/settings", () => { it("preserva instrucoes omitidas ao atualizar apenas a cor de destaque", async () => { mockPrisma.userSettings.upsert.mockResolvedValue({