feat(secretaria): monitor de cota dos providers + notificação ao admin

- brain: detecta 429/cota (quota/billing/insufficient/exceeded/limite da api/
  rate limit — inclui a msg PT do Gemini) e emite ext:provider.exhausted por
  provider da chain (throttle de 6h por provider), com fallback informado.
- routes: handler ext:provider.exhausted monta notificação profissional e envia
  por WhatsApp ao admin (admin_notify_phone da config secretaria), resolvendo o
  JID canônico via onWhatsApp (trata 9º dígito BR; pula se número não existe).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
VPS 4 Deploy Agent
2026-07-02 07:49:00 +02:00
parent aa085ce256
commit 6d9850d8d3
6 changed files with 172 additions and 4 deletions
@@ -12,13 +12,26 @@ Object.defineProperty(exports, "__esModule", { value: true });
exports.ProtocolEngine = void 0;
const tools_1 = require("./tools");
const embeddings_1 = require("./embeddings");
// ── Monitor de cota dos providers ────────────────────────────────────────────
// Detecta esgotamento de cota/crédito (429 quota/billing) e limita a frequência
// da notificação ao admin (uma vez a cada 6h por provider).
const QUOTA_NOTIFY_THROTTLE_MS = 6 * 60 * 60 * 1000;
const quotaNotifiedAt = new Map();
function isQuotaError(err) {
const m = (err?.message ?? '').toLowerCase();
return m.includes('quota') || m.includes('billing') || m.includes('insufficient')
|| m.includes('exceeded') || m.includes('limite da api') || m.includes('rate limit');
}
class ProtocolEngine {
constructor(db, config) {
this.db = db;
this.config = config;
// Contexto para notificações (setado no chat) — usado ao detectar cota esgotada.
this.notifyCtx = {};
}
// ── Chat ─────────────────────────────────────────────────────────────────
async chat(conversationId, userMessage, opts) {
this.notifyCtx = { hooks: opts?.hooks, tenantId: opts?.tenantId };
const conversation = await this.db('sec_conversations').where({ id: conversationId }).first();
if (!conversation)
throw new Error('Conversa não encontrada');
@@ -515,12 +528,19 @@ class ProtocolEngine {
const cfg = await this.config.get('secretaria');
const chain = this.buildFallbackChain(agent, cfg);
let lastError = new Error('Nenhum provider disponível');
for (const entry of chain) {
for (let i = 0; i < chain.length; i++) {
const entry = chain[i];
try {
return await this.callProvider(entry.provider, entry.model, agent, cfg, systemPrompt, messages);
}
catch (err) {
lastError = err;
// Cota/crédito do provider esgotou → notifica o admin (throttle) e segue
// para o próximo da chain.
if (isQuotaError(err)) {
const fallbackTo = chain.slice(i + 1).map((e) => e.provider);
this.notifyProviderExhausted(entry.provider, entry.model, err.message, fallbackTo);
}
if (this.isRecoverableError(err))
continue;
throw err;
@@ -528,6 +548,18 @@ class ProtocolEngine {
}
throw new Error(`Todos os providers falharam. Último erro: ${lastError.message}`);
}
// Notifica (best-effort, com throttle por provider) que a cota/crédito esgotou.
// Emite ext:provider.exhausted — o ext-api entrega ao admin via WhatsApp.
notifyProviderExhausted(provider, model, detail, fallbackTo) {
const now = Date.now();
if (now - (quotaNotifiedAt.get(provider) ?? 0) < QUOTA_NOTIFY_THROTTLE_MS)
return;
quotaNotifiedAt.set(provider, now);
this.notifyCtx.hooks?.emit('ext:provider.exhausted', {
tenantId: this.notifyCtx.tenantId,
provider, model, detail, fallbackTo, at: new Date().toISOString(),
}).catch(() => { });
}
/**
* Chama o provider e retorna { text, usage, provider, model }.
* usage: { input, output, cached?, total } — chars/tokens consumidos.