Haru is a GPU Hardware Abstraction Layer (HAL) for LLM inference fleets, inspired by the role the HAL plays in an operating system: it gives higher-level systems a small, stable, provider-neutral surface over messy, heterogeneous GPU infrastructure. The name also reads as haru (spring: 春) in Japanese.
Products that host LLM inference need GPU lifecycle management: provisioning machines, supervising runtimes, failing over between regions, deciding where traffic should go. Embedding that logic into a product control plane couples it to one deployment and one provider. Haru extracts it into an independent layer with its own state store, so a product control plane can stay focused on users, catalogs and metadata, and consume Haru through a small HTTP API.
Haru's initial mission is hot failover for self-hosted LLM inference at the cost of zero idle GPUs:
-
The active domain serves OpenAI-compatible inference traffic.
-
The standby domain keeps the same model runtimes resident, but puts vLLM into level 1 sleep mode: the server process stays alive, model weights are offloaded to CPU RAM and the KV cache is discarded. This frees the standby GPUs' VRAM, which is used to run preemptible LoRA training while the domain waits.
-
When the active domain fails, Haru promotes the standby:
- stop LoRA training (SIGTERM, then SIGKILL after a grace period; checkpointing is best-effort inside the grace window and failover never waits for a perfect checkpoint - training is required to be checkpoint/resume oriented),
- verify the GPUs actually released the training VRAM,
- wake vLLM (level 1 sleep makes this the fastest possible path: weights come back from CPU RAM instead of disk),
- run a synthetic inference probe against every model,
- flip the routing pointer (a single database compare-and-swap),
- best-effort: put the old active to sleep and hand it the training workload.
A promotion that fails before step 5 never moves routing: the old active keeps serving.
The intended layout is one GPU hosting a bundle of smaller models (one vLLM server per model) and a second GPU hosting one large model, mirrored across two failure domains (different regions or different clouds). Haru itself hard-codes none of this: fleets, domains, slots, models and placement are all data.
- SkyPilot is the lower-level multi-cloud GPU provisioning layer. Haru asks SkyPilot to create, stop and inspect GPU domains; AWS/GCP/region/spot/GPU constraints are expressed as SkyPilot task configuration, never as direct cloud API calls.
- SkyServe is the serving-oriented orchestration layer for active inference services: replicas, placement, recovery, load balancing.
- Haru is the higher-level GPU HAL that neither replaces: it owns Fleet/Domain/Slot state, Active/Standby promotion, the standby sleep-and-train lifecycle, route intent, and runtime supervision.
| Concept | Meaning |
|---|---|
| Fleet | One Active/Standby unit: a set of domains plus the single authoritative activeDomainId routing pointer and a policy (timeouts, auto-failover). |
| Domain | One failure domain: a provisioned GPU machine/cluster (a SkyPilot cluster, a SkyServe service, or a statically provisioned host) with a supervisor and a serving base URL. |
| Slot | One workload on one GPU: an inference slot (the models a GPU serves, each with its own vLLM server) or a training slot (the preemptible LoRA job that runs while the domain is standby). |
| Driver | The provisioning boundary (@haru/driver-skypilot, @haru/driver-skyserve): translate domain/service specs into SkyPilot/SkyServe YAML and wrap the sky CLI behind an injectable, testable exec function. |
| Supervisor | The per-domain agent (services/haru-supervisor): vLLM sleep/wake orchestration, training start/stop with grace/SIGKILL escalation, GPU memory checks, synthetic probes, readiness. |
| RouteIntent | The provider-neutral routing answer (active/standby targets, eligibility, weights, revision) that external routing layers consume. Haru contains no router-vendor logic. |
packages/protocol Zod schemas / typed API contracts (source of type truth)
packages/core Pure state machines, promotion planning, route intent
packages/db Neon/Postgres state store (Drizzle schema, migrations,
compare-and-swap repositories, PGlite test harness)
packages/driver-skypilot SkyPilot driver boundary
packages/driver-skyserve SkyServe driver boundary
services/haru-server Control API + reconciler + OpenAI-compatible chat proxy
services/haru-supervisor GPU-domain-side supervisor
The server owns durable truth; supervisors own execution. Every state
transition is a single-statement compare-and-swap (UPDATE ... WHERE state IN (...) RETURNING), which works identically on the Neon HTTP
driver (no interactive transactions) and on PGlite in tests. External
operations (SkyPilot provisioning, vLLM wake, probes) never run inside
a DB transaction. Promotions and demotions are operations rows with
a partial unique index enforcing one in-flight operation per fleet;
the reconciler advances the current step with re-entrant
check-and-nudge executors, so concurrent ticks race safely and crashed
steps resume idempotently.
@haru/db targets Neon as the documented and
tested production database, over drizzle-orm/neon-http. The SQL is
deliberately portable PostgreSQL: the test suite runs the committed
migrations against in-memory PGlite, and nothing uses Neon-specific
features beyond the HTTP driver's constraint that every write is a
single statement.
pnpm db:generate # drizzle-kit generate (committed under packages/db/drizzle)
pnpm db:push # push schema to $DATABASE_URL
pnpm db:seed # seed a fleet from a declarative layout JSON| Route | Purpose |
|---|---|
GET /healthz |
Liveness. |
GET /v1/fleets/:fleetId |
Full fleet snapshot (slug or UUID). |
POST /v1/fleets/:fleetId/reconcile |
Run one reconcile tick (heartbeats, auto-failover, one operation step). |
POST /v1/fleets/:fleetId/promote |
Promote a domain to active (idempotent; 200 no-op when already active, 202 accepted/joined, 409 conflicting in-flight operation). |
POST /v1/fleets/:fleetId/demote |
Put a standby to sleep + start training (the active domain can never be demoted directly). |
GET /v1/fleets/:fleetId/route-intent |
Provider-neutral routing answer. |
POST /v1/chat/completions |
OpenAI-compatible streaming proxy to the active domain (fleet chosen by X-Haru-Fleet header or HARU_DEFAULT_FLEET). |
Authentication: set HARU_API_TOKEN and send
Authorization: Bearer <token>. Unset means unauthenticated: the
server logs a loud warning and binds to 127.0.0.1 only (local
development mode; the same rule applies to the supervisor without
HARU_SUPERVISOR_TOKEN). The server-to-supervisor plane uses a
separate HARU_SUPERVISOR_TOKEN.
| Variable | Purpose |
|---|---|
DATABASE_URL |
Neon/Postgres connection string (required). |
PORT |
Listen port (default 8700). |
HARU_API_TOKEN |
Bearer token for the public API; unset = open AND the server binds to 127.0.0.1 only (dev only). |
HARU_SUPERVISOR_TOKEN |
Bearer token presented to domain supervisors. |
HARU_DEFAULT_FLEET |
Fleet used by /v1/chat/completions without an X-Haru-Fleet header. |
HARU_CHAT_HEADER_TIMEOUT_MS |
TTFB bound for the chat proxy (default 30000). Raise it for long non-streaming completions: their response headers only arrive after full generation. Any value is honored exactly: chat traffic runs on a dedicated dispatcher with undici's own headers/body idle timers disabled, so the bound is not capped at 300s and a streaming body that goes quiet mid-generation is never severed by the transport. |
HARU_SNAPSHOT_CACHE_TTL_MS |
Fleet snapshot cache TTL on the chat hot path (default 2000). Routing-pointer moves surface immediately regardless (every request revalidates against the fleet's route revision); this only bounds slot-state staleness. |
HARU_CHAT_MAX_BODY_BYTES |
Max chat request body size in bytes (default 33554432 = 32 MiB); a larger body gets 413 payload_too_large. The proxy must buffer the whole body to read model and forward it byte-identically, so this caps per-request memory. Raise it for very large multimodal or long-context payloads. |
HARU_RECONCILE_INTERVAL_MS |
Enables the background reconcile loop at this interval. Unset means no loop: heartbeats, autoFailover, and operation progress then only run when something POSTs /v1/fleets/:id/reconcile (e.g. external cron). |
HARU_RECONCILE_FLEETS |
Comma-separated fleet slugs the loop reconciles (falls back to HARU_DEFAULT_FLEET). |
The supervisor reads PORT (default 8701), HARU_SUPERVISOR_TOKEN,
and HARU_SUPERVISOR_CONFIG (inline JSON or a file path). The seed
script reads DATABASE_URL and optionally HARU_FLEET_LAYOUT.
POST /v1/chat/completionswith a normal OpenAI-style JSON body.modelselects the serving vLLM instance on the active domain; every other field (including vendor extensions) is forwarded byte-identically.X-Haru-Fleet: <slug-or-uuid>picks the fleet; falls back toHARU_DEFAULT_FLEET.- Responses stream through untouched (SSE or JSON). Errors use
{ "error": { "code", "message" } }with codes such asfleet_not_found,model_not_found,no_active_domain,upstream_timeout,upstream_unreachable.
Every vLLM server managed by a Haru supervisor must be started with:
--enable-sleep-modeandVLLM_SERVER_DEV_MODE=1(the sleep/wake admin endpoints are development-mode endpoints),- bound to
127.0.0.1only.
The sleep/wake/is_sleeping endpoints are private, local-only
controls. They are never exposed beyond the host; the supervisor's
authenticated API is the only external control surface, and the
haru-server chat proxy is structurally unable to reach them (it only
ever constructs /v1/chat/completions paths). Verify the endpoint
paths against your deployed vLLM version; this repo pins its
behaviour in services/haru-supervisor/src/vllm-client.ts.
A training slot's command is spawned verbatim, with no arguments
appended and no per-run input channel: POST /v1/training/start
carries no body, so the trainer is expected to find its own work
(poll a queue, read a config file, whatever it likes). The supervisor
only tells it where and on which GPU to run, through exactly two
environment variables added to the child's env:
| Variable | Meaning |
|---|---|
HARU_CHECKPOINT_DIR |
The slot's checkpointDir. The trainer must checkpoint here and resume from it on start: a promotion stops the run with SIGTERM plus a grace period, and anything not checkpointed is lost. |
HARU_GPU_INDEX |
The slot's gpuIndex. The trainer must pin itself to it (e.g. CUDA_VISIBLE_DEVICES). |
HARU_GPU_INDEX is not a convenience: a trainer that guessed would
take GPU 0, and on a standby domain GPU 0 is typically an inference
GPU holding a sleeping vLLM. Training there would fight the wake path
for VRAM and wedge the next promotion.
The run must also tolerate being killed at any moment (SIGTERM, then SIGKILL after the grace period) and being restarted later on a different host, resuming from the checkpoint directory. Failover speed always wins over a clean training tail.
Domains with provider: "static" skip the drivers entirely, so the
whole control loop runs against any OpenAI-compatible endpoints:
pnpm install && pnpm build
# 1. Point DATABASE_URL at a Neon database and apply the schema.
pnpm db:push
# 2. Seed the bundled generic two-domain example layout
# (packages/db/examples/fleet.example.json), or pass your own:
pnpm db:seed # or: pnpm db:seed -- --config my-fleet.json
# 3. Start the server (turbo builds workspace deps first).
HARU_DEFAULT_FLEET=default pnpm dev --filter=@haru/server
# 4. Talk to it.
curl -s localhost:8700/v1/fleets/default/route-intent
curl -s localhost:8700/v1/chat/completions \
-H 'content-type: application/json' -H 'x-haru-fleet: default' \
-d '{"model":"example-chat-small","messages":[{"role":"user","content":"hi"}]}'
curl -s -X POST localhost:8700/v1/fleets/default/promote \
-H 'content-type: application/json' \
-d '{"targetDomainId":"<standby domain id from the fleet snapshot>"}'
curl -s -X POST localhost:8700/v1/fleets/default/reconcile # repeat until settledpnpm install
pnpm build # turbo run build (topological)
pnpm typecheck # tsc --noEmit everywhere
pnpm lint # oxlint --type-aware --deny-warnings, then strict type-aware ESLint
pnpm format # oxfmt --write
pnpm format:check # CI gate
pnpm test # vitest everywhere (PGlite-backed DB and server tests)TypeScript 7 (tsc) builds and typechecks the code; a TypeScript 6.x
copy is installed at the workspace root only for typescript-eslint's
type-aware linting (its supported peer range is still <6.1.0). Drop
the extra copy once typescript-eslint supports TS 7 (see the comment
in pnpm-workspace.yaml). oxlint's type-aware rules run through the
tsgo-backed oxlint-tsgolint binary, independently of both copies.
See CONTRIBUTING.md (日本語) for development conventions and PR guidelines.
Contributor-facing deferred work is tracked with file references and intended fixes in KNOWN_ISSUES.md.
- Auto-failover needs a reconcile driver. Set
HARU_RECONCILE_INTERVAL_MS(plusHARU_RECONCILE_FLEETS) or drivePOST /v1/fleets/:id/reconcilefrom external cron; without either,autoFailoverpolicy is inert. - A reachable-but-dead active fails over only after
degradedGraceMs. When the active domain's supervisor answers but its models are not serving, the domain degrades immediately (visible in route intent) and escalates tofailed(triggering auto-failover when enabled) only after staying degraded past the policy grace (default 60 s); tunedegradedGraceMsto taste. - Model binding names are lowercase routing keys and the vLLM
server behind each binding must serve the same lowercase name (e.g.
--served-model-name); the chat proxy matches exactly and forwards the client body verbatim. - GPU memory verification requires
nvidia-sminumeric output; MIG-partitioned GPUs reporting[N/A]for memory fields are not supported by theverify_gpustep yet. - Re-applying a layout never updates existing rows (fleet policy, display name, existing slot specs): seeding is insert-only by design. Slot states for NEWLY added slots follow the live routing pointer.
- Chat routing may lag non-routing state changes by up to the
snapshot cache TTL (
HARU_SNAPSHOT_CACHE_TTL_MS, default 2 s). Routing-pointer moves are exempt: every request revalidates against the fleet's route revision, so a promotion switches chat traffic immediately.
- Direct AWS/GCP providers. SkyPilot and SkyServe are the only drivers; clouds are placement constraints, not integrations.
- Router/DNS/proxy reconciliation. Haru emits provider-neutral route intent; acting on it (DNS, edge proxies, CDN configuration) belongs to the consumer.
- Driver-backed provisioning in the reconciler. The drivers are
complete, tested boundaries, but the reconciler currently manages
statically provisioned domains; wiring
provider: skypilot | skyservedomains through launch/teardown steps is the next slice. - More than two domains per fleet, weighted/canary routing, and multi-fleet scheduling.