mirror of
https://github.com/diegosouzapw/OmniRoute.git
synced 2026-07-26 09:52:11 +03:00
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:
33
.env.example
33
.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
|
||||
|
||||
@@ -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) |
|
||||
|
||||
---
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
101
docs/architecture/cluster-decisions.md
Normal file
101
docs/architecture/cluster-decisions.md
Normal 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`).
|
||||
@@ -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. |
|
||||
|
||||
---
|
||||
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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> {
|
||||
|
||||
Reference in New Issue
Block a user