No description
  • Python 98.7%
  • Shell 1.3%
Find a file
Pratik Sinha 3a8f457821 Remove consensus panel feature (dead code)
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>
2026-07-28 13:19:46 +05:30
bin Add AGPL-3.0-or-later license 2026-06-17 16:58:51 +05:30
src/claude_router Remove consensus panel feature (dead code) 2026-07-28 13:19:46 +05:30
systemd systemd: load OC_GO_CC_API_KEY from EnvironmentFile (env.conf) 2026-07-28 11:51:59 +05:30
tests Remove consensus panel feature (dead code) 2026-07-28 13:19:46 +05:30
.gitignore chore: remove stale build/ artefact and gitignore it 2026-07-14 11:21:49 +05:30
CLAUDE.md Remove consensus panel feature (dead code) 2026-07-28 13:19:46 +05:30
GETTING-STARTED.md Remove consensus panel feature (dead code) 2026-07-28 13:19:46 +05:30
LICENSE Add AGPL-3.0-or-later license 2026-06-17 16:58:51 +05:30
pyproject.toml Add AGPL-3.0-or-later license 2026-06-17 16:58:51 +05:30
README.md Remove consensus panel feature (dead code) 2026-07-28 13:19:46 +05:30
requirements.txt Initial commit: Claude Router with per-project routing and cost tracking 2026-06-06 10:38:14 +05:30

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:

  1. opencode,deepseek-v4-pro (primary)
  2. ollama,deepseek-v4-pro:cloud (fallback 1 — local cloud)
  3. 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_URL and 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 tokens
  • vision — 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

  1. Add to providers:
"providers": {
  "newprovider": {
    "api_base_url": "https://api.newprovider.com/v1/chat/completions",
    "api_key": "${NEWPROVIDER_API_KEY}"
  }
}
  1. Use in router:
"router": {
  "default": "newprovider,model-name"
}
  1. Set env var before starting: export NEWPROVIDER_API_KEY=...

  2. 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_dir
  • resolve — keyword → provider, model
  • attempt — outbound call
  • fallback — primary failed, trying next
  • success — full metrics, cost, latency
  • error — 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:

  1. Increase api_timeout_ms in config (edit dotfiles, restart)
  2. Check upstream health: curl https://opencode.ai/zen/go/v1/models
  3. 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_control blocks — 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

  1. Add to the appropriate profile in ~/dotfiles/claude-router/config.json:

    "router": {
      "newKeyword": "provider,model-name"
    }
    
  2. Add cost rates in tracking.py::COST_RATES:

    "model-name": {"input": 0.0003, "output": 0.0012}
    
  3. Add to fallbacks (optional):

    "fallbacks": {
      "default": ["provider,model-name"]
    }
    
  4. Add a test in tests/test_transform.py if the model has a new content format.

  5. Restart: systemctl --user restart claude-router.service

Adding a new provider

  1. Add to providers in config:

    "providers": {
      "newprovider": {
        "api_base_url": "https://api.example.com/v1/chat/completions",
        "api_key": "${NEWPROVIDER_KEY}"
      }
    }
    
  2. Ensure the provider speaks OpenAI /v1/chat/completions format. If not, you need a new transform.py function.

  3. Set the env var in the systemd unit:

    EnvironmentFile=%h/.config/claude-router/env.conf
    

    Then ~/.config/claude-router/env.conf:

    NEWPROVIDER_KEY=sk-...
    
  4. Use it in router tables: "default": "newprovider,model-name"

  5. 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/ -v must 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.