# CLAUDE.md — polyrouter (open-source LLM router) ## What this file is The always-loaded operating rules for coding agents on this project. The **full architecture, data model, and acceptance criteria live in the archived OpenSpec capability contracts (`openspec/specs/`)** and the code itself. Read the relevant capability spec before proposing or implementing anything. This file is the always-loaded short version; the invariants below are non-negotiable. ## What we're building **polyrouter** — a self-hostable **LLM router / gateway**: one OpenAI- and Anthropic-compatible endpoint that sits between a user's AI agents and their LLM providers, routes each request to an appropriate model, adds fallbacks and spend limits, and records cost/tokens/latency. Local-first, no per-call markup, metadata-only by default. See §1 of the spec. **Naming:** the app is **polyrouter**. Where the spec shows Manifest-branded examples (`mnfst_…` key prefix, `x-manifest-tier` header), use the polyrouter equivalents (`poly_…`, `x-polyrouter-tier`) — spec §16 forbids reusing the reference project's branding. ## How we work (spec-driven, OpenSpec) - **No feature code without an approved change proposal.** Work is delivered as OpenSpec changes. - Flow: `/opsx:explore` (when unsure) or `/opsx:propose ` → **review `proposal.md` + `tasks.md` + spec deltas against the archived `openspec/specs/` contracts** → implement `tasks.md` in order → `archive` (merges deltas into `openspec/specs/`). - **Durable context lives in `openspec/project.md`** (the constitution — stack, conventions, invariants) and the archived `openspec/specs/` capability contracts. Keep them in sync when a decision changes; open a change to do it, don't edit silently. - **Implement only the current approved change's `tasks.md`.** If a task contradicts the spec or an invariant below, **stop and flag it** — never silently reinterpret scope. - Prefer **small, single-capability changes** in dependency order (see build order). One capability ≈ one proposal. - Lift each capability's "definition of done" from the spec's **§15 acceptance criteria** + the matching milestone; express them as WHEN/THEN scenarios in the delta spec. ## Build order & review gate (from spec §14) Build in this order; each is roughly one capability/change: 1. **Foundation** — monorepo (Turborepo + npm workspaces), shared types, Drizzle + Postgres + Redis, **central tenant-scoping guard**, config + boot fail-fast. 2. **Auth & identity** — Better Auth (session plane) + **HMAC agent-API-key plane**, first-user-admin guard, auth-endpoint rate limiting. 3. **Provider layer** — adapter interface, OpenAI/Anthropic adapters, provider CRUD, **seed the bundled versioned pricing table**, **SSRF-validate base_urls**, Redis circuit breaker. 4. **Protocol translation core** — the OpenAI↔Anthropic normalization module **with golden-file contract tests** (§6.3). Everything downstream depends on this; do it before the proxy exits "happy path." 5. **Inference proxy — Layer 0** — endpoints, explicit / `x-polyrouter-tier` / default routing, fallbacks + **mid-stream commit policy**, RequestLog with **price snapshot**, streaming drain + backpressure. → **⛔ REVIEW GATE: stop here for human review.** This is the shippable core; do not proceed to automatic routing until it's approved. 6. **Auto routing — Layer 1 (structural)** — system-prompt fingerprinting + per-agent baseline, language-neutral features. 7. **Auto routing — Layer 3 (cascade)** — cheap-first + escalation. (Self-host feature target reached: L0 + L1 + L3.) → **Workload axis (Epic W, shipped 2026-08-22; spec `workload-routing` + deltas in `structural-routing` / `semantic-routing` / `cascade-routing` / `routing-config` / `dashboard-config` / `analytics-api` / `dashboard-analytics` / `semantic-learning`):** `auto` additionally records a **workload** class — `code` / `vision` / `structured` (structural, baseline) and, via the flag-gated semantic module only, `research` / `writing` — plus the telemetry-only `none`. W-1 `add-workload-telemetry` records it; W-2 `add-workload-routing` routes it (`auto_workload` rules claim a class ahead of the bands, `decision_layer='workload'`); W-3 `add-semantic-workloads` adds the reserved classes (one embed shared with L2, margin/min-sim rails); W-4 `add-workload-scoped-bands` lets `auto_high`/`auto_low` carry a class scope (the scoped rule decides — no silent fall-back; cascade within a class; the recorded reason ends with ` scope=`). It is a second verdict from the L1/L2 mechanisms — **not an L4**, no new header, no new `ROUTING_AUTO_LAYERS` token — the taxonomy is fixed and bumps only by a change, and detection is **never keyword-based** (invariant 9). 8. **Limits, alerts & notifications** — Redis atomic counters, block/alert, **SMTP + Apprise** channels, async delivery, dedup. 9. **Dashboard SPA** — SolidJS + uPlot; connect-agent flow, routing UI, **routing-decision inspector**, limits/notifications UI. 10. **Observability** — OTel traces + Prometheus metrics on the proxy. 11. **Packaging** — single-container Docker + compose (app/postgres/redis/optional apprise) + install script. 12. **Cloud graduations (deferred; only when flagged)** — split data-plane to Hono/Go, events to Timescale/ClickHouse. (The Layer 2 embedding classifier + learning loop graduated via the add-semantic-* changes: it is a **flag-gated optional module** — see the tech-stack note — not cloud-tier.) ## Tech stack — canonical, do not re-litigate (spec §3) Pin these versions; do not substitute without a change proposal that says why. - **Language:** TypeScript **strict** everywhere. Runtime **Node.js 24.x LTS**, npm 10–11 (Node 24 bundles npm 11). - **Backend / control plane:** **NestJS 11**. **ORM: Drizzle** (NOT TypeORM). **PostgreSQL 16**. - **Cache / atomic counters / queue:** **Redis** (+ a Redis-backed job queue, e.g. BullMQ). - **Auth:** **Better Auth** (email/password + Google/GitHub/Discord OAuth). - **Proxy contract:** OpenAI-compatible `/v1/chat/completions` + `/v1/models`; Anthropic-compatible `/v1/messages`. - **Frontend:** **SolidJS** + **Vite**, **uPlot** for charts, custom CSS. - **Monorepo/build:** **Turborepo** + npm workspaces. **Docker** is the primary distribution (single container: NestJS serves SPA + API + proxy on one port). - **Data-plane split and Timescale/ClickHouse are CLOUD-TIER ONLY** — do not add them to the baseline build. The **semantic stack (embedder, classifier, learning) is a FLAG-GATED OPTIONAL MODULE**: never in the baseline build/image; activates only via `SEMANTIC_MODEL_PATH` + `ROUTING_AUTO_LAYERS`. ## Repository layout (spec §4) ``` packages/ shared/ # TS types, enums, constants (CJS + ESM) control-plane/ # NestJS: dashboard API, auth, provider/tier/rule CRUD, analytics src/{entities,analytics,auth,providers,routing-config,database/migrations}/ data-plane/ # the proxy (a NestJS module in baseline; extractable later) src/{proxy,proxy/translate,routing,recording}/ frontend/ # SolidJS SPA (Vite) src/{pages,components,services}/ ``` ## Non-negotiable invariants (a change that violates one fails review) 1. **Explicit routing is the reliable core.** `auto` and all "smart" layers are opt-in and **must degrade to explicit/default**. Never fail or stall a request because the smart router (embedding model, classifier) is unavailable. (§1, §7) 2. **Protocol translation is its own module** (`data-plane/proxy/translate/`) behind a `Normalized*` IR, backed by **golden-file contract tests**. Keep the proxy core protocol-agnostic; provider quirks stay in adapters. (§6.3) 3. **Mid-stream commit rule:** do not commit to a **model** until the upstream's **first token** arrives. Before that, fall back normally. After that, the model is committed — on upstream error, **terminate the stream with a clear terminal error; NEVER silently swap models**. A **transport preamble** — the `200` status, SSE headers, and comment keepalives, carrying **no model output** — MAY be sent before the first token after a bounded delay (`PROXY_STREAM_EARLY_COMMIT_MS`), so idle-reaping proxies/CDNs keep the connection; a pre-commit failure after it is delivered as a protocol-shaped in-stream error, and fallback/cascade still run until the first token. (§6.3, §7.4; amended by add-stream-keepalive) 4. **Cost is immutable.** Compute cost at request time against **snapshotted unit prices** stored on the RequestLog; never recompute historical cost against current prices. Flag missing/estimated usage (`usage_estimated`), never store silent nulls. Recorded prices come from a bundled versioned table; a provider's captured **`listed`** price (from `sync-models`) MAY serve as a clearly-marked estimate **fallback** ONLY when the catalog (exact + native-family) is unknown — never overriding it — and is snapshotted immutably like any source (record-listed-price-fallback). (§7.7) 5. **Tenant isolation is mandatory and central.** Every access to an Agent/Provider/Model/RoutingRule/Limit/NotificationChannel/RequestLog is ownership-scoped (`WHERE owner = current_principal`). **No by-id fetch without an ownership guard.** Enforce in a shared repository/guard, not per-handler. (§11.1) 6. **SSRF: validate every user-supplied, server-fetched URL** (custom provider base_urls, Apprise/webhook targets, `APPRISE_API_URL`). Resolve and block private/loopback/link-local/metadata ranges (incl. IPv6; validate the resolved IP to defeat DNS rebinding). Loopback local models are the **only** exception, gated on `MODE=selfhosted`. (§8, §10.1, §11.2) 7. **API keys:** agent keys are **HMAC-SHA256 + prefix lookup**, fast-verified — **never bcrypt**. Session passwords use a slow hash (argon2/bcrypt). The two credential planes stay separate. (§3.2, §11) 8. **Privacy & secrets:** store **metadata only**; never persist prompt/response bodies unless the user explicitly opts in. Encrypt provider and notification-channel credentials at rest; **never log secrets**. (§1, §5, §8, §10.1) 9. **Hot path stays cheap:** do **not** run a full tokenizer for billing — use provider `usage`; estimate only for routing (`chars/4`). No per-request generative-LLM classification. (§3.2, §7.1, §7.7) 10. **Shared state is atomic in Redis.** Budget and rate counters must be correct across multiple proxy instances (no per-instance drift). (§3.2, §10) 11. **Notifications are async and resilient.** Deliver off the request path (queue/Redis); a slow or failing channel **must never block a request or budget enforcement**. Dedup/rate-limit alerts; a failed send logs and continues. (§10.1) 12. **Graceful shutdown drains in-flight streams; streaming applies backpressure** (no unbounded buffering to a slow client). (§3.2) ## Commands (maintain these scripts) - `npm run dev` — control-plane (watch) + frontend (Vite) together - `npm run build` — production build (shared → control-plane → frontend) via Turborepo - `npm start` — start production server (serves SPA + API + proxy) - `npm test -w packages/` — unit tests (Jest for backend packages, Vitest for frontend/shared — spec §3.1) - `npm run test:e2e -w packages/control-plane` — e2e (Supertest), incl. **protocol contract, SSRF, tenant-isolation, cost-immutability** suites - `npm run db:generate -w packages/control-plane` / `npm run db:migrate` — Drizzle migrations (migrations run on boot) - `npx changeset` — add a release note for any user-facing change ## Definition of done (every change) - Tasks in `tasks.md` complete; code matches the approved delta spec. - Tests written/updated and green — including the relevant **contract / SSRF / tenant-isolation / cost-immutability** checks where the change touches those areas. - Migration generated if the schema changed; `npm run build` passes; lint clean; strict TS with no `any` escapes. - Changeset added if user-facing — **plus a one-line entry under `[Unreleased]` in [`CHANGELOG.md`](./CHANGELOG.md)** (Added/Changed/Fixed; the release flow retitles the section — see the file's header for the contract). - Spec/deltas updated and the change archived (source of truth stays current). ## Coding standards - Strict TypeScript; global `ValidationPipe` (`whitelist`, `forbidNonWhitelisted`) on all input. - Tenant scoping and secret encryption go through shared utilities, never re-implemented per endpoint. - Conventional, present-tense commit messages explaining **why**. - Keep PRs/changes focused on one capability. - Gate self-host-only behavior (local providers, localhost auto-login, SSRF loopback exception) on `MODE=selfhosted`. - UI work follows the [`STYLESEED.md`](./STYLESEED.md) design lock (one accent `#4F5DFF`, one focal point per screen) and must pass the `/ss-score` quality gate (≥ 80) before being shown. ## Do NOT - Proxy through a central third party in self-host mode; add a per-call fee; persist prompt/response bodies without opt-in. - Hardcode a closed provider allow-list (custom OpenAI/Anthropic endpoints must always be addable). - Lead with a brittle rule-based auto-classifier, or call a generative LLM to classify every request. - Swap models mid-stream after bytes are sent; recompute historical cost against current prices. - Add cloud-tier infrastructure (data-plane split, ClickHouse/Timescale) to the baseline build — or ship the optional semantic stack (embedder/classifier/learning) in the BASELINE build/image (it is opt-in via `SEMANTIC_MODEL_PATH`; the baseline image must stay ORT- and model-free). - **Compliance note:** reusing flat-rate *subscriptions* (ChatGPT Plus, Claude Max) programmatically likely violates those providers' ToS. Support it as a provider kind, but surface the risk to users; BYOK/API-key and local models don't carry it. (spec §16) ## OpenWiki This repository uses OpenWiki for recurring code documentation. Start with `openwiki/quickstart.md`, then follow its links to architecture, workflows, domain concepts, operations, integrations, testing guidance, and source maps. The scheduled OpenWiki GitHub Actions workflow refreshes the repository wiki. Do not hand-edit generated OpenWiki pages unless explicitly asked; prefer updating source code/docs and letting OpenWiki regenerate.