feat(frontend): Platform > IA > Providers/Modelos/Uso/Custos + achado real de autorização
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
This commit is contained in:
10
apps/frontend/src/app/platform/ia/uso/page.tsx
Normal file
10
apps/frontend/src/app/platform/ia/uso/page.tsx
Normal file
@@ -0,0 +1,10 @@
|
||||
import { requireSession } from "@/lib/session";
|
||||
import { apiFetch } from "@/lib/api";
|
||||
import type { TenantAiUsage } from "@/lib/platform-types";
|
||||
import { UsoView } from "./uso-view";
|
||||
|
||||
export default async function UsoPage() {
|
||||
const session = await requireSession();
|
||||
const usage = await apiFetch<TenantAiUsage[]>("/platform/ai-usage", session.accessToken);
|
||||
return <UsoView usage={usage} />;
|
||||
}
|
||||
52
apps/frontend/src/app/platform/ia/uso/uso-view.tsx
Normal file
52
apps/frontend/src/app/platform/ia/uso/uso-view.tsx
Normal file
@@ -0,0 +1,52 @@
|
||||
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>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user