Bespoke OpenRouter-only complexity router for OpenCode.
Classifies chat completion requests into four levels (easy, medium, hard, critical) and routes to configured OpenRouter models. Uses evidence-based complexity routing with active task frame extraction to prevent context contamination.
# Clone and prepare
git clone <repo>
cd dispatch
# Create and edit the env file with your OpenRouter API key
cp .env.example .env
# Use cp (not mv) so .env.example stays in the working tree
$EDITOR .env
# Build and run (config auto-generates on first start)
docker compose up -d --buildThe API key is loaded from .env via Docker Compose env_file. There is no host-export step. .env is gitignored — your key stays local.
The router automatically generates /config/router.yaml, /config/DISPATCH.md, and /config/exemplars.yaml on first run. Config changes are auto-reloaded without restart.
API key hot reload: .env is mounted into the container at /dispatch.env (read-only, outside /config). Edit your host .env to change the API key — Dispatch hot-reloads it within the poll interval (default 3 s). No container restart needed for key rotation. The previous working key stays active if the new file is invalid. Docker process env never changes; Dispatch reads the mounted file directly. /config contains only generated config/docs, never secrets.
Warnings:
- The router listens on plain HTTP (
:18087). For production, place it behind a TLS-terminating reverse proxy. - Never put your API key in the config file. Always use the
OPENROUTER_API_KEYenvironment variable via.env. - Prompt text is not logged by default (
log_prompts: false). Only enable it if you understand the privacy impact. - Trace mode (
trace_requests: false) is metadata-only — it never logs prompt content. - Optional security hardening: To run as a non-root user, add
user: "65532:65532"todocker-compose.ymland runchown 65532:65532 ./config.
Configure OpenCode to use Dispatch:
| Setting | Value |
|---|---|
| Base URL | http://localhost:18087/v1 |
| API Key | placeholder (key is server-side) |
| Model | dispatch/auto |
OpenCode may display only dispatch/auto — the final model selection happens inside the router and is not part of the model list. The selected model is visible via:
- Response header
X-Dispatch-Model - Structured log output (stderr)
GET /debug/statsendpointGET /debug/request?id=<request_id>metadata lookup
If your client can send X-Dispatch-Session-Id and X-Dispatch-Task-Id headers, Dispatch will use them for task-scoped session escalation. If not, Dispatch derives task keys from the active task frame. OpenCode does not need to send custom headers — routing works without them.
Dispatch does not classify the full conversation. Each request is classified using only the active task frame:
- New standalone user turn → frame starts at the latest user message. Old hard debugging context, old stack traces, old tool outputs are excluded.
- Continuation detected (e.g., "same error still happens", "try again", "tests still failing") → frame extends back to the original task boundary.
- No new user turn after tool results → frame includes the last user instruction and all tool results after it.
This prevents a long hard debugging session from contaminating an unrelated easy question in the same chat.
- Model alias:
dispatch/easy,dispatch/medium,dispatch/hard,dispatch/critical,dispatch/auto - Header:
X-Dispatch-Level: easy|medium|hard|critical - Precedence: Header > Model alias > Auto classification
| Endpoint | Method | Description |
|---|---|---|
/v1/chat/completions |
POST | Route and forward to OpenRouter |
/debug/route |
POST | Classify only (no upstream call), returns level/scores/reasons/frame |
/debug/stats |
GET | In-memory stats: request counts, by-level, by-model, by-status, avg duration, uptime, reload count |
/debug/request?id=<request_id> |
GET | Metadata lookup for a past request (no prompt text, bounded ring buffer) |
/debug/feedback |
POST | Submit feedback (disabled by default) |
/health |
GET | Health check (liveness). Returns 200 with status: "ok" when healthy, or 200 with status: "degraded" when config reload is failing. Includes reload error details. |
/readyz |
GET | Strict readiness. Returns 200 when fully healthy, 503 when config reload is failing. |
/version |
GET | Build info |
curl -s -X POST http://localhost:18087/debug/route \
-H "Content-Type: application/json" \
-d '{"model":"dispatch/auto","messages":[{"role":"user","content":"write a function to sort an array"}]}' | jqResponse includes level, model, scores, reasons, analysis, frame, request_id.
curl -s http://localhost:18087/debug/stats | jqcurl -s "http://localhost:18087/debug/request?id=<request_id>" | jqReturns metadata only — no prompt text, no tool output.
Enable in config:
debug:
feedback_enabled: true
feedback_path: "/config/feedback.jsonl"Then:
curl -s -X POST http://localhost:18087/debug/feedback \
-H "Content-Type: application/json" \
-d '{"request_id":"abc123","expected_level":"hard","note":"should have been hard"}'Edit /config/router.yaml and the router picks up changes automatically (default: every 3 seconds). No restart needed.
- If reload succeeds, new requests use the new config. In-flight requests continue with the old config snapshot.
- If reload fails (bad YAML, invalid config), the old config stays active and the error is logged.
- API key is not reloaded from config — it stays env-driven.
--check-configremains available for manual validation.- Reload stats are visible at
/debug/stats(config_reload_count,last_config_reload_unix).
When a reload fails, Dispatch keeps the last valid config in memory and marks the runtime as degraded. The container stays alive so debug endpoints remain available.
By default (strict degraded mode), /v1/chat/completions returns a local 503 until the config is fixed. This ensures users see an immediate error in OpenCode when router.yaml breaks, rather than silently continuing with an old config.
When degraded:
/healthreturns 200 with"status": "degraded"and reload error details./readyzreturns 503 ("status": "not_ready")./debug/statsshows explicit reload failure state (active_config_state,config_reload_failure_count,last_config_reload_error_truncated,degraded_blocked_total, etc.).- All responses include
X-Dispatch-Config-State: degradedandX-Dispatch-Config-Reload-Error: see /health or /debug/stats. - Degraded state clears automatically on next successful reload.
- Blocked requests do not count as upstream errors. They count as local config/degraded errors.
Opt-in lenient mode: To serve the last valid config during reload failures (old behavior), set:
config_reload:
fail_requests_when_degraded: falseIn lenient mode, requests forward as normal, the degraded headers are set, and health/readyz/stats still reflect the degraded state.
Why the container does not exit: Restart loops against a broken config make diagnosis harder (debug endpoints are gone). Startup with an invalid config still fails fast — this only applies to hot-reload failures.
Diagnose reload issues:
curl -s http://localhost:18087/health | jq
curl -s http://localhost:18087/readyz | jq
curl -s http://localhost:18087/debug/stats | jq '{active_config_state, config_reload_failure_count, last_config_reload_error_truncated}'Validate config before editing:
dispatch --check-config --config /config/router.yamlNo prompt/request mutation: Reload diagnostics live only in logs, /health, /readyz, /debug/stats, and optional Dispatch response headers. They never affect the upstream request body, model selection, provider config, or OpenRouter cache behavior.
OpenCode → Dispatch (classify active task frame) → OpenRouter
↓
POST /v1/chat/completions
POST /debug/route
GET /debug/stats
GET /debug/request?id=...
POST /debug/feedback
GET /health
GET /version
| Level | Default Model |
|---|---|
| easy | deepseek/deepseek-v4-flash |
| medium | deepseek/deepseek-v4-flash |
| hard | deepseek/deepseek-v4-pro |
| critical | z-ai/glm-5.2 |
Edit /config/router.yaml. See /config/DISPATCH.md for full documentation.
openrouter:
base_url: "https://openrouter.ai/api/v1"
api_key_env: "OPENROUTER_API_KEY"
http_referer: "https://github.com/OpusNano/dispatch"
site_title: "Dispatch"
server:
listen: ":18087"
max_body_size: 26214400 # 25 MiB
model_profiles:
deepseek_flash:
id: "deepseek/deepseek-v4-flash"
provider:
data_collection: "deny"
deepseek_pro:
id: "deepseek/deepseek-v4-pro"
provider:
data_collection: "deny"
glm_52:
id: "z-ai/glm-5.2"
provider:
data_collection: "deny"
levels:
easy:
use: deepseek_flash
medium:
use: deepseek_flash
hard:
use: deepseek_pro
critical:
use: glm_52
debug:
log_decisions: true
log_prompts: false
trace_requests: false
request_index_enabled: true
request_index_size: 500
feedback_enabled: false
config_reload:
enabled: true
poll_interval_seconds: 3
fail_requests_when_degraded: true# Tests
go test ./...
# Lint
go vet ./...
# Build
go build -o dispatch ./cmd/dispatch
# Check config
./dispatch --check-config --config /path/to/router.yaml
# Run
OPENROUTER_API_KEY=sk-or-... ./dispatch --config /path/to/router.yaml| Header | Description |
|---|---|
X-Dispatch-Request-Id |
Unique request ID for log correlation |
X-Dispatch-Level |
Selected level |
X-Dispatch-Model |
Routed model ID |
X-Dispatch-Score-Total |
Composite score |
X-Dispatch-Score-Complexity |
Complexity sub-score |
X-Dispatch-Score-Risk |
Risk sub-score |
X-Dispatch-Score-Agent-Pressure |
Agent pressure sub-score |
X-Dispatch-Reasons |
Classification reasons (truncated) |
X-Dispatch-Upstream-Status |
Upstream HTTP status (when >= 400) |
X-Dispatch-Upstream-Error-Code |
Upstream error code from OpenRouter JSON |
X-Dispatch-Upstream-Error-Type |
Typed error code (e.g. rate_limit_exceeded) |
X-Dispatch-Upstream-Provider |
Provider name from error metadata |
X-Dispatch-Upstream-Provider-Code |
Upstream provider error code |
X-Dispatch-Upstream-Retry-After |
Retry-After value from upstream |
X-Dispatch-Upstream-Retryable |
Heuristic: true/false/unknown |
- API key from env var only, never in config files.
- Prompts not logged by default. Do not enable
log_promptsunless you accept the privacy risk. - Trace mode is metadata-only — logs message structure, not content.
- Request metadata index stores hashes and metadata, never prompt text.
- Request body size capped.
- Streaming responses not buffered.
- Client headers not forwarded upstream.
- Request ID in response header and logs for correlation.
- Messages never modified: The upstream request forwarded to OpenRouter preserves the original
messages,tools,response_format, and all unknown fields. Dispatch only changesmodeland configuredproviderfields. Debug information is never injected into message content.
Dispatch classifies requests using the active task frame for routing decisions only. The upstream request forwarded to OpenRouter is a byte-preserving modification of the original:
| Field | Modified? | Details |
|---|---|---|
model |
Yes | Replaced with the selected level's model ID |
provider |
Yes | Merged with level config (order, only, ignore, data_collection, allow_fallbacks) |
messages |
Never | All roles, content, tool_calls, tool_call_id, content arrays preserved |
tools |
Never | All tool definitions preserved |
tool_choice |
Never | Preserved as-is |
response_format |
Never | Preserved as-is |
stream |
Never | Preserved as-is |
temperature, max_tokens, top_p, etc |
Never | All unknown fields preserved |
X-Dispatch-* headers, request_id, debug text |
Never injected | Debug info stays in response headers, logs, and /debug endpoints |
# Start Dispatch locally, then:
./scripts/smoke.sh
# Or against a different host:
./scripts/smoke.sh http://localhost:18087Change server.listen in router.yaml, update the Docker port mapping (-p), and restart.
dispatch: cannot write /config/router.yaml: permission denied
The ./config directory needs write access. If you're using non-root mode (user: "65532:65532"), fix with:
chown 65532:65532 ./configdispatch: OPENROUTER_API_KEY environment variable not set
Set it via .env file or -e OPENROUTER_API_KEY=sk-or-....
If api_key_file is configured (default /dispatch.env), Dispatch also
reads the key from that file at startup. File value wins when valid.
If both env var and file are missing/empty, Dispatch exits.
With default config, .env is mounted at /dispatch.env (read-only, outside
/config). Edit the host .env file and Dispatch picks up the new key within
3 seconds. No container restart needed.
If the file is deleted, unreadable, or contains an empty key, Dispatch keeps the previous working key and logs a warning. Docker's process env never changes — Dispatch reads the mounted file directly.
/config is for generated config/docs only — never place secrets there.
If an older experimental run created config/.env, delete it: rm -f config/.env.
Verify reload success via /debug/stats:
curl -s http://localhost:18087/debug/stats | jq '{api_key_present, api_key_prefix_valid, api_key_length, api_key_reload_count, last_api_key_reload_unix}'api_key_reload_countincrements on each successful reload.last_api_key_reload_unixis the timestamp of the last reload.- The actual key value is never exposed in stats, logs, or headers.
The OpenRouter API key is invalid, expired, or has no credits. Check your key at https://openrouter.ai/keys.
If the error body says "Missing Authentication header", it means Dispatch did not send the Authorization header upstream. Verify:
-
Check container env (from host):
docker inspect dispatch --format '{{range .Config.Env}}{{println .}}{{end}}' | grep -q '^OPENROUTER_API_KEY=' && echo "set" || echo "missing"
Scratch containers do not have
printenv. Usedocker inspect, notdocker exec printenv. -
Verify router.yaml matches:
openrouter.api_key_envmust be"OPENROUTER_API_KEY"(the env var name, not the key value). The generated config has this by default. -
Check docker-compose.yml: the
dispatchservice should haveenv_file: .envonly. If there is an explicitenvironment: OPENROUTER_API_KEY: ${OPENROUTER_API_KEY}line, remove it. The${VAR}substitution resolves from the host and can override the.envfile with an empty string. -
Check startup log: Dispatch logs
api_key_present: true,api_key_prefix_valid: true, andapi_key_lengthon successful startup. The key itself is never logged. -
Check /debug/stats:
api_key_presentfield shows whether Dispatch has an API key loaded. -
If env exists and error persists: this is a Dispatch bug/regression — report with the startup log output.
If the error is "User not found." or "Invalid API key" instead: auth IS working. The Authorization header was sent, but the API key in .env is invalid or expired. Check your OpenRouter dashboard.
Do not use docker exec printenv dispatch — scratch containers have no shell.
OpenRouter rate limiting. Reduce request volume or upgrade your plan.
OpenRouter requires both http_referer and site_title to be non-empty for app attribution. Edit /config/router.yaml:
openrouter:
http_referer: "https://github.com/OpusNano/dispatch"
site_title: "Dispatch"Changes auto-reload in 3 seconds. Only new requests are affected. Run dispatch --check-config to see warnings if either field is empty.
Dispatch does not retry internally and does not switch providers internally. OpenRouter owns provider fallback behavior. Dispatch passes OpenRouter errors through unchanged so OpenCode's retry logic can work.
If you see errors like Provider returned error or rate-limited upstream:
-
Check provider config in
/config/router.yaml:provider.order: []lets OpenRouter choose providers freely (most reliable)provider.order: ["baidu/fp8"]withallow_fallbacks: true— specific preference, fallback to others if unavailableprovider.order: ["baidu/fp8"]withallow_fallbacks: false— strict pinning, no fallback, hard-fails on provider issues
-
Diagnose via debug endpoints:
# See aggregated error stats curl -s http://localhost:18087/debug/stats | jq '{upstream_errors, by_upstream_provider, by_upstream_error_type, upstream_429_total, upstream_502_total, upstream_503_total}' # Look up a specific request by its X-Dispatch-Request-Id curl -s "http://localhost:18087/debug/request?id=<request_id>" | jq '{status, upstream_provider, upstream_error_type, upstream_provider_code, upstream_retryable, upstream_raw_truncated}'
-
Common fixes:
- Set
allow_fallbacks: trueor use emptyprovider.order: []to let OpenRouter route around problematic providers - Remove strict provider pinning if the provider is frequently rate-limited or down
- Check
upstream_rate_limits_totalin stats to see if rate limits are the pattern
- Set
-
Common OpenRouter errors Dispatch passes through:
| Status | Meaning | Retryable |
|---|---|---|
| 400 | Invalid request / content policy / context length | No |
| 401 | Invalid API key / authentication | No |
| 402 | Insufficient credits | No |
| 403 | Forbidden / guardrail / moderation | No |
| 408 | Timeout | Yes |
| 429 | Rate limited (check Retry-After header) | Yes |
| 502 | Provider unavailable / invalid response | Yes |
| 503 | No provider available / overloaded | Yes |
| 504 | Gateway timeout | Yes |
When OpenRouter returns Retry-After, Dispatch passes it through unchanged. OpenCode reads it and handles retry timing.
This is expected. The router selects the actual model internally per request. Check the X-Dispatch-Model response header or container logs to see which model was used.
If you save a bad config, the router keeps the old config active and logs the error. Check stderr logs for "config reload: validation failed, keeping old config".
By default, /v1/chat/completions responds with a local 503 until the config is fixed. The error response is OpenAI-compatible:
{
"error": {
"message": "Dispatch config reload failed; current router.yaml is invalid. Fix the config or check /health and /debug/stats.",
"code": 503,
"type": "dispatch_config_degraded"
}
}The reload failure is visible in:
/health— returns"status": "degraded"with error details./readyz— returns 503./debug/stats—active_config_state: "degraded_using_last_valid", failure counters, error text.- Response headers —
X-Dispatch-Config-State: degraded. - Container logs — rate-limited (first failure logged immediately, repeated identical failures logged every 5 minutes).
Common causes:
- YAML syntax error (indentation, unquoted colons, etc.)
- Duplicate pattern IDs
- Invalid regex in a pattern rule
- Threshold ordering violation
- Missing required fields
Fix: Edit /config/router.yaml to fix the issue. On next poll (default 3 seconds), Dispatch detects the change, reloads successfully, and clears the degraded state. No restart needed.
Run dispatch --check-config --config /config/router.yaml before or after editing to validate. Exits 0 if valid, 1 with details if invalid.
To allow requests to continue during reload failures (lenient mode), set:
config_reload:
fail_requests_when_degraded: falseThe container never exits on hot-reload failure so that /health, /readyz, and /debug/stats remain available for diagnosis. A start-up invalid config still fails fast.