diff --git a/bin/deepclaude-statusline b/bin/deepclaude-statusline new file mode 100755 index 0000000..a6adbf2 --- /dev/null +++ b/bin/deepclaude-statusline @@ -0,0 +1,100 @@ +#!/usr/bin/env bash +# Claude Code statusLine for deepclaude. Reads Claude Code's status JSON +# from stdin, polls the local proxy for routing + cumulative cost, and +# emits a one-line summary like: +# +# [claude-opus-4-7 → deepseek-v4-pro on api.deepseek.com] · ↑5.2K ↓1.1K · $0.04 (saved $0.13) +# +# Auto-installed by deepclaude on launch via ensure_statusline_installed. +# Requires jq. + +set -uo pipefail + +PROXY_PORT="${DEEPCLAUDE_PROXY_PORT:-3200}" +TIMEOUT="${DEEPCLAUDE_STATUSLINE_TIMEOUT:-1}" + +input="" +if [[ -t 0 ]]; then + input="{}" +else + input=$(cat 2>/dev/null || echo "{}") +fi + +if ! command -v jq >/dev/null 2>&1; then + echo "[deepclaude statusline: jq not installed]" + exit 0 +fi + +claude_model=$(echo "$input" | jq -r '.model.id // .model.display_name // empty' 2>/dev/null) + +status=$(curl -s --max-time "$TIMEOUT" "http://127.0.0.1:${PROXY_PORT}/_proxy/status" 2>/dev/null || echo "{}") +cost=$(curl -s --max-time "$TIMEOUT" "http://127.0.0.1:${PROXY_PORT}/_proxy/cost" 2>/dev/null || echo "{}") + +if [[ "$status" == "{}" ]]; then + echo "[deepclaude proxy not reachable on :${PROXY_PORT}]" + exit 0 +fi + +backend_host=$(echo "$status" | jq -r '.backend_host // "?"') + +# Prefer the model Claude Code passes on stdin — that's the *main* +# conversation model, stable across the haiku/flash subagent calls +# Claude Code makes for things like topic detection. Falling back to +# last_request would make the statusline flicker to the most recent +# subagent call. +display_model="${claude_model}" +if [[ -z "$display_model" ]]; then + display_model=$(echo "$status" | jq -r '.last_request.client_model // empty') +fi + +# Wire-side mapping for whatever the display model is, looked up from +# the proxy's MODEL_REMAP[state.mode]. Empty if no mapping (default +# mode where env=wire, so display_model is already the backend name). +display_wire="" +if [[ -n "$display_model" ]]; then + display_wire=$(echo "$status" | jq -r --arg m "$display_model" '.model_remap[$m] // empty') +fi +[[ -z "$display_wire" ]] && display_wire="$display_model" + +display_dest=$(echo "$status" | jq -r '.last_request.destination // empty') +[[ -z "$display_dest" ]] && display_dest="$backend_host" + +input_tokens=$(echo "$cost" | jq -r '.total_input_tokens // 0' 2>/dev/null) +output_tokens=$(echo "$cost" | jq -r '.total_output_tokens // 0' 2>/dev/null) +total_cost=$(echo "$cost" | jq -r '.total_cost // 0' 2>/dev/null) +savings=$(echo "$cost" | jq -r '.savings // 0' 2>/dev/null) +anthropic_eq=$(echo "$cost" | jq -r '.anthropic_equivalent // 0' 2>/dev/null) + +# 12345 → 12.3K, 1234567 → 1.2M +fmt_tokens() { + awk -v t="$1" 'BEGIN { + if (t >= 1000000) printf "%.1fM", t/1000000; + else if (t >= 1000) printf "%.1fK", t/1000; + else printf "%d", t; + }' +} +input_fmt=$(fmt_tokens "$input_tokens") +output_fmt=$(fmt_tokens "$output_tokens") +cost_fmt=$(awk -v c="$total_cost" 'BEGIN { printf "$%.2f", c }') + +# Show savings tail only when we'd round to >= $0.01. Percent is savings +# as a fraction of what Anthropic would have charged. +savings_part="" +if awk -v s="$savings" 'BEGIN { exit !(s >= 0.005) }'; then + savings_part=$(awk -v s="$savings" -v a="$anthropic_eq" 'BEGIN { + pct = (a > 0) ? (s / a) * 100 : 0; + printf " (saved $%.2f, %.0f%%)", s, pct; + }') +fi + +if [[ -n "$display_model" && "$display_model" != "$display_wire" ]]; then + model_part="[$display_model → $display_wire on $display_dest]" +elif [[ -n "$display_model" ]]; then + model_part="[$display_model on $display_dest]" +else + model_part="[deepclaude on $display_dest]" +fi + +# Trailing blank line gives a one-row gap below the status line. Closest a +# shell statusLine command can get to bottom padding. +printf '%s\n\n' "${model_part} · ↑${input_fmt} ↓${output_fmt} · ${cost_fmt}${savings_part}" diff --git a/deepclaude.sh b/deepclaude.sh old mode 100644 new mode 100755 index 5f59e3a..7f8c04b --- a/deepclaude.sh +++ b/deepclaude.sh @@ -4,7 +4,17 @@ set -euo pipefail -SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +# Resolve SCRIPT_DIR through any symlink chain (e.g. /usr/local/bin/deepclaude +# -> /path/to/repo/deepclaude.sh) so $SCRIPT_DIR/proxy/... works regardless of +# how the script was invoked. +_source="${BASH_SOURCE[0]}" +while [ -L "$_source" ]; do + _dir="$(cd "$(dirname "$_source")" && pwd)" + _source="$(readlink "$_source")" + [[ "$_source" != /* ]] && _source="$_dir/$_source" +done +SCRIPT_DIR="$(cd "$(dirname "$_source")" && pwd)" +unset _source _dir # --- Config --- DEEPSEEK_URL="https://api.deepseek.com/anthropic" @@ -85,6 +95,60 @@ set_model_env() { export CLAUDE_CODE_EFFORT_LEVEL="max" } +backend_long_name() { + case "$1" in + ds|deepseek) echo "deepseek" ;; + or|openrouter) echo "openrouter" ;; + fw|fireworks) echo "fireworks" ;; + anthropic) echo "anthropic" ;; + *) echo "ERROR: Unknown backend '$1'. Use: ds, or, fw, anthropic" >&2; return 1 ;; + esac +} + +# Sets PROXY_PID, PROXY_PORT, PROXY_LOG as script globals so the EXIT trap +# can clean up the node child. Must be called WITHOUT command substitution +# — $(start_proxy) runs in a subshell and globals never reach the parent. +# Requires: RESOLVED_URL, RESOLVED_KEY, BACKEND already set. +start_proxy() { + local backend_long + backend_long=$(backend_long_name "$BACKEND") || exit 1 + + PROXY_LOG="${PROXY_LOG:-/tmp/deepclaude-proxy.$$.log}" + : > "$PROXY_LOG" + node "$SCRIPT_DIR/proxy/start-proxy.js" "$RESOLVED_URL" "$RESOLVED_KEY" "$backend_long" >> "$PROXY_LOG" 2>&1 & + PROXY_PID=$! + + # The proxy emits a banner line, then a bare-numeric port line on a + # successful bind. Match the bare integer to skip the banner; do not + # introduce other numeric-only stdout in proxy startup. + local proxy_port="" + local tries=0 + while [[ -z "$proxy_port" ]] && [[ $tries -lt 30 ]]; do + if kill -0 "$PROXY_PID" 2>/dev/null; then + # `|| true`: with `set -o pipefail`, grep no-match (exit 1) + # would otherwise exit the script; we expect zero matches on + # early iterations before the proxy has emitted its port. + proxy_port=$(grep -E '^[0-9]+$' "$PROXY_LOG" 2>/dev/null | head -1 || true) + else + echo "ERROR: Proxy process died during startup" >&2 + echo " Log: $PROXY_LOG" >&2 + tail -20 "$PROXY_LOG" >&2 2>/dev/null + exit 1 + fi + [[ -z "$proxy_port" ]] && sleep 0.2 + tries=$((tries + 1)) + done + + if [[ -z "$proxy_port" ]]; then + echo "ERROR: Proxy failed to report a port within 6s" >&2 + echo " Log: $PROXY_LOG" >&2 + tail -20 "$PROXY_LOG" >&2 2>/dev/null + exit 1 + fi + + PROXY_PORT="$proxy_port" +} + show_status() { echo "" echo " deepclaude — Backend Status" @@ -151,6 +215,38 @@ show_help() { echo " CHEAPCLAUDE_DEFAULT_BACKEND Default backend (default: ds)" } +# Auto-installs the deepclaude statusLine into ~/.claude/settings.json on +# every launch. No-op if the user already has a statusLine configured +# (either ours or their own custom command). Silent skip if jq isn't on +# PATH so deepclaude still launches without it. +ensure_statusline_installed() { + command -v jq >/dev/null 2>&1 || return 0 + + local script_path="$SCRIPT_DIR/bin/deepclaude-statusline" + [[ -x "$script_path" ]] || return 0 + + local settings_dir="$HOME/.claude" + local settings_file="$settings_dir/settings.json" + mkdir -p "$settings_dir" + [[ -f "$settings_file" ]] || echo '{}' > "$settings_file" + + local existing + existing=$(jq -r '.statusLine.command // empty' "$settings_file" 2>/dev/null || echo "") + + if [[ -z "$existing" ]]; then + local tmp + tmp=$(mktemp) + if jq --arg cmd "$script_path" \ + '. + {statusLine: {type: "command", command: $cmd}}' \ + "$settings_file" > "$tmp"; then + mv "$tmp" "$settings_file" + echo " Installed deepclaude statusLine in $settings_file" + else + rm -f "$tmp" + fi + fi +} + do_switch() { local backend="$SWITCH_BACKEND" case "$backend" in @@ -206,18 +302,22 @@ launch_claude() { fi resolve_backend + ensure_statusline_installed + + echo " Starting model proxy for $BACKEND..." + start_proxy + echo " Proxy log: $PROXY_LOG" echo " Launching Claude Code via $BACKEND..." - echo " Endpoint: $RESOLVED_URL" + echo " Proxy on :$PROXY_PORT -> $RESOLVED_URL" echo " Model: $RESOLVED_OPUS (main) + $RESOLVED_HAIKU (subagents)" echo "" - export ANTHROPIC_BASE_URL="$RESOLVED_URL" - export ANTHROPIC_AUTH_TOKEN="$RESOLVED_KEY" + export ANTHROPIC_BASE_URL="http://127.0.0.1:$PROXY_PORT" set_model_env - unset ANTHROPIC_API_KEY - exec claude "$@" + # Don't `exec` — the EXIT trap needs to fire to stop the proxy. + claude "$@" } launch_remote() { @@ -231,6 +331,7 @@ launch_remote() { fi resolve_backend + ensure_statusline_installed echo " Starting model proxy for $BACKEND..." diff --git a/proxy/model-proxy.js b/proxy/model-proxy.js index 85a9295..fd2a4e6 100644 --- a/proxy/model-proxy.js +++ b/proxy/model-proxy.js @@ -146,6 +146,9 @@ export function startModelProxy({ targetUrl, apiKey, startPort = 3200, backends, apiKey: startBackend ? startBackend.apiKey : apiKey, useBearer: startBackend ? startBackend.useBearer : initialBearer, hadNonAnthropicSession: !!startBackend, + // Last /v1/messages we forwarded; surfaced via /_proxy/status so a + // statusLine integration can show client → wire mapping live. + lastRequest: null, }; let reqCount = 0; @@ -163,6 +166,8 @@ export function startModelProxy({ targetUrl, apiKey, startPort = 3200, backends, const summary = {}; let totalActual = 0; let totalAnthropic = 0; + let totalInput = 0; + let totalOutput = 0; for (const [backend, tokens] of Object.entries(costs)) { const p = PRICING_PER_M[backend] || PRICING_PER_M._single; const ap = PRICING_PER_M.anthropic; @@ -170,6 +175,8 @@ export function startModelProxy({ targetUrl, apiKey, startPort = 3200, backends, const anthropicEq = (tokens.input * ap.input + tokens.output * ap.output) / 1_000_000; totalActual += actual; totalAnthropic += anthropicEq; + totalInput += tokens.input; + totalOutput += tokens.output; summary[backend] = { input_tokens: tokens.input, output_tokens: tokens.output, @@ -180,6 +187,8 @@ export function startModelProxy({ targetUrl, apiKey, startPort = 3200, backends, } return { backends: summary, + total_input_tokens: totalInput, + total_output_tokens: totalOutput, total_cost: +totalActual.toFixed(4), anthropic_equivalent: +totalAnthropic.toFixed(4), savings: +((totalAnthropic - totalActual).toFixed(4)), @@ -216,8 +225,14 @@ export function startModelProxy({ targetUrl, apiKey, startPort = 3200, backends, clientRes.writeHead(200, { 'content-type': 'application/json' }); clientRes.end(JSON.stringify({ mode: state.mode, + backend_host: state.target.hostname, uptime: Math.round((Date.now() - t0Global) / 1000), requests: reqCount, + last_request: state.lastRequest, + // Statusline looks up the wire-side mapping for whatever + // model Claude Code says it's using (via stdin), without + // having to duplicate the table in shell. + model_remap: MODEL_REMAP[state.mode] || {}, })); return; } @@ -315,17 +330,33 @@ export function startModelProxy({ targetUrl, apiKey, startPort = 3200, backends, clientReq.on('end', () => { let body = Buffer.concat(chunks); - // Remap Anthropic model names to backend-specific names - if (isModelCall && MODEL_REMAP[state.mode]) { + // Remap Anthropic model names to backend-specific names, and + // capture client → wire mapping for /_proxy/status. + let clientModel = null; + let wireModel = null; + if (MODEL_PATHS.includes(urlPath)) { try { const parsed = JSON.parse(body); - const mapped = MODEL_REMAP[state.mode][parsed.model]; - if (mapped) { - console.log(`[MODEL-PROXY] #${reqId} model remap: ${parsed.model} → ${mapped}`); - parsed.model = mapped; - body = Buffer.from(JSON.stringify(parsed)); + clientModel = parsed.model || null; + wireModel = clientModel; + if (isModelCall && MODEL_REMAP[state.mode]) { + const mapped = MODEL_REMAP[state.mode][parsed.model]; + if (mapped) { + console.log(`[MODEL-PROXY] #${reqId} model remap: ${parsed.model} → ${mapped}`); + parsed.model = mapped; + wireModel = mapped; + body = Buffer.from(JSON.stringify(parsed)); + } } } catch { /* not JSON or parse error, pass through */ } + if (clientModel) { + state.lastRequest = { + client_model: clientModel, + wire_model: wireModel, + destination: dest.hostname, + timestamp: Date.now(), + }; + } } // Strip thinking blocks before forwarding. @@ -348,7 +379,16 @@ export function startModelProxy({ targetUrl, apiKey, startPort = 3200, backends, if (isModelCall) { try { const parsed = JSON.parse(body); - stripAllThinkingBlocks(parsed); + // DeepSeek's anthropic-compat endpoint expects its own + // thinking blocks passed back verbatim for continuity + // ("content[].thinking ... must be passed back"), so + // we don't strip thinking blocks here. Top-level + // thinking/context_management still go — non-Anthropic + // backends don't honor Anthropic's extended-thinking + // spec consistently, and a stale config field is a + // noisier error than no config at all. + delete parsed.thinking; + delete parsed.context_management; body = Buffer.from(JSON.stringify(parsed)); } catch { /* pass through */ } } diff --git a/proxy/start-proxy.js b/proxy/start-proxy.js index 5847076..cb57f29 100644 --- a/proxy/start-proxy.js +++ b/proxy/start-proxy.js @@ -7,9 +7,10 @@ const BACKEND_DEFS = { fireworks: { url: 'https://api.fireworks.ai/inference/v1', keyEnv: 'FIREWORKS_API_KEY' }, }; -// Legacy mode: start-proxy.js (used by deepclaude.sh/ps1) +// Legacy mode: start-proxy.js [defaultMode] (used by deepclaude.sh/ps1) const targetUrl = process.argv[2] || process.env.CHEAPCLAUDE_TARGET_URL; const apiKey = process.argv[3] || process.env.CHEAPCLAUDE_API_KEY; +const legacyDefaultMode = process.argv[4] || process.env.CHEAPCLAUDE_DEFAULT_MODE; if (targetUrl && apiKey) { // Legacy single-backend mode @@ -24,7 +25,7 @@ if (targetUrl && apiKey) { targetUrl, apiKey, backends: hasBackends ? backends : undefined, - defaultMode: hasBackends ? undefined : undefined, + defaultMode: legacyDefaultMode || undefined, }); console.log(port); } else {