mirror of
https://github.com/dtzp555-max/ocp.git
synced 2026-07-21 21:15:09 +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.**
|
> **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.
|
$ ocp usage
|
||||||
- **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.
|
Plan Usage Limits
|
||||||
- **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).
|
Current session 3% used
|
||||||
- **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.
|
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 |
|
Extra usage off
|
||||||
|----------|---------|-------------|
|
|
||||||
| `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 |
|
|
||||||
|
|
||||||
**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 |
|
Proxy: up 0h 37m | 5 reqs | 0 err | 0 timeout
|
||||||
|----------|-------------|-------------|
|
```
|
||||||
| `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` |
|
|
||||||
|
|
||||||
**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
|
## How It Works
|
||||||
|
|
||||||
**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
|
|
||||||
|
|
||||||
```
|
```
|
||||||
OpenClaw Gateway → proxy (localhost:3456) → claude -p CLI → Anthropic (via OAuth)
|
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.
|
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
|
## Quick Start
|
||||||
|
|
||||||
- **Node.js** >= 18
|
|
||||||
- **Claude CLI** installed and authenticated (`claude login`)
|
|
||||||
- **OpenClaw** installed
|
|
||||||
|
|
||||||
## Quick Start (Node.js)
|
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
git clone https://github.com/dtzp555-max/openclaw-claude-proxy.git
|
git clone https://github.com/dtzp555-max/openclaw-claude-proxy.git
|
||||||
@@ -84,119 +120,69 @@ cd openclaw-claude-proxy
|
|||||||
node setup.mjs
|
node setup.mjs
|
||||||
```
|
```
|
||||||
|
|
||||||
That's it. The setup script will:
|
The setup script will:
|
||||||
1. Verify Claude CLI is installed and authenticated
|
1. Verify Claude CLI is installed and authenticated
|
||||||
2. Add `claude-local` provider to `openclaw.json`
|
2. Add `claude-local` provider to `openclaw.json`
|
||||||
3. Add auth profiles to all agents
|
3. Start the proxy and install auto-start (launchd on macOS, systemd on Linux)
|
||||||
4. Start the proxy
|
|
||||||
5. Install auto-start on login (launchd on macOS, systemd on Linux)
|
|
||||||
|
|
||||||
Then set your preferred Claude model as default:
|
Then set your preferred model:
|
||||||
```bash
|
```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
|
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
|
```json
|
||||||
{
|
{
|
||||||
"model": "claude-opus-4-6",
|
"plugins": {
|
||||||
"session_id": "conv-abc-123",
|
"allow": ["ocp"],
|
||||||
"messages": [
|
"entries": { "ocp": { "enabled": true } }
|
||||||
{"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`.
|
Restart the gateway: `openclaw gateway restart`
|
||||||
**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
|
||||||
|
|
||||||
**API endpoints:**
|
| Endpoint | Method | Description |
|
||||||
- `GET /sessions` — list all active sessions
|
|----------|--------|-------------|
|
||||||
- `DELETE /sessions` — clear all sessions
|
| `/v1/models` | GET | List available models |
|
||||||
|
| `/v1/chat/completions` | POST | Chat completion (streaming + non-streaming) |
|
||||||
## Security
|
| `/health` | GET | Comprehensive health check |
|
||||||
|
| `/usage` | GET | Plan usage limits + per-model stats |
|
||||||
- **Localhost only** — the proxy binds to `127.0.0.1` and is not exposed to the internet or your local network
|
| `/status` | GET | Combined overview (usage + health) |
|
||||||
- **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
|
| `/settings` | GET | View tunable settings |
|
||||||
- **No API keys for Claude** — authentication to Anthropic goes through Claude CLI's OAuth session, no Anthropic credentials are stored in the proxy
|
| `/settings` | PATCH | Update settings at runtime |
|
||||||
- **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
|
| `/logs` | GET | Recent log entries (`?n=20&level=error`) |
|
||||||
- **Remove auto-start** at any time:
|
| `/sessions` | GET | List active sessions |
|
||||||
|
| `/sessions` | DELETE | Clear all sessions |
|
||||||
```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
|
|
||||||
}
|
|
||||||
]
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
### 3. Set as default model
|
|
||||||
|
|
||||||
```bash
|
|
||||||
openclaw config set agents.defaults.model.primary "claude-local/claude-opus-4-6"
|
|
||||||
openclaw gateway restart
|
|
||||||
```
|
|
||||||
|
|
||||||
## Available Models
|
## 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-opus-4-6` | claude-opus-4-6 | Most capable, slower |
|
||||||
| `claude-sonnet-4-6` | claude-sonnet-4-6 | Good balance of speed/quality |
|
| `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_PROXY_PORT` | `3456` | Listen port |
|
||||||
| `CLAUDE_BIN` | *(auto-detect)* | Path to claude binary |
|
| `CLAUDE_BIN` | *(auto-detect)* | Path to claude binary |
|
||||||
| `CLAUDE_TIMEOUT` | `300000` | Overall request timeout (ms) |
|
| `CLAUDE_TIMEOUT` | `300000` | Overall request timeout (ms) |
|
||||||
| `CLAUDE_FIRST_BYTE_TIMEOUT` | `90000` | Base first-byte timeout (ms), adaptive by model tier |
|
| `CLAUDE_FIRST_BYTE_TIMEOUT` | `90000` | Base first-byte timeout (ms) |
|
||||||
| `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_MAX_CONCURRENT` | `8` | Max concurrent claude processes |
|
| `CLAUDE_MAX_CONCURRENT` | `8` | Max concurrent claude processes |
|
||||||
| `CLAUDE_BREAKER_THRESHOLD` | `6` | Failures in window before circuit opens |
|
| `CLAUDE_MAX_PROMPT_CHARS` | `150000` | Prompt truncation limit (chars) |
|
||||||
| `CLAUDE_BREAKER_COOLDOWN` | `120000` | Base cooldown (ms) before half-open (graduates on re-open) |
|
| `CLAUDE_SESSION_TTL` | `3600000` | Session expiry (ms, default: 1 hour) |
|
||||||
| `CLAUDE_BREAKER_WINDOW` | `300000` | Sliding window duration (ms) for failure counting |
|
| `CLAUDE_ALLOWED_TOOLS` | `Bash,Read,...,Agent` | Comma-separated tools to pre-approve |
|
||||||
| `CLAUDE_BREAKER_HALF_OPEN_MAX` | `2` | Max concurrent probe requests in half-open state |
|
| `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 |
|
| `PROXY_API_KEY` | *(unset)* | Bearer token for API authentication |
|
||||||
|
|
||||||
## API Endpoints
|
## Session Management
|
||||||
|
|
||||||
- `GET /v1/models` — List available models
|
Multi-turn conversations use `--resume` to avoid resending full history on every request.
|
||||||
- `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
|
|
||||||
|
|
||||||
## 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.
|
```json
|
||||||
|
{
|
||||||
**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.
|
"model": "claude-sonnet-4-6",
|
||||||
|
"session_id": "conv-abc-123",
|
||||||
**When `PROXY_API_KEY` is not set**, authentication is disabled and all requests are accepted.
|
"messages": [
|
||||||
|
{"role": "user", "content": "Hello"},
|
||||||
```bash
|
{"role": "assistant", "content": "Hi!"},
|
||||||
# Start with auth enabled
|
{"role": "user", "content": "What did I just say?"}
|
||||||
PROXY_API_KEY=my-secret-token node server.mjs
|
]
|
||||||
|
}
|
||||||
```
|
```
|
||||||
|
|
||||||
## 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 |
|
| Process lifecycle | Pre-spawn idle workers | Spawn per request |
|
||||||
| Crash handling | Backoff → DEGRADED → manual restart | No crash loops (no idle workers) |
|
| Crash handling | Backoff spiral | No crash loops |
|
||||||
| Session support | None (stateless) | --resume with session tracking |
|
| Session support | None | `--resume` with tracking |
|
||||||
| Tool access | 6 tools hardcoded | Configurable, expanded defaults |
|
| Tool access | 6 hardcoded | Configurable, expanded |
|
||||||
| System prompt | None | CLAUDE_SYSTEM_PROMPT env |
|
| Prompt guard | None | Truncation at 150K chars |
|
||||||
| MCP support | None | CLAUDE_MCP_CONFIG env |
|
| Monitoring | Basic `/health` | `/usage`, `/status`, `/settings`, `/logs` |
|
||||||
| Concurrency | Unlimited (dangerous) | CLAUDE_MAX_CONCURRENT limit |
|
| CLI | None | `ocp` command |
|
||||||
| Auth monitoring | None | Periodic health checks |
|
| Gateway plugin | None | `/ocp` slash command |
|
||||||
| Diagnostics | Basic /health | Full stats, sessions, errors |
|
|
||||||
|
|
||||||
## Coexistence with Claude Code
|
## 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) |
|
| Protocol | HTTP (localhost:3456) | MCP (in-process) |
|
||||||
| Process model | Per-request spawn | Persistent session |
|
| Process model | Per-request spawn | Persistent session |
|
||||||
| Lifecycle | Daemon (auto-start, auto-recover) | Requires terminal |
|
| Lifecycle | Daemon (auto-start) | Requires terminal |
|
||||||
| Permission model | Pre-approved tools | Interactive prompts |
|
|
||||||
| Use case | Automated agent work | Human-in-the-loop |
|
| 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
|
## 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
|
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
cd ~/.openclaw/projects/claude-proxy # or wherever you cloned it
|
cd ~/.openclaw/projects/claude-proxy
|
||||||
git pull # pull latest version
|
git pull
|
||||||
node setup.mjs # reconfigure OpenClaw + start proxy
|
node setup.mjs
|
||||||
openclaw gateway restart
|
openclaw gateway restart
|
||||||
```
|
```
|
||||||
|
|
||||||
## Notes
|
## Changelog
|
||||||
|
|
||||||
- Cost shows as $0 because billing goes through your Claude subscription
|
### v3.0.0 (2026-03-24)
|
||||||
- Each request spawns a `claude -p` process; concurrent requests are capped by `CLAUDE_MAX_CONCURRENT`
|
- **`/ocp` CLI** — full management from terminal (`ocp usage`, `ocp settings`, etc.)
|
||||||
- The proxy must run on the same machine as the Claude CLI (uses local OAuth)
|
- **`/ocp` gateway plugin** — native slash command in Telegram/Discord
|
||||||
- Session data is stored by Claude CLI on disk; session map is in-memory (lost on proxy restart)
|
- **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
|
## 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",
|
"name": "openclaw-claude-proxy",
|
||||||
"version": "2.5.0",
|
"version": "3.0.0",
|
||||||
"description": "OpenAI-compatible proxy for Claude CLI v2 — sliding-window circuit breaker, adaptive first-byte timeout, structured logging",
|
"description": "OpenAI-compatible proxy for Claude CLI — plan usage monitoring, runtime settings, prompt truncation, OCP CLI",
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"bin": {
|
"bin": {
|
||||||
"openclaw-claude-proxy": "./server.mjs"
|
"openclaw-claude-proxy": "./server.mjs",
|
||||||
|
"ocp": "./ocp"
|
||||||
},
|
},
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"start": "node server.mjs",
|
"start": "node server.mjs",
|
||||||
|
|||||||
+491
-160
@@ -85,10 +85,11 @@ function resolveClaude() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// ── Configuration ───────────────────────────────────────────────────────
|
// ── Configuration ───────────────────────────────────────────────────────
|
||||||
|
// Settings marked with `let` can be changed at runtime via PATCH /settings.
|
||||||
const PORT = parseInt(process.env.CLAUDE_PROXY_PORT || "3456", 10);
|
const PORT = parseInt(process.env.CLAUDE_PROXY_PORT || "3456", 10);
|
||||||
const CLAUDE = resolveClaude();
|
const CLAUDE = resolveClaude();
|
||||||
const TIMEOUT = parseInt(process.env.CLAUDE_TIMEOUT || "300000", 10);
|
let TIMEOUT = parseInt(process.env.CLAUDE_TIMEOUT || "300000", 10);
|
||||||
const BASE_FIRST_BYTE_TIMEOUT = parseInt(process.env.CLAUDE_FIRST_BYTE_TIMEOUT || "90000", 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 PROXY_API_KEY = process.env.PROXY_API_KEY || "";
|
||||||
const SKIP_PERMISSIONS = process.env.CLAUDE_SKIP_PERMISSIONS === "true";
|
const SKIP_PERMISSIONS = process.env.CLAUDE_SKIP_PERMISSIONS === "true";
|
||||||
const ALLOWED_TOOLS = (process.env.CLAUDE_ALLOWED_TOOLS ||
|
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);
|
).split(",").map(s => s.trim()).filter(Boolean);
|
||||||
const SYSTEM_PROMPT = process.env.CLAUDE_SYSTEM_PROMPT || "";
|
const SYSTEM_PROMPT = process.env.CLAUDE_SYSTEM_PROMPT || "";
|
||||||
const MCP_CONFIG = process.env.CLAUDE_MCP_CONFIG || "";
|
const MCP_CONFIG = process.env.CLAUDE_MCP_CONFIG || "";
|
||||||
const SESSION_TTL = parseInt(process.env.CLAUDE_SESSION_TTL || "3600000", 10);
|
let SESSION_TTL = parseInt(process.env.CLAUDE_SESSION_TTL || "3600000", 10);
|
||||||
const MAX_CONCURRENT = parseInt(process.env.CLAUDE_MAX_CONCURRENT || "8", 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_THRESHOLD = parseInt(process.env.CLAUDE_BREAKER_THRESHOLD || "6", 10);
|
||||||
const BREAKER_COOLDOWN = parseInt(process.env.CLAUDE_BREAKER_COOLDOWN || "120000", 10);
|
const BREAKER_COOLDOWN = parseInt(process.env.CLAUDE_BREAKER_COOLDOWN || "120000", 10);
|
||||||
const BREAKER_WINDOW = parseInt(process.env.CLAUDE_BREAKER_WINDOW || "300000", 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 ─────────────────────────────
|
// ── Circuit breaker (DISABLED) ──────────────────────────────────────────
|
||||||
// Uses a time-windowed failure rate instead of consecutive-count. This prevents
|
// Disabled: CLI proxy has its own retry logic, and the breaker was causing
|
||||||
// multi-agent burst scenarios (e.g. ClawTeam spawning 5+ Opus agents) from
|
// cascading failures — once API got briefly slow, ALL agents lost connectivity
|
||||||
// tripping the breaker after just 3 quick timeouts.
|
// for 120s+ due to the breaker rejecting every request.
|
||||||
//
|
// The timeout/failure tracking stubs below are kept as no-ops so callers
|
||||||
// States: closed → open → half-open → closed (on success) or open (on failure)
|
// don't need to be changed.
|
||||||
// Half-open allows up to BREAKER_HALF_OPEN_MAX concurrent probes (not just 1).
|
function breakerRecordSuccess(_cliModel) {}
|
||||||
// Cooldown uses graduated backoff: doubles on each re-open, resets on success.
|
function breakerRecordTimeout(_cliModel) {}
|
||||||
const breakers = new Map(); // cliModel → BreakerState
|
function getBreakerState(_cliModel) { return { state: "closed" }; }
|
||||||
|
function getBreakerSnapshot() { return { _note: "circuit breaker disabled" }; }
|
||||||
|
|
||||||
function newBreakerState() {
|
// Legacy constants kept for /health display
|
||||||
return {
|
const _BREAKER_DISABLED_NOTE = "disabled";
|
||||||
state: "closed", // closed | open | half-open
|
/* Original breaker code removed — see git history for v2.5.0 implementation.
|
||||||
failureTimestamps: [], // timestamps of failures within the sliding window
|
Re-enable by reverting this block if needed in the future.
|
||||||
successCount: 0, // successes within window (for rate calculation)
|
Reason for disabling: CLI-proxy architecture means each request spawns a
|
||||||
openedAt: 0,
|
fresh claude process. The breaker was designed for persistent API connections
|
||||||
currentCooldown: BREAKER_COOLDOWN, // graduates on repeated opens
|
where a degraded backend benefits from back-off. With CLI spawning, timeouts
|
||||||
reopenCount: 0, // how many times breaker has re-opened without a full reset
|
are usually transient (API load, large prompts) and the breaker's 120s+
|
||||||
halfOpenProbes: 0, // active probe requests in half-open state
|
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 ───────────────────────────────────────────────────────
|
// ── Model mapping ───────────────────────────────────────────────────────
|
||||||
// Maps request model IDs and aliases to canonical claude CLI model IDs.
|
// Maps request model IDs and aliases to canonical claude CLI model IDs.
|
||||||
@@ -290,6 +190,57 @@ const stats = {
|
|||||||
};
|
};
|
||||||
const recentErrors = []; // last 20 errors
|
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) {
|
function trackError(msg) {
|
||||||
stats.errors++;
|
stats.errors++;
|
||||||
recentErrors.push({ time: new Date().toISOString(), message: String(msg).slice(0, 200) });
|
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 ──────────────────────────────────────
|
// ── 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) {
|
function messagesToPrompt(messages) {
|
||||||
return messages.map((m) => {
|
const full = messages.map((m) => {
|
||||||
const text = typeof m.content === "string" ? m.content : JSON.stringify(m.content);
|
const text = typeof m.content === "string" ? m.content : JSON.stringify(m.content);
|
||||||
if (m.role === "system") return `[System] ${text}`;
|
if (m.role === "system") return `[System] ${text}`;
|
||||||
if (m.role === "assistant") return `[Assistant] ${text}`;
|
if (m.role === "assistant") return `[Assistant] ${text}`;
|
||||||
return 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.
|
// Model tier multipliers for first-byte timeout.
|
||||||
// Opus is much slower to produce first token, especially with large contexts.
|
// Opus is much slower to produce first token, especially with large contexts.
|
||||||
const MODEL_TIMEOUT_TIERS = {
|
let MODEL_TIMEOUT_TIERS = {
|
||||||
"opus": { base: 90000, perPromptChar: 0.00020 }, // 90s base + ~20s per 100k chars
|
"opus": { base: 150000, perPromptChar: 0.00050 }, // 150s base + ~50s per 100k chars
|
||||||
"sonnet": { base: 60000, perPromptChar: 0.00010 }, // 60s base + ~10s per 100k chars
|
"sonnet": { base: 120000, perPromptChar: 0.00050 }, // 120s base + ~50s per 100k chars
|
||||||
"haiku": { base: 30000, perPromptChar: 0.00005 }, // 30s base + ~5s per 100k chars
|
"haiku": { base: 45000, perPromptChar: 0.00010 }, // 45s base + ~10s per 100k chars
|
||||||
};
|
};
|
||||||
|
|
||||||
function getModelTier(cliModel) {
|
function getModelTier(cliModel) {
|
||||||
@@ -392,22 +387,7 @@ function spawnClaudeProcess(model, messages, conversationId) {
|
|||||||
|
|
||||||
const cliModel = MODEL_MAP[model] || model;
|
const cliModel = MODEL_MAP[model] || model;
|
||||||
|
|
||||||
// Circuit breaker check: fail fast if model is in open state
|
// Circuit breaker: disabled (see comment at top of breaker section)
|
||||||
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 });
|
|
||||||
}
|
|
||||||
|
|
||||||
stats.activeRequests++;
|
stats.activeRequests++;
|
||||||
stats.totalRequests++;
|
stats.totalRequests++;
|
||||||
@@ -487,12 +467,14 @@ function spawnClaudeProcess(model, messages, conversationId) {
|
|||||||
proc.stdin.write(prompt);
|
proc.stdin.write(prompt);
|
||||||
proc.stdin.end();
|
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" });
|
logEvent("info", "claude_spawned", { model: cliModel, promptChars: prompt.length, firstByteTimeout: firstByteTimeoutMs, tier: getModelTier(cliModel), session: conversationId ? conversationId.slice(0, 12) + "..." : "none" });
|
||||||
|
|
||||||
// First-byte timeout
|
// First-byte timeout
|
||||||
const firstByteTimer = setTimeout(() => {
|
const firstByteTimer = setTimeout(() => {
|
||||||
if (!gotFirstByte && !cleaned) {
|
if (!gotFirstByte && !cleaned) {
|
||||||
stats.timeouts++;
|
stats.timeouts++;
|
||||||
|
recordModelError(cliModel, true);
|
||||||
breakerRecordTimeout(cliModel);
|
breakerRecordTimeout(cliModel);
|
||||||
logEvent("error", "first_byte_timeout", { model: cliModel, timeoutMs: firstByteTimeoutMs, promptChars: prompt.length });
|
logEvent("error", "first_byte_timeout", { model: cliModel, timeoutMs: firstByteTimeoutMs, promptChars: prompt.length });
|
||||||
try { proc.kill("SIGTERM"); } catch {}
|
try { proc.kill("SIGTERM"); } catch {}
|
||||||
@@ -504,6 +486,7 @@ function spawnClaudeProcess(model, messages, conversationId) {
|
|||||||
const overallTimer = setTimeout(() => {
|
const overallTimer = setTimeout(() => {
|
||||||
if (!cleaned) {
|
if (!cleaned) {
|
||||||
stats.timeouts++;
|
stats.timeouts++;
|
||||||
|
recordModelError(cliModel, true);
|
||||||
breakerRecordTimeout(cliModel);
|
breakerRecordTimeout(cliModel);
|
||||||
logEvent("error", "request_timeout", { model: cliModel, timeoutMs: TIMEOUT });
|
logEvent("error", "request_timeout", { model: cliModel, timeoutMs: TIMEOUT });
|
||||||
try { proc.kill("SIGTERM"); } catch {}
|
try { proc.kill("SIGTERM"); } catch {}
|
||||||
@@ -542,11 +525,13 @@ function callClaude(model, messages, conversationId) {
|
|||||||
const elapsed = Date.now() - t0;
|
const elapsed = Date.now() - t0;
|
||||||
cleanup();
|
cleanup();
|
||||||
if (code !== 0) {
|
if (code !== 0) {
|
||||||
|
recordModelError(cliModel, false);
|
||||||
logEvent("error", "claude_exit", { model: cliModel, code, signal: signal || "none", elapsed, stderr: stderr.slice(0, 300) });
|
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}`);
|
trackError(stderr.slice(0, 300) || stdout.slice(0, 300) || `claude exit ${code}`);
|
||||||
handleSessionFailure();
|
handleSessionFailure();
|
||||||
reject(new Error(stderr.slice(0, 300) || stdout.slice(0, 300) || `claude exit ${code}`));
|
reject(new Error(stderr.slice(0, 300) || stdout.slice(0, 300) || `claude exit ${code}`));
|
||||||
} else {
|
} else {
|
||||||
|
recordModelSuccess(cliModel, elapsed);
|
||||||
breakerRecordSuccess(cliModel);
|
breakerRecordSuccess(cliModel);
|
||||||
logEvent("info", "claude_ok", { model: cliModel, chars: stdout.length, elapsed, session: convId ? convId.slice(0, 12) + "..." : "none" });
|
logEvent("info", "claude_ok", { model: cliModel, chars: stdout.length, elapsed, session: convId ? convId.slice(0, 12) + "..." : "none" });
|
||||||
resolve(stdout.trim());
|
resolve(stdout.trim());
|
||||||
@@ -620,15 +605,14 @@ function callClaudeStreaming(model, messages, conversationId, res) {
|
|||||||
const elapsed = Date.now() - t0;
|
const elapsed = Date.now() - t0;
|
||||||
|
|
||||||
if (code !== 0) {
|
if (code !== 0) {
|
||||||
|
recordModelError(cliModel, false);
|
||||||
logEvent("error", "claude_exit", { model: cliModel, code, signal: signal || "none", elapsed, stderr: stderr.slice(0, 300) });
|
logEvent("error", "claude_exit", { model: cliModel, code, signal: signal || "none", elapsed, stderr: stderr.slice(0, 300) });
|
||||||
trackError(stderr.slice(0, 300) || `claude exit ${code}`);
|
trackError(stderr.slice(0, 300) || `claude exit ${code}`);
|
||||||
handleSessionFailure();
|
handleSessionFailure();
|
||||||
|
|
||||||
if (!headersSent && !res.writableEnded && !res.destroyed) {
|
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" } });
|
jsonResponse(res, 500, { error: { message: stderr.slice(0, 300) || `claude exit ${code}`, type: "proxy_error" } });
|
||||||
} else if (!res.writableEnded && !res.destroyed) {
|
} else if (!res.writableEnded && !res.destroyed) {
|
||||||
// Already streaming — close the stream gracefully
|
|
||||||
sendSSE(res, {
|
sendSSE(res, {
|
||||||
id, object: "chat.completion.chunk", created, model,
|
id, object: "chat.completion.chunk", created, model,
|
||||||
choices: [{ index: 0, delta: {}, finish_reason: "stop" }],
|
choices: [{ index: 0, delta: {}, finish_reason: "stop" }],
|
||||||
@@ -637,6 +621,7 @@ function callClaudeStreaming(model, messages, conversationId, res) {
|
|||||||
res.end();
|
res.end();
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
|
recordModelSuccess(cliModel, elapsed);
|
||||||
breakerRecordSuccess(cliModel);
|
breakerRecordSuccess(cliModel);
|
||||||
logEvent("info", "claude_ok", { model: cliModel, chars: totalChars, elapsed, session: convId ? convId.slice(0, 12) + "..." : "none" });
|
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 ─────────────────────────────────────────────
|
// ── Handle chat completions ─────────────────────────────────────────────
|
||||||
const MAX_BODY_SIZE = 5 * 1024 * 1024; // 5 MB
|
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, {
|
return jsonResponse(res, 200, {
|
||||||
status: binaryOk && authStatus.ok !== false && !anyBreakerOpen ? "ok" : "degraded",
|
status: binaryOk && authStatus.ok !== false ? "ok" : "degraded",
|
||||||
version: VERSION,
|
version: VERSION,
|
||||||
architecture: "on-demand (v2)",
|
architecture: "on-demand (v2)",
|
||||||
uptime: uptimeMs,
|
uptime: uptimeMs,
|
||||||
@@ -816,16 +1129,13 @@ const server = createServer(async (req, res) => {
|
|||||||
firstByteTimeout: BASE_FIRST_BYTE_TIMEOUT,
|
firstByteTimeout: BASE_FIRST_BYTE_TIMEOUT,
|
||||||
maxConcurrent: MAX_CONCURRENT,
|
maxConcurrent: MAX_CONCURRENT,
|
||||||
sessionTTL: SESSION_TTL,
|
sessionTTL: SESSION_TTL,
|
||||||
breakerThreshold: BREAKER_THRESHOLD,
|
circuitBreaker: "disabled",
|
||||||
breakerCooldown: BREAKER_COOLDOWN,
|
|
||||||
breakerWindow: BREAKER_WINDOW,
|
|
||||||
breakerHalfOpenMax: BREAKER_HALF_OPEN_MAX,
|
|
||||||
allowedTools: SKIP_PERMISSIONS ? "all (skip-permissions)" : ALLOWED_TOOLS,
|
allowedTools: SKIP_PERMISSIONS ? "all (skip-permissions)" : ALLOWED_TOOLS,
|
||||||
systemPrompt: SYSTEM_PROMPT ? `${SYSTEM_PROMPT.slice(0, 50)}...` : "(none)",
|
systemPrompt: SYSTEM_PROMPT ? `${SYSTEM_PROMPT.slice(0, 50)}...` : "(none)",
|
||||||
mcpConfig: MCP_CONFIG || "(none)",
|
mcpConfig: MCP_CONFIG || "(none)",
|
||||||
},
|
},
|
||||||
stats,
|
stats,
|
||||||
breakers: breakerState,
|
circuitBreaker: "disabled",
|
||||||
sessions: sessionList,
|
sessions: sessionList,
|
||||||
recentErrors: recentErrors.slice(-5),
|
recentErrors: recentErrors.slice(-5),
|
||||||
});
|
});
|
||||||
@@ -847,7 +1157,28 @@ const server = createServer(async (req, res) => {
|
|||||||
return jsonResponse(res, 200, { sessions: list });
|
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(`Models: ${MODELS.map((m) => m.id).join(", ")}`);
|
||||||
console.log(`Claude binary: ${CLAUDE}`);
|
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(`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(`Tools: ${SKIP_PERMISSIONS ? "all (skip-permissions)" : ALLOWED_TOOLS.join(", ")}`);
|
||||||
console.log(`Sessions: TTL=${SESSION_TTL / 1000}s`);
|
console.log(`Sessions: TTL=${SESSION_TTL / 1000}s`);
|
||||||
if (SYSTEM_PROMPT) console.log(`System prompt: "${SYSTEM_PROMPT.slice(0, 80)}..."`);
|
if (SYSTEM_PROMPT) console.log(`System prompt: "${SYSTEM_PROMPT.slice(0, 80)}..."`);
|
||||||
|
|||||||
Reference in New Issue
Block a user