From edc116f173f7d9c762431456432b4f340e8fccfc Mon Sep 17 00:00:00 2001 From: Paulo Carinhena <63272749+paulocarinhena@users.noreply.github.com> Date: Thu, 12 Nov 2026 19:30:48 -0300 Subject: [PATCH] Limita o uso da chave de IA compartilhada e o tamanho do chat. MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Resumo, tradução, pergunta, áudio e chat passam a ter cota por usuário quando usam a chave da instância. Co-authored-by: Cursor --- src/app/api/ai/ask/route.ts | 2 ++ src/app/api/ai/chat/route.ts | 27 +++++++++++++++++++++------ src/app/api/ai/speech/route.ts | 2 ++ src/app/api/ai/summarize/route.ts | 2 ++ src/app/api/ai/translate/route.ts | 2 ++ src/lib/ai/quota.ts | 27 +++++++++++++++++++++++++++ 6 files changed, 56 insertions(+), 6 deletions(-) create mode 100644 src/lib/ai/quota.ts diff --git a/src/app/api/ai/ask/route.ts b/src/app/api/ai/ask/route.ts index c1bf89f..d71e50a 100644 --- a/src/app/api/ai/ask/route.ts +++ b/src/app/api/ai/ask/route.ts @@ -6,6 +6,7 @@ import { getApiUser } from "@/lib/session"; import { getUserSettings } from "@/lib/app-settings"; import { resolveModel } from "@/lib/ai/providers"; import { articleText, errorMessage, languageInstruction } from "@/lib/ai/content"; +import { aiQuotaResponse, allowSharedAiCall } from "@/lib/ai/quota"; import { accessibleArticles } from "@/lib/article-state"; const MAX_HISTORY = 10; @@ -39,6 +40,7 @@ export async function POST(request: Request) { } catch (err) { return Response.json({ error: errorMessage(err, await getLocale()) }, { status: 400 }); } + if (!allowSharedAiCall(user.id, resolved.provider.userId == null)) return aiQuotaResponse(); const result = streamText({ model: resolved.model, diff --git a/src/app/api/ai/chat/route.ts b/src/app/api/ai/chat/route.ts index 6dda702..071e93e 100644 --- a/src/app/api/ai/chat/route.ts +++ b/src/app/api/ai/chat/route.ts @@ -11,12 +11,16 @@ import { articleText, errorMessage, languageInstruction } from "@/lib/ai/content import { parseJsonArray, truncate } from "@/lib/utils"; import { acquireLock } from "@/lib/jobs/lock"; import { semanticSearch } from "@/lib/ai/embeddings"; +import { aiQuotaResponse, allowSharedAiCall } from "@/lib/ai/quota"; export const maxDuration = 120; +const MAX_CHAT_MESSAGES = 40; +const MAX_CHAT_CHARS = 100_000; + const body = z.object({ threadId: z.string(), - messages: z.array(z.custom((m) => typeof m === "object" && m !== null && "role" in m)), + messages: z.array(z.custom((m) => typeof m === "object" && m !== null && "role" in m)).max(500), // Escolhas do compositor (opcionais): provedor, modelo e nível de raciocínio. providerId: z.string().optional(), model: z.string().max(200).optional(), @@ -29,7 +33,11 @@ export async function POST(request: Request) { const parsed = body.safeParse(await request.json().catch(() => null)); if (!parsed.success) return new Response("Bad request", { status: 400 }); - const { threadId, messages, providerId, model, reasoning } = parsed.data; + const { threadId, providerId, model, reasoning } = parsed.data; + const messages = parsed.data.messages + .filter((message) => message.role === "user" || message.role === "assistant" || message.role === "system") + .slice(-MAX_CHAT_MESSAGES); + if (JSON.stringify(messages).length > MAX_CHAT_CHARS) return new Response("Payload too large", { status: 413 }); const thread = await db.chatThread.findFirst({ where: { id: threadId, userId: user.id } }); if (!thread) return new Response("Not found", { status: 404 }); @@ -43,11 +51,18 @@ export async function POST(request: Request) { return Response.json({ error: errorMessage(err, locale) }, { status: 400 }); } + if (!allowSharedAiCall(user.id, resolved.provider.userId == null)) return aiQuotaResponse(); + const subscribed = { feed: { subscriptions: { some: { userId: user.id } } } }; - // Artigos da conversa: assinados ou salvos (links avulsos salvos não têm assinatura). - const contextArticles = await db.article.findMany({ - where: { id: { in: parseJsonArray(thread.articleIds) }, ...accessibleArticles(user.id) }, - take: 5, + // Artigos da conversa, na ordem em que foram anexados (no máximo cinco). + const contextIds = parseJsonArray(thread.articleIds).slice(0, 5); + const contextRows = contextIds.length + ? await db.article.findMany({ where: { id: { in: contextIds }, ...accessibleArticles(user.id) } }) + : []; + const contextById = new Map(contextRows.map((article) => [article.id, article])); + const contextArticles = contextIds.flatMap((id) => { + const article = contextById.get(id); + return article ? [article] : []; }); const tools = { diff --git a/src/app/api/ai/speech/route.ts b/src/app/api/ai/speech/route.ts index eb93666..7947950 100644 --- a/src/app/api/ai/speech/route.ts +++ b/src/app/api/ai/speech/route.ts @@ -7,6 +7,7 @@ import { getApiUser } from "@/lib/session"; import { articleText, errorMessage } from "@/lib/ai/content"; import { localizeError, LocalizedError } from "@/lib/localized-error"; import { mergeAudioParts, requestSpeech, resolveTtsProvider, ttsMimeType } from "@/lib/ai/tts"; +import { aiQuotaResponse, allowSharedAiCall } from "@/lib/ai/quota"; const body = z.object({ articleId: z.string(), @@ -28,6 +29,7 @@ export async function POST(request: Request) { try { const provider = await resolveTtsProvider(user.id); + if (!allowSharedAiCall(user.id, provider.userId == null)) return aiQuotaResponse(); const speechText = parsed.data.source === "summary" ? parsed.data.text! .replace(/\[([^\]]+)]\([^)]+\)/g, "$1") diff --git a/src/app/api/ai/summarize/route.ts b/src/app/api/ai/summarize/route.ts index c8e8a9b..d730882 100644 --- a/src/app/api/ai/summarize/route.ts +++ b/src/app/api/ai/summarize/route.ts @@ -7,6 +7,7 @@ import { getApiUser } from "@/lib/session"; import { getUserSettings } from "@/lib/app-settings"; import { resolveModel } from "@/lib/ai/providers"; import { answerInLanguage, articleText, errorMessage, languageInstruction } from "@/lib/ai/content"; +import { aiQuotaResponse, allowSharedAiCall } from "@/lib/ai/quota"; const body = z.object({ articleId: z.string(), force: z.boolean().optional() }); @@ -30,6 +31,7 @@ export async function POST(request: Request) { } catch (err) { return Response.json({ error: errorMessage(err, await getLocale()) }, { status: 400 }); } + if (!allowSharedAiCall(user.id, resolved.provider.userId == null)) return aiQuotaResponse(); const cacheKey = { articleId, diff --git a/src/app/api/ai/translate/route.ts b/src/app/api/ai/translate/route.ts index 8154ba0..83890ac 100644 --- a/src/app/api/ai/translate/route.ts +++ b/src/app/api/ai/translate/route.ts @@ -8,6 +8,7 @@ import { getUserSettings } from "@/lib/app-settings"; import { resolveModel } from "@/lib/ai/providers"; import { sanitizeArticleHtml } from "@/lib/feeds/sanitize"; import { articleHtml, errorMessage, languageInstruction, languageName } from "@/lib/ai/content"; +import { aiQuotaResponse, allowSharedAiCall } from "@/lib/ai/quota"; const body = z.object({ articleId: z.string(), force: z.boolean().optional() }); @@ -31,6 +32,7 @@ export async function POST(request: Request) { } catch (err) { return Response.json({ error: errorMessage(err, await getLocale()) }, { status: 400 }); } + if (!allowSharedAiCall(user.id, resolved.provider.userId == null)) return aiQuotaResponse(); const cacheKey = { articleId, diff --git a/src/lib/ai/quota.ts b/src/lib/ai/quota.ts new file mode 100644 index 0000000..3f87762 --- /dev/null +++ b/src/lib/ai/quota.ts @@ -0,0 +1,27 @@ +/** Cota por usuário quando a chamada usa a chave da instância, não a chave dele. */ +const WINDOW_MS = 10 * 60_000; +const MAX_CALLS = 30; + +const calls = new Map(); + +export function allowSharedAiCall(userId: string, usesSharedKey: boolean): boolean { + if (!usesSharedKey) return true; + const now = Date.now(); + const recent = (calls.get(userId) ?? []).filter((at) => now - at < WINDOW_MS); + if (recent.length >= MAX_CALLS) { + calls.set(userId, recent); + return false; + } + recent.push(now); + calls.set(userId, recent); + if (calls.size > 5000) { + for (const [key, stamps] of calls) { + if (stamps.every((at) => now - at >= WINDOW_MS)) calls.delete(key); + } + } + return true; +} + +export function aiQuotaResponse() { + return new Response("Too many requests", { status: 429, headers: { "retry-after": "60" } }); +}