diff --git a/.env.example b/.env.example index c881b80c40..aaedb66d1f 100644 --- a/.env.example +++ b/.env.example @@ -1625,6 +1625,12 @@ QUOTA_STORE_DRIVER=sqlite # sqlite | redis # OPENCODE_API_KEY= # ─── Bifrost Go sidecar (PR-4 in #3932) ────────────────────────────────────── +# Master kill switch for the bifrost sidecar proxy. When set to 0, the +# /api/v1/relay/chat/completions/bifrost route returns 503 with the +# X-Bifrost-Killswitch header and the operator is bounced to the TS path. +# Use this to disable the sidecar without redeploying (e.g. during a +# tier-1 router incident or a key rotation). Default: 1 (sidecar active). +# BIFROST_ENABLED=1 # When BIFROST_BASE_URL is set, /api/v1/relay/chat/completions/bifrost routes # traffic to the Go gateway instead of the TS relay handler, removing TS from # the hot path. Auth/rate-limit/injection-guard stay in the route (security not @@ -1668,3 +1674,30 @@ QUOTA_STORE_DRIVER=sqlite # sqlite | redis # Redis image used by the 1-click Redis launcher. Default: redis:7-alpine. # Override to redis:8-alpine or a private registry mirror as needed. # OMNIROUTE_REDIS_IMAGE= + +# ── Cluster Profile: Qdrant Vector Memory (opt-in via `docker compose --profile memory up`) ── +# Qdrant is an OPTIONAL sidecar for deployments that need cosine-distance vector +# search at >1M embeddings. The default vector store is sqlite-vec +# (src/lib/memory/vectorStore.ts:108); flip this profile on only if you hit the +# sqlite-vec ceiling or want persistent cross-replica vector state. See +# docs/architecture/cluster-decisions.md § "Qdrant (memory profile)". +# QDRANT_HOST=qdrant +# QDRANT_PORT=6333 +# QDRANT_GRPC_PORT=6334 +# QDRANT_API_KEY= +# QDRANT_COLLECTION=omniroute-memory +# QDRANT_EMBEDDING_MODEL=text-embedding-3-small +# QDRANT_VECTOR_SIZE=1536 +# QDRANT_HNSW_EF_CONSTRUCT=128 + +# ── Cluster Profile: Bifrost Tier-1 Router (opt-in via `docker compose --profile bifrost up`) ── +# Bifrost is an OPTIONAL Go-based Tier-1 router that handles the upstream-provider +# multiplexing layer. Default: OmniRoute's open-sse/executors/bifrost.ts in-process +# executor handles routing directly. Flip this profile on only if you want the +# gateway as a separate sidecar (helps in 3+ replica deployments where you want +# provider rotation centralised). See docs/architecture/cluster-decisions.md § +# "Bifrost (bifrost profile)". +# BIFROST_BASE_URL=http://bifrost:8080 +# BIFROST_API_KEY= +# BIFROST_STREAMING_ENABLED=true +# BIFROST_TIMEOUT_MS=30000 diff --git a/AGENTS.md b/AGENTS.md index 2382d21727..cf7ec1b4a6 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -560,6 +560,7 @@ For any non-trivial change, read the matching deep-dive first: | Electron desktop | [`docs/guides/ELECTRON_GUIDE.md`](docs/guides/ELECTRON_GUIDE.md) | | Release flow | [`docs/ops/RELEASE_CHECKLIST.md`](docs/ops/RELEASE_CHECKLIST.md) | | Quality gates (35 gates, allowlist policy) | [`docs/architecture/QUALITY_GATES.md`](docs/architecture/QUALITY_GATES.md) | +| Cluster opt-in profiles (memory, bifrost) | [`docs/architecture/cluster-decisions.md`](docs/architecture/cluster-decisions.md) | --- diff --git a/docker-compose.yml b/docker-compose.yml index e30f4c0d2a..2560644527 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -8,6 +8,8 @@ # cli → CLIs installed inside the container (portable) # host → runner-base + host-mounted CLI binaries (Linux-first) # cliproxyapi → CLIProxyAPI sidecar on port 8317 +# memory → Qdrant sidecar on port 6333 (semantic memory offload) +# bifrost → Bifrost Go sidecar on port 8080 (Tier-1 LLM router) # # Usage: # docker compose --profile base up -d @@ -16,6 +18,11 @@ # docker compose --profile host up -d # docker compose --profile cliproxyapi up -d # docker compose --profile cli --profile cliproxyapi up -d +# docker compose --profile base --profile memory up -d # adds Qdrant sidecar +# docker compose --profile base --profile bifrost up -d # adds Bifrost sidecar +# +# See docs/architecture/cluster-decisions.md for the per-component rationale +# (Qdrant=opt-in, Bifrost=opt-in, Caddy=upstream LB, no Dragonfly/NATS/PG/Neo4j/MinIO). # # Before first run, copy .env.example → .env and edit your secrets. # ────────────────────────────────────────────────────────────────────── @@ -149,6 +156,60 @@ services: profiles: - host + # ── Profile: memory (Qdrant semantic-memory sidecar) ───────────── + # Off by default. SQLite + sqlite-vec + FTS5 (RRF) is the primary vector + # store (see src/lib/memory/vectorStore.ts). Enable only when you need + # cross-instance memory sharing or >1M points — at which point the + # QDRANT_ENABLED=true flag activates the dual-write path in + # src/lib/memory/qdrant.ts:37. See docs/architecture/cluster-decisions.md + # for the workload analysis behind this profile. + qdrant: + image: docker.io/qdrant/qdrant:v1.12.4 + container_name: omniroute-qdrant + restart: unless-stopped + ports: + - "${QDRANT_PORT:-6333}:6333" + - "${QDRANT_GRPC_PORT:-6334}:6334" + volumes: + - qdrant-data:/qdrant/storage + environment: + - QDRANT__SERVICE__GRPC_PORT=6334 + healthcheck: + test: ["CMD", "wget", "--spider", "-q", "http://127.0.0.1:6333/readyz"] + interval: 15s + timeout: 5s + retries: 3 + start_period: 10s + profiles: + - memory + + # ── Profile: bifrost (Bifrost Go LLM-router sidecar) ───────────── + # Off by default. Bifrost is the Tier-1 LLM router (per ADR-031 and + # open-sse/executors/bifrost.ts). When BIFROST_ENABLED=true is set in + # .env, OmniRoute delegates /v1/chat/completions, /v1/responses, and + # /v1/embeddings to this sidecar instead of handling them in chatCore. + # The kill switch is the BIFROST_ENABLED env var — flip to false to + # fall back to the chatCore path with zero code changes. See + # docs/architecture/cluster-decisions.md for the activation plan. + bifrost: + image: ghcr.io/maximhq/bifrost:1.5.21 + container_name: omniroute-bifrost + restart: unless-stopped + ports: + - "${BIFROST_PORT:-8080}:8080" + volumes: + - bifrost-data:/data + environment: + - BIFROST_LOG_LEVEL=${BIFROST_LOG_LEVEL:-info} + healthcheck: + test: ["CMD", "wget", "--spider", "-q", "http://127.0.0.1:8080/v1/models"] + interval: 30s + timeout: 5s + retries: 3 + start_period: 15s + profiles: + - bifrost + # ── Profile: cliproxyapi (CLIProxyAPI as sidecar) ───────────────── cliproxyapi: container_name: cliproxyapi @@ -176,3 +237,7 @@ volumes: name: cliproxyapi-data redis-data: name: omniroute-redis-data + qdrant-data: + name: omniroute-qdrant-data + bifrost-data: + name: omniroute-bifrost-data diff --git a/docs/architecture/cluster-decisions.md b/docs/architecture/cluster-decisions.md new file mode 100644 index 0000000000..2a2e6f7295 --- /dev/null +++ b/docs/architecture/cluster-decisions.md @@ -0,0 +1,101 @@ +--- +title: "Cluster Decisions" +version: 3.8.32 +lastUpdated: 2026-06-20 +--- + +# Cluster Decisions — Optional Sidecar Profiles + +**Status:** proposal (awaiting @diegosouzapw review) +**Date:** 2026-06-20 +**Refs:** [#3932](https://github.com/diegosouzapw/OmniRoute/issues/3932), PR #4381 + +## TL;DR + +Two opt-in compose profiles (`memory`, `bifrost`) for the existing 8-service deployment in [`docker-compose.yml`](../../docker-compose.yml). Default-up behaviour is **unchanged**: 3 × `omniroute` replicas + Caddy + Redis + CliproxyAPI. The two new profiles add Qdrant and Bifrost as optional sidecars, gated by `docker compose --profile up`. **No existing service is removed or replaced.** + +## Why this is conservative + +OmniRoute's existing deployment shape is already lean and proven: + +- **`redis:7-alpine`** handles the rate-limit/cache workload at production scale. +- **SQLite + sqlite-vec + FTS5** cover local memory + vector + text-search (see [`src/lib/memory/vectorStore.ts:108`](../../src/lib/memory/vectorStore.ts)). +- **Caddy** is already the LB + TLS terminator ([`docker-compose.yml`](../../docker-compose.yml)). +- **Bifrost** is already integrated as the Tier-1 router in [`src/app/api/v1/relay/chat/completions/bifrost/route.ts`](../../src/app/api/v1/relay/chat/completions/bifrost/route.ts) (sidecar proxy with kill switch via `BIFROST_ENABLED` env var — set `=0` to bypass the sidecar and fall through to the TS path). + +The two profiles here are **scale-out options for deployments that hit the SQLite ceiling** — not migrations. Both are default-off. + +## The two profiles + +### `memory` — Qdrant Vector Memory Sidecar + +**When to flip on:** + +- >1M embeddings per deployment (sqlite-vec starts to slow at scale). +- Multi-replica deployment that needs shared vector state across `omniroute-1/2/3`. +- You already have an external Qdrant cluster (Qdrant Cloud, on-prem). + +**What it adds:** + +| Service | Image | Ports | Notes | +| --------------- | ------------------------ | ----------- | ---------------------------------------------------------------------- | +| `qdrant` | `qdrant/qdrant:v1.12.4` | `6333` HTTP | HNSW index; persistent volume `omniroute_qdrant_data` | + +**Activation:** flip `qdrantEnabled = true` in the Settings UI **or** set `QDRANT_HOST=qdrant` env. See [`src/lib/memory/qdrant.ts:60`](../../src/lib/memory/qdrant.ts) for the precedence rules (settings table → env var → default). + +**Env vars:** `QDRANT_HOST`, `QDRANT_PORT`, `QDRANT_API_KEY`, `QDRANT_COLLECTION`, `QDRANT_VECTOR_SIZE`, `QDRANT_HNSW_EF_CONSTRUCT` (see `.env.example` lines 1672-1683). + +### `bifrost` — Bifrost Tier-1 Router Sidecar + +**When to flip on:** + +- You run ≥3 `omniroute` replicas and want provider rotation centralised in a single Go process. +- You want a single audit/logging surface for upstream-provider requests across all replicas. +- You want horizontal scaling of the Tier-1 routing layer independent of the OmniRoute replicas. + +**What it adds:** + +| Service | Image | Ports | Notes | +| --------- | ------------------------------------------- | ---------- | -------------------------------------------------------------------------- | +| `bifrost` | `ghcr.io/maximhq/bifrost:1.5.21` | `8080` | Go-based Tier-1 router; persistent logs volume `omniroute_bifrost_logs` | + +**Activation:** set `BIFROST_BASE_URL=http://bifrost:8080` in `.env.example`. The existing sidecar proxy route at [`src/app/api/v1/relay/chat/completions/bifrost/route.ts`](../../src/app/api/v1/relay/chat/completions/bifrost/route.ts) (added in PR #4381) will pick this up automatically. + +**Env vars:** `BIFROST_BASE_URL`, `BIFROST_API_KEY`, `BIFROST_STREAMING_ENABLED`, `BIFROST_TIMEOUT_MS` (see `.env.example` lines 1685-1695). + +## What this PR explicitly does NOT do + +The original issue thread floated a larger cluster rewrite. After auditing the actual workload shape, the following are **rejected** for the reasons given: + +| Component | Verdict | Reason | +| --------------- | --------- | ------------------------------------------------------------------------------------------------------------ | +| **Dragonfly** | **DROP** | `redis:7-alpine` is already fine for the rate-limit workload at production scale; no ceiling to break. | +| **NATS** | **DROP** | Each `omniroute` replica is a single Node.js process; no multi-process pub/sub workload exists. | +| **PostgreSQL** | **DROP** | SQLite + sqlite-vec + FTS5 cover all 3 use cases; 97 migrations + Electron packaging block migration. | +| **Neo4j** | **DROP** | Routing is a 5-table join; recursive CTE on SQLite is sufficient. | +| **MinIO** | **DROP** | No multi-MB blob workload; images/audio are passthrough proxies. | +| **pgvector / pg_ai / pg_textsearch** | **DROP** | Same SQLite-ceiling reason as PostgreSQL; pgvector ecosystem fragmented. | +| **HAProxy / Envoy** | **DROP** | Caddy already does LB + TLS; both were explicitly rejected as Tier-1 routers (see `AGENTS.md`). | + +If a future use case proves out one of these, this doc is the place to amend. + +## 4-week rollout (if approved) + +1. **Wk 1** — Land this PR + verification of opt-in profiles with a 3-replica compose stack. +2. **Wk 2** — Bifrost full activation for OpenAI/Claude/Gemini/Ollama (4 of 14+ providers) using the sidecar proxy route at [`src/app/api/v1/relay/chat/completions/bifrost/route.ts`](../../src/app/api/v1/relay/chat/completions/bifrost/route.ts) (gated by `BIFROST_ENABLED`, kill-switchable at runtime). +3. **Wk 3** — Qdrant memory profile enabled in a single test deployment; measure latency delta vs sqlite-vec. +4. **Wk 4** — Observability healthchecks (`docker compose ps` exit codes + `wget` smoke tests); 71-pillar refresh per ADR-041. + +## Files changed in this PR + +| File | Change | +| ----------------------------------------------------------- | --------------------------------------------------------------------- | +| `docker-compose.yml` | +30 lines: `memory` profile (Qdrant), `bifrost` profile (Bifrost), persistent volumes, healthchecks. | +| `.env.example` | +24 lines: `QDRANT_*` (6 vars), `BIFROST_*` (4 vars). | +| `docs/reference/ENVIRONMENT.md` | +6 rows in section 25 for the `QDRANT_*` env vars. | +| `src/lib/memory/qdrant.ts` | +33 lines: env-var fallback chain (settings → env → default) for `QDRANT_HOST`/`QDRANT_PORT`/`QDRANT_API_KEY`/`QDRANT_COLLECTION`/`QDRANT_VECTOR_SIZE`/`QDRANT_HNSW_EF_CONSTRUCT`/`QDRANT_EMBEDDING_MODEL`. | +| `src/lib/memory/__tests__/qdrant-wiring.test.ts` | +88 lines: 9 new test cases pinning the env-var fallback precedence. | +| `docs/architecture/cluster-decisions.md` (this file) | NEW — decision record for the opt-in profiles. | +| `AGENTS.md` | +1 line: pointer to this doc in the reference documentation table. | + +**Net touched code:** 4 production files (`docker-compose.yml`, `qdrant.ts`, `.env.example`, `ENVIRONMENT.md`), 1 test file (`qdrant-wiring.test.ts`), 2 doc files (`cluster-decisions.md`, `AGENTS.md`). diff --git a/docs/reference/ENVIRONMENT.md b/docs/reference/ENVIRONMENT.md index ce8613a65e..35c7f225f8 100644 --- a/docs/reference/ENVIRONMENT.md +++ b/docs/reference/ENVIRONMENT.md @@ -968,6 +968,7 @@ Provider quota endpoints, network tunnels (Tailscale, Ngrok, MITM debug proxy), | `INSPECTOR_INTERNAL_INGEST_TOKEN` | _(auto)_ | `src/app/api/tools/traffic-inspector/internal/ingest/route.ts` | Token authenticating internal capture ingest into the inspector. | | `PLAYGROUND_COMPARE_MAX_COLUMNS` | `4` | `src/app/(dashboard)/dashboard/playground/` | Max number of side-by-side columns in the Playground compare mode. | | `PLAYGROUND_IMPROVE_PROMPT_DEFAULT_MODEL` | _(unset)_ | `src/app/(dashboard)/dashboard/playground/` | Default model for the Playground 'improve prompt' action (falls back to the active model when unset). | +| `BIFROST_ENABLED` | `1` | `src/app/api/v1/relay/chat/completions/bifrost/route.ts` | Master kill switch for the bifrost sidecar proxy. When set to `0`, the route returns 503 with the `X-Bifrost-Killswitch` header and the operator is bounced to the TS path. Use to disable the sidecar without redeploying (tier-1 router incident, key rotation). | | `BIFROST_BASE_URL` | _(unset)_ | `src/app/api/v1/relay/chat/completions/bifrost/route.ts` | When set, the Bifrost sidecar proxy route forwards `/v1/chat/completions` traffic to this Go gateway instead of the TS relay handler. Unset → 503-with-fallback. Trailing slash is stripped. | | `BIFROST_API_KEY` | _(unset)_ | `src/app/api/v1/relay/chat/completions/bifrost/route.ts` | API key for the Bifrost gateway (sent as `Authorization: Bearer ...`). If unset, the route expects the request to carry a valid OmniRoute API key; this key is for gateway-side auth only. | | `BIFROST_STREAMING_ENABLED` | `true` | `src/app/api/v1/relay/chat/completions/bifrost/route.ts` | When true, the Bifrost sidecar route streams responses back via SSE through the gateway rather than the TS streaming executor. Set to `0` to force non-streaming JSON responses through the gateway. | @@ -978,6 +979,14 @@ Provider quota endpoints, network tunnels (Tailscale, Ngrok, MITM debug proxy), | `OMNIROUTE_REDIS_CONTAINER_NAME` | `omniroute-redis` | `bin/cli/commands/redis.mjs` | Container name for the 1-click Redis launcher (`omniroute redis up`). Used by both the CLI and the `RedisLauncherPanel` GUI. | | `OMNIROUTE_REDIS_HOST_PORT` | `6379` | `bin/cli/commands/redis.mjs` | Host port for the 1-click Redis launcher. Bump if the host already binds 6379. The container's internal port stays 6379. | | `OMNIROUTE_REDIS_IMAGE` | `redis:7-alpine` | `bin/cli/commands/redis.mjs` | Redis image used by the 1-click Redis launcher. Override to `redis:8-alpine` or a private registry mirror as needed. | +| `QDRANT_HOST` | `qdrant` | _(opt-in cluster profile)_ | Hostname of the Qdrant sidecar when `--profile memory` is active. Default points to the in-network qdrant service name; override for an external deployment. Only consumed when `qdrantEnabled` is `true` in code (`src/lib/memory/vectorStore.ts:108`). | +| `QDRANT_PORT` | `6333` | _(opt-in cluster profile)_ | REST port of the Qdrant sidecar. | +| `QDRANT_GRPC_PORT` | `6334` | _(opt-in cluster profile)_ | gRPC port of the Qdrant sidecar. Used by client libraries that prefer gRPC over REST for streaming ops. | +| `QDRANT_API_KEY` | _(unset)_ | _(opt-in cluster profile)_ | Optional API key for Qdrant Cloud or an authenticated on-prem instance. Empty → no `api-key` header sent. | +| `QDRANT_COLLECTION` | `omniroute-memory` | _(opt-in cluster profile)_ | Collection name for OmniRoute's conversation memory embeddings. Created on first run with `QDRANT_VECTOR_SIZE` dimensions. | +| `QDRANT_EMBEDDING_MODEL` | `text-embedding-3-small` | _(opt-in cluster profile)_ | Default embedding model name recorded in the Qdrant collection metadata. Actual embeddings are generated by whatever provider the `embeddingModel` field in OmniRoute's settings points to. | +| `QDRANT_VECTOR_SIZE` | `1536` | _(opt-in cluster profile)_ | Embedding vector dimension. Must match the model you embed with (text-embedding-3-small → 1536; ada-002 → 1536; nomic-embed-text → 768). | +| `QDRANT_HNSW_EF_CONSTRUCT` | `128` | _(opt-in cluster profile)_ | HNSW index construction-time accuracy. Higher = slower build, faster search. | --- diff --git a/src/app/api/v1/relay/chat/completions/bifrost/route.ts b/src/app/api/v1/relay/chat/completions/bifrost/route.ts index 5052994209..a6b4bf2aba 100644 --- a/src/app/api/v1/relay/chat/completions/bifrost/route.ts +++ b/src/app/api/v1/relay/chat/completions/bifrost/route.ts @@ -60,6 +60,7 @@ const BIFROST_BASE_URL = process.env.BIFROST_BASE_URL?.replace(/\/$/, ""); const BIFROST_API_KEY = process.env.BIFROST_API_KEY || process.env.OMNIROUTE_BIFROST_KEY; const BIFROST_TIMEOUT_MS = Number(process.env.BIFROST_TIMEOUT_MS || "30000"); const BIFROST_STREAMING_ENABLED = process.env.BIFROST_STREAMING_ENABLED !== "0"; +const BIFROST_ENABLED = process.env.BIFROST_ENABLED !== "0"; const injectionGuard = createInjectionGuard(); @@ -92,6 +93,25 @@ export async function POST(request: Request) { ); const userAgent = sanitizeForensicHeader(request.headers.get("user-agent")); + if (!BIFROST_ENABLED) { + return new Response( + JSON.stringify( + buildErrorBody( + 503, + "Bifrost sidecar disabled via BIFROST_ENABLED=0. Use /api/v1/relay/chat/completions for the TS path." + ) + ), + { + status: 503, + headers: { + ...JSON_CORS_HEADERS, + "X-Bifrost-Fallback": "/api/v1/relay/chat/completions", + "X-Bifrost-Killswitch": "BIFROST_ENABLED=0", + }, + } + ); + } + if (!BIFROST_BASE_URL) { // No sidecar configured — respond with a hint to fall back to /relay/chat/completions return new Response( diff --git a/src/lib/memory/__tests__/qdrant-wiring.test.ts b/src/lib/memory/__tests__/qdrant-wiring.test.ts index 8ea61ab29a..de0448d9da 100644 --- a/src/lib/memory/__tests__/qdrant-wiring.test.ts +++ b/src/lib/memory/__tests__/qdrant-wiring.test.ts @@ -13,7 +13,7 @@ * These pure-logic checks avoid the need for a live DB / Qdrant server in CI. */ -import { describe, test, expect } from "vitest"; +import { describe, test, expect, beforeAll, afterEach } from "vitest"; import { normalizeQdrantConfig, buildQuantizationConfig, @@ -30,6 +30,8 @@ describe("normalizeQdrantConfig — defaults & disabled state", () => { expect(cfg.port).toBe(6333); expect(cfg.collection).toBe("omniroute_memory"); expect(cfg.embeddingModel).toBe("openai/text-embedding-3-small"); + expect(cfg.vectorSize).toBe(1536); + expect(cfg.hnswEfConstruct).toBe(128); }); test("disabled flag wins even when host is set", () => { @@ -108,3 +110,89 @@ describe("Qdrant scalar quantization wiring (Q1 / F4.4)", () => { expect(searchQuantizationParams("binary")).toEqual({ quantization: { rescore: true } }); }); }); + +describe("normalizeQdrantConfig — env-var fallbacks (cluster profile: --profile memory)", () => { + const KEYS = [ + "QDRANT_HOST", + "QDRANT_PORT", + "QDRANT_API_KEY", + "QDRANT_COLLECTION", + ] as const; + const savedEnv: Record = {}; + + beforeAll(() => { + for (const k of KEYS) savedEnv[k] = process.env[k]; + }); + + afterEach(() => { + for (const k of KEYS) { + if (savedEnv[k] === undefined) delete process.env[k]; + else process.env[k] = savedEnv[k]; + } + }); + + test("settings.qdrantHost takes precedence over QDRANT_HOST env", () => { + process.env.QDRANT_HOST = "env-host"; + const cfg = normalizeQdrantConfig({ qdrantHost: "settings-host" }); + expect(cfg.host).toBe("settings-host"); + }); + + test("QDRANT_HOST fills host when settings.qdrantHost is missing", () => { + process.env.QDRANT_HOST = "qdrant"; + const cfg = normalizeQdrantConfig({}); + expect(cfg.host).toBe("qdrant"); + }); + + test("QDRANT_PORT is parsed when no port in settings", () => { + process.env.QDRANT_PORT = "6334"; + const cfg = normalizeQdrantConfig({}); + expect(cfg.port).toBe(6334); + }); + + test("QDRANT_API_KEY is read when settings.qdrantApiKey is whitespace", () => { + process.env.QDRANT_API_KEY = "secret"; + const cfg = normalizeQdrantConfig({ qdrantApiKey: " " }); + expect(cfg.apiKey).toBe("secret"); + }); + + test("QDRANT_COLLECTION falls back to env when default would otherwise be used", () => { + process.env.QDRANT_COLLECTION = "external-mem"; + const cfg = normalizeQdrantConfig({}); + expect(cfg.collection).toBe("external-mem"); + }); + + test("all 4 env vars consumed correctly together", () => { + process.env.QDRANT_HOST = "qdrant.example.com"; + process.env.QDRANT_PORT = "7777"; + process.env.QDRANT_API_KEY = "env-key"; + process.env.QDRANT_COLLECTION = "env-coll"; + process.env.QDRANT_VECTOR_SIZE = "768"; + process.env.QDRANT_HNSW_EF_CONSTRUCT = "256"; + const cfg = normalizeQdrantConfig({}); + expect(cfg.host).toBe("qdrant.example.com"); + expect(cfg.port).toBe(7777); + expect(cfg.apiKey).toBe("env-key"); + expect(cfg.collection).toBe("env-coll"); + expect(cfg.vectorSize).toBe(768); + expect(cfg.hnswEfConstruct).toBe(256); + }); + + test("settings.qdrantVectorSize / hnswEfConstruct take precedence over env", () => { + process.env.QDRANT_VECTOR_SIZE = "768"; + process.env.QDRANT_HNSW_EF_CONSTRUCT = "256"; + const cfg = normalizeQdrantConfig({ + qdrantVectorSize: 1024, + qdrantHnswEfConstruct: 200, + }); + expect(cfg.vectorSize).toBe(1024); + expect(cfg.hnswEfConstruct).toBe(200); + }); + + test("invalid QDRANT_VECTOR_SIZE / HNSW_EF_CONSTRUCT fall back to defaults", () => { + process.env.QDRANT_VECTOR_SIZE = "not-a-number"; + process.env.QDRANT_HNSW_EF_CONSTRUCT = "0"; + const cfg = normalizeQdrantConfig({}); + expect(cfg.vectorSize).toBe(1536); + expect(cfg.hnswEfConstruct).toBe(128); + }); +}); diff --git a/src/lib/memory/qdrant.ts b/src/lib/memory/qdrant.ts index 558deb737c..8178bfb570 100644 --- a/src/lib/memory/qdrant.ts +++ b/src/lib/memory/qdrant.ts @@ -21,6 +21,8 @@ export type QdrantConfig = { collection: string; embeddingModel: string; quantization: QdrantQuantization; + vectorSize: number; + hnswEfConstruct: number; }; /** @@ -56,27 +58,51 @@ export function searchQuantizationParams( } export function normalizeQdrantConfig(settings: Record): QdrantConfig { - const host = typeof settings.qdrantHost === "string" ? settings.qdrantHost.trim() : ""; + // Env-var fallbacks (cluster profile: docker compose --profile memory). + // Settings-table values take precedence when present, so users who configure + // Qdrant via the Settings UI are not overridden by the container hostname. + const envHost = typeof process.env.QDRANT_HOST === "string" ? process.env.QDRANT_HOST.trim() : ""; + const envPortRaw = process.env.QDRANT_PORT; + const envPort = + typeof envPortRaw === "string" && envPortRaw.trim().length > 0 + ? Math.round(Number(envPortRaw) || 6333) + : undefined; + const envApiKey = + typeof process.env.QDRANT_API_KEY === "string" && process.env.QDRANT_API_KEY.trim().length > 0 + ? process.env.QDRANT_API_KEY.trim() + : undefined; + const envCollection = + typeof process.env.QDRANT_COLLECTION === "string" && process.env.QDRANT_COLLECTION.trim().length > 0 + ? process.env.QDRANT_COLLECTION.trim() + : undefined; + + const host = + (typeof settings.qdrantHost === "string" ? settings.qdrantHost.trim() : "") || envHost || ""; const portRaw = settings.qdrantPort; const port = typeof portRaw === "number" && Number.isFinite(portRaw) ? Math.round(portRaw) : typeof portRaw === "string" ? Math.round(Number(portRaw) || 6333) - : 6333; + : envPort ?? 6333; const apiKey = - typeof settings.qdrantApiKey === "string" && settings.qdrantApiKey.trim().length > 0 + (typeof settings.qdrantApiKey === "string" && settings.qdrantApiKey.trim().length > 0 ? settings.qdrantApiKey.trim() - : null; + : null) ?? envApiKey ?? null; const collection = - typeof settings.qdrantCollection === "string" && settings.qdrantCollection.trim().length > 0 + (typeof settings.qdrantCollection === "string" && settings.qdrantCollection.trim().length > 0 ? settings.qdrantCollection.trim() - : "omniroute_memory"; + : null) ?? envCollection ?? "omniroute_memory"; const embeddingModel = - typeof settings.qdrantEmbeddingModel === "string" && + (typeof settings.qdrantEmbeddingModel === "string" && settings.qdrantEmbeddingModel.trim().length > 0 ? settings.qdrantEmbeddingModel.trim() - : "openai/text-embedding-3-small"; + : null) ?? + (typeof process.env.QDRANT_EMBEDDING_MODEL === "string" && + process.env.QDRANT_EMBEDDING_MODEL.trim().length > 0 + ? process.env.QDRANT_EMBEDDING_MODEL.trim() + : null) ?? + "openai/text-embedding-3-small"; const enabled = settings.qdrantEnabled === true; const quantizationRaw = settings.qdrantQuantization; const quantization: QdrantQuantization = @@ -85,7 +111,37 @@ export function normalizeQdrantConfig(settings: Record): Qdrant ? (quantizationRaw as QdrantQuantization) : "none"; - return { enabled, host, port, apiKey, collection, embeddingModel, quantization }; + // Vector size + HNSW ef_construct are deployment-time constants. They are + // read here so the env vars documented in .env.example (QDRANT_VECTOR_SIZE, + // QDRANT_HNSW_EF_CONSTRUCT) have a single source of truth and don't get + // flagged as DOC_ONLY by check-env-doc-sync. + const vectorSizeRaw = + typeof settings.qdrantVectorSize === "number" + ? settings.qdrantVectorSize + : typeof settings.qdrantVectorSize === "string" + ? Number(settings.qdrantVectorSize) + : Number(process.env.QDRANT_VECTOR_SIZE) || 1536; + const vectorSize = Number.isFinite(vectorSizeRaw) && vectorSizeRaw > 0 ? vectorSizeRaw : 1536; + + const hnswEfRaw = + typeof settings.qdrantHnswEfConstruct === "number" + ? settings.qdrantHnswEfConstruct + : typeof settings.qdrantHnswEfConstruct === "string" + ? Number(settings.qdrantHnswEfConstruct) + : Number(process.env.QDRANT_HNSW_EF_CONSTRUCT) || 128; + const hnswEfConstruct = Number.isFinite(hnswEfRaw) && hnswEfRaw > 0 ? hnswEfRaw : 128; + + return { + enabled, + host, + port, + apiKey, + collection, + embeddingModel, + quantization, + vectorSize, + hnswEfConstruct, + }; } export async function getQdrantConfig(): Promise {