mirror of
https://github.com/melgarafael/DeskcommCRM.git
synced 2026-10-02 01:28:34 +08:00
feat(ai): aceitar embeddings via OpenRouter
This commit is contained in:
@@ -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
@@ -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=
|
||||
|
||||
@@ -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=
|
||||
|
||||
# -----------------------------------------------------------------------------
|
||||
|
||||
@@ -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 },
|
||||
);
|
||||
|
||||
@@ -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 (
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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
@@ -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.
|
||||
|
||||
---
|
||||
|
||||
|
||||
@@ -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 } : {}),
|
||||
|
||||
@@ -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)"
|
||||
|
||||
@@ -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
@@ -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
@@ -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,
|
||||
|
||||
@@ -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
@@ -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
@@ -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);
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -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
@@ -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." },
|
||||
|
||||
@@ -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",
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user