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:
2026-08-30 08:53:35 -03:00
parent 12af12f276
commit cc80310cb5
20 changed files with 877 additions and 8 deletions

40
TODO.md
View File

@@ -1617,6 +1617,46 @@ Usuários (agente.md secao 169)
Puppeteer, mostrando a explicação por que falha aqui (mesmo texto Puppeteer, mostrando a explicação por que falha aqui (mesmo texto
já usado em Infraestrutura > Saúde) já usado em Infraestrutura > Saúde)
## PHASE 45 — Platform > IA > Providers/Modelos/Uso/Custos (agente.md
secao 96-103, 124, 169) + achado real de autorização em `/ai/models`
- [x] achado real (achado revisando `AIModelsController` antes de
escrever a tela de Modelos): `POST /ai/models` e `DELETE /ai/models/
:id` não checavam `isPlatformUser` quando o provider/modelo era
`scope=GLOBAL` — como a RLS híbrida de `ai_models`/`ai_providers`
(secao 100, `OR tenant_id IS NULL`) deixa qualquer tenant ENXERGAR
um provider GLOBAL, qualquer Tenant Admin com a permission
`ai.manage` (escopo TENANT) conseguia **injetar um modelo no
catálogo visível por todos os tenants**, ou **desabilitar** um
modelo GLOBAL só sabendo o id — mesma classe de escalação já
corrigida em `AgentsController` (PHASE 27) e já prevenida
corretamente em `AIProvidersController` (o irmão deste controller,
que já tinha o check certo). Corrigido com o mesmo padrão: 403
explícito quando o alvo é GLOBAL e quem chama não é platform.
Confirmado com um teste de ataque de verdade: tenant Acme tentando
anexar um modelo a um provider GLOBAL (403 depois do fix, 201 antes)
e apagar um modelo GLOBAL (403 depois, sucesso antes) — e um teste
de regressão confirmando que Acme continua livre pra gerenciar o
próprio BYOK
- [x] Platform > IA > Providers/Modelos reaproveitam os mesmos endpoints
`/ai/providers`/`/ai/models` já existentes (só filtram scope
GLOBAL na tela, backend já filtra por RLS+isPlatformUser); Modelos
expõe os campos de custo unitário (inputCost/outputCost/audioCost)
que a tela de tenant nunca mostra, porque só platform precisa
cadastrar preço
- [x] `GET /platform/ai-usage` (novo) — uso bruto de `AIUsageRecord` de
TODOS os tenants no mês corrente (Uso) + custo estimado casando
cada registro com o `AIModel` correspondente por
`providerId`+`externalModelId` (Custos). Quando não dá pra casar
(provider apagado, custo nunca cadastrado), aquele registro fica
de fora da soma e o tenant vem marcado `costIncomplete: true` —
nunca um número inventado (secao 138/233)
- [x] Testado ponta a ponta: criado provider GLOBAL + modelo com custo
real (US$0,00015/tokenin, US$0,0006/tokenout), inserido uso de
teste direto no Postgres (100k tokens in + 20k out), confirmado
`/platform/ai-usage` devolvendo US$27,00 exatos (bate com a conta
manual) e a tela de Custos mostrando o mesmo número — tudo removido
no final
--- ---
## Riscos conhecidos ## Riscos conhecidos

View File

@@ -1,6 +1,6 @@
import { Body, Controller, Delete, Get, HttpCode, HttpStatus, NotFoundException, Post, Param, UseGuards } from "@nestjs/common"; import { Body, Controller, Delete, ForbiddenException, Get, HttpCode, HttpStatus, NotFoundException, Post, Param, UseGuards } from "@nestjs/common";
import { getPrismaClient, withTenantContext, type Prisma } from "@b2bcall/database"; import { getPrismaClient, withTenantContext, type Prisma } from "@b2bcall/database";
import { recordAuditEvent, type AccessTokenClaims } from "@b2bcall/auth"; import { recordAuditEvent, isPlatformUser, type AccessTokenClaims } from "@b2bcall/auth";
import { JwtAuthGuard } from "../common/guards/jwt-auth.guard"; import { JwtAuthGuard } from "../common/guards/jwt-auth.guard";
import { PermissionGuard } from "../common/guards/permission.guard"; import { PermissionGuard } from "../common/guards/permission.guard";
import { RequirePermission } from "../common/decorators/require-permission.decorator"; import { RequirePermission } from "../common/decorators/require-permission.decorator";
@@ -23,6 +23,16 @@ export class AIModelsController {
); );
if (!provider) throw new NotFoundException("Provider nao encontrado"); if (!provider) throw new NotFoundException("Provider nao encontrado");
// Achado real (mesma classe já corrigida em `AIProvidersController`):
// a RLS híbrida (secao 100, `OR tenant_id IS NULL`) deixa qualquer
// tenant ENXERGAR um provider GLOBAL, mas escrever um modelo nele é
// catálogo de plataforma — sem este check, qualquer Tenant Admin com
// `ai.manage` (permission de escopo TENANT) conseguia injetar um
// modelo no catálogo visível por TODOS os tenants.
if (provider.scope === "GLOBAL" && !(await isPlatformUser(user.sub))) {
throw new ForbiddenException("So' um usuario com role de plataforma pode adicionar modelo a um provider GLOBAL");
}
const model = await withTenantContext(prisma, tenantId, (tx) => const model = await withTenantContext(prisma, tenantId, (tx) =>
tx.aIModel.create({ tx.aIModel.create({
data: { data: {
@@ -67,14 +77,26 @@ export class AIModelsController {
const prisma = getPrismaClient(); const prisma = getPrismaClient();
const tenantId = user.tenantId!; const tenantId = user.tenantId!;
const result = await withTenantContext(prisma, tenantId, (tx) => const model = await withTenantContext(prisma, tenantId, (tx) => tx.aIModel.findFirst({ where: { id } }));
tx.aIModel.updateMany({ where: { id }, data: { enabled: false } }), if (!model) throw new NotFoundException();
);
if (result.count === 0) throw new NotFoundException(); // Mesmo achado do create(): sem este check, qualquer Tenant Admin com
// `ai.manage` conseguia desabilitar um modelo GLOBAL (visível e
// usado por todos os tenants) só por conhecer o id.
if (model.tenantId == null && !(await isPlatformUser(user.sub))) {
throw new ForbiddenException("So' um usuario com role de plataforma pode remover um modelo GLOBAL");
}
if (model.tenantId != null && model.tenantId !== tenantId) {
// Nunca deveria acontecer (RLS ja' filtra), mas nunca custa checar
// explicitamente antes de uma escrita (secao 31/146).
throw new NotFoundException();
}
await withTenantContext(prisma, tenantId, (tx) => tx.aIModel.update({ where: { id }, data: { enabled: false } }));
await recordAuditEvent(prisma, { await recordAuditEvent(prisma, {
action: "AI_MODEL_DELETE", action: "AI_MODEL_DELETE",
tenantId, tenantId: model.tenantId,
userId: user.sub, userId: user.sub,
entityType: "ai_model", entityType: "ai_model",
entityId: id, entityId: id,

View File

@@ -0,0 +1,76 @@
import { Controller, ForbiddenException, Get, UseGuards } from "@nestjs/common";
import { getPrismaClient, withTenantContext } from "@b2bcall/database";
import { isPlatformUser, type AccessTokenClaims } from "@b2bcall/auth";
import { JwtAuthGuard } from "../common/guards/jwt-auth.guard";
import { PermissionGuard } from "../common/guards/permission.guard";
import { RequirePermission } from "../common/decorators/require-permission.decorator";
import { CurrentUser } from "../common/decorators/current-user.decorator";
/**
* "IA > Uso/Custos" (agente.md secao 169) — visão de plataforma, todos os
* tenants, diferente do BYOK do tenant (`/ai/providers`, `/ai/models`,
* escopo TENANT/GLOBAL). Uso = quantidade bruta do ledger `AIUsageRecord`
* (mesmo ledger que `/reports/consumo` usa por tenant, aqui somado em
* todos). Custo = uso × preço unitário do `AIModel` correspondente
* (`providerId` + `model` batendo com `externalModelId`) — quando não dá
* pra casar um registro de uso com um AIModel cadastrado (ex.: provider
* apagado, nome de modelo mudou), o custo daquele registro fica de fora
* da soma e o tenant é marcado `costIncomplete: true`, nunca um número
* inventado (agente.md secao 138/233: null > estimativa disfarçada de
* número fechado).
*/
@UseGuards(JwtAuthGuard, PermissionGuard)
@Controller("platform/ai-usage")
export class PlatformAiUsageController {
@RequirePermission("tenants.view")
@Get()
async list(@CurrentUser() user: AccessTokenClaims): Promise<Record<string, unknown>[]> {
if (!(await isPlatformUser(user.sub))) {
throw new ForbiddenException("So' um usuario com role de plataforma pode ver uso de IA de todos os tenants");
}
const prisma = getPrismaClient();
const now = new Date();
const monthStart = new Date(Date.UTC(now.getUTCFullYear(), now.getUTCMonth(), 1));
const tenants = await prisma.tenant.findMany({ where: { deletedAt: null }, orderBy: { legalName: "asc" } });
const models = await prisma.aIModel.findMany({ select: { providerId: true, externalModelId: true, inputCost: true, outputCost: true, audioCost: true } });
const modelByKey = new Map(models.map((m) => [`${m.providerId}:${m.externalModelId}`, m]));
return Promise.all(
tenants.map(async (tenant) => {
const records = await withTenantContext(prisma, tenant.id, (tx) =>
tx.aIUsageRecord.findMany({ where: { tenantId: tenant.id, occurredAt: { gte: monthStart } } }),
);
const usage = { transcriptionSeconds: 0, analysisRequests: 0, inputTokens: 0, outputTokens: 0 };
let cost = 0;
let costIncomplete = false;
for (const r of records) {
if (r.type === "AI_TRANSCRIPTION_SECONDS") usage.transcriptionSeconds += r.quantity;
if (r.type === "AI_ANALYSIS_REQUEST") usage.analysisRequests += r.quantity;
if (r.type === "AI_INPUT_TOKENS") usage.inputTokens += r.quantity;
if (r.type === "AI_OUTPUT_TOKENS") usage.outputTokens += r.quantity;
const model = r.providerId && r.model ? modelByKey.get(`${r.providerId}:${r.model}`) : undefined;
if (!model) {
if (r.type !== "AI_ANALYSIS_REQUEST") costIncomplete = true;
continue;
}
if (r.type === "AI_TRANSCRIPTION_SECONDS" && model.audioCost != null) cost += r.quantity * model.audioCost;
else if (r.type === "AI_INPUT_TOKENS" && model.inputCost != null) cost += r.quantity * model.inputCost;
else if (r.type === "AI_OUTPUT_TOKENS" && model.outputCost != null) cost += r.quantity * model.outputCost;
}
return {
tenantId: tenant.id,
legalName: tenant.legalName,
usage,
estimatedCost: records.length === 0 ? null : cost,
costIncomplete,
};
}),
);
}
}

View File

@@ -6,6 +6,7 @@ import { PlatformHealthController } from "./platform-health.controller";
import { PlatformRolesController } from "./platform-roles.controller"; import { PlatformRolesController } from "./platform-roles.controller";
import { PlatformQuotasController } from "./platform-quotas.controller"; import { PlatformQuotasController } from "./platform-quotas.controller";
import { PlatformFreeswitchController } from "./platform-freeswitch.controller"; import { PlatformFreeswitchController } from "./platform-freeswitch.controller";
import { PlatformAiUsageController } from "./platform-ai-usage.controller";
@Module({ @Module({
controllers: [ controllers: [
@@ -16,6 +17,7 @@ import { PlatformFreeswitchController } from "./platform-freeswitch.controller";
PlatformRolesController, PlatformRolesController,
PlatformQuotasController, PlatformQuotasController,
PlatformFreeswitchController, PlatformFreeswitchController,
PlatformAiUsageController,
], ],
}) })
export class PlatformModule {} export class PlatformModule {}

Binary file not shown.

After

Width:  |  Height:  |  Size: 83 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 78 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 76 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 76 KiB

View File

@@ -0,0 +1,63 @@
import { Panel, PanelHeader } from "@/components/ui/panel";
import { Pill } from "@/components/ui/pill";
import { EmptyState, TBody, TD, TH, THead, TR, Table } from "@/components/ui/table";
import { formatCurrency } from "@/lib/format";
import type { TenantAiUsage } from "@/lib/platform-types";
export function CustosView({ usage }: { usage: TenantAiUsage[] }) {
const total = usage.reduce((sum, u) => sum + (u.estimatedCost ?? 0), 0);
const anyIncomplete = usage.some((u) => u.costIncomplete);
return (
<div className="space-y-5">
<div>
<h1 className="text-lg font-semibold text-foreground">IA Custos</h1>
<p className="mt-1 max-w-2xl text-sm text-muted-foreground">
Uso × custo unitário cadastrado em IA &gt; Modelos, mês corrente. Quando um registro de uso não bate com
nenhum modelo cadastrado (provider apagado, custo nunca preenchido), ele fica de fora da soma nunca um
número inventado no lugar.
</p>
</div>
<Panel className="p-5">
<PanelHeader title="Total estimado (todos os tenants)" />
<p className="mt-2 font-mono text-3xl font-semibold tabular-nums text-foreground">{formatCurrency(total, "USD")}</p>
{anyIncomplete && (
<p className="mt-2 text-xs text-muted-foreground">
Pelo menos um tenant tem uso sem custo cadastrado (marcado abaixo) o total real é maior que este número.
</p>
)}
</Panel>
<Panel>
<PanelHeader title="Custo por tenant" />
{usage.length === 0 ? (
<EmptyState title="Nenhum tenant" description="Nenhum tenant cadastrado ainda." />
) : (
<Table>
<THead>
<TR>
<TH>Tenant</TH>
<TH>Custo estimado</TH>
<TH>
<span className="sr-only">Aviso</span>
</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">
{u.estimatedCost != null ? formatCurrency(u.estimatedCost, "USD") : "—"}
</TD>
<TD>{u.costIncomplete && <Pill>Uso sem custo cadastrado</Pill>}</TD>
</TR>
))}
</TBody>
</Table>
)}
</Panel>
</div>
);
}

View File

@@ -0,0 +1,10 @@
import { requireSession } from "@/lib/session";
import { apiFetch } from "@/lib/api";
import type { TenantAiUsage } from "@/lib/platform-types";
import { CustosView } from "./custos-view";
export default async function CustosPage() {
const session = await requireSession();
const usage = await apiFetch<TenantAiUsage[]>("/platform/ai-usage", session.accessToken);
return <CustosView usage={usage} />;
}

View File

@@ -0,0 +1,57 @@
"use server";
import { revalidatePath } from "next/cache";
import { requireSession } from "@/lib/session";
import { apiFetch, ApiError } from "@/lib/api";
import type { AIModelConfig } from "@/lib/ai-config-types";
function extractErrorMessage(err: unknown): string {
if (err instanceof ApiError) {
try {
const parsed = JSON.parse(err.message);
if (Array.isArray(parsed.message)) return parsed.message.join(" ");
if (typeof parsed.message === "string") return parsed.message;
} catch {
// corpo não era JSON
}
return err.message || "Falha inesperada na API.";
}
return "Falha inesperada. Tente novamente.";
}
export interface CreateGlobalModelInput {
providerId: string;
externalModelId: string;
displayName: string;
capabilities: string[];
inputCost?: number;
outputCost?: number;
audioCost?: number;
}
export async function createGlobalModel(
input: CreateGlobalModelInput,
): Promise<{ ok: true; model: AIModelConfig } | { ok: false; error: string }> {
const session = await requireSession();
try {
const model = await apiFetch<AIModelConfig>("/ai/models", session.accessToken, {
method: "POST",
body: JSON.stringify(input),
});
revalidatePath("/platform/ia/modelos");
return { ok: true, model };
} catch (err) {
return { ok: false, error: extractErrorMessage(err) };
}
}
export async function deleteGlobalModel(id: string): Promise<{ ok: true } | { ok: false; error: string }> {
const session = await requireSession();
try {
await apiFetch<void>(`/ai/models/${id}`, session.accessToken, { method: "DELETE" });
revalidatePath("/platform/ia/modelos");
return { ok: true };
} catch (err) {
return { ok: false, error: extractErrorMessage(err) };
}
}

View File

@@ -0,0 +1,246 @@
"use client";
import { useMemo, useState, useTransition } from "react";
import { useRouter } from "next/navigation";
import { Cpu, Plus, Trash2, X } from "lucide-react";
import { Panel, PanelHeader } from "@/components/ui/panel";
import { Button } from "@/components/ui/button";
import { Input, Select, FieldLabel } from "@/components/ui/input";
import { Pill } from "@/components/ui/pill";
import { EmptyState, TBody, TD, TH, THead, TR, Table } from "@/components/ui/table";
import { formatCurrency } from "@/lib/format";
import { AI_CAPABILITIES, AI_CAPABILITY_LABELS, type AIModelConfig, type AIProviderConfig } from "@/lib/ai-config-types";
import { createGlobalModel, deleteGlobalModel } from "./actions";
export function ModelosView({ providers, models }: { providers: AIProviderConfig[]; models: AIModelConfig[] }) {
const [showForm, setShowForm] = useState(false);
const providersById = useMemo(() => Object.fromEntries(providers.map((p) => [p.id, p])), [providers]);
return (
<div className="space-y-5">
<div className="flex flex-wrap items-start justify-between gap-3">
<div>
<h1 className="text-lg font-semibold text-foreground">IA Modelos</h1>
<p className="mt-1 max-w-2xl text-sm text-muted-foreground">
Modelos dos providers globais, com custo unitário usado pra estimar gasto em IA &gt; Custos (agente.md
secao 102-103). Deixe em branco o que não se aplica ao modelo.
</p>
</div>
<Button type="button" onClick={() => setShowForm((s) => !s)} disabled={providers.length === 0}>
{showForm ? <X className="h-4 w-4" aria-hidden /> : <Plus className="h-4 w-4" aria-hidden />}
{showForm ? "Cancelar" : "Novo modelo"}
</Button>
</div>
{providers.length === 0 && (
<Panel className="p-4">
<p className="text-sm text-muted-foreground">Cadastre um provider global em IA &gt; Providers primeiro.</p>
</Panel>
)}
{showForm && <NewModelForm providers={providers} onDone={() => setShowForm(false)} />}
<Panel>
<PanelHeader title="Modelos globais" description={`${models.length} modelo(s)`} />
{models.length === 0 ? (
<EmptyState title="Nenhum modelo configurado ainda" description="Configure o primeiro a partir de um provider global." />
) : (
<Table>
<THead>
<TR>
<TH>Nome</TH>
<TH>Provider</TH>
<TH>ID externo</TH>
<TH>Capacidades</TH>
<TH>Custo (entrada/saída/áudio)</TH>
<TH>
<span className="sr-only">Ações</span>
</TH>
</TR>
</THead>
<TBody>
{models.map((m) => (
<TR key={m.id}>
<TD>
<span className="flex items-center gap-2 font-medium text-foreground">
<Cpu className="h-3.5 w-3.5 text-muted-foreground" aria-hidden />
{m.displayName}
</span>
</TD>
<TD className="text-muted-foreground">{providersById[m.providerId]?.name ?? m.providerId}</TD>
<TD className="font-mono text-muted-foreground">{m.externalModelId}</TD>
<TD>
<span className="flex flex-wrap gap-1">
{m.capabilities.map((c) => (
<Pill key={c}>{AI_CAPABILITY_LABELS[c] ?? c}</Pill>
))}
</span>
</TD>
<TD className="font-mono text-xs text-muted-foreground">
{m.inputCost != null ? formatCurrency(m.inputCost, "USD") : "—"} /{" "}
{m.outputCost != null ? formatCurrency(m.outputCost, "USD") : "—"} /{" "}
{m.audioCost != null ? formatCurrency(m.audioCost, "USD") : "—"}
</TD>
<TD>
<DeleteButton onDelete={() => deleteGlobalModel(m.id)} label={m.displayName} />
</TD>
</TR>
))}
</TBody>
</Table>
)}
</Panel>
</div>
);
}
function NewModelForm({ providers, onDone }: { providers: AIProviderConfig[]; onDone: () => void }) {
const [providerId, setProviderId] = useState(providers[0]?.id ?? "");
const [externalModelId, setExternalModelId] = useState("");
const [displayName, setDisplayName] = useState("");
const [capabilities, setCapabilities] = useState<string[]>([]);
const [inputCost, setInputCost] = useState("");
const [outputCost, setOutputCost] = useState("");
const [audioCost, setAudioCost] = useState("");
const [error, setError] = useState<string | null>(null);
const [pending, startTransition] = useTransition();
function toggleCapability(cap: string) {
setCapabilities((prev) => (prev.includes(cap) ? prev.filter((c) => c !== cap) : [...prev, cap]));
}
function onSubmit(e: React.FormEvent) {
e.preventDefault();
setError(null);
if (!providerId || !externalModelId.trim() || !displayName.trim()) {
setError("Provider, ID do modelo e nome de exibição são obrigatórios.");
return;
}
startTransition(async () => {
const result = await createGlobalModel({
providerId,
externalModelId: externalModelId.trim(),
displayName: displayName.trim(),
capabilities,
inputCost: inputCost ? Number(inputCost) : undefined,
outputCost: outputCost ? Number(outputCost) : undefined,
audioCost: audioCost ? Number(audioCost) : undefined,
});
if (!result.ok) {
setError(result.error);
return;
}
onDone();
});
}
return (
<Panel className="p-5">
<form onSubmit={onSubmit} noValidate className="space-y-4">
<div className="grid grid-cols-1 gap-4 sm:grid-cols-3">
<div>
<FieldLabel htmlFor="md-provider">Provider</FieldLabel>
<Select id="md-provider" value={providerId} onChange={(e) => setProviderId(e.target.value)} disabled={pending}>
{providers.map((p) => (
<option key={p.id} value={p.id}>
{p.name}
</option>
))}
</Select>
</div>
<div>
<FieldLabel htmlFor="md-external">ID do modelo (externo)</FieldLabel>
<Input id="md-external" value={externalModelId} onChange={(e) => setExternalModelId(e.target.value)} placeholder="gpt-4o-mini" className="font-mono" disabled={pending} />
</div>
<div>
<FieldLabel htmlFor="md-name">Nome de exibição</FieldLabel>
<Input id="md-name" value={displayName} onChange={(e) => setDisplayName(e.target.value)} placeholder="GPT-4o mini" disabled={pending} />
</div>
</div>
<div className="grid grid-cols-1 gap-4 sm:grid-cols-3">
<div>
<FieldLabel htmlFor="md-input-cost">Custo por token de entrada (USD)</FieldLabel>
<Input id="md-input-cost" type="number" min="0" step="0.000001" value={inputCost} onChange={(e) => setInputCost(e.target.value)} disabled={pending} />
</div>
<div>
<FieldLabel htmlFor="md-output-cost">Custo por token de saída (USD)</FieldLabel>
<Input id="md-output-cost" type="number" min="0" step="0.000001" value={outputCost} onChange={(e) => setOutputCost(e.target.value)} disabled={pending} />
</div>
<div>
<FieldLabel htmlFor="md-audio-cost">Custo por segundo de áudio (USD)</FieldLabel>
<Input id="md-audio-cost" type="number" min="0" step="0.000001" value={audioCost} onChange={(e) => setAudioCost(e.target.value)} disabled={pending} />
</div>
</div>
<div>
<FieldLabel htmlFor="md-cap-0">Capacidades</FieldLabel>
<div className="flex flex-wrap gap-3">
{AI_CAPABILITIES.map((cap, i) => (
<label key={cap} className="flex items-center gap-1.5 text-sm text-foreground">
<input
id={i === 0 ? "md-cap-0" : undefined}
type="checkbox"
checked={capabilities.includes(cap)}
onChange={() => toggleCapability(cap)}
disabled={pending}
className="h-4 w-4 rounded border-input text-primary focus-visible:ring-2 focus-visible:ring-ring"
/>
{AI_CAPABILITY_LABELS[cap]}
</label>
))}
</div>
</div>
{error && (
<p role="alert" className="rounded-md border border-destructive/30 bg-destructive/10 px-3 py-2 text-sm text-destructive">
{error}
</p>
)}
<div className="flex justify-end">
<Button type="submit" disabled={pending}>
{pending ? "Salvando…" : "Salvar modelo"}
</Button>
</div>
</form>
</Panel>
);
}
function DeleteButton({ onDelete, label }: { onDelete: () => Promise<{ ok: true } | { ok: false; error: string }>; label: string }) {
const router = useRouter();
const [confirming, setConfirming] = useState(false);
const [pending, startTransition] = useTransition();
const [error, setError] = useState<string | null>(null);
function onClick() {
if (!confirming) {
setConfirming(true);
return;
}
setError(null);
startTransition(async () => {
const result = await onDelete();
if (!result.ok) {
setError(result.error);
setConfirming(false);
return;
}
router.refresh();
});
}
return (
<div className="flex items-center justify-end gap-2">
{error && <span className="text-xs text-destructive">{error}</span>}
<Button
type="button"
variant={confirming ? "destructive" : "ghost"}
size="sm"
onClick={onClick}
disabled={pending}
aria-label={confirming ? `Confirmar remoção de ${label}` : `Remover ${label}`}
>
<Trash2 className="h-3.5 w-3.5" aria-hidden />
{confirming ? "Confirmar" : ""}
</Button>
</div>
);
}

View File

@@ -0,0 +1,13 @@
import { requireSession } from "@/lib/session";
import { apiFetch } from "@/lib/api";
import type { AIModelConfig, AIProviderConfig } from "@/lib/ai-config-types";
import { ModelosView } from "./modelos-view";
export default async function PlatformModelosPage() {
const session = await requireSession();
const [providers, models] = await Promise.all([
apiFetch<AIProviderConfig[]>("/ai/providers", session.accessToken),
apiFetch<AIModelConfig[]>("/ai/models", session.accessToken),
]);
return <ModelosView providers={providers} models={models} />;
}

View File

@@ -0,0 +1,59 @@
"use server";
import { revalidatePath } from "next/cache";
import { requireSession } from "@/lib/session";
import { apiFetch, ApiError } from "@/lib/api";
import type { AIProviderConfig } from "@/lib/ai-config-types";
function extractErrorMessage(err: unknown): string {
if (err instanceof ApiError) {
try {
const parsed = JSON.parse(err.message);
if (Array.isArray(parsed.message)) return parsed.message.join(" ");
if (typeof parsed.message === "string") return parsed.message;
} catch {
// corpo não era JSON
}
return err.message || "Falha inesperada na API.";
}
return "Falha inesperada. Tente novamente.";
}
export interface CreateGlobalProviderInput {
providerType: string;
name: string;
apiKey: string;
baseUrl?: string;
organization?: string;
project?: string;
}
/** Sempre `scope: "GLOBAL"` — visível de qualquer tenant (agente.md secao
* 100). O backend também checa `isPlatformUser` explicitamente, esta
* tela é só quem já está em `/platform`. */
export async function createGlobalProvider(
input: CreateGlobalProviderInput,
): Promise<{ ok: true; provider: AIProviderConfig } | { ok: false; error: string }> {
const session = await requireSession();
try {
const provider = await apiFetch<AIProviderConfig>("/ai/providers", session.accessToken, {
method: "POST",
body: JSON.stringify({ ...input, scope: "GLOBAL" }),
});
revalidatePath("/platform/ia/providers");
return { ok: true, provider };
} catch (err) {
return { ok: false, error: extractErrorMessage(err) };
}
}
export async function deleteGlobalProvider(id: string): Promise<{ ok: true } | { ok: false; error: string }> {
const session = await requireSession();
try {
await apiFetch<void>(`/ai/providers/${id}`, session.accessToken, { method: "DELETE" });
revalidatePath("/platform/ia/providers");
return { ok: true };
} catch (err) {
return { ok: false, error: extractErrorMessage(err) };
}
}

View File

@@ -0,0 +1,10 @@
import { requireSession } from "@/lib/session";
import { apiFetch } from "@/lib/api";
import type { AIProviderConfig } from "@/lib/ai-config-types";
import { ProvidersView } from "./providers-view";
export default async function PlatformProvidersPage() {
const session = await requireSession();
const providers = await apiFetch<AIProviderConfig[]>("/ai/providers", session.accessToken);
return <ProvidersView providers={providers} />;
}

View File

@@ -0,0 +1,175 @@
"use client";
import { useState, useTransition } from "react";
import { useRouter } from "next/navigation";
import { Bot, Plus, Trash2, X } from "lucide-react";
import { Panel, PanelHeader } from "@/components/ui/panel";
import { Button } from "@/components/ui/button";
import { Input, Select, FieldLabel } from "@/components/ui/input";
import { EmptyState, TBody, TD, TH, THead, TR, Table } from "@/components/ui/table";
import { PROVIDER_TYPES, type AIProviderConfig } from "@/lib/ai-config-types";
import { createGlobalProvider, deleteGlobalProvider } from "./actions";
export function ProvidersView({ providers }: { providers: AIProviderConfig[] }) {
const [showForm, setShowForm] = useState(false);
return (
<div className="space-y-5">
<div className="flex flex-wrap items-start justify-between gap-3">
<div>
<h1 className="text-lg font-semibold text-foreground">IA Providers</h1>
<p className="mt-1 max-w-2xl text-sm text-muted-foreground">
Providers de IA de escopo global (agente.md secao 100) visíveis por qualquer tenant, diferente do BYOK
que cada tenant cadastra em Configurações. A chave de API nunca é reexibida depois de salva.
</p>
</div>
<Button type="button" onClick={() => setShowForm((s) => !s)}>
{showForm ? <X className="h-4 w-4" aria-hidden /> : <Plus className="h-4 w-4" aria-hidden />}
{showForm ? "Cancelar" : "Novo provider global"}
</Button>
</div>
{showForm && <NewProviderForm onDone={() => setShowForm(false)} />}
<Panel>
<PanelHeader title="Providers globais" description={`${providers.length} provider(s)`} />
{providers.length === 0 ? (
<EmptyState title="Nenhum provider global ainda" description="Cadastre uma chave de API pra disponibilizar pra todos os tenants." />
) : (
<Table>
<THead>
<TR>
<TH>Nome</TH>
<TH>Tipo</TH>
<TH>Chave</TH>
<TH>
<span className="sr-only">Ações</span>
</TH>
</TR>
</THead>
<TBody>
{providers.map((p) => (
<ProviderRow key={p.id} provider={p} />
))}
</TBody>
</Table>
)}
</Panel>
</div>
);
}
function NewProviderForm({ onDone }: { onDone: () => void }) {
const [providerType, setProviderType] = useState<string>(PROVIDER_TYPES[0]);
const [name, setName] = useState("");
const [apiKey, setApiKey] = useState("");
const [error, setError] = useState<string | null>(null);
const [pending, startTransition] = useTransition();
function onSubmit(e: React.FormEvent) {
e.preventDefault();
setError(null);
if (!name.trim() || apiKey.trim().length < 10) {
setError("Nome obrigatório e chave de API com pelo menos 10 caracteres.");
return;
}
startTransition(async () => {
const result = await createGlobalProvider({ providerType, name: name.trim(), apiKey: apiKey.trim() });
if (!result.ok) {
setError(result.error);
return;
}
onDone();
});
}
return (
<Panel className="p-5">
<form onSubmit={onSubmit} noValidate className="space-y-4">
<div className="grid grid-cols-1 gap-4 sm:grid-cols-3">
<div>
<FieldLabel htmlFor="pr-type">Tipo</FieldLabel>
<Select id="pr-type" value={providerType} onChange={(e) => setProviderType(e.target.value)} disabled={pending}>
{PROVIDER_TYPES.map((t) => (
<option key={t} value={t}>
{t}
</option>
))}
</Select>
</div>
<div>
<FieldLabel htmlFor="pr-name">Nome</FieldLabel>
<Input id="pr-name" value={name} onChange={(e) => setName(e.target.value)} placeholder="Ex.: OpenAI da plataforma" disabled={pending} />
</div>
<div>
<FieldLabel htmlFor="pr-key">Chave de API</FieldLabel>
<Input id="pr-key" type="password" value={apiKey} onChange={(e) => setApiKey(e.target.value)} placeholder="sk-…" disabled={pending} />
</div>
</div>
{error && (
<p role="alert" className="rounded-md border border-destructive/30 bg-destructive/10 px-3 py-2 text-sm text-destructive">
{error}
</p>
)}
<div className="flex justify-end">
<Button type="submit" disabled={pending}>
{pending ? "Salvando…" : "Salvar provider"}
</Button>
</div>
</form>
</Panel>
);
}
function ProviderRow({ provider }: { provider: AIProviderConfig }) {
const router = useRouter();
const [confirming, setConfirming] = useState(false);
const [error, setError] = useState<string | null>(null);
const [pending, startTransition] = useTransition();
function onDelete() {
if (!confirming) {
setConfirming(true);
return;
}
setError(null);
startTransition(async () => {
const result = await deleteGlobalProvider(provider.id);
if (!result.ok) {
setError(result.error);
setConfirming(false);
return;
}
router.refresh();
});
}
return (
<TR>
<TD>
<span className="flex items-center gap-2 font-medium text-foreground">
<Bot className="h-3.5 w-3.5 text-muted-foreground" aria-hidden />
{provider.name}
</span>
</TD>
<TD className="font-mono text-muted-foreground">{provider.providerType}</TD>
<TD className="font-mono text-xs text-muted-foreground">{provider.apiKeyPreview}</TD>
<TD>
<div className="flex items-center justify-end gap-2">
{error && <span className="text-xs text-destructive">{error}</span>}
<Button
type="button"
variant={confirming ? "destructive" : "ghost"}
size="sm"
onClick={onDelete}
disabled={pending}
aria-label={confirming ? `Confirmar remoção de ${provider.name}` : `Remover ${provider.name}`}
>
<Trash2 className="h-3.5 w-3.5" aria-hidden />
{confirming ? "Confirmar" : ""}
</Button>
</div>
</TD>
</TR>
);
}

View 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} />;
}

View 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 &gt; 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>
);
}

View File

@@ -93,7 +93,28 @@ export const PLATFORM_NAV: NavSection[] = [
{ {
label: "IA", label: "IA",
icon: Sparkles, icon: Sparkles,
children: [{ label: "Providers" }, { label: "Modelos" }, { label: "Uso" }, { label: "Custos" }], children: [
{
label: "Providers",
href: "/platform/ia/providers",
description: "Providers de IA globais, visíveis por qualquer tenant",
},
{
label: "Modelos",
href: "/platform/ia/modelos",
description: "Modelos dos providers globais, com custo unitário",
},
{
label: "Uso",
href: "/platform/ia/uso",
description: "Uso bruto de IA de todos os tenants no mês corrente",
},
{
label: "Custos",
href: "/platform/ia/custos",
description: "Uso × custo unitário — estimado, nunca um número fixo",
},
],
}, },
{ {
label: "Sistema", label: "Sistema",

View File

@@ -101,6 +101,19 @@ export interface FreeswitchNodes {
gateways: unknown; gateways: unknown;
} }
export interface TenantAiUsage {
tenantId: string;
legalName: string;
usage: {
transcriptionSeconds: number;
analysisRequests: number;
inputTokens: number;
outputTokens: number;
};
estimatedCost: number | null;
costIncomplete: boolean;
}
export interface QuotaItem { export interface QuotaItem {
key: string; key: string;
label: string; label: string;