@johpaz/hive-sdk 0.5.0 → 0.5.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +33 -0
- package/README.md +9 -4
- package/docs/API-AGENTS.md +70 -0
- package/docs/INDEX.md +5 -1
- package/docs/UPGRADING.md +20 -0
- package/package.json +1 -1
- package/packages/core/src/agent/jev-decisions.ts +4 -6
- package/packages/core/src/agent/llm-client.ts +2 -2
- package/packages/core/src/agent/llm-providers/anthropic.ts +2 -1
- package/packages/core/src/agent/llm-providers/gemini.ts +3 -1
- package/packages/core/src/agent/realtime-providers/gemini-live.ts +2 -1
- package/packages/core/src/multimodal/vision-service.ts +7 -7
- package/packages/core/src/storage/crypto.ts +43 -17
- package/packages/core/src/storage/index.ts +1 -0
- package/packages/core/src/tools/web/computer-use.ts +2 -2
- package/packages/core/src/voice/index.ts +13 -13
package/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,38 @@
|
|
|
1
1
|
# Changelog
|
|
2
2
|
|
|
3
|
+
## 0.5.1
|
|
4
|
+
|
|
5
|
+
### Seguridad — claves aisladas por inquilino
|
|
6
|
+
|
|
7
|
+
- **Secretos filtrados entre inquilinos por la caché en memoria.** El
|
|
8
|
+
almacén de secretos cacheaba cada valor descifrado en un `Map` del proceso
|
|
9
|
+
indexado sólo por nombre (`provider:openai:api_key`), y el llavero del SO
|
|
10
|
+
también es de toda la máquina. La colección `secrets` sí está particionada,
|
|
11
|
+
pero la caché la tapaba: después de que un inquilino leyera o guardara su
|
|
12
|
+
clave, `loadProviderApiKey` le devolvía esa misma clave a cualquier otro
|
|
13
|
+
inquilino del proceso, y `resolveProviderConfig` la usaba para cobrarle a
|
|
14
|
+
la cuenta equivocada cuando la llamada no traía `credentials`. Ahora la
|
|
15
|
+
caché se indexa por inquilino y, con un inquilino activo, el llavero del SO
|
|
16
|
+
no se lee, no se escribe ni se borra. Sin inquilino (escritorio) todo sigue
|
|
17
|
+
igual.
|
|
18
|
+
- **La clave del entorno ya no se usa en nombre de un inquilino.** Con un
|
|
19
|
+
inquilino activo y sin `credentials` ni clave guardada, `resolveProviderConfig`
|
|
20
|
+
caía en `<PROVEEDOR>_API_KEY` del proceso, que es la cuenta de la plataforma;
|
|
21
|
+
lo mismo OCR (`vision-service`), voz (STT/TTS), `computer_use` y Jev. Ahora
|
|
22
|
+
todos pasan por `envSecret(nombre)`, que dentro de un inquilino devuelve
|
|
23
|
+
`undefined`. Los adaptadores de Anthropic, Gemini y Gemini Live pasan siempre
|
|
24
|
+
una cadena al SDK del proveedor, porque con `undefined` esos SDK leían la
|
|
25
|
+
variable de entorno por su cuenta. **Cambio de comportamiento:** un host
|
|
26
|
+
multi-inquilino que dependía de ese respaldo tiene que pasar la clave en
|
|
27
|
+
`credentials` o guardarla en los secretos del inquilino; sin eso la llamada
|
|
28
|
+
falla por falta de clave en vez de cobrarse a la plataforma. Sin inquilino
|
|
29
|
+
(escritorio) el entorno sigue siendo el último respaldo.
|
|
30
|
+
- Nuevo `envSecret(nombre)` en `@johpaz/hive-sdk/storage`: la variable de
|
|
31
|
+
entorno sólo fuera de un inquilino. Úsalo en tus propias tools en lugar de
|
|
32
|
+
`process.env.X_API_KEY`.
|
|
33
|
+
- Quitado `loadDurableProviderApiKey` (interno, agregado en 0.5.0 y nunca
|
|
34
|
+
exportado por un barrel): `loadProviderApiKey` ya es seguro por inquilino.
|
|
35
|
+
|
|
3
36
|
## 0.5.0
|
|
4
37
|
|
|
5
38
|
### Jev — plano de decisión (OpenRouter Decisions)
|
package/README.md
CHANGED
|
@@ -25,6 +25,8 @@ bun add @johpaz/hive-sdk
|
|
|
25
25
|
- **Runtime**: ejecución paralela de tools vía Bun Workers.
|
|
26
26
|
- **Gateway**: servidor HTTP/WebSocket para exponer agentes como API.
|
|
27
27
|
- **Memoria y estado**: HiveDB (colecciones + índice BM25), scratchpad, context compiler con compactación.
|
|
28
|
+
- **Jev (opcional)**: plano de decisión sobre la API Decisions de OpenRouter. Por turno elige qué historial, tools, skills, notas y reglas entran al contexto, poda resultados viejos entre iteraciones y decide si un lote de tools corre en paralelo. Sin clave de OpenRouter no existe y todo corre igual. Ver [API-AGENTS.md](./docs/API-AGENTS.md#jev-plano-de-decisión).
|
|
29
|
+
- **Multi-inquilino**: varios enjambres en una sola HiveDB con `runInTenant`; credenciales y clave de Jev por llamada (`credentials`, `jev`), sin que la clave de un inquilino ni la de la plataforma se usen en nombre de otro.
|
|
28
30
|
- **Servicios**: CRUD tipado de agentes, enjambres, skills, modelos, MCP y cron para montarle **la interfaz que quieras** — móvil, web o escritorio. Ver [API-SERVICES.md](./docs/API-SERVICES.md).
|
|
29
31
|
- **Sesiones**: un hilo por canal y por contacto, con historial, resumen y reanudación tras un corte.
|
|
30
32
|
- **Imágenes**: redimensionar y convertir con `Bun.Image`, sin dependencias nativas. Las imágenes entrantes se normalizan antes de llegar al modelo — una foto de cámara pasa de 217 KB a 4 KB.
|
|
@@ -197,7 +199,10 @@ capturar. El SDK lo resuelve con `resolvePort`: avisa y sigue con el default.
|
|
|
197
199
|
|
|
198
200
|
La API key de cada provider se guarda cifrada en la base. Como alternativa, el
|
|
199
201
|
SDK cae a `<PROVIDER>_API_KEY` del entorno, en mayúsculas y con el id del
|
|
200
|
-
provider tal cual
|
|
202
|
+
provider tal cual. **Sólo sin inquilino** (app de escritorio, un proceso por
|
|
203
|
+
instalación): dentro de `runInTenant` el entorno es de la plataforma, no del
|
|
204
|
+
cliente, y no se usa nunca — la clave llega en `credentials` o desde los
|
|
205
|
+
secretos del inquilino. Ver [UPGRADING.md](./docs/UPGRADING.md#051-claves-aisladas-por-inquilino).
|
|
201
206
|
|
|
202
207
|
```bash
|
|
203
208
|
OPENAI_API_KEY=sk-...
|
|
@@ -205,7 +210,7 @@ ANTHROPIC_API_KEY=sk-ant-...
|
|
|
205
210
|
GOOGLE_API_KEY=... # provider "gemini"
|
|
206
211
|
MODELSCOPE_API_KEY=ms-...
|
|
207
212
|
NVIDIA_API_KEY=nvapi-...
|
|
208
|
-
OPENROUTER_API_KEY=sk-or-...
|
|
213
|
+
OPENROUTER_API_KEY=sk-or-... # también activa Jev (con el provider openrouter habilitado)
|
|
209
214
|
```
|
|
210
215
|
|
|
211
216
|
## Tests
|
|
@@ -260,7 +265,7 @@ npm view @johpaz/hive-sdk dist-tags # verificar después del release
|
|
|
260
265
|
|
|
261
266
|
| Documento | Descripción |
|
|
262
267
|
|-----------|-------------|
|
|
263
|
-
| [API-AGENTS.md](docs/API-AGENTS.md) | createAgent, AgentLoop, Tool/Skill Selector, los 16 LLM Providers |
|
|
268
|
+
| [API-AGENTS.md](docs/API-AGENTS.md) | createAgent, AgentLoop, Tool/Skill Selector, los 16 LLM Providers, multi-inquilino y Jev |
|
|
264
269
|
| [API-CONTEXT-COMPILER.md](docs/API-CONTEXT-COMPILER.md) | Context Compiler, historial, Scratchpad, EthicsGuard, ACE |
|
|
265
270
|
| [API-TOOLS-SKILLS-CHANNELS.md](docs/API-TOOLS-SKILLS-CHANNELS.md) | Tools, Skills, MCP, Gateway, Channels, Tool Runtime, Storage |
|
|
266
271
|
| [API-DAG-SCHEDULER.md](docs/API-DAG-SCHEDULER.md) | DAGScheduler, TaskGraph, TaskNode, estrategias, presets |
|
|
@@ -271,4 +276,4 @@ npm view @johpaz/hive-sdk dist-tags # verificar después del release
|
|
|
271
276
|
|
|
272
277
|
---
|
|
273
278
|
|
|
274
|
-
*Hive SDK v0.5.
|
|
279
|
+
*Hive SDK v0.5.1 — MIT*
|
package/docs/API-AGENTS.md
CHANGED
|
@@ -7,6 +7,8 @@
|
|
|
7
7
|
3. [Tool Selector](#tool-selector)
|
|
8
8
|
4. [Skill Selector](#skill-selector)
|
|
9
9
|
5. [LLM Providers](#llm-providers)
|
|
10
|
+
6. [Multi-inquilino: credenciales y Jev](#multi-inquilino-credenciales-y-jev)
|
|
11
|
+
7. [Jev: plano de decisión](#jev-plano-de-decisión)
|
|
10
12
|
|
|
11
13
|
---
|
|
12
14
|
|
|
@@ -240,6 +242,74 @@ const result = await runAgentIsolated({
|
|
|
240
242
|
});
|
|
241
243
|
```
|
|
242
244
|
|
|
245
|
+
### Multi-inquilino: credenciales y Jev
|
|
246
|
+
|
|
247
|
+
Un host que sirve a varios clientes desde un mismo proceso corre cada turno
|
|
248
|
+
dentro de `runInTenant(tenantKey, …)` y pasa las claves **por llamada**:
|
|
249
|
+
|
|
250
|
+
```typescript
|
|
251
|
+
import { runAgent } from "@johpaz/hive-sdk";
|
|
252
|
+
import { runInTenant } from "@johpaz/hive-sdk/storage";
|
|
253
|
+
|
|
254
|
+
await runInTenant(tenantKey, async () => {
|
|
255
|
+
for await (const chunk of runAgent({
|
|
256
|
+
agentId, threadId, userMessage,
|
|
257
|
+
credentials: { apiKey: claveDelModelo, baseUrl }, // gana y corta ahí
|
|
258
|
+
jev: claveOpenRouter ? { apiKey: claveOpenRouter } : false,
|
|
259
|
+
onStep: async (paso) => { /* text · tool_call · tool_result · jev_decision */ },
|
|
260
|
+
})) { /* … */ }
|
|
261
|
+
});
|
|
262
|
+
```
|
|
263
|
+
|
|
264
|
+
Reglas con un inquilino activo (desde 0.5.1):
|
|
265
|
+
|
|
266
|
+
- `credentials.apiKey` gana. Sin ella, la clave sale de los secretos **de ese
|
|
267
|
+
inquilino** (`storeProviderApiKey` dentro de su `runInTenant`).
|
|
268
|
+
- Nunca se usa `<PROVIDER>_API_KEY` del entorno ni el llavero del SO: son de la
|
|
269
|
+
máquina, no del cliente. Si no hay clave, la llamada falla en vez de cobrarse
|
|
270
|
+
a la plataforma. Para tus propias tools usa `envSecret(nombre)`
|
|
271
|
+
(`@johpaz/hive-sdk/storage`), que aplica la misma regla.
|
|
272
|
+
- `credentials` y `jev` se propagan a `runAgentIsolated`, `runRoleSwarm` y
|
|
273
|
+
`runSwarm`.
|
|
274
|
+
|
|
275
|
+
### Jev: plano de decisión
|
|
276
|
+
|
|
277
|
+
Jev hace preguntas acotadas a la API Decisions de OpenRouter
|
|
278
|
+
(`typesafe/jev-1.13`) y usa las respuestas para recortar lo que recibe el
|
|
279
|
+
modelo principal. **Es opcional**: sin clave no existe, no se hace ninguna
|
|
280
|
+
llamada y el turno corre exactamente igual.
|
|
281
|
+
|
|
282
|
+
| Momento | Qué decide |
|
|
283
|
+
|---|---|
|
|
284
|
+
| Al compilar el contexto | Qué mensajes previos, tools, skills, notas del scratchpad y reglas del playbook entran; a qué especialista conviene delegar, y si depende de un MCP apagado. Los últimos 4 mensajes siempre quedan. |
|
|
285
|
+
| Entre iteraciones | Qué resultados viejos de tools se omiten y la siguiente acción (continuar, delegar, descubrir, cerrar). Sólo si hay ≥ 4 000 caracteres podables. |
|
|
286
|
+
| Antes de ejecutar tools | Si un lote de lecturas independientes, o de delegaciones a workers distintos, corre en paralelo. |
|
|
287
|
+
|
|
288
|
+
Lo omitido se puede recuperar: el prompt lista los ids y el coordinador tiene
|
|
289
|
+
`conversation_read`.
|
|
290
|
+
|
|
291
|
+
**Activación**, por orden:
|
|
292
|
+
|
|
293
|
+
1. `jev: { apiKey, mcpSettingsPath? }` en la llamada. `mcpSettingsPath` es el
|
|
294
|
+
texto con el que el coordinador le dice al usuario dónde encender un MCP
|
|
295
|
+
(por defecto «Ajustes → Entorno → MCP Servers»).
|
|
296
|
+
2. `jev: false` lo apaga.
|
|
297
|
+
3. Sin la opción: el provider `openrouter` habilitado y activo, con su clave
|
|
298
|
+
guardada; sin inquilino, también `OPENROUTER_API_KEY`.
|
|
299
|
+
|
|
300
|
+
**Qué ve el host.** Cada decisión llega por `onStep` como
|
|
301
|
+
`{ type: "jev_decision", message, jev }`, con agente, tipo (`context`,
|
|
302
|
+
`iteration`, `parallel`), resumen, tokens ahorrados estimados, latencia, costo,
|
|
303
|
+
especialista recomendado y MCP apagados. También se emite
|
|
304
|
+
`canvas:jev_decision`. `getUsageStats().jev` suma decisiones, costo y ahorro
|
|
305
|
+
(total y por agente). Fallos y cooldown se llevan por inquilino.
|
|
306
|
+
|
|
307
|
+
**Privacidad.** Se envían a OpenRouter, con la clave de la llamada, extractos
|
|
308
|
+
acotados: el objetivo del turno, fragmentos de mensajes previos, nombres y
|
|
309
|
+
descripciones de tools y skills, notas y reglas, fragmentos de resultados de
|
|
310
|
+
tools y el mapa del enjambre (nombres, no ids con prefijo de inquilino). Nunca
|
|
311
|
+
credenciales ni adjuntos. El detalle está en el [CHANGELOG](../CHANGELOG.md).
|
|
312
|
+
|
|
243
313
|
---
|
|
244
314
|
|
|
245
315
|
## Tool Selector
|
package/docs/INDEX.md
CHANGED
|
@@ -12,7 +12,7 @@
|
|
|
12
12
|
| [API-RESILIENCE.md](./API-RESILIENCE.md) | Reintentos con backoff y circuit breakers |
|
|
13
13
|
| [API-SESSIONS.md](./API-SESSIONS.md) | Sesiones por canal, historial, reanudación tras un corte |
|
|
14
14
|
| [API-SERVICES.md](./API-SERVICES.md) | **La superficie para una UI** — CRUD de agentes, enjambres, skills, modelos, MCP, cron |
|
|
15
|
-
| [API-AGENTS.md](./API-AGENTS.md) | createAgent, AgentLoop, Tool/Skill Selector, LLM Providers |
|
|
15
|
+
| [API-AGENTS.md](./API-AGENTS.md) | createAgent, AgentLoop, Tool/Skill Selector, LLM Providers, multi-inquilino y Jev |
|
|
16
16
|
| [API-DAG-SCHEDULER.md](./API-DAG-SCHEDULER.md) | DAGScheduler, TaskGraph, Estrategias, Presets |
|
|
17
17
|
| [API-CRON.md](./API-CRON.md) | Tareas programadas: expresiones, zona horaria, misfires, motor sin dependencias |
|
|
18
18
|
| [API-WORKERS-EVENTS.md](./API-WORKERS-EVENTS.md) | **Bun Workers**, createWorker, WorkerPool, AgentBus, EventBus, Canvas |
|
|
@@ -181,6 +181,10 @@ ANTHROPIC_API_KEY=sk-ant-... # Anthropic
|
|
|
181
181
|
LOG_LEVEL=info # debug | info | warn | error
|
|
182
182
|
```
|
|
183
183
|
|
|
184
|
+
Las `*_API_KEY` del entorno sólo se usan sin inquilino; dentro de
|
|
185
|
+
`runInTenant` la clave llega en `credentials` o desde los secretos del
|
|
186
|
+
inquilino (ver [UPGRADING.md](./UPGRADING.md#051-claves-aisladas-por-inquilino)).
|
|
187
|
+
|
|
184
188
|
---
|
|
185
189
|
|
|
186
190
|
## Tests
|
package/docs/UPGRADING.md
CHANGED
|
@@ -74,6 +74,26 @@ sigue sólo importa con el log causal encendido (`HIVE_CAUSAL_LOG=true` o
|
|
|
74
74
|
crudo y el SDK lo califica. Los eventos que entrega traen en `agentId` la
|
|
75
75
|
clave del shard; `formatCausalEvent` la muestra sin el tenant.
|
|
76
76
|
|
|
77
|
+
## 0.5.1: claves aisladas por inquilino
|
|
78
|
+
|
|
79
|
+
Sólo cambia algo si corres turnos dentro de `runInTenant` (un host
|
|
80
|
+
multi-inquilino). Sin inquilino todo sigue igual.
|
|
81
|
+
|
|
82
|
+
- **La caché de secretos y el llavero del SO ya no se comparten.** Antes, en
|
|
83
|
+
cuanto un inquilino leía o guardaba `provider:<id>:api_key`, los demás del
|
|
84
|
+
mismo proceso recibían esa clave. Ahora la caché es por inquilino y el
|
|
85
|
+
llavero no se toca dentro de un inquilino. No hay que cambiar código.
|
|
86
|
+
- **El entorno ya no es respaldo dentro de un inquilino.** Si un turno no
|
|
87
|
+
trae `credentials` y el inquilino no tiene clave guardada, antes se usaba
|
|
88
|
+
`<PROVIDER>_API_KEY` del proceso (la cuenta de la plataforma); ahora la
|
|
89
|
+
llamada falla por falta de clave. Aplica al modelo principal, OCR, voz,
|
|
90
|
+
`computer_use` y Jev. Qué hacer: pasar la clave en `credentials`, o
|
|
91
|
+
guardarla con `storeProviderApiKey` dentro del `runInTenant` del cliente. Si
|
|
92
|
+
quieres que un cliente use la cuenta de la plataforma, pásala tú en
|
|
93
|
+
`credentials`, a propósito.
|
|
94
|
+
- Para tus propias tools: `envSecret("MI_API_KEY")` en lugar de
|
|
95
|
+
`process.env.MI_API_KEY`.
|
|
96
|
+
|
|
77
97
|
## Compatibilidad y CI
|
|
78
98
|
|
|
79
99
|
Los workflows fijan Bun 1.4.2, instalan con `--frozen-lockfile`, ejecutan el
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@johpaz/hive-sdk",
|
|
3
|
-
"version": "0.5.
|
|
3
|
+
"version": "0.5.1",
|
|
4
4
|
"private": false,
|
|
5
5
|
"description": "Hive SDK — The Agent Harness SDK. Build, deploy, and scale AI agent applications with multi-channel support, context engineering, and swarm orchestration.",
|
|
6
6
|
"license": "MIT",
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
/** Optional decision plane. Jev is never used through the chat completions API. */
|
|
2
2
|
import { col } from "../storage/hive.ts"
|
|
3
3
|
import type { ProviderDoc } from "../storage/collections.ts"
|
|
4
|
-
import {
|
|
4
|
+
import { envSecret, loadProviderApiKey } from "../storage/crypto.ts"
|
|
5
5
|
import { recordJevDecision, recordUsage } from "../storage/usage.ts"
|
|
6
6
|
import { catalogModelKey } from "../storage/model-id.ts"
|
|
7
7
|
import { currentTenant } from "../storage/tenant.ts"
|
|
@@ -71,17 +71,15 @@ export interface JevResult {
|
|
|
71
71
|
/**
|
|
72
72
|
* The OpenRouter key Jev would use, or null when Jev is off.
|
|
73
73
|
*
|
|
74
|
-
* With a tenant in scope the key comes only from that tenant's
|
|
75
|
-
*
|
|
76
|
-
* from the process-wide secret cache, which is not partitioned.
|
|
74
|
+
* With a tenant in scope the key comes only from that tenant's secrets —
|
|
75
|
+
* never from `OPENROUTER_API_KEY`, which is the platform's.
|
|
77
76
|
*/
|
|
78
77
|
export async function getJevKey(option?: JevOption): Promise<string | null> {
|
|
79
78
|
if (option === false) return null
|
|
80
79
|
if (option) return option.apiKey || null
|
|
81
80
|
const provider = await (await col<ProviderDoc>("providers")).get("openrouter")
|
|
82
81
|
if (!provider?.doc.enabled || !provider.doc.active) return null
|
|
83
|
-
|
|
84
|
-
return (await loadProviderApiKey("openrouter")) || process.env.OPENROUTER_API_KEY || null
|
|
82
|
+
return (await loadProviderApiKey("openrouter")) || envSecret("OPENROUTER_API_KEY") || null
|
|
85
83
|
}
|
|
86
84
|
|
|
87
85
|
export interface JevStatus {
|
|
@@ -309,7 +309,7 @@ export async function resolveProviderConfig(
|
|
|
309
309
|
credentials?: ProviderCredentials
|
|
310
310
|
): Promise<Pick<LLMCallOptions, "provider" | "model" | "apiKey" | "baseUrl" | "numCtx" | "numGpu" | "contextWindow">> {
|
|
311
311
|
const { col } = await import("../storage/hive.ts")
|
|
312
|
-
const { loadProviderApiKey } = await import("../storage/crypto.ts")
|
|
312
|
+
const { envSecret, loadProviderApiKey } = await import("../storage/crypto.ts")
|
|
313
313
|
const providersCol = await col<import("../storage/collections.ts").ProviderDoc>("providers")
|
|
314
314
|
const modelsCol = await col<import("../storage/collections.ts").ModelDoc>("models")
|
|
315
315
|
|
|
@@ -327,7 +327,7 @@ export async function resolveProviderConfig(
|
|
|
327
327
|
apiKey = await loadProviderApiKey(providerId)
|
|
328
328
|
}
|
|
329
329
|
if (!apiKey) {
|
|
330
|
-
apiKey =
|
|
330
|
+
apiKey = envSecret(`${providerId.toUpperCase()}_API_KEY`) || ""
|
|
331
331
|
}
|
|
332
332
|
|
|
333
333
|
return {
|
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { envSecret } from "../../storage/crypto.ts"
|
|
1
2
|
import { logger } from "../../utils/logger.ts"
|
|
2
3
|
import { normalizeToolName, resolveMaxTokens, ensureArrayItems } from "./interface.ts"
|
|
3
4
|
import type { LLMCallOptions, LLMProvider, LLMResponse, LLMToolCall, ThinkingBlock } from "./interface.ts"
|
|
@@ -74,7 +75,7 @@ export class AnthropicProvider implements LLMProvider {
|
|
|
74
75
|
|
|
75
76
|
async call(options: LLMCallOptions): Promise<LLMResponse> {
|
|
76
77
|
const Anthropic = await import("@anthropic-ai/sdk")
|
|
77
|
-
const client = new Anthropic.default({ apiKey: options.apiKey })
|
|
78
|
+
const client = new Anthropic.default({ apiKey: options.apiKey || envSecret("ANTHROPIC_API_KEY") || "" })
|
|
78
79
|
|
|
79
80
|
// Anthropic requires tool names to match ^[a-zA-Z0-9_-]{1,128}$
|
|
80
81
|
// Native Hive tools use dots (e.g. cron.create) which violate this.
|
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { envSecret } from "../../storage/crypto.ts"
|
|
1
2
|
import { logger } from "../../utils/logger.ts"
|
|
2
3
|
import { sanitizeMessages, resolveMaxTokens, ensureArrayItems } from "./interface.ts"
|
|
3
4
|
import type { LLMCallOptions, LLMProvider, LLMResponse, LLMToolCall } from "./interface.ts"
|
|
@@ -100,7 +101,8 @@ export class GeminiProvider implements LLMProvider {
|
|
|
100
101
|
async call(options: LLMCallOptions): Promise<LLMResponse> {
|
|
101
102
|
const { GoogleGenAI } = await import("@google/genai")
|
|
102
103
|
|
|
103
|
-
|
|
104
|
+
// A string always: undefined makes @google/genai read GEMINI_API_KEY itself, inside a tenant too.
|
|
105
|
+
const clientOpts: any = { apiKey: options.apiKey || envSecret("GEMINI_API_KEY") || envSecret("GOOGLE_API_KEY") || "" }
|
|
104
106
|
if (options.baseUrl?.trim()) clientOpts.httpOptions = { baseUrl: options.baseUrl.trim() }
|
|
105
107
|
|
|
106
108
|
const ai = new GoogleGenAI(clientOpts)
|
|
@@ -10,6 +10,7 @@
|
|
|
10
10
|
* al contexto inicial en 3.x, así que no se usa acá.
|
|
11
11
|
*/
|
|
12
12
|
|
|
13
|
+
import { envSecret } from "../../storage/crypto.ts"
|
|
13
14
|
import { logger } from "../../utils/logger.ts";
|
|
14
15
|
import { ensureArrayItems } from "../llm-providers/interface.ts";
|
|
15
16
|
import type {
|
|
@@ -106,7 +107,7 @@ export class GeminiLiveProvider implements RealtimeProvider {
|
|
|
106
107
|
|
|
107
108
|
async connect(options: RealtimeSessionOptions): Promise<RealtimeSession> {
|
|
108
109
|
const { GoogleGenAI } = await import("@google/genai");
|
|
109
|
-
const ai = new GoogleGenAI({ apiKey: options.apiKey });
|
|
110
|
+
const ai = new GoogleGenAI({ apiKey: options.apiKey || envSecret("GEMINI_API_KEY") || envSecret("GOOGLE_API_KEY") || "" });
|
|
110
111
|
const cb = options.callbacks;
|
|
111
112
|
|
|
112
113
|
const config: Record<string, unknown> = {
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import { col } from "../storage/hive.ts"
|
|
2
2
|
import type { ChannelDoc, ModelDoc, ProviderDoc } from "../storage/collections.ts"
|
|
3
|
-
import { loadProviderApiKey } from "../storage/crypto.ts"
|
|
3
|
+
import { envSecret, loadProviderApiKey } from "../storage/crypto.ts"
|
|
4
4
|
import { logger } from "../utils/logger.ts"
|
|
5
5
|
import type { ImageInput, DocumentInput, VisionConfig } from "./types.ts"
|
|
6
6
|
import type { ContentPart } from "./types.ts"
|
|
@@ -177,7 +177,7 @@ class MultimodalService {
|
|
|
177
177
|
}
|
|
178
178
|
|
|
179
179
|
private async ocrWithOpenAI(image: ImageInput): Promise<string> {
|
|
180
|
-
const key = await this.getProviderApiKey("openai") ||
|
|
180
|
+
const key = await this.getProviderApiKey("openai") || envSecret("OPENAI_API_KEY")
|
|
181
181
|
if (!key) throw new Error("OPENAI_API_KEY not configured for OCR")
|
|
182
182
|
|
|
183
183
|
const imageUrl = await this.resolveImageUrl(image)
|
|
@@ -208,7 +208,7 @@ class MultimodalService {
|
|
|
208
208
|
}
|
|
209
209
|
|
|
210
210
|
private async ocrWithGemini(image: ImageInput): Promise<string> {
|
|
211
|
-
const key = await this.getProviderApiKey("gemini") ||
|
|
211
|
+
const key = await this.getProviderApiKey("gemini") || envSecret("GEMINI_API_KEY")
|
|
212
212
|
if (!key) throw new Error("GEMINI_API_KEY not configured for OCR")
|
|
213
213
|
|
|
214
214
|
let imagePart: any
|
|
@@ -243,7 +243,7 @@ class MultimodalService {
|
|
|
243
243
|
}
|
|
244
244
|
|
|
245
245
|
private async ocrWithAnthropic(image: ImageInput): Promise<string> {
|
|
246
|
-
const key = await this.getProviderApiKey("anthropic") ||
|
|
246
|
+
const key = await this.getProviderApiKey("anthropic") || envSecret("ANTHROPIC_API_KEY")
|
|
247
247
|
if (!key) throw new Error("ANTHROPIC_API_KEY not configured for OCR")
|
|
248
248
|
|
|
249
249
|
const imageUrl = await this.resolveImageUrl(image)
|
|
@@ -299,9 +299,9 @@ class MultimodalService {
|
|
|
299
299
|
])
|
|
300
300
|
|
|
301
301
|
return {
|
|
302
|
-
openai: openai || !!(
|
|
303
|
-
gemini: gemini || !!(
|
|
304
|
-
anthropic: anthropic || !!(
|
|
302
|
+
openai: openai || !!(envSecret("OPENAI_API_KEY")),
|
|
303
|
+
gemini: gemini || !!(envSecret("GEMINI_API_KEY")),
|
|
304
|
+
anthropic: anthropic || !!(envSecret("ANTHROPIC_API_KEY")),
|
|
305
305
|
}
|
|
306
306
|
}
|
|
307
307
|
|
|
@@ -4,6 +4,7 @@ import * as path from "node:path"
|
|
|
4
4
|
import { getHiveDir } from "../config/loader.ts"
|
|
5
5
|
import { logger } from "../utils/logger.ts"
|
|
6
6
|
import { col } from "./hive.ts"
|
|
7
|
+
import { currentTenant } from "./tenant.ts"
|
|
7
8
|
|
|
8
9
|
const log = logger.child("crypto")
|
|
9
10
|
const SERVICE = "hive"
|
|
@@ -25,8 +26,28 @@ interface SecretDoc {
|
|
|
25
26
|
// written *only* there does not survive a server restart. That is exactly
|
|
26
27
|
// what was wiping every provider API key, channel token and MCP header on
|
|
27
28
|
// restart in production.
|
|
28
|
-
|
|
29
|
+
//
|
|
30
|
+
// Multi-tenant: the `secrets` collection is partitioned by tenant through
|
|
31
|
+
// `col()`, but this process's memory and the OS keychain are not. So the
|
|
32
|
+
// in-memory cache is keyed by tenant as well, and with a tenant in scope the
|
|
33
|
+
// keychain is never read or written — a machine-wide entry named
|
|
34
|
+
// `provider:openai:api_key` belongs to no tenant in particular, and serving it
|
|
35
|
+
// (or overwriting it) on a tenant's behalf would hand one customer another's
|
|
36
|
+
// key.
|
|
37
|
+
|
|
38
|
+
/** Decrypted values, keyed by {@link _cacheKey}: never shared across tenants. */
|
|
29
39
|
const _mem = new Map<string, string>()
|
|
40
|
+
|
|
41
|
+
/** `name` for the desktop (no tenant); `<tenant>\0name` inside a tenant. */
|
|
42
|
+
function _cacheKey(name: string): string {
|
|
43
|
+
const tenant = currentTenant()
|
|
44
|
+
return tenant ? `${tenant}\0${name}` : name
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
/** The OS keychain only serves the tenant-less (desktop) scope. */
|
|
48
|
+
function _keychainInScope(): boolean {
|
|
49
|
+
return currentTenant() === null
|
|
50
|
+
}
|
|
30
51
|
let _keychainOk: boolean | null = null // null = untested
|
|
31
52
|
|
|
32
53
|
let _keychainApi: unknown = undefined
|
|
@@ -46,19 +67,21 @@ function _getKeychainApi(): any {
|
|
|
46
67
|
}
|
|
47
68
|
|
|
48
69
|
async function _get(name: string): Promise<string | null> {
|
|
49
|
-
const
|
|
70
|
+
const cacheKey = _cacheKey(name)
|
|
71
|
+
const cached = _mem.get(cacheKey)
|
|
50
72
|
if (cached !== undefined) return cached
|
|
51
73
|
|
|
52
74
|
// Durable store first — it is the one every write goes to.
|
|
53
75
|
const stored = await _readCollectionSecret(name)
|
|
54
76
|
if (stored) {
|
|
55
|
-
_mem.set(
|
|
77
|
+
_mem.set(cacheKey, stored)
|
|
56
78
|
return stored
|
|
57
79
|
}
|
|
58
80
|
|
|
59
81
|
// Legacy/desktop installs may only have the value in the OS keychain.
|
|
82
|
+
if (!_keychainInScope()) return null
|
|
60
83
|
const fromKeychain = await _keychainGet(name)
|
|
61
|
-
if (fromKeychain) _mem.set(
|
|
84
|
+
if (fromKeychain) _mem.set(cacheKey, fromKeychain)
|
|
62
85
|
return fromKeychain
|
|
63
86
|
}
|
|
64
87
|
|
|
@@ -69,9 +92,9 @@ async function _get(name: string): Promise<string | null> {
|
|
|
69
92
|
* silently accepting a secret that dies with the process.
|
|
70
93
|
*/
|
|
71
94
|
async function _set(name: string, value: string): Promise<boolean> {
|
|
72
|
-
_mem.set(name, value)
|
|
95
|
+
_mem.set(_cacheKey(name), value)
|
|
73
96
|
const durable = await persistSecretToCollection(name, value)
|
|
74
|
-
const mirrored = await _keychainSet(name, value)
|
|
97
|
+
const mirrored = _keychainInScope() ? await _keychainSet(name, value) : false
|
|
75
98
|
if (!durable && !mirrored) {
|
|
76
99
|
log.error(`[secrets] ${name} could not be persisted — it will be lost on restart`)
|
|
77
100
|
}
|
|
@@ -87,7 +110,6 @@ async function _readCollectionSecret(name: string): Promise<string | null> {
|
|
|
87
110
|
const secrets = await col<SecretDoc>("secrets")
|
|
88
111
|
const entry = await secrets.get(name)
|
|
89
112
|
if (!entry) return null
|
|
90
|
-
// `_get` caches it; `loadDurableProviderApiKey` must not.
|
|
91
113
|
return decryptSecret(entry.doc.ciphertext, entry.doc.iv) || null
|
|
92
114
|
} catch {
|
|
93
115
|
return null
|
|
@@ -134,11 +156,13 @@ async function _keychainSet(name: string, value: string): Promise<boolean> {
|
|
|
134
156
|
}
|
|
135
157
|
|
|
136
158
|
async function _del(name: string): Promise<void> {
|
|
137
|
-
_mem.delete(name)
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
159
|
+
_mem.delete(_cacheKey(name))
|
|
160
|
+
if (_keychainInScope()) {
|
|
161
|
+
try {
|
|
162
|
+
await (Bun as any).secrets.delete({ service: SERVICE, name })
|
|
163
|
+
} catch {
|
|
164
|
+
// ignore — might not exist or keychain unavailable
|
|
165
|
+
}
|
|
142
166
|
}
|
|
143
167
|
try {
|
|
144
168
|
const secrets = await col<SecretDoc>("secrets")
|
|
@@ -195,12 +219,14 @@ export async function loadProviderApiKey(id: string): Promise<string> {
|
|
|
195
219
|
}
|
|
196
220
|
|
|
197
221
|
/**
|
|
198
|
-
*
|
|
199
|
-
*
|
|
200
|
-
*
|
|
222
|
+
* A credential from the process environment (`OPENAI_API_KEY`, …) — only
|
|
223
|
+
* outside a tenant. The environment is the host's: inside a tenant it belongs
|
|
224
|
+
* to the platform, not the customer, and using it would bill the platform's
|
|
225
|
+
* account for a customer's call (or let one customer run on another's key).
|
|
226
|
+
* A tenant's credentials come from its own secrets or from `credentials`.
|
|
201
227
|
*/
|
|
202
|
-
export
|
|
203
|
-
return (
|
|
228
|
+
export function envSecret(name: string): string | undefined {
|
|
229
|
+
return currentTenant() ? undefined : process.env[name] || undefined
|
|
204
230
|
}
|
|
205
231
|
|
|
206
232
|
export async function storeProviderHeaders(id: string, headers: Record<string, unknown>): Promise<boolean> {
|
|
@@ -28,7 +28,7 @@
|
|
|
28
28
|
import type { Tool } from "../types.ts";
|
|
29
29
|
import { logger } from "../../utils/logger.ts";
|
|
30
30
|
import { getBrowserService } from "./browser-service.ts";
|
|
31
|
-
import { loadProviderApiKey } from "../../storage/crypto.ts";
|
|
31
|
+
import { envSecret, loadProviderApiKey } from "../../storage/crypto.ts";
|
|
32
32
|
|
|
33
33
|
const log = logger.child("computer-use");
|
|
34
34
|
|
|
@@ -417,7 +417,7 @@ export const computerUseTaskTool: Tool = {
|
|
|
417
417
|
const maxPasos = Math.max(1, Math.min(30, Number(params.max_pasos) || MAX_PASOS));
|
|
418
418
|
const confirmado = params.confirmado === true;
|
|
419
419
|
|
|
420
|
-
const apiKey = (await loadProviderApiKey("gemini")) ||
|
|
420
|
+
const apiKey = (await loadProviderApiKey("gemini")) || envSecret("GEMINI_API_KEY");
|
|
421
421
|
if (!apiKey) {
|
|
422
422
|
return { ok: false, error: "Falta la API key de Gemini (Ajustes → Proveedores)." };
|
|
423
423
|
}
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
import { resolvePort } from "../utils/port.ts";
|
|
2
2
|
import { col } from "../storage/hive.ts";
|
|
3
3
|
import type { ChannelDoc, ModelDoc } from "../storage/collections.ts";
|
|
4
|
-
import { loadProviderApiKey } from "../storage/crypto.ts";
|
|
4
|
+
import { envSecret, loadProviderApiKey } from "../storage/crypto.ts";
|
|
5
5
|
import { logger } from "../utils/logger.ts";
|
|
6
6
|
|
|
7
7
|
export interface VoiceConfig {
|
|
@@ -160,7 +160,7 @@ class VoiceService {
|
|
|
160
160
|
}
|
|
161
161
|
|
|
162
162
|
private async transcribeWithGroq(audio: AudioInput, modelId: string): Promise<string> {
|
|
163
|
-
const key = await this.getProviderApiKey("groq") ||
|
|
163
|
+
const key = await this.getProviderApiKey("groq") || envSecret("GROQ_API_KEY");
|
|
164
164
|
if (!key) {
|
|
165
165
|
throw new Error("GROQ_API_KEY not configured. Configúrala en Proveedores o en las variables de entorno.");
|
|
166
166
|
}
|
|
@@ -212,7 +212,7 @@ class VoiceService {
|
|
|
212
212
|
}
|
|
213
213
|
|
|
214
214
|
private async transcribeWithOpenAIWhisper(audio: AudioInput): Promise<string> {
|
|
215
|
-
const key = await this.getProviderApiKey("openai") ||
|
|
215
|
+
const key = await this.getProviderApiKey("openai") || envSecret("OPENAI_API_KEY");
|
|
216
216
|
if (!key) {
|
|
217
217
|
throw new Error("OPENAI_API_KEY not configured. Configúrala en Proveedores o en las variables de entorno.");
|
|
218
218
|
}
|
|
@@ -302,7 +302,7 @@ class VoiceService {
|
|
|
302
302
|
|
|
303
303
|
private async speakWithElevenLabs(text: string, modelId: string, voiceId?: string): Promise<AudioOutput> {
|
|
304
304
|
const apiKey = await this.getProviderApiKey("elevenlabs");
|
|
305
|
-
const key = apiKey ||
|
|
305
|
+
const key = apiKey || envSecret("ELEVENLABS_API_KEY");
|
|
306
306
|
|
|
307
307
|
if (!key) {
|
|
308
308
|
throw new Error("ELEVENLABS_API_KEY not configured");
|
|
@@ -341,7 +341,7 @@ class VoiceService {
|
|
|
341
341
|
|
|
342
342
|
private async speakWithOpenAI(text: string, modelId: string = "gpt-4o-mini-tts", voiceId?: string): Promise<AudioOutput> {
|
|
343
343
|
const apiKey = await this.getProviderApiKey("openai-tts");
|
|
344
|
-
const key = apiKey ||
|
|
344
|
+
const key = apiKey || envSecret("OPENAI_API_KEY");
|
|
345
345
|
|
|
346
346
|
if (!key) {
|
|
347
347
|
throw new Error("OPENAI_API_KEY not configured");
|
|
@@ -377,7 +377,7 @@ class VoiceService {
|
|
|
377
377
|
}
|
|
378
378
|
|
|
379
379
|
private async speakWithGemini(text: string, modelId: string, voiceId?: string): Promise<AudioOutput> {
|
|
380
|
-
const key =
|
|
380
|
+
const key = envSecret("GEMINI_API_KEY");
|
|
381
381
|
|
|
382
382
|
if (!key) {
|
|
383
383
|
throw new Error("GEMINI_API_KEY not configured");
|
|
@@ -431,7 +431,7 @@ class VoiceService {
|
|
|
431
431
|
}
|
|
432
432
|
|
|
433
433
|
private async speakWithQwen(text: string, modelId: string, voiceId?: string): Promise<AudioOutput> {
|
|
434
|
-
const key =
|
|
434
|
+
const key = envSecret("DASHSCOPE_API_KEY");
|
|
435
435
|
|
|
436
436
|
if (!key) {
|
|
437
437
|
throw new Error("DASHSCOPE_API_KEY not configured");
|
|
@@ -485,11 +485,11 @@ class VoiceService {
|
|
|
485
485
|
]);
|
|
486
486
|
|
|
487
487
|
return {
|
|
488
|
-
groq: groq || !!(
|
|
489
|
-
elevenlabs: elevenlabs || !!(
|
|
490
|
-
openai: openai || !!(
|
|
491
|
-
gemini: gemini || !!(
|
|
492
|
-
qwen: qwen || !!(
|
|
488
|
+
groq: groq || !!(envSecret("GROQ_API_KEY")),
|
|
489
|
+
elevenlabs: elevenlabs || !!(envSecret("ELEVENLABS_API_KEY")),
|
|
490
|
+
openai: openai || !!(envSecret("OPENAI_API_KEY")),
|
|
491
|
+
gemini: gemini || !!(envSecret("GEMINI_API_KEY")),
|
|
492
|
+
qwen: qwen || !!(envSecret("DASHSCOPE_API_KEY")),
|
|
493
493
|
};
|
|
494
494
|
}
|
|
495
495
|
|
|
@@ -561,7 +561,7 @@ class VoiceService {
|
|
|
561
561
|
|
|
562
562
|
async getElevenLabsVoices(): Promise<Array<{ id: string; name: string; category: string }>> {
|
|
563
563
|
const apiKey = await this.getProviderApiKey("elevenlabs");
|
|
564
|
-
const key = apiKey ||
|
|
564
|
+
const key = apiKey || envSecret("ELEVENLABS_API_KEY");
|
|
565
565
|
|
|
566
566
|
if (!key) {
|
|
567
567
|
throw new Error("ELEVENLABS_API_KEY not configured");
|