Agent-facing guide for working in ownllm — a subscription-OAuth, OpenAI-compatible API gateway
with real per-request model routing. For the human-facing intro see README.md; for
design depth see docs/ (start with architecture).
pnpm install
pnpm dev -- <args> # run the CLI under tsx (no build); e.g. pnpm dev -- serve
pnpm build # tsup -> dist/main.js (the bundled CLI)
pnpm typecheck # tsc --noEmit
pnpm lint # biome check . (fails on unformatted code)
pnpm format # biome check --write . (run this BEFORE lint)
pnpm test # vitest runThe verify loop before declaring anything done: pnpm format && pnpm lint && pnpm typecheck && pnpm test (and pnpm build if you touched build/runtime wiring). Match what CI runs.
src/cli/ citty commands (serve, auth, config, models, doctor)
src/config/ zod schema, ${ENV} loader, paths, loopback check
src/server/ Hono app, middleware, readiness; routes/ (chat-completions, responses) over a shared serve-upstream engine
src/router/ resolveModel/resolveChain(name) -> ordered route candidates; fallback breaker
src/providers/ types (extension contract), registry, codex/, xai/, copilot/, qwen/, minimax/, gemini/
src/translate/ CC types + error factory + param policy; shared wire.ts; responses/ (+ inbound from-client/to-client) chat/ anthropic/ gemini/ translators
src/auth/ Credential, AuthStore (0600+lock), single-flight RefreshManager, OAuth primitives
src/http/ host-pinned UpstreamClient, Codex cookie jar, SSE parser
tests/support/ shared test helpers (createTestApp, fakeModule, sseResponse, responses fixtures)
- TypeScript ESM, strict.
noUncheckedIndexedAccessis on —arr[0]isT | undefined, handle it.verbatimModuleSyntaxis on — useimport typefor types.exactOptionalPropertyTypesis off (assigningundefinedto an optional field is fine). - Biome is the formatter+linter: single quotes, no semicolons, 2-space indent, width 100,
trailing commas. It also organizes imports. Style is not negotiable — run
pnpm format. - Code style (from the maintainer's guidelines): no obvious comments — comment only what the
code can't say; prefer early returns over
if/else; prefer offensive programming for invariants; keep changes surgical (clean up only orphans your change created). - Parsing unknown JSON (upstream bodies, token responses): use
asRecord/getString/getNumber/omitfromsrc/util/json.tsrather than casts. - Tests run with
LOG_LEVEL=silent. Reusetests/support/*(real translator + fake transport viafakeModule,createTestApp,eventStream/sseText,INTERLEAVED_EVENTS). Provider OAuth network flows are not unit-tested (manual e2e); test the pure pieces + the route orchestration.
pnpm formatbeforepnpm lint.biome checkfails on unformatted code, and several Biome fixes are "unsafe" (not auto-applied):useOptionalChain,useTemplate,noDelete. For removing object keys, use theomit()helper orx = undefined(it's dropped on JSON serialization) — notdelete.instanceofneeds a value import. A type-only import (e.g.import type { OwnllmError }) makeserror instanceof OwnllmErrorthrow at runtime (the symbol isundefined), which surfaces as a 500. Import such classes as values.- Editing churns files. Format/organize-imports rewrites files after a Write/Edit; re-read before the next edit if the harness flags the file as modified.
- Subscription-only. No API-key provider or fallback (incl. no
XAI_API_KEY). That's the whole thesis. - Single-flight refresh (
auth/refresh.ts). Concurrent refreshes rotate the refresh token in parallel and mutually invalidate it → forced re-login. The in-process promise + cross-process file lock + reactive 10s min-interval guard exist for this. Don't add a code path that refreshes outsideRefreshManager. - Translator ownership rule. The base
ResponsesTranslatorowns all provider-agnostic CC→Responses semantics (multimodal,response_format,reasoning_effort, tool flattening,max_tokensprecedence). A provider'stransport.sanitizeBodyowns only that provider's quirks. Never do the same thing in both (drift). - Always stream upstream; relay to the client or aggregate at the edge.
fromUpstreamconsumes the event stream. - Fallback is pre-first-byte only. The route tries candidates (requested model + its direct
fallbacks, not transitive) until one yields a streamable upstream; once relay starts we're committed. Never fall back on a client disconnect/timeout, and don't penalize a model's breaker health for those.resolveChain(router/resolve.ts) dedupes so a cycle can't loop; the circuit breaker (router/breaker.ts) is in-process and keyed by model name. - Host-pinned egress. Bearer tokens/cookies go only to allowlisted hosts;
redirect:'manual'. Don't bypassUpstreamClient. - Redaction. Never log tokens.
Credentialredacts intoJSON/inspect; raw tokens come out only viatoStored()(persistence). Logger redactsauthorization/cookie/*_token/cf_clearance. - Error contract. Throw a
OwnllmErrorfromtranslate/errors.ts(the §11 factory); the app'sonErrorrenders the OpenAI envelope +x-request-id. Don't hand-roll error responses. - 403 ≠ 401. Branch by provider in
transport.classifyError: Codex Cloudflare 403 → transport block (codex_cloudflare_blocked), xAI 403 →xai_tier_denied. Never refresh-loop on 403/429. - Stateless. No conversation store; the deterministic conv-id (
translate/responses/conv-id.ts) pins the upstream prompt cache. Keep it deterministic over the prompt prefix.
Implement ProviderModule (src/providers/types.ts), register it in src/providers/registry.ts,
and enable it in config. Zero core edits — the router/server only call the registry + interfaces.
Reuse responsesTranslator if the provider speaks the OpenAI Responses API; otherwise add a new
translate/<format>/. Worked examples: src/providers/codex and src/providers/xai. Full guide:
docs/providers.md.
${ENV}interpolation runs post-parse on string values, not on raw text — avoids${...}in comments breaking the load and yields config-path errors.- OAuth is hand-rolled (not
openid-client/oauth4webapi): Codex uses bespoke device endpoints (server-issued PKCE verifier), and xAI requires a non-standardcode_challengeecho + a CORS/Private-Network loopback. Libraries fight these.
PLAN.md is the design doc (gitignored — local only). Its §0 Status tracks what's done vs.
deferred-optional vs. needs-real-subscription e2e. The maintainer runs /simplify after each
substantial change to keep the code lean.