From 3d20dd0f573989df8edc435b97d98a74bf38ce64 Mon Sep 17 00:00:00 2001 From: jax-novita Date: Thu, 6 Aug 2026 22:14:46 +0800 Subject: [PATCH] Add Novita as an embeddings provider option Boop's embeddings wrapper tries providers in order: Voyage, then OpenAI, then falls back to a local Transformers.js model. This adds Novita's OpenAI-compatible embeddings endpoint (baai/bge-m3) as a third paid option, tried after OpenAI and before the local fallback. Novita's endpoint returns 1024-dim vectors, matching the existing Convex vector index requirement, so no schema changes are needed. --- .env.example | 9 ++++--- CHANGELOG.md | 4 +++ README.md | 6 ++--- debug/src/components/EmbeddingBanner.tsx | 3 ++- server/embeddings.ts | 33 ++++++++++++++++++++---- 5 files changed, 42 insertions(+), 13 deletions(-) diff --git a/.env.example b/.env.example index ae2b3a2f..5f4c1793 100644 --- a/.env.example +++ b/.env.example @@ -78,12 +78,13 @@ BOOP_USER_PHONE= # BOOP_UPSTREAM_CHECK=true # ---- Embeddings (optional — paid providers for memory recall) ---- -# Tried in order: Voyage → OpenAI → local Transformers.js (Xenova/bge-large- -# en-v1.5, 1024-dim, free, ~440MB one-time download). Leave both blank to -# use the local fallback. All three produce 1024-dim vectors so the Convex -# index stays compatible regardless of which is active. +# Tried in order: Voyage → OpenAI → Novita → local Transformers.js (Xenova/ +# bge-large-en-v1.5, 1024-dim, free, ~440MB one-time download). Leave all +# blank to use the local fallback. All produce 1024-dim vectors so the +# Convex index stays compatible regardless of which is active. # VOYAGE_API_KEY= # OPENAI_API_KEY= +# NOVITA_API_KEY= # ---- Server ---- PORT=3456 diff --git a/CHANGELOG.md b/CHANGELOG.md index e0dd9297..290e5478 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -8,6 +8,10 @@ Format: --- +## Unreleased — Novita embeddings provider + +- Added: `NOVITA_API_KEY` as an embeddings provider option, tried after Voyage/OpenAI and before the local fallback. Uses `baai/bge-m3` via Novita's OpenAI-compatible `/openai/v1/embeddings` endpoint, which also produces 1024-dim vectors compatible with the existing Convex index. + ## Unreleased — Optional local Apple data - Added: optional Mac-only, read-only local Apple data connectors for iMessage/SMS history, Apple Notes, and Apple Reminders. The integration is off by default and requires both the master Apple data toggle and a per-source connect action before tools are exposed. diff --git a/README.md b/README.md index 2425c346..02f2ac48 100644 --- a/README.md +++ b/README.md @@ -47,7 +47,7 @@ Built on: - **Dispatcher + workers** pattern: a lean interaction agent decides what to do, spawns focused sub-agents that actually do the work. - **Pure dispatcher** — the interaction agent has only memory + spawn + automation + draft tools. Web access, files, and integrations are explicitly denied to it; sub-agents get `WebSearch` / `WebFetch` / the integrations. - **Tiered memory** (short / long / permanent) with post-turn extraction, decay, and cleaning. -- **Vector search** for recall with a local BGE-large fallback, or optional Voyage/OpenAI embeddings. +- **Vector search** for recall with a local BGE-large fallback, or optional Voyage/OpenAI/Novita embeddings. - **Memory consolidation** — a daily 3-phase adversarial pipeline (proposer → adversary → judge) that merges duplicates, resolves contradictions, and prunes noise. Uses the configured runtime, with provider-specific model defaults. Runs every 24h by default, also triggerable manually via `POST /consolidate`. - **Automations** — the agent can schedule recurring work from a text ("every morning at 8 summarize my calendar") and push results back to iMessage. - **Draft-and-send** — any external action stages a draft first; the agent only commits when the user confirms. @@ -436,7 +436,7 @@ Everything lives in `.env.local` (auto-created by `npm run setup`). See `.env.ex | `BOOP_UPSTREAM_CHECK` | no | Set to `false` to disable the new-version banner on `npm run dev`. Default: on. | | `PORT` | no | Default `3456`. | | `PUBLIC_URL` | no | Base URL used in the Sendblue webhook. Composio handles its own OAuth callbacks on `platform.composio.dev`, so this is just for inbound iMessage. | -| `VOYAGE_API_KEY` **or** `OPENAI_API_KEY` | optional | Unlocks vector recall. Falls back to substring. | +| `VOYAGE_API_KEY`, `OPENAI_API_KEY`, **or** `NOVITA_API_KEY` | optional | Unlocks vector recall. Falls back to substring. | | `COMPOSIO_API_KEY` | optional | Enables integrations. Without it, plain chat + memory + automations still work. Get one at [app.composio.dev/developers](https://app.composio.dev/developers?utm_source=chris&utm_medium=youtube&utm_campaign=collab). | | `COMPOSIO_USER_ID` | optional | Stable user id Composio keys connections under. Defaults to `boop-default`. | | `ANTHROPIC_API_KEY` | optional | Bypass the Claude Code subscription for the Claude runtime. | @@ -593,7 +593,7 @@ boop-agent/ │ ├── heartbeat.ts # Stale-agent sweep │ ├── consolidation.ts # 3-phase adversarial pipeline (proposer → adversary → judge) │ ├── usage.ts # aggregateUsageFromResult helper (shared cost aggregation) -│ ├── embeddings.ts # Voyage / OpenAI wrapper +│ ├── embeddings.ts # Voyage / OpenAI / Novita wrapper │ ├── composio.ts # Composio SDK wrapper (session + toolkit scoping) │ ├── composio-routes.ts # /composio/* HTTP routes for the Debug UI │ ├── browser-routes.ts # /browser/* HTTP routes for Local browser use diff --git a/debug/src/components/EmbeddingBanner.tsx b/debug/src/components/EmbeddingBanner.tsx index 6a0186c7..857cf1a0 100644 --- a/debug/src/components/EmbeddingBanner.tsx +++ b/debug/src/components/EmbeddingBanner.tsx @@ -2,7 +2,7 @@ import { useCallback, useEffect, useState } from "react"; import { useSocket } from "../lib/useSocket.js"; interface Status { - provider: "voyage" | "openai" | "local"; + provider: "voyage" | "openai" | "novita" | "local"; total: number; withEmbedding: number; withoutEmbedding: number; @@ -13,6 +13,7 @@ interface Status { const PROVIDER_LABEL: Record = { voyage: "Voyage (paid)", openai: "OpenAI (paid)", + novita: "Novita (paid)", local: "local (free, BGE-large)", }; diff --git a/server/embeddings.ts b/server/embeddings.ts index 6f86f0a2..79fc0499 100644 --- a/server/embeddings.ts +++ b/server/embeddings.ts @@ -1,7 +1,8 @@ /** - * Thin embeddings wrapper. Tries Voyage → OpenAI → local Transformers.js - * (Xenova/bge-large-en-v1.5). All three produce 1024-dim vectors so the - * Convex vector index stays compatible regardless of which provider runs. + * Thin embeddings wrapper. Tries Voyage → OpenAI → Novita → local + * Transformers.js (Xenova/bge-large-en-v1.5). All produce 1024-dim vectors + * so the Convex vector index stays compatible regardless of which provider + * runs. * * Local fallback ensures `recall()` always works — no API key required. * First local call downloads ~1.3GB and caches under Boop's local data folder. @@ -14,6 +15,7 @@ import { fileURLToPath } from "node:url"; const VOYAGE_MODEL = "voyage-3"; const OPENAI_MODEL = "text-embedding-3-large"; +const NOVITA_MODEL = "baai/bge-m3"; const LOCAL_MODEL = "Xenova/bge-large-en-v1.5"; const DIMENSIONS = 1024; const LOCAL_CACHE_DIR = resolve( @@ -28,11 +30,12 @@ const LOCAL_CACHE_DIR = resolve( let extractor: FeatureExtractionPipeline | null = null; let loading: Promise | null = null; -export type EmbeddingProvider = "voyage" | "openai" | "local"; +export type EmbeddingProvider = "voyage" | "openai" | "novita" | "local"; export function activeProvider(): EmbeddingProvider { if (process.env.VOYAGE_API_KEY) return "voyage"; if (process.env.OPENAI_API_KEY) return "openai"; + if (process.env.NOVITA_API_KEY) return "novita"; return "local"; } @@ -78,6 +81,23 @@ async function embedOpenAI(text: string): Promise { return json.data[0].embedding; } +async function embedNovita(text: string): Promise { + const res = await fetch("https://api.novita.ai/openai/v1/embeddings", { + method: "POST", + headers: { + "Content-Type": "application/json", + Authorization: `Bearer ${process.env.NOVITA_API_KEY}`, + }, + body: JSON.stringify({ + model: NOVITA_MODEL, + input: text, + }), + }); + if (!res.ok) throw new Error(`novita embeddings ${res.status}: ${await res.text()}`); + const json = (await res.json()) as { data: { embedding: number[] }[] }; + return json.data[0].embedding; +} + async function getLocalExtractor(): Promise { if (extractor) return extractor; if (loading) return loading; @@ -125,7 +145,9 @@ async function embedLocal(text: string): Promise { // recall() doesn't pay the ~5–15s model load. Safe to call at server // startup — failures are logged, not thrown. export function preloadLocalModel(): void { - if (process.env.VOYAGE_API_KEY || process.env.OPENAI_API_KEY) return; + if (process.env.VOYAGE_API_KEY || process.env.OPENAI_API_KEY || process.env.NOVITA_API_KEY) { + return; + } getLocalExtractor().catch((err) => { console.warn("[embeddings] local model preload failed:", err); }); @@ -135,6 +157,7 @@ export async function embed(text: string): Promise { try { if (process.env.VOYAGE_API_KEY) return await embedVoyage(text); if (process.env.OPENAI_API_KEY) return await embedOpenAI(text); + if (process.env.NOVITA_API_KEY) return await embedNovita(text); return await embedLocal(text); } catch (err) { console.warn("[embeddings] failed:", err);