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:
@@ -182,6 +182,60 @@ function buildExtRoutes(prisma, manager, db, config, hooks, io) {
|
|||||||
}
|
}
|
||||||
catch { /* notificação é best-effort */ }
|
catch { /* notificação é best-effort */ }
|
||||||
});
|
});
|
||||||
|
// ── Monitor de cota — provedor LLM sem saldo → notifica o admin ────────────
|
||||||
|
// Emitido pelo brain quando um provider retorna 429 (cota/crédito esgotado).
|
||||||
|
const PROVIDER_LABEL = {
|
||||||
|
openai: 'OpenAI', gemini: 'Google Gemini', anthropic: 'Anthropic', ollama: 'Ollama (local)',
|
||||||
|
};
|
||||||
|
hooks.register('ext:provider.exhausted', async (data) => {
|
||||||
|
try {
|
||||||
|
const prov = PROVIDER_LABEL[data.provider] ?? data.provider;
|
||||||
|
const fb = Array.isArray(data.fallbackTo) && data.fallbackTo.length
|
||||||
|
? data.fallbackTo.map((p) => PROVIDER_LABEL[p] ?? p).join(' → ')
|
||||||
|
: 'nenhum (todos esgotados)';
|
||||||
|
const when = new Date(data.at ?? Date.now()).toLocaleString('pt-BR', { dateStyle: 'short', timeStyle: 'short' });
|
||||||
|
const msg = [
|
||||||
|
'🔴 *Secretária IA — provedor sem saldo*',
|
||||||
|
'',
|
||||||
|
`O provedor *${prov}*${data.model ? ` (${data.model})` : ''} atingiu o limite de *cota/crédito*.`,
|
||||||
|
'▫️ Status: cota/billing esgotado (429)',
|
||||||
|
`▫️ Fallback em uso: ${fb}`,
|
||||||
|
'',
|
||||||
|
'⚠️ *Ação necessária:* recarregue o crédito da conta do provedor para manter o atendimento automático 24h.',
|
||||||
|
'',
|
||||||
|
`🕒 ${when}`,
|
||||||
|
'_NewWhats · monitor de provedores de IA_',
|
||||||
|
].join('\n');
|
||||||
|
console.warn(`[secretaria] provider sem saldo: ${prov} (${data.model ?? '—'}) — fallback: ${fb}`);
|
||||||
|
const secCfg = (await config.get('secretaria'));
|
||||||
|
const adminPhone = typeof secCfg?.admin_notify_phone === 'string' ? secCfg.admin_notify_phone.trim() : '';
|
||||||
|
if (!adminPhone)
|
||||||
|
return;
|
||||||
|
const instance = await prisma.instance.findFirst({ where: { tenantId: data.tenantId }, select: { id: true } });
|
||||||
|
if (!instance)
|
||||||
|
return;
|
||||||
|
const sock = manager?.getSocket(instance.id);
|
||||||
|
if (!sock)
|
||||||
|
return;
|
||||||
|
// Resolve o JID canônico via onWhatsApp — trata o 9º dígito (BR) e valida
|
||||||
|
// se o número está no WhatsApp; sem isso, a concatenação crua pode não
|
||||||
|
// corresponder ao JID real (mensagem "enviada" mas não entregue).
|
||||||
|
const cand = (() => { const d = adminPhone.replace(/\D/g, ''); return d.startsWith('55') ? d : '55' + d; })();
|
||||||
|
let jid = `${cand}@s.whatsapp.net`;
|
||||||
|
try {
|
||||||
|
const found = await sock.onWhatsApp(cand);
|
||||||
|
if (found?.[0]?.exists && found[0]?.jid)
|
||||||
|
jid = found[0].jid;
|
||||||
|
else {
|
||||||
|
console.warn(`[secretaria] admin ${cand} não está no WhatsApp — notificação não enviada`);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
catch { /* fallback: usa o jid montado */ }
|
||||||
|
await sock.sendMessage(jid, { text: msg });
|
||||||
|
}
|
||||||
|
catch { /* best-effort */ }
|
||||||
|
});
|
||||||
// ── GET /sessions ──────────────────────────────────────────────────────────
|
// ── GET /sessions ──────────────────────────────────────────────────────────
|
||||||
// Lista todas as instâncias do tenant com status e telefone.
|
// Lista todas as instâncias do tenant com status e telefone.
|
||||||
router.get('/sessions', async (req, res) => {
|
router.get('/sessions', async (req, res) => {
|
||||||
|
|||||||
File diff suppressed because one or more lines are too long
@@ -204,6 +204,54 @@ export function buildExtRoutes(
|
|||||||
} catch { /* notificação é best-effort */ }
|
} catch { /* notificação é best-effort */ }
|
||||||
})
|
})
|
||||||
|
|
||||||
|
// ── Monitor de cota — provedor LLM sem saldo → notifica o admin ────────────
|
||||||
|
// Emitido pelo brain quando um provider retorna 429 (cota/crédito esgotado).
|
||||||
|
const PROVIDER_LABEL: Record<string, string> = {
|
||||||
|
openai: 'OpenAI', gemini: 'Google Gemini', anthropic: 'Anthropic', ollama: 'Ollama (local)',
|
||||||
|
}
|
||||||
|
hooks.register('ext:provider.exhausted', async (data: any) => {
|
||||||
|
try {
|
||||||
|
const prov = PROVIDER_LABEL[data.provider] ?? data.provider
|
||||||
|
const fb = Array.isArray(data.fallbackTo) && data.fallbackTo.length
|
||||||
|
? data.fallbackTo.map((p: string) => PROVIDER_LABEL[p] ?? p).join(' → ')
|
||||||
|
: 'nenhum (todos esgotados)'
|
||||||
|
const when = new Date(data.at ?? Date.now()).toLocaleString('pt-BR', { dateStyle: 'short', timeStyle: 'short' })
|
||||||
|
const msg = [
|
||||||
|
'🔴 *Secretária IA — provedor sem saldo*',
|
||||||
|
'',
|
||||||
|
`O provedor *${prov}*${data.model ? ` (${data.model})` : ''} atingiu o limite de *cota/crédito*.`,
|
||||||
|
'▫️ Status: cota/billing esgotado (429)',
|
||||||
|
`▫️ Fallback em uso: ${fb}`,
|
||||||
|
'',
|
||||||
|
'⚠️ *Ação necessária:* recarregue o crédito da conta do provedor para manter o atendimento automático 24h.',
|
||||||
|
'',
|
||||||
|
`🕒 ${when}`,
|
||||||
|
'_NewWhats · monitor de provedores de IA_',
|
||||||
|
].join('\n')
|
||||||
|
|
||||||
|
console.warn(`[secretaria] provider sem saldo: ${prov} (${data.model ?? '—'}) — fallback: ${fb}`)
|
||||||
|
|
||||||
|
const secCfg = (await config.get('secretaria')) as any
|
||||||
|
const adminPhone = typeof secCfg?.admin_notify_phone === 'string' ? secCfg.admin_notify_phone.trim() : ''
|
||||||
|
if (!adminPhone) return
|
||||||
|
const instance = await prisma.instance.findFirst({ where: { tenantId: data.tenantId }, select: { id: true } })
|
||||||
|
if (!instance) return
|
||||||
|
const sock = manager?.getSocket(instance.id)
|
||||||
|
if (!sock) return
|
||||||
|
// Resolve o JID canônico via onWhatsApp — trata o 9º dígito (BR) e valida
|
||||||
|
// se o número está no WhatsApp; sem isso, a concatenação crua pode não
|
||||||
|
// corresponder ao JID real (mensagem "enviada" mas não entregue).
|
||||||
|
const cand = (() => { const d = adminPhone.replace(/\D/g, ''); return d.startsWith('55') ? d : '55' + d })()
|
||||||
|
let jid = `${cand}@s.whatsapp.net`
|
||||||
|
try {
|
||||||
|
const found = await sock.onWhatsApp(cand)
|
||||||
|
if (found?.[0]?.exists && found[0]?.jid) jid = found[0].jid
|
||||||
|
else { console.warn(`[secretaria] admin ${cand} não está no WhatsApp — notificação não enviada`); return }
|
||||||
|
} catch { /* fallback: usa o jid montado */ }
|
||||||
|
await sock.sendMessage(jid, { text: msg })
|
||||||
|
} catch { /* best-effort */ }
|
||||||
|
})
|
||||||
|
|
||||||
// ── GET /sessions ──────────────────────────────────────────────────────────
|
// ── GET /sessions ──────────────────────────────────────────────────────────
|
||||||
// Lista todas as instâncias do tenant com status e telefone.
|
// Lista todas as instâncias do tenant com status e telefone.
|
||||||
router.get('/sessions', async (req: Request, res: Response) => {
|
router.get('/sessions', async (req: Request, res: Response) => {
|
||||||
|
|||||||
@@ -12,13 +12,26 @@ Object.defineProperty(exports, "__esModule", { value: true });
|
|||||||
exports.ProtocolEngine = void 0;
|
exports.ProtocolEngine = void 0;
|
||||||
const tools_1 = require("./tools");
|
const tools_1 = require("./tools");
|
||||||
const embeddings_1 = require("./embeddings");
|
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 {
|
class ProtocolEngine {
|
||||||
constructor(db, config) {
|
constructor(db, config) {
|
||||||
this.db = db;
|
this.db = db;
|
||||||
this.config = config;
|
this.config = config;
|
||||||
|
// Contexto para notificações (setado no chat) — usado ao detectar cota esgotada.
|
||||||
|
this.notifyCtx = {};
|
||||||
}
|
}
|
||||||
// ── Chat ─────────────────────────────────────────────────────────────────
|
// ── Chat ─────────────────────────────────────────────────────────────────
|
||||||
async chat(conversationId, userMessage, opts) {
|
async chat(conversationId, userMessage, opts) {
|
||||||
|
this.notifyCtx = { hooks: opts?.hooks, tenantId: opts?.tenantId };
|
||||||
const conversation = await this.db('sec_conversations').where({ id: conversationId }).first();
|
const conversation = await this.db('sec_conversations').where({ id: conversationId }).first();
|
||||||
if (!conversation)
|
if (!conversation)
|
||||||
throw new Error('Conversa não encontrada');
|
throw new Error('Conversa não encontrada');
|
||||||
@@ -515,12 +528,19 @@ class ProtocolEngine {
|
|||||||
const cfg = await this.config.get('secretaria');
|
const cfg = await this.config.get('secretaria');
|
||||||
const chain = this.buildFallbackChain(agent, cfg);
|
const chain = this.buildFallbackChain(agent, cfg);
|
||||||
let lastError = new Error('Nenhum provider disponível');
|
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 {
|
try {
|
||||||
return await this.callProvider(entry.provider, entry.model, agent, cfg, systemPrompt, messages);
|
return await this.callProvider(entry.provider, entry.model, agent, cfg, systemPrompt, messages);
|
||||||
}
|
}
|
||||||
catch (err) {
|
catch (err) {
|
||||||
lastError = 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))
|
if (this.isRecoverableError(err))
|
||||||
continue;
|
continue;
|
||||||
throw err;
|
throw err;
|
||||||
@@ -528,6 +548,18 @@ class ProtocolEngine {
|
|||||||
}
|
}
|
||||||
throw new Error(`Todos os providers falharam. Último erro: ${lastError.message}`);
|
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 }.
|
* Chama o provider e retorna { text, usage, provider, model }.
|
||||||
* usage: { input, output, cached?, total } — chars/tokens consumidos.
|
* usage: { input, output, cached?, total } — chars/tokens consumidos.
|
||||||
|
|||||||
File diff suppressed because one or more lines are too long
@@ -14,12 +14,26 @@ import type { HookBus } from '../../backend/src/core/hook-bus'
|
|||||||
import { type ToolDef, type ToolContext, resolveTools, ALL_TOOL_NAMES } from './tools'
|
import { type ToolDef, type ToolContext, resolveTools, ALL_TOOL_NAMES } from './tools'
|
||||||
import { embed, cosineSimilarity, chunkText, hashText, hasEmbeddingKey } from './embeddings'
|
import { embed, cosineSimilarity, chunkText, hashText, hasEmbeddingKey } from './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<string, number>()
|
||||||
|
function isQuotaError(err: { message?: string }): boolean {
|
||||||
|
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')
|
||||||
|
}
|
||||||
|
|
||||||
export class ProtocolEngine {
|
export class ProtocolEngine {
|
||||||
constructor(
|
constructor(
|
||||||
private readonly db: Knex,
|
private readonly db: Knex,
|
||||||
private readonly config: PluginConfigStore,
|
private readonly config: PluginConfigStore,
|
||||||
) {}
|
) {}
|
||||||
|
|
||||||
|
// Contexto para notificações (setado no chat) — usado ao detectar cota esgotada.
|
||||||
|
private notifyCtx: { hooks?: HookBus; tenantId?: string } = {}
|
||||||
|
|
||||||
// ── Chat ─────────────────────────────────────────────────────────────────
|
// ── Chat ─────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
async chat(
|
async chat(
|
||||||
@@ -34,6 +48,7 @@ export class ProtocolEngine {
|
|||||||
clinicaId?: string // clínica do canal/workspace (escopo da ponte de agenda)
|
clinicaId?: string // clínica do canal/workspace (escopo da ponte de agenda)
|
||||||
},
|
},
|
||||||
): Promise<string> {
|
): Promise<string> {
|
||||||
|
this.notifyCtx = { hooks: opts?.hooks, tenantId: opts?.tenantId }
|
||||||
const conversation = await this.db('sec_conversations').where({ id: conversationId }).first()
|
const conversation = await this.db('sec_conversations').where({ id: conversationId }).first()
|
||||||
if (!conversation) throw new Error('Conversa não encontrada')
|
if (!conversation) throw new Error('Conversa não encontrada')
|
||||||
|
|
||||||
@@ -539,11 +554,18 @@ export class ProtocolEngine {
|
|||||||
|
|
||||||
let lastError: Error = new Error('Nenhum provider disponível')
|
let lastError: Error = new Error('Nenhum provider disponível')
|
||||||
|
|
||||||
for (const entry of chain) {
|
for (let i = 0; i < chain.length; i++) {
|
||||||
|
const entry = chain[i]!
|
||||||
try {
|
try {
|
||||||
return await this.callProvider(entry.provider, entry.model, agent, cfg, systemPrompt, messages)
|
return await this.callProvider(entry.provider, entry.model, agent, cfg, systemPrompt, messages)
|
||||||
} catch (err: any) {
|
} catch (err: any) {
|
||||||
lastError = 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
|
if (this.isRecoverableError(err)) continue
|
||||||
throw err
|
throw err
|
||||||
}
|
}
|
||||||
@@ -552,6 +574,18 @@ export class ProtocolEngine {
|
|||||||
throw new Error(`Todos os providers falharam. Último erro: ${lastError.message}`)
|
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.
|
||||||
|
private notifyProviderExhausted(provider: string, model: string, detail: string, fallbackTo: string[]): void {
|
||||||
|
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 }.
|
* Chama o provider e retorna { text, usage, provider, model }.
|
||||||
* usage: { input, output, cached?, total } — chars/tokens consumidos.
|
* usage: { input, output, cached?, total } — chars/tokens consumidos.
|
||||||
|
|||||||
Reference in New Issue
Block a user