feat(cluster): add memory + bifrost opt-in profiles (#3932 follow-up) (#4433)

Thanks @KooshaPari! Rebased onto release/v3.8.32. Opt-in memory/bifrost compose profiles + BIFROST_ENABLED killswitch land (fixed the doc image to maximhq + frontmatter on review). qdrant-wiring 17/17.
This commit is contained in:
KooshaPari
2026-06-21 04:42:27 -07:00
committed by GitHub
parent 2f17be3286
commit 147b6a36a4
8 changed files with 383 additions and 10 deletions

View File

@@ -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

View File

@@ -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) |
---

View File

@@ -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

View File

@@ -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 <name> 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`).

View File

@@ -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. |
---

View File

@@ -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(

View File

@@ -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<string, string | undefined> = {};
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);
});
});

View File

@@ -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<string, unknown>): 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<string, unknown>): 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<QdrantConfig> {