Providers/Modelos reaproveitam os endpoints /ai/providers e /ai/models já existentes (só filtram scope GLOBAL na tela); Modelos expõe custo unitário (inputCost/outputCost/audioCost) que a tela de tenant nunca mostra, porque só platform precisa cadastrar preço. GET /platform/ai-usage (novo) agrega uso bruto de AIUsageRecord de todos os tenants no mês corrente (Uso) e estima custo casando cada registro com o AIModel correspondente (Custos) — quando não dá pra casar, o registro fica de fora da soma e o tenant é marcado costIncomplete, nunca um número inventado. Achado real de autorização ao revisar AIModelsController antes de construir a tela de Modelos: POST/DELETE /ai/models não checava isPlatformUser quando o alvo era scope=GLOBAL — como a RLS híbrida (OR tenant_id IS NULL) deixa qualquer tenant ENXERGAR um provider/modelo GLOBAL, qualquer Tenant Admin com `ai.manage` (permission de escopo TENANT) conseguia injetar um modelo no catálogo global ou desabilitar um modelo GLOBAL só sabendo o id. O endpoint irmão (AIProvidersController) já tinha o check certo; corrigido com o mesmo padrão. Confirmado com um teste de ataque real: 403 depois do fix (era 201/sucesso antes), com regressão confirmando que BYOK do próprio tenant continua funcionando normalmente. Testado ponta a ponta: provider+modelo GLOBAL com custo real, uso de teste inserido direto no Postgres, /platform/ai-usage devolvendo o valor exato esperado (bate com a conta manual), tudo removido no final. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01BFaBaBSQGhyXGEgtTYZGV8
53 lines
2.2 KiB
TypeScript
53 lines
2.2 KiB
TypeScript
import { Panel, PanelHeader } from "@/components/ui/panel";
|
|
import { EmptyState, TBody, TD, TH, THead, TR, Table } from "@/components/ui/table";
|
|
import { formatDuration, formatInt } from "@/lib/format";
|
|
import type { TenantAiUsage } from "@/lib/platform-types";
|
|
|
|
export function UsoView({ usage }: { usage: TenantAiUsage[] }) {
|
|
const withUsage = usage.filter(
|
|
(u) => u.usage.transcriptionSeconds > 0 || u.usage.analysisRequests > 0 || u.usage.inputTokens > 0 || u.usage.outputTokens > 0,
|
|
);
|
|
|
|
return (
|
|
<div className="space-y-5">
|
|
<div>
|
|
<h1 className="text-lg font-semibold text-foreground">IA — Uso</h1>
|
|
<p className="mt-1 max-w-2xl text-sm text-muted-foreground">
|
|
Uso bruto de IA de todos os tenants no mês corrente (agente.md secao 124, ledger{" "}
|
|
<code className="font-mono text-xs">AIUsageRecord</code>) — quantidade, não custo (isso é IA > Custos).
|
|
</p>
|
|
</div>
|
|
|
|
<Panel>
|
|
<PanelHeader title="Uso por tenant" description={`${withUsage.length} de ${usage.length} tenant(s) com uso este mês`} />
|
|
{usage.length === 0 ? (
|
|
<EmptyState title="Nenhum tenant" description="Nenhum tenant cadastrado ainda." />
|
|
) : (
|
|
<Table>
|
|
<THead>
|
|
<TR>
|
|
<TH>Tenant</TH>
|
|
<TH>Transcrição</TH>
|
|
<TH>Análises</TH>
|
|
<TH>Tokens de entrada</TH>
|
|
<TH>Tokens de saída</TH>
|
|
</TR>
|
|
</THead>
|
|
<TBody>
|
|
{usage.map((u) => (
|
|
<TR key={u.tenantId}>
|
|
<TD className="text-foreground">{u.legalName}</TD>
|
|
<TD className="font-mono tabular-nums text-muted-foreground">{formatDuration(u.usage.transcriptionSeconds)}</TD>
|
|
<TD className="font-mono tabular-nums text-muted-foreground">{formatInt(u.usage.analysisRequests)}</TD>
|
|
<TD className="font-mono tabular-nums text-muted-foreground">{formatInt(u.usage.inputTokens)}</TD>
|
|
<TD className="font-mono tabular-nums text-muted-foreground">{formatInt(u.usage.outputTokens)}</TD>
|
|
</TR>
|
|
))}
|
|
</TBody>
|
|
</Table>
|
|
)}
|
|
</Panel>
|
|
</div>
|
|
);
|
|
}
|