+
+
+
+
+
+
+
+
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
+
+
+
+ )
+ })}
+
+ )}
+
+
+
+ )
+}
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 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({