Local OpenAI/Anthropic-compatible LLM gateway that manages runtime processes and switches models behind one endpoint.
docs/ is the source of truth for behaviour — start at docs/README.md, which
indexes the specification and maps the §N numbers the code comments cite.
README.md is the user-facing manual. This file records only what is not
obvious from the code and is not already in docs/.
develop is the default branch for all work; main carries releases only.
- Branch from
developand open every pull request against it. A feature branch offmainis a mistake even when the two point at the same commit. - Nothing lands on
mainexcept a release merge fromdevelop(plus the version bump that goes with it — see Packaging). git clonechecks out whatever the remote's HEAD names, so a fresh clone may land onmain;git switch developbefore the first commit.
docs/02-architecture.md has the full workflow. Two things that bite and are
not obvious there:
- Use
pnpm -r run <script>explicitly —pnpm -r cleanparses as a pnpm built-in, not as the workspace script. main()drops one leading--because pnpm forwards the separator into argv, where it would otherwise make--helpa positional operand.pnpm buildis two stages:pnpm -r run build(tsc per package, which is what tests and typecheck consume) and thennode scripts/bundle.mjs. The order is not optional — see Packaging.
Windows is a supported target, so:
- No shell utilities or globbing in scripts.
cleanisrimraf dist, notrm -rf;tsBuildInfoFileis${configDir}/dist/.tsbuildinfoso one directory removal covers everything and no glob is needed.pnpm devisnode scripts/dev.mjs, because the build output has to reach stderr and>&2is not portable. - Both path separators count.
expandHomeaccepts~/and~\\;doctordetects a path withisAbsoluteplus either separator, sinceC:\\tools\\mtplx.execontains no forward slash. - Fixtures are launched through Node. A shebang
.mjsis not executable on Windows, so tests passfixtureCli(script)— which setsbinarytoprocess.execPathandbinaryArgsto the script. Adapters takebinaryArgsfor exactly this (also useful foruvx mtplx). - Signals barely exist on Windows. Any
killterminates outright, so the graceful-then-force path in the executor is Unix-only;servealso listens forSIGBREAKthere. - Temp paths come from
os.tmpdir(), never a/tmpliteral.
There is no hand-maintained build graph: no TypeScript project references, no
root paths, no vitest aliases. It all derives from workspace:*, so adding a
package means: create it, add workspace:* where it is used, pnpm install.
Worth knowing: vitest transpiles with esbuild and does not typecheck, which
is why pnpm typecheck runs tsc over tests/ too.
npm i -g llm-runtime-dock must land one file with no runtime dependencies, so
scripts/bundle.mjs runs esbuild over src/index.ts into dist/index.js.
tsc cannot do this — it emits file-per-file and never inlines node_modules,
which is the whole reason a bundler is here at all.
Five things are load-bearing here — four in that script, one in the sources it reads:
-
pnpm -r run buildhas to run first. esbuild resolves@llm-runtime-dock/corethrough itsexportsfield, i.e. to that package'sdist/. The bundle therefore contains the last tsc output, not the current sources. Do not paper over this withconditions: ['source']. -
mainFields: ['module', 'main']. esbuild defaults to['main', 'module']onplatform: 'node'.jsonc-parserships noexportsmap and pointsmainat a UMD build whose innerrequire('./impl/format')calls survive bundling and then resolve againstdist/, killing the bundle on load.exportsbeatsmainFieldsoutright, so this only reaches packages that lack one. -
The
createRequirebanner is required, not defensive.@inquirer/promptspulls in a mixed CJS graph (mute-stream,signal-exit,wrap-ansi,yoctocolors-cjs) whoserequirecalls reach the ESM output. -
__LRD_VERSION__may appear only insrc/. esbuild'sdefineis a textual substitution across the whole bundle, so a reference from anypackages/*source would work here and then break that package's owntscrun, which never seessrc/globals.d.ts. -
zod is imported as
import * as z from 'zod', and theenlocale is registered by hand. Both halves, and both failures are invisible in review.import { z } from 'zod'— the obvious spelling, and what zod's own docs show — binds an object zod builds by re-exporting everything, which esbuild cannot see through: the bundle then carries all 53 locales, +273 kB for languages nothing selects. The namespace form tree-shakes them out, but it takesenwith them, and every validation message silently degrades to a bareInvalid inputwith no expected/received detail. Sopackages/core/src/config/schema.tsalso doesz.config(en()), importingzod/v4/locales/en.jsdirectly — 4 kB. Do not reach it throughz.locales.enorzod/v4/locales: those are index re-exports and pull all 53 back. Every adapter inherits the setting, because zod's config is global to the module instance and each of them reaches that module through@llm-runtime-dock/core. No test catches this. vitest runs unbundled sources, whereenloads regardless; onlydist/index.jstree-shakes. The guard is the bundle smoke test — runlrdagainst a config with a type error and check the message still names the expected and received types.
The bundle is deliberately unminified, for readable stack traces from a global
install. files lists dist/index.js rather than dist, so the sourcemap
stays build-local: esbuild embeds sourcesContent, and shipping the whole
TypeScript source to npm is not what a CLI install is for.
bin/lrd.mjs stays a separate hand-written shim. Its Node version check cannot
move into the bundle, because an ESM module is parsed in full before its first
statement runs — an old Node would report a syntax error instead of the message.
npm pack leaves devDependencies in the published manifest, so the ten
workspace:* entries are visible there (pnpm pack rewrites them to 0.1.0).
Harmless — npm never installs a dependency's devDependencies — but do not read
them as a claim that those packages exist on npm. They do not.
The root package.json is the only place a version is written. The ten
private packages carry a version too — that is what pnpm pack substitutes
for workspace:* — but scripts/sync-version.mjs generates them from the
root's, as the first stage of pnpm build. pnpm typecheck runs it with
--check, so drift fails rather than being silently repaired. Bump the root and
build; never edit the other ten. scripts/bundle.mjs throws on an empty root
version, because define is textual and would otherwise inline undefined.
pnpm-workspace.yaml sets minimumReleaseAge: 20160 (14 days): nothing enters
the lockfile until it has survived two weeks in the wild, since a compromised or
broken release is usually caught within days and the fix lands after that. The
policy picks the version, not the wishlist — staying a major behind is the
correct outcome, not a failure. If a specific version ever genuinely has to
bypass it, reintroduce minimumReleaseAgeExclude deliberately with a comment
saying why; do not let one accumulate as a by-product of an install.
That last sentence is not hypothetical. Setting minimumReleaseAge implicitly
turns minimumReleaseAgeStrict on, and the workspace file now spells it out,
because flipping it to false is what an install failure tempts you to do and
it is the wrong fix: pnpm then stops failing and instead rewrites
pnpm-workspace.yaml itself, auto-collecting every too-new version into a
minimumReleaseAgeExclude list. That is where the sixteen orphaned @inquirer/*
pins in the first commit came from — the generated list was committed while the
minimumReleaseAge that produced it never was, leaving a list that excluded
from nothing. Reproducing it takes one install with Strict: false and yields
those exact sixteen lines. An install that hits the cooldown has to fail and be
resolved by a person; pnpm has no maxAge or upper-bound counterpart, so
minimumReleaseAge plus this flag is the whole policy surface.
Four consequences, each of which has already cost time:
- pnpm verifies the existing lockfile against the policy, not just new
resolutions. Changing the number therefore forces a re-resolution, and
deleting
pnpm-lock.yamlis not enough — pnpm restores it fromnode_modules/.pnpm/lock.yaml, sonode_moduleshas to go too. @inquirer/promptsstays on^8.6.0. Its release cadence keeps the newest patch inside the cooldown, so bumping the caret to a8.7.xmakes the range unsatisfiable andpnpm installfails outright withERR_PNPM_NO_MATURE_MATCHING_VERSION. Widen the floor, never raise it to chase a release.pnpm-lock.yamlis in.prettierignore. pnpm owns that file's serialization; prettier rewrites 2197 lines of it into block style and the next install puts them back, soformat:checkwould go red after every install.- TypeScript is capped below 6.1 by
typescript-eslint, not by anything in this repository. Its peer range istypescript: >=4.8.4 <6.1.0, and there is notypescript-eslint@9— so TypeScript 7 installs fine and then breakspnpm lintwith no upgrade path.6.0.xis the ceiling and is what we run. Revisit only once typescript-eslint ships TS 7 support; do not bump TypeScript alone and do not silence the peer warning.
scripts/bundle.mjs targets node24, tied to the engines.node floor — the
two move together or the bundle claims support it does not have.
src/index.ts composition root — the repository root is the
published `llm-runtime-dock` package
bin/lrd.mjs the `lrd` binary's ESM shim
packages/core domain, scheduler, resident slot, config, proxy
packages/cli lrd commands
packages/runtimes/* mtplx, lm-studio, omlx, ollama, custom
packages/agents/* opencode, claude, codex
apps/gateway HTTP surface
The root is both the workspace and the one published package. Everything under
packages/ and apps/ is private: true and never reaches npm — it is folded
into a single bundled file at publish time (see Packaging).
Package names follow the spec (@llm-runtime-dock/adapter-mtplx) even though the
directories are grouped (packages/runtimes/mtplx).
.claude/skills/ holds the two extension procedures for contributors —
add-runtime-adapter and add-agent-integration — and opencode loads them from
there too, which is why there is one copy rather than two. Neither has a
dispatcher under .claude/commands/: Claude Code already registers a skill as
/<its name>, so a same-named command file only registers the name a second
time. .claude/commands/ holds verify-extension.md alone, because
.opencode/commands/verify-extension.md points at it as the single source of
that sequence. The files under .opencode/commands/ are dispatchers with no
procedure text in them. AGENTS.md is a pointer file for tools that do not read
this one; it must never become a second copy of it.
Two rules, enforced by ESLint (func-style, no-restricted-syntax), so a
violation fails pnpm lint rather than review:
- Every function is an arrow function. No
functiondeclarations, no function expressions, no object-literal method shorthand — a returned object uses arrow properties (foo: () => {}). - No classes. Stateful objects are closure factories: an exported
interface Xplus acreateX()that closes over its state and returns an object literal. Keeping the interface namedXmeans every type position (: DockService,: CliContext) reads unchanged; onlynew X(call sites becamecreateX(.
Two consequences worth knowing before editing:
GatewayError/CliErrorare interfaces, not classes. Build them withgatewayError(...)/cliError(...)and test them withisGatewayError/isCliError, which read anamespacefield rather than usinginstanceof.new Error(...)inside those factories is fine — the rule is about classes this codebase declares.- A closure factory has no
this. A helper that another member calls must be a namedconstin the factory body, referenced directly. Watch for a local variable shadowing such a helper:const port = port(runtime)is a self-reference, which is whyMtplxAdapternames its helperportOfandCustomAdapternames itsentryOf.
Three rules, and the first is the one that breaks silently:
- Pad first, colour second. Every width in the CLI comes from
String.length, which counts escape bytes.packages/cli/src/output.ts(columns,keyValue) encodes the ordering; use it rather than a freshpadEnd.packages/cli/tests/theme.test.tsasserts that stripping the escapes from a coloured run reproduces the plain run byte for byte. - Colour is resolved once, in
createCliContext, and reaches a command only ascontext.theme. No file undercommands/importspicocolors: a module-scope colouriser would ignore the per-context switch and start leaking escape sequences into--json.resolveColorreads the injectedenv, notprocess.env, and returnsfalsewhenever the caller injected its own writers — which is why no test in this repository has to know colour exists. - ASCII only.
doctorprintsok/warn/errordifferentiated by colour, never✔ ⚠ ✖: a legacy Windows console mangles them. - One scheme per block. A report is
label: valuerows throughkeyValue, with the label intheme.labeland the value styled by what it is (path,url,id). Prose lines and bare labels mixed into such a block read as three different formats.serveloads its config withloadConfig({ quiet: true })for exactly this reason: the path is a row of its block, not the loose breadcrumb every other command gets.
Help styling is commander's own (configureHelp({ styleTitle, … }), 13+), which
measures with displayWidth and so aligns around the escapes. getOutHasColors
is overridden because commander's default reads process.stdout.isTTY directly,
which would bypass all of the above; it is consulted at write time, i.e. after
argv is parsed, which is what makes lrd --help --no-color work at all.
Core's logger takes an optional LogPalette of plain functions rather than a
colour dependency, so packages/core stays free of terminal concerns.
An adapter with public members beyond RuntimeAdapter exports a widened
interface for them (MtplxAdapter/OmlxAdapter add serveArgs, LmStudioAdapter
adds loadArgs), so tests keep compiling against the concrete adapter.
- One resident model, globally — unless an entry sets
keep_resident. The flag is the only way out, and it is per entry, never a global mode. Two things are tracked: the loaded set (one rotating occupant plus every kept entry) and the serving token (exactly one, always). A kept entry is never released by a switch, and moving the token to or from one releases nothing either — both halves matter, since keeping a small model loaded while every trip to it still evicted the large one would be worse than not having the flag. The scheduler's FIFO pump is still the only thing that grants leases, so residency is not concurrency: the models share a GPU and take turns. Shutdown is the one place the flag stops applying —scheduler.shutdownreleases the kept map as well as the occupant, because a kept entry's lifetime is the gateway's and nothing outside the process would free it. - Shutdown must never wait unboundedly, or nothing is released.
server.close()resolves only when every connection has ended and an SSE stream never ends on its own, soRunningGateway.closedrops idle sockets at once, gives active onesgraceMs, then callscloseAllConnections();service.shutdown()runs in afinally.servelistens for SIGHUP as well as SIGINT/SIGTERM — closing a terminal sends it, and a child is not killed when its parent dies — and usesprocess.on, so a second press exits explicitly instead of forcing a SIGKILL that orphans every loaded model. - A kept entry on a
stop_serverruntime must own that runtime alone. MTPLX and custom serve one model per server, so starting a sibling entry meansmtplx stop --port …— the very thing the flag forbids.checkKeptResidencyinconfig/load.tsrejects it at load time, checking theruntimes:key and thehost:port, because two runtime keys aimed at one port are one server. keepLoadedis what stops an adapter undoing the flag.enforceSingleResidentunloads everything that is not its target, which is exactly a kept model on the same server.acquireandverifyIdentityboth take aResidencyContext, and core filters the list byruntimeIdand spells it with each entry's ownservedModelId— LM Studio answers to its--identifier, oMLX to a backend model name, and Ollama to a tag-qualified model name. Ollama normalizes an omitted:latesttag, so a foreign id would match nothing or the wrong thing.discovery: falsemust be filtered on the bare path, never indeclaredRuntimes. Two silent bugs follow from getting this wrong, and neither is visible in the diff.selectSubjectsbuildscoveredfrom the declared runtimes and then probes every uncovered adapter at itsdefaultProbeTarget, so an excluded runtime dropped from that list resurrects its adapter at an endpoint nobody declared, written by--saveunder the adapter's id. And the named-target lookup reads the same list, so filtering there makeslrd probe <that-runtime>— the one documented override — miss and fall through to a synthetic subject that loses the entry'shost/port/apiKeyEnv. Subjects carrydiscoverable; only the bare return and the adapter fan-out filter on it, and the fan-out returns empty rather than falling through.- In discovery, existing ownership is settled before anything else. A
catalogue belongs to the installation, not the server —
mtplx modelsdoes not know which port asked — so two runtimes of one adapter always rediscover each other's models, whichkeep_residentmakes the normal shape.saveDiscoverytherefore runs two passes: collect every candidate, then decide, checking the configured owner first. Doing it the other way round — the in-run "claimed by someone else" test first — reports a model as colliding with itself. A retained id must still beclaimed, or the staleness pass reads it as unfound and offers to delete it. disabledis enforced at one gate and by omission everywhere else.resolveModelis the only place a disabled entry is refused, which is what keeps it out of the scheduler —acquire/switchToare reachable only through it. Nothing else guards; the two lists that publish the catalogue (listLogicalModelsand themodelsloop inbuildApplyPlan) simply leave it out, andservedModelIdsis the one spelling of "which ids those are". Anything new that enumeratesconfig.modelsfor a user-facing purpose has to decide whether it filters:doctorandlrd modelsdeliberately do not, so the entry stays visible to the person who wrote it.applyowns keys, not blocks. Writing the gateway's own provider block whole — onemodify(content, ['provider', PROVIDER_ID], …)in opencode, orproviders[PROVIDER_ID] = { … }in codex — deletes everything the user put inside it: a per-modelvariants, an extraoptions.headers, awire_api, a comment between two keys. Both adapters therefore edit one leaf path at a time. It is invisible in review and shows up only as a hand-written key vanishing on the next apply. The single deletion that stays is a model entry whosemodels:id is gone, since the agent would otherwise offer a model the gateway 404s; an unknownlimitis not deleted, only not written.- Core must not import a concrete adapter. Only the composition root
(
src/index.ts) does. Runtime CLI flags belong in adapters, agent formats in agent packages. - No request-derived command execution. Argv arrays only;
shell: trueis opt-in configuration. A request selects which entry runs, never what runs. - Never
lms server stopduring a switch. The LM Studio server is shared;LmStudioAdapter.stop()throws on purpose. The scheduler only callsacquire/release, neverstop. - A
stop_serverruntime is stopped even when attached, not spawned, and that is logged as "releasing foreign …". - One
DEFAULT_PORTper adapter package. It is read both bydefaultProbeTargetand by theport ?? defaultfallback, and a test in each package pins the two together. Splitting them gives a probe that finds a server the gateway then fails to reach.mtplxandomlxboth default to 8000, while Ollama defaults to 11434;probe()asks a server whether it is its own before claiming it, and reportsforeign_serverwhen it is not. probe --startis per adapter, and MTPLX opts out on purpose. LM Studio and oMLX report nothing when their server is down, so starting it is what makes the probe useful; Ollama also requires a running server but has no daemon-style start command for probe; MTPLX reports its whole installed catalogue either way, so starting it would load one model and then "discover" that model.lms server startreads its port back,omlx startcannot — it takes none — so oMLX checks the endpoint it was given instead of trusting it.@inquirer/promptsis imported in exactly one file,packages/cli/src/prompt.ts. An adapter declares its probe questions as data (probeQuestions) and never learns how they are asked — the same rule that keepspackages/corefree of terminal concerns.runtimes:owns the endpoint,models:owns the model.RuntimeInstancestill carriesadapterId/host/portflattened, so the scheduler, proxy and gateway never see the split;rawRuntimeis where the custom adapter'sprocess/health/endpointblocks live now. BothrawandrawRuntimeareRecord<string, unknown>, so reading the wrong one returnsundefinedrather than failing to compile.- Only the
modelfield of a request body is rewritten (logical id → served id). No inference defaults are injected. That is a guarantee about fields, not bytes:rewriteModelFieldparses and re-serializes, so formatting, duplicate keys and number spelling (1.50→1.5) do not survive. Nothing reads or writestools,tool_choice,messagesorstreamanywhere on the request path. - Response headers are a denylist, not an allowlist. Hop-by-hop plus
content-length/content-encodingare dropped and everything else passes; an allowlist silently swallowedretry-after. The upstream'sx-request-idsurvives asx-upstream-request-id, because the gateway stamps its own. lrd serve --debugis the instrument, and it is a tee.RequestTapmay never alter a byte, and it captures both hops — assuming the client-side relay is 1:1 would defeat the point of measuring it./statusand/switchare loopback-only and answer a plain 403 refusal, not a gateway error code.
GatewayError codes become HTTP responses. CliError codes exit non-zero and
must never reach the response mapper — GATEWAY_NOT_RUNNING cannot be an HTTP
answer by definition.
Tests live in the package they cover; tests/ at the root holds only end-to-end
suites — those that span packages or spawn a real runtime process.
- Package tests import their own code from
../src(no build needed) and run viapnpm -r run test, or one at a time withpnpm --filter <pkg> run test. - Root e2e imports workspace packages by name, so it needs
pnpm buildfirst and exercises the realexportsentry points. - One
vitest.config.tsat the root serves every project: vitest resolvesinclude: ['tests/**']against whichever directory it runs in, and packages inherit the file. Do not add per-package vitest configs. - Core's tests must not import an adapter or agent — that is the §6 rule
pnpm enforces. Use
packages/core/tests/helpers/stubs.ts, which provides a configurableRuntimeAdapterandAgentIntegration.
tests/fixtures/fake-runtime.mjs is one configurable fake backend (503-loading
window, model mismatch, crash, stop delay, multi-model load/unload, LRU
auto-load, pinned models, credential required, SSE chunk count/delay/padding),
driven entirely by env vars. FAKE_STREAM_PAD_BYTES exists so a test can fill a
client's socket buffer: without enough bytes in flight res.write never returns
false and the gateway's backpressure path is never reached.
fake-mtplx.mjs, fake-lms.mjs, fake-omlx.mjs and fake-ollama.mjs wrap it as the runtime CLIs.
Adapters take a binary option so tests point them at the fakes. Fixtures are
used only by the root e2e suites.
A ConfigLocation with found: false resolves the write target to the user's
real ~/.config/llm-runtime-dock/config.yaml. Tests must always construct
locations with found: true inside a temp directory.