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
4 changes: 2 additions & 2 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -7,9 +7,9 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0

## [Unreleased]

### Changed
### Added

- Updated beta banner to v1 stable
- Opt-in command-backed model profile ensure hooks.

## [1.0.0] - 2026-03-01

Expand Down
5 changes: 3 additions & 2 deletions Makefile
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,7 @@ VAGRANT_DESTROY ?= 1
DIST_DIR ?= dist
BIN_DIR ?= bin
PACKAGE_NAME := devrail-router_$(VERSION)_$(GOOS)_$(GOARCH)
BUILD_OUTPUT := $(BIN_DIR)/devrail-router_$(GOOS)_$(GOARCH)
RELEASE_TARGETS ?= linux/amd64 linux/arm64 darwin/arm64

DOCKER_RUN := docker run --rm \
Expand Down Expand Up @@ -73,7 +74,7 @@ build: ## Build the devrail-router binary for GOOS/GOARCH
CGO_ENABLED=0 GOOS="$(GOOS)" GOARCH="$(GOARCH)" go build \
-trimpath \
-ldflags "-s -w -X main.version=$(VERSION)" \
-o "$(BIN_DIR)/devrail-router" \
-o "$(BUILD_OUTPUT)" \
./cmd/devrail-router

changelog: ## Generate CHANGELOG.md from conventional commits
Expand Down Expand Up @@ -131,7 +132,7 @@ lint: ## Run all linters
package: build ## Build a Linux/macOS tarball package
@rm -rf "$(DIST_DIR)/$(PACKAGE_NAME)"
@mkdir -p "$(DIST_DIR)/$(PACKAGE_NAME)"
cp "$(BIN_DIR)/devrail-router" "$(DIST_DIR)/$(PACKAGE_NAME)/devrail-router"
cp "$(BUILD_OUTPUT)" "$(DIST_DIR)/$(PACKAGE_NAME)/devrail-router"
cp LICENSE README.md CHANGELOG.md "$(DIST_DIR)/$(PACKAGE_NAME)/"
mkdir -p "$(DIST_DIR)/$(PACKAGE_NAME)/configs" \
"$(DIST_DIR)/$(PACKAGE_NAME)/docs" \
Expand Down
16 changes: 14 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -22,19 +22,25 @@ This repository is in early foundation work. The current service supports:
- `/v1/models`
- OpenAI-compatible `/v1/*` request proxying
- model alias rewriting
- opt-in model profile ensure hooks
- YAML configuration
- Linux tarball packaging
- Linux/systemd install script and unit
- Docker image and Compose smoke testing with a mock OpenAI-compatible backend

Routing policy, auth, telemetry, LM Studio lifecycle integration, and Omarchy
integration are planned next.
Routing policy, auth, telemetry, native LM Studio lifecycle integration, and
Omarchy integration are planned next.

Model aliases can also set basic concurrency guardrails with
`max_concurrent_requests`, `max_queue_size`, and `queue_timeout`. This lets heavy
local models wait or reject predictably instead of allowing multiple agents to
dogpile the same backend.

Aliases can opt into a command-backed `ensure` hook before proxying. This is
intended for host adapters such as LM Studio profile loaders that need to
guarantee context length, parallelism, TTL, or model identifier before a client
request reaches the backend.

## Quick Start

Build and test locally:
Expand Down Expand Up @@ -106,6 +112,12 @@ models:
max_concurrent_requests: 2
max_queue_size: 4
queue_timeout: 30s
ensure:
mode: command
command:
- /usr/local/bin/lmstudio-load-profile
- local-coder
timeout: 30s

backends:
- id: lmstudio
Expand Down
12 changes: 12 additions & 0 deletions configs/router.example.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,12 @@ models:
max_concurrent_requests: 2
max_queue_size: 4
queue_timeout: 30s
ensure:
mode: command
command:
- /usr/local/bin/lmstudio-load-profile
- local-coder
timeout: 30s
- id: local-coder-large
name: Local Coder Large
backend: lmstudio
Expand All @@ -22,6 +28,12 @@ models:
max_concurrent_requests: 1
max_queue_size: 2
queue_timeout: 2m
ensure:
mode: command
command:
- /usr/local/bin/lmstudio-load-profile
- local-coder-large
timeout: 45s

backends:
- id: lmstudio
Expand Down
31 changes: 31 additions & 0 deletions docs/architecture.md
Original file line number Diff line number Diff line change
Expand Up @@ -32,6 +32,7 @@ parallelism, TTL, GPU offload, auth, or scheduling policy.
The first router is deliberately simple:

- Clients request a DevRail model alias.
- The router optionally runs an alias-specific readiness hook.
- The router rewrites the request to the configured backend model.
- The backend handles inference.

Expand Down Expand Up @@ -62,3 +63,33 @@ full, DevRail Router returns an OpenAI-shaped `429` error. If the request waits
longer than `queue_timeout`, it returns `503`. Successful queued requests get an
`X-Devrail-Queue-Wait-Ms` response header, and queue decisions are logged with
active count, queued count, and wait time.

## Profile Ensure Hooks

Backends such as LM Studio can Just-In-Time load models, but backend defaults may
not match the alias contract that clients see. For example, an alias may
advertise a 64k context window while the backend's default JIT load only creates
an 8k context. A model alias can opt into a command-backed ensure hook:

```yaml
models:
- id: local-coder
backend: lmstudio
target_model: qwen3-coder-30b-a3b-instruct
context_window: 65536
ensure:
mode: command
command:
- /usr/local/bin/lmstudio-load-profile
- local-coder
timeout: 30s
```

The ensure hook runs after queue slot acquisition and before request forwarding.
If it fails or times out, DevRail Router returns an OpenAI-shaped `503` error and
does not send the request to the backend. This keeps clients from hanging behind
a model that is unloaded, incorrectly loaded, or busy switching profiles.

Command hooks are a conservative bridge for early local integrations. Native
backend adapters can later inspect runtime state directly and choose between
passive JIT loading, profile enforcement, or refusing unsafe model switches.
73 changes: 73 additions & 0 deletions internal/config/config.go
Original file line number Diff line number Diff line change
Expand Up @@ -33,6 +33,7 @@ type ModelConfig struct {
MaxConcurrentRequests int `yaml:"max_concurrent_requests"`
MaxQueueSize int `yaml:"max_queue_size"`
QueueTimeout string `yaml:"queue_timeout"`
Ensure EnsureConfig
}

type BackendConfig struct {
Expand All @@ -42,6 +43,39 @@ type BackendConfig struct {
APIKeyEnv string `yaml:"api_key_env"`
}

type EnsureConfig struct {
Mode string `yaml:"mode"`
Command CommandArgs `yaml:"command"`
Timeout string `yaml:"timeout"`
}

type CommandArgs []string

func (args *CommandArgs) UnmarshalYAML(value *yaml.Node) error {
switch value.Kind {
case yaml.ScalarNode:
var command string
if err := value.Decode(&command); err != nil {
return err
}
if command == "" {
*args = nil
return nil
}
*args = []string{"/bin/sh", "-c", command}
return nil
case yaml.SequenceNode:
var command []string
if err := value.Decode(&command); err != nil {
return err
}
*args = command
return nil
default:
return fmt.Errorf("command must be a string or list of strings")
}
}

func Load(path string) (Config, error) {
raw, err := os.ReadFile(path)
if err != nil {
Expand Down Expand Up @@ -115,6 +149,9 @@ func (cfg Config) Validate() error {
if _, err := model.QueueTimeoutDuration(); err != nil {
return fmt.Errorf("model %q queue_timeout is invalid: %w", model.ID, err)
}
if err := model.Ensure.Validate(); err != nil {
return fmt.Errorf("model %q ensure is invalid: %w", model.ID, err)
}
if _, ok := models[model.ID]; ok {
return fmt.Errorf("model %q is duplicated", model.ID)
}
Expand Down Expand Up @@ -159,3 +196,39 @@ func (model ModelConfig) QueueTimeoutDuration() (time.Duration, error) {

return duration, nil
}

func (ensure EnsureConfig) Validate() error {
switch ensure.Mode {
case "", "disabled":
return nil
case "command":
if len(ensure.Command) == 0 {
return errors.New("command is required when mode is command")
}
for _, arg := range ensure.Command {
if arg == "" {
return errors.New("command arguments must not be empty")
}
}
_, err := ensure.TimeoutDuration()
return err
default:
return fmt.Errorf("unknown mode %q", ensure.Mode)
}
}

func (ensure EnsureConfig) TimeoutDuration() (time.Duration, error) {
if ensure.Timeout == "" {
return 30 * time.Second, nil
}

duration, err := time.ParseDuration(ensure.Timeout)
if err != nil {
return 0, err
}
if duration <= 0 {
return 0, errors.New("duration must be positive")
}

return duration, nil
}
70 changes: 70 additions & 0 deletions internal/config/config_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -70,6 +70,76 @@ func TestValidateQueueSettings(t *testing.T) {
}
}

func TestLoadEnsureCommandString(t *testing.T) {
t.Parallel()

path := filepath.Join(t.TempDir(), "router.yaml")
raw := []byte(`
models:
- id: local-coder
backend: lmstudio
target_model: qwen3-coder-30b-a3b-instruct
ensure:
mode: command
command: /usr/local/bin/lmstudio-load-profile local-coder
timeout: 5s
backends:
- id: lmstudio
type: openai-compatible
base_url: http://127.0.0.1:1234/v1
`)
if err := os.WriteFile(path, raw, 0o600); err != nil {
t.Fatalf("write config: %v", err)
}

cfg, err := Load(path)
if err != nil {
t.Fatalf("load config: %v", err)
}

model := cfg.Models[0]
if model.Ensure.Mode != "command" {
t.Fatalf("unexpected ensure mode: %q", model.Ensure.Mode)
}
if got := []string(model.Ensure.Command); len(got) != 3 || got[0] != "/bin/sh" || got[1] != "-c" {
t.Fatalf("unexpected command args: %#v", got)
}
duration, err := model.Ensure.TimeoutDuration()
if err != nil {
t.Fatalf("parse timeout: %v", err)
}
if duration != 5*time.Second {
t.Fatalf("unexpected timeout: %s", duration)
}
}

func TestValidateEnsureCommandRequired(t *testing.T) {
t.Parallel()

cfg := Config{
Models: []ModelConfig{{
ID: "local-coder",
Backend: "lmstudio",
TargetModel: "qwen/qwen3.6-35b-a3b",
Ensure: EnsureConfig{
Mode: "command",
},
}},
Backends: []BackendConfig{{
ID: "lmstudio",
BaseURL: "http://127.0.0.1:1234/v1",
}},
}

err := cfg.Validate()
if err == nil {
t.Fatal("expected validation error")
}
if !strings.Contains(err.Error(), "ensure") {
t.Fatalf("expected ensure error, got: %v", err)
}
}

func TestValidateInvalidQueueTimeout(t *testing.T) {
t.Parallel()

Expand Down
Loading
Loading