Imported from seaavey/SRouter (
AGENTS.md). Install upstream withnpx skills add seaavey/SRouter. Copyright stays with the author.
Repository Guidelines
Project Overview
SRouter — a self-hosted, multi-provider LLM gateway ("Multi-Provider OpenAI & Anthropic Compatible LLM Gateway", server/src/app.rs:54). It exposes OpenAI-style (/v1/chat/completions, /v1/chat), Anthropic-style (/v1/messages), and image (/v1/images/generations) endpoints, authenticates callers, routes each model id onto a per-vendor provider driver, streams SSE back, logs every request to SQLite, and serves an operator API plus an optional SPA dashboard.
Two independently-toolchained halves in one repo, sharing only generated TypeScript bindings:
| Half | Stack | Notes |
|---|---|---|
server/ |
Rust edition 2024, axum 0.8, tokio, sqlx 0.9 (SQLite only), specta | The real product; behaviorally ported from a deleted Node apps/api |
client/ |
Bun + Vite + React 19, TanStack Router/Query, Tailwind v4, shadcn (base-mira on @base-ui/react) |
Early-stage SPA: auth spine (login/first-run setup), provider management, request logs, and usage/quota are live; keys and settings are still static mockups |
There is no workspace root manifest. The root carries the container entry points (Dockerfile, .dockerignore, docker-compose.yml, Makefile), and .github/workflows/ holds the CI plus the manual GHCR publish. cargo is the whole server build system.
Architecture & Data Flow
Boot (server/src/main.rs): dotenvy::dotenv() → telemetry::init() → APIConfig::from_env_map → AppDatabase::connect (runs migrations to SCHEMA_VERSION = 4) → bootstrap_admin_account_from_env → ProviderRegistry::with_database + register_custom_providers → AppState::with_security(...).with_database(...) → spawns catalog warm-up and a 60 s OAuth token sweeper → listeners::serve_main(create_router(state), 0.0.0.0:PORT).
Request: axum router (server/src/app.rs create_router) → per-mount guards → feature handler → model resolution → provider driver → UpstreamClient (reqwest) → SSE/JSON back out; interception/logging.rs writes the request log and settles quota best-effort.
Composition root / DI: manual, no container. AppState + SecurityState (server/src/state.rs) hold Arc<dyn Trait> stores; middleware attaches Extension<APIPrincipal>; handlers extract State, Extension, Path/Query, Request. Empty* store impls + SecurityState::unconfigured() keep a DB-less boot coherent.
Gateway pipeline (server/src/features/gateway/): routes.rs → chat.rs / messages.rs / images.rs → body parse (protocol/model.rs) → ensure_model_allowed_any (api_keys/access.rs) → token_saver → reserve_api_key_quota → ProviderRegistry::resolve(model) → ProviderAdapter/ProviderExecutor → upstream call. Streaming uses mpsc + ReceiverStream; provider failures become in-stream SSE error events (adapter.rs), never stream errors. interceptor.rs intercepts built-in search tool calls the client did not declare.
Router layering rules (critical, in app.rs): guards are applied per mount — read surfaces get api_key_auth, operator surfaces get require_admin_session + CSRF, OAuth callbacks are public, admin-auth routes enforce session per handler. /v1 and /v1/v1 nests each own a JSON 404 fallback so they never fall through to the SPA. Global layers, last-added = outermost: log_access → log_failed_requests → security_headers → cors. Root route + SPA fallback must be registered before the layers (Router::layer only wraps existing routes).
Bindings flow: Rust wire types derive Serialize + specta::Type → server/src/bindings.rs registry → cargo run --bin export_ts → writes both server/bindings.ts and client/src/generated/typed.ts. The client imports them through client/src/api/types.ts.
Non-Negotiable Rules
Read this before touching a file. Nothing here is enforced by tooling; it is enforced by review. Standard is a senior engineer who owns the code for the next six months, not one who ships the fastest diff.
Scope
- Do the requested change, completely, and nothing else. No unrequested features, refactors, dependencies, or "while I'm here" tidy-ups. Reduce scope only with explicit approval; never silently.
- A diff is the unit of work. It compiles, runs, and is verifiable on its own. Rename a symbol → every callsite lands in the same change, and the obsolete version leaves with it. No shims, re-exports, or deprecation aliases.
- Leave no scaffolding. No
TODO/FIXMEmarker, stub, mock, placeholder, or unreachable branch in shipped code.server/srcandclient/srccontain zero today — keep it that way. (Four comments infeatures/provider_auth/cite aTODO.mddeleted in233b7a6; that is a stale pointer, not a marker — the same class as the deaddocs/*.mdcitations, and not a pattern to copy.)
Design
- No over-engineering. The entire server defines 5 traits (
APIKeyStore,APIKeyRepository,AdminSessionStore,AdminAuthRepository,ProviderExecutor) and no DI container; the client has 19 runtime dependencies. Do not add an abstraction, generic parameter, trait, registry, plugin point, feature flag, or dependency until a second concrete caller exists. Three repeated lines beat a premature helper. - Reuse the vocabulary that exists.
client/src/components/ui/(shadcn/Base UI), the gauge kit for dials,APIError+constants.rson the server,TestDatabasein tests,create_*_routerfor mounts. A second convention for a job that already has one is the defect — even when the new one is nicer. - Match the file you are in. Same naming, module layout, error style, and comment style as its neighbours. Read the surrounding 50 lines before adding to anything.
- Comments explain why, never what. Record the trap that motivated the line (see
client/vite.config.tsonchangeOrigin,state.rsonunconfigured). No narration, no section banners, no restating the next line.
Correctness
- No
unwrap()on a production path. The codebase has exactly one (providers/registry.rs:282) and clippy will not flag yours. Propagate with?intoAPIError; useexpect("...")only where failure is provably impossible (static parse, literal) and always with a message. Lock poisoning is recovered, not unwrapped:.unwrap_or_else(|e| e.into_inner()). - One error type.
APIErrorwith builders, propagated by?. Noanyhow,thiserror,Box<dyn Error>outsidemain, and no per-module error enums. - Every client-visible string, code, and header name comes from
server/src/constants.rs. Never inline a message in a handler; interpolated messages become functions. - Frozen behaviour stays frozen. Node-parity semantics, generated files,
[profile.dev] debug = 1, per-mount guard placement, global layer order, and the frozen test contracts are all deliberate. If a change seems to require altering one, stop and report it rather than "fixing" it.
Evidence
- Verify by running, not by reasoning. Every behavioural change gets exercised on the real path — the handler, the CLI, or the browser. Tests alone are not proof. A green
cargo checkortscproves only that it compiles. - State exactly what you ran. Never claim coverage you did not exercise, never describe an untested path as working, and never report success from a command you skipped. Unverified is a valid answer; "probably fine" is not.
- Report a blocker; do not route around it. If the requested change cannot be made safely, say which invariant it breaks and what you tried — do not silently weaken a guard, widen a scope, or delete a test to make the change fit.
For server work, skills/srouter-server/SKILL.md is the detailed companion to the rules above; for client work, the companion is skills/srouter-frontend/SKILL.md.
Key Directories
| Path | Contents |
|---|---|
server/src/app.rs |
The only router assembly; ApiInfo, /health, route_not_found |
server/src/state.rs |
AppState, SecurityState, builders |
server/src/error.rs |
APIError + IntoResponse → OpenAI ErrorEnvelope |
server/src/constants.rs |
Every client-visible message, code, error_type, header name |
server/src/features/ |
One dir per feature: gateway/ (chat, messages, images, translation, interception, search), providers/ (registry, executors, per-vendor drivers, custom/, management/, rotation), catalog/, api_keys/, admin_auth/, provider_auth/, logs.rs, settings.rs, database_transfer/ |
server/src/http/ |
middleware/ (api_key_auth, admin_session, rate_limit, csrf, cors, body_limit, access_log, failure_log, security_headers), listeners.rs, static_files.rs |
server/src/infrastructure/ |
database/ (sqlite handle, migrations, stores), upstream/ (client + SSRF guards), telemetry.rs |
server/src/protocol/ |
Leaf wire types: model, usage, sse, image |
server/migrations/ |
NNNN snake_case.sql, DDL only, embedded via include_str!; no sqlx-cli |
server/tests/ |
42 integration suites + the 3.1k-line support/ harness |
client/src/routes/ |
TanStack file routes; protected screens belong under _authenticated/ |
client/src/api/ |
The only network layer (client.ts wrapper + queryOptions factories) |
server/src/benchmark/ |
k6 scenarios — external k6 binary, not part of cargo test |
Development Commands
Run from the repo root. Server (verbatim from CONTRIBUTING.md:12-37):
cargo run --manifest-path server/Cargo.toml # listens on :3000
cargo test --manifest-path server/Cargo.toml --test chat_completions # one suite
cargo test --manifest-path server/Cargo.toml --locked # everything
cargo fmt --manifest-path server/Cargo.toml -- --check
cargo clippy --manifest-path server/Cargo.toml --all-targets --all-features --locked -- -D warnings
cargo run --manifest-path server/Cargo.toml --bin export_ts # regenerate bindings (commit both files)
cargo test --manifest-path server/Cargo.toml --test opencode_live -- --ignored --nocapture # network test
Client, with Bun from client/:
bun run dev # vite; proxies /v1 → SROUTER_API_URL ?? http://127.0.0.1:3000, changeOrigin:false
bun run typecheck # tsr generate && tsc --noEmit
bun run lint # eslint .
bun run format # prettier --write "**/*.{ts,tsx}"
bun run build # tsr generate && tsc -b && vite build
Two runtime gotchas:
dotenvyreads.envrelative to the current working directory, so run the server with cwdserver/(or export vars) forserver/.envto apply.resolve_web_distdoes not searchclient/dist; serving the built SPA needsWEB_DIST_PATH=client/dist(dev builds proxy instead, so this only matters for production-ish runs).
Pricing snapshot maintenance (Python 3 stdlib, not a required dev step): python3 server/scripts/update_models_dev_pricing.py --update|--check.
Code Conventions & Common Patterns
Rust (server/) — 4-space indent, rustfmt defaults; no rustfmt.toml, so cargo fmt output is the law.
- Errors: one custom
APIError { status, message, error_type, code, param }with builders andimpl IntoResponse; propagate with?inResult<_, APIError>. Noanyhow/thiserror(zero hits).Box<dyn Error>only inmain. - Wire fields are
snake_case, both directions. All 248 fields inclient/src/generated/typed.tsare snake_case and the client depends on it. Do not add#[serde(rename_all = "camelCase")], and use#[serde(rename)]only for a name that is not a valid Rust identifier (error.type). Enum values mirror their protocol instead (UPPERCASEmethod,lowercaseobject kind,kebab-caseSSE tags) — follow the enum next to yours. Regenerate withexport_tsandgit diffthe generated file so a camelCase field cannot slip through: a Rust type change is a wire contract change. - Request bodies are wire shapes too. A
POST/PATCHbody type derivesspecta::Typeon the server and reaches the client throughexport_ts(CreateAPIKeyInput,AdminSetupInput,AdminLoginInput); the client never declares its own request shape. A body that carries a secret derives noDebug/PartialEq, so it cannot be printed by accident. - All client-visible strings/codes/headers come from
server/src/constants.rs— never inline literals in handlers; interpolated messages become functions.error.codeis theErrorCodeenum there, soAPIError::with_codetakes a variant and the generated client sees a closed union instead ofstring. - Async: tokio multi-thread. Traits stay dyn-compatible by returning
BoxFuture<'_, …>instead ofasync fn(see the rationale inproviders/executor.rs). Shared state isArc<..>+RwLock/Mutexwith poison recovery; streams flow throughmpsc/broadcast. - Provider drivers: implement
ProviderExecutorin the vendor module, register one constructor — no central dispatch match. File set per vendor:mod.rs+executor.rs/translate.rs/types.rs/auth.rs/catalog.rs/tests.rs. Runtime-registered drivers areArc<dyn>-based; catalog order lives inSEED_PROVIDERS. - Naming:
snake_casemodules grouped by feature withmod.rsre-exports; handlers are verbs (create_completion,list_quota); router factories arecreate_*_router; stores areSQLx*Storewith traits instore.rs/repository.rsandEmpty*fallbacks. - Docs: module-level
//!comments explain why and cite the Nodeapps/api/src/...:lineoracle. Match that style; adocs/directory does not exist — comments citingdocs/api-v1-contract.mdetc. are stale pointers from the Node port, and the contract really lives in the Rust types plus the route tests. - JSON shapes: pair
#[serde(skip_serializing_if)]with#[specta(optional)];bindings.rsrenders a customWireShapesformat that strips the skip attributes so no_Serialize/_Deserializesplits appear.
TypeScript (client/) — prettier: semi: false, double quotes, printWidth: 80, trailingComma: es5, tailwind class sorting with tailwindFunctions: [cn, cva].
strict+noUnusedLocals/noUnusedParameters+verbatimModuleSyntax+erasableSyntaxOnly→ useimport type/ inlinetypeimports.- Path alias
@/*→client/src/*(Vite + tsconfig). Files kebab-case; components PascalCase, named exports. - Type discipline. A client shape is a view of a generated wire type —
Pick/Omit/Partial/Exclude/Record, never a re-declared field list. Lookup tables are keyed by their union (Record<FontFamily, string>), not bystring. Noenum:erasableSyntaxOnlybans it, so use a literal union plus a const object joined withsatisfies Record<Union, V>(the generatedErrorCodeis exactly that). Aswitchover a union ends in aneverassignment. Full table:skills/srouter-frontend/references/conventions.md. - No
anyand nounknowninclient/src.anydisables the checker;unknowndefers every decision to a laterinstanceof. Take the narrowest true type instead —Error | nullfor a TanStack Query error,objectfor a JSON request body, the generated wire type for a response.neveris exempt: it is the exhaustiveness mechanism, not an escape hatch. The fourunknowns that existed were removed (validateSearchinfers its parameter;describetakesError | null;bodyisobject; the parsed response isobject | null). - All network access goes through
request<T>()inclient/src/api/client.ts(credentials: 'include', verbs limited to GET/POST/PATCH/DELETE, no PUT). The verb union isRequestMethod, derived from the generatedHTTPMethodviaExtract— the log type also carries PUT/HEAD/OPTIONS/CONNECT/TRACE/OTHER, none of which the operator API accepts. Never add a cross-origin API base URL or strip theOriginheader: the game is same-origin + HttpOnlySameSite=Laxcookie + server-sideOriginCSRF guard. - Server state via TanStack Query
queryOptionsfactories declared next to their endpoint (client/src/api/<domain>.ts); the shared['admin','status']cache entry is load-bearing for the auth guard and login page. Local state viauseState; no Redux/Zustand/immer, no form library, no toast library. cnis re-exported fromclient/src/lib/utils.ts(thecnpackage, not clsx+tailwind-merge); UI primitives live inclient/src/components/ui/(shadcn generated over@base-ui/react); the unreferencedclient/src/components/gauge/kit is the sanctioned dial/chart vocabulary — reuse it rather than adding a chart dep.
Never hand-edit generated files: client/src/generated/**, server/bindings.ts (regenerate via export_ts; server/tests/bindings.rs fails on drift) and client/src/routeTree.gen.ts (tsr generate / the Vite plugin; committed on purpose).
Important Files
server/src/main.rs— composition root;server/src/lib.rs— module list + flat re-exports.server/src/app.rs—create_router(); the single place route guards and layer order are decided.server/src/config.rs—APIConfig::from_env_map, the only env parser; itsDebugredacts secrets.server/src/bindings.rs+server/src/bin/export_ts.rs— specta registry and writer.server/src/infrastructure/database/migrations.rs— hand-written runner,SCHEMA_VERSION = 4,PRAGMA user_versioncarrier, refuses newer versions.server/tests/support/mod.rs—TestDatabase, fake upstreams, fixture stores, request builders; every new suite should start here.client/src/api/client.ts,client/src/api/admin.ts,client/src/routes/__root.tsx(queryClient singleton),client/src/routes/_authenticated.tsx(auth gate).server/.env.example— the authoritative env surface;CONTRIBUTING.md,SECURITY.md— process and security rules.
Runtime/Tooling Preferences
- Server: Rust
stable(edition 2024 ⇒ ≥1.85), pinned only to the channel byserver/rust-toolchain.toml; commitserver/Cargo.lockand use--lockedfor checks. No cargo features table; all dep features are inline.[profile.dev]/[profile.test] debug = 1with dependencies atdebug = falseis a deliberate disk-footprint decision — do not "fix" it to 2. - Client: Bun is the package manager (
client/bun.lockis the only lockfile); noengines/packageManagerpins, noVITE_*vars — the only client env var isSROUTER_API_URL(Vite config). Never add npm/pnpm/yarn lockfiles. - Database: SQLite is the only supported backend.
DATABASE_URLis refused at boot (owner ruling 2026-10-08) and must stay unset. All SQL is runtimesqlx::query(...).bind(...)— noquery!macros, no.sqlxoffline cache, so no database is needed to build or test. - Server env vars (names only; parsed once at boot, grouped by concern): listener/storage
PORT,DATABASE_PATH(default$HOME/.srouter/srouter.db),WEB_DIST_PATH; URLs/authSROUTER_PUBLIC_URL,SROUTER_CORS_ORIGINS,SROUTER_SECURE_COOKIES,SROUTER_ADMIN_PASSWORD,SROUTER_TRUST_CF_CONNECTING_IP; runtime/loggingNODE_ENV(developmentis refused at boot: production is the only mode the binary boots),SROUTER_ACCESS_LOG,SROUTER_FILE_LOG,RUST_LOG,SROUTER_UPSTREAM_CONCURRENCY; search fallbacksBRAVE_API_KEY,TAVILY_API_KEY,SERPER_API_KEY,SEARXNG_URL; read outsideAPIConfig:CLAUDE_OAUTH_CLIENT_ID,CODEBUDDY_PRODUCT_JSON,HOME. - Runtimes needed anyway: Python 3 only for the pricing script; Node is not needed at all; k6 only for the optional benchmark scenarios.
- History lives on branches
backup/pre-packages-removalandbackup/pre-apps-api-removal(the removedapps/,packages/*, CLI, docs site);CONTRIBUTING.mdstill claims "no Node toolchain" — stale,client/is live.
Testing & QA
- Framework: plain
cargo testonly. Single dev-dependency:tower(util, foroneshot). No nextest, serial_test, testcontainers, wiremock, insta, or coverage tooling anywhere. - Shape: 42 integration suites in
server/tests/*.rs(~485 test fns) + 70 in-crate#[cfg(test)]modules for provider translation/middleware/db units. Requests are driven either throughRouter::oneshotwith an injectedConnectInfo(SocketAddr)peer (most suites) or over a real127.0.0.1:0socket withreqwest(logs.rs,pricing.rs). - Isolation: every suite builds its own temp SQLite file via
TestDatabaseand fakes upstreams (Fake{Upstream,Qoder,CodeBuddy,Cline,Grok,Antigravity,Claude,Codex}intests/support/, all bound to random loopback ports and aborted onDrop). Tests must never open~/.srouter/srouter.dbor reach a real provider. The only global lock isEVENT_TEST_LOCKinlogs.rsaround process-global log-event broadcasts; prefer dependency injection over new globals. - Expectations: no coverage thresholds; CI (
.github/workflows/ci.yml) re-runsfmt,clippy, and the suite onmainpushes and pull requests into it, but run the full suite yourself before pushing (cargo test --manifest-path server/Cargo.toml --locked), thenfmt --checkandclippy -D warnings. Client-side verification isbun run typecheck && bun run lint && bun run build; there is no client test runner. - Frozen contracts a change must keep green:
server/tests/bindings.rs(generated TS),server/tests/schema.rs(user_version = 4, newer refused),server/tests/http_runtime.rs(six frozen response headers incl.X-Version),server/tests/database.rs(DATABASE_URLrefused before any SQLite file is touched). Route tests pin status codes and error bodies, so the contract is the Rust types + tests — there is no separate contract document to update. - Stated policy (
CONTRIBUTING.md): Conventional Commits (feat:,fix:,docs:,refactor:,test:,perf:,chore:), imperative subject, and say what you ran in the PR description. - Security constraints to respect (
SECURITY.md): provider credentials are cleartext JSON inside the SQLite file and in exports — treat the DB like a.env; gateway keys store only sha256 + prefix (unrecoverable); request logs keep metadata only, never prompt/completion text; report vulnerabilities privately tosecurity@srouter.web.id, never via a public issue.
