Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 8 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,14 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
### Added

- Opt-in command-backed model profile ensure hooks.
- Roadmap for maturing DevRail Router from a single-backend gateway into an
observable local inference control plane.
- Request IDs are now added to router responses and structured logs.

### Changed

- Router-originated proxy and request validation failures now return
OpenAI-shaped JSON errors consistently.

## [1.0.0] - 2026-03-01

Expand Down
2 changes: 1 addition & 1 deletion Makefile
Original file line number Diff line number Diff line change
Expand Up @@ -139,7 +139,7 @@ package: build ## Build a Linux/macOS tarball package
"$(DIST_DIR)/$(PACKAGE_NAME)/packaging/systemd" \
"$(DIST_DIR)/$(PACKAGE_NAME)/packaging/linux"
cp configs/router.example.yaml "$(DIST_DIR)/$(PACKAGE_NAME)/configs/"
cp docs/architecture.md docs/packaging.md "$(DIST_DIR)/$(PACKAGE_NAME)/docs/"
cp docs/architecture.md docs/packaging.md docs/roadmap.md "$(DIST_DIR)/$(PACKAGE_NAME)/docs/"
cp packaging/systemd/devrail-router.service "$(DIST_DIR)/$(PACKAGE_NAME)/packaging/systemd/"
cp packaging/linux/install.sh "$(DIST_DIR)/$(PACKAGE_NAME)/packaging/linux/"
chmod 0755 "$(DIST_DIR)/$(PACKAGE_NAME)/devrail-router" \
Expand Down
7 changes: 5 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -27,9 +27,12 @@ This repository is in early foundation work. The current service supports:
- Linux tarball packaging
- Linux/systemd install script and unit
- Docker image and Compose smoke testing with a mock OpenAI-compatible backend
- response telemetry for proxied backend calls
- consistent OpenAI-shaped errors for router-side failures
- request IDs in router responses and logs

Routing policy, auth, telemetry, native LM Studio lifecycle integration, and
Omarchy integration are planned next.
Routing policy, auth, native LM Studio lifecycle integration, richer telemetry,
and Omarchy integration are planned next. See [docs/roadmap.md](docs/roadmap.md).

Model aliases can also set basic concurrency guardrails with
`max_concurrent_requests`, `max_queue_size`, and `queue_timeout`. This lets heavy
Expand Down
112 changes: 112 additions & 0 deletions docs/roadmap.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,112 @@
# Roadmap

DevRail Router is the local-first control plane between agent clients and
private inference backends. The near-term goal is not to become a full model
server. It is to make local inference predictable enough that coding agents and
ops assistants can use it without every client learning backend-specific
lifecycle, queueing, telemetry, and safety behavior.

## Principles

- Keep the public client surface OpenAI-compatible.
- Prefer explicit aliases over hidden routing behavior.
- Make backend state visible before adding smarter routing.
- Fail with useful OpenAI-shaped errors instead of hanging clients.
- Keep host-specific lifecycle code replaceable by native adapters later.
- Treat local GPUs as shared infrastructure, not disposable accelerators.

## Phase 1: Reliable Single-Backend Gateway

Status: in progress.

This phase makes one local backend dependable for day-to-day tools such as
opencode, OpenClaw, and Hermes.

- Stable `/healthz` and `/v1/models` endpoints.
- Alias-to-target model rewriting.
- Bounded per-alias queueing and concurrency limits.
- Command-backed profile ensure hooks for LM Studio and similar hosts.
- Response telemetry for route, status, duration, bytes, and usage tokens.
- Linux tarball packaging with systemd installation.
- Docker and Vagrant smoke tests.
- Consistent OpenAI-shaped router errors.
- Request IDs in responses and router logs.

Useful next work:

- Add readiness checks for configured backends.
- Add configurable upstream transport timeouts.
- Capture streaming completion telemetry without buffering streams.
- Publish example configs for common LM Studio, Ollama, and vLLM setups.

## Phase 2: Native Backend Adapters

Status: planned.

Command hooks are a good bridge, but the router needs native adapters for
backends that expose enough local state.

- LM Studio adapter: inspect loaded model, context length, parallel slots, TTL,
and active slot state.
- Ollama adapter: inspect model availability, keepalive policy, and active load.
- vLLM/SGLang adapter: expose server readiness, model limits, batching state,
and GPU pressure where available.
- Adapter-level decisions for passive JIT load, explicit profile load, or
`503` when a backend is busy switching.
- Backend lifecycle telemetry: load duration, unload events, profile mismatch,
and rejected switches.

## Phase 3: Operator Telemetry And Evals

Status: planned.

The router should make quality and performance decisions measurable instead of
vibe-based.

- Structured request logs with request ID, alias, target model, upstream model,
queue wait, ensure duration, first-token latency, total duration, status, and
token usage.
- Optional local JSONL telemetry sink for offline analysis.
- Small repeatable eval harness for real agent workflows:
- commit hook failure handling
- push and remote-branch verification
- MR/PR creation and status checks
- CI failure parsing
- long-context repo editing
- security and SRE review prompts
- Model scorecards that combine quality, latency, throughput, memory use, and
failure modes.

## Phase 4: Policy Routing

Status: planned.

Once telemetry and evals exist, aliases can become policy-backed instead of
hard-coded to one model.

- `local-coder-auto` route that selects a small, large, or cloud-approved model
based on prompt size, requested tools, risk class, and current backend state.
- Deterministic routing policies that are explainable in logs.
- Budget and privacy gates for optional remote fallbacks.
- Second-pass review workflows where a stronger model audits selected outputs.
- Graceful degradation when local GPUs are hot, busy, or offline.

## Phase 5: Production Operations

Status: planned.

This phase turns the router from a lab service into durable local infrastructure.

- Auth for non-loopback deployments.
- Rate limits by client, alias, or token.
- Admin endpoint or CLI for draining, reloading config, and inspecting state.
- Prometheus metrics.
- Signed release artifacts and package repository support.
- Upgrade and rollback runbooks.
- Omarchy and desktop integration profiles.

## Current Bias

Prioritize observability, explicit profiles, and evals before automatic routing.
The project should earn trust by showing exactly what happened for each request
before it starts making hidden model-selection decisions.
59 changes: 44 additions & 15 deletions internal/server/server.go
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,8 @@ package server
import (
"bytes"
"context"
"crypto/rand"
"encoding/hex"
"encoding/json"
"errors"
"fmt"
Expand Down Expand Up @@ -98,39 +100,42 @@ func (s *Server) handleModels(w http.ResponseWriter, _ *http.Request) {
}

func (s *Server) proxyOpenAI(w http.ResponseWriter, r *http.Request) {
requestID := devrailRequestID(r)
w.Header().Set("X-Devrail-Request-ID", requestID)

modelID, body, err := requestModel(r)
if err != nil {
http.Error(w, err.Error(), http.StatusBadRequest)
writeOpenAIError(w, http.StatusBadRequest, err.Error(), "invalid_request_error", "invalid_request")
return
}

model, ok := s.cfg.Model(modelID)
if !ok {
http.Error(w, fmt.Sprintf("unknown model alias %q", modelID), http.StatusBadRequest)
writeOpenAIError(w, http.StatusBadRequest, fmt.Sprintf("unknown model alias %q", modelID), "invalid_request_error", "unknown_model_alias")
return
}

backend, ok := s.cfg.Backend(model.Backend)
if !ok {
http.Error(w, fmt.Sprintf("unknown backend %q", model.Backend), http.StatusInternalServerError)
writeOpenAIError(w, http.StatusInternalServerError, fmt.Sprintf("unknown backend %q", model.Backend), "devrail_config_error", "unknown_backend")
return
}

release, ok := s.acquireModelSlot(w, r, model)
release, ok := s.acquireModelSlot(w, r, model, requestID)
if !ok {
return
}
if release != nil {
defer release()
}

if !s.ensureModelReady(w, r, model) {
if !s.ensureModelReady(w, r, model, requestID) {
return
}

body, err = rewriteModel(body, model.TargetModel)
if err != nil {
http.Error(w, err.Error(), http.StatusBadRequest)
writeOpenAIError(w, http.StatusBadRequest, err.Error(), "invalid_request_error", "invalid_request")
return
}

Expand All @@ -140,7 +145,7 @@ func (s *Server) proxyOpenAI(w http.ResponseWriter, r *http.Request) {

target, err := url.Parse(backend.BaseURL)
if err != nil {
http.Error(w, "backend base_url is invalid", http.StatusInternalServerError)
writeOpenAIError(w, http.StatusInternalServerError, "backend base_url is invalid", "devrail_config_error", "invalid_backend_base_url")
return
}

Expand All @@ -154,28 +159,29 @@ func (s *Server) proxyOpenAI(w http.ResponseWriter, r *http.Request) {
setBackendAuth(req, backend)
}
proxy.ModifyResponse = func(resp *http.Response) error {
instrumentBackendResponse(resp, started, model, backend)
instrumentBackendResponse(resp, started, model, backend, requestID)
return nil
}
proxy.ErrorHandler = func(rw http.ResponseWriter, req *http.Request, proxyErr error) {
slog.Error(
"backend request failed",
"method", req.Method,
"path", req.URL.Path,
"request_id", requestID,
"alias", model.ID,
"target_model", model.TargetModel,
"backend", backend.ID,
"duration_ms", time.Since(started).Milliseconds(),
"error", proxyErr,
)
http.Error(rw, "backend request failed", http.StatusBadGateway)
writeOpenAIError(rw, http.StatusBadGateway, "backend request failed", "devrail_backend_error", "backend_request_failed")
}

slog.Info("routing request", "alias", model.ID, "target_model", model.TargetModel, "backend", backend.ID)
slog.Info("routing request", "request_id", requestID, "alias", model.ID, "target_model", model.TargetModel, "backend", backend.ID)
proxy.ServeHTTP(w, r)
}

func (s *Server) acquireModelSlot(w http.ResponseWriter, r *http.Request, model config.ModelConfig) (func(), bool) {
func (s *Server) acquireModelSlot(w http.ResponseWriter, r *http.Request, model config.ModelConfig, requestID string) (func(), bool) {
limiter, ok := s.limiters[model.ID]
if !ok {
return nil, true
Expand All @@ -186,6 +192,7 @@ func (s *Server) acquireModelSlot(w http.ResponseWriter, r *http.Request, model
w.Header().Set("X-Devrail-Queue-Wait-Ms", fmt.Sprintf("%d", waited.Milliseconds()))
slog.Info(
"acquired model slot",
"request_id", requestID,
"alias", model.ID,
"active", snapshot.active,
"queued", snapshot.queued,
Expand All @@ -205,6 +212,7 @@ func (s *Server) acquireModelSlot(w http.ResponseWriter, r *http.Request, model

slog.Warn(
"rejected queued request",
"request_id", requestID,
"alias", model.ID,
"active", snapshot.active,
"queued", snapshot.queued,
Expand All @@ -214,7 +222,7 @@ func (s *Server) acquireModelSlot(w http.ResponseWriter, r *http.Request, model
return nil, false
}

func (s *Server) ensureModelReady(w http.ResponseWriter, r *http.Request, model config.ModelConfig) bool {
func (s *Server) ensureModelReady(w http.ResponseWriter, r *http.Request, model config.ModelConfig, requestID string) bool {
if model.Ensure.Mode == "" || model.Ensure.Mode == "disabled" {
return true
}
Expand All @@ -229,14 +237,14 @@ func (s *Server) ensureModelReady(w http.ResponseWriter, r *http.Request, model

switch model.Ensure.Mode {
case "command":
return s.ensureModelReadyWithCommand(w, r, model)
return s.ensureModelReadyWithCommand(w, r, model, requestID)
default:
writeOpenAIError(w, http.StatusInternalServerError, "model ensure mode is unsupported", "devrail_ensure_unsupported", "ensure_unsupported")
return false
}
}

func (s *Server) ensureModelReadyWithCommand(w http.ResponseWriter, r *http.Request, model config.ModelConfig) bool {
func (s *Server) ensureModelReadyWithCommand(w http.ResponseWriter, r *http.Request, model config.ModelConfig, requestID string) bool {
timeout, err := model.Ensure.TimeoutDuration()
if err != nil {
writeOpenAIError(w, http.StatusInternalServerError, "model ensure timeout is invalid", "devrail_ensure_config_error", "ensure_config_error")
Expand Down Expand Up @@ -264,6 +272,7 @@ func (s *Server) ensureModelReadyWithCommand(w http.ResponseWriter, r *http.Requ

slog.Warn(
"model ensure command failed",
"request_id", requestID,
"alias", model.ID,
"target_model", model.TargetModel,
"error", err,
Expand All @@ -275,6 +284,7 @@ func (s *Server) ensureModelReadyWithCommand(w http.ResponseWriter, r *http.Requ

slog.Info(
"model ensure command completed",
"request_id", requestID,
"alias", model.ID,
"target_model", model.TargetModel,
"output", outputText,
Expand All @@ -283,6 +293,7 @@ func (s *Server) ensureModelReadyWithCommand(w http.ResponseWriter, r *http.Requ
}

type responseTelemetry struct {
RequestID string
Alias string
TargetModel string
Backend string
Expand Down Expand Up @@ -320,6 +331,7 @@ func (body *telemetryReadCloser) log() {
body.once.Do(func() {
slog.Info(
"backend response completed",
"request_id", body.telemetry.RequestID,
"alias", body.telemetry.Alias,
"target_model", body.telemetry.TargetModel,
"upstream_model", body.telemetry.UpstreamModel,
Expand All @@ -334,12 +346,13 @@ func (body *telemetryReadCloser) log() {
})
}

func instrumentBackendResponse(resp *http.Response, started time.Time, model config.ModelConfig, backend config.BackendConfig) {
func instrumentBackendResponse(resp *http.Response, started time.Time, model config.ModelConfig, backend config.BackendConfig, requestID string) {
if resp.Body == nil {
return
}

telemetry := &responseTelemetry{
RequestID: requestID,
Alias: model.ID,
TargetModel: model.TargetModel,
Backend: backend.ID,
Expand Down Expand Up @@ -393,6 +406,22 @@ func applyOpenAIUsageTelemetry(raw []byte, telemetry *responseTelemetry) {
telemetry.TotalTokens = payload.Usage.TotalTokens
}

func devrailRequestID(r *http.Request) string {
for _, header := range []string{"X-Devrail-Request-ID", "X-Request-ID"} {
value := strings.TrimSpace(r.Header.Get(header))
if value != "" {
return value
}
}

var raw [16]byte
if _, err := rand.Read(raw[:]); err == nil {
return hex.EncodeToString(raw[:])
}

return fmt.Sprintf("%d", time.Now().UnixNano())
}

func requestModel(r *http.Request) (string, []byte, error) {
if r.Body == nil {
return "", nil, fmt.Errorf("request body is required")
Expand Down
Loading
Loading