feat(ai): aceitar embeddings via OpenRouter

This commit is contained in:
Bruno Gonzaga Santos
2026-09-28 11:22:10 -03:00
parent ac3715c98d
commit e14427a9ad
21 changed files with 380 additions and 91 deletions
+7
View File
@@ -0,0 +1,7 @@
---
impacto: capacidade_nova
secao: adicionado
titulo: Use uma chave OpenRouter para preparar o acervo de conhecimento
---
O acervo aceita uma chave OpenRouter cadastrada em Credenciais ou na instalação para indexar e consultar documentos com o mesmo modelo de embedding. A tela do acervo permite cadastrar essa chave sem sair da página. Quem já usa OpenAI continua com a configuração anterior. Crédito: @obrunogonzaga.
+4 -2
View File
@@ -219,8 +219,10 @@ ANTHROPIC_API_KEY=
# O invariante que segura estas afirmações: tests/unit/openrouter-alcance.test.ts
OPENROUTER_API_KEY=
OPENROUTER_BASE_URL= # vazio = https://openrouter.ai/api/v1
# Pra embeddings (text-embedding-3-small) usados no RAG por tenant e pra transcrever áudio.
# OPCIONAL: é o ÚLTIMO degrau. Quem preferir pode cadastrar a chave PELA TELA, em
# Para embeddings do RAG, OPENROUTER_API_KEY acima também serve quando não há
# chave OpenAI da organização ou gateway configurado. A busca e a indexação usam
# o mesmo text-embedding-3-small; a chave OpenAI segue necessária para transcrever áudio.
# OPCIONAL: é o ÚLTIMO degrau OpenAI. Quem preferir pode cadastrar a chave PELA TELA, em
# IA › Credenciais (ou no próprio passo de cadastrar material) — vale por organização,
# fica cifrada no banco e dispensa mexer neste arquivo.
OPENAI_API_KEY=
+1 -1
View File
@@ -162,7 +162,7 @@ ANTHROPIC_API_KEY=
OPENROUTER_API_KEY=
# vazio = https://openrouter.ai/api/v1
OPENROUTER_BASE_URL=
# opcional — embeddings do RAG
# opcional — transcrição de áudio; embeddings do RAG também aceitam OpenRouter
OPENAI_API_KEY=
# -----------------------------------------------------------------------------
+6 -4
View File
@@ -11,7 +11,7 @@
* A resposta diz três coisas, e as três são acionáveis na tela:
* * se dá para indexar agora;
* * de ONDE a chave sai (a pessoa precisa saber qual está valendo);
* * quais chaves OpenAI a organização já tem, para escolher em vez de digitar
* * quais chaves de embedding a organização já tem, para escolher em vez de digitar
* outra.
*
* Nunca devolve material de credencial: só rótulo e os quatro últimos dígitos,
@@ -41,9 +41,9 @@ export async function GET(): Promise<Response> {
const supabase = await createClient();
const { data: credenciais } = await supabase
.from("ai_provider_credentials_safe")
.select("id, label, api_key_last4, validated_at, validation_error, is_active")
.select("id, provider, label, api_key_last4, validated_at, validation_error, is_active")
.eq("organization_id", activeOrg.orgId)
.eq("provider", "openai")
.in("provider", ["openai", "openrouter"])
.order("created_at", { ascending: true });
return ok(
@@ -53,7 +53,9 @@ export async function GET(): Promise<Response> {
explicacao: chave ? EXPLICACAO_DA_ORIGEM[chave.origem] : null,
chave_em_uso: chave?.rotulo ?? null,
avisos: chave?.avisos ?? [],
credenciais_openai: credenciais ?? [],
credenciais_embedding: credenciais ?? [],
// Compatibilidade com clientes da rota anterior.
credenciais_openai: (credenciais ?? []).filter((c) => c.provider === "openai"),
},
{ requestId },
);
+3 -3
View File
@@ -64,9 +64,9 @@ export default async function AcervoPage() {
resolverChaveDeEmbedding(activeOrg.orgId),
supabase
.from("ai_provider_credentials_safe")
.select("id, label, api_key_last4, validated_at, validation_error, is_active")
.select("id, provider, label, api_key_last4, validated_at, validation_error, is_active")
.eq("organization_id", activeOrg.orgId)
.eq("provider", "openai")
.in("provider", ["openai", "openrouter"])
.order("created_at", { ascending: true }),
]);
@@ -94,7 +94,7 @@ export default async function AcervoPage() {
explicacao: chave ? EXPLICACAO_DA_ORIGEM[chave.origem] : null,
chave_em_uso: chave?.rotulo ?? null,
avisos: chave?.avisos ?? [],
credenciais_openai: (credenciais ?? []) as EstadoDaChave["credenciais_openai"],
credenciais_embedding: (credenciais ?? []) as EstadoDaChave["credenciais_embedding"],
};
return (
+54 -17
View File
@@ -4,8 +4,8 @@ import { useT } from "@/hooks/i18n/useT";
/**
* A CHAVE QUE FAZ O MATERIAL VIRAR CONHECIMENTO — dita na tela, resolvida ali.
*
* Preparar um material para o agente encontrá-lo exige uma chave da OpenAI. Isso
* era verdade e não estava escrito em lugar nenhum do caminho: a tela de
* Preparar um material para o agente encontrá-lo exige uma chave de embedding,
* da OpenAI ou OpenRouter. Antes, a tela de
* conhecimento prometia "a indexação começa em instantes", o material subia, e
* numa instalação sem chave nada acontecia — para sempre, sem erro, sem estado,
* sem aviso.
@@ -39,8 +39,9 @@ export interface EstadoDaChave {
explicacao: string | null;
chave_em_uso: string | null;
avisos: string[];
credenciais_openai: Array<{
credenciais_embedding: Array<{
id: string;
provider: "openai" | "openrouter";
label: string;
api_key_last4: string | null;
validated_at: string | null;
@@ -58,11 +59,11 @@ interface Props {
export function ChaveDeConhecimento({ estado, onChaveCadastrada }: Props) {
const t = useT();
const [abrindo, setAbrindo] = useState(false);
// O rótulo padrão é traduzido porque é o que a pessoa vê preenchido e o que
// ela grava — não um identificador técnico.
const [rotulo, setRotulo] = useState(t("Chave da OpenAI"));
const [provedor, setProvedor] = useState<"openai" | "openrouter">("openai");
const [rotulo, setRotulo] = useState("");
const [chave, setChave] = useState("");
const [enviando, setEnviando] = useState(false);
const nomeDaChave = provedor === "openrouter" ? t("Chave da OpenRouter") : t("Chave da OpenAI");
async function cadastrar() {
if (chave.trim().length < 8) {
@@ -72,11 +73,11 @@ export function ChaveDeConhecimento({ estado, onChaveCadastrada }: Props) {
setEnviando(true);
try {
await apiClient.post("/api/v1/ai/credentials", {
provider: "openai",
label: rotulo.trim() || t("Chave da OpenAI"),
provider: provedor,
label: rotulo.trim() || nomeDaChave,
api_key: chave.trim(),
});
toast.success(t("Chave salva. Estamos conferindo com a OpenAI — leva alguns segundos."));
toast.success(t("Chave salva. Estamos conferindo com o provedor — leva alguns segundos."));
setChave("");
setAbrindo(false);
// Quem recarrega até a validação voltar é o `refetchInterval` do hook: a
@@ -95,7 +96,7 @@ export function ChaveDeConhecimento({ estado, onChaveCadastrada }: Props) {
// uma — e a pessoa cola outra, achando que errou a primeira.
const conferindo =
!estado.pode_indexar &&
estado.credenciais_openai.some((c) => c.is_active && !c.validated_at && !c.validation_error);
estado.credenciais_embedding.some((c) => c.is_active && !c.validated_at && !c.validation_error);
if (conferindo) {
return (
@@ -104,7 +105,7 @@ export function ChaveDeConhecimento({ estado, onChaveCadastrada }: Props) {
className="flex items-center gap-2 text-xs text-text-muted"
>
<KeyRound className="h-3.5 w-3.5 animate-pulse" aria-hidden />
<span>{t("Conferindo a chave com a OpenAI — leva alguns segundos.")}</span>
<span>{t("Conferindo a chave de embedding — leva alguns segundos.")}</span>
</div>
);
}
@@ -149,11 +150,11 @@ export function ChaveDeConhecimento({ estado, onChaveCadastrada }: Props) {
<TriangleAlert className="mt-0.5 h-4 w-4 shrink-0 text-warning-fg" aria-hidden />
<div className="space-y-1">
<h3 className="text-sm font-medium">
{t("Falta uma chave da OpenAI para o agente aprender o seu material")}
{t("Falta uma chave de embedding para o agente aprender o seu material")}
</h3>
<p className="text-xs text-text-muted">
{t(
"Preparar um documento para o agente encontrá-lo usa a OpenAI, mesmo que o resto do seu assistente rode em outro provedor. Sem ela você consegue cadastrar o material, mas ele fica esperando — e o agente segue sem saber o que está nele.",
"O material usa text-embedding-3-small, acessível pela OpenAI ou OpenRouter. Sem uma dessas chaves, você pode cadastrar o documento, mas ele fica esperando para ser preparado.",
)}
</p>
</div>
@@ -161,22 +162,54 @@ export function ChaveDeConhecimento({ estado, onChaveCadastrada }: Props) {
{abrindo ? (
<div className="space-y-3">
<fieldset className="space-y-2">
<legend className="text-sm font-medium">{t("Provedor da chave de embedding")}</legend>
<div className="flex gap-4 text-sm">
<label className="flex items-center gap-2">
<input
type="radio"
name="embedding-provider"
value="openai"
checked={provedor === "openai"}
onChange={() => setProvedor("openai")}
disabled={enviando}
/>
OpenAI
</label>
<label className="flex items-center gap-2">
<input
type="radio"
name="embedding-provider"
value="openrouter"
data-testid="conhecimento-provedor-openrouter"
checked={provedor === "openrouter"}
onChange={() => setProvedor("openrouter")}
disabled={enviando}
/>
OpenRouter
</label>
</div>
<p className="text-xs text-text-muted">
{t("O texto dos materiais é enviado ao provedor escolhido para preparar a busca.")}
</p>
</fieldset>
<div className="space-y-1">
<Label htmlFor="chave-rotulo">{t("Como você quer chamar esta chave")}</Label>
<Input
id="chave-rotulo"
value={rotulo}
onChange={(e) => setRotulo(e.target.value)}
placeholder={nomeDaChave}
disabled={enviando}
/>
</div>
<div className="space-y-1">
<Label htmlFor="chave-valor">{t("Chave da OpenAI")}</Label>
<Label htmlFor="chave-valor">{nomeDaChave}</Label>
<Input
id="chave-valor"
data-testid="conhecimento-chave-input"
type="password"
placeholder="sk-…"
placeholder={provedor === "openrouter" ? "sk-or-…" : "sk-…"}
value={chave}
onChange={(e) => setChave(e.target.value)}
disabled={enviando}
@@ -185,12 +218,16 @@ export function ChaveDeConhecimento({ estado, onChaveCadastrada }: Props) {
<p className="text-xs text-text-muted">
{t("Você pega em")}{" "}
<a
href="https://platform.openai.com/api-keys"
href={
provedor === "openrouter"
? "https://openrouter.ai/keys"
: "https://platform.openai.com/api-keys"
}
target="_blank"
rel="noreferrer"
className="font-medium text-foreground underline underline-offset-4"
>
platform.openai.com/api-keys
{provedor === "openrouter" ? "openrouter.ai/keys" : "platform.openai.com/api-keys"}
</a>
. {t("Ela é guardada cifrada e nunca aparece de volta na tela.")}
</p>
+2 -2
View File
@@ -127,7 +127,7 @@ export function NovoMaterialDialog({ aberto, onFechar, onCriado, podeIndexar }:
toast.success(
podeIndexar
? t("Material cadastrado. Estou preparando — em instantes o agente já sabe.")
: t("Material cadastrado. Ele fica esperando a chave da OpenAI para ser preparado."),
: t("Material cadastrado. Ele fica esperando uma chave de embedding para ser preparado."),
);
limpar();
onCriado();
@@ -245,7 +245,7 @@ export function NovoMaterialDialog({ aberto, onFechar, onCriado, podeIndexar }:
{!podeIndexar ? (
<p className="text-xs text-warning-fg" data-testid="material-aviso-sem-chave">
{t(
"Sem uma chave da OpenAI, o material fica guardado e esperando — o agente só passa a conhecê-lo depois que a chave for cadastrada.",
"Sem uma chave de embedding, o material fica guardado e esperando — o agente só passa a conhecê-lo depois que a chave for cadastrada.",
)}
</p>
) : null}
+5 -11
View File
@@ -76,7 +76,7 @@ fica em `http://127.0.0.1:54323` e o WAHA em `http://127.0.0.1:3030`.
4. [Upstash Redis — rate limit + idempotência](#2-upstash-redis--rate-limit--idempotência)
5. [WAHA — WhatsApp](#3-waha--whatsapp)
6. [Anthropic + Vercel AI Gateway — IA](#4-anthropic--vercel-ai-gateway--ia)
7. [OpenAI — embeddings do RAG](#5-openai--embeddings-do-rag)
7. [OpenAI ou OpenRouter — embeddings do RAG](#5-openai-ou-openrouter--embeddings-do-rag)
8. [Sentry — monitoramento de erros](#6-sentry--monitoramento-de-erros)
9. [E-mail transacional — SMTP ou Resend](#7-e-mail-transacional--smtp-ou-resend)
10. [Nuvemshop — integração e-commerce](#8-nuvemshop--integração-e-commerce)
@@ -119,7 +119,7 @@ Se você quer rodar o app o mais rápido possível com o mínimo viável:
**🟡 Pra testar features de IA (+10 min):**
4. [Anthropic](#4-anthropic--vercel-ai-gateway--ia) ou Vercel AI Gateway.
5. [OpenAI](#5-openai--embeddings-do-rag) — embeddings do RAG.
5. [OpenAI ou OpenRouter](#5-openai-ou-openrouter--embeddings-do-rag) — embeddings do RAG.
**🟡 Pra testar WhatsApp (+15 min):**
6. [WAHA](#3-waha--whatsapp) + ngrok (precisa URL pública).
@@ -350,17 +350,11 @@ ANTHROPIC_API_KEY=sk-ant-api03-...
---
## 5. OpenAI — embeddings do RAG
## 5. OpenAI ou OpenRouter — embeddings do RAG
**O que é:** Usado **só** pra gerar embeddings (vetores) das bases de conhecimento dos tenants pro chatbot RAG. Não usamos GPT pra gerar texto — esse trabalho é do Claude. **Custo:** baratíssimo. `text-embedding-3-small` = $0.02 / 1M tokens.
O acervo usa `openai/text-embedding-3-small` com 1536 dimensões para indexar e buscar. A chave pode vir da OpenAI ou da OpenRouter; não troque o modelo só de um lado, porque o agente deixaria de encontrar o material.
1. Acesse <https://platform.openai.com> → **Sign up**. 💳
2. Adicione método de pagamento (eles não dão mais crédito grátis em conta nova).
3. **API Keys → Create new secret key** → nome `deskcomm-dev-embeddings` → copie.
```env
OPENAI_API_KEY=sk-proj-...
```
Cadastre a chave em **IA → Credenciais** ou no próprio acervo, escolhendo o provedor. Também é possível definir `OPENAI_API_KEY` ou `OPENROUTER_API_KEY` na instalação. Uma credencial ativa da organização tem prioridade sobre a chave da instalação; quando há credenciais dos dois provedores, a OpenAI mantém a precedência anterior. A chave OpenAI continua sendo necessária para transcrever áudio, mesmo que o acervo use OpenRouter.
---
+3 -3
View File
@@ -71,13 +71,13 @@ export function useKnowledgeSources(opts?: { initialData?: SourceRow[] }) {
* ## Por que isto tem `refetchInterval`
*
* A validação da chave com o provedor acontece EM SEGUNDO PLANO — `guardarCredencial`
* responde assim que cifra e grava, e só depois confirma com a OpenAI. Entre os
* responde assim que cifra e grava, e só depois confirma com o provedor. Entre os
* dois momentos a chave existe e ainda não é utilizável, então a tela continua
* dizendo "falta uma chave" para quem acabou de colá-la. Medido na prova de
* tela: a credencial estava no banco e validada, e a tela seguia mostrando o
* aviso — só um F5 mudava.
*
* O intervalo é ligado APENAS nesse estado transitório: existe credencial OpenAI
* O intervalo é ligado APENAS nesse estado transitório: existe credencial de embedding
* e ela ainda não serve. Fora dele o polling seria custo sem informação.
*/
export function useEstadoDaChave(initial?: EstadoDaChave) {
@@ -90,7 +90,7 @@ export function useEstadoDaChave(initial?: EstadoDaChave) {
refetchInterval: (query) => {
const d = query.state.data;
if (!d) return false;
const esperandoValidacao = !d.pode_indexar && d.credenciais_openai.length > 0;
const esperandoValidacao = !d.pode_indexar && d.credenciais_embedding.length > 0;
return esperandoValidacao ? 2_000 : false;
},
...(initial ? { initialData: initial } : {}),
+1 -1
View File
@@ -285,7 +285,7 @@ y, si de verdad es un Traefik, pon REVERSE_PROXY=traefik en el .env y vuelve a e
["Chave da OpenRouter — a IA que atende (openrouter.ai/keys; Enter pula: dá para cadastrar depois pela tela, em IA › Credenciais)"]="Clave de OpenRouter — la IA que atiende (openrouter.ai/keys; Enter la salta: puedes registrarla después desde la pantalla, en IA › Credenciales)"
["Chave da OpenAI — a IA que atende (platform.openai.com/api-keys; Enter pula: dá para cadastrar depois pela tela, em IA › Credenciais)"]="Clave de OpenAI — la IA que atiende (platform.openai.com/api-keys; Enter la salta: puedes registrarla después desde la pantalla, en IA › Credenciales)"
["Chave da Anthropic — a IA que atende (console.anthropic.com; Enter pula: dá para cadastrar depois pela tela, em IA › Credenciais)"]="Clave de Anthropic — la IA que atiende (console.anthropic.com; Enter la salta: puedes registrarla después desde la pantalla, en IA › Credenciales)"
["Chave da OpenAI — só para ouvir áudios e usar a base de conhecimento (Enter pula: dá para cadastrar depois pela tela, em IA › Credenciais)"]="Clave de OpenAI — solo para escuchar audios y usar la base de conocimiento (Enter la salta: puedes registrarla después desde la pantalla, en IA › Credenciales)"
["Chave da OpenAI — para ouvir áudios; a base de conhecimento aceita OpenRouter (Enter pula: dá para cadastrar depois pela tela, em IA › Credenciais)"]="Clave de OpenAI — para escuchar audios; la base de conocimiento acepta OpenRouter (Enter la salta: puedes registrarla después desde la pantalla, en IA › Credenciales)"
["E-mail do primeiro admin (dono)"]="Correo del primer admin (dueño)"
["Senha do primeiro admin (mínimo 8 caracteres)"]="Contraseña del primer admin (mínimo 8 caracteres)"
["Nome que aparece na interface (Enter para o padrão)"]="Nombre que aparece en la interfaz (Enter para el predeterminado)"
+3 -3
View File
@@ -1214,15 +1214,15 @@ case "$AI_PROVIDER" in
esac
# A chave da OpenAI é pedida À PARTE quando ela NÃO é o provedor de conversa,
# porque dois pontos do sistema dependem dela mesmo assim: ouvir áudio (o
# Whisper é da OpenAI) e indexar a base de conhecimento. Sem esta linha, quem
# porque ouvir áudio ainda depende dela (o Whisper é da OpenAI). A base de
# conhecimento também aceita a chave OpenRouter, com o mesmo modelo fixo. Sem esta linha, quem
# escolhe OpenRouter instala achando que está completo e descobre semanas depois
# que o agente nunca ouviu um áudio — que é exatamente o defeito já visto em
# produção, com a chave certa no .env e indo para o endpoint errado.
if [ "$AI_PROVIDER" = "openai" ]; then
CAMPO_OPENAI_EXTRA=""
else
CAMPO_OPENAI_EXTRA="OPENAI_API_KEY|Chave da OpenAI — só para ouvir áudios e usar a base de conhecimento (Enter pula: dá para cadastrar depois pela tela, em IA › Credenciais)||v_openai|secret|opcional"
CAMPO_OPENAI_EXTRA="OPENAI_API_KEY|Chave da OpenAI — para ouvir áudios; a base de conhecimento aceita OpenRouter (Enter pula: dá para cadastrar depois pela tela, em IA › Credenciais)||v_openai|secret|opcional"
fi
# ── A versão que esta instalação vai rodar ───────────────────────────────────
+38 -1
View File
@@ -20,7 +20,7 @@
* a chave da organização é usada, e a ausência dela vira erro TIPADO em vez de
* uma falha genérica que a tela não sabe traduzir.
*/
import { beforeEach, describe, expect, it, vi } from "vitest";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
const embedSpy = vi.fn();
vi.mock("ai", () => ({
@@ -53,6 +53,7 @@ beforeEach(() => {
chaveMock = () => ({
apiKey: "sk-da-organizacao",
baseUrl: null,
provedor: "openai",
viaGateway: false,
origem: "credencial_da_organizacao",
rotulo: "Chave principal",
@@ -60,6 +61,8 @@ beforeEach(() => {
});
});
afterEach(() => vi.unstubAllGlobals());
describe("embedText", () => {
it("SEM gateway, usa o provider OpenAI explícito — nunca a string com barra", async () => {
await embedText("oi", { organizationId: "org-1" });
@@ -79,6 +82,7 @@ describe("embedText", () => {
chaveMock = () => ({
apiKey: null,
baseUrl: null,
provedor: "gateway",
viaGateway: true,
origem: "gateway_da_instalacao",
rotulo: null,
@@ -98,6 +102,38 @@ describe("embedText", () => {
expect(r.promptTokens).toBe(7);
});
it("OpenRouter recebe o id completo do modelo fixo de embedding", async () => {
const fetchSpy = vi.fn(async () => new Response(JSON.stringify({
data: [{ object: "embedding", index: 0, embedding: Array(1536).fill(0) }],
usage: { prompt_tokens: 1, total_tokens: 1 },
}), { status: 200, headers: { "content-type": "application/json" } }));
vi.stubGlobal("fetch", fetchSpy);
chaveMock = () => ({
apiKey: "sk-or-teste",
baseUrl: "https://openrouter.ai/api/v1",
provedor: "openrouter",
viaGateway: false,
origem: "credencial_da_organizacao",
rotulo: "OpenRouter",
avisos: [],
});
await embedText("oi", { organizationId: "org-1" });
const arg = embedSpy.mock.calls[0]?.[0] as {
model: { modelId: string; doEmbed: (args: { values: string[] }) => Promise<unknown> };
};
expect(arg.model.modelId).toBe("openai/text-embedding-3-small");
await arg.model.doEmbed({ values: ["oi"] });
const [url, request] = fetchSpy.mock.calls[0] as unknown as [string, RequestInit];
expect(url).toBe("https://openrouter.ai/api/v1/embeddings");
expect(JSON.parse(String(request.body))).toMatchObject({
model: "openai/text-embedding-3-small",
input: ["oi"],
});
expect(request.headers).toMatchObject({ authorization: "Bearer sk-or-teste" });
});
it("organização SEM chave nenhuma vira erro tipado, não uma falha genérica", async () => {
chaveMock = () => null;
@@ -125,6 +161,7 @@ describe("embedText", () => {
const chave = {
apiKey: "sk-x",
baseUrl: null,
provedor: "openai" as const,
viaGateway: false,
origem: "credencial_da_organizacao" as const,
rotulo: "x",
+10 -7
View File
@@ -2,8 +2,8 @@
* Embedding do RAG — indexação e busca, o mesmo modelo dos dois lados.
*
* A chave vem de `lib/ai/embeddings/chave.ts`, que resolve pela organização:
* binding do ponto → credencial OpenAI da org → gateway da instalação → chave
* da instalação. Até a 0181 este arquivo lia SÓ `process.env`, e o efeito era o
* binding do ponto → credencial OpenAI/OpenRouter da org → gateway da instalação
* → chave da instalação. Até a 0181 este arquivo lia SÓ `process.env`, e o efeito era o
* pior possível para quem instala: cadastrar a chave da OpenAI pela tela não
* habilitava a base de conhecimento, enquanto duas telas do produto prometiam
* que sim.
@@ -49,8 +49,8 @@ export class SemChaveDeEmbeddingError extends Error {
readonly code = "embedding_sem_chave";
constructor(readonly organizationId: string) {
super(
"Esta organização não tem chave da OpenAI para indexar nem consultar o material. " +
"Cadastre uma em Credenciais, ou defina OPENAI_API_KEY na instalação.",
"Esta organização não tem chave de embedding para indexar nem consultar o material. " +
"Cadastre uma chave OpenAI ou OpenRouter em Credenciais.",
);
this.name = "SemChaveDeEmbeddingError";
}
@@ -72,16 +72,19 @@ export async function embedText(
// lê `AI_GATEWAY_API_KEY` do process.env. Headers vão junto p/ observabilidade
// por tenant + ZDR.
//
// SEM gateway: precisa ser o provider OpenAI EXPLÍCITO. Passar a string com
// SEM gateway: precisa ser um provider EXPLÍCITO. Passar a string com
// barra aqui não cai no OpenAI direto — no AI SDK, id com barra é resolvido
// pelo gateway da Vercel mesmo sem chave, entrando no plano anônimo, cujo teto
// devolve `GatewayRateLimitError` e derruba a busca na base de conhecimento.
// devolve `GatewayRateLimitError`. OpenAI direto usa id sem prefixo; OpenRouter
// recebe o slug completo que a API de embeddings dela exige.
const resolvido = chave.viaGateway
? modelId
: createOpenAI({
apiKey: chave.apiKey ?? "",
...(chave.baseUrl ? { baseURL: chave.baseUrl } : {}),
}).textEmbeddingModel(modelId.replace(/^openai\//, ""));
}).textEmbeddingModel(
chave.provedor === "openrouter" ? modelId : modelId.replace(/^openai\//, ""),
);
const result = await embed({
model: resolvido,
+146
View File
@@ -0,0 +1,146 @@
import { beforeEach, describe, expect, it, vi } from "vitest";
const state = vi.hoisted(() => ({
bindings: [] as Array<Record<string, unknown>>,
credentials: [] as Array<Record<string, unknown>>,
env: {
AI_GATEWAY_API_KEY: "",
AI_GATEWAY_BASE_URL: "",
OPENAI_API_KEY: "",
OPENROUTER_API_KEY: "",
OPENROUTER_BASE_URL: "",
},
}));
vi.mock("@/lib/env", () => ({ env: state.env }));
vi.mock("@/lib/ai/gateway", () => ({ OPENROUTER_BASE_URL: "https://openrouter.ai/api/v1" }));
vi.mock("@/lib/logger", () => ({ logger: { warn: vi.fn() } }));
vi.mock("@/lib/crypto/aes_gcm", () => ({
byteaToBuffer: (v: unknown) => v,
decryptKey: ({ ciphertext }: { ciphertext: unknown }) => ciphertext,
}));
vi.mock("@/lib/supabase/admin", () => ({
createAdminClient: () => ({
from: (table: string) => {
const rows = table === "ai_purpose_bindings" ? state.bindings : state.credentials;
const filters: Array<(row: Record<string, unknown>) => boolean> = [];
const query = {
select: () => query,
eq: (column: string, value: unknown) => {
filters.push((row: Record<string, unknown>) => row[column] === value);
return query;
},
not: (column: string, _operator: string, value: unknown) => {
filters.push((row: Record<string, unknown>) => row[column] !== value);
return query;
},
order: async () => ({ data: rows.filter((row) => filters.every((f) => f(row))) }),
maybeSingle: async () => ({
data: rows.find((row) => filters.every((f) => f(row))) ?? null,
}),
};
return query;
},
}),
}));
import { resolverChaveDeEmbedding } from "@/lib/ai/embeddings/chave";
function credential(overrides: Record<string, unknown>) {
return {
id: "cred-1",
organization_id: "org-1",
provider: "openrouter",
label: "Chave de conhecimento",
api_key_encrypted: "sk-or-teste",
api_key_iv: "iv",
api_key_tag: "tag",
is_active: true,
validated_at: "2026-09-28T00:00:00Z",
created_at: "2026-09-28T00:00:00Z",
...overrides,
};
}
beforeEach(() => {
state.bindings = [];
state.credentials = [];
state.env.AI_GATEWAY_API_KEY = "";
state.env.OPENAI_API_KEY = "";
state.env.OPENROUTER_API_KEY = "";
});
describe("resolverChaveDeEmbedding", () => {
it("credencial OpenRouter validada serve à indexação e à consulta da própria organização", async () => {
state.credentials = [
credential({ organization_id: "outra-org", api_key_encrypted: "chave-alheia" }),
credential({}),
];
const indexar = await resolverChaveDeEmbedding("org-1", "embedding_indexar");
const consultar = await resolverChaveDeEmbedding("org-1", "embedding_consultar");
for (const chave of [indexar, consultar]) {
expect(chave).toMatchObject({
apiKey: "sk-or-teste",
baseUrl: "https://openrouter.ai/api/v1",
provedor: "openrouter",
origem: "credencial_da_organizacao",
});
}
expect(await resolverChaveDeEmbedding("outra-org")).toMatchObject({ apiKey: "chave-alheia" });
});
it("preserva a precedência da credencial OpenAI existente", async () => {
state.credentials = [
credential({}),
credential({ id: "cred-openai", provider: "openai", api_key_encrypted: "sk-openai-teste" }),
];
expect(await resolverChaveDeEmbedding("org-1")).toMatchObject({
apiKey: "sk-openai-teste",
baseUrl: null,
provedor: "openai",
});
});
it("não usa credencial OpenRouter inativa ou ainda não validada", async () => {
state.credentials = [
credential({ is_active: false }),
credential({ id: "cred-2", validated_at: null }),
];
expect(await resolverChaveDeEmbedding("org-1")).toBeNull();
});
it("aceita a chave OpenRouter da instalação quando não há credencial da organização", async () => {
state.env.OPENROUTER_API_KEY = "sk-or-instalacao";
expect(await resolverChaveDeEmbedding("org-1")).toMatchObject({
apiKey: "sk-or-instalacao",
baseUrl: "https://openrouter.ai/api/v1",
provedor: "openrouter",
origem: "chave_da_instalacao",
});
});
it("binding explícito de OpenRouter usa a própria credencial e o modelo permanece fixo", async () => {
state.bindings = [
{
organization_id: "org-1",
purpose: "embedding_indexar",
is_enabled: true,
credential_id: "cred-1",
model_id: "openai/text-embedding-3-small",
base_url: null,
},
];
state.credentials = [credential({})];
expect(await resolverChaveDeEmbedding("org-1")).toMatchObject({
provedor: "openrouter",
baseUrl: "https://openrouter.ai/api/v1",
origem: "binding_do_ponto",
});
});
});
+50 -20
View File
@@ -25,12 +25,12 @@
* 1. **Binding do ponto** (`ai_purpose_bindings`) — a escolha explícita feita
* no painel de provedores. É a superfície que o operador enxerga, então ela
* vence.
* 2. **Credencial OpenAI da organização** (`ai_provider_credentials`, ativa e
* validada). É o degrau que faz "cadastrei a chave na tela e funcionou"
* 2. **Credencial da organização** (`ai_provider_credentials`, ativa e
* validada): OpenAI primeiro, OpenRouter como alternativa. É o degrau que faz "cadastrei a chave na tela e funcionou"
* virar verdade sem exigir que ninguém entenda o que é um binding.
* 3. **Gateway da Vercel** (`AI_GATEWAY_API_KEY`) — quando a instalação roteia
* tudo por ele.
* 4. **Chave da instalação** (`OPENAI_API_KEY`) — o que o `install.sh` pede.
* 4. **Chave da instalação** (`OPENAI_API_KEY`, depois `OPENROUTER_API_KEY`).
* 5. Nada. E "nada" é uma resposta legítima que o chamador precisa saber
* mostrar, não um erro para engolir.
*
@@ -47,6 +47,7 @@
* com que modelo foi calculada (`ai_knowledge_versions.embedding_model`).
*/
import { byteaToBuffer, decryptKey } from "@/lib/crypto/aes_gcm";
import { OPENROUTER_BASE_URL } from "@/lib/ai/gateway";
import { env } from "@/lib/env";
import { logger } from "@/lib/logger";
import { createAdminClient } from "@/lib/supabase/admin";
@@ -66,7 +67,7 @@ export type OrigemDaChave =
export const EXPLICACAO_DA_ORIGEM: Record<OrigemDaChave, string> = {
binding_do_ponto: "Escolhida por você no painel de Provedores.",
credencial_da_organizacao: "Usando a chave OpenAI cadastrada em Credenciais.",
credencial_da_organizacao: "Usando uma chave da organização cadastrada em Credenciais.",
gateway_da_instalacao: "Usando o gateway de IA configurado nesta instalação.",
chave_da_instalacao: "Usando a chave que veio na instalação.",
};
@@ -74,8 +75,9 @@ export const EXPLICACAO_DA_ORIGEM: Record<OrigemDaChave, string> = {
export interface ChaveDeEmbedding {
/** Plaintext. Vive só no escopo de quem chamou — nunca logada nem persistida. */
apiKey: string | null;
/** `null` = falar direto com a OpenAI. */
/** `null` = falar direto com a OpenAI, quando não houver gateway. */
baseUrl: string | null;
provedor: "openai" | "openrouter" | "gateway";
/** Quando true, a chamada vai pelo gateway (o SDK lê a chave do process.env). */
viaGateway: boolean;
origem: OrigemDaChave;
@@ -113,7 +115,9 @@ export async function resolverChaveDeEmbedding(
}
return {
apiKey: credencial.apiKey,
baseUrl: binding.base_url,
baseUrl:
binding.base_url ?? (credencial.provedor === "openrouter" ? OPENROUTER_BASE_URL : null),
provedor: credencial.provedor,
viaGateway: false,
origem: "binding_do_ponto",
rotulo: credencial.rotulo,
@@ -126,18 +130,22 @@ export async function resolverChaveDeEmbedding(
);
}
// 2 · A credencial OpenAI da organização, sem exigir binding nenhum.
const daOrg = await credencialOpenAiDaOrganizacao(organizationId);
// 2 · Credencial da organização, sem exigir binding nenhum. OpenAI conserva
// a precedência anterior; OpenRouter atende quem não cadastrou OpenAI.
const daOrg =
(await credencialDaOrganizacao(organizationId, "openai")) ??
(await credencialDaOrganizacao(organizationId, "openrouter"));
if (daOrg) {
if (daOrg.quantas > 1) {
avisos.push(
`Esta organização tem ${daOrg.quantas} chaves OpenAI cadastradas e nenhuma escolhida para ` +
`a base de conhecimento. Usando "${daOrg.rotulo}" — escolha uma em Provedores para não depender disso.`,
`Esta organização tem ${daOrg.quantas} chaves ${daOrg.provedor} cadastradas e nenhuma escolhida para ` +
`a base de conhecimento. Usando "${daOrg.rotulo}" — desative as excedentes em Credenciais para não depender da ordem.`,
);
}
return {
apiKey: daOrg.apiKey,
baseUrl: null,
baseUrl: daOrg.provedor === "openrouter" ? OPENROUTER_BASE_URL : null,
provedor: daOrg.provedor,
viaGateway: false,
origem: "credencial_da_organizacao",
rotulo: daOrg.rotulo,
@@ -150,6 +158,7 @@ export async function resolverChaveDeEmbedding(
return {
apiKey: null,
baseUrl: env.AI_GATEWAY_BASE_URL || null,
provedor: "gateway",
viaGateway: true,
origem: "gateway_da_instalacao",
rotulo: null,
@@ -157,11 +166,24 @@ export async function resolverChaveDeEmbedding(
};
}
// 4 · A chave que o install.sh pediu.
// 4 · Chaves da instalação. OpenAI conserva a precedência anterior.
if (env.OPENAI_API_KEY) {
return {
apiKey: env.OPENAI_API_KEY,
baseUrl: null,
provedor: "openai",
viaGateway: false,
origem: "chave_da_instalacao",
rotulo: null,
avisos,
};
}
if (env.OPENROUTER_API_KEY) {
return {
apiKey: env.OPENROUTER_API_KEY,
baseUrl: OPENROUTER_BASE_URL,
provedor: "openrouter",
viaGateway: false,
origem: "chave_da_instalacao",
rotulo: null,
@@ -223,18 +245,18 @@ async function lerBindingDeEmbedding(
async function decifrarCredencial(
credentialId: string,
organizationId: string,
): Promise<{ apiKey: string; rotulo: string } | null> {
): Promise<{ apiKey: string; rotulo: string; provedor: "openai" | "openrouter" } | null> {
try {
const admin = createAdminClient();
const { data } = await admin
.from("ai_provider_credentials")
.select("label, api_key_encrypted, api_key_iv, api_key_tag")
.select("provider, label, api_key_encrypted, api_key_iv, api_key_tag")
.eq("id", credentialId)
.eq("organization_id", organizationId)
.eq("is_active", true)
.not("validated_at", "is", null)
.maybeSingle();
if (!data) return null;
if (!data || (data.provider !== "openai" && data.provider !== "openrouter")) return null;
return {
apiKey: decryptKey({
ciphertext: byteaToBuffer(data.api_key_encrypted),
@@ -242,6 +264,7 @@ async function decifrarCredencial(
tag: byteaToBuffer(data.api_key_tag),
}),
rotulo: String((data as { label?: string }).label ?? ""),
provedor: data.provider,
};
} catch {
// Sem detalhe no log: qualquer eco aqui corre o risco de carregar material
@@ -251,23 +274,29 @@ async function decifrarCredencial(
}
/**
* A credencial OpenAI ativa e validada da organização.
* A credencial ativa e validada da organização, OpenAI ou OpenRouter.
*
* Desempate DETERMINÍSTICO pela mais antiga: com duas chaves e nenhuma escolha,
* "a mais recente" faria o comportamento mudar sozinho no dia em que alguém
* cadastrasse outra. A tela evita o caso oferecendo a escolha; aqui o que
* cadastrasse outra. A tela de Credenciais deixa desativar as excedentes; aqui o que
* importa é não variar.
*/
async function credencialOpenAiDaOrganizacao(
async function credencialDaOrganizacao(
organizationId: string,
): Promise<{ apiKey: string; rotulo: string; quantas: number } | null> {
provedor: "openai" | "openrouter",
): Promise<{
apiKey: string;
rotulo: string;
quantas: number;
provedor: "openai" | "openrouter";
} | null> {
try {
const admin = createAdminClient();
const { data } = await admin
.from("ai_provider_credentials")
.select("id, label, api_key_encrypted, api_key_iv, api_key_tag")
.eq("organization_id", organizationId)
.eq("provider", "openai")
.eq("provider", provedor)
.eq("is_active", true)
.not("validated_at", "is", null)
.order("created_at", { ascending: true });
@@ -290,6 +319,7 @@ async function credencialOpenAiDaOrganizacao(
}),
rotulo: primeira.label,
quantas: linhas.length,
provedor,
};
} catch {
return null;
+3 -2
View File
@@ -85,8 +85,9 @@ export function resolveLanguageModel(model: ModelId): LanguageModel | null {
export function isEmbeddingProviderConfigured(): boolean {
// Embeddings go through the gateway when `AI_GATEWAY_API_KEY` is set;
// otherwise the worker calls `openai/...` directly via OPENAI_API_KEY.
return Boolean(env.AI_GATEWAY_API_KEY) || Boolean(env.OPENAI_API_KEY);
// otherwise the worker uses an OpenAI or OpenRouter key from the installation.
// Tenant credentials are resolved by temChaveDeEmbedding(organizationId).
return Boolean(env.AI_GATEWAY_API_KEY || env.OPENAI_API_KEY || env.OPENROUTER_API_KEY);
}
/**
+1 -1
View File
@@ -60,7 +60,7 @@ export const PROVEDORES = [
id: "openai",
rotulo: "OpenAI (GPT)",
quandoUsar:
"Necessário para transcrever áudio e para indexar o seu material — esses dois pontos usam tecnologia da OpenAI mesmo quando o resto está em outro provedor.",
"Na configuração padrão, transcreve áudio. Para indexar material, uma chave OpenAI ou OpenRouter atende ao mesmo modelo de embedding.",
aceitaEndpointProprio: true,
catalogoSincronizavel: false,
ondePegarAChave: "https://platform.openai.com/api-keys",
+12 -9
View File
@@ -2642,8 +2642,8 @@ export const DICIONARIO: Traducoes = {
"O padrão recomendado para conversar com o cliente: é o que melhor segue instruções longas e usa as ferramentas do CRM.": {
es: "La opción recomendada por defecto para conversar con el cliente. Es la que mejor sigue instrucciones largas y usa las herramientas del CRM.",
},
"Necessário para transcrever áudio e para indexar o seu material — esses dois pontos usam tecnologia da OpenAI mesmo quando o resto está em outro provedor.": {
es: "Necesario para transcribir audio e indexar tu material: ambas funciones usan tecnología de OpenAI, aunque el resto esté con otro proveedor.",
"Na configuração padrão, transcreve áudio. Para indexar material, uma chave OpenAI ou OpenRouter atende ao mesmo modelo de embedding.": {
es: "En la configuración predeterminada, transcribe audio. Para indexar material, una clave de OpenAI u OpenRouter puede usar el mismo modelo de embeddings.",
},
"Alternativa com contexto muito longo e custo baixo para tarefas de classificação.": {
es: "Alternativa con contexto muy largo y bajo costo para tareas de clasificación.",
@@ -8642,10 +8642,13 @@ export const DICIONARIO: Traducoes = {
"Os dias ficam bloqueados até eu conseguir — é mais seguro que oferecer um horário que talvez não exista. Numa instalação nova, isso costuma ser a jornada de atendimento que ainda não foi publicada.": { es: "Los días quedan bloqueados hasta que lo consiga; es más seguro que ofrecer un horario que quizá no exista. En una instalación nueva, esto suele ocurrir porque la jornada de atención aún no se ha publicado." },
"Nenhum horário livre em": { es: "Ningún horario libre en" },
"Não há horário livre publicado neste mês.": { es: "No hay horario libre publicado en este mes." },
"Conferindo a chave com a OpenAI — leva alguns segundos.": { es: "Verificando la clave con OpenAI. Tarda unos segundos." },
"Conferindo a chave de embedding — leva alguns segundos.": { es: "Verificando la clave de embeddings. Tarda unos segundos." },
"Pronto para preparar material.": { es: "Listo para preparar material." },
"Falta uma chave da OpenAI para o agente aprender o seu material": { es: "Falta una clave de OpenAI para que el agente aprenda tu material" },
"Preparar um documento para o agente encontrá-lo usa a OpenAI, mesmo que o resto do seu assistente rode em outro provedor. Sem ela você consegue cadastrar o material, mas ele fica esperando — e o agente segue sem saber o que está nele.": { es: "Para preparar un documento y que el agente pueda encontrarlo se usa OpenAI, aunque el resto de tu asistente funcione con otro proveedor. Sin una clave, puedes registrar el material, pero queda en espera y el agente no sabrá qué contiene." },
"Falta uma chave de embedding para o agente aprender o seu material": { es: "Falta una clave de embeddings para que el agente aprenda tu material" },
"O material usa text-embedding-3-small, acessível pela OpenAI ou OpenRouter. Sem uma dessas chaves, você pode cadastrar o documento, mas ele fica esperando para ser preparado.": { es: "El material usa text-embedding-3-small, accesible mediante OpenAI u OpenRouter. Sin una de esas claves, puedes registrar el documento, pero quedará pendiente de preparación." },
"Provedor da chave de embedding": { es: "Proveedor de la clave de embeddings" },
"O texto dos materiais é enviado ao provedor escolhido para preparar a busca.": { es: "El texto de los materiales se envía al proveedor elegido para preparar la búsqueda." },
"Chave da OpenRouter": { es: "Clave de OpenRouter" },
"Como você quer chamar esta chave": { es: "Cómo quieres llamar a esta clave" },
"Você pega em": { es: "La consigues en" },
". Ela é guardada cifrada e nunca aparece de volta na tela.": { es: ". Se guarda cifrada y nunca vuelve a aparecer en la pantalla." },
@@ -8661,7 +8664,7 @@ export const DICIONARIO: Traducoes = {
"Que tipo de material é": { es: "Qué tipo de material es" },
"PDF, Markdown, CSV ou texto, até 20 MB. Um PDF só de imagens escaneadas não tem letra nenhuma para ler — envie uma versão com texto selecionável. Planilha Excel? Salve como CSV primeiro.": { es: "PDF, Markdown, CSV o texto, de hasta 20 MB. Un PDF que solo tiene imágenes escaneadas no contiene texto que se pueda leer: sube una versión con texto seleccionable. ¿Tienes una hoja de cálculo de Excel? Guárdala primero como CSV." },
"…ou cole o texto aqui": { es: "…o pega el texto aquí" },
"Sem uma chave da OpenAI, o material fica guardado e esperando — o agente só passa a conhecê-lo depois que a chave for cadastrada.": { es: "Sin una clave de OpenAI, el material queda guardado en espera. El agente solo lo conocerá cuando registres la clave." },
"Sem uma chave de embedding, o material fica guardado e esperando — o agente só passa a conhecê-lo depois que a chave for cadastrada.": { es: "Sin una clave de embeddings, el material queda guardado en espera. El agente solo lo conocerá cuando registres la clave." },
"São estes os trechos que ele procura antes de responder. Quando ele erra sobre este assunto, é aqui que se vê o porquê.": { es: "Estos son los fragmentos que busca antes de responder. Cuando se equivoca sobre este tema, aquí se ve por qué." },
"Não consegui ler os trechos agora.": { es: "No pude leer los fragmentos ahora." },
"Este material ainda não foi preparado — não há trecho nenhum para o agente encontrar.": { es: "Este material todavía no se ha preparado, así que no hay ningún fragmento que el agente pueda encontrar." },
@@ -9361,7 +9364,7 @@ export const DICIONARIO: Traducoes = {
"Ver o acervo": { es: "Ver el acervo" },
"Chave da OpenAI": { es: "Clave de OpenAI" },
"Cole a chave inteira antes de salvar.": { es: "Pega la clave completa antes de guardar." },
"Chave salva. Estamos conferindo com a OpenAI — leva alguns segundos.": { es: "Clave guardada. La estamos verificando con OpenAI. Tardará unos segundos." },
"Chave salva. Estamos conferindo com o provedor — leva alguns segundos.": { es: "Clave guardada. La estamos verificando con el proveedor. Tardará unos segundos." },
"Usando a chave": { es: "Usando la clave" },
"Ela é guardada cifrada e nunca aparece de volta na tela.": { es: "Se guarda cifrada y nunca vuelve a aparecer en pantalla." },
"Salvar chave": { es: "Guardar clave" },
@@ -9372,7 +9375,7 @@ export const DICIONARIO: Traducoes = {
"Envie um arquivo ou cole o conteúdo.": { es: "Envía un archivo o pega el contenido." },
"Não consegui guardar o arquivo.": { es: "No pude guardar el archivo." },
"Material cadastrado. Estou preparando — em instantes o agente já sabe.": { es: "Material registrado. Lo estoy preparando; en unos instantes el agente ya lo sabrá." },
"Material cadastrado. Ele fica esperando a chave da OpenAI para ser preparado.": { es: "Material registrado. Queda a la espera de la clave de OpenAI para prepararse." },
"Material cadastrado. Ele fica esperando uma chave de embedding para ser preparado.": { es: "Material registrado. Queda a la espera de una clave de embeddings para prepararse." },
"Ensinar algo novo ao agente": { es: "Enseñarle algo nuevo al agente" },
"Nome do material": { es: "Nombre del material" },
"Perguntas frequentes da loja": { es: "Preguntas frecuentes de la tienda" },
@@ -9407,7 +9410,7 @@ export const DICIONARIO: Traducoes = {
"Os produtos sincronizados da sua loja, com preço, descrição e disponibilidade.": { es: "Los productos sincronizados de tu tienda, con precio, descripción y disponibilidad." },
"Entra sozinho: os produtos vêm da sincronização com a sua loja, não de conteúdo digitado aqui.": { es: "Entra solo: los productos vienen de la sincronización con tu tienda, no de contenido escrito aquí." },
"Escolhida por você no painel de Provedores.": { es: "La elegiste en el panel de Proveedores." },
"Usando a chave OpenAI cadastrada em Credenciais.": { es: "Usando la clave de OpenAI registrada en Credenciales." },
"Usando uma chave da organização cadastrada em Credenciais.": { es: "Usando una clave de la organización registrada en Credenciales." },
"Usando o gateway de IA configurado nesta instalação.": { es: "Usando el gateway de IA configurado en esta instalación." },
"Usando a chave que veio na instalação.": { es: "Usando la clave que vino con la instalación." },
"A chave escolhida no painel de Provedores para este ponto não está utilizável (desativada, apagada ou ainda não validada). Seguindo com a próxima chave disponível.": { es: "La clave que elegiste en el panel de Proveedores para este punto no se puede usar (está desactivada, eliminada o aún sin validar). Se usará la siguiente clave disponible." },
+2
View File
@@ -173,6 +173,8 @@ test.describe("acervo de conhecimento", () => {
// O aviso não é um beco: o conserto abre na própria tela.
await page.getByTestId("conhecimento-cadastrar-chave").click();
await expect(page.getByTestId("conhecimento-chave-input")).toBeVisible();
await page.getByTestId("conhecimento-provedor-openrouter").check();
await expect(page.getByTestId("conhecimento-chave-input")).toHaveAttribute("placeholder", "sk-or-…");
}
});
@@ -92,7 +92,7 @@ const CHAVE_OK: EstadoDaChave = {
explicacao: "Usando a chave OpenAI cadastrada em Credenciais.",
chave_em_uso: "Chave principal",
avisos: [],
credenciais_openai: [],
credenciais_embedding: [],
};
beforeEach(() => {
@@ -317,4 +317,29 @@ describe("ChaveDeConhecimento — o beco vira saída", () => {
expect(screen.getByTestId("conhecimento-chave-ok")).toBeInTheDocument();
expect(screen.getByText(/Chave principal/)).toBeInTheDocument();
});
it("cadastrar chave OpenRouter pela tela envia o provedor correto", async () => {
const spy = dublarFetch();
render(
<ChaveDeConhecimento
estado={{ ...CHAVE_OK, pode_indexar: false, chave_em_uso: null }}
onChaveCadastrada={() => {}}
/>,
);
fireEvent.click(screen.getByTestId("conhecimento-cadastrar-chave"));
fireEvent.click(screen.getByTestId("conhecimento-provedor-openrouter"));
expect(screen.getByTestId("conhecimento-chave-input")).toHaveAttribute("placeholder", "sk-or-…");
fireEvent.change(screen.getByTestId("conhecimento-chave-input"), {
target: { value: "sk-or-chave-de-teste" },
});
fireEvent.click(screen.getByTestId("conhecimento-chave-salvar"));
await waitFor(() => expect(spy).toHaveBeenCalledTimes(1));
expect(corpoEnviado(spy)).toMatchObject({
provider: "openrouter",
label: "Chave da OpenRouter",
api_key: "sk-or-chave-de-teste",
});
});
});
+3 -3
View File
@@ -594,14 +594,14 @@ export async function processRagIndexer(row: EventRow): Promise<HandlerResult> {
await marcarFonte(row.organization_id, fonte.id, {
last_index_status: "sem_credencial",
last_index_error:
"Falta uma chave da OpenAI para indexar. Cadastre uma em IA › Credenciais " +
"(ou defina OPENAI_API_KEY na instalação) e este material entra sozinho.",
"Falta uma chave de embedding para indexar. Cadastre uma chave OpenAI ou OpenRouter " +
"em IA › Credenciais e este material entra sozinho.",
});
await avisarNaCentral(
row.organization_id,
fonte,
`"${fonte.name}" ainda não entrou na base de conhecimento`,
"Falta uma chave da OpenAI para preparar o material. Cadastre uma em IA › Credenciais " +
"Falta uma chave de embedding para preparar o material. Cadastre uma em IA › Credenciais " +
"e a indexação recomeça sozinha — nada do que você enviou foi perdido.",
);
// `retry` e não `skipped`: o drain conta `skipped` como sucesso e marca o