earlvanze

sage-router

Local-first AI model routing for serious agents. One endpoint. Any provider. The router figures out the rest.

earlvanze 6 Updated 2w ago

Resources

20
GitHub

Install

npx skillscat add earlvanze/sage-router

Install via the SkillsCat registry.

SKILL.md

Sage Router

HTTP server on :8790 that routes chat requests to the optimal provider based on intent classification.

Endpoints

  • POST /v1/chat/completions — OpenAI-compatible; routes automatically
  • POST /v1/responses — OpenAI Responses API compatible; supports Codex custom provider traffic
  • POST /v1/messages — Anthropic Messages API compatible; translates to/from OpenAI format internally
  • GET /health — Provider status, model lists, routing debug

Any Anthropic-compatible tool (Cursor, Aider, Claude Code, Zed, Continue, OpenHands) can point at http://localhost:8790 as the API base URL. Both streaming and non-streaming are supported.

Active Providers

Providers are discovered from app-owned or harness config at startup. For
Umbrel and Docker installs, write provider config under the mounted app config
directory, usually /config/openclaw/openclaw.json. For local OpenClaw installs,
~/.openclaw/openclaw.json remains supported.

Rules:

  • skips the router's own sage-router provider entry to avoid recursion
  • resolves ${ENV_VAR} values for baseUrl and apiKey
  • includes openai-codex only when a token or imported app-owned auth JSON exists, unless an explicit gateway fallback is enabled
  • recognizes Google Gemini providers from generativelanguage.googleapis.com
  • auto-discovers Google models when the provider exists but models is empty in openclaw.json
  • normalizes anthropic or Anthropic-hosted anthropic-messages providers onto the local Dario proxy at localhost:3456
  • starts the Dario user service when Anthropic compatibility is needed and the service is not already running; in Docker, the image bundles @askalf/dario and autostarts dario proxy when credentials are mounted at /root/.dario
  • supports temporary provider suppression via SAGE_ROUTER_DISABLED_PROVIDERS=name1,name2

Multiple credentials per provider

A single provider may carry an ordered pool of credentials — multiple API keys
and/or multiple OAuth subscription paths (e.g. several ChatGPT/Codex accounts).
The router tries them in order and fails over to the next on auth/quota/transient
errors (401/403/429/5xx, rate-limit, quota, billing, overload). Failover
runs in both the non-streaming completion path and the streaming open path: a
rate-limited (429) key transparently yields to the next credential before any
bytes are committed to the client. Mid-stream failures (after the SSE response
has started) cannot be retried and fall back to the next provider in the route
chain as before.

Provider config entries accept, alongside the legacy single apiKey:

  • apiKeys: list of {label, key} API key credentials
  • oauthPaths: list of {label, accessToken, refreshToken?, expires?, profile?} OAuth subscription paths
  • credentialStrategy: how the pool is selected each request — failover
    (ordered, primary first; default), round-robin (rotate the starting key to
    spread load/quota), lru (least-recently-used first), or random

The legacy single apiKey is preserved as the default credential for backward
compatibility. ${ENV_VAR} references are resolved at request time. A global
default strategy can be set via SAGE_ROUTER_CREDENTIAL_STRATEGY; the per-key
cooldown window is SAGE_ROUTER_CREDENTIAL_COOLDOWN_SECONDS (default 60).

For openai-codex, existing multi-account OAuth profiles in
~/.openclaw/agents/main/agent/auth-profiles.json are folded into the pool
automatically, so ChatGPT subscription paths are usable with failover.

Dashboard (operator) configuration:

  • GET /setup/credentials — masked summary of every provider's credentials
  • POST /setup/credentials/add — add an API key or OAuth subscription path to a
    provider (creates the provider if it does not exist; never overwrites existing
    credentials)
  • POST /setup/credentials/remove — remove a credential by provider + label/slot
  • POST /setup/credentials/strategy — set a provider's credentialStrategy

The web dashboard exposes a Credentials card to add, list, and remove
credentials per provider, with primary targets Ollama (ollama/ollama-cloud),
OpenAI (openai-completions/openai-codex-responses), and Anthropic
(anthropic-messages, routed through Dario).

GET /health shows:

  • configured: all discovered providers
  • providers: reachable providers with model lists
  • disabled: providers suppressed by env

Multimodal input routing

Requests carrying multimodal inputs are detected via a deep payload scan (chat
content blocks, Responses API input items, tool calls/results, and data:
URIs) and set the corresponding requirement:

  • images (image_url / input_image / data:image/) -> vision
  • audio (input_audio / audio / data:audio/) -> audio
  • video (input_video / video / data:video/) -> video
  • documents/files -> document (and longContext)

Routing is strictly capability-based. model_capabilities reports vision,
audio, and video from each model's declared input modalities / flags, and
model_meets_requirements rejects models that lack the required modality:

  • text-only GLM models (glm-5, glm-5.2:cloud) are rejected for image requests
    (vision unsupported); image-capable GLM variants (e.g. glm-4v) are allowed
  • audio/video inputs route only to models declaring that input modality

auto and agentic profiles constrain allowProviders/allowModels/
frontierLargeOnly. When a multimodal request has no capable model under those
constraints, the router relaxes the profile allow-lists (keeping safety
deny-lists) and re-selects globally in both the forced-provider and auto-route
paths, so multimodal requests route to a capable model instead of failing.

GET /health exposes imageCapable, audioCapable, and videoCapable: the
exact models currently treated as capable of each modality (per provider, GLM
flagged). The dashboard Health card renders all three summaries.

Modality learning

On every successful completion the router records the modalities that a model
actually served (image, audio, video, document, text) into an
append-only ledger persisted at APP_MODEL_MODALITIES (env
SAGE_ROUTER_MODEL_MODALITIES, default
~/.openclaw/openclaw/model-modalities.json). Disk writes are throttled (at
most once per 5 s unless forced).

Hosted/CDN deployments can share the ledger across all router nodes through
Supabase by applying
supabase/migrations/20260626003000_model_modalities.sql and enabling
SAGE_ROUTER_MODEL_MODALITIES_SHARED_ENABLED=1 with
SAGE_ROUTER_SUPABASE_URL plus SAGE_ROUTER_SUPABASE_SERVICE_ROLE_KEY.
When SAGE_ROUTER_SUPABASE_MIRROR_ENABLED=1, shared modality learning is on by
default. Nodes merge the shared table into local memory periodically
(SAGE_ROUTER_MODEL_MODALITIES_SHARED_REFRESH_SECONDS, default 60) and mirror
new observations through the atomic sage_router_record_model_modalities RPC,
so one CDN/Tailnet backend can benefit from modalities learned by another.
The Cloudflare API Worker records the same response headers into that RPC with
ctx.waitUntil, which keeps edge requests fast while making CDN observations
durable in the shared ledger. Public Tailnet edge health exposes
modelModalities.sharedEnabled, and the Cloudflare origin gate requires it
before treating an origin as public-edge-ready.

Learned modalities feed back into model_capabilities as an augmentation: a
model is treated as supporting a modality if it declares it or it has served
that modality before, so routing improves as the router observes more traffic.
When a request needs a learned modality, score_provider_model adds a
learned_modality:* contribution so models with proven successful history are
preferred among otherwise-capable candidates.

Observability:

  • LAST_ROUTE_DEBUG['modalities'] and the X-Sage-Router-Modalities response
    header expose the modalities of the active request
  • GET /setup/model-modalities (operator) returns modelModalities plus the
    ledger path; the dashboard renders a "Learned Modalities" card
  • POST /setup/model-modalities/update and /reset let operators edit or clear
    learned modalities; the dashboard exposes per-model save/reset and reset-all

Routing Logic

The router does not perform mid-stream switching. Once a request is sent to a provider, the full response is returned or the attempt fails. If it fails, the next candidate in the chain is tried sequentially. There is no partial-output fallback or streaming handoff between providers.

Flow:

  • detect intent from the latest user message
  • estimate complexity from prompt length
  • score every reachable (provider, model) pair globally — not per-provider — from the discovered provider config
  • in local-first, operate as local-strict: reject centralized Internet API providers and only allow local/LAN/Tailnet endpoints plus approved decentralized providers such as Darkbloom, with Ollama :cloud models excluded
  • for GENERAL, blend static heuristics with persisted empirical latency stats by provider and model
  • rank candidates by API type, model-name hints, complexity, and measured latency
  • attempt the top SAGE_ROUTER_MAX_PROVIDER_ATTEMPTS candidates in order
  • sage-router provider (the router itself, model auto) is scored as a low-priority recursive fallback, never preferred
  • paid plans can request premium Fusion with model: "sage-router/fusion" or the server tool {"type":"sage-router:fusion"}; server-tool markers are handled before downstream provider routing and gated with the same plan checks

Intent scoring is generic, for example:

  • code and analysis strongly favor Anthropic/OpenAI-style reasoning models
  • general/realtime requests prefer fast direct providers first
  • general traffic learns from real successful request latency over time, with light exploration for cold providers/models
  • complex prompts boost larger reasoning models and penalize mini/haiku-class models

Intent is detected by keyword matching on the latest user message. Complexity is estimated by word count.

API

  • GET /health — JSON with reachable providers, configured providers, and disabled providers
  • POST /v1/chat/completions — OpenAI-compatible; routes automatically
  • POST /v1/responses — OpenAI Responses API compatible; translates to/from Chat Completions internally

Notes

  • openai-codex is kept as an optional bridge, not a required first hop.
  • Anthropic compatibility is provided through Dario, so anthropic can stay in openclaw.json while routing locally through dario.
  • The repo systemd unit is template-style and expects local machine values in ~/.config/sage-router/sage-router.env.
  • Empirical latency memory is persisted at ~/.cache/sage-router/latency-stats.json by default.
  • When the OpenClaw gateway model-set path is unhealthy, the helper falls back to running without provider/model overrides instead of failing hard.
  • If any provider starts misbehaving, suppress it with SAGE_ROUTER_DISABLED_PROVIDERS instead of editing the router.
  • For reliable Umbrel/OpenClaw/Remnic use, point clients at http://sage-router:8790/v1 on umbrel_main_network, set unauthenticated Ollama auto-pull patterns to empty, and keep quota-bound providers disabled until credentials are healthy.
  • GitHub workflows now include CI syntax checks and CodeQL analysis for Python + JavaScript.
  • See BRANCH_PROTECTION.md for the exact required-check setup on GitHub.

Install

Install the user service from the repo copy:

mkdir -p ~/.config/systemd/user ~/.config/sage-router
cp systemd/sage-router.service ~/.config/systemd/user/sage-router.service
cp systemd/sage-router.env.example ~/.config/sage-router/sage-router.env
# edit ~/.config/sage-router/sage-router.env for your machine
systemctl --user daemon-reload
systemctl --user enable --now sage-router.service

Notes:

  • the repo unit is now env-driven and does not hardcode your home path, Node version, or workspace location
  • set SAGE_ROUTER_HOME to the actual repo path on your machine
  • optionally set SAGE_ROUTER_PATH_PREFIX if your Python, Node, or Dario bins are not already on PATH

If an Anthropic provider is detected and Dario is not installed yet, install Dario first:

Service

systemctl --user status sage-router
systemctl --user restart sage-router
journalctl --user -u sage-router -f   # live logs

Docker production notes

  • Docker image includes Node, Python, Sage Router, and @askalf/dario.
  • Mount an app-owned config directory at /config and use the dashboard setup flow for provider config or Codex auth JSON imports.
  • Enable llama.cpp classifier sidecar with docker compose --profile classifier up -d and SAGE_ROUTER_INTENT_CLASSIFIER_ENABLED=1.
  • Production classifier flags: SAGE_ROUTER_INTENT_CLASSIFIER_PROVIDER=llamacpp, SAGE_ROUTER_INTENT_CLASSIFIER_BASE_URL=http://llamacpp-classifier:8080, SAGE_ROUTER_INTENT_CLASSIFIER_MODEL=classifier.

Router profiles

Sage Router supports named routing profiles in router-profiles.json next to router.py.

Request a profile with any of:

  • model: "sage-router/<profile>"
  • model: "<profile>"
  • top-level profile, routerProfile, or sageRouterProfile

Profile fields currently supported:

  • route: fast, balanced, best, local-first, realtime
  • thinking: low, medium, high
  • capability/quality flags: requiresQuality, requiresReasoning, requiresTools, frontierLargeOnly, frontierOrReasoningTools, suppressIntermediateToolText, qualitySensitive, reasoning, tools, preferTools, json, vision, document, longContext
  • constraints: allowProviders, denyProviders, allowModels, denyModels, minParamsB

Current profiles:

  • frontier: default high-quality frontier routing profile. Forces best/high, reasoning, quality-sensitive, suppresses tool-call narration, and blocks tiny/free filler models.
  • frontier-large: strict frontier-large-only routing.
  • fast-local: low-latency local-first routing.
  • coding-max: high-thinking code route with weak model exclusions.

Codex/OpenClaw /goal compatibility is automatic. Raw /goal ... messages and
Codex <codex_internal_context source="goal"> blocks are normalized into
plain persistent objective context, then routed with best/high, reasoning,
long-context, agentic requirements so providers do not treat /goal as an
ordinary unknown slash command.