Skip to content

Commit ac8bb91

Browse files
authored
Merge pull request #210 from melgarafael/fix/provedores-de-ia-forward
fix(ia): os 53 defeitos que entraram com o painel de provedores — e os dois gates que não rodavam
2 parents 34c1a97 + 62557b8 commit ac8bb91

56 files changed

Lines changed: 2239 additions & 123 deletions

File tree

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

.github/workflows/ci.yml

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -40,6 +40,12 @@ jobs:
4040
# Scripts do kit self-host num repo git descartável (docker/crontab
4141
# dublados). É o único gate que exercita o update.sh — um erro aqui
4242
# rebobina a instalação de quem hospeda, e nenhum teste TS o veria.
43+
#
44+
# Desde 2026-08-08 inclui `hostgator-setup-kit/test-validators.sh`, os 163
45+
# casos que cobrem o install.sh. Eles existiam e NÃO rodavam em job nenhum
46+
# (issue #191): dois defeitos que tornavam a instalação impossível —
47+
# escolher OpenRouter travava o instalador num laço, escolher OpenAI
48+
# deixava o .env pela metade — passaram por todos os checks verdes. 15s.
4349
- name: Kit self-host (bash)
4450
run: pnpm test:shell
4551

.github/workflows/e2e.yml

Lines changed: 20 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -178,6 +178,11 @@ jobs:
178178
pnpm exec tsx scripts/seed-e2e-escalacao.ts
179179
pnpm exec tsx --env-file=.env.local scripts/seed-e2e-capacidades-ausentes.ts
180180
pnpm exec tsx scripts/seed-e2e-followup-agent.ts
181+
# O catálogo da OpenRouter não existe em banco fresco (o baseline
182+
# semeia só anthropic/openai/google; os demais vêm do cron diário, que
183+
# busca na internet). Sem esta linha, `prova-painel-provedores` mede a
184+
# sorte do ambiente em vez da tela.
185+
pnpm exec tsx scripts/seed-e2e-catalogo-openrouter.ts
181186
182187
# O que ficou DE FORA (e por quê) é declarado no passo de summary —
183188
# cobertura parcial silenciosa se lê como cobertura total.
@@ -240,7 +245,8 @@ jobs:
240245
distribuicao-atendimento.spec.ts followup-journey.spec.ts \
241246
webhooks.spec.ts \
242247
capacidades-do-agente.spec.ts \
243-
escopo-de-funil-do-agente.spec.ts
248+
escopo-de-funil-do-agente.spec.ts \
249+
prova-painel-provedores.spec.ts
244250
env:
245251
# Só o que NÃO vem do `.env.e2e`: isto é ajuste de CI, não config do
246252
# produto. CI = 1 IP para todos os specs, e o teto de produção
@@ -254,18 +260,25 @@ jobs:
254260
{
255261
echo "## E2E — cobertura deste job"
256262
echo ""
257-
echo "**Rodou (32 de 33 specs):** smoke, auth, error-pages, password-recovery,"
258-
echo "signup-journey, rbac-roles, inbox-scope, reset-password-mfa,"
259-
echo "degradacao-silenciosa, vps-webhook-outbound-ssrf, kanban-owner-filter,"
260-
echo "queue-assign, risk-radar, invite-lifecycle, system-update,"
263+
echo "**Rodou (37 de 39 specs no disco):** smoke, auth, error-pages,"
264+
echo "password-recovery, signup-journey, rbac-roles, inbox-scope,"
265+
echo "reset-password-mfa, degradacao-silenciosa, vps-webhook-outbound-ssrf,"
266+
echo "kanban-owner-filter, queue-assign, conversa-vira-lead, contato-salva-email,"
267+
echo "confirmar-dado-do-contato, risk-radar, invite-lifecycle, system-update,"
261268
echo "agente-novo-e-uso, agente-organiza-operacao, central-de-avisos-capacidades,"
262269
echo "escalacao-ciclo, navegacao, olhar-telas-do-epico, pipelines-gestao,"
263270
echo "qa-agente-usa-as-maos, qa-selo-no-funil-usado, qa-telas-descobertas-w4,"
264271
echo "retorno-anti-morte, followup-builder, followup-queue,"
265-
echo "distribuicao-atendimento, followup-journey, capacidades-do-agente, webhooks."
272+
echo "distribuicao-atendimento, followup-journey, capacidades-do-agente,"
273+
echo "escopo-de-funil-do-agente, prova-painel-provedores, webhooks."
266274
echo ""
267-
echo "**Não rodou (1):**"
275+
echo "**Não rodou (2):**"
268276
echo "- precisa de WAHA + Redis + Resend + Nuvemshop: vps-fresh-onboarding (P0)"
277+
echo "- agente-papeis-operador"
278+
echo ""
279+
echo "A contagem sai de \`ls tests/e2e/*.spec.ts\` contra as duas invocações do"
280+
echo "playwright acima — o texto anterior dizia \"32 de 33\" com 39 specs no disco,"
281+
echo "e cobertura declarada a mais é pior que cobertura parcial declarada."
269282
echo ""
270283
echo "Expandir é trabalho de seguimento — issue #63."
271284
} >> "$GITHUB_STEP_SUMMARY"

app/api/v1/admin/tenants/[id]/route.ts

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -94,8 +94,12 @@ export async function GET(
9494
// números divergem de propósito: este é o total do tenant, aquele é a
9595
// fila de SLA da plataforma.
9696
.not("status", "in", "(completed,failed)"),
97+
// `llm_calls` e não `ai_invocations`: a migration 0130 deixou a segunda sem
98+
// nenhum escritor (`lib/ai/log-invocation.ts` passou a gravar na primeira).
99+
// Lendo a tabela morta, este contador viraria ZERO em 30 dias para todo
100+
// tenant — com o dinheiro saindo. É o mesmo sintoma que a 0130 veio matar.
97101
admin
98-
.from("ai_invocations")
102+
.from("llm_calls")
99103
.select("*", { count: "exact", head: true })
100104
.eq("organization_id", id)
101105
.gte(

app/api/v1/admin/usage/route.ts

Lines changed: 17 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -149,15 +149,22 @@ export async function GET(req: NextRequest) {
149149
}
150150
}
151151

152-
// ---- ai_invocations per org ----
152+
// ---- consumo de IA por org: `llm_calls`, a tabela única (migration 0130) ----
153+
//
154+
// Lia `ai_invocations`, e a 0130 deixou essa tabela SEM NENHUM ESCRITOR:
155+
// `lib/ai/log-invocation.ts` passou a gravar em `llm_calls`. O painel de
156+
// plataforma continuaria somando o histórico congelado e, passados os 30 dias
157+
// da janela, mostraria ZERO consumo para todo tenant com o dinheiro saindo —
158+
// exatamente o sintoma que a 0130 existe para matar, reintroduzido na tela do
159+
// outro lado. `tests/unit/telemetria-tem-um-leitor-so.test.ts` guarda isto.
153160
const aiInvCountMap = new Map<string, number>();
154161
const aiTokensMap = new Map<string, number>();
155162
const aiCostMap = new Map<string, number>();
156163

157164
if (orgIds.length > 0) {
158165
const { data: aiRows, error: aiErr } = await admin
159-
.from("ai_invocations")
160-
.select("organization_id, prompt_tokens, completion_tokens, cost_cents")
166+
.from("llm_calls")
167+
.select("organization_id, input_tokens, output_tokens, cost_cents")
161168
.in("organization_id", orgIds)
162169
.gte("created_at", startIso);
163170
if (!aiErr && aiRows) {
@@ -167,8 +174,8 @@ export async function GET(req: NextRequest) {
167174
aiTokensMap.set(
168175
oid,
169176
(aiTokensMap.get(oid) ?? 0) +
170-
((row.prompt_tokens as number) ?? 0) +
171-
((row.completion_tokens as number) ?? 0),
177+
((row.input_tokens as number) ?? 0) +
178+
((row.output_tokens as number) ?? 0),
172179
);
173180
aiCostMap.set(
174181
oid,
@@ -233,9 +240,10 @@ export async function GET(req: NextRequest) {
233240
const aiTokensDayMap = new Map<string, number>();
234241
if (orgIds.length > 0) {
235242
const filterOrgIds = tenant_id ? [tenant_id] : orgIds;
243+
// `llm_calls`, pelo mesmo motivo do bloco acima (migration 0130).
236244
const { data: aiDays, error: aiDayErr } = await admin
237-
.from("ai_invocations")
238-
.select("created_at, prompt_tokens, completion_tokens, cost_cents")
245+
.from("llm_calls")
246+
.select("created_at, input_tokens, output_tokens, cost_cents")
239247
.in("organization_id", filterOrgIds)
240248
.gte("created_at", startIso);
241249
if (!aiDayErr && aiDays) {
@@ -248,8 +256,8 @@ export async function GET(req: NextRequest) {
248256
aiTokensDayMap.set(
249257
day,
250258
(aiTokensDayMap.get(day) ?? 0) +
251-
((row.prompt_tokens as number) ?? 0) +
252-
((row.completion_tokens as number) ?? 0),
259+
((row.input_tokens as number) ?? 0) +
260+
((row.output_tokens as number) ?? 0),
253261
);
254262
}
255263
}

app/api/v1/ai/credentials/route.ts

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,7 @@ import { audit } from "@/lib/audit";
1818
import { requireRole } from "@/lib/auth/require-role";
1919
import { bufToBytea, encryptKey } from "@/lib/crypto/aes_gcm";
2020
import { validateProviderKey, type Provider } from "@/lib/ai/provider-validators";
21+
import { IDS_DE_PROVEDOR } from "@/lib/ai/pontos/provedores";
2122
import { createAdminClient } from "@/lib/supabase/admin";
2223
import { createClient } from "@/lib/supabase/server";
2324

@@ -27,7 +28,11 @@ const SAFE_COLUMNS =
2728
"id, organization_id, provider, label, api_key_last4, validated_at, validation_error, models_available, is_active, created_by, created_at, updated_at";
2829

2930
const createSchema = z.object({
30-
provider: z.enum(["anthropic", "openai", "google"]),
31+
// Derivado de `lib/ai/pontos/provedores.ts`, a lista única desde a migration
32+
// 0127. Enquanto era uma cópia à mão, o banco aceitava OpenRouter e ESTA rota
33+
// recusava com 422 — o operador via a tela de Provedores oferecer OpenRouter
34+
// e não tinha onde cadastrar a chave.
35+
provider: z.enum(IDS_DE_PROVEDOR),
3136
label: z.string().trim().min(1).max(80),
3237
api_key: z.string().trim().min(8).max(2048),
3338
});

app/api/v1/ai/providers/[provider]/models/route.ts

Lines changed: 6 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -10,10 +10,14 @@ import { type NextRequest } from "next/server";
1010
import { ok, fail } from "@/lib/api/wrappers";
1111
import { loadAuthUser, resolveActiveOrg } from "@/lib/auth/server";
1212
import { createClient } from "@/lib/supabase/server";
13+
import { ehProvedorSuportado } from "@/lib/ai/pontos/provedores";
1314

1415
export const dynamic = "force-dynamic";
1516

16-
const PROVIDERS = new Set(["anthropic", "openai", "google"]);
17+
// A lista única (`lib/ai/pontos/provedores.ts`) — não uma quarta cópia. Esta
18+
// rota alimenta o seletor de modelos; com a lista velha, pedir os modelos da
19+
// OpenRouter devolvia "provedor desconhecido" para um provedor que a tela ao
20+
// lado oferecia.
1721

1822
const MODEL_COLUMNS =
1923
"id, provider, model_id, display_name, description, context_window, input_price_per_million_cents, output_price_per_million_cents, supports_tools, is_default_for_provider, deprecated_at, released_at";
@@ -25,7 +29,7 @@ export async function GET(
2529
const requestId = randomUUID();
2630
const { provider } = await ctx.params;
2731

28-
if (!PROVIDERS.has(provider)) {
32+
if (!ehProvedorSuportado(provider)) {
2933
return fail("not_found", "Provider desconhecido.", 404, { requestId });
3034
}
3135

app/api/v1/ai/providers/route.ts

Lines changed: 36 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -25,7 +25,7 @@ import {
2525
type LinhaDeBinding,
2626
} from "@/lib/ai/pontos/resolver";
2727
import { PAPEIS, PONTOS_DE_IA, PONTO_POR_ID } from "@/lib/ai/pontos/registro";
28-
import { PROVEDORES } from "@/lib/ai/pontos/provedores";
28+
import { PROVEDORES, ehProvedorSuportado } from "@/lib/ai/pontos/provedores";
2929
import { validarBinding } from "@/lib/ai/pontos/validar-binding";
3030
import { createClient } from "@/lib/supabase/server";
3131

@@ -121,8 +121,18 @@ export async function GET(): Promise<Response> {
121121
exige: ponto.exige,
122122
sintomaDeFalha: ponto.sintomaDeFalha,
123123
fixo: ponto.fixo ?? null,
124-
/** Escolha do agente publicado — a tela mostra como leitura, com link. */
125-
mandadoPeloAgente: PONTOS_DO_AGENTE_PUBLICADO.has(ponto.id),
124+
/**
125+
* Escolha do agente publicado — a tela mostra como leitura, com link.
126+
*
127+
* Depende de EXISTIR versão publicada: é a mesma condição que o resolvedor
128+
* usa (`resolver.ts` exige `agentePublicado !== null`). Sem o `&&`, uma
129+
* instalação recém-feita — nenhum agente publicado ainda — abria o painel
130+
* com os DOIS pontos que respondem o cliente sem seletor, dizendo que são
131+
* governados por uma versão publicada que não existe e mandando
132+
* configurar num lugar vazio. É a primeira tela da feature; travá-la no
133+
* primeiro uso é o pior lugar para esse defeito estar.
134+
*/
135+
mandadoPeloAgente: agentePublicado !== null && PONTOS_DO_AGENTE_PUBLICADO.has(ponto.id),
126136
efetivo: {
127137
provider: decisao.provider,
128138
modelId: decisao.modelId,
@@ -156,7 +166,19 @@ export async function GET(): Promise<Response> {
156166

157167
const corpoDoPut = z.object({
158168
purpose: z.string().min(1),
159-
provider: z.string().min(1),
169+
// A migration 0127 removeu os CHECKs do banco dizendo que "a garantia de que
170+
// a tela não oferece opção inválida passa a morar" na lista de provedores —
171+
// mas a lista não era aplicada em NENHUM ponto de escrita. Um PUT direto (e a
172+
// API é pública) gravava `provider: "foobar"`, a rota respondia 200, e todo
173+
// uso daquele ponto morria em produção com provedor desconhecido. Metade da
174+
// defesa transferida e nunca instalada.
175+
provider: z
176+
.string()
177+
.min(1)
178+
.refine(ehProvedorSuportado, {
179+
message:
180+
"provedor não suportado por esta instalação — escolha um da lista em Agente de IA → Provedores",
181+
}),
160182
model_id: z.string().min(1),
161183
credential_id: z.string().uuid().nullable().optional(),
162184
base_url: z.string().url().nullable().optional(),
@@ -240,7 +262,7 @@ export async function PUT(req: NextRequest): Promise<Response> {
240262
},
241263
{ onConflict: "organization_id,purpose" },
242264
)
243-
.select("purpose, provider, model_id, credential_id, base_url, is_enabled")
265+
.select("id, purpose, provider, model_id, credential_id, base_url, is_enabled")
244266
.maybeSingle();
245267

246268
if (error) return fail("save_failed", error.message, 500);
@@ -255,11 +277,19 @@ export async function PUT(req: NextRequest): Promise<Response> {
255277
organizationId: org.orgId,
256278
actorUserId: user.id,
257279
resourceType: "ai_purpose_binding",
258-
resourceId: corpo.purpose,
280+
// O ID DA LINHA, não o `purpose`. `api_audit_log.resource_id` é **uuid**, e
281+
// `purpose` é texto (`stage_classifier`): o INSERT falhava com 22P02
282+
// (`invalid input syntax for type uuid`) e — como o audit é
283+
// fire-and-forget — o erro ia só para o log do servidor. Resultado: NENHUMA
284+
// troca de modelo era auditada, num painel cujo efeito é justamente mudar
285+
// para onde o dinheiro e os dados do cliente vão. Achado dirigindo a tela;
286+
// nenhum gate via, porque nada assertava a linha de auditoria.
287+
resourceId: (gravado as { id?: string }).id ?? null,
259288
// O modelo entra no metadata, a credencial NÃO — só o id dela seria
260289
// inócuo, mas o hábito de mandar campo de credencial para o audit é o que
261290
// acaba vazando a chave quando alguém troca o campo de lugar.
262291
metadata: {
292+
purpose: corpo.purpose,
263293
provider: corpo.provider,
264294
model_id: corpo.model_id,
265295
tem_endpoint_proprio: Boolean(corpo.base_url),

app/api/v1/ai/runs/route.ts

Lines changed: 17 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,7 @@
88
* porque é assim que o operador pensa depois de configurar o painel: "troquei o
99
* modelo do classificador de estágio, está funcionando?".
1010
*/
11+
import { z } from "zod";
1112
import type { NextRequest } from "next/server";
1213

1314
import { fail, ok } from "@/lib/api/wrappers";
@@ -59,6 +60,12 @@ interface LinhaDeExecucao {
5960
created_at: string;
6061
}
6162

63+
const filtrosDaQuery = z.object({
64+
purpose: z.string().min(1).max(64).optional(),
65+
status: z.enum(["ok", "erro"]).optional(),
66+
limit: z.coerce.number().int().min(1).max(200).default(100),
67+
});
68+
6269
export async function GET(req: NextRequest): Promise<Response> {
6370
const user = await requireAuth();
6471
const org = await resolveActiveOrg(user);
@@ -67,10 +74,15 @@ export async function GET(req: NextRequest): Promise<Response> {
6774
return fail("forbidden", "requer papel de gerente ou superior", 403);
6875
}
6976

70-
const url = new URL(req.url);
71-
const purpose = url.searchParams.get("purpose");
72-
const status = url.searchParams.get("status");
73-
const limite = Math.min(Number(url.searchParams.get("limit") ?? 100), 200);
77+
// Zod na query string, como a rota irmã de uso já faz. `Math.min(Number(…))`
78+
// não valida nada: `?limit=abc` virava `NaN` e `?limit=-5` passava direto,
79+
// o PostgREST recusava, e o erro dele voltava como **500** com a mensagem
80+
// crua no corpo — resposta de servidor para um erro do cliente.
81+
const filtros = filtrosDaQuery.safeParse(Object.fromEntries(new URL(req.url).searchParams));
82+
if (!filtros.success) {
83+
return fail("invalid_query", "filtros inválidos", 422, { details: filtros.error.issues });
84+
}
85+
const { purpose, status, limit: limite } = filtros.data;
7486

7587
const db = await createClient();
7688
let q = db
@@ -83,7 +95,7 @@ export async function GET(req: NextRequest): Promise<Response> {
8395
.limit(limite);
8496

8597
if (purpose) q = q.eq("purpose", purpose);
86-
if (status === "erro" || status === "ok") q = q.eq("status", status);
98+
if (status) q = q.eq("status", status);
8799

88100
const { data, error } = await q;
89101
if (error) return fail("query_failed", error.message, 500);

app/app/ai/agents/[id]/_components/ModelPicker.tsx

Lines changed: 7 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -11,8 +11,14 @@ import {
1111
SelectTrigger,
1212
SelectValue,
1313
} from "@/components/ui/select";
14+
import { PROVEDORES } from "@/lib/ai/pontos/provedores";
1415

15-
export type Provider = "anthropic" | "openai" | "google";
16+
/**
17+
* Derivado de `lib/ai/pontos/provedores.ts` — a mesma lista única da tela de
18+
* Credenciais e da rota. Como literal aqui, o seletor de modelo do agente não
19+
* conseguia representar um agente publicado em OpenRouter.
20+
*/
21+
export type Provider = (typeof PROVEDORES)[number]["id"];
1622

1723
export interface ModelOption {
1824
provider: Provider;

app/app/ai/credentials/_components/AddCredentialDialog.tsx

Lines changed: 8 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -32,9 +32,11 @@ import {
3232
type CredentialRow,
3333
type Provider,
3434
} from "@/hooks/ai/useCredentials";
35+
import { IDS_DE_PROVEDOR, PROVEDORES } from "@/lib/ai/pontos/provedores";
3536

3637
const formSchema = z.object({
37-
provider: z.enum(["anthropic", "openai", "google"]),
38+
// Derivado da lista única (`lib/ai/pontos/provedores.ts`), como a rota.
39+
provider: z.enum(IDS_DE_PROVEDOR),
3840
label: z.string().trim().min(1, "Obrigatório").max(80),
3941
api_key: z.string().trim().min(8, "API key muito curta").max(2048),
4042
});
@@ -141,9 +143,11 @@ export function AddCredentialDialog({ open, onOpenChange }: Props) {
141143
<SelectValue />
142144
</SelectTrigger>
143145
<SelectContent>
144-
<SelectItem value="anthropic">Anthropic</SelectItem>
145-
<SelectItem value="openai">OpenAI</SelectItem>
146-
<SelectItem value="google">Google</SelectItem>
146+
{PROVEDORES.map((p) => (
147+
<SelectItem key={p.id} value={p.id}>
148+
{p.rotulo}
149+
</SelectItem>
150+
))}
147151
</SelectContent>
148152
</Select>
149153
{errors.provider && (

0 commit comments

Comments
 (0)