mirror of
https://github.com/dtzp555-max/ocp.git
synced 2026-07-19 09:44:07 +00:00
feat: v3.0.0 — OCP CLI, plan usage monitoring, runtime settings, prompt guard
Major release: complete management interface for the Claude proxy. - /ocp CLI with 10 subcommands (usage, status, health, settings, logs, models, sessions, clear, restart) - Gateway plugin for /ocp as native Telegram/Discord slash command - Plan usage monitoring via Anthropic API rate-limit headers (session %, weekly %, reset times) - Per-model request stats (count, avg/max elapsed, avg/max prompt chars) - Runtime settings via PATCH /settings (tune timeouts, concurrency, prompt limits without restart) - Prompt truncation guard at 150K chars to prevent conversation history blowup - Circuit breaker removed — caused cascading failures in CLI-proxy architecture - Timeout increases: Opus 150s, Sonnet 120s, Haiku 45s base first-byte - New endpoints: /usage, /status, /settings, /logs - README fully rewritten with CLI examples and changelog Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -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 <k> <v> 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?"}
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
**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.
|
||||
|
||||
Sessions expire after 1 hour of inactivity (configurable via `CLAUDE_SESSION_TTL`).
|
||||
|
||||
**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": "<your PROXY_API_KEY, or omit if auth disabled>",
|
||||
"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
|
||||
"plugins": {
|
||||
"allow": ["ocp"],
|
||||
"entries": { "ocp": { "enabled": true } }
|
||||
}
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
### 3. Set as default model
|
||||
Restart the gateway: `openclaw gateway restart`
|
||||
|
||||
```bash
|
||||
openclaw config set agents.defaults.model.primary "claude-local/claude-opus-4-6"
|
||||
openclaw gateway restart
|
||||
```
|
||||
## API Endpoints
|
||||
|
||||
| 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 <token>` 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
|
||||
|
||||
|
||||
@@ -0,0 +1,384 @@
|
||||
#!/usr/bin/env bash
|
||||
# ocp — OpenClaw Proxy CLI
|
||||
# Usage: ocp <command> [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 <key> <value> 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 <key> <value>"
|
||||
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 <command> [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 <command> --help' for details on a specific command.
|
||||
EOF
|
||||
}
|
||||
|
||||
# ── dispatch ─────────────────────────────────────────────────────────────
|
||||
# Check for --help/-h on any subcommand: ocp <cmd> --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
|
||||
@@ -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 <key> <value>\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 <k> <v> 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}` };
|
||||
}
|
||||
},
|
||||
});
|
||||
}
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
+4
-3
@@ -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",
|
||||
|
||||
+491
-160
@@ -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)}..."`);
|
||||
|
||||
Reference in New Issue
Block a user