diff --git a/README.md b/README.md index 7dc8902..7f5558f 100644 --- a/README.md +++ b/README.md @@ -1,66 +1,108 @@ -# openclaw-claude-proxy +# openclaw-claude-proxy (OCP) > **Already paying for Claude Pro/Max? Use it as your OpenClaw model provider — $0 extra API cost.** -A lightweight, zero-dependency proxy that lets [OpenClaw](https://github.com/openclaw/openclaw) agents talk to Claude through your existing subscription. One command to set up, one file to run. +A lightweight, zero-dependency proxy that lets [OpenClaw](https://github.com/openclaw/openclaw) agents talk to Claude through your existing subscription. One command to set up, one file to run. Now with built-in plan usage monitoring, runtime settings, and a CLI. -## v2.5.0 — Emergency Fix: Sliding-Window Circuit Breaker +## What's New in v3.0.0 -**Incident (2026-03-22):** Multi-agent burst (ClawTeam with 5+ Opus agents) caused cascading timeout failure. The old consecutive-count circuit breaker (threshold=3) tripped within seconds, blocking ALL requests globally — including unrelated agents and new sessions. With fallback models removed, this resulted in complete service outage ("LLM request timed out." on every message). +### `/ocp` — Your Proxy Command Center -**Root cause:** v2.4.0's circuit breaker counted consecutive failures per model. When ClawTeam spawned multiple concurrent Opus agents and Claude API had moderate latency, 3 quick timeouts opened the breaker for the entire model. With `fallbacks: []`, the gateway had no alternative path. +Full management interface available from Telegram, Discord, or any terminal. -**What's new in v2.5.0:** -- **Sliding-window circuit breaker** — counts failures in a 5-minute window (default: 6 failures) instead of 3 consecutive. Multi-agent bursts no longer trip the breaker instantly. -- **Graduated backoff** — cooldown doubles on each re-open (120s → 240s → 300s cap), resets fully on first success. Prevents oscillation between open/half-open during extended API issues. -- **Multi-probe half-open** — allows 2 concurrent probe requests in half-open state (was 1), improving recovery speed. -- **Increased default timeouts** — Opus first-byte 60s→90s, Sonnet 45s→60s, overall 120s→300s, max concurrent 5→8. Designed for large agent system prompts (30K+ chars). -- **Health endpoint shows breaker state** — `/health` now exposes per-model breaker state (window failures, cooldown, reopen count). Status is "degraded" when any breaker is open. +``` +$ ocp usage +Plan Usage Limits +───────────────────────────────────── + Current session 3% used + Resets in 4h 32m (Tue, Mar 24, 10:00 PM) -**New env vars:** + Weekly (all models) 3% used + Resets in 6d 6h (Tue, Mar 31, 12:00 AM) -| Variable | Default | Description | -|----------|---------|-------------| -| `CLAUDE_BREAKER_WINDOW` | `300000` | Sliding window duration (ms) for failure counting | -| `CLAUDE_BREAKER_HALF_OPEN_MAX` | `2` | Max concurrent probe requests in half-open state | + Extra usage off -**Updated defaults:** +Model Stats +Model Req OK Er AvgT MaxT AvgP MaxP +────────────────────────────────────────────────────── +haiku 1 1 0 6s 6s 0K 0K +opus 2 2 0 20s 26s 42K 43K +sonnet 2 2 0 24s 24s 41K 41K +Total 5 -| Variable | Old Default | New Default | -|----------|-------------|-------------| -| `CLAUDE_TIMEOUT` | `120000` | `300000` | -| `CLAUDE_FIRST_BYTE_TIMEOUT` | `45000` | `90000` | -| `CLAUDE_MAX_CONCURRENT` | `5` | `8` | -| `CLAUDE_BREAKER_THRESHOLD` | `3` | `6` | -| `CLAUDE_BREAKER_COOLDOWN` | `60000` | `120000` | +Proxy: up 0h 37m | 5 reqs | 0 err | 0 timeout +``` -**Upgrade:** Pull latest and restart the proxy. The new defaults take effect immediately. If you have custom env vars set in your plist/service file, review and adjust them. +**All commands:** + +``` +$ ocp --help +ocp usage Plan usage limits & model stats +ocp status Quick overview +ocp health Proxy diagnostics +ocp settings View tunable settings +ocp settings Update a setting at runtime +ocp logs [N] [level] Recent logs (default: 20, error) +ocp models Available models +ocp sessions Active sessions +ocp clear Clear all sessions +ocp restart Restart proxy +ocp restart gateway Restart gateway +``` + +In **Telegram/Discord**, use `/ocp usage`, `/ocp settings`, etc. — registered as a native slash command via the OCP gateway plugin. + +### Runtime Settings (No Restart Needed) + +``` +$ ocp settings +OCP Settings +───────────────────────────────────── + timeout 300000 ms Overall request timeout + firstByteTimeout 90000 ms Base first-byte timeout + maxConcurrent 8 Max concurrent claude processes + sessionTTL 3600000 ms Session idle expiry + maxPromptChars 150000 chars Prompt truncation limit + +Timeout Tiers (first-byte): + opus base=150000ms perChar=0.0005 + sonnet base=120000ms perChar=0.0005 + haiku base= 45000ms perChar=0.0001 +``` + +Change any setting live: + +``` +$ ocp settings maxPromptChars 200000 +✓ maxPromptChars = 200000 + +$ ocp settings maxConcurrent 999 +✗ maxConcurrent: value 999 out of range [1, 32] +``` + +### Circuit Breaker Removed + +The v2.5.0 circuit breaker has been **removed entirely**. It was designed for direct API connections but caused cascading failures in the CLI-proxy architecture — once API got briefly slow, the breaker blocked ALL agents for 120s+, making the problem worse. With CLI spawning, timeouts are transient and don't benefit from back-off. + +### Prompt Truncation Guard + +New safety valve prevents runaway context from conversation history accumulation (a recurring issue where prompts balloon from 40K to 400K+ chars). + +- Default limit: **150K characters** (configurable via `maxPromptChars`) +- When exceeded: keeps system messages + as many recent messages as fit +- Logs `prompt_truncated` events for monitoring + +### Increased Timeouts + +| Model | Old Base | New Base | Per 100K chars | +|-------|----------|----------|----------------| +| Opus | 90s | **150s** | +50s | +| Sonnet | 60s | **120s** | +50s | +| Haiku | 30s | **45s** | +10s | --- -## v2.0.0 — Major Upgrade - -**What's new:** -- **On-demand spawning** — eliminates the pool crash loops, DEGRADED states, and stdin timeout errors from v1.x. Each request spawns a fresh `claude -p` process with stdin written immediately. No more stale workers, no more backoff spirals. -- **Session management** — multi-turn conversations use `--resume` to avoid resending full history. Reduces token waste and enables Claude Code's built-in context compression on long conversations. -- **Full tool access** — expanded default tools (Bash, Read, Write, Edit, Glob, Grep, WebSearch, WebFetch, Agent). Configurable via `CLAUDE_ALLOWED_TOOLS` or bypass all checks with `CLAUDE_SKIP_PERMISSIONS=true`. -- **System prompt pass-through** — set `CLAUDE_SYSTEM_PROMPT` to inject context into every request. -- **MCP config support** — set `CLAUDE_MCP_CONFIG` to load MCP servers (Telegram, etc.) into claude -p calls. -- **Concurrency control** — `CLAUDE_MAX_CONCURRENT` prevents runaway process spawning (default: 5). -- **Auth health monitoring** — periodic `claude auth status` checks with status exposed on `/health`. -- **Session API** — `GET /sessions` to list, `DELETE /sessions` to clear active sessions. -- **Improved diagnostics** — `/health` endpoint shows stats, active sessions, recent errors, auth status, and full config. - -**Coexistence with Claude Code interactive mode:** -OCP and Claude Code (interactive/Telegram) run on completely different paths and can coexist on the same machine without conflict: -- OCP: `localhost:3456` (HTTP) → spawns `claude -p` processes (per-request, stateless) -- CC: MCP protocol (in-process) → persistent interactive session -- No shared ports, no shared processes, no shared sessions - -**Daemon advantage over CC:** -OCP runs as a system daemon (launchd/systemd) that auto-starts on boot and auto-recovers from crashes. Unlike Claude Code interactive mode, OCP does not require a terminal session to stay open — it survives disconnects, reboots, and SSH drops. Combined with OpenClaw's memory system, this means your agents never lose continuity. - -## How it works +## How It Works ``` OpenClaw Gateway → proxy (localhost:3456) → claude -p CLI → Anthropic (via OAuth) @@ -68,13 +110,7 @@ OpenClaw Gateway → proxy (localhost:3456) → claude -p CLI → Anthropic (via The proxy translates OpenAI-compatible `/v1/chat/completions` requests into `claude -p` CLI calls. Anthropic sees normal Claude Code usage under your subscription — no API billing, no separate key. -## Prerequisites - -- **Node.js** >= 18 -- **Claude CLI** installed and authenticated (`claude login`) -- **OpenClaw** installed - -## Quick Start (Node.js) +## Quick Start ```bash git clone https://github.com/dtzp555-max/openclaw-claude-proxy.git @@ -84,119 +120,69 @@ cd openclaw-claude-proxy node setup.mjs ``` -That's it. The setup script will: +The setup script will: 1. Verify Claude CLI is installed and authenticated 2. Add `claude-local` provider to `openclaw.json` -3. Add auth profiles to all agents -4. Start the proxy -5. Install auto-start on login (launchd on macOS, systemd on Linux) +3. Start the proxy and install auto-start (launchd on macOS, systemd on Linux) -Then set your preferred Claude model as default: +Then set your preferred model: ```bash -openclaw config set agents.defaults.model.primary "claude-local/claude-opus-4-6" +openclaw config set agents.defaults.model.primary "claude-local/claude-sonnet-4-6" openclaw gateway restart ``` -## Session Management (v2.0) +### Install the CLI -Multi-turn conversations can use sessions to avoid resending full message history on every request. +The `ocp` command is included in the repo: -**How to enable:** Include a `session_id` or `conversation_id` field in your request body, or set the `X-Session-Id` / `X-Conversation-Id` header. +```bash +# Option 1: symlink to PATH +ln -sf $(pwd)/ocp /usr/local/bin/ocp +# Option 2: npm link (if installed globally) +npm link +``` + +### Install the Gateway Plugin (for Telegram/Discord) + +Copy the plugin to the OpenClaw extensions directory: + +```bash +cp -r ocp-plugin/ ~/.openclaw/extensions/ocp/ +# Or into the bundled extensions: +cp -r ocp-plugin/ /opt/homebrew/lib/node_modules/openclaw/dist/extensions/ocp/ +``` + +Add to `~/.openclaw/openclaw.json`: ```json { - "model": "claude-opus-4-6", - "session_id": "conv-abc-123", - "messages": [ - {"role": "user", "content": "Hello"}, - {"role": "assistant", "content": "Hi there!"}, - {"role": "user", "content": "What did I just say?"} - ] + "plugins": { + "allow": ["ocp"], + "entries": { "ocp": { "enabled": true } } + } } ``` -**First request** with a new session_id: all messages are sent, session is persisted via `--session-id`. -**Subsequent requests** with the same session_id: only the latest user message is sent via `--resume`, reducing token consumption. +Restart the gateway: `openclaw gateway restart` -Sessions expire after 1 hour of inactivity (configurable via `CLAUDE_SESSION_TTL`). +## API Endpoints -**API endpoints:** -- `GET /sessions` — list all active sessions -- `DELETE /sessions` — clear all sessions - -## Security - -- **Localhost only** — the proxy binds to `127.0.0.1` and is not exposed to the internet or your local network -- **Bearer token auth (optional)** — set `PROXY_API_KEY` to require a Bearer token on all requests (except `/health`). When unset, auth is disabled for backwards compatibility -- **No API keys for Claude** — authentication to Anthropic goes through Claude CLI's OAuth session, no Anthropic credentials are stored in the proxy -- **Auto-start via launchd/systemd** — `node setup.mjs` installs a user-level launch agent (macOS) or systemd user service (Linux) so the proxy starts automatically on login -- **Remove auto-start** at any time: - -```bash -node uninstall.mjs -``` - -## Manual Install - -### 1. Start the proxy - -```bash -node server.mjs -# or in background: -bash start.sh -``` - -### 2. Configure OpenClaw - -Add to `~/.openclaw/openclaw.json` under `models.providers`: - -```json -"claude-local": { - "baseUrl": "http://127.0.0.1:3456/v1", - "api": "openai-completions", - "apiKey": "", - "models": [ - { - "id": "claude-opus-4-6", - "name": "Claude Opus 4.6", - "reasoning": true, - "input": ["text"], - "cost": { "input": 0, "output": 0, "cacheRead": 0, "cacheWrite": 0 }, - "contextWindow": 200000, - "maxTokens": 16384 - }, - { - "id": "claude-sonnet-4-6", - "name": "Claude Sonnet 4.6", - "reasoning": true, - "input": ["text"], - "cost": { "input": 0, "output": 0, "cacheRead": 0, "cacheWrite": 0 }, - "contextWindow": 200000, - "maxTokens": 16384 - }, - { - "id": "claude-haiku-4", - "name": "Claude Haiku 4", - "reasoning": false, - "input": ["text"], - "cost": { "input": 0, "output": 0, "cacheRead": 0, "cacheWrite": 0 }, - "contextWindow": 200000, - "maxTokens": 8192 - } - ] -} -``` - -### 3. Set as default model - -```bash -openclaw config set agents.defaults.model.primary "claude-local/claude-opus-4-6" -openclaw gateway restart -``` +| Endpoint | Method | Description | +|----------|--------|-------------| +| `/v1/models` | GET | List available models | +| `/v1/chat/completions` | POST | Chat completion (streaming + non-streaming) | +| `/health` | GET | Comprehensive health check | +| `/usage` | GET | Plan usage limits + per-model stats | +| `/status` | GET | Combined overview (usage + health) | +| `/settings` | GET | View tunable settings | +| `/settings` | PATCH | Update settings at runtime | +| `/logs` | GET | Recent log entries (`?n=20&level=error`) | +| `/sessions` | GET | List active sessions | +| `/sessions` | DELETE | Clear all sessions | ## Available Models -| Model ID | Claude CLI model | Notes | +| Model ID | Claude CLI Model | Notes | |----------|-----------------|-------| | `claude-opus-4-6` | claude-opus-4-6 | Most capable, slower | | `claude-sonnet-4-6` | claude-sonnet-4-6 | Good balance of speed/quality | @@ -209,87 +195,101 @@ openclaw gateway restart | `CLAUDE_PROXY_PORT` | `3456` | Listen port | | `CLAUDE_BIN` | *(auto-detect)* | Path to claude binary | | `CLAUDE_TIMEOUT` | `300000` | Overall request timeout (ms) | -| `CLAUDE_FIRST_BYTE_TIMEOUT` | `90000` | Base first-byte timeout (ms), adaptive by model tier | -| `CLAUDE_ALLOWED_TOOLS` | `Bash,Read,...,Agent` | Comma-separated tools to pre-approve | -| `CLAUDE_SKIP_PERMISSIONS` | `false` | Set `true` to bypass all permission checks | -| `CLAUDE_SYSTEM_PROMPT` | *(empty)* | System prompt appended to all requests | -| `CLAUDE_MCP_CONFIG` | *(empty)* | Path to MCP server config JSON file | -| `CLAUDE_SESSION_TTL` | `3600000` | Session expiry in ms (default: 1 hour) | +| `CLAUDE_FIRST_BYTE_TIMEOUT` | `90000` | Base first-byte timeout (ms) | | `CLAUDE_MAX_CONCURRENT` | `8` | Max concurrent claude processes | -| `CLAUDE_BREAKER_THRESHOLD` | `6` | Failures in window before circuit opens | -| `CLAUDE_BREAKER_COOLDOWN` | `120000` | Base cooldown (ms) before half-open (graduates on re-open) | -| `CLAUDE_BREAKER_WINDOW` | `300000` | Sliding window duration (ms) for failure counting | -| `CLAUDE_BREAKER_HALF_OPEN_MAX` | `2` | Max concurrent probe requests in half-open state | +| `CLAUDE_MAX_PROMPT_CHARS` | `150000` | Prompt truncation limit (chars) | +| `CLAUDE_SESSION_TTL` | `3600000` | Session expiry (ms, default: 1 hour) | +| `CLAUDE_ALLOWED_TOOLS` | `Bash,Read,...,Agent` | Comma-separated tools to pre-approve | +| `CLAUDE_SKIP_PERMISSIONS` | `false` | Bypass all permission checks | +| `CLAUDE_SYSTEM_PROMPT` | *(empty)* | System prompt appended to all requests | +| `CLAUDE_MCP_CONFIG` | *(empty)* | Path to MCP server config JSON | | `PROXY_API_KEY` | *(unset)* | Bearer token for API authentication | -## API Endpoints +## Session Management -- `GET /v1/models` — List available models -- `POST /v1/chat/completions` — Chat completion (streaming + non-streaming) -- `GET /health` — Comprehensive health check (stats, sessions, auth, config) -- `GET /sessions` — List active sessions -- `DELETE /sessions` — Clear all sessions +Multi-turn conversations use `--resume` to avoid resending full history on every request. -## Authentication +Include a `session_id` field in the request body or `X-Session-Id` header: -The proxy supports optional Bearer token authentication via the `PROXY_API_KEY` environment variable. - -**When `PROXY_API_KEY` is set**, all requests (except `GET /health`) must include a valid `Authorization: Bearer ` header. Requests with a missing or invalid token receive a `401 Unauthorized` response. - -**When `PROXY_API_KEY` is not set**, authentication is disabled and all requests are accepted. - -```bash -# Start with auth enabled -PROXY_API_KEY=my-secret-token node server.mjs +```json +{ + "model": "claude-sonnet-4-6", + "session_id": "conv-abc-123", + "messages": [ + {"role": "user", "content": "Hello"}, + {"role": "assistant", "content": "Hi!"}, + {"role": "user", "content": "What did I just say?"} + ] +} ``` -## Architecture: v1 vs v2 +Sessions expire after 1 hour of inactivity (configurable via `CLAUDE_SESSION_TTL`). -| | v1.x (pool) | v2.0 (on-demand) | +## Security + +- **Localhost only** — binds to `127.0.0.1`, not exposed to the network +- **Bearer token auth (optional)** — set `PROXY_API_KEY` to require auth on all requests except `/health` +- **No API keys** — authentication to Anthropic goes through Claude CLI's OAuth session +- **Auto-start** — launchd (macOS) / systemd (Linux) via `node setup.mjs` +- **Remove auto-start**: `node uninstall.mjs` + +## Architecture + +| | v1.x (pool) | v2.0+ (on-demand) | |---|---|---| | Process lifecycle | Pre-spawn idle workers | Spawn per request | -| Crash handling | Backoff → DEGRADED → manual restart | No crash loops (no idle workers) | -| Session support | None (stateless) | --resume with session tracking | -| Tool access | 6 tools hardcoded | Configurable, expanded defaults | -| System prompt | None | CLAUDE_SYSTEM_PROMPT env | -| MCP support | None | CLAUDE_MCP_CONFIG env | -| Concurrency | Unlimited (dangerous) | CLAUDE_MAX_CONCURRENT limit | -| Auth monitoring | None | Periodic health checks | -| Diagnostics | Basic /health | Full stats, sessions, errors | +| Crash handling | Backoff spiral | No crash loops | +| Session support | None | `--resume` with tracking | +| Tool access | 6 hardcoded | Configurable, expanded | +| Prompt guard | None | Truncation at 150K chars | +| Monitoring | Basic `/health` | `/usage`, `/status`, `/settings`, `/logs` | +| CLI | None | `ocp` command | +| Gateway plugin | None | `/ocp` slash command | ## Coexistence with Claude Code -OCP and Claude Code interactive mode (including Telegram bots) are completely independent: +OCP and Claude Code interactive mode are completely independent: -| | OCP (this proxy) | CC interactive | +| | OCP (this proxy) | Claude Code | |---|---|---| | Protocol | HTTP (localhost:3456) | MCP (in-process) | | Process model | Per-request spawn | Persistent session | -| Lifecycle | Daemon (auto-start, auto-recover) | Requires terminal | -| Permission model | Pre-approved tools | Interactive prompts | +| Lifecycle | Daemon (auto-start) | Requires terminal | | Use case | Automated agent work | Human-in-the-loop | -Both can run on the same machine simultaneously. No shared state, no port conflicts. +Both run on the same machine simultaneously. No shared state, no port conflicts. -## Recovery after OpenClaw upgrade - -OpenClaw upgrades (`npm update -g openclaw`) **do not overwrite** the user config at `~/.openclaw/openclaw.json`. However, if the claude-local models stop working after an upgrade: - -### One-command recovery +## Recovery After OpenClaw Upgrade ```bash -cd ~/.openclaw/projects/claude-proxy # or wherever you cloned it -git pull # pull latest version -node setup.mjs # reconfigure OpenClaw + start proxy +cd ~/.openclaw/projects/claude-proxy +git pull +node setup.mjs openclaw gateway restart ``` -## Notes +## Changelog -- Cost shows as $0 because billing goes through your Claude subscription -- Each request spawns a `claude -p` process; concurrent requests are capped by `CLAUDE_MAX_CONCURRENT` -- The proxy must run on the same machine as the Claude CLI (uses local OAuth) -- Session data is stored by Claude CLI on disk; session map is in-memory (lost on proxy restart) +### v3.0.0 (2026-03-24) +- **`/ocp` CLI** — full management from terminal (`ocp usage`, `ocp settings`, etc.) +- **`/ocp` gateway plugin** — native slash command in Telegram/Discord +- **Plan usage monitoring** — real-time session/weekly limits via Anthropic API rate-limit headers +- **Per-model stats** — request count, avg/max elapsed time, avg/max prompt size +- **Runtime settings** — `PATCH /settings` to tune timeouts, concurrency, prompt limits without restart +- **Prompt truncation** — auto-truncate prompts exceeding 150K chars to prevent timeout cascades +- **Circuit breaker removed** — caused more harm than good in CLI-proxy architecture +- **Timeout increases** — Opus 150s, Sonnet 120s, Haiku 45s (base first-byte) +- **New endpoints** — `/usage`, `/status`, `/settings`, `/logs` + +### v2.5.0 (2026-03-22) +- Sliding-window circuit breaker (replaced consecutive-count) +- Graduated backoff, multi-probe half-open +- Increased default timeouts for large agent prompts + +### v2.0.0 +- On-demand spawning (replaced pool architecture) +- Session management with `--resume` +- Full tool access, system prompt, MCP config support ## License diff --git a/ocp b/ocp new file mode 100755 index 0000000..cfe2dc8 --- /dev/null +++ b/ocp @@ -0,0 +1,384 @@ +#!/usr/bin/env bash +# ocp — OpenClaw Proxy CLI +# Usage: ocp [args] +# +# Talks to the local claude-proxy at http://127.0.0.1:3456 + +set -euo pipefail + +PROXY="http://127.0.0.1:3456" + +_json() { python3 -m json.tool 2>/dev/null || cat; } + +_bar() { + local pct=$1 width=20 + local filled=$(( pct * width / 100 )) + local empty=$(( width - filled )) + printf '[' + printf '█%.0s' $(seq 1 $filled 2>/dev/null) || true + printf '░%.0s' $(seq 1 $empty 2>/dev/null) || true + printf '] %d%%' "$pct" +} + +# ── usage ──────────────────────────────────────────────────────────────── +cmd_usage_help() { + cat <<'EOF' +ocp usage — Show Claude plan usage limits + +Displays current session utilization, weekly limits, extra usage status, +and proxy request statistics. Data is fetched from the Anthropic API +via a minimal probe call (cached for 5 minutes). + +Usage: ocp usage +EOF +} + +cmd_usage() { + local data + data=$(curl -sf --max-time 15 "$PROXY/usage" 2>&1) || { echo "Error: proxy unreachable"; exit 1; } + + echo "$data" | python3 -c " +import sys, json + +d = json.loads(sys.stdin.read()) +p = d['plan'] +s = p['currentSession'] +w = p['weeklyLimits']['allModels'] +e = p['extraUsage'] +px = d['proxy'] +models = d.get('models', {}) + +print('Plan Usage Limits') +print('─────────────────────────────────────') +print(f' Current session {s[\"percent\"]:>4} used') +print(f' Resets in {s[\"resetsIn\"]} ({s[\"resetsAtHuman\"]})') +print() +print(f' Weekly (all models) {w[\"percent\"]:>4} used') +print(f' Resets in {w[\"resetsIn\"]} ({w[\"resetsAtHuman\"]})') +print() +status_icon = 'on' if e['status'] == 'allowed' else 'off' +print(f' Extra usage {status_icon}') +print() + +if models: + print('Model Stats (since proxy start)') + print('─────────────────────────────────────────────────────────────────────') + hdr = f' {\"Model\":<25} {\"Reqs\":>5} {\"OK\":>4} {\"Err\":>4} {\"Avg Time\":>9} {\"Max Time\":>9} {\"Avg Prompt\":>11} {\"Max Prompt\":>11}' + print(hdr) + print(' ' + '─' * (len(hdr) - 2)) + total_reqs = 0 + for model in sorted(models): + m = models[model] + total_reqs += m['requests'] + avg_t = f'{m[\"avgElapsed\"]/1000:.0f}s' if m['avgElapsed'] else '-' + max_t = f'{m[\"maxElapsed\"]/1000:.0f}s' if m['maxElapsed'] else '-' + avg_p = f'{m[\"avgPromptChars\"]/1000:.0f}K' if m['avgPromptChars'] else '-' + max_p = f'{m[\"maxPromptChars\"]/1000:.0f}K' if m['maxPromptChars'] else '-' + print(f' {model:<25} {m[\"requests\"]:>5} {m[\"successes\"]:>4} {m[\"errors\"]:>4} {avg_t:>9} {max_t:>9} {avg_p:>11} {max_p:>11}') + print(f' {\"Total\":<25} {total_reqs:>5}') +else: + print(' No model requests yet.') + +print() +print(f'Proxy: up {px[\"uptime\"]} | {px[\"totalRequests\"]} reqs | {px[\"activeRequests\"]} active | {px[\"errors\"]} err | {px[\"timeouts\"]} timeout') +" +} + +# ── status ─────────────────────────────────────────────────────────────── +cmd_status_help() { + cat <<'EOF' +ocp status — Quick combined overview (usage + health) + +Usage: ocp status +EOF +} + +cmd_status() { + curl -sf --max-time 15 "$PROXY/status" | _json +} + +# ── health ─────────────────────────────────────────────────────────────── +cmd_health_help() { + cat <<'EOF' +ocp health — Proxy health and diagnostics + +Shows proxy status, version, uptime, auth, config, sessions, and recent errors. + +Usage: ocp health +EOF +} + +cmd_health() { + curl -sf --max-time 10 "$PROXY/health" | _json +} + +# ── logs ───────────────────────────────────────────────────────────────── +cmd_logs_help() { + cat <<'EOF' +ocp logs — Show recent proxy log entries + +Usage: ocp logs [N] [LEVEL] + +Arguments: + N Number of entries to show (default: 20, max: 200) + LEVEL Filter level: error, warn, info, all (default: error) + +Examples: + ocp logs Last 20 errors + ocp logs 50 all Last 50 entries of any level + ocp logs 10 warn Last 10 warnings +EOF +} + +cmd_logs() { + local n=${1:-20} + local level=${2:-error} + curl -sf --max-time 10 "$PROXY/logs?n=$n&level=$level" | python3 -c " +import sys, json +d = json.loads(sys.stdin.read()) +entries = d.get('entries', []) +if not entries: + print(f'No {d.get(\"level\",\"\")} log entries.') +else: + for e in entries: + if 'raw' in e: + print(e['raw'][:200]) + else: + ts = e.get('ts','')[:19] + ev = e.get('event','?') + lvl = e.get('level','') + model = e.get('model','') + extra = ' '.join(f'{k}={v}' for k,v in e.items() if k not in ('ts','event','level','model')) + parts = [ts, lvl.upper(), ev] + if model: parts.append(model) + if extra: parts.append(extra) + print(' | '.join(parts)) +" +} + +# ── models ─────────────────────────────────────────────────────────────── +cmd_models_help() { + cat <<'EOF' +ocp models — List available Claude models + +Usage: ocp models +EOF +} + +cmd_models() { + curl -sf --max-time 5 "$PROXY/v1/models" | python3 -c " +import sys, json +d = json.loads(sys.stdin.read()) +for m in d.get('data', []): + print(f\" {m['id']}\") +" +} + +# ── sessions ───────────────────────────────────────────────────────────── +cmd_sessions_help() { + cat <<'EOF' +ocp sessions — List active CLI sessions + +Usage: ocp sessions +EOF +} + +cmd_sessions() { + curl -sf --max-time 5 "$PROXY/sessions" | python3 -c " +import sys, json +d = json.loads(sys.stdin.read()) +sessions = d.get('sessions', []) +if not sessions: + print('No active sessions.') +else: + for s in sessions: + print(f\" {s['id'][:16]}... model={s['model']} msgs={s['messages']} last={s['lastUsed']}\") +" +} + +# ── clear ──────────────────────────────────────────────────────────────── +cmd_clear_help() { + cat <<'EOF' +ocp clear — Clear all active CLI sessions + +Usage: ocp clear +EOF +} + +cmd_clear() { + local result + result=$(curl -sf --max-time 5 -X DELETE "$PROXY/sessions") + local count + count=$(echo "$result" | python3 -c "import sys,json; print(json.loads(sys.stdin.read())['cleared'])") + echo "Cleared $count sessions." +} + +# ── restart ────────────────────────────────────────────────────────────── +cmd_restart_help() { + cat <<'EOF' +ocp restart — Restart the proxy or gateway + +Usage: + ocp restart Restart the Claude proxy service + ocp restart gateway Restart the OpenClaw gateway + (briefly disconnects all Telegram/Discord bots) +EOF +} + +cmd_restart() { + if [[ "${1:-}" == "gateway" ]]; then + echo "Restarting gateway..." + openclaw gateway restart 2>&1 + else + echo "Restarting proxy..." + launchctl kickstart -k gui/501/ai.openclaw.proxy 2>/dev/null || { + echo "launchctl failed, trying kill + restart..." + pkill -f "claude-proxy/server.mjs" 2>/dev/null || true + sleep 1 + cd "$HOME/.openclaw/projects/claude-proxy" && nohup node server.mjs >> "$HOME/.openclaw/logs/proxy.log" 2>&1 & + } + sleep 3 + if curl -sf --max-time 5 "$PROXY/health" > /dev/null 2>&1; then + echo "✓ Proxy restarted successfully." + cmd_usage + else + echo "✗ Proxy not responding after restart." + fi + fi +} + +# ── settings ───────────────────────────────────────────────────────────── +cmd_settings_help() { + cat <<'EOF' +ocp settings — View or update proxy settings at runtime + +Usage: + ocp settings Show all tunable settings + ocp settings Update a setting (no restart needed) + +Tunable keys: + timeout Overall request timeout (ms) [30000 - 600000] + firstByteTimeout Base first-byte timeout (ms) [15000 - 300000] + maxConcurrent Max concurrent claude processes [1 - 32] + sessionTTL Session idle expiry (ms) [60000 - 86400000] + maxPromptChars Prompt truncation limit (chars) [10000 - 1000000] + tiers.opus.base Opus first-byte timeout base (ms) [30000 - 600000] + tiers.opus.perChar Opus per-char timeout (ms/char) [0 - 0.01] + tiers.sonnet.base Sonnet first-byte timeout base (ms) [30000 - 600000] + tiers.sonnet.perChar Sonnet per-char timeout (ms/char) [0 - 0.01] + tiers.haiku.base Haiku first-byte timeout base (ms) [15000 - 300000] + tiers.haiku.perChar Haiku per-char timeout (ms/char) [0 - 0.01] + +Examples: + ocp settings maxPromptChars 200000 + ocp settings tiers.sonnet.base 150000 +EOF +} + +cmd_settings() { + if [[ -z "${1:-}" ]]; then + # GET — show all settings + curl -sf --max-time 5 "$PROXY/settings" | python3 -c " +import sys, json +d = json.loads(sys.stdin.read()) +print('OCP Settings') +print('─────────────────────────────────────') +for k in ('timeout','firstByteTimeout','maxConcurrent','sessionTTL','maxPromptChars'): + v = d.get(k, {}) + val = v.get('value', '?') + unit = v.get('unit', '') + desc = v.get('desc', '') + print(f' {k:<20} {val:>8} {unit:<6} {desc}') +t = d.get('tiers', {}) +print() +print('Timeout Tiers (first-byte):') +for tier in ('opus','sonnet','haiku'): + info = t.get(tier, {}) + print(f' {tier:<8} base={info.get(\"base\",\"?\"):>6}ms perChar={info.get(\"perPromptChar\",\"?\")}') +" + elif [[ "${1:-}" == "--help" || "${1:-}" == "-h" ]]; then + cmd_settings_help + elif [[ -z "${2:-}" ]]; then + echo "Usage: ocp settings " + echo "Run 'ocp settings --help' for available keys." + return 1 + else + # PATCH — update a setting + local key="$1" + local value="$2" + local result + result=$(curl -s --max-time 5 -X PATCH "$PROXY/settings" \ + -H "Content-Type: application/json" \ + -d "{\"$key\": $value}" 2>&1) + if echo "$result" | python3 -c "import sys,json; d=json.loads(sys.stdin.read()); errs=d.get('errors',[]); exit(1 if errs else 0)" 2>/dev/null; then + echo "✓ $key = $value" + else + echo "$result" | python3 -c " +import sys, json +d = json.loads(sys.stdin.read()) +for e in d.get('errors', []): + print(f'✗ {e}') +" 2>/dev/null || echo "✗ $result" + fi + fi +} + +# ── help ───────────────────────────────────────────────────────────────── +cmd_help() { + cat <<'EOF' +ocp — OpenClaw Proxy CLI + +Usage: ocp [args] + +Commands: + usage Plan usage limits + status Quick overview (usage + health) + health Proxy diagnostics + settings View or update tunable settings + logs [N] [level] Recent logs (default: 20, error) + models Available models + sessions Active sessions + clear Clear all sessions + restart Restart proxy + restart gateway Restart gateway + +Run 'ocp --help' for details on a specific command. +EOF +} + +# ── dispatch ───────────────────────────────────────────────────────────── +# Check for --help/-h on any subcommand: ocp --help +subcmd="${1:-help}" +shift 2>/dev/null || true + +# Global --help +if [[ "$subcmd" == "--help" || "$subcmd" == "-h" || "$subcmd" == "help" ]]; then + cmd_help + exit 0 +fi + +# Per-subcommand --help +for arg in "$@"; do + if [[ "$arg" == "--help" || "$arg" == "-h" ]]; then + fn="cmd_${subcmd}_help" + if declare -f "$fn" > /dev/null 2>&1; then + "$fn" + else + echo "No help available for '$subcmd'." + fi + exit 0 + fi +done + +case "$subcmd" in + usage) cmd_usage ;; + status) cmd_status ;; + health) cmd_health ;; + settings) cmd_settings "${1:-}" "${2:-}" ;; + logs) cmd_logs "${1:-20}" "${2:-error}" ;; + models) cmd_models ;; + sessions) cmd_sessions ;; + clear) cmd_clear ;; + restart) cmd_restart "${1:-}" ;; + *) echo "Unknown command: $subcmd"; echo ""; cmd_help; exit 1 ;; +esac diff --git a/ocp-plugin/index.js b/ocp-plugin/index.js new file mode 100644 index 0000000..6d90070 --- /dev/null +++ b/ocp-plugin/index.js @@ -0,0 +1,217 @@ +/** + * OCP Plugin — registers /ocp as a native slash command in OpenClaw gateway. + * Calls the local claude-proxy at http://127.0.0.1:3456 and formats the response. + */ + +const PROXY = "http://127.0.0.1:3456"; + +// Wrap output in monospace code block for Telegram/Discord alignment +function mono(text) { return "```\n" + text + "\n```"; } + +async function fetchJSON(path) { + const resp = await fetch(`${PROXY}${path}`, { signal: AbortSignal.timeout(15000) }); + if (!resp.ok) throw new Error(`proxy ${resp.status}: ${resp.statusText}`); + return resp.json(); +} + +function bar(pct, width = 16) { + const filled = Math.round(pct * width); + return "█".repeat(filled) + "░".repeat(width - filled); +} + +function fmtMs(ms) { + return ms >= 1000 ? `${(ms / 1000).toFixed(0)}s` : `${ms}ms`; +} + +function fmtChars(c) { + return c >= 1000 ? `${(c / 1000).toFixed(0)}K` : `${c}`; +} + +// ── Subcommand handlers ───────────────────────────────────────────────── + +async function cmdUsage() { + const d = await fetchJSON("/usage"); + const s = d.plan.currentSession; + const w = d.plan.weeklyLimits.allModels; + const e = d.plan.extraUsage; + const px = d.proxy; + const models = d.models || {}; + + let out = "Plan Usage Limits\n"; + out += "─────────────────────────────\n"; + out += `Current session ${bar(s.utilization)} ${s.percent}\n`; + out += ` Resets in ${s.resetsIn} (${s.resetsAtHuman})\n\n`; + out += `Weekly (all) ${bar(w.utilization)} ${w.percent}\n`; + out += ` Resets in ${w.resetsIn} (${w.resetsAtHuman})\n\n`; + out += `Extra usage ${e.status === "allowed" ? "on" : "off"}\n\n`; + + const modelNames = Object.keys(models).sort(); + if (modelNames.length > 0) { + out += "Model Stats\n"; + const hdr = `${"Model".padEnd(14)} ${"Req".padStart(4)} ${"OK".padStart(3)} ${"Er".padStart(3)} ${"AvgT".padStart(5)} ${"MaxT".padStart(5)} ${"AvgP".padStart(5)} ${"MaxP".padStart(5)}`; + out += hdr + "\n"; + out += "─".repeat(hdr.length) + "\n"; + let total = 0; + for (const name of modelNames) { + const m = models[name]; + total += m.requests; + const short = name.replace("claude-", "").replace("-4-5-20251001", "").replace("-4-6", ""); + out += `${short.padEnd(14)} ${String(m.requests).padStart(4)} ${String(m.successes).padStart(3)} ${String(m.errors).padStart(3)} ${fmtMs(m.avgElapsed).padStart(5)} ${fmtMs(m.maxElapsed).padStart(5)} ${fmtChars(m.avgPromptChars).padStart(5)} ${fmtChars(m.maxPromptChars).padStart(5)}\n`; + } + out += `${"Total".padEnd(14)} ${String(total).padStart(4)}\n`; + } + + out += `\nProxy: up ${px.uptime} | ${px.totalRequests} reqs | ${px.errors} err | ${px.timeouts} timeout`; + return out; +} + +async function cmdHealth() { + const d = await fetchJSON("/health"); + let out = `Status: ${d.status} | v${d.version}\n`; + out += `Uptime: ${d.uptimeHuman}\n`; + out += `Auth: ${d.auth?.ok ? "ok" : d.auth?.message || "unknown"}\n`; + out += `Binary: ${d.claudeBinaryOk ? "ok" : "missing"}\n`; + out += `Sessions: ${d.sessions?.length || 0} active\n`; + out += `Requests: ${d.stats?.totalRequests || 0} total, ${d.stats?.activeRequests || 0} active\n`; + out += `Errors: ${d.stats?.errors || 0} | Timeouts: ${d.stats?.timeouts || 0}\n`; + if (d.recentErrors?.length) { + out += "\nRecent errors:\n"; + for (const e of d.recentErrors.slice(-3)) { + out += ` ${e.time?.slice(11, 19) || "?"} ${e.message}\n`; + } + } + return out; +} + +async function cmdStatus() { + const d = await fetchJSON("/status"); + const icon = d.proxy?.status === "ok" ? "🟢" : d.proxy?.status === "degraded" ? "🟡" : "🔴"; + let out = `${icon} ${d.proxy?.status} | v${d.proxy?.version} | up ${d.proxy?.uptime} | auth ${d.proxy?.auth}\n`; + out += `Sessions: ${d.proxy?.activeSessions || 0}\n`; + out += `Requests: ${d.requests?.total || 0} | active ${d.requests?.active || 0} | err ${d.requests?.errors || 0} | timeout ${d.requests?.timeouts || 0}\n`; + if (d.plan?.currentSession) { + out += `\nSession: ${d.plan.currentSession.percent} (resets ${d.plan.currentSession.resetsIn})\n`; + out += `Weekly: ${d.plan.weeklyLimits?.allModels?.percent} (resets ${d.plan.weeklyLimits?.allModels?.resetsIn})`; + } + return out; +} + +async function cmdSettings(args) { + if (!args) { + const d = await fetchJSON("/settings"); + let out = "OCP Settings\n─────────────────────────────\n"; + for (const k of ["timeout", "firstByteTimeout", "maxConcurrent", "sessionTTL", "maxPromptChars"]) { + const v = d[k]; + if (v) out += `${k.padEnd(20)} ${String(v.value).padStart(8)} ${(v.unit || "").padEnd(6)} ${v.desc}\n`; + } + if (d.tiers) { + out += "\nTimeout tiers:\n"; + for (const t of ["opus", "sonnet", "haiku"]) { + const info = d.tiers[t]; + if (info) out += ` ${t.padEnd(8)} base=${info.base}ms perChar=${info.perPromptChar}\n`; + } + } + return out; + } + + // Parse "key value" + const parts = args.trim().split(/\s+/); + if (parts.length < 2 || parts[0] === "--help" || parts[0] === "-h") { + return "Usage: /ocp settings \nKeys: timeout, firstByteTimeout, maxConcurrent, sessionTTL, maxPromptChars, tiers.opus.base, tiers.sonnet.base, tiers.haiku.base, tiers.*.perChar"; + } + const [key, val] = parts; + const numVal = Number(val); + if (isNaN(numVal)) return `Error: value must be a number, got "${val}"`; + + const resp = await fetch(`${PROXY}/settings`, { + method: "PATCH", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ [key]: numVal }), + signal: AbortSignal.timeout(5000), + }); + const d = await resp.json(); + if (d.errors?.length) return `✗ ${d.errors.join("; ")}`; + return `✓ ${key} = ${numVal}`; +} + +async function cmdModels() { + const d = await fetchJSON("/v1/models"); + return (d.data || []).map((m) => ` ${m.id}`).join("\n") || "No models."; +} + +async function cmdSessions() { + const d = await fetchJSON("/sessions"); + if (!d.sessions?.length) return "No active sessions."; + return d.sessions.map((s) => ` ${s.id.slice(0, 16)}… model=${s.model} msgs=${s.messages}`).join("\n"); +} + +async function cmdClear() { + const resp = await fetch(`${PROXY}/sessions`, { method: "DELETE", signal: AbortSignal.timeout(5000) }); + const d = await resp.json(); + return `Cleared ${d.cleared} sessions.`; +} + +async function cmdLogs(args) { + const parts = (args || "").trim().split(/\s+/); + const n = parseInt(parts[0]) || 20; + const level = parts[1] || "error"; + const d = await fetchJSON(`/logs?n=${n}&level=${level}`); + if (!d.entries?.length) return `No ${level} log entries.`; + return d.entries.map((e) => { + if (e.raw) return e.raw.slice(0, 120); + return `${(e.ts || "").slice(11, 19)} ${(e.level || "").toUpperCase()} ${e.event || "?"} ${e.model || ""}`; + }).join("\n"); +} + +function cmdHelp() { + return `OCP Commands +───────────────────────────── +/ocp usage Plan usage & model stats +/ocp status Quick overview +/ocp health Proxy diagnostics +/ocp settings View tunable settings +/ocp settings Update a setting +/ocp logs [N] [level] Recent logs (default: 20, error) +/ocp models Available models +/ocp sessions Active sessions +/ocp clear Clear all sessions`; +} + +// ── Plugin entry point ────────────────────────────────────────────────── + +export default function (api) { + console.log("[ocp] OCP plugin loading, registering /ocp command..."); + api.registerCommand({ + name: "ocp", + description: "OpenClaw Proxy commands — usage, health, settings, logs, etc.", + acceptsArgs: true, + requireAuth: true, + handler: async (ctx) => { + const raw = (ctx.args || "").trim(); + const spaceIdx = raw.indexOf(" "); + const subcmd = spaceIdx === -1 ? raw : raw.slice(0, spaceIdx); + const subargs = spaceIdx === -1 ? "" : raw.slice(spaceIdx + 1).trim(); + + try { + let text; + switch (subcmd) { + case "usage": text = await cmdUsage(); break; + case "health": text = await cmdHealth(); break; + case "status": text = await cmdStatus(); break; + case "settings": text = await cmdSettings(subargs || null); break; + case "models": text = await cmdModels(); break; + case "sessions": text = await cmdSessions(); break; + case "clear": text = await cmdClear(); break; + case "logs": text = await cmdLogs(subargs); break; + case "help": case "--help": case "-h": case "": + text = cmdHelp(); break; + default: + text = `Unknown subcommand: ${subcmd}\n\n${cmdHelp()}`; + } + return { text: mono(text) }; + } catch (err) { + return { text: `OCP error: ${err.message}` }; + } + }, + }); +} diff --git a/ocp-plugin/openclaw.plugin.json b/ocp-plugin/openclaw.plugin.json new file mode 100644 index 0000000..26c5bd2 --- /dev/null +++ b/ocp-plugin/openclaw.plugin.json @@ -0,0 +1,17 @@ +{ + "id": "ocp", + "name": "OCP Commands", + "description": "Slash commands for the OpenClaw Proxy — /ocp usage, /ocp settings, /ocp health, etc.", + "version": "1.0.0", + "configSchema": { + "type": "object", + "additionalProperties": false, + "properties": { + "proxyUrl": { + "type": "string", + "default": "http://127.0.0.1:3456", + "description": "URL of the Claude proxy" + } + } + } +} \ No newline at end of file diff --git a/ocp-plugin/package.json b/ocp-plugin/package.json new file mode 100644 index 0000000..237ad14 --- /dev/null +++ b/ocp-plugin/package.json @@ -0,0 +1,14 @@ +{ + "name": "ocp", + "version": "1.0.0", + "description": "Slash commands for the OpenClaw Proxy", + "main": "index.js", + "type": "module", + "keywords": ["openclaw", "plugin", "ocp", "proxy"], + "license": "MIT", + "openclaw": { + "type": "plugin", + "id": "ocp", + "pluginManifest": "openclaw.plugin.json" + } +} \ No newline at end of file diff --git a/package.json b/package.json index 88fb651..421df26 100644 --- a/package.json +++ b/package.json @@ -1,10 +1,11 @@ { "name": "openclaw-claude-proxy", - "version": "2.5.0", - "description": "OpenAI-compatible proxy for Claude CLI v2 — sliding-window circuit breaker, adaptive first-byte timeout, structured logging", + "version": "3.0.0", + "description": "OpenAI-compatible proxy for Claude CLI — plan usage monitoring, runtime settings, prompt truncation, OCP CLI", "type": "module", "bin": { - "openclaw-claude-proxy": "./server.mjs" + "openclaw-claude-proxy": "./server.mjs", + "ocp": "./ocp" }, "scripts": { "start": "node server.mjs", diff --git a/server.mjs b/server.mjs index b24e372..354ebf1 100644 --- a/server.mjs +++ b/server.mjs @@ -85,10 +85,11 @@ function resolveClaude() { } // ── Configuration ─────────────────────────────────────────────────────── +// Settings marked with `let` can be changed at runtime via PATCH /settings. const PORT = parseInt(process.env.CLAUDE_PROXY_PORT || "3456", 10); const CLAUDE = resolveClaude(); -const TIMEOUT = parseInt(process.env.CLAUDE_TIMEOUT || "300000", 10); -const BASE_FIRST_BYTE_TIMEOUT = parseInt(process.env.CLAUDE_FIRST_BYTE_TIMEOUT || "90000", 10); +let TIMEOUT = parseInt(process.env.CLAUDE_TIMEOUT || "300000", 10); +let BASE_FIRST_BYTE_TIMEOUT = parseInt(process.env.CLAUDE_FIRST_BYTE_TIMEOUT || "90000", 10); const PROXY_API_KEY = process.env.PROXY_API_KEY || ""; const SKIP_PERMISSIONS = process.env.CLAUDE_SKIP_PERMISSIONS === "true"; const ALLOWED_TOOLS = (process.env.CLAUDE_ALLOWED_TOOLS || @@ -96,8 +97,8 @@ const ALLOWED_TOOLS = (process.env.CLAUDE_ALLOWED_TOOLS || ).split(",").map(s => s.trim()).filter(Boolean); const SYSTEM_PROMPT = process.env.CLAUDE_SYSTEM_PROMPT || ""; const MCP_CONFIG = process.env.CLAUDE_MCP_CONFIG || ""; -const SESSION_TTL = parseInt(process.env.CLAUDE_SESSION_TTL || "3600000", 10); -const MAX_CONCURRENT = parseInt(process.env.CLAUDE_MAX_CONCURRENT || "8", 10); +let SESSION_TTL = parseInt(process.env.CLAUDE_SESSION_TTL || "3600000", 10); +let MAX_CONCURRENT = parseInt(process.env.CLAUDE_MAX_CONCURRENT || "8", 10); const BREAKER_THRESHOLD = parseInt(process.env.CLAUDE_BREAKER_THRESHOLD || "6", 10); const BREAKER_COOLDOWN = parseInt(process.env.CLAUDE_BREAKER_COOLDOWN || "120000", 10); const BREAKER_WINDOW = parseInt(process.env.CLAUDE_BREAKER_WINDOW || "300000", 10); @@ -116,129 +117,28 @@ function logEvent(level, event, data = {}) { } } -// ── Per-model sliding-window circuit breaker ───────────────────────────── -// Uses a time-windowed failure rate instead of consecutive-count. This prevents -// multi-agent burst scenarios (e.g. ClawTeam spawning 5+ Opus agents) from -// tripping the breaker after just 3 quick timeouts. -// -// States: closed → open → half-open → closed (on success) or open (on failure) -// Half-open allows up to BREAKER_HALF_OPEN_MAX concurrent probes (not just 1). -// Cooldown uses graduated backoff: doubles on each re-open, resets on success. -const breakers = new Map(); // cliModel → BreakerState +// ── Circuit breaker (DISABLED) ────────────────────────────────────────── +// Disabled: CLI proxy has its own retry logic, and the breaker was causing +// cascading failures — once API got briefly slow, ALL agents lost connectivity +// for 120s+ due to the breaker rejecting every request. +// The timeout/failure tracking stubs below are kept as no-ops so callers +// don't need to be changed. +function breakerRecordSuccess(_cliModel) {} +function breakerRecordTimeout(_cliModel) {} +function getBreakerState(_cliModel) { return { state: "closed" }; } +function getBreakerSnapshot() { return { _note: "circuit breaker disabled" }; } -function newBreakerState() { - return { - state: "closed", // closed | open | half-open - failureTimestamps: [], // timestamps of failures within the sliding window - successCount: 0, // successes within window (for rate calculation) - openedAt: 0, - currentCooldown: BREAKER_COOLDOWN, // graduates on repeated opens - reopenCount: 0, // how many times breaker has re-opened without a full reset - halfOpenProbes: 0, // active probe requests in half-open state - }; -} +// Legacy constants kept for /health display +const _BREAKER_DISABLED_NOTE = "disabled"; +/* Original breaker code removed — see git history for v2.5.0 implementation. + Re-enable by reverting this block if needed in the future. + Reason for disabling: CLI-proxy architecture means each request spawns a + fresh claude process. The breaker was designed for persistent API connections + where a degraded backend benefits from back-off. With CLI spawning, timeouts + are usually transient (API load, large prompts) and the breaker's 120s+ + cooldown with graduated backoff made things worse, not better. +*/ -function pruneWindow(b) { - const cutoff = Date.now() - BREAKER_WINDOW; - b.failureTimestamps = b.failureTimestamps.filter(ts => ts > cutoff); -} - -function getBreakerState(cliModel) { - if (!breakers.has(cliModel)) { - breakers.set(cliModel, newBreakerState()); - } - const b = breakers.get(cliModel); - - // Auto-recover: if cooldown has elapsed, transition to half-open - if (b.state === "open" && Date.now() - b.openedAt >= b.currentCooldown) { - b.state = "half-open"; - b.halfOpenProbes = 0; - logEvent("info", "breaker_half_open", { model: cliModel, cooldownMs: b.currentCooldown, reopenCount: b.reopenCount }); - } - return b; -} - -function breakerRecordSuccess(cliModel) { - const b = getBreakerState(cliModel); - b.successCount++; - - if (b.state === "half-open") { - b.halfOpenProbes = Math.max(0, b.halfOpenProbes - 1); - } - - if (b.state !== "closed") { - logEvent("info", "breaker_reset", { - model: cliModel, - previousFailures: b.failureTimestamps.length, - previousState: b.state, - reopenCount: b.reopenCount, - }); - // Full reset on success — graduated backoff resets too - b.state = "closed"; - b.openedAt = 0; - b.currentCooldown = BREAKER_COOLDOWN; - b.reopenCount = 0; - b.halfOpenProbes = 0; - b.failureTimestamps = []; - b.successCount = 0; - } -} - -function breakerRecordTimeout(cliModel) { - const b = getBreakerState(cliModel); - const now = Date.now(); - b.failureTimestamps.push(now); - pruneWindow(b); - - if (b.state === "half-open") { - b.halfOpenProbes = Math.max(0, b.halfOpenProbes - 1); - } - - const windowFailures = b.failureTimestamps.length; - logEvent("warn", "breaker_failure", { - model: cliModel, - windowFailures, - threshold: BREAKER_THRESHOLD, - windowMs: BREAKER_WINDOW, - state: b.state, - }); - - if (windowFailures >= BREAKER_THRESHOLD && b.state !== "open") { - b.state = "open"; - b.openedAt = now; - b.halfOpenProbes = 0; - // Graduated backoff: double cooldown on each re-open, cap at 5 min - if (b.reopenCount > 0) { - b.currentCooldown = Math.min(b.currentCooldown * 2, 300000); - } - b.reopenCount++; - logEvent("error", "breaker_open", { - model: cliModel, - windowFailures, - cooldownMs: b.currentCooldown, - reopenCount: b.reopenCount, - }); - } -} - -// Expose breaker snapshot for /health endpoint -function getBreakerSnapshot() { - const snapshot = {}; - for (const [model, b] of breakers) { - pruneWindow(b); - snapshot[model] = { - state: b.state, - windowFailures: b.failureTimestamps.length, - threshold: BREAKER_THRESHOLD, - windowMs: BREAKER_WINDOW, - currentCooldown: b.currentCooldown, - reopenCount: b.reopenCount, - halfOpenProbes: b.halfOpenProbes, - ...(b.openedAt ? { openedAt: new Date(b.openedAt).toISOString() } : {}), - }; - } - return snapshot; -} // ── Model mapping ─────────────────────────────────────────────────────── // Maps request model IDs and aliases to canonical claude CLI model IDs. @@ -290,6 +190,57 @@ const stats = { }; const recentErrors = []; // last 20 errors +// Per-model request stats +const modelStats = new Map(); // cliModel → { requests, errors, timeouts, totalElapsed, maxElapsed, totalPromptChars, maxPromptChars } + +function getModelStats(cliModel) { + if (!modelStats.has(cliModel)) { + modelStats.set(cliModel, { + requests: 0, successes: 0, errors: 0, timeouts: 0, + totalElapsed: 0, maxElapsed: 0, + totalPromptChars: 0, maxPromptChars: 0, + }); + } + return modelStats.get(cliModel); +} + +function recordModelRequest(cliModel, promptChars) { + const m = getModelStats(cliModel); + m.requests++; + m.totalPromptChars += promptChars; + if (promptChars > m.maxPromptChars) m.maxPromptChars = promptChars; +} + +function recordModelSuccess(cliModel, elapsedMs) { + const m = getModelStats(cliModel); + m.successes++; + m.totalElapsed += elapsedMs; + if (elapsedMs > m.maxElapsed) m.maxElapsed = elapsedMs; +} + +function recordModelError(cliModel, isTimeout) { + const m = getModelStats(cliModel); + m.errors++; + if (isTimeout) m.timeouts++; +} + +function getModelStatsSnapshot() { + const result = {}; + for (const [model, m] of modelStats) { + result[model] = { + requests: m.requests, + successes: m.successes, + errors: m.errors, + timeouts: m.timeouts, + avgElapsed: m.successes > 0 ? Math.round(m.totalElapsed / m.successes) : 0, + maxElapsed: m.maxElapsed, + avgPromptChars: m.requests > 0 ? Math.round(m.totalPromptChars / m.requests) : 0, + maxPromptChars: m.maxPromptChars, + }; + } + return result; +} + function trackError(msg) { stats.errors++; recentErrors.push({ time: new Date().toISOString(), message: String(msg).slice(0, 200) }); @@ -353,21 +304,65 @@ function buildCliArgs(cliModel, sessionInfo) { } // ── Format messages to prompt text ────────────────────────────────────── +// Truncation guard: if total chars exceed MAX_PROMPT_CHARS, keep the system +// message(s) + first user message + last N messages, dropping the middle. +// This prevents runaway context from gateway-side conversation accumulation. +let MAX_PROMPT_CHARS = parseInt(process.env.CLAUDE_MAX_PROMPT_CHARS || "150000", 10); + function messagesToPrompt(messages) { - return messages.map((m) => { + const full = messages.map((m) => { const text = typeof m.content === "string" ? m.content : JSON.stringify(m.content); if (m.role === "system") return `[System] ${text}`; if (m.role === "assistant") return `[Assistant] ${text}`; return text; - }).join("\n\n"); + }); + + const joined = full.join("\n\n"); + if (joined.length <= MAX_PROMPT_CHARS) return joined; + + // Truncation: keep system messages, first user msg, and trim from the tail + logEvent("warn", "prompt_truncated", { + originalChars: joined.length, + maxChars: MAX_PROMPT_CHARS, + originalMessages: messages.length, + }); + + const system = []; + const rest = []; + for (let i = 0; i < full.length; i++) { + if (messages[i].role === "system") system.push(full[i]); + else rest.push(full[i]); + } + + // Keep system + as many recent messages as fit + const systemText = system.join("\n\n"); + const budget = MAX_PROMPT_CHARS - systemText.length - 200; // 200 for separator + const kept = []; + let used = 0; + for (let i = rest.length - 1; i >= 0; i--) { + if (used + rest[i].length + 2 > budget) break; + kept.unshift(rest[i]); + used += rest[i].length + 2; + } + + const truncNote = `[System] Note: ${rest.length - kept.length} older messages were truncated to fit context limit.`; + const result = [systemText, truncNote, ...kept].filter(Boolean).join("\n\n"); + + logEvent("info", "prompt_after_truncation", { + chars: result.length, + keptMessages: kept.length, + droppedMessages: rest.length - kept.length, + }); + + return result; } // Model tier multipliers for first-byte timeout. // Opus is much slower to produce first token, especially with large contexts. -const MODEL_TIMEOUT_TIERS = { - "opus": { base: 90000, perPromptChar: 0.00020 }, // 90s base + ~20s per 100k chars - "sonnet": { base: 60000, perPromptChar: 0.00010 }, // 60s base + ~10s per 100k chars - "haiku": { base: 30000, perPromptChar: 0.00005 }, // 30s base + ~5s per 100k chars +let MODEL_TIMEOUT_TIERS = { + "opus": { base: 150000, perPromptChar: 0.00050 }, // 150s base + ~50s per 100k chars + "sonnet": { base: 120000, perPromptChar: 0.00050 }, // 120s base + ~50s per 100k chars + "haiku": { base: 45000, perPromptChar: 0.00010 }, // 45s base + ~10s per 100k chars }; function getModelTier(cliModel) { @@ -392,22 +387,7 @@ function spawnClaudeProcess(model, messages, conversationId) { const cliModel = MODEL_MAP[model] || model; - // Circuit breaker check: fail fast if model is in open state - const breaker = getBreakerState(cliModel); - if (breaker.state === "open") { - const remainingMs = breaker.currentCooldown - (Date.now() - breaker.openedAt); - logEvent("warn", "breaker_rejected", { model: cliModel, remainingCooldownMs: remainingMs, reopenCount: breaker.reopenCount }); - throw new Error(`circuit breaker open for ${cliModel}: ${breaker.failureTimestamps.length} timeouts in window, retry in ${Math.ceil(remainingMs / 1000)}s`); - } - // Half-open: allow limited probe requests - if (breaker.state === "half-open" && breaker.halfOpenProbes >= BREAKER_HALF_OPEN_MAX) { - logEvent("warn", "breaker_half_open_full", { model: cliModel, activeProbes: breaker.halfOpenProbes, max: BREAKER_HALF_OPEN_MAX }); - throw new Error(`circuit breaker half-open for ${cliModel}: ${breaker.halfOpenProbes}/${BREAKER_HALF_OPEN_MAX} probe slots in use, wait for probe result`); - } - if (breaker.state === "half-open") { - breaker.halfOpenProbes++; - logEvent("info", "breaker_probe", { model: cliModel, activeProbes: breaker.halfOpenProbes, max: BREAKER_HALF_OPEN_MAX }); - } + // Circuit breaker: disabled (see comment at top of breaker section) stats.activeRequests++; stats.totalRequests++; @@ -487,12 +467,14 @@ function spawnClaudeProcess(model, messages, conversationId) { proc.stdin.write(prompt); proc.stdin.end(); + recordModelRequest(cliModel, prompt.length); logEvent("info", "claude_spawned", { model: cliModel, promptChars: prompt.length, firstByteTimeout: firstByteTimeoutMs, tier: getModelTier(cliModel), session: conversationId ? conversationId.slice(0, 12) + "..." : "none" }); // First-byte timeout const firstByteTimer = setTimeout(() => { if (!gotFirstByte && !cleaned) { stats.timeouts++; + recordModelError(cliModel, true); breakerRecordTimeout(cliModel); logEvent("error", "first_byte_timeout", { model: cliModel, timeoutMs: firstByteTimeoutMs, promptChars: prompt.length }); try { proc.kill("SIGTERM"); } catch {} @@ -504,6 +486,7 @@ function spawnClaudeProcess(model, messages, conversationId) { const overallTimer = setTimeout(() => { if (!cleaned) { stats.timeouts++; + recordModelError(cliModel, true); breakerRecordTimeout(cliModel); logEvent("error", "request_timeout", { model: cliModel, timeoutMs: TIMEOUT }); try { proc.kill("SIGTERM"); } catch {} @@ -542,11 +525,13 @@ function callClaude(model, messages, conversationId) { const elapsed = Date.now() - t0; cleanup(); if (code !== 0) { + recordModelError(cliModel, false); logEvent("error", "claude_exit", { model: cliModel, code, signal: signal || "none", elapsed, stderr: stderr.slice(0, 300) }); trackError(stderr.slice(0, 300) || stdout.slice(0, 300) || `claude exit ${code}`); handleSessionFailure(); reject(new Error(stderr.slice(0, 300) || stdout.slice(0, 300) || `claude exit ${code}`)); } else { + recordModelSuccess(cliModel, elapsed); breakerRecordSuccess(cliModel); logEvent("info", "claude_ok", { model: cliModel, chars: stdout.length, elapsed, session: convId ? convId.slice(0, 12) + "..." : "none" }); resolve(stdout.trim()); @@ -620,15 +605,14 @@ function callClaudeStreaming(model, messages, conversationId, res) { const elapsed = Date.now() - t0; if (code !== 0) { + recordModelError(cliModel, false); logEvent("error", "claude_exit", { model: cliModel, code, signal: signal || "none", elapsed, stderr: stderr.slice(0, 300) }); trackError(stderr.slice(0, 300) || `claude exit ${code}`); handleSessionFailure(); if (!headersSent && !res.writableEnded && !res.destroyed) { - // No output was sent yet — return a JSON error jsonResponse(res, 500, { error: { message: stderr.slice(0, 300) || `claude exit ${code}`, type: "proxy_error" } }); } else if (!res.writableEnded && !res.destroyed) { - // Already streaming — close the stream gracefully sendSSE(res, { id, object: "chat.completion.chunk", created, model, choices: [{ index: 0, delta: {}, finish_reason: "stop" }], @@ -637,6 +621,7 @@ function callClaudeStreaming(model, messages, conversationId, res) { res.end(); } } else { + recordModelSuccess(cliModel, elapsed); breakerRecordSuccess(cliModel); logEvent("info", "claude_ok", { model: cliModel, chars: totalChars, elapsed, session: convId ? convId.slice(0, 12) + "..." : "none" }); @@ -693,6 +678,337 @@ function completionResponse(res, id, model, content) { }); } +// ── Plan usage probe ──────────────────────────────────────────────────── +// Reads the OAuth token from macOS keychain and makes a minimal API call +// to Anthropic to capture rate-limit headers (plan usage info). +// Caches the result for 5 minutes to avoid excessive API calls. + +let usageCache = { data: null, fetchedAt: 0 }; +const USAGE_CACHE_TTL = 300000; // 5 min + +function getOAuthToken() { + try { + const raw = execFileSync("security", [ + "find-generic-password", "-s", "Claude Code-credentials", "-w" + ], { encoding: "utf8", timeout: 5000 }).trim(); + const creds = JSON.parse(raw); + return creds?.claudeAiOauth?.accessToken || null; + } catch { + return null; + } +} + +async function fetchUsageFromApi() { + const token = getOAuthToken(); + if (!token) { + return { error: "No OAuth token found in keychain" }; + } + + // Minimal API call to haiku (cheapest) with max_tokens=1 — we only need the headers + const body = JSON.stringify({ + model: "claude-haiku-4-5-20251001", + max_tokens: 1, + messages: [{ role: "user", content: "." }], + }); + + const controller = new AbortController(); + const timeout = setTimeout(() => controller.abort(), 15000); + + try { + const resp = await fetch("https://api.anthropic.com/v1/messages", { + method: "POST", + headers: { + "x-api-key": token, + "anthropic-version": "2023-06-01", + "Content-Type": "application/json", + }, + body, + signal: controller.signal, + }); + clearTimeout(timeout); + + // Extract all rate-limit headers + const rl = {}; + for (const [k, v] of resp.headers) { + if (k.startsWith("anthropic-ratelimit")) { + rl[k] = v; + } + } + + // Parse into structured usage object + const now = Date.now(); + const session5hUtil = parseFloat(rl["anthropic-ratelimit-unified-5h-utilization"] || "0"); + const session5hReset = parseInt(rl["anthropic-ratelimit-unified-5h-reset"] || "0", 10); + const weekly7dUtil = parseFloat(rl["anthropic-ratelimit-unified-7d-utilization"] || "0"); + const weekly7dReset = parseInt(rl["anthropic-ratelimit-unified-7d-reset"] || "0", 10); + const overageStatus = rl["anthropic-ratelimit-unified-overage-status"] || "unknown"; + const overageDisabledReason = rl["anthropic-ratelimit-unified-overage-disabled-reason"] || ""; + const status = rl["anthropic-ratelimit-unified-status"] || "unknown"; + const representativeClaim = rl["anthropic-ratelimit-unified-representative-claim"] || ""; + const fallbackPct = parseFloat(rl["anthropic-ratelimit-unified-fallback-percentage"] || "0"); + + function formatReset(epochSec) { + if (!epochSec) return "unknown"; + const diff = epochSec * 1000 - now; + if (diff <= 0) return "now"; + const h = Math.floor(diff / 3600000); + const m = Math.floor((diff % 3600000) / 60000); + if (h > 24) { + const d = Math.floor(h / 24); + return `${d}d ${h % 24}h`; + } + return h > 0 ? `${h}h ${m}m` : `${m}m`; + } + + function resetDay(epochSec) { + if (!epochSec) return ""; + const d = new Date(epochSec * 1000); + return d.toLocaleDateString("en-US", { weekday: "short", month: "short", day: "numeric", hour: "numeric", minute: "2-digit" }); + } + + return { + status, + fetchedAt: new Date(now).toISOString(), + plan: { + currentSession: { + utilization: session5hUtil, + percent: `${Math.round(session5hUtil * 100)}%`, + resetsIn: formatReset(session5hReset), + resetsAt: session5hReset ? new Date(session5hReset * 1000).toISOString() : null, + resetsAtHuman: resetDay(session5hReset), + }, + weeklyLimits: { + allModels: { + utilization: weekly7dUtil, + percent: `${Math.round(weekly7dUtil * 100)}%`, + resetsIn: formatReset(weekly7dReset), + resetsAt: weekly7dReset ? new Date(weekly7dReset * 1000).toISOString() : null, + resetsAtHuman: resetDay(weekly7dReset), + }, + }, + extraUsage: { + status: overageStatus, + disabledReason: overageDisabledReason || undefined, + }, + representativeClaim, + fallbackPercentage: fallbackPct, + }, + proxy: { + totalRequests: stats.totalRequests, + activeRequests: stats.activeRequests, + errors: stats.errors, + timeouts: stats.timeouts, + uptime: `${Math.floor((now - START_TIME) / 3600000)}h ${Math.floor(((now - START_TIME) % 3600000) / 60000)}m`, + }, + models: getModelStatsSnapshot(), + _raw: rl, + }; + } catch (err) { + clearTimeout(timeout); + return { error: `Failed to fetch usage: ${err.message}` }; + } +} + +async function handleUsage(_req, res) { + const now = Date.now(); + let data; + if (usageCache.data && (now - usageCache.fetchedAt) < USAGE_CACHE_TTL) { + data = usageCache.data; + } else { + data = await fetchUsageFromApi(); + if (!data.error) { + usageCache = { data, fetchedAt: now }; + } + } + // Always attach live model stats and proxy stats (not cached) + const uptimeMs = now - START_TIME; + const response = { + ...data, + proxy: { + totalRequests: stats.totalRequests, + activeRequests: stats.activeRequests, + errors: stats.errors, + timeouts: stats.timeouts, + uptime: `${Math.floor(uptimeMs / 3600000)}h ${Math.floor((uptimeMs % 3600000) / 60000)}m`, + }, + models: getModelStatsSnapshot(), + }; + jsonResponse(res, data.error ? 502 : 200, response); +} + +// ── Logs endpoint ────────────────────────────────────────────────────── +// Returns recent structured log entries from the proxy log file. +// GET /logs?n=20&level=error (default: n=30, level=all) +function handleLogs(req, res) { + const url = new URL(req.url, `http://127.0.0.1:${PORT}`); + const n = Math.min(parseInt(url.searchParams.get("n") || "30", 10), 200); + const level = url.searchParams.get("level") || "all"; // all | error | warn | info + + const LOG_PATH = join(process.env.HOME || "/tmp", ".openclaw/logs/proxy.log"); + let lines; + try { + const raw = readFileSync(LOG_PATH, "utf8"); + lines = raw.split("\n").filter(Boolean); + } catch (err) { + return jsonResponse(res, 500, { error: `Cannot read log: ${err.message}` }); + } + + // Parse JSON lines, fall back to raw text + let entries = lines.slice(-n * 3).map(line => { + try { return JSON.parse(line); } catch { return { raw: line }; } + }); + + // Filter by level + if (level !== "all") { + entries = entries.filter(e => { + if (e.level) return e.level === level; + if (level === "error") return e.raw?.includes("error") || e.raw?.includes("Error"); + return true; + }); + } + + entries = entries.slice(-n); + + return jsonResponse(res, 200, { + count: entries.length, + level, + entries, + }); +} + +// ── Status endpoint (combined summary) ───────────────────────────────── +async function handleStatus(_req, res) { + const now = Date.now(); + const uptimeMs = now - START_TIME; + + // Get usage (from cache if fresh) + let usage = null; + if (usageCache.data && (now - usageCache.fetchedAt) < USAGE_CACHE_TTL) { + usage = usageCache.data; + } else { + usage = await fetchUsageFromApi(); + if (!usage.error) usageCache = { data: usage, fetchedAt: now }; + } + + // Auth + let binaryOk = false; + try { accessSync(CLAUDE, constants.X_OK); binaryOk = true; } catch {} + + return jsonResponse(res, 200, { + proxy: { + status: binaryOk && authStatus.ok !== false ? "ok" : "degraded", + version: VERSION, + uptime: `${Math.floor(uptimeMs / 3600000)}h ${Math.floor((uptimeMs % 3600000) / 60000)}m`, + auth: authStatus.ok ? "ok" : authStatus.message, + activeSessions: sessions.size, + }, + requests: { + total: stats.totalRequests, + active: stats.activeRequests, + errors: stats.errors, + timeouts: stats.timeouts, + }, + plan: usage?.plan || usage?.error || null, + recentErrors: recentErrors.slice(-3), + }); +} + +// ── Settings endpoint ─────────────────────────────────────────────────── +// GET /settings → view current tunable parameters +// PATCH /settings → update one or more parameters at runtime +// +// Tunable keys and their types/ranges: +const SETTINGS_SCHEMA = { + timeout: { type: "number", min: 30000, max: 600000, unit: "ms", desc: "Overall request timeout" }, + firstByteTimeout: { type: "number", min: 15000, max: 300000, unit: "ms", desc: "Base first-byte timeout" }, + maxConcurrent: { type: "number", min: 1, max: 32, unit: "", desc: "Max concurrent claude processes" }, + sessionTTL: { type: "number", min: 60000, max: 86400000, unit: "ms", desc: "Session idle expiry" }, + maxPromptChars: { type: "number", min: 10000, max: 1000000, unit: "chars", desc: "Prompt truncation limit" }, + "tiers.opus.base": { type: "number", min: 30000, max: 600000, unit: "ms", desc: "Opus base first-byte timeout" }, + "tiers.opus.perChar": { type: "number", min: 0, max: 0.01, unit: "ms/char", desc: "Opus per-char timeout addition" }, + "tiers.sonnet.base": { type: "number", min: 30000, max: 600000, unit: "ms", desc: "Sonnet base first-byte timeout" }, + "tiers.sonnet.perChar": { type: "number", min: 0, max: 0.01, unit: "ms/char", desc: "Sonnet per-char timeout addition" }, + "tiers.haiku.base": { type: "number", min: 15000, max: 300000, unit: "ms", desc: "Haiku base first-byte timeout" }, + "tiers.haiku.perChar": { type: "number", min: 0, max: 0.01, unit: "ms/char", desc: "Haiku per-char timeout addition" }, +}; + +function getSettings() { + return { + timeout: { value: TIMEOUT, ...SETTINGS_SCHEMA.timeout }, + firstByteTimeout: { value: BASE_FIRST_BYTE_TIMEOUT, ...SETTINGS_SCHEMA.firstByteTimeout }, + maxConcurrent: { value: MAX_CONCURRENT, ...SETTINGS_SCHEMA.maxConcurrent }, + sessionTTL: { value: SESSION_TTL, ...SETTINGS_SCHEMA.sessionTTL }, + maxPromptChars: { value: MAX_PROMPT_CHARS, ...SETTINGS_SCHEMA.maxPromptChars }, + tiers: { + opus: { base: MODEL_TIMEOUT_TIERS.opus.base, perPromptChar: MODEL_TIMEOUT_TIERS.opus.perPromptChar }, + sonnet: { base: MODEL_TIMEOUT_TIERS.sonnet.base, perPromptChar: MODEL_TIMEOUT_TIERS.sonnet.perPromptChar }, + haiku: { base: MODEL_TIMEOUT_TIERS.haiku.base, perPromptChar: MODEL_TIMEOUT_TIERS.haiku.perPromptChar }, + }, + }; +} + +function applySettingUpdate(key, value) { + const schema = SETTINGS_SCHEMA[key]; + if (!schema) return `unknown setting: ${key}`; + if (typeof value !== schema.type) return `${key}: expected ${schema.type}, got ${typeof value}`; + if (value < schema.min || value > schema.max) return `${key}: value ${value} out of range [${schema.min}, ${schema.max}]`; + + switch (key) { + case "timeout": TIMEOUT = value; break; + case "firstByteTimeout": BASE_FIRST_BYTE_TIMEOUT = value; break; + case "maxConcurrent": MAX_CONCURRENT = value; break; + case "sessionTTL": SESSION_TTL = value; break; + case "maxPromptChars": MAX_PROMPT_CHARS = value; break; + case "tiers.opus.base": MODEL_TIMEOUT_TIERS.opus.base = value; break; + case "tiers.opus.perChar": MODEL_TIMEOUT_TIERS.opus.perPromptChar = value; break; + case "tiers.sonnet.base": MODEL_TIMEOUT_TIERS.sonnet.base = value; break; + case "tiers.sonnet.perChar": MODEL_TIMEOUT_TIERS.sonnet.perPromptChar = value; break; + case "tiers.haiku.base": MODEL_TIMEOUT_TIERS.haiku.base = value; break; + case "tiers.haiku.perChar": MODEL_TIMEOUT_TIERS.haiku.perPromptChar = value; break; + default: return `${key}: not implemented`; + } + logEvent("info", "setting_changed", { key, value }); + return null; // success +} + +async function handleSettings(req, res) { + if (req.method === "GET") { + return jsonResponse(res, 200, getSettings()); + } + + // PATCH + let body = ""; + for await (const chunk of req) { + body += chunk; + if (body.length > 10000) return jsonResponse(res, 413, { error: "Body too large" }); + } + let updates; + try { updates = JSON.parse(body); } catch { return jsonResponse(res, 400, { error: "Invalid JSON" }); } + + if (typeof updates !== "object" || Array.isArray(updates)) { + return jsonResponse(res, 400, { error: "Expected JSON object with key-value pairs" }); + } + + const results = {}; + const errors = []; + for (const [key, value] of Object.entries(updates)) { + const err = applySettingUpdate(key, value); + if (err) { + errors.push(err); + results[key] = { error: err }; + } else { + results[key] = { ok: true, value }; + } + } + + const status = errors.length === 0 ? 200 : (Object.keys(results).length > errors.length ? 207 : 400); + return jsonResponse(res, status, { + results, + ...(errors.length ? { errors } : {}), + current: getSettings(), + }); +} + // ── Handle chat completions ───────────────────────────────────────────── const MAX_BODY_SIZE = 5 * 1024 * 1024; // 5 MB @@ -799,11 +1115,8 @@ const server = createServer(async (req, res) => { }); } - const breakerState = getBreakerSnapshot(); - const anyBreakerOpen = Object.values(breakerState).some(b => b.state === "open"); - return jsonResponse(res, 200, { - status: binaryOk && authStatus.ok !== false && !anyBreakerOpen ? "ok" : "degraded", + status: binaryOk && authStatus.ok !== false ? "ok" : "degraded", version: VERSION, architecture: "on-demand (v2)", uptime: uptimeMs, @@ -816,16 +1129,13 @@ const server = createServer(async (req, res) => { firstByteTimeout: BASE_FIRST_BYTE_TIMEOUT, maxConcurrent: MAX_CONCURRENT, sessionTTL: SESSION_TTL, - breakerThreshold: BREAKER_THRESHOLD, - breakerCooldown: BREAKER_COOLDOWN, - breakerWindow: BREAKER_WINDOW, - breakerHalfOpenMax: BREAKER_HALF_OPEN_MAX, + circuitBreaker: "disabled", allowedTools: SKIP_PERMISSIONS ? "all (skip-permissions)" : ALLOWED_TOOLS, systemPrompt: SYSTEM_PROMPT ? `${SYSTEM_PROMPT.slice(0, 50)}...` : "(none)", mcpConfig: MCP_CONFIG || "(none)", }, stats, - breakers: breakerState, + circuitBreaker: "disabled", sessions: sessionList, recentErrors: recentErrors.slice(-5), }); @@ -847,7 +1157,28 @@ const server = createServer(async (req, res) => { return jsonResponse(res, 200, { sessions: list }); } - jsonResponse(res, 404, { error: "Not found. Endpoints: GET /v1/models, POST /v1/chat/completions, GET /health, GET|DELETE /sessions" }); + // GET /usage — fetch plan usage limits from Anthropic API + if (req.url === "/usage" && req.method === "GET") { + return handleUsage(req, res); + } + + // GET /logs — recent proxy log entries (errors and key events) + if (req.url?.startsWith("/logs") && req.method === "GET") { + return handleLogs(req, res); + } + + // GET /status — combined usage + health summary + if (req.url === "/status" && req.method === "GET") { + return handleStatus(req, res); + } + + // GET /settings — view current tunable settings + // PATCH /settings — update settings at runtime (JSON body) + if (req.url === "/settings" && (req.method === "GET" || req.method === "PATCH")) { + return handleSettings(req, res); + } + + jsonResponse(res, 404, { error: "Not found. Endpoints: GET /v1/models, POST /v1/chat/completions, GET /health, GET /usage, GET /status, GET /logs, GET|PATCH /settings, GET|DELETE /sessions" }); }); @@ -910,7 +1241,7 @@ server.listen(PORT, "127.0.0.1", () => { console.log(`Models: ${MODELS.map((m) => m.id).join(", ")}`); console.log(`Claude binary: ${CLAUDE}`); console.log(`Timeout: ${TIMEOUT}ms (base first-byte: ${BASE_FIRST_BYTE_TIMEOUT}ms, adaptive by model/prompt) | Max concurrent: ${MAX_CONCURRENT}`); - console.log(`Circuit breaker: threshold=${BREAKER_THRESHOLD} in ${BREAKER_WINDOW/1000}s window, cooldown=${BREAKER_COOLDOWN/1000}s (graduated), half-open probes=${BREAKER_HALF_OPEN_MAX}`); + console.log(`Circuit breaker: disabled`); console.log(`Tools: ${SKIP_PERMISSIONS ? "all (skip-permissions)" : ALLOWED_TOOLS.join(", ")}`); console.log(`Sessions: TTL=${SESSION_TTL / 1000}s`); if (SYSTEM_PROMPT) console.log(`System prompt: "${SYSTEM_PROMPT.slice(0, 80)}..."`);