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
21 changes: 18 additions & 3 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -49,7 +49,8 @@ A fourth mode — analyzers trained on real routing outcomes — ships with [Rou
- **Bring your own keys** — per-provider API keys stored encrypted in a local SQLite database.
- **No telemetry** — nothing leaves your machine except the LLM calls you route.
- **Interaction log & stats** — every request records the chosen model, rationale, cost, and latency; browse usage and spend locally.
- **Single binary** — Go backend, SQLite storage, no external services.
- **Web console** — a built-in dashboard at `http://localhost:4242` for usage, models, keys, preferences, and a routing playground. Optional: everything works headless via the CLI and API (`server.ui: false` turns it off).
- **Single binary** — Go backend, SQLite storage, embedded UI, no external services.

## Install

Expand Down Expand Up @@ -82,7 +83,7 @@ go build -o route42 ./cmd/route42

```bash
# 1. Run
route42 serve # starts on localhost:4242
route42 serve # gateway + web console on localhost:4242

# 2. Add a provider key (or none — Ollama-only works fine)
curl -X POST localhost:4242/api/keys -d '{"provider":"openai","api_key":"sk-..."}'
Expand All @@ -95,6 +96,18 @@ curl localhost:4242/api/chat/completions \

Point any OpenAI client at `http://localhost:4242` and it just works.

## Web console

`route42 serve` also hosts a small web console at [http://localhost:4242](http://localhost:4242) — the same look as the Route42 Pro desktop app, embedded in the binary (plain HTML/CSS/JS, no build step, no external assets):

- **Dashboard** — gateway health, discovered local models, usage/spend/tokens over time, per-model and per-category breakdowns, copy-paste client snippets.
- **Models** — the full routable catalog with quality/price metrics and availability, plus provider key management.
- **Preferences** — routing priority, model filters, cost/latency limits, and fallback, with a live profile summary.
- **Playground** — type a prompt, see the full routing decision (complexity, category, ranked candidates), and run it with streaming output.
- **Interaction History** — the local request log: model, category, tokens, cost, latency.

The console is optional. The gateway is fully operable headless via the CLI and HTTP API; set `server.ui: false` (or `ROUTE42_UI=false`) to disable it. If `server.api_token` is set, the console prompts for the token and sends it on its API calls.

## CLI

The `route42` binary is both the gateway and a management tool:
Expand Down Expand Up @@ -234,10 +247,12 @@ Use base URL `http://localhost:4242/v1`, any non-empty API key (unless you set `
| `PUT` | `/api/prefs` | Replace preferences (validated). |
| `POST` | `/api/recommend` | Ranked candidates + explanation, no execution (`{"messages":[...]}`). |
| `GET` | `/api/stats?days=N` | Usage aggregates (0 = all time). |
| `GET` | `/api/interactions?limit=N` | Recent interaction log entries, newest first (default 50, max 500). |
| `GET` | `/api/models` *(alias `/v1/models`)* | Catalog + local discovery + availability (OpenAI list shape). |
| `GET` | `/health` | Liveness/readiness (always public). |
| `GET` | `/` | Embedded web console (disable with `server.ui: false`). |

Optional auth: set `server.api_token` in config and send `Authorization: Bearer <token>` on `/api/*` routes (`/health` stays public).
Optional auth: set `server.api_token` in config and send `Authorization: Bearer <token>` on `/api/*` routes (`/health` and the console assets stay public).

## Documentation

Expand Down
3 changes: 3 additions & 0 deletions cmd/route42/serve.go
Original file line number Diff line number Diff line change
Expand Up @@ -38,6 +38,9 @@ func runServe(args []string) error {
defer stop()

env.logger.Info("route42 starting", "version", version, "port", env.cfg.Server.Port, "analyzer", env.cfg.Analyzer.Mode)
if env.cfg.Server.UI {
env.logger.Info("web console enabled", "url", fmt.Sprintf("http://localhost:%d", env.cfg.Server.Port))
}
if err := srv.Run(ctx); err != nil {
return fmt.Errorf("serve: %w", err)
}
Expand Down
5 changes: 4 additions & 1 deletion docs/config.md
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,7 @@ run creates the database and works Ollama-only with no keys).
server:
port: 4242 # 1..65535
api_token: "" # optional bearer token for /api/* (empty = no auth)
ui: true # serve the embedded web console at /

analyzer:
mode: heuristic # heuristic | llm | hybrid
Expand Down Expand Up @@ -54,7 +55,8 @@ prefs: # initial routing preferences (first-run seed)
| Field | Default | Validation | Notes |
|---|---|---|---|
| `port` | `4242` | 1–65535 | TCP port the gateway listens on. |
| `api_token` | `""` | — | When set, `/api/*` and `/v1/*` require `Authorization: Bearer <token>`. `/health` is always public. Empty (default) = no auth, for local single-user use. |
| `api_token` | `""` | — | When set, `/api/*` and `/v1/*` require `Authorization: Bearer <token>`. `/health` and the web console assets are always public (the console asks for the token and sends it on its API calls). Empty (default) = no auth, for local single-user use. |
| `ui` | `true` | — | Serves the embedded web console at `/`. Set `false` for a headless gateway; the CLI and HTTP API are unaffected either way. |

### `analyzer`

Expand Down Expand Up @@ -116,6 +118,7 @@ All `ROUTE42_*` variables override file values. Provider keys use
|---|---|
| `ROUTE42_PORT` | `server.port` |
| `ROUTE42_API_TOKEN` | `server.api_token` |
| `ROUTE42_UI` | `server.ui` |
| `ROUTE42_ANALYZER_MODE` | `analyzer.mode` |
| `ROUTE42_ANALYZER_LLM_MODEL` | `analyzer.llm.model` |
| `ROUTE42_ANALYZER_LLM_TIMEOUT_MS` | `analyzer.llm.timeout_ms` |
Expand Down
31 changes: 31 additions & 0 deletions internal/api/interactions.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,31 @@
package api

import (
"net/http"
"strconv"

"github.com/krugis/route42app/internal/store"
)

// handleInteractions returns the most recent interaction records, newest
// first. The `limit` query parameter (default 50, max 500) bounds the page.
func (s *Server) handleInteractions(w http.ResponseWriter, r *http.Request) {
limit := 50
if l := r.URL.Query().Get("limit"); l != "" {
if v, err := strconv.Atoi(l); err == nil && v > 0 {
limit = v
}
}
if limit > 500 {
limit = 500
}
interactions, err := s.store.RecentInteractions(limit)
if err != nil {
writeError(w, http.StatusInternalServerError, "load interactions: "+err.Error(), "server_error")
return
}
if interactions == nil {
interactions = []store.Interaction{}
}
writeJSON(w, http.StatusOK, map[string]any{"interactions": interactions})
}
16 changes: 14 additions & 2 deletions internal/api/routes.go
Original file line number Diff line number Diff line change
@@ -1,6 +1,10 @@
package api

import "net/http"
import (
"net/http"

"github.com/krugis/route42app/internal/webui"
)

// registerRoutes wires every Route42 endpoint onto the ServeMux. /v1/*
// mirror /api/* so any OpenAI SDK pointed at the gateway works unchanged.
Expand All @@ -22,13 +26,21 @@ func (s *Server) registerRoutes(mux *http.ServeMux) {
// Routing recommendation (no execution).
mux.HandleFunc("POST /api/recommend", s.handleRecommend)

// Usage stats.
// Usage stats and the interaction log.
mux.HandleFunc("GET /api/stats", s.handleStats)
mux.HandleFunc("GET /api/interactions", s.handleInteractions)

// Models list (catalog + local discovery + availability).
mux.HandleFunc("GET /api/models", s.handleModels)
mux.HandleFunc("GET /v1/models", s.handleModels)

// Health (always public).
mux.HandleFunc("GET /health", s.handleHealth)

// Embedded web console (optional; server.ui). Registered on the GET /
// catch-all so every API route above still wins. The gateway is fully
// usable without it via the CLI and the HTTP API.
if s.cfg.Server.UI {
mux.Handle("GET /", webui.Handler())
}
}
6 changes: 4 additions & 2 deletions internal/api/server.go
Original file line number Diff line number Diff line change
Expand Up @@ -184,14 +184,16 @@ func (s *Server) Handler() http.Handler {
}

// withAuth applies the optional static-token auth to /api and /v1 routes
// when server.api_token is set. /health is always public.
// when server.api_token is set. /health and the static web console assets
// stay public — the console holds no data of its own and calls the guarded
// /api routes with the token the user enters.
func (s *Server) withAuth(h http.Handler) http.Handler {
token := s.cfg.Server.APIToken
if token == "" {
return h
}
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if strings.HasPrefix(r.URL.Path, "/health") {
if !strings.HasPrefix(r.URL.Path, "/api/") && !strings.HasPrefix(r.URL.Path, "/v1/") {
h.ServeHTTP(w, r)
return
}
Expand Down
126 changes: 126 additions & 0 deletions internal/api/webui_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,126 @@
package api

import (
"encoding/json"
"net/http"
"net/http/httptest"
"path/filepath"
"strings"
"testing"

"github.com/krugis/route42app/internal/config"
"github.com/krugis/route42app/internal/store"
)

// newUITestServer builds a Server on a temp store with the given config
// mutations applied to the defaults.
func newUITestServer(t *testing.T, mutate func(*config.Config)) *httptest.Server {
t.Helper()
cfg := config.Default()
cfg.DB.Path = filepath.Join(t.TempDir(), "test.db")
if mutate != nil {
mutate(cfg)
}
st, err := store.Open(cfg.DB.Path)
if err != nil {
t.Fatalf("open store: %v", err)
}
t.Cleanup(func() { st.Close() })
if err := st.EnsurePrefs(cfg.Prefs); err != nil {
t.Fatalf("seed prefs: %v", err)
}
srv, err := New(cfg, st, Options{})
if err != nil {
t.Fatalf("build server: %v", err)
}
ts := httptest.NewServer(srv.Handler())
t.Cleanup(ts.Close)
return ts
}

func get(t *testing.T, url, token string) *http.Response {
t.Helper()
req, err := http.NewRequest(http.MethodGet, url, nil)
if err != nil {
t.Fatalf("new request: %v", err)
}
if token != "" {
req.Header.Set("Authorization", "Bearer "+token)
}
res, err := http.DefaultClient.Do(req)
if err != nil {
t.Fatalf("GET %s: %v", url, err)
}
t.Cleanup(func() { res.Body.Close() })
return res
}

func TestWebUIServedAtRoot(t *testing.T) {
ts := newUITestServer(t, nil) // UI defaults to on

res := get(t, ts.URL+"/", "")
if res.StatusCode != http.StatusOK {
t.Fatalf("GET /: status = %d, want 200", res.StatusCode)
}
if ct := res.Header.Get("Content-Type"); !strings.Contains(ct, "text/html") {
t.Fatalf("GET /: content-type = %q, want text/html", ct)
}

for _, asset := range []string{"/app.js", "/style.css", "/logo.svg"} {
if res := get(t, ts.URL+asset, ""); res.StatusCode != http.StatusOK {
t.Errorf("GET %s: status = %d, want 200", asset, res.StatusCode)
}
}
}

func TestWebUIDisabled(t *testing.T) {
ts := newUITestServer(t, func(c *config.Config) { c.Server.UI = false })

if res := get(t, ts.URL+"/", ""); res.StatusCode != http.StatusNotFound {
t.Fatalf("GET / with ui disabled: status = %d, want 404", res.StatusCode)
}
// The API is unaffected.
if res := get(t, ts.URL+"/health", ""); res.StatusCode != http.StatusOK {
t.Fatalf("GET /health with ui disabled: status = %d, want 200", res.StatusCode)
}
if res := get(t, ts.URL+"/api/prefs", ""); res.StatusCode != http.StatusOK {
t.Fatalf("GET /api/prefs with ui disabled: status = %d, want 200", res.StatusCode)
}
}

func TestWebUIAuthScope(t *testing.T) {
ts := newUITestServer(t, func(c *config.Config) { c.Server.APIToken = "secret" })

// Static console and health stay public.
if res := get(t, ts.URL+"/", ""); res.StatusCode != http.StatusOK {
t.Fatalf("GET / with auth on: status = %d, want 200", res.StatusCode)
}
if res := get(t, ts.URL+"/health", ""); res.StatusCode != http.StatusOK {
t.Fatalf("GET /health with auth on: status = %d, want 200", res.StatusCode)
}
// API routes still require the token.
if res := get(t, ts.URL+"/api/prefs", ""); res.StatusCode != http.StatusUnauthorized {
t.Fatalf("GET /api/prefs without token: status = %d, want 401", res.StatusCode)
}
if res := get(t, ts.URL+"/api/prefs", "secret"); res.StatusCode != http.StatusOK {
t.Fatalf("GET /api/prefs with token: status = %d, want 200", res.StatusCode)
}
}

func TestInteractionsEndpoint(t *testing.T) {
ts := newUITestServer(t, nil)

res := get(t, ts.URL+"/api/interactions?limit=10", "")
if res.StatusCode != http.StatusOK {
t.Fatalf("GET /api/interactions: status = %d, want 200", res.StatusCode)
}
var body struct {
Interactions []store.Interaction `json:"interactions"`
}
if err := json.NewDecoder(res.Body).Decode(&body); err != nil {
t.Fatalf("decode interactions: %v", err)
}
if body.Interactions == nil {
t.Fatal("interactions should be an empty array, not null")
}
}
32 changes: 22 additions & 10 deletions internal/config/config.go
Original file line number Diff line number Diff line change
Expand Up @@ -29,6 +29,9 @@ type Server struct {
// APIToken, when set, requires "Authorization: Bearer <token>" on all
// /api endpoints. Empty (default) means no auth: local single-user use.
APIToken string `yaml:"api_token"`
// UI serves the embedded web console at "/" (default true). The gateway
// works identically without it via the CLI and the HTTP API.
UI bool `yaml:"ui"`
}

// Analyzer selects and configures the prompt analyzer.
Expand Down Expand Up @@ -75,15 +78,17 @@ type Provider struct {
// After that, preferences live in the database (editable via /api/prefs).
type Prefs struct {
// Priority is one of balanced, fast, cheap, accurate.
Priority string `yaml:"priority"`
MaxCostCents float64 `yaml:"max_cost_cents"`
LatencyToleranceMs int `yaml:"latency_tolerance_ms"`
OnlyFree bool `yaml:"only_free"`
OnlyLocal bool `yaml:"only_local"`
MaxResponseTokens int `yaml:"max_response_tokens"`
DefaultModel string `yaml:"default_model"`
FallbackDepth int `yaml:"fallback_depth"`
DisallowedModels []string `yaml:"disallowed_models"`
// The json tags define the /api/prefs and `prefs set --json` wire
// format; they intentionally mirror the yaml names.
Priority string `yaml:"priority" json:"priority"`
MaxCostCents float64 `yaml:"max_cost_cents" json:"max_cost_cents"`
LatencyToleranceMs int `yaml:"latency_tolerance_ms" json:"latency_tolerance_ms"`
OnlyFree bool `yaml:"only_free" json:"only_free"`
OnlyLocal bool `yaml:"only_local" json:"only_local"`
MaxResponseTokens int `yaml:"max_response_tokens" json:"max_response_tokens"`
DefaultModel string `yaml:"default_model" json:"default_model"`
FallbackDepth int `yaml:"fallback_depth" json:"fallback_depth"`
DisallowedModels []string `yaml:"disallowed_models" json:"disallowed_models"`
}

// Analyzer modes.
Expand All @@ -100,7 +105,7 @@ var validPriorities = []string{"balanced", "fast", "cheap", "accurate"}
// with only a local Ollama installation.
func Default() *Config {
return &Config{
Server: Server{Port: 4242},
Server: Server{Port: 4242, UI: true},
Analyzer: Analyzer{
Mode: ModeHeuristic,
LLM: AnalyzerLLM{Model: "qwen2.5:0.5b", TimeoutMs: 1500, HybridWeight: 0.5},
Expand Down Expand Up @@ -171,6 +176,13 @@ func (c *Config) applyEnv() error {
if v := os.Getenv("ROUTE42_API_TOKEN"); v != "" {
c.Server.APIToken = v
}
if v := os.Getenv("ROUTE42_UI"); v != "" {
on, err := strconv.ParseBool(v)
if err != nil {
return fmt.Errorf("ROUTE42_UI: %q is not a boolean", v)
}
c.Server.UI = on
}
if v := os.Getenv("ROUTE42_ANALYZER_MODE"); v != "" {
c.Analyzer.Mode = v
}
Expand Down
Loading
Loading