diff --git a/.devrail.yml b/.devrail.yml index e351ea0..ccdc1c2 100644 --- a/.devrail.yml +++ b/.devrail.yml @@ -7,7 +7,7 @@ languages: # - terraform # - ansible # - ruby - # - go + - go # - javascript # - rust diff --git a/README.md b/README.md index f7ded63..ca7f53f 100644 --- a/README.md +++ b/README.md @@ -1,122 +1,114 @@ -# Project Name +# DevRail Router -> Built with [DevRail](https://devrail.dev) `v1` standards. See [STABILITY.md](STABILITY.md) for component status. +> Local-first LLM routing and control plane for private AI infrastructure. - - -A new project bootstrapped from the [DevRail GitHub template](https://github.com/devrail-dev/github-repo-template). - - - [![DevRail compliant](https://devrail.dev/images/badge.svg)](https://devrail.dev) [![License: MIT](https://img.shields.io/badge/License-MIT-yellow.svg)](LICENSE) - - -## Quick Start - -1. Click **"Use this template"** on [github.com/devrail-dev/github-repo-template](https://github.com/devrail-dev/github-repo-template) to create a new repository. -2. Edit `.devrail.yml` and uncomment the languages used in your project. -3. Run `make install-hooks` to set up pre-commit hooks. -## Usage +DevRail Router presents one OpenAI-compatible endpoint to local agents and +developer tools, then routes requests to private inference backends such as LM +Studio, Ollama, vLLM, SGLang, or approved cloud fallbacks. -The Makefile is the universal execution interface. Every target produces consistent behavior whether invoked by a developer, CI pipeline, or AI agent. +The initial target user is an operator running mixed self-hosted inference +hardware who wants a private subscription-style backend for tools such as +Hermes, OpenClaw, opencode, and other local agents. -| Target | Purpose | -|---|---| -| `make help` | Show available targets (default) | -| `make lint` | Run all linters for declared languages | -| `make format` | Run all formatters for declared languages | -| `make fix` | Auto-fix formatting issues in-place | -| `make test` | Run project test suite | -| `make security` | Run language-specific security scanners | -| `make scan` | Run universal scanning (trivy, gitleaks) | -| `make docs` | Generate documentation | -| `make check` | Run all of the above; report composite summary | -| `make install-hooks` | Install pre-commit and pre-push hooks | +## Status -All targets except `help` and `install-hooks` delegate to the dev-toolchain Docker container (`ghcr.io/devrail-dev/dev-toolchain:v1`). +This repository is in early foundation work. The current service supports: -## Configuration +- a small Go HTTP service +- `/healthz` +- `/v1/models` +- OpenAI-compatible `/v1/*` request proxying +- model alias rewriting +- YAML configuration +- Linux/systemd packaging notes -### `.devrail.yml` +Routing policy, auth, telemetry, LM Studio lifecycle integration, and Omarchy +integration are planned next. -Every DevRail-managed repository includes a `.devrail.yml` file at the repo root. This file declares the project's languages and settings, and is read by the Makefile, CI pipelines, and AI agents. +## Quick Start -```yaml -languages: - - python - - bash +Build and test locally: -fail_fast: false -log_format: json +```sh +go test ./... +go build ./cmd/devrail-router ``` -Uncomment the languages used in your project and configure settings as needed. - -### Branch Protection - -To enforce CI checks before merging pull requests: - -1. Go to **Settings > Branches > Branch protection rules** -2. Add a rule for the `main` branch -3. Enable **"Require status checks to pass before merging"** -4. Select all five status checks: `lint`, `format`, `security`, `test`, `docs` +Run against the example config: -### GitHub Template Repository - -This repo is configured as a GitHub template. To enable this on your fork: - -1. Go to **Settings > General** -2. Check **"Template repository"** under the repository name section -3. Users will then see a **"Use this template"** button on the repo page - -## Contributing - -See [DEVELOPMENT.md](DEVELOPMENT.md) for development standards, coding conventions, and contribution guidelines. +```sh +go run ./cmd/devrail-router serve -config configs/router.example.yaml +``` -To add a new language ecosystem to DevRail, see the [Contributing to DevRail](https://github.com/devrail-dev/devrail-standards/blob/main/standards/contributing.md) guide. +List exposed model aliases: -This project follows [Conventional Commits](https://www.conventionalcommits.org/). All commits use the `type(scope): description` format. +```sh +curl http://127.0.0.1:8080/v1/models +``` -## Retrofit Existing Project +Send a chat completion through the router: -To add DevRail standards to an existing GitHub repository: +```sh +curl http://127.0.0.1:8080/v1/chat/completions \ + -H 'Content-Type: application/json' \ + -d '{ + "model": "local-coder", + "messages": [{"role": "user", "content": "Reply with ok."}], + "max_tokens": 32 + }' +``` -### Step 1: Core Configuration +## Configuration -- [ ] Copy `.devrail.yml` and uncomment your project's languages -- [ ] Copy `.editorconfig` -- [ ] Merge `.gitignore` patterns into your existing .gitignore -- [ ] Copy `Makefile` (or merge targets if you have an existing Makefile) +See `configs/router.example.yaml`. -### Step 2: Pre-Commit Hooks +```yaml +server: + address: 127.0.0.1:8080 + +models: + - id: local-coder + name: Local Coder + backend: lmstudio + target_model: qwen3-coder-30b-a3b-instruct + context_window: 65536 + max_output_tokens: 4096 + tool_calls: true + +backends: + - id: lmstudio + type: openai-compatible + base_url: http://127.0.0.1:1234/v1 +``` -- [ ] Copy `.pre-commit-config.yaml` and uncomment hooks for your languages -- [ ] Run `make install-hooks` +## Packaging Direction -### Step 3: Agent Instruction Files +Linux is the first-class target: -- [ ] Copy `DEVELOPMENT.md`, `CLAUDE.md`, `AGENTS.md`, `.cursorrules` -- [ ] Copy `.opencode/agents.yaml` +- Binary: `/usr/local/bin/devrail-router` +- Config: `/etc/devrail/router.yaml` +- State: `/var/lib/devrail-router` +- Service user: `devrail-router` +- Service manager: systemd -### Step 4: CI Workflows +See `docs/packaging.md` and `packaging/systemd/devrail-router.service`. -- [ ] Copy `.github/workflows/` directory (lint.yml, format.yml, security.yml, test.yml, docs.yml) -- [ ] Configure branch protection: Settings > Branches > Require status checks +Omarchy support is planned as a separate integration profile. See +`integrations/omarchy/README.md`. -### Step 5: Project Documentation +## Development -- [ ] Copy `.github/PULL_REQUEST_TEMPLATE.md` -- [ ] Copy `.github/CODEOWNERS` and configure for your team -- [ ] Copy `CHANGELOG.md` if not already present +This project follows [DevRail](https://devrail.dev) development standards. -### Step 6: Verify +```sh +make check +``` -- [ ] Run `make check` and fix any issues -- [ ] Create a test commit to verify pre-commit hooks fire -- [ ] Create a test PR to verify CI workflows run +All DevRail checks run through `ghcr.io/devrail-dev/dev-toolchain:v1`. ## License -This project is licensed under the MIT License. See [LICENSE](LICENSE) for details. +MIT. See [LICENSE](LICENSE). diff --git a/cmd/devrail-router/main.go b/cmd/devrail-router/main.go new file mode 100644 index 0000000..dc8a4ba --- /dev/null +++ b/cmd/devrail-router/main.go @@ -0,0 +1,126 @@ +package main + +import ( + "context" + "flag" + "fmt" + "log/slog" + "net/http" + "os" + "os/signal" + "syscall" + "time" + + "github.com/devrail-dev/devrail-router/internal/config" + "github.com/devrail-dev/devrail-router/internal/server" +) + +const version = "0.1.0-dev" + +func main() { + os.Exit(run(os.Args[1:])) +} + +func run(args []string) int { + if len(args) == 0 { + args = []string{"serve"} + } + + switch args[0] { + case "serve": + return serve(args[1:]) + case "check": + return check(args[1:]) + case "version": + fmt.Println(version) + return 0 + case "help", "-h", "--help": + usage() + return 0 + default: + fmt.Fprintf(os.Stderr, "unknown command %q\n\n", args[0]) + usage() + return 2 + } +} + +func serve(args []string) int { + fs := flag.NewFlagSet("serve", flag.ContinueOnError) + configPath := fs.String("config", config.DefaultPath, "path to router config") + if err := fs.Parse(args); err != nil { + return 2 + } + + cfg, err := config.Load(*configPath) + if err != nil { + slog.Error("load config", "error", err) + return 1 + } + + handler, err := server.New(cfg) + if err != nil { + slog.Error("create server", "error", err) + return 1 + } + + httpServer := &http.Server{ + Addr: cfg.Server.Address, + Handler: handler, + ReadHeaderTimeout: 15 * time.Second, + } + + errCh := make(chan error, 1) + go func() { + slog.Info("starting devrail router", "address", cfg.Server.Address) + errCh <- httpServer.ListenAndServe() + }() + + signalCh := make(chan os.Signal, 1) + signal.Notify(signalCh, syscall.SIGINT, syscall.SIGTERM) + + select { + case err := <-errCh: + if err != nil && err != http.ErrServerClosed { + slog.Error("server stopped", "error", err) + return 1 + } + case sig := <-signalCh: + slog.Info("shutting down", "signal", sig.String()) + ctx, cancel := context.WithTimeout(context.Background(), 15*time.Second) + defer cancel() + if err := httpServer.Shutdown(ctx); err != nil { + slog.Error("shutdown failed", "error", err) + return 1 + } + } + + return 0 +} + +func check(args []string) int { + fs := flag.NewFlagSet("check", flag.ContinueOnError) + configPath := fs.String("config", config.DefaultPath, "path to router config") + if err := fs.Parse(args); err != nil { + return 2 + } + + cfg, err := config.Load(*configPath) + if err != nil { + slog.Error("config invalid", "error", err) + return 1 + } + + slog.Info("config ok", "address", cfg.Server.Address, "models", len(cfg.Models), "backends", len(cfg.Backends)) + return 0 +} + +func usage() { + fmt.Fprintf(os.Stderr, `DevRail Router %s + +Usage: + devrail-router serve [-config path] + devrail-router check [-config path] + devrail-router version + +`, version) +} diff --git a/configs/router.example.yaml b/configs/router.example.yaml new file mode 100644 index 0000000..6f4e6a1 --- /dev/null +++ b/configs/router.example.yaml @@ -0,0 +1,23 @@ +server: + address: 127.0.0.1:8080 + +models: + - id: local-coder + name: Local Coder + backend: lmstudio + target_model: qwen3-coder-30b-a3b-instruct + context_window: 65536 + max_output_tokens: 4096 + tool_calls: true + - id: local-coder-large + name: Local Coder Large + backend: lmstudio + target_model: qwen/qwen3.6-35b-a3b + context_window: 131072 + max_output_tokens: 4096 + tool_calls: true + +backends: + - id: lmstudio + type: openai-compatible + base_url: http://127.0.0.1:1234/v1 diff --git a/docs/architecture.md b/docs/architecture.md new file mode 100644 index 0000000..55abc45 --- /dev/null +++ b/docs/architecture.md @@ -0,0 +1,39 @@ +# Architecture + +DevRail Router is a local-first OpenAI-compatible gateway for private AI +infrastructure. It is designed for operators who run a mix of local inference +backends, agent frameworks, coding tools, and optional cloud fallback. + +## Initial Scope + +- Present stable model aliases to clients such as opencode, Hermes, and + OpenClaw. +- Route aliases to OpenAI-compatible backends such as LM Studio. +- Preserve a small, debuggable Linux service that can run under systemd. +- Collect enough request and backend signal to support smarter routing later. + +## Layers + +1. Core service: HTTP API, alias registry, routing policy, auth, and telemetry. +2. Runtime packaging: systemd, Linux tarballs, container image, and eventually + Homebrew or launchd for macOS. +3. Host integrations: LM Studio, Ollama, vLLM, SGLang, GPU telemetry, thermal + state, and desktop integrations such as Omarchy. + +## Backend Philosophy + +DevRail Router should not replace backend-specific lifecycle features. If LM +Studio can Just-In-Time load a model safely, the router should let it. The +router should only intervene when a profile requires specific context length, +parallelism, TTL, GPU offload, auth, or scheduling policy. + +## Near-Term Routing + +The first router is deliberately simple: + +- Clients request a DevRail model alias. +- The router rewrites the request to the configured backend model. +- The backend handles inference. + +Future routing can add deterministic policy, queueing, health-aware selection, +RouteLLM-style strong/weak model routing, and second-pass review workflows. diff --git a/docs/packaging.md b/docs/packaging.md new file mode 100644 index 0000000..b1fa518 --- /dev/null +++ b/docs/packaging.md @@ -0,0 +1,37 @@ +# Packaging + +DevRail Router packages the portable service separately from host-specific +integration. + +## Linux First + +The primary Linux installation target is: + +- Binary: `/usr/local/bin/devrail-router` +- Config: `/etc/devrail/router.yaml` +- State: `/var/lib/devrail-router` +- Service user: `devrail-router` +- Service manager: systemd + +The systemd unit lives at `packaging/systemd/devrail-router.service`. + +## Container Image + +A container image is useful for proxy-only deployments and CI smoke tests. It is +not the first-class LM Studio host install path because local desktop app and GPU +integration are easier from a native Linux service. + +## Omarchy + +Omarchy support should be an integration profile, not a fork of the core router. +See `integrations/omarchy/README.md` for the expected plugin layout and safety +constraints. + +## macOS ARM + +macOS support should arrive after the Linux service is stable: + +- Homebrew tap under `devrail-dev/tap` +- launchd plist +- LM Studio adapter using macOS paths +- no local GPU assumptions for the first macOS release diff --git a/go.mod b/go.mod new file mode 100644 index 0000000..cb4d845 --- /dev/null +++ b/go.mod @@ -0,0 +1,7 @@ +module github.com/devrail-dev/devrail-router + +go 1.23 + +toolchain go1.25.13 + +require gopkg.in/yaml.v3 v3.0.1 diff --git a/go.sum b/go.sum new file mode 100644 index 0000000..a62c313 --- /dev/null +++ b/go.sum @@ -0,0 +1,4 @@ +gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405 h1:yhCVgyC4o1eVCa2tZl7eS0r+SDo693bJlVdllGtEeKM= +gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= +gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= +gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= diff --git a/integrations/omarchy/README.md b/integrations/omarchy/README.md new file mode 100644 index 0000000..63f5df1 --- /dev/null +++ b/integrations/omarchy/README.md @@ -0,0 +1,28 @@ +# Omarchy Integration + +Omarchy support is planned as a plugin or installer profile for hosts where +DevRail Router should feel native in the desktop environment. + +Reference: + +## Direction + +- Keep the core router service independent of Omarchy. +- Use Omarchy plugin files only for status, controls, and setup affordances. +- Prefer a user-owned plugin under `~/.config/omarchy/plugins/`. +- Validate plugin folders with `omarchy plugin validate`. +- Lint QML entrypoints with `qmllint -I "$OMARCHY_PATH/shell"`. + +## Likely Plugin Shape + +A first plugin should probably be a `bar-widget` with a details panel showing: + +- router status +- active backend +- loaded model/profile when known +- recent routing decisions +- thermal or health warnings when available + +The plugin must not start a second Quickshell process. Any privileged setup +belongs in the Linux installer or an explicit admin command, not in the QML +runtime. diff --git a/internal/config/config.go b/internal/config/config.go new file mode 100644 index 0000000..d75fd5e --- /dev/null +++ b/internal/config/config.go @@ -0,0 +1,132 @@ +package config + +import ( + "errors" + "fmt" + "net/url" + "os" + + "gopkg.in/yaml.v3" +) + +const DefaultPath = "/etc/devrail/router.yaml" + +type Config struct { + Server ServerConfig `yaml:"server"` + Models []ModelConfig `yaml:"models"` + Backends []BackendConfig `yaml:"backends"` +} + +type ServerConfig struct { + Address string `yaml:"address"` +} + +type ModelConfig struct { + ID string `yaml:"id"` + Name string `yaml:"name"` + Backend string `yaml:"backend"` + TargetModel string `yaml:"target_model"` + ContextWindow int `yaml:"context_window"` + MaxOutputTokens int `yaml:"max_output_tokens"` + ToolCalls bool `yaml:"tool_calls"` +} + +type BackendConfig struct { + ID string `yaml:"id"` + Type string `yaml:"type"` + BaseURL string `yaml:"base_url"` + APIKeyEnv string `yaml:"api_key_env"` +} + +func Load(path string) (Config, error) { + raw, err := os.ReadFile(path) + if err != nil { + return Config{}, err + } + + var cfg Config + if err := yaml.Unmarshal(raw, &cfg); err != nil { + return Config{}, err + } + + cfg.ApplyDefaults() + if err := cfg.Validate(); err != nil { + return Config{}, err + } + + return cfg, nil +} + +func (cfg *Config) ApplyDefaults() { + if cfg.Server.Address == "" { + cfg.Server.Address = "127.0.0.1:8080" + } +} + +func (cfg Config) Validate() error { + if len(cfg.Backends) == 0 { + return errors.New("at least one backend is required") + } + if len(cfg.Models) == 0 { + return errors.New("at least one model alias is required") + } + + backends := make(map[string]BackendConfig, len(cfg.Backends)) + for _, backend := range cfg.Backends { + if backend.ID == "" { + return errors.New("backend id is required") + } + if backend.BaseURL == "" { + return fmt.Errorf("backend %q base_url is required", backend.ID) + } + if _, err := url.ParseRequestURI(backend.BaseURL); err != nil { + return fmt.Errorf("backend %q base_url is invalid: %w", backend.ID, err) + } + if _, ok := backends[backend.ID]; ok { + return fmt.Errorf("backend %q is duplicated", backend.ID) + } + backends[backend.ID] = backend + } + + models := make(map[string]struct{}, len(cfg.Models)) + for _, model := range cfg.Models { + if model.ID == "" { + return errors.New("model id is required") + } + if model.Backend == "" { + return fmt.Errorf("model %q backend is required", model.ID) + } + if _, ok := backends[model.Backend]; !ok { + return fmt.Errorf("model %q references unknown backend %q", model.ID, model.Backend) + } + if model.TargetModel == "" { + return fmt.Errorf("model %q target_model is required", model.ID) + } + if _, ok := models[model.ID]; ok { + return fmt.Errorf("model %q is duplicated", model.ID) + } + models[model.ID] = struct{}{} + } + + return nil +} + +func (cfg Config) Model(id string) (ModelConfig, bool) { + for _, model := range cfg.Models { + if model.ID == id { + return model, true + } + } + + return ModelConfig{}, false +} + +func (cfg Config) Backend(id string) (BackendConfig, bool) { + for _, backend := range cfg.Backends { + if backend.ID == id { + return backend, true + } + } + + return BackendConfig{}, false +} diff --git a/internal/config/config_test.go b/internal/config/config_test.go new file mode 100644 index 0000000..ee49c40 --- /dev/null +++ b/internal/config/config_test.go @@ -0,0 +1,58 @@ +package config + +import ( + "os" + "path/filepath" + "testing" +) + +func TestLoadValidConfig(t *testing.T) { + t.Parallel() + + path := filepath.Join(t.TempDir(), "router.yaml") + raw := []byte(` +models: + - id: local-coder + backend: lmstudio + target_model: qwen/qwen3.6-35b-a3b +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) + } + + if cfg.Server.Address != "127.0.0.1:8080" { + t.Fatalf("unexpected default address: %q", cfg.Server.Address) + } + if _, ok := cfg.Model("local-coder"); !ok { + t.Fatal("expected local-coder model") + } +} + +func TestValidateUnknownBackend(t *testing.T) { + t.Parallel() + + cfg := Config{ + Models: []ModelConfig{{ + ID: "local-coder", + Backend: "missing", + TargetModel: "qwen/qwen3.6-35b-a3b", + }}, + Backends: []BackendConfig{{ + ID: "lmstudio", + BaseURL: "http://127.0.0.1:1234/v1", + }}, + } + + if err := cfg.Validate(); err == nil { + t.Fatal("expected validation error") + } +} diff --git a/internal/server/server.go b/internal/server/server.go new file mode 100644 index 0000000..164b4c9 --- /dev/null +++ b/internal/server/server.go @@ -0,0 +1,189 @@ +package server + +import ( + "bytes" + "encoding/json" + "fmt" + "io" + "log/slog" + "net/http" + "net/http/httputil" + "net/url" + "os" + "strings" + + "github.com/devrail-dev/devrail-router/internal/config" +) + +type Server struct { + cfg config.Config +} + +func New(cfg config.Config) (*Server, error) { + if err := cfg.Validate(); err != nil { + return nil, err + } + + return &Server{cfg: cfg}, nil +} + +func (s *Server) ServeHTTP(w http.ResponseWriter, r *http.Request) { + switch { + case r.URL.Path == "/healthz": + s.handleHealth(w, r) + case r.URL.Path == "/v1/models": + s.handleModels(w, r) + case strings.HasPrefix(r.URL.Path, "/v1/"): + s.proxyOpenAI(w, r) + default: + http.NotFound(w, r) + } +} + +func (s *Server) handleHealth(w http.ResponseWriter, _ *http.Request) { + writeJSON(w, http.StatusOK, map[string]string{"status": "ok"}) +} + +func (s *Server) handleModels(w http.ResponseWriter, _ *http.Request) { + type modelResponse struct { + ID string `json:"id"` + Object string `json:"object"` + OwnedBy string `json:"owned_by"` + Name string `json:"name,omitempty"` + ContextWindow int `json:"context_window,omitempty"` + TargetModel string `json:"target_model,omitempty"` + } + + models := make([]modelResponse, 0, len(s.cfg.Models)) + for _, model := range s.cfg.Models { + models = append(models, modelResponse{ + ID: model.ID, + Object: "model", + OwnedBy: "devrail-router", + Name: model.Name, + ContextWindow: model.ContextWindow, + TargetModel: model.TargetModel, + }) + } + + writeJSON(w, http.StatusOK, map[string]any{ + "object": "list", + "data": models, + }) +} + +func (s *Server) proxyOpenAI(w http.ResponseWriter, r *http.Request) { + modelID, body, err := requestModel(r) + if err != nil { + http.Error(w, err.Error(), http.StatusBadRequest) + return + } + + model, ok := s.cfg.Model(modelID) + if !ok { + http.Error(w, fmt.Sprintf("unknown model alias %q", modelID), http.StatusBadRequest) + return + } + + backend, ok := s.cfg.Backend(model.Backend) + if !ok { + http.Error(w, fmt.Sprintf("unknown backend %q", model.Backend), http.StatusInternalServerError) + return + } + + body, err = rewriteModel(body, model.TargetModel) + if err != nil { + http.Error(w, err.Error(), http.StatusBadRequest) + return + } + + r.Body = io.NopCloser(bytes.NewReader(body)) + r.ContentLength = int64(len(body)) + r.Header.Set("Content-Length", fmt.Sprintf("%d", len(body))) + + target, err := url.Parse(backend.BaseURL) + if err != nil { + http.Error(w, "backend base_url is invalid", http.StatusInternalServerError) + return + } + + proxy := httputil.NewSingleHostReverseProxy(target) + originalDirector := proxy.Director + proxy.Director = func(req *http.Request) { + originalDirector(req) + req.URL.Path = joinPath(target.Path, r.URL.Path) + req.Host = target.Host + setBackendAuth(req, backend) + } + proxy.ErrorHandler = func(rw http.ResponseWriter, req *http.Request, proxyErr error) { + slog.Error("backend request failed", "method", req.Method, "path", req.URL.Path, "backend", backend.ID, "error", proxyErr) + http.Error(rw, "backend request failed", http.StatusBadGateway) + } + + slog.Info("routing request", "alias", model.ID, "target_model", model.TargetModel, "backend", backend.ID) + proxy.ServeHTTP(w, r) +} + +func requestModel(r *http.Request) (string, []byte, error) { + if r.Body == nil { + return "", nil, fmt.Errorf("request body is required") + } + + body, err := io.ReadAll(r.Body) + if err != nil { + return "", nil, fmt.Errorf("read request body: %w", err) + } + + var payload map[string]any + if err := json.Unmarshal(body, &payload); err != nil { + return "", nil, fmt.Errorf("parse request body: %w", err) + } + + model, ok := payload["model"].(string) + if !ok || model == "" { + return "", nil, fmt.Errorf("request body must include a model string") + } + + return model, body, nil +} + +func rewriteModel(body []byte, targetModel string) ([]byte, error) { + var payload map[string]any + if err := json.Unmarshal(body, &payload); err != nil { + return nil, fmt.Errorf("parse request body: %w", err) + } + + payload["model"] = targetModel + body, err := json.Marshal(payload) + if err != nil { + return nil, fmt.Errorf("encode request body: %w", err) + } + + return body, nil +} + +func setBackendAuth(req *http.Request, backend config.BackendConfig) { + if backend.APIKeyEnv == "" { + return + } + + apiKey := os.Getenv(backend.APIKeyEnv) + if apiKey == "" { + req.Header.Del("Authorization") + return + } + + req.Header.Set("Authorization", "Bearer "+apiKey) +} + +func joinPath(basePath, requestPath string) string { + return strings.TrimRight(basePath, "/") + "/" + strings.TrimLeft(requestPath, "/") +} + +func writeJSON(w http.ResponseWriter, status int, value any) { + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(status) + if err := json.NewEncoder(w).Encode(value); err != nil { + slog.Error("write response", "error", err) + } +} diff --git a/internal/server/server_test.go b/internal/server/server_test.go new file mode 100644 index 0000000..11a4919 --- /dev/null +++ b/internal/server/server_test.go @@ -0,0 +1,81 @@ +package server + +import ( + "encoding/json" + "net/http" + "net/http/httptest" + "strings" + "testing" + + "github.com/devrail-dev/devrail-router/internal/config" +) + +func TestModelsEndpointReturnsAliases(t *testing.T) { + t.Parallel() + + srv := testServer(t) + req := httptest.NewRequest(http.MethodGet, "/v1/models", nil) + rec := httptest.NewRecorder() + + srv.ServeHTTP(rec, req) + + if rec.Code != http.StatusOK { + t.Fatalf("unexpected status: %d", rec.Code) + } + + var payload struct { + Data []struct { + ID string `json:"id"` + TargetModel string `json:"target_model"` + } `json:"data"` + } + if err := json.Unmarshal(rec.Body.Bytes(), &payload); err != nil { + t.Fatalf("decode response: %v", err) + } + if len(payload.Data) != 1 { + t.Fatalf("unexpected model count: %d", len(payload.Data)) + } + if payload.Data[0].ID != "local-coder" { + t.Fatalf("unexpected model id: %q", payload.Data[0].ID) + } +} + +func TestUnknownAliasReturnsBadRequest(t *testing.T) { + t.Parallel() + + srv := testServer(t) + req := httptest.NewRequest( + http.MethodPost, + "/v1/chat/completions", + strings.NewReader(`{"model":"missing","messages":[]}`), + ) + rec := httptest.NewRecorder() + + srv.ServeHTTP(rec, req) + + if rec.Code != http.StatusBadRequest { + t.Fatalf("unexpected status: %d", rec.Code) + } +} + +func testServer(t *testing.T) *Server { + t.Helper() + + srv, err := New(config.Config{ + Server: config.ServerConfig{Address: "127.0.0.1:0"}, + Models: []config.ModelConfig{{ + ID: "local-coder", + Backend: "lmstudio", + TargetModel: "qwen/qwen3.6-35b-a3b", + }}, + Backends: []config.BackendConfig{{ + ID: "lmstudio", + BaseURL: "http://127.0.0.1:1234/v1", + }}, + }) + if err != nil { + t.Fatalf("create server: %v", err) + } + + return srv +} diff --git a/packaging/systemd/devrail-router.service b/packaging/systemd/devrail-router.service new file mode 100644 index 0000000..30d0bbe --- /dev/null +++ b/packaging/systemd/devrail-router.service @@ -0,0 +1,22 @@ +[Unit] +Description=DevRail Router local-first LLM gateway +Documentation=https://github.com/devrail-dev/devrail-router +After=network-online.target +Wants=network-online.target + +[Service] +Type=simple +User=devrail-router +Group=devrail-router +ExecStart=/usr/local/bin/devrail-router serve -config /etc/devrail/router.yaml +Restart=on-failure +RestartSec=5s +NoNewPrivileges=true +PrivateTmp=true +ProtectSystem=strict +ProtectHome=true +ReadWritePaths=/var/lib/devrail-router +ReadOnlyPaths=/etc/devrail + +[Install] +WantedBy=multi-user.target