Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
100 changes: 100 additions & 0 deletions bin/deepclaude-statusline
Original file line number Diff line number Diff line change
@@ -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}"
113 changes: 107 additions & 6 deletions deepclaude.sh
100644 → 100755
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down Expand Up @@ -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"
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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() {
Expand All @@ -231,6 +331,7 @@ launch_remote() {
fi

resolve_backend
ensure_statusline_installed

echo " Starting model proxy for $BACKEND..."

Expand Down
56 changes: 48 additions & 8 deletions proxy/model-proxy.js
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand All @@ -163,13 +166,17 @@ 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;
const actual = (tokens.input * p.input + tokens.output * p.output) / 1_000_000;
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,
Expand All @@ -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)),
Expand Down Expand Up @@ -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;
}
Expand Down Expand Up @@ -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.
Expand All @@ -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 */ }
}
Expand Down
5 changes: 3 additions & 2 deletions proxy/start-proxy.js
Original file line number Diff line number Diff line change
Expand Up @@ -7,9 +7,10 @@ const BACKEND_DEFS = {
fireworks: { url: 'https://api.fireworks.ai/inference/v1', keyEnv: 'FIREWORKS_API_KEY' },
};

// Legacy mode: start-proxy.js <targetUrl> <apiKey> (used by deepclaude.sh/ps1)
// Legacy mode: start-proxy.js <targetUrl> <apiKey> [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
Expand All @@ -24,7 +25,7 @@ if (targetUrl && apiKey) {
targetUrl,
apiKey,
backends: hasBackends ? backends : undefined,
defaultMode: hasBackends ? undefined : undefined,
defaultMode: legacyDefaultMode || undefined,
});
console.log(port);
} else {
Expand Down