- Python 98.7%
- Shell 1.3%
Consensus was unreachable from real Claude Code sessions: Claude Code never sends model=consensus, and the auto-trigger required model==default with no tools — live traffic always sends a raw model ID and always carries tools. The only 56 historical invocations were manual curl tests. Removing it: - server.py: run_consensus_panel, dispatch_panelist, create_synthesis_prompt, /v1/consensus endpoint, the auto-detect block and CONSENSUS_TRIGGERS - transform.py: anthropic_message_to_sse (only used to fake-stream the panel) - config.py: consensus load/validate, get_panelists, get_consensus_judge_keyword - tests: test_consensus.py, benchmark/, and the consensus/fake-stream tests - docs: BENCHMARKING.md and consensus sections in README/GETTING-STARTED/CLAUDE Tests: 56 passing (was 87; the 31 removed were all consensus/fake-stream). Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> |
||
|---|---|---|
| bin | ||
| src/claude_router | ||
| systemd | ||
| tests | ||
| .gitignore | ||
| CLAUDE.md | ||
| GETTING-STARTED.md | ||
| LICENSE | ||
| pyproject.toml | ||
| README.md | ||
| requirements.txt | ||
Claude Router
A self-hosted HTTP proxy that sits between Claude Code and upstream model providers. It receives Anthropic-format requests, resolves model keywords to a provider,model pair, converts the request to OpenAI format, forwards to the upstream, and converts the response back. Tracks per-request token usage and cost in SQLite.
Replaces CCR (Claude Code Router) v2.0.0 — which has a broken Router that silently falls back to the default model and ignores per-keyword routing. Claude Router is fully owned, transparent, and instrumented.
- Default port: 3458 (CCR continues on 3457 for legacy sessions)
- Listens on: 127.0.0.1 only (no network exposure)
- License: AGPL-3.0-or-later
- Project: dotfiles holds shared config, this repo holds code
Part 1 — User Guide
Quick start
# Verify the service is running
systemctl --user status claude-router.service
# Health check
curl http://127.0.0.1:3458/health
# → {"status":"ok"}
# Check current routing profile
curl http://127.0.0.1:3458/profile
# → {"profile":"full","available":["full","lite"]}
If not running:
systemctl --user enable --now claude-router.service
Configuration
Config lives at ~/.config/claude-router/config.json, which is a symlink to ~/dotfiles/claude-router/config.json. Never edit the symlink directly — edit the dotfiles copy and commit.
{
"server": {
"host": "127.0.0.1",
"port": 3458,
"api_timeout_ms": 900000
},
"providers": {
"opencode": {
"api_base_url": "https://opencode.ai/zen/go/v1/chat/completions",
"api_key": "${OC_GO_CC_API_KEY}"
},
"ollama": {
"api_base_url": "http://localhost:11434/v1/chat/completions",
"api_key": "ollama"
}
},
"router": { ... full profile ... },
"router_lite": { ... lite profile ... },
"projects": { ... per-project overrides ... },
"budget": { ... daily limits ... }
}
Routing profiles
Claude Router holds two parallel profiles in one config — router (full) and router_lite (cheaper). Only one is active at a time. Switching is instant, in-memory, no restart.
Switch profile
curl -X POST http://localhost:3458/switch/lite
# → {"profile":"lite","message":"Switched to lite routing"}
curl -X POST http://localhost:3458/switch/full
# → {"profile":"full","message":"Switched to full routing"}
curl http://localhost:3458/profile
# → {"profile":"full","available":["full","lite"]}
You can call these from inside a running Claude Code session with the Bash tool — the very next request uses the new profile.
Full vs Lite comparison
| Keyword | Full | Lite | When to use |
|---|---|---|---|
background |
opencode,deepseek-v4-flash |
same | cheap, fast — always the same |
default |
opencode,deepseek-v4-pro |
opencode,deepseek-v4-flash |
lite for trivial work, full for real editing |
think |
ollama,deepseek-v4-pro:cloud |
same | reasoning is the same in both |
longContext |
ollama,kimi-k2.6:cloud |
same | large context is the same in both |
vision |
ollama,kimi-k2.6:cloud |
ollama,kimi-k2.6:cloud |
vision is the same in both |
Note: the router_lite profile in config.json is currently a near-duplicate of router (only default is cheaper). The structure is in place to diverge the two profiles further as you tune which keywords benefit from a lite alternative.
Keywords explained
| Keyword | Purpose | Typical use |
|---|---|---|
background |
Quick, cheap tasks | cat a file, count lines, simple lookups |
default |
Daily work | dotfiles edits, config tweaks, Read/Write/Bash |
think |
Reasoning-heavy | plan reviews, architecture decisions |
longContext |
>60K token context | full codebase analysis |
vision |
Image requests (auto) | automatic — no need to request manually |
Auto-vision routing: when Claude Code sends an image in any request (regardless of keyword), the router detects the image block and forces the keyword to vision. You don't need to do anything.
Fallback chains
Each keyword has a fallback chain if the primary provider fails. For example, default in full:
opencode,deepseek-v4-pro(primary)ollama,deepseek-v4-pro:cloud(fallback 1 — local cloud)opencode,kimi-k2.6(fallback 2)
The router tries them in order and returns success from the first one that responds.
Per-project routing (optional)
In projects, override routes for specific directories:
"projects": {
"/home/pratik/Developer/small-tool": {
"default": "opencode,deepseek-v4-flash"
}
}
When the working directory starts with the project path, the override is used before the global router.
Per-request project hint
Claude Code (via the fish wrapper) sends an x-project-dir header so the router can apply per-project overrides regardless of where Claude Code's CWD is.
curl -H "x-project-dir: /home/pratik/Developer/small-tool" \
http://localhost:3458/v1/messages
Environment variables
| Variable | Purpose | Required for |
|---|---|---|
OC_GO_CC_API_KEY |
Opencode Go API key | opencode provider |
CLAUDE_ROUTER_PORT |
Override port | manual starts (default 3458) |
CLAUDE_ROUTER_LOG_DIR |
Override log directory | debugging |
The systemd unit sets OC_GO_CC_API_KEY from ~/.local/share/opencode/auth.json at startup. The fish wrapper exports it for interactive sessions.
Setup
Systemd install (one time)
mkdir -p ~/.config/systemd/user
cp ~/Developer/claude-router/systemd/claude-router.service ~/.config/systemd/user/
systemctl --user daemon-reload
systemctl --user enable --now claude-router.service
systemctl --user status claude-router.service
Fish wrapper (per-project routing)
Source this in ~/.config/fish/config.fish:
source ~/Developer/claude-router/bin/claude-router.fish
This sets x-project-dir for the session, so per-project routing works.
Configuration Guide
Complete Configuration Schema
The config at ~/.config/claude-router/config.json has the following structure:
{
"server": {
"host": "127.0.0.1", // Bind address (never expose externally)
"port": 3458, // HTTP port
"api_timeout_ms": 900000 // Upstream request timeout (15 minutes)
},
"providers": {
"providername": {
"api_base_url": "https://api.example.com/v1/chat/completions",
"api_key": "${ENV_VAR_NAME}" // Will be expanded from environment
}
},
"router": {
"background": "provider,model",
"default": "provider,model",
"think": "provider,model",
"longContext": "provider,model",
"vision": "provider,model",
"longContextThreshold": 60000, // Token threshold for auto-routing to longContext
"fallbacks": {
"keyword": ["provider,model", "provider,model2"], // Fallback chain for each keyword
"default": [...]
}
},
"router_lite": {
// Optional: lighter/cheaper variant of router above
// Structure identical to "router" block
},
"router_opencode": {
// Optional: provider-specific profile (all keywords use opencode)
// Useful for testing a single provider
},
"projects": {
"/absolute/path/to/project": {
"default": "provider,model", // Override for this project
"think": "provider,model"
}
},
"budget": {
"providername": {
"daily_limit_tokens": 100000 // Optional: daily token limit
}
}
}
Configuration Sections Explained
server
- host: Always
127.0.0.1— never expose the router externally. Claude Code runs locally and connects to it - port: Default 3458. Change if needed, but update
ANTHROPIC_BASE_URLand systemd unit accordingly - api_timeout_ms: How long to wait for upstream provider responses. Default 15 minutes handles long-context requests to slow providers
providers
Define each upstream provider. The router currently supports OpenAI-compatible APIs (Opencode, Ollama, etc).
Example with environment variable interpolation:
"providers": {
"opencode": {
"api_base_url": "https://opencode.ai/zen/go/v1/chat/completions",
"api_key": "${OC_GO_CC_API_KEY}"
},
"ollama": {
"api_base_url": "http://localhost:11434/v1/chat/completions",
"api_key": "ollama" // Ollama requires this exact string
}
}
The router will fail at startup if any ${VAR} reference is not set in the environment.
router (full profile)
The default routing table. Maps keywords to provider,model pairs:
"router": {
"background": "opencode,deepseek-v4-flash", // Cheap, fast
"default": "opencode,deepseek-v4-pro", // Balanced
"think": "ollama,deepseek-v4-pro:cloud", // Reasoning
"longContext": "ollama,kimi-k2.6:cloud", // Large context
"vision": "ollama,kimi-k2.6:cloud", // Images
"longContextThreshold": 60000,
"fallbacks": { ... }
}
Available keywords (user can request any of these, or the router auto-routes):
background— trivial work (fast, cheap)default— normal work (text editing, config changes)think— reasoning-heavy (architecture, debugging)longContext— requests >60K tokensvision— image requests (auto-detected, no user action needed)- Custom keywords — you can add more
router_lite, router_opencode, router_ollama (dynamic profiles)
The router supports dynamic profiles — multiple routing tables in one config, switchable at runtime without restart.
Why use profiles?
- Lite: cheaper models for low-stakes work (counting lines, trivial edits)
- Opencode: test one provider in isolation (useful when debugging Ollama issues)
- Ollama: use only local/cloud models (offline capable)
- Full: your primary, balanced routing (default)
Define multiple profiles in the same config:
{
"router": { ... full profile ... },
"router_lite": { ... cheaper profile ... },
"router_opencode": { ... opencode-only profile ... },
"router_ollama": { ... ollama-only profile ... }
}
Switch at runtime:
curl -X POST http://localhost:3458/switch/lite
curl -X POST http://localhost:3458/switch/opencode
curl -X POST http://localhost:3458/switch/full
The switch applies immediately to all subsequent requests. No restart needed.
projects
Per-project routing overrides. When Claude Code's working directory matches a project path, overridden keywords use the custom route:
"projects": {
"/home/user/work/expensive-ml-project": {
"default": "opencode,deepseek-v4-pro" // Always use pro for this project
},
"/home/user/work/cheap-scripts": {
"default": "opencode,deepseek-v4-flash" // Always use flash for this project
}
}
Matching is prefix-based — if the CWD starts with the project path, the override applies.
budget
Optional daily token limits per provider (not enforced, only tracked):
"budget": {
"opencode": {
"daily_limit_tokens": 500000
},
"ollama": {
"daily_limit_tokens": null // No limit
}
}
Getting Started: Step-by-Step
1. Install Claude Router
cd ~/Developer/claude-router
git clone ssh://your-repo/claude-router.git
python3 -m venv ~/.local/venvs/claude-router
~/.local/venvs/claude-router/bin/pip install -e .
2. Create configuration
Copy the example to ~/.config/claude-router/config.json:
mkdir -p ~/.config/claude-router
cat > ~/.config/claude-router/config.json <<'EOF'
{
"server": {
"host": "127.0.0.1",
"port": 3458,
"api_timeout_ms": 900000
},
"providers": {
"opencode": {
"api_base_url": "https://opencode.ai/zen/go/v1/chat/completions",
"api_key": "${OC_GO_CC_API_KEY}"
},
"ollama": {
"api_base_url": "http://localhost:11434/v1/chat/completions",
"api_key": "ollama"
}
},
"router": {
"background": "opencode,deepseek-v4-flash",
"default": "opencode,deepseek-v4-pro",
"think": "ollama,deepseek-v4-pro:cloud",
"longContext": "ollama,kimi-k2.7-code:cloud",
"vision": "ollama,kimi-k2.6:cloud",
"longContextThreshold": 60000,
"fallbacks": {
"background": ["ollama,deepseek-v4-flash:cloud", "opencode,deepseek-v4-pro"],
"default": ["ollama,deepseek-v4-pro:cloud", "opencode,kimi-k2.7-code"],
"think": ["opencode,kimi-k2.7-code", "ollama,deepseek-v4-pro:cloud"],
"longContext": ["opencode,kimi-k2.7-code", "ollama,glm-5.1:cloud"],
"vision": ["ollama,minimax-m3:cloud", "opencode,kimi-k2.7-code"]
}
},
"projects": {},
"budget": {
"opencode": { "daily_limit_tokens": null },
"ollama": { "daily_limit_tokens": null }
}
}
EOF
3. Set environment variables
export OC_GO_CC_API_KEY="your-api-key-here"
# Or read from a file:
export OC_GO_CC_API_KEY=$(cat ~/.opencode/api-key.txt)
4. Start the router
Option A: Systemd (recommended)
mkdir -p ~/.config/systemd/user
cp ~/Developer/claude-router/systemd/claude-router.service ~/.config/systemd/user/
systemctl --user daemon-reload
systemctl --user enable --now claude-router.service
systemctl --user status claude-router.service
Option B: Manual (for testing)
export OC_GO_CC_API_KEY="your-key"
~/.local/venvs/claude-router/bin/python -m claude_router.server
5. Configure Claude Code
Set the base URL to point to the router:
export ANTHROPIC_BASE_URL=http://127.0.0.1:3458
Verify the connection:
curl http://127.0.0.1:3458/health
# → {"status":"ok"}
6. (Optional) Add to shell config
For persistent activation, add to ~/.config/fish/config.fish (or .bashrc):
set -x ANTHROPIC_BASE_URL http://127.0.0.1:3458
Common Configuration Scenarios
Scenario: Use only Opencode (e.g., testing)
Add a router_opencode profile and switch to it:
curl -X POST http://localhost:3458/switch/opencode
Scenario: Use only Ollama (offline capable)
Add a router_ollama profile with all keywords pointing to Ollama:
"router_ollama": {
"default": "ollama,deepseek-v4-pro:cloud",
"think": "ollama,deepseek-v4-pro:cloud",
"vision": "ollama,kimi-k2.6:cloud",
...
}
Then switch: curl -X POST http://localhost:3458/switch/ollama
Scenario: Override routing for a specific project
"projects": {
"/home/user/ml-research": {
"default": "opencode,deepseek-v4-pro",
"think": "opencode,deepseek-v4-pro"
}
}
Now any work in /home/user/ml-research uses pro models only.
Scenario: Add a new provider
- Add to
providers:
"providers": {
"newprovider": {
"api_base_url": "https://api.newprovider.com/v1/chat/completions",
"api_key": "${NEWPROVIDER_API_KEY}"
}
}
- Use in router:
"router": {
"default": "newprovider,model-name"
}
-
Set env var before starting:
export NEWPROVIDER_API_KEY=... -
Restart:
systemctl --user restart claude-router.service
Monitoring
Cost and usage stats
SQLite database at ~/.claude-code-router/router.db.
~/.local/venvs/claude-router/bin/python -c "
from claude_router.tracking import UsageTracker
print(UsageTracker().stats('today'))
print(UsageTracker().stats('7d'))
"
Output:
{'period': 'today', 'requests': 47, 'input_tokens': 18293, 'output_tokens': 4521, 'cost_usd': 0.0108}
{'period': '7d', 'requests': 312, 'input_tokens': 124023, 'output_tokens': 28934, 'cost_usd': 0.0734}
Structured logs
JSON-lines at ~/Developer/claude-router/logs/router.log (rotates at 10MB × 5 backups).
Each line is a JSON event:
tail -f ~/Developer/claude-router/logs/router.log | jq .
Events:
request_start— incoming request, keyword, project_dirresolve— keyword → provider, modelattempt— outbound callfallback— primary failed, trying nextsuccess— full metrics, cost, latencyerror— all providers failed
# Today's spend by model
~/.local/venvs/claude-router/bin/python -c "
import sqlite3, json
conn = sqlite3.connect('~/.claude-code-router/router.db')
for row in conn.execute('SELECT model, SUM(cost_estimate) FROM requests WHERE timestamp > date(\"now\") GROUP BY model ORDER BY 2 DESC'):
print(f'{row[0]:30s} \${row[1]:.4f}')
"
Troubleshooting
"API_TIMEOUT_MS=900000ms, try increasing it"
The upstream provider timed out. Either:
- Increase
api_timeout_msin config (edit dotfiles, restart) - Check upstream health:
curl https://opencode.ai/zen/go/v1/models - Check Ollama:
systemctl status ollama
Images return 502
Image requests are auto-routed to vision keyword. Verify:
curl -s -X POST http://localhost:3458/v1/messages \
-H "Content-Type: application/json" \
-d '{"model":"vision","max_tokens":5,"messages":[{"role":"user","content":[{"type":"text","text":"hi"},{"type":"image","source":{"type":"base64","media_type":"image/png","data":"iVBORw0KGgo="}}]}]}' \
| jq .model
If this returns the vision model (kimi-k2.6:cloud or minimax-m3:cloud), routing works. If it returns a text-only model, the image was not detected — file a bug.
No requests reaching the router
Check Claude Code's ANTHROPIC_BASE_URL:
echo $ANTHROPIC_BASE_URL
# → http://127.0.0.1:3458
If unset or pointing to 3457, you're hitting CCR instead.
401 from opencode
OC_GO_CC_API_KEY is missing or invalid:
export OC_GO_CC_API_KEY=$(jq -r '."opencode-go".key' ~/.local/share/opencode/auth.json)
systemctl --user restart claude-router.service
502 with "no such file or directory"
Ollama not running. The router catches the connection error and tries fallbacks. If all fallbacks also need Ollama, request fails with 502.
systemctl --user status ollama
curl http://localhost:11434/api/tags
What doesn't work yet
- Streaming responses — non-streaming only. Claude Code works fine without streaming but you'll see responses arrive as one chunk
- Tool use images — images in tool results are not yet converted (text only)
- Anthropic
cache_controlblocks — not preserved - Token usage previews — Claude Code sometimes shows "input tokens" before the request finishes; we don't emit streaming usage events
Part 2 — Developer Guide
Architecture
Claude Code (Anthropic /v1/messages)
→ Router (localhost:3458)
1. server.py: parse body, detect images, switch keyword if needed
2. config.py: resolve keyword → provider,model (per-project → profile)
3. transform.py: Anthropic → OpenAI format
4. providers.py: httpx POST to upstream
5. transform.py: OpenAI → Anthropic response
6. tracking.py: log to SQLite
→ Claude Code continues
Module layout
src/claude_router/
├── __init__.py
├── server.py # FastAPI app, request lifecycle
├── config.py # RouterConfig: profile switching, resolution
├── providers.py # forward_request() to upstream via httpx
├── transform.py # anthropic_to_openai(), openai_to_anthropic()
├── tracking.py # UsageTracker: SQLite cost logger
└── logger.py # Structured JSON-lines log
Request lifecycle in detail
server.py::proxy handles every request:
@app.post("/v1/messages")
async def proxy(request: Request) -> Response:
body = await request.body()
anthropic_payload = json.loads(body)
# 1. Extract keyword
model_keyword = anthropic_payload.get("model", "default")
project_dir = request.headers.get("x-project-dir", "")
# 2. Auto-vision: any image block forces vision keyword
if has_images(anthropic_payload):
model_keyword = "vision"
# 3. Resolve to provider,model (per-project → profile)
route = config.resolve(model_keyword, project_dir)
provider_name, model_name = route.split(",", 1)
provider = config.get_provider(provider_name)
# 4. Convert format
openai_payload = anthropic_to_openai(anthropic_payload, model_name)
# 5. Build fallback chain
providers_to_try = [(provider, model_name)]
for fallback in config.get_fallbacks(model_keyword):
# ... add (provider, model) tuples ...
# 6. Try each in order
for prov, mod in providers_to_try:
try:
upstream = await forward_request(prov, mod, openai_payload, API_TIMEOUT)
break
except ProviderError:
continue # try next
# 7. Convert back, track, return
upstream_data = json.loads(upstream.content)
anthropic_response = openai_to_anthropic(upstream_data, model_name)
tracker.log(provider, model, keyword, input_tokens, output_tokens, project_dir)
return Response(content=json.dumps(anthropic_response), ...)
Profile switching in depth
config.py::RouterConfig holds both profiles. The active profile is in-memory and per-process — there is no shared state, no file, no env var.
class RouterConfig:
def __init__(self):
# Parsed at init
self.global_router = ... # from "router" block
self.fallbacks = ... # from "router.fallbacks"
self.global_router_lite = ... # from "router_lite" block
self.fallbacks_lite = ... # from "router_lite.fallbacks"
self._active_profile = "full" # mutable, default
def set_profile(self, profile: str):
if profile not in ("full", "lite"):
raise ValueError(...)
self._active_profile = profile
def resolve(self, keyword, cwd=CWD):
router_table = self.global_router_lite if self._active_profile == "lite" else self.global_router
# ... per-project override, then global, then default ...
The HTTP endpoints in server.py are thin wrappers:
@app.post("/switch/{profile}")
async def switch_profile(profile: str):
config.set_profile(profile)
return {"profile": config.active_profile, "message": f"Switched to {profile}"}
Why in-memory: A ~/.config/claude-router/profile file was considered but rejected — adding a file means another moving part to keep in sync across machines. The router is a single process; in-memory state is the simplest correct solution.
Reset on restart: A systemctl --user restart always returns to full. This is intentional — predictable defaults.
Format conversion
transform.py is the trickiest module. Two functions:
anthropic_to_openai(payload, model) -> dict
Converts an Anthropic /v1/messages request to OpenAI /v1/chat/completions.
| Anthropic | OpenAI |
|---|---|
system (string) |
messages[0] with role: system |
messages[].content (string) |
messages[].content (string) |
messages[].content (array of blocks) |
One message per block, OR array content if blocks are text+image |
messages[].content[].type: tool_use |
messages[].tool_calls[].function |
messages[].content[].type: tool_result |
messages[] with role: tool |
messages[].content[].type: image |
messages[].content[].type: image_url (base64 data URL) |
tools[].input_schema |
tools[].function.parameters |
Image conversion is the subtle bit. Anthropic puts text and image in separate blocks of the same message:
{
"role": "user",
"content": [
{"type": "text", "text": "What is this?"},
{"type": "image", "source": {"type": "base64", "media_type": "image/png", "data": "..."}}
]
}
OpenAI expects them in one message with array content:
{
"role": "user",
"content": [
{"type": "text", "text": "What is this?"},
{"type": "image_url", "image_url": {"url": "data:image/png;base64,..."}}
]
}
The current implementation groups text+image parts into a single message — do not split them. Splitting breaks vision models (verified during the original image-routing fix).
openai_to_anthropic(data, model) -> dict
Converts an OpenAI chat completion response back to Anthropic format.
| OpenAI | Anthropic |
|---|---|
choices[0].message.content |
content[].type: text |
choices[0].message.tool_calls[].function |
content[].type: tool_use |
choices[0].finish_reason: "stop" |
stop_reason: "end_turn" |
usage.prompt_tokens |
usage.input_tokens |
usage.completion_tokens |
usage.output_tokens |
Reasoning fallback: Kimi K2.6 and similar reasoning models emit their response in a reasoning field when thinking tokens consume all output. We fall back to reasoning when content is empty:
text = message.get("content", "") or message.get("reasoning", "")
If neither is present, the response will have empty content[] — Claude Code handles this gracefully (shows "no response").
Adding a new model
-
Add to the appropriate profile in
~/dotfiles/claude-router/config.json:"router": { "newKeyword": "provider,model-name" } -
Add cost rates in
tracking.py::COST_RATES:"model-name": {"input": 0.0003, "output": 0.0012} -
Add to fallbacks (optional):
"fallbacks": { "default": ["provider,model-name"] } -
Add a test in
tests/test_transform.pyif the model has a new content format. -
Restart:
systemctl --user restart claude-router.service
Adding a new provider
-
Add to
providersin config:"providers": { "newprovider": { "api_base_url": "https://api.example.com/v1/chat/completions", "api_key": "${NEWPROVIDER_KEY}" } } -
Ensure the provider speaks OpenAI
/v1/chat/completionsformat. If not, you need a newtransform.pyfunction. -
Set the env var in the systemd unit:
EnvironmentFile=%h/.config/claude-router/env.confThen
~/.config/claude-router/env.conf:NEWPROVIDER_KEY=sk-... -
Use it in router tables:
"default": "newprovider,model-name" -
Restart.
Adding a new profile
Edit config.py if you want a third profile (e.g., premium):
# In _load:
self.global_router_premium = {...}
self.fallbacks_premium = {...}
# In set_profile:
if profile not in ("full", "lite", "premium"):
raise ValueError(...)
# In _active_router:
if self._active_profile == "premium" and self.global_router_premium:
return self.global_router_premium
# ... etc
Update server.py available list:
@app.get("/profile")
async def get_profile():
return {"profile": config.active_profile, "available": ["full", "lite", "premium"]}
Testing
~/.local/venvs/claude-router/bin/python -m pytest tests/ -v
Tests live in tests/test_transform.py and cover the format conversion. They use sample payloads and assert the converted structure. No integration tests against live providers — too flaky.
When adding a new test:
def test_my_feature():
"""Description of what's being tested."""
input_payload = {...}
result = anthropic_to_openai(input_payload, "model-name")
assert result["messages"][0]["content"] == "expected"
Debugging
Verbose logs
tail -f ~/Developer/claude-router/logs/router.log | jq '{ts, event, keyword, model, cost_usd, latency_ms, error}'
Manual request
# Health
curl http://localhost:3458/health
# Force a specific keyword
curl -s -X POST http://localhost:3458/v1/messages \
-H "Content-Type: application/json" \
-d '{"model":"background","max_tokens":5,"messages":[{"role":"user","content":"hi"}]}' \
| jq .
# Per-project
curl -s -X POST http://localhost:3458/v1/messages \
-H "Content-Type: application/json" \
-H "x-project-dir: /home/pratik/Developer/myproject" \
-d '{"model":"default","max_tokens":5,"messages":[{"role":"user","content":"hi"}]}' \
| jq .
Check upstream is reachable
# Opencode
curl -H "Authorization: Bearer $OC_GO_CC_API_KEY" \
https://opencode.ai/zen/go/v1/chat/completions \
-d '{"model":"deepseek-v4-pro","messages":[{"role":"user","content":"hi"}],"max_tokens":5}'
# Ollama
curl http://localhost:11434/api/tags
Enable debug logging
Edit systemd/claude-router.service to add --log-level debug to the uvicorn invocation. Restart.
Known issues / future work
| Issue | Notes |
|---|---|
| Streaming not supported | FastAPI supports it; needs chunked transform |
| Tool result images | Tool results can include images; not yet converted |
Anthropic cache_control blocks |
Not preserved across the round trip |
| Profile switch doesn't notify running requests | Switch is per-request, not retroactive |
| No rate limiting | Relies on upstream quotas |
| No request signing | Internal only, bound to 127.0.0.1 |
Versioning and contributing
- Branch:
main - Python: 3.10+
- Commit format:
<verb> <subject>(e.g. "Add per-project routing") - Pre-commit:
pytest tests/ -vmust pass - Co-author:
Co-Authored-By: Claude Sonnet 4.6 (1M context) <noreply@anthropic.com>
Bug reports and feature requests go in this repo's issues. Config schema changes go in dotfiles repo under claude-router/config.json and need a corresponding code change in config.py to parse the new fields.