mirror of
https://github.com/dtzp555-max/ocp.git
synced 2026-07-22 21:45:08 +00:00
Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
7434f6adc0 | ||
|
|
8a7951134e | ||
|
|
47434a8ba7 | ||
|
|
1d95dbeebc | ||
|
|
9e6bef729b | ||
|
|
256b2bfb77 | ||
|
|
384e66bfa6 | ||
|
|
76a8c56c88 | ||
|
|
cedd2acecc | ||
|
|
9a22372e28 | ||
|
|
917ff60014 |
@@ -4,13 +4,61 @@
|
|||||||
|
|
||||||
A lightweight, zero-dependency proxy that lets [OpenClaw](https://github.com/openclaw/openclaw) agents talk to Claude through your existing subscription. One command to set up, one file to run.
|
A lightweight, zero-dependency proxy that lets [OpenClaw](https://github.com/openclaw/openclaw) agents talk to Claude through your existing subscription. One command to set up, one file to run.
|
||||||
|
|
||||||
**Why?**
|
## v2.5.0 — Emergency Fix: Sliding-Window Circuit Breaker
|
||||||
- **$0 API cost** — uses your Claude Pro/Max subscription, not pay-per-token API
|
|
||||||
- **Zero dependencies** — single Node.js file, no `npm install`
|
**Incident (2026-03-22):** Multi-agent burst (ClawTeam with 5+ Opus agents) caused cascading timeout failure. The old consecutive-count circuit breaker (threshold=3) tripped within seconds, blocking ALL requests globally — including unrelated agents and new sessions. With fallback models removed, this resulted in complete service outage ("LLM request timed out." on every message).
|
||||||
- **One command setup** — `node setup.mjs` handles everything
|
|
||||||
- **OpenAI-compatible** — standard `/v1/chat/completions` endpoint
|
**Root cause:** v2.4.0's circuit breaker counted consecutive failures per model. When ClawTeam spawned multiple concurrent Opus agents and Claude API had moderate latency, 3 quick timeouts opened the breaker for the entire model. With `fallbacks: []`, the gateway had no alternative path.
|
||||||
- **All Claude models** — Opus 4.6, Sonnet 4.6, Haiku 4
|
|
||||||
- **Streaming support** — real-time SSE responses
|
**What's new in v2.5.0:**
|
||||||
|
- **Sliding-window circuit breaker** — counts failures in a 5-minute window (default: 6 failures) instead of 3 consecutive. Multi-agent bursts no longer trip the breaker instantly.
|
||||||
|
- **Graduated backoff** — cooldown doubles on each re-open (120s → 240s → 300s cap), resets fully on first success. Prevents oscillation between open/half-open during extended API issues.
|
||||||
|
- **Multi-probe half-open** — allows 2 concurrent probe requests in half-open state (was 1), improving recovery speed.
|
||||||
|
- **Increased default timeouts** — Opus first-byte 60s→90s, Sonnet 45s→60s, overall 120s→300s, max concurrent 5→8. Designed for large agent system prompts (30K+ chars).
|
||||||
|
- **Health endpoint shows breaker state** — `/health` now exposes per-model breaker state (window failures, cooldown, reopen count). Status is "degraded" when any breaker is open.
|
||||||
|
|
||||||
|
**New env vars:**
|
||||||
|
|
||||||
|
| Variable | Default | Description |
|
||||||
|
|----------|---------|-------------|
|
||||||
|
| `CLAUDE_BREAKER_WINDOW` | `300000` | Sliding window duration (ms) for failure counting |
|
||||||
|
| `CLAUDE_BREAKER_HALF_OPEN_MAX` | `2` | Max concurrent probe requests in half-open state |
|
||||||
|
|
||||||
|
**Updated defaults:**
|
||||||
|
|
||||||
|
| Variable | Old Default | New Default |
|
||||||
|
|----------|-------------|-------------|
|
||||||
|
| `CLAUDE_TIMEOUT` | `120000` | `300000` |
|
||||||
|
| `CLAUDE_FIRST_BYTE_TIMEOUT` | `45000` | `90000` |
|
||||||
|
| `CLAUDE_MAX_CONCURRENT` | `5` | `8` |
|
||||||
|
| `CLAUDE_BREAKER_THRESHOLD` | `3` | `6` |
|
||||||
|
| `CLAUDE_BREAKER_COOLDOWN` | `60000` | `120000` |
|
||||||
|
|
||||||
|
**Upgrade:** Pull latest and restart the proxy. The new defaults take effect immediately. If you have custom env vars set in your plist/service file, review and adjust them.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## v2.0.0 — Major Upgrade
|
||||||
|
|
||||||
|
**What's new:**
|
||||||
|
- **On-demand spawning** — eliminates the pool crash loops, DEGRADED states, and stdin timeout errors from v1.x. Each request spawns a fresh `claude -p` process with stdin written immediately. No more stale workers, no more backoff spirals.
|
||||||
|
- **Session management** — multi-turn conversations use `--resume` to avoid resending full history. Reduces token waste and enables Claude Code's built-in context compression on long conversations.
|
||||||
|
- **Full tool access** — expanded default tools (Bash, Read, Write, Edit, Glob, Grep, WebSearch, WebFetch, Agent). Configurable via `CLAUDE_ALLOWED_TOOLS` or bypass all checks with `CLAUDE_SKIP_PERMISSIONS=true`.
|
||||||
|
- **System prompt pass-through** — set `CLAUDE_SYSTEM_PROMPT` to inject context into every request.
|
||||||
|
- **MCP config support** — set `CLAUDE_MCP_CONFIG` to load MCP servers (Telegram, etc.) into claude -p calls.
|
||||||
|
- **Concurrency control** — `CLAUDE_MAX_CONCURRENT` prevents runaway process spawning (default: 5).
|
||||||
|
- **Auth health monitoring** — periodic `claude auth status` checks with status exposed on `/health`.
|
||||||
|
- **Session API** — `GET /sessions` to list, `DELETE /sessions` to clear active sessions.
|
||||||
|
- **Improved diagnostics** — `/health` endpoint shows stats, active sessions, recent errors, auth status, and full config.
|
||||||
|
|
||||||
|
**Coexistence with Claude Code interactive mode:**
|
||||||
|
OCP and Claude Code (interactive/Telegram) run on completely different paths and can coexist on the same machine without conflict:
|
||||||
|
- OCP: `localhost:3456` (HTTP) → spawns `claude -p` processes (per-request, stateless)
|
||||||
|
- CC: MCP protocol (in-process) → persistent interactive session
|
||||||
|
- No shared ports, no shared processes, no shared sessions
|
||||||
|
|
||||||
|
**Daemon advantage over CC:**
|
||||||
|
OCP runs as a system daemon (launchd/systemd) that auto-starts on boot and auto-recovers from crashes. Unlike Claude Code interactive mode, OCP does not require a terminal session to stay open — it survives disconnects, reboots, and SSH drops. Combined with OpenClaw's memory system, this means your agents never lose continuity.
|
||||||
|
|
||||||
## How it works
|
## How it works
|
||||||
|
|
||||||
@@ -22,7 +70,7 @@ The proxy translates OpenAI-compatible `/v1/chat/completions` requests into `cla
|
|||||||
|
|
||||||
## Prerequisites
|
## Prerequisites
|
||||||
|
|
||||||
- **Node.js** ≥ 18
|
- **Node.js** >= 18
|
||||||
- **Claude CLI** installed and authenticated (`claude login`)
|
- **Claude CLI** installed and authenticated (`claude login`)
|
||||||
- **OpenClaw** installed
|
- **OpenClaw** installed
|
||||||
|
|
||||||
@@ -49,6 +97,33 @@ openclaw config set agents.defaults.model.primary "claude-local/claude-opus-4-6"
|
|||||||
openclaw gateway restart
|
openclaw gateway restart
|
||||||
```
|
```
|
||||||
|
|
||||||
|
## 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?"}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**First request** with a new session_id: all messages are sent, session is persisted via `--session-id`.
|
||||||
|
**Subsequent requests** with the same session_id: only the latest user message is sent via `--resume`, reducing token consumption.
|
||||||
|
|
||||||
|
Sessions expire after 1 hour of inactivity (configurable via `CLAUDE_SESSION_TTL`).
|
||||||
|
|
||||||
|
**API endpoints:**
|
||||||
|
- `GET /sessions` — list all active sessions
|
||||||
|
- `DELETE /sessions` — clear all sessions
|
||||||
|
|
||||||
## Security
|
## Security
|
||||||
|
|
||||||
- **Localhost only** — the proxy binds to `127.0.0.1` and is not exposed to the internet or your local network
|
- **Localhost only** — the proxy binds to `127.0.0.1` and is not exposed to the internet or your local network
|
||||||
@@ -123,9 +198,37 @@ openclaw gateway restart
|
|||||||
|
|
||||||
| Model ID | Claude CLI model | Notes |
|
| Model ID | Claude CLI model | Notes |
|
||||||
|----------|-----------------|-------|
|
|----------|-----------------|-------|
|
||||||
| `claude-opus-4-6` | opus | Most capable, slower |
|
| `claude-opus-4-6` | claude-opus-4-6 | Most capable, slower |
|
||||||
| `claude-sonnet-4-6` | sonnet | Good balance of speed/quality |
|
| `claude-sonnet-4-6` | claude-sonnet-4-6 | Good balance of speed/quality |
|
||||||
| `claude-haiku-4` | haiku | Fastest, lightweight |
|
| `claude-haiku-4` | claude-haiku-4-5-20251001 | Fastest, lightweight |
|
||||||
|
|
||||||
|
## Environment Variables
|
||||||
|
|
||||||
|
| Variable | Default | Description |
|
||||||
|
|----------|---------|-------------|
|
||||||
|
| `CLAUDE_PROXY_PORT` | `3456` | Listen port |
|
||||||
|
| `CLAUDE_BIN` | *(auto-detect)* | Path to claude binary |
|
||||||
|
| `CLAUDE_TIMEOUT` | `300000` | Overall request timeout (ms) |
|
||||||
|
| `CLAUDE_FIRST_BYTE_TIMEOUT` | `90000` | Base first-byte timeout (ms), adaptive by model tier |
|
||||||
|
| `CLAUDE_ALLOWED_TOOLS` | `Bash,Read,...,Agent` | Comma-separated tools to pre-approve |
|
||||||
|
| `CLAUDE_SKIP_PERMISSIONS` | `false` | Set `true` to bypass all permission checks |
|
||||||
|
| `CLAUDE_SYSTEM_PROMPT` | *(empty)* | System prompt appended to all requests |
|
||||||
|
| `CLAUDE_MCP_CONFIG` | *(empty)* | Path to MCP server config JSON file |
|
||||||
|
| `CLAUDE_SESSION_TTL` | `3600000` | Session expiry in ms (default: 1 hour) |
|
||||||
|
| `CLAUDE_MAX_CONCURRENT` | `8` | Max concurrent claude processes |
|
||||||
|
| `CLAUDE_BREAKER_THRESHOLD` | `6` | Failures in window before circuit opens |
|
||||||
|
| `CLAUDE_BREAKER_COOLDOWN` | `120000` | Base cooldown (ms) before half-open (graduates on re-open) |
|
||||||
|
| `CLAUDE_BREAKER_WINDOW` | `300000` | Sliding window duration (ms) for failure counting |
|
||||||
|
| `CLAUDE_BREAKER_HALF_OPEN_MAX` | `2` | Max concurrent probe requests in half-open state |
|
||||||
|
| `PROXY_API_KEY` | *(unset)* | Bearer token for API authentication |
|
||||||
|
|
||||||
|
## API Endpoints
|
||||||
|
|
||||||
|
- `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
|
## Authentication
|
||||||
|
|
||||||
@@ -133,87 +236,44 @@ The proxy supports optional Bearer token authentication via the `PROXY_API_KEY`
|
|||||||
|
|
||||||
**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 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 (backwards compatible with v1.6.x and earlier).
|
**When `PROXY_API_KEY` is not set**, authentication is disabled and all requests are accepted.
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
# Start with auth enabled
|
# Start with auth enabled
|
||||||
PROXY_API_KEY=my-secret-token node server.mjs
|
PROXY_API_KEY=my-secret-token node server.mjs
|
||||||
|
|
||||||
# Configure OpenClaw provider with the matching key
|
|
||||||
# In openclaw.json, set apiKey under the claude-local provider:
|
|
||||||
"claude-local": {
|
|
||||||
"baseUrl": "http://127.0.0.1:3456/v1",
|
|
||||||
"api": "openai-completions",
|
|
||||||
"apiKey": "my-secret-token",
|
|
||||||
...
|
|
||||||
}
|
|
||||||
```
|
```
|
||||||
|
|
||||||
The proxy logs auth status on startup: `Auth: enabled (PROXY_API_KEY set)` or `Auth: disabled (no PROXY_API_KEY)`.
|
## Architecture: v1 vs v2
|
||||||
|
|
||||||
## Environment Variables
|
| | 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 |
|
||||||
|
|
||||||
| Variable | Default | Description |
|
## Coexistence with Claude Code
|
||||||
|----------|---------|-------------|
|
|
||||||
| `CLAUDE_PROXY_PORT` | `3456` | Listen port |
|
|
||||||
| `CLAUDE_BIN` | `claude` | Path to claude binary |
|
|
||||||
| `CLAUDE_TIMEOUT` | `120000` | Request timeout (ms) |
|
|
||||||
| `PROXY_API_KEY` | *(unset)* | Bearer token for API authentication; when unset, auth is disabled |
|
|
||||||
|
|
||||||
## API Endpoints
|
OCP and Claude Code interactive mode (including Telegram bots) are completely independent:
|
||||||
|
|
||||||
- `GET /v1/models` — List available models
|
| | OCP (this proxy) | CC interactive |
|
||||||
- `POST /v1/chat/completions` — Chat completion (streaming + non-streaming)
|
|---|---|---|
|
||||||
- `GET /health` — Health check
|
| 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 |
|
||||||
|
|
||||||
## Server / Advanced: Docker
|
Both can run on the same machine simultaneously. No shared state, no port conflicts.
|
||||||
|
|
||||||
For server deployments or if you prefer Docker:
|
|
||||||
|
|
||||||
```bash
|
|
||||||
git clone https://github.com/dtzp555-max/openclaw-claude-proxy.git
|
|
||||||
cd openclaw-claude-proxy
|
|
||||||
cp .env.example .env # add your CLAUDE_SESSION_TOKEN / CLAUDE_COOKIES
|
|
||||||
docker compose up -d
|
|
||||||
```
|
|
||||||
|
|
||||||
Or as a single command if you already have a `.env` ready:
|
|
||||||
|
|
||||||
```bash
|
|
||||||
git clone https://github.com/dtzp555-max/openclaw-claude-proxy.git && cd openclaw-claude-proxy && docker compose up -d
|
|
||||||
```
|
|
||||||
|
|
||||||
Health check: `curl http://localhost:3456/health`
|
|
||||||
|
|
||||||
## Recovery after OpenClaw upgrade
|
## Recovery after OpenClaw upgrade
|
||||||
|
|
||||||
OpenClaw upgrades (`npm update -g openclaw`) **do not overwrite** the user config at `~/.openclaw/openclaw.json`. However, if the claude-local models stop working after an upgrade, follow these steps:
|
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:
|
||||||
|
|
||||||
### Quick diagnosis
|
|
||||||
|
|
||||||
```bash
|
|
||||||
# 1. Check if proxy is running
|
|
||||||
curl http://127.0.0.1:3456/health
|
|
||||||
# Expected: {"status":"ok"}
|
|
||||||
|
|
||||||
# 2. Verify Claude CLI works
|
|
||||||
claude -p "hello" --model sonnet --output-format text
|
|
||||||
# Expected: text response
|
|
||||||
|
|
||||||
# 3. Verify OpenClaw config
|
|
||||||
cat ~/.openclaw/openclaw.json | grep -A3 claude-local
|
|
||||||
```
|
|
||||||
|
|
||||||
### Common issues and fixes
|
|
||||||
|
|
||||||
| Symptom | Cause | Fix |
|
|
||||||
|---------|-------|-----|
|
|
||||||
| Agent doesn't reply, no proxy logs | Gateway didn't load claude-local provider | Check `models.providers.claude-local` in `openclaw.json` |
|
|
||||||
| Proxy reports `exit 1` | Claude CLI not logged in or token expired | Run `claude login` to re-authenticate |
|
|
||||||
| `🔑 unknown` in `/status` | Normal — no API key, using OAuth | Does not affect functionality, safe to ignore |
|
|
||||||
| `/status` shows Context 0% | Messages not reaching proxy (SSE format issue) | Ensure proxy is latest version with streaming support |
|
|
||||||
| Gateway reports `invalid api type` | OpenClaw renamed API type in new version | Check `api` field is still valid (e.g., `openai-completions`) |
|
|
||||||
| Proxy startup `EADDRINUSE` | Port 3456 already in use | `lsof -i :3456` to find and kill the old process |
|
|
||||||
|
|
||||||
### One-command recovery
|
### One-command recovery
|
||||||
|
|
||||||
@@ -224,19 +284,12 @@ node setup.mjs # reconfigure OpenClaw + start proxy
|
|||||||
openclaw gateway restart
|
openclaw gateway restart
|
||||||
```
|
```
|
||||||
|
|
||||||
### Pre-upgrade backup (recommended)
|
|
||||||
|
|
||||||
```bash
|
|
||||||
cp ~/.openclaw/openclaw.json ~/.openclaw/openclaw.json.bak
|
|
||||||
```
|
|
||||||
|
|
||||||
## Notes
|
## Notes
|
||||||
|
|
||||||
- Cost shows as $0 because billing goes through your Claude subscription
|
- Cost shows as $0 because billing goes through your Claude subscription
|
||||||
- The `🔑` field in `/status` shows the configured auth key (or `unknown` if auth is disabled) — this is normal
|
- Each request spawns a `claude -p` process; concurrent requests are capped by `CLAUDE_MAX_CONCURRENT`
|
||||||
- Each request spawns a `claude -p` process; concurrent requests are supported
|
|
||||||
- The proxy must run on the same machine as the Claude CLI (uses local OAuth)
|
- The proxy must run on the same machine as the Claude CLI (uses local OAuth)
|
||||||
- The same Claude account can be used on multiple machines (shared usage quota)
|
- Session data is stored by Claude CLI on disk; session map is in-memory (lost on proxy restart)
|
||||||
|
|
||||||
## License
|
## License
|
||||||
|
|
||||||
|
|||||||
@@ -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.
|
||||||
@@ -0,0 +1,28 @@
|
|||||||
|
{
|
||||||
|
"name": "openclaw-claude-proxy",
|
||||||
|
"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"
|
||||||
|
},
|
||||||
|
"scripts": {
|
||||||
|
"start": "node server.mjs",
|
||||||
|
"setup": "node setup.mjs"
|
||||||
|
},
|
||||||
|
"keywords": [
|
||||||
|
"openclaw",
|
||||||
|
"claude",
|
||||||
|
"proxy",
|
||||||
|
"openai",
|
||||||
|
"anthropic"
|
||||||
|
],
|
||||||
|
"license": "MIT",
|
||||||
|
"engines": {
|
||||||
|
"node": ">=18"
|
||||||
|
},
|
||||||
|
"repository": {
|
||||||
|
"type": "git",
|
||||||
|
"url": "https://github.com/dtzp555-max/openclaw-claude-proxy"
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,643 @@
|
|||||||
|
#!/usr/bin/env node
|
||||||
|
/**
|
||||||
|
* 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.
|
||||||
|
*
|
||||||
|
* 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: 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";
|
||||||
|
import { randomUUID } from "node:crypto";
|
||||||
|
import { readFileSync, accessSync, constants } from "node:fs";
|
||||||
|
import { fileURLToPath } from "node:url";
|
||||||
|
import { dirname, join } from "node:path";
|
||||||
|
|
||||||
|
const __dirname = dirname(fileURLToPath(import.meta.url));
|
||||||
|
const _pkg = JSON.parse(readFileSync(join(__dirname, "package.json"), "utf8"));
|
||||||
|
|
||||||
|
// ── Resolve claude binary ───────────────────────────────────────────────
|
||||||
|
// Priority: CLAUDE_BIN env > well-known paths > which lookup
|
||||||
|
// Fail-fast if not found — never start with an unresolvable binary.
|
||||||
|
function resolveClaude() {
|
||||||
|
if (process.env.CLAUDE_BIN) {
|
||||||
|
try {
|
||||||
|
accessSync(process.env.CLAUDE_BIN, constants.X_OK);
|
||||||
|
return process.env.CLAUDE_BIN;
|
||||||
|
} catch {
|
||||||
|
console.error(`FATAL: CLAUDE_BIN="${process.env.CLAUDE_BIN}" is set but not executable.`);
|
||||||
|
process.exit(1);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const candidates = [
|
||||||
|
"/opt/homebrew/bin/claude",
|
||||||
|
"/usr/local/bin/claude",
|
||||||
|
"/usr/bin/claude",
|
||||||
|
join(process.env.HOME || "", ".local/bin/claude"),
|
||||||
|
];
|
||||||
|
for (const p of candidates) {
|
||||||
|
try { accessSync(p, constants.X_OK); console.warn(`[init] CLAUDE_BIN not set, resolved to ${p}`); return p; } catch {}
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const resolved = execFileSync("which", ["claude"], { encoding: "utf8", timeout: 5000 }).trim();
|
||||||
|
if (resolved) { console.warn(`[init] CLAUDE_BIN not set, resolved via which: ${resolved}`); return resolved; }
|
||||||
|
} catch {}
|
||||||
|
|
||||||
|
console.error(
|
||||||
|
"FATAL: claude binary not found.\n" +
|
||||||
|
" Set CLAUDE_BIN=/path/to/claude or ensure claude is in PATH.\n" +
|
||||||
|
" Checked: " + candidates.join(", ")
|
||||||
|
);
|
||||||
|
process.exit(1);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Configuration ───────────────────────────────────────────────────────
|
||||||
|
const PORT = parseInt(process.env.CLAUDE_PROXY_PORT || "3456", 10);
|
||||||
|
const CLAUDE = resolveClaude();
|
||||||
|
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();
|
||||||
|
|
||||||
|
// ── 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 = {
|
||||||
|
"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",
|
||||||
|
"claude-opus-4": "claude-opus-4-6",
|
||||||
|
"claude-haiku-4": "claude-haiku-4-5-20251001",
|
||||||
|
"claude-haiku-4-5": "claude-haiku-4-5-20251001",
|
||||||
|
"opus": "claude-opus-4-6",
|
||||||
|
"sonnet": "claude-sonnet-4-6",
|
||||||
|
"haiku": "claude-haiku-4-5-20251001",
|
||||||
|
};
|
||||||
|
|
||||||
|
const MODELS = [
|
||||||
|
{ id: "claude-opus-4-6", name: "Claude Opus 4.6" },
|
||||||
|
{ id: "claude-sonnet-4-6", name: "Claude Sonnet 4.6" },
|
||||||
|
{ id: "claude-haiku-4-5-20251001", name: "Claude Haiku 4.5" },
|
||||||
|
];
|
||||||
|
|
||||||
|
// ── 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 }
|
||||||
|
|
||||||
|
setInterval(() => {
|
||||||
|
const now = Date.now();
|
||||||
|
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}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check auth on start and every 10 minutes
|
||||||
|
checkAuth();
|
||||||
|
setInterval(checkAuth, 600000);
|
||||||
|
|
||||||
|
// ── Build CLI arguments ─────────────────────────────────────────────────
|
||||||
|
function buildCliArgs(cliModel, sessionInfo) {
|
||||||
|
const args = ["-p", "--model", cliModel, "--output-format", "text"];
|
||||||
|
|
||||||
|
// 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");
|
||||||
|
}
|
||||||
|
|
||||||
|
// Permissions
|
||||||
|
if (SKIP_PERMISSIONS) {
|
||||||
|
args.push("--dangerously-skip-permissions");
|
||||||
|
} else if (ALLOWED_TOOLS.length > 0) {
|
||||||
|
args.push("--allowedTools", ...ALLOWED_TOOLS);
|
||||||
|
}
|
||||||
|
|
||||||
|
// System prompt
|
||||||
|
if (SYSTEM_PROMPT) {
|
||||||
|
args.push("--append-system-prompt", SYSTEM_PROMPT);
|
||||||
|
}
|
||||||
|
|
||||||
|
// MCP config
|
||||||
|
if (MCP_CONFIG) {
|
||||||
|
args.push("--mcp-config", MCP_CONFIG);
|
||||||
|
}
|
||||||
|
|
||||||
|
return args;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── 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");
|
||||||
|
}
|
||||||
|
|
||||||
|
// 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 ─────────────────────────────────────────────────────
|
||||||
|
// 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) => {
|
||||||
|
if (stats.activeRequests >= MAX_CONCURRENT) {
|
||||||
|
return reject(new Error(`concurrency limit reached (${stats.activeRequests}/${MAX_CONCURRENT})`));
|
||||||
|
}
|
||||||
|
|
||||||
|
const cliModel = MODEL_MAP[model] || model;
|
||||||
|
|
||||||
|
// Circuit breaker check: fail fast if model is in open state
|
||||||
|
const breaker = getBreakerState(cliModel);
|
||||||
|
if (breaker.state === "open") {
|
||||||
|
const remainingMs = BREAKER_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;
|
||||||
|
|
||||||
|
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 {
|
||||||
|
resolve(result);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
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));
|
||||||
|
}
|
||||||
|
|
||||||
|
function sendSSE(res, data) {
|
||||||
|
res.write(`data: ${JSON.stringify(data)}\n\n`);
|
||||||
|
}
|
||||||
|
|
||||||
|
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);
|
||||||
|
sendSSE(res, {
|
||||||
|
id, object: "chat.completion.chunk", created, model,
|
||||||
|
choices: [{ index: 0, delta: { role: "assistant" }, finish_reason: null }],
|
||||||
|
});
|
||||||
|
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 }],
|
||||||
|
});
|
||||||
|
}
|
||||||
|
sendSSE(res, {
|
||||||
|
id, object: "chat.completion.chunk", created, model,
|
||||||
|
choices: [{ index: 0, delta: {}, finish_reason: "stop" }],
|
||||||
|
});
|
||||||
|
res.write("data: [DONE]\n\n");
|
||||||
|
res.end();
|
||||||
|
}
|
||||||
|
|
||||||
|
function completionResponse(res, id, model, content) {
|
||||||
|
jsonResponse(res, 200, {
|
||||||
|
id, object: "chat.completion",
|
||||||
|
created: Math.floor(Date.now() / 1000),
|
||||||
|
model,
|
||||||
|
choices: [{ index: 0, message: { role: "assistant", content }, finish_reason: "stop" }],
|
||||||
|
usage: { prompt_tokens: 0, completion_tokens: 0, total_tokens: 0 },
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Handle chat completions ─────────────────────────────────────────────
|
||||||
|
async function handleChatCompletions(req, res) {
|
||||||
|
let body = "";
|
||||||
|
for await (const chunk of req) body += chunk;
|
||||||
|
|
||||||
|
let parsed;
|
||||||
|
try { parsed = JSON.parse(body); } catch { return jsonResponse(res, 400, { error: "Invalid JSON" }); }
|
||||||
|
|
||||||
|
const messages = parsed.messages || parsed.input || [{ role: "user", content: parsed.prompt || "" }];
|
||||||
|
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, conversationId);
|
||||||
|
const id = `chatcmpl-${randomUUID()}`;
|
||||||
|
|
||||||
|
if (stream) {
|
||||||
|
streamResponse(res, id, model, content);
|
||||||
|
} else {
|
||||||
|
completionResponse(res, id, model, content);
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
console.error(`[proxy] error: ${err.message}`);
|
||||||
|
if (res.headersSent || res.writableEnded || res.destroyed) {
|
||||||
|
try { res.end(); } catch {}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
jsonResponse(res, 500, { error: { message: err.message, type: "proxy_error" } });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── HTTP server ─────────────────────────────────────────────────────────
|
||||||
|
const server = createServer(async (req, res) => {
|
||||||
|
res.setHeader("Access-Control-Allow-Origin", "*");
|
||||||
|
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)
|
||||||
|
if (PROXY_API_KEY && req.url !== "/health") {
|
||||||
|
const auth = req.headers["authorization"] || "";
|
||||||
|
const token = auth.startsWith("Bearer ") ? auth.slice(7) : "";
|
||||||
|
if (token !== PROXY_API_KEY) {
|
||||||
|
return jsonResponse(res, 401, { error: { message: "Unauthorized: invalid or missing Bearer token", type: "auth_error" } });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// GET /v1/models
|
||||||
|
if (req.url === "/v1/models" && req.method === "GET") {
|
||||||
|
return jsonResponse(res, 200, {
|
||||||
|
object: "list",
|
||||||
|
data: MODELS.map((m) => ({
|
||||||
|
id: m.id, object: "model", owned_by: "anthropic",
|
||||||
|
created: Math.floor(Date.now() / 1000),
|
||||||
|
})),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// POST /v1/chat/completions
|
||||||
|
if (req.url === "/v1/chat/completions" && req.method === "POST") {
|
||||||
|
return handleChatCompletions(req, res);
|
||||||
|
}
|
||||||
|
|
||||||
|
// GET /health — comprehensive diagnostics
|
||||||
|
if (req.url === "/health") {
|
||||||
|
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 && 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`,
|
||||||
|
claudeBinary: CLAUDE,
|
||||||
|
claudeBinaryOk: binaryOk,
|
||||||
|
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),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// 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, GET|DELETE /sessions" });
|
||||||
|
});
|
||||||
|
|
||||||
|
// ── 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 (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.`);
|
||||||
|
});
|
||||||
+9
-3
@@ -1,7 +1,7 @@
|
|||||||
{
|
{
|
||||||
"name": "openclaw-claude-proxy",
|
"name": "openclaw-claude-proxy",
|
||||||
"version": "1.7.0",
|
"version": "2.5.0",
|
||||||
"description": "OpenAI-compatible proxy that routes requests through Claude CLI — use your Claude Pro/Max subscription as an OpenClaw model provider",
|
"description": "OpenAI-compatible proxy for Claude CLI v2 — sliding-window circuit breaker, adaptive first-byte timeout, structured logging",
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"bin": {
|
"bin": {
|
||||||
"openclaw-claude-proxy": "./server.mjs"
|
"openclaw-claude-proxy": "./server.mjs"
|
||||||
@@ -10,7 +10,13 @@
|
|||||||
"start": "node server.mjs",
|
"start": "node server.mjs",
|
||||||
"setup": "node setup.mjs"
|
"setup": "node setup.mjs"
|
||||||
},
|
},
|
||||||
"keywords": ["openclaw", "claude", "proxy", "openai", "anthropic"],
|
"keywords": [
|
||||||
|
"openclaw",
|
||||||
|
"claude",
|
||||||
|
"proxy",
|
||||||
|
"openai",
|
||||||
|
"anthropic"
|
||||||
|
],
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"engines": {
|
"engines": {
|
||||||
"node": ">=18"
|
"node": ">=18"
|
||||||
|
|||||||
+762
-308
File diff suppressed because it is too large
Load Diff
@@ -119,7 +119,7 @@ try {
|
|||||||
const out = execSync('claude -p --output-format text --no-session-persistence -- "ping"', {
|
const out = execSync('claude -p --output-format text --no-session-persistence -- "ping"', {
|
||||||
encoding: "utf-8",
|
encoding: "utf-8",
|
||||||
timeout: 30000,
|
timeout: 30000,
|
||||||
env: { ...process.env, CLAUDECODE: undefined },
|
env: { ...process.env, CLAUDECODE: undefined, ANTHROPIC_API_KEY: undefined, ANTHROPIC_BASE_URL: undefined, ANTHROPIC_AUTH_TOKEN: undefined },
|
||||||
}).trim();
|
}).trim();
|
||||||
if (out.length > 0) {
|
if (out.length > 0) {
|
||||||
log(`Claude CLI authenticated (test response: "${out.slice(0, 40)}...")`);
|
log(`Claude CLI authenticated (test response: "${out.slice(0, 40)}...")`);
|
||||||
|
|||||||
@@ -1,11 +1,12 @@
|
|||||||
#!/bin/bash
|
#!/bin/bash
|
||||||
# Start claude-proxy if not already running
|
# Start openclaw-claude-proxy if not already running
|
||||||
if ! lsof -i :3456 -sTCP:LISTEN &>/dev/null; then
|
PORT=${CLAUDE_PROXY_PORT:-3456}
|
||||||
|
if ! lsof -i :$PORT -sTCP:LISTEN &>/dev/null; then
|
||||||
unset CLAUDECODE
|
unset CLAUDECODE
|
||||||
nohup /opt/homebrew/bin/node /Users/taodeng/.openclaw/projects/claude-proxy/server.mjs \
|
nohup node "/Users/taodeng/.openclaw/projects/claude-proxy/openclaw-claude-proxy/server.mjs" \
|
||||||
>> ~/.openclaw/logs/claude-proxy.log \
|
>> "/Users/taodeng/.openclaw/logs/claude-proxy.log" \
|
||||||
2>> ~/.openclaw/logs/claude-proxy.err.log &
|
2>> "/Users/taodeng/.openclaw/logs/claude-proxy.err.log" &
|
||||||
echo "claude-proxy started (pid $!)"
|
echo "claude-proxy started on port $PORT (pid $!)"
|
||||||
else
|
else
|
||||||
echo "claude-proxy already running"
|
echo "claude-proxy already running on port $PORT"
|
||||||
fi
|
fi
|
||||||
|
|||||||
Reference in New Issue
Block a user