CCR Difficulty Routing Install – Script
These are the commands to run the command
chmod +x install-ccr-difficulty-routing.sh
./install-ccr-difficulty-routing.sh
This is the script to install everything from the guide
#!/usr/bin/env bash
#
# install-ccr-difficulty-routing.sh
#
# Interactive installer for the Claude Code Router (CCR) difficulty-routing
# layer that sits on top of an existing LiteLLM gateway.
#
# It prompts for every value the guide leaves as a placeholder, shows an
# example for each, validates what it can BEFORE using it, backs up anything
# it edits, and asks for confirmation before each step that changes the system.
#
# Run it as the unprivileged service user that owns your rootless container
# stack (for example: ssh in as that user, then ./install-ccr-difficulty-routing.sh).
# Do NOT run it as root.
set -u # treat unset variables as errors; we deliberately do NOT use -e so we
# can handle failures with friendly messages instead of silent exits.
# ----------------------------------------------------------------------------
# Small helpers for readable, color-coded output and safe prompting.
# ----------------------------------------------------------------------------
if [ -t 1 ]; then
BOLD="$(printf '\033[1m')"; RESET="$(printf '\033[0m')"
RED="$(printf '\033[31m')"; GRN="$(printf '\033[32m')"
YEL="$(printf '\033[33m')"; CYN="$(printf '\033[36m')"
else
BOLD=""; RESET=""; RED=""; GRN=""; YEL=""; CYN=""
fi
say() { printf "%s\n" "$*"; }
info() { printf "%s%s%s\n" "$CYN" "$*" "$RESET"; }
good() { printf "%s%s%s\n" "$GRN" "$*" "$RESET"; }
warn() { printf "%s%s%s\n" "$YEL" "$*" "$RESET"; }
err() { printf "%s%s%s\n" "$RED" "$*" "$RESET" 1>&2; }
hr() { printf '%s\n' "------------------------------------------------------------"; }
# Abort with a message.
die() { err ""; err "STOPPED: $*"; err "Nothing further was changed by this run."; exit 1; }
# Ask a yes/no question; default No unless DEFAULT_YES=1 passed as $2.
confirm() {
local prompt="$1" def="${2:-no}" ans
if [ "$def" = "yes" ]; then
printf "%s [Y/n] " "$prompt"
else
printf "%s [y/N] " "$prompt"
fi
read -r ans
ans="${ans:-$def}"
case "$ans" in
y|Y|yes|YES) return 0 ;;
*) return 1 ;;
esac
}
# Prompt for a value, showing an example, with optional validation.
# Usage: ask_var VARNAME "Description" "example-value"
# The value is stored in the global variable named by VARNAME.
ask_var() {
local __name="$1" __desc="$2" __example="$3" __val=""
while :; do
printf "\n%s%s%s\n" "$BOLD" "$__desc" "$RESET"
printf " example: %s%s%s\n" "$CYN" "$__example" "$RESET"
printf " value> "
read -r __val
if [ -z "$__val" ]; then
warn " A value is required. Please enter one (or press Ctrl+C to quit)."
continue
fi
break
done
printf -v "$__name" '%s' "$__val"
}
# ----------------------------------------------------------------------------
# Phase 0: sanity checks on the environment before we ask anything.
# ----------------------------------------------------------------------------
clear 2>/dev/null || true
hr
say "${BOLD}CCR Difficulty-Routing Installer${RESET}"
hr
say "This sets up Claude Code Router in front of your existing LiteLLM gateway,"
say "so simple requests go to a local model and complex ones go to the cloud."
say ""
say "It will: prompt for your values, validate them, install CCR, write its"
say "config, add a drop-params fix to your LiteLLM config (with a backup),"
say "and start CCR as a service. You confirm before each system change."
hr
# Must not be root.
if [ "$(id -u)" = "0" ]; then
die "You are running as root. Re-run as your normal service user (e.g. the user that owns your rootless Docker stack)."
fi
RUN_USER="$(id -un)"
RUN_HOME="$HOME"
info "Running as user: $RUN_USER (home: $RUN_HOME)"
# Required tools.
for t in curl python3 node npm; do
if ! command -v "$t" >/dev/null 2>&1; then
if [ "$t" = "node" ] || [ "$t" = "npm" ]; then
die "'$t' is not on your PATH. Install Node (a recent LTS) for this user first, then re-run."
else
die "'$t' is required but not found. Install it and re-run."
fi
fi
done
good "Required tools found: curl, python3, node ($(node --version)), npm"
# docker is needed only for the LiteLLM restart; warn but continue if absent.
HAVE_DOCKER=1
if ! command -v docker >/dev/null 2>&1; then
HAVE_DOCKER=0
warn "docker not found on PATH. The script can still write configs, but you'll"
warn "have to restart LiteLLM yourself after it edits the config."
fi
# ----------------------------------------------------------------------------
# Phase 1: collect and validate all variables.
# ----------------------------------------------------------------------------
hr
say "${BOLD}Step 1 of 7: Your settings${RESET}"
say "Enter each value when prompted. The script checks them as you go."
hr
# --- Local model host ---
while :; do
ask_var LOCAL_MODEL_HOST \
"Local model server host and port (where your local model is served)" \
"192.168.2.109:1234"
info " Checking that $LOCAL_MODEL_HOST is reachable..."
MODELS_JSON="$(curl -s --max-time 5 "http://$LOCAL_MODEL_HOST/v1/models" 2>/dev/null)"
if [ -z "$MODELS_JSON" ]; then
warn " Could not reach http://$LOCAL_MODEL_HOST/v1/models."
warn " Make sure the local model server is running and reachable from here."
confirm " Try a different host/port?" yes && continue
die "Local model server not reachable. Start it and re-run."
fi
# Extract available model ids.
AVAILABLE_IDS="$(printf '%s' "$MODELS_JSON" | python3 -c \
"import sys,json
try:
d=json.load(sys.stdin)
print('\n'.join(m.get('id','') for m in d.get('data',[])))
except Exception:
pass" 2>/dev/null)"
if [ -z "$AVAILABLE_IDS" ]; then
warn " Reached the server but could not read a model list from it."
confirm " Try a different host/port?" yes && continue
die "Local model server did not return a usable model list."
fi
good " Reachable. Models currently loaded there:"
printf '%s\n' "$AVAILABLE_IDS" | sed 's/^/ - /'
break
done
# --- Local model id (validated against what the server actually reports) ---
while :; do
ask_var LOCAL_MODEL_ID \
"Exact local model id to use (copy one from the list above, verbatim)" \
"qwen/qwen3.6-35b-a3b"
if printf '%s\n' "$AVAILABLE_IDS" | grep -qxF "$LOCAL_MODEL_ID"; then
good " Confirmed: '$LOCAL_MODEL_ID' is loaded on the server."
break
else
warn " '$LOCAL_MODEL_ID' is NOT in the list the server reported."
warn " It must match exactly (case, slashes, everything)."
confirm " Enter it again?" yes && continue
die "Local model id did not match a loaded model."
fi
done
# --- LiteLLM master key ---
# Prefer an already-exported value; otherwise prompt.
if [ -n "${LITELLM_MASTER_KEY:-}" ]; then
info "Found LITELLM_MASTER_KEY already set in your environment; using it."
else
ask_var LITELLM_MASTER_KEY \
"Your LiteLLM master key (the bearer token your LiteLLM proxy expects)" \
"sk-1234... (whatever you set as LITELLM_MASTER_KEY)"
fi
# --- LiteLLM reachability + cloud model names ---
LITELLM_URL="http://127.0.0.1:4000"
info "Checking LiteLLM at $LITELLM_URL ..."
LITELLM_MODELS_JSON="$(curl -s --max-time 5 "$LITELLM_URL/v1/models" \
-H "Authorization: Bearer $LITELLM_MASTER_KEY" 2>/dev/null)"
if [ -z "$LITELLM_MODELS_JSON" ]; then
warn "Could not reach LiteLLM at $LITELLM_URL, or the key was rejected."
warn "This installer assumes LiteLLM is already running on loopback port 4000."
confirm "Continue anyway (not recommended)?" no || die "LiteLLM not reachable. Start it and re-run."
LITELLM_MODEL_IDS=""
else
LITELLM_MODEL_IDS="$(printf '%s' "$LITELLM_MODELS_JSON" | python3 -c \
"import sys,json
try:
d=json.load(sys.stdin)
print('\n'.join(m.get('id','') for m in d.get('data',[])))
except Exception:
pass" 2>/dev/null)"
good "LiteLLM reachable. Cloud model names it exposes:"
printf '%s\n' "$LITELLM_MODEL_IDS" | sed 's/^/ - /'
fi
# Helper to validate a cloud model name against LiteLLM's list (if we have one).
validate_cloud_model() {
local name="$1"
[ -z "$LITELLM_MODEL_IDS" ] && return 0 # can't validate, accept
printf '%s\n' "$LITELLM_MODEL_IDS" | grep -qxF "$name"
}
while :; do
ask_var CLOUD_COMPLEX_MODEL \
"Cloud model name for COMPLEX work (your stronger/sonnet-class model in LiteLLM)" \
"foundry-sonnet"
if validate_cloud_model "$CLOUD_COMPLEX_MODEL"; then break; fi
warn " '$CLOUD_COMPLEX_MODEL' is not in LiteLLM's model list above."
confirm " Enter it again?" yes && continue
confirm " Use it anyway?" no && break
done
while :; do
ask_var CLOUD_SIMPLE_MODEL \
"Cloud model name for the cheaper/faster tier (haiku-class), used for the on-demand speed switch and failover" \
"foundry-haiku"
if validate_cloud_model "$CLOUD_SIMPLE_MODEL"; then break; fi
warn " '$CLOUD_SIMPLE_MODEL' is not in LiteLLM's model list above."
confirm " Enter it again?" yes && continue
confirm " Use it anyway?" no && break
done
# --- LiteLLM config path (validated against the running container's mount) ---
DEFAULT_LITELLM_CFG="$RUN_HOME/litellm/config.yaml"
if [ "$HAVE_DOCKER" = "1" ]; then
MOUNTED_CFG="$(docker inspect litellm \
--format '{{range .Mounts}}{{if eq .Destination "/app/config.yaml"}}{{.Source}}{{end}}{{end}}' 2>/dev/null)"
if [ -n "$MOUNTED_CFG" ]; then
DEFAULT_LITELLM_CFG="$MOUNTED_CFG"
info "Detected the config file mounted into the litellm container:"
info " $MOUNTED_CFG"
fi
fi
while :; do
printf "\n%sPath to your LiteLLM config.yaml%s\n" "$BOLD" "$RESET"
printf " example: %s%s%s\n" "$CYN" "$DEFAULT_LITELLM_CFG" "$RESET"
printf " press Enter to accept the example, or type a different path> "
read -r LITELLM_CFG
LITELLM_CFG="${LITELLM_CFG:-$DEFAULT_LITELLM_CFG}"
if [ -f "$LITELLM_CFG" ]; then
good " Found: $LITELLM_CFG"
break
fi
warn " No file at: $LITELLM_CFG"
confirm " Try a different path?" yes && continue
die "LiteLLM config not found. Locate it and re-run."
done
# ----------------------------------------------------------------------------
# Summary + go/no-go before any changes.
# ----------------------------------------------------------------------------
hr
say "${BOLD}Review your settings${RESET}"
hr
printf " Local model host: %s\n" "$LOCAL_MODEL_HOST"
printf " Local model id: %s\n" "$LOCAL_MODEL_ID"
printf " Cloud COMPLEX model: %s\n" "$CLOUD_COMPLEX_MODEL"
printf " Cloud SIMPLE model: %s\n" "$CLOUD_SIMPLE_MODEL"
printf " LiteLLM config: %s\n" "$LITELLM_CFG"
printf " Service user / home: %s / %s\n" "$RUN_USER" "$RUN_HOME"
hr
say "From here on, the script makes changes. It will back up anything it edits,"
say "and ask before each step."
confirm "Proceed?" yes || die "You chose not to proceed."
CCR_DIR="$RUN_HOME/.claude-code-router"
SECRETS_FILE="$RUN_HOME/.config/claude-secrets.env"
mkdir -p "$CCR_DIR" "$RUN_HOME/.config"
# ----------------------------------------------------------------------------
# Step 2: install CCR.
# ----------------------------------------------------------------------------
hr
say "${BOLD}Step 2 of 7: Install Claude Code Router${RESET}"
if command -v ccr >/dev/null 2>&1; then
good "CCR already installed ($(ccr version 2>/dev/null | head -1))."
else
if confirm "Install @musistudio/claude-code-router globally with npm?" yes; then
npm install -g @musistudio/claude-code-router || die "npm install of CCR failed."
command -v ccr >/dev/null 2>&1 || die "CCR installed but 'ccr' is not on PATH. Open a new shell or check your npm global bin path."
good "CCR installed."
else
die "CCR is required. Re-run and allow the install."
fi
fi
NODE_BIN="$(readlink -f "$(command -v node)")"
CCR_CLI="$(readlink -f "$(command -v ccr)")"
info "node binary: $NODE_BIN"
info "ccr script: $CCR_CLI"
# ----------------------------------------------------------------------------
# Step 3: secrets (CCR key).
# ----------------------------------------------------------------------------
hr
say "${BOLD}Step 3 of 7: Secrets${RESET}"
touch "$SECRETS_FILE"; chmod 600 "$SECRETS_FILE"
# Ensure LITELLM_MASTER_KEY is recorded in the secrets file (CCR's service reads this file).
if ! grep -q '^LITELLM_MASTER_KEY=' "$SECRETS_FILE" 2>/dev/null; then
printf 'LITELLM_MASTER_KEY=%s\n' "$LITELLM_MASTER_KEY" >> "$SECRETS_FILE"
info "Recorded LITELLM_MASTER_KEY in $SECRETS_FILE"
fi
# Generate a CCR API key if not already present.
if grep -q '^CCR_APIKEY=' "$SECRETS_FILE" 2>/dev/null; then
CCR_APIKEY="$(grep '^CCR_APIKEY=' "$SECRETS_FILE" | head -1 | cut -d= -f2-)"
good "Reusing existing CCR_APIKEY from $SECRETS_FILE"
else
CCR_APIKEY="$(openssl rand -hex 32 2>/dev/null || python3 -c 'import secrets;print(secrets.token_hex(32))')"
printf 'CCR_APIKEY=%s\n' "$CCR_APIKEY" >> "$SECRETS_FILE"
good "Generated a new CCR_APIKEY and stored it in $SECRETS_FILE"
fi
chmod 600 "$SECRETS_FILE"
# ----------------------------------------------------------------------------
# Step 4: patch LiteLLM config (drop-params fix), with backup + validation.
# ----------------------------------------------------------------------------
hr
say "${BOLD}Step 4 of 7: LiteLLM drop-params fix${RESET}"
say "Cloud requests through CCR carry thinking-related fields that some backends"
say "reject. This adds drop_params + additional_drop_params to each cloud model"
say "in your LiteLLM config so those fields are stripped."
# Use python to do a safe, idempotent edit. Requires pyyaml; install if missing.
if ! python3 -c 'import yaml' 2>/dev/null; then
info "Installing the Python yaml module (needed to edit the config safely)..."
pip install pyyaml --break-system-packages -q 2>/dev/null || \
pip3 install pyyaml --break-system-packages -q 2>/dev/null || \
warn "Could not install pyyaml automatically; will fall back to a textual check."
fi
if confirm "Edit $LITELLM_CFG now (a timestamped backup will be made first)?" yes; then
BACKUP="$LITELLM_CFG.bak.$(date +%Y%m%d-%H%M%S 2>/dev/null || echo manual)"
cp "$LITELLM_CFG" "$BACKUP" || die "Could not back up the LiteLLM config."
good "Backup written: $BACKUP"
PATCH_OK=0
if python3 -c 'import yaml' 2>/dev/null; then
python3 - "$LITELLM_CFG" << 'PYEOF'
import sys, yaml
path = sys.argv[1]
with open(path) as f:
cfg = yaml.safe_load(f) or {}
drops = ["reasoning", "thinking", "enable_thinking"]
# Global settings (second line of defense).
ls = cfg.setdefault("litellm_settings", {})
ls["drop_params"] = True
ls.setdefault("modify_params", True)
existing = ls.get("additional_drop_params") or []
ls["additional_drop_params"] = sorted(set(existing) | set(drops))
# Per-model drops on every model whose provider looks like a cloud Claude route.
# Heuristic: any model whose litellm_params.model contains 'claude' or 'azure_ai'.
changed = 0
for entry in cfg.get("model_list", []) or []:
lp = entry.get("litellm_params", {}) or {}
model = str(lp.get("model", ""))
if "claude" in model.lower() or "azure_ai" in model.lower() or "anthropic" in model.lower():
lp["drop_params"] = True
ex = lp.get("additional_drop_params") or []
lp["additional_drop_params"] = sorted(set(ex) | set(drops))
entry["litellm_params"] = lp
changed += 1
with open(path, "w") as f:
yaml.safe_dump(cfg, f, sort_keys=False, default_flow_style=False)
print(f"PATCHED_MODELS={changed}")
PYEOF
if [ $? -eq 0 ]; then
PATCH_OK=1
# Validate it still parses.
if python3 -c "import yaml,sys; yaml.safe_load(open('$LITELLM_CFG'))" 2>/dev/null; then
good "Config patched and still valid YAML."
else
warn "Patched file failed YAML validation. Restoring backup."
cp "$BACKUP" "$LITELLM_CFG"
die "LiteLLM config edit produced invalid YAML; original restored from backup."
fi
fi
fi
if [ "$PATCH_OK" != "1" ]; then
warn "Automatic YAML edit unavailable. Please add these lines under each cloud"
warn "model's litellm_params, and under litellm_settings, by hand:"
say " drop_params: true"
say " additional_drop_params: [\"reasoning\", \"thinking\", \"enable_thinking\"]"
confirm "Have you added them (or will edit after)? Continue?" yes || die "Add the drop params and re-run."
fi
# Restart LiteLLM to load the change.
if [ "$HAVE_DOCKER" = "1" ]; then
if confirm "Restart the litellm container now to load the change?" yes; then
docker restart litellm >/dev/null 2>&1 && good "litellm restarted." || warn "docker restart litellm failed; restart it yourself."
sleep 5
fi
else
warn "docker not available here; restart LiteLLM yourself so the change takes effect."
fi
# Verify the drop now works: a request WITH a reasoning field should succeed.
info "Verifying the cloud path accepts a request carrying a reasoning field..."
TEST="$(curl -s --max-time 20 "$LITELLM_URL/v1/chat/completions" \
-H "Authorization: Bearer $LITELLM_MASTER_KEY" \
-H "Content-Type: application/json" \
-d "{\"model\":\"$CLOUD_COMPLEX_MODEL\",\"messages\":[{\"role\":\"user\",\"content\":\"hi\"}],\"max_tokens\":5,\"reasoning\":{\"effort\":\"high\"},\"enable_thinking\":false}" 2>/dev/null)"
if printf '%s' "$TEST" | grep -qi "not permitted"; then
warn "The cloud backend STILL rejects a thinking-related field:"
printf '%s\n' "$TEST" | head -3
warn "You may need to add the offending field name to additional_drop_params."
elif printf '%s' "$TEST" | grep -qi '"content"'; then
good "Cloud path accepts the request. Drop-params fix confirmed working."
else
warn "Unexpected response from the test call (may be a transient cloud error):"
printf '%s\n' "$TEST" | head -3
fi
else
warn "Skipped the LiteLLM edit. Cloud routing will 400 until the drop params are added."
fi
# ----------------------------------------------------------------------------
# Step 5: write CCR config.json and difficulty-router.js.
# ----------------------------------------------------------------------------
hr
say "${BOLD}Step 5 of 7: CCR config and router${RESET}"
CONFIG_JSON="$CCR_DIR/config.json"
ROUTER_JS="$CCR_DIR/difficulty-router.js"
DECISION_LOG="$CCR_DIR/decisions.log"
PIDFILE="$CCR_DIR/.claude-code-router.pid"
# Back up any existing CCR config/router.
for f in "$CONFIG_JSON" "$ROUTER_JS"; do
[ -f "$f" ] && cp "$f" "$f.bak.$(date +%Y%m%d-%H%M%S 2>/dev/null || echo manual)" && info "Backed up $(basename "$f")"
done
cat > "$CONFIG_JSON" << EOF
{
"LOG": true,
"LOG_LEVEL": "info",
"HOST": "127.0.0.1",
"PORT": 3456,
"APIKEY": "\$CCR_APIKEY",
"CUSTOM_ROUTER_PATH": "$ROUTER_JS",
"Providers": [
{
"name": "local",
"api_base_url": "http://$LOCAL_MODEL_HOST/v1/chat/completions",
"api_key": "local",
"models": ["$LOCAL_MODEL_ID"]
},
{
"name": "cloud",
"api_base_url": "http://127.0.0.1:4000/v1/chat/completions",
"api_key": "\$LITELLM_MASTER_KEY",
"models": ["$CLOUD_COMPLEX_MODEL", "$CLOUD_SIMPLE_MODEL"],
"transformer": {
"use": [["reasoning", { "enable": false }]]
}
}
],
"Router": {
"default": "local,$LOCAL_MODEL_ID",
"background": "local,$LOCAL_MODEL_ID",
"think": "cloud,$CLOUD_COMPLEX_MODEL",
"longContext": "cloud,$CLOUD_COMPLEX_MODEL",
"longContextThreshold": 60000,
"fallback": "cloud,$CLOUD_COMPLEX_MODEL"
}
}
EOF
# Validate JSON.
if python3 -c "import json; json.load(open('$CONFIG_JSON'))" 2>/dev/null; then
good "Wrote and validated $CONFIG_JSON"
else
die "Generated config.json is not valid JSON (this is a bug in the installer)."
fi
# Write the difficulty router. Note: \$ keeps shell from expanding inside the heredoc;
# we DO expand the model/host/path values we substitute deliberately.
cat > "$ROUTER_JS" << EOF
const fs = require('fs');
const LOCAL = "local,$LOCAL_MODEL_ID";
const CLOUD = "cloud,$CLOUD_COMPLEX_MODEL";
const LOCAL_BASE = "http://$LOCAL_MODEL_HOST";
const LOCAL_URL = LOCAL_BASE + "/v1/chat/completions";
const LOCAL_HEALTH = LOCAL_BASE + "/v1/models";
const LOCAL_MODEL = "$LOCAL_MODEL_ID";
const DECISION_LOG = "$DECISION_LOG";
const CLASSIFY_TIMEOUT_MS = 15000;
const HEALTH_TIMEOUT_MS = 800;
const HEALTH_CACHE_MS = 15000;
const CLASSIFY_PROMPT =
"You are routing requests between a capable local model and a " +
"frontier model. The local model is strong and handles most " +
"routine coding tasks well: edits, renames, formatting, " +
"docstrings, simple refactors, straightforward questions, " +
"boilerplate, and single-function changes. Route those SIMPLE. " +
"Only route COMPLEX when the task genuinely needs frontier-level " +
"reasoning: multi-service debugging, architecture, subtle " +
"concurrency or security issues, or large multi-file refactors. " +
"Default to SIMPLE for ordinary work; reserve COMPLEX for the " +
"genuinely hard. After any reasoning, your LAST line must be exactly " +
"one word, either SIMPLE or COMPLEX, on its own with nothing after it. " +
"End your response with that word.";
function decisionLog(line) {
try {
fs.appendFileSync(DECISION_LOG, new Date().toISOString() + " " + line + "\n");
} catch (e) {}
}
let healthCache = { up: false, checkedAt: 0 };
async function isLocalUp() {
const now = Date.now();
if (now - healthCache.checkedAt < HEALTH_CACHE_MS) return healthCache.up;
const controller = new AbortController();
const timer = setTimeout(() => controller.abort(), HEALTH_TIMEOUT_MS);
let up = false;
try {
const res = await fetch(LOCAL_HEALTH, { signal: controller.signal });
if (res.ok) {
const data = await res.json();
const ids = (data && data.data ? data.data : []).map(m => m.id);
up = ids.includes(LOCAL_MODEL);
}
} catch (e) {
up = false;
} finally {
clearTimeout(timer);
}
healthCache = { up, checkedAt: now };
return up;
}
function extractRecentText(messages, maxTurns = 4) {
const recent = messages.slice(-maxTurns);
return recent.map(m => {
const c = m.content;
if (typeof c === "string") return m.role + ": " + c;
if (Array.isArray(c)) {
const text = c.map(p => p.text || "").filter(Boolean).join(" ");
return m.role + ": " + text;
}
return "";
}).filter(Boolean).join("\n").slice(0, 4000);
}
async function classifyLocally(text) {
const controller = new AbortController();
const timer = setTimeout(() => controller.abort(), CLASSIFY_TIMEOUT_MS);
try {
const res = await fetch(LOCAL_URL, {
method: "POST",
headers: { "Content-Type": "application/json" },
signal: controller.signal,
body: JSON.stringify({
model: LOCAL_MODEL,
temperature: 0,
max_tokens: 8192,
messages: [
{ role: "system", content: CLASSIFY_PROMPT },
{ role: "user", content: text }
]
})
});
const data = await res.json();
const verdict = ((data && data.choices && data.choices[0] && data.choices[0].message && data.choices[0].message.content) || "").toUpperCase();
const lastSimple = verdict.lastIndexOf("SIMPLE");
const lastComplex = verdict.lastIndexOf("COMPLEX");
if (lastSimple === -1 && lastComplex === -1) {
try { fs.appendFileSync(DECISION_LOG, new Date().toISOString() + " classify-empty len=" + verdict.length + "\n"); } catch (_e) {}
return null;
}
return (lastComplex > lastSimple) ? "COMPLEX" : "SIMPLE";
} catch (e) {
const reason = (e && e.name === "AbortError") ? "classify-timeout" : ("classify-error:" + (e && e.message));
try { fs.appendFileSync(DECISION_LOG, new Date().toISOString() + " " + reason + "\n"); } catch (_e) {}
return null;
} finally {
clearTimeout(timer);
}
}
module.exports = async function router(req, config) {
let up = false, verdict = null, decision = CLOUD;
try {
const messages = (req && req.body && req.body.messages) ? req.body.messages : [];
if (messages.length === 0) {
decisionLog("empty messages -> " + CLOUD);
return CLOUD;
}
up = await isLocalUp();
if (!up) {
decisionLog("localUp=false -> " + CLOUD + " (skip classify)");
return CLOUD;
}
const text = extractRecentText(messages);
verdict = await classifyLocally(text);
decision = (verdict === "SIMPLE") ? LOCAL : CLOUD;
decisionLog("localUp=" + up + " verdict=" + verdict + " -> " + decision);
return decision;
} catch (e) {
decisionLog("error=" + (e && e.message) + " -> " + CLOUD);
return CLOUD;
}
};
EOF
# Validate the router parses as JS.
if node -c "$ROUTER_JS" 2>/dev/null; then
good "Wrote and syntax-checked $ROUTER_JS"
else
die "Generated difficulty-router.js has a syntax error (installer bug)."
fi
# ----------------------------------------------------------------------------
# Step 6: launcher function in .bashrc.
# ----------------------------------------------------------------------------
hr
say "${BOLD}Step 6 of 7: Claude Code launcher${RESET}"
mkdir -p "$RUN_HOME/.claude-ccr"
[ -f "$RUN_HOME/.claude-ccr/settings.json" ] || \
echo '{"effortLevel":"high","theme":"dark"}' > "$RUN_HOME/.claude-ccr/settings.json"
if grep -q 'claude-ccr()' "$RUN_HOME/.bashrc" 2>/dev/null; then
good "A claude-ccr() launcher already exists in ~/.bashrc; leaving it as-is."
else
if confirm "Add a 'claude-ccr' launcher function to ~/.bashrc?" yes; then
cat >> "$RUN_HOME/.bashrc" << 'EOF'
claude-ccr() {
set -a; source ~/.config/claude-secrets.env; set +a
CLAUDE_CONFIG_DIR=~/.claude-ccr \
ANTHROPIC_BASE_URL=http://127.0.0.1:3456 \
ANTHROPIC_AUTH_TOKEN="$CCR_APIKEY" \
CLAUDE_CODE_DISABLE_EXPERIMENTAL_BETAS=1 \
claude "$@"
}
EOF
good "Added claude-ccr() to ~/.bashrc (run 'source ~/.bashrc' or open a new shell to use it)."
fi
fi
# ----------------------------------------------------------------------------
# Step 7: systemd user service (with the pidfile-cleanup fix).
# ----------------------------------------------------------------------------
hr
say "${BOLD}Step 7 of 7: Run CCR as a service${RESET}"
UNIT="$RUN_HOME/.config/systemd/user/ccr.service"
mkdir -p "$RUN_HOME/.config/systemd/user"
[ -f "$UNIT" ] && cp "$UNIT" "$UNIT.bak.$(date +%Y%m%d-%H%M%S 2>/dev/null || echo manual)" && info "Backed up existing unit."
cat > "$UNIT" << EOF
[Unit]
Description=Claude Code Router
After=network-online.target
Wants=network-online.target
[Service]
Type=simple
EnvironmentFile=$SECRETS_FILE
Environment=PATH=$(dirname "$NODE_BIN"):/usr/local/bin:/usr/bin:/bin
Environment=HOME=$RUN_HOME
ExecStartPre=/bin/rm -f $PIDFILE
ExecStart=$NODE_BIN $CCR_CLI start
Restart=on-failure
RestartSec=5
StandardOutput=append:$CCR_DIR/ccr-service.log
StandardError=append:$CCR_DIR/ccr-service.log
[Install]
WantedBy=default.target
EOF
good "Wrote service unit: $UNIT"
if confirm "Enable and start the CCR service now?" yes; then
# Clear any hand-started instance and stale pidfile first.
ccr stop >/dev/null 2>&1
pkill -f 'claude-code-router/dist/cli.js' >/dev/null 2>&1
rm -f "$PIDFILE"
sleep 2
systemctl --user daemon-reload
systemctl --user reset-failed ccr.service >/dev/null 2>&1
systemctl --user enable --now ccr.service
sleep 4
# Load CCR_APIKEY for the verification curl.
set -a; . "$SECRETS_FILE"; set +a
STATE="$(systemctl --user is-active ccr.service 2>/dev/null)"
CODE="$(curl -s --max-time 5 http://127.0.0.1:3456/ -H "Authorization: Bearer $CCR_APIKEY" -o /dev/null -w "%{http_code}" 2>/dev/null)"
if [ "$STATE" = "active" ] && [ "$CODE" = "200" ]; then
good "CCR service is active and responding (HTTP 200)."
# Stability re-check.
PID1="$(systemctl --user show -p MainPID --value ccr.service 2>/dev/null)"
sleep 8
PID2="$(systemctl --user show -p MainPID --value ccr.service 2>/dev/null)"
if [ -n "$PID1" ] && [ "$PID1" = "$PID2" ]; then
good "Stable: same process id ($PID1) after several seconds. No restart loop."
else
warn "The process id changed ($PID1 -> $PID2). It may be restarting; check:"
warn " journalctl --user -u ccr.service -n 20 --no-pager"
fi
else
warn "Service state: $STATE, HTTP: $CODE"
warn "Check logs with: journalctl --user -u ccr.service -n 20 --no-pager"
fi
else
warn "Service not started. You can start it later with:"
say " systemctl --user daemon-reload && systemctl --user enable --now ccr.service"
fi
# ----------------------------------------------------------------------------
# Done + linger note.
# ----------------------------------------------------------------------------
hr
good "Setup complete."
hr
say "Use it: ${BOLD}source ~/.bashrc && claude-ccr${RESET}"
say "Watch routing: tail -f $DECISION_LOG"
say "Endpoint only: tail -f $DECISION_LOG | grep --line-buffered -oE 'local|cloud'"
say ""
say "What to expect: roughly a third to a half of requests landing on the local"
say "model is a healthy split. There is no tool guard by design; tool-using turns"
say "are classified normally and fall back to cloud only if the local model fails."
say "To see why any classifications fell back to cloud:"
say " grep -oE 'classify-(timeout|empty|error)' $DECISION_LOG | sort | uniq -c"
say ""
say "Switch the cloud tier on the fly inside a session:"
say " /model cloud,$CLOUD_SIMPLE_MODEL (cheaper, faster)"
say " /model cloud,$CLOUD_COMPLEX_MODEL (stronger)"
say ""
warn "Boot survival (one-time, needs an administrator):"
say " The service starts when you log in. To start it at cold boot before any"
say " login, an admin must run, once:"
say " loginctl enable-linger $RUN_USER"
say " Until then, after a reboot just log in and the service starts."
hr