mirror of
https://github.com/dtzp555-max/ocp.git
synced 2026-07-21 21:15:09 +00:00
Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
b6b33d2623 | ||
|
|
8796a5fc19 | ||
|
|
609ceb28b0 | ||
|
|
3843ec8fe6 | ||
|
|
afe0c6e1be | ||
|
|
a6007393a3 | ||
|
|
8592150f7a | ||
|
|
eb76971ffc | ||
|
|
d54e73ef89 | ||
|
|
3f10b459f5 |
@@ -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,6 +1,6 @@
|
||||
# OCP — Open Claude Proxy
|
||||
|
||||
> **Status: Stable (v3.0.0)** — Feature-complete. Bug fixes only.
|
||||
> **Status: Stable (v3.3.1)** — Feature-complete. Bug fixes only.
|
||||
|
||||
> **Already paying for Claude Pro/Max? Use your subscription as an OpenAI-compatible API — $0 extra cost.**
|
||||
|
||||
@@ -95,7 +95,8 @@ ocp sessions Active sessions
|
||||
ocp clear Clear all sessions
|
||||
ocp restart Restart proxy
|
||||
ocp restart gateway Restart gateway
|
||||
ocp version Show version
|
||||
ocp update Update to latest version
|
||||
ocp update --check Check for updates without applying
|
||||
ocp --help Command reference
|
||||
```
|
||||
|
||||
@@ -111,6 +112,16 @@ 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)
|
||||
|
||||
```
|
||||
@@ -157,6 +168,7 @@ OCP was originally built for [OpenClaw](https://github.com/openclaw/openclaw) an
|
||||
- **`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
|
||||
|
||||
@@ -176,14 +188,61 @@ Add to `~/.openclaw/openclaw.json`:
|
||||
|
||||
Restart: `openclaw gateway restart`
|
||||
|
||||
## Troubleshooting
|
||||
|
||||
### Requests fail with exit 143 / SIGTERM after ~60 seconds
|
||||
|
||||
**Symptom:** Claude returns errors or stops responding after about 60 seconds, especially during tool use (Bash, Read, etc.).
|
||||
|
||||
**Cause:** OpenClaw's gateway has a default `idleTimeoutSeconds` of 60 seconds. When Claude calls tools, the token stream pauses while the tool executes — if that takes longer than 60s, the gateway kills the connection.
|
||||
|
||||
**Fix:** `setup.mjs` (v3.2.1+) sets this automatically. If you installed an older version, add this to `~/.openclaw/openclaw.json`:
|
||||
|
||||
```json
|
||||
{
|
||||
"agents": {
|
||||
"defaults": {
|
||||
"llm": {
|
||||
"idleTimeoutSeconds": 0
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
Then restart: `openclaw gateway restart`
|
||||
|
||||
### Agents stuck in "typing" but never respond
|
||||
|
||||
Usually caused by stuck sessions from previous timeout errors. Fix:
|
||||
|
||||
```bash
|
||||
# Clear all sessions
|
||||
ocp clear
|
||||
|
||||
# Restart both services
|
||||
ocp restart
|
||||
openclaw gateway restart
|
||||
```
|
||||
|
||||
If that doesn't help, manually clear the session store:
|
||||
```bash
|
||||
# Find and reset stuck Telegram sessions
|
||||
cat ~/.openclaw/agents/main/sessions/sessions.json
|
||||
# Remove entries with "telegram" channel, then restart gateway
|
||||
```
|
||||
|
||||
## Upgrading from v3.0.x
|
||||
|
||||
If you installed OCP before v3.1.0, the auto-start service used names that OpenClaw's gateway detected as conflicting (`ai.openclaw.proxy` on macOS, `openclaw-proxy` on Linux). Running `node setup.mjs` or `ocp update` will automatically migrate to the new neutral names.
|
||||
|
||||
## 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) |
|
||||
| `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) |
|
||||
@@ -198,20 +257,6 @@ Restart: `openclaw gateway restart`
|
||||
- **No API keys needed** — authentication goes through Claude CLI's OAuth session
|
||||
- **Auto-start** — launchd (macOS) / systemd (Linux)
|
||||
|
||||
## Known Issues
|
||||
|
||||
### `/ocp` command returns "Unknown skill: ocp" (OpenClaw only)
|
||||
|
||||
The `/ocp` plugin command may intermittently stop working in Telegram/Discord. This is caused by an OpenClaw gateway bug ([openclaw/openclaw#26895](https://github.com/openclaw/openclaw/issues/26895)).
|
||||
|
||||
**Workaround:**
|
||||
```
|
||||
/new
|
||||
/ocp restart
|
||||
```
|
||||
|
||||
**Important:** Do not add `ocp` to agent `skills` lists or place a `SKILL.md` in workspace skills — this creates a routing conflict.
|
||||
|
||||
## License
|
||||
|
||||
MIT
|
||||
|
||||
@@ -231,12 +231,27 @@ cmd_restart() {
|
||||
openclaw gateway restart 2>&1
|
||||
else
|
||||
echo "Restarting proxy..."
|
||||
launchctl kickstart -k gui/501/ai.openclaw.proxy 2>/dev/null || {
|
||||
echo "launchctl failed, trying kill + restart..."
|
||||
pkill -f "claude-proxy/server.mjs" 2>/dev/null || true
|
||||
# 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
|
||||
cd "$HOME/.openclaw/projects/claude-proxy" && nohup node server.mjs >> "$HOME/.openclaw/logs/proxy.log" 2>&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."
|
||||
@@ -323,6 +338,102 @@ for e in d.get('errors', []):
|
||||
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'
|
||||
@@ -341,6 +452,8 @@ Commands:
|
||||
clear Clear all sessions
|
||||
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
|
||||
@@ -380,5 +493,6 @@ case "$subcmd" in
|
||||
sessions) cmd_sessions ;;
|
||||
clear) cmd_clear ;;
|
||||
restart) cmd_restart "${1:-}" ;;
|
||||
update) cmd_update "${1:-}" ;;
|
||||
*) echo "Unknown command: $subcmd"; echo ""; cmd_help; exit 1 ;;
|
||||
esac
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
"id": "ocp",
|
||||
"name": "OCP Commands",
|
||||
"description": "Slash commands for the OpenClaw Proxy — /ocp usage, /ocp settings, /ocp health, etc.",
|
||||
"version": "3.1.0",
|
||||
"version": "3.3.1",
|
||||
"configSchema": {
|
||||
"type": "object",
|
||||
"additionalProperties": false,
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "ocp",
|
||||
"version": "3.1.0",
|
||||
"version": "3.3.1",
|
||||
"description": "Slash commands for the OpenClaw Proxy",
|
||||
"main": "index.js",
|
||||
"type": "module",
|
||||
|
||||
+1
-1
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "openclaw-claude-proxy",
|
||||
"version": "3.1.0",
|
||||
"version": "3.3.1",
|
||||
"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": {
|
||||
|
||||
+15
-74
@@ -1,30 +1,19 @@
|
||||
#!/usr/bin/env node
|
||||
/**
|
||||
* openclaw-claude-proxy v2.5.0 — OpenAI-compatible proxy for Claude CLI
|
||||
* openclaw-claude-proxy — 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.5.0:
|
||||
* - Sliding-window circuit breaker: uses time-windowed failure rate instead of
|
||||
* consecutive-count, preventing multi-agent burst scenarios from tripping the
|
||||
* breaker too aggressively. Half-open state allows configurable probe requests.
|
||||
* - Graduated backoff: cooldown doubles on each re-open (capped at 5min),
|
||||
* resets fully on success.
|
||||
* - Health endpoint now exposes per-model breaker state and sliding window stats.
|
||||
* - Increased default timeout tiers for Opus/Sonnet to handle large agent prompts.
|
||||
*
|
||||
* 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
|
||||
* Timeout design: single CLAUDE_TIMEOUT (default 600s / 10 min).
|
||||
* No separate first-byte or idle timeout — Claude tool-use causes long pauses
|
||||
* in the token stream (30s-5min) that make fine-grained timeouts unreliable.
|
||||
* This matches LiteLLM, OpenAI SDK, and other major LLM proxies.
|
||||
*
|
||||
* 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: 300000)
|
||||
* CLAUDE_FIRST_BYTE_TIMEOUT — base first-byte timeout in ms (default: 90000)
|
||||
* CLAUDE_TIMEOUT — per-request timeout in ms (default: 600000)
|
||||
* 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
|
||||
@@ -89,8 +78,7 @@ function resolveClaude() {
|
||||
// Settings marked with `let` can be changed at runtime via PATCH /settings.
|
||||
const PORT = parseInt(process.env.CLAUDE_PROXY_PORT || "3456", 10);
|
||||
const CLAUDE = resolveClaude();
|
||||
let TIMEOUT = parseInt(process.env.CLAUDE_TIMEOUT || "300000", 10);
|
||||
let BASE_FIRST_BYTE_TIMEOUT = parseInt(process.env.CLAUDE_FIRST_BYTE_TIMEOUT || "90000", 10);
|
||||
let TIMEOUT = parseInt(process.env.CLAUDE_TIMEOUT || "600000", 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 ||
|
||||
@@ -358,26 +346,13 @@ function messagesToPrompt(messages) {
|
||||
return result;
|
||||
}
|
||||
|
||||
// Model tier multipliers for first-byte timeout.
|
||||
// Opus is much slower to produce first token, especially with large contexts.
|
||||
let MODEL_TIMEOUT_TIERS = {
|
||||
"opus": { base: 150000, perPromptChar: 0.00050 }, // 150s base + ~50s per 100k chars
|
||||
"sonnet": { base: 120000, perPromptChar: 0.00050 }, // 120s base + ~50s per 100k chars
|
||||
"haiku": { base: 45000, perPromptChar: 0.00010 }, // 45s base + ~10s per 100k chars
|
||||
};
|
||||
|
||||
// Model tier — used for logging only (no timeout logic).
|
||||
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));
|
||||
}
|
||||
|
||||
// ── Spawn claude CLI (shared setup) ─────────────────────────────────────
|
||||
// Resolves session logic, builds CLI args, spawns the process, and sets up
|
||||
// timeouts. Returns context object or throws synchronously.
|
||||
@@ -437,7 +412,6 @@ function spawnClaudeProcess(model, messages, conversationId) {
|
||||
activeProcesses.add(proc);
|
||||
|
||||
const t0 = Date.now();
|
||||
const firstByteTimeoutMs = computeFirstByteTimeout(cliModel, prompt.length);
|
||||
let gotFirstByte = false;
|
||||
let cleaned = false;
|
||||
|
||||
@@ -445,7 +419,6 @@ function spawnClaudeProcess(model, messages, conversationId) {
|
||||
if (cleaned) return;
|
||||
cleaned = true;
|
||||
clearTimeout(overallTimer);
|
||||
clearTimeout(firstByteTimer);
|
||||
stats.activeRequests--;
|
||||
}
|
||||
|
||||
@@ -459,7 +432,6 @@ function spawnClaudeProcess(model, messages, conversationId) {
|
||||
function markFirstByte() {
|
||||
if (!gotFirstByte) {
|
||||
gotFirstByte = true;
|
||||
clearTimeout(firstByteTimer);
|
||||
console.log(`[claude] first-byte model=${cliModel} elapsed=${Date.now() - t0}ms`);
|
||||
}
|
||||
}
|
||||
@@ -469,27 +441,17 @@ function spawnClaudeProcess(model, messages, conversationId) {
|
||||
proc.stdin.end();
|
||||
|
||||
recordModelRequest(cliModel, prompt.length);
|
||||
logEvent("info", "claude_spawned", { model: cliModel, promptChars: prompt.length, firstByteTimeout: firstByteTimeoutMs, tier: getModelTier(cliModel), session: conversationId ? conversationId.slice(0, 12) + "..." : "none" });
|
||||
logEvent("info", "claude_spawned", { model: cliModel, promptChars: prompt.length, timeout: TIMEOUT, tier: getModelTier(cliModel), session: conversationId ? conversationId.slice(0, 12) + "..." : "none" });
|
||||
|
||||
// First-byte timeout
|
||||
const firstByteTimer = setTimeout(() => {
|
||||
if (!gotFirstByte && !cleaned) {
|
||||
stats.timeouts++;
|
||||
recordModelError(cliModel, true);
|
||||
breakerRecordTimeout(cliModel);
|
||||
logEvent("error", "first_byte_timeout", { model: cliModel, timeoutMs: firstByteTimeoutMs, promptChars: prompt.length });
|
||||
try { proc.kill("SIGTERM"); } catch {}
|
||||
setTimeout(() => { try { proc.kill("SIGKILL"); } catch {} }, 5000);
|
||||
}
|
||||
}, firstByteTimeoutMs);
|
||||
|
||||
// Overall request timeout
|
||||
// Single request timeout — no separate first-byte timer.
|
||||
// Claude tool-use causes long pauses in the token stream (30s-5min),
|
||||
// making first-byte/idle timeouts unreliable. One generous timeout is simpler and correct.
|
||||
const overallTimer = setTimeout(() => {
|
||||
if (!cleaned) {
|
||||
stats.timeouts++;
|
||||
recordModelError(cliModel, true);
|
||||
breakerRecordTimeout(cliModel);
|
||||
logEvent("error", "request_timeout", { model: cliModel, timeoutMs: TIMEOUT });
|
||||
logEvent("error", "request_timeout", { model: cliModel, timeoutMs: TIMEOUT, elapsed: Date.now() - t0 });
|
||||
try { proc.kill("SIGTERM"); } catch {}
|
||||
setTimeout(() => { try { proc.kill("SIGKILL"); } catch {} }, 5000);
|
||||
}
|
||||
@@ -929,31 +891,18 @@ async function handleStatus(_req, res) {
|
||||
//
|
||||
// Tunable keys and their types/ranges:
|
||||
const SETTINGS_SCHEMA = {
|
||||
timeout: { type: "number", min: 30000, max: 600000, unit: "ms", desc: "Overall request timeout" },
|
||||
firstByteTimeout: { type: "number", min: 15000, max: 300000, unit: "ms", desc: "Base first-byte timeout" },
|
||||
timeout: { type: "number", min: 30000, max: 1800000, unit: "ms", desc: "Request timeout (default: 600s)" },
|
||||
maxConcurrent: { type: "number", min: 1, max: 32, unit: "", desc: "Max concurrent claude processes" },
|
||||
sessionTTL: { type: "number", min: 60000, max: 86400000, unit: "ms", desc: "Session idle expiry" },
|
||||
maxPromptChars: { type: "number", min: 10000, max: 1000000, unit: "chars", desc: "Prompt truncation limit" },
|
||||
"tiers.opus.base": { type: "number", min: 30000, max: 600000, unit: "ms", desc: "Opus base first-byte timeout" },
|
||||
"tiers.opus.perChar": { type: "number", min: 0, max: 0.01, unit: "ms/char", desc: "Opus per-char timeout addition" },
|
||||
"tiers.sonnet.base": { type: "number", min: 30000, max: 600000, unit: "ms", desc: "Sonnet base first-byte timeout" },
|
||||
"tiers.sonnet.perChar": { type: "number", min: 0, max: 0.01, unit: "ms/char", desc: "Sonnet per-char timeout addition" },
|
||||
"tiers.haiku.base": { type: "number", min: 15000, max: 300000, unit: "ms", desc: "Haiku base first-byte timeout" },
|
||||
"tiers.haiku.perChar": { type: "number", min: 0, max: 0.01, unit: "ms/char", desc: "Haiku per-char timeout addition" },
|
||||
};
|
||||
|
||||
function getSettings() {
|
||||
return {
|
||||
timeout: { value: TIMEOUT, ...SETTINGS_SCHEMA.timeout },
|
||||
firstByteTimeout: { value: BASE_FIRST_BYTE_TIMEOUT, ...SETTINGS_SCHEMA.firstByteTimeout },
|
||||
maxConcurrent: { value: MAX_CONCURRENT, ...SETTINGS_SCHEMA.maxConcurrent },
|
||||
sessionTTL: { value: SESSION_TTL, ...SETTINGS_SCHEMA.sessionTTL },
|
||||
maxPromptChars: { value: MAX_PROMPT_CHARS, ...SETTINGS_SCHEMA.maxPromptChars },
|
||||
tiers: {
|
||||
opus: { base: MODEL_TIMEOUT_TIERS.opus.base, perPromptChar: MODEL_TIMEOUT_TIERS.opus.perPromptChar },
|
||||
sonnet: { base: MODEL_TIMEOUT_TIERS.sonnet.base, perPromptChar: MODEL_TIMEOUT_TIERS.sonnet.perPromptChar },
|
||||
haiku: { base: MODEL_TIMEOUT_TIERS.haiku.base, perPromptChar: MODEL_TIMEOUT_TIERS.haiku.perPromptChar },
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
@@ -965,16 +914,9 @@ function applySettingUpdate(key, value) {
|
||||
|
||||
switch (key) {
|
||||
case "timeout": TIMEOUT = value; break;
|
||||
case "firstByteTimeout": BASE_FIRST_BYTE_TIMEOUT = value; break;
|
||||
case "maxConcurrent": MAX_CONCURRENT = value; break;
|
||||
case "sessionTTL": SESSION_TTL = value; break;
|
||||
case "maxPromptChars": MAX_PROMPT_CHARS = value; break;
|
||||
case "tiers.opus.base": MODEL_TIMEOUT_TIERS.opus.base = value; break;
|
||||
case "tiers.opus.perChar": MODEL_TIMEOUT_TIERS.opus.perPromptChar = value; break;
|
||||
case "tiers.sonnet.base": MODEL_TIMEOUT_TIERS.sonnet.base = value; break;
|
||||
case "tiers.sonnet.perChar": MODEL_TIMEOUT_TIERS.sonnet.perPromptChar = value; break;
|
||||
case "tiers.haiku.base": MODEL_TIMEOUT_TIERS.haiku.base = value; break;
|
||||
case "tiers.haiku.perChar": MODEL_TIMEOUT_TIERS.haiku.perPromptChar = value; break;
|
||||
default: return `${key}: not implemented`;
|
||||
}
|
||||
logEvent("info", "setting_changed", { key, value });
|
||||
@@ -1136,7 +1078,6 @@ const server = createServer(async (req, res) => {
|
||||
auth: authStatus,
|
||||
config: {
|
||||
timeout: TIMEOUT,
|
||||
firstByteTimeout: BASE_FIRST_BYTE_TIMEOUT,
|
||||
maxConcurrent: MAX_CONCURRENT,
|
||||
sessionTTL: SESSION_TTL,
|
||||
circuitBreaker: "disabled",
|
||||
@@ -1250,7 +1191,7 @@ server.listen(PORT, "127.0.0.1", () => {
|
||||
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(`Timeout: ${TIMEOUT / 1000}s | Max concurrent: ${MAX_CONCURRENT}`);
|
||||
console.log(`Circuit breaker: disabled`);
|
||||
console.log(`Tools: ${SKIP_PERMISSIONS ? "all (skip-permissions)" : ALLOWED_TOOLS.join(", ")}`);
|
||||
console.log(`Sessions: TTL=${SESSION_TTL / 1000}s`);
|
||||
|
||||
@@ -169,6 +169,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,7 +298,16 @@ 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");
|
||||
|
||||
Reference in New Issue
Block a user