Compare commits

...
68 Commits
Author SHA1 Message Date
f3745fa8fe docs(readme): sync to v3.7.0 — anon key, auto-discovery, current model IDs (#17)
Brings README up to date with the four PRs merged on 2026-04-12:

* #13 ocp-connect v1.1.0 — per-agent auth-profiles write
* #14 ocp-connect v1.2.0 — IDE detection rewrite + hint density
* #15 OCP server v3.7.0 — PROXY_ANONYMOUS_KEY allowlist
* #16 ocp-connect v1.3.0 — auto-discover anonymous key from /health

## Changes

1. Banner updated `v3.6.0` to `v3.7.0`.

2. New "Zero-config" paragraph in Client Setup showing
   `./ocp-connect <server-ip>` works without `--key` when the
   server admin has set PROXY_ANONYMOUS_KEY.

3. Client Setup example output replaced to reflect v1.3.0 actual
   behavior: `OCP Connect v1.3.0` banner, `Remote OCP v3.7.0`,
   new `ⓘ Using server-advertised anonymous key` block, new
   `Per-agent auth profile seeded (2)` line with both main and
   macbook_bot agentDir paths, new three-line smoke test caveat,
   and correct `claude-haiku-4-5-20251001` model ID.

4. "The script automatically" list expanded from 3 bullets to 5:
   added auto-discovery note (v1.3.0+/v3.7.0+), added OpenClaw
   per-agent profile note, corrected IDE hint bullet to list
   Cline/Continue.dev/Cursor/opencode explicitly as "manual
   configuration required" rather than the earlier inaccurate
   "configures" wording.

5. Available Models table updated `claude-haiku-4` to
   `claude-haiku-4-5-20251001` to match the actual `/v1/models`
   response.

6. Environment Variables table: new `PROXY_ANONYMOUS_KEY` row
   added after `PROXY_API_KEY`, with a link to the existing
   "Anonymous Access (optional)" section.

## Not changed

* `package.json` already at 3.7.0 from #15.
* ocp-connect script unchanged (v1.3.0 from #16).
* server.mjs unchanged (v3.7.0 from #15).
* No behavioral changes. README text only.

## Test

`git diff --stat main -- README.md` = +24 -7 on one file. Markdown
spot-checked for broken table alignment and link targets. The
anonymous key section anchor `#anonymous-access-optional` resolves
to the existing heading at line 234.

Co-authored-by: taodeng <taodeng@Taos-MBP>
Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-12 21:29:06 +10:00
d71e0455e3 feat(ocp-connect): auto-discover anonymous key from /health (v1.3.0) (#16)
Closes the zero-config loop opened by #15 (server anonymous allowlist).

## Why

After #15 (v3.7.0) the OCP server exposes the admin-chosen anonymous
key via `GET /health.anonymousKey` whenever `PROXY_ANONYMOUS_KEY` is
set. Before this PR, users still had to copy that key manually and
pass it as `--key` every time they ran `ocp-connect`. This commit
closes the last mile: `ocp-connect <host>` now reads the key from
the server and uses it automatically when no `--key` was provided.

Result: `ocp-connect 172.16.2.30` (zero args) on a fresh machine
configures OpenClaw multi-agent correctly against a v3.7.0 OCP
instance that has `PROXY_ANONYMOUS_KEY` set. No admin coordination,
no per-user keys, no manual flags.

## What changes

`OCP_CONNECT_VERSION` 1.2.0 to 1.3.0.

New Step 2.5 between the existing Step 2 (Show remote info) and
Step 3 (Determine if key is needed). The block:

1. Short-circuits if `--key` was passed explicitly, so user intent
   always wins over auto-discovery.
2. Parses `health_json.anonymousKey` via python3 with try/except,
   defensively treating any parse error as "no anon key" so the
   script falls through to the existing Step 3 path.
3. If the server advertised a non-empty anon key, assigns it to the
   internal `key` variable and prints an info banner with the
   truncated key value and a reference to issue #12 section 14
   Path A so users can understand what happened.

All downstream code (key verification via /v1/models, rc file
exports, launchctl setenv, OpenClaw agentDir profile seeding, smoke
test, IDE hints) works unchanged because it reads the same `key`
variable.

Also: defensive check on `--key` to reject explicit empty values
(`--key ""`) with a clear error message. Existing bash `${2:?msg}`
expansion already rejects empty strings, but the new check is an
extra safety net and gives a friendlier error (`omit --key entirely
for anonymous mode`).

## Backward compatibility

Old OCP servers (less than 3.7.0) that don't have the
`anonymousKey` field in `/health`: `python3 d.get("anonymousKey")`
returns None, new block outputs empty string, `[[ -n "" ]]` is
false, block falls through to the existing Step 3 path. Script
behavior is 100% identical to v1.2.0 for these users.

New OCP servers that haven't set `PROXY_ANONYMOUS_KEY`: field is
null, same fall-through path as above.

No user-visible regression in either case. No new CLI flag, no new
prompt, no new error path for existing users.

## Test evidence

Live offline test on macOS 13 against real 172.16.2.30 (OCP v3.7.0
with `PROXY_ANONYMOUS_KEY=ocp_public_anon_v1`):

Step 1: cold install state
  - stopped OpenClaw gateway
  - rm ~/.openclaw/agents/{main,macbook_bot}/agent/auth-profiles.json
  - stripped models.* and agents.defaults.{model,models} from openclaw.json
  - cleaned OCP LAN block from .bashrc
  - unset OPENAI_BASE_URL and OPENAI_API_KEY from launchctl env

Step 2: ran ~/projects/ocp/ocp-connect 172.16.2.30 (no args)

Output:
  OCP Connect v1.3.0
  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 section 14 Path A)
  API accessible (3 models available)
  Per-agent auth profile seeded (2):
    - ~/.openclaw/agents/main/agent/auth-profiles.json
    - ~/.openclaw/agents/macbook_bot/agent/auth-profiles.json
  Smoke test passed: OK
  EXIT 0

Step 3: verified profiles contain the anon key
  macbook_bot: key = ocp_public_anon_v1
  main: key = ocp_public_anon_v1

Additional regression tests:
  - ocp-connect 172.16.2.30 --key ocp_real_admin_key -> still uses
    the explicit key, auto-discovery banner NOT shown
  - ocp-connect --version -> "ocp-connect 1.3.0"
  - ocp-connect 172.16.2.30 --key "" -> rejected with clear error

## Code review

One independent opus reviewer. Verdict PASS WITH CONCERNS, 0
blockers. Reviewer's Concern B (--key "" edge case) turned out to
be a false alarm on my side verification: bash `${2:?}` expansion
already rejects empty strings. I added an extra defensive check
anyway for clearer error message and safety if the arg parser is
refactored in the future. Other concerns (nice-to-have
simplifications like `print(k or '')`) are cosmetic and deferred.

Co-authored-by: taodeng <taodeng@Taos-MBP>
Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-12 21:12:44 +10:00
38070fbabb feat(server): anonymous key allowlist for multi-mode (v3.7.0) (#15)
Implements issue #12 section 14 Path A. Lets OCP admin designate a
single well-known "anonymous" key that bypasses validateKey() while
keeping AUTH_MODE=multi. Gives OpenClaw multi-agent clients (which
MUST send a non-empty Authorization header per their per-agent
auth-profiles schema) a way to connect without every user needing
a personal key.

## Background

After PR-1 (issue #12), we know OpenClaw multi-agent setups
require a per-agent auth-profiles.json with a non-empty `key`
field. OCP multi-mode rejects any non-empty Bearer token that
isn't in the keys database (server.mjs line 1152), which creates
a deadlock: the only way for OpenClaw to use OCP is with a real
admin-issued key.

Path A resolves this by letting the admin opt in to a public
anonymous key that clients can auto-discover via /health. OpenClaw
writes this key into its agent profiles just like a real key; OCP
server short-circuits validateKey when the key matches the
allowlist. No per-user coordination needed, admin still controls
the policy (can rotate, can unset, can rate-limit).

## Changes

### server.mjs

* Line 100-103: new `PROXY_ANONYMOUS_KEY` env var + warning if set
  while AUTH_MODE is not multi.
* Line 1127-1138 (localhost branch): anonymous allowlist short-circuit
  before validateKey so localhost clients using the anon key are
  labeled "anonymous" instead of "local" in stats.
* Line 1153-1163 (multi branch): the same allowlist check between
  the ADMIN_KEY check and validateKey. Uses timingSafeEqual with
  explicit length check (consistent with the admin and shared key
  patterns above).
* Line 1221 (/health response): new `anonymousKey` field, null when
  not set, the actual value when set. Admins opted into public
  access when they set the env var, so exposing the key here is
  intentional and lets ocp-connect auto-discover it without
  out-of-band coordination.

### package.json

Version 3.6.0 to 3.7.0 (per dev iron rule 5, version bump before push).

### README.md

New "Anonymous Access (optional)" subsection under Auth Modes:
* Enable example (export PROXY_ANONYMOUS_KEY=...)
* Client-side /health discovery explanation
* Security note: opt-in to public access, rate-limit warning
* "Not a secret" note: /health is unauthenticated, the key is
  publicly readable by design; treat it as a convenience handle,
  not as an access credential.

## Test evidence

Offline tests on macOS 13, local server on 0.0.0.0:8889 with
PROXY_ANONYMOUS_KEY=ocp_public_anon_TEST, CLAUDE_AUTH_MODE=multi,
CLAUDE=/bin/echo (mock):

* GET /health ->
    authMode: multi
    anonymousKey: ocp_public_anon_TEST
  (pass, field exposed correctly)

* POST /v1/chat/completions with Bearer ocp_WRONG_KEY from
  172.16.2.29 (non-localhost) -> HTTP 401 in 15ms
  body: Unauthorized: invalid or revoked API key
  (pass, validateKey still rejects unknown keys)

* POST /v1/chat/completions with Bearer ocp_public_anon_TEST from
  172.16.2.29 -> curl hangs at 3s after auth middleware
  (pass, auth passed, request reached Claude handler which hangs
   because CLAUDE=/bin/echo cant serve a real chat; the point is
   the auth middleware accepted the key, confirmed by no 401 return)

* POST /v1/chat/completions with no Authorization from 172.16.2.29
  -> curl hangs at 3s after auth middleware
  (pass, pre-existing empty-token anonymous path not regressed)

node --check server.mjs: syntax OK.

## Code review

One independent opus reviewer. Verdict: PASS WITH CONCERNS, zero
blockers. Two strong-recommend items addressed in this commit:

1. Localhost branch was not covered in the first draft. Reviewer
   pointed out that a localhost client using the anon key would be
   labeled "local" instead of "anonymous" in stats, making operations
   visibility worse. Fixed by applying the same anonymous allowlist
   check in the localhost branch at line 1127-1138.

2. README security note was missing the explicit "not a secret"
   framing. Added a paragraph clarifying that because /health is
   unauthenticated, the anonymous key is publicly readable by
   anyone who can reach the server, which is intentional.

Reviewer nits (tokenBuf3 naming, stats subcategory for header-less
vs anon-key anonymous) are deferred as they do not affect
correctness.

## Upstream dependencies on this commit

After this PR is merged and the OCP instance at 172.16.2.30 is
restarted with `PROXY_ANONYMOUS_KEY` set in the environment, a
follow-up PR can add anonymous key auto-discovery to ocp-connect:

* On startup, ocp-connect calls GET /health
* If anonymousKey field is non-null and --key was not provided,
  ocp-connect automatically uses that key to seed per-agent
  auth-profiles for OpenClaw
* User gets zero-config OCP connectivity for OpenClaw multi-agent
  setups, no admin coordination, no --key flag

That follow-up is NOT in this PR. This PR is server-only.

Co-authored-by: taodeng <taodeng@Taos-MBP>
Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-12 19:36:26 +10:00
2adea6368d fix(ocp-connect): rewrite IDE detection + improve hint density (v1.2.0) (#14)
Follow-up to #12 / #13. Fixes the IDE detection failures documented in
issue #12 section 9.3 (IDE detection coverage matrix).

## What was broken

PR-1 fixed the OpenClaw multi-agent auth bug but left section 9 of the
report (IDE detection coverage) unaddressed. Empirical testing showed
the script's "Other IDEs detected" section had a 25% effective hit rate:

- Cline -> grep pattern was `grep -qi cline` but the actual VSCode
  extension ID is `saoudrizwan.claude-dev`. Hit rate 0%.
- Continue.dev -> checked `~/.continue/config.json` but the directory
  is not created until the user opens the Continue panel for the
  first time, AND v1.x uses `config.yaml` not `config.json`. Hit
  rate 0% on a fresh install.
- Cursor -> only checked `command -v cursor` and `~/.cursor`. Both
  fail when the user installs Cursor.app from the official dmg
  without enabling the optional shell command and without launching
  the app. brew cask installs work by side effect (it links the
  binary). Hit rate 100% via brew, 0% via dmg.
- opencode -> not detected at all. opencode (https://opencode.ai) is
  one of the most popular AI CLI tools, missing entirely from the
  hint list.

The hints themselves were also too sparse. Each one was a single
line like `Cursor: Settings -> OpenAI Base URL = ...`, with no API
key, no model IDs, no path to the actual settings page. Users had
to guess.

## What this commit does

### Detection rewrite (Cline / Continue.dev / Cursor / opencode)

Cline: prefer `code --list-extensions`, fall back to ls, match
pattern `cline|saoudrizwan\.claude-dev`. Real-world Cline detection
goes from 0% to 100%.

Continue.dev: three-condition OR -- VSCode extension ID
`continue.continue` OR `~/.continue/config.yaml` OR
`~/.continue/config.json`. Catches all three real-world install
states (extension installed but never opened / config.yaml / legacy
config.json).

Cursor: add `[[ -d "/Applications/Cursor.app" ]]` as a third
fallback alongside `command -v cursor` and `~/.cursor`. Now detects
Cursor regardless of how it was installed.

opencode: NEW detection branch. Checks `command -v opencode`,
`~/.opencode/bin/opencode`, and `~/.local/share/opencode`. Catches
the official curl install path and any future package manager
installs.

### Hint density improvements

Each hint now includes:
- The exact base URL to paste
- The API key (truncated as `${key:0:8}...${key: -4}` if longer
  than 16 chars to avoid screenshot leakage; or "(none -- anonymous
  mode)" message when --key was not provided)
- The list of available model IDs, extracted from the actual
  /v1/models response so the user does not have to guess
- The exact menu path or config file location

Continue.dev hint specifically shows the YAML snippet under the
`models:` top-level key (per reviewer feedback) so the user can
paste it directly without schema confusion.

opencode hint explicitly says "not yet auto-configured by
ocp-connect (PR follow-up)" so users know to use
`opencode providers login openai` until a future PR adds direct
auth.json writing. opencode does NOT read OPENAI_API_KEY env var
(empirically verified) so the existing rc-file export strategy
does not help it.

### NOT in scope (deferred)

- opencode auto-configure (writing auth.json + opencode.json) --
  schema needs more investigation, opencode model whitelist gets
  in the way of custom claude model IDs
- Continue.dev auto-configure -- v1.x config.yaml schema is well
  enough known but parsing/merging YAML in bash without extra
  dependencies is awkward
- Aider, Cody, Zed, etc. detection -- not in this PR scope

These are future work tracked in #12 section 9.6.

### Version bump (per dev iron rule #5)

OCP_CONNECT_VERSION 1.1.0 -> 1.2.0.

## Test evidence

Offline matrix run on macOS 13 with VSCode 1.115.0, opencode 1.4.3,
Cline 3.78.0, Continue.dev 1.2.22, Cursor 3.0.16:

- All 4 IDEs installed -> all 4 hints fire with full detail
- anonymous mode -> hints fire, key display reads
  "(none -- anonymous mode...)" instead of blank
- HOME=tmpdir mock + only Cline mock dir -> Cline + Cursor (system
  app) hints fire, others suppressed
- HOME=tmpdir mock + nothing -> only Cursor (system app) fires
- --version -> "ocp-connect 1.2.0"
- --help -> still shows the PR-1 wording for item 5

Hit rate matrix on the test machine (5/5 IDEs installed):

| IDE          | before PR-2a | after PR-2a |
|--------------|--------------|-------------|
| OpenClaw     | YES (PR-1)   | YES         |
| Cline        | NO           | YES         |
| Continue.dev | NO           | YES         |
| Cursor       | YES (brew)   | YES         |
| opencode     | NO           | YES         |

Effective detection rate: 25% -> ~100%.

## Code review

One independent opus reviewer (spec compliance + bash 3.2 compat +
UX). Verdict: PASS WITH CONCERNS, 0 blockers. Two non-blocking
suggestions both addressed in this commit:

1. Continue.dev YAML hint -- added `models:` top-level key so
   pasted snippet validates against Continue v1.x schema
2. anonymous mode -- key display now shows
   "(none -- anonymous mode; most external IDEs require a non-empty
    API Key)" instead of a blank field

B1 (OpenClaw per-agent auth) section is intentionally untouched.
Verified by diff line count: all changes fall in lines 347-440
(configure_ides function) + lines 9-13 (version constant). No
edits inside the Python heredoc block.

Co-authored-by: taodeng <taodeng@Taos-MBP>
Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-12 17:59:59 +10:00
7860f71943 fix(ocp-connect): seed per-agent auth-profiles for OpenClaw multi-agent + UX hotfixes (v1.1.0) (#13)
* fix(ocp-connect): seed per-agent auth-profiles for OpenClaw multi-agent + UX hotfixes

Fixes #12. ocp-connect bumped to v1.1.0.

## What was broken

OpenClaw's per-agent auth loader reads <agentDir>/auth-profiles.json
(NOT the root openclaw.json's auth.profiles section, NOT env vars).
ocp-connect only wrote the root config, so OpenClaw multi-agent setups
- any agent with an explicit or implicit agentDir, including the
default `main` agent - silently failed with "No API key found for
provider X" on every Telegram/IDE message. The smoke test passed but
the user's actual workflow was broken.

3 unrelated UX papercuts compounded the bad experience:

* Smoke test only verified OCP direct connectivity, never the
  IDE/agent -> OCP path, so the script printed "Smoke test passed"
  while the bot was silently broken.
* `read ... </dev/tty 2>/dev/null || fallback` leaked
  `Device not configured` errors from the parent shell in CI/SSH/no-tty
  environments. The fallback path worked correctly, but the stderr
  noise looked like a bug.
* Help text claimed `Detects and configures IDEs (OpenClaw, Cline,
  Continue.dev, Cursor)` while only OpenClaw is actually configured.

Full investigation, evidence, and reviewer findings in #12.

## What this commit does

### B1: write per-agent auth-profiles.json (CRITICAL)

After writing root openclaw.json, iterate `agents.list[]`. For each
agent: derive agentDir from the explicit `agentDir` field, OR fall
back to `<openclaw_root>/agents/<id>/agent/` for agents that omit it.
This catches the default `main` agent, discovered via end-to-end
Telegram testing. Merge into existing auth-profiles.json (preserving
other providers' keys), upsert `<provider>:default` with the real
key, chmod 0600.

Anonymous mode (no --key) explicitly skips agentDir writes (empty
keys are dropped by OpenClaw's pi-auth-credentials anyway) and
prints a clear warning that OpenClaw multi-agent requires --key.
This is the "Path C" decision from #12 section 14.

Error handling is split for safety:
* `JSONDecodeError` on existing file -> back up to `.bak`, rebuild
* `OSError` on read -> skip this agent. Do NOT clobber the user's
  existing profile, which may hold other providers' real keys.
* `OSError` on mkdir/write -> record in `_failed` list, surface in
  output. Not silently swallowed.

Three independent report sections (`_seeded` / `_failed` /
`_skipped_anonymous`) so mixed scenarios don't hide warnings behind
each other.

### B2: smoke test caveat

After "Smoke test passed", append three lines explaining that the
test only verifies OCP direct connectivity, and instructing the user
to restart OpenClaw and send a real bot message to verify
end-to-end.

### B3: suppress /dev/tty stderr

Wrap all four `read ... </dev/tty` calls as
`{ read ... </dev/tty; } 2>/dev/null || fallback` so the brace group
captures the shell's own `Device not configured` error before it
reaches the user's terminal. The fallback semantics are unchanged.

### B6: help text wording

Change item 5 from old wording mentioning IDE auto-configuration to
"Configures OpenClaw automatically; prints setup hints for other
IDEs - manual configuration required". Detection logic itself is NOT
changed in this commit. That is deferred to a future PR. See #12
section 9 for details.

### Version bump

Per dev iron rule #5 (version bump before push):
* `OCP_CONNECT_VERSION="1.1.0"` constant
* `--version` CLI flag
* Banner shows version

## Test evidence

End-to-end verified on macOS 13 with the actual OpenClaw 2026.4.11
+ Telegram bot setup that exposed #12:

1. cold-install state (clean ~/.openclaw, no agent profiles)
2. run `ocp-connect 172.16.2.30 --key ocp_xxx` -> exit 0
3. verify both `~/.openclaw/agents/main/agent/auth-profiles.json`
   and `~/.openclaw/agents/macbook_bot/agent/auth-profiles.json`
   are written with mode 0600 and the real key
4. restart gateway -> `agent model: ocp/claude-sonnet-4-6` loads
5. send a real Telegram message -> bot responds via
   `ocp/claude-sonnet-4-6`, `[telegram] sendMessage ok`,
   `auth-state.json` updates `lastUsed` and shows
   `errorCount: 0, lastGood: ocp:default`
6. zero `No API key found` / 401 / lane-error events anywhere

Offline test matrix (also all green):
* anonymous mode -> warning prints, no agentDir write
* corrupted existing auth-profiles.json -> backed up to .bak, rebuilt
* no-tty execution -> 0 `Device not configured` leakage
* `--help` -> new B6 wording
* `--version` -> `ocp-connect 1.1.0`

## Code review

Two independent reviewer subagents (opus, spec compliance + code
quality) plus one blind implementer subagent (sonnet, control
group). The blind implementer reproduced exactly the two MUST-FIX
issues the code-quality reviewer flagged in the first draft (silent
data loss on `except Exception`, misleading success on per-agent
write failure), validating that the review process caught real
non-obvious problems. Both must-fixes are addressed in this commit;
re-tested green.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* fix(ocp-connect): drop "with agentDir" from anonymous warning

The message previously said "agents with agentDir" but B1.5 now also
seeds agents that omit the field (their dir is derived). Reviewer
feedback on PR #13.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

---------

Co-authored-by: taodeng <taodeng@Taos-MBP>
Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-12 17:36:02 +10:00
taodengandClaude Opus 4.6 e326cee9dd docs: remove outdated troubleshooting workarounds
Removed v3.0.x migration guide, old idleTimeoutSeconds workaround
(auto-handled by setup.mjs since v3.2.1), and manual session cleanup
instructions. Replaced with concise troubleshooting for common issues.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-04-12 13:29:05 +10:00
taodengandClaude Opus 4.6 f8ca9b85b0 bump version to 3.6.0 for IDE auto-configuration feature
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-04-12 13:27:47 +10:00
taodengandClaude Opus 4.6 c22b0dd074 feat: interactive IDE configuration in ocp-connect
ocp-connect now detects installed IDEs and offers to configure them
automatically. OpenClaw gets full interactive setup (provider name,
primary/backup priority, model aliases). Other IDEs (Cline, Continue.dev,
Cursor) get manual config instructions printed.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-04-12 13:27:07 +10:00
taodengandClaude Opus 4.6 7f46405152 fix: ocp-connect writes to all shell rc files + system-level env vars
- Writes to both .bashrc and .zshrc on macOS (covers both shells)
- macOS: launchctl setenv for GUI apps and daemons
- Linux: ~/.config/environment.d/ocp.conf for systemd user services
- Ensures IDEs and daemons can discover OCP models

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-04-12 12:58:27 +10:00
taodengandClaude Opus 4.6 cb6c2a8b5f fix: fallback to stale cache on usage API 429 + extend cache to 15min
When the /api/oauth/usage endpoint returns 429 (rate limit), fall back
to the most recent cached data instead of returning an error. Also
extended cache TTL from 5min to 15min to reduce API calls.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-04-12 09:44:18 +10:00
taodengandClaude Opus 4.6 02c6758a61 feat: CLAUDE_NO_CONTEXT mode to suppress context injection (closes #11)
Adds CLAUDE_NO_CONTEXT=true env var that passes
CLAUDE_CODE_DISABLE_CLAUDE_MDS=1 and CLAUDE_CODE_DISABLE_AUTO_MEMORY=1
to Claude CLI child processes. This suppresses CLAUDE.md and auto-memory
injection while preserving OAuth auth — needed for third-party agents
like Hermes that have their own memory systems.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-04-11 16:40:36 +10:00
taodengandClaude Opus 4.6 6d0e43ec37 fix: ocp-connect tries anonymous access before requiring key
In zero-config auth mode, the server allows anonymous access even in
multi mode. The script now probes /v1/models first — if it succeeds,
no key is needed. Also updated README examples and version to 3.5.1.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-04-11 13:42:00 +10:00
taodengandClaude Opus 4.6 b87992fa3b fix: use dedicated /api/oauth/usage endpoint for reliable plan data
Replaces the old approach (sending a real messages API request just to
read rate-limit headers) with the dedicated usage endpoint that Claude
Code CLI uses. Fixes intermittent "unknown" plan usage.

- GET /api/oauth/usage with Bearer auth + anthropic-beta header
- Auto-refresh expired OAuth tokens via refresh_token grant
- Try both keychain label formats (claude-code-credentials, Claude Code-credentials)
- Zero API quota consumption for usage checks
- Parse new JSON response format (five_hour/seven_day structure)

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-04-11 10:52:16 +10:00
taodengandClaude Opus 4.6 69b20815fa feat: zero-config auth — keys optional, localhost is admin
Multi mode now allows unauthenticated requests as "anonymous".
Keys are optional: provide one for per-key usage tracking, or
skip it for zero-config access. Invalid keys are still rejected.

Localhost requests are always admin-level (can manage keys,
view dashboard data, etc.) without any token.

This makes OCP much friendlier for home LAN setups — family
members can use it immediately without any key configuration.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-04-11 10:11:08 +10:00
taodengandClaude Opus 4.6 3eecca35ce feat: localhost bypass auth in multi mode
Requests from 127.0.0.1/::1 skip authentication even in multi/shared
auth modes. Only remote (LAN) clients need API keys.

This simplifies local tool integration — IDEs and agents on the same
machine no longer need to configure Bearer tokens.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-04-11 10:09:07 +10:00
taodengandClaude Opus 4.6 9f1a21f7ad docs: update dashboard screenshot with live usage data
Session 53%, Weekly 40% — proper colors via Playwright

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-04-11 10:04:10 +10:00
taodengandClaude Opus 4.6 a0f9268af5 fix: dashboard token via URL param + correct screenshot
- Support ?token=xxx query parameter for dashboard auth (enables
  headless browser screenshots and direct links)
- Token is saved to localStorage and URL is cleaned up
- Fix pathname matching to handle query parameters in URL
- Replace html2canvas screenshot with Playwright (accurate colors)

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-04-11 09:56:21 +10:00
taodengandClaude Opus 4.6 087e26346f feat: add ocp-connect lightweight client + restructure README
- Add standalone `ocp-connect` script for zero-dependency client setup
  (only needs curl + python3, no Node/repo clone required)
- Add `ocp connect` command to main CLI
- Add `authMode` field to /health endpoint
- Restructure README with clear Server Setup / Client Setup sections
- Add dashboard screenshot to README
- Fix smoke test model name (claude-haiku-4-5-20251001)
- Fix auth mode detection for shared/multi/none
- Fix rc file cleanup idempotency (blank line handling)
- Fix key input echo (now hidden with read -rs)
- Add host validation
- Bump version to 3.5.0

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-04-11 09:48:33 +10:00
taodengandClaude Opus 4.6 ed53c5e0c5 docs: add Telegram/Discord /ocp slash command usage to README
Clarify that /ocp commands are for Telegram/Discord via gateway plugin,
while terminal CLI uses ocp (without slash).

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-04-10 21:48:55 +10:00
taodengandClaude Opus 4.6 18cb759359 Merge feature/lan-mode: LAN sharing with multi-key auth, dashboard, CLI
Add LAN mode to share one Claude Pro/Max subscription across a household:
- 3-tier auth: none / shared / multi (per-user API keys)
- SQLite-backed key management and per-request usage tracking
- Embedded web dashboard with real-time monitoring
- CLI commands: keys, lan, usage --by-key
- Zero external dependencies (uses node:sqlite)

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-04-10 21:45:17 +10:00
taodengandClaude Opus 4.6 cc745aa5d9 docs: update README with complete LAN mode reference
Add LAN CLI commands (keys, lan, usage --by-key), new API endpoints
(dashboard, keys, usage), LAN env vars (CLAUDE_BIND, CLAUDE_AUTH_MODE,
OCP_ADMIN_KEY), and expanded security section.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-04-10 21:45:07 +10:00
taodengandClaude Opus 4.6 1983f9372d fix: CLI auth support for multi-key mode + fix LAN IP detection
- Add _curl wrapper that reads OCP_ADMIN_KEY env or ~/.ocp/admin-key file
- All admin API calls (keys, usage) use _curl for auth
- Fix ocp lan IP detection to try en0 and en1

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-04-10 21:36:16 +10:00
taodengandClaude Opus 4.6 471bda2c40 fix: make /dashboard a public endpoint (no auth required)
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-04-10 21:21:36 +10:00
taodengandClaude Opus 4.6 566a01a6bd refactor: switch from better-sqlite3 to node:sqlite (zero dependencies)
better-sqlite3 native addon fails on Node v25. Node.js built-in SQLite
(node:sqlite) has an identical synchronous API and requires no compilation.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-04-10 21:20:24 +10:00
taodeng 43daf8162c docs: add LAN mode documentation and family sharing guide 2026-04-10 21:17:25 +10:00
taodeng aa0adab784 feat: add LAN mode config options to setup.mjs 2026-04-10 21:17:19 +10:00
taodengandClaude Sonnet 4.6 4f4e1edf16 feat: add ocp keys, ocp lan, and ocp usage --by-key CLI commands
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-10 21:16:25 +10:00
taodeng ea57db6ceb feat: add embedded web dashboard with real-time usage display 2026-04-10 21:15:44 +10:00
taodengandClaude Sonnet 4.6 5fbeaed568 security: fix key exposure, timing-safe admin auth, remove admin bypass, cap params, harden usage recording
- Fix 1 (keys.mjs): listKeys() no longer leaks full API key field in response
- Fix 2 (server.mjs): Remove BIND_ADDRESS admin bypass from isAdmin check
- Fix 3 (server.mjs): Admin key comparison now uses timingSafeEqual
- Fix 4 (server.mjs): Cap limit (max 500) and hours (max 720) in GET /api/usage
- Fix 5 (server.mjs): Streaming error path now computes promptChars from messages
- Fix 6 (server.mjs): Warn at startup if AUTH_MODE=shared but PROXY_API_KEY is empty
- Fix 7 (server.mjs): All recordUsage calls wrapped in try/catch to prevent DB errors crashing server
- Fix 8 (server.mjs): CORS fallback changed from "*" to specific origin (http://127.0.0.1:<PORT>)

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-10 21:13:17 +10:00
taodengandClaude Sonnet 4.6 2b18884d2a feat: add LAN bind, 3-mode auth, usage recording, key/usage API endpoints
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-10 21:07:22 +10:00
taodeng 4f72f4844e feat: add keys.mjs — API key management and SQLite usage store 2026-04-10 21:04:00 +10:00
taodeng bc17b27f2b chore: add better-sqlite3 dependency, bump to v3.4.0 2026-04-10 21:02:40 +10:00
taodengandClaude Opus 4.6 3e8ff7a509 docs: add LAN mode implementation plan
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-04-10 21:00:22 +10:00
taodengandClaude Opus 4.6 b6b33d2623 chore: add MIT LICENSE file
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-04-10 20:37:11 +10:00
taodengandClaude Opus 4.6 8796a5fc19 docs: update README version to v3.3.1
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-05 07:49:54 +10:00
taodengandClaude Opus 4.6 609ceb28b0 fix: use stable node symlink path in macOS plist, avoid Cellar breakage
setup.mjs now resolves /opt/homebrew/Cellar/node/X.Y.Z/ to
/opt/homebrew/opt/node/ so the LaunchAgent plist survives node upgrades.
Bump to v3.3.1.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-04-04 10:01:54 +10:00
taodengandClaude Opus 4.6 3843ec8fe6 refactor: simplify timeout to single CLAUDE_TIMEOUT (default 600s)
Remove firstByteTimeout, adaptive tier algorithm, and MODEL_TIMEOUT_TIERS.
Claude tool-use causes 30s-5min pauses in token streams, making fine-grained
timeouts unreliable — they repeatedly killed valid requests. A single generous
timeout (10 min, matching LiteLLM/OpenAI SDK defaults) is simpler and correct.

Breaking: CLAUDE_FIRST_BYTE_TIMEOUT env var removed, default timeout changed
from 300s to 600s. Bump to v3.3.0.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-04-04 09:46:12 +10:00
taodengandClaude Opus 4.6 afe0c6e1be fix: computeFirstByteTimeout now respects CLAUDE_FIRST_BYTE_TIMEOUT env var
The adaptive timeout calculation ignored BASE_FIRST_BYTE_TIMEOUT (set via
CLAUDE_FIRST_BYTE_TIMEOUT env), always using the tier base (120s for Sonnet).
Now uses whichever is larger: adaptive or env override. Bump to v3.2.2.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-04-04 09:26:01 +10:00
taodengandClaude Opus 4.6 a6007393a3 fix: auto-configure idleTimeoutSeconds=0 to prevent tool-call timeouts
setup.mjs now sets agents.defaults.llm.idleTimeoutSeconds=0 in
openclaw.json during installation. Without this, OpenClaw's default
60s idle timeout kills Claude connections during tool use (Bash, Read,
etc.), causing exit 143 errors and stuck sessions.

Also adds troubleshooting section to README. Bump to v3.2.1.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-04-03 08:50:19 +10:00
taodengandClaude Opus 4.6 8592150f7a docs: update README for v3.2.0 — add update command, upgrade notes
- Document ocp update / ocp update --check
- Add Self-Update section
- Add Upgrading from v3.0.x migration notes
- Remove Known Issues (resolved upstream)
- Note neutral service names in OpenClaw Integration

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-04-03 08:22:55 +10:00
taodengandClaude Opus 4.6 eb76971ffc fix: resolve symlinks in ocp update/restart for linked installs
BASH_SOURCE returns the symlink path, not the target. This broke
ocp update on systems where ocp is installed as a symlink
(e.g. ~/.local/bin/ocp → repo/ocp).

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-04-03 08:20:26 +10:00
taodengandClaude Opus 4.6 d54e73ef89 fix: prevent git fetch failure from aborting ocp update --check
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-04-03 08:19:41 +10:00
taodengandClaude Opus 4.6 3f10b459f5 feat: add ocp update command + fix restart for new service names
- `ocp update`: pulls latest from GitHub, syncs plugin, restarts proxy
- `ocp update --check`: shows current vs latest version without applying
- `ocp restart`: now handles both dev.ocp.proxy and legacy ai.openclaw.proxy
  service names on macOS/Linux
- Bumps to v3.2.0

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-04-03 08:18:41 +10:00
taodengandClaude Opus 4.6 b72e449337 fix: use neutral service names to avoid OpenClaw gateway detection
OpenClaw's gateway scans LaunchAgent plists and systemd units for
"openclaw" markers and flags them as conflicting gateway-like services.
Renamed service identifiers to avoid false positives:

- macOS: ai.openclaw.proxy → dev.ocp.proxy
- Linux: openclaw-proxy.service → ocp-proxy.service
- Logs: ~/.openclaw/logs/ → ~/.ocp/logs/

Setup auto-removes legacy service names on upgrade. Uninstall handles
both old and new names. Also bumps version to 3.1.0.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-04-03 08:13:55 +10:00
taodengandClaude Opus 4.6 2b05558b8b rebrand: Open Claude Proxy — multi-IDE positioning
Renamed from OpenClaw Control Plane to Open Claude Proxy. README
rewritten to position OCP as a universal proxy for any IDE that
speaks the OpenAI protocol, not just OpenClaw.

Added: supported tools table (Cline, OpenCode, Aider, Continue.dev),
simplified Quick Start, architecture diagram showing multi-IDE flow.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-31 12:06:41 +10:00
taodengandClaude Opus 4.6 d9b7c0d905 docs: add Known Issues — /ocp Unknown skill workaround
Documented the intermittent "Unknown skill: ocp" issue caused by
OpenClaw gateway session routing bug (#26895). Workaround: /new
followed by /ocp restart.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-30 20:50:17 +10:00
taodengandClaude Opus 4.6 27cd8b1735 fix: correct server.mjs path in start.sh
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-27 16:38:18 +10:00
taodengandClaude Opus 4.6 9dd35c90ae docs: remove premature Known Issues section
The slash session workaround was incomplete — root cause still under
investigation. Removed to avoid misleading users. Will add back with
accurate info once the issue is fully resolved.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-27 11:04:38 +10:00
taodengandClaude Opus 4.6 c005a8fd27 remove backends command — OCP is stable v3, no Phase 2
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-27 10:57:58 +10:00
taodengandClaude Opus 4.6 77a3f48cfd feat: add restart, version, test, backends commands to /ocp plugin
New subcommands:
- /ocp restart [gateway|all] — restart proxy, gateway, or both
- /ocp version — show version, uptime, platform info
- /ocp test — end-to-end proxy test (sends haiku request)
- /ocp backends — list registered backends with health status

Also includes clean-slash-sessions.sh workaround for OpenClaw #26895.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-27 09:09:23 +10:00
taodengandClaude Opus 4.6 7390a19ec6 docs: add prominent warning about slash session bug near plugin install
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-26 17:37:10 +10:00
taodengandClaude Opus 4.6 04643217ae docs: add known issue — slash session hijacks plugin commands
Document OpenClaw gateway bug (#26895, #54485) where telegram:slash
sessions interfere with registerCommand plugin routing. Include
workaround script and warning not to add ocp to agent skills lists.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-26 17:36:42 +10:00
taodengandClaude Opus 4.6 3de1868313 fix: read OAuth token from Linux credentials file as fallback
On Linux (no macOS keychain), read ~/.claude/.credentials.json first.
Falls through to macOS keychain if file not found. Fixes /ocp usage
showing "No OAuth token" on Linux servers and RPi.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-25 20:42:16 +10:00
taodengandClaude Opus 4.6 75cff38f4c docs: improve CLI install instructions for cloud/Linux users
Common issue: ocp not in PATH after clone. Added explicit symlink
instructions with absolute paths and a troubleshooting note.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-25 19:52:22 +10:00
taodengandClaude Opus 4.6 a8a0af43c9 docs: mark OCP as stable v3.0.0, update repo name and description
- README: add "Status: Stable" badge, rename title to OCP — OpenClaw Control Plane
- package.json: update description and repo URL to dtzp555-max/ocp
- No code changes. Feature-complete, bug fixes only going forward.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-25 19:49:16 +10:00
taodengandClaude Opus 4.6 77a6f8ed59 revert: rollback Phase 1 and OAuth fix — broke plan usage functionality
Reverts b5be1c0 and 1f1b387. These changes broke the OAuth token
authentication for fetching plan usage data from Anthropic API,
causing /ocp usage to show 0% with "unknown" reset times.

Root cause: code changes inadvertently altered the fetchUsageFromApi
flow, breaking the existing working OAuth x-api-key authentication.

This restores server.mjs to the last known working state (f6f4f68).
Phase 1 modular files removed — will be re-implemented in a separate
branch with proper isolation testing.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-25 18:16:04 +10:00
taodengandClaude Opus 4.6 b5be1c04f4 fix: auto-refresh expired OAuth token for plan usage data (v3.0.1)
When the Claude Code OAuth token expires, /ocp usage showed 0% for all
plan limits. OCP now detects expiry and triggers a refresh via Claude CLI,
then re-reads the updated token from keychain. The 5-minute usage cache
prevents excessive refresh attempts.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-25 16:18:15 +10:00
taodengandClaude Opus 4.6 1f1b3871da feat: Phase 1 — modular backend architecture (v4.0-alpha)
Internal refactor: extract BackendAdapter, ModelRegistry, AgentRouter,
SessionManager, and StatsCollector as standalone modules. Claude CLI
logic encapsulated in ClaudeCliAdapter.

External API completely unchanged — all existing endpoints behave
identically. Legacy request path (callClaude/callClaudeStreaming)
untouched. v4 modules load in parallel for validation.

New endpoints:
  GET /backends  — backend health and status
  GET /routing   — agent routing table

Architecture:
  unified-chunk.mjs    — streaming protocol (delta/done/error)
  backends/base.mjs    — BackendAdapter interface
  backends/claude-cli.mjs — Claude CLI adapter (core)
  model-registry.mjs   — model → backend mapping (Layer 1)
  agent-router.mjs     — agent policy routing (Layer 2)
  session-manager.mjs  — conversation session management
  stats-collector.mjs  — per-model/backend/agent metrics

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-25 08:02:53 +10:00
taodengandClaude Opus 4.6 f6f4f68ecd Add upgrade guide for v2.x skill-based /ocp users
Explains removing old skills/ocp/SKILL.md to avoid conflict
with the new plugin-based command handler.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-24 20:07:29 +10:00
taodengandClaude Opus 4.6 47e39d7820 feat: v3.0.0 — OCP CLI, plan usage monitoring, runtime settings, prompt guard
Major release: complete management interface for the Claude proxy.

- /ocp CLI with 10 subcommands (usage, status, health, settings, logs, models, sessions, clear, restart)
- Gateway plugin for /ocp as native Telegram/Discord slash command
- Plan usage monitoring via Anthropic API rate-limit headers (session %, weekly %, reset times)
- Per-model request stats (count, avg/max elapsed, avg/max prompt chars)
- Runtime settings via PATCH /settings (tune timeouts, concurrency, prompt limits without restart)
- Prompt truncation guard at 150K chars to prevent conversation history blowup
- Circuit breaker removed — caused cascading failures in CLI-proxy architecture
- Timeout increases: Opus 150s, Sonnet 120s, Haiku 45s base first-byte
- New endpoints: /usage, /status, /settings, /logs
- README fully rewritten with CLI examples and changelog

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-24 17:33:17 +10:00
taodengandClaude Opus 4.6 7434f6adc0 docs: v2.5.0 release notes — sliding-window circuit breaker incident writeup
Document the 2026-03-22 multi-agent cascading timeout incident, root cause
analysis, and all new/changed defaults. Update env vars table.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-22 19:48:23 +10:00
taodengandClaude Opus 4.6 8a7951134e feat: v2.5.0 — sliding-window circuit breaker, graduated backoff, increased timeouts
Emergency fix for multi-agent (ClawTeam) burst scenario that caused cascading
failures: when multiple Opus agents ran concurrently, the old consecutive-count
breaker (threshold=3) tripped within seconds, blocking ALL subsequent requests
globally — including non-ClawTeam agents and new sessions.

Changes:
- Circuit breaker now uses a sliding time window (default 5min) instead of
  consecutive failure count. 6 failures in 5min triggers open, vs old 3-in-a-row.
- Half-open state allows 2 concurrent probe requests (was 1), improving recovery
  speed when API comes back.
- Graduated backoff: cooldown doubles on each re-open (120s→240s→300s cap),
  resets fully on first success.
- Increased default timeouts: Opus first-byte 60s→90s, Sonnet 45s→60s,
  overall 120s→300s, max concurrent 5→8.
- /health endpoint now exposes per-model breaker state, window stats, and
  marks status as "degraded" when any breaker is open.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-22 19:47:23 +10:00
47434a8ba7 fix: security hardening + real streaming (#4)
* Add graceful shutdown handling for SIGTERM and SIGINT

On receiving SIGTERM or SIGINT, the server now:
1. Stops accepting new connections (server.close)
2. Clears session cleanup and auth check intervals
3. Sends SIGTERM to all active child processes
4. Waits up to 5s for processes to exit, then SIGKILL + exit(1)
5. Exits cleanly with exit(0) once all processes are gone

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

* fix: security hardening — bind localhost, validate models, limit body size

- Bind to 127.0.0.1 instead of 0.0.0.0 to prevent network exposure
- Restrict CORS Access-Control-Allow-Origin to http://127.0.0.1
- Add 10MB request body size limit (413 on exceed)
- Validate model parameter against known MODEL_MAP keys (400 on unknown)
- Remove catch-all POST route; only /v1/chat/completions accepted
- Use crypto.timingSafeEqual for API key comparison
- Sanitize error messages to strip internal file paths

Bumps version to 2.4.1.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

* fix: replace fake streaming with real stdout-piped SSE streaming

Previously, streaming requests waited for the entire claude CLI process
to complete, then split the output into artificial 500-char chunks sent
as SSE events. This defeated the purpose of streaming and added latency.

Now, stdout from the claude child process is piped directly to SSE
chunks as data arrives. Each `data` event from the process stdout
becomes an immediate SSE delta event, giving clients real incremental
output. The shared process setup logic is extracted into
spawnClaudeProcess() to avoid duplication between streaming and
non-streaming paths. Client disconnect detection kills the child process
to free resources.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

* fix: tighten CORS to dynamic localhost-only and reduce body limit to 5MB

- CORS now dynamically matches localhost/127.0.0.1 origins instead of
  static http://127.0.0.1, preventing cross-origin abuse while
  supporting any localhost port
- Body size limit reduced from 10MB to 5MB to limit OOM risk

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

---------

Co-authored-by: Oracle Public Cloud User <opc@instance-20230820-1333.subnet07301351.vcn07301351.oraclevcn.com>
Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-21 18:46:36 +10:00
taodengandClaude Opus 4.6 1d95dbeebc feat: v2.4.0 — per-model circuit breaker, adaptive timeout tiers, structured logging
- Circuit breaker: consecutive timeouts (default 3) mark model as degraded for 60s,
  failing fast instead of blocking gateway with repeated timeout attempts
- Per-model timeout tiers: Opus 60s base, Sonnet 45s, Haiku 30s, plus adaptive
  scaling by prompt size (~15s/100k chars for Opus)
- Structured JSON logging for spawns, timeouts, breaker state changes
- Late close guard: prevents double-settle when timeout fires before proc.close

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-21 17:38:45 +10:00
taodeng 9e6bef729b fix: harden proxy timeout handling for opus 2026-03-21 17:32:21 +10:00
taodeng 256b2bfb77 docs: ship v2.3.0 positioning and coexistence guide 2026-03-21 14:02:33 +10:00
taodeng 384e66bfa6 feat: v2.2.0 — faster fallback with first-byte timeout
- Reduced default CLAUDE_TIMEOUT from 300s to 120s for faster fallback
- Added CLAUDE_FIRST_BYTE_TIMEOUT (default 30s): aborts early if Claude
  CLI produces no output, preventing silent hangs
- First-byte timing logged for every request for observability
- Health endpoint now reports firstByteTimeout in config
2026-03-21 13:57:23 +10:00
taodengandClaude Opus 4.6 76a8c56c88 feat: v2.1.0 — faster fallback with first-byte timeout and reduced defaults
- Default timeout: 300s → 120s (CLAUDE_TIMEOUT)
- New: first-byte timeout at 30s (CLAUDE_FIRST_BYTE_TIMEOUT) — aborts early
  if Claude CLI produces no stdout, triggering faster fallback
- Pool disabled by default (POOL_SIZE=0) — on-demand spawning is now the
  default; set CLAUDE_POOL_SIZE>0 to re-enable
- Health endpoint now reports timeout and firstByteTimeout values
- Startup log shows pool mode (disabled/on-demand vs active)

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-21 13:53:27 +10:00
20 changed files with 5691 additions and 681 deletions
+21
View File
@@ -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.
+422 -201
View File
@@ -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.
![OCP Dashboard](docs/images/dashboard.png)
### 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
View File
@@ -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
+160
View File
@@ -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; }
}
Executable
+858
View File
@@ -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
View File
@@ -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 "$@"
+301
View File
@@ -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}` };
}
},
});
}
+17
View File
@@ -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"
}
}
}
}
+14
View File
@@ -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"
}
}
+182
View File
@@ -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.
+2 -2
View File
@@ -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
View File
@@ -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.`);
});
+20
View File
@@ -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
View File
@@ -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
View File
File diff suppressed because it is too large Load Diff
+72 -14
View File
@@ -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 {
+1 -1
View File
@@ -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
View File
@@ -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`);