diff --git a/TODO.md b/TODO.md index 19a9e7f..b5eebcc 100644 --- a/TODO.md +++ b/TODO.md @@ -1617,6 +1617,46 @@ Usuários (agente.md secao 169) Puppeteer, mostrando a explicação por que falha aqui (mesmo texto 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 diff --git a/apps/api/src/ai/ai-models.controller.ts b/apps/api/src/ai/ai-models.controller.ts index 4503f06..18d5c8a 100644 --- a/apps/api/src/ai/ai-models.controller.ts +++ b/apps/api/src/ai/ai-models.controller.ts @@ -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 { recordAuditEvent, type AccessTokenClaims } from "@b2bcall/auth"; +import { recordAuditEvent, 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"; @@ -23,6 +23,16 @@ export class AIModelsController { ); 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) => tx.aIModel.create({ data: { @@ -67,14 +77,26 @@ export class AIModelsController { const prisma = getPrismaClient(); const tenantId = user.tenantId!; - const result = await withTenantContext(prisma, tenantId, (tx) => - tx.aIModel.updateMany({ where: { id }, data: { enabled: false } }), - ); - if (result.count === 0) throw new NotFoundException(); + const model = await withTenantContext(prisma, tenantId, (tx) => tx.aIModel.findFirst({ where: { id } })); + if (!model) 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, { action: "AI_MODEL_DELETE", - tenantId, + tenantId: model.tenantId, userId: user.sub, entityType: "ai_model", entityId: id, diff --git a/apps/api/src/platform/platform-ai-usage.controller.ts b/apps/api/src/platform/platform-ai-usage.controller.ts new file mode 100644 index 0000000..94a6df3 --- /dev/null +++ b/apps/api/src/platform/platform-ai-usage.controller.ts @@ -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[]> { + 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, + }; + }), + ); + } +} diff --git a/apps/api/src/platform/platform.module.ts b/apps/api/src/platform/platform.module.ts index 4061ccb..81c99b5 100644 --- a/apps/api/src/platform/platform.module.ts +++ b/apps/api/src/platform/platform.module.ts @@ -6,6 +6,7 @@ import { PlatformHealthController } from "./platform-health.controller"; import { PlatformRolesController } from "./platform-roles.controller"; import { PlatformQuotasController } from "./platform-quotas.controller"; import { PlatformFreeswitchController } from "./platform-freeswitch.controller"; +import { PlatformAiUsageController } from "./platform-ai-usage.controller"; @Module({ controllers: [ @@ -16,6 +17,7 @@ import { PlatformFreeswitchController } from "./platform-freeswitch.controller"; PlatformRolesController, PlatformQuotasController, PlatformFreeswitchController, + PlatformAiUsageController, ], }) export class PlatformModule {} diff --git a/apps/frontend/.impeccable/review/ia-custos-desktop.png b/apps/frontend/.impeccable/review/ia-custos-desktop.png new file mode 100644 index 0000000..43d3de4 Binary files /dev/null and b/apps/frontend/.impeccable/review/ia-custos-desktop.png differ diff --git a/apps/frontend/.impeccable/review/ia-modelos-desktop.png b/apps/frontend/.impeccable/review/ia-modelos-desktop.png new file mode 100644 index 0000000..0de8e74 Binary files /dev/null and b/apps/frontend/.impeccable/review/ia-modelos-desktop.png differ diff --git a/apps/frontend/.impeccable/review/ia-providers-desktop.png b/apps/frontend/.impeccable/review/ia-providers-desktop.png new file mode 100644 index 0000000..5c084c6 Binary files /dev/null and b/apps/frontend/.impeccable/review/ia-providers-desktop.png differ diff --git a/apps/frontend/.impeccable/review/ia-uso-desktop.png b/apps/frontend/.impeccable/review/ia-uso-desktop.png new file mode 100644 index 0000000..d111ae9 Binary files /dev/null and b/apps/frontend/.impeccable/review/ia-uso-desktop.png differ diff --git a/apps/frontend/src/app/platform/ia/custos/custos-view.tsx b/apps/frontend/src/app/platform/ia/custos/custos-view.tsx new file mode 100644 index 0000000..af1638d --- /dev/null +++ b/apps/frontend/src/app/platform/ia/custos/custos-view.tsx @@ -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 ( +
+
+

IA — Custos

+

+ Uso × custo unitário cadastrado em IA > 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. +

+
+ + + +

{formatCurrency(total, "USD")}

+ {anyIncomplete && ( +

+ Pelo menos um tenant tem uso sem custo cadastrado (marcado abaixo) — o total real é maior que este número. +

+ )} +
+ + + + {usage.length === 0 ? ( + + ) : ( + + + + + + + + + + {usage.map((u) => ( + + + + + + ))} + +
TenantCusto estimado + Aviso +
{u.legalName} + {u.estimatedCost != null ? formatCurrency(u.estimatedCost, "USD") : "—"} + {u.costIncomplete && Uso sem custo cadastrado}
+ )} +
+
+ ); +} diff --git a/apps/frontend/src/app/platform/ia/custos/page.tsx b/apps/frontend/src/app/platform/ia/custos/page.tsx new file mode 100644 index 0000000..d597255 --- /dev/null +++ b/apps/frontend/src/app/platform/ia/custos/page.tsx @@ -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("/platform/ai-usage", session.accessToken); + return ; +} diff --git a/apps/frontend/src/app/platform/ia/modelos/actions.ts b/apps/frontend/src/app/platform/ia/modelos/actions.ts new file mode 100644 index 0000000..d6005dc --- /dev/null +++ b/apps/frontend/src/app/platform/ia/modelos/actions.ts @@ -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("/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(`/ai/models/${id}`, session.accessToken, { method: "DELETE" }); + revalidatePath("/platform/ia/modelos"); + return { ok: true }; + } catch (err) { + return { ok: false, error: extractErrorMessage(err) }; + } +} diff --git a/apps/frontend/src/app/platform/ia/modelos/modelos-view.tsx b/apps/frontend/src/app/platform/ia/modelos/modelos-view.tsx new file mode 100644 index 0000000..e35ed8e --- /dev/null +++ b/apps/frontend/src/app/platform/ia/modelos/modelos-view.tsx @@ -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 ( +
+
+
+

IA — Modelos

+

+ Modelos dos providers globais, com custo unitário — usado pra estimar gasto em IA > Custos (agente.md + secao 102-103). Deixe em branco o que não se aplica ao modelo. +

+
+ +
+ + {providers.length === 0 && ( + +

Cadastre um provider global em IA > Providers primeiro.

+
+ )} + + {showForm && setShowForm(false)} />} + + + + {models.length === 0 ? ( + + ) : ( + + + + + + + + + + + + + {models.map((m) => ( + + + + + + + + + ))} + +
NomeProviderID externoCapacidadesCusto (entrada/saída/áudio) + Ações +
+ + + {m.displayName} + + {providersById[m.providerId]?.name ?? m.providerId}{m.externalModelId} + + {m.capabilities.map((c) => ( + {AI_CAPABILITY_LABELS[c] ?? c} + ))} + + + {m.inputCost != null ? formatCurrency(m.inputCost, "USD") : "—"} /{" "} + {m.outputCost != null ? formatCurrency(m.outputCost, "USD") : "—"} /{" "} + {m.audioCost != null ? formatCurrency(m.audioCost, "USD") : "—"} + + deleteGlobalModel(m.id)} label={m.displayName} /> +
+ )} +
+
+ ); +} + +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([]); + const [inputCost, setInputCost] = useState(""); + const [outputCost, setOutputCost] = useState(""); + const [audioCost, setAudioCost] = useState(""); + const [error, setError] = useState(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 ( + +
+
+
+ Provider + +
+
+ ID do modelo (externo) + setExternalModelId(e.target.value)} placeholder="gpt-4o-mini" className="font-mono" disabled={pending} /> +
+
+ Nome de exibição + setDisplayName(e.target.value)} placeholder="GPT-4o mini" disabled={pending} /> +
+
+
+
+ Custo por token de entrada (USD) + setInputCost(e.target.value)} disabled={pending} /> +
+
+ Custo por token de saída (USD) + setOutputCost(e.target.value)} disabled={pending} /> +
+
+ Custo por segundo de áudio (USD) + setAudioCost(e.target.value)} disabled={pending} /> +
+
+
+ Capacidades +
+ {AI_CAPABILITIES.map((cap, i) => ( + + ))} +
+
+ {error && ( +

+ {error} +

+ )} +
+ +
+
+
+ ); +} + +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(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 ( +
+ {error && {error}} + +
+ ); +} diff --git a/apps/frontend/src/app/platform/ia/modelos/page.tsx b/apps/frontend/src/app/platform/ia/modelos/page.tsx new file mode 100644 index 0000000..fa40c77 --- /dev/null +++ b/apps/frontend/src/app/platform/ia/modelos/page.tsx @@ -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("/ai/providers", session.accessToken), + apiFetch("/ai/models", session.accessToken), + ]); + return ; +} diff --git a/apps/frontend/src/app/platform/ia/providers/actions.ts b/apps/frontend/src/app/platform/ia/providers/actions.ts new file mode 100644 index 0000000..2706634 --- /dev/null +++ b/apps/frontend/src/app/platform/ia/providers/actions.ts @@ -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("/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(`/ai/providers/${id}`, session.accessToken, { method: "DELETE" }); + revalidatePath("/platform/ia/providers"); + return { ok: true }; + } catch (err) { + return { ok: false, error: extractErrorMessage(err) }; + } +} diff --git a/apps/frontend/src/app/platform/ia/providers/page.tsx b/apps/frontend/src/app/platform/ia/providers/page.tsx new file mode 100644 index 0000000..a3c8e88 --- /dev/null +++ b/apps/frontend/src/app/platform/ia/providers/page.tsx @@ -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("/ai/providers", session.accessToken); + return ; +} diff --git a/apps/frontend/src/app/platform/ia/providers/providers-view.tsx b/apps/frontend/src/app/platform/ia/providers/providers-view.tsx new file mode 100644 index 0000000..95d889e --- /dev/null +++ b/apps/frontend/src/app/platform/ia/providers/providers-view.tsx @@ -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 ( +
+
+
+

IA — Providers

+

+ 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. +

+
+ +
+ + {showForm && setShowForm(false)} />} + + + + {providers.length === 0 ? ( + + ) : ( + + + + + + + + + + + {providers.map((p) => ( + + ))} + +
NomeTipoChave + Ações +
+ )} +
+
+ ); +} + +function NewProviderForm({ onDone }: { onDone: () => void }) { + const [providerType, setProviderType] = useState(PROVIDER_TYPES[0]); + const [name, setName] = useState(""); + const [apiKey, setApiKey] = useState(""); + const [error, setError] = useState(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 ( + +
+
+
+ Tipo + +
+
+ Nome + setName(e.target.value)} placeholder="Ex.: OpenAI da plataforma" disabled={pending} /> +
+
+ Chave de API + setApiKey(e.target.value)} placeholder="sk-…" disabled={pending} /> +
+
+ {error && ( +

+ {error} +

+ )} +
+ +
+
+
+ ); +} + +function ProviderRow({ provider }: { provider: AIProviderConfig }) { + const router = useRouter(); + const [confirming, setConfirming] = useState(false); + const [error, setError] = useState(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 ( + + + + + {provider.name} + + + {provider.providerType} + {provider.apiKeyPreview} + +
+ {error && {error}} + +
+ + + ); +} diff --git a/apps/frontend/src/app/platform/ia/uso/page.tsx b/apps/frontend/src/app/platform/ia/uso/page.tsx new file mode 100644 index 0000000..982e03f --- /dev/null +++ b/apps/frontend/src/app/platform/ia/uso/page.tsx @@ -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("/platform/ai-usage", session.accessToken); + return ; +} diff --git a/apps/frontend/src/app/platform/ia/uso/uso-view.tsx b/apps/frontend/src/app/platform/ia/uso/uso-view.tsx new file mode 100644 index 0000000..2dff2f4 --- /dev/null +++ b/apps/frontend/src/app/platform/ia/uso/uso-view.tsx @@ -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 ( +
+
+

IA — Uso

+

+ Uso bruto de IA de todos os tenants no mês corrente (agente.md secao 124, ledger{" "} + AIUsageRecord) — quantidade, não custo (isso é IA > Custos). +

+
+ + + + {usage.length === 0 ? ( + + ) : ( + + + + + + + + + + + + {usage.map((u) => ( + + + + + + + + ))} + +
TenantTranscriçãoAnálisesTokens de entradaTokens de saída
{u.legalName}{formatDuration(u.usage.transcriptionSeconds)}{formatInt(u.usage.analysisRequests)}{formatInt(u.usage.inputTokens)}{formatInt(u.usage.outputTokens)}
+ )} +
+
+ ); +} diff --git a/apps/frontend/src/components/platform-shell/nav-data.ts b/apps/frontend/src/components/platform-shell/nav-data.ts index 1da627f..9c4f5f8 100644 --- a/apps/frontend/src/components/platform-shell/nav-data.ts +++ b/apps/frontend/src/components/platform-shell/nav-data.ts @@ -93,7 +93,28 @@ export const PLATFORM_NAV: NavSection[] = [ { label: "IA", 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", diff --git a/apps/frontend/src/lib/platform-types.ts b/apps/frontend/src/lib/platform-types.ts index 2f68f1b..c53bb73 100644 --- a/apps/frontend/src/lib/platform-types.ts +++ b/apps/frontend/src/lib/platform-types.ts @@ -101,6 +101,19 @@ export interface FreeswitchNodes { 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 { key: string; label: string;