Compare commits

..
Author SHA1 Message Date
taodengandClaude Sonnet 4.6 7a731fc36e fix(ocp-cli): replace eval-curl with bash array to preserve JSON body quoting
`eval curl "$_AUTH_HEADER" "$@"` re-tokenizes its argument list according
to bash word-splitting rules. When OCP_ADMIN_KEY is set, the JSON body
`'{"name": "laptop"}'` (which contains a space) gets split into
`'{name:'` and `'laptop}'` — two separate args — so curl receives a
malformed body and the server rejects the request.

Fix: replace the `_AUTH_HEADER` string + `eval` pattern with a bash array
`_AUTH_ARGS`. Array expansion via `"${_AUTH_ARGS[@]}"` preserves word
boundaries across substitution with no eval required. Both code paths
(OCP_ADMIN_KEY env var and ~/.ocp/admin-key file fallback) and the empty
case (no admin key) are preserved unchanged.

Verified via `bash -x` trace:
  Before: `curl … -d '{name:' 'laptop}'` (body split, malformed)
  After:  `curl … -d '{"name": "testkey-pr-c"}'` (body intact, single arg)

Identified during fresh-state Round 2 testing on MacBook.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-09 00:43:45 +10:00
49baffe2da fix(setup): decouple OpenClaw config patch from being mandatory (#73)
OCP markets itself as a standalone OpenAI-compatible proxy with six
supported IDE clients (Cline / Cursor / Continue.dev / OpenCode / Aider /
OpenClaw). The README §Server Setup says "node setup.mjs" is the
installer entrypoint. But on a truly fresh box without OpenClaw
installed, setup.mjs hard-fails at line 111:

    if (!existsSync(CONFIG_PATH)) fail(`OpenClaw config not found...`);

This contradicts the standalone-proxy stance and was caught during
PR #71 dogfood testing on Pi231.

Root cause: the OpenClaw config patch (lines 110-148) was baked in as
required because OCP started life as an OpenClaw-internal helper. The
project has since rebranded to standalone OCP without decoupling the
installer.

Fix: gate the OpenClaw config patch (Step 2) and auth-profiles patch
(Step 3) on existsSync(CONFIG_PATH). When OpenClaw is present, behavior
is byte-for-byte identical to current main (verified via dry-run hash
diff against ~/.openclaw/openclaw.json — UNCHANGED). When OpenClaw is
absent, the installer logs a graceful warning, skips both patch
sections, and continues to start.sh / launchd-plist / systemd-unit
creation as before. The summary banner is also conditional: when
OpenClaw is absent, it points the user at README § "Client Setup"
instead of giving openclaw.json edit instructions.

Also moves `import { readdirSync }` from mid-file (line 178, post-use)
to the top-level imports block; this was a latent ESM-hoisting quirk
that worked but is now syntactically required at the top because the
readdirSync call moved inside an `if` block.

Out of scope (Iron Rule 11, single-layer PR): server.mjs, models.json,
scripts/sync-openclaw.mjs, README.md, ALIGNMENT.md, package.json
version, the duplicate-spawn / health-verify logic at line 268+ (that's
a separate fix/setup-spawn-conflict PR).

Mac mini production unaffected: ~/.openclaw/openclaw.json already
exists there, so the OpenClaw-present path is preserved. `ocp update`
doesn't invoke setup.mjs, so running services are not touched.

Smoke-tested locally:
- Path 1 (OpenClaw present): dry-run output functionally identical to
  current main; config sha256 unchanged after run.
- Path 2 (OpenClaw absent, OPENCLAW_STATE_DIR=/tmp/no-such-dir-*):
  graceful warn, both patch sections skipped, banner shows
  standalone-mode message, dry-run completes successfully.
- npm test: 43/43 pass.

Co-authored-by: dtzp555 <dtzp555@gmail.com>
Co-authored-by: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-08 15:03:27 +10:00
4b01d4e768 fix(setup): remove duplicate server spawn; verify health post-install (#72)
## Dogfood evidence (Pi231, 2026-05-08)

A user ran `node setup.mjs --bind 0.0.0.0 --auth-mode multi` and got:
- setup.mjs exit 0
- /health responded with authMode:"none" and server bound to 127.0.0.1 only
- ps showed two server.mjs processes: one orphan from setup (wrong config),
  one systemd child restart-looping on EADDRINUSE

## Root cause (two-step conflict)

Step 6 (the deleted block) called `execSync('bash "${startPath}"')` which ran
start.sh's `nohup node server.mjs &` — spawning the server WITHOUT exporting
CLAUDE_BIND or CLAUDE_AUTH_MODE. That server ran with default bind=127.0.0.1
and authMode=none, ignoring the user's CLI flags.

Step 7 then wrote the systemd unit/launchd plist WITH the correct env vars and
bootstrapped the service — but port 3456 was already taken by Step 6's spawn,
causing EADDRINUSE and a silent restart loop. setup.mjs exited 0 because Step 6
had "succeeded" (a server was running, just the wrong one).

## What changed

1. Deleted Step 6 entirely (the `execSync('bash "${startPath}"')` block).
   The systemd/launchd service installed in Step 7 is now the sole authoritative
   start path. start.sh is unchanged and still available for manual non-systemd use.

2. Added Step 8 inside the `if (!DRY_RUN)` block: after Step 7 bootstrap,
   waits 3 s, then GETs http://127.0.0.1:${PORT}/health with a 5 s timeout.
   - On 200 OK: logs version, authMode, and bind socket (best-effort).
   - On failure: prints clear error pointing to service logs, exits 1.
   - Skipped when --no-start is set (existing flag).

Identified during PR #71 dogfood testing on Pi231 (RPi4 / Debian Bookworm).

Co-authored-by: dtzp555 <dtzp555@gmail.com>
Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-08 15:02:45 +10:00
36fa81d1e6 docs(install): add §Quick install with AI assistance + plug five new-user pitfalls (#71)
* docs(install): add §Quick install with AI assistance + plug five new-user pitfalls

Context: a returning user observed that letting an AI assistant follow the
README to install OCP would mostly work but stumble on a handful of small
gaps — missing OS qualifier, no admin-key generation hint, server IP
discovery buried, and a few common setup errors not in Troubleshooting.
This patch closes those gaps and adds a copy-paste prompt section for
new users who'd rather have an AI walk them through the install.

Five additive changes (README only, server.mjs untouched):

1. **§Server Setup prerequisites** — add the macOS/Linux qualifier
   ("Windows is not supported — setup.mjs installs launchd / systemd")
   and `git`, both of which were implicit before.

2. **OCP_ADMIN_KEY generation hint** — replace the placeholder
   `your-secret-admin-key` with a one-line `openssl rand -base64 32`
   example, plus a reminder to add the export to ~/.zshrc / ~/.bashrc
   so it survives shells.

3. **§Client Setup** — add an inline note pointing readers to run
   `ocp lan` on the server to discover the server's LAN IP. Previously
   `ocp lan` was mentioned only in §Server Setup, leaving client-side
   readers to guess.

4. **§Troubleshooting** — three new entries for setup-time errors
   (claude not found / EADDRINUSE 3456 / node version), with the
   specific recovery commands. Existing entries left unchanged.

5. **§Installation → new ###Quick install with AI assistance subsection**
   — three copy-paste prompts (single-machine, LAN server, client) that
   pin the AI to the right README path, name the verification step, and
   forbid silent retries. Includes a pointer to the manual handbook
   sections for readers who prefer that path.

Doc-only change. server.mjs not modified. ALIGNMENT.md Rule 5 (cli.js
citation requirement) does not apply. Same pattern as #68, #69, #70.

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

* docs(install): add Claude CLI install command + ####Headless install notes

Live Pi231 (RPi4 / Debian Bookworm) test of PR #71's "Quick install with AI
assistance → LAN server" prompt surfaced two more new-user pitfalls in the
README's Prerequisites + Server Setup flow:

1. **Claude CLI install command was missing.** Previous text said "Claude CLI
   installed and authenticated" with a docs link — but the actual install
   command (`npm install -g @anthropic-ai/claude-code`) appeared nowhere.
   An AI assistant following the prompt has to fetch external docs to
   guess at the install path. Now inlined.

2. **Headless servers (Pi / NAS / VPS) had no auth guidance.** OCP's main
   deployment targets are always-on headless devices. `claude auth login`
   actually works headless (prints URL + code, OAuth completes on any
   browser-capable device), and `claude setup-token` provides a long-lived
   token — but neither was documented. New ####Headless install notes
   subsection explains both paths.

Test trail (Pi231):
-  Linux (aarch64 Debian Bookworm), Node v22.22.2, git 2.39.5 — prereqs
  satisfied except Claude CLI.
-  `claude` not on PATH (expected — fresh Pi). README didn't tell the
  user how to install it. → fix #1 above.
- ⚠️ Even after AI fetches the install command, headless OAuth was a
  documentation gap. → fix #2 above.

Doc-only change. server.mjs not modified. ALIGNMENT.md Rule 5 does not
apply. Extends PR #71 (same install-UX layer per Iron Rule 11 IDR).

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

---------

Co-authored-by: dtzp555 <dtzp555@gmail.com>
Co-authored-by: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-08 15:02:40 +10:00
cce0110253 docs(why-ocp): reorder bullets + soften alignment framing + visceral hooks (#70)
The "Why OCP?" section previously led with technical/governance bullets
(SSE heartbeat, alignment, models.json) and buried the most relatable
selling points (LAN multi-user keys, ocp-connect IDE auto-config, cache).
First-time readers stopped reading before reaching the bullets that would
have sold them on the project.

Changes (README.md only, line 19-26):

1. Reorder: human-relatable benefits first, governance discipline last.
   New order: LAN multi-user → ocp-connect → cache → quota → SSE heartbeat
   → alignment → models.json. Quota is split out from the old combined
   "quota + cache" bullet so each gets its own one-liner.

2. Soften the alignment bullet's framing. The previous prose flagged
   "Other Claude proxies have shipped exactly that" and was deleted —
   no need to call out competitors. Replaced with a measured "LLM-assisted
   code drifts easily — it's tempting to invent plausible-looking endpoints
   that cli.js doesn't actually use" plus a deliberately understated
   payoff: "your setup keeps working when cli.js ships its next minor."

3. Add visceral hooks where bullets benefit from them:
   - SSE heartbeat: "If you've ever watched your IDE die at the 60s idle
     mark during a long Claude tool-use pause — that's nginx/Cloudflare
     default behavior" (frames the problem in user-felt terms before
     describing the fix).
   - Per-key quota: concrete example "set a kid's iPad to 20/day, a
     partner's laptop to 100/week" (replaces abstract "limits per key").

4. Cache bullet now explicitly states the per-key isolation guarantee:
   "cross-user pollution is impossible by hash construction, not by
   application logic" — this addresses the most common pre-adoption
   concern (and reflects the v3.13.0 D1 design).

5. Total length compressed ~20% despite added context — old bullets had
   redundant "what" descriptions; new bullets lead with the "what for".

Doc-only change. server.mjs not touched. ALIGNMENT.md Rule 5 (cli.js
citation requirement) does not apply. Same pattern as #68, #69.

Co-authored-by: dtzp555 <dtzp555@gmail.com>
Co-authored-by: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-08 13:57:06 +10:00
40391791a1 docs(funding): raise sponsorship visibility — top badges + intro CTA + expanded support section (#69)
Context: Buy Me a Coffee + Stripe onboarding now fully live (verified
buymeacoffee.com/dtzp555 returns og:type=profile with Support CTA, default
$3 price tier, membership tier active). Previous §Support OCP at line 709
was effectively buried — last section before License, missed by most readers.

Changes (README.md only, server.mjs untouched):

1. Top of file — three shields.io badges (License MIT, latest release,
   Buy Me a Coffee) under H1, before tagline. Standard OSS pattern
   (Vue / Vite / Tailwind), high visibility, doesn't disrupt prose.

2. Just under tagline — one-line italic personal note pointing to
   §Support OCP, with inline  link as fallback for users who don't
   scroll. Quotes the spirit of the longer section without duplicating it.

3. §Support OCP expanded — adds the "open source from day one" framing
   (not freemium, not commercial-turned-open), the "my family uses it
   daily" angle, and an explicit feedback / issues invitation. The
   debugging-history paragraph is preserved verbatim.

Doc-only change. ALIGNMENT.md Rule 5 (cli.js citation) does not apply —
server.mjs is not modified. Same pattern as #68.

Co-authored-by: dtzp555 <dtzp555@gmail.com>
Co-authored-by: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-08 13:29:58 +10:00
342a0a44f5 docs(funding): add Buy Me a Coffee link + GitHub FUNDING.yml (#68)
- .github/FUNDING.yml — enables GitHub's native "Sponsor" button on the
  repo page, pointing to buymeacoffee.com/dtzp555. Other platforms
  (GitHub Sponsors, Ko-fi) are commented out and can be enabled later
  by uncommenting + filling in handles.
- README.md § Support OCP — new section just before License. States the
  free-and-open-source commitment, lists the kinds of work that don't
  show up in commits (multi-machine debugging, IDE validation, drift
  incidents, concurrency leaks), and offers a single  link for users
  who want to support continued maintenance. Explicitly disclaims paid
  tiers / premium features so the open-source posture stays unambiguous.

server.mjs is not modified; this commit is doc-only and therefore exempt
from the cli.js citation requirement (ALIGNMENT.md Rule 5 applies only
to commits that touch server.mjs).

Co-authored-by: dtzp555 <dtzp555@gmail.com>
Co-authored-by: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-08 03:29:27 +10:00
9494fd6c69 chore(release): v3.13.0 — cache layer hardening (per-key isolation + bypass + chunked replay + singleflight) (#67)
Per release_kit overlay (CLAUDE.md § Iron Rule 5.5):
- package.json bumped 3.12.0 → 3.13.0
- CHANGELOG.md updated with v3.13.0 entry
- README.md § Response Cache updated to document the four hardening features

This is a release preparation commit. Tag push to v3.13.0 will trigger
.github/workflows/release.yml which auto-creates the GitHub Release.

cli.js does not perform proxy-layer response caching, stampede protection,
or replay; this release only ships internal cache-layer correctness and
concurrency improvements that do not change the OpenAI-compatible wire
surface visible to clients. No client-observable wire shape change.

Co-authored-by: dtzp555 <dtzp555@gmail.com>
Co-authored-by: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-07 17:19:44 +10:00
5ff30ac9b6 feat(cache): singleflight stampede protection on non-streaming path (#66)
cli.js does not perform proxy-layer stampede protection. The singleflight
layer is a value-add proxy operation that exists only inside OCP, between
concurrent client requests and the single upstream cli.js spawn. It does
not introduce, alter, or remove any endpoint, header, request field, or
response field that cli.js emits or expects — no client-observable wire
shape change.

Justification under ALIGNMENT.md Rule 2: the singleflight Map deduplicates
concurrent identical non-streaming cache-miss requests so only one cli.js
spawn runs per unique hash window. All followers receive the same resolved
(or rejected) content. This is a proxy-internal concurrency optimization,
not a wire-level protocol change.

Spec: docs/superpowers/specs/2026-05-07-cache-upgrade-design.md (D4)

Changes:
- keys.mjs: add singleflight(hash, fn) + getInflightStats() exports;
  in-memory Map cleared via Promise.finally() on each settlement
- server.mjs: import singleflight + getInflightStats; wrap non-streaming
  cache-enabled path through singleflight with inner recheck; add TODO
  comment in callClaudeStreaming (streaming-path dedup is explicitly out
  of scope for v3.13.0, see spec D4 streaming caveat); extend /cache/stats
  to return inflight + requesters fields (additive, no removed fields)
- test-features.mjs: 7 new PR-B singleflight tests covering basic dedup,
  failure fan-out, map cleanup (success + failure), different-hash
  independence, getInflightStats shape, and sequential-call non-sharing;
  all 31 tests pass (24 existing + 7 new)

Streaming-path singleflight is explicitly out of scope; TODO left in
callClaudeStreaming for a future follow-up ticket.

Co-authored-by: dtzp555 <dtzp555@gmail.com>
Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-07 17:16:46 +10:00
16eeb66557 feat(cache): per-key isolation, cache_control bypass, chunked stream replay (#65)
* docs(governance): ADR 0005 (no multi-provider) + cache upgrade spec

Governance prelude for the cache upgrade work:

- docs/adr/0005-no-multi-provider.md — locks in the decision that
  OCP stays single-provider (Anthropic via cli.js spawn). Cache
  improvements are explicitly in scope (decision §3); multi-provider
  refactor is explicitly out of scope, with three documented trigger
  conditions for revisiting.

- docs/adr/README.md — index updated to reference 0005.

- docs/superpowers/specs/2026-05-07-cache-upgrade-design.md — design
  for the cache upgrade work split into PR-A (per-key isolation,
  cache_control bypass, chunked stream replay) and PR-B (singleflight
  stampede protection). Each design decision has a written rationale.

server.mjs is not modified; this commit is doc-only and therefore
exempt from the cli.js citation requirement (ALIGNMENT.md Rule 5
applies only to commits that touch server.mjs).

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

* feat(cache): per-key isolation, cache_control bypass, chunked stream replay

cli.js does not perform response caching at the proxy layer. OCP's response
cache is a value-add operation internal to OCP, between the wire and the
cli.js spawn. It does not introduce, rename, or alter any endpoint, header,
request field, or response field that cli.js emits or expects — this change
qualifies under ALIGNMENT.md Rule 2's value-add carve-out for non-wire-
affecting proxy operations. no client-observable wire shape change.

Spec: docs/superpowers/specs/2026-05-07-cache-upgrade-design.md

D1 — Per-key cache isolation (cacheHash v2 format)
  keys.mjs: prepend `v2|k:<keyId or "anon">|` before existing hash fields.
  Backward-compatible: absent/null/empty keyId folds to "anon".
  v1-format rows in response_cache are abandoned naturally; TTL cleanup at
  server.mjs:185 reaps them within one window. No migration needed.
  server.mjs: pass keyId: req._authKeyId at the single cacheHash call site
  (line ~1221).

D2 — cache_control bypass
  keys.mjs: export hasCacheControl(messages) — walks messages and nested
  content arrays for presence of cache_control field.
  server.mjs: if hasCacheControl(messages) is true, set req._cacheHash = null
  and log cache_skipped{reason: cache_control_present}; existing
  `if (CACHE_TTL > 0 && req._cacheHash)` guards on write-back handle the skip.

D3 — Chunked stream replay (80 codepoints/chunk, no artificial delay)
  server.mjs: replace single-chunk cached.response emission with an
  Array.from(cached.response) loop in steps of CACHE_REPLAY_CHUNK_SIZE=80.
  Array.from ensures multibyte UTF-8 codepoints (e.g. CJK) are never split.

Tests: 12 new cases in test-features.mjs (36 total, 0 failed).

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

---------

Co-authored-by: dtzp555 <dtzp555@gmail.com>
Co-authored-by: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-07 13:50:50 +10:00
c998d21a4f chore(ci): add gitleaks workflow to scan PRs and pushes to main (#64)
The repo has shipped `.gitleaks.toml` (with project-specific allowlist
entries — public OAuth client ID, README placeholders, an old plan doc)
since the privacy remediation work, but no GitHub Action invoked it.
The config was orphan: real protection only when someone ran gitleaks
locally, never gating merges.

This workflow wires `.gitleaks.toml` into CI:

- Triggers on every `pull_request` (any branch) and `push` to `main`.
- Uses `gitleaks/gitleaks-action@v2`, which auto-detects the repo-root
  `.gitleaks.toml` and applies its allowlist.
- Hard-fails on any leak. No `continue-on-error`. Public repo policy.
- `permissions: contents: read` — minimum required scope.
- `fetch-depth: 0` so the action can scan full history (the action's
  default behavior; explicit here for clarity).

Verification path:
- The workflow runs on this PR itself; if any secret were ever committed
  to the repo, the scan fails here. Prior audit confirmed the tracked
  tree is clean of real secrets, so this PR's own scan should pass.

Refs: audit side-finding 4 of 4.

Co-authored-by: dtzp555 <dtzp555@gmail.com>
Co-authored-by: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-05 09:54:36 +10:00
5be369ed68 docs(changelog): normalize version heading format to ## vX.Y.Z — YYYY-MM-DD (#63)
Audit side-finding: heading style drift in CHANGELOG.md.

Before:
  ## v3.12.0 (2026-04-25)     ← parens style (1 entry)
  ## v3.11.1 — 2026-04-21     ← em-dash style (canonical, majority)
  ## v3.11.0 — 2026-04-20     ← em-dash style (canonical, majority)

After: all 3 entries use the em-dash form (2 of 3 entries already used it,
so it's the canonical pattern by majority).

Only the v3.12.0 heading line changed. The body content under each
heading is untouched — only the date-format separator changes from
parens to em-dash.

Verification:
- `grep "^## " CHANGELOG.md` after the edit → all entries match
  `## vX.Y.Z — YYYY-MM-DD`.
- `git diff CHANGELOG.md` shows exactly one line changed.

Refs: audit side-finding 3 of 4.

Co-authored-by: dtzp555 <dtzp555@gmail.com>
Co-authored-by: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-05 09:54:04 +10:00
3d52ffc152 chore(deps): bump engines to >=22.5 to match node:sqlite usage in keys.mjs (#62)
OCP uses Node.js built-in SQLite (`node:sqlite`) in keys.mjs:3 for the
LAN-mode key store. The `node:sqlite` module is only available in:

- Node 22.5.0+ behind --experimental-sqlite flag
- Node 23.0.0+ without any flag (fully stable)

The previous `engines: ">=18"` was inaccurate and would have caused
opaque "Cannot find module 'node:sqlite'" failures for users on
Node 18-22.4 the moment LAN mode (multi-key) was enabled.

Changes:
- package.json: engines.node ">=18" → ">=22.5"
- README.md: prerequisite "Node.js 18+" → "Node.js 22.5+ (Node 23+ recommended …)"
  with a one-line note explaining the flag distinction so users on 22.x know
  they may need --experimental-sqlite.

Verification:
- Source usage of node:sqlite confirmed via grep: keys.mjs:3
  (`import { DatabaseSync } from "node:sqlite";`).
- Local node --version = v25.8.0 (well above 22.5); `npm install` exit 0
  with no engine-mismatch warning.
- No other source file imports node:sqlite (single point of usage).

Refs: audit side-finding 2 of 4.

Co-authored-by: dtzp555 <dtzp555@gmail.com>
Co-authored-by: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-05 09:53:46 +10:00
68b0838074 chore(deps): delete stale package-lock.json (was v3.4.0 vs package.json v3.12.0) (#61)
The repo-tracked package-lock.json declared "version": "3.4.0" while
package.json is at v3.12.0 — 8 minor versions of drift. Since
package.json has zero dependencies (only built-in node:sqlite, node:http,
node:https), the lockfile carried no useful information. It was pure
noise that would mislead anyone running `npm ci` into thinking they were
installing v3.4.0.

Verification:
- package.json has no `dependencies` or `devDependencies` field (grep -A2 '"dependencies"' package.json → no match).
- Fresh clone + checkout + `npm install` → exit 0, "audited 1 package in 93ms, found 0 vulnerabilities".
- node_modules is correctly empty after install (no bin shims to relink).

Refs: audit side-finding 1 of 4.

Co-authored-by: dtzp555 <dtzp555@gmail.com>
Co-authored-by: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-05 09:53:20 +10:00
313cb13a78 docs: align README + governance docs with current state (uninstall, files, ADR index, ship-archive) (#59)
Multiple documentation polish items rolled into one PR (one layer:
"docs alignment with current state").

### README.md

- **Uninstall section** added between Server Setup and Client Setup
  (was missing — `node uninstall.mjs` exists but went undocumented).
- **OpenClaw definition** added as a footnote on first README mention
  (the architecture diagram and Supported Tools table both reference
  OpenClaw without ever defining it).
- **Repository Layout section** added before Security — table of
  top-level files (server.mjs, setup.mjs, uninstall.mjs, keys.mjs,
  models.json, ocp/ocp-connect, dashboard.html, scripts/, .claude/skills/,
  ocp-plugin/, docs/adr/, ALIGNMENT.md, AGENTS.md, CLAUDE.md) so a new
  contributor knows what each file does.
- **LICENSE link** added to the License section footer.

### docs/adr/README.md (new)

- Index of the three published ADRs (0002, 0003, 0004) with a one-line
  description each.
- Explains the `0001` placeholder (early internal proposal that was
  superseded; numbering deliberately starts at `0002`).
- Guidance on when to write a new ADR vs. when a commit message suffices.

### Spec/plan housekeeping

- `specs/.gitkeep` removed (the empty `specs/` placeholder confused the
  picture; canonical paths are `docs/superpowers/plans/` for active plans
  and `docs/superpowers/specs/` for long-lived design docs that other
  code references).
- Shipped plans moved to `docs/superpowers/plans/shipped/`:
  - `2026-04-10-lan-mode.md` (shipped: README LAN mode section)
  - `2026-04-25-47-sse-heartbeat-plan.md` (shipped: v3.12.0 per CHANGELOG)
- `2026-04-25-47-sse-heartbeat-design.md` left in `docs/superpowers/specs/`
  unchanged because both `server.mjs:565` and `CHANGELOG.md:7` link to
  that exact path; moving it would require a `server.mjs` edit, which
  needs `cli.js` citation per ALIGNMENT.md Rule 1.

### AGENTS.md

- Updated "Key files to know" to add `docs/adr/README.md`,
  `docs/superpowers/plans/`, and `memory/constitution.md`.
- Note explaining `memory/constitution.md` is spec-kit's standard
  location, distinct from `~/.cc-rules/memory/` and `ALIGNMENT.md`.
- Updated "Handoff expectations" item 5 from `docs/superpowers/specs/*/tasks.md`
  (which never matched anything — there were no `tasks.md` files there)
  to `docs/superpowers/plans/` (excluding `shipped/`).

### Coordination with PR #53

PR #53 is open and adds "Why OCP?", "Comparison", and "Governance"
sections to README. This PR deliberately avoids those areas — only edits
the Supported Tools table footnote, inserts Uninstall before Client Setup,
inserts Repository Layout before Security, and updates the License footer.
No expected merge conflict.

Refs: audit findings M6, M8, M9, M11, L2, L3, L6.

Co-authored-by: dtzp555 <dtzp555@gmail.com>
2026-05-05 09:44:49 +10:00
e4b010af5e docs(readme): add Why OCP, comparison table, governance section (#53)
Adds three positioning sections to make the README convert clones-to-stars
better:

1. "Why OCP?" near the top — 6 differentiator bullets with evidence links
   (SSE heartbeat / ALIGNMENT.md / models.json SPOT / multi-key /
   per-key quota / ocp-connect).
2. "Comparison" subsection — honest table vs claude-code-router and
   anthropic-proxy. Acknowledges CCR's larger ecosystem; positions OCP
   as cli.js-aligned + subscription-multiplexing focused.
3. "Governance" section near the bottom — links to ALIGNMENT.md, AGENTS.md,
   ADRs, alignment.yml. Consolidates the governance-link surface in one
   place rather than scattered.

Net diff +43 -0. No content removed. No anchors broken. No code changes.

Co-authored-by: dtzp555 <dtzp555@gmail.com>
Co-authored-by: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-05 09:44:37 +10:00
0752f666fb test: wire test-features.mjs to npm test + add minimal CI smoke workflow (#60)
`test-features.mjs` shipped at v3.8.0 (per CHANGELOG of the keys.mjs
quota+cache work) and is referenced from AGENTS.md as the project's only
test artifact, but until now nothing actually ran it — no `npm test`
script, no CI step. Wiring it up so it runs on every push and PR.

### Changes

- `package.json`: add `"test": "node test-features.mjs"` to scripts.
- `.github/workflows/test.yml` (new): single-job workflow that runs
  `npm test` on push to main and on every PR. Uses Node 24 because
  `keys.mjs` imports `node:sqlite`, which is stable in Node 23+ (Node
  24 is the current LTS; Node 22 would need `--experimental-sqlite`).
  No `npm install` step — OCP has zero external runtime dependencies
  per `package-lock.json`.
- `AGENTS.md`: note that `test-features.mjs` runs via `npm test` and
  is enforced by `.github/workflows/test.yml`.

### Why this is a hard check, not a soft check

`test-features.mjs` is self-contained — it imports `keys.mjs` and
exercises the SQLite-backed key/quota/cache code paths against a
throwaway test DB at `~/.ocp/ocp-test.db`. It does NOT require:

- a live claude CLI binary
- a running OCP server
- any network access

So CI can run it as a real check; no `continue-on-error` needed.

### Local verification

```
$ npm test
[...]
=== Results: 24 passed, 0 failed ===
```

24 assertions cover createKey / listKeys / quota math / cache hash
determinism / cache TTL / clearCache. Exit code is 1 on any failure
(`process.exit(failed > 0 ? 1 : 0)` at the bottom of test-features.mjs).

### Future expansion

If the suite later grows to include tests that DO require a live claude
CLI or a running OCP, mark those steps `continue-on-error: true` (or
split them into a separate job). The comment in `test.yml` documents
this contract.

Refs: audit (test-features.mjs orphan / unrunnable in CI).

Co-authored-by: dtzp555 <dtzp555@gmail.com>
2026-05-05 09:44:25 +10:00
ae4a829904 chore(naming): rename package + code refs from openclaw-claude-proxy to OCP (#57)
The npm name `ocp` is squatted (deprecated `node-ocp` v0.0.1), so the
package name is renamed to `open-claude-proxy` (verified via
`npm view open-claude-proxy` → 404, confirming availability).

### Changes

- `package.json`:
  - `"name"`: `openclaw-claude-proxy` → `open-claude-proxy`
  - `"bin"` map UNCHANGED: both `openclaw-claude-proxy` and `ocp`
    binaries still resolve, preserving back-compat for existing installs
    that reference `openclaw-claude-proxy` as a CLI command.
- `setup.mjs`: header JSDoc + start.sh banner string
- `uninstall.mjs`: header JSDoc

### Intentionally NOT changed

- `server.mjs` startup banner (line 1628):
  `console.log('openclaw-claude-proxy v...')`. Editing `server.mjs`
  requires `cli.js:NNNN` citation per ALIGNMENT.md Rule 1, and a
  cosmetic log-string rename has no `cli.js` correspondence. Deferred
  — would need an ALIGNMENT.md scope justification PR if pursued.
- `bin/openclaw-claude-proxy` symlink/path: existing installs depend on
  this name; kept as a back-compat alias.

### Evidence

```
$ npm view ocp
ocp@0.0.1 | DEPRECATED — squatted by node-ocp
$ npm view open-claude-proxy
404 — available
```

Refs: audit (naming consistency).

Co-authored-by: dtzp555 <dtzp555@gmail.com>
2026-05-05 09:44:12 +10:00
51e908e145 chore(repo): remove broken/undocumented Docker files (#58)
The Dockerfile, docker-compose.yml, and .dockerignore are removed because
the Dockerfile is structurally broken AND Docker is not a documented
runtime for OCP.

### Evidence — Dockerfile is broken

The Dockerfile COPYs only 3 files:

```
COPY server.mjs ./
COPY setup.mjs ./
COPY package.json ./
```

But `setup.mjs:43` reads `models.json` at runtime
(`JSON.parse(readFileSync(join(__dirname, "models.json"), ...))`),
and `server.mjs` requires `keys.mjs`, `dashboard.html`, etc. The
container as built would crash on first run.

### Evidence — Docker is undocumented

```
$ grep -i docker README.md AGENTS.md CLAUDE.md
# 0 hits
```

No README install path, no AGENTS.md runtime mention, no CLAUDE.md
release-kit reference. These files were ghost infrastructure.

### Why delete vs. fix

Fixing the Dockerfile to actually work would require: COPYing 6+ files,
verifying the container can write to `~/.openclaw/`, deciding on auth
mounting (claude CLI binary + auth state), and adding documentation
across README/AGENTS/CLAUDE. None of that is justified by a request.

Easier to remove the dead files now and reintroduce a working
Dockerfile when there's an actual demand for containerised OCP, with
proper documentation alongside.

Refs: audit (dead infrastructure cleanup).

Co-authored-by: dtzp555 <dtzp555@gmail.com>
2026-05-05 09:44:00 +10:00
d99534dc35 chore(repo): add .gitignore for runtime artifacts; commit scripts/heartbeat-field-check.sh (#55)
Two related changes — both repo-hygiene, no behavior impact.

### `.gitignore` (new)

Ignores runtime artifacts that should never be tracked:

- `logs/` and `*.log` — proxy.log, last_send.log, heartbeat-field-check log
- `node_modules/` — dependency cache
- `.env`, `.env.*` — local secrets/config
- `.DS_Store`, `*.swp`, `*~` — editor/OS scratch

`logs/` was previously untracked-but-present in working trees; the new
ignore makes that intent explicit and prevents accidental commits.

### `scripts/heartbeat-field-check.sh` (commit existing untracked file)

This script was authored as a one-shot field-evidence gatherer for the
v3.12.0 SSE heartbeat work (PR #49 / issue #47). It fired successfully on
2026-05-02 09:00 Australia/Brisbane via launchd
(`~/Library/LaunchAgents/dev.ocp.heartbeat-check.plist`) and posted a
summary comment to issue #47.

Useful tooling pattern (one-shot field check + launchd schedule + dry-run
flag), worth keeping under version control rather than letting it die in
an untracked working tree.

Verification: `git status` clean after both adds.

Co-authored-by: dtzp555 <dtzp555@gmail.com>
2026-05-05 09:43:47 +10:00
39ca20536e docs(governance): correct CLAUDE.md + AGENTS.md to reflect actual alignment.yml blacklist (#56)
Both `CLAUDE.md` (line 25) and `AGENTS.md` (line 46) previously claimed the
`alignment.yml` blacklist included two tokens — `api/oauth/usage` and
`api/usage`. The actual workflow has a single token in `BLACKLIST`:
`api.anthropic.com/api/oauth/usage`.

Doc-vs-CI drift. Fix is doc-side only — `alignment.yml` itself is unchanged
because adding a second blacklist token (`api/usage`) is a constitutional
change that requires an `ALIGNMENT.md` amendment PR per ALIGNMENT.md
Amendment Procedure.

If `api/usage` should be added to the blacklist, that's a separate PR with
ALIGNMENT.md amendment + reviewer sign-off.

Refs: audit findings (governance doc accuracy).

Co-authored-by: dtzp555 <dtzp555@gmail.com>
2026-05-05 09:43:34 +10:00
8b3f50912e chore(repo): remove v2.x stale openclaw-claude-proxy/ folder + dead start.sh (#54)
Both removed:

- `openclaw-claude-proxy/` — v2.4.0 stale duplicate of the proxy. Current
  proxy is `server.mjs` at repo root (v3.x); the nested folder was an early
  packaging artifact that never got deleted. Audit finding H1.
- `start.sh` — hardcoded `/Users/taodeng/.openclaw/...` paths that only ever
  worked on the original maintainer's home directory. The real install is
  produced by `setup.mjs` (see setup.mjs:212-237 which writes the correct
  per-user start hook). Audit findings H5, H6 (path leak + dead script).

Verification: `git grep "/Users/taodeng/"` returns 0 hits in tracked files.

Refs: audit findings H1, H5, H6.

Co-authored-by: dtzp555 <dtzp555@gmail.com>
2026-05-05 09:43:05 +10:00
780462c763 fix(cli): set DISABLE_AUTOUPDATER=1 in manual restart fallback (#52)
When ocp restart falls back to nohup (no launchctl/systemd
service registered), prepend DISABLE_AUTOUPDATER=1 so the
spawned server.mjs and any claude subprocess it spawns skip
the in-binary auto-updater check.

Why: claude-code's native binary contains an auto-updater
that fires after each successful invocation. Partial
install.cjs failures during update leave bin/claude.exe as
an ASCII shim → spawnSync ENOEXEC → OCP slot lockup
(symptoms in #37, #40 forensics). Setting this env at the
launch site stops the trigger.

Note: only covers the manual nohup path. Users with
launchctl plist or systemd unit must add the env there too
(plist EnvironmentVariables or systemd Environment=).
Authoritative settings location is ~/.claude/settings.json
env key — see learnings/claude_code_disable_autoupdate.md
in cc-rules for the full 4-layer guidance.

Verified: 24h+ stable on Mac mini after applying full
multi-layer fix on 2026-04-30 04:20 (claude.exe mtime
unchanged, OCP uptime 22h54m, 0 errors over 3 real calls).

No server.mjs change — ALIGNMENT.md unaffected.

Co-authored-by: dtzp555 <dtzp555@gmail.com>
2026-05-01 17:36:47 +10:00
733a2ed4c2 chore(server): add session-create-vs-resume + lifetime instrumentation (refs #41 #42) (#51)
Adds structured logging for the two forensic candidates so the eventual
fixes can be evidence-driven (per #41/#42 bodies: "Evidence needed before
code change — do not patch speculatively"). NO bug fix in this PR — the
stale-create-entry behavior (#41) and the lastUsed-resistant-expiry
behavior (#42) are intentionally left unchanged so they can be observed
in production /logs.

Three changes (server.mjs):

1. Session-create branch records `firstSeen` alongside `lastUsed` (same
   timestamp), giving the sweep loop an absolute-age signal independent
   of the actively-bumped `lastUsed` field. Resume branch is untouched
   so `firstSeen` is preserved across resumes.

2. handleSessionFailure now emits a structured `session_failure` event
   with `mode: "resume"` (action: "deleted") OR `mode: "create"`
   (action: "kept"). The "kept" branch makes #41's "stale create entries
   never deleted" pattern visible in /logs without changing behavior.

3. TTL sweep emits `session_expired` (info, with idleMs + ageMs) on
   actual expiry, and `session_long_lived` (warn) when ageMs > 4×TTL
   but the entry is not being expired this tick. The latter is the
   #42 evidence trigger — a session whose lastUsed keeps getting bumped
   and so resists idle-based expiry.

cli.js citation: N/A — logEvent payloads are OCP-internal observability,
never sent on the wire to the Anthropic API. ALIGNMENT.md Rule 2 (no
invention) is scoped to endpoints/headers/request/response fields, not
to internal server logging.

Independent reviewer: fresh-context sonnet APPROVE_WITH_MINOR.
Two minor notes — repeated `session_long_lived` emissions per 60s
sweep tick is expected (downstream analysis dedupes by conversationId).
LOC: +19 / -3 = +16 net.

Co-authored-by: Claude Opus 4.7 <noreply@anthropic.com>
2026-04-25 19:19:38 +10:00
dtzp555-maxandGitHub ca2d23d230 chore(speckit): add compatibility/metadata frontmatter to SKILL.md files (refs #39) (#50)
Backfill the two YAML frontmatter blocks that upstream
`specify_cli/agents.py:build_skill_frontmatter()` emits:

  compatibility: "Requires spec-kit project structure with .specify/ directory"
  metadata:
    author: github-spec-kit
    source: claude:templates/commands/<cmd>.md

Applied uniformly across all 9 speckit-* SKILL.md files. Each file gets the
two blocks appended to its existing frontmatter (between the closing fields
and the closing `---`). No functional change — these fields are informational
only, per #39 body and PR #38 reviewer note.

Verified:
- All 9 files have `compatibility:` key (grep -lc, 9/9)
- All 9 files have `metadata:` block with `author: github-spec-kit` (9/9)
- All 9 files have `source: claude:templates/commands/<cmd>.md` matching the
  command name (9/9)
- No existing fields modified or removed; pure additive +4 lines per file

Upstream reference (build_skill_frontmatter):
https://github.com/github/spec-kit/blob/main/src/specify_cli/agents.py
2026-04-25 19:19:35 +10:00
b871b72b6b feat(server): SSE heartbeat on streaming path (#47) (#49)
* docs(spec): design for #47 SSE heartbeat on streaming path

Draft spec for an opt-in idle-watchdog SSE heartbeat covering both
pre-first-byte and mid-stream silent windows. Default disabled,
controlled by CLAUDE_HEARTBEAT_INTERVAL. Targets ~40 LOC.

Decisions captured: D1 whole-stream reset-on-byte; D2 SSE comment
frame; D3 default disabled; D4 relocate ensureHeaders() to post-spawn;
D5 X-Accel-Buffering: no on both SSE header sites; D6 single log line
per affected request.

Scope-locked: does not touch CLAUDE_TIMEOUT semantics, the separate
server.mjs:480-489 dangling-client bug, issues #41/#42, or the
non-streaming path.

Includes privacy preflight and cloud-testing plan per maintainer
feedback.

Refs: #47

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

* docs(plan): implementation plan for #47 SSE heartbeat

6 phases: pre-work (file 480-bug), implementation (5 tasks on
feat/47-sse-heartbeat), opus fresh-context review, cloud verification
on Mac rig, privacy preflight, PR+release.

Each implementation task carries concrete code, syntax check, and a
scoped commit message. LOC budget enforced in Task 1.6 gate (~45
server.mjs lines max). Reviewer checklist scopes scope-lock, ALIGNMENT,
privacy, and heartbeat-cannot-abort discipline.

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

* feat(server): add startHeartbeat helper + HEARTBEAT_INTERVAL env var

Per design doc (refs #47). Helper is a per-request idle watchdog that
emits `: keepalive\n\n` SSE comment frames; returns a {reset, stop} handle.
No wiring yet — helper is unused, safe to commit in isolation.

cli.js citation: N/A — SSE response shaping is an OCP-owned translation
layer, not a cli.js operation. See AGENTS.md and ALIGNMENT.md Rule 2.

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

* refactor(server): eagerly send SSE headers post-spawn (D4, refs #47)

Moves the ensureHeaders() call from "on first stdout byte" to
"immediately after successful spawn." This is a prerequisite for the
heartbeat covering the pre-first-byte silent window (the 'processing
large contexts' failure mode in #47).

Behavioral consequence: the narrow "spawn succeeded but subprocess died
before any byte" branch at server.mjs:610-611 becomes effectively dead
in the common case. The post-headers SSE-stop path (612-619) handles
it instead. The branch remains defensively for the client-closed-before-
ensureHeaders race.

Isolated commit per design doc §D4 so reviewer can focus on this one
behavior change.

cli.js citation: N/A — SSE header emission is OCP response-shaping.

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

* feat(server): wire heartbeat into streaming path (refs #47)

- sendSSE() accepts optional hb handle and calls hb.reset() before write
- callClaudeStreaming starts heartbeat after ensureHeaders() and passes
  hb to the three streaming sendSSE call sites
- All three exit paths (proc close, proc error, res close) call
  hb.stop() to guarantee timer cleanup; no-op handle when disabled
  means zero runtime cost when CLAUDE_HEARTBEAT_INTERVAL=0

Heartbeat never aborts — only writes comment frames and re-arms. Aligns
with v3.3 timeout discipline (single CLAUDE_TIMEOUT, no secondary
client-killing timers).

cli.js citation: N/A — SSE response shaping is OCP translation layer.

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

* feat(server): add X-Accel-Buffering: no to SSE response headers (refs #47)

nginx (and many LBs / Cloudflare) default to proxy_buffering=on, which
would buffer heartbeat comment frames indefinitely and defeat the
feature silently. This header hints no-buffering; other stacks ignore
it. Applied at both SSE header sites (real streaming + cache-hit).

cli.js citation: N/A — response header shaping is OCP translation layer.

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

* chore(release): v3.12.0 — streaming heartbeat (refs #47)

Bundles the release-kit companion files per Iron Rule 5.2 / 11 example:
version bump across package.json + ocp-plugin + openclaw.plugin.json,
CHANGELOG v3.12.0 section, README env var row + "Streaming heartbeat"
explainer.

Tag push to v3.12.0 triggers .github/workflows/release.yml to create
the GitHub Release automatically.

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

* fix(server): ensureHeaders returns true when already sent (refs #47)

Phase 3 smoke test revealed every content chunk was being dropped
after the D4 eager ensureHeaders() call: the stdout.on('data') guard
`if (!ensureHeaders()) return;` early-returned on every chunk because
ensureHeaders returned false for the already-sent case (conflated with
the dead-connection case).

Split the two conditions explicitly: return false only when res is
ended/destroyed; return true when headers are (already or now) sent.
This also fixes a latent multi-chunk bug on main that was masked
because claude CLI typically outputs in one stdout chunk.

Verified: node -c server.mjs; subsequent re-run of Phase 3 smoke test
now sees streaming content chunks + heartbeat frames.

cli.js citation: N/A — SSE response shaping is OCP translation layer.

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

---------

Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-25 09:09:47 +10:00
cff06439fa chore(privacy): remediation follow-up from ocp#44 (#45)
Three-part follow-up from the 2026-04-22 privacy postmortem:

1. OAUTH_CLIENT_ID verified as public Claude Code constant (not a secret).
   Added gitleaks allowlist entry. The value 9d1c250a-e61b-44d9-88ed-5944d1962f5e
   is the public PKCE client ID used by the Claude Code CLI — public clients in
   PKCE flows have no client secret, so the ID itself carries no secret value.
   Introduced in commit b87992f (the 2026-04-11 drift incident); the constant
   mirrors what cli.js embeds for its OAuth token-refresh flow.

2. README.md API key example made clearly fake (ocp_example12345abcde...) to
   avoid gitleaks false-positives and clarify to readers it is not real.
   The ocp_ prefix IS the real prefix (keys.mjs line 80: randomBytes(24).base64url),
   so the prior example could be mistaken for a real truncated key.

3. PR template: added Privacy self-check section for PUBLIC repos below the
   existing 5.3 user-visible change self-check section.

4. .gitleaks.toml: new file with allowlist for the three confirmed non-issues
   (OAUTH_CLIENT_ID constant, README placeholder regex, old plan doc path).

No code or behavior change beyond the docs, README, and new config file.

Closes part of ocp#44.

Co-authored-by: Tao Deng <dtzp555@gmail.com>
Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-22 11:40:50 +10:00
1c29f4867f chore(privacy): scrub personal identifiers from public repo (#43)
Removes all occurrences of maintainer's real name and handle from
tracked files in this PUBLIC repository. Replaces with generic terms
like "project maintainer" / "the maintainer".

Files scrubbed:
- CLAUDE.md (2 instances)
- ALIGNMENT.md (Auditor field)
- docs/adr/0002, 0003, 0004 (Authors/Deciders fields + body text)
- memory/constitution.md (1 instance)
- docs/superpowers/plans/2026-04-10-lan-mode.md (Users/taodeng paths → $HOME)

Also:
- Replaces specific machine hostnames (Taos-Mac-mini, Taos-MacBook-Pro)
  with role-based names (home-mac, travel-macbook) in ADR 0001 (if
  committed; currently untracked).
- Replaces /Users/taodeng/ paths with $HOME/ in old plan doc.

NOTE: git history still contains the personal info. See postmortem
(issue TBD) for whether to rewrite history via git-filter-repo.

Co-authored-by: Tao Deng <dtzp555@gmail.com>
Co-authored-by: Claude Opus 4.7 <noreply@anthropic.com>
2026-04-22 11:01:44 +10:00
49 changed files with 2664 additions and 1121 deletions
+4
View File
@@ -4,6 +4,10 @@ description: Perform a non-destructive cross-artifact consistency and quality an
argument-hint: "Optional focus areas for analysis" argument-hint: "Optional focus areas for analysis"
user-invocable: true user-invocable: true
disable-model-invocation: false disable-model-invocation: false
compatibility: "Requires spec-kit project structure with .specify/ directory"
metadata:
author: github-spec-kit
source: claude:templates/commands/analyze.md
--- ---
## User Input ## User Input
@@ -4,6 +4,10 @@ description: Generate a custom checklist for the current feature based on user r
argument-hint: "Domain or focus area for the checklist" argument-hint: "Domain or focus area for the checklist"
user-invocable: true user-invocable: true
disable-model-invocation: false disable-model-invocation: false
compatibility: "Requires spec-kit project structure with .specify/ directory"
metadata:
author: github-spec-kit
source: claude:templates/commands/checklist.md
--- ---
## Checklist Purpose: "Unit Tests for English" ## Checklist Purpose: "Unit Tests for English"
+4
View File
@@ -4,6 +4,10 @@ description: Identify underspecified areas in the current feature spec by asking
argument-hint: "Optional areas to clarify in the spec" argument-hint: "Optional areas to clarify in the spec"
user-invocable: true user-invocable: true
disable-model-invocation: false disable-model-invocation: false
compatibility: "Requires spec-kit project structure with .specify/ directory"
metadata:
author: github-spec-kit
source: claude:templates/commands/clarify.md
--- ---
## User Input ## User Input
@@ -4,6 +4,10 @@ description: Create or update the project constitution from interactive or provi
argument-hint: "Principles or values for the project constitution" argument-hint: "Principles or values for the project constitution"
user-invocable: true user-invocable: true
disable-model-invocation: false disable-model-invocation: false
compatibility: "Requires spec-kit project structure with .specify/ directory"
metadata:
author: github-spec-kit
source: claude:templates/commands/constitution.md
--- ---
## User Input ## User Input
@@ -4,6 +4,10 @@ description: Execute the implementation plan by processing and executing all tas
argument-hint: "Optional implementation guidance or task filter" argument-hint: "Optional implementation guidance or task filter"
user-invocable: true user-invocable: true
disable-model-invocation: false disable-model-invocation: false
compatibility: "Requires spec-kit project structure with .specify/ directory"
metadata:
author: github-spec-kit
source: claude:templates/commands/implement.md
--- ---
## User Input ## User Input
+4
View File
@@ -4,6 +4,10 @@ description: Execute the implementation planning workflow using the plan templat
argument-hint: "Optional guidance for the planning phase" argument-hint: "Optional guidance for the planning phase"
user-invocable: true user-invocable: true
disable-model-invocation: false disable-model-invocation: false
compatibility: "Requires spec-kit project structure with .specify/ directory"
metadata:
author: github-spec-kit
source: claude:templates/commands/plan.md
--- ---
## User Input ## User Input
+4
View File
@@ -4,6 +4,10 @@ description: Create or update the feature specification from a natural language
argument-hint: "Describe the feature you want to specify" argument-hint: "Describe the feature you want to specify"
user-invocable: true user-invocable: true
disable-model-invocation: false disable-model-invocation: false
compatibility: "Requires spec-kit project structure with .specify/ directory"
metadata:
author: github-spec-kit
source: claude:templates/commands/specify.md
--- ---
## User Input ## User Input
+4
View File
@@ -4,6 +4,10 @@ description: Generate an actionable, dependency-ordered tasks.md for the feature
argument-hint: "Optional task generation constraints" argument-hint: "Optional task generation constraints"
user-invocable: true user-invocable: true
disable-model-invocation: false disable-model-invocation: false
compatibility: "Requires spec-kit project structure with .specify/ directory"
metadata:
author: github-spec-kit
source: claude:templates/commands/tasks.md
--- ---
## User Input ## User Input
@@ -4,6 +4,10 @@ description: Convert existing tasks into actionable, dependency-ordered GitHub i
argument-hint: "Optional filter or label for GitHub issues" argument-hint: "Optional filter or label for GitHub issues"
user-invocable: true user-invocable: true
disable-model-invocation: false disable-model-invocation: false
compatibility: "Requires spec-kit project structure with .specify/ directory"
metadata:
author: github-spec-kit
source: claude:templates/commands/taskstoissues.md
--- ---
## User Input ## User Input
-8
View File
@@ -1,8 +0,0 @@
.git
.gitignore
*.md
node_modules
.env
.env.*
scripts/
start.sh
+9
View File
@@ -0,0 +1,9 @@
# GitHub recognizes this file and shows a "Sponsor" button on the repo page.
# Add other platforms here as they get set up. Empty / commented-out entries
# are skipped silently.
buy_me_a_coffee: dtzp555
# github: [dtzp555-max] # uncomment after GitHub Sponsors enrollment is approved
# ko_fi: dtzp555
# custom: ["https://example.com/donate"]
+9
View File
@@ -45,3 +45,12 @@ Reviewers: this section is for you, not the author. Do not approve until every b
- [ ] This PR has no user-visible changes → stated "no user-visible change" in summary above - [ ] This PR has no user-visible changes → stated "no user-visible change" in summary above
Reviewers: if "user-visible" is checked but README diff is empty, block merge (per 5.3 reviewer gate). Reviewers: if "user-visible" is checked but README diff is empty, block merge (per 5.3 reviewer gate).
### Privacy self-check (for PUBLIC repos) — Iron Rule adjacent
- [ ] This PR does not introduce real names, nicknames, or handles that identify specific individuals. All references use role-based terms (`project maintainer`, `contributor`, `user`, `reviewer`).
- [ ] This PR does not introduce literal personal paths (`/Users/<username>/`, `/home/<username>/`). Uses `$HOME/` or `~/` instead.
- [ ] This PR does not introduce personal machine hostnames. Uses role-based names or generic descriptors.
- [ ] This PR does not introduce personal email addresses beyond automated placeholders like `noreply@<vendor>.com`.
Reviewers: if any of the above is violated and the repo is PUBLIC, block merge and request scrub.
+32
View File
@@ -0,0 +1,32 @@
name: gitleaks
# Secret scanning gate. Runs on every PR (any branch) and every push to main.
# Configuration is read from the repo-root `.gitleaks.toml` automatically.
# Hard-fails the build on any detected leak — public repo, no tolerance.
#
# To extend the allowlist (e.g. a new known-safe placeholder), edit
# `.gitleaks.toml`. Do not add `continue-on-error` here without an explicit
# governance decision.
on:
push:
branches: [main]
pull_request:
permissions:
contents: read
jobs:
scan:
name: gitleaks scan (hard fail)
runs-on: ubuntu-latest
steps:
- name: Checkout (full history)
uses: actions/checkout@v4
with:
fetch-depth: 0
- name: Run gitleaks
uses: gitleaks/gitleaks-action@v2
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
+41
View File
@@ -0,0 +1,41 @@
name: Tests
on:
push:
branches: [main]
pull_request:
jobs:
test-features:
name: test-features.mjs (smoke)
runs-on: ubuntu-latest
# `test-features.mjs` is self-contained — it runs assertions against the
# `keys.mjs` DB layer using a throwaway test database. It does NOT need a
# live claude CLI or a running OCP server. So this job runs as a hard
# check on every push / PR.
#
# If a future expansion of the suite adds tests that DO require a live
# claude CLI or a running OCP server, mark those steps `continue-on-error:
# true` (or split them into a separate job) — CI must not be flaky on
# things outside the contributor's machine.
steps:
- name: Checkout
uses: actions/checkout@v4
- name: Setup Node
uses: actions/setup-node@v4
with:
# Node 24 ships `node:sqlite` as stable. The test imports keys.mjs,
# which uses `import { DatabaseSync } from "node:sqlite"`.
# Node 22 also works with `--experimental-sqlite`, but we run on 24
# to keep the CI step simple and to match what released OCP runs on.
node-version: '24'
# OCP has zero runtime npm dependencies (package-lock.json shows only
# the project's own package and zero external entries). No install
# step needed — `node:*` modules are built into Node 24.
- name: Run test-features.mjs
run: npm test
+15
View File
@@ -0,0 +1,15 @@
# Runtime artifacts
logs/
*.log
# Dependencies
node_modules/
# Environment files
.env
.env.*
# Editor / OS scratch
.DS_Store
*.swp
*~
+14
View File
@@ -0,0 +1,14 @@
title = "OCP gitleaks configuration"
[allowlist]
description = "OCP known-safe allowlist"
regexes = [
# Public OAuth client ID for Claude Code PKCE flow (constant, not a secret)
'''9d1c250a-e61b-44d9-88ed-5944d1962f5e''',
# OCP README API key placeholder example
'''ocp_(example|XXX+|xxxx+)''',
]
paths = [
# Old plan doc with test-admin-123 placeholders
'''docs/superpowers/plans/2026-04-10-lan-mode\.md''',
]
+6 -4
View File
@@ -22,7 +22,7 @@ Runtime: Node.js (ESM, `.mjs` throughout). No build step. No bundler. `server.mj
- `models.json` as the single source of truth for model metadata - `models.json` as the single source of truth for model metadata
- GitHub Actions for CI (`alignment.yml`, `release.yml`) - GitHub Actions for CI (`alignment.yml`, `release.yml`)
- `gh` CLI assumed for PR creation and release automation - `gh` CLI assumed for PR creation and release automation
- No TypeScript. No test framework beyond `test-features.mjs`. Keep dependencies minimal. - No TypeScript. No test framework beyond `test-features.mjs` (run via `npm test`; CI workflow `.github/workflows/test.yml`). Keep dependencies minimal.
--- ---
@@ -36,14 +36,16 @@ Runtime: Node.js (ESM, `.mjs` throughout). No build step. No bundler. `server.mj
- `ALIGNMENT.md` — the constitution. Binding for any `server.mjs` change. See ADR 0002. - `ALIGNMENT.md` — the constitution. Binding for any `server.mjs` change. See ADR 0002.
- `.github/workflows/alignment.yml` — CI blacklist grep; fails the build on known-hallucinated tokens. - `.github/workflows/alignment.yml` — CI blacklist grep; fails the build on known-hallucinated tokens.
- `CLAUDE.md` — Claude-Code-specific session instructions + release_kit overlay (Iron Rule 5.5). - `CLAUDE.md` — Claude-Code-specific session instructions + release_kit overlay (Iron Rule 5.5).
- `docs/adr/` — Architecture Decision Records. Read these before proposing governance or SPOT changes. - `docs/adr/` — Architecture Decision Records. Read these before proposing governance or SPOT changes. See `docs/adr/README.md` for the index.
- `docs/superpowers/plans/` — active spec-kit plans. `docs/superpowers/plans/shipped/` archives plans that have been delivered (don't propose changes against shipped plans — they're history). `docs/superpowers/specs/` holds long-lived design documents that other code references (e.g., the SSE heartbeat design referenced from `server.mjs`).
- `memory/constitution.md` — spec-kit's project constitution (its standard `memory/` location). Distinct from `~/.cc-rules/memory/` (cross-machine personal memory) and from this repo's `ALIGNMENT.md` (the OCP code-level constitution).
--- ---
## Project-specific constraints ## Project-specific constraints
- **`ALIGNMENT.md` is binding.** Any PR touching `server.mjs` must cite `cli.js:NNNN` (or `cli.js vE4 <functionName>`) in the commit body and PR description. See `CLAUDE.md` § "Hard requirements for `server.mjs` changes" and ADR 0002. - **`ALIGNMENT.md` is binding.** Any PR touching `server.mjs` must cite `cli.js:NNNN` (or `cli.js vE4 <functionName>`) in the commit body and PR description. See `CLAUDE.md` § "Hard requirements for `server.mjs` changes" and ADR 0002.
- **Alignment CI is not suppressible.** The `alignment.yml` workflow greps `server.mjs` for known-hallucinated tokens (including `api/oauth/usage`, `api/usage`). Adding to the blacklist is fine; removing entries requires an `ALIGNMENT.md` amendment PR. - **Alignment CI is not suppressible.** The `alignment.yml` workflow greps `server.mjs` for known-hallucinated tokens (currently blocking `api.anthropic.com/api/oauth/usage`). Adding new tokens is done via PR amendment to `alignment.yml`; removing entries requires an `ALIGNMENT.md` amendment PR.
- **No self-approval.** Implementation author cannot merge their own PR (Iron Rule 10). A fresh-context reviewer must open `cli.js` at the cited lines and confirm in the review comment. - **No self-approval.** Implementation author cannot merge their own PR (Iron Rule 10). A fresh-context reviewer must open `cli.js` at the cited lines and confirm in the review comment.
- **`models.json` is the only place to add/edit models.** Do not touch `MODEL_MAP` or `MODELS` arrays directly in `server.mjs` or `setup.mjs`. See ADR 0003. - **`models.json` is the only place to add/edit models.** Do not touch `MODEL_MAP` or `MODELS` arrays directly in `server.mjs` or `setup.mjs`. See ADR 0003.
- **OpenClaw boundary.** `scripts/sync-openclaw.mjs` only writes `models.providers["claude-local"].models` and `agents.defaults.models["claude-local/*"]` in `~/.openclaw/openclaw.json`. Do not expand scope. See ADR 0004. - **OpenClaw boundary.** `scripts/sync-openclaw.mjs` only writes `models.providers["claude-local"].models` and `agents.defaults.models["claude-local/*"]` in `~/.openclaw/openclaw.json`. Do not expand scope. See ADR 0004.
@@ -66,7 +68,7 @@ A fresh session picking up OCP work should read, in order:
2. `ALIGNMENT.md` — constitution; non-optional. 2. `ALIGNMENT.md` — constitution; non-optional.
3. `CLAUDE.md` — tool-specific instructions and release_kit overlay. 3. `CLAUDE.md` — tool-specific instructions and release_kit overlay.
4. `docs/adr/` — most recent ADRs first; they explain why the current structure exists. 4. `docs/adr/` — most recent ADRs first; they explain why the current structure exists.
5. Any active spec under `docs/superpowers/specs/*/tasks.md` (if present). 5. Any active plan under `docs/superpowers/plans/` (excluding `shipped/` which is the archive).
6. `~/.cc-rules/memory/auto/MEMORY.md` — cross-machine memory index. 6. `~/.cc-rules/memory/auto/MEMORY.md` — cross-machine memory index.
Only after these should the session touch code. Only after these should the session touch code.
+1 -1
View File
@@ -44,7 +44,7 @@ OCP (Open Claude Proxy) is a **proxy layer** for the Claude Code CLI. It forward
- **Claude Code version under audit:** `2.1.89` - **Claude Code version under audit:** `2.1.89`
- **`cli.js` SHA-256:** `a9950ef6407fdc750bddb673852485500387e524a99d42385cb81e7d17128e01` - **`cli.js` SHA-256:** `a9950ef6407fdc750bddb673852485500387e524a99d42385cb81e7d17128e01`
- **Audit date:** `2026-04-20` - **Audit date:** `2026-04-20`
- **Auditor:** `Tao Deng` - **Auditor:** `project maintainer`
The audit pin is updated once per year (see Annual Alignment Audit) and whenever a drift incident forces a re-verification. The audit pin is updated once per year (see Annual Alignment Audit) and whenever a drift incident forces a re-verification.
+39
View File
@@ -1,5 +1,44 @@
# Changelog # Changelog
## v3.13.0 — 2026-05-07
### Features (cache layer hardening)
- **Per-key cache isolation** (D1) — the cache key now includes the API key id, so distinct keys never share cache entries. Anonymous/unauthenticated callers share one `anon` pool. Hash format upgraded to `v2`; legacy v1-format rows orphan and are reaped by the existing TTL cleanup interval (no migration script).
- **`cache_control` bypass** (D2) — when a request carries an Anthropic `cache_control` annotation (top-level or nested in a content array), OCP skips its own cache entirely. The caller is using Anthropic-side prompt caching deliberately, and OCP must not interfere. A `cache_skipped{reason: cache_control_present}` log line is emitted on bypass.
- **Chunked stream replay** (D3) — when a streaming request hits the cache, the cached content is now emitted as multiple SSE chunks (80 codepoints/chunk, codepoint-safe via `Array.from()`) instead of a single large delta. Multibyte characters (CJK / emoji) stay intact.
- **Singleflight stampede protection** (D4) — concurrent identical cache-miss requests now share one upstream `cli.js` spawn instead of spawning N processes. Followers receive byte-identical responses to what the leader returns. All-or-nothing failure semantics: if the leader errors, all followers receive the same error. Streaming-path singleflight is explicitly out of scope (TODO left for follow-up).
### Behavior changes
- `/cache/stats` response now includes additive fields `inflight` and `requesters` (current in-flight singleflight entries and total waiting callers). Existing fields `entries`, `totalHits`, `sizeBytes` are preserved unchanged.
### Governance
- New ADR [`docs/adr/0005-no-multi-provider.md`](docs/adr/0005-no-multi-provider.md): OCP stays single-provider (Anthropic via `cli.js` spawn). Multi-provider gateway refactor explicitly out of scope; cache improvements are explicitly in scope.
- Design spec for this release: [`docs/superpowers/specs/2026-05-07-cache-upgrade-design.md`](docs/superpowers/specs/2026-05-07-cache-upgrade-design.md).
### No new env vars / no public API surface change
This release adds no new env vars or endpoints. All four improvements are internal correctness/concurrency upgrades to the existing `CLAUDE_CACHE_TTL`-gated cache layer. No client-observable wire shape change.
## v3.12.0 — 2026-04-25
### Features
- **Streaming heartbeat** — opt-in SSE comment frame (`: keepalive\n\n`) emitted during silent windows on the streaming response. Controlled by `CLAUDE_HEARTBEAT_INTERVAL` env var (ms; `0` = disabled, default). Covers both pre-first-byte and mid-stream tool-use pauses. Addresses #47. See [design doc](docs/superpowers/specs/2026-04-25-47-sse-heartbeat-design.md).
- **`X-Accel-Buffering: no`** response header added to SSE responses so heartbeats survive nginx/Cloudflare default buffering.
### Behavior changes
- SSE headers are now sent immediately after the claude CLI spawns successfully, not on first stdout byte. The rare "spawn succeeded but subprocess died before any byte" path now closes the SSE stream cleanly rather than returning a JSON error.
### Config additions
| Variable | Default | Description |
|---|---|---|
| `CLAUDE_HEARTBEAT_INTERVAL` | `0` (disabled) | Interval in ms for SSE keepalive comment frames on streaming path. Resets on every real frame. |
## v3.11.1 — 2026-04-21 ## v3.11.1 — 2026-04-21
### Fixes ### Fixes
+3 -3
View File
@@ -22,7 +22,7 @@
Every PR that modifies `server.mjs` must satisfy all three of the following. A PR missing any one of them is blocked from merge. Every PR that modifies `server.mjs` must satisfy all three of the following. A PR missing any one of them is blocked from merge.
1. **`cli.js` citation.** The commit message and PR body declare the corresponding `cli.js` function name and line number range, using the format `cli.js:NNNN` or `cli.js vE4 <functionName>`. If `cli.js` does not perform the operation, the PR must state this explicitly and justify scope under `ALIGNMENT.md` Rule 2 (in practice, this almost always means the PR should be closed). 1. **`cli.js` citation.** The commit message and PR body declare the corresponding `cli.js` function name and line number range, using the format `cli.js:NNNN` or `cli.js vE4 <functionName>`. If `cli.js` does not perform the operation, the PR must state this explicitly and justify scope under `ALIGNMENT.md` Rule 2 (in practice, this almost always means the PR should be closed).
2. **CI blacklist pass.** The `alignment.yml` workflow must pass. The workflow greps `server.mjs` for known-hallucinated tokens (including `api/oauth/usage` and `api/usage`) and fails the build on any hit. Do not suppress the workflow. Do not add allowlist entries without an amendment PR to `ALIGNMENT.md`. 2. **CI blacklist pass.** The `alignment.yml` workflow must pass. The workflow greps `server.mjs` for known-hallucinated tokens (currently blocking `api.anthropic.com/api/oauth/usage`) and fails the build on any hit. New tokens are added via PR amendment to `alignment.yml`; removing entries requires an `ALIGNMENT.md` amendment PR. Do not suppress the workflow.
3. **Independent reviewer (Iron Rule 10).** The implementation author may not self-approve. A separate reviewer — human or a subagent spawned with a fresh context — must read the diff, verify the `cli.js` citation by opening `cli.js` at the cited lines, and explicitly approve. A review comment that does not confirm the `cli.js` citation was checked is not a valid approval. 3. **Independent reviewer (Iron Rule 10).** The implementation author may not self-approve. A separate reviewer — human or a subagent spawned with a fresh context — must read the diff, verify the `cli.js` citation by opening `cli.js` at the cited lines, and explicitly approve. A review comment that does not confirm the `cli.js` citation was checked is not a valid approval.
--- ---
@@ -35,7 +35,7 @@ This repo operates under the CC Development Iron Rules (CC 开发铁律) v1.3. T
- **Iron Rule 11 (Incremental Diff Review).** Non-trivial work is split into the minimum reviewable unit — one PR per layer per severity. `ALIGNMENT.md`, `CLAUDE.md`, the PR template, and the CI workflow are therefore shipped as the same constitutional PR (they are one layer: governance), but any subsequent `server.mjs` remediation lands as its own PR. - **Iron Rule 11 (Incremental Diff Review).** Non-trivial work is split into the minimum reviewable unit — one PR per layer per severity. `ALIGNMENT.md`, `CLAUDE.md`, the PR template, and the CI workflow are therefore shipped as the same constitutional PR (they are one layer: governance), but any subsequent `server.mjs` remediation lands as its own PR.
- **Iron Rule 12 (Pre-Brainstorm Prior-Art Search).** Before proposing any new endpoint or header, search GitHub, Anthropic docs, and the `cli.js` bundle. For OCP specifically, the `cli.js` grep is the decisive search: if it does not hit, Rule 2 of the constitution applies. - **Iron Rule 12 (Pre-Brainstorm Prior-Art Search).** Before proposing any new endpoint or header, search GitHub, Anthropic docs, and the `cli.js` bundle. For OCP specifically, the `cli.js` grep is the decisive search: if it does not hit, Rule 2 of the constitution applies.
The full iron rules are at `~/.claude/CC_DEV_IRON_RULES.md` (symlinked from the cc-rules repo on Tao's workstations). Load them into session context with `/cc-rules` when needed. The full iron rules are at `~/.claude/CC_DEV_IRON_RULES.md` (symlinked from the cc-rules repo on the maintainer's workstations). Load them into session context with `/cc-rules` when needed.
--- ---
@@ -58,7 +58,7 @@ The full iron rules are at `~/.claude/CC_DEV_IRON_RULES.md` (symlinked from the
## Project-level escalation ## Project-level escalation
If a design decision cannot be resolved by reference to `cli.js` and `ALIGNMENT.md`, escalate to Tao (老大) via `/cc-chat` rather than guessing. Silent guessing is what produced the 2026-04-11 drift. If a design decision cannot be resolved by reference to `cli.js` and `ALIGNMENT.md`, escalate to the project maintainer via `/cc-chat` rather than guessing. Silent guessing is what produced the 2026-04-11 drift.
--- ---
-14
View File
@@ -1,14 +0,0 @@
FROM node:20-alpine
WORKDIR /app
COPY server.mjs ./
COPY setup.mjs ./
COPY package.json ./
ENV CLAUDE_SESSION_TOKEN="" \
CLAUDE_COOKIES=""
EXPOSE 3456
CMD ["node", "server.mjs"]
+259 -10
View File
@@ -1,7 +1,11 @@
# OCP — Open Claude Proxy # OCP — Open Claude Proxy
[![License: MIT](https://img.shields.io/badge/License-MIT-blue.svg)](LICENSE) [![GitHub release](https://img.shields.io/github/v/release/dtzp555-max/ocp)](https://github.com/dtzp555-max/ocp/releases) [![Buy Me a Coffee](https://img.shields.io/badge/Buy_Me_a_Coffee-ffdd00?logo=buy-me-a-coffee&logoColor=black)](https://buymeacoffee.com/dtzp555)
> **Already paying for Claude Pro/Max? Use your subscription as an OpenAI-compatible API — $0 extra cost.** > **Already paying for Claude Pro/Max? Use your subscription as an OpenAI-compatible API — $0 extra cost.**
*Open source from day one, used daily by my family, maintained on nights and weekends. If OCP saves you money too, you can [☕ buy me a coffee](https://buymeacoffee.com/dtzp555) — [full story below](#support-ocp).*
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. 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.
``` ```
@@ -14,6 +18,38 @@ OpenClaw ───┘
One proxy. Multiple IDEs. All models. **$0 API cost.** One proxy. Multiple IDEs. All models. **$0 API cost.**
## Why OCP?
There are several Claude proxy projects. OCP picks a specific lane: **align tightly with what `cli.js` actually does, observe + multiplex what's already there, don't extend the protocol.** What you get:
- **LAN multi-user keys** (v3.7.0) — share one Claude Pro/Max subscription with family, friends, or your own devices. Each user gets a per-key API token (no OAuth session leak), with independent usage tracking and one-line revocation.
- **`ocp-connect` one-shot IDE setup** — one command on the client machine detects and configures Claude Code, Cursor, Cline, Continue.dev, OpenCode, and OpenClaw. No pasting `OPENAI_BASE_URL` six times.
- **Response cache with per-key isolation + singleflight** (v3.13.0). Optional SHA-256 prompt cache, isolated per API key (cross-user pollution is impossible by hash construction, not by application logic), with stampede protection on concurrent identical prompts. Off by default. ([PR #65](https://github.com/dtzp555-max/ocp/pull/65), [PR #66](https://github.com/dtzp555-max/ocp/pull/66))
- **Per-key request quotas** (v3.8.0). Daily / weekly / monthly limits per key — set a kid's iPad to 20/day, a partner's laptop to 100/week. ([PR #18](https://github.com/dtzp555-max/ocp/pull/18))
- **SSE heartbeat for long reasoning** ([v3.12.0](https://github.com/dtzp555-max/ocp/releases/tag/v3.12.0), opt-in). If you've ever watched your IDE die at the 60s idle mark during a long Claude tool-use pause — that's nginx/Cloudflare default behavior. OCP emits an SSE comment frame to keep the connection alive without polluting the response. ([PR #49](https://github.com/dtzp555-max/ocp/pull/49))
- **`cli.js` alignment + CI guardrail.** LLM-assisted code drifts easily — it's tempting to invent plausible-looking endpoints that `cli.js` doesn't actually use. [`ALIGNMENT.md`](./ALIGNMENT.md) is binding: every endpoint OCP exposes must cite a `cli.js` line. The [`alignment.yml`](./.github/workflows/alignment.yml) CI workflow blocks PRs that introduce known-hallucinated tokens. The payoff is boring: your setup keeps working when `cli.js` ships its next minor.
- **`models.json` single source of truth** (v3.11.0). Adding a model is one file edit; both `/v1/models` and the OpenClaw bootstrap derive from it. ([PR #30](https://github.com/dtzp555-max/ocp/pull/30))
### Comparison
OCP and the alternatives serve adjacent but distinct needs. Pick the one that fits your use case:
| Feature | OCP | claude-code-router | anthropic-proxy |
|---|---|---|---|
| Forwards Claude Code subscription as OpenAI API | yes | yes | yes |
| Routes to multiple model backends (OpenAI, Gemini, etc.) | no | yes | partial |
| SSE heartbeat for long reasoning | yes (opt-in) | no | no |
| Per-key quota + LAN multi-user keys | yes | no | no |
| Response cache | yes (opt-in) | no | no |
| OpenClaw / IDE auto-config | yes | no | no |
| Model-routing rules / model-switching | no | yes | no |
| GitHub stars / ecosystem size | small | large | mid |
| Governance discipline (CI-enforced alignment with cli.js) | yes | n/a | n/a |
**Plain English**: `claude-code-router` is the routing-and-switching power tool — pick it if you want to mix Anthropic, OpenAI, Gemini, and local models behind one endpoint. `anthropic-proxy` is the minimal forwarder. **OCP focuses on disciplined `cli.js`-aligned forwarding plus subscription multiplexing** — pick it if you want to share one Claude Pro/Max subscription across IDEs, devices, and people, with LAN auth, quotas, and a governance contract that prevents endpoint drift.
OCP is single-maintainer + LLM-assisted, currently pre-1.0. It runs the maintainer's daily Claude Code workflow. If something breaks, [open an issue](https://github.com/dtzp555-max/ocp/issues).
## Supported Tools ## Supported Tools
Any tool that accepts `OPENAI_BASE_URL` works with OCP: Any tool that accepts `OPENAI_BASE_URL` works with OCP:
@@ -24,9 +60,11 @@ Any tool that accepts `OPENAI_BASE_URL` works with OCP:
| **OpenCode** | `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` | | **Aider** | `aider --openai-api-base http://127.0.0.1:3456/v1` |
| **Continue.dev** | config.json → `apiBase: "http://127.0.0.1:3456/v1"` | | **Continue.dev** | config.json → `apiBase: "http://127.0.0.1:3456/v1"` |
| **OpenClaw** | `setup.mjs` auto-configures | | **OpenClaw** [^openclaw] | `setup.mjs` auto-configures |
| **Any OpenAI client** | Set base URL to `http://127.0.0.1:3456/v1` | | **Any OpenAI client** | Set base URL to `http://127.0.0.1:3456/v1` |
[^openclaw]: **OpenClaw** is an IDE-agnostic AI coding agent (sibling project to OCP). When OCP runs on the same machine, OpenClaw can use it as a local provider — see `scripts/sync-openclaw.mjs` and ADR 0004.
## Installation ## Installation
OCP has two roles: **Server** (runs the proxy, needs Claude CLI) and **Client** (connects to a server, zero dependencies). OCP has two roles: **Server** (runs the proxy, needs Claude CLI) and **Client** (connects to a server, zero dependencies).
@@ -45,13 +83,96 @@ OCP has two roles: **Server** (runs the proxy, needs Claude CLI) and **Client**
--- ---
### Quick install with AI assistance
If you've got Claude Code, Cursor, or any other AI coding assistant on this machine, you can copy-paste one of these prompts and let the AI walk through the install for you. Each prompt pins the AI to the right README section, names the verification step, and forbids silent retries — so you stay in the loop.
**Single-machine use** — install OCP for IDEs on this same machine only:
```text
I want to install OCP on this machine to use my Claude Pro/Max subscription
as an OpenAI-compatible API for local IDEs.
Please follow https://github.com/dtzp555-max/ocp/blob/main/README.md
"Server Setup" → "Single-machine use" path:
1. Verify prerequisites: macOS or Linux, Node.js 22.5+, git, Claude CLI
installed and logged in (`claude auth status`). Install missing pieces
using my system's package manager.
2. git clone the repo, cd in, and run `node setup.mjs`.
3. Verify with `curl http://127.0.0.1:3456/v1/models` (should list 4 models).
4. Add `export OPENAI_BASE_URL=http://127.0.0.1:3456/v1` to my shell rc.
5. Tell me to reload my shell and try a tool like Cline / Continue / Cursor.
Before each step, tell me what you'll run and wait for confirmation.
On any error, diagnose first — don't auto-retry.
```
**LAN mode (server)** — install OCP as a server so your family or multiple devices can share it:
```text
I want to install OCP on this device as a LAN server so my family and other
devices on the network can share my Claude Pro/Max subscription.
Please follow https://github.com/dtzp555-max/ocp/blob/main/README.md
"Server Setup" → "LAN mode" path:
1. Verify prerequisites: macOS or Linux (Windows not supported), Node.js
22.5+, git, Claude CLI installed and authenticated.
2. Generate a strong admin key with `openssl rand -base64 32`. Save it —
I'll need it to manage per-user keys later.
3. git clone https://github.com/dtzp555-max/ocp.git && cd ocp
4. Run `node setup.mjs --bind 0.0.0.0 --auth-mode multi`.
5. Add OCP_ADMIN_KEY to my shell rc (~/.zshrc or ~/.bashrc).
6. Run `ocp lan` to show me the LAN IP and connect command.
7. Optionally create example keys: `ocp keys add laptop`, `ocp keys add tablet`.
8. Verify: `curl http://127.0.0.1:3456/v1/models` returns 4 models.
Tell me each step before running it. On error, diagnose before retrying.
```
**Client connect** — configure this device to use an existing OCP server on your LAN:
```text
There's an OCP server at <SERVER_IP> on my LAN. Configure this machine to
use it for any local IDEs (Cursor, Cline, Continue.dev, OpenCode, Claude
Code, OpenClaw).
Server IP: <SERVER_IP>
API key (leave blank if the server has anonymous mode enabled): <OPTIONAL_KEY>
Please follow https://github.com/dtzp555-max/ocp/blob/main/README.md
"Client Setup" path:
1. Download ocp-connect:
curl -fsSL https://raw.githubusercontent.com/dtzp555-max/ocp/main/ocp-connect -o ocp-connect
chmod +x ocp-connect
2. Run `./ocp-connect <SERVER_IP>` (add `--key <KEY>` if you have one).
3. Follow any IDE-specific manual hints it prints.
4. Verify: `curl http://<SERVER_IP>:3456/v1/models` returns 4 models.
5. Tell me to reload my shell + restart any IDE that was already running.
Don't auto-retry on error. Tell me the failure mode first.
```
> If you'd rather do everything manually, the **Server Setup** and **Client Setup** sections below have the same steps in handbook form.
---
### Server Setup ### 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. > **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:** **Prerequisites:**
- Node.js 18+ - macOS or Linux (Windows is not supported — `setup.mjs` installs launchd / systemd auto-start)
- [Claude CLI](https://docs.anthropic.com/en/docs/claude-cli) installed and authenticated (`claude auth login`) - Node.js 22.5+ (Node 23+ recommended — `node:sqlite` is fully stable without flags from 23.0; on 22.522.x it works behind `--experimental-sqlite`)
- `git`
- [Claude CLI](https://docs.anthropic.com/en/docs/claude-cli) — install and authenticate:
```bash
npm install -g @anthropic-ai/claude-code
claude auth login # prints a URL + code — open URL on any browser, sign in, paste code back
```
Headless servers (Pi / NAS / VPS without a desktop browser): see [Headless install notes](#headless-install-notes) below.
```bash ```bash
# 1. Clone and run setup # 1. Clone and run setup
@@ -79,11 +200,13 @@ node setup.mjs --bind 0.0.0.0 --auth-mode multi
Then create API keys for each person/device: Then create API keys for each person/device:
```bash ```bash
export OCP_ADMIN_KEY=your-secret-admin-key # Generate a strong admin key (one-time — save it for later key management):
export OCP_ADMIN_KEY=$(openssl rand -base64 32)
# Add the same export line to ~/.zshrc or ~/.bashrc so it persists.
ocp keys add wife-laptop ocp keys add wife-laptop
# ✓ Key created for "wife-laptop" # ✓ Key created for "wife-laptop"
# API Key: ocp_xDYzOB9ZKYzn... # API Key: ocp_example12345abcde...
# Copy this key now — you won't see it again. # Copy this key now — you won't see it again.
ocp keys add son-ipad ocp keys add son-ipad
@@ -98,11 +221,40 @@ curl http://127.0.0.1:3456/v1/models
# Returns: claude-opus-4-7, claude-opus-4-6, claude-sonnet-4-6, claude-haiku-4-5-20251001 # Returns: claude-opus-4-7, claude-opus-4-6, claude-sonnet-4-6, claude-haiku-4-5-20251001
``` ```
#### Headless install notes
OCP is designed for always-on devices that often don't have a desktop browser — Mac mini, NAS, Raspberry Pi, cloud VPS. The Claude CLI auth flow still works headless:
**Option 1 — interactive OAuth over SSH (one-shot).** `claude auth login` prints a URL + 8-digit code. Open the URL on **any** device with a browser (your laptop, phone), sign in to your Anthropic account, and paste the code back into the SSH session. No browser needed on the server itself.
**Option 2 — long-lived token (auth once, no re-prompts).**
```bash
claude setup-token # subscription-backed long-lived token
```
Same Claude subscription as Option 1; the token is stored in Claude CLI's normal config location. Useful when you'd rather not redo the OAuth flow when sessions expire.
If `claude auth login` errors out with something like `cannot open browser`, you've hit the same case — fall back to either option above.
---
### Uninstall
```bash
# From the cloned repo
node uninstall.mjs
```
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. Does not delete `~/.openclaw/`, `~/.ocp/`, or the cloned repo — remove those manually if desired.
--- ---
### Client Setup ### 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). > 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).
>
> **Find the server's LAN IP** by running `ocp lan` on the server machine — it prints both the IP and a ready-to-share connect command.
**One-command setup** — download the lightweight `ocp-connect` script: **One-command setup** — download the lightweight `ocp-connect` script:
@@ -428,17 +580,20 @@ ocp settings cacheTTL 300000
``` ```
**How it works:** **How it works:**
- Cache key = SHA-256 of `model` + `messages` + `temperature` + `max_tokens` + `top_p` - Cache key = SHA-256 of `v2|<keyId or "anon">|model + messages + temperature + max_tokens + top_p`
- **Per-key isolation** — different API keys never share cache entries; anonymous callers share one `anon` pool
- Cache hits return instantly — no Claude CLI process spawned - Cache hits return instantly — no Claude CLI process spawned
- Works for both streaming and non-streaming requests - **Streaming hits** are replayed as multiple SSE chunks (80 codepoints each), not one large delta — incremental render preserved
- **`cache_control` bypass** — if a request carries an Anthropic `cache_control` annotation (top-level or nested in `content[]`), OCP skips its own cache entirely so it doesn't interfere with Anthropic-side prompt caching
- **Singleflight stampede protection** — concurrent identical cache-miss requests share one upstream `cli.js` spawn; followers receive byte-identical responses to the leader's call. Non-streaming path only (streaming-path singleflight is a known TODO)
- Multi-turn conversations (with `session_id`) are never cached - Multi-turn conversations (with `session_id`) are never cached
- Expired entries are cleaned up automatically every 10 minutes - Expired entries are cleaned up automatically every 10 minutes
**Management:** **Management:**
```bash ```bash
# View cache stats # View cache stats (now includes singleflight in-flight counts)
curl http://127.0.0.1:3456/cache/stats curl http://127.0.0.1:3456/cache/stats
# → { "entries": 42, "totalHits": 156, "sizeBytes": 284000 } # → { "entries": 42, "totalHits": 156, "sizeBytes": 284000, "inflight": 0, "requesters": 0 }
# Clear all cached responses # Clear all cached responses
curl -X DELETE http://127.0.0.1:3456/cache curl -X DELETE http://127.0.0.1:3456/cache
@@ -449,6 +604,8 @@ ocp settings cacheTTL 0
Cache is **disabled by default** (`CLAUDE_CACHE_TTL=0`). All data is stored locally in `~/.ocp/ocp.db`. Cache is **disabled by default** (`CLAUDE_CACHE_TTL=0`). All data is stored locally in `~/.ocp/ocp.db`.
**Hash format upgrade in v3.13.0:** legacy `v1` cache rows from earlier versions don't match new `v2`-format lookups; they orphan and are reaped by the TTL cleanup interval within one window. No migration script required.
## How It Works ## How It Works
``` ```
@@ -543,6 +700,37 @@ After installing the gateway plugin, use `/ocp` slash commands in your chat:
## Troubleshooting ## Troubleshooting
### Setup fails with "claude: command not found"
`setup.mjs` requires the Claude CLI to be on `PATH`. Install it via the [official guide](https://docs.anthropic.com/en/docs/claude-cli), confirm with `which claude`, then run `claude auth login` before re-running `node setup.mjs`.
### Setup fails with "EADDRINUSE: port 3456 already in use"
Something else is already bound to port 3456 — usually an old OCP instance. Check what:
```bash
lsof -nP -iTCP:3456 -sTCP:LISTEN
```
If it's an old OCP process, stop it before re-running setup:
```bash
ocp stop # if the CLI is on PATH
launchctl bootout gui/$(id -u)/dev.ocp.proxy # macOS launchd fallback
sudo systemctl stop ocp-proxy # Linux systemd fallback
```
### Setup fails with "node: command not found" or version error
OCP requires Node.js 22.5+. Install:
```bash
brew install node # macOS
# Linux: see https://nodejs.org/en/download for current install commands
```
Confirm with `node --version` (should be ≥ v22.5).
### Requests fail or agents stuck ### Requests fail or agents stuck
```bash ```bash
@@ -593,6 +781,7 @@ Future `ocp update` invocations sync automatically.
| `OCP_ADMIN_KEY` | *(unset)* | Admin key for key management (multi mode) | | `OCP_ADMIN_KEY` | *(unset)* | Admin key for key management (multi mode) |
| `CLAUDE_BIN` | *(auto-detect)* | Path to claude binary | | `CLAUDE_BIN` | *(auto-detect)* | Path to claude binary |
| `CLAUDE_TIMEOUT` | `600000` | Request timeout (ms, default: 10 min) | | `CLAUDE_TIMEOUT` | `600000` | Request timeout (ms, default: 10 min) |
| `CLAUDE_HEARTBEAT_INTERVAL` | `0` | Streaming SSE keepalive interval (ms). `0` = disabled. See "Streaming heartbeat" section. |
| `CLAUDE_MAX_CONCURRENT` | `8` | Max concurrent claude processes | | `CLAUDE_MAX_CONCURRENT` | `8` | Max concurrent claude processes |
| `CLAUDE_MAX_PROMPT_CHARS` | `150000` | Prompt truncation limit (chars) | | `CLAUDE_MAX_PROMPT_CHARS` | `150000` | Prompt truncation limit (chars) |
| `CLAUDE_SESSION_TTL` | `3600000` | Session expiry (ms, default: 1 hour) | | `CLAUDE_SESSION_TTL` | `3600000` | Session expiry (ms, default: 1 hour) |
@@ -603,6 +792,36 @@ Future `ocp update` invocations sync automatically.
| `PROXY_API_KEY` | *(unset)* | Bearer token for shared-mode authentication | | `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). | | `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). |
### Streaming heartbeat
When `CLAUDE_HEARTBEAT_INTERVAL` is set to a positive integer (milliseconds), OCP emits an SSE comment frame (`: keepalive\n\n`) on streaming responses whenever the stream has been idle for that duration. The timer resets on every real chunk, so heartbeats only fire during genuine silent windows (for example, Claude CLI tool-use pauses of 30s5min, or a long "processing large contexts" delay before the first token).
Use cases: downstream HTTP clients or load balancers with idle-connection timeouts that would otherwise abort a slow-but-alive request. `CLAUDE_HEARTBEAT_INTERVAL=30000` (30s) is a reasonable starting value if your downstream has a 60s idle timeout.
Heartbeats are inert SSE comment lines — conforming SSE clients ignore them. If your downstream client's SSE parser crashes on comment frames, leave this disabled (the default) and file an issue so we can consider an alternate frame format.
OCP also sends `X-Accel-Buffering: no` on SSE responses so nginx-default proxy buffering does not hold heartbeats in an upstream buffer.
## Repository Layout
Top-level files a contributor or operator may need to know:
| Path | Role |
|------|------|
| `server.mjs` | The proxy itself; every request path lives here. Governed by `ALIGNMENT.md`. |
| `setup.mjs` | First-time installer — verifies Claude CLI, patches OpenClaw config, installs auto-start. |
| `uninstall.mjs` | Reverses the launchd / systemd auto-start install. |
| `keys.mjs` | API-key management module (multi-mode auth: create/list/revoke, quotas, usage tracking). |
| `models.json` | Single source of truth for model IDs, aliases, context windows. See ADR 0003. |
| `ocp` / `ocp-connect` | User-facing CLI wrappers (server-side / client-side respectively). |
| `dashboard.html` | Static dashboard served from `/dashboard`. |
| `scripts/sync-openclaw.mjs` | Idempotent OpenClaw registry sync invoked by `ocp update`. See ADR 0004. |
| `.claude/skills/` | Project-specific Claude Code skills. |
| `ocp-plugin/` | OpenClaw gateway plugin (optional installation). |
| `docs/adr/` | Architecture Decision Records. Read these before proposing governance or SPOT changes — see [`docs/adr/README.md`](docs/adr/README.md). |
| `ALIGNMENT.md` | The constitution. Binding for any `server.mjs` change. |
| `AGENTS.md` / `CLAUDE.md` | Agent and Claude-Code-specific session instructions. |
## Security ## Security
- **Localhost by default** — binds to `127.0.0.1`; set `CLAUDE_BIND=0.0.0.0` to enable LAN access - **Localhost by default** — binds to `127.0.0.1`; set `CLAUDE_BIND=0.0.0.0` to enable LAN access
@@ -614,6 +833,36 @@ Future `ocp update` invocations sync automatically.
- **Keys stored locally** — `~/.ocp/ocp.db` (SQLite), never sent to external services - **Keys stored locally** — `~/.ocp/ocp.db` (SQLite), never sent to external services
- **Auto-start** — launchd (macOS) / systemd (Linux) - **Auto-start** — launchd (macOS) / systemd (Linux)
## Governance
OCP runs under a small set of binding documents so contributions stay aligned with what `cli.js` actually does, not what an LLM thinks it does:
- **[`ALIGNMENT.md`](./ALIGNMENT.md)** — the constitution. Every endpoint OCP exposes must correspond to something `cli.js` actually does, with a line-number citation. Background in [ADR 0002](./docs/adr/0002-alignment-constitution.md).
- **[`.github/workflows/alignment.yml`](./.github/workflows/alignment.yml)** — CI guardrail. Greps `server.mjs` for known-hallucinated tokens and fails the build on any hit. Not suppressible without an `ALIGNMENT.md` amendment PR.
- **[`AGENTS.md`](./AGENTS.md)** — guidelines any AI coding agent (Claude Code / Cursor / Copilot / Codex / Gemini) should read before touching this repo.
- **[`models.json`](./models.json)** — single source of truth for the model registry. See [ADR 0003](./docs/adr/0003-models-json-spot.md).
- **[`docs/adr/`](./docs/adr/)** — architecture decision records explaining why current structure exists.
If you want to contribute: read `ALIGNMENT.md` first, search `cli.js` for the operation you're proposing, and cite the line number in your PR.
## Support OCP
OCP has been **open source from day one** — not a freemium tool, not a commercial product turned open, just open. It will stay that way forever. No paid tiers, no premium features, no "Pro" version locked behind a paywall.
I built it because my family and I needed it. We use OCP every day across our own machines and IDEs — keeping one Claude Pro/Max subscription powering everything, saving the per-token API cost we'd otherwise pay. It's been quietly heartwarming to hear from users online who say OCP has saved them money the same way it saves ours. That's the whole point.
Behind every version are hundreds of hours that don't show up in commits: building it from scratch, adding new features as the Claude Code ecosystem evolves, debugging across Mac / Windows / Linux machines, validating against half a dozen IDEs (Claude Code, Cursor, Cline, OpenCode, Aider, Continue.dev, OpenClaw), tracking down `cli.js` drift, OAuth refresh edge cases, SSE streaming quirks, concurrency leaks, and the occasional incident that turns into a multi-day investigation (the [2026-04-11 alignment drift](./docs/adr/0002-alignment-constitution.md), the [v3.11.1 concurrency leak](./CHANGELOG.md), the v3.12 SSE replay regression).
**The commitment**: this project will keep being updated, keep getting new features, and will stay open source as long as I'm able to maintain it.
**Please try it.** If something breaks or could be better, [open an issue](https://github.com/dtzp555-max/ocp/issues) — feedback is genuinely what keeps the project moving.
And if OCP saves you (or your team, or your family) real money and you'd like to chip in toward the next debugging session:
- ☕ **[Buy me a coffee](https://buymeacoffee.com/dtzp555)**
Donations directly fund the time it takes to keep OCP saving the community money.
## License ## License
MIT MIT — see [`LICENSE`](LICENSE).
-7
View File
@@ -1,7 +0,0 @@
services:
claude-proxy:
build: .
ports:
- "3456:3456"
env_file: .env
restart: unless-stopped
+3 -3
View File
@@ -2,7 +2,7 @@
- **Date**: 2026-04-20 - **Date**: 2026-04-20
- **Status**: Accepted - **Status**: Accepted
- **Authors**: Tao Deng, Claude Opus 4.7 (drafting) - **Authors**: project maintainer (with AI drafting assistance)
- **Related**: PR #20, commit 2853088; supersedes implicit "keep the proxy honest" discipline - **Related**: PR #20, commit 2853088; supersedes implicit "keep the proxy honest" discipline
## Context ## Context
@@ -37,7 +37,7 @@ Supporting mechanisms:
- `CLAUDE.md` enshrines hard requirements for `server.mjs` PRs: `cli.js` citation, CI blacklist pass, independent reviewer who opens `cli.js` at the cited lines. - `CLAUDE.md` enshrines hard requirements for `server.mjs` PRs: `cli.js` citation, CI blacklist pass, independent reviewer who opens `cli.js` at the cited lines.
- `.github/workflows/alignment.yml` greps `server.mjs` on every PR for the known-hallucinated token set (`api/oauth/usage`, `api/usage`, et al.) and fails the build on any hit. - `.github/workflows/alignment.yml` greps `server.mjs` on every PR for the known-hallucinated token set (`api/oauth/usage`, `api/usage`, et al.) and fails the build on any hit.
- `.github/PULL_REQUEST_TEMPLATE.md` makes the `cli.js` citation and the reviewer's cli.js-opened confirmation mandatory fields. - `.github/PULL_REQUEST_TEMPLATE.md` makes the `cli.js` citation and the reviewer's cli.js-opened confirmation mandatory fields.
- A bootstrap audit pin: Claude Code `2.1.89`, `cli.js` SHA-256 `a9950ef6407fdc750bddb673852485500387e524a99d42385cb81e7d17128e01`, auditor Tao Deng, date 2026-04-20. The pin is refreshed annually on 11 April (the drift anniversary) or on any re-verification event. - A bootstrap audit pin: Claude Code `2.1.89`, `cli.js` SHA-256 `a9950ef6407fdc750bddb673852485500387e524a99d42385cb81e7d17128e01`, auditor: project maintainer, date 2026-04-20. The pin is refreshed annually on 11 April (the drift anniversary) or on any re-verification event.
- A documented Historical Lesson section in `ALIGNMENT.md` that names the drift commits by SHA, so the incident cannot be rewritten or quietly forgotten. - A documented Historical Lesson section in `ALIGNMENT.md` that names the drift commits by SHA, so the incident cannot be rewritten or quietly forgotten.
## Consequences ## Consequences
@@ -63,7 +63,7 @@ Supporting mechanisms:
## Alternatives considered ## Alternatives considered
**(a) Pure human discipline — no CI, no template, no ADR.** Tao would simply commit to grepping `cli.js` on every change, and reviewers would commit to verifying. Rejected: the 2026-04-11 drift already happened under exactly this regime. Tao is meticulous, and the drift still shipped. Social discipline alone cannot prevent LLM hallucination from slipping through, especially when the LLM's output is superficially plausible. **(a) Pure human discipline — no CI, no template, no ADR.** the maintainer would simply commit to grepping `cli.js` on every change, and reviewers would commit to verifying. Rejected: the 2026-04-11 drift already happened under exactly this regime. the maintainer is meticulous, and the drift still shipped. Social discipline alone cannot prevent LLM hallucination from slipping through, especially when the LLM's output is superficially plausible.
**(b) Automatic `cli.js` diff on every PR.** A CI step that diffs `server.mjs`'s network surface against a parsed `cli.js` AST, blocking on any mismatch. Rejected as too fragile: `cli.js` v2.1.90+ ships as a minified/obfuscated binary, making AST-level grep invalid without an unofficial unbundling step. Any such pipeline becomes a maintenance burden on Anthropic's release cadence, and would routinely false-positive. The blacklist approach is lower-precision but dramatically more robust. **(b) Automatic `cli.js` diff on every PR.** A CI step that diffs `server.mjs`'s network surface against a parsed `cli.js` AST, blocking on any mismatch. Rejected as too fragile: `cli.js` v2.1.90+ ships as a minified/obfuscated binary, making AST-level grep invalid without an unofficial unbundling step. Any such pipeline becomes a maintenance burden on Anthropic's release cadence, and would routinely false-positive. The blacklist approach is lower-precision but dramatically more robust.
+1 -1
View File
@@ -2,7 +2,7 @@
- **Date**: 2026-04-20 - **Date**: 2026-04-20
- **Status**: Accepted - **Status**: Accepted
- **Authors**: Tao Deng, Claude Opus 4.7 (drafting) - **Authors**: project maintainer (with AI drafting assistance)
- **Related**: PR #30, commit c6f7850; precursor to ADR 0004 - **Related**: PR #30, commit c6f7850; precursor to ADR 0004
## Context ## Context
+1 -1
View File
@@ -2,7 +2,7 @@
- **Date**: 2026-04-20 - **Date**: 2026-04-20
- **Status**: Accepted - **Status**: Accepted
- **Authors**: Tao Deng, Claude Opus 4.7 (drafting) - **Authors**: project maintainer (with AI drafting assistance)
- **Related**: PR #31, commit 5ef163a; builds on ADR 0003 - **Related**: PR #31, commit 5ef163a; builds on ADR 0003
## Context ## Context
+79
View File
@@ -0,0 +1,79 @@
# 0005 — OCP Stays Single-Provider; No Multi-Provider Refactor
- **Date**: 2026-05-06
- **Status**: Accepted
- **Authors**: project maintainer (with AI advisory drafting)
- **Related**: ADR 0002 (Alignment Constitution), ADR 0003 (`models.json` SPOT)
## Context
OCP's `server.mjs` reached 1667 lines and now provides response cache, per-key quota, session tracking, model-level stats, and SSE heartbeat — all targeting a single backend path: `spawn` the locally installed `cli.js` and let it transact with `api.anthropic.com`. This architecture is the source of OCP's only real differentiator: **`cli.js` behavior-level alignment** (session create-vs-resume semantics, tool_use id reuse, SSE quirks, etc.) — none of which a generic LLM gateway has, because none of them speak this protocol.
The maintainer evaluated extending OCP to support OpenAI / Gemini / OpenRouter / Together / Groq / Ollama — i.e., turning OCP into a multi-provider gateway resembling Helicone, LiteLLM, OpenRouter, or Portkey. The motivation for that extension: reduce dependency on Anthropic, broaden OCP's commercial surface, and stop being grayscale-positioned (the `cli.js` spawn pattern depends on the local Pro/Max subscription, which Anthropic could fingerprint and disable).
The honest engineering estimate for that extension:
| Phase | Net New LOC | Calendar Time (part-time) |
|---|---|---|
| Provider abstraction + OpenAI | ~1230 + schema migration | 2 weeks |
| Add Gemini | ~550 | +1.5 weeks |
| OpenAI-compatible family (OpenRouter / Together / Groq) | ~300 | +1 week |
| Tests, docs, hardening | — | +1.5 weeks |
| **Multi-provider v1** | ~2080 | **~7 weeks focused** |
That number is not the real cost. The real cost is **strategic**:
1. **Loss of unique value.** `cli.js` behavior alignment is meaningless for OpenAI / Gemini / Ollama traffic. Going multi-provider means OCP's only moat applies to ~30% of its surface; the other 70% is generic gateway code already done better by Helicone / LiteLLM.
2. **Hybrid architecture awkwardness.** A multi-provider OCP would have two paths: `spawn(cli.js)` for Claude (still grayscale, depends on Pro subscription), and direct API call for everyone else (clean, BYOK). Customers asking "what is OCP?" would hear two different answers depending on which model they pick. This is worse than either pure path.
3. **Direct competition with funded incumbents.** Helicone (~$5M raised, YC W23), OpenRouter (~$1B valuation), LiteLLM (significant enterprise revenue), Portkey, Langfuse, Cloudflare AI Gateway — all already do multi-provider gateway with mature dashboards, audit logs, SOC2, and team features. OCP would enter that market 2+ years late with one engineer.
4. **The grayscale problem isn't solved by adding providers.** As long as OCP keeps the `cli.js` spawn path for Anthropic, it remains grayscale for that path; adding OpenAI alongside doesn't make the Anthropic path any less dependent on a Pro/Max subscription that wasn't licensed for proxying.
The maintainer's separate decision (recorded in personal notes, not this repo) is that **OCP itself will not be commercialized**; it will remain a personal power tool plus open-source contribution. Any commercial gateway work, if pursued, will start from a clean codebase with BYOK from day one — not from OCP.
Given that, the multi-provider extension would buy OCP nothing: not a moat, not commercial readiness, not even meaningfully better personal utility (the maintainer overwhelmingly uses Claude).
## Decision
OCP stays single-provider. Specifically:
1. **No new providers added to `server.mjs`.** The dispatch path remains `spawn(cli.js) → api.anthropic.com`. Pull requests that introduce a `providers/` directory or a model-to-provider router are declined on the basis of this ADR.
2. **`models.json` schema stays Anthropic-only.** No `provider` field, no per-model cost/capability metadata that anticipates other providers. If non-Anthropic models ever need to be referenced (e.g., for OpenClaw provider list completeness), they live in a separate file or in OpenClaw's own config — not in OCP's SPOT.
3. **Cache improvements are in scope.** The existing response cache (in `keys.mjs`: `cacheHash` / `getCachedResponse` / `setCachedResponse` / `clearCache`) is acceptable to upgrade with stream replay, stampede protection (singleflight), per-key isolation, and Anthropic `cache_control` awareness. These reinforce the single-provider position; they do not create provider-extension surface area.
4. **Anthropic alignment work continues to be encouraged.** Anything that deepens `cli.js` behavior alignment — session lifetime, tool_use id semantics, SSE behavior, multi-account routing, model-tier observability — is the project's actual value and should be prioritized over generic-gateway features.
5. **Commercial work, if pursued, starts elsewhere.** A separate repository, separate name, BYOK from day one, no `cli.js` spawn. That repo is out of scope for OCP and is not bound by this ADR.
## Consequences
**Positive**
- Project scope stays bounded. The maintainer can keep evolving OCP at part-time pace without the multi-provider maintenance burden (every provider's API breaks at some point and demands attention).
- The unique value (`cli.js` alignment) is preserved and continues to compound — every new alignment fix increases OCP's distance from generic gateways.
- Future contributors reading the code see one architecture, not a hybrid; debugging stays tractable.
- Decisions about commercialization are decoupled from OCP's technical evolution. OCP can stay grayscale-personal-tool indefinitely without that being a blocker for any future commercial product.
**Negative**
- OCP cannot serve any user who needs OpenAI / Gemini / local LLM access. Those users must route through a different gateway (Helicone, LiteLLM, OpenRouter) or call providers directly.
- If Anthropic substantially changes `cli.js` (e.g., adds client attestation, removes the spawn-and-forward pattern, or migrates `claude` to a non-CLI form factor), OCP's core architecture breaks and there is no second backend to fall back to.
- The maintainer must resist a recurring temptation: "while I'm in here, let me just add OpenAI." The whole point of this ADR is to make that temptation cost a documented amendment, not a quiet PR.
**Neutral**
- This ADR records a non-decision in code: nothing in `server.mjs` changes today. Its purpose is to make future contributors (including the maintainer) explain themselves before going against it. Per the project's PR template and Iron Rule 11, an amendment to this ADR is the gating step before any provider-extension PR.
## Trigger conditions for revisiting this ADR
This ADR should be revisited (and possibly amended or superseded) if any of the following occur:
1. Anthropic ships a feature that breaks the `cli.js` spawn pattern OCP depends on, and the maintainer wants to keep OCP useful.
2. The maintainer makes a deliberate decision to commercialize OCP (rather than start a separate codebase). This requires explicit re-scoping; "let me try" is not enough.
3. A genuine user need emerges — e.g., the maintainer themselves starts using OpenAI / Gemini frequently from Claude Code workflows — that single-provider OCP cannot serve.
In all three cases, the response is **first amend this ADR**, then write code. Order is not optional.
+38
View File
@@ -0,0 +1,38 @@
# Architecture Decision Records
This directory holds the OCP Architecture Decision Records (ADRs) — short documents that capture the **why** behind structural choices.
Read these before proposing governance, SPOT (single-source-of-truth), or process changes.
## Numbering
ADRs start at `0002`. The first one (`0001`) was reserved for an early
internal proposal that was superseded before publication; `0002` is
deliberately the first published record so the archived `0001` slot
remains a placeholder rather than being silently renumbered.
New ADRs increment from the highest existing number. Filenames are
`NNNN-<short-slug>.md`.
## Index
| ADR | Title | What it covers |
|---|---|---|
| [0002](0002-alignment-constitution.md) | Alignment Constitution | The `ALIGNMENT.md` constitution: why every `server.mjs` change requires `cli.js` citation + independent reviewer + CI blacklist pass. Background: the 2026-04-11 drift incident. |
| [0003](0003-models-json-spot.md) | `models.json` as SPOT | Why model IDs / aliases / context windows live in a single JSON file (not duplicated in `server.mjs` and `setup.mjs` arrays). v3.11.0 refactor. |
| [0004](0004-openclaw-auto-sync.md) | OpenClaw Auto-Sync | Why `scripts/sync-openclaw.mjs` runs on `ocp update`, what its scope boundary is (writes only `models.providers["claude-local"].models` and `agents.defaults.models["claude-local/*"]`), and the idempotency contract. |
| [0005](0005-no-multi-provider.md) | No Multi-Provider | Why OCP stays single-provider (Anthropic-via-cli.js) and does not extend to OpenAI / Gemini / OpenRouter. Cost estimate: ~7 weeks for a v1 that buys neither moat nor commercial readiness. Separate commercial work starts in a separate repo. |
## When to write a new ADR
Open one whenever:
- A structural rule is being added or changed (e.g., new SPOT, new boundary, new CI guardrail).
- A decision encodes a lesson from an incident or drift.
- A future contributor reading the code alone could plausibly undo or re-litigate the choice.
Skip ADRs for routine implementation choices (algorithm pick, naming) — those belong in commit messages.
## Format
Keep ADRs short — Context / Decision / Consequences is the standard skeleton. Cite incidents, PRs, or commits where useful.
@@ -31,7 +31,7 @@
- [ ] **Step 1: Install better-sqlite3** - [ ] **Step 1: Install better-sqlite3**
```bash ```bash
cd /Users/taodeng/.openclaw/projects/claude-proxy cd $HOME/.openclaw/projects/claude-proxy
npm install better-sqlite3 npm install better-sqlite3
``` ```
@@ -1085,7 +1085,7 @@ git commit -m "docs: add LAN mode documentation and family sharing guide"
- [ ] **Step 1: Start OCP in LAN mode with multi-key auth** - [ ] **Step 1: Start OCP in LAN mode with multi-key auth**
```bash ```bash
cd /Users/taodeng/.openclaw/projects/claude-proxy cd $HOME/.openclaw/projects/claude-proxy
CLAUDE_BIND=0.0.0.0 CLAUDE_AUTH_MODE=multi OCP_ADMIN_KEY=test-admin-123 node server.mjs & CLAUDE_BIND=0.0.0.0 CLAUDE_AUTH_MODE=multi OCP_ADMIN_KEY=test-admin-123 node server.mjs &
sleep 3 sleep 3
``` ```
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,193 @@
# Design: SSE Heartbeat on Streaming Path
**Issue:** [#47](https://github.com/dtzp555-max/ocp/issues/47)
**Date:** 2026-04-25
**Status:** Draft (awaiting maintainer approval)
**Target version:** v3.12.0 (minor — new opt-in feature + env var)
---
## Overview
Add an opt-in idle-watchdog heartbeat to OCP's streaming response. When enabled, OCP emits an SSE comment frame (`: keepalive\n\n`) whenever the stream has been idle for a configurable interval. Timer resets on every real frame. Covers both pre-first-byte and mid-stream silent windows. Default **disabled**. Zero behavior change for existing deployments on upgrade.
Companion tweak: `X-Accel-Buffering: no` response header added to both SSE header sites so heartbeats survive nginx-default proxy buffering.
## Motivation
Per [#47](https://github.com/dtzp555-max/ocp/issues/47): when `claude -p` takes a long time to respond (processing large contexts, or executing long tool calls that pause the token stream for 30s5min), OCP emits no bytes to the downstream client for up to 600s. The caller cannot distinguish "slow but alive" from "hung." A recent incident reported 15 consecutive 600s silent waits cascading into a 2-hour downstream gateway outage.
SSE heartbeats at the application layer let a caller observe liveness without OCP introducing any new client-killing timer.
## Key decisions (with rationale)
Six decisions were fixed during brainstorming. Each is presented as "decision → rationale" so future readers can judge whether a decision is still load-bearing.
### D1. Coverage: whole-stream with idle-watchdog reset-on-byte
A per-request timer starts when SSE headers are written and resets on every real `sendSSE()` call. Heartbeat fires only during genuine idle windows — never during healthy token bursts.
**Rationale.** The `server.mjs:8-10` comment documents Claude tool-use pauses as "30s-5min pauses in the token stream." This means silent windows happen both pre-first-byte AND mid-stream. Covering only one of the two windows means re-opening this issue in three months when a different user reports the uncovered case. The reset-on-byte discipline is a ~2-LOC discipline (`clearTimeout` + `setTimeout` inside `sendSSE`) and is the standard "idle watchdog" pattern.
### D2. Frame format: SSE comment (`: keepalive\n\n`)
**Rationale.** Per SSE spec / MDN, lines starting with `:` are comments and MUST be ignored by conforming parsers. This is the maximally inert shape we can emit. Alternatives considered:
- `event: ping` named event — Anthropic's own Messages API uses this, but on an OpenAI-compatible surface, downstream clients don't recognize that event name, so risk of client confusion is higher.
- Empty-delta JSON chunk — parser-safe on OpenAI-compatible clients but burns an event id and is less observably "a heartbeat" in logs.
The known risk with SSE comments is that some SDKs crash on empty comment frames (`openai-go` issue #556). The default-disabled posture (D3) mitigates this: users who opt in can also verify their client tolerates comments. If the comment format turns out to be broken in the wild for common OCP callers, we can add a second format behind `CLAUDE_HEARTBEAT_FORMAT` in a follow-up — **not in this PR**.
### D3. Default disabled
`CLAUDE_HEARTBEAT_INTERVAL=0` (meaning disabled) is the default when the env var is unset. Any positive integer enables at that ms interval.
**Rationale.** Existing deployments see zero byte-shape change on upgrade. Users who have pingvvino's problem set the env var and get the fix. This is a reversible posture: once field evidence shows comment frames are safe across current OCP callers, a future minor can flip the default to `30000`.
### D4. Header relocation: `ensureHeaders()` moves earlier
Currently `ensureHeaders()` is invoked inside the `proc.stdout` handler, so SSE headers are written only on first byte from claude CLI. This PR moves the `ensureHeaders()` call to immediately after successful `spawn()` return.
**Rationale.** You cannot emit SSE frames before sending SSE headers. For heartbeats to cover the pre-first-byte silent window (pingvvino's "processing large contexts" case), headers must be sent earlier. Behavioral consequence: the narrow "spawn succeeded but subprocess erroneously died before any byte" branch — currently a JSON error response — becomes an SSE error event + `[DONE]` + `res.end()`. Pre-spawn errors (before `spawn()`) still return JSON, unchanged. The affected path is rare (claude CLI either spawns or it doesn't).
### D5. Transport-layer buffering hint: `X-Accel-Buffering: no`
Added to both SSE response header sites (real-streaming `ensureHeaders()` and the cache-hit simulated-streaming header write).
**Rationale.** nginx (and many LBs / Cloudflare) default to buffering proxied responses. Without this header, heartbeat bytes may accumulate in an upstream buffer and never reach the client, defeating the feature silently. This header is a nginx-specific hint; other stacks ignore it. 1 line per site, 2 sites total, indistinguishable-from-no-op for stacks that don't use it.
### D6. Observability: single log line per affected request
On the first heartbeat fire within a request, emit a structured log entry (`logEvent("info", "heartbeat_active", { session, intervalMs })`). No log spam for subsequent fires in the same request.
**Rationale.** For a first-mover feature the question "did the heartbeat actually work for that hung request?" needs to be answerable from the existing `/logs` endpoint alone, without external tooling. One line per affected request gives proof-of-life without polluting the log stream during healthy traffic (where the timer resets and never fires).
## Architecture
Single-file change in `server.mjs`. One new helper plus small patches to existing sites.
```
startHeartbeat(res, intervalMs, sessionId) → { reset(), stop() }
if intervalMs <= 0: return no-op handle
internal: handle = setTimeout(intervalMs, onFire)
onFire():
res.write(": keepalive\n\n")
if !hasFired: logEvent("info", "heartbeat_active", { session, intervalMs }); hasFired = true
handle = setTimeout(intervalMs, onFire)
reset(): clearTimeout(handle); handle = setTimeout(intervalMs, onFire)
stop(): clearTimeout(handle); handle = null
```
Handle created once per streaming request. `sendSSE()` calls `heartbeat.reset()` before its `res.write()`. All exit paths call `heartbeat.stop()`.
## Components and LOC budget
| Location | Change | Est LOC |
|---|---|---|
| `server.mjs` env block | Parse `CLAUDE_HEARTBEAT_INTERVAL` | 1 |
| `server.mjs` new `startHeartbeat()` function | Per spec above | 12 |
| `server.mjs:565-579` `ensureHeaders()` | Add `"X-Accel-Buffering": "no"` to header object | 1 |
| `server.mjs:~548-554` streaming entry | Move `ensureHeaders()` call to post-spawn; create heartbeat handle | 3 (net) |
| `server.mjs:669` `sendSSE()` | Accept optional `hb` param; call `hb?.reset()` before `res.write()` | 2 |
| `server.mjs` streaming exit hooks (proc 'close', proc 'error', req 'close') | Call `hb.stop()` | 3 |
| `server.mjs:~610-611` pre-first-byte error branch | If headers sent, SSE error + `[DONE]` + `res.end()` instead of JSON | 3 |
| `server.mjs:1171` cache-hit header write | Add `"X-Accel-Buffering": "no"` | 1 |
| `README.md` env var table | One new row | 1 |
| `README.md` new short section | "Streaming heartbeat" paragraph + nginx note | 5 |
| `CHANGELOG.md` new v3.12.0 entry | Features + Config additions | 4 |
| `package.json` + `ocp-plugin/package.json` + `ocp-plugin/openclaw.plugin.json` | Version bump 3.11.1 → 3.12.0 | 3 |
**Estimated total: ~40 lines.** This is 510 lines over the 2535 budget set in brainstorming. The overshoot is accounted for by D4 (header relocation + SSE error branch) and D5 (X-Accel-Buffering at two sites). Reviewer may reject if actual code lands materially larger than ~45 lines of server.mjs code excluding docs and version files.
## Data flow (streaming request, heartbeat enabled)
1. Client sends `POST /v1/chat/completions` with `stream=true`.
2. Cache miss → `callClaudeStreaming()` invoked.
3. `spawn()` claude subprocess succeeds → `ensureHeaders(res)` writes SSE headers including `X-Accel-Buffering: no`.
4. `const hb = startHeartbeat(res, HEARTBEAT_INTERVAL, sessionId)` arms the watchdog (no-op if interval is 0).
5. Watchdog ticks after `HEARTBEAT_INTERVAL` ms of idle. On fire: `: keepalive\n\n` out; first-fire logs once; re-arm.
6. Every real `sendSSE()` write calls `hb.reset()` — cancels and re-arms timer.
7. Healthy token bursts → heartbeat never fires.
8. Tool-use pause → timer elapses → heartbeat fires → client stays alive → re-arm → repeat until next chunk arrives.
9. On proc 'close' (success / `[DONE]`) / proc 'error' / req 'close' / `CLAUDE_TIMEOUT` kill → `hb.stop()`.
## Error handling
- **Client disconnect mid-heartbeat.** `req.on('close')` fires → `hb.stop()`. Any in-flight write becomes a no-op / emits `'error'` on `res`; existing code already tolerates this.
- **`CLAUDE_TIMEOUT` (600s) fires mid-request.** Existing timeout handler SIGTERM's subprocess → proc 'close' → `hb.stop()`. This PR does **not** fix the separate issue that the current timeout path does not `res.end()` or emit an SSE error frame; that is documented as a separate issue.
- **`spawn()` throws synchronously.** Heartbeat never started. Existing JSON error response unchanged.
- **`spawn()` succeeds, subprocess errors before first byte.** Headers have already been written (per D4). Branch emits an SSE error event + `[DONE]` + `res.end()` instead of a JSON error. Documented behavior change.
## Testing plan
OCP has no unit test framework beyond `test-features.mjs`. Verification is manual + cloud-backed.
### Manual local smoke test
1. Set `CLAUDE_HEARTBEAT_INTERVAL=5000` (5s for easy observation) and start OCP.
2. Issue a streaming completion with a prompt that triggers a tool-use pause (e.g., ask for a large file read or long reasoning):
```
curl -N http://localhost:3456/v1/chat/completions \
-H "Authorization: Bearer $OCP_KEY" \
-d '{"model":"claude-opus-4-7","stream":true,
"messages":[{"role":"user","content":"read the attached 200KB text and summarize"}]}'
```
3. Confirm `: keepalive` comment lines appear in the raw response during the pause, at ~5s cadence.
4. Confirm `/logs` shows exactly one `heartbeat_active` entry for the request.
5. With `CLAUDE_HEARTBEAT_INTERVAL=0` (default), confirm no heartbeats and no log line.
### Cloud-backed test run (pre-push, required)
Per project feedback, tests must pass before any push to the public repo. Options, in preference order:
1. **GitHub Actions** — add a temporary smoke workflow (or piggyback on an existing one) that runs `test-features.mjs` against a sandboxed claude mock. Not viable if `test-features.mjs` requires a real claude CLI auth.
2. **Remote Linux test host via cc-chat handoff** — push feature branch, instruct a cloud machine with claude CLI installed to run the manual steps above, capture output, return verdict via cc-chat.
3. **Docker/compose locally** — if the maintainer has Docker available, `docker-compose.yml` is present and can be extended.
The implementation subagent and the reviewer subagent MUST include the chosen verification evidence in the PR body (command + output excerpt, sanitized of any identifiers) before the PR is opened for merge review.
### Downstream-parser compatibility
Before merging, verify at least one real downstream client (OCP's own `ocp-connect`, plus — if feasible — the current OpenClaw gateway) does not crash on comment frames. If a target client crashes, either (a) adjust the default to 0 and document the incompatibility, or (b) scope a follow-up PR for `CLAUDE_HEARTBEAT_FORMAT=empty-delta` as an alternative.
## Privacy preflight (for public-repo push)
Before `gh pr create` or any `git push` to `dtzp555-max/ocp`, run the following scan on the full diff (`git diff origin/main...HEAD`):
1. **Use OCP's PR template privacy self-check** — the `.github/PULL_REQUEST_TEMPLATE.md` Privacy self-check section is the canonical list. Fill every checkbox.
2. **Run `.gitleaks.toml` via gitleaks if available.**
3. **Manual grep on the diff** for these patterns (sanitize any hits before commit):
- Personal names in any language (check commit-author trailers especially — `Co-Authored-By` lines have leaked names before).
- Email addresses beyond automated placeholders (`noreply@*`).
- Local paths like `/Users/<name>/`, `/home/<name>/`, `C:\Users\<name>\` — replace with `$HOME/` or `~/`.
- Machine hostnames — use role-based names or generic descriptors.
- IPs, internal URLs, tailnet names.
4. **Log samples and test output** pasted into spec / README / CHANGELOG / PR body must be sanitized pre-paste, not pre-push. This spec doc itself was drafted under this discipline.
Historical reference: PR #43 / postmortem #44 (2026-04-22) scrubbed a prior leak and established the current apparatus. The user has explicitly flagged this as a scar to avoid re-treading for this PR.
## Scope lock (out of scope)
- `server.mjs:480-489` `CLAUDE_TIMEOUT` dangling-client behavior (no `res.end()` / no SSE error frame on timeout kill) — **will be filed as a separate issue** before this PR opens.
- Issue #41 (handleSessionFailure deletes on resume only) — separate.
- Issue #42 (SESSION_TTL and `lastUsed` interaction) — separate.
- Any new first-byte / idle / adaptive-tier timeout logic — explicitly forbidden per the v3.3 lesson (`server.mjs:8-11`, commit 3843ec8).
- Non-streaming chat path — no HTTP-level fix possible per [#47](https://github.com/dtzp555-max/ocp/issues/47)'s own conclusion.
- Named-event format (`event: ping`) or empty-delta JSON chunk variants — possible follow-up PR if field evidence shows comment frames break real clients.
- Changes to `CLAUDE_TIMEOUT` default — unchanged (stays 600s).
- Circuit breaker revival — explicitly forbidden.
## ALIGNMENT.md disposition
`cli.js` does not itself emit SSE heartbeat frames — claude CLI speaks newline-delimited JSON to stdout, not SSE. SSE is an OCP-owned translation layer. Per `ALIGNMENT.md` Rule 2 / `AGENTS.md` ("OCP forwards, observes, and multiplexes traffic that cli.js already emits"): heartbeats are a translation-layer response-shaping concern, not a new endpoint and not a behavior mimicry. The PR body will state this explicitly in the `cli.js` citation checkbox and reference this design doc.
## IDR (Iron Rule 11) disposition
Single PR. Scope is one feature (SSE heartbeat) × one layer (streaming response formatting) × one severity (minor opt-in addition). Release-kit companion files (version bump, CHANGELOG, README) are bundled with the code change per the explicit Iron Rule 11 example ("版本 bump 相关的小改动 + README + CHANGELOG 可以同 PR"). The separate filings for the dangling-client bug (`server.mjs:480-489`) and any follow-up heartbeat format variants are IDR-compliant — each lands as its own PR.
## Related
- Issue: [#47](https://github.com/dtzp555-max/ocp/issues/47)
- Prior timeout scar: commit 3843ec8 (v3.3.0 "simplify timeout to single CLAUDE_TIMEOUT")
- Prior privacy scar: PR [#43](https://github.com/dtzp555-max/ocp/pull/43) / postmortem [#44](https://github.com/dtzp555-max/ocp/issues/44)
- Constitution: `ALIGNMENT.md`
- Project instructions: `AGENTS.md`, `CLAUDE.md`
@@ -0,0 +1,147 @@
# Design: Response Cache Upgrade (Per-Key Isolation, cache_control Bypass, Chunked Stream Replay, Singleflight)
**Date:** 2026-05-07
**Status:** Draft (awaiting maintainer approval)
**Target version:** v3.13.0 (minor — internal correctness/concurrency improvements; no new public env vars or endpoints)
**Driving ADR:** [ADR 0005 — No Multi-Provider](../../adr/0005-no-multi-provider.md), decision §3 ("Cache improvements are in scope")
---
## Overview
OCP already has a response cache (`keys.mjs:296` `cacheHash` / `keys.mjs:311` `getCachedResponse` / `keys.mjs:324` `setCachedResponse`), wired into the proxy core at `server.mjs:1220` (non-streaming path read), `server.mjs:1227` (cache-hit-on-streaming-request replay), and `server.mjs:683` (streaming write-back). Today it has four functional gaps. This PR pair closes all four, in two minimum-reviewable units, **without changing the public API surface**.
| Gap | Impact today | Fix lands in |
|---|---|---|
| All keys share one cache pool | Key A's cache hit can leak Key B's prompt response | PR-A |
| Anthropic `cache_control` markers not detected | OCP cache may interfere with Anthropic prompt caching that the user explicitly requested | PR-A |
| Stream cache hit replays whole content in one SSE chunk | Downstream renders all-at-once; some SDKs misbehave on huge single deltas | PR-A |
| Concurrent identical cache misses all spawn `cli.js` independently | Cache stampede: N requests → N spawns → N billable calls | PR-B |
---
## Constitutional alignment (ALIGNMENT.md)
**`cli.js` does not perform response caching at the proxy layer.** The OCP response cache is a value-add operation that exists only inside OCP, between the wire (clients ↔ OCP) and the spawn (OCP ↔ `cli.js`). It does not introduce, rename, or alter any endpoint, header, request field, or response field that `cli.js` emits or expects. Cache hits return content byte-identical to what `cli.js` returned on the original miss, with the same `chat.completion` / `chat.completion.chunk` shape — **no client-observable wire shape change**.
This PR pair extends the existing cache (introduced in earlier commits) without expanding its surface. No new endpoints. No new headers. No new env vars exposed publicly (we add internal counters readable via the existing `/cache/stats` endpoint, but the response shape only gains numeric fields, not new structural fields).
Per Rule 1 / Rule 5: every commit body in this PR pair will state the absence of `cli.js` reference explicitly and justify scope under Rule 2's value-add carve-out for non-wire-affecting proxy operations.
---
## Key decisions (with rationale)
### D1. Per-key isolation via hash input, not schema column
`cacheHash` gains an optional `keyId` input. Distinct `keyId` values produce distinct hashes for the same prompt, so SQLite-level isolation falls out for free without a schema change.
**Rationale.** Adding a `key_id` column to `response_cache` requires either (a) dropping the existing `hash UNIQUE` index and replacing with a composite `(hash, key_id) UNIQUE`, which SQLite cannot do via plain `ALTER TABLE` and would require a table-rebuild migration, or (b) tolerating duplicate `hash` rows, which contradicts the existing schema comment and breaks `setCachedResponse`'s `ON CONFLICT(hash)` upsert clause.
The hash-input approach is reversible (we can switch to a schema column later if analytics across keys becomes a real need) and zero-risk on the SQL plane. The trade-off — losing the ability to query "which keys have cached this prompt?" — has no current consumer.
**Hash input format.** `cacheHash` prepends a version tag and key tag before the existing inputs:
```
v2|k:<keyId or "anon">|<model>|...rest as today
```
The `v2` prefix means existing v1-format rows in the cache table no longer hash-match any new request. They are abandoned, not deleted; the existing TTL-based `clearCache(CACHE_TTL)` cleanup interval at `server.mjs:185` reaps them within one TTL window. **No migration step is needed.** This is acceptable because the cache is by definition ephemeral and best-effort.
**Anonymous fallback.** When the request has no authenticated key (`req._authKeyId === undefined`), `keyId` is `"anon"`. Anonymous-mode users (PROXY_ANONYMOUS_KEY or no auth) share one anonymous pool, which preserves the only legitimate today-multi-user use case (a household running OCP without per-user keys). If this becomes a problem we can add per-IP scoping later, but anonymous-pool sharing is acceptable for v1 because anonymous mode is fundamentally a trust-everyone-on-LAN posture.
### D2. `cache_control` bypass: detect anywhere, skip OCP cache entirely
If any element in `messages` (top-level or nested in `content` arrays) carries a `cache_control` field, OCP sets `req._cacheHash = null` and skips both lookup and write-back.
**Rationale.** Anthropic's [prompt caching](https://docs.anthropic.com/en/docs/build-with-claude/prompt-caching) is opt-in by client-side annotation. A user who annotates `cache_control: { type: "ephemeral" }` is explicitly requesting that *Anthropic's* cache serve the call (and is paying the reduced cache-read pricing). Layering OCP's response cache on top in this case is wrong on two counts:
1. The user's intent is "cache at provider, not at proxy." OCP overruling that intent silently is the same drift family as the 2026-04-11 incident — proxy invents behavior the upstream surface doesn't request.
2. OCP cache hits would make `usage.cache_read_input_tokens` (the client-observable signal that prompt caching worked) appear inconsistent — sometimes present, sometimes absent — depending on whether OCP cached.
Detection is purely structural: walk `messages`, for each `m` check `m.cache_control` (rare top-level form) and if `m.content` is an array, check each part. No semantic interpretation; if the field is present, we bypass.
**Implementation site.** A small helper `hasCacheControl(messages)` exported from `keys.mjs`, called in `handleChatCompletions` immediately before the existing `cacheHash` call. If it returns true, we skip the cache-lookup branch entirely.
### D3. Chunked stream replay (80 chars/chunk, no artificial delay)
Today's cache-hit-on-streaming-request branch (`server.mjs:12271237`) sends the entire cached content in a single `delta.content` chunk. This works for spec-compliant SSE clients but visibly degrades the UX (no incremental render) and has tripped at least one buggy SDK in the wild that assumes deltas are small.
The fix splits cached content into ~80-character substrings, each sent as a separate `chat.completion.chunk` SSE event. **No artificial delay between chunks** — they ship as fast as `res.write` accepts. This preserves OCP's "ship as fast as possible" disposition; we are simulating *the chunk shape* of streaming, not the *latency*.
**Why 80 chars?** Compromise: small enough that even a multi-paragraph cached response yields >5 chunks (visible incremental render), large enough that even a 4 KB response only produces 50 chunks (not 4000 single-char events). Tunable later via internal constant; not exposed as env var per scope-creep avoidance.
**Boundary safety.** UTF-8 multibyte characters: we slice by `Array.from(content)` (so each iteration step is a full code point) and group every 80 code points. This avoids producing invalid UTF-8 mid-character.
### D4. Singleflight stampede protection: in-process Map, all-or-nothing failure
`keys.mjs` exports `singleflight(hash, fn)`. An in-memory `Map<hash, Promise>` deduplicates concurrent identical cache-miss flows. The first request executes `fn()`; concurrent requests with the same hash receive the same promise. When the promise settles (resolve or reject), the map entry is deleted.
**Rationale (single-process scope).** OCP runs as a single Node.js process per host. A `Map` is sufficient. Adding Redis or another shared store would be the start of a multi-instance evolution, which is out of scope per ADR 0005 (OCP is a personal power tool, not a horizontally-scaled SaaS).
**All-or-nothing failure semantics.** When the leader's `fn()` rejects, all followers receive the same rejection. The alternative — letting followers retry independently after a leader failure — risks N retries of an already-broken upstream, which is exactly what stampede protection was meant to prevent. Followers can retry at the *next* request, with idle backoff handled by the client. This matches Go's `golang.org/x/sync/singleflight` reference behavior.
**Streaming caveat.** Singleflight wraps the *non-streaming* code path only in PR-B. For streaming, deduplicating concurrent identical streaming requests is materially harder (we'd need to fan out one upstream stream to N downstream connections in real time, with backpressure). It's also a less common case (cache stampedes typically come from non-streaming batch jobs hitting the proxy in parallel). Streaming dedup is **explicitly out of scope** for this PR pair; leave a TODO comment in `callClaudeStreaming` for a future ticket.
**Map size unboundedness.** In normal operation the map is empty most of the time (entries delete on Promise settlement). Pathological case: an upstream call that hangs forever leaks one Map entry per stuck request. The existing `TIMEOUT` guard on `callClaude` (server.mjs spawn timeout) bounds this — the Promise will reject (timeout) within `TIMEOUT` ms, and the entry clears. No additional sweep needed.
---
## PR boundaries
### PR-A — Foundation (D1 + D2 + D3)
**Files touched:**
- `keys.mjs`: extend `cacheHash` with optional `keyId`/version prefix; add `hasCacheControl(messages)` helper
- `server.mjs`: pass `req._authKeyId` to `cacheHash`; check `hasCacheControl` and bypass; chunk cache-hit replay at line 12271237
- `test-features.mjs`: add cases for keyId isolation, cache_control bypass, chunked replay shape
**LOC budget:** ~80 production + ~50 test
**Risk:** Low — all changes are additive or guard-clause; existing cache behavior preserved when `keyId` defaults to "anon" and no `cache_control` present.
**Backward compat:** v1-format hashes naturally orphan; TTL cleanup reaps within one window; no migration script.
### PR-B — Concurrency (D4)
**Files touched:**
- `keys.mjs`: add `singleflight(hash, fn)` and `getInflightStats()` exports
- `server.mjs`: wrap non-streaming cache-miss path through `singleflight`; add inflight count to `/cache/stats` response
- `test-features.mjs`: add concurrent-request test that asserts only 1 spawn occurs for N=10 simultaneous identical requests
**LOC budget:** ~70 production + ~40 test
**Risk:** Medium — concurrency code is harder to reason about; mitigation is an explicit test case for the dedup behavior.
**Streaming explicitly out of scope:** TODO comment placed in `callClaudeStreaming` for follow-up ticket.
---
## Testing strategy
**Unit-ish (in `test-features.mjs`):**
1. `cacheHash` with two different `keyId` values → different hashes
2. `cacheHash` v2 prefix present in output (sanity check)
3. `hasCacheControl` returns true for top-level `cache_control` and for nested in `content[]`
4. `hasCacheControl` returns false for benign messages
5. Chunked replay: cached "abcdefgh..." (160 chars) produces 2 deltas
**Integration (manual smoke before merge):**
1. Set `CLAUDE_CACHE_TTL=60000`; create key A and key B; identical prompt from each → both spawn fresh; second-call from same key → cache hit
2. Send a message with `cache_control` annotation → OCP logs `cache_skipped: cache_control_present`; no cache write
3. Streaming cache hit visibly produces multiple SSE deltas (`curl -N | grep "data: "` shows >1 lines)
**Concurrent (PR-B only):**
1. Spawn 10 simultaneous identical non-streaming requests; assert (via `/cache/stats` inflight peak or via a process spawn counter) only 1 `cli.js` spawn occurred
---
## Out of scope (deliberately deferred)
- **Streaming singleflight** — see D4 streaming caveat. TODO in code.
- **Semantic cache** (embedding-based near-match) — needs an embedding provider + vector index. Punt to v3.14+ if there's user demand.
- **Cross-process cache** (Redis backend) — violates ADR 0005's "personal power tool" posture.
- **Cache versioning by model ID hash** — model upgrades currently invalidate cache organically because model is in the hash; if Anthropic ever silently changes a model's behavior without a model ID bump, that's a separate alignment problem.
- **Per-key cache TTL override** — single global TTL (existing `CLAUDE_CACHE_TTL`) is fine; per-key TTL is a knob no one has asked for.
---
## Rollback plan
If either PR introduces a regression, the rollback is a clean git revert. The cache layer is opt-in (default `CLAUDE_CACHE_TTL=0` = disabled), so users who never enabled the cache are unaffected by any cache-layer regression. Users who *had* enabled the cache lose only ephemeral state on revert. No persistent on-disk state is reshaped by this PR pair (we explicitly avoid schema migrations per D1 rationale).
+51 -1
View File
@@ -292,9 +292,13 @@ export function getKeyQuota(keyId) {
// ── Response cache ── // ── Response cache ──
// Generate a cache key from model + messages + request params that affect output // Generate a cache key from model + messages + request params that affect output.
// opts.keyId isolates per-API-key cache pools (v2 hash format).
// When keyId is absent/null/empty, falls back to "anon" (shared anonymous pool).
export function cacheHash(model, messages, opts = {}) { export function cacheHash(model, messages, opts = {}) {
const keyId = opts.keyId || "anon";
const h = createHash("sha256"); const h = createHash("sha256");
h.update(`v2|k:${keyId}|`);
h.update(model); h.update(model);
if (opts.temperature != null) h.update(`t:${opts.temperature}`); if (opts.temperature != null) h.update(`t:${opts.temperature}`);
if (opts.max_tokens != null) h.update(`mt:${opts.max_tokens}`); if (opts.max_tokens != null) h.update(`mt:${opts.max_tokens}`);
@@ -306,6 +310,22 @@ export function cacheHash(model, messages, opts = {}) {
return h.digest("hex"); return h.digest("hex");
} }
// Check whether any message (or content part) carries an Anthropic cache_control field.
// If true, OCP should skip its own cache to avoid interfering with prompt-caching intent.
export function hasCacheControl(messages) {
for (const m of messages || []) {
if (m && typeof m === "object") {
if (m.cache_control) return true;
if (Array.isArray(m.content)) {
for (const part of m.content) {
if (part && typeof part === "object" && part.cache_control) return true;
}
}
}
}
return false;
}
// Look up a cached response. Returns { response, hits } or null. // Look up a cached response. Returns { response, hits } or null.
// Also updates last_hit_at and increments hits counter on hit. // Also updates last_hit_at and increments hits counter on hit.
export function getCachedResponse(hash, ttlMs) { export function getCachedResponse(hash, ttlMs) {
@@ -351,6 +371,36 @@ export function getCacheStats() {
return { entries: total, totalHits, sizeBytes }; return { entries: total, totalHits, sizeBytes };
} }
// ── Singleflight stampede protection ──
// In-memory singleflight Map: hash → { promise, requesters }
// Deduplicates concurrent identical cache-miss flows so only one upstream call runs.
// Per ADR 0005 / spec D4: in-process scope only (single Node process per host).
const inflightMap = new Map();
export function singleflight(hash, fn) {
const existing = inflightMap.get(hash);
if (existing) {
existing.requesters++;
return existing.promise;
}
// Wrap fn() in Promise.resolve().then() so synchronous throws don't escape.
const promise = Promise.resolve().then(fn).finally(() => {
inflightMap.delete(hash);
});
inflightMap.set(hash, { promise, requesters: 1 });
return promise;
}
export function getInflightStats() {
let totalRequesters = 0;
for (const entry of inflightMap.values()) totalRequesters += entry.requesters;
return {
inflight: inflightMap.size,
requesters: totalRequesters,
};
}
// Find a key by id or name (returns { id, name } or null) // Find a key by id or name (returns { id, name } or null)
export function findKey(idOrName) { export function findKey(idOrName) {
const d = getDb(); const d = getDb();
+1 -1
View File
@@ -45,6 +45,6 @@ work-state file.
This constitution is subordinate to `AGENTS.md` for project-specific memory policy This constitution is subordinate to `AGENTS.md` for project-specific memory policy
and to CC 开发铁律 for development discipline. Amendments require a PR reviewed by and to CC 开发铁律 for development discipline. Amendments require a PR reviewed by
the project maintainer (Tao Deng). the project maintainer.
**Version**: 1.0.0 | **Ratified**: 2026-04-21 | **Last Amended**: 2026-04-21 **Version**: 1.0.0 | **Ratified**: 2026-04-21 | **Last Amended**: 2026-04-21
+7 -10
View File
@@ -8,21 +8,18 @@ set -euo pipefail
PROXY="http://127.0.0.1:3456" 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 args for multi-key mode: reads from OCP_ADMIN_KEY env or ~/.ocp/admin-key file
_AUTH_HEADER="" # Using a bash array preserves word boundaries — no eval needed.
_AUTH_ARGS=()
if [[ -n "${OCP_ADMIN_KEY:-}" ]]; then if [[ -n "${OCP_ADMIN_KEY:-}" ]]; then
_AUTH_HEADER="-H \"Authorization: Bearer $OCP_ADMIN_KEY\"" _AUTH_ARGS=(-H "Authorization: Bearer $OCP_ADMIN_KEY")
elif [[ -f "$HOME/.ocp/admin-key" ]]; then elif [[ -f "$HOME/.ocp/admin-key" ]]; then
_AUTH_HEADER="-H \"Authorization: Bearer $(cat "$HOME/.ocp/admin-key")\"" _AUTH_ARGS=(-H "Authorization: Bearer $(cat "$HOME/.ocp/admin-key")")
fi fi
# Wrapper: curl with optional auth # Wrapper: curl with optional auth
_curl() { _curl() {
if [[ -n "$_AUTH_HEADER" ]]; then curl "${_AUTH_ARGS[@]}" "$@"
eval curl "$_AUTH_HEADER" "$@"
else
curl "$@"
fi
} }
_json() { python3 -m json.tool 2>/dev/null || cat; } _json() { python3 -m json.tool 2>/dev/null || cat; }
@@ -604,7 +601,7 @@ cmd_restart() {
self_r="${BASH_SOURCE[0]}" self_r="${BASH_SOURCE[0]}"
while [[ -L "$self_r" ]]; do self_r="$(readlink "$self_r")"; done while [[ -L "$self_r" ]]; do self_r="$(readlink "$self_r")"; done
script_dir="$(cd "$(dirname "$self_r")" && pwd)" script_dir="$(cd "$(dirname "$self_r")" && pwd)"
nohup node "$script_dir/server.mjs" >> "$HOME/.ocp/logs/proxy.log" 2>&1 & DISABLE_AUTOUPDATER=1 nohup node "$script_dir/server.mjs" >> "$HOME/.ocp/logs/proxy.log" 2>&1 &
fi fi
sleep 3 sleep 3
if curl -sf --max-time 5 "$PROXY/health" > /dev/null 2>&1; then if curl -sf --max-time 5 "$PROXY/health" > /dev/null 2>&1; then
+1 -1
View File
@@ -2,7 +2,7 @@
"id": "ocp", "id": "ocp",
"name": "OCP Commands", "name": "OCP Commands",
"description": "Slash commands for the OpenClaw Proxy — /ocp usage, /ocp settings, /ocp health, etc.", "description": "Slash commands for the OpenClaw Proxy — /ocp usage, /ocp settings, /ocp health, etc.",
"version": "3.3.1", "version": "3.12.0",
"configSchema": { "configSchema": {
"type": "object", "type": "object",
"additionalProperties": false, "additionalProperties": false,
+1 -1
View File
@@ -1,6 +1,6 @@
{ {
"name": "ocp", "name": "ocp",
"version": "3.3.1", "version": "3.12.0",
"description": "Slash commands for the OpenClaw Proxy", "description": "Slash commands for the OpenClaw Proxy",
"main": "index.js", "main": "index.js",
"type": "module", "type": "module",
-182
View File
@@ -1,182 +0,0 @@
# 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.
-28
View File
@@ -1,28 +0,0 @@
{
"name": "openclaw-claude-proxy",
"version": "2.4.0",
"description": "OpenAI-compatible proxy for Claude CLI v2 — per-model circuit breaker, adaptive first-byte timeout, structured logging",
"type": "module",
"bin": {
"openclaw-claude-proxy": "./server.mjs"
},
"scripts": {
"start": "node server.mjs",
"setup": "node setup.mjs"
},
"keywords": [
"openclaw",
"claude",
"proxy",
"openai",
"anthropic"
],
"license": "MIT",
"engines": {
"node": ">=18"
},
"repository": {
"type": "git",
"url": "https://github.com/dtzp555-max/openclaw-claude-proxy"
}
}
-643
View File
@@ -1,643 +0,0 @@
#!/usr/bin/env node
/**
* openclaw-claude-proxy v2.4.0 — OpenAI-compatible proxy for Claude CLI
*
* Translates OpenAI chat/completions requests into `claude -p` CLI calls,
* letting you use your Claude Pro/Max subscription as an OpenClaw model provider.
*
* v2.4.0:
* - Per-model circuit breaker: consecutive timeouts temporarily mark a model as degraded
* - Adaptive first-byte timeout: scales by model tier + prompt size
* - Structured JSON logging for key events (easier to parse/alert on)
* - On-demand spawning (no pool), session management, full tool access
*
* Env vars:
* CLAUDE_PROXY_PORT — listen port (default: 3456)
* CLAUDE_BIN — path to claude binary (default: auto-detect)
* CLAUDE_TIMEOUT — per-request timeout in ms (default: 120000)
* CLAUDE_FIRST_BYTE_TIMEOUT — base first-byte timeout in ms (default: 45000)
* CLAUDE_ALLOWED_TOOLS — comma-separated tools to allow (default: expanded set)
* CLAUDE_SKIP_PERMISSIONS — "true" to bypass all permission checks (default: false)
* CLAUDE_SYSTEM_PROMPT — system prompt appended to all requests
* CLAUDE_MCP_CONFIG — path to MCP server config JSON file
* CLAUDE_SESSION_TTL — session TTL in ms (default: 3600000 = 1h)
* CLAUDE_MAX_CONCURRENT — max concurrent claude processes (default: 5)
* CLAUDE_BREAKER_THRESHOLD — consecutive timeouts before circuit opens (default: 3)
* CLAUDE_BREAKER_COOLDOWN — ms to wait before retrying after circuit opens (default: 60000)
* PROXY_API_KEY — Bearer token for API auth (optional)
*/
import { createServer } from "node:http";
import { spawn, execFileSync } from "node:child_process";
import { randomUUID } from "node:crypto";
import { readFileSync, accessSync, constants } from "node:fs";
import { fileURLToPath } from "node:url";
import { dirname, join } from "node:path";
const __dirname = dirname(fileURLToPath(import.meta.url));
const _pkg = JSON.parse(readFileSync(join(__dirname, "package.json"), "utf8"));
// ── Resolve claude binary ───────────────────────────────────────────────
// Priority: CLAUDE_BIN env > well-known paths > which lookup
// Fail-fast if not found — never start with an unresolvable binary.
function resolveClaude() {
if (process.env.CLAUDE_BIN) {
try {
accessSync(process.env.CLAUDE_BIN, constants.X_OK);
return process.env.CLAUDE_BIN;
} catch {
console.error(`FATAL: CLAUDE_BIN="${process.env.CLAUDE_BIN}" is set but not executable.`);
process.exit(1);
}
}
const candidates = [
"/opt/homebrew/bin/claude",
"/usr/local/bin/claude",
"/usr/bin/claude",
join(process.env.HOME || "", ".local/bin/claude"),
];
for (const p of candidates) {
try { accessSync(p, constants.X_OK); console.warn(`[init] CLAUDE_BIN not set, resolved to ${p}`); return p; } catch {}
}
try {
const resolved = execFileSync("which", ["claude"], { encoding: "utf8", timeout: 5000 }).trim();
if (resolved) { console.warn(`[init] CLAUDE_BIN not set, resolved via which: ${resolved}`); return resolved; }
} catch {}
console.error(
"FATAL: claude binary not found.\n" +
" Set CLAUDE_BIN=/path/to/claude or ensure claude is in PATH.\n" +
" Checked: " + candidates.join(", ")
);
process.exit(1);
}
// ── Configuration ───────────────────────────────────────────────────────
const PORT = parseInt(process.env.CLAUDE_PROXY_PORT || "3456", 10);
const CLAUDE = resolveClaude();
const TIMEOUT = parseInt(process.env.CLAUDE_TIMEOUT || "120000", 10);
const BASE_FIRST_BYTE_TIMEOUT = parseInt(process.env.CLAUDE_FIRST_BYTE_TIMEOUT || "45000", 10);
const PROXY_API_KEY = process.env.PROXY_API_KEY || "";
const SKIP_PERMISSIONS = process.env.CLAUDE_SKIP_PERMISSIONS === "true";
const ALLOWED_TOOLS = (process.env.CLAUDE_ALLOWED_TOOLS ||
"Bash,Read,Write,Edit,Glob,Grep,WebSearch,WebFetch,Agent"
).split(",").map(s => s.trim()).filter(Boolean);
const SYSTEM_PROMPT = process.env.CLAUDE_SYSTEM_PROMPT || "";
const MCP_CONFIG = process.env.CLAUDE_MCP_CONFIG || "";
const SESSION_TTL = parseInt(process.env.CLAUDE_SESSION_TTL || "3600000", 10);
const MAX_CONCURRENT = parseInt(process.env.CLAUDE_MAX_CONCURRENT || "5", 10);
const BREAKER_THRESHOLD = parseInt(process.env.CLAUDE_BREAKER_THRESHOLD || "3", 10);
const BREAKER_COOLDOWN = parseInt(process.env.CLAUDE_BREAKER_COOLDOWN || "60000", 10);
const VERSION = _pkg.version;
const START_TIME = Date.now();
// ── Structured logging helper ───────────────────────────────────────────
function logEvent(level, event, data = {}) {
const entry = { ts: new Date().toISOString(), level, event, ...data };
if (level === "error" || level === "warn") {
console.error(JSON.stringify(entry));
} else {
console.log(JSON.stringify(entry));
}
}
// ── Per-model circuit breaker ───────────────────────────────────────────
// Tracks consecutive timeouts per model. When threshold is reached, the
// model is marked "open" (degraded) for BREAKER_COOLDOWN ms. During that
// window, requests for this model fail fast with a clear error instead of
// waiting for yet another timeout that would block the gateway.
const breakers = new Map(); // cliModel → { failures, state, openedAt }
function getBreakerState(cliModel) {
if (!breakers.has(cliModel)) {
breakers.set(cliModel, { failures: 0, state: "closed", openedAt: 0 });
}
const b = breakers.get(cliModel);
// Auto-recover: if cooldown has elapsed, transition to half-open
if (b.state === "open" && Date.now() - b.openedAt >= BREAKER_COOLDOWN) {
b.state = "half-open";
logEvent("info", "breaker_half_open", { model: cliModel, cooldownMs: BREAKER_COOLDOWN });
}
return b;
}
function breakerRecordSuccess(cliModel) {
const b = getBreakerState(cliModel);
if (b.failures > 0 || b.state !== "closed") {
logEvent("info", "breaker_reset", { model: cliModel, previousFailures: b.failures, previousState: b.state });
}
b.failures = 0;
b.state = "closed";
b.openedAt = 0;
}
function breakerRecordTimeout(cliModel) {
const b = getBreakerState(cliModel);
b.failures++;
logEvent("warn", "breaker_failure", { model: cliModel, consecutiveFailures: b.failures, threshold: BREAKER_THRESHOLD });
if (b.failures >= BREAKER_THRESHOLD && b.state !== "open") {
b.state = "open";
b.openedAt = Date.now();
logEvent("error", "breaker_open", { model: cliModel, failures: b.failures, cooldownMs: BREAKER_COOLDOWN });
}
}
// ── Model mapping ───────────────────────────────────────────────────────
// Maps request model IDs and aliases to canonical claude CLI model IDs.
const MODEL_MAP = {
"claude-opus-4-6": "claude-opus-4-6",
"claude-sonnet-4-6": "claude-sonnet-4-6",
"claude-haiku-4-5-20251001": "claude-haiku-4-5-20251001",
"claude-opus-4": "claude-opus-4-6",
"claude-haiku-4": "claude-haiku-4-5-20251001",
"claude-haiku-4-5": "claude-haiku-4-5-20251001",
"opus": "claude-opus-4-6",
"sonnet": "claude-sonnet-4-6",
"haiku": "claude-haiku-4-5-20251001",
};
const MODELS = [
{ id: "claude-opus-4-6", name: "Claude Opus 4.6" },
{ id: "claude-sonnet-4-6", name: "Claude Sonnet 4.6" },
{ id: "claude-haiku-4-5-20251001", name: "Claude Haiku 4.5" },
];
// ── Session management ──────────────────────────────────────────────────
// Maps conversation IDs (from caller) to Claude CLI session UUIDs.
// Enables --resume for multi-turn conversations, reducing token waste.
const sessions = new Map(); // conversationId → { uuid, messageCount, lastUsed, model }
setInterval(() => {
const now = Date.now();
for (const [id, s] of sessions) {
if (now - s.lastUsed > SESSION_TTL) {
sessions.delete(id);
console.log(`[session] expired ${id.slice(0, 12)}... (idle ${Math.round((now - s.lastUsed) / 60000)}m)`);
}
}
}, 60000);
// ── Stats & diagnostics ─────────────────────────────────────────────────
const stats = {
totalRequests: 0,
activeRequests: 0,
errors: 0,
timeouts: 0,
sessionHits: 0,
sessionMisses: 0,
oneOffRequests: 0,
};
const recentErrors = []; // last 20 errors
function trackError(msg) {
stats.errors++;
recentErrors.push({ time: new Date().toISOString(), message: String(msg).slice(0, 200) });
if (recentErrors.length > 20) recentErrors.shift();
}
// ── Auth health check ───────────────────────────────────────────────────
let authStatus = { ok: null, lastCheck: 0, message: "" };
async function checkAuth() {
try {
const env = { ...process.env };
delete env.CLAUDECODE;
delete env.ANTHROPIC_API_KEY;
delete env.ANTHROPIC_BASE_URL;
delete env.ANTHROPIC_AUTH_TOKEN;
execFileSync(CLAUDE, ["auth", "status"], { encoding: "utf8", timeout: 10000, env });
authStatus = { ok: true, lastCheck: Date.now(), message: "authenticated" };
} catch (e) {
const msg = (e.stderr || e.message || "").slice(0, 200);
authStatus = { ok: false, lastCheck: Date.now(), message: msg };
console.error(`[auth] check failed: ${msg}`);
}
}
// Check auth on start and every 10 minutes
checkAuth();
setInterval(checkAuth, 600000);
// ── Build CLI arguments ─────────────────────────────────────────────────
function buildCliArgs(cliModel, sessionInfo) {
const args = ["-p", "--model", cliModel, "--output-format", "text"];
// Session handling
if (sessionInfo?.resume) {
args.push("--resume", sessionInfo.uuid);
} else if (sessionInfo?.uuid) {
args.push("--session-id", sessionInfo.uuid);
} else {
args.push("--no-session-persistence");
}
// Permissions
if (SKIP_PERMISSIONS) {
args.push("--dangerously-skip-permissions");
} else if (ALLOWED_TOOLS.length > 0) {
args.push("--allowedTools", ...ALLOWED_TOOLS);
}
// System prompt
if (SYSTEM_PROMPT) {
args.push("--append-system-prompt", SYSTEM_PROMPT);
}
// MCP config
if (MCP_CONFIG) {
args.push("--mcp-config", MCP_CONFIG);
}
return args;
}
// ── Format messages to prompt text ──────────────────────────────────────
function messagesToPrompt(messages) {
return messages.map((m) => {
const text = typeof m.content === "string" ? m.content : JSON.stringify(m.content);
if (m.role === "system") return `[System] ${text}`;
if (m.role === "assistant") return `[Assistant] ${text}`;
return text;
}).join("\n\n");
}
// Model tier multipliers for first-byte timeout.
// Opus is much slower to produce first token, especially with large contexts.
const MODEL_TIMEOUT_TIERS = {
"opus": { base: 60000, perPromptChar: 0.00015 }, // 60s base + ~15s per 100k chars
"sonnet": { base: 45000, perPromptChar: 0.00008 }, // 45s base + ~8s per 100k chars
"haiku": { base: 30000, perPromptChar: 0.00005 }, // 30s base + ~5s per 100k chars
};
function getModelTier(cliModel) {
if (cliModel.includes("opus")) return "opus";
if (cliModel.includes("haiku")) return "haiku";
return "sonnet";
}
function computeFirstByteTimeout(cliModel, promptLength) {
const tier = MODEL_TIMEOUT_TIERS[getModelTier(cliModel)];
const timeout = tier.base + Math.floor(promptLength * tier.perPromptChar);
return Math.min(timeout, Math.max(TIMEOUT - 5000, 10000));
}
// ── Call claude CLI ─────────────────────────────────────────────────────
// On-demand spawning: each request spawns a fresh `claude -p` process.
// No pool = no crash loops, no stale workers, no degraded states.
// Stdin is written immediately so there's no 3s stdin timeout issue.
function callClaude(model, messages, conversationId) {
return new Promise((resolve, reject) => {
if (stats.activeRequests >= MAX_CONCURRENT) {
return reject(new Error(`concurrency limit reached (${stats.activeRequests}/${MAX_CONCURRENT})`));
}
const cliModel = MODEL_MAP[model] || model;
// Circuit breaker check: fail fast if model is in open state
const breaker = getBreakerState(cliModel);
if (breaker.state === "open") {
const remainingMs = BREAKER_COOLDOWN - (Date.now() - breaker.openedAt);
logEvent("warn", "breaker_rejected", { model: cliModel, remainingCooldownMs: remainingMs });
return reject(new Error(`circuit breaker open for ${cliModel}: ${breaker.failures} consecutive timeouts, retry in ${Math.ceil(remainingMs / 1000)}s`));
}
stats.activeRequests++;
stats.totalRequests++;
let sessionInfo = null;
let prompt;
// ── Session logic ──
if (conversationId && sessions.has(conversationId)) {
// Resume existing session: only send the latest user message
const session = sessions.get(conversationId);
session.lastUsed = Date.now();
sessionInfo = { uuid: session.uuid, resume: true };
stats.sessionHits++;
const lastUserMsg = [...messages].reverse().find((m) => m.role === "user");
prompt = lastUserMsg
? (typeof lastUserMsg.content === "string" ? lastUserMsg.content : JSON.stringify(lastUserMsg.content))
: "";
session.messageCount = messages.length;
console.log(`[session] resume conv=${conversationId.slice(0, 12)}... uuid=${session.uuid.slice(0, 8)}... msgs=${messages.length} prompt_chars=${prompt.length}`);
} else if (conversationId) {
// New session: send all messages, persist session for future --resume
const uuid = randomUUID();
sessions.set(conversationId, { uuid, messageCount: messages.length, lastUsed: Date.now(), model: cliModel });
sessionInfo = { uuid, resume: false };
stats.sessionMisses++;
prompt = messagesToPrompt(messages);
console.log(`[session] new conv=${conversationId.slice(0, 12)}... uuid=${uuid.slice(0, 8)}... msgs=${messages.length}`);
} else {
// One-off request, no session
stats.oneOffRequests++;
prompt = messagesToPrompt(messages);
}
const cliArgs = buildCliArgs(cliModel, sessionInfo);
const env = { ...process.env };
delete env.CLAUDECODE;
delete env.ANTHROPIC_API_KEY;
delete env.ANTHROPIC_BASE_URL;
delete env.ANTHROPIC_AUTH_TOKEN;
const proc = spawn(CLAUDE, cliArgs, { env, stdio: ["pipe", "pipe", "pipe"] });
let stdout = "";
let stderr = "";
const t0 = Date.now();
const firstByteTimeoutMs = computeFirstByteTimeout(cliModel, prompt.length);
let settled = false;
let gotFirstByte = false;
function settle(err, result) {
if (settled) return;
settled = true;
clearTimeout(timer);
clearTimeout(firstByteTimer);
stats.activeRequests--;
if (err) {
trackError(err.message || String(err));
// If session resume failed, remove session so next request starts fresh
if (sessionInfo?.resume && conversationId) {
console.warn(`[session] resume failed for ${conversationId.slice(0, 12)}..., removing stale session`);
sessions.delete(conversationId);
}
reject(err);
} else {
resolve(result);
}
}
proc.stdout.on("data", (d) => {
if (!gotFirstByte) {
gotFirstByte = true;
clearTimeout(firstByteTimer);
console.log(`[claude] first-byte model=${cliModel} elapsed=${Date.now() - t0}ms`);
}
stdout += d;
});
proc.stderr.on("data", (d) => (stderr += d));
proc.on("close", (code, signal) => {
const elapsed = Date.now() - t0;
if (settled) {
logEvent("warn", "late_close", { model: cliModel, code, signal: signal || "none", elapsed });
return;
}
if (code !== 0) {
logEvent("error", "claude_exit", { model: cliModel, code, signal: signal || "none", elapsed, stderr: stderr.slice(0, 300) });
settle(new Error(stderr.slice(0, 300) || stdout.slice(0, 300) || `claude exit ${code}`));
} else {
breakerRecordSuccess(cliModel);
logEvent("info", "claude_ok", { model: cliModel, chars: stdout.length, elapsed, session: conversationId ? conversationId.slice(0, 12) + "..." : "none" });
settle(null, stdout.trim());
}
});
proc.on("error", (err) => {
console.error(`[claude] spawn error: ${err.message}`);
settle(err);
});
// Write prompt to stdin immediately — no idle timeout issue
proc.stdin.write(prompt);
proc.stdin.end();
logEvent("info", "claude_spawned", { model: cliModel, promptChars: prompt.length, firstByteTimeout: firstByteTimeoutMs, tier: getModelTier(cliModel), session: conversationId ? conversationId.slice(0, 12) + "..." : "none" });
// First-byte timeout: abort early if Claude CLI produces no output
const firstByteTimer = setTimeout(() => {
if (!gotFirstByte && !settled) {
stats.timeouts++;
breakerRecordTimeout(cliModel);
logEvent("error", "first_byte_timeout", { model: cliModel, timeoutMs: firstByteTimeoutMs, promptChars: prompt.length });
try { proc.kill("SIGTERM"); } catch {}
setTimeout(() => { try { proc.kill("SIGKILL"); } catch {} }, 5000);
settle(new Error(`first-byte timeout after ${firstByteTimeoutMs}ms`));
}
}, firstByteTimeoutMs);
// Overall request timeout with graceful kill
const timer = setTimeout(() => {
if (settled) return;
stats.timeouts++;
breakerRecordTimeout(cliModel);
logEvent("error", "request_timeout", { model: cliModel, timeoutMs: TIMEOUT });
try { proc.kill("SIGTERM"); } catch {}
setTimeout(() => { try { proc.kill("SIGKILL"); } catch {} }, 5000);
settle(new Error(`timeout after ${TIMEOUT}ms`));
}, TIMEOUT);
});
}
// ── Response helpers ────────────────────────────────────────────────────
function jsonResponse(res, status, data) {
if (res.headersSent || res.writableEnded || res.destroyed) return;
res.writeHead(status, { "Content-Type": "application/json" });
res.end(JSON.stringify(data));
}
function sendSSE(res, data) {
res.write(`data: ${JSON.stringify(data)}\n\n`);
}
function streamResponse(res, id, model, content) {
if (res.headersSent || res.writableEnded || res.destroyed) return;
res.writeHead(200, {
"Content-Type": "text/event-stream",
"Cache-Control": "no-cache",
"Connection": "keep-alive",
});
const created = Math.floor(Date.now() / 1000);
sendSSE(res, {
id, object: "chat.completion.chunk", created, model,
choices: [{ index: 0, delta: { role: "assistant" }, finish_reason: null }],
});
for (let i = 0; i < content.length; i += 500) {
sendSSE(res, {
id, object: "chat.completion.chunk", created, model,
choices: [{ index: 0, delta: { content: content.slice(i, i + 500) }, finish_reason: null }],
});
}
sendSSE(res, {
id, object: "chat.completion.chunk", created, model,
choices: [{ index: 0, delta: {}, finish_reason: "stop" }],
});
res.write("data: [DONE]\n\n");
res.end();
}
function completionResponse(res, id, model, content) {
jsonResponse(res, 200, {
id, object: "chat.completion",
created: Math.floor(Date.now() / 1000),
model,
choices: [{ index: 0, message: { role: "assistant", content }, finish_reason: "stop" }],
usage: { prompt_tokens: 0, completion_tokens: 0, total_tokens: 0 },
});
}
// ── Handle chat completions ─────────────────────────────────────────────
async function handleChatCompletions(req, res) {
let body = "";
for await (const chunk of req) body += chunk;
let parsed;
try { parsed = JSON.parse(body); } catch { return jsonResponse(res, 400, { error: "Invalid JSON" }); }
const messages = parsed.messages || parsed.input || [{ role: "user", content: parsed.prompt || "" }];
const model = parsed.model || "claude-sonnet-4-6";
const stream = parsed.stream;
// Session ID: from request body, header, or null (one-off)
const conversationId = parsed.session_id || parsed.conversation_id || req.headers["x-session-id"] || req.headers["x-conversation-id"] || null;
if (!messages?.length) return jsonResponse(res, 400, { error: "messages required" });
try {
const content = await callClaude(model, messages, conversationId);
const id = `chatcmpl-${randomUUID()}`;
if (stream) {
streamResponse(res, id, model, content);
} else {
completionResponse(res, id, model, content);
}
} catch (err) {
console.error(`[proxy] error: ${err.message}`);
if (res.headersSent || res.writableEnded || res.destroyed) {
try { res.end(); } catch {}
return;
}
jsonResponse(res, 500, { error: { message: err.message, type: "proxy_error" } });
}
}
// ── HTTP server ─────────────────────────────────────────────────────────
const server = createServer(async (req, res) => {
res.setHeader("Access-Control-Allow-Origin", "*");
res.setHeader("Access-Control-Allow-Methods", "GET, POST, DELETE, OPTIONS");
res.setHeader("Access-Control-Allow-Headers", "Content-Type, Authorization, X-Session-Id, X-Conversation-Id");
if (req.method === "OPTIONS") { res.writeHead(204); res.end(); return; }
// Bearer token auth (skip for /health and when PROXY_API_KEY is not set)
if (PROXY_API_KEY && req.url !== "/health") {
const auth = req.headers["authorization"] || "";
const token = auth.startsWith("Bearer ") ? auth.slice(7) : "";
if (token !== PROXY_API_KEY) {
return jsonResponse(res, 401, { error: { message: "Unauthorized: invalid or missing Bearer token", type: "auth_error" } });
}
}
// GET /v1/models
if (req.url === "/v1/models" && req.method === "GET") {
return jsonResponse(res, 200, {
object: "list",
data: MODELS.map((m) => ({
id: m.id, object: "model", owned_by: "anthropic",
created: Math.floor(Date.now() / 1000),
})),
});
}
// POST /v1/chat/completions
if (req.url === "/v1/chat/completions" && req.method === "POST") {
return handleChatCompletions(req, res);
}
// GET /health — comprehensive diagnostics
if (req.url === "/health") {
let binaryOk = false;
try { accessSync(CLAUDE, constants.X_OK); binaryOk = true; } catch {}
const uptimeMs = Date.now() - START_TIME;
const sessionList = [];
for (const [id, s] of sessions) {
sessionList.push({
id: id.slice(0, 12) + "...",
model: s.model,
messages: s.messageCount,
idleMs: Date.now() - s.lastUsed,
});
}
return jsonResponse(res, 200, {
status: binaryOk && authStatus.ok !== false ? "ok" : "degraded",
version: VERSION,
architecture: "on-demand (v2)",
uptime: uptimeMs,
uptimeHuman: `${Math.floor(uptimeMs / 3600000)}h ${Math.floor((uptimeMs % 3600000) / 60000)}m`,
claudeBinary: CLAUDE,
claudeBinaryOk: binaryOk,
auth: authStatus,
config: {
timeout: TIMEOUT,
firstByteTimeout: BASE_FIRST_BYTE_TIMEOUT,
maxConcurrent: MAX_CONCURRENT,
sessionTTL: SESSION_TTL,
allowedTools: SKIP_PERMISSIONS ? "all (skip-permissions)" : ALLOWED_TOOLS,
systemPrompt: SYSTEM_PROMPT ? `${SYSTEM_PROMPT.slice(0, 50)}...` : "(none)",
mcpConfig: MCP_CONFIG || "(none)",
},
stats,
sessions: sessionList,
recentErrors: recentErrors.slice(-5),
});
}
// DELETE /sessions — clear all sessions
if (req.url === "/sessions" && req.method === "DELETE") {
const count = sessions.size;
sessions.clear();
return jsonResponse(res, 200, { cleared: count });
}
// GET /sessions — list active sessions
if (req.url === "/sessions" && req.method === "GET") {
const list = [];
for (const [id, s] of sessions) {
list.push({ id, uuid: s.uuid, model: s.model, messages: s.messageCount, lastUsed: new Date(s.lastUsed).toISOString() });
}
return jsonResponse(res, 200, { sessions: list });
}
// Catch-all POST
if (req.method === "POST") {
return handleChatCompletions(req, res);
}
jsonResponse(res, 404, { error: "Not found. Endpoints: GET /v1/models, POST /v1/chat/completions, GET /health, GET|DELETE /sessions" });
});
// ── Start ───────────────────────────────────────────────────────────────
server.listen(PORT, "0.0.0.0", () => {
console.log(`openclaw-claude-proxy v${VERSION} listening on http://0.0.0.0:${PORT}`);
console.log(`Architecture: on-demand spawning (no pool)`);
console.log(`Models: ${MODELS.map((m) => m.id).join(", ")}`);
console.log(`Claude binary: ${CLAUDE}`);
console.log(`Timeout: ${TIMEOUT}ms (base first-byte: ${BASE_FIRST_BYTE_TIMEOUT}ms, adaptive by model/prompt) | Max concurrent: ${MAX_CONCURRENT}`);
console.log(`Tools: ${SKIP_PERMISSIONS ? "all (skip-permissions)" : ALLOWED_TOOLS.join(", ")}`);
console.log(`Sessions: TTL=${SESSION_TTL / 1000}s`);
if (SYSTEM_PROMPT) console.log(`System prompt: "${SYSTEM_PROMPT.slice(0, 80)}..."`);
if (MCP_CONFIG) console.log(`MCP config: ${MCP_CONFIG}`);
console.log(`Auth: ${PROXY_API_KEY ? "enabled (PROXY_API_KEY set)" : "disabled (no PROXY_API_KEY)"}`);
console.log(`---`);
console.log(`Coexistence: This proxy does NOT conflict with Claude Code interactive mode.`);
console.log(` OCP uses: localhost:${PORT} (HTTP) → claude -p (per-request process)`);
console.log(` CC uses: MCP protocol (in-process) → persistent session`);
console.log(` Both can run simultaneously on the same machine.`);
});
-20
View File
@@ -1,20 +0,0 @@
{
"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,6 +1,6 @@
{ {
"name": "openclaw-claude-proxy", "name": "open-claude-proxy",
"version": "3.11.1", "version": "3.13.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.", "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", "type": "module",
"bin": { "bin": {
@@ -9,7 +9,8 @@
}, },
"scripts": { "scripts": {
"start": "node server.mjs", "start": "node server.mjs",
"setup": "node setup.mjs" "setup": "node setup.mjs",
"test": "node test-features.mjs"
}, },
"keywords": [ "keywords": [
"openclaw", "openclaw",
@@ -20,7 +21,7 @@
], ],
"license": "MIT", "license": "MIT",
"engines": { "engines": {
"node": ">=18" "node": ">=22.5"
}, },
"repository": { "repository": {
"type": "git", "type": "git",
+132
View File
@@ -0,0 +1,132 @@
#!/bin/bash
# One-shot field-evidence gatherer for OCP v3.12.0 SSE heartbeat.
# Scheduled by ~/Library/LaunchAgents/dev.ocp.heartbeat-check.plist to fire
# once at 2026-05-02 09:00 Australia/Brisbane. Gathers evidence from local
# OCP logs + GitHub issue #47 + repo issue search, posts a summary comment
# on #47, and exits. Does NOT open PRs or change code — the maintainer
# decides after reading the summary.
#
# Dry-run: ./heartbeat-field-check.sh --dry-run (prints summary, skips post)
set -euo pipefail
REPO="dtzp555-max/ocp"
SHIP_DATE="2026-04-25"
# Baseline captured at script-install time so internal testing entries from
# Phase 3 verification (~5 entries from 2026-04-25T00:0000:48Z) don't get
# counted as field evidence. Any heartbeat_active log entry with ts >= this
# timestamp is treated as a real opt-in.
BASELINE_TS="2026-04-25T01:00:00Z"
PROXY_LOG="$HOME/ocp/logs/proxy.log"
OUT_DIR="$HOME/ocp/logs"
SELF_LOG="$OUT_DIR/heartbeat-field-check-$(date +%Y-%m-%d).log"
DRY_RUN=0
[ "${1:-}" = "--dry-run" ] && DRY_RUN=1
mkdir -p "$OUT_DIR"
exec > >(tee -a "$SELF_LOG") 2>&1
echo "=== heartbeat field-evidence check: $(date -u +%Y-%m-%dT%H:%M:%SZ) (dry_run=$DRY_RUN) ==="
# ── signal 1: local proxy log ─────────────────────────────────────────────
if [ -r "$PROXY_LOG" ]; then
# Only count entries with ts >= BASELINE_TS (string sort works on RFC3339)
HEARTBEAT_COUNT=$(grep '"event":"heartbeat_active"' "$PROXY_LOG" 2>/dev/null \
| awk -v base="$BASELINE_TS" '
match($0, /"ts":"[^"]+"/) {
ts = substr($0, RSTART+6, RLENGTH-7);
if (ts >= base) c++
}
END { print c+0 }')
else
HEARTBEAT_COUNT=0
fi
echo "signal 1 — heartbeat_active log entries since $BASELINE_TS: $HEARTBEAT_COUNT"
# ── signal 2: comments on #47 since ship ──────────────────────────────────
NEW_47_JSON="/tmp/ocp-47-new-comments-$$.json"
gh issue view 47 --repo "$REPO" --json comments \
--jq '[.comments[] | select(.createdAt >= "'"$SHIP_DATE"'T00:00:00Z")]' \
> "$NEW_47_JSON" 2>/dev/null || echo "[]" > "$NEW_47_JSON"
NEW_COMMENTS=$(jq 'length' "$NEW_47_JSON")
echo "signal 2 — new comments on #47 since $SHIP_DATE: $NEW_COMMENTS"
# Build a compact, human-readable excerpt for the summary body
NEW_47_EXCERPT=""
if [ "$NEW_COMMENTS" -gt 0 ]; then
NEW_47_EXCERPT=$(jq -r '.[] | "- **@\(.author.login)** (\(.createdAt)): " + (.body | gsub("\r"; "") | split("\n")[0])[:180]' "$NEW_47_JSON")
fi
# ── signal 3: other heartbeat-related issues since ship ──────────────────
OTHER_ISSUES_JSON="/tmp/ocp-heartbeat-issues-$$.json"
gh search issues "repo:$REPO heartbeat" --json number,title,state,createdAt --limit 30 \
--jq '[.[] | select(.createdAt >= "'"$SHIP_DATE"'T00:00:00Z" and .number != 47 and .number != 48)]' \
> "$OTHER_ISSUES_JSON" 2>/dev/null || echo "[]" > "$OTHER_ISSUES_JSON"
OTHER_ISSUES=$(jq 'length' "$OTHER_ISSUES_JSON")
echo "signal 3 — other heartbeat-related issues since ship: $OTHER_ISSUES"
OTHER_ISSUES_EXCERPT=""
if [ "$OTHER_ISSUES" -gt 0 ]; then
OTHER_ISSUES_EXCERPT=$(jq -r '.[] | "- #\(.number) [\(.state)] \(.title)"' "$OTHER_ISSUES_JSON")
fi
# ── compose summary ──────────────────────────────────────────────────────
BODY_FILE="/tmp/ocp-47-summary-$$.md"
{
echo "### Automated 7-day field-evidence check (v3.12.0)"
echo
echo "_Triggered by a local launchd scheduled task on the maintainer's rig at $(date -u +%Y-%m-%dT%H:%M:%SZ)._"
echo
echo "| Signal | Count |"
echo "|---|---|"
echo "| \`heartbeat_active\` log entries on prod rig (since baseline $BASELINE_TS) | $HEARTBEAT_COUNT |"
echo "| New comments on #47 since $SHIP_DATE | $NEW_COMMENTS |"
echo "| Other heartbeat-related issues filed since $SHIP_DATE | $OTHER_ISSUES |"
echo
if [ -n "$NEW_47_EXCERPT" ]; then
echo "**New #47 comments (first line each):**"
echo
echo "$NEW_47_EXCERPT"
echo
fi
if [ -n "$OTHER_ISSUES_EXCERPT" ]; then
echo "**Other heartbeat-related issues:**"
echo
echo "$OTHER_ISSUES_EXCERPT"
echo
fi
echo "**Decision guidance for maintainer (manual):**"
echo
echo "- If any of the above indicate a **crash report** on \`: keepalive\` comment frames → leave default at \`0\` and file a \`CLAUDE_HEARTBEAT_FORMAT=empty-delta\` follow-up issue (spec \`§D2\` fallback plan)."
echo "- If there is at least one **opt-in confirmation** (a user reports \`CLAUDE_HEARTBEAT_INTERVAL\` fixed their timeout issue) and no crash reports → consider opening a PR for v3.13.0 flipping the default to \`30000\`, following the same ALIGNMENT + independent-reviewer + release-kit discipline as PR #49."
echo "- If all three signals are zero → extend the soak window or close this follow-up as \"no field evidence.\""
echo
echo "This bot does not open PRs or change code. The maintainer reviews and acts."
} > "$BODY_FILE"
echo "--- summary preview ---"
cat "$BODY_FILE"
echo "--- end preview ---"
# ── post (unless dry-run) ────────────────────────────────────────────────
if [ "$DRY_RUN" -eq 1 ]; then
echo "DRY RUN — skipping gh issue comment"
else
gh issue comment 47 --repo "$REPO" --body-file "$BODY_FILE" && echo "comment posted on #47"
fi
# ── cleanup + self-disable so the plist doesn't linger loaded forever ────
rm -f "$NEW_47_JSON" "$OTHER_ISSUES_JSON" "$BODY_FILE"
if [ "$DRY_RUN" -eq 0 ]; then
# Unload + remove the plist so this never fires again
PLIST="$HOME/Library/LaunchAgents/dev.ocp.heartbeat-check.plist"
if [ -f "$PLIST" ]; then
launchctl bootout "gui/$(id -u)" "$PLIST" 2>/dev/null || launchctl unload "$PLIST" 2>/dev/null || true
rm -f "$PLIST"
echo "self-disabled: removed $PLIST"
fi
fi
echo "=== done ==="
+142 -37
View File
@@ -25,6 +25,7 @@
* CLAUDE_BREAKER_WINDOW — sliding window duration in ms (default: 300000 = 5min) * CLAUDE_BREAKER_WINDOW — sliding window duration in ms (default: 300000 = 5min)
* CLAUDE_BREAKER_HALF_OPEN_MAX — max concurrent probes in half-open state (default: 2) * CLAUDE_BREAKER_HALF_OPEN_MAX — max concurrent probes in half-open state (default: 2)
* PROXY_API_KEY — Bearer token for API auth (optional) * PROXY_API_KEY — Bearer token for API auth (optional)
* CLAUDE_HEARTBEAT_INTERVAL — SSE heartbeat interval in ms on streaming path (default: 0 = disabled)
*/ */
import { createServer } from "node:http"; import { createServer } from "node:http";
import { spawn, execFileSync } from "node:child_process"; import { spawn, execFileSync } from "node:child_process";
@@ -33,7 +34,7 @@ import { readFileSync, accessSync, existsSync, constants } from "node:fs";
import { fileURLToPath } from "node:url"; import { fileURLToPath } from "node:url";
import { dirname, join } from "node:path"; import { dirname, join } from "node:path";
import { homedir } from "node:os"; import { homedir } from "node:os";
import { validateKey, recordUsage, getUsageByKey, getUsageTimeline, getRecentUsage, createKey, listKeys, revokeKey, closeDb, checkQuota, updateKeyQuota, getKeyQuota, findKey, cacheHash, getCachedResponse, setCachedResponse, clearCache, getCacheStats } from "./keys.mjs"; import { validateKey, recordUsage, getUsageByKey, getUsageTimeline, getRecentUsage, createKey, listKeys, revokeKey, closeDb, checkQuota, updateKeyQuota, getKeyQuota, findKey, cacheHash, getCachedResponse, setCachedResponse, clearCache, getCacheStats, hasCacheControl, singleflight, getInflightStats } from "./keys.mjs";
const __dirname = dirname(fileURLToPath(import.meta.url)); const __dirname = dirname(fileURLToPath(import.meta.url));
const _pkg = JSON.parse(readFileSync(join(__dirname, "package.json"), "utf8")); const _pkg = JSON.parse(readFileSync(join(__dirname, "package.json"), "utf8"));
@@ -94,6 +95,7 @@ const BREAKER_THRESHOLD = parseInt(process.env.CLAUDE_BREAKER_THRESHOLD || "6",
const BREAKER_COOLDOWN = parseInt(process.env.CLAUDE_BREAKER_COOLDOWN || "120000", 10); const BREAKER_COOLDOWN = parseInt(process.env.CLAUDE_BREAKER_COOLDOWN || "120000", 10);
const BREAKER_WINDOW = parseInt(process.env.CLAUDE_BREAKER_WINDOW || "300000", 10); const BREAKER_WINDOW = parseInt(process.env.CLAUDE_BREAKER_WINDOW || "300000", 10);
const BREAKER_HALF_OPEN_MAX = parseInt(process.env.CLAUDE_BREAKER_HALF_OPEN_MAX || "2", 10); const BREAKER_HALF_OPEN_MAX = parseInt(process.env.CLAUDE_BREAKER_HALF_OPEN_MAX || "2", 10);
const HEARTBEAT_INTERVAL = parseInt(process.env.CLAUDE_HEARTBEAT_INTERVAL || "0", 10);
const BIND_ADDRESS = process.env.CLAUDE_BIND || "127.0.0.1"; const BIND_ADDRESS = process.env.CLAUDE_BIND || "127.0.0.1";
const NO_CONTEXT = process.env.CLAUDE_NO_CONTEXT === "true"; const NO_CONTEXT = process.env.CLAUDE_NO_CONTEXT === "true";
const AUTH_MODE = process.env.CLAUDE_AUTH_MODE || (PROXY_API_KEY ? "shared" : "none"); const AUTH_MODE = process.env.CLAUDE_AUTH_MODE || (PROXY_API_KEY ? "shared" : "none");
@@ -163,9 +165,18 @@ const sessions = new Map(); // conversationId → { uuid, messageCount, lastUsed
const sessionCleanupInterval = setInterval(() => { const sessionCleanupInterval = setInterval(() => {
const now = Date.now(); const now = Date.now();
for (const [id, s] of sessions) { for (const [id, s] of sessions) {
if (now - s.lastUsed > SESSION_TTL) { const idleMs = now - s.lastUsed;
const ageMs = s.firstSeen ? now - s.firstSeen : null;
if (idleMs > SESSION_TTL) {
sessions.delete(id); sessions.delete(id);
console.log(`[session] expired ${id.slice(0, 12)}... (idle ${Math.round((now - s.lastUsed) / 60000)}m)`); console.log(`[session] expired ${id.slice(0, 12)}... (idle ${Math.round(idleMs / 60000)}m)`);
logEvent("info", "session_expired", { conversationId: id.slice(0, 12) + "...", idleMs, ageMs });
} else if (ageMs !== null && ageMs > 4 * SESSION_TTL) {
// #42 evidence-gathering: a session whose firstSeen is more than 4× TTL old
// but whose lastUsed keeps getting bumped (never idle long enough to expire)
// is the suspected bug. Log without action so the pattern can be confirmed
// in /logs. Do NOT enforce an absolute age cap here speculatively.
logEvent("warn", "session_long_lived", { conversationId: id.slice(0, 12) + "...", idleMs, ageMs });
} }
} }
}, 60000); }, 60000);
@@ -404,7 +415,8 @@ function spawnClaudeProcess(model, messages, conversationId) {
} else if (conversationId) { } else if (conversationId) {
const uuid = randomUUID(); const uuid = randomUUID();
sessions.set(conversationId, { uuid, messageCount: messages.length, lastUsed: Date.now(), model: cliModel }); const now = Date.now();
sessions.set(conversationId, { uuid, messageCount: messages.length, firstSeen: now, lastUsed: now, model: cliModel });
sessionInfo = { uuid, resume: false }; sessionInfo = { uuid, resume: false };
stats.sessionMisses++; stats.sessionMisses++;
prompt = messagesToPrompt(messages); prompt = messagesToPrompt(messages);
@@ -456,7 +468,13 @@ function spawnClaudeProcess(model, messages, conversationId) {
function handleSessionFailure() { function handleSessionFailure() {
if (sessionInfo?.resume && conversationId) { if (sessionInfo?.resume && conversationId) {
console.warn(`[session] resume failed for ${conversationId.slice(0, 12)}..., removing stale session`); console.warn(`[session] resume failed for ${conversationId.slice(0, 12)}..., removing stale session`);
logEvent("warn", "session_failure", { mode: "resume", conversationId: conversationId.slice(0, 12) + "...", action: "deleted" });
sessions.delete(conversationId); sessions.delete(conversationId);
} else if (sessionInfo && !sessionInfo.resume && conversationId) {
// #41 evidence-gathering: session-create failures currently leave a stale entry
// in the sessions map. Log without action so the staleness pattern can be
// confirmed in /logs before any code change. Do NOT delete here speculatively.
logEvent("warn", "session_failure", { mode: "create", conversationId: conversationId.slice(0, 12) + "...", action: "kept" });
} }
} }
@@ -542,9 +560,37 @@ function callClaude(model, messages, conversationId) {
}); });
} }
// ── SSE heartbeat (opt-in idle watchdog) ────────────────────────────────
// Emits `: keepalive\n\n` SSE comment frames during silent windows on the
// streaming response. Design: docs/superpowers/specs/2026-04-25-47-sse-heartbeat-design.md
// This is a downstream liveness hint only — it MUST NOT be able to abort
// or time out a request. That discipline is load-bearing: v2.2-v2.5's
// first-byte/adaptive-tier timeouts "repeatedly killed valid requests"
// (see server.mjs top-of-file comment and commit 3843ec8).
function startHeartbeat(res, intervalMs, sessionId) {
if (!intervalMs || intervalMs <= 0) return { reset: () => {}, stop: () => {} };
let handle = null;
let hasFired = false;
const onFire = () => {
if (res.writableEnded || res.destroyed) return;
res.write(": keepalive\n\n");
if (!hasFired) {
hasFired = true;
logEvent("info", "heartbeat_active", { session: sessionId, intervalMs });
}
handle = setTimeout(onFire, intervalMs);
};
handle = setTimeout(onFire, intervalMs);
return {
reset: () => { if (handle) { clearTimeout(handle); handle = setTimeout(onFire, intervalMs); } },
stop: () => { if (handle) { clearTimeout(handle); handle = null; } },
};
}
// ── Call claude CLI (real streaming) ───────────────────────────────────── // ── Call claude CLI (real streaming) ─────────────────────────────────────
// Pipes stdout from the claude process directly to SSE chunks as they arrive. // Pipes stdout from the claude process directly to SSE chunks as they arrive.
// Each data chunk becomes a proper SSE event with delta content in real time. // Each data chunk becomes a proper SSE event with delta content in real time.
// TODO(cache-singleflight-stream): streaming-path singleflight is out of scope for v3.13.0; see spec D4 streaming caveat.
function callClaudeStreaming(model, messages, conversationId, res, authInfo = {}) { function callClaudeStreaming(model, messages, conversationId, res, authInfo = {}) {
const id = `chatcmpl-${randomUUID()}`; const id = `chatcmpl-${randomUUID()}`;
const created = Math.floor(Date.now() / 1000); const created = Math.floor(Date.now() / 1000);
@@ -563,12 +609,14 @@ function callClaudeStreaming(model, messages, conversationId, res, authInfo = {}
let cachedContent = ""; // accumulate for cache write-back let cachedContent = ""; // accumulate for cache write-back
function ensureHeaders() { function ensureHeaders() {
if (headersSent || res.writableEnded || res.destroyed) return false; if (res.writableEnded || res.destroyed) return false;
if (headersSent) return true;
headersSent = true; headersSent = true;
res.writeHead(200, { res.writeHead(200, {
"Content-Type": "text/event-stream", "Content-Type": "text/event-stream",
"Cache-Control": "no-cache", "Cache-Control": "no-cache",
"Connection": "keep-alive", "Connection": "keep-alive",
"X-Accel-Buffering": "no",
}); });
// Send initial role chunk // Send initial role chunk
sendSSE(res, { sendSSE(res, {
@@ -578,6 +626,15 @@ function callClaudeStreaming(model, messages, conversationId, res, authInfo = {}
return true; return true;
} }
// D4 (spec 2026-04-25): eagerly send SSE headers post-spawn so the
// heartbeat started in the next statement (Task 1.3) covers the
// pre-first-byte silent window. Behavior change: the `code !== 0`
// before-first-byte branch at server.mjs:610-611 becomes effectively
// unreachable in the common case — the post-headers SSE-stop path
// (612-619) handles it instead.
ensureHeaders();
const hb = startHeartbeat(res, HEARTBEAT_INTERVAL, convId);
proc.stdout.on("data", (d) => { proc.stdout.on("data", (d) => {
markFirstByte(); markFirstByte();
const text = d.toString(); const text = d.toString();
@@ -590,13 +647,14 @@ function callClaudeStreaming(model, messages, conversationId, res, authInfo = {}
sendSSE(res, { sendSSE(res, {
id, object: "chat.completion.chunk", created, model, id, object: "chat.completion.chunk", created, model,
choices: [{ index: 0, delta: { content: text }, finish_reason: null }], choices: [{ index: 0, delta: { content: text }, finish_reason: null }],
}); }, hb);
}); });
proc.stderr.on("data", (d) => (stderr += d)); proc.stderr.on("data", (d) => (stderr += d));
proc.on("close", (code, signal) => { proc.on("close", (code, signal) => {
activeProcesses.delete(proc); activeProcesses.delete(proc);
hb.stop();
cleanup(); cleanup();
const elapsed = Date.now() - t0; const elapsed = Date.now() - t0;
@@ -613,7 +671,7 @@ function callClaudeStreaming(model, messages, conversationId, res, authInfo = {}
sendSSE(res, { sendSSE(res, {
id, object: "chat.completion.chunk", created, model, id, object: "chat.completion.chunk", created, model,
choices: [{ index: 0, delta: {}, finish_reason: "stop" }], choices: [{ index: 0, delta: {}, finish_reason: "stop" }],
}); }, hb);
res.write("data: [DONE]\n\n"); res.write("data: [DONE]\n\n");
res.end(); res.end();
} }
@@ -632,7 +690,7 @@ function callClaudeStreaming(model, messages, conversationId, res, authInfo = {}
sendSSE(res, { sendSSE(res, {
id, object: "chat.completion.chunk", created, model, id, object: "chat.completion.chunk", created, model,
choices: [{ index: 0, delta: {}, finish_reason: "stop" }], choices: [{ index: 0, delta: {}, finish_reason: "stop" }],
}); }, hb);
res.write("data: [DONE]\n\n"); res.write("data: [DONE]\n\n");
res.end(); res.end();
} }
@@ -641,6 +699,7 @@ function callClaudeStreaming(model, messages, conversationId, res, authInfo = {}
proc.on("error", (err) => { proc.on("error", (err) => {
console.error(`[claude] spawn error: ${err.message}`); console.error(`[claude] spawn error: ${err.message}`);
hb.stop();
cleanup(); cleanup();
trackError(err.message); trackError(err.message);
handleSessionFailure(); handleSessionFailure();
@@ -653,6 +712,7 @@ function callClaudeStreaming(model, messages, conversationId, res, authInfo = {}
// If client disconnects, kill the process to free resources // If client disconnects, kill the process to free resources
res.on("close", () => { res.on("close", () => {
hb.stop();
if (!proc.killed) { if (!proc.killed) {
try { proc.kill("SIGTERM"); } catch {} try { proc.kill("SIGTERM"); } catch {}
} }
@@ -666,7 +726,8 @@ function jsonResponse(res, status, data) {
res.end(JSON.stringify(data)); res.end(JSON.stringify(data));
} }
function sendSSE(res, data) { function sendSSE(res, data, hb) {
hb?.reset();
res.write(`data: ${JSON.stringify(data)}\n\n`); res.write(`data: ${JSON.stringify(data)}\n\n`);
} }
@@ -1158,30 +1219,43 @@ async function handleChatCompletions(req, res) {
// Cache check (only when cache is enabled and no active conversation/session) // Cache check (only when cache is enabled and no active conversation/session)
if (CACHE_TTL > 0 && !conversationId) { if (CACHE_TTL > 0 && !conversationId) {
const hash = cacheHash(model, messages, { temperature: parsed.temperature, max_tokens: parsed.max_tokens, top_p: parsed.top_p }); // D2: skip OCP cache entirely when messages carry cache_control annotations;
req._cacheHash = hash; // store for later write-back // the client is requesting Anthropic-side prompt caching, not OCP-layer caching.
try { if (hasCacheControl(messages)) {
const cached = getCachedResponse(hash, CACHE_TTL); req._cacheHash = null;
if (cached) { logEvent("info", "cache_skipped", { reason: "cache_control_present" });
logEvent("info", "cache_hit", { model, hash: hash.slice(0, 12), hits: cached.hits }); } else {
if (stream) { // D1: include keyId in hash to isolate per-key cache pools (v2 format)
// Simulate streaming for cached response const hash = cacheHash(model, messages, { keyId: req._authKeyId, temperature: parsed.temperature, max_tokens: parsed.max_tokens, top_p: parsed.top_p });
const id = `chatcmpl-${randomUUID()}`; req._cacheHash = hash; // store for later write-back
const created = Math.floor(Date.now() / 1000); try {
res.writeHead(200, { "Content-Type": "text/event-stream", "Cache-Control": "no-cache", "Connection": "keep-alive" }); const cached = getCachedResponse(hash, CACHE_TTL);
sendSSE(res, { id, object: "chat.completion.chunk", created, model, choices: [{ index: 0, delta: { role: "assistant" }, finish_reason: null }] }); if (cached) {
sendSSE(res, { id, object: "chat.completion.chunk", created, model, choices: [{ index: 0, delta: { content: cached.response }, finish_reason: null }] }); logEvent("info", "cache_hit", { model, hash: hash.slice(0, 12), hits: cached.hits });
sendSSE(res, { id, object: "chat.completion.chunk", created, model, choices: [{ index: 0, delta: {}, finish_reason: "stop" }] }); if (stream) {
res.write("data: [DONE]\n\n"); // D3: replay cached content as chunked SSE stream (80 codepoints/chunk)
res.end(); const CACHE_REPLAY_CHUNK_SIZE = 80;
return; const id = `chatcmpl-${randomUUID()}`;
} else { const created = Math.floor(Date.now() / 1000);
const id = `chatcmpl-${randomUUID()}`; res.writeHead(200, { "Content-Type": "text/event-stream", "Cache-Control": "no-cache", "Connection": "keep-alive", "X-Accel-Buffering": "no" });
return completionResponse(res, id, model, cached.response); sendSSE(res, { id, object: "chat.completion.chunk", created, model, choices: [{ index: 0, delta: { role: "assistant" }, finish_reason: null }] });
const codepoints = Array.from(cached.response);
for (let i = 0; i < codepoints.length; i += CACHE_REPLAY_CHUNK_SIZE) {
const chunk = codepoints.slice(i, i + CACHE_REPLAY_CHUNK_SIZE).join("");
sendSSE(res, { id, object: "chat.completion.chunk", created, model, choices: [{ index: 0, delta: { content: chunk }, finish_reason: null }] });
}
sendSSE(res, { id, object: "chat.completion.chunk", created, model, choices: [{ index: 0, delta: {}, finish_reason: "stop" }] });
res.write("data: [DONE]\n\n");
res.end();
return;
} else {
const id = `chatcmpl-${randomUUID()}`;
return completionResponse(res, id, model, cached.response);
}
} }
} catch (e) {
logEvent("error", "cache_check_failed", { error: e.message });
} }
} catch (e) {
logEvent("error", "cache_check_failed", { error: e.message });
} }
} }
@@ -1192,14 +1266,45 @@ async function handleChatCompletions(req, res) {
const t0Usage = Date.now(); const t0Usage = Date.now();
const promptChars = messages.reduce((a, m) => a + (typeof m.content === "string" ? m.content.length : JSON.stringify(m.content).length), 0); const promptChars = messages.reduce((a, m) => a + (typeof m.content === "string" ? m.content.length : JSON.stringify(m.content).length), 0);
// Non-streaming path with stampede protection: wrap the upstream call in singleflight
// when cache is enabled and a hash is present. Concurrent identical requests share
// one upstream spawn; followers receive the same promise. Streaming-path dedup is
// explicitly out of scope (see TODO comment above callClaudeStreaming).
if (CACHE_TTL > 0 && req._cacheHash) {
try {
const content = await singleflight(req._cacheHash, async () => {
// Re-check cache inside the singleflight: a follower that enters before the
// leader finishes will wait on the shared promise (not reach here), but a
// request that races in just after the previous singleflight cleared the map
// will re-read the freshly-populated cache entry here rather than spawning.
const recheck = getCachedResponse(req._cacheHash, CACHE_TTL);
if (recheck) return recheck.response;
const c = await callClaude(model, messages, conversationId);
try { setCachedResponse(req._cacheHash, model, c); } catch (e) { logEvent("error", "cache_write_failed", { error: e.message }); }
return c;
});
const id = `chatcmpl-${randomUUID()}`;
completionResponse(res, id, model, content);
try { recordUsage({ keyId: req._authKeyId, keyName: req._authKeyName, model, promptChars, responseChars: content.length, elapsedMs: Date.now() - t0Usage, success: true }); } catch (e) { logEvent("error", "usage_record_failed", { error: e.message }); }
return;
} catch (err) {
try { recordUsage({ keyId: req._authKeyId, keyName: req._authKeyName, model, promptChars, responseChars: 0, elapsedMs: Date.now() - t0Usage, success: false }); } catch (e) { logEvent("error", "usage_record_failed", { error: e.message }); }
console.error(`[proxy] error: ${err.message}`);
if (res.headersSent || res.writableEnded || res.destroyed) {
try { res.end(); } catch {}
return;
}
const safeMessage = (err.message || "Internal error").replace(/\/[\w/.\-]+/g, "[path]");
return jsonResponse(res, 500, { error: { message: safeMessage, type: "proxy_error" } });
}
}
// Fallback: cache disabled (CACHE_TTL=0) or no _cacheHash — original path untouched.
try { try {
const content = await callClaude(model, messages, conversationId); const content = await callClaude(model, messages, conversationId);
const id = `chatcmpl-${randomUUID()}`; const id = `chatcmpl-${randomUUID()}`;
completionResponse(res, id, model, content); completionResponse(res, id, model, content);
// Write to cache
if (CACHE_TTL > 0 && req._cacheHash) {
try { setCachedResponse(req._cacheHash, model, content); } catch (e) { logEvent("error", "cache_write_failed", { error: e.message }); }
}
try { recordUsage({ keyId: req._authKeyId, keyName: req._authKeyName, model, promptChars, responseChars: content.length, elapsedMs: Date.now() - t0Usage, success: true }); } catch (e) { logEvent("error", "usage_record_failed", { error: e.message }); } try { recordUsage({ keyId: req._authKeyId, keyName: req._authKeyName, model, promptChars, responseChars: content.length, elapsedMs: Date.now() - t0Usage, success: true }); } catch (e) { logEvent("error", "usage_record_failed", { error: e.message }); }
} catch (err) { } catch (err) {
try { recordUsage({ keyId: req._authKeyId, keyName: req._authKeyName, model, promptChars, responseChars: 0, elapsedMs: Date.now() - t0Usage, success: false }); } catch (e) { logEvent("error", "usage_record_failed", { error: e.message }); } try { recordUsage({ keyId: req._authKeyId, keyName: req._authKeyName, model, promptChars, responseChars: 0, elapsedMs: Date.now() - t0Usage, success: false }); } catch (e) { logEvent("error", "usage_record_failed", { error: e.message }); }
@@ -1478,10 +1583,10 @@ const server = createServer(async (req, res) => {
}); });
} }
// GET /cache/stats — cache statistics // GET /cache/stats — cache statistics (entries, hits, size, inflight singleflight count)
if (pathname === "/cache/stats" && req.method === "GET") { if (pathname === "/cache/stats" && req.method === "GET") {
if (!isAdmin) return jsonResponse(res, 403, { error: "Admin access required" }); if (!isAdmin) return jsonResponse(res, 403, { error: "Admin access required" });
return jsonResponse(res, 200, getCacheStats()); return jsonResponse(res, 200, { ...getCacheStats(), ...getInflightStats() });
} }
// DELETE /cache — clear cache // DELETE /cache — clear cache
+187 -125
View File
@@ -1,6 +1,6 @@
#!/usr/bin/env node #!/usr/bin/env node
/** /**
* openclaw-claude-proxy setup * OCP (Open Claude Proxy) setup
* *
* Automatically configures OpenClaw to use Claude CLI as a model provider. * Automatically configures OpenClaw to use Claude CLI as a model provider.
* Run: node setup.mjs [--port 3456] [--default-model opus|sonnet|haiku] [--dry-run] * Run: node setup.mjs [--port 3456] [--default-model opus|sonnet|haiku] [--dry-run]
@@ -12,7 +12,7 @@
* 4. Creates start.sh for easy launch * 4. Creates start.sh for easy launch
* 5. Optionally starts the proxy * 5. Optionally starts the proxy
*/ */
import { readFileSync, writeFileSync, existsSync, mkdirSync, unlinkSync } from "node:fs"; import { readFileSync, writeFileSync, existsSync, mkdirSync, unlinkSync, readdirSync } from "node:fs";
import { execSync } from "node:child_process"; import { execSync } from "node:child_process";
import { join, dirname } from "node:path"; import { join, dirname } from "node:path";
import { homedir } from "node:os"; import { homedir } from "node:os";
@@ -107,106 +107,112 @@ try {
warn("Make sure you're logged in: claude login"); warn("Make sure you're logged in: claude login");
} }
// Check openclaw config // Check openclaw config (optional — OCP runs standalone without OpenClaw)
if (!existsSync(CONFIG_PATH)) fail(`OpenClaw config not found at ${CONFIG_PATH}`); const OPENCLAW_PRESENT = existsSync(CONFIG_PATH);
log(`OpenClaw config: ${CONFIG_PATH}`); if (OPENCLAW_PRESENT) {
log(`OpenClaw config: ${CONFIG_PATH}`);
} else {
warn(`OpenClaw not detected at ${CONFIG_PATH} — skipping OpenClaw integration.`);
warn(`To register OCP with OpenClaw later, install OpenClaw and re-run \`node setup.mjs\`,`);
warn(`or run \`ocp update\` if OpenClaw is installed afterward.`);
}
// ── Step 2: Patch openclaw.json ───────────────────────────────────────── // ── Step 2: Patch openclaw.json ─────────────────────────────────────────
console.log("\n📝 Configuring OpenClaw...\n"); if (OPENCLAW_PRESENT) {
console.log("\n📝 Configuring OpenClaw...\n");
const config = readJSON(CONFIG_PATH); const config = readJSON(CONFIG_PATH);
// Ensure models.providers exists // Ensure models.providers exists
if (!config.models) config.models = {}; if (!config.models) config.models = {};
if (!config.models.providers) config.models.providers = {}; if (!config.models.providers) config.models.providers = {};
// Add/update claude-local provider // Add/update claude-local provider
config.models.providers[PROVIDER_NAME] = { config.models.providers[PROVIDER_NAME] = {
baseUrl: `http://127.0.0.1:${PORT}/v1`, baseUrl: `http://127.0.0.1:${PORT}/v1`,
api: "openai-completions", api: "openai-completions",
authHeader: false, authHeader: false,
models: MODELS, models: MODELS,
}; };
log(`Provider "${PROVIDER_NAME}" → http://127.0.0.1:${PORT}/v1`); log(`Provider "${PROVIDER_NAME}" → http://127.0.0.1:${PORT}/v1`);
// Ensure auth profile in config // Ensure auth profile in config
if (!config.auth) config.auth = {}; if (!config.auth) config.auth = {};
if (!config.auth.profiles) config.auth.profiles = {}; if (!config.auth.profiles) config.auth.profiles = {};
config.auth.profiles[`${PROVIDER_NAME}:default`] = { config.auth.profiles[`${PROVIDER_NAME}:default`] = {
provider: PROVIDER_NAME, provider: PROVIDER_NAME,
mode: "api_key", mode: "api_key",
}; };
log(`Auth profile "${PROVIDER_NAME}:default" registered`); log(`Auth profile "${PROVIDER_NAME}:default" registered`);
// Add models to agents.defaults.models // Add models to agents.defaults.models
if (!config.agents) config.agents = {}; if (!config.agents) config.agents = {};
if (!config.agents.defaults) config.agents.defaults = {}; if (!config.agents.defaults) config.agents.defaults = {};
if (!config.agents.defaults.models) config.agents.defaults.models = {}; if (!config.agents.defaults.models) config.agents.defaults.models = {};
for (const [key, val] of Object.entries(MODEL_ALIASES)) { for (const [key, val] of Object.entries(MODEL_ALIASES)) {
config.agents.defaults.models[key] = val; config.agents.defaults.models[key] = val;
}
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`);
// ── Step 3: Patch auth-profiles.json ────────────────────────────────────
console.log("\n🔑 Configuring auth profiles...\n");
// Find all agent auth-profiles.json files
const agentsDir = join(OPENCLAW_DIR, "agents");
const agentDirs = existsSync(agentsDir)
? readdirSync(agentsDir).filter((d) => {
const ap = join(agentsDir, d, "agent", "auth-profiles.json");
return existsSync(ap);
})
: [];
import { readdirSync } from "node:fs";
for (const agentId of agentDirs) {
const apPath = join(agentsDir, agentId, "agent", "auth-profiles.json");
try {
const ap = readJSON(apPath);
if (!ap.profiles) ap.profiles = {};
// Add claude-local profile if missing
if (!ap.profiles[`${PROVIDER_NAME}:default`]) {
ap.profiles[`${PROVIDER_NAME}:default`] = {
type: "api_key",
provider: PROVIDER_NAME,
key: "local-proxy-no-auth",
};
}
// Add to lastGood if missing
if (!ap.lastGood) ap.lastGood = {};
if (!ap.lastGood[PROVIDER_NAME]) {
ap.lastGood[PROVIDER_NAME] = `${PROVIDER_NAME}:default`;
}
writeJSON(apPath, ap);
log(`Agent "${agentId}" auth profile updated`);
} catch (e) {
warn(`Skipped agent "${agentId}": ${e.message}`);
} }
} log(`Model aliases added to agents.defaults.models`);
if (agentDirs.length === 0) { // Set idleTimeoutSeconds to 0 — critical for Claude tool-use.
warn("No agent auth-profiles.json found — you may need to restart the gateway first"); // 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`);
// ── Step 3: Patch auth-profiles.json ────────────────────────────────────
console.log("\n🔑 Configuring auth profiles...\n");
// Find all agent auth-profiles.json files
const agentsDir = join(OPENCLAW_DIR, "agents");
const agentDirs = existsSync(agentsDir)
? readdirSync(agentsDir).filter((d) => {
const ap = join(agentsDir, d, "agent", "auth-profiles.json");
return existsSync(ap);
})
: [];
for (const agentId of agentDirs) {
const apPath = join(agentsDir, agentId, "agent", "auth-profiles.json");
try {
const ap = readJSON(apPath);
if (!ap.profiles) ap.profiles = {};
// Add claude-local profile if missing
if (!ap.profiles[`${PROVIDER_NAME}:default`]) {
ap.profiles[`${PROVIDER_NAME}:default`] = {
type: "api_key",
provider: PROVIDER_NAME,
key: "local-proxy-no-auth",
};
}
// Add to lastGood if missing
if (!ap.lastGood) ap.lastGood = {};
if (!ap.lastGood[PROVIDER_NAME]) {
ap.lastGood[PROVIDER_NAME] = `${PROVIDER_NAME}:default`;
}
writeJSON(apPath, ap);
log(`Agent "${agentId}" auth profile updated`);
} catch (e) {
warn(`Skipped agent "${agentId}": ${e.message}`);
}
}
if (agentDirs.length === 0) {
warn("No agent auth-profiles.json found — you may need to restart the gateway first");
}
} }
// ── Step 4: Create start.sh ───────────────────────────────────────────── // ── Step 4: Create start.sh ─────────────────────────────────────────────
@@ -217,7 +223,7 @@ const logDir = join(OPENCLAW_DIR, "logs");
if (!existsSync(logDir)) mkdirSync(logDir, { recursive: true }); if (!existsSync(logDir)) mkdirSync(logDir, { recursive: true });
const startSh = `#!/bin/bash const startSh = `#!/bin/bash
# Start openclaw-claude-proxy if not already running # Start OCP (Open Claude Proxy) if not already running
PORT=\${CLAUDE_PROXY_PORT:-${PORT}} PORT=\${CLAUDE_PROXY_PORT:-${PORT}}
if ! lsof -i :\$PORT -sTCP:LISTEN &>/dev/null; then if ! lsof -i :\$PORT -sTCP:LISTEN &>/dev/null; then
unset CLAUDECODE unset CLAUDECODE
@@ -238,38 +244,46 @@ if (!DRY_RUN) {
log(`Launcher: ${startPath}`); log(`Launcher: ${startPath}`);
// ── Step 5: Summary ───────────────────────────────────────────────────── // ── Step 5: Summary ─────────────────────────────────────────────────────
console.log(` const banner = [
`╔══════════════════════════════════════════════════════════════╗`,
Setup complete! `║ Setup complete! ║`,
`╠══════════════════════════════════════════════════════════════╣`,
`║ ║`,
Provider: ${PROVIDER_NAME.padEnd(44)} `║ Provider: ${PROVIDER_NAME.padEnd(44)}`,
Port: ${String(PORT).padEnd(44)} `║ Port: ${String(PORT).padEnd(44)}`,
Models: ${`see models.json (${MODELS.length} available)`.padEnd(44)} `║ Models: ${`see models.json (${MODELS.length} available)`.padEnd(44)}`,
Default: ${DEFAULT_MODEL_ID.padEnd(44)} `║ Default: ${DEFAULT_MODEL_ID.padEnd(44)}`,
`║ ║`,
Start proxy: `║ Start proxy: ║`,
bash ${startPath.replace(HOME, "~").padEnd(50)} `║ bash ${startPath.replace(HOME, "~").padEnd(50)}`,
`║ ║`,
Or directly: `║ Or directly: ║`,
node ${serverPath.replace(HOME, "~").padEnd(49)} `║ node ${serverPath.replace(HOME, "~").padEnd(49)}`,
`║ ║`,
Set as default model in openclaw.json: ];
agents.defaults.model.primary = if (OPENCLAW_PRESENT) {
"${PROVIDER_NAME}/${DEFAULT_MODEL_ID}"${" ".repeat(Math.max(0, 30 - PROVIDER_NAME.length - DEFAULT_MODEL_ID.length))} banner.push(
`║ Set as default model in openclaw.json: ║`,
Then restart gateway: `║ agents.defaults.model.primary = ║`,
openclaw gateway restart `║ "${PROVIDER_NAME}/${DEFAULT_MODEL_ID}"${" ".repeat(Math.max(0, 30 - PROVIDER_NAME.length - DEFAULT_MODEL_ID.length))}`,
`║ ║`,
`║ Then restart gateway: ║`,
`); `║ openclaw gateway restart ║`,
`║ ║`,
// ── Step 6: Optionally start ──────────────────────────────────────────── );
if (!SKIP_START && !DRY_RUN) { } else {
try { banner.push(
execSync(`bash "${startPath}"`, { stdio: "inherit" }); `║ OpenClaw not detected — running in standalone mode. ║`,
} catch { /* ignore */ } `║ Point your IDE (Cline / Cursor / Continue / OpenCode / ║`,
`║ Aider / OpenClaw) at: ║`,
`║ http://${BIND_ADDRESS}:${String(PORT)}/v1${" ".repeat(Math.max(0, 47 - BIND_ADDRESS.length - String(PORT).length))}`,
`║ ║`,
`║ See README § "Client Setup" for per-IDE instructions. ║`,
`║ ║`,
);
} }
banner.push(`╚══════════════════════════════════════════════════════════════╝`);
console.log("\n" + banner.join("\n") + "\n");
// ── Step 7: Install auto-start on boot ────────────────────────────────── // ── Step 7: Install auto-start on boot ──────────────────────────────────
if (!DRY_RUN) { if (!DRY_RUN) {
@@ -405,4 +419,52 @@ WantedBy=default.target
} }
console.log("\n✅ Auto-start installed — proxy will start automatically on login\n"); console.log("\n✅ Auto-start installed — proxy will start automatically on login\n");
// ── Step 8: Post-install health verification ───────────────────────────
if (!SKIP_START) {
console.log("⏳ Waiting for server to bind...\n");
await new Promise(r => setTimeout(r, 3000));
const healthUrl = `http://127.0.0.1:${PORT}/health`;
let verified = false;
try {
const controller = new AbortController();
const timer = setTimeout(() => controller.abort(), 5000);
const res = await fetch(healthUrl, { signal: controller.signal });
clearTimeout(timer);
if (res.ok) {
const body = await res.json().catch(() => ({}));
console.log(` ✓ Health check passed (${healthUrl})`);
console.log(` version: ${body.version ?? "unknown"}`);
console.log(` authMode: ${body.authMode ?? "unknown"}`);
// Verify bind socket
try {
const bindCheck = process.platform === "linux"
? execSync(`ss -tlnp 2>/dev/null | grep ':${PORT}'`, { encoding: "utf-8" }).trim()
: execSync(`lsof -nP -iTCP:${PORT} -sTCP:LISTEN 2>/dev/null`, { encoding: "utf-8" }).trim();
if (bindCheck) {
console.log(` bind: ${bindCheck.split("\n")[0]}`);
}
} catch { /* bind check is best-effort */ }
verified = true;
} else {
warn(`Health check returned HTTP ${res.status} — service may not have started cleanly`);
}
} catch (e) {
const isTimeout = e.name === "AbortError" || (e.cause && e.cause.code === "UND_ERR_CONNECT_TIMEOUT");
warn(`Health check failed: ${isTimeout ? "timeout (5s)" : e.message}`);
}
if (!verified) {
const logHint = process.platform === "linux"
? "journalctl --user -u ocp-proxy -n 50"
: `tail -n 100 ~/.ocp/logs/proxy.log`;
console.error(`\n ✗ Server did not respond on port ${PORT} within 5 seconds.`);
console.error(` Check service logs:\n ${logHint}\n`);
process.exit(1);
}
}
} }
View File
-12
View File
@@ -1,12 +0,0 @@
#!/bin/bash
# Start openclaw-claude-proxy if not already running
PORT=${CLAUDE_PROXY_PORT:-3456}
if ! lsof -i :$PORT -sTCP:LISTEN &>/dev/null; then
unset CLAUDECODE
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 $!)"
else
echo "claude-proxy already running on port $PORT"
fi
+202 -1
View File
@@ -3,7 +3,8 @@
* Integration test for Quota + Cache features. * Integration test for Quota + Cache features.
* Tests database layer functions directly no server needed. * Tests database layer functions directly no server needed.
*/ */
import { getDb, createKey, listKeys, validateKey, recordUsage, checkQuota, updateKeyQuota, getKeyQuota, findKey, cacheHash, getCachedResponse, setCachedResponse, clearCache, getCacheStats, closeDb } from "./keys.mjs"; import { getDb, createKey, listKeys, validateKey, recordUsage, checkQuota, updateKeyQuota, getKeyQuota, findKey, cacheHash, getCachedResponse, setCachedResponse, clearCache, getCacheStats, closeDb, hasCacheControl, singleflight, getInflightStats } from "./keys.mjs";
import { createHash } from "node:crypto";
import { strict as assert } from "node:assert"; import { strict as assert } from "node:assert";
import { unlinkSync } from "node:fs"; import { unlinkSync } from "node:fs";
import { join } from "node:path"; import { join } from "node:path";
@@ -253,6 +254,206 @@ test("clearCache with TTL only removes old entries", () => {
clearCache(); clearCache();
}); });
// ── PR-A: Per-key isolation (D1), cache_control bypass (D2), chunked replay (D3) ──
console.log("\nPR-A Cache Upgrade:");
const msgsBase = [{ role: "user", content: "Shared prompt text" }];
test("D1: cacheHash with two distinct keyIds produces different hashes", () => {
const h1 = cacheHash("sonnet", msgsBase, { keyId: "key-aaa" });
const h2 = cacheHash("sonnet", msgsBase, { keyId: "key-bbb" });
assert.notEqual(h1, h2);
});
test("D1: cacheHash with keyId=undefined and keyId='anon' produce the same hash", () => {
const hUndef = cacheHash("sonnet", msgsBase, { keyId: undefined });
const hAnon = cacheHash("sonnet", msgsBase, { keyId: "anon" });
assert.equal(hUndef, hAnon);
});
test("D1: cacheHash with keyId=null and keyId='anon' produce the same hash", () => {
const hNull = cacheHash("sonnet", msgsBase, { keyId: null });
const hAnon = cacheHash("sonnet", msgsBase, { keyId: "anon" });
assert.equal(hNull, hAnon);
});
test("D1: v2 prefix — hash differs from a v1-style baseline (no prefix)", () => {
// Reproduce a v1-style hash manually to confirm v2 differs
const v1 = createHash("sha256")
.update("sonnet")
.update(msgsBase[0].role)
.update(msgsBase[0].content)
.digest("hex");
const v2 = cacheHash("sonnet", msgsBase, { keyId: "anon" });
assert.notEqual(v1, v2);
});
test("D1: cacheHash is reproducible for same keyId (determinism)", () => {
const h1 = cacheHash("sonnet", msgsBase, { keyId: "key-xyz" });
const h2 = cacheHash("sonnet", msgsBase, { keyId: "key-xyz" });
assert.equal(h1, h2);
});
test("D2: hasCacheControl returns true for top-level cache_control on message", () => {
const msgs = [{ role: "user", cache_control: { type: "ephemeral" }, content: "hello" }];
assert.equal(hasCacheControl(msgs), true);
});
test("D2: hasCacheControl returns true for nested cache_control in content array", () => {
const msgs = [{ role: "user", content: [{ type: "text", text: "x", cache_control: { type: "ephemeral" } }] }];
assert.equal(hasCacheControl(msgs), true);
});
test("D2: hasCacheControl returns false for plain string content", () => {
const msgs = [{ role: "user", content: "plain string" }];
assert.equal(hasCacheControl(msgs), false);
});
test("D2: hasCacheControl returns false for content array without cache_control", () => {
const msgs = [{ role: "user", content: [{ type: "text", text: "x" }] }];
assert.equal(hasCacheControl(msgs), false);
});
test("D2: hasCacheControl handles null/empty input gracefully", () => {
assert.equal(hasCacheControl(null), false);
assert.equal(hasCacheControl([]), false);
assert.equal(hasCacheControl([null, undefined]), false);
});
// D3: chunked stream replay — verify the logic by simulating what server.mjs does
test("D3: 160-char cached response produces 2 chunks at 80 codepoints/chunk", () => {
const content = "a".repeat(160);
const CACHE_REPLAY_CHUNK_SIZE = 80;
const codepoints = Array.from(content);
const chunks = [];
for (let i = 0; i < codepoints.length; i += CACHE_REPLAY_CHUNK_SIZE) {
chunks.push(codepoints.slice(i, i + CACHE_REPLAY_CHUNK_SIZE).join(""));
}
assert.equal(chunks.length, 2);
assert.equal(chunks[0].length, 80);
assert.equal(chunks[1].length, 80);
});
test("D3: chunked replay uses Array.from — multibyte codepoints stay intact", () => {
// Each Chinese character is 1 codepoint but 3 UTF-8 bytes
const chinese = "你好世界".repeat(25); // 100 codepoints
const CACHE_REPLAY_CHUNK_SIZE = 80;
const codepoints = Array.from(chinese);
const chunks = [];
for (let i = 0; i < codepoints.length; i += CACHE_REPLAY_CHUNK_SIZE) {
chunks.push(codepoints.slice(i, i + CACHE_REPLAY_CHUNK_SIZE).join(""));
}
assert.equal(chunks.length, 2);
assert.equal(Array.from(chunks[0]).length, 80);
assert.equal(Array.from(chunks[1]).length, 20);
// Verify each character is a complete codepoint (no mojibake)
for (const chunk of chunks) {
for (const cp of Array.from(chunk)) {
assert.equal(cp.length <= 2, true); // surrogate pairs are length 2, single chars length 1
}
}
});
// ── PR-B Singleflight tests (async) ──
async function asyncTest(name, fn) {
try {
await fn();
passed++;
console.log(`${name}`);
} catch (e) {
failed++;
console.log(`${name}: ${e.message}`);
}
}
async function runSingleflightTests() {
console.log("\nPR-B Singleflight:");
// 1. Basic dedup: 10 concurrent calls with same hash execute fn only once.
await asyncTest("basic dedup: 10 concurrent callers execute fn only once", async () => {
let callCount = 0;
const fn = () => new Promise(resolve => {
callCount++;
setTimeout(() => resolve("result-A"), 20);
});
const results = await Promise.all(Array.from({ length: 10 }, () => singleflight("sf-dedup-1", fn)));
assert.equal(callCount, 1, `fn called ${callCount} times, expected 1`);
assert.ok(results.every(r => r === "result-A"), "all 10 callers should receive the same return value");
});
// 2. Failure fan-out: all followers reject when leader rejects.
await asyncTest("failure fan-out: all followers reject with leader error", async () => {
let callCount = 0;
const fn = () => new Promise((_, reject) => {
callCount++;
setTimeout(() => reject(new Error("upstream-fail")), 20);
});
const promises = Array.from({ length: 10 }, () => singleflight("sf-fail-1", fn));
const results = await Promise.allSettled(promises);
assert.equal(callCount, 1, `fn called ${callCount} times, expected 1`);
assert.ok(results.every(r => r.status === "rejected"), "all 10 should be rejected");
assert.ok(results.every(r => r.reason?.message === "upstream-fail"), "all should share the same error message");
});
// 3a. Map cleanup after success: inflight count returns to 0 after promise resolves.
await asyncTest("map cleanup after success: inflight=0 after promise settles", async () => {
const fn = () => new Promise(resolve => setTimeout(() => resolve("done"), 10));
await singleflight("sf-cleanup-ok", fn);
const stats = getInflightStats();
assert.equal(stats.inflight, 0, `expected inflight=0 after settlement, got ${stats.inflight}`);
});
// 3b. Map cleanup after failure: inflight count returns to 0 after promise rejects.
await asyncTest("map cleanup after failure: inflight=0 after promise rejects", async () => {
const fn = () => new Promise((_, reject) => setTimeout(() => reject(new Error("fail")), 10));
try { await singleflight("sf-cleanup-fail", fn); } catch {}
const stats = getInflightStats();
assert.equal(stats.inflight, 0, `expected inflight=0 after rejection, got ${stats.inflight}`);
});
// 4. Different hashes don't share: two parallel calls with distinct hashes both execute.
await asyncTest("different hashes do not share a singleflight entry", async () => {
let countA = 0;
let countB = 0;
const fnA = () => new Promise(resolve => { countA++; setTimeout(() => resolve("A"), 20); });
const fnB = () => new Promise(resolve => { countB++; setTimeout(() => resolve("B"), 20); });
const [rA, rB] = await Promise.all([singleflight("sf-hash-A", fnA), singleflight("sf-hash-B", fnB)]);
assert.equal(countA, 1);
assert.equal(countB, 1);
assert.equal(rA, "A");
assert.equal(rB, "B");
});
// 5. getInflightStats shape: returns { inflight: number, requesters: number }.
await asyncTest("getInflightStats returns correct shape", async () => {
// Verify shape against a settled state (inflight=0 is still the right shape).
const stats = getInflightStats();
assert.equal(typeof stats.inflight, "number", "inflight should be a number");
assert.equal(typeof stats.requesters, "number", "requesters should be a number");
// Also verify live counts: start a pending fn, check inflight>0, then resolve.
const { promise: blocker, resolve: resolveBlocker } = Promise.withResolvers();
const fn = () => blocker;
const p = singleflight("sf-stats-shape", fn);
const liveStats = getInflightStats();
assert.ok(liveStats.inflight >= 1, `expected inflight>=1, got ${liveStats.inflight}`);
resolveBlocker("ok");
await p;
});
// 6. Sequential calls don't share: singleflight is for concurrent dedup only.
await asyncTest("sequential calls with same hash each execute fn independently", async () => {
let callCount = 0;
const fn = () => new Promise(resolve => { callCount++; setTimeout(() => resolve(callCount), 10); });
const r1 = await singleflight("sf-sequential", fn);
const r2 = await singleflight("sf-sequential", fn);
assert.equal(callCount, 2, `fn should have been called twice, got ${callCount}`);
assert.equal(r1, 1);
assert.equal(r2, 2);
});
}
await runSingleflightTests();
// ── Cleanup ── // ── Cleanup ──
closeDb(); closeDb();
+1 -1
View File
@@ -1,6 +1,6 @@
#!/usr/bin/env node #!/usr/bin/env node
/** /**
* openclaw-claude-proxy uninstaller * OCP (Open Claude Proxy) uninstaller
* *
* Stops and removes the launchd (macOS) or systemd (Linux) auto-start entry. * Stops and removes the launchd (macOS) or systemd (Linux) auto-start entry.
* Handles both legacy (ai.openclaw.proxy / openclaw-proxy) and current * Handles both legacy (ai.openclaw.proxy / openclaw-proxy) and current