mirror of
https://github.com/dtzp555-max/ocp.git
synced 2026-07-22 13:35:08 +00:00
Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
cb249f259f | ||
|
|
193c6bf637 |
@@ -1,9 +0,0 @@
|
||||
# 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"]
|
||||
@@ -1,27 +1,5 @@
|
||||
# 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
|
||||
|
||||
@@ -1,13 +1,7 @@
|
||||
# OCP — Open Claude Proxy
|
||||
|
||||
[](LICENSE) [](https://github.com/dtzp555-max/ocp/releases) [](https://buymeacoffee.com/dtzp555)
|
||||
|
||||
> **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).*
|
||||
|
||||
*If OCP saves you a setup, a ⭐ helps other folks discover it. Issue reports are even more useful — that's the highest-quality feedback this project gets.*
|
||||
|
||||
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.
|
||||
|
||||
```
|
||||
@@ -22,15 +16,14 @@ 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:
|
||||
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.** Concretely:
|
||||
|
||||
- **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))
|
||||
- **SSE heartbeat on streaming** ([v3.12.0](https://github.com/dtzp555-max/ocp/releases/tag/v3.12.0), opt-in via `CLAUDE_HEARTBEAT_INTERVAL`). Long Claude reasoning or tool-use pauses can sit silent for minutes; downstream load balancers and reverse proxies often kill the connection at 60s idle. OCP emits an SSE comment frame during silent windows so the connection stays alive without polluting the response. ([PR #49](https://github.com/dtzp555-max/ocp/pull/49))
|
||||
- **Alignment constitution + CI guardrail.** [`ALIGNMENT.md`](./ALIGNMENT.md) is the binding spec: every endpoint OCP exposes must correspond to something `cli.js` actually does, with a line-number citation. The [`alignment.yml`](./.github/workflows/alignment.yml) workflow auto-blocks PRs that introduce known-hallucinated tokens (`api/oauth/usage`, `api/usage`, etc). Hard to drift, even with LLM-assisted contributions.
|
||||
- **`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. No more drift between server and installer. ([PR #30](https://github.com/dtzp555-max/ocp/pull/30))
|
||||
- **Multi anonymous-key distribution** (v3.7.0). Share one OCP instance with friends/family/devices without exposing your OAuth session — each gets a per-user key, with usage tracking and revocation.
|
||||
- **Per-key quota + response cache** (v3.8.0). Daily/weekly/monthly request limits per key. Optional SHA-256 prompt cache for development loops. ([PR #18](https://github.com/dtzp555-max/ocp/pull/18))
|
||||
- **`ocp-connect` IDE auto-config.** Detects Claude Code, Cursor, Cline, Continue.dev, opencode, and OpenClaw on the client machine and configures them in one command.
|
||||
|
||||
### Comparison
|
||||
|
||||
@@ -85,96 +78,13 @@ 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
|
||||
|
||||
> **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:**
|
||||
- macOS or Linux (Windows is not supported — `setup.mjs` installs launchd / systemd auto-start)
|
||||
- Node.js 22.5+ (Node 23+ recommended — `node:sqlite` is fully stable without flags from 23.0; on 22.5–22.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.
|
||||
- [Claude CLI](https://docs.anthropic.com/en/docs/claude-cli) installed and authenticated (`claude auth login`)
|
||||
|
||||
```bash
|
||||
# 1. Clone and run setup
|
||||
@@ -187,8 +97,7 @@ The setup script will:
|
||||
1. Verify Claude CLI is installed and authenticated
|
||||
2. Start the proxy on port 3456
|
||||
3. Install auto-start (launchd on macOS, systemd on Linux)
|
||||
|
||||
After install the `ocp` CLI lives at `~/ocp/ocp`. To put it on your PATH, either symlink it manually (`ln -sf ~/ocp/ocp ~/.local/bin/ocp` if `~/.local/bin` is on your PATH, or `sudo ln -sf ~/ocp/ocp /usr/local/bin/ocp` for a system-wide symlink) or add an alias (`alias ocp=~/ocp/ocp`). Otherwise invoke it as `~/ocp/ocp <subcommand>`. The rest of this README assumes `ocp` is on your PATH.
|
||||
4. Symlink `ocp` to `/usr/local/bin` for CLI access
|
||||
|
||||
**Single-machine use** — just set your IDE to use the proxy:
|
||||
```bash
|
||||
@@ -203,9 +112,7 @@ node setup.mjs --bind 0.0.0.0 --auth-mode multi
|
||||
|
||||
Then create API keys for each person/device:
|
||||
```bash
|
||||
# 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.
|
||||
export OCP_ADMIN_KEY=your-secret-admin-key
|
||||
|
||||
ocp keys add wife-laptop
|
||||
# ✓ Key created for "wife-laptop"
|
||||
@@ -224,22 +131,6 @@ 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
|
||||
```
|
||||
|
||||
#### 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
|
||||
@@ -256,8 +147,6 @@ Removes the launchd (macOS) or systemd (Linux) auto-start entry. Handles both le
|
||||
### 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).
|
||||
>
|
||||
> **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:
|
||||
|
||||
@@ -398,15 +287,11 @@ In `multi` mode, the admin can designate a single well-known "anonymous" key tha
|
||||
|
||||
**Enable**:
|
||||
|
||||
The anonymous key is wired into the service unit (launchd plist on macOS, systemd unit on Linux) at install time. Export `PROXY_ANONYMOUS_KEY` in your shell before running `setup.mjs`, and `setup.mjs` will write it into the service unit env so the auto-started proxy picks it up:
|
||||
|
||||
```bash
|
||||
export PROXY_ANONYMOUS_KEY=ocp_public_anon # or any string of your choice
|
||||
node setup.mjs --bind 0.0.0.0 --auth-mode multi
|
||||
ocp start # or however you start the server
|
||||
```
|
||||
|
||||
If OCP is already installed without it, re-export the env var and re-run `node setup.mjs` (the installer is idempotent — it refreshes the service unit). Then `ocp restart` so the running proxy picks up the new env. Setting `PROXY_ANONYMOUS_KEY` only in your interactive shell **does not** affect the auto-started proxy — the service unit is the source of truth for its environment.
|
||||
|
||||
**Client side**: the anonymous key value is exposed via `GET /health` as the field `anonymousKey` (null when not set). Clients like `ocp-connect` can auto-discover and use it, so the end user doesn't need to get a personal key from the admin.
|
||||
|
||||
**Security note**: setting this env var is an **opt-in** to public access — anyone who can reach your OCP endpoint can use it, up to any rate limits you configure. Don't enable this on internet-exposed OCP instances without additional protection.
|
||||
@@ -587,20 +472,17 @@ ocp settings cacheTTL 300000
|
||||
```
|
||||
|
||||
**How it works:**
|
||||
- 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 key = SHA-256 of `model` + `messages` + `temperature` + `max_tokens` + `top_p`
|
||||
- Cache hits return instantly — no Claude CLI process spawned
|
||||
- **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)
|
||||
- Works for both streaming and non-streaming requests
|
||||
- Multi-turn conversations (with `session_id`) are never cached
|
||||
- Expired entries are cleaned up automatically every 10 minutes
|
||||
|
||||
**Management:**
|
||||
```bash
|
||||
# View cache stats (now includes singleflight in-flight counts)
|
||||
# View cache stats
|
||||
curl http://127.0.0.1:3456/cache/stats
|
||||
# → { "entries": 42, "totalHits": 156, "sizeBytes": 284000, "inflight": 0, "requesters": 0 }
|
||||
# → { "entries": 42, "totalHits": 156, "sizeBytes": 284000 }
|
||||
|
||||
# Clear all cached responses
|
||||
curl -X DELETE http://127.0.0.1:3456/cache
|
||||
@@ -611,8 +493,6 @@ ocp settings cacheTTL 0
|
||||
|
||||
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
|
||||
|
||||
```
|
||||
@@ -707,37 +587,6 @@ After installing the gateway plugin, use `/ocp` slash commands in your chat:
|
||||
|
||||
## 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
|
||||
|
||||
```bash
|
||||
@@ -852,24 +701,6 @@ OCP runs under a small set of binding documents so contributions stay aligned wi
|
||||
|
||||
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
|
||||
|
||||
MIT — see [`LICENSE`](LICENSE).
|
||||
|
||||
+2
-21
@@ -55,13 +55,7 @@
|
||||
</div>
|
||||
|
||||
<div class="section">
|
||||
<div class="flex" style="justify-content: space-between; align-items: center;">
|
||||
<h2 style="margin: 0;">Usage by Key</h2>
|
||||
<label id="usage-scope-toggle" class="flex" style="display:none; gap: 0.4rem; font-size: 0.8rem; color: #94a3b8; cursor: pointer;">
|
||||
<input type="checkbox" id="usage-show-all" style="cursor: pointer;">
|
||||
<span>Show all keys</span>
|
||||
</label>
|
||||
</div>
|
||||
<h2>Usage by Key</h2>
|
||||
<table id="key-usage-table">
|
||||
<thead><tr><th>Key</th><th>Requests</th><th>OK</th><th>Err</th><th>Avg Time</th><th>Last Request</th></tr></thead>
|
||||
<tbody></tbody>
|
||||
@@ -176,8 +170,7 @@ async function refreshStatus() {
|
||||
|
||||
async function refreshUsage() {
|
||||
try {
|
||||
const showAll = localStorage.getItem("ocp_usage_show_all") === "1";
|
||||
const data = await api(showAll ? "/api/usage?all=true" : "/api/usage");
|
||||
const data = await api("/api/usage");
|
||||
const tbody = document.querySelector("#key-usage-table tbody");
|
||||
tbody.innerHTML = (data.byKey || []).map(k => `
|
||||
<tr>
|
||||
@@ -211,8 +204,6 @@ async function refreshKeys() {
|
||||
try {
|
||||
const data = await api("/api/keys");
|
||||
document.getElementById("key-mgmt-section").style.display = "";
|
||||
// Admin-only "Show all keys" toggle for /api/usage scope.
|
||||
document.getElementById("usage-scope-toggle").style.display = "flex";
|
||||
const tbody = document.querySelector("#keys-table tbody");
|
||||
tbody.innerHTML = (data.keys || []).map(k => `
|
||||
<tr>
|
||||
@@ -249,16 +240,6 @@ async function refreshAll() {
|
||||
document.getElementById("refresh-indicator").textContent = `Updated ${new Date().toLocaleTimeString()}`;
|
||||
}
|
||||
|
||||
// Wire "Show all keys" toggle (visibility gated to admin via refreshKeys()).
|
||||
(function setupUsageScopeToggle() {
|
||||
const cb = document.getElementById("usage-show-all");
|
||||
cb.checked = localStorage.getItem("ocp_usage_show_all") === "1";
|
||||
cb.addEventListener("change", () => {
|
||||
localStorage.setItem("ocp_usage_show_all", cb.checked ? "1" : "0");
|
||||
refreshUsage();
|
||||
});
|
||||
})();
|
||||
|
||||
refreshAll();
|
||||
setInterval(refreshAll, 30000);
|
||||
</script>
|
||||
|
||||
Binary file not shown.
|
Before Width: | Height: | Size: 366 KiB After Width: | Height: | Size: 222 KiB |
@@ -371,36 +371,6 @@ export function getCacheStats() {
|
||||
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)
|
||||
export function findKey(idOrName) {
|
||||
const d = getDb();
|
||||
|
||||
@@ -8,18 +8,21 @@ set -euo pipefail
|
||||
|
||||
PROXY="http://127.0.0.1:3456"
|
||||
|
||||
# Auth args for multi-key mode: reads from OCP_ADMIN_KEY env or ~/.ocp/admin-key file
|
||||
# Using a bash array preserves word boundaries — no eval needed.
|
||||
_AUTH_ARGS=()
|
||||
# Auth header for multi-key mode: reads from OCP_ADMIN_KEY env or ~/.ocp/admin-key file
|
||||
_AUTH_HEADER=""
|
||||
if [[ -n "${OCP_ADMIN_KEY:-}" ]]; then
|
||||
_AUTH_ARGS=(-H "Authorization: Bearer $OCP_ADMIN_KEY")
|
||||
_AUTH_HEADER="-H \"Authorization: Bearer $OCP_ADMIN_KEY\""
|
||||
elif [[ -f "$HOME/.ocp/admin-key" ]]; then
|
||||
_AUTH_ARGS=(-H "Authorization: Bearer $(cat "$HOME/.ocp/admin-key")")
|
||||
_AUTH_HEADER="-H \"Authorization: Bearer $(cat "$HOME/.ocp/admin-key")\""
|
||||
fi
|
||||
|
||||
# Wrapper: curl with optional auth
|
||||
_curl() {
|
||||
curl "${_AUTH_ARGS[@]}" "$@"
|
||||
if [[ -n "$_AUTH_HEADER" ]]; then
|
||||
eval curl "$_AUTH_HEADER" "$@"
|
||||
else
|
||||
curl "$@"
|
||||
fi
|
||||
}
|
||||
|
||||
_json() { python3 -m json.tool 2>/dev/null || cat; }
|
||||
|
||||
+3
-13
@@ -582,19 +582,11 @@ print(k if k else '')
|
||||
if [[ "${SHELL:-}" == */fish ]]; then
|
||||
echo " Note: fish shell detected. Writing to ~/.bashrc — add to fish config manually."
|
||||
rc_files+=("$HOME/.bashrc")
|
||||
elif $is_mac; then
|
||||
# macOS: default shell since Catalina (2019) is zsh.
|
||||
# Always write ~/.zshrc (create if absent — zsh tolerates an empty file).
|
||||
# Only write ~/.bashrc if it already exists (don't surprise users with new files).
|
||||
[[ -f "$HOME/.bashrc" ]] && rc_files+=("$HOME/.bashrc")
|
||||
# zshrc: always include on macOS; create the file if it doesn't exist yet
|
||||
[[ -f "$HOME/.zshrc" ]] || touch "$HOME/.zshrc"
|
||||
rc_files+=("$HOME/.zshrc")
|
||||
else
|
||||
# Linux / other: write to whichever rc files already exist or match current shell
|
||||
# Always write both on macOS (default shell is zsh but some tools source bashrc)
|
||||
[[ -f "$HOME/.bashrc" || "${SHELL:-}" == */bash ]] && rc_files+=("$HOME/.bashrc")
|
||||
[[ -f "$HOME/.zshrc" || "${SHELL:-}" == */zsh ]] && rc_files+=("$HOME/.zshrc")
|
||||
# If neither exists, fall back to creating one for the current shell
|
||||
# If neither exists, create for current shell
|
||||
[[ ${#rc_files[@]} -eq 0 ]] && rc_files+=("$HOME/.${SHELL##*/}rc")
|
||||
fi
|
||||
|
||||
@@ -713,9 +705,7 @@ PYEOF
|
||||
|
||||
echo ""
|
||||
echo " Done. Reload your shell to apply:"
|
||||
for rc_file in "${rc_files[@]}"; do
|
||||
echo " source $rc_file"
|
||||
done
|
||||
echo " source $rc_file"
|
||||
}
|
||||
|
||||
main "$@"
|
||||
|
||||
+3
-2
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "open-claude-proxy",
|
||||
"version": "3.13.0",
|
||||
"version": "3.12.0",
|
||||
"description": "OCP (Open Claude Proxy) — use your Claude Pro/Max subscription as an OpenAI-compatible API for any IDE. Works with Cline, OpenCode, Aider, Continue.dev, OpenClaw, and more.",
|
||||
"type": "module",
|
||||
"bin": {
|
||||
@@ -10,7 +10,8 @@
|
||||
"scripts": {
|
||||
"start": "node server.mjs",
|
||||
"setup": "node setup.mjs",
|
||||
"test": "node test-features.mjs"
|
||||
"test": "node test-features.mjs",
|
||||
"traffic": "node scripts/github-traffic.mjs"
|
||||
},
|
||||
"keywords": [
|
||||
"openclaw",
|
||||
|
||||
@@ -0,0 +1,185 @@
|
||||
#!/usr/bin/env node
|
||||
// github-traffic.mjs — fetch GitHub Traffic Insights for this repo.
|
||||
//
|
||||
// Usage:
|
||||
// GITHUB_TOKEN=ghp_xxx node scripts/github-traffic.mjs # pretty report
|
||||
// GITHUB_TOKEN=ghp_xxx node scripts/github-traffic.mjs --json # raw JSON
|
||||
// GITHUB_TOKEN=ghp_xxx node scripts/github-traffic.mjs --save # write snapshot file
|
||||
//
|
||||
// Options:
|
||||
// --owner=<user> Override repo owner (default: dtzp555-max)
|
||||
// --repo=<name> Override repo name (default: ocp)
|
||||
// --json Print raw JSON instead of a formatted report
|
||||
// --save[=path] Append snapshot as JSONL to path (default: ./traffic-history.jsonl)
|
||||
//
|
||||
// Requires a token with push access to the repository. Traffic endpoints
|
||||
// return the last 14 days of data — run daily to build a longer history.
|
||||
|
||||
const API = "https://api.github.com";
|
||||
|
||||
function parseArgs(argv) {
|
||||
const args = { owner: "dtzp555-max", repo: "ocp", json: false, save: null };
|
||||
for (const a of argv.slice(2)) {
|
||||
if (a === "--json") args.json = true;
|
||||
else if (a === "--save") args.save = "traffic-history.jsonl";
|
||||
else if (a.startsWith("--save=")) args.save = a.slice(7);
|
||||
else if (a.startsWith("--owner=")) args.owner = a.slice(8);
|
||||
else if (a.startsWith("--repo=")) args.repo = a.slice(7);
|
||||
else if (a === "-h" || a === "--help") { printHelp(); process.exit(0); }
|
||||
else { console.error(`Unknown argument: ${a}`); process.exit(2); }
|
||||
}
|
||||
return args;
|
||||
}
|
||||
|
||||
function printHelp() {
|
||||
console.log(`github-traffic.mjs — fetch GitHub Traffic Insights
|
||||
|
||||
Usage:
|
||||
GITHUB_TOKEN=<token> node scripts/github-traffic.mjs [options]
|
||||
|
||||
Options:
|
||||
--owner=<user> Repo owner (default: dtzp555-max)
|
||||
--repo=<name> Repo name (default: ocp)
|
||||
--json Print raw JSON (for piping to jq)
|
||||
--save[=path] Append snapshot as JSONL (default: ./traffic-history.jsonl)
|
||||
-h, --help Show this help
|
||||
|
||||
Requires GITHUB_TOKEN with push access to the repository.`);
|
||||
}
|
||||
|
||||
async function gh(path, token) {
|
||||
const res = await fetch(API + path, {
|
||||
headers: {
|
||||
"Authorization": `Bearer ${token}`,
|
||||
"Accept": "application/vnd.github+json",
|
||||
"X-GitHub-Api-Version": "2022-11-28",
|
||||
"User-Agent": "ocp-traffic-script",
|
||||
},
|
||||
});
|
||||
if (!res.ok) {
|
||||
const body = await res.text();
|
||||
throw new Error(`GET ${path} → ${res.status} ${res.statusText}: ${body.slice(0, 200)}`);
|
||||
}
|
||||
return res.json();
|
||||
}
|
||||
|
||||
async function fetchAll(owner, repo, token) {
|
||||
const base = `/repos/${owner}/${repo}`;
|
||||
const [repoInfo, views, clones, referrers, paths] = await Promise.all([
|
||||
gh(base, token),
|
||||
gh(`${base}/traffic/views`, token),
|
||||
gh(`${base}/traffic/clones`, token),
|
||||
gh(`${base}/traffic/popular/referrers`, token),
|
||||
gh(`${base}/traffic/popular/paths`, token),
|
||||
]);
|
||||
return {
|
||||
fetched_at: new Date().toISOString(),
|
||||
repo: {
|
||||
full_name: repoInfo.full_name,
|
||||
stars: repoInfo.stargazers_count,
|
||||
watchers: repoInfo.subscribers_count,
|
||||
forks: repoInfo.forks_count,
|
||||
open_issues: repoInfo.open_issues_count,
|
||||
pushed_at: repoInfo.pushed_at,
|
||||
},
|
||||
views, clones, referrers, paths,
|
||||
};
|
||||
}
|
||||
|
||||
function bar(value, max, width = 20) {
|
||||
if (!max) return "";
|
||||
const n = Math.round((value / max) * width);
|
||||
return "█".repeat(n) + "░".repeat(width - n);
|
||||
}
|
||||
|
||||
function formatReport(data) {
|
||||
const { repo, views, clones, referrers, paths } = data;
|
||||
const lines = [];
|
||||
lines.push(`\n📊 GitHub Traffic — ${repo.full_name}`);
|
||||
lines.push(` ⭐ ${repo.stars} 👁 ${repo.watchers ?? "?"} 🍴 ${repo.forks} 🐛 ${repo.open_issues} open issues`);
|
||||
lines.push(` Last push: ${repo.pushed_at}`);
|
||||
lines.push(` Fetched: ${data.fetched_at}\n`);
|
||||
|
||||
lines.push(`── Views (last 14 days) ────────────────────────────────────`);
|
||||
lines.push(` Total: ${views.count} Unique: ${views.uniques}`);
|
||||
const maxV = Math.max(1, ...views.views.map(v => v.count));
|
||||
for (const v of views.views) {
|
||||
const day = v.timestamp.slice(0, 10);
|
||||
lines.push(` ${day} ${String(v.count).padStart(4)} (${String(v.uniques).padStart(3)} uniq) ${bar(v.count, maxV)}`);
|
||||
}
|
||||
|
||||
lines.push(`\n── Clones (last 14 days) ───────────────────────────────────`);
|
||||
lines.push(` Total: ${clones.count} Unique: ${clones.uniques}`);
|
||||
if (clones.clones.length) {
|
||||
const maxC = Math.max(1, ...clones.clones.map(c => c.count));
|
||||
for (const c of clones.clones) {
|
||||
const day = c.timestamp.slice(0, 10);
|
||||
lines.push(` ${day} ${String(c.count).padStart(4)} (${String(c.uniques).padStart(3)} uniq) ${bar(c.count, maxC)}`);
|
||||
}
|
||||
} else {
|
||||
lines.push(` (no clones recorded)`);
|
||||
}
|
||||
|
||||
lines.push(`\n── Top Referrers ───────────────────────────────────────────`);
|
||||
if (referrers.length) {
|
||||
const maxR = Math.max(1, ...referrers.map(r => r.count));
|
||||
for (const r of referrers) {
|
||||
lines.push(` ${r.referrer.padEnd(28)} ${String(r.count).padStart(5)} views (${r.uniques} uniq) ${bar(r.count, maxR, 15)}`);
|
||||
}
|
||||
} else {
|
||||
lines.push(` (no referrer data)`);
|
||||
}
|
||||
|
||||
lines.push(`\n── Popular Content ─────────────────────────────────────────`);
|
||||
if (paths.length) {
|
||||
const maxP = Math.max(1, ...paths.map(p => p.count));
|
||||
for (const p of paths) {
|
||||
lines.push(` ${String(p.count).padStart(5)} views ${String(p.uniques).padStart(4)} uniq ${p.path}`);
|
||||
lines.push(` ${bar(p.count, maxP, 40)} ${p.title.slice(0, 60)}`);
|
||||
}
|
||||
} else {
|
||||
lines.push(` (no popular content data)`);
|
||||
}
|
||||
lines.push("");
|
||||
return lines.join("\n");
|
||||
}
|
||||
|
||||
async function main() {
|
||||
const args = parseArgs(process.argv);
|
||||
const token = process.env.GITHUB_TOKEN;
|
||||
if (!token) {
|
||||
console.error("Error: GITHUB_TOKEN environment variable is required.");
|
||||
console.error("Create a token with 'repo' scope: https://github.com/settings/tokens");
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
let data;
|
||||
try {
|
||||
data = await fetchAll(args.owner, args.repo, token);
|
||||
} catch (err) {
|
||||
console.error(`Failed to fetch traffic: ${err.message}`);
|
||||
if (err.message.includes("403")) {
|
||||
console.error("Note: traffic endpoints require push access to the repository.");
|
||||
}
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
if (args.save) {
|
||||
const { writeFileSync, appendFileSync, existsSync } = await import("node:fs");
|
||||
const line = JSON.stringify(data) + "\n";
|
||||
if (existsSync(args.save)) appendFileSync(args.save, line);
|
||||
else writeFileSync(args.save, line);
|
||||
console.error(`Snapshot appended to ${args.save}`);
|
||||
}
|
||||
|
||||
if (args.json) {
|
||||
console.log(JSON.stringify(data, null, 2));
|
||||
} else {
|
||||
console.log(formatReport(data));
|
||||
}
|
||||
}
|
||||
|
||||
main().catch(err => {
|
||||
console.error(err);
|
||||
process.exit(1);
|
||||
});
|
||||
+15
-122
@@ -30,59 +30,19 @@
|
||||
import { createServer } from "node:http";
|
||||
import { spawn, execFileSync } from "node:child_process";
|
||||
import { randomUUID, timingSafeEqual } from "node:crypto";
|
||||
import { readFileSync, readdirSync, accessSync, existsSync, constants } from "node:fs";
|
||||
import { readFileSync, accessSync, existsSync, constants } from "node:fs";
|
||||
import { fileURLToPath } from "node:url";
|
||||
import { dirname, join } from "node:path";
|
||||
import { homedir } from "node:os";
|
||||
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";
|
||||
import { validateKey, recordUsage, getUsageByKey, getUsageTimeline, getRecentUsage, createKey, listKeys, revokeKey, closeDb, checkQuota, updateKeyQuota, getKeyQuota, findKey, cacheHash, getCachedResponse, setCachedResponse, clearCache, getCacheStats, hasCacheControl } from "./keys.mjs";
|
||||
|
||||
const __dirname = dirname(fileURLToPath(import.meta.url));
|
||||
const _pkg = JSON.parse(readFileSync(join(__dirname, "package.json"), "utf8"));
|
||||
const modelsConfig = JSON.parse(readFileSync(join(__dirname, "models.json"), "utf8"));
|
||||
|
||||
// ── Resolve claude binary ───────────────────────────────────────────────
|
||||
// Priority: CLAUDE_BIN env > well-known paths > nvm/fnm/asdf user-local
|
||||
// installs > which lookup. Fail-fast if not found — never start with an
|
||||
// unresolvable binary.
|
||||
function _listVersionDirs(parent) {
|
||||
try { return readdirSync(parent); } catch { return []; }
|
||||
}
|
||||
function _collectNodeManagerCandidates(home) {
|
||||
if (!home) return [];
|
||||
const out = [];
|
||||
|
||||
// nvm: $HOME/.nvm/versions/node/<version>/bin/claude
|
||||
const nvmRoot = join(home, ".nvm/versions/node");
|
||||
for (const v of _listVersionDirs(nvmRoot)) {
|
||||
out.push(join(nvmRoot, v, "bin/claude"));
|
||||
}
|
||||
// nvm default alias: resolve $HOME/.nvm/aliases/default if it points to a version
|
||||
try {
|
||||
const aliasFile = join(home, ".nvm/aliases/default");
|
||||
const aliasVer = readFileSync(aliasFile, "utf8").trim();
|
||||
if (aliasVer) {
|
||||
const direct = join(nvmRoot, aliasVer, "bin/claude");
|
||||
if (!out.includes(direct)) out.unshift(direct);
|
||||
}
|
||||
} catch {}
|
||||
|
||||
// fnm: $HOME/.fnm/node-versions/<version>/installation/bin/claude
|
||||
const fnmRoot = join(home, ".fnm/node-versions");
|
||||
for (const v of _listVersionDirs(fnmRoot)) {
|
||||
out.push(join(fnmRoot, v, "installation/bin/claude"));
|
||||
}
|
||||
|
||||
// asdf: $HOME/.asdf/installs/nodejs/<version>/bin/claude
|
||||
const asdfRoot = join(home, ".asdf/installs/nodejs");
|
||||
for (const v of _listVersionDirs(asdfRoot)) {
|
||||
out.push(join(asdfRoot, v, "bin/claude"));
|
||||
}
|
||||
|
||||
// npm prefix-relocated: $HOME/.npm-global/bin/claude
|
||||
out.push(join(home, ".npm-global/bin/claude"));
|
||||
|
||||
return out;
|
||||
}
|
||||
// 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 {
|
||||
@@ -94,13 +54,11 @@ function resolveClaude() {
|
||||
}
|
||||
}
|
||||
|
||||
const home = process.env.HOME || "";
|
||||
const candidates = [
|
||||
"/opt/homebrew/bin/claude",
|
||||
"/usr/local/bin/claude",
|
||||
"/usr/bin/claude",
|
||||
join(home, ".local/bin/claude"),
|
||||
..._collectNodeManagerCandidates(home),
|
||||
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 {}
|
||||
@@ -114,8 +72,6 @@ function resolveClaude() {
|
||||
console.error(
|
||||
"FATAL: claude binary not found.\n" +
|
||||
" Set CLAUDE_BIN=/path/to/claude or ensure claude is in PATH.\n" +
|
||||
" Hint: if you use nvm/fnm/asdf, set CLAUDE_BIN to the absolute path\n" +
|
||||
" shown by `which claude` in your interactive shell.\n" +
|
||||
" Checked: " + candidates.join(", ")
|
||||
);
|
||||
process.exit(1);
|
||||
@@ -634,7 +590,6 @@ function startHeartbeat(res, intervalMs, sessionId) {
|
||||
// ── Call claude CLI (real streaming) ─────────────────────────────────────
|
||||
// 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.
|
||||
// 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 = {}) {
|
||||
const id = `chatcmpl-${randomUUID()}`;
|
||||
const created = Math.floor(Date.now() / 1000);
|
||||
@@ -1310,45 +1265,14 @@ async function handleChatCompletions(req, res) {
|
||||
|
||||
const t0Usage = Date.now();
|
||||
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 {
|
||||
const content = await callClaude(model, messages, conversationId);
|
||||
const id = `chatcmpl-${randomUUID()}`;
|
||||
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 }); }
|
||||
} 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 }); }
|
||||
@@ -1616,52 +1540,21 @@ const server = createServer(async (req, res) => {
|
||||
}
|
||||
|
||||
if (req.url?.startsWith("/api/usage") && req.method === "GET") {
|
||||
// Least-privilege scope rules (security audit follow-up):
|
||||
// - non-admin authenticated key → only own rows
|
||||
// - anonymous (PROXY_ANONYMOUS_KEY) → only "anonymous" rows; ?all=true ignored
|
||||
// - admin without ?all=true → only own ("admin") rows
|
||||
// - admin with ?all=true → full byKey/recent (legacy behavior); audited
|
||||
// Authenticated callers are required (anyone reaching here passed the auth gate above);
|
||||
// remote+no-auth requests would have been rejected before this point.
|
||||
if (!isAdmin) return jsonResponse(res, 403, { error: "Admin access required" });
|
||||
const url = new URL(req.url, `http://${BIND_ADDRESS}:${PORT}`);
|
||||
const since = url.searchParams.get("since");
|
||||
const until = url.searchParams.get("until");
|
||||
const wantAll = url.searchParams.get("all") === "true";
|
||||
const callerName = req._authKeyName;
|
||||
|
||||
// Anonymous callers may never opt into all-keys view, even if they pass ?all=true.
|
||||
const isAnonCaller = callerName === "anonymous";
|
||||
const fullScope = isAdmin && wantAll && !isAnonCaller;
|
||||
|
||||
// scopeName === null when fullScope is true (no filter); otherwise the key_name to filter by.
|
||||
const scopeName = fullScope ? null : callerName;
|
||||
|
||||
if (fullScope) {
|
||||
logEvent("info", "admin_usage_full_scope", { caller: callerName, ip: req.socket.remoteAddress || null });
|
||||
}
|
||||
|
||||
const byKeyAll = getUsageByKey({ since, until });
|
||||
const recentAll = getRecentUsage(Math.min(parseInt(url.searchParams.get("limit") || "50", 10), 500));
|
||||
const timeline = getUsageTimeline({
|
||||
keyName: scopeName || undefined,
|
||||
hours: Math.min(parseInt(url.searchParams.get("hours") || "24", 10), 720),
|
||||
});
|
||||
|
||||
const byKey = scopeName ? byKeyAll.filter((row) => row.key_name === scopeName) : byKeyAll;
|
||||
const recent = scopeName ? recentAll.filter((row) => row.key_name === scopeName) : recentAll;
|
||||
|
||||
return jsonResponse(res, 200, {
|
||||
byKey,
|
||||
timeline,
|
||||
recent,
|
||||
scope: { self: scopeName, all: fullScope },
|
||||
byKey: getUsageByKey({ since, until }),
|
||||
timeline: getUsageTimeline({ hours: Math.min(parseInt(url.searchParams.get("hours") || "24", 10), 720) }),
|
||||
recent: getRecentUsage(Math.min(parseInt(url.searchParams.get("limit") || "50", 10), 500)),
|
||||
});
|
||||
}
|
||||
|
||||
// GET /cache/stats — cache statistics (entries, hits, size, inflight singleflight count)
|
||||
// GET /cache/stats — cache statistics
|
||||
if (pathname === "/cache/stats" && req.method === "GET") {
|
||||
if (!isAdmin) return jsonResponse(res, 403, { error: "Admin access required" });
|
||||
return jsonResponse(res, 200, { ...getCacheStats(), ...getInflightStats() });
|
||||
return jsonResponse(res, 200, getCacheStats());
|
||||
}
|
||||
|
||||
// DELETE /cache — clear cache
|
||||
|
||||
@@ -12,7 +12,7 @@
|
||||
* 4. Creates start.sh for easy launch
|
||||
* 5. Optionally starts the proxy
|
||||
*/
|
||||
import { readFileSync, writeFileSync, existsSync, mkdirSync, unlinkSync, readdirSync } from "node:fs";
|
||||
import { readFileSync, writeFileSync, existsSync, mkdirSync, unlinkSync } from "node:fs";
|
||||
import { execSync } from "node:child_process";
|
||||
import { join, dirname } from "node:path";
|
||||
import { homedir } from "node:os";
|
||||
@@ -39,29 +39,6 @@ const PROVIDER_NAME = opt("provider-name", "claude-local");
|
||||
const BIND_ADDRESS = opt("bind", "127.0.0.1");
|
||||
const AUTH_MODE_CONFIG = opt("auth-mode", "none");
|
||||
|
||||
// ── Service-env injection: CLAUDE_BIN, OCP_ADMIN_KEY, PROXY_ANONYMOUS_KEY ──
|
||||
// These are read from the user's shell env at install time and written into
|
||||
// the service unit (plist / systemd) so the daemon picks them up on boot.
|
||||
|
||||
// CLAUDE_BIN — detect at install time; omit if not found (server.mjs fallback)
|
||||
let CLAUDE_BIN_INJECT = null;
|
||||
if (process.env.CLAUDE_BIN) {
|
||||
CLAUDE_BIN_INJECT = process.env.CLAUDE_BIN;
|
||||
} else {
|
||||
try {
|
||||
const detected = execSync("which claude 2>/dev/null", { encoding: "utf-8" }).trim();
|
||||
if (detected && existsSync(detected)) {
|
||||
CLAUDE_BIN_INJECT = detected;
|
||||
}
|
||||
} catch { /* which not available or claude not on PATH — omit */ }
|
||||
}
|
||||
|
||||
// OCP_ADMIN_KEY — omit entirely when empty/unset; don't write empty string
|
||||
const OCP_ADMIN_KEY_INJECT = process.env.OCP_ADMIN_KEY || null;
|
||||
|
||||
// PROXY_ANONYMOUS_KEY — same pattern
|
||||
const PROXY_ANON_KEY_INJECT = process.env.PROXY_ANONYMOUS_KEY || null;
|
||||
|
||||
// ── Models: derived from models.json (single source of truth) ──────────
|
||||
const modelsConfig = JSON.parse(readFileSync(join(__dirname, "models.json"), "utf-8"));
|
||||
|
||||
@@ -130,114 +107,108 @@ try {
|
||||
warn("Make sure you're logged in: claude login");
|
||||
}
|
||||
|
||||
// Check openclaw config (optional — OCP runs standalone without OpenClaw)
|
||||
const OPENCLAW_PRESENT = existsSync(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.`);
|
||||
}
|
||||
// Check openclaw config
|
||||
if (!existsSync(CONFIG_PATH)) fail(`OpenClaw config not found at ${CONFIG_PATH}`);
|
||||
log(`OpenClaw config: ${CONFIG_PATH}`);
|
||||
|
||||
// ── Step 2: Patch openclaw.json ─────────────────────────────────────────
|
||||
if (OPENCLAW_PRESENT) {
|
||||
console.log("\n📝 Configuring OpenClaw...\n");
|
||||
console.log("\n📝 Configuring OpenClaw...\n");
|
||||
|
||||
const config = readJSON(CONFIG_PATH);
|
||||
const config = readJSON(CONFIG_PATH);
|
||||
|
||||
// Ensure models.providers exists
|
||||
if (!config.models) config.models = {};
|
||||
if (!config.models.providers) config.models.providers = {};
|
||||
// Ensure models.providers exists
|
||||
if (!config.models) config.models = {};
|
||||
if (!config.models.providers) config.models.providers = {};
|
||||
|
||||
// Add/update claude-local provider
|
||||
config.models.providers[PROVIDER_NAME] = {
|
||||
baseUrl: `http://127.0.0.1:${PORT}/v1`,
|
||||
api: "openai-completions",
|
||||
authHeader: false,
|
||||
models: MODELS,
|
||||
};
|
||||
log(`Provider "${PROVIDER_NAME}" → http://127.0.0.1:${PORT}/v1`);
|
||||
// Add/update claude-local provider
|
||||
config.models.providers[PROVIDER_NAME] = {
|
||||
baseUrl: `http://127.0.0.1:${PORT}/v1`,
|
||||
api: "openai-completions",
|
||||
authHeader: false,
|
||||
models: MODELS,
|
||||
};
|
||||
log(`Provider "${PROVIDER_NAME}" → http://127.0.0.1:${PORT}/v1`);
|
||||
|
||||
// Ensure auth profile in config
|
||||
if (!config.auth) config.auth = {};
|
||||
if (!config.auth.profiles) config.auth.profiles = {};
|
||||
config.auth.profiles[`${PROVIDER_NAME}:default`] = {
|
||||
provider: PROVIDER_NAME,
|
||||
mode: "api_key",
|
||||
};
|
||||
log(`Auth profile "${PROVIDER_NAME}:default" registered`);
|
||||
// Ensure auth profile in config
|
||||
if (!config.auth) config.auth = {};
|
||||
if (!config.auth.profiles) config.auth.profiles = {};
|
||||
config.auth.profiles[`${PROVIDER_NAME}:default`] = {
|
||||
provider: PROVIDER_NAME,
|
||||
mode: "api_key",
|
||||
};
|
||||
log(`Auth profile "${PROVIDER_NAME}:default" registered`);
|
||||
|
||||
// Add models to agents.defaults.models
|
||||
if (!config.agents) config.agents = {};
|
||||
if (!config.agents.defaults) config.agents.defaults = {};
|
||||
if (!config.agents.defaults.models) config.agents.defaults.models = {};
|
||||
for (const [key, val] of Object.entries(MODEL_ALIASES)) {
|
||||
config.agents.defaults.models[key] = val;
|
||||
}
|
||||
log(`Model aliases added to agents.defaults.models`);
|
||||
// Add models to agents.defaults.models
|
||||
if (!config.agents) config.agents = {};
|
||||
if (!config.agents.defaults) config.agents.defaults = {};
|
||||
if (!config.agents.defaults.models) config.agents.defaults.models = {};
|
||||
for (const [key, val] of Object.entries(MODEL_ALIASES)) {
|
||||
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}`);
|
||||
}
|
||||
// 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`);
|
||||
writeJSON(CONFIG_PATH, config);
|
||||
log(`Config saved`);
|
||||
|
||||
// ── Step 3: Patch auth-profiles.json ────────────────────────────────────
|
||||
console.log("\n🔑 Configuring auth profiles...\n");
|
||||
// ── 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);
|
||||
})
|
||||
: [];
|
||||
// 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 = {};
|
||||
import { readdirSync } from "node:fs";
|
||||
|
||||
// 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",
|
||||
};
|
||||
}
|
||||
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 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}`);
|
||||
// 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",
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
if (agentDirs.length === 0) {
|
||||
warn("No agent auth-profiles.json found — you may need to restart the gateway first");
|
||||
// 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 ─────────────────────────────────────────────
|
||||
console.log("\n🚀 Creating launcher...\n");
|
||||
|
||||
@@ -267,71 +238,40 @@ if (!DRY_RUN) {
|
||||
log(`Launcher: ${startPath}`);
|
||||
|
||||
// ── Step 5: Summary ─────────────────────────────────────────────────────
|
||||
const banner = [
|
||||
`╔══════════════════════════════════════════════════════════════╗`,
|
||||
`║ Setup complete! ║`,
|
||||
`╠══════════════════════════════════════════════════════════════╣`,
|
||||
`║ ║`,
|
||||
`║ Provider: ${PROVIDER_NAME.padEnd(44)}║`,
|
||||
`║ Port: ${String(PORT).padEnd(44)}║`,
|
||||
`║ Models: ${`see models.json (${MODELS.length} available)`.padEnd(44)}║`,
|
||||
`║ Default: ${DEFAULT_MODEL_ID.padEnd(44)}║`,
|
||||
`║ ║`,
|
||||
`║ Start proxy: ║`,
|
||||
`║ bash ${startPath.replace(HOME, "~").padEnd(50)}║`,
|
||||
`║ ║`,
|
||||
`║ Or directly: ║`,
|
||||
`║ node ${serverPath.replace(HOME, "~").padEnd(49)}║`,
|
||||
`║ ║`,
|
||||
];
|
||||
if (OPENCLAW_PRESENT) {
|
||||
banner.push(
|
||||
`║ Set as default model in openclaw.json: ║`,
|
||||
`║ agents.defaults.model.primary = ║`,
|
||||
`║ "${PROVIDER_NAME}/${DEFAULT_MODEL_ID}"${" ".repeat(Math.max(0, 30 - PROVIDER_NAME.length - DEFAULT_MODEL_ID.length))}║`,
|
||||
`║ ║`,
|
||||
`║ Then restart gateway: ║`,
|
||||
`║ openclaw gateway restart ║`,
|
||||
`║ ║`,
|
||||
);
|
||||
} else {
|
||||
banner.push(
|
||||
`║ OpenClaw not detected — running in standalone mode. ║`,
|
||||
`║ 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. ║`,
|
||||
`║ ║`,
|
||||
);
|
||||
console.log(`
|
||||
╔══════════════════════════════════════════════════════════════╗
|
||||
║ Setup complete! ║
|
||||
╠══════════════════════════════════════════════════════════════╣
|
||||
║ ║
|
||||
║ Provider: ${PROVIDER_NAME.padEnd(44)}║
|
||||
║ Port: ${String(PORT).padEnd(44)}║
|
||||
║ Models: ${`see models.json (${MODELS.length} available)`.padEnd(44)}║
|
||||
║ Default: ${DEFAULT_MODEL_ID.padEnd(44)}║
|
||||
║ ║
|
||||
║ Start proxy: ║
|
||||
║ bash ${startPath.replace(HOME, "~").padEnd(50)}║
|
||||
║ ║
|
||||
║ Or directly: ║
|
||||
║ node ${serverPath.replace(HOME, "~").padEnd(49)}║
|
||||
║ ║
|
||||
║ Set as default model in openclaw.json: ║
|
||||
║ agents.defaults.model.primary = ║
|
||||
║ "${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) {
|
||||
try {
|
||||
execSync(`bash "${startPath}"`, { stdio: "inherit" });
|
||||
} catch { /* ignore */ }
|
||||
}
|
||||
banner.push(`╚══════════════════════════════════════════════════════════════╝`);
|
||||
console.log("\n" + banner.join("\n") + "\n");
|
||||
|
||||
// ── Step 7: Install auto-start on boot ──────────────────────────────────
|
||||
|
||||
// Log service-env injection plan (shown in both dry-run and live mode)
|
||||
console.log("\n🔧 Service unit env vars to inject:\n");
|
||||
if (CLAUDE_BIN_INJECT) {
|
||||
log(`CLAUDE_BIN: ${CLAUDE_BIN_INJECT}`);
|
||||
} else {
|
||||
log(`CLAUDE_BIN: (not found — server.mjs will auto-detect at runtime)`);
|
||||
}
|
||||
if (OCP_ADMIN_KEY_INJECT) {
|
||||
log(`OCP_ADMIN_KEY: injected (length: ${OCP_ADMIN_KEY_INJECT.length})`);
|
||||
} else {
|
||||
log(`OCP_ADMIN_KEY: (unset — admin endpoints disabled)`);
|
||||
}
|
||||
if (PROXY_ANON_KEY_INJECT) {
|
||||
log(`PROXY_ANONYMOUS_KEY: injected (set)`);
|
||||
} else {
|
||||
log(`PROXY_ANONYMOUS_KEY: (unset — anonymous access disabled)`);
|
||||
}
|
||||
|
||||
if (DRY_RUN) {
|
||||
console.log("\n [dry-run] would write service unit with above env vars\n");
|
||||
}
|
||||
|
||||
if (!DRY_RUN) {
|
||||
console.log("\n🔄 Installing auto-start on login...\n");
|
||||
|
||||
@@ -404,13 +344,7 @@ if (!DRY_RUN) {
|
||||
<key>CLAUDE_BIND</key>
|
||||
<string>${BIND_ADDRESS}</string>
|
||||
<key>CLAUDE_AUTH_MODE</key>
|
||||
<string>${AUTH_MODE_CONFIG}</string>${CLAUDE_BIN_INJECT ? `
|
||||
<key>CLAUDE_BIN</key>
|
||||
<string>${CLAUDE_BIN_INJECT}</string>` : ""}${OCP_ADMIN_KEY_INJECT ? `
|
||||
<key>OCP_ADMIN_KEY</key>
|
||||
<string>${OCP_ADMIN_KEY_INJECT}</string>` : ""}${PROXY_ANON_KEY_INJECT ? `
|
||||
<key>PROXY_ANONYMOUS_KEY</key>
|
||||
<string>${PROXY_ANON_KEY_INJECT}</string>` : ""}
|
||||
<string>${AUTH_MODE_CONFIG}</string>
|
||||
</dict>
|
||||
<key>RunAtLoad</key>
|
||||
<true/>
|
||||
@@ -448,7 +382,7 @@ After=network.target
|
||||
ExecStart=${nodeBin} ${serverPath}
|
||||
Environment=CLAUDE_PROXY_PORT=${PORT}
|
||||
Environment=CLAUDE_BIND=${BIND_ADDRESS}
|
||||
Environment=CLAUDE_AUTH_MODE=${AUTH_MODE_CONFIG}${CLAUDE_BIN_INJECT ? `\nEnvironment=CLAUDE_BIN=${CLAUDE_BIN_INJECT}` : ""}${OCP_ADMIN_KEY_INJECT ? `\nEnvironment=OCP_ADMIN_KEY=${OCP_ADMIN_KEY_INJECT}` : ""}${PROXY_ANON_KEY_INJECT ? `\nEnvironment=PROXY_ANONYMOUS_KEY=${PROXY_ANON_KEY_INJECT}` : ""}
|
||||
Environment=CLAUDE_AUTH_MODE=${AUTH_MODE_CONFIG}
|
||||
Restart=always
|
||||
RestartSec=5
|
||||
StandardOutput=append:${logPath}
|
||||
@@ -471,52 +405,4 @@ WantedBy=default.target
|
||||
}
|
||||
|
||||
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);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
+1
-101
@@ -3,7 +3,7 @@
|
||||
* Integration test for Quota + Cache features.
|
||||
* Tests database layer functions directly — no server needed.
|
||||
*/
|
||||
import { getDb, createKey, listKeys, validateKey, recordUsage, checkQuota, updateKeyQuota, getKeyQuota, findKey, cacheHash, getCachedResponse, setCachedResponse, clearCache, getCacheStats, closeDb, hasCacheControl, singleflight, getInflightStats } from "./keys.mjs";
|
||||
import { getDb, createKey, listKeys, validateKey, recordUsage, checkQuota, updateKeyQuota, getKeyQuota, findKey, cacheHash, getCachedResponse, setCachedResponse, clearCache, getCacheStats, closeDb, hasCacheControl } from "./keys.mjs";
|
||||
import { createHash } from "node:crypto";
|
||||
import { strict as assert } from "node:assert";
|
||||
import { unlinkSync } from "node:fs";
|
||||
@@ -354,106 +354,6 @@ test("D3: chunked replay uses Array.from — multibyte codepoints stay intact",
|
||||
}
|
||||
});
|
||||
|
||||
// ── 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 ──
|
||||
closeDb();
|
||||
|
||||
|
||||
Reference in New Issue
Block a user