LLM Router
A unified gateway that aggregates multiple LLM providers behind a single endpoint. Clients use their preferred protocol (OpenAI or Anthropic Messages) and the gateway handles routing, protocol translation, and load balancing.
Features
- Multi-Provider: OpenAI, Claude, Gemini, Ollama, Mistral, ZAi — configure once, route by model name
- Protocol Translation: Clients speak OpenAI or Anthropic Messages; the gateway translates as needed, and also exposes a full Ollama-compatible API under
/ollama - Weight-Based Load Balancing: Distribute load across providers with configurable weights
- Smart Routing: Request the
automodel and a Scriptling script picks the best provider/model based on tags, load, and request content - MCP Aggregator: Combine tools, resources, and prompts from multiple remote MCP servers with namespace isolation, OAuth support, and per-tool visibility / allow / deny filtering
- Chat UI: Built-in interface at
/chatwith personas, conversation history, slash commands,@prompt/@resourcemenus, live MCP tool calling, markdown rendering, andskill://resources auto-surfaced to the model - Personas: System prompts, default models, and generation parameters — defined in the config file or managed through the admin UI
- Admin UI: Optional web interface at
/adminto manage providers, personas, and MCP servers (create / edit / delete, enable / disable), browse models (with rescan) and tools, and test-run MCP tools directly from the browser - Responses API: OpenAI-compatible responses storage (emulated for all providers)
- Conversations API: n8n-compatible conversation management
- Optional Auth: Bearer token protection for all endpoints
Installation
Homebrew (macOS & Linux)
brew tap paularlott/tap
brew install llmrouter
Download from GitHub Releases
Download the latest release for your platform from github.com/paularlott/llmrouter/releases:
| Platform | Architecture | Download |
|---|---|---|
| macOS | Intel (AMD64) | llmrouter-darwin-amd64.zip |
| macOS | Apple Silicon (ARM64) | llmrouter-darwin-arm64.zip |
| Linux | AMD64 | llmrouter-linux-amd64.zip |
| Linux | ARM64 | llmrouter-linux-arm64.zip |
| Windows | AMD64 | llmrouter-windows-amd64.zip |
| Windows | ARM64 | llmrouter-windows-arm64.zip |
Extract the archive and place the binary in your PATH.
Build from Source
git clone https://github.com/paularlott/llmrouter.git
cd llmrouter
go build -o llmrouter .
./llmrouter server
./llmrouter -config /path/to/config.toml server
Configuration
[server]
host = "0.0.0.0"
port = 12345
token = "your-secret-token" # Optional bearer token
admin_password = "admin123" # Optional: enables admin UI at /admin
storage_path = "./data" # Omit for memory-only storage (under [server])
[logging]
level = "info" # trace | debug | info | warn | error
format = "console" # console | json
[responses]
ttl_days = 30
[conversations]
ttl_days = 30
[[providers]]
name = "openai"
provider = "openai" # openai | claude | gemini | ollama | mistral | zai
token = "sk-..."
enabled = true
weight = 1.0 # 0.0-2.0, default 1.0; higher = preferred
tags = ["capable", "expensive"] # optional tags for smart routing
[providers.model_tags] # optional per-model tags
"gpt-4o" = ["capable", "expensive"]
"gpt-4o-mini" = ["fast", "cheap"]
[providers.model_aliases] # optional alias -> real model name, resolved per-provider
"gpt4" = "gpt-4o"
"gpt4-mini" = "gpt-4o-mini"
[[providers]]
name = "anthropic"
provider = "claude"
token = "sk-ant-..."
enabled = true
model_allowlist = ["claude-opus-4-5", "claude-sonnet-4-5"] # Required for Claude
tags = ["capable"]
[providers.model_aliases] # same alias "fast", different real model on this provider
"fast" = "claude-sonnet-4-5"
[providers.model_tags]
"claude-opus-4-5" = ["capable", "expensive"]
"claude-sonnet-4-5" = ["capable", "fast"]
[[providers]]
name = "google"
provider = "gemini"
token = "your-google-key"
enabled = true
model_allowlist = ["gemini-2.5-flash-lite"] # Optional: restrict to specific models
models = ["gemini-2.5-flash-lite"] # Optional: override model list entirely (still health-checks the API)
[[providers]]
name = "local"
provider = "ollama"
base_url = "http://localhost:11434/v1"
enabled = true
routes_dir = "./routers" # Optional: directory of smart-router <model>.toml/.py pairs
[mcp]
[[mcp.remote_servers]]
namespace = "tools"
url = "https://tools.example.com/mcp"
token = "secret"
tool_visibility = "native" # native | discoverable
tool_allowlist = ["search", "query"] # Optional: only these tools are enabled
tool_denylist = ["delete"] # Optional: these tools are disabled
Provider Types
| Provider | Default Base URL | Embeddings | Model Discovery |
|---|---|---|---|
openai |
https://api.openai.com/v1 | Yes | Auto |
claude |
https://api.anthropic.com/v1 | No | Must specify |
gemini |
https://generativelanguage.googleapis.com/... | Yes | Auto |
ollama |
https://ollama.com/v1/ | Yes | Auto |
mistral |
https://api.mistral.ai/v1 | Yes | Auto |
zai |
https://api.z.ai/api/paas/v4/ | Yes | Auto |
base_url is optional — each provider has a built-in default. Set it to override (e.g. local LM Studio).
model_allowlist restricts the provider to only the listed models. For Claude this is required (no discovery API). For other providers it is optional.
model_denylist excludes specific models from auto-discovery. Ignored when model_allowlist is set.
models overrides the model list entirely — the provider's /models API is still called (for health checks) but its response is discarded and the configured list is used instead. Useful for providers that return no models or an incomplete list.
model_aliases maps short or friendly names to real model IDs. Aliases appear in /v1/models alongside real models and support the same weight-based load balancing and round-robin behaviour. When multiple providers define the same alias, each provider maps it to its own real model — requests via that alias are distributed across all of them, and each provider receives its own real model name.
Smart Routing
Smart routing lets a client request a virtual model name that triggers a Scriptling script, which picks the real provider and model. You define routers as files in a folder: each <model>.toml (plus an optional <model>.py companion) becomes a router triggered by the model name <model> (the filename stem).
routes_dir = "./routers" # directory of <model>.toml/.py pairs
For example, ./routers/auto.toml + ./routers/auto.py make clients that request the model auto run auto.py:
# routers/auto.toml — the stem ("auto") is the model name clients send
enabled = true
default_model = "mistralai/ministral-3-3b" # used when the script returns nothing or fails
[vars] # optional key-value pairs exposed to the script as the `vars` library
openai_key = "sk-..."
# routers/auto.py
import router
router.set_model("mistralai/mistral-small-latest")
A .toml without a .py companion is a pure alias — every request for that name goes to default_model. A .py without a .toml is just an importable library (shared by all routers in the folder). Router names must be a single path segment (no /); a name that collides with a real provider model is skipped. Every router model is injected into /v1/models so clients can discover it.
Shared libraries placed in the routers folder are importable by every router. Add more search directories with the global, repeatable --libpath flag (libpath / lib_paths in config), shared with the scriptling MCP tools. The folder and every libpath dir are watched — script, config, or library changes are picked up within ~100 ms with no restart.
Provider and Model Tags
Tags are arbitrary strings assigned to providers and individual models. The routing script uses them to select the right provider/model for each request.
[[providers]]
name = "mistral"
provider = "mistral"
token = "..."
enabled = true
tags = ["fast", "cheap"] # provider-level tags
[providers.model_tags]
"mistralai/ministral-3-3b" = ["small", "fast", "cheap"]
"mistralai/mistral-small-latest" = ["small", "fast"]
The routing script can then query by tag:
import router
req = router.get_request()
# Route tool-heavy requests to a capable model
if router.is_chat_completion() and len(req["tools"]) > 0:
models = router.models_by_tag("capable")
else:
models = router.models_by_tag("cheap")
if models:
router.set_model(models[0])
Use router.model_tags(model_id) to narrow a candidate list by a secondary tag:
import router
# Start broad: all "capable" models
candidates = router.models_by_tag("capable")
# Narrow: prefer "super_fast" within that set
fast = [m for m in candidates if "super_fast" in router.model_tags(m)]
cheap = [m for m in candidates if "cheap" in router.model_tags(m)]
models = fast or cheap or candidates
if models:
router.set_model(models[0])
See docs/scriptling-router-library.md for the full script API reference.
Script Libraries
Every routing script has access to all Scriptling standard libraries (json, re, math, random, hashlib, base64, uuid, datetime, time, urllib, etc.) plus the following extended and Scriptling-specific libraries:
| Library | Description |
|---|---|
requests |
HTTP client |
secrets |
Cryptographically strong random numbers |
html.parser |
HTML/XHTML parser |
logging |
Logging to the router log |
yaml |
YAML parsing |
toml |
TOML parsing |
sys |
System parameters |
scriptling.ai |
AI/LLM client for OpenAI-compatible APIs |
scriptling.ai.agent |
Agentic AI loop with automatic tool execution |
scriptling.mcp |
MCP tool interaction |
scriptling.toon |
TOON encoding/decoding |
scriptling.similarity |
String matching and similarity utilities |
scriptling.net.resolve |
DNS resolution (IP, SRV, srv+http URLs) |
scriptling.template.html |
HTML template rendering |
scriptling.template.text |
Text template rendering |
scriptling.runtime |
Background tasks, KV store, sync primitives |
Filesystem access (os, pathlib, glob), subprocess execution, and wait_for are not available in routing scripts.
Script Variables
Use the [vars] table in a router's .toml to pass tokens or other config to the script without hard-coding them:
# routers/auto.toml
[vars]
openai_key = "sk-..."
import vars
import scriptling.ai as ai
client = ai.Client("", api_key=vars.openai_key) # attribute access
key = vars.get("openai_key") # or dynamic lookup
key = vars.get("missing", "") # with a default
All values are strings. vars is always available (even with no vars defined), so vars.get(name, default="") is always callable.
Weight-Based Load Balancing
When multiple providers serve the same model, the router selects using score = active_completions / weight. Lower score wins.
| Weight | Effect |
|---|---|
0.0 |
Last resort only |
1.0 |
Normal (default) |
2.0 |
Preferred — gets 2× the traffic share |
MCP Tool Visibility
| Mode | Behavior |
|---|---|
native |
Tools appear in tools/list, directly callable |
discoverable |
Hidden from list, searchable via tool_search only |
MCP Tool Filtering
Tools from remote MCP servers can be filtered using tool_allowlist or tool_denylist:
tool_allowlist: When defined, only the listed tools are enabled. All other tools are disabled.tool_denylist: When defined, all tools are enabled except those in the list.
Note: If both are defined, tool_allowlist takes precedence and tool_denylist is ignored.
[[mcp.remote_servers]]
namespace = "github"
url = "https://github.example.com/mcp"
token = "secret"
tool_visibility = "native"
tool_allowlist = ["search_repos", "get_issue", "create_issue"] # Only these 3 tools are enabled
[[mcp.remote_servers]]
namespace = "slack"
url = "https://slack.example.com/mcp"
token = "secret"
tool_visibility = "native"
tool_denylist = ["delete_message", "ban_user"] # All tools enabled except these 2
Scripting Tools
Define custom MCP tools using .toml (metadata) and .py (implementation) file pairs. Tools are loaded from a configurable directory and automatically reloaded when files change.
./llmrouter server --tools-dir ./tools --plugin-dir ./plugins --libpath ./libs
Directory structure:
tools/
├── calculator.toml
├── calculator.py
├── weather.toml
└── weather.py
Tool metadata (.toml):
description = "Calculate the sum of two numbers"
keywords = ["math", "add", "sum"]
[[parameters]]
name = "a"
type = "int"
description = "First number"
required = true
[[parameters]]
name = "b"
type = "int"
description = "Second number"
required = true
Tool implementation (.py):
import scriptling.mcp.tool as tool
a = tool.get_int("a")
b = tool.get_int("b")
result = a + b
tool.return_string(f"{a} + {b} = {result}")
Configuration:
[scripting]
tools_dir = "./tools" # Directory containing .toml/.py tool pairs
plugin_dirs = ["./plugins"] # Directories containing plugin executables
lib_paths = ["./libs"] # Additional directories for scriptling libraries
Tools can be marked as discoverable = true to hide them from tools/list and make them searchable via tool_search only.
Chat UI
When admin_password is set and a personas directory is configured, a built-in chat interface is available at /chat with conversation history, personas, slash commands, @prompt / @resource menus, MCP tool calling, and markdown rendering.
./llmrouter server --personas-dir ./personas --commands-dir ./commands --resources-dir ./resources
Skills: Resources with a skill:// URI prefix are automatically surfaced to the LLM. On every chat request, the router queries the MCP server for skill:// resources and appends their names and descriptions to the persona's system prompt. A virtual tool (lmchatkit__get_skill) is injected into the tool list — the model calls it to retrieve a skill's full instructions on demand. The tool routes to the standard MCP ReadResource API, so skills work from any source (files, remote servers, or custom providers). The tool is auto-approved (no user prompt) since it's a read-only context fetch. Skills are transient — the stored conversation is not modified; the augmentation is recomputed on each request.
Example skill resource directory structure:
resources/
└── skill/
├── golang.md → skill://golang.md
└── testing.md → skill://testing.md
MCP OAuth Authentication
For MCP servers that require OAuth authentication, configure the OAuth fields instead of token:
[[mcp.remote_servers]]
namespace = "my-oauth-service"
url = "https://api.example.com/mcp"
auth_type = "oauth"
oauth_client_id = "your-client-id"
oauth_token_url = "https://auth.example.com/token"
oauth_access_token = "current-access-token"
oauth_refresh_token = "refresh-token" # Optional: for token refresh
API Endpoints
All endpoints are available under both /v1/ and /ollama/ base paths. For example, models can be listed at either /v1/models or /ollama/v1/models.
Chat & Models
GET /v1/models
POST /v1/chat/completions # OpenAI format, streaming supported
POST /v1/messages # Anthropic Messages format
POST /v1/messages/count_tokens # Anthropic token counting (emulated)
POST /v1/embeddings
GET /health
OpenAI chat completion requests preserve provider-specific top-level fields when forwarding upstream, including fields produced by client-side extra_body options such as ZAi thinking-mode settings.
Ollama Compatible
The /ollama/ base path provides full Ollama API compatibility, allowing tools like VS Code (Copilot), LM Studio, and other Ollama clients to connect directly. Set the Ollama base URL to http://host:port/ollama.
GET /ollama/api/version # API version info
GET /ollama/api/tags # List models
GET /ollama/api/ps # List running models
POST /ollama/api/chat # Chat with messages (supports images)
POST /ollama/api/generate # Generate from prompt (supports images)
POST /ollama/api/embed # Embeddings (batch)
POST /ollama/api/embeddings # Embeddings (single)
POST /ollama/api/show # Model details
Images sent via Ollama's images field are automatically converted with the correct media type (JPEG, PNG, GIF, WebP) based on file signature detection.
Responses API
POST /v1/responses
GET /v1/responses/{id}
DELETE /v1/responses/{id}
GET /v1/responses
POST /v1/responses/{id}/cancel
POST /v1/responses/compact
Conversations API
POST /v1/conversations
GET /v1/conversations/{id}
POST /v1/conversations/{id}
DELETE /v1/conversations/{id}
GET /v1/conversations/{conversation_id}/items
POST /v1/conversations/{conversation_id}/items
GET /v1/conversations/{conversation_id}/items/{item_id}
DELETE /v1/conversations/{conversation_id}/items/{item_id}
MCP
POST /mcp # MCP protocol — aggregates tools from all configured remote servers
Admin UI
When server.admin_password is set, a web-based admin interface is available at /admin. From it you can manage providers, personas, and MCP servers (create / edit / delete, enable / disable), browse models and trigger a rescan, inspect tools / resources / prompts, and test-run MCP tools directly from the browser. Items created here are stored under storage_path and take effect live (config-file entries are shown read-only alongside them).
GET /admin # Admin UI (requires password login)
GET /admin/login # Login page
Authentication
When server.token is set, all endpoints except /health require:
Authorization: Bearer your-secret-token
CLI
./llmrouter server # Start server
./llmrouter -config custom.toml server # Custom config
./llmrouter server -port 8080 # Override port
./llmrouter server -token secret123 # Set bearer token
./llmrouter server --tools-dir ./tools # Load scriptling MCP tools
./llmrouter server --plugin-dir ./plugins # Load scriptling plugins
./llmrouter server --libpath ./libs # Add library directories
./llmrouter models # List available models
./llmrouter ask gpt-4o "What is 2+2?" # Ask a model a question
./llmrouter tool calculator '{"op":"add","a":1,"b":2}' # Execute MCP tool
Development
Building
task # Build for current platform
task build-all # Build all platforms with ZIP archives
task release # Build, create GitHub release, and update Homebrew formula
make # Alternative via Makefile
Supported platforms: Linux, macOS, Windows × AMD64/ARM64.
Architecture
Client (OpenAI or Messages protocol)
│
▼
LLM Router
├── Protocol Layer (OpenAI ↔ Messages translation via mcp/ai)
├── Routing Layer (model → provider, weight-based load balancing)
├── Smart Routing (Scriptling script, tag-based selection, hot-reload)
├── Provider Layer (openai | claude | gemini | ollama | mistral | zai)
├── MCP Aggregator (remote MCP servers with namespace + visibility control)
├── Scripting Tools (local .toml/.py tool pairs with file watching)
├── Responses API (emulated for all providers, in-memory index only)
└── Conversations (n8n-compatible, stored in SnapshotKV)
Protocol translation is handled by the mcp/ai package — the gateway always works in OpenAI format internally and translates at the edges.
License
See LICENSE.txt for details.
No comments yet
Be the first to share your take.