mirror of
https://github.com/dtzp555-max/ocp.git
synced 2026-07-21 21:15:09 +00:00
Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
f3745fa8fe | ||
|
|
d71e0455e3 | ||
|
|
38070fbabb | ||
|
|
2adea6368d | ||
|
|
7860f71943 | ||
|
|
e326cee9dd | ||
|
|
f8ca9b85b0 | ||
|
|
c22b0dd074 | ||
|
|
7f46405152 | ||
|
|
cb6c2a8b5f | ||
|
|
02c6758a61 | ||
|
|
6d0e43ec37 | ||
|
|
b87992fa3b | ||
|
|
69b20815fa | ||
|
|
3eecca35ce | ||
|
|
9f1a21f7ad | ||
|
|
a0f9268af5 | ||
|
|
087e26346f | ||
|
|
ed53c5e0c5 | ||
|
|
18cb759359 | ||
|
|
cc745aa5d9 | ||
|
|
1983f9372d | ||
|
|
471bda2c40 | ||
|
|
566a01a6bd | ||
|
|
43daf8162c | ||
|
|
aa0adab784 | ||
|
|
4f4e1edf16 | ||
|
|
ea57db6ceb | ||
|
|
5fbeaed568 | ||
|
|
2b18884d2a | ||
|
|
4f72f4844e | ||
|
|
bc17b27f2b | ||
|
|
3e8ff7a509 | ||
|
|
b6b33d2623 | ||
|
|
8796a5fc19 | ||
|
|
609ceb28b0 | ||
|
|
3843ec8fe6 | ||
|
|
afe0c6e1be | ||
|
|
a6007393a3 | ||
|
|
8592150f7a | ||
|
|
eb76971ffc | ||
|
|
d54e73ef89 | ||
|
|
3f10b459f5 | ||
|
|
b72e449337 | ||
|
|
2b05558b8b | ||
|
|
d9b7c0d905 | ||
|
|
27cd8b1735 | ||
|
|
9dd35c90ae | ||
|
|
c005a8fd27 | ||
|
|
77a3f48cfd | ||
|
|
7390a19ec6 | ||
|
|
04643217ae | ||
|
|
3de1868313 | ||
|
|
75cff38f4c | ||
|
|
a8a0af43c9 | ||
|
|
77a6f8ed59 | ||
|
|
b5be1c04f4 | ||
|
|
1f1b3871da | ||
|
|
f6f4f68ecd | ||
|
|
47e39d7820 | ||
|
|
7434f6adc0 | ||
|
|
8a7951134e | ||
|
|
47434a8ba7 | ||
|
|
1d95dbeebc | ||
|
|
9e6bef729b | ||
|
|
256b2bfb77 | ||
|
|
384e66bfa6 | ||
|
|
76a8c56c88 |
@@ -0,0 +1,21 @@
|
||||
MIT License
|
||||
|
||||
Copyright (c) 2026 Tao Deng
|
||||
|
||||
Permission is hereby granted, free of charge, to any person obtaining a copy
|
||||
of this software and associated documentation files (the "Software"), to deal
|
||||
in the Software without restriction, including without limitation the rights
|
||||
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
||||
copies of the Software, and to permit persons to whom the Software is
|
||||
furnished to do so, subject to the following conditions:
|
||||
|
||||
The above copyright notice and this permission notice shall be included in all
|
||||
copies or substantial portions of the Software.
|
||||
|
||||
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
||||
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
||||
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
||||
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
||||
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
||||
SOFTWARE.
|
||||
@@ -1,256 +1,477 @@
|
||||
# openclaw-claude-proxy
|
||||
# OCP — Open Claude Proxy
|
||||
|
||||
> **Already paying for Claude Pro/Max? Use it as your OpenClaw model provider — $0 extra API cost.**
|
||||
> **Status: Stable (v3.7.0)** — Feature-complete. Bug fixes only.
|
||||
|
||||
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.
|
||||
> **Already paying for Claude Pro/Max? Use your subscription as an OpenAI-compatible API — $0 extra cost.**
|
||||
|
||||
## v2.0.0 — Major Upgrade
|
||||
|
||||
**What's new:**
|
||||
- **On-demand spawning** — eliminates the pool crash loops, DEGRADED states, and stdin timeout errors from v1.x. Each request spawns a fresh `claude -p` process with stdin written immediately. No more stale workers, no more backoff spirals.
|
||||
- **Session management** — multi-turn conversations use `--resume` to avoid resending full history. Reduces token waste and enables Claude Code's built-in context compression on long conversations.
|
||||
- **Full tool access** — expanded default tools (Bash, Read, Write, Edit, Glob, Grep, WebSearch, WebFetch, Agent). Configurable via `CLAUDE_ALLOWED_TOOLS` or bypass all checks with `CLAUDE_SKIP_PERMISSIONS=true`.
|
||||
- **System prompt pass-through** — set `CLAUDE_SYSTEM_PROMPT` to inject context into every request.
|
||||
- **MCP config support** — set `CLAUDE_MCP_CONFIG` to load MCP servers (Telegram, etc.) into claude -p calls.
|
||||
- **Concurrency control** — `CLAUDE_MAX_CONCURRENT` prevents runaway process spawning (default: 5).
|
||||
- **Auth health monitoring** — periodic `claude auth status` checks with status exposed on `/health`.
|
||||
- **Session API** — `GET /sessions` to list, `DELETE /sessions` to clear active sessions.
|
||||
- **Improved diagnostics** — `/health` endpoint shows stats, active sessions, recent errors, auth status, and full config.
|
||||
|
||||
**Coexistence with Claude Code interactive mode:**
|
||||
OCP and Claude Code (interactive/Telegram) run on completely different paths and can coexist on the same machine without conflict:
|
||||
- OCP: `localhost:3456` (HTTP) → spawns `claude -p` processes (per-request, stateless)
|
||||
- CC: MCP protocol (in-process) → persistent interactive session
|
||||
- No shared ports, no shared processes, no shared sessions
|
||||
|
||||
**Daemon advantage over CC:**
|
||||
OCP runs as a system daemon (launchd/systemd) that auto-starts on boot and auto-recovers from crashes. Unlike Claude Code interactive mode, OCP does not require a terminal session to stay open — it survives disconnects, reboots, and SSH drops. Combined with OpenClaw's memory system, this means your agents never lose continuity.
|
||||
|
||||
## How it works
|
||||
OCP turns your Claude Pro/Max subscription into a standard OpenAI-compatible API on localhost. Any tool that speaks the OpenAI protocol can use it — no separate API key, no extra billing.
|
||||
|
||||
```
|
||||
OpenClaw Gateway → proxy (localhost:3456) → claude -p CLI → Anthropic (via OAuth)
|
||||
Cline ──┐
|
||||
OpenCode ───┤
|
||||
Aider ───┼──→ OCP :3456 ──→ Claude CLI ──→ Your subscription
|
||||
Continue.dev ───┤
|
||||
OpenClaw ───┘
|
||||
```
|
||||
|
||||
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.
|
||||
One proxy. Multiple IDEs. All models. **$0 API cost.**
|
||||
|
||||
## Prerequisites
|
||||
## Supported Tools
|
||||
|
||||
- **Node.js** >= 18
|
||||
- **Claude CLI** installed and authenticated (`claude login`)
|
||||
- **OpenClaw** installed
|
||||
Any tool that accepts `OPENAI_BASE_URL` works with OCP:
|
||||
|
||||
## Quick Start (Node.js)
|
||||
| Tool | Configuration |
|
||||
|------|--------------|
|
||||
| **Cline** | Settings → `OPENAI_BASE_URL=http://127.0.0.1:3456/v1` |
|
||||
| **OpenCode** | `OPENAI_BASE_URL=http://127.0.0.1:3456/v1` |
|
||||
| **Aider** | `aider --openai-api-base http://127.0.0.1:3456/v1` |
|
||||
| **Continue.dev** | config.json → `apiBase: "http://127.0.0.1:3456/v1"` |
|
||||
| **OpenClaw** | `setup.mjs` auto-configures |
|
||||
| **Any OpenAI client** | Set base URL to `http://127.0.0.1:3456/v1` |
|
||||
|
||||
## Installation
|
||||
|
||||
OCP has two roles: **Server** (runs the proxy, needs Claude CLI) and **Client** (connects to a server, zero dependencies).
|
||||
|
||||
```
|
||||
┌─ Server (always-on device) ─────────────────────────────┐
|
||||
│ Mac mini / NAS / Raspberry Pi / Desktop │
|
||||
│ Claude CLI + OCP server → bound to 0.0.0.0:3456 │
|
||||
└───────────────────────┬─────────────────────────────────┘
|
||||
│ LAN
|
||||
┌───────────────────┼───────────────────┐
|
||||
▼ ▼ ▼
|
||||
Laptop Phone/Tablet Pi / Server
|
||||
(client) (browser) (client)
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Server Setup
|
||||
|
||||
> **Recommended:** Install OCP on a device that stays powered on — Mac mini, NAS, Raspberry Pi, or a desktop that doesn't sleep. This ensures all clients always have access.
|
||||
|
||||
**Prerequisites:**
|
||||
- Node.js 18+
|
||||
- [Claude CLI](https://docs.anthropic.com/en/docs/claude-cli) installed and authenticated (`claude auth login`)
|
||||
|
||||
```bash
|
||||
git clone https://github.com/dtzp555-max/openclaw-claude-proxy.git
|
||||
cd openclaw-claude-proxy
|
||||
|
||||
# Auto-configure OpenClaw + start proxy + install auto-start
|
||||
# 1. Clone and run setup
|
||||
git clone https://github.com/dtzp555-max/ocp.git
|
||||
cd ocp
|
||||
node setup.mjs
|
||||
```
|
||||
|
||||
That's it. The setup script will:
|
||||
The setup script will:
|
||||
1. Verify Claude CLI is installed and authenticated
|
||||
2. Add `claude-local` provider to `openclaw.json`
|
||||
3. Add auth profiles to all agents
|
||||
4. Start the proxy
|
||||
5. Install auto-start on login (launchd on macOS, systemd on Linux)
|
||||
2. Start the proxy on port 3456
|
||||
3. Install auto-start (launchd on macOS, systemd on Linux)
|
||||
4. Symlink `ocp` to `/usr/local/bin` for CLI access
|
||||
|
||||
Then set your preferred Claude model as default:
|
||||
**Single-machine use** — just set your IDE to use the proxy:
|
||||
```bash
|
||||
openclaw config set agents.defaults.model.primary "claude-local/claude-opus-4-6"
|
||||
openclaw gateway restart
|
||||
export OPENAI_BASE_URL=http://127.0.0.1:3456/v1
|
||||
```
|
||||
|
||||
## Session Management (v2.0)
|
||||
|
||||
Multi-turn conversations can use sessions to avoid resending full message history on every request.
|
||||
|
||||
**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.
|
||||
|
||||
```json
|
||||
{
|
||||
"model": "claude-opus-4-6",
|
||||
"session_id": "conv-abc-123",
|
||||
"messages": [
|
||||
{"role": "user", "content": "Hello"},
|
||||
{"role": "assistant", "content": "Hi there!"},
|
||||
{"role": "user", "content": "What did I just say?"}
|
||||
]
|
||||
}
|
||||
**LAN mode** — share with other devices on your network:
|
||||
```bash
|
||||
# Enable LAN access with per-user auth (recommended)
|
||||
node setup.mjs --bind 0.0.0.0 --auth-mode multi
|
||||
```
|
||||
|
||||
**First request** with a new session_id: all messages are sent, session is persisted via `--session-id`.
|
||||
**Subsequent requests** with the same session_id: only the latest user message is sent via `--resume`, reducing token consumption.
|
||||
Then create API keys for each person/device:
|
||||
```bash
|
||||
export OCP_ADMIN_KEY=your-secret-admin-key
|
||||
|
||||
Sessions expire after 1 hour of inactivity (configurable via `CLAUDE_SESSION_TTL`).
|
||||
ocp keys add wife-laptop
|
||||
# ✓ Key created for "wife-laptop"
|
||||
# API Key: ocp_xDYzOB9ZKYzn...
|
||||
# Copy this key now — you won't see it again.
|
||||
|
||||
**API endpoints:**
|
||||
- `GET /sessions` — list all active sessions
|
||||
- `DELETE /sessions` — clear all sessions
|
||||
ocp keys add son-ipad
|
||||
ocp keys add pi-server
|
||||
```
|
||||
|
||||
## Security
|
||||
Run `ocp lan` to see your IP and ready-to-share instructions.
|
||||
|
||||
- **Localhost only** — the proxy binds to `127.0.0.1` and is not exposed to the internet or your local network
|
||||
- **Bearer token auth (optional)** — set `PROXY_API_KEY` to require a Bearer token on all requests (except `/health`). When unset, auth is disabled for backwards compatibility
|
||||
- **No API keys for Claude** — authentication to Anthropic goes through Claude CLI's OAuth session, no Anthropic credentials are stored in the proxy
|
||||
- **Auto-start via launchd/systemd** — `node setup.mjs` installs a user-level launch agent (macOS) or systemd user service (Linux) so the proxy starts automatically on login
|
||||
- **Remove auto-start** at any time:
|
||||
**Verify:**
|
||||
```bash
|
||||
curl http://127.0.0.1:3456/v1/models
|
||||
# Returns: claude-opus-4-6, claude-sonnet-4-6, claude-haiku-4
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Client Setup
|
||||
|
||||
> Clients do **not** need to install Node.js, Claude CLI, or the OCP repo. Only `curl` and `python3` are required (pre-installed on most Linux/Mac systems).
|
||||
|
||||
**One-command setup** — download the lightweight `ocp-connect` script:
|
||||
|
||||
```bash
|
||||
node uninstall.mjs
|
||||
curl -fsSL https://raw.githubusercontent.com/dtzp555-max/ocp/main/ocp-connect -o ocp-connect
|
||||
chmod +x ocp-connect
|
||||
./ocp-connect <server-ip>
|
||||
```
|
||||
|
||||
## Manual Install
|
||||
|
||||
### 1. Start the proxy
|
||||
**Zero-config** — when the server admin has set `PROXY_ANONYMOUS_KEY` (see [Anonymous Access](#anonymous-access-optional) below), just pass the server IP and nothing else. `ocp-connect` reads the anonymous key from `/health` and uses it automatically:
|
||||
|
||||
```bash
|
||||
node server.mjs
|
||||
# or in background:
|
||||
bash start.sh
|
||||
./ocp-connect <server-ip>
|
||||
```
|
||||
|
||||
### 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
|
||||
}
|
||||
]
|
||||
}
|
||||
If the server requires a key, pass it with `--key`:
|
||||
```bash
|
||||
./ocp-connect <server-ip> --key <your-api-key>
|
||||
```
|
||||
|
||||
### 3. Set as default model
|
||||
Or as a one-liner (no file saved):
|
||||
```bash
|
||||
curl -fsSL https://raw.githubusercontent.com/dtzp555-max/ocp/main/ocp-connect | bash -s -- <server-ip>
|
||||
```
|
||||
|
||||
Example:
|
||||
```
|
||||
$ ./ocp-connect 192.168.1.100
|
||||
|
||||
OCP Connect v1.3.0
|
||||
─────────────────────────────────────
|
||||
Remote: http://192.168.1.100:3456
|
||||
|
||||
Checking connectivity...
|
||||
✓ Connected
|
||||
|
||||
Remote OCP v3.7.0 (auth: multi)
|
||||
|
||||
ⓘ Using server-advertised anonymous key: ocp_publ...n_v1
|
||||
(set by admin via PROXY_ANONYMOUS_KEY; see issue #12 §14 Path A)
|
||||
|
||||
Testing API access...
|
||||
✓ API accessible (3 models available)
|
||||
|
||||
Shell config:
|
||||
✓ .bashrc
|
||||
✓ .zshrc
|
||||
OPENAI_BASE_URL=http://192.168.1.100:3456/v1
|
||||
|
||||
System-level (launchctl):
|
||||
✓ OPENAI_BASE_URL set for GUI apps and daemons
|
||||
|
||||
IDE Configuration
|
||||
─────────────────────────────────────
|
||||
Detected: OpenClaw (~/.openclaw/openclaw.json)
|
||||
|
||||
Configure OpenClaw to use this OCP? [Y/n] y
|
||||
Provider name (models show as <name>/model-id) [ocp]: ocp
|
||||
|
||||
How should OCP models be configured?
|
||||
1) Primary — use OCP by default, keep existing models as backup
|
||||
2) Backup — keep current primary, add OCP as additional option
|
||||
|
||||
Choice [1]: 1
|
||||
|
||||
Writing OpenClaw config...
|
||||
✓ Per-agent auth profile seeded (2):
|
||||
• ~/.openclaw/agents/main/agent/auth-profiles.json
|
||||
• ~/.openclaw/agents/macbook_bot/agent/auth-profiles.json
|
||||
✓ OpenClaw configured
|
||||
Provider: ocp
|
||||
Models:
|
||||
• ocp/claude-opus-4-6
|
||||
• ocp/claude-sonnet-4-6
|
||||
• ocp/claude-haiku-4-5-20251001
|
||||
Priority: PRIMARY (default model)
|
||||
|
||||
Restart OpenClaw to apply: openclaw gateway restart
|
||||
|
||||
Running smoke test...
|
||||
✓ Smoke test passed: OK
|
||||
Note: smoke test only verifies OCP is reachable and the key is valid.
|
||||
It does not verify your IDE/agent end-to-end. To verify OpenClaw works,
|
||||
restart it (`openclaw gateway restart`) and send a test message to your bot.
|
||||
|
||||
Done. Reload your shell to apply:
|
||||
source ~/.zshrc
|
||||
```
|
||||
|
||||
The script automatically:
|
||||
- Writes env vars to all relevant shell rc files (`.bashrc`, `.zshrc`)
|
||||
- Sets system-level env vars (`launchctl setenv` on macOS, `environment.d` on Linux)
|
||||
- **Auto-discovers anonymous key** from `/health.anonymousKey` when no `--key` given (v1.3.0+, requires server v3.7.0+)
|
||||
- Configures OpenClaw automatically (including per-agent `auth-profiles.json` for multi-agent setups)
|
||||
- Detects Cline, Continue.dev, Cursor, and opencode, and prints setup hints (manual configuration required for these IDEs)
|
||||
|
||||
On macOS, `launchctl setenv` vars reset on reboot — re-run `ocp-connect` after restart.
|
||||
|
||||
**Manual setup** — if you prefer not to use the script:
|
||||
```bash
|
||||
export OPENAI_BASE_URL=http://<server-ip>:3456/v1
|
||||
export OPENAI_API_KEY=ocp_<your-key>
|
||||
```
|
||||
Add these lines to `~/.bashrc` or `~/.zshrc` to persist across sessions.
|
||||
|
||||
---
|
||||
|
||||
### Monitoring (Server-side)
|
||||
|
||||
```bash
|
||||
openclaw config set agents.defaults.model.primary "claude-local/claude-opus-4-6"
|
||||
openclaw gateway restart
|
||||
# Per-key usage stats
|
||||
ocp usage --by-key
|
||||
# Key Reqs OK Err Avg Time
|
||||
# wife-laptop 5 5 0 8.0s
|
||||
# son-ipad 3 3 0 6.2s
|
||||
|
||||
# Manage keys
|
||||
ocp keys # List all keys
|
||||
ocp keys revoke son-ipad # Revoke a key
|
||||
```
|
||||
|
||||
**Web Dashboard:** Open `http://<server-ip>:3456/dashboard` in any browser for real-time monitoring — per-key usage, request history, plan utilization, and system health.
|
||||
|
||||

|
||||
|
||||
### Auth Modes
|
||||
|
||||
| Mode | Env | Use Case |
|
||||
|------|-----|----------|
|
||||
| `none` | `CLAUDE_AUTH_MODE=none` | Trusted home network, no auth needed |
|
||||
| `shared` | `CLAUDE_AUTH_MODE=shared` + `PROXY_API_KEY=xxx` | Everyone shares one key |
|
||||
| `multi` | `CLAUDE_AUTH_MODE=multi` + `OCP_ADMIN_KEY=xxx` | Per-person keys with usage tracking (recommended) |
|
||||
|
||||
### Anonymous Access (optional)
|
||||
|
||||
In `multi` mode, the admin can designate a single well-known "anonymous" key that bypasses `validateKey()` and grants public read/write access. This is useful for letting LAN users (or clients like OpenClaw multi-agent setups) connect without individual per-user keys.
|
||||
|
||||
**Enable**:
|
||||
|
||||
```bash
|
||||
export PROXY_ANONYMOUS_KEY=ocp_public_anon # or any string of your choice
|
||||
ocp start # or however you start the server
|
||||
```
|
||||
|
||||
**Client side**: the anonymous key value is exposed via `GET /health` as the field `anonymousKey` (null when not set). Clients like `ocp-connect` can auto-discover and use it, so the end user doesn't need to get a personal key from the admin.
|
||||
|
||||
**Security note**: setting this env var is an **opt-in** to public access — anyone who can reach your OCP endpoint can use it, up to any rate limits you configure. Don't enable this on internet-exposed OCP instances without additional protection.
|
||||
|
||||
**Not a secret**: because `/health` is an unauthenticated endpoint, the anonymous key is **publicly readable** by anyone who can reach the server. That is intentional — the key exists so clients can self-configure without out-of-band coordination. Treat it as a convenience handle, not as an access credential.
|
||||
|
||||
### Important Notes
|
||||
|
||||
- All users share your Claude Pro/Max **rate limits** (5h session + 7d weekly)
|
||||
- `ocp usage` shows how much quota remains
|
||||
- Keys are stored in `~/.ocp/ocp.db` (SQLite, zero external dependencies)
|
||||
- Admin key is required for key management API endpoints
|
||||
- The dashboard (`/dashboard`) and health check (`/health`) are always public
|
||||
|
||||
## Built-in Usage Monitoring
|
||||
|
||||
Check your subscription usage from the terminal:
|
||||
|
||||
```
|
||||
$ ocp usage
|
||||
Plan Usage Limits
|
||||
─────────────────────────────────────
|
||||
Current session 21% used
|
||||
Resets in 3h 12m (Tue, Mar 28, 10:00 PM)
|
||||
|
||||
Weekly (all models) 45% used
|
||||
Resets in 4d 2h (Tue, Mar 31, 12:00 AM)
|
||||
|
||||
Extra usage off
|
||||
|
||||
Model Stats
|
||||
Model Req OK Er AvgT MaxT AvgP MaxP
|
||||
──────────────────────────────────────────────────────
|
||||
opus 5 5 0 32s 87s 42K 43K
|
||||
sonnet 18 18 0 20s 45s 36K 56K
|
||||
Total 23
|
||||
|
||||
Proxy: up 6h 32m | 23 reqs | 0 err | 0 timeout
|
||||
```
|
||||
|
||||
### All Commands
|
||||
|
||||
```
|
||||
ocp usage Plan usage limits & model stats
|
||||
ocp usage --by-key Per-key usage breakdown (LAN mode)
|
||||
ocp status Quick overview
|
||||
ocp health Proxy diagnostics
|
||||
ocp keys List all API keys (multi mode)
|
||||
ocp keys add <name> Create a new API key
|
||||
ocp keys revoke <name> Revoke an API key
|
||||
ocp connect <ip> One-command LAN client setup
|
||||
ocp lan Show LAN connection info & IP
|
||||
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
|
||||
ocp update Update to latest version
|
||||
ocp update --check Check for updates without applying
|
||||
ocp --help Command reference
|
||||
```
|
||||
|
||||
### Install the CLI
|
||||
|
||||
```bash
|
||||
# Symlink to PATH (recommended)
|
||||
sudo ln -sf $(pwd)/ocp /usr/local/bin/ocp
|
||||
|
||||
# Verify
|
||||
ocp --help
|
||||
```
|
||||
|
||||
> **Cloud/Linux servers:** If `ocp: command not found`, the binary isn't in PATH. Full path: `~/.openclaw/projects/ocp/ocp`
|
||||
|
||||
### Self-Update
|
||||
|
||||
```bash
|
||||
# Check if a new version is available
|
||||
ocp update --check
|
||||
|
||||
# Pull latest, sync plugin, restart proxy — one command
|
||||
ocp update
|
||||
```
|
||||
|
||||
### Runtime Settings (No Restart Needed)
|
||||
|
||||
```
|
||||
$ ocp settings maxPromptChars 200000
|
||||
✓ maxPromptChars = 200000
|
||||
|
||||
$ ocp settings maxConcurrent 4
|
||||
✓ maxConcurrent = 4
|
||||
```
|
||||
|
||||
## How It Works
|
||||
|
||||
```
|
||||
Your IDE → OCP (localhost:3456) → claude -p CLI → Anthropic (via subscription)
|
||||
```
|
||||
|
||||
OCP translates OpenAI-compatible `/v1/chat/completions` requests into `claude -p` CLI calls. Anthropic sees normal Claude Code usage — no API billing, no separate key needed.
|
||||
|
||||
## Available Models
|
||||
|
||||
| Model ID | Claude CLI model | Notes |
|
||||
|----------|-----------------|-------|
|
||||
| `claude-opus-4-6` | claude-opus-4-6 | Most capable, slower |
|
||||
| `claude-sonnet-4-6` | claude-sonnet-4-6 | Good balance of speed/quality |
|
||||
| `claude-haiku-4` | claude-haiku-4-5-20251001 | Fastest, lightweight |
|
||||
| Model ID | Notes |
|
||||
|----------|-------|
|
||||
| `claude-opus-4-6` | Most capable, slower |
|
||||
| `claude-sonnet-4-6` | Good balance of speed/quality |
|
||||
| `claude-haiku-4-5-20251001` | Fastest, lightweight |
|
||||
|
||||
## API Endpoints
|
||||
|
||||
| Endpoint | Method | Description |
|
||||
|----------|--------|-------------|
|
||||
| `/v1/models` | GET | List available models |
|
||||
| `/v1/chat/completions` | POST | Chat completion (streaming + non-streaming) |
|
||||
| `/health` | GET | Comprehensive health check |
|
||||
| `/usage` | GET | Plan usage limits + per-model stats |
|
||||
| `/status` | GET | Combined overview (usage + health) |
|
||||
| `/settings` | GET/PATCH | View or update settings at runtime |
|
||||
| `/logs` | GET | Recent log entries (`?n=20&level=error`) |
|
||||
| `/sessions` | GET/DELETE | List or clear active sessions |
|
||||
| `/dashboard` | GET | Web dashboard (always public) |
|
||||
| `/api/keys` | GET/POST | List or create API keys (admin only) |
|
||||
| `/api/keys/:id` | DELETE | Revoke an API key (admin only) |
|
||||
| `/api/usage` | GET | Per-key usage stats (`?since=&until=&hours=&limit=`) |
|
||||
|
||||
## OpenClaw Integration
|
||||
|
||||
OCP was originally built for [OpenClaw](https://github.com/openclaw/openclaw) and includes deep integration:
|
||||
|
||||
- **`setup.mjs`** auto-configures the `claude-local` provider in `openclaw.json`
|
||||
- **Gateway plugin** registers `/ocp` as a native slash command in Telegram/Discord
|
||||
- **Multi-agent** — 8 concurrent requests sharing one subscription
|
||||
- **No conflicts** — uses neutral service names (`dev.ocp.proxy` / `ocp-proxy`) that don't trigger OpenClaw's gateway-like service detection
|
||||
|
||||
### Install the Gateway Plugin
|
||||
|
||||
```bash
|
||||
cp -r ocp-plugin/ ~/.openclaw/extensions/ocp/
|
||||
```
|
||||
|
||||
Add to `~/.openclaw/openclaw.json`:
|
||||
```json
|
||||
{
|
||||
"plugins": {
|
||||
"allow": ["ocp"],
|
||||
"entries": { "ocp": { "enabled": true } }
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
Restart: `openclaw gateway restart`
|
||||
|
||||
### Telegram / Discord Usage
|
||||
|
||||
After installing the gateway plugin, use `/ocp` slash commands in your chat:
|
||||
|
||||
```
|
||||
/ocp status — Quick overview
|
||||
/ocp usage — Plan usage limits & model stats
|
||||
/ocp models — Available models
|
||||
/ocp health — Proxy diagnostics
|
||||
/ocp keys — List all API keys (multi mode)
|
||||
/ocp keys add <name> — Create a new key
|
||||
/ocp keys revoke <name> — Revoke a key
|
||||
```
|
||||
|
||||
> **Note:** Terminal CLI uses `ocp <command>`, Telegram/Discord uses `/ocp <command>`.
|
||||
|
||||
## Troubleshooting
|
||||
|
||||
### Requests fail or agents stuck
|
||||
|
||||
```bash
|
||||
# Clear sessions and restart
|
||||
ocp clear
|
||||
ocp restart
|
||||
|
||||
# If using OpenClaw gateway
|
||||
openclaw gateway restart
|
||||
```
|
||||
|
||||
### Usage shows "unknown"
|
||||
|
||||
Usually caused by an expired Claude CLI session. Fix:
|
||||
```bash
|
||||
claude auth login
|
||||
ocp restart
|
||||
```
|
||||
|
||||
## Environment Variables
|
||||
|
||||
| Variable | Default | Description |
|
||||
|----------|---------|-------------|
|
||||
| `CLAUDE_PROXY_PORT` | `3456` | Listen port |
|
||||
| `CLAUDE_BIND` | `127.0.0.1` | Bind address (`0.0.0.0` for LAN access) |
|
||||
| `CLAUDE_AUTH_MODE` | `none` | Auth mode: `none`, `shared`, or `multi` |
|
||||
| `OCP_ADMIN_KEY` | *(unset)* | Admin key for key management (multi mode) |
|
||||
| `CLAUDE_BIN` | *(auto-detect)* | Path to claude binary |
|
||||
| `CLAUDE_TIMEOUT` | `300000` | Request timeout (ms) |
|
||||
| `CLAUDE_TIMEOUT` | `600000` | Request timeout (ms, default: 10 min) |
|
||||
| `CLAUDE_MAX_CONCURRENT` | `8` | Max concurrent claude processes |
|
||||
| `CLAUDE_MAX_PROMPT_CHARS` | `150000` | Prompt truncation limit (chars) |
|
||||
| `CLAUDE_SESSION_TTL` | `3600000` | Session expiry (ms, default: 1 hour) |
|
||||
| `CLAUDE_ALLOWED_TOOLS` | `Bash,Read,...,Agent` | Comma-separated tools to pre-approve |
|
||||
| `CLAUDE_SKIP_PERMISSIONS` | `false` | 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` | `5` | Max concurrent claude processes |
|
||||
| `PROXY_API_KEY` | *(unset)* | Bearer token for API authentication |
|
||||
| `CLAUDE_SKIP_PERMISSIONS` | `false` | Bypass all permission checks |
|
||||
| `CLAUDE_NO_CONTEXT` | `false` | Suppress CLAUDE.md and auto-memory injection (pure API mode) |
|
||||
| `PROXY_API_KEY` | *(unset)* | Bearer token for shared-mode authentication |
|
||||
| `PROXY_ANONYMOUS_KEY` | *(unset)* | Well-known anonymous key allowlist (multi mode). When set, this exact string bypasses `validateKey()` and grants public access. Exposed via `/health.anonymousKey` so clients auto-discover. See [Anonymous Access](#anonymous-access-optional). |
|
||||
|
||||
## API Endpoints
|
||||
## Security
|
||||
|
||||
- `GET /v1/models` — List available models
|
||||
- `POST /v1/chat/completions` — Chat completion (streaming + non-streaming)
|
||||
- `GET /health` — Comprehensive health check (stats, sessions, auth, config)
|
||||
- `GET /sessions` — List active sessions
|
||||
- `DELETE /sessions` — Clear all sessions
|
||||
|
||||
## Authentication
|
||||
|
||||
The proxy supports optional Bearer token authentication via the `PROXY_API_KEY` environment variable.
|
||||
|
||||
**When `PROXY_API_KEY` is set**, all requests (except `GET /health`) must include a valid `Authorization: Bearer <token>` header. Requests with a missing or invalid token receive a `401 Unauthorized` response.
|
||||
|
||||
**When `PROXY_API_KEY` is not set**, authentication is disabled and all requests are accepted.
|
||||
|
||||
```bash
|
||||
# Start with auth enabled
|
||||
PROXY_API_KEY=my-secret-token node server.mjs
|
||||
```
|
||||
|
||||
## Architecture: v1 vs v2
|
||||
|
||||
| | v1.x (pool) | v2.0 (on-demand) |
|
||||
|---|---|---|
|
||||
| Process lifecycle | Pre-spawn idle workers | Spawn per request |
|
||||
| Crash handling | Backoff → DEGRADED → manual restart | No crash loops (no idle workers) |
|
||||
| Session support | None (stateless) | --resume with session tracking |
|
||||
| Tool access | 6 tools hardcoded | Configurable, expanded defaults |
|
||||
| System prompt | None | CLAUDE_SYSTEM_PROMPT env |
|
||||
| MCP support | None | CLAUDE_MCP_CONFIG env |
|
||||
| Concurrency | Unlimited (dangerous) | CLAUDE_MAX_CONCURRENT limit |
|
||||
| Auth monitoring | None | Periodic health checks |
|
||||
| Diagnostics | Basic /health | Full stats, sessions, errors |
|
||||
|
||||
## Coexistence with Claude Code
|
||||
|
||||
OCP and Claude Code interactive mode (including Telegram bots) are completely independent:
|
||||
|
||||
| | OCP (this proxy) | CC interactive |
|
||||
|---|---|---|
|
||||
| Protocol | HTTP (localhost:3456) | MCP (in-process) |
|
||||
| Process model | Per-request spawn | Persistent session |
|
||||
| Lifecycle | Daemon (auto-start, auto-recover) | Requires terminal |
|
||||
| Permission model | Pre-approved tools | Interactive prompts |
|
||||
| Use case | Automated agent work | Human-in-the-loop |
|
||||
|
||||
Both can run on the same machine simultaneously. No shared state, no port conflicts.
|
||||
|
||||
## Recovery after OpenClaw upgrade
|
||||
|
||||
OpenClaw upgrades (`npm update -g openclaw`) **do not overwrite** the user config at `~/.openclaw/openclaw.json`. However, if the claude-local models stop working after an upgrade:
|
||||
|
||||
### One-command recovery
|
||||
|
||||
```bash
|
||||
cd ~/.openclaw/projects/claude-proxy # or wherever you cloned it
|
||||
git pull # pull latest version
|
||||
node setup.mjs # reconfigure OpenClaw + start proxy
|
||||
openclaw gateway restart
|
||||
```
|
||||
|
||||
## Notes
|
||||
|
||||
- Cost shows as $0 because billing goes through your Claude subscription
|
||||
- Each request spawns a `claude -p` process; concurrent requests are capped by `CLAUDE_MAX_CONCURRENT`
|
||||
- The proxy must run on the same machine as the Claude CLI (uses local OAuth)
|
||||
- Session data is stored by Claude CLI on disk; session map is in-memory (lost on proxy restart)
|
||||
- **Localhost by default** — binds to `127.0.0.1`; set `CLAUDE_BIND=0.0.0.0` to enable LAN access
|
||||
- **3-tier auth** — `none` (trusted network), `shared` (single key), `multi` (per-user keys with usage tracking)
|
||||
- **Timing-safe key comparison** — prevents timing attacks on API keys and admin keys
|
||||
- **Admin-only key management** — creating, listing, and revoking keys requires the admin key
|
||||
- **Public endpoints** — `/health` and `/dashboard` are always accessible without auth
|
||||
- **No API keys needed** — authentication goes through Claude CLI's OAuth session
|
||||
- **Keys stored locally** — `~/.ocp/ocp.db` (SQLite), never sent to external services
|
||||
- **Auto-start** — launchd (macOS) / systemd (Linux)
|
||||
|
||||
## License
|
||||
|
||||
|
||||
+247
@@ -0,0 +1,247 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||
<title>OCP Dashboard</title>
|
||||
<style>
|
||||
* { box-sizing: border-box; margin: 0; padding: 0; }
|
||||
body { font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif; background: #0f172a; color: #e2e8f0; padding: 1.5rem; }
|
||||
h1 { font-size: 1.5rem; margin-bottom: 1rem; color: #38bdf8; }
|
||||
h2 { font-size: 1.1rem; margin: 1.5rem 0 0.5rem; color: #94a3b8; }
|
||||
.grid { display: grid; grid-template-columns: repeat(auto-fit, minmax(200px, 1fr)); gap: 1rem; margin-bottom: 1.5rem; }
|
||||
.card { background: #1e293b; border-radius: 8px; padding: 1rem; }
|
||||
.card .label { font-size: 0.75rem; color: #64748b; text-transform: uppercase; }
|
||||
.card .value { font-size: 1.5rem; font-weight: 700; margin-top: 0.25rem; }
|
||||
.card .sub { font-size: 0.8rem; color: #94a3b8; margin-top: 0.25rem; }
|
||||
table { width: 100%; border-collapse: collapse; margin-top: 0.5rem; }
|
||||
th, td { text-align: left; padding: 0.5rem 0.75rem; border-bottom: 1px solid #334155; font-size: 0.85rem; }
|
||||
th { color: #64748b; font-weight: 600; }
|
||||
.tag { display: inline-block; padding: 2px 8px; border-radius: 4px; font-size: 0.75rem; }
|
||||
.tag-ok { background: #065f46; color: #6ee7b7; }
|
||||
.tag-err { background: #7f1d1d; color: #fca5a5; }
|
||||
.btn { background: #2563eb; color: white; border: none; padding: 0.5rem 1rem; border-radius: 6px; cursor: pointer; font-size: 0.85rem; }
|
||||
.btn:hover { background: #1d4ed8; }
|
||||
.btn-sm { padding: 0.25rem 0.5rem; font-size: 0.75rem; }
|
||||
.btn-danger { background: #dc2626; }
|
||||
.btn-danger:hover { background: #b91c1c; }
|
||||
input { background: #1e293b; border: 1px solid #334155; color: #e2e8f0; padding: 0.4rem 0.75rem; border-radius: 6px; font-size: 0.85rem; }
|
||||
.flex { display: flex; gap: 0.5rem; align-items: center; }
|
||||
.mono { font-family: "SF Mono", "Fira Code", monospace; font-size: 0.8rem; }
|
||||
.bar-bg { background: #334155; border-radius: 4px; height: 8px; overflow: hidden; margin-top: 0.5rem; }
|
||||
.bar-fill { height: 100%; border-radius: 4px; transition: width 0.5s; }
|
||||
.bar-blue { background: #3b82f6; }
|
||||
.bar-amber { background: #f59e0b; }
|
||||
.bar-red { background: #ef4444; }
|
||||
#refresh-indicator { font-size: 0.75rem; color: #475569; }
|
||||
.section { margin-bottom: 2rem; }
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
|
||||
<div class="flex" style="justify-content: space-between; margin-bottom: 1.5rem;">
|
||||
<h1>OCP Dashboard</h1>
|
||||
<div class="flex">
|
||||
<span id="refresh-indicator"></span>
|
||||
<button class="btn btn-sm" onclick="refreshAll()">Refresh</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="grid" id="status-cards"></div>
|
||||
|
||||
<div class="section">
|
||||
<h2>Plan Usage</h2>
|
||||
<div class="grid" id="plan-cards"></div>
|
||||
</div>
|
||||
|
||||
<div class="section">
|
||||
<h2>Usage by Key</h2>
|
||||
<table id="key-usage-table">
|
||||
<thead><tr><th>Key</th><th>Requests</th><th>OK</th><th>Err</th><th>Avg Time</th><th>Last Request</th></tr></thead>
|
||||
<tbody></tbody>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
<div class="section" id="key-mgmt-section" style="display:none">
|
||||
<h2>API Keys</h2>
|
||||
<div class="flex" style="margin-bottom: 0.75rem;">
|
||||
<input type="text" id="new-key-name" placeholder="Key name (e.g. wife-laptop)">
|
||||
<button class="btn btn-sm" onclick="addKey()">Create Key</button>
|
||||
</div>
|
||||
<table id="keys-table">
|
||||
<thead><tr><th>Name</th><th>Key</th><th>Created</th><th>Status</th><th></th></tr></thead>
|
||||
<tbody></tbody>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
<div class="section">
|
||||
<h2>Recent Requests</h2>
|
||||
<table id="recent-table">
|
||||
<thead><tr><th>Time</th><th>Key</th><th>Model</th><th>Prompt</th><th>Response</th><th>Time</th><th>Status</th></tr></thead>
|
||||
<tbody></tbody>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
<script>
|
||||
const BASE = window.location.origin;
|
||||
const headers = {};
|
||||
const urlToken = new URLSearchParams(window.location.search).get("token");
|
||||
const storedToken = urlToken || localStorage.getItem("ocp_token");
|
||||
if (urlToken) { localStorage.setItem("ocp_token", urlToken); history.replaceState(null, "", "/dashboard"); }
|
||||
if (storedToken) headers["Authorization"] = `Bearer ${storedToken}`;
|
||||
|
||||
async function api(path) {
|
||||
const resp = await fetch(BASE + path, { headers });
|
||||
if (resp.status === 401 || resp.status === 403) {
|
||||
const token = prompt("Enter your OCP admin key:");
|
||||
if (token) {
|
||||
localStorage.setItem("ocp_token", token);
|
||||
headers["Authorization"] = `Bearer ${token}`;
|
||||
return api(path);
|
||||
}
|
||||
}
|
||||
return resp.json();
|
||||
}
|
||||
|
||||
async function apiPost(path, body) {
|
||||
const resp = await fetch(BASE + path, {
|
||||
method: "POST", headers: { ...headers, "Content-Type": "application/json" },
|
||||
body: JSON.stringify(body),
|
||||
});
|
||||
return resp.json();
|
||||
}
|
||||
|
||||
async function apiDelete(path) {
|
||||
const resp = await fetch(BASE + path, { method: "DELETE", headers });
|
||||
return resp.json();
|
||||
}
|
||||
|
||||
function fmtTime(ms) {
|
||||
if (!ms) return "-";
|
||||
return ms > 1000 ? (ms/1000).toFixed(1) + "s" : Math.round(ms) + "ms";
|
||||
}
|
||||
|
||||
function fmtChars(n) {
|
||||
if (!n) return "-";
|
||||
return n > 1000 ? (n/1000).toFixed(0) + "K" : String(n);
|
||||
}
|
||||
|
||||
function barColor(pct) {
|
||||
if (pct >= 80) return "bar-red";
|
||||
if (pct >= 50) return "bar-amber";
|
||||
return "bar-blue";
|
||||
}
|
||||
|
||||
async function refreshStatus() {
|
||||
const data = await api("/status");
|
||||
const p = data.proxy || {};
|
||||
const r = data.requests || {};
|
||||
|
||||
document.getElementById("status-cards").innerHTML = `
|
||||
<div class="card"><div class="label">Status</div><div class="value"><span class="tag ${p.status === 'ok' ? 'tag-ok' : 'tag-err'}">${p.status || '?'}</span></div><div class="sub">v${p.version || '?'}</div></div>
|
||||
<div class="card"><div class="label">Uptime</div><div class="value">${p.uptime || '?'}</div></div>
|
||||
<div class="card"><div class="label">Requests</div><div class="value">${r.total || 0}</div><div class="sub">${r.active || 0} active</div></div>
|
||||
<div class="card"><div class="label">Errors</div><div class="value">${r.errors || 0}</div><div class="sub">${r.timeouts || 0} timeouts</div></div>
|
||||
<div class="card"><div class="label">Sessions</div><div class="value">${p.activeSessions || 0}</div></div>
|
||||
`;
|
||||
|
||||
const plan = data.plan;
|
||||
if (plan && typeof plan === 'object' && !plan.error) {
|
||||
const s = plan.currentSession || {};
|
||||
const w = plan.weeklyLimits?.allModels || {};
|
||||
const sPct = Math.round((s.utilization || 0) * 100);
|
||||
const wPct = Math.round((w.utilization || 0) * 100);
|
||||
document.getElementById("plan-cards").innerHTML = `
|
||||
<div class="card">
|
||||
<div class="label">Session (5h)</div>
|
||||
<div class="value">${s.percent || '?'}</div>
|
||||
<div class="bar-bg"><div class="bar-fill ${barColor(sPct)}" style="width:${sPct}%"></div></div>
|
||||
<div class="sub">Resets in ${s.resetsIn || '?'}</div>
|
||||
</div>
|
||||
<div class="card">
|
||||
<div class="label">Weekly (7d)</div>
|
||||
<div class="value">${w.percent || '?'}</div>
|
||||
<div class="bar-bg"><div class="bar-fill ${barColor(wPct)}" style="width:${wPct}%"></div></div>
|
||||
<div class="sub">Resets in ${w.resetsIn || '?'}</div>
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
}
|
||||
|
||||
async function refreshUsage() {
|
||||
try {
|
||||
const data = await api("/api/usage");
|
||||
const tbody = document.querySelector("#key-usage-table tbody");
|
||||
tbody.innerHTML = (data.byKey || []).map(k => `
|
||||
<tr>
|
||||
<td>${k.key_name}</td>
|
||||
<td>${k.requests}</td>
|
||||
<td>${k.successes}</td>
|
||||
<td>${k.errors}</td>
|
||||
<td>${fmtTime(k.avg_elapsed_ms)}</td>
|
||||
<td class="mono">${k.last_request || '-'}</td>
|
||||
</tr>
|
||||
`).join("") || '<tr><td colspan="6" style="color:#475569">No usage data yet</td></tr>';
|
||||
|
||||
const rtbody = document.querySelector("#recent-table tbody");
|
||||
rtbody.innerHTML = (data.recent || []).slice(0, 20).map(r => `
|
||||
<tr>
|
||||
<td class="mono">${r.created_at?.slice(11, 19) || '?'}</td>
|
||||
<td>${r.key_name}</td>
|
||||
<td>${r.model}</td>
|
||||
<td>${fmtChars(r.prompt_chars)}</td>
|
||||
<td>${fmtChars(r.response_chars)}</td>
|
||||
<td>${fmtTime(r.elapsed_ms)}</td>
|
||||
<td><span class="tag ${r.success ? 'tag-ok' : 'tag-err'}">${r.success ? 'ok' : 'err'}</span></td>
|
||||
</tr>
|
||||
`).join("") || '<tr><td colspan="7" style="color:#475569">No requests yet</td></tr>';
|
||||
} catch(e) {
|
||||
console.warn("Usage API not available:", e);
|
||||
}
|
||||
}
|
||||
|
||||
async function refreshKeys() {
|
||||
try {
|
||||
const data = await api("/api/keys");
|
||||
document.getElementById("key-mgmt-section").style.display = "";
|
||||
const tbody = document.querySelector("#keys-table tbody");
|
||||
tbody.innerHTML = (data.keys || []).map(k => `
|
||||
<tr>
|
||||
<td>${k.name}</td>
|
||||
<td class="mono">${k.keyPreview}</td>
|
||||
<td class="mono">${k.created_at}</td>
|
||||
<td><span class="tag ${k.revoked ? 'tag-err' : 'tag-ok'}">${k.revoked ? 'revoked' : 'active'}</span></td>
|
||||
<td>${k.revoked ? '' : `<button class="btn btn-sm btn-danger" onclick="revokeKeyUI('${k.name}')">Revoke</button>`}</td>
|
||||
</tr>
|
||||
`).join("");
|
||||
} catch(e) { /* not admin */ }
|
||||
}
|
||||
|
||||
async function addKey() {
|
||||
const name = document.getElementById("new-key-name").value.trim();
|
||||
if (!name) return alert("Enter a key name");
|
||||
const result = await apiPost("/api/keys", { name });
|
||||
if (result.key) {
|
||||
alert("New API Key (copy it now — you won't see it again):\n\n" + result.key);
|
||||
document.getElementById("new-key-name").value = "";
|
||||
refreshKeys();
|
||||
}
|
||||
}
|
||||
|
||||
async function revokeKeyUI(name) {
|
||||
if (!confirm(`Revoke key "${name}"?`)) return;
|
||||
await apiDelete(`/api/keys/${encodeURIComponent(name)}`);
|
||||
refreshKeys();
|
||||
}
|
||||
|
||||
async function refreshAll() {
|
||||
document.getElementById("refresh-indicator").textContent = "Refreshing...";
|
||||
await Promise.all([refreshStatus(), refreshUsage(), refreshKeys()]);
|
||||
document.getElementById("refresh-indicator").textContent = `Updated ${new Date().toLocaleTimeString()}`;
|
||||
}
|
||||
|
||||
refreshAll();
|
||||
setInterval(refreshAll, 30000);
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
Binary file not shown.
|
After Width: | Height: | Size: 222 KiB |
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,160 @@
|
||||
// keys.mjs — API key management and usage tracking for OCP LAN mode
|
||||
// Uses Node.js built-in SQLite (node:sqlite) — zero external dependencies.
|
||||
import { DatabaseSync } from "node:sqlite";
|
||||
import { randomBytes } from "node:crypto";
|
||||
import { join } from "node:path";
|
||||
import { mkdirSync } from "node:fs";
|
||||
import { homedir } from "node:os";
|
||||
|
||||
const OCP_DIR = join(homedir(), ".ocp");
|
||||
mkdirSync(OCP_DIR, { recursive: true });
|
||||
const DB_PATH = join(OCP_DIR, "ocp.db");
|
||||
|
||||
let db;
|
||||
|
||||
export function getDb() {
|
||||
if (!db) {
|
||||
db = new DatabaseSync(DB_PATH);
|
||||
db.exec("PRAGMA journal_mode = WAL");
|
||||
db.exec("PRAGMA foreign_keys = ON");
|
||||
initSchema();
|
||||
}
|
||||
return db;
|
||||
}
|
||||
|
||||
function initSchema() {
|
||||
db.exec(`
|
||||
CREATE TABLE IF NOT EXISTS api_keys (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
key TEXT UNIQUE NOT NULL,
|
||||
name TEXT NOT NULL,
|
||||
created_at TEXT NOT NULL DEFAULT (datetime('now')),
|
||||
revoked INTEGER NOT NULL DEFAULT 0
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS usage_log (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
key_id INTEGER,
|
||||
key_name TEXT NOT NULL DEFAULT 'anonymous',
|
||||
model TEXT NOT NULL,
|
||||
prompt_chars INTEGER NOT NULL DEFAULT 0,
|
||||
response_chars INTEGER NOT NULL DEFAULT 0,
|
||||
elapsed_ms INTEGER NOT NULL DEFAULT 0,
|
||||
success INTEGER NOT NULL DEFAULT 1,
|
||||
created_at TEXT NOT NULL DEFAULT (datetime('now')),
|
||||
FOREIGN KEY (key_id) REFERENCES api_keys(id)
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_usage_created ON usage_log(created_at);
|
||||
CREATE INDEX IF NOT EXISTS idx_usage_key ON usage_log(key_id);
|
||||
`);
|
||||
}
|
||||
|
||||
// ── Key CRUD ──
|
||||
|
||||
export function createKey(name) {
|
||||
const key = "ocp_" + randomBytes(24).toString("base64url");
|
||||
const d = getDb();
|
||||
const stmt = d.prepare("INSERT INTO api_keys (key, name) VALUES (?, ?)");
|
||||
const result = stmt.run(key, name);
|
||||
return { id: result.lastInsertRowid, key, name };
|
||||
}
|
||||
|
||||
export function listKeys() {
|
||||
const d = getDb();
|
||||
return d.prepare(
|
||||
"SELECT id, key, name, created_at, revoked FROM api_keys ORDER BY created_at DESC"
|
||||
).all().map(({ key, ...rest }) => ({
|
||||
...rest,
|
||||
keyPreview: key.slice(0, 8) + "..." + key.slice(-4),
|
||||
}));
|
||||
}
|
||||
|
||||
export function revokeKey(idOrName) {
|
||||
const d = getDb();
|
||||
const stmt = d.prepare(
|
||||
"UPDATE api_keys SET revoked = 1 WHERE (id = ? OR name = ?) AND revoked = 0"
|
||||
);
|
||||
return stmt.run(idOrName, idOrName).changes > 0;
|
||||
}
|
||||
|
||||
export function validateKey(key) {
|
||||
const d = getDb();
|
||||
const row = d.prepare(
|
||||
"SELECT id, name FROM api_keys WHERE key = ? AND revoked = 0"
|
||||
).get(key);
|
||||
return row || null;
|
||||
}
|
||||
|
||||
// ── Usage recording ──
|
||||
|
||||
export function recordUsage({ keyId, keyName, model, promptChars, responseChars, elapsedMs, success }) {
|
||||
const d = getDb();
|
||||
d.prepare(`
|
||||
INSERT INTO usage_log (key_id, key_name, model, prompt_chars, response_chars, elapsed_ms, success)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?)
|
||||
`).run(keyId ?? null, keyName || "anonymous", model, promptChars, responseChars, elapsedMs, success ? 1 : 0);
|
||||
}
|
||||
|
||||
// ── Usage queries ──
|
||||
|
||||
export function getUsageByKey({ since, until } = {}) {
|
||||
const d = getDb();
|
||||
let where = "WHERE 1=1";
|
||||
const params = [];
|
||||
if (since) { where += " AND created_at >= ?"; params.push(since); }
|
||||
if (until) { where += " AND created_at <= ?"; params.push(until); }
|
||||
|
||||
return d.prepare(`
|
||||
SELECT
|
||||
key_name,
|
||||
COUNT(*) as requests,
|
||||
SUM(CASE WHEN success = 1 THEN 1 ELSE 0 END) as successes,
|
||||
SUM(CASE WHEN success = 0 THEN 1 ELSE 0 END) as errors,
|
||||
SUM(prompt_chars) as total_prompt_chars,
|
||||
SUM(response_chars) as total_response_chars,
|
||||
SUM(elapsed_ms) as total_elapsed_ms,
|
||||
AVG(elapsed_ms) as avg_elapsed_ms,
|
||||
MIN(created_at) as first_request,
|
||||
MAX(created_at) as last_request
|
||||
FROM usage_log
|
||||
${where}
|
||||
GROUP BY key_name
|
||||
ORDER BY requests DESC
|
||||
`).all(...params);
|
||||
}
|
||||
|
||||
export function getUsageTimeline({ keyName, hours = 24 } = {}) {
|
||||
const d = getDb();
|
||||
const since = new Date(Date.now() - hours * 3600000).toISOString();
|
||||
let where = "WHERE created_at >= ?";
|
||||
const params = [since];
|
||||
if (keyName) { where += " AND key_name = ?"; params.push(keyName); }
|
||||
|
||||
return d.prepare(`
|
||||
SELECT
|
||||
strftime('%Y-%m-%dT%H:00:00', created_at) as hour,
|
||||
COUNT(*) as requests,
|
||||
SUM(prompt_chars) as prompt_chars,
|
||||
SUM(response_chars) as response_chars,
|
||||
AVG(elapsed_ms) as avg_elapsed_ms
|
||||
FROM usage_log
|
||||
${where}
|
||||
GROUP BY hour
|
||||
ORDER BY hour
|
||||
`).all(...params);
|
||||
}
|
||||
|
||||
export function getRecentUsage(limit = 50) {
|
||||
const d = getDb();
|
||||
return d.prepare(`
|
||||
SELECT key_name, model, prompt_chars, response_chars, elapsed_ms, success, created_at
|
||||
FROM usage_log
|
||||
ORDER BY created_at DESC
|
||||
LIMIT ?
|
||||
`).all(limit);
|
||||
}
|
||||
|
||||
export function closeDb() {
|
||||
if (db) { db.close(); db = null; }
|
||||
}
|
||||
@@ -0,0 +1,858 @@
|
||||
#!/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"
|
||||
|
||||
# Auth header for multi-key mode: reads from OCP_ADMIN_KEY env or ~/.ocp/admin-key file
|
||||
_AUTH_HEADER=""
|
||||
if [[ -n "${OCP_ADMIN_KEY:-}" ]]; then
|
||||
_AUTH_HEADER="-H \"Authorization: Bearer $OCP_ADMIN_KEY\""
|
||||
elif [[ -f "$HOME/.ocp/admin-key" ]]; then
|
||||
_AUTH_HEADER="-H \"Authorization: Bearer $(cat "$HOME/.ocp/admin-key")\""
|
||||
fi
|
||||
|
||||
# Wrapper: curl with optional auth
|
||||
_curl() {
|
||||
if [[ -n "$_AUTH_HEADER" ]]; then
|
||||
eval curl "$_AUTH_HEADER" "$@"
|
||||
else
|
||||
curl "$@"
|
||||
fi
|
||||
}
|
||||
|
||||
_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 [--by-key]
|
||||
|
||||
Options:
|
||||
--by-key Show per-key usage statistics
|
||||
EOF
|
||||
}
|
||||
|
||||
cmd_usage() {
|
||||
if [[ "${1:-}" == "--by-key" ]]; then
|
||||
local data
|
||||
data=$(_curl -sf --max-time 15 "$PROXY/api/usage" 2>&1) || { echo "Error: proxy unreachable or usage API not available"; exit 1; }
|
||||
echo "$data" | python3 -c "
|
||||
import sys, json
|
||||
d = json.loads(sys.stdin.read())
|
||||
by_key = d.get('byKey', [])
|
||||
if not by_key:
|
||||
print('No usage data yet.')
|
||||
else:
|
||||
print('Usage by Key')
|
||||
print('─────────────────────────────────────────────────────────────────')
|
||||
hdr = f' {\"Key\":<20} {\"Reqs\":>5} {\"OK\":>4} {\"Err\":>4} {\"Avg Time\":>9} {\"Last Request\":<20}'
|
||||
print(hdr)
|
||||
print(' ' + '─' * (len(hdr) - 2))
|
||||
for k in by_key:
|
||||
avg_t = f'{k[\"avg_elapsed_ms\"]/1000:.1f}s' if k['avg_elapsed_ms'] else '-'
|
||||
print(f' {k[\"key_name\"]:<20} {k[\"requests\"]:>5} {k[\"successes\"]:>4} {k[\"errors\"]:>4} {avg_t:>9} {k[\"last_request\"]:<20}')
|
||||
"
|
||||
return
|
||||
fi
|
||||
|
||||
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."
|
||||
}
|
||||
|
||||
# ── keys ────────────────────────────────────────────────────────────────
|
||||
cmd_keys_help() {
|
||||
cat <<'EOF'
|
||||
ocp keys — Manage API keys (multi-key auth mode)
|
||||
|
||||
Usage:
|
||||
ocp keys List all keys
|
||||
ocp keys add <name> Create a new key
|
||||
ocp keys revoke <name|id> Revoke a key
|
||||
|
||||
Examples:
|
||||
ocp keys add wife-laptop
|
||||
ocp keys add son-ipad
|
||||
ocp keys revoke wife-laptop
|
||||
EOF
|
||||
}
|
||||
|
||||
cmd_keys() {
|
||||
case "${1:-}" in
|
||||
add)
|
||||
if [[ -z "${2:-}" ]]; then echo "Usage: ocp keys add <name>"; return 1; fi
|
||||
local result
|
||||
result=$(_curl -sf --max-time 5 -X POST "$PROXY/api/keys" \
|
||||
-H "Content-Type: application/json" \
|
||||
-d "{\"name\": \"$2\"}" 2>&1) || { echo "Error: proxy unreachable or unauthorized"; exit 1; }
|
||||
echo "$result" | python3 -c "
|
||||
import sys, json
|
||||
d = json.loads(sys.stdin.read())
|
||||
if 'key' in d:
|
||||
print(f'✓ Key created for \"{d[\"name\"]}\"')
|
||||
print(f'')
|
||||
print(f' API Key: {d[\"key\"]}')
|
||||
print(f'')
|
||||
print(f' Copy this key now — you won\\'t see it again.')
|
||||
print(f' Configure in IDE: OPENAI_API_KEY={d[\"key\"]}')
|
||||
else:
|
||||
print(f'✗ {d.get(\"error\", \"Unknown error\")}')
|
||||
"
|
||||
;;
|
||||
revoke)
|
||||
if [[ -z "${2:-}" ]]; then echo "Usage: ocp keys revoke <name|id>"; return 1; fi
|
||||
_curl -sf --max-time 5 -X DELETE "$PROXY/api/keys/$2" | python3 -c "
|
||||
import sys, json
|
||||
d = json.loads(sys.stdin.read())
|
||||
if d.get('revoked'):
|
||||
print(f'✓ Key \"{d[\"idOrName\"]}\" revoked.')
|
||||
else:
|
||||
print(f'✗ Key not found or already revoked.')
|
||||
"
|
||||
;;
|
||||
--help|-h)
|
||||
cmd_keys_help
|
||||
;;
|
||||
"")
|
||||
_curl -sf --max-time 5 "$PROXY/api/keys" | python3 -c "
|
||||
import sys, json
|
||||
d = json.loads(sys.stdin.read())
|
||||
keys = d.get('keys', [])
|
||||
if not keys:
|
||||
print('No API keys configured.')
|
||||
print('Create one: ocp keys add <name>')
|
||||
else:
|
||||
print('API Keys')
|
||||
print('─────────────────────────────────────────────────')
|
||||
for k in keys:
|
||||
status = '✗ revoked' if k['revoked'] else '✓ active'
|
||||
print(f' {k[\"name\"]:<20} {k[\"keyPreview\"]:<20} {status} {k[\"created_at\"]}')
|
||||
" 2>/dev/null || { echo "Error: proxy unreachable or key management not available"; exit 1; }
|
||||
;;
|
||||
*)
|
||||
echo "Unknown subcommand: $1"; cmd_keys_help; return 1 ;;
|
||||
esac
|
||||
}
|
||||
|
||||
# ── connect ─────────────────────────────────────────────────────────────
|
||||
cmd_connect_help() {
|
||||
cat <<'EOF'
|
||||
ocp connect — Connect this machine to a remote OCP as a LAN client
|
||||
|
||||
Configures OPENAI_BASE_URL (and optionally OPENAI_API_KEY) in your shell
|
||||
rc file so tools like Claude Code point to a remote OCP instance.
|
||||
|
||||
Usage:
|
||||
ocp connect <host-ip> [--port PORT] [--key API_KEY]
|
||||
|
||||
Arguments:
|
||||
host-ip IP address of the machine running OCP
|
||||
--port PORT Port OCP listens on (default: 3456)
|
||||
--key API_KEY API key to use (prompted if remote requires auth)
|
||||
|
||||
Examples:
|
||||
ocp connect 192.168.1.10
|
||||
ocp connect 192.168.1.10 --port 8080
|
||||
ocp connect 192.168.1.10 --key sk-abc123
|
||||
EOF
|
||||
}
|
||||
|
||||
cmd_connect() {
|
||||
local host="" port=3456 key=""
|
||||
|
||||
# Parse args
|
||||
while [[ $# -gt 0 ]]; do
|
||||
case "$1" in
|
||||
--port) port="${2:?'--port requires a value'}"; shift 2 ;;
|
||||
--key) key="${2:?'--key requires a value'}"; shift 2 ;;
|
||||
--help|-h) cmd_connect_help; return 0 ;;
|
||||
-*) echo "Unknown option: $1"; cmd_connect_help; return 1 ;;
|
||||
*) host="$1"; shift ;;
|
||||
esac
|
||||
done
|
||||
|
||||
if [[ -z "$host" ]]; then
|
||||
echo "Error: host IP is required."
|
||||
echo ""
|
||||
cmd_connect_help
|
||||
return 1
|
||||
fi
|
||||
|
||||
if ! [[ "$host" =~ ^[a-zA-Z0-9._-]+$ ]]; then
|
||||
echo "Error: invalid host '$host'"
|
||||
return 1
|
||||
fi
|
||||
|
||||
local base_url="http://$host:$port"
|
||||
|
||||
echo "OCP Connect"
|
||||
echo "─────────────────────────────────────"
|
||||
echo " Remote: $base_url"
|
||||
echo ""
|
||||
|
||||
# Step 1: Test connectivity via /health
|
||||
echo " Checking connectivity..."
|
||||
local health_json
|
||||
health_json=$(curl -sf --max-time 10 "$base_url/health" 2>/dev/null) || {
|
||||
echo " ✗ Cannot reach $base_url/health"
|
||||
echo " Make sure OCP is running on $host and bound to 0.0.0.0 (LAN mode)."
|
||||
return 1
|
||||
}
|
||||
echo " ✓ Connected"
|
||||
echo ""
|
||||
|
||||
# Step 2: Show remote info
|
||||
local remote_version auth_mode
|
||||
remote_version=$(echo "$health_json" | python3 -c "import sys,json; d=json.loads(sys.stdin.read()); print(d.get('version','?'))" 2>/dev/null || echo "?")
|
||||
auth_mode=$(echo "$health_json" | python3 -c "import sys,json; d=json.loads(sys.stdin.read()); print(d.get('authMode','none'))" 2>/dev/null || echo "none")
|
||||
|
||||
echo " Remote OCP v$remote_version (auth: $auth_mode)"
|
||||
echo ""
|
||||
|
||||
# Step 3: Determine if key is needed
|
||||
local needs_key=0
|
||||
if [[ "$auth_mode" != "none" ]]; then
|
||||
needs_key=1
|
||||
fi
|
||||
|
||||
if [[ $needs_key -eq 1 && -z "$key" ]]; then
|
||||
echo " Remote requires authentication."
|
||||
echo " Ask the admin to run: ocp keys add <name>"
|
||||
printf " Enter API key (or press Enter to skip): "
|
||||
read -rs key </dev/tty
|
||||
echo
|
||||
if [[ -z "$key" ]]; then
|
||||
echo ""
|
||||
echo " ✗ No key provided — cannot connect to an auth-required remote without a key."
|
||||
return 1
|
||||
fi
|
||||
fi
|
||||
|
||||
# Step 4: Test API access via /v1/models
|
||||
echo " Testing API access..."
|
||||
local models_out models_ok=0
|
||||
if [[ -n "$key" ]]; then
|
||||
models_out=$(curl -sf --max-time 10 \
|
||||
-H "Authorization: Bearer $key" \
|
||||
"$base_url/v1/models" 2>/dev/null) && models_ok=1
|
||||
else
|
||||
models_out=$(curl -sf --max-time 10 "$base_url/v1/models" 2>/dev/null) && models_ok=1
|
||||
fi
|
||||
|
||||
if [[ $models_ok -eq 0 ]]; then
|
||||
echo " ✗ API access failed — key may be invalid or revoked."
|
||||
return 1
|
||||
fi
|
||||
local model_count
|
||||
model_count=$(echo "$models_out" | python3 -c "import sys,json; print(len(json.loads(sys.stdin.read()).get('data',[])))" 2>/dev/null || echo "?")
|
||||
echo " ✓ API accessible ($model_count models available)"
|
||||
echo ""
|
||||
|
||||
# Step 5: Detect shell rc file
|
||||
local rc_file
|
||||
if [[ "${SHELL:-}" == */zsh ]]; then
|
||||
rc_file="$HOME/.zshrc"
|
||||
else
|
||||
rc_file="$HOME/.bashrc"
|
||||
fi
|
||||
|
||||
# Step 6: Remove any previously written OCP LAN lines
|
||||
if [[ -f "$rc_file" ]]; then
|
||||
local tmp_rc
|
||||
tmp_rc=$(mktemp)
|
||||
if python3 - "$rc_file" "$tmp_rc" <<'PYEOF'
|
||||
import sys
|
||||
src, dst = sys.argv[1], sys.argv[2]
|
||||
lines = open(src).readlines()
|
||||
out = []
|
||||
skip = False
|
||||
for line in lines:
|
||||
s = line.rstrip('\n')
|
||||
if s == '# OCP LAN (added by ocp connect)':
|
||||
skip = True
|
||||
continue
|
||||
if skip and (s == '' or s.startswith('export OPENAI_BASE_URL=') or s.startswith('export OPENAI_API_KEY=')):
|
||||
continue
|
||||
skip = False
|
||||
out.append(line)
|
||||
open(dst, 'w').writelines(out)
|
||||
PYEOF
|
||||
then
|
||||
cp "$tmp_rc" "$rc_file"
|
||||
fi
|
||||
rm -f "$tmp_rc"
|
||||
fi
|
||||
|
||||
# Step 7: Append new config
|
||||
{
|
||||
echo ""
|
||||
echo "# OCP LAN (added by ocp connect)"
|
||||
echo "export OPENAI_BASE_URL=$base_url/v1"
|
||||
if [[ -n "$key" ]]; then
|
||||
echo "export OPENAI_API_KEY=$key"
|
||||
fi
|
||||
} >> "$rc_file"
|
||||
|
||||
echo " Written to $rc_file:"
|
||||
echo " OPENAI_BASE_URL=$base_url/v1"
|
||||
if [[ -n "$key" ]]; then
|
||||
echo " OPENAI_API_KEY=${key:0:8}..."
|
||||
fi
|
||||
echo ""
|
||||
|
||||
# Step 8: Quick smoke test — send a minimal chat completion
|
||||
echo " Running smoke test..."
|
||||
local chat_payload='{"model":"claude-haiku-4-5-20251001","messages":[{"role":"user","content":"Reply with OK only."}],"max_tokens":10}'
|
||||
local chat_out chat_ok=0
|
||||
if [[ -n "$key" ]]; then
|
||||
chat_out=$(curl -sf --max-time 30 \
|
||||
-H "Authorization: Bearer $key" \
|
||||
-H "Content-Type: application/json" \
|
||||
-d "$chat_payload" \
|
||||
"$base_url/v1/chat/completions" 2>/dev/null) && chat_ok=1
|
||||
else
|
||||
chat_out=$(curl -sf --max-time 30 \
|
||||
-H "Content-Type: application/json" \
|
||||
-d "$chat_payload" \
|
||||
"$base_url/v1/chat/completions" 2>/dev/null) && chat_ok=1
|
||||
fi
|
||||
|
||||
if [[ $chat_ok -eq 1 ]]; then
|
||||
local reply
|
||||
reply=$(echo "$chat_out" | python3 -c "import sys,json; d=json.loads(sys.stdin.read()); print(d['choices'][0]['message']['content'].strip())" 2>/dev/null || echo "(response received)")
|
||||
echo " ✓ Smoke test passed: $reply"
|
||||
else
|
||||
echo " ⚠ Smoke test failed (proxy is reachable but chat completion did not succeed)."
|
||||
echo " The env vars have still been written. Check the remote OCP logs."
|
||||
fi
|
||||
|
||||
echo ""
|
||||
echo " Done. Reload your shell to apply:"
|
||||
echo " source $rc_file"
|
||||
}
|
||||
|
||||
# ── lan ─────────────────────────────────────────────────────────────────
|
||||
cmd_lan_help() {
|
||||
cat <<'EOF'
|
||||
ocp lan — Quick LAN mode setup guide
|
||||
|
||||
Shows current network configuration and connection instructions
|
||||
for other devices on the same network.
|
||||
|
||||
Usage: ocp lan
|
||||
EOF
|
||||
}
|
||||
|
||||
cmd_lan() {
|
||||
local ip
|
||||
ip=$(ipconfig getifaddr en0 2>/dev/null || ipconfig getifaddr en1 2>/dev/null || hostname -I 2>/dev/null | awk '{print $1}' || echo "unknown")
|
||||
local port=3456
|
||||
|
||||
echo "OCP LAN Setup"
|
||||
echo "─────────────────────────────────────"
|
||||
echo ""
|
||||
echo " Your IP: $ip"
|
||||
echo " Port: $port"
|
||||
echo ""
|
||||
echo " For IDE users, set:"
|
||||
echo " OPENAI_BASE_URL=http://$ip:$port/v1"
|
||||
echo " OPENAI_API_KEY=<your-key> (if auth enabled)"
|
||||
echo ""
|
||||
echo " Dashboard: http://$ip:$port/dashboard"
|
||||
echo ""
|
||||
|
||||
if curl -sf --max-time 2 "http://$ip:$port/health" > /dev/null 2>&1; then
|
||||
echo " Status: ✓ LAN-accessible"
|
||||
else
|
||||
echo " Status: ✗ Not LAN-accessible (bound to localhost only)"
|
||||
echo ""
|
||||
echo " To enable LAN mode, set env var and restart:"
|
||||
echo " CLAUDE_BIND=0.0.0.0"
|
||||
echo " ocp restart"
|
||||
fi
|
||||
}
|
||||
|
||||
# ── 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..."
|
||||
# Try current service name, then legacy, then manual restart
|
||||
local uid
|
||||
uid=$(id -u)
|
||||
if launchctl kickstart -k "gui/$uid/dev.ocp.proxy" 2>/dev/null; then
|
||||
true
|
||||
elif launchctl kickstart -k "gui/$uid/ai.openclaw.proxy" 2>/dev/null; then
|
||||
true
|
||||
elif systemctl --user restart ocp-proxy 2>/dev/null; then
|
||||
true
|
||||
elif systemctl --user restart openclaw-proxy 2>/dev/null; then
|
||||
true
|
||||
else
|
||||
echo "Service restart failed, trying kill + restart..."
|
||||
pkill -f "server.mjs.*CLAUDE_PROXY" 2>/dev/null || pkill -f "claude-proxy/server.mjs" 2>/dev/null || true
|
||||
sleep 1
|
||||
local self_r script_dir
|
||||
self_r="${BASH_SOURCE[0]}"
|
||||
while [[ -L "$self_r" ]]; do self_r="$(readlink "$self_r")"; done
|
||||
script_dir="$(cd "$(dirname "$self_r")" && pwd)"
|
||||
nohup node "$script_dir/server.mjs" >> "$HOME/.ocp/logs/proxy.log" 2>&1 &
|
||||
fi
|
||||
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
|
||||
}
|
||||
|
||||
# ── update ──────────────────────────────────────────────────────────────
|
||||
cmd_update_help() {
|
||||
cat <<'EOF'
|
||||
ocp update — Update OCP to the latest version
|
||||
|
||||
Pulls the latest code from GitHub, restarts the proxy service,
|
||||
and optionally syncs the plugin to the OpenClaw extensions directory.
|
||||
|
||||
Usage:
|
||||
ocp update Pull latest and restart
|
||||
ocp update --check Check for updates without applying
|
||||
EOF
|
||||
}
|
||||
|
||||
cmd_update() {
|
||||
local script_dir self
|
||||
self="${BASH_SOURCE[0]}"
|
||||
# Resolve symlinks (e.g. ~/.local/bin/ocp → real location)
|
||||
while [[ -L "$self" ]]; do self="$(readlink "$self")"; done
|
||||
script_dir="$(cd "$(dirname "$self")" && pwd)"
|
||||
|
||||
# Check-only mode
|
||||
if [[ "${1:-}" == "--check" ]]; then
|
||||
cd "$script_dir"
|
||||
git fetch origin main --quiet 2>/dev/null || true
|
||||
local local_ver remote_ver
|
||||
local_ver=$(python3 -c "import json; print(json.load(open('package.json'))['version'])" 2>/dev/null || echo "?")
|
||||
remote_ver=$(git show origin/main:package.json 2>/dev/null | python3 -c "import sys,json; print(json.load(sys.stdin)['version'])" 2>/dev/null || echo "?")
|
||||
local behind
|
||||
behind=$(git rev-list HEAD..origin/main --count 2>/dev/null || echo "?")
|
||||
echo "OCP Update Check"
|
||||
echo "─────────────────────────────────────"
|
||||
echo " Current: v$local_ver"
|
||||
echo " Latest: v$remote_ver"
|
||||
if [[ "$behind" == "0" ]]; then
|
||||
echo " Status: ✓ Up to date"
|
||||
else
|
||||
echo " Status: $behind commit(s) behind"
|
||||
echo ""
|
||||
echo " Run 'ocp update' to apply."
|
||||
fi
|
||||
return 0
|
||||
fi
|
||||
|
||||
echo "Updating OCP..."
|
||||
echo ""
|
||||
|
||||
# 1. Pull latest
|
||||
cd "$script_dir"
|
||||
local old_ver
|
||||
old_ver=$(python3 -c "import json; print(json.load(open('package.json'))['version'])" 2>/dev/null || echo "?")
|
||||
|
||||
echo " Pulling latest from GitHub..."
|
||||
if ! git pull origin main --ff-only 2>&1 | sed 's/^/ /'; then
|
||||
echo " ✗ Git pull failed. Resolve conflicts manually in: $script_dir"
|
||||
return 1
|
||||
fi
|
||||
|
||||
local new_ver
|
||||
new_ver=$(python3 -c "import json; print(json.load(open('package.json'))['version'])" 2>/dev/null || echo "?")
|
||||
|
||||
if [[ "$old_ver" == "$new_ver" ]]; then
|
||||
echo " ✓ Already at latest (v$new_ver)"
|
||||
else
|
||||
echo " ✓ Updated v$old_ver → v$new_ver"
|
||||
fi
|
||||
|
||||
# 2. Sync plugin to extensions dir
|
||||
local ext_dir="$HOME/.openclaw/extensions/ocp"
|
||||
if [[ -d "$ext_dir" && -d "$script_dir/ocp-plugin" ]]; then
|
||||
echo ""
|
||||
echo " Syncing OCP plugin..."
|
||||
cp "$script_dir/ocp-plugin/index.js" "$ext_dir/index.js" 2>/dev/null
|
||||
cp "$script_dir/ocp-plugin/package.json" "$ext_dir/package.json" 2>/dev/null
|
||||
cp "$script_dir/ocp-plugin/openclaw.plugin.json" "$ext_dir/openclaw.plugin.json" 2>/dev/null
|
||||
echo " ✓ Plugin synced to $ext_dir"
|
||||
fi
|
||||
|
||||
# 3. Restart proxy
|
||||
echo ""
|
||||
echo " Restarting proxy..."
|
||||
cmd_restart > /dev/null 2>&1
|
||||
sleep 2
|
||||
|
||||
if curl -sf --max-time 5 "$PROXY/health" > /dev/null 2>&1; then
|
||||
local running_ver
|
||||
running_ver=$(curl -sf --max-time 5 "$PROXY/health" | python3 -c "import sys,json; print(json.loads(sys.stdin.read())['version'])" 2>/dev/null || echo "?")
|
||||
echo " ✓ Proxy running (v$running_ver)"
|
||||
else
|
||||
echo " ⚠ Proxy not responding — check: ocp health"
|
||||
fi
|
||||
|
||||
echo ""
|
||||
echo "Done."
|
||||
}
|
||||
|
||||
# ── help ─────────────────────────────────────────────────────────────────
|
||||
cmd_help() {
|
||||
cat <<'EOF'
|
||||
ocp — OpenClaw Proxy CLI
|
||||
|
||||
Usage: ocp <command> [args]
|
||||
|
||||
Commands:
|
||||
usage Plan usage limits (--by-key for per-key stats)
|
||||
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
|
||||
keys Manage API keys (add/list/revoke)
|
||||
lan LAN mode setup guide
|
||||
connect <ip> Connect to a remote OCP (sets env vars in rc file)
|
||||
restart Restart proxy
|
||||
restart gateway Restart gateway
|
||||
update Update OCP to latest version
|
||||
update --check Check for updates
|
||||
|
||||
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 "${1:-}" ;;
|
||||
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 ;;
|
||||
keys) cmd_keys "${1:-}" "${2:-}" ;;
|
||||
lan) cmd_lan ;;
|
||||
connect) cmd_connect "$@" ;;
|
||||
restart) cmd_restart "${1:-}" ;;
|
||||
update) cmd_update "${1:-}" ;;
|
||||
*) echo "Unknown command: $subcmd"; echo ""; cmd_help; exit 1 ;;
|
||||
esac
|
||||
Executable
+711
@@ -0,0 +1,711 @@
|
||||
#!/usr/bin/env bash
|
||||
# ocp-connect — Lightweight client script to connect to a remote OCP instance
|
||||
# No dependencies beyond curl and python3 (available on most Linux/Mac systems)
|
||||
#
|
||||
# Install:
|
||||
# curl -fsSL https://raw.githubusercontent.com/dtzp555-max/ocp/main/ocp-connect -o ocp-connect
|
||||
# chmod +x ocp-connect
|
||||
#
|
||||
# Or run directly:
|
||||
# curl -fsSL https://raw.githubusercontent.com/dtzp555-max/ocp/main/ocp-connect | bash -s -- <host-ip> --key <key>
|
||||
#
|
||||
set -euo pipefail
|
||||
|
||||
OCP_CONNECT_VERSION="1.3.0"
|
||||
|
||||
show_version() {
|
||||
echo "ocp-connect $OCP_CONNECT_VERSION"
|
||||
}
|
||||
|
||||
show_help() {
|
||||
cat <<'EOF'
|
||||
ocp-connect — Connect this machine to a remote OCP (Open Claude Proxy)
|
||||
|
||||
Configures OPENAI_BASE_URL and OPENAI_API_KEY in your shell rc file
|
||||
so tools like Claude Code, Cline, Aider, etc. point to the remote OCP.
|
||||
|
||||
Usage:
|
||||
ocp-connect <host-ip> [options]
|
||||
|
||||
Options:
|
||||
--port PORT Port OCP listens on (default: 3456)
|
||||
--key API_KEY API key (prompted interactively if remote requires auth)
|
||||
--version Print version and exit
|
||||
--help, -h Show this help
|
||||
|
||||
Examples:
|
||||
ocp-connect 192.168.1.10
|
||||
ocp-connect 192.168.1.10 --port 8080
|
||||
ocp-connect 192.168.1.10 --key ocp_abc123
|
||||
|
||||
What it does:
|
||||
1. Tests connectivity to the remote OCP
|
||||
2. Verifies your API key (if auth is enabled)
|
||||
3. Writes OPENAI_BASE_URL and OPENAI_API_KEY to ~/.bashrc or ~/.zshrc
|
||||
4. Sets system-level env vars (launchctl on macOS, systemd on Linux)
|
||||
5. Configures OpenClaw automatically; prints setup hints for other IDEs
|
||||
(Cline, Continue.dev, Cursor — manual configuration required)
|
||||
6. Runs a smoke test to confirm everything works
|
||||
|
||||
After running, reload your shell: source ~/.bashrc (or ~/.zshrc)
|
||||
EOF
|
||||
}
|
||||
|
||||
configure_ides() {
|
||||
local base_url="$1" key="$2" models_out="$3"
|
||||
|
||||
# --- OpenClaw ---
|
||||
local oc_config="$HOME/.openclaw/openclaw.json"
|
||||
local oc_found=false
|
||||
if command -v openclaw &>/dev/null || [[ -f "$oc_config" ]]; then
|
||||
oc_found=true
|
||||
fi
|
||||
|
||||
if $oc_found; then
|
||||
echo " IDE Configuration"
|
||||
echo " ─────────────────────────────────────"
|
||||
echo " Detected: OpenClaw ($oc_config)"
|
||||
echo ""
|
||||
printf " Configure OpenClaw to use this OCP? [Y/n] "
|
||||
local oc_answer
|
||||
{ read -r oc_answer </dev/tty; } 2>/dev/null || oc_answer="y"
|
||||
oc_answer="${oc_answer:-y}"
|
||||
|
||||
if [[ "$oc_answer" =~ ^[Yy]$ ]]; then
|
||||
# Ask for provider name
|
||||
printf " Provider name (models show as <name>/model-id) [ocp]: "
|
||||
local provider_name
|
||||
{ read -r provider_name </dev/tty; } 2>/dev/null || provider_name=""
|
||||
provider_name="${provider_name:-ocp}"
|
||||
# Sanitize: only allow alphanumeric, dash, underscore
|
||||
provider_name=$(echo "$provider_name" | tr -cd 'a-zA-Z0-9_-')
|
||||
[[ -z "$provider_name" ]] && provider_name="ocp"
|
||||
|
||||
# Ask for priority
|
||||
echo ""
|
||||
echo " How should OCP models be configured?"
|
||||
echo " 1) Primary — use OCP by default, keep existing models as backup"
|
||||
echo " 2) Backup — keep current primary, add OCP as additional option"
|
||||
echo ""
|
||||
printf " Choice [1]: "
|
||||
local priority_choice
|
||||
{ read -r priority_choice </dev/tty; } 2>/dev/null || priority_choice="1"
|
||||
priority_choice="${priority_choice:-1}"
|
||||
|
||||
echo ""
|
||||
echo " Writing OpenClaw config..."
|
||||
|
||||
# Use python3 to safely manipulate JSON
|
||||
local py_ok=0
|
||||
python3 - "$oc_config" "$base_url" "$key" "$provider_name" "$priority_choice" "$models_out" <<'PYEOF' && py_ok=1
|
||||
import sys, json, os
|
||||
|
||||
config_path = sys.argv[1]
|
||||
base_url = sys.argv[2]
|
||||
api_key = sys.argv[3]
|
||||
provider_name = sys.argv[4]
|
||||
priority = sys.argv[5] # "1" = primary, "2" = backup
|
||||
models_json_str = sys.argv[6]
|
||||
|
||||
# Parse models from OCP /v1/models response
|
||||
try:
|
||||
models_data = json.loads(models_json_str)
|
||||
model_ids = [m["id"] for m in models_data.get("data", [])]
|
||||
except:
|
||||
model_ids = ["claude-opus-4-6", "claude-sonnet-4-6", "claude-haiku-4"]
|
||||
|
||||
# Build provider entry
|
||||
provider = {
|
||||
"baseUrl": base_url + "/v1",
|
||||
"api": "openai-completions",
|
||||
"authHeader": bool(api_key),
|
||||
"models": []
|
||||
}
|
||||
|
||||
# Model metadata mapping (prefix match for versioned IDs like claude-haiku-4-5-20251001)
|
||||
model_meta = {
|
||||
"claude-opus-4": {"name": "Claude Opus (OCP)", "reasoning": True, "maxTokens": 16384},
|
||||
"claude-sonnet-4": {"name": "Claude Sonnet (OCP)", "reasoning": True, "maxTokens": 16384},
|
||||
"claude-haiku-4": {"name": "Claude Haiku (OCP)", "reasoning": False, "maxTokens": 8192},
|
||||
}
|
||||
|
||||
def get_model_meta(mid):
|
||||
"""Match model metadata by prefix."""
|
||||
for prefix, meta in sorted(model_meta.items(), key=lambda x: -len(x[0])):
|
||||
if mid.startswith(prefix):
|
||||
return meta
|
||||
return {"name": mid + " (OCP)", "reasoning": False, "maxTokens": 8192}
|
||||
|
||||
for mid in model_ids:
|
||||
meta = get_model_meta(mid)
|
||||
provider["models"].append({
|
||||
"id": mid,
|
||||
"name": meta["name"],
|
||||
"reasoning": meta["reasoning"],
|
||||
"input": ["text"],
|
||||
"cost": {"input": 0, "output": 0, "cacheRead": 0, "cacheWrite": 0},
|
||||
"contextWindow": 200000,
|
||||
"maxTokens": meta["maxTokens"]
|
||||
})
|
||||
|
||||
# Load or create config
|
||||
if os.path.exists(config_path):
|
||||
with open(config_path, "r") as f:
|
||||
config = json.load(f)
|
||||
else:
|
||||
os.makedirs(os.path.dirname(config_path), exist_ok=True)
|
||||
config = {}
|
||||
|
||||
# Ensure models.providers exists
|
||||
config.setdefault("models", {})
|
||||
config["models"].setdefault("mode", "merge")
|
||||
config["models"].setdefault("providers", {})
|
||||
|
||||
# Remove any previous OCP provider with the same name
|
||||
config["models"]["providers"][provider_name] = provider
|
||||
|
||||
# Set up auth profile if key is provided
|
||||
if api_key:
|
||||
config.setdefault("auth", {})
|
||||
config["auth"].setdefault("profiles", {})
|
||||
config["auth"]["profiles"][provider_name + ":default"] = {
|
||||
"provider": provider_name,
|
||||
"mode": "api_key"
|
||||
}
|
||||
|
||||
# Configure agent defaults — add model aliases
|
||||
config.setdefault("agents", {})
|
||||
config["agents"].setdefault("defaults", {})
|
||||
config["agents"]["defaults"].setdefault("models", {})
|
||||
|
||||
# Build alias map (prefix match)
|
||||
alias_prefixes = {
|
||||
"claude-opus-4": "Claude Opus",
|
||||
"claude-sonnet-4": "Claude Sonnet",
|
||||
"claude-haiku-4": "Claude Haiku",
|
||||
}
|
||||
|
||||
for mid in model_ids:
|
||||
full_id = provider_name + "/" + mid
|
||||
alias = mid # fallback
|
||||
for prefix, name in sorted(alias_prefixes.items(), key=lambda x: -len(x[0])):
|
||||
if mid.startswith(prefix):
|
||||
alias = name
|
||||
break
|
||||
config["agents"]["defaults"]["models"][full_id] = {"alias": alias}
|
||||
|
||||
# Handle primary/backup
|
||||
if priority == "1":
|
||||
# OCP as primary — pick the best model (prefer sonnet for daily use)
|
||||
primary_model = provider_name + "/claude-sonnet-4-6" if "claude-sonnet-4-6" in model_ids else provider_name + "/" + model_ids[0]
|
||||
config["agents"]["defaults"].setdefault("model", {})
|
||||
config["agents"]["defaults"]["model"]["primary"] = primary_model
|
||||
# Keep existing fallbacks
|
||||
config["agents"]["defaults"]["model"].setdefault("fallbacks", [])
|
||||
|
||||
# If backup (priority == "2"), don't change the primary — just add models to the list
|
||||
|
||||
# Update agent list entries that use old provider name patterns
|
||||
# (only update if agents.list exists and has entries using old OCP-like providers)
|
||||
if "list" in config.get("agents", {}):
|
||||
for agent in config["agents"]["list"]:
|
||||
agent_model = agent.get("model", {})
|
||||
if priority == "1" and agent_model.get("primary", "").startswith("claude-local/"):
|
||||
# Migrate from claude-local to new provider
|
||||
old_model_id = agent_model["primary"].split("/", 1)[1]
|
||||
if old_model_id in model_ids:
|
||||
agent_model["primary"] = provider_name + "/" + old_model_id
|
||||
# Also update subagents if they use claude-local
|
||||
sub = agent.get("subagents", config["agents"]["defaults"].get("subagents", {}))
|
||||
# Don't modify subagents in agent entries — they inherit from defaults
|
||||
|
||||
# Update defaults subagents model if using claude-local
|
||||
if priority == "1":
|
||||
sub = config["agents"]["defaults"].get("subagents", {})
|
||||
if sub.get("model", "").startswith("claude-local/"):
|
||||
old_id = sub["model"].split("/", 1)[1]
|
||||
if old_id in model_ids:
|
||||
sub["model"] = provider_name + "/" + old_id
|
||||
|
||||
with open(config_path, "w") as f:
|
||||
json.dump(config, f, indent=2, ensure_ascii=False)
|
||||
f.write("\n")
|
||||
|
||||
# === B1 fix: seed per-agent auth-profiles.json for OpenClaw multi-agent setups ===
|
||||
# OpenClaw's per-agent auth loader reads <agentDir>/auth-profiles.json (NOT the
|
||||
# root openclaw.json's auth.profiles section). Without a real key in each agent's
|
||||
# agentDir, OpenClaw rejects the lane with "No API key found for provider X".
|
||||
# See https://github.com/dtzp555-max/ocp/issues/12 for the full investigation.
|
||||
_seeded = []
|
||||
_failed = []
|
||||
_skipped_anonymous = []
|
||||
_profile_key = provider_name + ":default"
|
||||
# OpenClaw stores per-agent auth in <openclaw_root>/agents/<id>/agent/ when
|
||||
# the agent has no explicit `agentDir` field — derive that path so the
|
||||
# default `main` agent (which never sets agentDir) is also seeded.
|
||||
_openclaw_root = os.path.dirname(config_path)
|
||||
for _agent in config.get("agents", {}).get("list", []):
|
||||
_agent_dir = _agent.get("agentDir")
|
||||
if not _agent_dir:
|
||||
_agent_id = _agent.get("id")
|
||||
if not _agent_id:
|
||||
continue
|
||||
_agent_dir = os.path.join(_openclaw_root, "agents", _agent_id, "agent")
|
||||
if not api_key:
|
||||
# Anonymous mode is incompatible with OpenClaw per-agent auth (empty key
|
||||
# is dropped by OpenClaw's pi-auth-credentials). Per OCP issue #12 §14
|
||||
# decision: take Path C (require --key for OpenClaw multi-agent setups).
|
||||
_skipped_anonymous.append(_agent_dir)
|
||||
continue
|
||||
_profiles_path = os.path.join(_agent_dir, "auth-profiles.json")
|
||||
try:
|
||||
os.makedirs(_agent_dir, exist_ok=True)
|
||||
except OSError as _e:
|
||||
_failed.append((_profiles_path, "mkdir: " + str(_e)))
|
||||
continue
|
||||
if os.path.exists(_profiles_path):
|
||||
try:
|
||||
with open(_profiles_path) as _f:
|
||||
_ap = json.load(_f)
|
||||
except json.JSONDecodeError:
|
||||
# Corrupted JSON — back up and rebuild from scratch.
|
||||
_bak = _profiles_path + ".bak"
|
||||
try:
|
||||
os.rename(_profiles_path, _bak)
|
||||
print(f" ⚠ {_profiles_path} was corrupt; backed up to {_bak}")
|
||||
except OSError:
|
||||
pass
|
||||
_ap = {"version": 1, "profiles": {}}
|
||||
except OSError as _e:
|
||||
# Read failure (permissions, disk) — skip to AVOID clobbering
|
||||
# the user's existing profile (which may hold other providers' keys).
|
||||
_failed.append((_profiles_path, "read: " + str(_e)))
|
||||
continue
|
||||
else:
|
||||
_ap = {"version": 1, "profiles": {}}
|
||||
_ap.setdefault("version", 1)
|
||||
_ap.setdefault("profiles", {})
|
||||
_ap["profiles"][_profile_key] = {
|
||||
"type": "api_key",
|
||||
"provider": provider_name,
|
||||
"key": api_key
|
||||
}
|
||||
try:
|
||||
with open(_profiles_path, "w") as _f:
|
||||
json.dump(_ap, _f, indent=2, ensure_ascii=False)
|
||||
_f.write("\n")
|
||||
os.chmod(_profiles_path, 0o600)
|
||||
_seeded.append(_profiles_path)
|
||||
except OSError as _e:
|
||||
_failed.append((_profiles_path, "write: " + str(_e)))
|
||||
|
||||
# Report — all three sections are independent (mixed scenarios are surfaced).
|
||||
if _seeded:
|
||||
print(f" ✓ Per-agent auth profile seeded ({len(_seeded)}):")
|
||||
for _p in _seeded:
|
||||
print(f" • {_p}")
|
||||
if _failed:
|
||||
print(f" ⚠ Per-agent auth profile FAILED ({len(_failed)}):")
|
||||
for _p, _err in _failed:
|
||||
print(f" • {_p}: {_err}")
|
||||
print(" OpenClaw will report \"No API key found\" for the failed agents.")
|
||||
print(" Fix the underlying error (permissions / disk) and re-run ocp-connect.")
|
||||
if _skipped_anonymous:
|
||||
print(f" ⚠ OpenClaw multi-agent mode detected ({len(_skipped_anonymous)} agents).")
|
||||
print(" Anonymous mode does not work for OpenClaw per-agent auth.")
|
||||
print(" Re-run with: ocp-connect <host> --key ocp_xxx")
|
||||
PYEOF
|
||||
|
||||
if [[ $py_ok -eq 1 ]]; then
|
||||
echo " ✓ OpenClaw configured"
|
||||
echo " Provider: $provider_name"
|
||||
echo " Models:"
|
||||
# List models from the already-fetched models_out
|
||||
echo "$models_out" | python3 -c "
|
||||
import sys,json
|
||||
d=json.loads(sys.stdin.read())
|
||||
pn='$provider_name'
|
||||
for m in d.get('data',[]):
|
||||
print(' • ' + pn + '/' + m['id'])
|
||||
" 2>/dev/null
|
||||
if [[ "$priority_choice" == "1" ]]; then
|
||||
echo " Priority: PRIMARY (default model)"
|
||||
else
|
||||
echo " Priority: BACKUP (available in model selector)"
|
||||
fi
|
||||
echo ""
|
||||
echo " Restart OpenClaw to apply: openclaw gateway restart"
|
||||
else
|
||||
echo " ⚠ Failed to write OpenClaw config. You can configure it manually."
|
||||
fi
|
||||
else
|
||||
echo " Skipped OpenClaw configuration."
|
||||
fi
|
||||
echo ""
|
||||
fi
|
||||
|
||||
# --- Other IDEs: print manual instructions ---
|
||||
local other_ides_shown=false
|
||||
|
||||
# Collect VS Code extension list once (reused by Cline and Continue.dev checks)
|
||||
local _vscode_exts=""
|
||||
if command -v code &>/dev/null; then
|
||||
_vscode_exts=$(code --list-extensions 2>/dev/null || true)
|
||||
fi
|
||||
if [[ -z "$_vscode_exts" && -d "$HOME/.vscode/extensions" ]]; then
|
||||
_vscode_exts=$(ls "$HOME/.vscode/extensions/" 2>/dev/null || true)
|
||||
fi
|
||||
|
||||
# Extract model IDs from /v1/models response for use in hints
|
||||
local _model_ids
|
||||
_model_ids=$(echo "$models_out" | python3 -c "
|
||||
import sys,json
|
||||
try:
|
||||
d=json.loads(sys.stdin.read())
|
||||
print(', '.join(m['id'] for m in d.get('data', [])))
|
||||
except Exception:
|
||||
print('claude-sonnet-4-6, claude-opus-4-6, claude-haiku-4-5-20251001')
|
||||
" 2>/dev/null || echo "claude-sonnet-4-6, claude-opus-4-6, claude-haiku-4-5-20251001")
|
||||
|
||||
# Key display: truncate if longer than 16 chars to avoid screenshot leakage,
|
||||
# show explicit "(none)" in anonymous mode so users don't paste blank fields.
|
||||
local _key_display
|
||||
if [[ -z "$key" ]]; then
|
||||
_key_display="(none — anonymous mode; most external IDEs require a non-empty API Key)"
|
||||
elif [[ ${#key} -gt 16 ]]; then
|
||||
_key_display="${key:0:8}...${key: -4}"
|
||||
else
|
||||
_key_display="$key"
|
||||
fi
|
||||
|
||||
# Detect Cline (VS Code extension saoudrizwan.claude-dev, see issue #12)
|
||||
if echo "$_vscode_exts" | grep -qiE 'cline|saoudrizwan\.claude-dev'; then
|
||||
if ! $other_ides_shown; then
|
||||
echo " Other IDEs detected:"
|
||||
other_ides_shown=true
|
||||
fi
|
||||
echo " • Cline: VSCode → Cline panel → Settings → API Provider = \"OpenAI Compatible\""
|
||||
echo " Base URL: $base_url/v1"
|
||||
echo " API Key: $_key_display"
|
||||
echo " Model ID: $_model_ids"
|
||||
fi
|
||||
|
||||
# Detect Continue.dev (extension ID continue.continue or config file, see issue #12)
|
||||
if echo "$_vscode_exts" | grep -qi 'continue\.continue' \
|
||||
|| [[ -f "$HOME/.continue/config.yaml" ]] \
|
||||
|| [[ -f "$HOME/.continue/config.json" ]]; then
|
||||
if ! $other_ides_shown; then
|
||||
echo " Other IDEs detected:"
|
||||
other_ides_shown=true
|
||||
fi
|
||||
echo " • Continue.dev: edit ~/.continue/config.yaml — add under \`models:\` (top-level key):"
|
||||
echo " models:"
|
||||
echo " - name: OCP Sonnet"
|
||||
echo " provider: openai"
|
||||
echo " model: claude-sonnet-4-6"
|
||||
echo " apiBase: $base_url/v1"
|
||||
echo " apiKey: $_key_display"
|
||||
echo " (other model IDs: $_model_ids)"
|
||||
fi
|
||||
|
||||
# Detect Cursor (command, ~/.cursor dir, or /Applications/Cursor.app, see issue #12)
|
||||
if command -v cursor &>/dev/null \
|
||||
|| [[ -d "$HOME/.cursor" ]] \
|
||||
|| [[ -d "/Applications/Cursor.app" ]]; then
|
||||
if ! $other_ides_shown; then
|
||||
echo " Other IDEs detected:"
|
||||
other_ides_shown=true
|
||||
fi
|
||||
echo " • Cursor: Cmd+Shift+P → 'Cursor Settings' → Models →"
|
||||
echo " OpenAI API Key: $_key_display"
|
||||
echo " Override OpenAI Base URL: $base_url/v1"
|
||||
echo " Custom OpenAI Models: $_model_ids"
|
||||
fi
|
||||
|
||||
# Detect opencode (https://opencode.ai, SST team CLI, see issue #12)
|
||||
if command -v opencode &>/dev/null \
|
||||
|| [[ -x "$HOME/.opencode/bin/opencode" ]] \
|
||||
|| [[ -d "$HOME/.local/share/opencode" ]]; then
|
||||
if ! $other_ides_shown; then
|
||||
echo " Other IDEs detected:"
|
||||
other_ides_shown=true
|
||||
fi
|
||||
echo " • opencode: not yet auto-configured by ocp-connect (PR follow-up)."
|
||||
echo " Run \`opencode providers login openai\` and provide:"
|
||||
echo " Base URL: $base_url/v1"
|
||||
echo " API Key: $_key_display"
|
||||
echo " Available model IDs: $_model_ids"
|
||||
fi
|
||||
|
||||
if $other_ides_shown; then
|
||||
echo ""
|
||||
fi
|
||||
}
|
||||
|
||||
main() {
|
||||
local host="" port=3456 key=""
|
||||
|
||||
# Parse args
|
||||
while [[ $# -gt 0 ]]; do
|
||||
case "$1" in
|
||||
--port) port="${2:?'--port requires a value'}"; shift 2 ;;
|
||||
--key) key="${2:?'--key requires a value'}"
|
||||
[[ -z "$key" ]] && { echo "Error: --key cannot be empty (omit --key entirely for anonymous mode)"; exit 1; }
|
||||
shift 2 ;;
|
||||
--version) show_version; exit 0 ;;
|
||||
--help|-h) show_help; exit 0 ;;
|
||||
-*) echo "Unknown option: $1"; show_help; exit 1 ;;
|
||||
*) host="$1"; shift ;;
|
||||
esac
|
||||
done
|
||||
|
||||
if [[ -z "$host" ]]; then
|
||||
echo "Error: host IP is required."
|
||||
echo ""
|
||||
show_help
|
||||
exit 1
|
||||
fi
|
||||
|
||||
if ! [[ "$host" =~ ^[a-zA-Z0-9._-]+$ ]]; then
|
||||
echo "Error: invalid host '$host'"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
# Check dependencies
|
||||
for cmd in curl python3; do
|
||||
if ! command -v "$cmd" &>/dev/null; then
|
||||
echo "Error: '$cmd' is required but not found."
|
||||
exit 1
|
||||
fi
|
||||
done
|
||||
|
||||
local base_url="http://$host:$port"
|
||||
|
||||
echo "OCP Connect v$OCP_CONNECT_VERSION"
|
||||
echo "─────────────────────────────────────"
|
||||
echo " Remote: $base_url"
|
||||
echo ""
|
||||
|
||||
# Step 1: Test connectivity via /health
|
||||
echo " Checking connectivity..."
|
||||
local health_json
|
||||
health_json=$(curl -sf --max-time 10 "$base_url/health" 2>/dev/null) || {
|
||||
echo " ✗ Cannot reach $base_url/health"
|
||||
echo " Make sure OCP is running on $host and bound to 0.0.0.0 (LAN mode)."
|
||||
exit 1
|
||||
}
|
||||
echo " ✓ Connected"
|
||||
echo ""
|
||||
|
||||
# Step 2: Show remote info
|
||||
local remote_version auth_mode
|
||||
remote_version=$(echo "$health_json" | python3 -c "import sys,json; d=json.loads(sys.stdin.read()); print(d.get('version','?'))" 2>/dev/null || echo "?")
|
||||
auth_mode=$(echo "$health_json" | python3 -c "import sys,json; d=json.loads(sys.stdin.read()); print(d.get('authMode','none'))" 2>/dev/null || echo "none")
|
||||
|
||||
echo " Remote OCP v$remote_version (auth: $auth_mode)"
|
||||
echo ""
|
||||
|
||||
# Step 2.5: auto-discover anonymous key from /health (issue #12 §14 Path A).
|
||||
# When the OCP admin set PROXY_ANONYMOUS_KEY, the server advertises it via
|
||||
# /health.anonymousKey. If the user didn't pass --key, use it automatically so
|
||||
# `ocp-connect <host>` works zero-config for OpenClaw multi-agent setups.
|
||||
if [[ -z "$key" ]]; then
|
||||
local anon_key
|
||||
anon_key=$(echo "$health_json" | python3 -c "
|
||||
import sys, json
|
||||
try:
|
||||
d = json.loads(sys.stdin.read())
|
||||
k = d.get('anonymousKey')
|
||||
except Exception:
|
||||
k = None
|
||||
print(k if k else '')
|
||||
" 2>/dev/null || echo "")
|
||||
if [[ -n "$anon_key" ]]; then
|
||||
key="$anon_key"
|
||||
local _anon_display="$anon_key"
|
||||
if [[ ${#anon_key} -gt 16 ]]; then
|
||||
_anon_display="${anon_key:0:8}...${anon_key: -4}"
|
||||
fi
|
||||
echo " ⓘ Using server-advertised anonymous key: $_anon_display"
|
||||
echo " (set by admin via PROXY_ANONYMOUS_KEY; see issue #12 §14 Path A)"
|
||||
echo ""
|
||||
fi
|
||||
fi
|
||||
|
||||
# Step 3: Determine if key is needed
|
||||
if [[ "$auth_mode" != "none" && -z "$key" ]]; then
|
||||
# Try anonymous access first (zero-config: server may allow it)
|
||||
if curl -sf --max-time 5 "$base_url/v1/models" >/dev/null 2>&1; then
|
||||
echo " Server allows anonymous access — no key needed."
|
||||
echo ""
|
||||
else
|
||||
echo " Remote requires authentication."
|
||||
echo " Ask the OCP admin to run: ocp keys add <name>"
|
||||
printf " Enter API key (or press Enter to skip): "
|
||||
{ read -rs key </dev/tty; } 2>/dev/null || key=""
|
||||
echo
|
||||
if [[ -z "$key" ]]; then
|
||||
echo ""
|
||||
echo " ✗ No key provided — cannot connect to an auth-required remote without a key."
|
||||
echo " Use: ocp-connect $host --key <key>"
|
||||
exit 1
|
||||
fi
|
||||
fi
|
||||
fi
|
||||
|
||||
# Step 4: Test API access via /v1/models
|
||||
echo " Testing API access..."
|
||||
local models_out models_ok=0
|
||||
if [[ -n "$key" ]]; then
|
||||
models_out=$(curl -sf --max-time 10 \
|
||||
-H "Authorization: Bearer $key" \
|
||||
"$base_url/v1/models" 2>/dev/null) && models_ok=1
|
||||
else
|
||||
models_out=$(curl -sf --max-time 10 "$base_url/v1/models" 2>/dev/null) && models_ok=1
|
||||
fi
|
||||
|
||||
if [[ $models_ok -eq 0 ]]; then
|
||||
echo " ✗ API access failed — key may be invalid or revoked."
|
||||
exit 1
|
||||
fi
|
||||
local model_count
|
||||
model_count=$(echo "$models_out" | python3 -c "import sys,json; print(len(json.loads(sys.stdin.read()).get('data',[])))" 2>/dev/null || echo "?")
|
||||
echo " ✓ API accessible ($model_count models available)"
|
||||
echo ""
|
||||
|
||||
# Step 5: Detect shell rc files and OS
|
||||
local rc_files=()
|
||||
local is_mac=false
|
||||
[[ "$(uname)" == "Darwin" ]] && is_mac=true
|
||||
|
||||
# Write to all relevant rc files
|
||||
if [[ "${SHELL:-}" == */fish ]]; then
|
||||
echo " Note: fish shell detected. Writing to ~/.bashrc — add to fish config manually."
|
||||
rc_files+=("$HOME/.bashrc")
|
||||
else
|
||||
# Always write both on macOS (default shell is zsh but some tools source bashrc)
|
||||
[[ -f "$HOME/.bashrc" || "${SHELL:-}" == */bash ]] && rc_files+=("$HOME/.bashrc")
|
||||
[[ -f "$HOME/.zshrc" || "${SHELL:-}" == */zsh ]] && rc_files+=("$HOME/.zshrc")
|
||||
# If neither exists, create for current shell
|
||||
[[ ${#rc_files[@]} -eq 0 ]] && rc_files+=("$HOME/.${SHELL##*/}rc")
|
||||
fi
|
||||
|
||||
# Step 6: Remove any previously written OCP LAN lines (idempotent) from all rc files
|
||||
for rc_file in "${rc_files[@]}"; do
|
||||
if [[ -f "$rc_file" ]]; then
|
||||
local tmp_rc
|
||||
tmp_rc=$(mktemp)
|
||||
if python3 - "$rc_file" "$tmp_rc" <<'PYEOF'
|
||||
import sys
|
||||
src, dst = sys.argv[1], sys.argv[2]
|
||||
lines = open(src).readlines()
|
||||
out = []
|
||||
skip = False
|
||||
for line in lines:
|
||||
s = line.rstrip('\n')
|
||||
if s == '# OCP LAN (added by ocp connect)':
|
||||
skip = True
|
||||
continue
|
||||
if skip and (s == '' or s.startswith('export OPENAI_BASE_URL=') or s.startswith('export OPENAI_API_KEY=')):
|
||||
continue
|
||||
skip = False
|
||||
out.append(line)
|
||||
open(dst, 'w').writelines(out)
|
||||
PYEOF
|
||||
then
|
||||
cp "$tmp_rc" "$rc_file"
|
||||
fi
|
||||
rm -f "$tmp_rc"
|
||||
fi
|
||||
done
|
||||
|
||||
# Step 7: Append new config to all rc files
|
||||
for rc_file in "${rc_files[@]}"; do
|
||||
{
|
||||
echo ""
|
||||
echo "# OCP LAN (added by ocp connect)"
|
||||
echo "export OPENAI_BASE_URL=$base_url/v1"
|
||||
if [[ -n "$key" ]]; then
|
||||
echo "export OPENAI_API_KEY=$key"
|
||||
fi
|
||||
} >> "$rc_file"
|
||||
done
|
||||
|
||||
echo " Shell config:"
|
||||
for rc_file in "${rc_files[@]}"; do
|
||||
echo " ✓ $(basename "$rc_file")"
|
||||
done
|
||||
echo " OPENAI_BASE_URL=$base_url/v1"
|
||||
if [[ -n "$key" ]]; then
|
||||
echo " OPENAI_API_KEY=${key:0:8}..."
|
||||
fi
|
||||
|
||||
# Step 7b: System-level env vars (for IDEs, daemons, GUI apps)
|
||||
if $is_mac; then
|
||||
# macOS: launchctl setenv makes vars visible to all GUI apps and launchd services
|
||||
launchctl setenv OPENAI_BASE_URL "$base_url/v1" 2>/dev/null
|
||||
if [[ -n "$key" ]]; then
|
||||
launchctl setenv OPENAI_API_KEY "$key" 2>/dev/null
|
||||
fi
|
||||
echo ""
|
||||
echo " System-level (launchctl):"
|
||||
echo " ✓ OPENAI_BASE_URL set for GUI apps and daemons"
|
||||
echo " Note: launchctl vars reset on reboot. Add to Login Items or re-run ocp-connect."
|
||||
else
|
||||
# Linux: write to environment.d for systemd user services
|
||||
local env_dir="$HOME/.config/environment.d"
|
||||
mkdir -p "$env_dir" 2>/dev/null
|
||||
{
|
||||
echo "OPENAI_BASE_URL=$base_url/v1"
|
||||
if [[ -n "$key" ]]; then
|
||||
echo "OPENAI_API_KEY=$key"
|
||||
fi
|
||||
} > "$env_dir/ocp.conf"
|
||||
echo ""
|
||||
echo " System-level (systemd):"
|
||||
echo " ✓ $env_dir/ocp.conf"
|
||||
echo " Applies to systemd user services after re-login."
|
||||
fi
|
||||
echo ""
|
||||
|
||||
# Step 7c: Interactive IDE configuration
|
||||
configure_ides "$base_url" "$key" "$models_out"
|
||||
|
||||
# Step 8: Quick smoke test
|
||||
echo " Running smoke test..."
|
||||
# Pick the first available model from /v1/models
|
||||
local smoke_model
|
||||
smoke_model=$(echo "$models_out" | python3 -c "import sys,json; d=json.loads(sys.stdin.read()); print(d['data'][0]['id'])" 2>/dev/null || echo "claude-haiku-4-5-20251001")
|
||||
local chat_payload="{\"model\":\"$smoke_model\",\"messages\":[{\"role\":\"user\",\"content\":\"Reply with OK only.\"}],\"max_tokens\":10}"
|
||||
local chat_out chat_ok=0
|
||||
if [[ -n "$key" ]]; then
|
||||
chat_out=$(curl -sf --max-time 30 \
|
||||
-H "Authorization: Bearer $key" \
|
||||
-H "Content-Type: application/json" \
|
||||
-d "$chat_payload" \
|
||||
"$base_url/v1/chat/completions" 2>/dev/null) && chat_ok=1
|
||||
else
|
||||
chat_out=$(curl -sf --max-time 30 \
|
||||
-H "Content-Type: application/json" \
|
||||
-d "$chat_payload" \
|
||||
"$base_url/v1/chat/completions" 2>/dev/null) && chat_ok=1
|
||||
fi
|
||||
|
||||
if [[ $chat_ok -eq 1 ]]; then
|
||||
local reply
|
||||
reply=$(echo "$chat_out" | python3 -c "import sys,json; d=json.loads(sys.stdin.read()); print(d['choices'][0]['message']['content'].strip())" 2>/dev/null || echo "(response received)")
|
||||
echo " ✓ Smoke test passed: $reply"
|
||||
echo " Note: smoke test only verifies OCP is reachable and the key is valid."
|
||||
echo " It does not verify your IDE/agent end-to-end. To verify OpenClaw works,"
|
||||
echo " restart it (\`openclaw gateway restart\`) and send a test message to your bot."
|
||||
else
|
||||
echo " ⚠ Smoke test failed (proxy is reachable but chat completion did not succeed)."
|
||||
echo " The env vars have still been written. Check the remote OCP logs."
|
||||
fi
|
||||
|
||||
echo ""
|
||||
echo " Done. Reload your shell to apply:"
|
||||
echo " source $rc_file"
|
||||
}
|
||||
|
||||
main "$@"
|
||||
@@ -0,0 +1,301 @@
|
||||
/**
|
||||
* 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 plan = d.plan || {};
|
||||
const s = plan.currentSession || {};
|
||||
const w = plan.weeklyLimits?.allModels || {};
|
||||
const e = plan.extraUsage || {};
|
||||
const px = d.proxy;
|
||||
const models = d.models || {};
|
||||
|
||||
let out = "";
|
||||
|
||||
// Show subscription info if available
|
||||
if (plan.subscription) {
|
||||
out += `Plan: ${plan.subscription} (${plan.rateLimitTier || "default"})\n`;
|
||||
}
|
||||
|
||||
if (s.utilization !== null && s.utilization !== undefined) {
|
||||
// Full plan usage data available (API key mode)
|
||||
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`;
|
||||
} else {
|
||||
// No API key — show note
|
||||
out += "Session/weekly %: claude.ai/settings\n";
|
||||
out += "(Anthropic OAuth API doesn't expose limits yet)\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 cmdVersion() {
|
||||
const d = await fetchJSON("/health");
|
||||
return `OCP v${d.version || "?"}\nUptime: ${d.uptimeHuman || "?"}\nNode: ${process.version}\nPlatform: ${process.platform} ${process.arch}`;
|
||||
}
|
||||
|
||||
async function cmdTest() {
|
||||
const t0 = Date.now();
|
||||
try {
|
||||
const resp = await fetch(`${PROXY}/v1/chat/completions`, {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({
|
||||
model: "claude-haiku-4-5-20251001",
|
||||
messages: [{ role: "user", content: "say ok" }],
|
||||
max_tokens: 5,
|
||||
}),
|
||||
signal: AbortSignal.timeout(30000),
|
||||
});
|
||||
const d = await resp.json();
|
||||
const elapsed = Date.now() - t0;
|
||||
if (d.choices?.[0]?.message?.content) {
|
||||
return `✓ Proxy OK (${elapsed}ms)\n Model: haiku\n Response: "${d.choices[0].message.content.slice(0, 50)}"`;
|
||||
}
|
||||
return `✗ Unexpected response: ${JSON.stringify(d).slice(0, 100)}`;
|
||||
} catch (e) {
|
||||
return `✗ Test failed (${Date.now() - t0}ms): ${e.message}`;
|
||||
}
|
||||
}
|
||||
|
||||
async function cmdRestart(args) {
|
||||
const target = (args || "").trim().toLowerCase();
|
||||
const { execSync } = await import("node:child_process");
|
||||
try {
|
||||
if (target === "gateway") {
|
||||
execSync("launchctl kickstart -k gui/501/ai.openclaw.gateway", { timeout: 15000 });
|
||||
return "✓ Gateway restarted";
|
||||
} else if (target === "all") {
|
||||
execSync("launchctl kickstart -k gui/501/ai.openclaw.proxy", { timeout: 15000 });
|
||||
// Gateway restart will kill this plugin too, so do it last
|
||||
execSync("launchctl kickstart -k gui/501/ai.openclaw.gateway", { timeout: 15000 });
|
||||
return "✓ Proxy + Gateway restarted";
|
||||
} else {
|
||||
execSync("launchctl kickstart -k gui/501/ai.openclaw.proxy", { timeout: 15000 });
|
||||
return "✓ Proxy restarted";
|
||||
}
|
||||
} catch (e) {
|
||||
// Try systemd for Linux
|
||||
try {
|
||||
if (target === "gateway") {
|
||||
execSync("systemctl --user restart openclaw-gateway", { timeout: 15000 });
|
||||
return "✓ Gateway restarted";
|
||||
} else {
|
||||
execSync("systemctl --user restart openclaw-proxy 2>/dev/null || pkill -f 'node.*server.mjs' && sleep 2 && cd ~/.openclaw/projects/*/; node server.mjs &", { timeout: 15000, shell: true });
|
||||
return "✓ Proxy restarted";
|
||||
}
|
||||
} catch (e2) {
|
||||
return `✗ Restart failed: ${e2.message?.slice(0, 100)}`;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
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
|
||||
/ocp restart Restart proxy
|
||||
/ocp restart gateway Restart gateway
|
||||
/ocp restart all Restart both
|
||||
/ocp version Version & platform info
|
||||
/ocp test End-to-end proxy test`;
|
||||
}
|
||||
|
||||
// ── 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 "restart": text = await cmdRestart(subargs); break;
|
||||
case "version": text = await cmdVersion(); break;
|
||||
case "test": text = await cmdTest(); 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": "3.3.1",
|
||||
"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": "3.3.1",
|
||||
"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"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,182 @@
|
||||
# openclaw-claude-proxy v2.3.0
|
||||
|
||||
Use your **Claude Pro / Max** subscription as an **OpenAI-compatible local endpoint**.
|
||||
|
||||
`openclaw-claude-proxy` accepts OpenAI-style chat completion requests, then runs them through the local `claude` CLI. That means tools which only know how to talk to an OpenAI API can still use Claude models through a local base URL.
|
||||
|
||||
## Why v2 matters
|
||||
|
||||
v2 is not just a bugfix release. It changes the runtime model:
|
||||
|
||||
- **On-demand spawning** instead of fragile warm pools
|
||||
- **Session resume** support for multi-turn conversations
|
||||
- **Faster fallback** with first-byte timeout + lower default request timeout
|
||||
- **Full tool access** via configurable allowed tools
|
||||
- **MCP config + system prompt pass-through**
|
||||
- **Health / sessions / diagnostics endpoints**
|
||||
- **Safe coexistence with Claude Code channel / interactive mode**
|
||||
|
||||
## The short pitch
|
||||
|
||||
If Claude's new channel workflow feels useful, OCP v2 now covers the same practical ground for many local agent/tooling setups:
|
||||
|
||||
- multi-turn continuity
|
||||
- tool-enabled Claude runs
|
||||
- local orchestration
|
||||
- stable process isolation
|
||||
- coexistence with your normal Claude Code workflow
|
||||
|
||||
And it adds a few advantages that channel users usually still want:
|
||||
|
||||
- **OpenAI-compatible HTTP API** for existing tools
|
||||
- **Works with OpenClaw, Cursor, Continue, Open WebUI, LangChain, and anything with custom base URL support**
|
||||
- **Explicit health checks and diagnostics**
|
||||
- **Model/provider failover can happen outside Claude itself**
|
||||
- **No lock-in to a single client UX**
|
||||
|
||||
## Coexistence with Claude Code channel
|
||||
|
||||
This is the important part: **OCP v2 does not replace Claude Code channel, and it does not need to. They can coexist on the same machine.**
|
||||
|
||||
### Claude Code channel / interactive mode
|
||||
- persistent interactive workflow
|
||||
- MCP protocol / in-process experience
|
||||
- great when you are directly driving Claude Code
|
||||
|
||||
### OCP v2
|
||||
- local HTTP server on `localhost`
|
||||
- OpenAI-compatible API surface
|
||||
- per-request `claude -p` execution with session resume when you want continuity
|
||||
- ideal for external tools, routers, orchestrators, OpenClaw providers, and local automation
|
||||
|
||||
### Practical takeaway
|
||||
Use both:
|
||||
- use **Claude Code channel** when you want Claude's native interactive workflow
|
||||
- use **OCP v2** when another app expects an OpenAI-style API but you still want to use Claude
|
||||
|
||||
They solve adjacent problems, not identical ones.
|
||||
|
||||
## Unique advantages of OCP v2
|
||||
|
||||
1. **API compatibility**
|
||||
- Drop into tools that already support OpenAI-compatible endpoints.
|
||||
- No need to wait for each tool to add native Claude channel support.
|
||||
|
||||
2. **Routing freedom**
|
||||
- Put OCP behind OpenClaw or another router.
|
||||
- Mix Claude with fallback providers outside the Claude client itself.
|
||||
|
||||
3. **Operational visibility**
|
||||
- `/health`, `/sessions`, recent errors, auth state, resolved binary path, timeout config.
|
||||
- Much easier to debug than a black-box local integration.
|
||||
|
||||
4. **Safer runtime model**
|
||||
- v2 removes the old pre-spawn pool crash loop.
|
||||
- No stale workers, no degraded warm pool states, fewer hidden failure modes.
|
||||
|
||||
5. **Configurable tools and behavior**
|
||||
- allowed tools
|
||||
- skip permissions mode
|
||||
- system prompt append
|
||||
- MCP config passthrough
|
||||
- session TTL
|
||||
- concurrency limits
|
||||
|
||||
## Install
|
||||
|
||||
```bash
|
||||
git clone https://github.com/dtzp555-max/openclaw-claude-proxy
|
||||
cd openclaw-claude-proxy
|
||||
npm install
|
||||
node server.mjs
|
||||
```
|
||||
|
||||
Default base URL:
|
||||
|
||||
```text
|
||||
http://127.0.0.1:3456/v1
|
||||
```
|
||||
|
||||
## Quick OpenAI-compatible config
|
||||
|
||||
```json
|
||||
{
|
||||
"baseURL": "http://127.0.0.1:3456/v1",
|
||||
"apiKey": "anything"
|
||||
}
|
||||
```
|
||||
|
||||
If `PROXY_API_KEY` is unset, auth is disabled. If you set it, pass it as a Bearer token.
|
||||
|
||||
## Environment variables
|
||||
|
||||
| Variable | Default | Purpose |
|
||||
|---|---:|---|
|
||||
| `CLAUDE_PROXY_PORT` | `3456` | Listen port |
|
||||
| `CLAUDE_BIN` | auto-detect | Claude CLI binary path |
|
||||
| `CLAUDE_TIMEOUT` | `120000` | Overall per-request timeout |
|
||||
| `CLAUDE_FIRST_BYTE_TIMEOUT` | `30000` | Abort if Claude produces no stdout quickly |
|
||||
| `CLAUDE_ALLOWED_TOOLS` | expanded set | Comma-separated allowed tools |
|
||||
| `CLAUDE_SKIP_PERMISSIONS` | `false` | Bypass permission checks |
|
||||
| `CLAUDE_SYSTEM_PROMPT` | unset | Append a system prompt to every request |
|
||||
| `CLAUDE_MCP_CONFIG` | unset | Path to MCP config JSON |
|
||||
| `CLAUDE_SESSION_TTL` | `3600000` | Session TTL |
|
||||
| `CLAUDE_MAX_CONCURRENT` | `5` | Max concurrent Claude processes |
|
||||
| `PROXY_API_KEY` | unset | Optional Bearer token auth |
|
||||
|
||||
## Endpoints
|
||||
|
||||
- `GET /health`
|
||||
- `GET /v1/models`
|
||||
- `POST /v1/chat/completions`
|
||||
- `GET /sessions`
|
||||
- `DELETE /sessions`
|
||||
|
||||
## Example health response highlights
|
||||
|
||||
`/health` reports useful operational state such as:
|
||||
|
||||
- resolved Claude binary path
|
||||
- whether the binary is executable
|
||||
- auth status
|
||||
- timeouts
|
||||
- current sessions
|
||||
- recent errors
|
||||
- basic request stats
|
||||
|
||||
## Version highlights
|
||||
|
||||
### v2.3.0
|
||||
- clarified v2 positioning and coexistence story in docs
|
||||
- officially documents faster fallback defaults
|
||||
- recommends OCP v2 as the API bridge layer for Claude-powered tools
|
||||
|
||||
### v2.2.0
|
||||
- first-byte timeout
|
||||
- reduced default timeout for faster fallback
|
||||
|
||||
### v2.0.0
|
||||
- on-demand architecture
|
||||
- session management
|
||||
- full tool access
|
||||
- MCP + system prompt passthrough
|
||||
- concurrency control
|
||||
- coexistence with Claude Code interactive mode
|
||||
|
||||
## When to use OCP v2 vs Claude channel
|
||||
|
||||
Choose **OCP v2** when:
|
||||
- your app only supports OpenAI-compatible endpoints
|
||||
- you want routing / failover outside Claude
|
||||
- you want explicit health checks and local diagnostics
|
||||
- you want Claude available to multiple local tools through one endpoint
|
||||
|
||||
Choose **Claude channel** when:
|
||||
- you are primarily living inside Claude Code itself
|
||||
- you want Claude's native interactive workflow directly
|
||||
|
||||
Use **both together** when you want the best of both worlds.
|
||||
|
||||
---
|
||||
|
||||
If you already pay for Claude Pro or Max, OCP v2 turns that subscription into a practical local API bridge for the rest of your tooling stack.
|
||||
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"name": "openclaw-claude-proxy",
|
||||
"version": "1.8.0",
|
||||
"description": "OpenAI-compatible proxy that routes requests through Claude CLI \u2014 use your Claude Pro/Max subscription as an OpenClaw model provider",
|
||||
"version": "2.4.0",
|
||||
"description": "OpenAI-compatible proxy for Claude CLI v2 — per-model circuit breaker, adaptive first-byte timeout, structured logging",
|
||||
"type": "module",
|
||||
"bin": {
|
||||
"openclaw-claude-proxy": "./server.mjs"
|
||||
|
||||
+392
-270
@@ -1,21 +1,30 @@
|
||||
#!/usr/bin/env node
|
||||
/**
|
||||
* openclaw-claude-proxy — OpenAI-compatible proxy for Claude CLI
|
||||
* openclaw-claude-proxy v2.4.0 — OpenAI-compatible proxy for Claude CLI
|
||||
*
|
||||
* Translates OpenAI chat/completions requests into `claude -p` CLI calls,
|
||||
* letting you use your Claude Pro/Max subscription as an OpenClaw model provider.
|
||||
*
|
||||
* Features:
|
||||
* - Process pool: pre-spawns CLI processes to eliminate cold start latency
|
||||
* - SSE streaming + non-streaming responses
|
||||
* - Concurrent request support
|
||||
* v2.4.0:
|
||||
* - Per-model circuit breaker: consecutive timeouts temporarily mark a model as degraded
|
||||
* - Adaptive first-byte timeout: scales by model tier + prompt size
|
||||
* - Structured JSON logging for key events (easier to parse/alert on)
|
||||
* - On-demand spawning (no pool), session management, full tool access
|
||||
*
|
||||
* Env vars:
|
||||
* CLAUDE_PROXY_PORT — listen port (default: 3456)
|
||||
* CLAUDE_BIN — path to claude binary (default: "claude")
|
||||
* CLAUDE_TIMEOUT — per-request timeout in ms (default: 120000)
|
||||
* CLAUDE_POOL_SIZE — warm process pool size per model (default: 1)
|
||||
* PROXY_API_KEY — Bearer token for API authentication (optional, if unset auth is disabled)
|
||||
* CLAUDE_PROXY_PORT — listen port (default: 3456)
|
||||
* CLAUDE_BIN — path to claude binary (default: auto-detect)
|
||||
* CLAUDE_TIMEOUT — per-request timeout in ms (default: 120000)
|
||||
* CLAUDE_FIRST_BYTE_TIMEOUT — base first-byte timeout in ms (default: 45000)
|
||||
* CLAUDE_ALLOWED_TOOLS — comma-separated tools to allow (default: expanded set)
|
||||
* CLAUDE_SKIP_PERMISSIONS — "true" to bypass all permission checks (default: false)
|
||||
* CLAUDE_SYSTEM_PROMPT — system prompt appended to all requests
|
||||
* CLAUDE_MCP_CONFIG — path to MCP server config JSON file
|
||||
* CLAUDE_SESSION_TTL — session TTL in ms (default: 3600000 = 1h)
|
||||
* CLAUDE_MAX_CONCURRENT — max concurrent claude processes (default: 5)
|
||||
* CLAUDE_BREAKER_THRESHOLD — consecutive timeouts before circuit opens (default: 3)
|
||||
* CLAUDE_BREAKER_COOLDOWN — ms to wait before retrying after circuit opens (default: 60000)
|
||||
* PROXY_API_KEY — Bearer token for API auth (optional)
|
||||
*/
|
||||
import { createServer } from "node:http";
|
||||
import { spawn, execFileSync } from "node:child_process";
|
||||
@@ -64,26 +73,85 @@ function resolveClaude() {
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
// ── Configuration ───────────────────────────────────────────────────────
|
||||
const PORT = parseInt(process.env.CLAUDE_PROXY_PORT || "3456", 10);
|
||||
const CLAUDE = resolveClaude();
|
||||
const TIMEOUT = parseInt(process.env.CLAUDE_TIMEOUT || "300000", 10);
|
||||
const POOL_SIZE = parseInt(process.env.CLAUDE_POOL_SIZE || "1", 10);
|
||||
const POOL_MAX_IDLE = parseInt(process.env.CLAUDE_POOL_MAX_IDLE || "60000", 10); // max idle time before recycle
|
||||
const TIMEOUT = parseInt(process.env.CLAUDE_TIMEOUT || "120000", 10);
|
||||
const BASE_FIRST_BYTE_TIMEOUT = parseInt(process.env.CLAUDE_FIRST_BYTE_TIMEOUT || "45000", 10);
|
||||
const PROXY_API_KEY = process.env.PROXY_API_KEY || "";
|
||||
const SKIP_PERMISSIONS = process.env.CLAUDE_SKIP_PERMISSIONS === "true";
|
||||
const ALLOWED_TOOLS = (process.env.CLAUDE_ALLOWED_TOOLS ||
|
||||
"Bash,Read,Write,Edit,Glob,Grep,WebSearch,WebFetch,Agent"
|
||||
).split(",").map(s => s.trim()).filter(Boolean);
|
||||
const SYSTEM_PROMPT = process.env.CLAUDE_SYSTEM_PROMPT || "";
|
||||
const MCP_CONFIG = process.env.CLAUDE_MCP_CONFIG || "";
|
||||
const SESSION_TTL = parseInt(process.env.CLAUDE_SESSION_TTL || "3600000", 10);
|
||||
const MAX_CONCURRENT = parseInt(process.env.CLAUDE_MAX_CONCURRENT || "5", 10);
|
||||
const BREAKER_THRESHOLD = parseInt(process.env.CLAUDE_BREAKER_THRESHOLD || "3", 10);
|
||||
const BREAKER_COOLDOWN = parseInt(process.env.CLAUDE_BREAKER_COOLDOWN || "60000", 10);
|
||||
|
||||
const VERSION = _pkg.version;
|
||||
const START_TIME = Date.now();
|
||||
|
||||
// Model alias mapping: request model → claude CLI --model arg
|
||||
// Maps both shorthand aliases AND full model IDs to the canonical full model ID
|
||||
// that the claude CLI accepts. Using short names like "sonnet"/"opus"/"haiku"
|
||||
// causes the CLI to reject the --model arg and crash immediately.
|
||||
// ── Structured logging helper ───────────────────────────────────────────
|
||||
function logEvent(level, event, data = {}) {
|
||||
const entry = { ts: new Date().toISOString(), level, event, ...data };
|
||||
if (level === "error" || level === "warn") {
|
||||
console.error(JSON.stringify(entry));
|
||||
} else {
|
||||
console.log(JSON.stringify(entry));
|
||||
}
|
||||
}
|
||||
|
||||
// ── Per-model circuit breaker ───────────────────────────────────────────
|
||||
// Tracks consecutive timeouts per model. When threshold is reached, the
|
||||
// model is marked "open" (degraded) for BREAKER_COOLDOWN ms. During that
|
||||
// window, requests for this model fail fast with a clear error instead of
|
||||
// waiting for yet another timeout that would block the gateway.
|
||||
const breakers = new Map(); // cliModel → { failures, state, openedAt }
|
||||
|
||||
function getBreakerState(cliModel) {
|
||||
if (!breakers.has(cliModel)) {
|
||||
breakers.set(cliModel, { failures: 0, state: "closed", openedAt: 0 });
|
||||
}
|
||||
const b = breakers.get(cliModel);
|
||||
|
||||
// Auto-recover: if cooldown has elapsed, transition to half-open
|
||||
if (b.state === "open" && Date.now() - b.openedAt >= BREAKER_COOLDOWN) {
|
||||
b.state = "half-open";
|
||||
logEvent("info", "breaker_half_open", { model: cliModel, cooldownMs: BREAKER_COOLDOWN });
|
||||
}
|
||||
return b;
|
||||
}
|
||||
|
||||
function breakerRecordSuccess(cliModel) {
|
||||
const b = getBreakerState(cliModel);
|
||||
if (b.failures > 0 || b.state !== "closed") {
|
||||
logEvent("info", "breaker_reset", { model: cliModel, previousFailures: b.failures, previousState: b.state });
|
||||
}
|
||||
b.failures = 0;
|
||||
b.state = "closed";
|
||||
b.openedAt = 0;
|
||||
}
|
||||
|
||||
function breakerRecordTimeout(cliModel) {
|
||||
const b = getBreakerState(cliModel);
|
||||
b.failures++;
|
||||
logEvent("warn", "breaker_failure", { model: cliModel, consecutiveFailures: b.failures, threshold: BREAKER_THRESHOLD });
|
||||
|
||||
if (b.failures >= BREAKER_THRESHOLD && b.state !== "open") {
|
||||
b.state = "open";
|
||||
b.openedAt = Date.now();
|
||||
logEvent("error", "breaker_open", { model: cliModel, failures: b.failures, cooldownMs: BREAKER_COOLDOWN });
|
||||
}
|
||||
}
|
||||
|
||||
// ── Model mapping ───────────────────────────────────────────────────────
|
||||
// Maps request model IDs and aliases to canonical claude CLI model IDs.
|
||||
const MODEL_MAP = {
|
||||
// Full canonical IDs (pass through as-is)
|
||||
"claude-opus-4-6": "claude-opus-4-6",
|
||||
"claude-sonnet-4-6": "claude-sonnet-4-6",
|
||||
"claude-haiku-4-5-20251001": "claude-haiku-4-5-20251001",
|
||||
// Short aliases → full canonical IDs
|
||||
"claude-opus-4": "claude-opus-4-6",
|
||||
"claude-haiku-4": "claude-haiku-4-5-20251001",
|
||||
"claude-haiku-4-5": "claude-haiku-4-5-20251001",
|
||||
@@ -98,270 +166,287 @@ const MODELS = [
|
||||
{ id: "claude-haiku-4-5-20251001", name: "Claude Haiku 4.5" },
|
||||
];
|
||||
|
||||
// ── Process Pool ──────────────────────────────────────────────────────────
|
||||
// Pre-spawns `claude -p` processes that read prompts from stdin.
|
||||
// When a request arrives, we grab a warm process and pipe the prompt in.
|
||||
// After the process finishes, a new one is spawned to replace it.
|
||||
// ── Session management ──────────────────────────────────────────────────
|
||||
// Maps conversation IDs (from caller) to Claude CLI session UUIDs.
|
||||
// Enables --resume for multi-turn conversations, reducing token waste.
|
||||
const sessions = new Map(); // conversationId → { uuid, messageCount, lastUsed, model }
|
||||
|
||||
const pool = new Map(); // model → [{ proc, ready }]
|
||||
|
||||
// Exponential backoff state per model: tracks consecutive fast failures
|
||||
// to prevent a tight spawn/die loop when workers crash on startup.
|
||||
// Delays: 2s base, doubled each failure, capped at 60s.
|
||||
// After 5 consecutive fast crashes (each lived < 10s, all within 60s),
|
||||
// the model is marked "degraded" and respawning stops entirely.
|
||||
const poolBackoff = new Map(); // model → { failures: number, timer: TimeoutId|null, degraded: boolean, windowStart: number }
|
||||
|
||||
function spawnWarm(cliModel) {
|
||||
const env = { ...process.env };
|
||||
delete env.CLAUDECODE;
|
||||
delete env.ANTHROPIC_API_KEY;
|
||||
delete env.ANTHROPIC_BASE_URL;
|
||||
delete env.ANTHROPIC_AUTH_TOKEN;
|
||||
|
||||
const proc = spawn(CLAUDE, [
|
||||
"-p", "--model", cliModel,
|
||||
"--output-format", "text",
|
||||
"--no-session-persistence",
|
||||
"--allowedTools", "Bash", "Read", "Write", "Edit", "Glob", "Grep",
|
||||
], { env, stdio: ["pipe", "pipe", "pipe"] });
|
||||
|
||||
const entry = { proc, cliModel, ready: true, spawnedAt: Date.now() };
|
||||
|
||||
// Capture stderr from pool workers so crash reasons are visible in logs
|
||||
let stderrBuf = "";
|
||||
proc.stderr.on("data", (d) => {
|
||||
stderrBuf += d;
|
||||
if (stderrBuf.length > 500) stderrBuf = stderrBuf.slice(-500);
|
||||
});
|
||||
|
||||
proc.on("error", (err) => {
|
||||
console.error(`[pool] spawn error model=${cliModel}: ${err.message}`);
|
||||
entry.ready = false;
|
||||
});
|
||||
|
||||
proc.on("exit", (code) => {
|
||||
const livedMs = Date.now() - entry.spawnedAt;
|
||||
entry.ready = false;
|
||||
|
||||
// Log stderr from crashed pool worker (first 500 chars) to aid debugging
|
||||
if (stderrBuf.trim()) {
|
||||
console.error(`[pool] worker stderr model=${cliModel} exit=${code} lived=${livedMs}ms: ${stderrBuf.slice(0, 500)}`);
|
||||
}
|
||||
|
||||
// If the process survived > 10s, it was healthy — reset the backoff counter and window
|
||||
if (livedMs > 10000) {
|
||||
const state = poolBackoff.get(cliModel);
|
||||
if (state && (state.failures > 0 || state.degraded)) {
|
||||
console.log(`[pool] resetting backoff for model=${cliModel} (lived ${livedMs}ms)`);
|
||||
state.failures = 0;
|
||||
state.degraded = false;
|
||||
state.windowStart = Date.now();
|
||||
}
|
||||
}
|
||||
|
||||
// Remove from pool
|
||||
const arr = pool.get(cliModel);
|
||||
if (arr) {
|
||||
const idx = arr.indexOf(entry);
|
||||
if (idx !== -1) arr.splice(idx, 1);
|
||||
}
|
||||
// Replenish: treat as crash (apply backoff) only if it died fast (< 10s)
|
||||
const isCrash = livedMs <= 10000;
|
||||
replenishPool(cliModel, isCrash);
|
||||
});
|
||||
|
||||
return entry;
|
||||
}
|
||||
|
||||
// Recycle idle processes to prevent stale connections
|
||||
function recycleStaleProcesses() {
|
||||
setInterval(() => {
|
||||
const now = Date.now();
|
||||
for (const [cliModel, arr] of pool) {
|
||||
for (const entry of arr) {
|
||||
if (entry.ready && (now - entry.spawnedAt) > POOL_MAX_IDLE) {
|
||||
console.log(`[pool] recycling stale process model=${cliModel} (idle ${Math.round((now - entry.spawnedAt) / 1000)}s)`);
|
||||
entry.ready = false;
|
||||
entry.proc.kill();
|
||||
// exit handler will replenish
|
||||
}
|
||||
for (const [id, s] of sessions) {
|
||||
if (now - s.lastUsed > SESSION_TTL) {
|
||||
sessions.delete(id);
|
||||
console.log(`[session] expired ${id.slice(0, 12)}... (idle ${Math.round((now - s.lastUsed) / 60000)}m)`);
|
||||
}
|
||||
}
|
||||
}, 60000);
|
||||
|
||||
// ── Stats & diagnostics ─────────────────────────────────────────────────
|
||||
const stats = {
|
||||
totalRequests: 0,
|
||||
activeRequests: 0,
|
||||
errors: 0,
|
||||
timeouts: 0,
|
||||
sessionHits: 0,
|
||||
sessionMisses: 0,
|
||||
oneOffRequests: 0,
|
||||
};
|
||||
const recentErrors = []; // last 20 errors
|
||||
|
||||
function trackError(msg) {
|
||||
stats.errors++;
|
||||
recentErrors.push({ time: new Date().toISOString(), message: String(msg).slice(0, 200) });
|
||||
if (recentErrors.length > 20) recentErrors.shift();
|
||||
}
|
||||
|
||||
// ── Auth health check ───────────────────────────────────────────────────
|
||||
let authStatus = { ok: null, lastCheck: 0, message: "" };
|
||||
|
||||
async function checkAuth() {
|
||||
try {
|
||||
const env = { ...process.env };
|
||||
delete env.CLAUDECODE;
|
||||
delete env.ANTHROPIC_API_KEY;
|
||||
delete env.ANTHROPIC_BASE_URL;
|
||||
delete env.ANTHROPIC_AUTH_TOKEN;
|
||||
execFileSync(CLAUDE, ["auth", "status"], { encoding: "utf8", timeout: 10000, env });
|
||||
authStatus = { ok: true, lastCheck: Date.now(), message: "authenticated" };
|
||||
} catch (e) {
|
||||
const msg = (e.stderr || e.message || "").slice(0, 200);
|
||||
authStatus = { ok: false, lastCheck: Date.now(), message: msg };
|
||||
console.error(`[auth] check failed: ${msg}`);
|
||||
}
|
||||
}
|
||||
|
||||
setInterval(recycleStaleProcesses, 15000); // check every 15s
|
||||
// Check auth on start and every 10 minutes
|
||||
checkAuth();
|
||||
setInterval(checkAuth, 600000);
|
||||
|
||||
const BACKOFF_BASE_MS = 2000; // 2s starting delay
|
||||
const BACKOFF_MAX_MS = 60000; // 60s ceiling
|
||||
const CRASH_LIMIT = 5; // max consecutive fast crashes before degraded
|
||||
const CRASH_WINDOW_MS = 60000; // window for counting consecutive fast crashes (60s)
|
||||
// ── Build CLI arguments ─────────────────────────────────────────────────
|
||||
function buildCliArgs(cliModel, sessionInfo) {
|
||||
const args = ["-p", "--model", cliModel, "--output-format", "text"];
|
||||
|
||||
// replenishPool(cliModel, isCrash)
|
||||
// isCrash=false → initial or manual fill, no backoff applied
|
||||
// isCrash=true → called from exit handler after a fast crash
|
||||
function replenishPool(cliModel, isCrash = false) {
|
||||
if (!pool.has(cliModel)) pool.set(cliModel, []);
|
||||
if (!poolBackoff.has(cliModel)) poolBackoff.set(cliModel, { failures: 0, timer: null, degraded: false, windowStart: Date.now() });
|
||||
|
||||
const arr = pool.get(cliModel);
|
||||
const state = poolBackoff.get(cliModel);
|
||||
|
||||
// If this model is degraded (too many consecutive fast crashes), stop respawning
|
||||
if (state.degraded) {
|
||||
console.error(`[pool] DEGRADED: model=${cliModel} will not be respawned. Restart the proxy to retry.`);
|
||||
return;
|
||||
// Session handling
|
||||
if (sessionInfo?.resume) {
|
||||
args.push("--resume", sessionInfo.uuid);
|
||||
} else if (sessionInfo?.uuid) {
|
||||
args.push("--session-id", sessionInfo.uuid);
|
||||
} else {
|
||||
args.push("--no-session-persistence");
|
||||
}
|
||||
|
||||
const alive = arr.filter((e) => e.ready).length;
|
||||
const needed = POOL_SIZE - alive;
|
||||
if (needed <= 0) return;
|
||||
|
||||
// Cancel any pending backoff timer for this model before scheduling a new one
|
||||
if (state.timer) {
|
||||
clearTimeout(state.timer);
|
||||
state.timer = null;
|
||||
// Permissions
|
||||
if (SKIP_PERMISSIONS) {
|
||||
args.push("--dangerously-skip-permissions");
|
||||
} else if (ALLOWED_TOOLS.length > 0) {
|
||||
args.push("--allowedTools", ...ALLOWED_TOOLS);
|
||||
}
|
||||
|
||||
// Only track failures and apply backoff when this is a crash respawn
|
||||
if (!isCrash) {
|
||||
// Immediate spawn — no backoff on initial fill or manual replenish
|
||||
const currentAlive = arr.filter((e) => e.ready).length;
|
||||
const currentNeeded = POOL_SIZE - currentAlive;
|
||||
for (let i = 0; i < currentNeeded; i++) {
|
||||
const entry = spawnWarm(cliModel);
|
||||
arr.push(entry);
|
||||
console.log(`[pool] pre-spawned model=${cliModel} (pool size: ${arr.filter(e => e.ready).length})`);
|
||||
}
|
||||
return;
|
||||
// System prompt
|
||||
if (SYSTEM_PROMPT) {
|
||||
args.push("--append-system-prompt", SYSTEM_PROMPT);
|
||||
}
|
||||
|
||||
// --- Crash path: apply exponential backoff and degraded-state logic ---
|
||||
|
||||
const now = Date.now();
|
||||
|
||||
// Reset window if the last crash was outside the rolling window
|
||||
if ((now - state.windowStart) > CRASH_WINDOW_MS) {
|
||||
state.windowStart = now;
|
||||
state.failures = 0;
|
||||
// MCP config
|
||||
if (MCP_CONFIG) {
|
||||
args.push("--mcp-config", MCP_CONFIG);
|
||||
}
|
||||
|
||||
state.failures += 1;
|
||||
|
||||
// Check if we've hit the crash limit within the rolling window
|
||||
if (state.failures >= CRASH_LIMIT) {
|
||||
state.degraded = true;
|
||||
console.error(
|
||||
`[pool] DEGRADED: model=${cliModel} crashed ${state.failures} times in ` +
|
||||
`${Math.round((now - state.windowStart) / 1000)}s. ` +
|
||||
`Stopping respawn to prevent CPU spin. Restart the proxy to retry.`
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
// Exponential backoff: 2s, 4s, 8s, 16s, 32s … capped at 60s
|
||||
const delayMs = Math.min(BACKOFF_BASE_MS * Math.pow(2, state.failures - 1), BACKOFF_MAX_MS);
|
||||
console.warn(`[pool] backoff model=${cliModel} delay=${delayMs}ms (failures=${state.failures}/${CRASH_LIMIT})`);
|
||||
|
||||
state.timer = setTimeout(() => {
|
||||
state.timer = null;
|
||||
const currentAlive = arr.filter((e) => e.ready).length;
|
||||
const currentNeeded = POOL_SIZE - currentAlive;
|
||||
for (let i = 0; i < currentNeeded; i++) {
|
||||
const entry = spawnWarm(cliModel);
|
||||
arr.push(entry);
|
||||
console.log(`[pool] re-spawned model=${cliModel} (pool size: ${arr.filter(e => e.ready).length}, failures=${state.failures}/${CRASH_LIMIT})`);
|
||||
}
|
||||
}, delayMs);
|
||||
return args;
|
||||
}
|
||||
|
||||
function getWarmProcess(cliModel) {
|
||||
const arr = pool.get(cliModel) || [];
|
||||
const entry = arr.find((e) => e.ready);
|
||||
if (entry) {
|
||||
entry.ready = false; // mark as in-use
|
||||
const warmMs = Date.now() - entry.spawnedAt;
|
||||
console.log(`[pool] using warm process model=${cliModel} (warm for ${warmMs}ms)`);
|
||||
return entry.proc;
|
||||
}
|
||||
return null;
|
||||
// ── Format messages to prompt text ──────────────────────────────────────
|
||||
function messagesToPrompt(messages) {
|
||||
return messages.map((m) => {
|
||||
const text = typeof m.content === "string" ? m.content : JSON.stringify(m.content);
|
||||
if (m.role === "system") return `[System] ${text}`;
|
||||
if (m.role === "assistant") return `[Assistant] ${text}`;
|
||||
return text;
|
||||
}).join("\n\n");
|
||||
}
|
||||
|
||||
// Initialize pool for all models
|
||||
function initPool() {
|
||||
for (const cliModel of new Set(Object.values(MODEL_MAP))) {
|
||||
replenishPool(cliModel);
|
||||
}
|
||||
// Model tier multipliers for first-byte timeout.
|
||||
// Opus is much slower to produce first token, especially with large contexts.
|
||||
const MODEL_TIMEOUT_TIERS = {
|
||||
"opus": { base: 60000, perPromptChar: 0.00015 }, // 60s base + ~15s per 100k chars
|
||||
"sonnet": { base: 45000, perPromptChar: 0.00008 }, // 45s base + ~8s per 100k chars
|
||||
"haiku": { base: 30000, perPromptChar: 0.00005 }, // 30s base + ~5s per 100k chars
|
||||
};
|
||||
|
||||
function getModelTier(cliModel) {
|
||||
if (cliModel.includes("opus")) return "opus";
|
||||
if (cliModel.includes("haiku")) return "haiku";
|
||||
return "sonnet";
|
||||
}
|
||||
|
||||
function computeFirstByteTimeout(cliModel, promptLength) {
|
||||
const tier = MODEL_TIMEOUT_TIERS[getModelTier(cliModel)];
|
||||
const timeout = tier.base + Math.floor(promptLength * tier.perPromptChar);
|
||||
return Math.min(timeout, Math.max(TIMEOUT - 5000, 10000));
|
||||
}
|
||||
|
||||
// ── Call claude CLI ─────────────────────────────────────────────────────
|
||||
function callClaude(model, messages) {
|
||||
// On-demand spawning: each request spawns a fresh `claude -p` process.
|
||||
// No pool = no crash loops, no stale workers, no degraded states.
|
||||
// Stdin is written immediately so there's no 3s stdin timeout issue.
|
||||
function callClaude(model, messages, conversationId) {
|
||||
return new Promise((resolve, reject) => {
|
||||
const prompt = messages
|
||||
.map((m) => {
|
||||
const text = typeof m.content === "string" ? m.content : JSON.stringify(m.content);
|
||||
if (m.role === "system") return `[System] ${text}`;
|
||||
if (m.role === "assistant") return `[Assistant] ${text}`;
|
||||
return text;
|
||||
})
|
||||
.join("\n\n");
|
||||
if (stats.activeRequests >= MAX_CONCURRENT) {
|
||||
return reject(new Error(`concurrency limit reached (${stats.activeRequests}/${MAX_CONCURRENT})`));
|
||||
}
|
||||
|
||||
const cliModel = MODEL_MAP[model] || model;
|
||||
|
||||
// Try to use a warm process from the pool
|
||||
let proc = getWarmProcess(cliModel);
|
||||
let usedPool = !!proc;
|
||||
|
||||
if (!proc) {
|
||||
// Cold start fallback: spawn fresh
|
||||
console.log(`[pool] no warm process for model=${cliModel}, cold starting...`);
|
||||
const env = { ...process.env };
|
||||
delete env.CLAUDECODE;
|
||||
delete env.ANTHROPIC_API_KEY;
|
||||
delete env.ANTHROPIC_BASE_URL;
|
||||
delete env.ANTHROPIC_AUTH_TOKEN;
|
||||
proc = spawn(CLAUDE, [
|
||||
"-p", "--model", cliModel,
|
||||
"--output-format", "text",
|
||||
"--no-session-persistence",
|
||||
"--allowedTools", "Bash", "Read", "Write", "Edit", "Glob", "Grep",
|
||||
"--", prompt,
|
||||
], { env, stdio: ["ignore", "pipe", "pipe"] });
|
||||
// Circuit breaker check: fail fast if model is in open state
|
||||
const breaker = getBreakerState(cliModel);
|
||||
if (breaker.state === "open") {
|
||||
const remainingMs = BREAKER_COOLDOWN - (Date.now() - breaker.openedAt);
|
||||
logEvent("warn", "breaker_rejected", { model: cliModel, remainingCooldownMs: remainingMs });
|
||||
return reject(new Error(`circuit breaker open for ${cliModel}: ${breaker.failures} consecutive timeouts, retry in ${Math.ceil(remainingMs / 1000)}s`));
|
||||
}
|
||||
|
||||
stats.activeRequests++;
|
||||
stats.totalRequests++;
|
||||
|
||||
let sessionInfo = null;
|
||||
let prompt;
|
||||
|
||||
// ── Session logic ──
|
||||
if (conversationId && sessions.has(conversationId)) {
|
||||
// Resume existing session: only send the latest user message
|
||||
const session = sessions.get(conversationId);
|
||||
session.lastUsed = Date.now();
|
||||
sessionInfo = { uuid: session.uuid, resume: true };
|
||||
stats.sessionHits++;
|
||||
|
||||
const lastUserMsg = [...messages].reverse().find((m) => m.role === "user");
|
||||
prompt = lastUserMsg
|
||||
? (typeof lastUserMsg.content === "string" ? lastUserMsg.content : JSON.stringify(lastUserMsg.content))
|
||||
: "";
|
||||
session.messageCount = messages.length;
|
||||
|
||||
console.log(`[session] resume conv=${conversationId.slice(0, 12)}... uuid=${session.uuid.slice(0, 8)}... msgs=${messages.length} prompt_chars=${prompt.length}`);
|
||||
|
||||
} else if (conversationId) {
|
||||
// New session: send all messages, persist session for future --resume
|
||||
const uuid = randomUUID();
|
||||
sessions.set(conversationId, { uuid, messageCount: messages.length, lastUsed: Date.now(), model: cliModel });
|
||||
sessionInfo = { uuid, resume: false };
|
||||
stats.sessionMisses++;
|
||||
prompt = messagesToPrompt(messages);
|
||||
|
||||
console.log(`[session] new conv=${conversationId.slice(0, 12)}... uuid=${uuid.slice(0, 8)}... msgs=${messages.length}`);
|
||||
|
||||
} else {
|
||||
// One-off request, no session
|
||||
stats.oneOffRequests++;
|
||||
prompt = messagesToPrompt(messages);
|
||||
}
|
||||
|
||||
const cliArgs = buildCliArgs(cliModel, sessionInfo);
|
||||
|
||||
const env = { ...process.env };
|
||||
delete env.CLAUDECODE;
|
||||
delete env.ANTHROPIC_API_KEY;
|
||||
delete env.ANTHROPIC_BASE_URL;
|
||||
delete env.ANTHROPIC_AUTH_TOKEN;
|
||||
|
||||
const proc = spawn(CLAUDE, cliArgs, { env, stdio: ["pipe", "pipe", "pipe"] });
|
||||
|
||||
let stdout = "";
|
||||
let stderr = "";
|
||||
const t0 = Date.now();
|
||||
const firstByteTimeoutMs = computeFirstByteTimeout(cliModel, prompt.length);
|
||||
let settled = false;
|
||||
let gotFirstByte = false;
|
||||
|
||||
proc.stdout.on("data", (d) => (stdout += d));
|
||||
proc.stderr.on("data", (d) => (stderr += d));
|
||||
proc.on("close", (code) => {
|
||||
const elapsed = Date.now() - t0;
|
||||
if (code !== 0) {
|
||||
console.error(`[claude] exit=${code} model=${cliModel} elapsed=${elapsed}ms stderr=${stderr.slice(0, 300)}`);
|
||||
reject(new Error(stderr || stdout || `exit ${code}`));
|
||||
function settle(err, result) {
|
||||
if (settled) return;
|
||||
settled = true;
|
||||
clearTimeout(timer);
|
||||
clearTimeout(firstByteTimer);
|
||||
stats.activeRequests--;
|
||||
|
||||
if (err) {
|
||||
trackError(err.message || String(err));
|
||||
|
||||
// If session resume failed, remove session so next request starts fresh
|
||||
if (sessionInfo?.resume && conversationId) {
|
||||
console.warn(`[session] resume failed for ${conversationId.slice(0, 12)}..., removing stale session`);
|
||||
sessions.delete(conversationId);
|
||||
}
|
||||
|
||||
reject(err);
|
||||
} else {
|
||||
console.log(`[claude] ok model=${cliModel} chars=${stdout.length} elapsed=${elapsed}ms pool=${usedPool}`);
|
||||
resolve(stdout.trim());
|
||||
resolve(result);
|
||||
}
|
||||
});
|
||||
proc.on("error", reject);
|
||||
|
||||
// Log prompt size for debugging
|
||||
console.log(`[claude] request model=${cliModel} prompt_chars=${prompt.length} pool=${usedPool}`);
|
||||
|
||||
// If using pool process, send prompt via stdin
|
||||
if (usedPool) {
|
||||
proc.stdin.write(prompt);
|
||||
proc.stdin.end();
|
||||
}
|
||||
|
||||
const timer = setTimeout(() => { proc.kill(); reject(new Error("timeout")); }, TIMEOUT);
|
||||
proc.on("close", () => clearTimeout(timer));
|
||||
proc.stdout.on("data", (d) => {
|
||||
if (!gotFirstByte) {
|
||||
gotFirstByte = true;
|
||||
clearTimeout(firstByteTimer);
|
||||
console.log(`[claude] first-byte model=${cliModel} elapsed=${Date.now() - t0}ms`);
|
||||
}
|
||||
stdout += d;
|
||||
});
|
||||
proc.stderr.on("data", (d) => (stderr += d));
|
||||
|
||||
proc.on("close", (code, signal) => {
|
||||
const elapsed = Date.now() - t0;
|
||||
if (settled) {
|
||||
logEvent("warn", "late_close", { model: cliModel, code, signal: signal || "none", elapsed });
|
||||
return;
|
||||
}
|
||||
if (code !== 0) {
|
||||
logEvent("error", "claude_exit", { model: cliModel, code, signal: signal || "none", elapsed, stderr: stderr.slice(0, 300) });
|
||||
settle(new Error(stderr.slice(0, 300) || stdout.slice(0, 300) || `claude exit ${code}`));
|
||||
} else {
|
||||
breakerRecordSuccess(cliModel);
|
||||
logEvent("info", "claude_ok", { model: cliModel, chars: stdout.length, elapsed, session: conversationId ? conversationId.slice(0, 12) + "..." : "none" });
|
||||
settle(null, stdout.trim());
|
||||
}
|
||||
});
|
||||
|
||||
proc.on("error", (err) => {
|
||||
console.error(`[claude] spawn error: ${err.message}`);
|
||||
settle(err);
|
||||
});
|
||||
|
||||
// Write prompt to stdin immediately — no idle timeout issue
|
||||
proc.stdin.write(prompt);
|
||||
proc.stdin.end();
|
||||
|
||||
logEvent("info", "claude_spawned", { model: cliModel, promptChars: prompt.length, firstByteTimeout: firstByteTimeoutMs, tier: getModelTier(cliModel), session: conversationId ? conversationId.slice(0, 12) + "..." : "none" });
|
||||
|
||||
// First-byte timeout: abort early if Claude CLI produces no output
|
||||
const firstByteTimer = setTimeout(() => {
|
||||
if (!gotFirstByte && !settled) {
|
||||
stats.timeouts++;
|
||||
breakerRecordTimeout(cliModel);
|
||||
logEvent("error", "first_byte_timeout", { model: cliModel, timeoutMs: firstByteTimeoutMs, promptChars: prompt.length });
|
||||
try { proc.kill("SIGTERM"); } catch {}
|
||||
setTimeout(() => { try { proc.kill("SIGKILL"); } catch {} }, 5000);
|
||||
settle(new Error(`first-byte timeout after ${firstByteTimeoutMs}ms`));
|
||||
}
|
||||
}, firstByteTimeoutMs);
|
||||
|
||||
// Overall request timeout with graceful kill
|
||||
const timer = setTimeout(() => {
|
||||
if (settled) return;
|
||||
stats.timeouts++;
|
||||
breakerRecordTimeout(cliModel);
|
||||
logEvent("error", "request_timeout", { model: cliModel, timeoutMs: TIMEOUT });
|
||||
try { proc.kill("SIGTERM"); } catch {}
|
||||
setTimeout(() => { try { proc.kill("SIGKILL"); } catch {} }, 5000);
|
||||
settle(new Error(`timeout after ${TIMEOUT}ms`));
|
||||
}, TIMEOUT);
|
||||
});
|
||||
}
|
||||
|
||||
// ── Response helpers ────────────────────────────────────────────────────
|
||||
function jsonResponse(res, status, data) {
|
||||
if (res.headersSent || res.writableEnded || res.destroyed) return;
|
||||
res.writeHead(status, { "Content-Type": "application/json" });
|
||||
res.end(JSON.stringify(data));
|
||||
}
|
||||
@@ -371,25 +456,23 @@ function sendSSE(res, data) {
|
||||
}
|
||||
|
||||
function streamResponse(res, id, model, content) {
|
||||
if (res.headersSent || res.writableEnded || res.destroyed) return;
|
||||
res.writeHead(200, {
|
||||
"Content-Type": "text/event-stream",
|
||||
"Cache-Control": "no-cache",
|
||||
"Connection": "keep-alive",
|
||||
});
|
||||
const created = Math.floor(Date.now() / 1000);
|
||||
// Role chunk
|
||||
sendSSE(res, {
|
||||
id, object: "chat.completion.chunk", created, model,
|
||||
choices: [{ index: 0, delta: { role: "assistant" }, finish_reason: null }],
|
||||
});
|
||||
// Content chunks (~500 chars each)
|
||||
for (let i = 0; i < content.length; i += 500) {
|
||||
sendSSE(res, {
|
||||
id, object: "chat.completion.chunk", created, model,
|
||||
choices: [{ index: 0, delta: { content: content.slice(i, i + 500) }, finish_reason: null }],
|
||||
});
|
||||
}
|
||||
// Finish
|
||||
sendSSE(res, {
|
||||
id, object: "chat.completion.chunk", created, model,
|
||||
choices: [{ index: 0, delta: {}, finish_reason: "stop" }],
|
||||
@@ -420,10 +503,13 @@ async function handleChatCompletions(req, res) {
|
||||
const model = parsed.model || "claude-sonnet-4-6";
|
||||
const stream = parsed.stream;
|
||||
|
||||
// Session ID: from request body, header, or null (one-off)
|
||||
const conversationId = parsed.session_id || parsed.conversation_id || req.headers["x-session-id"] || req.headers["x-conversation-id"] || null;
|
||||
|
||||
if (!messages?.length) return jsonResponse(res, 400, { error: "messages required" });
|
||||
|
||||
try {
|
||||
const content = await callClaude(model, messages);
|
||||
const content = await callClaude(model, messages, conversationId);
|
||||
const id = `chatcmpl-${randomUUID()}`;
|
||||
|
||||
if (stream) {
|
||||
@@ -444,8 +530,8 @@ async function handleChatCompletions(req, res) {
|
||||
// ── HTTP server ─────────────────────────────────────────────────────────
|
||||
const server = createServer(async (req, res) => {
|
||||
res.setHeader("Access-Control-Allow-Origin", "*");
|
||||
res.setHeader("Access-Control-Allow-Methods", "GET, POST, OPTIONS");
|
||||
res.setHeader("Access-Control-Allow-Headers", "Content-Type, Authorization");
|
||||
res.setHeader("Access-Control-Allow-Methods", "GET, POST, DELETE, OPTIONS");
|
||||
res.setHeader("Access-Control-Allow-Headers", "Content-Type, Authorization, X-Session-Id, X-Conversation-Id");
|
||||
if (req.method === "OPTIONS") { res.writeHead(204); res.end(); return; }
|
||||
|
||||
// Bearer token auth (skip for /health and when PROXY_API_KEY is not set)
|
||||
@@ -473,49 +559,85 @@ const server = createServer(async (req, res) => {
|
||||
return handleChatCompletions(req, res);
|
||||
}
|
||||
|
||||
// GET /health — includes pool status, version, uptime
|
||||
// GET /health — comprehensive diagnostics
|
||||
if (req.url === "/health") {
|
||||
const poolStatus = {};
|
||||
for (const [model, arr] of pool) {
|
||||
const readyCount = arr.filter(e => e.ready).length;
|
||||
const errorCount = arr.filter(e => !e.ready).length;
|
||||
poolStatus[model] = {
|
||||
total: arr.length,
|
||||
ready: readyCount,
|
||||
error: errorCount,
|
||||
status: readyCount > 0 ? "ready" : "error",
|
||||
};
|
||||
}
|
||||
const uptimeMs = Date.now() - START_TIME;
|
||||
let binaryOk = false;
|
||||
try { accessSync(CLAUDE, constants.X_OK); binaryOk = true; } catch {}
|
||||
|
||||
const uptimeMs = Date.now() - START_TIME;
|
||||
const sessionList = [];
|
||||
for (const [id, s] of sessions) {
|
||||
sessionList.push({
|
||||
id: id.slice(0, 12) + "...",
|
||||
model: s.model,
|
||||
messages: s.messageCount,
|
||||
idleMs: Date.now() - s.lastUsed,
|
||||
});
|
||||
}
|
||||
|
||||
return jsonResponse(res, 200, {
|
||||
status: binaryOk ? "ok" : "degraded",
|
||||
status: binaryOk && authStatus.ok !== false ? "ok" : "degraded",
|
||||
version: VERSION,
|
||||
architecture: "on-demand (v2)",
|
||||
uptime: uptimeMs,
|
||||
uptimeHuman: `${Math.floor(uptimeMs / 3600000)}h ${Math.floor((uptimeMs % 3600000) / 60000)}m ${Math.floor((uptimeMs % 60000) / 1000)}s`,
|
||||
uptimeHuman: `${Math.floor(uptimeMs / 3600000)}h ${Math.floor((uptimeMs % 3600000) / 60000)}m`,
|
||||
claudeBinary: CLAUDE,
|
||||
claudeBinaryOk: binaryOk,
|
||||
pool: poolStatus,
|
||||
auth: authStatus,
|
||||
config: {
|
||||
timeout: TIMEOUT,
|
||||
firstByteTimeout: BASE_FIRST_BYTE_TIMEOUT,
|
||||
maxConcurrent: MAX_CONCURRENT,
|
||||
sessionTTL: SESSION_TTL,
|
||||
allowedTools: SKIP_PERMISSIONS ? "all (skip-permissions)" : ALLOWED_TOOLS,
|
||||
systemPrompt: SYSTEM_PROMPT ? `${SYSTEM_PROMPT.slice(0, 50)}...` : "(none)",
|
||||
mcpConfig: MCP_CONFIG || "(none)",
|
||||
},
|
||||
stats,
|
||||
sessions: sessionList,
|
||||
recentErrors: recentErrors.slice(-5),
|
||||
});
|
||||
}
|
||||
|
||||
// Catch-all: try to handle any POST with messages
|
||||
// DELETE /sessions — clear all sessions
|
||||
if (req.url === "/sessions" && req.method === "DELETE") {
|
||||
const count = sessions.size;
|
||||
sessions.clear();
|
||||
return jsonResponse(res, 200, { cleared: count });
|
||||
}
|
||||
|
||||
// GET /sessions — list active sessions
|
||||
if (req.url === "/sessions" && req.method === "GET") {
|
||||
const list = [];
|
||||
for (const [id, s] of sessions) {
|
||||
list.push({ id, uuid: s.uuid, model: s.model, messages: s.messageCount, lastUsed: new Date(s.lastUsed).toISOString() });
|
||||
}
|
||||
return jsonResponse(res, 200, { sessions: list });
|
||||
}
|
||||
|
||||
// Catch-all POST
|
||||
if (req.method === "POST") {
|
||||
return handleChatCompletions(req, res);
|
||||
}
|
||||
|
||||
jsonResponse(res, 404, { error: "Not found. Endpoints: GET /v1/models, POST /v1/chat/completions, GET /health" });
|
||||
jsonResponse(res, 404, { error: "Not found. Endpoints: GET /v1/models, POST /v1/chat/completions, GET /health, GET|DELETE /sessions" });
|
||||
});
|
||||
|
||||
// ── Start ──────────────────────────────────────────────────────────────
|
||||
initPool();
|
||||
|
||||
// ── Start ───────────────────────────────────────────────────────────────
|
||||
server.listen(PORT, "0.0.0.0", () => {
|
||||
console.log(`openclaw-claude-proxy v${VERSION} listening on http://0.0.0.0:${PORT}`);
|
||||
console.log(`Architecture: on-demand spawning (no pool)`);
|
||||
console.log(`Models: ${MODELS.map((m) => m.id).join(", ")}`);
|
||||
console.log(`Claude binary: ${CLAUDE}`);
|
||||
console.log(`Timeout: ${TIMEOUT}ms`);
|
||||
console.log(`Pool size: ${POOL_SIZE} per model, max idle: ${POOL_MAX_IDLE / 1000}s`);
|
||||
console.log(`Timeout: ${TIMEOUT}ms (base first-byte: ${BASE_FIRST_BYTE_TIMEOUT}ms, adaptive by model/prompt) | Max concurrent: ${MAX_CONCURRENT}`);
|
||||
console.log(`Tools: ${SKIP_PERMISSIONS ? "all (skip-permissions)" : ALLOWED_TOOLS.join(", ")}`);
|
||||
console.log(`Sessions: TTL=${SESSION_TTL / 1000}s`);
|
||||
if (SYSTEM_PROMPT) console.log(`System prompt: "${SYSTEM_PROMPT.slice(0, 80)}..."`);
|
||||
if (MCP_CONFIG) console.log(`MCP config: ${MCP_CONFIG}`);
|
||||
console.log(`Auth: ${PROXY_API_KEY ? "enabled (PROXY_API_KEY set)" : "disabled (no PROXY_API_KEY)"}`);
|
||||
console.log(`---`);
|
||||
console.log(`Coexistence: This proxy does NOT conflict with Claude Code interactive mode.`);
|
||||
console.log(` OCP uses: localhost:${PORT} (HTTP) → claude -p (per-request process)`);
|
||||
console.log(` CC uses: MCP protocol (in-process) → persistent session`);
|
||||
console.log(` Both can run simultaneously on the same machine.`);
|
||||
});
|
||||
|
||||
Generated
+20
@@ -0,0 +1,20 @@
|
||||
{
|
||||
"name": "openclaw-claude-proxy",
|
||||
"version": "3.4.0",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "openclaw-claude-proxy",
|
||||
"version": "3.4.0",
|
||||
"license": "MIT",
|
||||
"bin": {
|
||||
"ocp": "ocp",
|
||||
"openclaw-claude-proxy": "server.mjs"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
+5
-4
@@ -1,10 +1,11 @@
|
||||
{
|
||||
"name": "openclaw-claude-proxy",
|
||||
"version": "2.0.0",
|
||||
"description": "OpenAI-compatible proxy that routes requests through Claude CLI \u2014 use your Claude Pro/Max subscription as an OpenClaw model provider",
|
||||
"version": "3.7.0",
|
||||
"description": "OCP (Open Claude Proxy) — use your Claude Pro/Max subscription as an OpenAI-compatible API for any IDE. Works with Cline, OpenCode, Aider, Continue.dev, OpenClaw, and more.",
|
||||
"type": "module",
|
||||
"bin": {
|
||||
"openclaw-claude-proxy": "./server.mjs"
|
||||
"openclaw-claude-proxy": "./server.mjs",
|
||||
"ocp": "./ocp"
|
||||
},
|
||||
"scripts": {
|
||||
"start": "node server.mjs",
|
||||
@@ -23,6 +24,6 @@
|
||||
},
|
||||
"repository": {
|
||||
"type": "git",
|
||||
"url": "https://github.com/dtzp555-max/openclaw-claude-proxy"
|
||||
"url": "https://github.com/dtzp555-max/ocp"
|
||||
}
|
||||
}
|
||||
|
||||
+1053
-166
File diff suppressed because it is too large
Load Diff
@@ -12,7 +12,7 @@
|
||||
* 4. Creates start.sh for easy launch
|
||||
* 5. Optionally starts the proxy
|
||||
*/
|
||||
import { readFileSync, writeFileSync, existsSync, mkdirSync } from "node:fs";
|
||||
import { readFileSync, writeFileSync, existsSync, mkdirSync, unlinkSync } from "node:fs";
|
||||
import { execSync } from "node:child_process";
|
||||
import { join, dirname } from "node:path";
|
||||
import { homedir } from "node:os";
|
||||
@@ -36,6 +36,8 @@ const DEFAULT_MODEL = opt("default-model", "opus"); // opus | sonnet | haiku
|
||||
const DRY_RUN = flag("dry-run");
|
||||
const SKIP_START = flag("no-start");
|
||||
const PROVIDER_NAME = opt("provider-name", "claude-local");
|
||||
const BIND_ADDRESS = opt("bind", "127.0.0.1");
|
||||
const AUTH_MODE_CONFIG = opt("auth-mode", "none");
|
||||
|
||||
const MODEL_ID_MAP = {
|
||||
opus: "claude-opus-4-6",
|
||||
@@ -169,6 +171,19 @@ for (const [key, val] of Object.entries(MODEL_ALIASES)) {
|
||||
}
|
||||
log(`Model aliases added to agents.defaults.models`);
|
||||
|
||||
// Set idleTimeoutSeconds to 0 — critical for Claude tool-use.
|
||||
// When Claude calls tools (Bash, Read, etc.), the token stream pauses for 30-120s.
|
||||
// OpenClaw's default idleTimeoutSeconds (60s) kills the connection mid-tool-call,
|
||||
// causing exit 143 (SIGTERM) and stuck sessions. Setting to 0 disables the idle timer.
|
||||
if (!config.agents.defaults.llm) config.agents.defaults.llm = {};
|
||||
if (config.agents.defaults.llm.idleTimeoutSeconds === undefined ||
|
||||
config.agents.defaults.llm.idleTimeoutSeconds > 0) {
|
||||
config.agents.defaults.llm.idleTimeoutSeconds = 0;
|
||||
log(`Set agents.defaults.llm.idleTimeoutSeconds = 0 (prevents tool-call timeouts)`);
|
||||
} else {
|
||||
log(`idleTimeoutSeconds already configured: ${config.agents.defaults.llm.idleTimeoutSeconds}`);
|
||||
}
|
||||
|
||||
writeJSON(CONFIG_PATH, config);
|
||||
log(`Config saved`);
|
||||
|
||||
@@ -285,26 +300,62 @@ if (!DRY_RUN) {
|
||||
console.log("\n🔄 Installing auto-start on login...\n");
|
||||
|
||||
const platform = process.platform;
|
||||
const nodeBin = process.execPath;
|
||||
// Use stable symlink path instead of versioned Cellar path (e.g. /opt/homebrew/opt/node/bin/node
|
||||
// instead of /opt/homebrew/Cellar/node/25.8.0/bin/node) so the plist survives node upgrades.
|
||||
let nodeBin = process.execPath;
|
||||
if (platform === "darwin" && nodeBin.includes("/Cellar/")) {
|
||||
const stable = nodeBin.replace(/\/Cellar\/[^/]+\/[^/]+\//, "/opt/");
|
||||
if (existsSync(stable)) {
|
||||
nodeBin = stable;
|
||||
log(`Using stable node path: ${nodeBin}`);
|
||||
}
|
||||
}
|
||||
|
||||
// Ensure logs dir exists
|
||||
const logsDir = join(OPENCLAW_DIR, "logs");
|
||||
if (!existsSync(logsDir)) mkdirSync(logsDir, { recursive: true });
|
||||
|
||||
// Use neutral service names to avoid OpenClaw gateway's extra-service detection.
|
||||
// OpenClaw scans LaunchAgent plists and systemd units for "openclaw" / "clawdbot"
|
||||
// markers and flags them as conflicting gateway-like services. Using "dev.ocp.*"
|
||||
// and "ocp-proxy" keeps the proxy invisible to that heuristic.
|
||||
const OCP_HOME = join(HOME, ".ocp");
|
||||
const ocpLogsDir = join(OCP_HOME, "logs");
|
||||
if (!existsSync(ocpLogsDir)) mkdirSync(ocpLogsDir, { recursive: true });
|
||||
|
||||
// Uninstall legacy service names if present (upgrade path)
|
||||
if (platform === "darwin") {
|
||||
const legacyPlist = join(HOME, "Library", "LaunchAgents", "ai.openclaw.proxy.plist");
|
||||
if (existsSync(legacyPlist)) {
|
||||
try { execSync(`launchctl bootout gui/$(id -u) "${legacyPlist}" 2>/dev/null`); } catch { /* ignore */ }
|
||||
try { unlinkSync(legacyPlist); } catch { /* ignore */ }
|
||||
log(`Removed legacy plist: ai.openclaw.proxy`);
|
||||
}
|
||||
} else if (platform === "linux") {
|
||||
const legacyService = join(HOME, ".config", "systemd", "user", "openclaw-proxy.service");
|
||||
if (existsSync(legacyService)) {
|
||||
try { execSync(`systemctl --user stop openclaw-proxy 2>/dev/null`); } catch { /* ignore */ }
|
||||
try { execSync(`systemctl --user disable openclaw-proxy 2>/dev/null`); } catch { /* ignore */ }
|
||||
try { unlinkSync(legacyService); } catch { /* ignore */ }
|
||||
try { execSync(`systemctl --user daemon-reload`); } catch { /* ignore */ }
|
||||
log(`Removed legacy systemd service: openclaw-proxy`);
|
||||
}
|
||||
}
|
||||
|
||||
if (platform === "darwin") {
|
||||
// macOS: launchd
|
||||
const plistDir = join(HOME, "Library", "LaunchAgents");
|
||||
if (!existsSync(plistDir)) mkdirSync(plistDir, { recursive: true });
|
||||
|
||||
const plistPath = join(plistDir, "ai.openclaw.proxy.plist");
|
||||
const logPath = join(logsDir, "proxy.log");
|
||||
const plistPath = join(plistDir, "dev.ocp.proxy.plist");
|
||||
const logPath = join(ocpLogsDir, "proxy.log");
|
||||
|
||||
const plistXml = `<?xml version="1.0" encoding="UTF-8"?>
|
||||
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
|
||||
<plist version="1.0">
|
||||
<dict>
|
||||
<key>Label</key>
|
||||
<string>ai.openclaw.proxy</string>
|
||||
<string>dev.ocp.proxy</string>
|
||||
<key>ProgramArguments</key>
|
||||
<array>
|
||||
<string>${nodeBin}</string>
|
||||
@@ -314,6 +365,10 @@ if (!DRY_RUN) {
|
||||
<dict>
|
||||
<key>CLAUDE_PROXY_PORT</key>
|
||||
<string>${PORT}</string>
|
||||
<key>CLAUDE_BIND</key>
|
||||
<string>${BIND_ADDRESS}</string>
|
||||
<key>CLAUDE_AUTH_MODE</key>
|
||||
<string>${AUTH_MODE_CONFIG}</string>
|
||||
</dict>
|
||||
<key>RunAtLoad</key>
|
||||
<true/>
|
||||
@@ -330,27 +385,30 @@ if (!DRY_RUN) {
|
||||
writeFileSync(plistPath, plistXml);
|
||||
log(`Plist written: ${plistPath}`);
|
||||
|
||||
// Unload first (in case it was already loaded) then load
|
||||
try { execSync(`launchctl unload "${plistPath}" 2>/dev/null`); } catch { /* ignore */ }
|
||||
execSync(`launchctl load "${plistPath}"`);
|
||||
log(`launchctl loaded ai.openclaw.proxy`);
|
||||
// Bootout first (in case it was already loaded) then bootstrap
|
||||
try { execSync(`launchctl bootout gui/$(id -u) "${plistPath}" 2>/dev/null`); } catch { /* ignore */ }
|
||||
execSync(`launchctl bootstrap gui/$(id -u) "${plistPath}"`);
|
||||
log(`launchctl loaded dev.ocp.proxy`);
|
||||
|
||||
} else if (platform === "linux") {
|
||||
// Linux: systemd user service
|
||||
const systemdDir = join(HOME, ".config", "systemd", "user");
|
||||
if (!existsSync(systemdDir)) mkdirSync(systemdDir, { recursive: true });
|
||||
|
||||
const servicePath = join(systemdDir, "openclaw-proxy.service");
|
||||
const logPath = join(logsDir, "proxy.log");
|
||||
const servicePath = join(systemdDir, "ocp-proxy.service");
|
||||
const logPath = join(ocpLogsDir, "proxy.log");
|
||||
|
||||
const serviceUnit = `[Unit]
|
||||
Description=OpenClaw Claude Proxy
|
||||
Description=OCP — Open Claude Proxy
|
||||
After=network.target
|
||||
|
||||
[Service]
|
||||
ExecStart=${nodeBin} ${serverPath}
|
||||
Environment=CLAUDE_PROXY_PORT=${PORT}
|
||||
Environment=CLAUDE_BIND=${BIND_ADDRESS}
|
||||
Environment=CLAUDE_AUTH_MODE=${AUTH_MODE_CONFIG}
|
||||
Restart=always
|
||||
RestartSec=5
|
||||
StandardOutput=append:${logPath}
|
||||
StandardError=append:${logPath}
|
||||
|
||||
@@ -362,8 +420,8 @@ WantedBy=default.target
|
||||
log(`Service file written: ${servicePath}`);
|
||||
|
||||
execSync(`systemctl --user daemon-reload`);
|
||||
execSync(`systemctl --user enable openclaw-proxy`);
|
||||
execSync(`systemctl --user start openclaw-proxy`);
|
||||
execSync(`systemctl --user enable ocp-proxy`);
|
||||
execSync(`systemctl --user start ocp-proxy`);
|
||||
log(`systemd user service enabled and started`);
|
||||
|
||||
} else {
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
PORT=${CLAUDE_PROXY_PORT:-3456}
|
||||
if ! lsof -i :$PORT -sTCP:LISTEN &>/dev/null; then
|
||||
unset CLAUDECODE
|
||||
nohup node "/Users/taodeng/.openclaw/projects/claude-proxy/openclaw-claude-proxy/server.mjs" \
|
||||
nohup node "/Users/taodeng/.openclaw/projects/claude-proxy/server.mjs" \
|
||||
>> "/Users/taodeng/.openclaw/logs/claude-proxy.log" \
|
||||
2>> "/Users/taodeng/.openclaw/logs/claude-proxy.err.log" &
|
||||
echo "claude-proxy started on port $PORT (pid $!)"
|
||||
|
||||
+35
-23
@@ -3,6 +3,9 @@
|
||||
* openclaw-claude-proxy uninstaller
|
||||
*
|
||||
* Stops and removes the launchd (macOS) or systemd (Linux) auto-start entry.
|
||||
* Handles both legacy (ai.openclaw.proxy / openclaw-proxy) and current
|
||||
* (dev.ocp.proxy / ocp-proxy) service names.
|
||||
*
|
||||
* Run: node uninstall.mjs
|
||||
*/
|
||||
import { existsSync, unlinkSync } from "node:fs";
|
||||
@@ -15,43 +18,52 @@ const HOME = homedir();
|
||||
function log(msg) { console.log(` ✓ ${msg}`); }
|
||||
function warn(msg) { console.log(` ⚠ ${msg}`); }
|
||||
|
||||
console.log("\n🗑 Uninstalling openclaw-claude-proxy auto-start...\n");
|
||||
console.log("\n🗑 Uninstalling OCP auto-start...\n");
|
||||
|
||||
const platform = process.platform;
|
||||
|
||||
if (platform === "darwin") {
|
||||
const plistPath = join(HOME, "Library", "LaunchAgents", "ai.openclaw.proxy.plist");
|
||||
|
||||
// Remove current service
|
||||
const plistPath = join(HOME, "Library", "LaunchAgents", "dev.ocp.proxy.plist");
|
||||
if (existsSync(plistPath)) {
|
||||
try {
|
||||
execSync(`launchctl unload "${plistPath}" 2>/dev/null`);
|
||||
log("launchd service stopped and unloaded");
|
||||
} catch {
|
||||
warn("launchctl unload failed (service may not have been running)");
|
||||
}
|
||||
try { execSync(`launchctl bootout gui/$(id -u) "${plistPath}" 2>/dev/null`); } catch { /* ignore */ }
|
||||
unlinkSync(plistPath);
|
||||
log(`Plist removed: ${plistPath}`);
|
||||
} else {
|
||||
warn(`Plist not found: ${plistPath}`);
|
||||
log(`Removed: ${plistPath}`);
|
||||
}
|
||||
|
||||
// Remove legacy service
|
||||
const legacyPath = join(HOME, "Library", "LaunchAgents", "ai.openclaw.proxy.plist");
|
||||
if (existsSync(legacyPath)) {
|
||||
try { execSync(`launchctl bootout gui/$(id -u) "${legacyPath}" 2>/dev/null`); } catch { /* ignore */ }
|
||||
unlinkSync(legacyPath);
|
||||
log(`Removed legacy: ${legacyPath}`);
|
||||
}
|
||||
|
||||
if (!existsSync(plistPath) && !existsSync(legacyPath)) {
|
||||
warn("No plist found (service may not have been installed)");
|
||||
}
|
||||
|
||||
} else if (platform === "linux") {
|
||||
const servicePath = join(HOME, ".config", "systemd", "user", "openclaw-proxy.service");
|
||||
|
||||
try { execSync(`systemctl --user stop openclaw-proxy 2>/dev/null`); } catch { /* ignore */ }
|
||||
log("systemd service stopped");
|
||||
|
||||
try { execSync(`systemctl --user disable openclaw-proxy 2>/dev/null`); } catch { /* ignore */ }
|
||||
log("systemd service disabled");
|
||||
|
||||
// Remove current service
|
||||
const servicePath = join(HOME, ".config", "systemd", "user", "ocp-proxy.service");
|
||||
try { execSync(`systemctl --user stop ocp-proxy 2>/dev/null`); } catch { /* ignore */ }
|
||||
try { execSync(`systemctl --user disable ocp-proxy 2>/dev/null`); } catch { /* ignore */ }
|
||||
if (existsSync(servicePath)) {
|
||||
unlinkSync(servicePath);
|
||||
log(`Service file removed: ${servicePath}`);
|
||||
} else {
|
||||
warn(`Service file not found: ${servicePath}`);
|
||||
log(`Removed: ${servicePath}`);
|
||||
}
|
||||
|
||||
// Remove legacy service
|
||||
const legacyPath = join(HOME, ".config", "systemd", "user", "openclaw-proxy.service");
|
||||
try { execSync(`systemctl --user stop openclaw-proxy 2>/dev/null`); } catch { /* ignore */ }
|
||||
try { execSync(`systemctl --user disable openclaw-proxy 2>/dev/null`); } catch { /* ignore */ }
|
||||
if (existsSync(legacyPath)) {
|
||||
unlinkSync(legacyPath);
|
||||
log(`Removed legacy: ${legacyPath}`);
|
||||
}
|
||||
|
||||
try { execSync(`systemctl --user daemon-reload`); } catch { /* ignore */ }
|
||||
log("systemd daemon reloaded");
|
||||
|
||||
} else {
|
||||
warn(`Auto-start not supported on ${platform} — nothing to remove`);
|
||||
|
||||
Reference in New Issue
Block a user