mirror of
https://github.com/dtzp555-max/ocp.git
synced 2026-07-22 21:45:08 +00:00
Compare commits
54
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
e031df3f99 | ||
|
|
324c206f95 | ||
|
|
1dd6fb9440 | ||
|
|
9e25160527 | ||
|
|
49c6d32e3b | ||
|
|
7766fa0868 | ||
|
|
70faeff067 | ||
|
|
7a69d72886 | ||
|
|
a8601a6d30 | ||
|
|
cd6ec2a212 | ||
|
|
ab03c13332 | ||
|
|
55c576bbb1 | ||
|
|
750b25ba77 | ||
|
|
fd6e875bd7 | ||
|
|
8c0b97f3ae | ||
|
|
68acf15373 | ||
|
|
a71c939bf8 | ||
|
|
d245c62df7 | ||
|
|
047750e642 | ||
|
|
3bdeb50ed5 | ||
|
|
fbbf3b6c7c | ||
|
|
d760d7fcce | ||
|
|
5e2effd05b | ||
|
|
fb2d1d3feb | ||
|
|
12b09c236e | ||
|
|
c0f2d3ab20 | ||
|
|
0d61da5153 | ||
|
|
49baffe2da | ||
|
|
4b01d4e768 | ||
|
|
36fa81d1e6 | ||
|
|
cce0110253 | ||
|
|
40391791a1 | ||
|
|
342a0a44f5 | ||
|
|
9494fd6c69 | ||
|
|
5ff30ac9b6 | ||
|
|
16eeb66557 | ||
|
|
c998d21a4f | ||
|
|
5be369ed68 | ||
|
|
3d52ffc152 | ||
|
|
68b0838074 | ||
|
|
313cb13a78 | ||
|
|
e4b010af5e | ||
|
|
0752f666fb | ||
|
|
ae4a829904 | ||
|
|
51e908e145 | ||
|
|
d99534dc35 | ||
|
|
39ca20536e | ||
|
|
8b3f50912e | ||
|
|
780462c763 | ||
|
|
733a2ed4c2 | ||
|
|
ca2d23d230 | ||
|
|
b871b72b6b | ||
|
|
cff06439fa | ||
|
|
1c29f4867f |
@@ -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,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,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,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,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
|
||||||
|
|||||||
@@ -1,8 +0,0 @@
|
|||||||
.git
|
|
||||||
.gitignore
|
|
||||||
*.md
|
|
||||||
node_modules
|
|
||||||
.env
|
|
||||||
.env.*
|
|
||||||
scripts/
|
|
||||||
start.sh
|
|
||||||
@@ -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"]
|
||||||
@@ -4,22 +4,46 @@
|
|||||||
|
|
||||||
<!-- One or two sentences describing the change and why it is in scope for OCP. -->
|
<!-- One or two sentences describing the change and why it is in scope for OCP. -->
|
||||||
|
|
||||||
|
## Endpoint Class (REQUIRED)
|
||||||
|
|
||||||
|
Per `ALIGNMENT.md` and ADR 0006, every PR that touches a network-facing endpoint must declare its class. Pick the most specific applicable class (Hybrid covers PRs that touch both A and B):
|
||||||
|
|
||||||
|
- [ ] **Class A** — forwards a `cli.js` operation (e.g., `/v1/messages`, `/api/oauth/*`, or the Anthropic-side wire call inside `/usage`)
|
||||||
|
- [ ] **Class B** — extends an OCP-owned compatibility endpoint (per ADR 0006). Sub-bucket:
|
||||||
|
- [ ] B.1 — OpenAI-compatibility surface (`/v1/chat/completions`, `/v1/models`)
|
||||||
|
- [ ] B.2 — OCP-administrative surface (`/health`, `/dashboard`, `/sessions`, `/logs`, `/status`, `/settings`, `/api/keys*`, `/api/usage`, `/cache*`)
|
||||||
|
- [ ] **Hybrid** — touches both classes (e.g., `/usage` if the PR modifies both the Anthropic wire call AND the local synthesis layer). Both evidence sections below must be filled.
|
||||||
|
- [ ] **Not endpoint-touching** — refactor / docs / tooling that does not modify any request handler. Skip both evidence sections; explain in Summary.
|
||||||
|
|
||||||
## Claude Code Alignment Evidence (REQUIRED)
|
## Claude Code Alignment Evidence (REQUIRED)
|
||||||
|
|
||||||
Per `ALIGNMENT.md`, every PR that touches `server.mjs` or any network-facing surface must fill out this section. PRs with this section blank or unchecked will receive a `request changes` review and cannot be merged.
|
PRs with the relevant evidence section blank or unchecked will receive a `request changes` review and cannot be merged.
|
||||||
|
|
||||||
|
### If Class A
|
||||||
|
|
||||||
- [ ] **Corresponding `cli.js` reference.** I have identified the `cli.js` function and line range that performs the operation this PR forwards. Citation (format `cli.js:NNNN` or `cli.js vE4 <functionName>`):
|
- [ ] **Corresponding `cli.js` reference.** I have identified the `cli.js` function and line range that performs the operation this PR forwards. Citation (format `cli.js:NNNN` or `cli.js vE4 <functionName>`):
|
||||||
<!-- e.g. cli.js:18423-18467 (function: sendUserMessage) -->
|
<!-- e.g. cli.js:18423-18467 (function: sendUserMessage) -->
|
||||||
|
|
||||||
- [ ] **If `cli.js` does not perform this operation**, I have stated this explicitly below and justified the scope under `ALIGNMENT.md` Rule 2. (Note: in almost all cases this means the PR should be closed, not merged. Proxy layers do not invent endpoints.)
|
- [ ] **If `cli.js` does not perform this operation**, I have stated this explicitly below and justified the scope under `ALIGNMENT.md` Rule 2. (Note: in almost all cases this means the PR should be closed, not merged. Proxy layers do not invent endpoints. If the endpoint is in fact Class B, switch the class above and use the Class B section instead.)
|
||||||
<!-- Justification, if applicable. Empty is fine when cli.js does perform the operation. -->
|
<!-- Justification, if applicable. Empty is fine when cli.js does perform the operation. -->
|
||||||
|
|
||||||
- [ ] **Commit message citations.** Every "Claude Code uses X" or "cli.js uses X" assertion in every commit of this PR is immediately followed by a `cli.js:NNNN` or `cli.js vE4 <functionName>` citation. I have verified this by rereading each commit message.
|
- [ ] **Commit message citations.** Every "Claude Code uses X" or "cli.js uses X" assertion in every commit of this PR is immediately followed by a `cli.js:NNNN` or `cli.js vE4 <functionName>` citation. I have verified this by rereading each commit message.
|
||||||
|
|
||||||
|
### If Class B
|
||||||
|
|
||||||
|
- [ ] **Authorizing ADR.** Cite the ADR number that authorizes the endpoint this PR modifies (e.g., "ADR 0006 — OpenAI shim scope"). For B.1 endpoints (`/v1/chat/completions`, `/v1/models`), this is ADR 0006. For grandfathered B.2 endpoints, this is "ADR 0006 (grandfathered as of v3.16.4)." For new B.2 endpoints, cite the endpoint's own authorizing ADR; if none exists, the PR cannot proceed — the authorizing ADR must be drafted and merged first.
|
||||||
|
<!-- e.g., ADR 0006 -->
|
||||||
|
|
||||||
|
- [ ] **Specification citation.** For B.1 endpoints, link to the relevant section of OpenAI's `/v1/chat/completions` specification (https://platform.openai.com/docs/api-reference/chat/create), including the specific field or behaviour being implemented. For B.2 endpoints with their own ADR, cite the ADR section that specifies the behaviour. For grandfathered B.2 endpoints, the PR must be a behaviour-preserving refactor — link the existing handler code being modified.
|
||||||
|
<!-- B.1 example: OpenAI chat/completions, `response_format` parameter, https://platform.openai.com/docs/api-reference/chat/create#chat-create-response_format -->
|
||||||
|
<!-- B.2 example: ADR 00NN § "Behaviour" -->
|
||||||
|
|
||||||
|
- [ ] **No invention beyond the specification.** I confirm this PR does not introduce any field or behaviour not present in OpenAI's spec for the endpoint (B.1) or beyond the scope of the authorizing ADR (B.2). For grandfathered B.2 endpoints, I confirm the change is behaviour-preserving (no contract drift). If something the user actually wants is not in the spec, the right answer is to close this PR and propose an upstream spec change or a new ADR.
|
||||||
|
|
||||||
## Type of change
|
## Type of change
|
||||||
|
|
||||||
- [ ] Bug fix (alignment with existing `cli.js` behavior)
|
- [ ] Bug fix (alignment with existing `cli.js` behavior, or with the cited spec / ADR for Class B)
|
||||||
- [ ] Feature (new `cli.js` behavior now surfaced through OCP)
|
- [ ] Feature (new `cli.js` behavior now surfaced through OCP, or new field already in OpenAI's spec for Class B)
|
||||||
- [ ] Refactor (no wire-level behavior change)
|
- [ ] Refactor (no wire-level behavior change)
|
||||||
- [ ] Deletion (unalignable feature removal per `ALIGNMENT.md` Unalignable Policy)
|
- [ ] Deletion (unalignable feature removal per `ALIGNMENT.md` Unalignable Policy)
|
||||||
- [ ] Documentation / governance
|
- [ ] Documentation / governance
|
||||||
@@ -28,14 +52,16 @@ Per `ALIGNMENT.md`, every PR that touches `server.mjs` or any network-facing sur
|
|||||||
|
|
||||||
Reviewers: this section is for you, not the author. Do not approve until every box is checked.
|
Reviewers: this section is for you, not the author. Do not approve until every box is checked.
|
||||||
|
|
||||||
- [ ] I opened `cli.js` at the cited line range and confirmed the operation matches.
|
- [ ] If Class A, I opened `cli.js` at the cited line range and confirmed the operation matches. If Class B, I opened the OpenAI spec at the cited section (B.1) or the authorizing ADR (B.2) and confirmed the behaviour described in this PR matches the cited reference.
|
||||||
- [ ] I ran (or confirmed CI ran) `.github/workflows/alignment.yml` and it passed.
|
- [ ] I ran (or confirmed CI ran) `.github/workflows/alignment.yml` and it passed.
|
||||||
- [ ] I am not the commit author of any commit in this PR (Iron Rule 10).
|
- [ ] I am not the commit author of any commit in this PR (Iron Rule 10).
|
||||||
- [ ] If the PR asserts scope without a `cli.js` citation, I confirmed the justification is sound per `ALIGNMENT.md` Rule 2.
|
- [ ] If the PR asserts scope without a `cli.js` citation (Class A) or without an ADR (Class B), I confirmed the justification is sound per `ALIGNMENT.md` Rule 2 and ADR 0006.
|
||||||
|
- [ ] If the PR is Class B and adds a new endpoint or new method, I confirmed the authorizing ADR lands in the same merge or before this PR.
|
||||||
|
|
||||||
## Related
|
## Related
|
||||||
|
|
||||||
- `ALIGNMENT.md` Rule(s) invoked: <!-- e.g. Rule 3 -->
|
- `ALIGNMENT.md` Rule(s) invoked: <!-- e.g. Rule 3, or Rule 3 (Class B mapping) -->
|
||||||
|
- Authorizing ADR (Class B only): <!-- e.g. ADR 0006 -->
|
||||||
- Related issue / prior PR: <!-- #NNN -->
|
- Related issue / prior PR: <!-- #NNN -->
|
||||||
- Historical lesson reference (if relevant): <!-- e.g. 2026-04-11 drift, b87992f -->
|
- Historical lesson reference (if relevant): <!-- e.g. 2026-04-11 drift, b87992f -->
|
||||||
|
|
||||||
@@ -45,3 +71,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.
|
||||||
|
|||||||
@@ -4,6 +4,11 @@ on:
|
|||||||
pull_request:
|
pull_request:
|
||||||
paths:
|
paths:
|
||||||
- 'server.mjs'
|
- 'server.mjs'
|
||||||
|
- 'setup.mjs'
|
||||||
|
- 'scripts/**'
|
||||||
|
- 'lib/**'
|
||||||
|
- 'ocp'
|
||||||
|
- 'ocp-connect'
|
||||||
- '.github/workflows/alignment.yml'
|
- '.github/workflows/alignment.yml'
|
||||||
|
|
||||||
jobs:
|
jobs:
|
||||||
@@ -66,6 +71,80 @@ jobs:
|
|||||||
|
|
||||||
echo "Blacklist scan clean."
|
echo "Blacklist scan clean."
|
||||||
|
|
||||||
|
port-spot:
|
||||||
|
name: port literal SPOT (hard fail)
|
||||||
|
# Background: from 2026-05-08 (PR #71 dogfood accident) through 2026-05-13
|
||||||
|
# a hardcoded "3478" in scripts/upgrade.mjs + scripts/doctor.mjs cascaded
|
||||||
|
# into wrong baseUrl writes for the OpenClaw "claude-local" provider,
|
||||||
|
# taking out the "大内总管" Telegram agent.
|
||||||
|
#
|
||||||
|
# Rule: the only places allowed to write a literal port number in source
|
||||||
|
# are (a) lib/constants.mjs (the SPOT), (b) bash scripts ocp / ocp-connect
|
||||||
|
# (which can't import .mjs and must keep the literal in sync — flagged
|
||||||
|
# with a `// keep in sync with lib/constants.mjs` style comment), and
|
||||||
|
# (c) test-features.mjs (intentionally pins historical ports for plist /
|
||||||
|
# systemd parser tests). Everything else MUST import from lib/constants.mjs.
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
steps:
|
||||||
|
- name: Checkout
|
||||||
|
uses: actions/checkout@v4
|
||||||
|
|
||||||
|
- name: Scan for hardcoded port literals outside SPOT
|
||||||
|
shell: bash
|
||||||
|
run: |
|
||||||
|
set -euo pipefail
|
||||||
|
|
||||||
|
# Files/paths exempt from the SPOT requirement.
|
||||||
|
EXEMPT_REGEX='^(lib/constants\.mjs|test-features\.mjs|ocp|ocp-connect|CHANGELOG\.md|README\.md|docs/|\.github/workflows/alignment\.yml)'
|
||||||
|
|
||||||
|
# Hardcoded port literals to forbid in non-exempt source.
|
||||||
|
FORBIDDEN_PORTS=("3478" "3456")
|
||||||
|
|
||||||
|
FAIL=0
|
||||||
|
for port in "${FORBIDDEN_PORTS[@]}"; do
|
||||||
|
HITS="$(git ls-files | grep -E '\.(mjs|js|ts|json)$' \
|
||||||
|
| xargs grep -n -E "[^0-9]${port}[^0-9]" 2>/dev/null \
|
||||||
|
| grep -v -E "${EXEMPT_REGEX}" \
|
||||||
|
|| true)"
|
||||||
|
if [ -n "$HITS" ]; then
|
||||||
|
echo "::error::Hardcoded port literal '${port}' found outside lib/constants.mjs:"
|
||||||
|
echo "$HITS"
|
||||||
|
FAIL=1
|
||||||
|
fi
|
||||||
|
done
|
||||||
|
|
||||||
|
if [ "$FAIL" -ne 0 ]; then
|
||||||
|
cat <<'EOF'
|
||||||
|
|
||||||
|
============================================================
|
||||||
|
PORT LITERAL SPOT VIOLATION
|
||||||
|
============================================================
|
||||||
|
A hardcoded TCP port literal was found in a source file
|
||||||
|
that should import from lib/constants.mjs instead.
|
||||||
|
|
||||||
|
Background: this rule exists because between 2026-05-08 and
|
||||||
|
2026-05-13 a stray hardcoded "3478" in scripts/upgrade.mjs
|
||||||
|
and scripts/doctor.mjs cascaded into downstream OpenClaw
|
||||||
|
config writes, taking out the OpenClaw Telegram agent.
|
||||||
|
See v3.16.3 CHANGELOG and lib/constants.mjs header comment.
|
||||||
|
|
||||||
|
Required action:
|
||||||
|
1. Import DEFAULT_PORT (or related constant) from
|
||||||
|
lib/constants.mjs instead of hardcoding the literal.
|
||||||
|
2. If the file genuinely cannot import .mjs (e.g. bash
|
||||||
|
script), add it to EXEMPT_REGEX in this workflow and
|
||||||
|
add a `keep in sync with lib/constants.mjs` comment
|
||||||
|
at the reference.
|
||||||
|
3. For test files that intentionally pin historical ports
|
||||||
|
(test-features.mjs), the regex already exempts them.
|
||||||
|
|
||||||
|
============================================================
|
||||||
|
EOF
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
echo "Port SPOT scan clean."
|
||||||
|
|
||||||
commit-citation:
|
commit-citation:
|
||||||
name: commit message citation (soft check)
|
name: commit message citation (soft check)
|
||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
|
|||||||
@@ -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 }}
|
||||||
@@ -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
@@ -0,0 +1,15 @@
|
|||||||
|
# Runtime artifacts
|
||||||
|
logs/
|
||||||
|
*.log
|
||||||
|
|
||||||
|
# Dependencies
|
||||||
|
node_modules/
|
||||||
|
|
||||||
|
# Environment files
|
||||||
|
.env
|
||||||
|
.env.*
|
||||||
|
|
||||||
|
# Editor / OS scratch
|
||||||
|
.DS_Store
|
||||||
|
*.swp
|
||||||
|
*~
|
||||||
@@ -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''',
|
||||||
|
]
|
||||||
@@ -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.
|
||||||
|
|||||||
+67
-5
@@ -8,10 +8,14 @@
|
|||||||
|
|
||||||
OCP (Open Claude Proxy) is a **proxy layer** for the Claude Code CLI. It forwards, observes, and multiplexes the traffic that `cli.js` already emits. It is **not** an extension layer. If `cli.js` does not perform a given operation, or performs it differently, OCP does not invent one.
|
OCP (Open Claude Proxy) is a **proxy layer** for the Claude Code CLI. It forwards, observes, and multiplexes the traffic that `cli.js` already emits. It is **not** an extension layer. If `cli.js` does not perform a given operation, or performs it differently, OCP does not invent one.
|
||||||
|
|
||||||
|
This Core Principle applies in full to **Class A** endpoints (the `cli.js`-mirror surface). A second class of endpoint — **Class B**, the OCP-owned compatibility surface — has its own scope discipline anchored to its own specification authority. See "Scope Clarification: OCP-Owned Compatibility Endpoints (Class B)" below and ADR 0006.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## Rules
|
## Rules
|
||||||
|
|
||||||
|
The following Rules apply to **Class A operations** (the `cli.js`-mirror surface — the inbound `/v1/messages` forwarding route, the outbound `/v1/messages` wire call used by `handleUsage()` for rate-limit-header extraction, the OAuth bearer machinery, and any future operations OCP forwards from `cli.js` to Anthropic). For the Class B mapping of each rule, see the Class B section below.
|
||||||
|
|
||||||
1. **Rule 1 (Grep First).** Before adding, renaming, or changing any endpoint, header, parameter, or response shape, the author must `grep` the reference `cli.js` and record the exact line numbers in the commit message and PR body. An absent grep hit is itself a finding and must be declared.
|
1. **Rule 1 (Grep First).** Before adding, renaming, or changing any endpoint, header, parameter, or response shape, the author must `grep` the reference `cli.js` and record the exact line numbers in the commit message and PR body. An absent grep hit is itself a finding and must be declared.
|
||||||
|
|
||||||
2. **Rule 2 (No Invention).** OCP must not introduce endpoints, headers, request fields, or response fields that are not present in `cli.js`. Speculative "Claude Code probably uses X" statements are prohibited. If the behavior is not observable in `cli.js`, the feature is out of scope.
|
2. **Rule 2 (No Invention).** OCP must not introduce endpoints, headers, request fields, or response fields that are not present in `cli.js`. Speculative "Claude Code probably uses X" statements are prohibited. If the behavior is not observable in `cli.js`, the feature is out of scope.
|
||||||
@@ -44,7 +48,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.
|
||||||
|
|
||||||
@@ -68,20 +72,78 @@ On 2026-04-11, commit `b87992f` ("fix: use dedicated /api/oauth/usage endpoint f
|
|||||||
|
|
||||||
## Unalignable Policy
|
## Unalignable Policy
|
||||||
|
|
||||||
A feature is **unalignable** if, after a good-faith search, it cannot be mapped to a specific `cli.js` line range or function.
|
A feature is **unalignable** if, after a good-faith search, it cannot be mapped to a specific `cli.js` line range or function (Class A) or to a specific OpenAI specification section AND an authorizing ADR (Class B).
|
||||||
|
|
||||||
- Unalignable features are **deleted**, not disabled, not feature-flagged, not deprecated.
|
- Unalignable features are **deleted**, not disabled, not feature-flagged, not deprecated.
|
||||||
- Deletion is the default outcome of an alignment audit finding. The burden of proof is on the feature, not on the auditor.
|
- Deletion is the default outcome of an alignment audit finding. The burden of proof is on the feature, not on the auditor.
|
||||||
- A deletion PR does not require user-facing deprecation notice, because the feature was never legitimately in scope.
|
- A deletion PR does not require user-facing deprecation notice, because the feature was never legitimately in scope.
|
||||||
- If a user workflow depended on an unalignable feature, the correct remediation is to upstream the behavior into `cli.js` or to move it out of OCP into a separate tool. OCP does not retain it.
|
- If a user workflow depended on an unalignable feature, the correct remediation is to upstream the behavior into `cli.js` (Class A) or into OpenAI's spec (Class B) or to move it out of OCP into a separate tool. OCP does not retain it.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Scope Clarification: OCP-Owned Compatibility Endpoints (Class B)
|
||||||
|
|
||||||
|
OCP has two classes of endpoint. Rules 1–5 above were drafted in the aftermath of the 2026-04-11 forwarding drift and are written in the language of a one-to-one proxy; they apply verbatim to **Class A** endpoints. **Class B** endpoints — the OCP-owned compatibility surface where `cli.js` is not the wire authority — have their own scope discipline, anchored to their own specification authority. The full rationale lives in **ADR 0006 (OpenAI Shim Scope)**.
|
||||||
|
|
||||||
|
**Class A** — `cli.js`-mirror endpoints. The endpoint exists because `cli.js` performs the equivalent operation and OCP forwards, observes, or multiplexes that operation. Rules 1–5 above apply verbatim. Citation format: `cli.js:NNNN` or `cli.js vE4 <functionName>`.
|
||||||
|
|
||||||
|
**Class B** — OCP-owned compatibility endpoints. The endpoint exists because OCP itself surfaces it, with no `cli.js` analogue. Two sub-buckets: **B.1** (OpenAI-compatibility surface — protocol authority is OpenAI's `/v1/chat/completions` specification) and **B.2** (OCP-administrative surface — authority is the ADR that authorized the endpoint's existence).
|
||||||
|
|
||||||
|
### Grandfather provision for existing B.2 inventory
|
||||||
|
|
||||||
|
ADR 0006 retroactively authorizes the B.2 endpoints listed in the inventory table below, **frozen at their current behaviour as of v3.16.4**. This is a one-time provision; it does not extend to new B.2 endpoints or to B.1 endpoints. Any change to the contract (request shape, response shape, semantics) of a grandfathered B.2 endpoint is treated as a new authorization request and requires either a behaviour-preserving refactor PR or its own ADR. Any new B.2 endpoint, or any new method on a grandfathered B.2 endpoint, requires its own ADR before merge.
|
||||||
|
|
||||||
|
### Current Class B inventory
|
||||||
|
|
||||||
|
| Endpoint | Method | Sub-bucket | Authorizing ADR |
|
||||||
|
|---|---|---|---|
|
||||||
|
| `/v1/chat/completions` | POST | B.1 (OpenAI-compat) | ADR 0006 |
|
||||||
|
| `/v1/models` | GET | B.1 (OpenAI-compat) | ADR 0006; content sourced from `models.json` per ADR 0003 |
|
||||||
|
| `/health` | GET | B.2 (administrative) | ADR 0006 (grandfathered as of v3.16.4) |
|
||||||
|
| `/dashboard` | GET | B.2 (administrative) | ADR 0006 (grandfathered as of v3.16.4) |
|
||||||
|
| `/sessions` | GET, DELETE | B.2 (administrative) | ADR 0006 (grandfathered as of v3.16.4) |
|
||||||
|
| `/logs` | GET | B.2 (administrative) | ADR 0006 (grandfathered as of v3.16.4) |
|
||||||
|
| `/status` | GET | B.2 (administrative) | ADR 0006 (grandfathered as of v3.16.4) |
|
||||||
|
| `/settings` | GET, PATCH | B.2 (administrative) | ADR 0006 (grandfathered as of v3.16.4) |
|
||||||
|
| `/api/keys` | GET, POST | B.2 (administrative) | ADR 0006 (grandfathered as of v3.16.4) |
|
||||||
|
| `/api/keys/:id` | DELETE | B.2 (administrative) | ADR 0006 (grandfathered as of v3.16.4) |
|
||||||
|
| `/api/keys/:id/quota` | GET, PATCH | B.2 (administrative) | ADR 0006 (grandfathered as of v3.16.4) |
|
||||||
|
| `/api/usage` | GET | B.2 (administrative) | ADR 0006 (grandfathered as of v3.16.4) |
|
||||||
|
| `/cache/stats` | GET | B.2 (administrative) | ADR 0006 (grandfathered as of v3.16.4) |
|
||||||
|
| `/cache` | DELETE | B.2 (administrative) | ADR 0006 (grandfathered as of v3.16.4) |
|
||||||
|
|
||||||
|
**Hybrid note.** `/usage` is a hybrid endpoint: the underlying call to `api.anthropic.com/v1/messages` (used to extract `anthropic-ratelimit-unified-*` headers, per the in-file comment block at `server.mjs` line 845–849) is Class A and requires the standard `cli.js` citation; the local synthesis layer that adds `proxy:` stats and `models:` snapshot is Class B and is authorized by ADR 0006. A PR touching only the wire-call layer is Class A; a PR touching only the synthesis layer is Class B; a PR touching both must satisfy both citation requirements.
|
||||||
|
|
||||||
|
### Class B citation requirement
|
||||||
|
|
||||||
|
Class B PRs cite **the relevant specification section + the authorizing ADR**, in place of `cli.js:NNNN`. Examples:
|
||||||
|
|
||||||
|
- B.1: "OpenAI `chat/completions` API, `response_format` parameter (https://platform.openai.com/docs/api-reference/chat/create), authorized by ADR 0006."
|
||||||
|
- B.2 (grandfathered): "Authorized by ADR 0006 (grandfathered as of v3.16.4)."
|
||||||
|
- B.2 (with its own ADR): "Authorized by ADR 00NN (the ADR that originally authorized the endpoint)."
|
||||||
|
|
||||||
|
### Rule mapping for Class B
|
||||||
|
|
||||||
|
| Class A rule | Class B mapping |
|
||||||
|
|---|---|
|
||||||
|
| Rule 1 (Grep First) | Read the cited OpenAI spec section (B.1) or the authorizing ADR (B.2) before writing code. Record the spec URL and ADR number in the PR body. |
|
||||||
|
| Rule 2 (No Invention) | OCP must not introduce fields or behaviour not present in OpenAI's spec for the endpoint (B.1) or outside the scope of the authorizing ADR (B.2). For grandfathered B.2 endpoints, "scope" is the v3.16.4 behaviour snapshot. |
|
||||||
|
| Rule 3 (Match the Implementation) | Match OpenAI's spec wire-format (B.1) or the ADR's specified behaviour (B.2). |
|
||||||
|
| Rule 4 (Unalignable Features Are Deleted) | A Class B endpoint that maps to nothing in OpenAI's spec **and** lacks an authorizing ADR (including not being in the grandfather inventory) is unalignable and is deleted on the same terms as a Class A unalignable feature. |
|
||||||
|
| Rule 5 (Cite Line Numbers in Commits) | Cite the OpenAI spec section URL + authorizing ADR number in the commit body (B.1) or the authorizing ADR number alone (B.2). |
|
||||||
|
|
||||||
|
### New Class B endpoint procedure
|
||||||
|
|
||||||
|
Any new Class B endpoint, or any new method on an existing Class B endpoint (including grandfathered ones), requires its own ADR before merge. An "ADR-less" new Class B endpoint is itself an alignment finding under Rule 4.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## Annual Alignment Audit
|
## Annual Alignment Audit
|
||||||
|
|
||||||
- **Date:** 11 April each year (the anniversary of the `b87992f` drift).
|
- **Date:** 11 April each year (the anniversary of the `b87992f` drift).
|
||||||
- **Scope:** Diff the current `cli.js` against the pinned SHA-256 in the Golden Reference section. For every network call in `server.mjs`, re-verify that the corresponding `cli.js` reference still exists at the cited line numbers (adjust citations if line numbers shifted across Claude Code versions).
|
- **Scope (Class A):** Diff the current `cli.js` against the pinned SHA-256 in the Golden Reference section. For every network call in `server.mjs`, re-verify that the corresponding `cli.js` reference still exists at the cited line numbers (adjust citations if line numbers shifted across Claude Code versions).
|
||||||
- **Output:** A signed audit note committed to `docs/alignment-audits/YYYY-04-11.md`, updating the pin.
|
- **Scope (Class B):** Audit B.1 endpoints against OpenAI's current `/v1/chat/completions` specification snapshot. Audit B.2 endpoints against their authorizing ADR — for grandfathered endpoints, verify the endpoint behaviour still matches its v3.16.4 snapshot; for ADR-specific endpoints, verify behaviour still matches the ADR. The B.1 specification pin lives in `docs/openai-compat-pin.md` (created alongside the first B.1 audit; not required for ADR 0006 to land).
|
||||||
|
- **Output:** A signed audit note committed to `docs/alignment-audits/YYYY-04-11.md`, updating the Class A pin and (once `docs/openai-compat-pin.md` exists) the B.1 pin.
|
||||||
- **Failure mode:** Any audit finding that cannot be reconciled triggers an immediate deletion PR per the Unalignable Policy.
|
- **Failure mode:** Any audit finding that cannot be reconciled triggers an immediate deletion PR per the Unalignable Policy.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|||||||
+236
@@ -1,5 +1,241 @@
|
|||||||
# Changelog
|
# Changelog
|
||||||
|
|
||||||
|
## Unreleased
|
||||||
|
|
||||||
|
### Model — add claude-opus-4-8
|
||||||
|
|
||||||
|
Add `claude-opus-4-8` as the newest Opus to `models.json` (index 0, newest first). Repoint `aliases.opus` from `claude-opus-4-7` to `claude-opus-4-8`. `claude-opus-4-7` remains in the list callable by literal id. `legacyAliases.claude-opus-4` left pointing at `claude-opus-4-7` (no change — legacy alias tracks the prior generation). README Available Models table and model-count references updated accordingly.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## v3.16.4 — 2026-05-13
|
||||||
|
|
||||||
|
### Refactor — port-literal SPOT + CI guardrail
|
||||||
|
|
||||||
|
Closes the structural side of the port-drift cascade addressed by v3.16.2
|
||||||
|
and v3.16.3. Those two releases reverted plist / plugin / scripts back to
|
||||||
|
3456 line-by-line, but the underlying invitation to drift — a hardcoded
|
||||||
|
port literal scattered across six source files — was still intact.
|
||||||
|
|
||||||
|
Changes:
|
||||||
|
|
||||||
|
- **New `lib/constants.mjs`** — single source of truth for shared literals.
|
||||||
|
Exports `DEFAULT_PORT = 3456`, `LOCAL_HOST = "127.0.0.1"`,
|
||||||
|
`OPENAI_API_BASE = "/v1"`, `LOCAL_PROXY_URL`.
|
||||||
|
- **`server.mjs:127`, `setup.mjs:36`, `scripts/upgrade.mjs:137`,
|
||||||
|
`scripts/doctor.mjs:84` + `:205`, `scripts/sync-openclaw.mjs:73`** —
|
||||||
|
all replaced with imports from `lib/constants.mjs`. Behavior is
|
||||||
|
identical; the literal `3456` now exists in exactly one place per
|
||||||
|
language (`lib/constants.mjs` for `.mjs`, `ocp` + `ocp-connect` for
|
||||||
|
bash, `test-features.mjs` for pinned historical-port tests).
|
||||||
|
- **`.github/workflows/alignment.yml`** — extended the path filter to
|
||||||
|
`setup.mjs`, `scripts/**`, `lib/**`, `ocp`, `ocp-connect`. Added a new
|
||||||
|
`port-spot` hard-fail job that greps for any hardcoded `3478` or `3456`
|
||||||
|
literal in `.mjs/.js/.ts/.json` outside the EXEMPT_REGEX (which lists
|
||||||
|
`lib/constants.mjs`, `test-features.mjs`, the bash CLIs, docs, and the
|
||||||
|
workflow itself). Any future PR re-introducing a hardcoded port
|
||||||
|
literal will be blocked at CI before it can cascade.
|
||||||
|
- Doc comments in `server.mjs` env-var summary and `setup.mjs` usage
|
||||||
|
banner reworded so the literal `3456` no longer appears as
|
||||||
|
documentation text (CI grep is intentionally aggressive — it does not
|
||||||
|
parse comments — so doc strings reference `DEFAULT_PORT from
|
||||||
|
lib/constants.mjs` instead).
|
||||||
|
|
||||||
|
No behavior change for any user. `CLAUDE_PROXY_PORT` env var remains
|
||||||
|
the runtime override; the only difference is the unset-env fallback
|
||||||
|
now flows through one shared constant.
|
||||||
|
|
||||||
|
ALIGNMENT.md hard-requirements: this PR modifies `server.mjs` (one-line
|
||||||
|
import + one literal swap, mechanical). No cli.js operation changed;
|
||||||
|
the citation requirement does not apply. SPOT principle (Rule 2 spirit)
|
||||||
|
is the entire motivation.
|
||||||
|
|
||||||
|
## v3.16.3 — 2026-05-13
|
||||||
|
|
||||||
|
### Fixes — completes v3.16.2 port-drift revert
|
||||||
|
|
||||||
|
v3.16.2 reverted the plugin / `openclaw.plugin.json` / README / Mac mini
|
||||||
|
plist back to `3456` (the historical source default since `593d0dc`), but
|
||||||
|
missed three places in `scripts/` that still defaulted to `3478`. Those
|
||||||
|
three lines were the residual cascade source: every time `ocp doctor` or
|
||||||
|
`ocp upgrade` ran without `CLAUDE_PROXY_PORT` in the env, they probed
|
||||||
|
`3478`, reported "OCP not responding" against a healthy 3456 instance,
|
||||||
|
and (in the case of OpenClaw sync follow-ups on the maintainer's host)
|
||||||
|
re-introduced 3478 into downstream config.
|
||||||
|
|
||||||
|
Changes:
|
||||||
|
|
||||||
|
- `scripts/upgrade.mjs:137` — default port `3478` → `3456`.
|
||||||
|
- `scripts/doctor.mjs:84` — default port `3478` → `3456`.
|
||||||
|
- `scripts/doctor.mjs:205` — default port `3478` → `3456`.
|
||||||
|
|
||||||
|
No behavior change for users who set `CLAUDE_PROXY_PORT` explicitly; env
|
||||||
|
still takes precedence. The fix only affects the unset-env fallback,
|
||||||
|
which now matches `server.mjs:126` and the rest of the codebase.
|
||||||
|
|
||||||
|
Test plan: existing `test-features.mjs` cases that pin
|
||||||
|
`CLAUDE_PROXY_PORT=3478` continue to pass — they use the env path, not
|
||||||
|
the default.
|
||||||
|
|
||||||
|
## v3.16.2 — 2026-05-12
|
||||||
|
|
||||||
|
### Fixes — corrects v3.16.1
|
||||||
|
|
||||||
|
The v3.16.1 fix was directionally correct (plugin now reads env first, falls back to a hardcoded default) but **the narrative and the hardcoded default were both wrong**.
|
||||||
|
|
||||||
|
What v3.16.1 said: "OCP server moved to 3478 default in v3.14+; plugin lagged at 3456."
|
||||||
|
What is actually true:
|
||||||
|
- **OCP server source default has been `3456` since `593d0dc` (initial release) and has never changed.** Every line in `server.mjs`, `setup.mjs`, and the `ocp` CLI still uses `3456` as the documented and code-level default.
|
||||||
|
- The single OCP installation observed on `3478` is the maintainer's Mac mini, whose plist was rewritten with `--port 3478` during a PR #71 dogfood smoke-test accident on 2026-05-08 (see `~/.cc-rules/memory/learnings/subagent_setup_mjs_prod_host_collision.md`). The plist drift was never reconciled back to source default, and v3.16.1 incorrectly canonised the post-accident value as if it had been a release decision.
|
||||||
|
|
||||||
|
This release:
|
||||||
|
- Restores the plugin fallback to `http://127.0.0.1:3456` to match server source default.
|
||||||
|
- Updates `openclaw.plugin.json` `configSchema.proxyUrl.default` back to `3456`.
|
||||||
|
- Restores README §"Environment Variables" `CLAUDE_PROXY_PORT` default to `3456`.
|
||||||
|
- Plugin reads `OCP_PROXY_URL` env (full URL) first, then `CLAUDE_PROXY_PORT` env (port only), then falls back to `3456`. Hosts whose OCP plist injects a non-default port must also inject the same `CLAUDE_PROXY_PORT` into the OpenClaw plist for the plugin to follow.
|
||||||
|
- Maintainer's Mac mini plist was reverted from `3478` to `3456` as part of this release deploy (no source change reflects this; it was a one-host correction).
|
||||||
|
|
||||||
|
### Governance
|
||||||
|
|
||||||
|
- No `cli.js` citation needed (no `server.mjs` change). ALIGNMENT.md Rule 2 not engaged.
|
||||||
|
|
||||||
|
## v3.16.1 — 2026-05-12 (superseded — narrative incorrect; see v3.16.2 erratum)
|
||||||
|
|
||||||
|
### Fixes (as shipped — note erratum above)
|
||||||
|
|
||||||
|
- **OCP plugin port lag** — `ocp-plugin/index.js` hard-coded `http://127.0.0.1:3456`. ~~While OCP server moved to 3478 in v3.14+,~~ **(corrected v3.16.2: no such move ever happened.)** The Mac mini's plist was on `3478` only as residue from a dogfood accident. Result: `/ocp` slash commands from the home Telegram bot returned "OCP error: fetch failed". v3.16.1 changed the plugin default to `3478` (wrong direction; v3.16.2 reverts to `3456`).
|
||||||
|
|
||||||
|
### Governance
|
||||||
|
|
||||||
|
- No `cli.js` citation needed (no `server.mjs` change). ALIGNMENT.md Rule 2 not engaged.
|
||||||
|
|
||||||
|
## v3.16.0 — 2026-05-10
|
||||||
|
|
||||||
|
### Features
|
||||||
|
|
||||||
|
- **`ocp doctor --check oauth`** (PR #93) — fast path that runs only the OAuth check, skipping
|
||||||
|
version detection / from-version / git operations / models endpoint. ~50ms vs. full doctor's
|
||||||
|
~200-500ms. Use cases: AI agent repair loops, post-`claude auth login` verify, quick health
|
||||||
|
gates. Help text in `cmd_doctor_help` now reflects working behaviour.
|
||||||
|
- **`ocp update --rollback --gc`** — manually garbage-collect old upgrade snapshots.
|
||||||
|
Retention policy: keep last 5 snapshots OR snapshots newer than 30 days OR the single most
|
||||||
|
recent (always-keep safety net). `--dry-run` previews. Successful `ocp update` runs auto-GC
|
||||||
|
at the end of the full path; light path does not (no snapshot created there).
|
||||||
|
|
||||||
|
### Behavior changes
|
||||||
|
|
||||||
|
- After a successful cross-minor `ocp update`, the auto-GC emits `[gc] removed N old snapshots`
|
||||||
|
to stderr if any were collected. Safe to ignore; manual gc is `ocp update --rollback --gc`.
|
||||||
|
|
||||||
|
### Governance
|
||||||
|
|
||||||
|
- No `cli.js` citation needed (no `server.mjs` change). ALIGNMENT.md Rule 2 not engaged.
|
||||||
|
- PR #93 (--check oauth) merged separately; this release bundles it with the GC feature.
|
||||||
|
|
||||||
|
## v3.15.1 — 2026-05-10
|
||||||
|
|
||||||
|
### Fixes
|
||||||
|
|
||||||
|
- **doctor: dynamic `latest_version` from `origin/main:package.json`** — v3.15.0 doctor used a hard-coded `latest = "v3.14.0"` fallback, which made any v3.15.0+ install report `kind = upgrade` (against a stale value). `ocp update` would then attempt `git checkout v3.14.0` — a downgrade. Doctor now fetches `git -C ~/ocp show origin/main:package.json` to determine the actual latest version; on failure (offline, fresh clone with no remote), falls back to `currentVersion` so `kind = noop` instead of recommending a downgrade.
|
||||||
|
|
||||||
|
## v3.15.0 — 2026-05-10
|
||||||
|
|
||||||
|
### Features
|
||||||
|
|
||||||
|
- **`ocp doctor`** — health & upgrade-readiness check; primary entry for AI-driven debugging.
|
||||||
|
`--json` mode emits a `next_action` with `ai_executable[]` for agents to run verbatim
|
||||||
|
and `human_required[]` for steps requiring the user (typically only OAuth).
|
||||||
|
- **`ocp update` cross-version path** — for cross-minor jumps (e.g. v3.10 → v3.14),
|
||||||
|
`ocp update` now runs doctor → snapshot → `setup.mjs` (with the plist env-merge from
|
||||||
|
PR #90) → service restart → post-flight `/health` + `/v1/models` verification.
|
||||||
|
Same-patch updates retain the existing light path; users see no change for routine
|
||||||
|
patch bumps.
|
||||||
|
- **`ocp update --rollback`** — restore the most recent (or specified) upgrade snapshot.
|
||||||
|
Snapshots are saved to `~/.ocp/upgrade-snapshot-<ISO-ts>/` and never auto-deleted.
|
||||||
|
- **Fresh-install routing** — `ocp update` on installations < v3.4.0 routes to a fresh-install
|
||||||
|
flow (with `--yes` to skip confirmation; AI agents pass this). OAuth survives via Claude
|
||||||
|
Code's credential store; users do not re-OAuth unless their token was independently broken.
|
||||||
|
- **AI prompt blocks in README** — §Installation, §Upgrading, and §Troubleshooting each
|
||||||
|
start with a copy-paste prompt for Claude Code / Cursor / Copilot, so users can drive
|
||||||
|
install / setup / upgrade through their existing AI assistant.
|
||||||
|
|
||||||
|
### Behavior changes
|
||||||
|
|
||||||
|
- `ocp update` may take 10–30s longer when a cross-minor jump triggers the full path
|
||||||
|
(snapshot + post-flight). Patch bumps are unchanged.
|
||||||
|
- Pre-v3.4.0 installs are routed to fresh-install rather than failing silently or
|
||||||
|
half-migrating.
|
||||||
|
|
||||||
|
### Governance
|
||||||
|
|
||||||
|
- No `cli.js` citation needed (no `server.mjs` change). ALIGNMENT.md Rule 2 not engaged.
|
||||||
|
- Depends on PR #90 (plist env merge bug fix; merged before this release).
|
||||||
|
|
||||||
|
## v3.14.0 — 2026-05-10
|
||||||
|
|
||||||
|
### Features (security hardening)
|
||||||
|
|
||||||
|
- **Per-key session isolation** (PR #86, S1) — the `sessions` Map in `server.mjs` is now keyed by `${keyName}|${conversationId}` instead of bare `conversationId`. Before this fix, two clients using distinct API keys but the same `session_id` value (e.g. both defaulting to `"default"`) would share the same `cli.js` subprocess and conversation history, creating a cross-tenant leak path. Post-fix each (key, session) pair is isolated end-to-end, extending the per-key cache isolation shipped in v3.13.0 D1 to the session layer.
|
||||||
|
- **On-disk credential file modes 0700/0600** (PR #87, S2) — `setup.mjs` now creates `~/.ocp` at mode 0700 and both `admin-key` and `ocp.db` at mode 0600. An idempotent `reconcileFileModes()` call in `server.mjs` startup tightens any existing installation to these modes automatically on every launch, so existing prod boxes fix themselves without manual `chmod`. Before this fix, all three files were created at the process's default umask (typically world-readable 0644 / 0755), leaving plaintext credentials readable by other local users.
|
||||||
|
- **`/api/usage` default scope = self; admin all-keys requires `?all=true`** (PR #88, S3) — the usage endpoint now applies a least-privilege default: anonymous callers receive only their own rows, non-admin authenticated callers receive only their own rows, and admin callers receive only their own rows unless they explicitly pass `?all=true`. When `?all=true` is used, an audit log line is emitted. Before this fix, any admin-token holder could silently enumerate usage data for every key on the server.
|
||||||
|
|
||||||
|
### Behavior changes
|
||||||
|
|
||||||
|
- **Breaking change for admin tooling**: `/api/usage` no longer returns all-keys data by default. Existing cron jobs, dashboards, or scripts that rely on the admin token seeing all-keys output must add `?all=true` to their request URL after upgrading to v3.14.0.
|
||||||
|
- **File mode reconcile at server startup** logs a one-line notice per path when mode is tightened (e.g. `[security] tightened ~/.ocp/ocp.db → 0600`). No action is required from the operator; the reconcile is idempotent and silent when modes are already correct.
|
||||||
|
- **`sessions` Map key is now `${keyName}|${conversationId}` internally.** No client-visible wire change — the `session_id` field in request/response is unchanged.
|
||||||
|
|
||||||
|
### Verification
|
||||||
|
|
||||||
|
- Stress-test pass: 11/11 phases including S1/S2/S3 security regression checks (Phase E, I, J). 35-minute sustained run, 60 calls, 0 errors, 0 timeouts. RSS dropped 51→47 MB across the window. Per-key cache isolation, singleflight, cache_control bypass, quota enforcement, file-mode reconcile, and scope guard against escalation all verified against running code.
|
||||||
|
|
||||||
|
### Governance
|
||||||
|
|
||||||
|
- All three PRs (#86, #87, #88) include the explicit `cli.js`-citation-not-applicable disclaimer (per PR #75 pattern) since they are OCP-internal access-control, session-state, and file-permission changes with no corresponding `cli.js` operation to cite.
|
||||||
|
|
||||||
|
### No new env vars / no public API surface change beyond the documented breaking change
|
||||||
|
|
||||||
|
This release adds no new env vars or endpoints. The only externally visible change is the `/api/usage` scope guard (breaking for admin all-keys consumers; see Behavior changes above).
|
||||||
|
|
||||||
|
## 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
|
||||||
|
|||||||
@@ -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
@@ -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"]
|
|
||||||
@@ -1,7 +1,13 @@
|
|||||||
# OCP — Open Claude Proxy
|
# 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.**
|
> **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.
|
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 +20,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,11 +62,27 @@ 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
|
||||||
|
|
||||||
|
The simplest path: ask your AI.
|
||||||
|
|
||||||
|
Paste this prompt to Claude Code / Cursor / Copilot:
|
||||||
|
|
||||||
|
```
|
||||||
|
Install OCP for me. Read README §Manual Installation and follow it.
|
||||||
|
Tell me when I need to run `claude auth login`.
|
||||||
|
```
|
||||||
|
|
||||||
|
The AI will run `git clone`, `npm install`, `node setup.mjs`, and tell you
|
||||||
|
when to OAuth.
|
||||||
|
|
||||||
|
### Manual 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 +99,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 5 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 5 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 5 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.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.
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
# 1. Clone and run setup
|
# 1. Clone and run setup
|
||||||
@@ -64,7 +201,8 @@ The setup script will:
|
|||||||
1. Verify Claude CLI is installed and authenticated
|
1. Verify Claude CLI is installed and authenticated
|
||||||
2. Start the proxy on port 3456
|
2. Start the proxy on port 3456
|
||||||
3. Install auto-start (launchd on macOS, systemd on Linux)
|
3. Install auto-start (launchd on macOS, systemd on Linux)
|
||||||
4. Symlink `ocp` to `/usr/local/bin` for CLI access
|
|
||||||
|
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.
|
||||||
|
|
||||||
**Single-machine use** — just set your IDE to use the proxy:
|
**Single-machine use** — just set your IDE to use the proxy:
|
||||||
```bash
|
```bash
|
||||||
@@ -79,11 +217,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
|
||||||
@@ -95,14 +235,43 @@ Run `ocp lan` to see your IP and ready-to-share instructions.
|
|||||||
**Verify:**
|
**Verify:**
|
||||||
```bash
|
```bash
|
||||||
curl http://127.0.0.1:3456/v1/models
|
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-8, 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:
|
||||||
|
|
||||||
@@ -145,7 +314,7 @@ OCP Connect v1.3.0
|
|||||||
(set by admin via PROXY_ANONYMOUS_KEY; see issue #12 §14 Path A)
|
(set by admin via PROXY_ANONYMOUS_KEY; see issue #12 §14 Path A)
|
||||||
|
|
||||||
Testing API access...
|
Testing API access...
|
||||||
✓ API accessible (4 models available)
|
✓ API accessible (5 models available)
|
||||||
|
|
||||||
Shell config:
|
Shell config:
|
||||||
✓ .bashrc
|
✓ .bashrc
|
||||||
@@ -175,6 +344,7 @@ OCP Connect v1.3.0
|
|||||||
✓ OpenClaw configured
|
✓ OpenClaw configured
|
||||||
Provider: ocp
|
Provider: ocp
|
||||||
Models:
|
Models:
|
||||||
|
• ocp/claude-opus-4-8
|
||||||
• ocp/claude-opus-4-7
|
• ocp/claude-opus-4-7
|
||||||
• ocp/claude-opus-4-6
|
• ocp/claude-opus-4-6
|
||||||
• ocp/claude-sonnet-4-6
|
• ocp/claude-sonnet-4-6
|
||||||
@@ -237,17 +407,23 @@ ocp keys revoke son-ipad # Revoke a key
|
|||||||
| `shared` | `CLAUDE_AUTH_MODE=shared` + `PROXY_API_KEY=xxx` | Everyone shares one key |
|
| `shared` | `CLAUDE_AUTH_MODE=shared` + `PROXY_API_KEY=xxx` | Everyone shares one key |
|
||||||
| `multi` | `CLAUDE_AUTH_MODE=multi` + `OCP_ADMIN_KEY=xxx` | Per-person keys with usage tracking (recommended) |
|
| `multi` | `CLAUDE_AUTH_MODE=multi` + `OCP_ADMIN_KEY=xxx` | Per-person keys with usage tracking (recommended) |
|
||||||
|
|
||||||
|
> **Usage scope (v3.14.0+):** `/api/usage` returns the caller's own rows by default. Admin callers must pass `?all=true` to retrieve data for all keys; doing so emits an audit log line.
|
||||||
|
|
||||||
### Anonymous Access (optional)
|
### Anonymous Access (optional)
|
||||||
|
|
||||||
In `multi` mode, the admin can designate a single well-known "anonymous" key that bypasses `validateKey()` and grants public read/write access. This is useful for letting LAN users (or clients like OpenClaw multi-agent setups) connect without individual per-user keys.
|
In `multi` mode, the admin can designate a single well-known "anonymous" key that bypasses `validateKey()` and grants public read/write access. This is useful for letting LAN users (or clients like OpenClaw multi-agent setups) connect without individual per-user keys.
|
||||||
|
|
||||||
**Enable**:
|
**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
|
```bash
|
||||||
export PROXY_ANONYMOUS_KEY=ocp_public_anon # or any string of your choice
|
export PROXY_ANONYMOUS_KEY=ocp_public_anon # or any string of your choice
|
||||||
ocp start # or however you start the server
|
node setup.mjs --bind 0.0.0.0 --auth-mode multi
|
||||||
```
|
```
|
||||||
|
|
||||||
|
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.
|
**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.
|
**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.
|
||||||
@@ -301,6 +477,7 @@ When a key exceeds its quota, OCP returns HTTP 429 with a structured error:
|
|||||||
- Keys are stored in `~/.ocp/ocp.db` (SQLite, zero external dependencies)
|
- Keys are stored in `~/.ocp/ocp.db` (SQLite, zero external dependencies)
|
||||||
- Admin key is required for key management API endpoints
|
- Admin key is required for key management API endpoints
|
||||||
- The dashboard (`/dashboard`) and health check (`/health`) are always public
|
- The dashboard (`/dashboard`) and health check (`/health`) are always public
|
||||||
|
- File modes for `~/.ocp` (0700), `admin-key` + `ocp.db` (0600) are auto-tightened at server startup as of v3.14.0
|
||||||
|
|
||||||
## Built-in Usage Monitoring
|
## Built-in Usage Monitoring
|
||||||
|
|
||||||
@@ -339,6 +516,7 @@ ocp keys List all API keys (multi mode)
|
|||||||
ocp keys add <name> Create a new API key
|
ocp keys add <name> Create a new API key
|
||||||
ocp keys revoke <name> Revoke an API key
|
ocp keys revoke <name> Revoke an API key
|
||||||
ocp connect <ip> One-command LAN client setup
|
ocp connect <ip> One-command LAN client setup
|
||||||
|
ocp doctor Health & upgrade-readiness check; primary entry for AI-driven debugging. --json produces a next_action for AI agents.
|
||||||
ocp lan Show LAN connection info & IP
|
ocp lan Show LAN connection info & IP
|
||||||
ocp settings View tunable settings
|
ocp settings View tunable settings
|
||||||
ocp settings <k> <v> Update a setting at runtime
|
ocp settings <k> <v> Update a setting at runtime
|
||||||
@@ -365,17 +543,57 @@ ocp --help
|
|||||||
|
|
||||||
> **Cloud/Linux servers:** If `ocp: command not found`, the binary isn't in PATH. Full path: `~/.openclaw/projects/ocp/ocp`
|
> **Cloud/Linux servers:** If `ocp: command not found`, the binary isn't in PATH. Full path: `~/.openclaw/projects/ocp/ocp`
|
||||||
|
|
||||||
### Self-Update
|
## Upgrading
|
||||||
|
|
||||||
|
The simplest path: ask your AI.
|
||||||
|
|
||||||
|
Paste this prompt:
|
||||||
|
|
||||||
|
```
|
||||||
|
Upgrade my OCP. Run `ocp update` and follow whatever it says.
|
||||||
|
If it tells me to run `claude auth login`, I'll do that.
|
||||||
|
```
|
||||||
|
|
||||||
|
What `ocp update` does:
|
||||||
|
|
||||||
|
- **Patch bump** (e.g. `v3.14.0 → v3.14.1`):
|
||||||
|
light path (git pull + npm install + restart).
|
||||||
|
- **Cross-minor** (e.g. `v3.10 → v3.14`):
|
||||||
|
full path: pre-flight check, snapshot, `setup.mjs` (with plist env-merge),
|
||||||
|
service restart, post-flight `/health` and `/v1/models` verification.
|
||||||
|
- **Old version** (< v3.4.0):
|
||||||
|
fresh-install. Pre-v3.4 lacked admin-key/usage-db, so there is nothing to
|
||||||
|
migrate. Your OAuth token (managed by the Claude Code CLI, not OCP) is
|
||||||
|
preserved; you do not need to re-OAuth unless your token expired
|
||||||
|
separately.
|
||||||
|
|
||||||
|
Snapshots are saved to `~/.ocp/upgrade-snapshot-<ISO-ts>/` and never
|
||||||
|
auto-deleted. Clean old ones with `rm -rf ~/.ocp/upgrade-snapshot-*` once
|
||||||
|
you're confident the upgrade is stable.
|
||||||
|
|
||||||
|
### Manual upgrade — same command, no AI
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
# Check if a new version is available
|
ocp update # smart-pick path
|
||||||
ocp update --check
|
ocp update --check # show available updates, don't apply
|
||||||
|
ocp update --dry-run # preview plan
|
||||||
# Pull latest, sync plugin, restart proxy — one command
|
ocp update --target v3.13.0 # pin a specific version
|
||||||
ocp update
|
ocp update --rollback --yes # restore most recent snapshot (--yes confirms)
|
||||||
|
ocp update --rollback --list # list snapshots, no mutation
|
||||||
|
ocp update --rollback --dry-run # preview rollback plan
|
||||||
```
|
```
|
||||||
|
|
||||||
`ocp update` runs (in order): `git pull` → `npm install` → plugin sync → **OpenClaw model registry sync** (v3.11.0+) → proxy restart → health check.
|
### When upgrade fails
|
||||||
|
|
||||||
|
`ocp update` prints a recovery line on failure. To restore from the snapshot:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
ocp update --rollback --yes # --yes confirms the destructive restore
|
||||||
|
ocp doctor
|
||||||
|
```
|
||||||
|
|
||||||
|
If `ocp doctor` still reports problems after rollback, open a GitHub issue
|
||||||
|
with the snapshot path and the doctor JSON output (`ocp doctor --json`).
|
||||||
|
|
||||||
### OpenClaw Auto-Sync (v3.11.0+)
|
### OpenClaw Auto-Sync (v3.11.0+)
|
||||||
|
|
||||||
@@ -428,17 +646,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,20 +670,23 @@ 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
|
||||||
|
|
||||||
```
|
```
|
||||||
Your IDE → OCP (localhost:3456) → claude -p CLI → Anthropic (via subscription)
|
Your IDE → OCP (localhost:3456) → claude --output-format stream-json CLI → Anthropic (via subscription)
|
||||||
```
|
```
|
||||||
|
|
||||||
OCP translates OpenAI-compatible `/v1/chat/completions` requests into `claude -p` CLI calls. Anthropic sees normal Claude Code usage — no API billing, no separate key needed.
|
OCP translates OpenAI-compatible `/v1/chat/completions` requests into `claude --output-format stream-json` CLI calls. Anthropic sees normal Claude Code usage — no API billing, no separate key needed.
|
||||||
|
|
||||||
## Available Models
|
## Available Models
|
||||||
|
|
||||||
| Model ID | Notes |
|
| Model ID | Notes |
|
||||||
|----------|-------|
|
|----------|-------|
|
||||||
| `claude-opus-4-7` | Most capable (default for `opus` alias) |
|
| `claude-opus-4-8` | Most capable (default for `opus` alias) |
|
||||||
| `claude-opus-4-6` | Previous Opus, retained for pinning |
|
| `claude-opus-4-7` | Previous Opus, retained for pinning |
|
||||||
|
| `claude-opus-4-6` | Older Opus, retained for pinning |
|
||||||
| `claude-sonnet-4-6` | Good balance of speed/quality (default for `sonnet` alias) |
|
| `claude-sonnet-4-6` | Good balance of speed/quality (default for `sonnet` alias) |
|
||||||
| `claude-haiku-4-5-20251001` | Fastest, lightweight (default for `haiku` alias) |
|
| `claude-haiku-4-5-20251001` | Fastest, lightweight (default for `haiku` alias) |
|
||||||
|
|
||||||
@@ -493,7 +717,7 @@ The canonical list lives in [`models.json`](./models.json) — the single source
|
|||||||
| `/api/keys` | GET/POST | List or create API keys (admin only) |
|
| `/api/keys` | GET/POST | List or create API keys (admin only) |
|
||||||
| `/api/keys/:id` | DELETE | Revoke an API key (admin only) |
|
| `/api/keys/:id` | DELETE | Revoke an API key (admin only) |
|
||||||
| `/api/keys/:id/quota` | GET/PATCH | View or set per-key quota (admin only) |
|
| `/api/keys/:id/quota` | GET/PATCH | View or set per-key quota (admin only) |
|
||||||
| `/api/usage` | GET | Per-key usage stats (`?since=&until=&hours=&limit=`) |
|
| `/api/usage` | GET | Per-key usage stats (`?since=&until=&hours=&limit=`); returns self only by default — pass `?all=true` (admin only) for all-keys data |
|
||||||
| `/cache/stats` | GET | Cache statistics (admin only) |
|
| `/cache/stats` | GET | Cache statistics (admin only) |
|
||||||
| `/cache` | DELETE | Clear response cache (admin only) |
|
| `/cache` | DELETE | Clear response cache (admin only) |
|
||||||
|
|
||||||
@@ -543,6 +767,52 @@ After installing the gateway plugin, use `/ocp` slash commands in your chat:
|
|||||||
|
|
||||||
## Troubleshooting
|
## Troubleshooting
|
||||||
|
|
||||||
|
The simplest path: ask your AI.
|
||||||
|
|
||||||
|
Paste this prompt:
|
||||||
|
|
||||||
|
```
|
||||||
|
Run `ocp doctor` and follow its `next_action`. Tell me if you hit
|
||||||
|
anything that needs human input.
|
||||||
|
```
|
||||||
|
|
||||||
|
The doctor produces a JSON `next_action` with `ai_executable[]` (commands
|
||||||
|
the agent runs verbatim) and `human_required[]` (steps that need you,
|
||||||
|
typically just OAuth).
|
||||||
|
|
||||||
|
### Manual debugging
|
||||||
|
|
||||||
|
### 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
|
||||||
@@ -587,12 +857,14 @@ Future `ocp update` invocations sync automatically.
|
|||||||
|
|
||||||
| Variable | Default | Description |
|
| Variable | Default | Description |
|
||||||
|----------|---------|-------------|
|
|----------|---------|-------------|
|
||||||
| `CLAUDE_PROXY_PORT` | `3456` | Listen port |
|
| `CLAUDE_PROXY_PORT` | `3456` | Listen port (server-side). Also consumed by the OpenClaw `ocp-plugin` to dial the local proxy. |
|
||||||
|
| `OCP_PROXY_URL` | *(unset)* | Plugin-side full URL override (e.g. `http://10.0.0.5:3456`). Wins over `CLAUDE_PROXY_PORT` when both are set. Read by `ocp-plugin/index.js` only — server ignores it. |
|
||||||
| `CLAUDE_BIND` | `127.0.0.1` | Bind address (`0.0.0.0` for LAN access) |
|
| `CLAUDE_BIND` | `127.0.0.1` | Bind address (`0.0.0.0` for LAN access) |
|
||||||
| `CLAUDE_AUTH_MODE` | `none` | Auth mode: `none`, `shared`, or `multi` |
|
| `CLAUDE_AUTH_MODE` | `none` | Auth mode: `none`, `shared`, or `multi` |
|
||||||
| `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 +875,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 30s–5min, 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 +916,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).
|
||||||
|
|||||||
+21
-2
@@ -55,7 +55,13 @@
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="section">
|
<div class="section">
|
||||||
<h2>Usage by Key</h2>
|
<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>
|
||||||
<table id="key-usage-table">
|
<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>
|
<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>
|
<tbody></tbody>
|
||||||
@@ -170,7 +176,8 @@ async function refreshStatus() {
|
|||||||
|
|
||||||
async function refreshUsage() {
|
async function refreshUsage() {
|
||||||
try {
|
try {
|
||||||
const data = await api("/api/usage");
|
const showAll = localStorage.getItem("ocp_usage_show_all") === "1";
|
||||||
|
const data = await api(showAll ? "/api/usage?all=true" : "/api/usage");
|
||||||
const tbody = document.querySelector("#key-usage-table tbody");
|
const tbody = document.querySelector("#key-usage-table tbody");
|
||||||
tbody.innerHTML = (data.byKey || []).map(k => `
|
tbody.innerHTML = (data.byKey || []).map(k => `
|
||||||
<tr>
|
<tr>
|
||||||
@@ -204,6 +211,8 @@ async function refreshKeys() {
|
|||||||
try {
|
try {
|
||||||
const data = await api("/api/keys");
|
const data = await api("/api/keys");
|
||||||
document.getElementById("key-mgmt-section").style.display = "";
|
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");
|
const tbody = document.querySelector("#keys-table tbody");
|
||||||
tbody.innerHTML = (data.keys || []).map(k => `
|
tbody.innerHTML = (data.keys || []).map(k => `
|
||||||
<tr>
|
<tr>
|
||||||
@@ -240,6 +249,16 @@ async function refreshAll() {
|
|||||||
document.getElementById("refresh-indicator").textContent = `Updated ${new Date().toLocaleTimeString()}`;
|
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();
|
refreshAll();
|
||||||
setInterval(refreshAll, 30000);
|
setInterval(refreshAll, 30000);
|
||||||
</script>
|
</script>
|
||||||
|
|||||||
@@ -1,7 +0,0 @@
|
|||||||
services:
|
|
||||||
claude-proxy:
|
|
||||||
build: .
|
|
||||||
ports:
|
|
||||||
- "3456:3456"
|
|
||||||
env_file: .env
|
|
||||||
restart: unless-stopped
|
|
||||||
@@ -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.
|
||||||
|
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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.
|
||||||
@@ -0,0 +1,132 @@
|
|||||||
|
# 0006 — OpenAI Shim Scope: Class A vs Class B Endpoints
|
||||||
|
|
||||||
|
- **Date**: 2026-05-20
|
||||||
|
- **Status**: Proposed — owner reviewing
|
||||||
|
- **Authors**: project maintainer (with AI drafting assistance)
|
||||||
|
- **Related**: `ALIGNMENT.md` (the constitution); ADR 0002 (Alignment Constitution provenance, PR #20, commit 2853088); PR #99 by external contributor (triggering incident — OpenAI `response_format` honoring on `/v1/chat/completions`)
|
||||||
|
|
||||||
|
## Context
|
||||||
|
|
||||||
|
`ALIGNMENT.md` was drafted in the aftermath of the 2026-04-11 drift (commit `b87992f` — fabricated `/api/oauth/usage` endpoint) and ratified in PR #20 / commit 2853088. Its five Rules are written in the language of a one-to-one proxy: Rule 1 (Grep First), Rule 2 (No Invention), Rule 3 (Match the Implementation), Rule 4 (Unalignable Features Are Deleted), Rule 5 (Cite Line Numbers in Commits). All five anchor explicitly on `cli.js` as the golden reference. This is correct and binding for the endpoints OCP was originally designed to forward — `/v1/messages`, `/api/oauth/*`, and the rate-limit-header extraction path that backs `/usage` — because for those, `cli.js` is the literal wire authority and any deviation is a drift risk.
|
||||||
|
|
||||||
|
OCP also exposes a second class of endpoint that the constitution does not currently distinguish: **OpenAI-compatible surface** that exists so non-Claude-Code clients (Honcho, OpenWebUI, OpenAI SDK consumers, BYO scripts) can talk to claude via OCP. The flagship is `/v1/chat/completions`, which translates between OpenAI's request/response schema and `cli.js`'s native protocol. `cli.js` never speaks OpenAI's wire format — by construction it cannot, because OpenAI and Anthropic are different vendors with different protocols. There is no `cli.js:NNNN` to cite for OpenAI's `messages[].role` field handling, OpenAI's streaming `delta` shape, OpenAI's `stop` event names, or OpenAI's `response_format` parameter. The protocol authority for these is OpenAI's published specification, not `cli.js`.
|
||||||
|
|
||||||
|
The structural gap surfaced when PR #99 (external contributor `jaekwon-park`) added support for the OpenAI `response_format` request field on `/v1/chat/completions`. A strict reading of Rule 2 ("OCP must not introduce request fields that are not present in `cli.js`") blocks the PR. But the same strict reading also blocks the existence of `/v1/chat/completions` itself — every OpenAI-shaped field on that endpoint is, by definition, not in `cli.js`. The endpoint has been in OCP since before the constitution was written and is used by real downstream consumers. The constitution and the endpoint cannot both be correct under the current reading.
|
||||||
|
|
||||||
|
The 2026-04-11 drift remains the cautionary tale that drove the constitution and remains binding. The drift was not "OCP exposed an endpoint that wasn't in `cli.js`" — it was specifically "OCP claimed to forward `cli.js`'s `/api/oauth/usage` call when no such call exists in `cli.js`." That is a Class A failure mode: a forwarding endpoint that lied about what it was forwarding. The fix to that failure mode (Rules 1, 2, 3, 5; CI blacklist; reviewer gate) was correct then and is correct now. This ADR does not relitigate that decision and does not soften Rules 1–5 for the class of endpoint they were designed to discipline.
|
||||||
|
|
||||||
|
What this ADR does is acknowledge that OCP has two classes of endpoint, and that the discipline that fits Class A does not fit Class B without distortion. Class B needs its own anchor (OpenAI's specification) and its own authorization gate (an ADR per endpoint), so contributors know exactly which rule set applies to their PR and so Class B never becomes a backdoor for "OCP can do anything OpenAI-shaped."
|
||||||
|
|
||||||
|
## Decision
|
||||||
|
|
||||||
|
Introduce an explicit two-class taxonomy of OCP endpoints:
|
||||||
|
|
||||||
|
- **Class A — `cli.js`-mirror endpoints.** Endpoints that exist because `cli.js` performs the equivalent operation and OCP forwards, observes, or multiplexes that operation. Rules 1–5 of `ALIGNMENT.md` apply verbatim. The citation requirement is `cli.js:NNNN` (or `cli.js vE4 <functionName>`).
|
||||||
|
|
||||||
|
- **Class B — OCP-owned compatibility endpoints.** Endpoints that exist because OCP itself surfaces them, with no `cli.js` analogue. They fall into two sub-buckets:
|
||||||
|
- **B.1 — OpenAI-compatibility surface.** Endpoints implementing OpenAI's published API contract so non-Anthropic clients can use OCP. The protocol authority is OpenAI's specification.
|
||||||
|
- **B.2 — OCP-administrative surface.** Endpoints that exist purely to operate the proxy itself (health, dashboard, key management, cache control). The authority for these is the ADR that authorized the endpoint's existence.
|
||||||
|
|
||||||
|
For Class B endpoints, the citation requirement shifts from `cli.js:NNNN` to **(a)** the relevant specification section (OpenAI spec section for B.1, or the authorizing ADR for B.2) **and (b)** the ADR that authorized the endpoint's existence in the first place.
|
||||||
|
|
||||||
|
### Grandfather provision for existing B.2 inventory
|
||||||
|
|
||||||
|
ADR 0006 retroactively authorizes the existing B.2 endpoints listed in the inventory table below, **frozen at their current behaviour as of v3.16.4**. This is a one-time grandfather provision intended to avoid a 12-ADR back-fill burden for endpoints that have existed in OCP since before any constitutional governance was written.
|
||||||
|
|
||||||
|
The grandfather provision is narrowly scoped:
|
||||||
|
|
||||||
|
- It covers only the B.2 endpoints enumerated in the inventory table as of this ADR's merge date.
|
||||||
|
- It freezes those endpoints at their **current behaviour**. Any change to the request shape, response shape, or semantics of a grandfathered B.2 endpoint is treated as a new authorization request and requires either (a) a behaviour-preserving refactor PR with no contract change, or (b) its own ADR.
|
||||||
|
- It does **not** authorize new B.2 endpoints. Any new B.2 endpoint, or any new method on a grandfathered B.2 endpoint, requires its own ADR before merge.
|
||||||
|
- It does **not** extend to B.1 (OpenAI-compat) endpoints. B.1 endpoints are bounded by OpenAI's published specification, not by a behaviour snapshot — there is no grandfather equivalent for them.
|
||||||
|
|
||||||
|
The structural intent is: take the one-time hit of declaring "current B.2 surface is authorized" cleanly, then make every future addition pay the ADR-per-endpoint cost. This prevents Class B from becoming a backdoor for general OCP-owned-surface invention while not blocking the present ADR on twelve back-fill PRs.
|
||||||
|
|
||||||
|
### Current Class B inventory (enumerated from `server.mjs`)
|
||||||
|
|
||||||
|
The following endpoints exist today in `server.mjs` and are Class B (no `cli.js` analogue):
|
||||||
|
|
||||||
|
| Endpoint | Method | Sub-bucket | Authorizing ADR |
|
||||||
|
|---|---|---|---|
|
||||||
|
| `/v1/chat/completions` | POST | B.1 (OpenAI-compat) | ADR 0006 |
|
||||||
|
| `/v1/models` | GET | B.1 (OpenAI-compat) | ADR 0006; content sourced from `models.json` per ADR 0003 |
|
||||||
|
| `/health` | GET | B.2 (administrative) | ADR 0006 (grandfathered as of v3.16.4) |
|
||||||
|
| `/dashboard` | GET | B.2 (administrative) | ADR 0006 (grandfathered as of v3.16.4) |
|
||||||
|
| `/sessions` | GET, DELETE | B.2 (administrative) | ADR 0006 (grandfathered as of v3.16.4) |
|
||||||
|
| `/logs` | GET | B.2 (administrative) | ADR 0006 (grandfathered as of v3.16.4) |
|
||||||
|
| `/status` | GET | B.2 (administrative) | ADR 0006 (grandfathered as of v3.16.4) |
|
||||||
|
| `/settings` | GET, PATCH | B.2 (administrative) | ADR 0006 (grandfathered as of v3.16.4) |
|
||||||
|
| `/api/keys` | GET, POST | B.2 (administrative) | ADR 0006 (grandfathered as of v3.16.4) |
|
||||||
|
| `/api/keys/:id` | DELETE | B.2 (administrative) | ADR 0006 (grandfathered as of v3.16.4) |
|
||||||
|
| `/api/keys/:id/quota` | GET, PATCH | B.2 (administrative) | ADR 0006 (grandfathered as of v3.16.4) |
|
||||||
|
| `/api/usage` | GET | B.2 (administrative) | ADR 0006 (grandfathered as of v3.16.4) |
|
||||||
|
| `/cache/stats` | GET | B.2 (administrative) | ADR 0006 (grandfathered as of v3.16.4) |
|
||||||
|
| `/cache` | DELETE | B.2 (administrative) | ADR 0006 (grandfathered as of v3.16.4) |
|
||||||
|
|
||||||
|
For Class A reference, the current Class A inventory is `/v1/messages` (forwarded directly to `api.anthropic.com/v1/messages`) and the OAuth bearer / rate-limit-header machinery used by `handleUsage()` (which calls `https://api.anthropic.com/v1/messages` to extract `anthropic-ratelimit-unified-*` headers, per the in-file comment at line 845–849). The `GET /usage` endpoint surface itself is Class B (administrative augmentation: it adds `proxy:` and `models:` blocks not present in any upstream API), but the data fetch underlying it is Class A — see "Hybrid endpoints" below.
|
||||||
|
|
||||||
|
### Hybrid endpoints
|
||||||
|
|
||||||
|
`/usage` is a hybrid: the wire call out to `api.anthropic.com/v1/messages` is Class A and must continue to cite `cli.js`; the local synthesis on top (`proxy:` stats block, `models:` snapshot, response shape) is Class B and is authorized by this ADR. Any future change strictly to the wire-call layer is Class A; any change strictly to the synthesis layer is Class B. A PR touching both must satisfy both citation requirements.
|
||||||
|
|
||||||
|
## What does NOT change
|
||||||
|
|
||||||
|
The following continue to apply verbatim and are not weakened by this ADR:
|
||||||
|
|
||||||
|
- **Rules 1, 2, 3, 4, 5 of `ALIGNMENT.md`** for all Class A endpoints. The 2026-04-11 drift discipline is unchanged. Class A PRs still require `cli.js:NNNN` citations, still must match `cli.js`'s wire format byte-for-byte, and still face the Unalignable Policy if the citation cannot be produced.
|
||||||
|
- **CI blacklist** (`.github/workflows/alignment.yml`). The known-hallucinated token list (currently `api.anthropic.com/api/oauth/usage`) continues to be greppable-and-failable on every PR.
|
||||||
|
- **Reviewer gate** (CLAUDE.md hard requirements + Iron Rule 10). Implementation author may not self-approve; a fresh-context reviewer opens `cli.js` at the cited lines for Class A PRs.
|
||||||
|
- **Annual Alignment Audit** on 11 April. The Class A audit (re-verify each `server.mjs` Class A reference against the pinned `cli.js` SHA-256) continues unchanged.
|
||||||
|
- **Unalignable Policy.** A Class A endpoint that cannot be traced to a `cli.js` reference is still deleted, not deprecated.
|
||||||
|
- **Historical Lesson section in `ALIGNMENT.md`.** The 2026-04-11 drift remains the named cautionary incident, with commit SHAs intact.
|
||||||
|
|
||||||
|
## What additionally applies to Class B
|
||||||
|
|
||||||
|
The following are new and apply only to Class B endpoints:
|
||||||
|
|
||||||
|
1. **OpenAI specification as protocol authority (B.1).** The OpenAI compatibility surface follows OpenAI's published `/v1/chat/completions` specification (https://platform.openai.com/docs/api-reference/chat/create) — not OCP imagination, not "OpenAI probably does X," not generalization from adjacent OpenAI endpoints. The same anti-invention discipline that Rule 2 imposes for `cli.js` applies, with OpenAI's spec substituted as the reference.
|
||||||
|
|
||||||
|
2. **ADR-authorized endpoint existence.** Any new Class B endpoint, or any new Class B endpoint method, requires its own ADR before merge. The grandfather provision above covers existing B.2 inventory only. An "ADR-less" Class B endpoint added after this ADR merges is itself an alignment finding and is subject to deletion under a Class B equivalent of the Unalignable Policy (see Rule 4 mapping in `ALIGNMENT.md`'s new section).
|
||||||
|
|
||||||
|
3. **Class B citation format.** Class B PRs cite (a) the relevant specification section and (b) the authorizing ADR. Example for B.1: "OpenAI `chat/completions` API, `response_format` parameter (https://platform.openai.com/docs/api-reference/chat/create), authorized by ADR 0006." Example for B.2: "Authorized by ADR 0006 (grandfathered)" for grandfathered endpoints, or "Authorized by ADR 00NN" for endpoints with their own ADR.
|
||||||
|
|
||||||
|
4. **Class B audit cadence.** Class B endpoints are audited annually alongside the Class A audit. B.1 endpoints are audited against OpenAI's current `/v1/chat/completions` specification snapshot. B.2 endpoints (grandfathered or ADR-specific) are audited against their authorizing ADR — for grandfathered endpoints, the audit verifies the endpoint behaviour still matches its v3.16.4 snapshot; for ADR-specific endpoints, the audit verifies behaviour still matches the ADR. The B.1 specification pin lives in `docs/openai-compat-pin.md` (to be created alongside the first B.1 audit; not a prerequisite for this ADR to land).
|
||||||
|
|
||||||
|
5. **Reviewer expectation.** The fresh-context reviewer for a Class B PR opens the cited OpenAI spec section (B.1) or the authorizing ADR (B.2) instead of opening `cli.js`. The "I am not the commit author" rule and the "explicit approval comment naming the verified reference" rule continue.
|
||||||
|
|
||||||
|
## Consequences
|
||||||
|
|
||||||
|
**Positive**
|
||||||
|
|
||||||
|
- PR #99 becomes mergeable with a one-line scope declaration ("Class B — extends `/v1/chat/completions` per ADR 0006") plus the existing alignment-evidence section adapted to Class B citation format. The structural ambiguity that blocked it is removed.
|
||||||
|
- Future Class B contributors have a clear template and a defensible scope: "extend `/v1/chat/completions` for an OpenAI-spec field that's already in OpenAI's spec" is a well-formed PR; "add a new OCP-invented field that looks OpenAI-shaped" is not, and the same anti-invention discipline that protects Class A protects Class B.
|
||||||
|
- The Class A surface is structurally unchanged. Reviewers reading the new `ALIGNMENT.md` see a clean Class A regime with all five Rules intact, plus an enumerated and explicitly scoped Class B carve-out.
|
||||||
|
- The administrative endpoint surface (B.2) is no longer in a "is this even allowed under the constitution?" limbo. The grandfather provision cleanly authorizes the current inventory; new B.2 endpoints must earn their ADR.
|
||||||
|
|
||||||
|
**Negative**
|
||||||
|
|
||||||
|
- OCP now maintains a second alignment surface. OpenAI's `/v1/chat/completions` specification is also a moving target (OpenAI ships changes more than once per year, including breaking ones), so the B.1 audit has real work attached.
|
||||||
|
- The grandfather provision freezes the current B.2 behaviour. If a grandfathered B.2 endpoint has a latent bug or undesirable behaviour, "fixing" it is a contract change and now requires an ADR (or a behaviour-preserving refactor). This is intentional friction to prevent silent contract drift.
|
||||||
|
- Contributors must now choose Class A or Class B on every PR. Some will misclassify. The PR template's required Class A/B radio (see PR template update) and the reviewer's spec-or-cli verification step are the structural counter-measures.
|
||||||
|
|
||||||
|
**Mitigations**
|
||||||
|
|
||||||
|
- The Class B inventory is small (currently 14 endpoints) and is enumerated explicitly in `ALIGNMENT.md`. New entries require an ADR per item 2 above, so the inventory cannot grow silently.
|
||||||
|
- Anthropic-side change frequency (which drives Class A audit cost) is structurally higher than OpenAI's `chat/completions` shape, which has been stable across multiple OpenAI API versions. The marginal B.1 audit cost is low. The grandfathered B.2 audit cost is also low — most of those endpoints have not changed in months.
|
||||||
|
- The B.1 specification pin in `docs/openai-compat-pin.md` lets the audit anchor on a specific OpenAI spec snapshot, the same way the Class A pin anchors on a specific `cli.js` SHA-256. Drift detection then works the same way for both classes.
|
||||||
|
|
||||||
|
## Historical Lesson — explicit non-relitigation
|
||||||
|
|
||||||
|
This ADR does not relitigate the 2026-04-11 drift. The drift commit `b87992f` was Class A — it claimed `cli.js` forwarded a call that `cli.js` did not in fact make. The fix (constitution + CI blacklist + reviewer gate) was correct and remains binding for Class A. This ADR carves out Class B because the discipline that fits Class A does not fit a class of endpoint where `cli.js` is not the wire authority — not because the discipline was wrong, and not because the drift lesson is any less load-bearing.
|
||||||
|
|
||||||
|
A reviewer or future maintainer reading this ADR should not infer: "OCP relaxed its alignment rules." The Class A regime is structurally identical to the version that shipped in PR #20. What changed is that the constitution now names the scope of that regime precisely (the class of endpoint for which `cli.js` is the wire authority) instead of implicitly applying it to every endpoint, including ones the regime was never designed for.
|
||||||
|
|
||||||
|
## Alternatives considered
|
||||||
|
|
||||||
|
**(a) Refuse Class B as a category; close PR #99 and delete `/v1/chat/completions`.** This would resolve the structural ambiguity by enforcing Rule 2 maximally — if `cli.js` doesn't speak OpenAI's protocol, neither does OCP. Rejected: there is an existing user base on the OpenAI-compat surface, the surface is genuinely useful (it is OCP's bridge to non-Claude-Code agents), and deletion would be a load-bearing user-facing breakage in service of a doctrinal point that the constitution was never designed to make. The constitution was a response to the 2026-04-11 forwarding drift, not a charter against any OCP-owned surface.
|
||||||
|
|
||||||
|
**(b) Soften Rule 2 to "OCP must not introduce surface area not present in `cli.js` OR not authorized by an ADR."** This is the obvious diff and would unblock PR #99 with the smallest possible textual change. Rejected because it loses the precision that Class A needs. A combined Rule 2 means a Class A reviewer has to read the PR description twice to figure out which authority applies. The Class A/B split makes the question explicit at the PR-template level (the author picks the class) and at the reviewer level (the reviewer opens the appropriate reference). The cost of the split is one new section in `ALIGNMENT.md`; the benefit is no ambiguity in either class.
|
||||||
|
|
||||||
|
**(c) Move `/v1/chat/completions` and all OpenAI-compat surface out of OCP into a separate "ocp-openai-shim" repository.** This would cleanly resolve the scope question by moving Class B out of OCP entirely. Rejected as premature: the maintainer is one person, the OpenAI-compat surface today is a single endpoint plus its support, and the operational cost of two repositories (separate releases, separate CI, separate version coordination) exceeds the cost of one constitution with two named classes. If the OpenAI-compat surface ever grows to the size where a separate repo is justified, ADR 0006 is the natural pivot point — at that future date, the carve-out becomes a separation.
|
||||||
|
|
||||||
|
**(d) Twelve-ADR back-fill for the existing B.2 inventory before this ADR can merge.** Considered and rejected on cost grounds. Each back-fill ADR would be a short paragraph explaining what an existing endpoint does and why it's allowed; the educational value is low and the merge friction is high (12 PRs through the reviewer gate). The grandfather provision above achieves the same authorization outcome in one paragraph, while still requiring an ADR for any future B.2 endpoint. The trade-off: grandfathered endpoints are not individually documented to ADR-depth. Mitigation: the inventory table in `ALIGNMENT.md` lists every grandfathered endpoint by path and method, so the audit surface remains explicit.
|
||||||
@@ -0,0 +1,39 @@
|
|||||||
|
# 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. |
|
||||||
|
| [0006](0006-openai-shim-scope.md) | OpenAI Shim Scope | The Class A / Class B taxonomy. Class A endpoints (`cli.js`-mirror) keep Rules 1–5 verbatim; Class B endpoints (OCP-owned compatibility surface — `/v1/chat/completions`, `/v1/models`, admin endpoints) are anchored to OpenAI's spec (B.1) or to an authorizing ADR (B.2). Triggered by PR #99 (external `response_format` honoring). Grandfathers the existing B.2 inventory at v3.16.4. |
|
||||||
|
|
||||||
|
## 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.
|
||||||
Binary file not shown.
|
Before Width: | Height: | Size: 222 KiB After Width: | Height: | Size: 366 KiB |
@@ -0,0 +1,11 @@
|
|||||||
|
# OpenAI Compatibility Pin (Class B.1)
|
||||||
|
|
||||||
|
**Status:** Placeholder — populated at first B.1 audit per ADR 0006 §"Class B audit cadence".
|
||||||
|
|
||||||
|
This file is the Class B.1 counterpart to the Class A `cli.js` audit pin in `ALIGNMENT.md` §"Golden Reference". When the first annual alignment audit covers Class B (per `ALIGNMENT.md` §"Annual Alignment Audit"), this file will be populated with:
|
||||||
|
|
||||||
|
- The OpenAI `/v1/chat/completions` specification snapshot date being audited against (and the source URL the snapshot was taken from).
|
||||||
|
- The list of B.1 endpoints (currently `/v1/chat/completions`, `/v1/models`) and, for each one, the specific OpenAI spec fields and behaviours it honors.
|
||||||
|
- Drift detection notes for any OpenAI spec changes since the previous audit, and any OCP code changes required to track those changes.
|
||||||
|
|
||||||
|
Until populated, this file's existence is only a forward reference so that the link in `ALIGNMENT.md` does not 404. The actual audit procedure is defined in `ALIGNMENT.md` §"Annual Alignment Audit" (Class B scope) and ADR 0006 §"Class B audit cadence".
|
||||||
+2
-2
@@ -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 30s–5min), 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 5–10 lines over the 25–35 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:1227–1237`) 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 1227–1237
|
||||||
|
- `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).
|
||||||
@@ -3,11 +3,13 @@
|
|||||||
import { DatabaseSync } from "node:sqlite";
|
import { DatabaseSync } from "node:sqlite";
|
||||||
import { randomBytes, createHash } from "node:crypto";
|
import { randomBytes, createHash } from "node:crypto";
|
||||||
import { join } from "node:path";
|
import { join } from "node:path";
|
||||||
import { mkdirSync } from "node:fs";
|
import { mkdirSync, chmodSync } from "node:fs";
|
||||||
import { homedir } from "node:os";
|
import { homedir } from "node:os";
|
||||||
|
|
||||||
const OCP_DIR = join(homedir(), ".ocp");
|
const OCP_DIR = join(homedir(), ".ocp");
|
||||||
mkdirSync(OCP_DIR, { recursive: true });
|
mkdirSync(OCP_DIR, { recursive: true, mode: 0o700 });
|
||||||
|
// Tighten the directory mode in case it already existed with broader permissions.
|
||||||
|
try { chmodSync(OCP_DIR, 0o700); } catch { /* ignore EPERM on pre-existing dirs */ }
|
||||||
const DB_PATH = join(OCP_DIR, "ocp.db");
|
const DB_PATH = join(OCP_DIR, "ocp.db");
|
||||||
|
|
||||||
let db;
|
let db;
|
||||||
@@ -18,6 +20,8 @@ export function getDb() {
|
|||||||
db.exec("PRAGMA journal_mode = WAL");
|
db.exec("PRAGMA journal_mode = WAL");
|
||||||
db.exec("PRAGMA foreign_keys = ON");
|
db.exec("PRAGMA foreign_keys = ON");
|
||||||
initSchema();
|
initSchema();
|
||||||
|
// Tighten mode on the DB file (0600) after creation / first open.
|
||||||
|
try { chmodSync(DB_PATH, 0o600); } catch { /* ignore — same-user access still works */ }
|
||||||
}
|
}
|
||||||
return db;
|
return db;
|
||||||
}
|
}
|
||||||
@@ -292,9 +296,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 +314,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 +375,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();
|
||||||
|
|||||||
@@ -0,0 +1,33 @@
|
|||||||
|
/**
|
||||||
|
* OCP shared constants — single source of truth.
|
||||||
|
*
|
||||||
|
* Any literal that appears in more than one place across server.mjs, setup.mjs,
|
||||||
|
* scripts/* belongs here so port-drift / URL-drift cascades cannot recur.
|
||||||
|
*
|
||||||
|
* Background: from 2026-05-08 (PR #71 dogfood accident) through 2026-05-13
|
||||||
|
* (v3.16.3) a single hardcoded "3478" in scripts/upgrade.mjs + scripts/doctor.mjs
|
||||||
|
* cascaded into every downstream config write, ultimately taking out the
|
||||||
|
* OpenClaw "大内总管" Telegram agent. See CHANGELOG v3.16.2 and v3.16.3.
|
||||||
|
*
|
||||||
|
* Adding a new constant: prefer ALL_CAPS_SNAKE_CASE. Document the consumers.
|
||||||
|
* If a literal is referenced from a shell script (ocp, ocp-connect, setup.sh)
|
||||||
|
* that can't import .mjs, add a `// keep in sync with lib/constants.mjs` note
|
||||||
|
* at the shell-script reference; CI grep prevents drift.
|
||||||
|
*/
|
||||||
|
|
||||||
|
// Default TCP port the OCP HTTP proxy listens on. Set by env CLAUDE_PROXY_PORT
|
||||||
|
// at runtime; this is the fallback when env is unset.
|
||||||
|
// Consumers: server.mjs, setup.mjs, scripts/upgrade.mjs, scripts/doctor.mjs,
|
||||||
|
// scripts/sync-openclaw.mjs. Shell scripts ocp / ocp-connect keep the literal
|
||||||
|
// "3456" in sync with this value (see CI gate in .github/workflows/alignment.yml).
|
||||||
|
export const DEFAULT_PORT = 3456;
|
||||||
|
|
||||||
|
// Localhost bind for client-side fetches (curl, health checks).
|
||||||
|
export const LOCAL_HOST = "127.0.0.1";
|
||||||
|
|
||||||
|
// OpenAI-compatible API base path appended to the proxy URL.
|
||||||
|
export const OPENAI_API_BASE = "/v1";
|
||||||
|
|
||||||
|
// Convenience: full local URL the OCP proxy listens on by default.
|
||||||
|
// scripts that want to probe locally can use this directly.
|
||||||
|
export const LOCAL_PROXY_URL = `http://${LOCAL_HOST}:${DEFAULT_PORT}`;
|
||||||
@@ -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
|
||||||
|
|||||||
+9
-1
@@ -2,6 +2,14 @@
|
|||||||
"$schema": "./models.schema.json",
|
"$schema": "./models.schema.json",
|
||||||
"version": 1,
|
"version": 1,
|
||||||
"models": [
|
"models": [
|
||||||
|
{
|
||||||
|
"id": "claude-opus-4-8",
|
||||||
|
"displayName": "Claude Opus 4.8",
|
||||||
|
"openclawName": "Claude Opus 4.8 (via CLI)",
|
||||||
|
"reasoning": true,
|
||||||
|
"contextWindow": 200000,
|
||||||
|
"maxTokens": 16384
|
||||||
|
},
|
||||||
{
|
{
|
||||||
"id": "claude-opus-4-7",
|
"id": "claude-opus-4-7",
|
||||||
"displayName": "Claude Opus 4.7",
|
"displayName": "Claude Opus 4.7",
|
||||||
@@ -36,7 +44,7 @@
|
|||||||
}
|
}
|
||||||
],
|
],
|
||||||
"aliases": {
|
"aliases": {
|
||||||
"opus": "claude-opus-4-7",
|
"opus": "claude-opus-4-8",
|
||||||
"sonnet": "claude-sonnet-4-6",
|
"sonnet": "claude-sonnet-4-6",
|
||||||
"haiku": "claude-haiku-4-5-20251001"
|
"haiku": "claude-haiku-4-5-20251001"
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -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
|
||||||
@@ -695,25 +692,35 @@ for e in d.get('errors', []):
|
|||||||
# ── update ──────────────────────────────────────────────────────────────
|
# ── update ──────────────────────────────────────────────────────────────
|
||||||
cmd_update_help() {
|
cmd_update_help() {
|
||||||
cat <<'EOF'
|
cat <<'EOF'
|
||||||
ocp update — Update OCP to the latest version
|
ocp update — Smart upgrade dispatcher
|
||||||
|
|
||||||
Pulls the latest code from GitHub, restarts the proxy service,
|
Runs `ocp doctor` internally to choose the right path:
|
||||||
and optionally syncs the plugin to the OpenClaw extensions directory.
|
• Patch bump (same minor): light path (git pull + npm install + restart)
|
||||||
|
• Cross-minor (e.g. v3.10→v3.14): full path with snapshot + post-flight
|
||||||
|
• Old version (< v3.4.0): fresh-install (asks first; AI passes --yes)
|
||||||
|
|
||||||
Usage:
|
Usage:
|
||||||
ocp update Pull latest and restart
|
ocp update Smart auto-pick path
|
||||||
ocp update --check Check for updates without applying
|
ocp update --check Show available updates, don't apply
|
||||||
|
ocp update --dry-run Preview the plan, don't mutate
|
||||||
|
ocp update --target v3.13.0 Pin a specific version
|
||||||
|
ocp update --yes Skip y/N prompts (AI agents pass this)
|
||||||
|
ocp update --rollback Restore the most recent upgrade snapshot
|
||||||
|
ocp update --rollback --list List available snapshots
|
||||||
|
ocp update --rollback <path> Restore a specific snapshot
|
||||||
|
ocp update --rollback --dry-run Preview rollback plan
|
||||||
|
ocp update --rollback --gc Delete old snapshots (keep last 5, or <30 days)
|
||||||
|
ocp update --rollback --gc --dry-run Preview what would be deleted
|
||||||
EOF
|
EOF
|
||||||
}
|
}
|
||||||
|
|
||||||
cmd_update() {
|
cmd_update() {
|
||||||
local script_dir self
|
local script_dir self
|
||||||
self="${BASH_SOURCE[0]}"
|
self="${BASH_SOURCE[0]}"
|
||||||
# Resolve symlinks (e.g. ~/.local/bin/ocp → real location)
|
|
||||||
while [[ -L "$self" ]]; do self="$(readlink "$self")"; done
|
while [[ -L "$self" ]]; do self="$(readlink "$self")"; done
|
||||||
script_dir="$(cd "$(dirname "$self")" && pwd)"
|
script_dir="$(cd "$(dirname "$self")" && pwd)"
|
||||||
|
|
||||||
# Check-only mode
|
# Pass through --check fast path (existing behaviour)
|
||||||
if [[ "${1:-}" == "--check" ]]; then
|
if [[ "${1:-}" == "--check" ]]; then
|
||||||
cd "$script_dir"
|
cd "$script_dir"
|
||||||
git fetch origin main --quiet 2>/dev/null || true
|
git fetch origin main --quiet 2>/dev/null || true
|
||||||
@@ -730,71 +737,104 @@ cmd_update() {
|
|||||||
echo " Status: ✓ Up to date"
|
echo " Status: ✓ Up to date"
|
||||||
else
|
else
|
||||||
echo " Status: $behind commit(s) behind"
|
echo " Status: $behind commit(s) behind"
|
||||||
echo ""
|
|
||||||
echo " Run 'ocp update' to apply."
|
echo " Run 'ocp update' to apply."
|
||||||
fi
|
fi
|
||||||
return 0
|
return 0
|
||||||
fi
|
fi
|
||||||
|
|
||||||
echo "Updating OCP..."
|
# Rollback path
|
||||||
echo ""
|
if [[ "${1:-}" == "--rollback" ]]; then
|
||||||
|
shift
|
||||||
|
exec node "$script_dir/scripts/upgrade.mjs" --rollback "$@"
|
||||||
|
fi
|
||||||
|
|
||||||
# 1. Pull latest
|
# Doctor-driven path selection
|
||||||
|
local kind
|
||||||
|
kind=$(node "$script_dir/scripts/doctor.mjs" --json 2>/dev/null | python3 -c "import sys,json; print(json.load(sys.stdin)['next_action']['kind'])" 2>/dev/null || echo "unknown")
|
||||||
|
|
||||||
|
case "$kind" in
|
||||||
|
noop)
|
||||||
|
echo "Already at latest. Nothing to do."
|
||||||
|
return 0
|
||||||
|
;;
|
||||||
|
update)
|
||||||
|
_cmd_update_light "$script_dir"
|
||||||
|
;;
|
||||||
|
upgrade|fresh_install)
|
||||||
|
exec node "$script_dir/scripts/upgrade.mjs" "$@"
|
||||||
|
;;
|
||||||
|
fix_oauth|fix_service)
|
||||||
|
echo "Pre-upgrade check failed: $kind"
|
||||||
|
echo "Run \`ocp doctor\` for details and ai_executable steps."
|
||||||
|
return 1
|
||||||
|
;;
|
||||||
|
*)
|
||||||
|
echo "Unknown doctor kind: $kind. Run \`ocp doctor --json\` to inspect."
|
||||||
|
return 1
|
||||||
|
;;
|
||||||
|
esac
|
||||||
|
}
|
||||||
|
|
||||||
|
# Existing light-path body extracted into a helper so cmd_update can call it conditionally.
|
||||||
|
_cmd_update_light() {
|
||||||
|
local script_dir="$1"
|
||||||
|
echo "Updating OCP (light path)..."
|
||||||
cd "$script_dir"
|
cd "$script_dir"
|
||||||
local old_ver
|
local old_ver new_ver
|
||||||
old_ver=$(python3 -c "import json; print(json.load(open('package.json'))['version'])" 2>/dev/null || echo "?")
|
old_ver=$(python3 -c "import json; print(json.load(open('package.json'))['version'])" 2>/dev/null || echo "?")
|
||||||
|
|
||||||
echo " Pulling latest from GitHub..."
|
echo " Pulling latest from GitHub..."
|
||||||
if ! git pull origin main --ff-only 2>&1 | sed 's/^/ /'; then
|
if ! git pull origin main --ff-only 2>&1 | sed 's/^/ /'; then
|
||||||
echo " ✗ Git pull failed. Resolve conflicts manually in: $script_dir"
|
echo " ✗ Git pull failed. Resolve conflicts manually in: $script_dir"
|
||||||
return 1
|
return 1
|
||||||
fi
|
fi
|
||||||
|
|
||||||
local new_ver
|
|
||||||
new_ver=$(python3 -c "import json; print(json.load(open('package.json'))['version'])" 2>/dev/null || echo "?")
|
new_ver=$(python3 -c "import json; print(json.load(open('package.json'))['version'])" 2>/dev/null || echo "?")
|
||||||
|
|
||||||
if [[ "$old_ver" == "$new_ver" ]]; then
|
if [[ "$old_ver" == "$new_ver" ]]; then
|
||||||
echo " ✓ Already at latest (v$new_ver)"
|
echo " ✓ Already at latest (v$new_ver)"
|
||||||
else
|
else
|
||||||
echo " ✓ Updated v$old_ver → v$new_ver"
|
echo " ✓ Updated v$old_ver → v$new_ver"
|
||||||
fi
|
fi
|
||||||
|
|
||||||
# 2. Sync plugin to extensions dir
|
# Sync plugin (existing logic preserved)
|
||||||
local ext_dir="$HOME/.openclaw/extensions/ocp"
|
local ext_dir="$HOME/.openclaw/extensions/ocp"
|
||||||
if [[ -d "$ext_dir" && -d "$script_dir/ocp-plugin" ]]; then
|
if [[ -d "$ext_dir" && -d "$script_dir/ocp-plugin" ]]; then
|
||||||
echo ""
|
|
||||||
echo " Syncing OCP plugin..."
|
echo " Syncing OCP plugin..."
|
||||||
cp "$script_dir/ocp-plugin/index.js" "$ext_dir/index.js" 2>/dev/null
|
cp "$script_dir/ocp-plugin/index.js" "$ext_dir/index.js" 2>/dev/null
|
||||||
cp "$script_dir/ocp-plugin/package.json" "$ext_dir/package.json" 2>/dev/null
|
cp "$script_dir/ocp-plugin/package.json" "$ext_dir/package.json" 2>/dev/null
|
||||||
cp "$script_dir/ocp-plugin/openclaw.plugin.json" "$ext_dir/openclaw.plugin.json" 2>/dev/null
|
cp "$script_dir/ocp-plugin/openclaw.plugin.json" "$ext_dir/openclaw.plugin.json" 2>/dev/null
|
||||||
echo " ✓ Plugin synced to $ext_dir"
|
echo " ✓ Plugin synced"
|
||||||
fi
|
fi
|
||||||
|
|
||||||
# 3. Sync OpenClaw registry from models.json (non-fatal)
|
|
||||||
if command -v node >/dev/null 2>&1 && [[ -f "$script_dir/scripts/sync-openclaw.mjs" ]]; then
|
if command -v node >/dev/null 2>&1 && [[ -f "$script_dir/scripts/sync-openclaw.mjs" ]]; then
|
||||||
echo ""
|
|
||||||
echo " Syncing OpenClaw registry..."
|
echo " Syncing OpenClaw registry..."
|
||||||
if ! node "$script_dir/scripts/sync-openclaw.mjs" 2>&1 | sed 's/^/ /'; then
|
node "$script_dir/scripts/sync-openclaw.mjs" 2>&1 | sed 's/^/ /' || echo " ⚠ OpenClaw sync failed (non-fatal)"
|
||||||
echo " ⚠ OpenClaw sync failed (non-fatal, continuing)"
|
|
||||||
fi
|
|
||||||
fi
|
fi
|
||||||
|
|
||||||
# 4. Restart proxy
|
|
||||||
echo ""
|
|
||||||
echo " Restarting proxy..."
|
echo " Restarting proxy..."
|
||||||
cmd_restart > /dev/null 2>&1
|
cmd_restart > /dev/null 2>&1
|
||||||
sleep 2
|
}
|
||||||
|
|
||||||
if curl -sf --max-time 5 "$PROXY/health" > /dev/null 2>&1; then
|
cmd_doctor_help() {
|
||||||
local running_ver
|
cat <<'EOF'
|
||||||
running_ver=$(curl -sf --max-time 5 "$PROXY/health" | python3 -c "import sys,json; print(json.loads(sys.stdin.read())['version'])" 2>/dev/null || echo "?")
|
ocp doctor — Health & upgrade-readiness check
|
||||||
echo " ✓ Proxy running (v$running_ver)"
|
|
||||||
else
|
|
||||||
echo " ⚠ Proxy not responding — check: ocp health"
|
|
||||||
fi
|
|
||||||
|
|
||||||
echo ""
|
Runs a series of checks (Node version, git state, service health,
|
||||||
echo "Done."
|
OAuth token, plist customisation, OpenClaw provider) and emits either
|
||||||
|
human-readable PASS/WARN/FAIL output or a JSON next_action that
|
||||||
|
AI agents can execute.
|
||||||
|
|
||||||
|
Usage:
|
||||||
|
ocp doctor Human-readable output
|
||||||
|
ocp doctor --json JSON for AI agents and ocp update internal use
|
||||||
|
ocp doctor --check oauth Fast path: OAuth check only
|
||||||
|
EOF
|
||||||
|
}
|
||||||
|
|
||||||
|
cmd_doctor() {
|
||||||
|
local script_dir self
|
||||||
|
self="${BASH_SOURCE[0]}"
|
||||||
|
while [[ -L "$self" ]]; do self="$(readlink "$self")"; done
|
||||||
|
script_dir="$(cd "$(dirname "$self")" && pwd)"
|
||||||
|
exec node "$script_dir/scripts/doctor.mjs" "$@"
|
||||||
}
|
}
|
||||||
|
|
||||||
# ── help ─────────────────────────────────────────────────────────────────
|
# ── help ─────────────────────────────────────────────────────────────────
|
||||||
@@ -862,6 +902,7 @@ case "$subcmd" in
|
|||||||
lan) cmd_lan ;;
|
lan) cmd_lan ;;
|
||||||
connect) cmd_connect "$@" ;;
|
connect) cmd_connect "$@" ;;
|
||||||
restart) cmd_restart "${1:-}" ;;
|
restart) cmd_restart "${1:-}" ;;
|
||||||
update) cmd_update "${1:-}" ;;
|
doctor) cmd_doctor "$@" ;;
|
||||||
|
update) cmd_update "$@" ;;
|
||||||
*) echo "Unknown command: $subcmd"; echo ""; cmd_help; exit 1 ;;
|
*) echo "Unknown command: $subcmd"; echo ""; cmd_help; exit 1 ;;
|
||||||
esac
|
esac
|
||||||
|
|||||||
+13
-3
@@ -582,11 +582,19 @@ print(k if k else '')
|
|||||||
if [[ "${SHELL:-}" == */fish ]]; then
|
if [[ "${SHELL:-}" == */fish ]]; then
|
||||||
echo " Note: fish shell detected. Writing to ~/.bashrc — add to fish config manually."
|
echo " Note: fish shell detected. Writing to ~/.bashrc — add to fish config manually."
|
||||||
rc_files+=("$HOME/.bashrc")
|
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
|
else
|
||||||
# Always write both on macOS (default shell is zsh but some tools source bashrc)
|
# Linux / other: write to whichever rc files already exist or match current shell
|
||||||
[[ -f "$HOME/.bashrc" || "${SHELL:-}" == */bash ]] && rc_files+=("$HOME/.bashrc")
|
[[ -f "$HOME/.bashrc" || "${SHELL:-}" == */bash ]] && rc_files+=("$HOME/.bashrc")
|
||||||
[[ -f "$HOME/.zshrc" || "${SHELL:-}" == */zsh ]] && rc_files+=("$HOME/.zshrc")
|
[[ -f "$HOME/.zshrc" || "${SHELL:-}" == */zsh ]] && rc_files+=("$HOME/.zshrc")
|
||||||
# If neither exists, create for current shell
|
# If neither exists, fall back to creating one for the current shell
|
||||||
[[ ${#rc_files[@]} -eq 0 ]] && rc_files+=("$HOME/.${SHELL##*/}rc")
|
[[ ${#rc_files[@]} -eq 0 ]] && rc_files+=("$HOME/.${SHELL##*/}rc")
|
||||||
fi
|
fi
|
||||||
|
|
||||||
@@ -705,7 +713,9 @@ PYEOF
|
|||||||
|
|
||||||
echo ""
|
echo ""
|
||||||
echo " Done. Reload your shell to apply:"
|
echo " Done. Reload your shell to apply:"
|
||||||
echo " source $rc_file"
|
for rc_file in "${rc_files[@]}"; do
|
||||||
|
echo " source $rc_file"
|
||||||
|
done
|
||||||
}
|
}
|
||||||
|
|
||||||
main "$@"
|
main "$@"
|
||||||
|
|||||||
+13
-3
@@ -1,9 +1,19 @@
|
|||||||
/**
|
/**
|
||||||
* OCP Plugin — registers /ocp as a native slash command in OpenClaw gateway.
|
* OCP Plugin — registers /ocp as a native slash command in OpenClaw gateway.
|
||||||
* Calls the local claude-proxy at http://127.0.0.1:3456 and formats the response.
|
* Calls the local claude-proxy and formats the response.
|
||||||
|
*
|
||||||
|
* Port resolution (in priority order):
|
||||||
|
* 1. OCP_PROXY_URL env (full URL, e.g. http://10.0.0.5:3456)
|
||||||
|
* 2. CLAUDE_PROXY_PORT env (port only; localhost assumed)
|
||||||
|
* 3. Fallback: http://127.0.0.1:3456 (OCP server source default since v1.0)
|
||||||
|
*
|
||||||
|
* If a particular host's OCP plist injects a non-default CLAUDE_PROXY_PORT,
|
||||||
|
* the OpenClaw launchd plist for that host must also inject the same
|
||||||
|
* CLAUDE_PROXY_PORT into the plugin's env, or the plugin will fall back to
|
||||||
|
* 3456 and miss the server.
|
||||||
*/
|
*/
|
||||||
|
const PROXY = process.env.OCP_PROXY_URL
|
||||||
const PROXY = "http://127.0.0.1:3456";
|
|| (process.env.CLAUDE_PROXY_PORT ? `http://127.0.0.1:${process.env.CLAUDE_PROXY_PORT}` : "http://127.0.0.1:3456");
|
||||||
|
|
||||||
// Wrap output in monospace code block for Telegram/Discord alignment
|
// Wrap output in monospace code block for Telegram/Discord alignment
|
||||||
function mono(text) { return "```\n" + text + "\n```"; }
|
function mono(text) { return "```\n" + text + "\n```"; }
|
||||||
|
|||||||
@@ -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.16.2",
|
||||||
"configSchema": {
|
"configSchema": {
|
||||||
"type": "object",
|
"type": "object",
|
||||||
"additionalProperties": false,
|
"additionalProperties": false,
|
||||||
@@ -10,7 +10,7 @@
|
|||||||
"proxyUrl": {
|
"proxyUrl": {
|
||||||
"type": "string",
|
"type": "string",
|
||||||
"default": "http://127.0.0.1:3456",
|
"default": "http://127.0.0.1:3456",
|
||||||
"description": "URL of the Claude proxy"
|
"description": "URL of the Claude proxy. Overridable via OCP_PROXY_URL or CLAUDE_PROXY_PORT env."
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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",
|
||||||
|
|||||||
@@ -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.
|
|
||||||
@@ -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"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -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.`);
|
|
||||||
});
|
|
||||||
Generated
-20
@@ -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
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "openclaw-claude-proxy",
|
"name": "open-claude-proxy",
|
||||||
"version": "3.11.1",
|
"version": "3.16.4",
|
||||||
"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",
|
||||||
|
|||||||
@@ -0,0 +1,301 @@
|
|||||||
|
#!/usr/bin/env node
|
||||||
|
/**
|
||||||
|
* scripts/doctor.mjs — OCP health & upgrade-readiness check.
|
||||||
|
*
|
||||||
|
* Usage:
|
||||||
|
* ocp doctor human-readable PASS/WARN/FAIL
|
||||||
|
* ocp doctor --json machine-readable JSON for AI agents + ocp update
|
||||||
|
* ocp doctor --check oauth fast path: only OAuth check
|
||||||
|
*
|
||||||
|
* Exit codes:
|
||||||
|
* 0 all PASS or WARN-only
|
||||||
|
* 1 any FAIL
|
||||||
|
*/
|
||||||
|
import { readFileSync, existsSync } from "node:fs";
|
||||||
|
import { join } from "node:path";
|
||||||
|
import { homedir } from "node:os";
|
||||||
|
import { execSync } from "node:child_process";
|
||||||
|
import { DEFAULT_PORT } from "../lib/constants.mjs";
|
||||||
|
|
||||||
|
const SCHEMA_VERSION = "1";
|
||||||
|
|
||||||
|
function semverParts(v) {
|
||||||
|
const m = String(v).replace(/^v/, "").match(/^(\d+)\.(\d+)\.(\d+)/);
|
||||||
|
if (!m) return null;
|
||||||
|
return { major: +m[1], minor: +m[2], patch: +m[3] };
|
||||||
|
}
|
||||||
|
|
||||||
|
function semverCompare(a, b) {
|
||||||
|
const A = semverParts(a), B = semverParts(b);
|
||||||
|
if (!A || !B) return 0;
|
||||||
|
if (A.major !== B.major) return A.major - B.major;
|
||||||
|
if (A.minor !== B.minor) return A.minor - B.minor;
|
||||||
|
return A.patch - B.patch;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function runDoctor(opts = {}) {
|
||||||
|
const checks = [];
|
||||||
|
const push = (id, level, message, extra = {}) =>
|
||||||
|
checks.push({ id, level, message, ...extra });
|
||||||
|
|
||||||
|
// --- fast path: --check oauth ---
|
||||||
|
if (opts.checkOnly === "oauth") {
|
||||||
|
return runOauthOnly(opts, checks, push);
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- version detection ---
|
||||||
|
const ocpDir = opts.ocpDir || join(homedir(), "ocp");
|
||||||
|
let currentVersion = opts.mockVersion;
|
||||||
|
if (!currentVersion) {
|
||||||
|
try {
|
||||||
|
const pkg = JSON.parse(readFileSync(join(ocpDir, "package.json"), "utf8"));
|
||||||
|
currentVersion = `v${pkg.version}`;
|
||||||
|
} catch {
|
||||||
|
currentVersion = "unknown";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// Resolve latest from origin/main (cheap: `git show origin/main:package.json`).
|
||||||
|
// Falls back to current_version when network/git unavailable, so kind = noop instead
|
||||||
|
// of recommending a downgrade against a stale hardcoded value.
|
||||||
|
let latestVersion = opts.mockLatest;
|
||||||
|
if (!latestVersion) {
|
||||||
|
try {
|
||||||
|
const out = execSync(`git -C ${ocpDir} show origin/main:package.json 2>/dev/null`, { stdio: ["pipe", "pipe", "pipe"] }).toString();
|
||||||
|
const remotePkg = JSON.parse(out);
|
||||||
|
latestVersion = `v${remotePkg.version}`;
|
||||||
|
} catch {
|
||||||
|
latestVersion = currentVersion;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
push("current_version", "PASS", `current=${currentVersion}`);
|
||||||
|
|
||||||
|
// --- from-version supported? ---
|
||||||
|
const fromSupported = !!semverParts(currentVersion) && semverCompare(currentVersion, "v3.4.0") >= 0;
|
||||||
|
push("from_version_supported", fromSupported ? "PASS" : "FAIL",
|
||||||
|
fromSupported ? "≥ v3.4.0" : `${currentVersion} < v3.4.0; in-place upgrade not supported`);
|
||||||
|
|
||||||
|
// --- service health check (mockable) ---
|
||||||
|
let healthOk = true, oauthOk = true;
|
||||||
|
if (!opts.skipNetwork) {
|
||||||
|
let health;
|
||||||
|
if (opts.mockHealth !== undefined) {
|
||||||
|
health = opts.mockHealth;
|
||||||
|
} else {
|
||||||
|
try {
|
||||||
|
const port = process.env.CLAUDE_PROXY_PORT || String(DEFAULT_PORT);
|
||||||
|
const out = execSync(`curl -sf --max-time 3 http://127.0.0.1:${port}/health`, { stdio: ["pipe", "pipe", "pipe"] }).toString();
|
||||||
|
health = { status: 200, body: JSON.parse(out) };
|
||||||
|
} catch (e) {
|
||||||
|
health = { error: String(e.message || e) };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (health.error || health.status !== 200) {
|
||||||
|
healthOk = false;
|
||||||
|
push("service_running", "FAIL", `service unreachable: ${health.error || `status ${health.status}`}`);
|
||||||
|
} else if (!health.body || typeof health.body !== "object") {
|
||||||
|
healthOk = false;
|
||||||
|
push("service_running", "FAIL", "service /health returned 200 but empty/non-JSON body");
|
||||||
|
} else {
|
||||||
|
push("service_running", "PASS", "service responding on /health");
|
||||||
|
const authOk = health.body?.auth?.ok;
|
||||||
|
if (!authOk) {
|
||||||
|
oauthOk = false;
|
||||||
|
push("oauth_ok", "FAIL", `auth.ok=false: ${health.body?.auth?.message || "unknown"}`);
|
||||||
|
} else {
|
||||||
|
push("oauth_ok", "PASS", "OAuth token valid");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- determine next_action.kind (priority: fresh_install > fix_service > fix_oauth > noop > update > upgrade) ---
|
||||||
|
let kind;
|
||||||
|
if (!fromSupported) {
|
||||||
|
kind = "fresh_install";
|
||||||
|
} else if (!opts.skipNetwork && !healthOk) {
|
||||||
|
kind = "fix_service";
|
||||||
|
} else if (!opts.skipNetwork && !oauthOk) {
|
||||||
|
kind = "fix_oauth";
|
||||||
|
} else {
|
||||||
|
const cur = semverParts(currentVersion), lat = semverParts(latestVersion);
|
||||||
|
if (!cur) {
|
||||||
|
kind = "fresh_install";
|
||||||
|
} else if (semverCompare(currentVersion, latestVersion) === 0) {
|
||||||
|
kind = "noop";
|
||||||
|
} else if (lat && cur.major === lat.major && cur.minor === lat.minor) {
|
||||||
|
kind = "update";
|
||||||
|
} else {
|
||||||
|
kind = "upgrade";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- next_action shape ---
|
||||||
|
let next_action;
|
||||||
|
if (kind === "fresh_install") {
|
||||||
|
next_action = {
|
||||||
|
kind,
|
||||||
|
human_required: ["claude auth login (only if OAuth becomes invalid after reinstall)"],
|
||||||
|
ai_executable: [
|
||||||
|
`launchctl bootout gui/$(id -u)/ai.openclaw.proxy 2>/dev/null || true`,
|
||||||
|
`launchctl bootout gui/$(id -u)/dev.ocp.proxy 2>/dev/null || true`,
|
||||||
|
`mv ${join(homedir(), ".ocp")} ${join(homedir(), ".ocp.backup-")}$(date +%s) 2>/dev/null || true`,
|
||||||
|
`rm -rf ${ocpDir}`,
|
||||||
|
`git clone https://github.com/dtzp555-max/ocp ${ocpDir}`,
|
||||||
|
`cd ${ocpDir} && npm install --no-audit --no-fund && node setup.mjs`,
|
||||||
|
`${ocpDir}/ocp doctor`
|
||||||
|
],
|
||||||
|
verify: "ocp doctor expects PASS on all checks"
|
||||||
|
};
|
||||||
|
} else if (kind === "noop") {
|
||||||
|
next_action = { kind, human_required: [], ai_executable: [], verify: "already at latest" };
|
||||||
|
} else if (kind === "fix_oauth") {
|
||||||
|
next_action = {
|
||||||
|
kind,
|
||||||
|
human_required: [],
|
||||||
|
ai_executable: [
|
||||||
|
`cd "$(npm root -g)/@anthropic-ai/claude-code" && node install.cjs`,
|
||||||
|
`launchctl bootout gui/$(id -u)/dev.ocp.proxy 2>/dev/null || true`,
|
||||||
|
`launchctl bootstrap gui/$(id -u) ${join(homedir(), "Library", "LaunchAgents", "dev.ocp.proxy.plist")}`,
|
||||||
|
`${ocpDir}/ocp doctor`
|
||||||
|
],
|
||||||
|
verify: "ocp doctor expects oauth_ok=PASS",
|
||||||
|
reference: "~/.cc-rules/memory/learnings/ocp_claude_native_binary_postinstall.md"
|
||||||
|
};
|
||||||
|
} else if (kind === "fix_service") {
|
||||||
|
next_action = {
|
||||||
|
kind,
|
||||||
|
human_required: [],
|
||||||
|
ai_executable: [
|
||||||
|
`launchctl bootout gui/$(id -u)/dev.ocp.proxy 2>/dev/null || true`,
|
||||||
|
`launchctl bootstrap gui/$(id -u) ${join(homedir(), "Library", "LaunchAgents", "dev.ocp.proxy.plist")}`,
|
||||||
|
`${ocpDir}/ocp doctor`
|
||||||
|
],
|
||||||
|
verify: "ocp doctor expects service_running=PASS"
|
||||||
|
};
|
||||||
|
} else {
|
||||||
|
next_action = {
|
||||||
|
kind,
|
||||||
|
human_required: [],
|
||||||
|
ai_executable: [`${ocpDir}/ocp update --yes`],
|
||||||
|
verify: "ocp doctor expects PASS on all checks"
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
const fail_count = checks.filter(c => c.level === "FAIL").length;
|
||||||
|
const warn_count = checks.filter(c => c.level === "WARN").length;
|
||||||
|
return {
|
||||||
|
schema_version: SCHEMA_VERSION,
|
||||||
|
timestamp: new Date().toISOString(),
|
||||||
|
ready_to_upgrade: fail_count === 0,
|
||||||
|
current_version: currentVersion,
|
||||||
|
latest_version: latestVersion,
|
||||||
|
from_version_supported: fromSupported,
|
||||||
|
fail_count,
|
||||||
|
warn_count,
|
||||||
|
checks,
|
||||||
|
next_action
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function runOauthOnly(opts, checks, push) {
|
||||||
|
let healthOk = true, oauthOk = true;
|
||||||
|
let health;
|
||||||
|
if (opts.mockHealth !== undefined) {
|
||||||
|
health = opts.mockHealth;
|
||||||
|
} else {
|
||||||
|
try {
|
||||||
|
const port = process.env.CLAUDE_PROXY_PORT || String(DEFAULT_PORT);
|
||||||
|
const out = execSync(`curl -sf --max-time 3 http://127.0.0.1:${port}/health`, { stdio: ["pipe", "pipe", "pipe"] }).toString();
|
||||||
|
health = { status: 200, body: JSON.parse(out) };
|
||||||
|
} catch (e) {
|
||||||
|
health = { error: String(e.message || e) };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (health.error || health.status !== 200) {
|
||||||
|
healthOk = false;
|
||||||
|
push("oauth_ok", "FAIL", `service unreachable: ${health.error || `status ${health.status}`}`);
|
||||||
|
} else if (!health.body || typeof health.body !== "object") {
|
||||||
|
healthOk = false;
|
||||||
|
push("oauth_ok", "FAIL", "service /health returned 200 but empty/non-JSON body");
|
||||||
|
} else if (!health.body?.auth?.ok) {
|
||||||
|
oauthOk = false;
|
||||||
|
push("oauth_ok", "FAIL", `auth.ok=false: ${health.body?.auth?.message || "unknown"}`);
|
||||||
|
} else {
|
||||||
|
push("oauth_ok", "PASS", "OAuth token valid");
|
||||||
|
}
|
||||||
|
|
||||||
|
const kind = !healthOk ? "fix_service" : !oauthOk ? "fix_oauth" : "noop";
|
||||||
|
|
||||||
|
let next_action;
|
||||||
|
const ocpDir = opts.ocpDir || join(homedir(), "ocp");
|
||||||
|
if (kind === "noop") {
|
||||||
|
next_action = { kind, human_required: [], ai_executable: [], verify: "OAuth healthy" };
|
||||||
|
} else if (kind === "fix_oauth") {
|
||||||
|
next_action = {
|
||||||
|
kind,
|
||||||
|
human_required: [],
|
||||||
|
ai_executable: [
|
||||||
|
`cd "$(npm root -g)/@anthropic-ai/claude-code" && node install.cjs`,
|
||||||
|
`launchctl bootout gui/$(id -u)/dev.ocp.proxy 2>/dev/null || true`,
|
||||||
|
`launchctl bootstrap gui/$(id -u) ${join(homedir(), "Library", "LaunchAgents", "dev.ocp.proxy.plist")}`,
|
||||||
|
`${ocpDir}/ocp doctor --check oauth`
|
||||||
|
],
|
||||||
|
verify: "ocp doctor --check oauth expects PASS",
|
||||||
|
reference: "~/.cc-rules/memory/learnings/ocp_claude_native_binary_postinstall.md"
|
||||||
|
};
|
||||||
|
} else {
|
||||||
|
next_action = {
|
||||||
|
kind,
|
||||||
|
human_required: [],
|
||||||
|
ai_executable: [
|
||||||
|
`launchctl bootout gui/$(id -u)/dev.ocp.proxy 2>/dev/null || true`,
|
||||||
|
`launchctl bootstrap gui/$(id -u) ${join(homedir(), "Library", "LaunchAgents", "dev.ocp.proxy.plist")}`,
|
||||||
|
`${ocpDir}/ocp doctor --check oauth`
|
||||||
|
],
|
||||||
|
verify: "ocp doctor --check oauth expects service_running=PASS"
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
const fail_count = checks.filter(c => c.level === "FAIL").length;
|
||||||
|
// "skipped" = --check oauth fast path intentionally omits version detection.
|
||||||
|
// AI agents should NOT semver-compare against current_version/latest_version when
|
||||||
|
// either equals "skipped"; the full path provides those fields when needed.
|
||||||
|
return {
|
||||||
|
schema_version: SCHEMA_VERSION,
|
||||||
|
timestamp: new Date().toISOString(),
|
||||||
|
ready_to_upgrade: fail_count === 0,
|
||||||
|
current_version: opts.mockVersion || "skipped",
|
||||||
|
latest_version: opts.mockLatest || "skipped",
|
||||||
|
from_version_supported: true,
|
||||||
|
fail_count,
|
||||||
|
warn_count: 0,
|
||||||
|
checks,
|
||||||
|
next_action
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// CLI entrypoint — use fileURLToPath + realpath to handle symlinked install paths
|
||||||
|
// (e.g. /tmp/ → /private/tmp/ on macOS would otherwise miss the guard).
|
||||||
|
import { fileURLToPath } from "node:url";
|
||||||
|
import { realpathSync } from "node:fs";
|
||||||
|
function _isMain() {
|
||||||
|
if (!process.argv[1]) return false;
|
||||||
|
try {
|
||||||
|
return realpathSync(fileURLToPath(import.meta.url)) === realpathSync(process.argv[1]);
|
||||||
|
} catch { return false; }
|
||||||
|
}
|
||||||
|
if (_isMain()) {
|
||||||
|
const wantJson = process.argv.includes("--json");
|
||||||
|
const checkIdx = process.argv.indexOf("--check");
|
||||||
|
const checkOnly = checkIdx !== -1 ? process.argv[checkIdx + 1] : undefined;
|
||||||
|
const result = await runDoctor({ checkOnly });
|
||||||
|
if (wantJson) {
|
||||||
|
console.log(JSON.stringify(result, null, 2));
|
||||||
|
} else {
|
||||||
|
console.log(`OCP doctor — ${result.current_version} → ${result.latest_version}`);
|
||||||
|
for (const c of result.checks) console.log(` [${c.level}] ${c.id}: ${c.message}`);
|
||||||
|
console.log(`\nSummary: ${result.fail_count} FAIL, ${result.warn_count} WARN`);
|
||||||
|
console.log(`Next action: ${result.next_action.kind}`);
|
||||||
|
}
|
||||||
|
process.exit(result.fail_count === 0 ? 0 : 1);
|
||||||
|
}
|
||||||
Executable
+132
@@ -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:00–00: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 ==="
|
||||||
@@ -0,0 +1,86 @@
|
|||||||
|
// scripts/lib/plist-merge.mjs
|
||||||
|
//
|
||||||
|
// Preserves user-customised env vars when setup.mjs rewrites the unit file.
|
||||||
|
//
|
||||||
|
// Rule:
|
||||||
|
// - keys present in NEW template → template value wins (template is source of truth)
|
||||||
|
// - keys ONLY in EXISTING (not in template) → preserved verbatim
|
||||||
|
//
|
||||||
|
// No new dependencies — regex-based, plist <key>X</key><string>Y</string> shape
|
||||||
|
// is stable enough for our hand-written templates in setup.mjs.
|
||||||
|
|
||||||
|
const PLIST_KV_RE = /<key>([^<]+)<\/key>\s*<string>([^<]*)<\/string>/g;
|
||||||
|
|
||||||
|
export function parsePlistEnv(plistContent) {
|
||||||
|
if (!plistContent) return {};
|
||||||
|
if (Buffer.isBuffer(plistContent)) plistContent = plistContent.toString("utf8");
|
||||||
|
// Restrict to the EnvironmentVariables dict to avoid catching Label, etc.
|
||||||
|
const envBlock = plistContent.match(/<key>EnvironmentVariables<\/key>\s*<dict>([\s\S]*?)<\/dict>/);
|
||||||
|
if (!envBlock) return {};
|
||||||
|
const out = {};
|
||||||
|
let m;
|
||||||
|
PLIST_KV_RE.lastIndex = 0;
|
||||||
|
while ((m = PLIST_KV_RE.exec(envBlock[1])) !== null) {
|
||||||
|
out[m[1]] = m[2];
|
||||||
|
}
|
||||||
|
return out;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function mergePlistEnv(existing, template) {
|
||||||
|
if (!existing) return template;
|
||||||
|
const existingEnv = parsePlistEnv(existing);
|
||||||
|
const templateEnv = parsePlistEnv(template);
|
||||||
|
const KNOWN = new Set(Object.keys(templateEnv));
|
||||||
|
|
||||||
|
const preserved = {};
|
||||||
|
for (const [k, v] of Object.entries(existingEnv)) {
|
||||||
|
if (!KNOWN.has(k)) preserved[k] = v;
|
||||||
|
}
|
||||||
|
if (Object.keys(preserved).length === 0) return template;
|
||||||
|
|
||||||
|
const lines = Object.entries(preserved)
|
||||||
|
.map(([k, v]) => ` <key>${k}</key>\n <string>${v}</string>`)
|
||||||
|
.join("\n");
|
||||||
|
|
||||||
|
// Inject before the closing </dict> of EnvironmentVariables
|
||||||
|
return template.replace(
|
||||||
|
/(<key>EnvironmentVariables<\/key>\s*<dict>[\s\S]*?)(\n\s*<\/dict>)/,
|
||||||
|
`$1\n${lines}$2`
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const SYSTEMD_KV_RE = /^Environment=([^=]+)=(.*)$/gm;
|
||||||
|
|
||||||
|
export function parseSystemdEnv(serviceContent) {
|
||||||
|
if (!serviceContent) return {};
|
||||||
|
if (Buffer.isBuffer(serviceContent)) serviceContent = serviceContent.toString("utf8");
|
||||||
|
const out = {};
|
||||||
|
let m;
|
||||||
|
SYSTEMD_KV_RE.lastIndex = 0;
|
||||||
|
while ((m = SYSTEMD_KV_RE.exec(serviceContent)) !== null) {
|
||||||
|
out[m[1]] = m[2];
|
||||||
|
}
|
||||||
|
return out;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function mergeSystemdEnv(existing, template) {
|
||||||
|
if (!existing) return template;
|
||||||
|
const existingEnv = parseSystemdEnv(existing);
|
||||||
|
const templateEnv = parseSystemdEnv(template);
|
||||||
|
const KNOWN = new Set(Object.keys(templateEnv));
|
||||||
|
|
||||||
|
const preservedLines = Object.entries(existingEnv)
|
||||||
|
.filter(([k]) => !KNOWN.has(k))
|
||||||
|
.map(([k, v]) => `Environment=${k}=${v}`);
|
||||||
|
if (preservedLines.length === 0) return template;
|
||||||
|
|
||||||
|
// Guard: if template has no Environment= anchor, cannot inject — return template as-is.
|
||||||
|
// (In practice the OCP systemd template always has Environment= lines.)
|
||||||
|
if (!/^Environment=/m.test(template)) return template;
|
||||||
|
|
||||||
|
// Inject after the last existing Environment= line in the template
|
||||||
|
return template.replace(
|
||||||
|
/(^Environment=[^\n]+\n)((?!Environment=).*$)/ms,
|
||||||
|
`$1${preservedLines.join("\n")}\n$2`
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,115 @@
|
|||||||
|
import { mkdirSync, writeFileSync, readFileSync, copyFileSync, existsSync, readdirSync, statSync, rmSync } from "node:fs";
|
||||||
|
import { join } from "node:path";
|
||||||
|
|
||||||
|
export function writeSnapshot({ homeDir, fromCommit, fromVersion, toVersion, extraFiles = [] }) {
|
||||||
|
const ts = new Date().toISOString().replace(/\.\d+Z$/, "Z");
|
||||||
|
const root = join(homeDir, ".ocp", `upgrade-snapshot-${ts}`);
|
||||||
|
mkdirSync(root, { recursive: true });
|
||||||
|
|
||||||
|
// Standard manifest files
|
||||||
|
writeFileSync(join(root, "from-commit.txt"), fromCommit + "\n");
|
||||||
|
writeFileSync(join(root, "from-version.txt"), fromVersion + "\n");
|
||||||
|
writeFileSync(join(root, "to-version.txt"), toVersion + "\n");
|
||||||
|
|
||||||
|
// Optional captures (best-effort, never fatal)
|
||||||
|
const tryCopy = (src, dst) => {
|
||||||
|
try {
|
||||||
|
if (existsSync(src)) copyFileSync(src, dst);
|
||||||
|
} catch (err) {
|
||||||
|
console.error(`[snapshot] warn: could not copy ${src} (${err.code || err.message})`);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
tryCopy(join(homeDir, "Library", "LaunchAgents", "dev.ocp.proxy.plist"), join(root, "plist"));
|
||||||
|
tryCopy(join(homeDir, ".config", "systemd", "user", "ocp-proxy.service"), join(root, "service"));
|
||||||
|
tryCopy(join(homeDir, ".ocp", "ocp.db"), join(root, "db.bak"));
|
||||||
|
tryCopy(join(homeDir, ".ocp", "admin-key"), join(root, "admin-key"));
|
||||||
|
tryCopy(join(homeDir, ".openclaw", "openclaw.json"), join(root, "openclaw.json"));
|
||||||
|
|
||||||
|
for (const { src, name } of extraFiles) tryCopy(src, join(root, name));
|
||||||
|
|
||||||
|
return root;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function readSnapshot(snapshotPath) {
|
||||||
|
const read = (n) => {
|
||||||
|
try { return readFileSync(join(snapshotPath, n), "utf8").trim(); } catch { return null; }
|
||||||
|
};
|
||||||
|
return {
|
||||||
|
path: snapshotPath,
|
||||||
|
fromCommit: read("from-commit.txt"),
|
||||||
|
fromVersion: read("from-version.txt"),
|
||||||
|
toVersion: read("to-version.txt")
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export function listSnapshots(homeDir) {
|
||||||
|
const root = join(homeDir, ".ocp");
|
||||||
|
if (!existsSync(root)) return [];
|
||||||
|
return readdirSync(root)
|
||||||
|
.filter(name => name.startsWith("upgrade-snapshot-"))
|
||||||
|
.map(name => ({ name, path: join(root, name), mtime: statSync(join(root, name)).mtimeMs }))
|
||||||
|
.sort((a, b) => a.name.localeCompare(b.name));
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Garbage-collect old upgrade snapshots.
|
||||||
|
*
|
||||||
|
* Retention rule (a snapshot is KEPT if any of these is true):
|
||||||
|
* - It is among the last `keepCount` snapshots (sorted oldest→newest)
|
||||||
|
* - Its timestamp is within `keepDays` of `now`
|
||||||
|
* - It is the single most-recent snapshot (always-keep safety net)
|
||||||
|
*
|
||||||
|
* @param {string} homeDir - Root containing ~/.ocp/
|
||||||
|
* @param {object} opts
|
||||||
|
* @param {number} [opts.keepCount=5] - Minimum count to keep
|
||||||
|
* @param {number} [opts.keepDays=30] - Keep snapshots newer than N days
|
||||||
|
* @param {boolean} [opts.dryRun=false] - If true, report plan but don't delete
|
||||||
|
* @param {Date} [opts.now=new Date()] - Override clock for testing
|
||||||
|
* @returns {{kept: Array, removed: Array, dryRun: boolean}}
|
||||||
|
*/
|
||||||
|
export function gcSnapshots(homeDir, opts = {}) {
|
||||||
|
const keepCount = opts.keepCount ?? 5;
|
||||||
|
const keepDays = opts.keepDays ?? 30;
|
||||||
|
const dryRun = !!opts.dryRun;
|
||||||
|
const now = opts.now || new Date();
|
||||||
|
|
||||||
|
const all = listSnapshots(homeDir); // sorted oldest→newest
|
||||||
|
if (all.length === 0) return { kept: [], removed: [], dryRun };
|
||||||
|
if (all.length === 1) return { kept: all, removed: [], dryRun }; // always keep most recent
|
||||||
|
|
||||||
|
const cutoffMs = now.getTime() - keepDays * 24 * 60 * 60 * 1000;
|
||||||
|
const lastN = new Set(all.slice(-keepCount).map(s => s.path));
|
||||||
|
|
||||||
|
const kept = [], removed = [];
|
||||||
|
for (let i = 0; i < all.length; i++) {
|
||||||
|
const s = all[i];
|
||||||
|
const isMostRecent = i === all.length - 1;
|
||||||
|
const isInLastN = lastN.has(s.path);
|
||||||
|
const isWithinDays = parseSnapshotTimestamp(s.name) >= cutoffMs;
|
||||||
|
if (isMostRecent || isInLastN || isWithinDays) {
|
||||||
|
kept.push(s);
|
||||||
|
} else {
|
||||||
|
removed.push(s);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!dryRun) {
|
||||||
|
for (const s of removed) {
|
||||||
|
try {
|
||||||
|
rmSync(s.path, { recursive: true, force: true });
|
||||||
|
} catch (err) {
|
||||||
|
console.error(`[snapshot] warn: could not remove ${s.path} (${err.code || err.message})`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return { kept, removed, dryRun };
|
||||||
|
}
|
||||||
|
|
||||||
|
function parseSnapshotTimestamp(name) {
|
||||||
|
// upgrade-snapshot-2026-05-11T08:30:00Z → epoch ms
|
||||||
|
const m = name.match(/upgrade-snapshot-(.+)$/);
|
||||||
|
if (!m) return 0;
|
||||||
|
const t = Date.parse(m[1]);
|
||||||
|
return Number.isFinite(t) ? t : 0;
|
||||||
|
}
|
||||||
@@ -9,6 +9,7 @@ import { readFileSync, writeFileSync, existsSync, copyFileSync } from "node:fs";
|
|||||||
import { join, dirname } from "node:path";
|
import { join, dirname } from "node:path";
|
||||||
import { fileURLToPath } from "node:url";
|
import { fileURLToPath } from "node:url";
|
||||||
import { homedir } from "node:os";
|
import { homedir } from "node:os";
|
||||||
|
import { DEFAULT_PORT, LOCAL_HOST, OPENAI_API_BASE } from "../lib/constants.mjs";
|
||||||
|
|
||||||
const __dirname = dirname(fileURLToPath(import.meta.url));
|
const __dirname = dirname(fileURLToPath(import.meta.url));
|
||||||
const REPO_ROOT = join(__dirname, "..");
|
const REPO_ROOT = join(__dirname, "..");
|
||||||
@@ -70,7 +71,7 @@ if (!config.models.providers) config.models.providers = {};
|
|||||||
if (!config.models.providers[PROVIDER_NAME]) {
|
if (!config.models.providers[PROVIDER_NAME]) {
|
||||||
// First-time registration
|
// First-time registration
|
||||||
config.models.providers[PROVIDER_NAME] = {
|
config.models.providers[PROVIDER_NAME] = {
|
||||||
baseUrl: "http://127.0.0.1:3456/v1",
|
baseUrl: `http://${LOCAL_HOST}:${DEFAULT_PORT}${OPENAI_API_BASE}`,
|
||||||
api: "openai-completions",
|
api: "openai-completions",
|
||||||
authHeader: false,
|
authHeader: false,
|
||||||
models: desiredModels,
|
models: desiredModels,
|
||||||
|
|||||||
@@ -0,0 +1,345 @@
|
|||||||
|
#!/usr/bin/env node
|
||||||
|
/**
|
||||||
|
* scripts/upgrade.mjs — OCP unified upgrade dispatcher.
|
||||||
|
*
|
||||||
|
* Paths:
|
||||||
|
* noop current == latest, exit 0
|
||||||
|
* light same major.minor, patch bump only (existing fast path; delegated to bash)
|
||||||
|
* full cross-minor (snapshot + setup.mjs + post-flight)
|
||||||
|
* fresh_install from-version < v3.4.0 (--yes required for non-interactive)
|
||||||
|
* rollback restore from snapshot
|
||||||
|
*/
|
||||||
|
import { runDoctor } from "./doctor.mjs";
|
||||||
|
import { execSync } from "node:child_process";
|
||||||
|
import { homedir } from "node:os";
|
||||||
|
import { join } from "node:path";
|
||||||
|
import { existsSync, copyFileSync } from "node:fs";
|
||||||
|
import { writeSnapshot, listSnapshots, readSnapshot, gcSnapshots } from "./lib/snapshot.mjs";
|
||||||
|
import { DEFAULT_PORT } from "../lib/constants.mjs";
|
||||||
|
|
||||||
|
export async function runUpgrade(opts = {}) {
|
||||||
|
const dryRun = !!opts.dryRun;
|
||||||
|
const yes = !!opts.yes;
|
||||||
|
// yes is reserved for Bundle 3 (fresh-install / rollback interactive gate); not used in upgrade-path here.
|
||||||
|
const plan = [];
|
||||||
|
|
||||||
|
// --- rollback path (no doctor needed; snapshot is authoritative) ---
|
||||||
|
if (opts.rollback) {
|
||||||
|
return await runRollback(opts);
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- doctor pre-flight ---
|
||||||
|
const doctor = opts.mockDoctor || await runDoctor();
|
||||||
|
if (!doctor.ready_to_upgrade && doctor.next_action.kind !== "fresh_install") {
|
||||||
|
throw new Error(`doctor FAIL: ${doctor.next_action.kind} (run "ocp doctor" for details)`);
|
||||||
|
}
|
||||||
|
|
||||||
|
const kind = doctor.next_action.kind;
|
||||||
|
plan.push(`[doctor] from=${doctor.current_version} to=${doctor.latest_version} kind=${kind}`);
|
||||||
|
|
||||||
|
// --- noop ---
|
||||||
|
if (kind === "noop") {
|
||||||
|
plan.push(`[noop] already at latest (${doctor.latest_version})`);
|
||||||
|
return { path: "noop", executed: true, changed: false, plan };
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- dry-run early exit ---
|
||||||
|
if (dryRun) {
|
||||||
|
plan.push(`[plan] would proceed with ${kind} path`);
|
||||||
|
if (kind === "upgrade") {
|
||||||
|
plan.push(`[plan] phase 1: snapshot to ~/.ocp/upgrade-snapshot-<ts>/`);
|
||||||
|
plan.push(`[plan] phase 2: git checkout ${doctor.latest_version} && npm install`);
|
||||||
|
plan.push(`[plan] phase 3: node setup.mjs`);
|
||||||
|
plan.push(`[plan] phase 4: launchctl bootout/bootstrap`);
|
||||||
|
plan.push(`[plan] phase 5: post-flight /health + /v1/models`);
|
||||||
|
} else if (kind === "update") {
|
||||||
|
plan.push(`[plan] light path: git pull + npm install + restart`);
|
||||||
|
} else if (kind === "fresh_install") {
|
||||||
|
plan.push(`[plan] fresh-install ai_executable[]:`);
|
||||||
|
for (const cmd of doctor.next_action.ai_executable) plan.push(` - ${cmd}`);
|
||||||
|
}
|
||||||
|
return { path: kind, executed: false, plan };
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- non-dry-run paths ---
|
||||||
|
if (kind === "update") {
|
||||||
|
return { path: "update", executed: true, changed: true, plan: [...plan, "[light] delegated to bash cmd_update existing logic"] };
|
||||||
|
}
|
||||||
|
|
||||||
|
if (kind === "upgrade") {
|
||||||
|
return await runFullUpgrade({ doctor, opts });
|
||||||
|
}
|
||||||
|
|
||||||
|
if (kind === "fresh_install") {
|
||||||
|
return await runFreshInstall({ doctor, opts });
|
||||||
|
}
|
||||||
|
|
||||||
|
throw new Error(`path ${kind} not yet implemented`);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function runFullUpgrade({ doctor, opts }) {
|
||||||
|
const phases = [];
|
||||||
|
let snapshotPath = null;
|
||||||
|
const exec = (cmd, label) => {
|
||||||
|
if (opts.mockExec) {
|
||||||
|
phases.push({ name: label, cmd, status: "skipped-mock" });
|
||||||
|
return "";
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
const out = execSync(cmd, { stdio: ["pipe", "pipe", "pipe"] }).toString();
|
||||||
|
phases.push({ name: label, cmd, status: "ok" });
|
||||||
|
return out;
|
||||||
|
} catch (err) {
|
||||||
|
const detail = err.stderr?.toString().trim();
|
||||||
|
phases.push({ name: label, cmd, status: "fail", stderr: detail });
|
||||||
|
throw Object.assign(
|
||||||
|
new Error(`phase ${label} failed: ${detail || err.message}`),
|
||||||
|
{ phases, cmd }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
const ocpDir = opts.ocpDir || join(homedir(), "ocp");
|
||||||
|
|
||||||
|
try {
|
||||||
|
// phase 1: pre-flight (doctor already passed; just record)
|
||||||
|
phases.push({ name: "pre-flight", status: "ok", note: `kind=upgrade from=${doctor.current_version} to=${doctor.latest_version}` });
|
||||||
|
|
||||||
|
// phase 2: snapshot
|
||||||
|
const fromCommit = opts.mockExec
|
||||||
|
? "mock-commit"
|
||||||
|
: execSync(`git -C ${ocpDir} rev-parse HEAD`).toString().trim();
|
||||||
|
snapshotPath = opts.mockExec
|
||||||
|
? "/tmp/mock-snapshot"
|
||||||
|
: writeSnapshot({ homeDir: homedir(), fromCommit, fromVersion: doctor.current_version, toVersion: doctor.latest_version });
|
||||||
|
phases.push({ name: "snapshot", path: snapshotPath, status: "ok" });
|
||||||
|
|
||||||
|
// phase 3: fetch + install
|
||||||
|
exec(`git -C ${ocpDir} fetch --tags --quiet`, "fetch+install");
|
||||||
|
exec(`git -C ${ocpDir} checkout ${doctor.latest_version}`, "fetch+install");
|
||||||
|
exec(`npm --prefix ${ocpDir} install --no-audit --no-fund`, "fetch+install");
|
||||||
|
|
||||||
|
// phase 4: reconfigure
|
||||||
|
exec(`node ${ocpDir}/setup.mjs`, "reconfigure");
|
||||||
|
|
||||||
|
// phase 5: restart (heads-up note printed before invoking)
|
||||||
|
if (!opts.mockExec) {
|
||||||
|
console.error(`[heads-up] restarting OCP service in 3s — expect ~5–10s blip on requests in flight.`);
|
||||||
|
await new Promise(r => setTimeout(r, 3000));
|
||||||
|
}
|
||||||
|
if (process.platform === "darwin") {
|
||||||
|
exec(`launchctl bootout gui/$(id -u)/dev.ocp.proxy 2>/dev/null || true`, "restart");
|
||||||
|
exec(`launchctl bootstrap gui/$(id -u) ${join(homedir(), "Library", "LaunchAgents", "dev.ocp.proxy.plist")}`, "restart");
|
||||||
|
} else {
|
||||||
|
exec(`systemctl --user restart ocp-proxy.service`, "restart");
|
||||||
|
}
|
||||||
|
|
||||||
|
// phase 6: post-flight (10s budget; skipped under mockExec)
|
||||||
|
if (!opts.mockExec) {
|
||||||
|
const port = process.env.CLAUDE_PROXY_PORT || String(DEFAULT_PORT);
|
||||||
|
let ok = false;
|
||||||
|
for (let i = 0; i < 10; i++) {
|
||||||
|
try {
|
||||||
|
const out = execSync(`curl -sf --max-time 2 http://127.0.0.1:${port}/health`).toString();
|
||||||
|
const body = JSON.parse(out);
|
||||||
|
if (body.auth?.ok === true) { ok = true; break; }
|
||||||
|
} catch { /* retry */ }
|
||||||
|
await new Promise(r => setTimeout(r, 1000));
|
||||||
|
}
|
||||||
|
if (!ok) {
|
||||||
|
phases.push({ name: "post-flight", status: "fail", message: "health did not return auth.ok=true within 10s" });
|
||||||
|
throw new Error("post-flight failed");
|
||||||
|
}
|
||||||
|
execSync(`curl -sf --max-time 3 http://127.0.0.1:${port}/v1/models > /dev/null`);
|
||||||
|
phases.push({ name: "post-flight", status: "ok" });
|
||||||
|
} else {
|
||||||
|
phases.push({ name: "post-flight", status: "skipped-mock" });
|
||||||
|
}
|
||||||
|
|
||||||
|
// Auto-GC old snapshots after successful upgrade (best-effort, never throws).
|
||||||
|
try {
|
||||||
|
const gc = gcSnapshots(homedir(), { keepCount: 5, keepDays: 30 });
|
||||||
|
if (gc.removed.length > 0) {
|
||||||
|
console.error(`[gc] removed ${gc.removed.length} old snapshots; kept ${gc.kept.length}`);
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
console.error(`[gc] warn: snapshot GC failed: ${e.message}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
return { path: "upgrade", executed: true, changed: true, snapshotPath, phases };
|
||||||
|
} catch (err) {
|
||||||
|
if (snapshotPath && !err.snapshotPath) {
|
||||||
|
Object.assign(err, {
|
||||||
|
snapshotPath,
|
||||||
|
phases,
|
||||||
|
hint: "Working tree may be at new version. Run `ocp update --rollback` to restore from snapshot."
|
||||||
|
});
|
||||||
|
}
|
||||||
|
throw err;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function runFreshInstall({ doctor, opts }) {
|
||||||
|
if (!opts.yes) {
|
||||||
|
throw new Error("fresh_install requires --yes for non-interactive execution (or run interactively and answer y)");
|
||||||
|
}
|
||||||
|
const steps = [];
|
||||||
|
for (const cmd of doctor.next_action.ai_executable) {
|
||||||
|
if (opts.mockExec) {
|
||||||
|
steps.push({ cmd, status: "skipped-mock" });
|
||||||
|
} else {
|
||||||
|
try {
|
||||||
|
execSync(cmd, { stdio: "inherit" });
|
||||||
|
steps.push({ cmd, status: "ok" });
|
||||||
|
} catch (e) {
|
||||||
|
const detail = e.stderr?.toString().trim() || e.message;
|
||||||
|
steps.push({ cmd, status: "fail", error: String(detail) });
|
||||||
|
throw Object.assign(new Error(`fresh_install step failed: ${cmd} — ${detail}`), { steps });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return { path: "fresh_install", executed: true, changed: true, steps };
|
||||||
|
}
|
||||||
|
|
||||||
|
async function runRollback(opts) {
|
||||||
|
const homeDir = opts.homeDir || homedir();
|
||||||
|
const snapshots = opts.mockSnapshots ?? listSnapshots(homeDir);
|
||||||
|
|
||||||
|
if (opts.gc) {
|
||||||
|
const result = gcSnapshots(homeDir, { dryRun: opts.dryRun });
|
||||||
|
return { path: opts.dryRun ? "rollback-gc-dry-run" : "rollback-gc", ...result };
|
||||||
|
}
|
||||||
|
|
||||||
|
if (opts.list) {
|
||||||
|
return { path: "rollback-list", snapshots };
|
||||||
|
}
|
||||||
|
if (snapshots.length === 0) {
|
||||||
|
throw new Error("no upgrade snapshots found in ~/.ocp/upgrade-snapshot-*");
|
||||||
|
}
|
||||||
|
|
||||||
|
const target = opts.snapshotPath
|
||||||
|
? snapshots.find(s => s.path === opts.snapshotPath)
|
||||||
|
: snapshots[snapshots.length - 1];
|
||||||
|
if (!target) throw new Error(`snapshot not found: ${opts.snapshotPath} (must be inside ~/.ocp/upgrade-snapshot-*)`);
|
||||||
|
|
||||||
|
const meta = opts.mockSnapshotMeta ?? readSnapshot(target.path);
|
||||||
|
if (!meta.fromCommit) throw new Error(`snapshot ${target.path} has no from-commit.txt`);
|
||||||
|
|
||||||
|
const phases = [];
|
||||||
|
if (opts.dryRun) {
|
||||||
|
return {
|
||||||
|
path: "rollback-dry-run",
|
||||||
|
executed: false,
|
||||||
|
target: target.path,
|
||||||
|
plan: [
|
||||||
|
`git checkout ${meta.fromCommit}`,
|
||||||
|
`cp ${target.path}/plist ~/Library/LaunchAgents/dev.ocp.proxy.plist`,
|
||||||
|
`cp ${target.path}/db.bak ~/.ocp/ocp.db`,
|
||||||
|
`launchctl bootout/bootstrap`,
|
||||||
|
`ocp doctor`
|
||||||
|
]
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!opts.yes) throw new Error("rollback requires --yes for non-interactive execution");
|
||||||
|
|
||||||
|
const exec = (cmd, label) => {
|
||||||
|
if (opts.mockExec) {
|
||||||
|
phases.push({ name: label, cmd, status: "skipped-mock" });
|
||||||
|
return "";
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
execSync(cmd, { stdio: ["pipe", "pipe", "pipe"] });
|
||||||
|
phases.push({ name: label, cmd, status: "ok" });
|
||||||
|
} catch (err) {
|
||||||
|
const detail = err.stderr?.toString().trim();
|
||||||
|
phases.push({ name: label, cmd, status: "fail", stderr: detail });
|
||||||
|
throw Object.assign(
|
||||||
|
new Error(`rollback phase ${label} failed: ${detail || err.message}`),
|
||||||
|
{ phases, target: target.path }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const ocpDir = opts.ocpDir || join(homedir(), "ocp");
|
||||||
|
exec(`git -C ${ocpDir} checkout ${meta.fromCommit}`, "git-checkout");
|
||||||
|
|
||||||
|
if (!opts.mockExec) {
|
||||||
|
const tryCopy = (src, dst) => {
|
||||||
|
try {
|
||||||
|
if (existsSync(src)) copyFileSync(src, dst);
|
||||||
|
} catch (err) {
|
||||||
|
console.error(`[rollback] warn: could not restore ${src} → ${dst} (${err.code || err.message})`);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
tryCopy(join(target.path, "plist"), join(homeDir, "Library", "LaunchAgents", "dev.ocp.proxy.plist"));
|
||||||
|
tryCopy(join(target.path, "service"), join(homeDir, ".config", "systemd", "user", "ocp-proxy.service"));
|
||||||
|
tryCopy(join(target.path, "db.bak"), join(homeDir, ".ocp", "ocp.db"));
|
||||||
|
tryCopy(join(target.path, "admin-key"), join(homeDir, ".ocp", "admin-key"));
|
||||||
|
phases.push({ name: "restore-files", status: "ok" });
|
||||||
|
} else {
|
||||||
|
phases.push({ name: "restore-files", status: "skipped-mock" });
|
||||||
|
}
|
||||||
|
|
||||||
|
exec(`npm --prefix ${ocpDir} install --no-audit --no-fund`, "npm-install");
|
||||||
|
|
||||||
|
if (!opts.mockExec) {
|
||||||
|
console.error(`[heads-up] restarting OCP service in 3s — expect ~5–10s blip on requests in flight.`);
|
||||||
|
await new Promise(r => setTimeout(r, 3000));
|
||||||
|
}
|
||||||
|
if (process.platform === "darwin") {
|
||||||
|
exec(`launchctl bootout gui/$(id -u)/dev.ocp.proxy 2>/dev/null || true`, "restart");
|
||||||
|
exec(`launchctl bootstrap gui/$(id -u) ${join(homedir(), "Library", "LaunchAgents", "dev.ocp.proxy.plist")}`, "restart");
|
||||||
|
} else {
|
||||||
|
exec(`systemctl --user restart ocp-proxy.service`, "restart");
|
||||||
|
}
|
||||||
|
|
||||||
|
return { path: "rollback", executed: true, changed: true, target: target.path, phases };
|
||||||
|
}
|
||||||
|
|
||||||
|
// CLI entrypoint — use fileURLToPath + realpath to handle symlinked install paths.
|
||||||
|
import { fileURLToPath } from "node:url";
|
||||||
|
import { realpathSync } from "node:fs";
|
||||||
|
function _isMain() {
|
||||||
|
if (!process.argv[1]) return false;
|
||||||
|
try {
|
||||||
|
return realpathSync(fileURLToPath(import.meta.url)) === realpathSync(process.argv[1]);
|
||||||
|
} catch { return false; }
|
||||||
|
}
|
||||||
|
if (_isMain()) {
|
||||||
|
const args = process.argv.slice(2);
|
||||||
|
const dryRun = args.includes("--dry-run");
|
||||||
|
const yes = args.includes("--yes");
|
||||||
|
const rollback = args.includes("--rollback");
|
||||||
|
const list = args.includes("--list");
|
||||||
|
const gc = args.includes("--gc");
|
||||||
|
const targetIdx = args.indexOf("--target");
|
||||||
|
const target = targetIdx !== -1 ? args[targetIdx + 1] : undefined;
|
||||||
|
// First non-flag positional after --rollback is the snapshot path
|
||||||
|
let snapshotPath;
|
||||||
|
if (rollback) {
|
||||||
|
const rb = args.indexOf("--rollback");
|
||||||
|
const cand = args[rb + 1];
|
||||||
|
if (cand && !cand.startsWith("--")) snapshotPath = cand;
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
const result = await runUpgrade({ dryRun, yes, rollback, list, gc, snapshotPath, target });
|
||||||
|
if (result.plan) for (const line of result.plan) console.log(line);
|
||||||
|
if (result.phases) for (const p of result.phases) console.log(`[${p.name}] ${p.status}${p.cmd ? `: ${p.cmd}` : ""}`);
|
||||||
|
if (result.steps) for (const s of result.steps) console.log(` ${s.status === "ok" ? "✓" : s.status === "skipped-mock" ? "·" : "✗"} ${s.cmd}`);
|
||||||
|
if (result.snapshots) {
|
||||||
|
console.log(`Found ${result.snapshots.length} snapshots:`);
|
||||||
|
for (const s of result.snapshots) console.log(` ${s.name}`);
|
||||||
|
}
|
||||||
|
if (result.removed && result.kept) {
|
||||||
|
console.log(`Snapshots: kept ${result.kept.length}, ${result.dryRun ? "would remove" : "removed"} ${result.removed.length}`);
|
||||||
|
for (const s of result.removed) console.log(` - ${s.name}`);
|
||||||
|
}
|
||||||
|
process.exit(0);
|
||||||
|
} catch (e) {
|
||||||
|
console.error(`✗ ${e.message}`);
|
||||||
|
if (e.snapshotPath) console.error(` snapshot: ${e.snapshotPath}`);
|
||||||
|
if (e.target) console.error(` target: ${e.target}`);
|
||||||
|
if (e.hint) console.error(` hint: ${e.hint}`);
|
||||||
|
process.exit(1);
|
||||||
|
}
|
||||||
|
}
|
||||||
+564
-133
@@ -2,7 +2,7 @@
|
|||||||
/**
|
/**
|
||||||
* openclaw-claude-proxy — OpenAI-compatible proxy for Claude CLI
|
* openclaw-claude-proxy — OpenAI-compatible proxy for Claude CLI
|
||||||
*
|
*
|
||||||
* Translates OpenAI chat/completions requests into `claude -p` CLI calls,
|
* Translates OpenAI chat/completions requests into `claude --output-format stream-json` CLI calls,
|
||||||
* letting you use your Claude Pro/Max subscription as an OpenClaw model provider.
|
* letting you use your Claude Pro/Max subscription as an OpenClaw model provider.
|
||||||
*
|
*
|
||||||
* Timeout design: single CLAUDE_TIMEOUT (default 600s / 10 min).
|
* Timeout design: single CLAUDE_TIMEOUT (default 600s / 10 min).
|
||||||
@@ -11,7 +11,7 @@
|
|||||||
* This matches LiteLLM, OpenAI SDK, and other major LLM proxies.
|
* This matches LiteLLM, OpenAI SDK, and other major LLM proxies.
|
||||||
*
|
*
|
||||||
* Env vars:
|
* Env vars:
|
||||||
* CLAUDE_PROXY_PORT — listen port (default: 3456)
|
* CLAUDE_PROXY_PORT — listen port (default: DEFAULT_PORT from lib/constants.mjs)
|
||||||
* CLAUDE_BIN — path to claude binary (default: auto-detect)
|
* CLAUDE_BIN — path to claude binary (default: auto-detect)
|
||||||
* CLAUDE_TIMEOUT — per-request timeout in ms (default: 600000)
|
* CLAUDE_TIMEOUT — per-request timeout in ms (default: 600000)
|
||||||
* CLAUDE_ALLOWED_TOOLS — comma-separated tools to allow (default: expanded set)
|
* CLAUDE_ALLOWED_TOOLS — comma-separated tools to allow (default: expanded set)
|
||||||
@@ -25,23 +25,65 @@
|
|||||||
* 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";
|
||||||
import { randomUUID, timingSafeEqual } from "node:crypto";
|
import { randomUUID, timingSafeEqual } from "node:crypto";
|
||||||
import { readFileSync, accessSync, existsSync, constants } from "node:fs";
|
import { readFileSync, readdirSync, accessSync, existsSync, constants, chmodSync, statSync } 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";
|
||||||
|
import { DEFAULT_PORT } from "./lib/constants.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"));
|
||||||
const modelsConfig = JSON.parse(readFileSync(join(__dirname, "models.json"), "utf8"));
|
const modelsConfig = JSON.parse(readFileSync(join(__dirname, "models.json"), "utf8"));
|
||||||
|
|
||||||
// ── Resolve claude binary ───────────────────────────────────────────────
|
// ── Resolve claude binary ───────────────────────────────────────────────
|
||||||
// Priority: CLAUDE_BIN env > well-known paths > which lookup
|
// Priority: CLAUDE_BIN env > well-known paths > nvm/fnm/asdf user-local
|
||||||
// Fail-fast if not found — never start with an unresolvable binary.
|
// 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;
|
||||||
|
}
|
||||||
function resolveClaude() {
|
function resolveClaude() {
|
||||||
if (process.env.CLAUDE_BIN) {
|
if (process.env.CLAUDE_BIN) {
|
||||||
try {
|
try {
|
||||||
@@ -53,11 +95,13 @@ function resolveClaude() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const home = process.env.HOME || "";
|
||||||
const candidates = [
|
const candidates = [
|
||||||
"/opt/homebrew/bin/claude",
|
"/opt/homebrew/bin/claude",
|
||||||
"/usr/local/bin/claude",
|
"/usr/local/bin/claude",
|
||||||
"/usr/bin/claude",
|
"/usr/bin/claude",
|
||||||
join(process.env.HOME || "", ".local/bin/claude"),
|
join(home, ".local/bin/claude"),
|
||||||
|
..._collectNodeManagerCandidates(home),
|
||||||
];
|
];
|
||||||
for (const p of candidates) {
|
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 { accessSync(p, constants.X_OK); console.warn(`[init] CLAUDE_BIN not set, resolved to ${p}`); return p; } catch {}
|
||||||
@@ -71,14 +115,147 @@ function resolveClaude() {
|
|||||||
console.error(
|
console.error(
|
||||||
"FATAL: claude binary not found.\n" +
|
"FATAL: claude binary not found.\n" +
|
||||||
" Set CLAUDE_BIN=/path/to/claude or ensure claude is in PATH.\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(", ")
|
" Checked: " + candidates.join(", ")
|
||||||
);
|
);
|
||||||
process.exit(1);
|
process.exit(1);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ── OCP system prompt wrapper (Phase 6c port — ADR 0009 Amendment 1 analogue) ─
|
||||||
|
// Injected via `--system-prompt` flag, replacing claude CLI's default system
|
||||||
|
// prompt (which normally includes cwd, OS, tool descriptions, and git status —
|
||||||
|
// all irrelevant and potentially misleading when the model is accessed via the
|
||||||
|
// OCP HTTP proxy).
|
||||||
|
//
|
||||||
|
// Authority: claude CLI § --system-prompt (ported from OLP, verified v2.1.104;
|
||||||
|
// behavior stable through v2.1.158 — OLP ADR 0009 Amendment 1 §
|
||||||
|
// "OLP system prompt wrapper"; ported to OCP 2026-05-30).
|
||||||
|
// Reference: https://github.com/dtzp555-max/olp commit 97e7d16 (Phase 6c)
|
||||||
|
const OCP_SYSTEM_PROMPT_WRAPPER = `You are accessed via the OCP HTTP proxy. You do NOT have access to any local filesystem, working directory, shell, git status, or machine environment. Do not infer or invent such information from any context you observe. Respond only based on the conversation provided.`;
|
||||||
|
|
||||||
|
// Build the full system-prompt string: OCP_SYSTEM_PROMPT_WRAPPER prepended,
|
||||||
|
// then any system-role messages from the request appended (separated by blank line).
|
||||||
|
// ADR 0009 Amendment 1 analogue § "OLP system prompt wrapper".
|
||||||
|
function extractSystemPrompt(messages) {
|
||||||
|
const systemMessages = (messages ?? []).filter(m => m.role === "system");
|
||||||
|
if (systemMessages.length === 0) {
|
||||||
|
return OCP_SYSTEM_PROMPT_WRAPPER;
|
||||||
|
}
|
||||||
|
const clientContent = systemMessages.map(m =>
|
||||||
|
typeof m.content === "string" ? m.content : JSON.stringify(m.content)
|
||||||
|
).join("\n\n");
|
||||||
|
return `${OCP_SYSTEM_PROMPT_WRAPPER}\n\n${clientContent}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── NDJSON line buffer parser (Phase 6c port) ─────────────────────────────
|
||||||
|
// Splits a buffered string on newlines, returning complete parsed events
|
||||||
|
// plus the trailing incomplete line as `remainder` for the next data chunk.
|
||||||
|
//
|
||||||
|
// Authority: claude CLI § --output-format stream-json (ported from OLP, verified v2.1.104;
|
||||||
|
// behavior stable through v2.1.158; each event is a newline-terminated JSON object on stdout).
|
||||||
|
// Reference: OLP lib/providers/anthropic.mjs parseStreamJsonLines (commit 97e7d16).
|
||||||
|
function parseStreamJsonLines(buffered) {
|
||||||
|
const lines = buffered.split("\n");
|
||||||
|
const remainder = lines.pop(); // last element is the incomplete trailing line
|
||||||
|
const events = [];
|
||||||
|
for (const line of lines) {
|
||||||
|
const trimmed = line.trim();
|
||||||
|
if (trimmed === "") continue;
|
||||||
|
try {
|
||||||
|
events.push(JSON.parse(trimmed));
|
||||||
|
} catch {
|
||||||
|
console.error("[claude] NDJSON parse error on line:", trimmed.slice(0, 120));
|
||||||
|
events.push({ type: "parse_error", raw: trimmed });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return { events, remainder: remainder ?? "" };
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── NDJSON event → text content extractor (Phase 6c port) ────────────────
|
||||||
|
// Maps claude CLI stream-json NDJSON events to { text, stop, error } signals.
|
||||||
|
// Returns:
|
||||||
|
// { text: string } — content delta to forward
|
||||||
|
// { stop: true } — terminal event (emit finish_reason=stop)
|
||||||
|
// { error: string } — error event (emit error stop)
|
||||||
|
// null — consumed event (log/ignore)
|
||||||
|
//
|
||||||
|
// Authority: claude CLI § --output-format stream-json (ported from OLP, verified v2.1.104;
|
||||||
|
// behavior stable through v2.1.158).
|
||||||
|
// Reference: OLP lib/providers/anthropic.mjs anthropicStreamJsonEventToIR (commit 97e7d16).
|
||||||
|
//
|
||||||
|
// @param {object} event — parsed NDJSON event
|
||||||
|
// @param {boolean} isFirstDelta — true if no content has been yielded yet
|
||||||
|
function parseStreamJsonEvent(event, isFirstDelta) {
|
||||||
|
const t = event?.type;
|
||||||
|
|
||||||
|
// system/* — first-event init + other system meta (api_retry etc.)
|
||||||
|
if (t === "system") return null;
|
||||||
|
// user — echo of user message; consumed
|
||||||
|
if (t === "user") return null;
|
||||||
|
|
||||||
|
// stream_event — contains nested content_block_delta
|
||||||
|
if (t === "stream_event") {
|
||||||
|
const inner = event.event ?? event;
|
||||||
|
if (inner?.type === "content_block_delta" && inner.delta?.type === "text_delta") {
|
||||||
|
return { text: inner.delta.text ?? "" };
|
||||||
|
}
|
||||||
|
// Other stream_event sub-types (content_block_start, message_delta, etc.) — consumed
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
// assistant — aggregate message (fallback when no prior content_block_delta seen)
|
||||||
|
// Empirically (claude CLI without --include-partial-messages, verified v2.1.104 through v2.1.158): fast/short
|
||||||
|
// responses may emit ONLY the aggregate assistant event, no content_block_delta events.
|
||||||
|
// If isFirstDelta is true, extract text here; otherwise it's a duplicate, ignore.
|
||||||
|
// Reference: OLP commit 65f945c (assistant-aggregate fallback, fold-in).
|
||||||
|
if (t === "assistant") {
|
||||||
|
if (isFirstDelta) {
|
||||||
|
const blocks = event.message?.content;
|
||||||
|
if (Array.isArray(blocks)) {
|
||||||
|
const text = blocks
|
||||||
|
.filter(b => b && b.type === "text" && typeof b.text === "string")
|
||||||
|
.map(b => b.text)
|
||||||
|
.join("");
|
||||||
|
if (text) return { text };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
// result — terminal event
|
||||||
|
if (t === "result") {
|
||||||
|
if (event.is_error === true) {
|
||||||
|
return { error: event.error_message ?? event.result ?? "claude returned is_error" };
|
||||||
|
}
|
||||||
|
return { stop: true };
|
||||||
|
}
|
||||||
|
|
||||||
|
// rate_limit_event / usage — log for observability, don't forward
|
||||||
|
if (t === "rate_limit_event" || t === "usage") {
|
||||||
|
logEvent("info", "claude_stream_event", { type: t, data: JSON.stringify(event).slice(0, 200) });
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
// control_request — per Anthropic stream-json docs
|
||||||
|
if (t === "control_request") {
|
||||||
|
console.error("[claude] stream_json control_request event (ignored):", JSON.stringify(event).slice(0, 120));
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
// parse_error — already logged by parseStreamJsonLines
|
||||||
|
if (t === "parse_error") return null;
|
||||||
|
|
||||||
|
// Unknown event type — log + skip; future-proof for new claude CLI events
|
||||||
|
if (t !== undefined) {
|
||||||
|
console.error("[claude] unknown stream_json event type:", t);
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
// ── Configuration ───────────────────────────────────────────────────────
|
// ── Configuration ───────────────────────────────────────────────────────
|
||||||
// Settings marked with `let` can be changed at runtime via PATCH /settings.
|
// Settings marked with `let` can be changed at runtime via PATCH /settings.
|
||||||
const PORT = parseInt(process.env.CLAUDE_PROXY_PORT || "3456", 10);
|
const PORT = parseInt(process.env.CLAUDE_PROXY_PORT || String(DEFAULT_PORT), 10);
|
||||||
const CLAUDE = resolveClaude();
|
const CLAUDE = resolveClaude();
|
||||||
let TIMEOUT = parseInt(process.env.CLAUDE_TIMEOUT || "600000", 10);
|
let TIMEOUT = parseInt(process.env.CLAUDE_TIMEOUT || "600000", 10);
|
||||||
const PROXY_API_KEY = process.env.PROXY_API_KEY || "";
|
const PROXY_API_KEY = process.env.PROXY_API_KEY || "";
|
||||||
@@ -94,6 +271,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");
|
||||||
@@ -121,6 +299,44 @@ function logEvent(level, event, data = {}) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ── Startup file-mode reconciliation ───────────────────────────────────
|
||||||
|
// Idempotently tightens OCP credential-bearing files to 700/600 so that
|
||||||
|
// existing installs (created before this fix) are hardened on next restart.
|
||||||
|
// Wrapped in try/catch — chmod failure must never crash startup.
|
||||||
|
// Does NOT touch systemd units or launchd plists; those are managed by setup.mjs.
|
||||||
|
function _tightenFileModesIfPossible() {
|
||||||
|
const ocpDir = join(homedir(), ".ocp");
|
||||||
|
const targets = [
|
||||||
|
{ path: ocpDir, mode: 0o700, label: "~/.ocp (dir)" },
|
||||||
|
{ path: join(ocpDir, "admin-key"), mode: 0o600, label: "~/.ocp/admin-key" },
|
||||||
|
{ path: join(ocpDir, "ocp.db"), mode: 0o600, label: "~/.ocp/ocp.db" },
|
||||||
|
];
|
||||||
|
let tightened = 0;
|
||||||
|
let alreadyOk = 0;
|
||||||
|
for (const { path, mode, label } of targets) {
|
||||||
|
try {
|
||||||
|
const st = statSync(path);
|
||||||
|
const current = st.mode & 0o777;
|
||||||
|
if (current !== mode) {
|
||||||
|
chmodSync(path, mode);
|
||||||
|
tightened++;
|
||||||
|
} else {
|
||||||
|
alreadyOk++;
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
if (e.code !== "ENOENT") {
|
||||||
|
// File exists but chmod failed (e.g. EPERM) — log and move on
|
||||||
|
logEvent("warn", "file_mode_tighten_failed", { path: label, error: e.message });
|
||||||
|
}
|
||||||
|
// ENOENT is fine — file doesn't exist yet
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (tightened > 0) {
|
||||||
|
logEvent("info", "file_modes_tightened", { tightened, alreadyOk });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
_tightenFileModesIfPossible();
|
||||||
|
|
||||||
// ── Circuit breaker (DISABLED) ──────────────────────────────────────────
|
// ── Circuit breaker (DISABLED) ──────────────────────────────────────────
|
||||||
// Disabled: CLI proxy has its own retry logic, and the breaker was causing
|
// Disabled: CLI proxy has its own retry logic, and the breaker was causing
|
||||||
// cascading failures — once API got briefly slow, ALL agents lost connectivity
|
// cascading failures — once API got briefly slow, ALL agents lost connectivity
|
||||||
@@ -156,16 +372,36 @@ const MODEL_MAP = Object.fromEntries([
|
|||||||
const MODELS = modelsConfig.models.map(m => ({ id: m.id, name: m.displayName }));
|
const MODELS = modelsConfig.models.map(m => ({ id: m.id, name: m.displayName }));
|
||||||
|
|
||||||
// ── Session management ──────────────────────────────────────────────────
|
// ── Session management ──────────────────────────────────────────────────
|
||||||
// Maps conversation IDs (from caller) to Claude CLI session UUIDs.
|
// Maps namespaced session keys to Claude CLI session UUIDs.
|
||||||
|
// Key format: "${keyName}|${conversationId}" — prevents cross-key collision
|
||||||
|
// when two callers (different API keys or anon + authenticated) use the same
|
||||||
|
// session_id string. Anonymous callers use "anon"; admin uses "admin".
|
||||||
// Enables --resume for multi-turn conversations, reducing token waste.
|
// Enables --resume for multi-turn conversations, reducing token waste.
|
||||||
const sessions = new Map(); // conversationId → { uuid, messageCount, lastUsed, model }
|
const sessions = new Map(); // `${keyName}|${conversationId}` → { uuid, messageCount, lastUsed, model }
|
||||||
|
|
||||||
|
// Build the namespaced key used for all sessions Map operations.
|
||||||
|
// Returns null when conversationId is falsy (one-off requests bypass session tracking).
|
||||||
|
function _sessionKey(conversationId, keyName) {
|
||||||
|
return conversationId ? `${keyName || "anon"}|${conversationId}` : null;
|
||||||
|
}
|
||||||
|
|
||||||
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;
|
||||||
|
// id is "${keyName}|${conversationId}"; strip prefix for log output
|
||||||
|
const convIdShort = id.includes("|") ? id.slice(id.indexOf("|") + 1, id.indexOf("|") + 13) : id.slice(0, 12);
|
||||||
|
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 ${convIdShort}... (idle ${Math.round(idleMs / 60000)}m)`);
|
||||||
|
logEvent("info", "session_expired", { conversationId: convIdShort + "...", 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: convIdShort + "...", idleMs, ageMs });
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}, 60000);
|
}, 60000);
|
||||||
@@ -276,17 +512,29 @@ checkAuth();
|
|||||||
const authCheckInterval = setInterval(checkAuth, 600000);
|
const authCheckInterval = setInterval(checkAuth, 600000);
|
||||||
|
|
||||||
// ── Build CLI arguments ─────────────────────────────────────────────────
|
// ── Build CLI arguments ─────────────────────────────────────────────────
|
||||||
function buildCliArgs(cliModel, sessionInfo) {
|
// Phase 6c port (2026-05-30): removed `-p` / `--output-format text`.
|
||||||
const args = ["-p", "--model", cliModel, "--output-format", "text"];
|
// Now uses `--output-format stream-json --verbose --no-session-persistence
|
||||||
|
// --system-prompt <OCP_SYSTEM_PROMPT_WRAPPER + client system messages>`.
|
||||||
// Session handling
|
//
|
||||||
if (sessionInfo?.resume) {
|
// Authority: claude CLI § --output-format stream-json, § --verbose,
|
||||||
args.push("--resume", sessionInfo.uuid);
|
// § --no-session-persistence, § --system-prompt (ported from OLP, verified v2.1.104;
|
||||||
} else if (sessionInfo?.uuid) {
|
// behavior stable through v2.1.158).
|
||||||
args.push("--session-id", sessionInfo.uuid);
|
// Reference: OLP ADR 0009 Amendment 1 + commit 97e7d16.
|
||||||
} else {
|
//
|
||||||
args.push("--no-session-persistence");
|
// Session flags (--resume, --session-id) are dropped: they are incompatible
|
||||||
}
|
// with stream-json mode without -p. OCP always passes full conversation context
|
||||||
|
// via stdin instead (messagesToPrompt), preserving multi-turn correctness.
|
||||||
|
// CLAUDE_SYSTEM_PROMPT env var is absorbed into the system prompt via
|
||||||
|
// extractSystemPrompt() at the caller level; APPEND_SYSTEM_PROMPT no longer used.
|
||||||
|
// Note: ALLOWED_TOOLS / SKIP_PERMISSIONS / MCP_CONFIG are preserved as before.
|
||||||
|
function buildCliArgs(cliModel, systemPrompt) {
|
||||||
|
const args = [
|
||||||
|
"--model", cliModel,
|
||||||
|
"--output-format", "stream-json",
|
||||||
|
"--verbose",
|
||||||
|
"--no-session-persistence",
|
||||||
|
"--system-prompt", systemPrompt,
|
||||||
|
];
|
||||||
|
|
||||||
// Permissions
|
// Permissions
|
||||||
if (SKIP_PERMISSIONS) {
|
if (SKIP_PERMISSIONS) {
|
||||||
@@ -295,11 +543,6 @@ function buildCliArgs(cliModel, sessionInfo) {
|
|||||||
args.push("--allowedTools", ...ALLOWED_TOOLS);
|
args.push("--allowedTools", ...ALLOWED_TOOLS);
|
||||||
}
|
}
|
||||||
|
|
||||||
// System prompt
|
|
||||||
if (SYSTEM_PROMPT) {
|
|
||||||
args.push("--append-system-prompt", SYSTEM_PROMPT);
|
|
||||||
}
|
|
||||||
|
|
||||||
// MCP config
|
// MCP config
|
||||||
if (MCP_CONFIG) {
|
if (MCP_CONFIG) {
|
||||||
args.push("--mcp-config", MCP_CONFIG);
|
args.push("--mcp-config", MCP_CONFIG);
|
||||||
@@ -370,9 +613,16 @@ function getModelTier(cliModel) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// ── Spawn claude CLI (shared setup) ─────────────────────────────────────
|
// ── Spawn claude CLI (shared setup) ─────────────────────────────────────
|
||||||
// Resolves session logic, builds CLI args, spawns the process, and sets up
|
// Builds CLI args, spawns the process, and sets up timeouts.
|
||||||
// timeouts. Returns context object or throws synchronously.
|
// Returns context object or throws synchronously.
|
||||||
function spawnClaudeProcess(model, messages, conversationId) {
|
//
|
||||||
|
// Phase 6c port (2026-05-30): session resume (--resume / --session-id) is
|
||||||
|
// dropped because it is incompatible with stream-json mode without -p.
|
||||||
|
// OCP now always passes the full serialized conversation via stdin
|
||||||
|
// (messagesToPrompt), so multi-turn correctness is preserved without sessions.
|
||||||
|
// The sessions Map is retained for stats/logging but no longer drives --resume.
|
||||||
|
// Reference: OLP ADR 0009 Amendment 1 + commit 97e7d16.
|
||||||
|
function spawnClaudeProcess(model, messages, conversationId, keyName) {
|
||||||
if (stats.activeRequests >= MAX_CONCURRENT) {
|
if (stats.activeRequests >= MAX_CONCURRENT) {
|
||||||
throw new Error(`concurrency limit reached (${stats.activeRequests}/${MAX_CONCURRENT})`);
|
throw new Error(`concurrency limit reached (${stats.activeRequests}/${MAX_CONCURRENT})`);
|
||||||
}
|
}
|
||||||
@@ -384,39 +634,22 @@ function spawnClaudeProcess(model, messages, conversationId) {
|
|||||||
stats.activeRequests++;
|
stats.activeRequests++;
|
||||||
stats.totalRequests++;
|
stats.totalRequests++;
|
||||||
|
|
||||||
let sessionInfo = null;
|
// Phase 6c: always serialize full conversation via stdin (no session resume).
|
||||||
let prompt;
|
// System messages are extracted and passed via --system-prompt; the remaining
|
||||||
|
// messages (user/assistant/tool) are serialized by messagesToPrompt.
|
||||||
|
const systemPrompt = extractSystemPrompt(messages);
|
||||||
|
|
||||||
// ── Session logic ──
|
// messagesToPrompt skips system messages now that they go via --system-prompt.
|
||||||
if (conversationId && sessions.has(conversationId)) {
|
// Filter them out before calling to avoid double-injection.
|
||||||
const session = sessions.get(conversationId);
|
const nonSystemMessages = messages.filter(m => m.role !== "system");
|
||||||
session.lastUsed = Date.now();
|
const prompt = messagesToPrompt(nonSystemMessages);
|
||||||
sessionInfo = { uuid: session.uuid, resume: true };
|
|
||||||
stats.sessionHits++;
|
|
||||||
|
|
||||||
const lastUserMsg = [...messages].reverse().find((m) => m.role === "user");
|
stats.oneOffRequests++;
|
||||||
prompt = lastUserMsg
|
if (conversationId) {
|
||||||
? (typeof lastUserMsg.content === "string" ? lastUserMsg.content : JSON.stringify(lastUserMsg.content))
|
console.log(`[session] stateless conv=${conversationId.slice(0, 12)}... key=${keyName || "anon"} msgs=${messages.length} prompt_chars=${prompt.length}`);
|
||||||
: "";
|
|
||||||
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) {
|
|
||||||
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 {
|
|
||||||
stats.oneOffRequests++;
|
|
||||||
prompt = messagesToPrompt(messages);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const cliArgs = buildCliArgs(cliModel, sessionInfo);
|
const cliArgs = buildCliArgs(cliModel, systemPrompt);
|
||||||
|
|
||||||
const env = { ...process.env };
|
const env = { ...process.env };
|
||||||
delete env.CLAUDECODE;
|
delete env.CLAUDECODE;
|
||||||
@@ -454,9 +687,10 @@ function spawnClaudeProcess(model, messages, conversationId) {
|
|||||||
proc.once("exit", cleanup);
|
proc.once("exit", cleanup);
|
||||||
|
|
||||||
function handleSessionFailure() {
|
function handleSessionFailure() {
|
||||||
if (sessionInfo?.resume && conversationId) {
|
// Phase 6c: session resume (--resume/--session-id) is no longer used;
|
||||||
console.warn(`[session] resume failed for ${conversationId.slice(0, 12)}..., removing stale session`);
|
// OCP always passes full context via stdin. No session state to clean up.
|
||||||
sessions.delete(conversationId);
|
if (conversationId) {
|
||||||
|
logEvent("warn", "session_failure", { mode: "stateless", conversationId: conversationId.slice(0, 12) + "...", action: "none" });
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -492,25 +726,48 @@ function spawnClaudeProcess(model, messages, conversationId) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// ── Call claude CLI (non-streaming) ─────────────────────────────────────
|
// ── Call claude CLI (non-streaming) ─────────────────────────────────────
|
||||||
// On-demand spawning: each request spawns a fresh `claude -p` process.
|
// On-demand spawning: each request spawns a fresh claude process.
|
||||||
// No pool = no crash loops, no stale workers, no degraded states.
|
// No pool = no crash loops, no stale workers, no degraded states.
|
||||||
// Stdin is written immediately so there's no 3s stdin timeout issue.
|
// Stdin is written immediately so there's no 3s stdin timeout issue.
|
||||||
function callClaude(model, messages, conversationId) {
|
//
|
||||||
|
// Phase 6c port (2026-05-30): stdout is now NDJSON (stream-json format).
|
||||||
|
// We accumulate full text across all content_block_delta events plus the
|
||||||
|
// assistant-aggregate fallback, then resolve with the assembled string.
|
||||||
|
// Reference: OLP ADR 0009 Amendment 1 + commit 97e7d16.
|
||||||
|
function callClaude(model, messages, conversationId, keyName) {
|
||||||
return new Promise((resolve, reject) => {
|
return new Promise((resolve, reject) => {
|
||||||
let ctx;
|
let ctx;
|
||||||
try {
|
try {
|
||||||
ctx = spawnClaudeProcess(model, messages, conversationId);
|
ctx = spawnClaudeProcess(model, messages, conversationId, keyName);
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
return reject(err);
|
return reject(err);
|
||||||
}
|
}
|
||||||
|
|
||||||
const { proc, cliModel, conversationId: convId, t0, cleanup, handleSessionFailure, markFirstByte } = ctx;
|
const { proc, cliModel, conversationId: convId, t0, cleanup, handleSessionFailure, markFirstByte } = ctx;
|
||||||
let stdout = "";
|
let lineBuffer = "";
|
||||||
|
let assembledText = "";
|
||||||
|
let isFirstDelta = true;
|
||||||
|
let resultEventSeen = false;
|
||||||
let stderr = "";
|
let stderr = "";
|
||||||
|
|
||||||
proc.stdout.on("data", (d) => {
|
proc.stdout.on("data", (d) => {
|
||||||
markFirstByte();
|
markFirstByte();
|
||||||
stdout += d;
|
lineBuffer += d.toString();
|
||||||
|
const { events, remainder } = parseStreamJsonLines(lineBuffer);
|
||||||
|
lineBuffer = remainder;
|
||||||
|
for (const event of events) {
|
||||||
|
const parsed = parseStreamJsonEvent(event, isFirstDelta);
|
||||||
|
if (!parsed) continue;
|
||||||
|
if (parsed.text !== undefined) {
|
||||||
|
assembledText += parsed.text;
|
||||||
|
isFirstDelta = false;
|
||||||
|
} else if (parsed.stop) {
|
||||||
|
resultEventSeen = true;
|
||||||
|
} else if (parsed.error) {
|
||||||
|
// is_error result — treat as process error
|
||||||
|
reject(new Error(parsed.error));
|
||||||
|
}
|
||||||
|
}
|
||||||
});
|
});
|
||||||
proc.stderr.on("data", (d) => (stderr += d));
|
proc.stderr.on("data", (d) => (stderr += d));
|
||||||
|
|
||||||
@@ -518,17 +775,19 @@ function callClaude(model, messages, conversationId) {
|
|||||||
activeProcesses.delete(proc);
|
activeProcesses.delete(proc);
|
||||||
const elapsed = Date.now() - t0;
|
const elapsed = Date.now() - t0;
|
||||||
cleanup();
|
cleanup();
|
||||||
if (code !== 0) {
|
// Tolerate null exit code when result event was seen (sandbox-wrap noise, same
|
||||||
|
// as OLP commit 2864275 — bwrap shell exits null after model completes).
|
||||||
|
if (code !== 0 && !resultEventSeen) {
|
||||||
recordModelError(cliModel, false);
|
recordModelError(cliModel, false);
|
||||||
logEvent("error", "claude_exit", { model: cliModel, code, signal: signal || "none", elapsed, stderr: stderr.slice(0, 300) });
|
logEvent("error", "claude_exit", { model: cliModel, code, signal: signal || "none", elapsed, stderr: stderr.slice(0, 300) });
|
||||||
trackError(stderr.slice(0, 300) || stdout.slice(0, 300) || `claude exit ${code}`);
|
trackError(stderr.slice(0, 300) || assembledText.slice(0, 300) || `claude exit ${code}`);
|
||||||
handleSessionFailure();
|
handleSessionFailure();
|
||||||
reject(new Error(stderr.slice(0, 300) || stdout.slice(0, 300) || `claude exit ${code}`));
|
reject(new Error(stderr.slice(0, 300) || assembledText.slice(0, 300) || `claude exit ${code}`));
|
||||||
} else {
|
} else {
|
||||||
recordModelSuccess(cliModel, elapsed);
|
recordModelSuccess(cliModel, elapsed);
|
||||||
breakerRecordSuccess(cliModel);
|
breakerRecordSuccess(cliModel);
|
||||||
logEvent("info", "claude_ok", { model: cliModel, chars: stdout.length, elapsed, session: convId ? convId.slice(0, 12) + "..." : "none" });
|
logEvent("info", "claude_ok", { model: cliModel, chars: assembledText.length, elapsed, session: convId ? convId.slice(0, 12) + "..." : "none" });
|
||||||
resolve(stdout.trim());
|
resolve(assembledText);
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -542,16 +801,49 @@ 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 as SSE chunks as they arrive.
|
||||||
// Each data chunk becomes a proper SSE event with delta content in real time.
|
// Each NDJSON content_block_delta text event becomes one SSE delta.
|
||||||
|
// TODO(cache-singleflight-stream): streaming-path singleflight is out of scope for v3.13.0; see spec D4 streaming caveat.
|
||||||
|
//
|
||||||
|
// Phase 6c port (2026-05-30): stdout is now NDJSON (stream-json format).
|
||||||
|
// We parse line-by-line and forward content_block_delta text events as SSE.
|
||||||
|
// The result event triggers the stop/[DONE] sequence.
|
||||||
|
// Reference: OLP ADR 0009 Amendment 1 + commits 97e7d16, 65f945c.
|
||||||
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);
|
||||||
|
|
||||||
let ctx;
|
let ctx;
|
||||||
try {
|
try {
|
||||||
ctx = spawnClaudeProcess(model, messages, conversationId);
|
ctx = spawnClaudeProcess(model, messages, conversationId, authInfo.keyName);
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
return jsonResponse(res, 500, { error: { message: err.message, type: "proxy_error" } });
|
return jsonResponse(res, 500, { error: { message: err.message, type: "proxy_error" } });
|
||||||
}
|
}
|
||||||
@@ -561,14 +853,19 @@ function callClaudeStreaming(model, messages, conversationId, res, authInfo = {}
|
|||||||
let headersSent = false;
|
let headersSent = false;
|
||||||
let totalChars = 0;
|
let totalChars = 0;
|
||||||
let cachedContent = ""; // accumulate for cache write-back
|
let cachedContent = ""; // accumulate for cache write-back
|
||||||
|
let lineBuffer = "";
|
||||||
|
let isFirstDelta = true;
|
||||||
|
let resultEventSeen = false;
|
||||||
|
|
||||||
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,29 +875,77 @@ 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 covers the pre-first-byte silent window.
|
||||||
|
ensureHeaders();
|
||||||
|
const hb = startHeartbeat(res, HEARTBEAT_INTERVAL, convId);
|
||||||
|
|
||||||
proc.stdout.on("data", (d) => {
|
proc.stdout.on("data", (d) => {
|
||||||
markFirstByte();
|
markFirstByte();
|
||||||
const text = d.toString();
|
lineBuffer += d.toString();
|
||||||
totalChars += text.length;
|
const { events, remainder } = parseStreamJsonLines(lineBuffer);
|
||||||
if (CACHE_TTL > 0) cachedContent += text;
|
lineBuffer = remainder;
|
||||||
|
|
||||||
if (!ensureHeaders()) return;
|
for (const event of events) {
|
||||||
|
const parsed = parseStreamJsonEvent(event, isFirstDelta);
|
||||||
|
if (!parsed) continue;
|
||||||
|
|
||||||
// Stream each chunk as it arrives from the CLI process
|
if (parsed.text !== undefined) {
|
||||||
sendSSE(res, {
|
// content_block_delta text — forward as SSE delta
|
||||||
id, object: "chat.completion.chunk", created, model,
|
const text = parsed.text;
|
||||||
choices: [{ index: 0, delta: { content: text }, finish_reason: null }],
|
totalChars += text.length;
|
||||||
});
|
if (CACHE_TTL > 0) cachedContent += text;
|
||||||
|
isFirstDelta = false;
|
||||||
|
|
||||||
|
if (!ensureHeaders()) continue;
|
||||||
|
sendSSE(res, {
|
||||||
|
id, object: "chat.completion.chunk", created, model,
|
||||||
|
choices: [{ index: 0, delta: { content: text }, finish_reason: null }],
|
||||||
|
}, hb);
|
||||||
|
|
||||||
|
} else if (parsed.stop) {
|
||||||
|
// result event — emit stop and [DONE] immediately
|
||||||
|
resultEventSeen = true;
|
||||||
|
if (!ensureHeaders()) continue;
|
||||||
|
sendSSE(res, {
|
||||||
|
id, object: "chat.completion.chunk", created, model,
|
||||||
|
choices: [{ index: 0, delta: {}, finish_reason: "stop" }],
|
||||||
|
}, hb);
|
||||||
|
if (!res.writableEnded && !res.destroyed) {
|
||||||
|
res.write("data: [DONE]\n\n");
|
||||||
|
res.end();
|
||||||
|
}
|
||||||
|
|
||||||
|
} else if (parsed.error) {
|
||||||
|
// is_error result — emit error stop
|
||||||
|
resultEventSeen = true;
|
||||||
|
logEvent("error", "claude_result_error", { model: cliModel, error: parsed.error.slice(0, 200) });
|
||||||
|
trackError(parsed.error.slice(0, 200));
|
||||||
|
if (!headersSent && !res.writableEnded && !res.destroyed) {
|
||||||
|
jsonResponse(res, 500, { error: { message: parsed.error, type: "provider_error" } });
|
||||||
|
} else if (!res.writableEnded && !res.destroyed) {
|
||||||
|
sendSSE(res, {
|
||||||
|
id, object: "chat.completion.chunk", created, model,
|
||||||
|
choices: [{ index: 0, delta: {}, finish_reason: "stop" }],
|
||||||
|
}, hb);
|
||||||
|
res.write("data: [DONE]\n\n");
|
||||||
|
res.end();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
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;
|
||||||
|
|
||||||
if (code !== 0) {
|
// Tolerate null exit code when result event was seen (sandbox-wrap noise, same
|
||||||
|
// as OLP commit 2864275 — bwrap shell exits null after model completes).
|
||||||
|
if (code !== 0 && !resultEventSeen) {
|
||||||
recordModelError(cliModel, false);
|
recordModelError(cliModel, false);
|
||||||
try { recordUsage({ keyId: authInfo.keyId, keyName: authInfo.keyName, model, promptChars: messages.reduce((a, m) => a + (typeof m.content === "string" ? m.content.length : JSON.stringify(m.content).length), 0), responseChars: 0, elapsedMs: elapsed, success: false }); } catch (e) { logEvent("error", "usage_record_failed", { error: e.message }); }
|
try { recordUsage({ keyId: authInfo.keyId, keyName: authInfo.keyName, model, promptChars: messages.reduce((a, m) => a + (typeof m.content === "string" ? m.content.length : JSON.stringify(m.content).length), 0), responseChars: 0, elapsedMs: elapsed, success: false }); } catch (e) { logEvent("error", "usage_record_failed", { error: e.message }); }
|
||||||
logEvent("error", "claude_exit", { model: cliModel, code, signal: signal || "none", elapsed, stderr: stderr.slice(0, 300) });
|
logEvent("error", "claude_exit", { model: cliModel, code, signal: signal || "none", elapsed, stderr: stderr.slice(0, 300) });
|
||||||
@@ -613,7 +958,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();
|
||||||
}
|
}
|
||||||
@@ -627,20 +972,25 @@ function callClaudeStreaming(model, messages, conversationId, res, authInfo = {}
|
|||||||
try { setCachedResponse(authInfo.cacheHash, model, cachedContent); } catch (e) { logEvent("error", "cache_write_failed", { error: e.message }); }
|
try { setCachedResponse(authInfo.cacheHash, model, cachedContent); } catch (e) { logEvent("error", "cache_write_failed", { error: e.message }); }
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!headersSent) ensureHeaders();
|
// If result event already closed the response, nothing more to do.
|
||||||
if (!res.writableEnded && !res.destroyed) {
|
// Otherwise emit a synthetic stop (version drift safety net, same as OLP).
|
||||||
sendSSE(res, {
|
if (!resultEventSeen) {
|
||||||
id, object: "chat.completion.chunk", created, model,
|
if (!headersSent) ensureHeaders();
|
||||||
choices: [{ index: 0, delta: {}, finish_reason: "stop" }],
|
if (!res.writableEnded && !res.destroyed) {
|
||||||
});
|
sendSSE(res, {
|
||||||
res.write("data: [DONE]\n\n");
|
id, object: "chat.completion.chunk", created, model,
|
||||||
res.end();
|
choices: [{ index: 0, delta: {}, finish_reason: "stop" }],
|
||||||
|
}, hb);
|
||||||
|
res.write("data: [DONE]\n\n");
|
||||||
|
res.end();
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
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 +1003,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 +1017,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 +1510,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 +1557,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, req._authKeyName);
|
||||||
|
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, req._authKeyName);
|
||||||
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 }); }
|
||||||
@@ -1331,8 +1727,10 @@ const server = createServer(async (req, res) => {
|
|||||||
const uptimeMs = Date.now() - START_TIME;
|
const uptimeMs = Date.now() - START_TIME;
|
||||||
const sessionList = [];
|
const sessionList = [];
|
||||||
for (const [id, s] of sessions) {
|
for (const [id, s] of sessions) {
|
||||||
|
// id is "${keyName}|${conversationId}"; expose only the public-facing conversationId
|
||||||
|
const convId = id.includes("|") ? id.slice(id.indexOf("|") + 1) : id;
|
||||||
sessionList.push({
|
sessionList.push({
|
||||||
id: id.slice(0, 12) + "...",
|
id: convId.slice(0, 12) + "...",
|
||||||
model: s.model,
|
model: s.model,
|
||||||
messages: s.messageCount,
|
messages: s.messageCount,
|
||||||
idleMs: Date.now() - s.lastUsed,
|
idleMs: Date.now() - s.lastUsed,
|
||||||
@@ -1377,7 +1775,9 @@ const server = createServer(async (req, res) => {
|
|||||||
if (req.url === "/sessions" && req.method === "GET") {
|
if (req.url === "/sessions" && req.method === "GET") {
|
||||||
const list = [];
|
const list = [];
|
||||||
for (const [id, s] of sessions) {
|
for (const [id, s] of sessions) {
|
||||||
list.push({ id, uuid: s.uuid, model: s.model, messages: s.messageCount, lastUsed: new Date(s.lastUsed).toISOString() });
|
// id is "${keyName}|${conversationId}"; expose only the public-facing conversationId
|
||||||
|
const convId = id.includes("|") ? id.slice(id.indexOf("|") + 1) : id;
|
||||||
|
list.push({ id: convId, uuid: s.uuid, model: s.model, messages: s.messageCount, lastUsed: new Date(s.lastUsed).toISOString() });
|
||||||
}
|
}
|
||||||
return jsonResponse(res, 200, { sessions: list });
|
return jsonResponse(res, 200, { sessions: list });
|
||||||
}
|
}
|
||||||
@@ -1467,21 +1867,52 @@ const server = createServer(async (req, res) => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (req.url?.startsWith("/api/usage") && req.method === "GET") {
|
if (req.url?.startsWith("/api/usage") && req.method === "GET") {
|
||||||
if (!isAdmin) return jsonResponse(res, 403, { error: "Admin access required" });
|
// 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.
|
||||||
const url = new URL(req.url, `http://${BIND_ADDRESS}:${PORT}`);
|
const url = new URL(req.url, `http://${BIND_ADDRESS}:${PORT}`);
|
||||||
const since = url.searchParams.get("since");
|
const since = url.searchParams.get("since");
|
||||||
const until = url.searchParams.get("until");
|
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, {
|
return jsonResponse(res, 200, {
|
||||||
byKey: getUsageByKey({ since, until }),
|
byKey,
|
||||||
timeline: getUsageTimeline({ hours: Math.min(parseInt(url.searchParams.get("hours") || "24", 10), 720) }),
|
timeline,
|
||||||
recent: getRecentUsage(Math.min(parseInt(url.searchParams.get("limit") || "50", 10), 500)),
|
recent,
|
||||||
|
scope: { self: scopeName, all: fullScope },
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
// 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
|
||||||
@@ -1583,7 +2014,7 @@ server.listen(PORT, BIND_ADDRESS, () => {
|
|||||||
else console.log(`Cache: disabled (set CLAUDE_CACHE_TTL to enable)`);
|
else console.log(`Cache: disabled (set CLAUDE_CACHE_TTL to enable)`);
|
||||||
console.log(`---`);
|
console.log(`---`);
|
||||||
console.log(`Coexistence: This proxy does NOT conflict with Claude Code interactive mode.`);
|
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(` OCP uses: localhost:${PORT} (HTTP) → claude --output-format stream-json (per-request process)`);
|
||||||
console.log(` CC uses: MCP protocol (in-process) → persistent session`);
|
console.log(` CC uses: MCP protocol (in-process) → persistent session`);
|
||||||
console.log(` Both can run simultaneously on the same machine.`);
|
console.log(` Both can run simultaneously on the same machine.`);
|
||||||
|
|
||||||
|
|||||||
@@ -1,9 +1,10 @@
|
|||||||
#!/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 N] [--default-model opus|sonnet|haiku] [--dry-run]
|
||||||
|
* (default port = DEFAULT_PORT from lib/constants.mjs)
|
||||||
*
|
*
|
||||||
* What it does:
|
* What it does:
|
||||||
* 1. Verifies claude CLI is installed and authenticated
|
* 1. Verifies claude CLI is installed and authenticated
|
||||||
@@ -12,11 +13,13 @@
|
|||||||
* 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, chmodSync } from "node:fs";
|
||||||
|
import { mergePlistEnv, mergeSystemdEnv } from "./scripts/lib/plist-merge.mjs";
|
||||||
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";
|
||||||
import { fileURLToPath } from "node:url";
|
import { fileURLToPath } from "node:url";
|
||||||
|
import { DEFAULT_PORT } from "./lib/constants.mjs";
|
||||||
|
|
||||||
const __dirname = dirname(fileURLToPath(import.meta.url));
|
const __dirname = dirname(fileURLToPath(import.meta.url));
|
||||||
const HOME = homedir();
|
const HOME = homedir();
|
||||||
@@ -31,7 +34,7 @@ const opt = (name, fallback) => {
|
|||||||
return i >= 0 && args[i + 1] ? args[i + 1] : fallback;
|
return i >= 0 && args[i + 1] ? args[i + 1] : fallback;
|
||||||
};
|
};
|
||||||
|
|
||||||
const PORT = parseInt(opt("port", "3456"), 10);
|
const PORT = parseInt(opt("port", String(DEFAULT_PORT)), 10);
|
||||||
const DEFAULT_MODEL = opt("default-model", "opus"); // opus | sonnet | haiku
|
const DEFAULT_MODEL = opt("default-model", "opus"); // opus | sonnet | haiku
|
||||||
const DRY_RUN = flag("dry-run");
|
const DRY_RUN = flag("dry-run");
|
||||||
const SKIP_START = flag("no-start");
|
const SKIP_START = flag("no-start");
|
||||||
@@ -39,6 +42,29 @@ const PROVIDER_NAME = opt("provider-name", "claude-local");
|
|||||||
const BIND_ADDRESS = opt("bind", "127.0.0.1");
|
const BIND_ADDRESS = opt("bind", "127.0.0.1");
|
||||||
const AUTH_MODE_CONFIG = opt("auth-mode", "none");
|
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) ──────────
|
// ── Models: derived from models.json (single source of truth) ──────────
|
||||||
const modelsConfig = JSON.parse(readFileSync(join(__dirname, "models.json"), "utf-8"));
|
const modelsConfig = JSON.parse(readFileSync(join(__dirname, "models.json"), "utf-8"));
|
||||||
|
|
||||||
@@ -107,106 +133,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 +249,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,40 +270,71 @@ 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 ──────────────────────────────────
|
||||||
|
|
||||||
|
// 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) {
|
if (!DRY_RUN) {
|
||||||
console.log("\n🔄 Installing auto-start on login...\n");
|
console.log("\n🔄 Installing auto-start on login...\n");
|
||||||
|
|
||||||
@@ -344,7 +407,13 @@ if (!DRY_RUN) {
|
|||||||
<key>CLAUDE_BIND</key>
|
<key>CLAUDE_BIND</key>
|
||||||
<string>${BIND_ADDRESS}</string>
|
<string>${BIND_ADDRESS}</string>
|
||||||
<key>CLAUDE_AUTH_MODE</key>
|
<key>CLAUDE_AUTH_MODE</key>
|
||||||
<string>${AUTH_MODE_CONFIG}</string>
|
<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>` : ""}
|
||||||
</dict>
|
</dict>
|
||||||
<key>RunAtLoad</key>
|
<key>RunAtLoad</key>
|
||||||
<true/>
|
<true/>
|
||||||
@@ -358,8 +427,15 @@ if (!DRY_RUN) {
|
|||||||
</plist>
|
</plist>
|
||||||
`;
|
`;
|
||||||
|
|
||||||
writeFileSync(plistPath, plistXml);
|
const existingPlist = existsSync(plistPath) ? readFileSync(plistPath, "utf8") : null;
|
||||||
log(`Plist written: ${plistPath}`);
|
const finalPlistXml = mergePlistEnv(existingPlist, plistXml);
|
||||||
|
writeFileSync(plistPath, finalPlistXml);
|
||||||
|
chmodSync(plistPath, 0o600);
|
||||||
|
if (existingPlist && finalPlistXml !== plistXml) {
|
||||||
|
log(`Plist written: ${plistPath} (mode 600, preserved user env vars)`);
|
||||||
|
} else {
|
||||||
|
log(`Plist written: ${plistPath} (mode 600)`);
|
||||||
|
}
|
||||||
|
|
||||||
// Bootout first (in case it was already loaded) then bootstrap
|
// Bootout first (in case it was already loaded) then bootstrap
|
||||||
try { execSync(`launchctl bootout gui/$(id -u) "${plistPath}" 2>/dev/null`); } catch { /* ignore */ }
|
try { execSync(`launchctl bootout gui/$(id -u) "${plistPath}" 2>/dev/null`); } catch { /* ignore */ }
|
||||||
@@ -382,7 +458,7 @@ After=network.target
|
|||||||
ExecStart=${nodeBin} ${serverPath}
|
ExecStart=${nodeBin} ${serverPath}
|
||||||
Environment=CLAUDE_PROXY_PORT=${PORT}
|
Environment=CLAUDE_PROXY_PORT=${PORT}
|
||||||
Environment=CLAUDE_BIND=${BIND_ADDRESS}
|
Environment=CLAUDE_BIND=${BIND_ADDRESS}
|
||||||
Environment=CLAUDE_AUTH_MODE=${AUTH_MODE_CONFIG}
|
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}` : ""}
|
||||||
Restart=always
|
Restart=always
|
||||||
RestartSec=5
|
RestartSec=5
|
||||||
StandardOutput=append:${logPath}
|
StandardOutput=append:${logPath}
|
||||||
@@ -392,8 +468,15 @@ StandardError=append:${logPath}
|
|||||||
WantedBy=default.target
|
WantedBy=default.target
|
||||||
`;
|
`;
|
||||||
|
|
||||||
writeFileSync(servicePath, serviceUnit);
|
const existingService = existsSync(servicePath) ? readFileSync(servicePath, "utf8") : null;
|
||||||
log(`Service file written: ${servicePath}`);
|
const finalServiceUnit = mergeSystemdEnv(existingService, serviceUnit);
|
||||||
|
writeFileSync(servicePath, finalServiceUnit);
|
||||||
|
chmodSync(servicePath, 0o600);
|
||||||
|
if (existingService && finalServiceUnit !== serviceUnit) {
|
||||||
|
log(`Service file written: ${servicePath} (mode 600, preserved user env vars)`);
|
||||||
|
} else {
|
||||||
|
log(`Service file written: ${servicePath} (mode 600)`);
|
||||||
|
}
|
||||||
|
|
||||||
execSync(`systemctl --user daemon-reload`);
|
execSync(`systemctl --user daemon-reload`);
|
||||||
execSync(`systemctl --user enable ocp-proxy`);
|
execSync(`systemctl --user enable ocp-proxy`);
|
||||||
@@ -405,4 +488,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);
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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
|
|
||||||
+941
-1
@@ -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,945 @@ 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();
|
||||||
|
|
||||||
|
// ── Plist Env Merge Tests ──
|
||||||
|
import { mergePlistEnv, mergeSystemdEnv } from "./scripts/lib/plist-merge.mjs";
|
||||||
|
|
||||||
|
console.log("\nPlist env merge:");
|
||||||
|
|
||||||
|
const SAMPLE_TEMPLATE_PLIST = `<?xml version="1.0" encoding="UTF-8"?>
|
||||||
|
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
|
||||||
|
<plist version="1.0">
|
||||||
|
<dict>
|
||||||
|
<key>Label</key>
|
||||||
|
<string>dev.ocp.proxy</string>
|
||||||
|
<key>EnvironmentVariables</key>
|
||||||
|
<dict>
|
||||||
|
<key>CLAUDE_PROXY_PORT</key>
|
||||||
|
<string>3478</string>
|
||||||
|
<key>CLAUDE_BIND</key>
|
||||||
|
<string>127.0.0.1</string>
|
||||||
|
<key>CLAUDE_AUTH_MODE</key>
|
||||||
|
<string>multi</string>
|
||||||
|
</dict>
|
||||||
|
</dict>
|
||||||
|
</plist>`;
|
||||||
|
|
||||||
|
const SAMPLE_EXISTING_PLIST = `<?xml version="1.0" encoding="UTF-8"?>
|
||||||
|
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
|
||||||
|
<plist version="1.0">
|
||||||
|
<dict>
|
||||||
|
<key>Label</key>
|
||||||
|
<string>dev.ocp.proxy</string>
|
||||||
|
<key>EnvironmentVariables</key>
|
||||||
|
<dict>
|
||||||
|
<key>CLAUDE_PROXY_PORT</key>
|
||||||
|
<string>3456</string>
|
||||||
|
<key>CLAUDE_BIND</key>
|
||||||
|
<string>127.0.0.1</string>
|
||||||
|
<key>CLAUDE_AUTH_MODE</key>
|
||||||
|
<string>none</string>
|
||||||
|
<key>CLAUDE_HEARTBEAT_INTERVAL</key>
|
||||||
|
<string>2000</string>
|
||||||
|
<key>CLAUDE_CACHE_TTL</key>
|
||||||
|
<string>600</string>
|
||||||
|
</dict>
|
||||||
|
</dict>
|
||||||
|
</plist>`;
|
||||||
|
|
||||||
|
test("mergePlistEnv preserves unknown user keys", () => {
|
||||||
|
const merged = mergePlistEnv(SAMPLE_EXISTING_PLIST, SAMPLE_TEMPLATE_PLIST);
|
||||||
|
assert.match(merged, /<key>CLAUDE_HEARTBEAT_INTERVAL<\/key>\s*<string>2000<\/string>/);
|
||||||
|
assert.match(merged, /<key>CLAUDE_CACHE_TTL<\/key>\s*<string>600<\/string>/);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("mergePlistEnv overrides known template keys", () => {
|
||||||
|
const merged = mergePlistEnv(SAMPLE_EXISTING_PLIST, SAMPLE_TEMPLATE_PLIST);
|
||||||
|
assert.match(merged, /<key>CLAUDE_PROXY_PORT<\/key>\s*<string>3478<\/string>/);
|
||||||
|
assert.match(merged, /<key>CLAUDE_AUTH_MODE<\/key>\s*<string>multi<\/string>/);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("mergePlistEnv first-install returns template unchanged when existing is null", () => {
|
||||||
|
const merged = mergePlistEnv(null, SAMPLE_TEMPLATE_PLIST);
|
||||||
|
assert.equal(merged, SAMPLE_TEMPLATE_PLIST);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("mergePlistEnv first-install returns template unchanged when existing is empty", () => {
|
||||||
|
const merged = mergePlistEnv("", SAMPLE_TEMPLATE_PLIST);
|
||||||
|
assert.equal(merged, SAMPLE_TEMPLATE_PLIST);
|
||||||
|
});
|
||||||
|
|
||||||
|
const SAMPLE_TEMPLATE_SYSTEMD = `[Unit]
|
||||||
|
Description=OCP — Open Claude Proxy
|
||||||
|
After=network.target
|
||||||
|
|
||||||
|
[Service]
|
||||||
|
ExecStart=/usr/bin/node /home/u/ocp/server.mjs
|
||||||
|
Environment=CLAUDE_PROXY_PORT=3478
|
||||||
|
Environment=CLAUDE_BIND=127.0.0.1
|
||||||
|
Environment=CLAUDE_AUTH_MODE=multi
|
||||||
|
Restart=always
|
||||||
|
`;
|
||||||
|
|
||||||
|
const SAMPLE_EXISTING_SYSTEMD = `[Unit]
|
||||||
|
Description=OCP — Open Claude Proxy
|
||||||
|
After=network.target
|
||||||
|
|
||||||
|
[Service]
|
||||||
|
ExecStart=/usr/bin/node /home/u/ocp/server.mjs
|
||||||
|
Environment=CLAUDE_PROXY_PORT=3456
|
||||||
|
Environment=CLAUDE_BIND=127.0.0.1
|
||||||
|
Environment=CLAUDE_AUTH_MODE=none
|
||||||
|
Environment=CLAUDE_HEARTBEAT_INTERVAL=2000
|
||||||
|
Environment=CLAUDE_CACHE_TTL=600
|
||||||
|
Restart=always
|
||||||
|
`;
|
||||||
|
|
||||||
|
test("mergeSystemdEnv preserves unknown user Environment lines", () => {
|
||||||
|
const merged = mergeSystemdEnv(SAMPLE_EXISTING_SYSTEMD, SAMPLE_TEMPLATE_SYSTEMD);
|
||||||
|
assert.match(merged, /Environment=CLAUDE_HEARTBEAT_INTERVAL=2000/);
|
||||||
|
assert.match(merged, /Environment=CLAUDE_CACHE_TTL=600/);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("mergeSystemdEnv overrides known template keys", () => {
|
||||||
|
const merged = mergeSystemdEnv(SAMPLE_EXISTING_SYSTEMD, SAMPLE_TEMPLATE_SYSTEMD);
|
||||||
|
assert.match(merged, /Environment=CLAUDE_PROXY_PORT=3478/);
|
||||||
|
assert.match(merged, /Environment=CLAUDE_AUTH_MODE=multi/);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("mergeSystemdEnv first-install returns template unchanged", () => {
|
||||||
|
assert.equal(mergeSystemdEnv(null, SAMPLE_TEMPLATE_SYSTEMD), SAMPLE_TEMPLATE_SYSTEMD);
|
||||||
|
assert.equal(mergeSystemdEnv("", SAMPLE_TEMPLATE_SYSTEMD), SAMPLE_TEMPLATE_SYSTEMD);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("mergePlistEnv is idempotent", () => {
|
||||||
|
const r1 = mergePlistEnv(SAMPLE_EXISTING_PLIST, SAMPLE_TEMPLATE_PLIST);
|
||||||
|
assert.equal(mergePlistEnv(r1, SAMPLE_TEMPLATE_PLIST), r1);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("mergeSystemdEnv is idempotent", () => {
|
||||||
|
const r1 = mergeSystemdEnv(SAMPLE_EXISTING_SYSTEMD, SAMPLE_TEMPLATE_SYSTEMD);
|
||||||
|
assert.equal(mergeSystemdEnv(r1, SAMPLE_TEMPLATE_SYSTEMD), r1);
|
||||||
|
});
|
||||||
|
|
||||||
|
// ── Doctor JSON Contract Tests ──
|
||||||
|
import { runDoctor } from "./scripts/doctor.mjs";
|
||||||
|
|
||||||
|
console.log("\nDoctor:");
|
||||||
|
|
||||||
|
test("doctor --json shape: required top-level keys", async () => {
|
||||||
|
const result = await runDoctor({ skipNetwork: true, mockVersion: "v3.10.0", mockLatest: "v3.14.0" });
|
||||||
|
for (const k of ["schema_version", "ready_to_upgrade", "current_version", "latest_version",
|
||||||
|
"from_version_supported", "fail_count", "warn_count", "checks", "next_action"]) {
|
||||||
|
assert.ok(k in result, `missing key: ${k}`);
|
||||||
|
}
|
||||||
|
assert.equal(result.schema_version, "1");
|
||||||
|
});
|
||||||
|
|
||||||
|
test("doctor detects from-version < v3.4.0 → fresh_install", async () => {
|
||||||
|
const result = await runDoctor({ skipNetwork: true, mockVersion: "v3.2.0", mockLatest: "v3.14.0" });
|
||||||
|
assert.equal(result.from_version_supported, false);
|
||||||
|
assert.equal(result.next_action.kind, "fresh_install");
|
||||||
|
assert.ok(Array.isArray(result.next_action.ai_executable));
|
||||||
|
assert.ok(result.next_action.ai_executable.length > 0);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("doctor next_action.kind enum is one of allowed values", async () => {
|
||||||
|
const result = await runDoctor({ skipNetwork: true, mockVersion: "v3.10.0", mockLatest: "v3.14.0" });
|
||||||
|
const ALLOWED = ["noop", "update", "upgrade", "fresh_install", "fix_oauth", "fix_service"];
|
||||||
|
assert.ok(ALLOWED.includes(result.next_action.kind), `kind=${result.next_action.kind} not in enum`);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("doctor noop when current==latest", async () => {
|
||||||
|
const result = await runDoctor({ skipNetwork: true, mockVersion: "v3.14.0", mockLatest: "v3.14.0" });
|
||||||
|
assert.equal(result.next_action.kind, "noop");
|
||||||
|
assert.equal(result.ready_to_upgrade, true);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("doctor patch-bump same minor → update kind", async () => {
|
||||||
|
const result = await runDoctor({ skipNetwork: true, mockVersion: "v3.14.0", mockLatest: "v3.14.1" });
|
||||||
|
assert.equal(result.next_action.kind, "update");
|
||||||
|
});
|
||||||
|
|
||||||
|
test("doctor cross-minor → upgrade kind", async () => {
|
||||||
|
const result = await runDoctor({ skipNetwork: true, mockVersion: "v3.10.0", mockLatest: "v3.14.0" });
|
||||||
|
assert.equal(result.next_action.kind, "upgrade");
|
||||||
|
});
|
||||||
|
|
||||||
|
test("doctor OAuth FAIL → fix_oauth kind", async () => {
|
||||||
|
const result = await runDoctor({
|
||||||
|
skipNetwork: false,
|
||||||
|
mockVersion: "v3.10.0",
|
||||||
|
mockLatest: "v3.14.0",
|
||||||
|
mockHealth: { status: 200, body: { auth: { ok: false, message: "ENOEXEC" } } }
|
||||||
|
});
|
||||||
|
assert.equal(result.next_action.kind, "fix_oauth");
|
||||||
|
assert.ok(result.next_action.ai_executable.some(c => c.includes("install.cjs")));
|
||||||
|
});
|
||||||
|
|
||||||
|
test("doctor service down → fix_service kind", async () => {
|
||||||
|
const result = await runDoctor({
|
||||||
|
skipNetwork: false,
|
||||||
|
mockVersion: "v3.10.0",
|
||||||
|
mockLatest: "v3.14.0",
|
||||||
|
mockHealth: { error: "ECONNREFUSED" }
|
||||||
|
});
|
||||||
|
assert.equal(result.next_action.kind, "fix_service");
|
||||||
|
});
|
||||||
|
|
||||||
|
test("doctor unparseable version → fresh_install", async () => {
|
||||||
|
const result = await runDoctor({ skipNetwork: true, mockVersion: "garbage", mockLatest: "v3.14.0" });
|
||||||
|
assert.equal(result.from_version_supported, false);
|
||||||
|
assert.equal(result.next_action.kind, "fresh_install");
|
||||||
|
});
|
||||||
|
|
||||||
|
test("doctor empty health body → fix_service (not fix_oauth)", async () => {
|
||||||
|
const result = await runDoctor({
|
||||||
|
skipNetwork: false,
|
||||||
|
mockVersion: "v3.10.0",
|
||||||
|
mockLatest: "v3.14.0",
|
||||||
|
mockHealth: { status: 200, body: null }
|
||||||
|
});
|
||||||
|
assert.equal(result.next_action.kind, "fix_service");
|
||||||
|
});
|
||||||
|
|
||||||
|
test("doctor falls back to currentVersion when origin/main unreachable (no stale latest)", async () => {
|
||||||
|
// Use a non-existent ocpDir so git command fails; without the fix this would still
|
||||||
|
// hard-code v3.14.0 as latest and recommend a downgrade for a future v3.15.0+ user.
|
||||||
|
const result = await runDoctor({
|
||||||
|
skipNetwork: true,
|
||||||
|
mockVersion: "v3.15.0",
|
||||||
|
ocpDir: "/nonexistent-ocp-dir-for-test"
|
||||||
|
});
|
||||||
|
assert.equal(result.latest_version, "v3.15.0");
|
||||||
|
assert.equal(result.next_action.kind, "noop");
|
||||||
|
});
|
||||||
|
|
||||||
|
// ── Upgrade Tests ──
|
||||||
|
import { runUpgrade } from "./scripts/upgrade.mjs";
|
||||||
|
|
||||||
|
console.log("\nUpgrade:");
|
||||||
|
|
||||||
|
test("upgrade --dry-run prints plan, no side effects", async () => {
|
||||||
|
const result = await runUpgrade({
|
||||||
|
dryRun: true,
|
||||||
|
yes: true,
|
||||||
|
mockDoctor: { ready_to_upgrade: true, next_action: { kind: "upgrade" }, current_version: "v3.10.0", latest_version: "v3.14.0" }
|
||||||
|
});
|
||||||
|
assert.equal(result.executed, false);
|
||||||
|
assert.ok(result.plan.length > 0);
|
||||||
|
assert.ok(result.plan.some(line => line.toLowerCase().includes("snapshot")));
|
||||||
|
});
|
||||||
|
|
||||||
|
test("upgrade noop returns early when current==latest", async () => {
|
||||||
|
const result = await runUpgrade({
|
||||||
|
yes: true,
|
||||||
|
mockDoctor: { ready_to_upgrade: true, next_action: { kind: "noop" }, current_version: "v3.14.0", latest_version: "v3.14.0" }
|
||||||
|
});
|
||||||
|
assert.equal(result.path, "noop");
|
||||||
|
assert.equal(result.executed, true);
|
||||||
|
assert.equal(result.changed, false);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("upgrade aborts on doctor FAIL", async () => {
|
||||||
|
await assert.rejects(async () => {
|
||||||
|
await runUpgrade({
|
||||||
|
yes: true,
|
||||||
|
mockDoctor: { ready_to_upgrade: false, fail_count: 1, next_action: { kind: "fix_oauth" } }
|
||||||
|
});
|
||||||
|
}, /doctor FAIL/);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("upgrade full path executes 5 phases", async () => {
|
||||||
|
const result = await runUpgrade({
|
||||||
|
yes: true,
|
||||||
|
dryRun: false,
|
||||||
|
mockExec: true,
|
||||||
|
mockDoctor: { ready_to_upgrade: true, next_action: { kind: "upgrade" },
|
||||||
|
current_version: "v3.10.0", latest_version: "v3.14.0" }
|
||||||
|
});
|
||||||
|
assert.equal(result.path, "upgrade");
|
||||||
|
// Plan asks for 6 phases by name; verify each appears as a phase entry
|
||||||
|
const phaseNames = result.phases.map(p => p.name);
|
||||||
|
for (const expected of ["pre-flight", "snapshot", "fetch+install", "reconfigure", "restart", "post-flight"]) {
|
||||||
|
assert.ok(phaseNames.includes(expected), `missing phase: ${expected}; got ${phaseNames.join(",")}`);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// ── Snapshot Tests ──
|
||||||
|
import { writeSnapshot, readSnapshot, listSnapshots, gcSnapshots } from "./scripts/lib/snapshot.mjs";
|
||||||
|
import { mkdtempSync, rmSync, mkdirSync as tMkdirSync, writeFileSync as testWriteFile, existsSync as testExistsSync } from "node:fs";
|
||||||
|
import { tmpdir } from "node:os";
|
||||||
|
import { join as testJoin } from "node:path";
|
||||||
|
|
||||||
|
console.log("\nSnapshot:");
|
||||||
|
|
||||||
|
test("writeSnapshot creates dir + manifest files", () => {
|
||||||
|
const root = mkdtempSync(testJoin(tmpdir(), "ocp-snap-test-"));
|
||||||
|
const dotOcp = testJoin(root, ".ocp");
|
||||||
|
tMkdirSync(dotOcp, { recursive: true });
|
||||||
|
testWriteFile(testJoin(dotOcp, "ocp.db"), "fake-sqlite-bytes");
|
||||||
|
|
||||||
|
const path = writeSnapshot({
|
||||||
|
homeDir: root,
|
||||||
|
fromCommit: "abc1234",
|
||||||
|
fromVersion: "v3.10.0",
|
||||||
|
toVersion: "v3.14.0",
|
||||||
|
extraFiles: []
|
||||||
|
});
|
||||||
|
const m = readSnapshot(path);
|
||||||
|
assert.equal(m.fromCommit, "abc1234");
|
||||||
|
assert.equal(m.fromVersion, "v3.10.0");
|
||||||
|
rmSync(root, { recursive: true, force: true });
|
||||||
|
});
|
||||||
|
|
||||||
|
test("listSnapshots returns sorted by ISO timestamp", () => {
|
||||||
|
const root = mkdtempSync(testJoin(tmpdir(), "ocp-snap-list-"));
|
||||||
|
const dotOcp = testJoin(root, ".ocp");
|
||||||
|
tMkdirSync(dotOcp, { recursive: true });
|
||||||
|
for (const ts of ["2026-05-01T10:00:00Z", "2026-05-02T10:00:00Z", "2026-05-03T10:00:00Z"]) {
|
||||||
|
tMkdirSync(testJoin(dotOcp, `upgrade-snapshot-${ts}`));
|
||||||
|
}
|
||||||
|
const list = listSnapshots(root);
|
||||||
|
assert.equal(list.length, 3);
|
||||||
|
assert.ok(list[0].path.includes("2026-05-01"));
|
||||||
|
assert.ok(list[2].path.includes("2026-05-03"));
|
||||||
|
rmSync(root, { recursive: true, force: true });
|
||||||
|
});
|
||||||
|
|
||||||
|
test("upgrade error after snapshot carries snapshotPath + hint", async () => {
|
||||||
|
// Use mockExec=true so no real commands are run.
|
||||||
|
// Verify the success path returns a snapshotPath (Fix B regression guard).
|
||||||
|
const result = await runUpgrade({
|
||||||
|
yes: true,
|
||||||
|
dryRun: false,
|
||||||
|
mockExec: true,
|
||||||
|
mockDoctor: { ready_to_upgrade: true, next_action: { kind: "upgrade" },
|
||||||
|
current_version: "v3.10.0", latest_version: "v3.14.0" }
|
||||||
|
});
|
||||||
|
assert.ok(result.snapshotPath, "successful upgrade returns snapshotPath");
|
||||||
|
assert.equal(result.path, "upgrade");
|
||||||
|
assert.equal(result.executed, true);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("upgrade fresh_install requires --yes for non-interactive", async () => {
|
||||||
|
await assert.rejects(async () => {
|
||||||
|
await runUpgrade({
|
||||||
|
yes: false,
|
||||||
|
mockExec: true,
|
||||||
|
mockDoctor: { ready_to_upgrade: false, from_version_supported: false,
|
||||||
|
next_action: { kind: "fresh_install", ai_executable: ["echo would-rm-rf"] },
|
||||||
|
current_version: "v3.2.0", latest_version: "v3.14.0" }
|
||||||
|
});
|
||||||
|
}, /requires --yes/);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("upgrade fresh_install with --yes runs ai_executable", async () => {
|
||||||
|
const result = await runUpgrade({
|
||||||
|
yes: true,
|
||||||
|
mockExec: true,
|
||||||
|
mockDoctor: { ready_to_upgrade: false, from_version_supported: false,
|
||||||
|
next_action: { kind: "fresh_install",
|
||||||
|
ai_executable: ["echo step-1", "echo step-2", "echo step-3"] },
|
||||||
|
current_version: "v3.2.0", latest_version: "v3.14.0" }
|
||||||
|
});
|
||||||
|
assert.equal(result.path, "fresh_install");
|
||||||
|
assert.equal(result.steps.length, 3);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("rollback --list returns snapshots", async () => {
|
||||||
|
const result = await runUpgrade({
|
||||||
|
rollback: true,
|
||||||
|
list: true,
|
||||||
|
mockSnapshots: [
|
||||||
|
{ name: "upgrade-snapshot-2026-05-01T10:00:00Z", path: "/tmp/snap-1" },
|
||||||
|
{ name: "upgrade-snapshot-2026-05-02T10:00:00Z", path: "/tmp/snap-2" }
|
||||||
|
]
|
||||||
|
});
|
||||||
|
assert.equal(result.path, "rollback-list");
|
||||||
|
assert.equal(result.snapshots.length, 2);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("rollback with no snapshots fails clearly", async () => {
|
||||||
|
await assert.rejects(async () => {
|
||||||
|
await runUpgrade({ rollback: true, dryRun: true, mockSnapshots: [] });
|
||||||
|
}, /no upgrade snapshots/);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("rollback --dry-run produces a plan without mutation", async () => {
|
||||||
|
const result = await runUpgrade({
|
||||||
|
rollback: true,
|
||||||
|
dryRun: true,
|
||||||
|
mockSnapshots: [{ name: "upgrade-snapshot-2026-05-11T08:30:00Z", path: "/tmp/snap-x" }],
|
||||||
|
mockSnapshotMeta: { fromCommit: "abc1234", fromVersion: "v3.10.0", toVersion: "v3.14.0", path: "/tmp/snap-x" }
|
||||||
|
});
|
||||||
|
assert.equal(result.path, "rollback-dry-run");
|
||||||
|
assert.equal(result.executed, false);
|
||||||
|
assert.ok(result.plan.length > 0);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("rollback latest snapshot restores files (mockExec)", async () => {
|
||||||
|
const result = await runUpgrade({
|
||||||
|
rollback: true,
|
||||||
|
yes: true,
|
||||||
|
mockExec: true,
|
||||||
|
mockSnapshots: [{ name: "upgrade-snapshot-2026-05-11T08:30:00Z", path: "/tmp/snap-x" }],
|
||||||
|
mockSnapshotMeta: { fromCommit: "abc1234", fromVersion: "v3.10.0", toVersion: "v3.14.0", path: "/tmp/snap-x" }
|
||||||
|
});
|
||||||
|
assert.equal(result.path, "rollback");
|
||||||
|
assert.equal(result.executed, true);
|
||||||
|
assert.ok(result.phases.some(p => p.name === "git-checkout"));
|
||||||
|
});
|
||||||
|
|
||||||
|
test("gcSnapshots keeps last N regardless of age", () => {
|
||||||
|
const root = mkdtempSync(testJoin(tmpdir(), "ocp-gc-test-"));
|
||||||
|
const dotOcp = testJoin(root, ".ocp");
|
||||||
|
tMkdirSync(dotOcp, { recursive: true });
|
||||||
|
for (const ts of ["2026-04-01T10:00:00Z", "2026-04-15T10:00:00Z", "2026-04-30T10:00:00Z", "2026-05-01T10:00:00Z", "2026-05-10T10:00:00Z"]) {
|
||||||
|
tMkdirSync(testJoin(dotOcp, `upgrade-snapshot-${ts}`));
|
||||||
|
}
|
||||||
|
const result = gcSnapshots(root, { keepCount: 3, keepDays: 0, now: new Date("2026-05-11T00:00:00Z") });
|
||||||
|
assert.equal(result.kept.length, 3);
|
||||||
|
assert.equal(result.removed.length, 2);
|
||||||
|
assert.ok(result.kept[0].name.includes("2026-04-30"));
|
||||||
|
assert.ok(result.kept[2].name.includes("2026-05-10"));
|
||||||
|
rmSync(root, { recursive: true, force: true });
|
||||||
|
});
|
||||||
|
|
||||||
|
test("gcSnapshots keeps snapshots newer than keepDays regardless of count", () => {
|
||||||
|
const root = mkdtempSync(testJoin(tmpdir(), "ocp-gc-days-"));
|
||||||
|
const dotOcp = testJoin(root, ".ocp");
|
||||||
|
tMkdirSync(dotOcp, { recursive: true });
|
||||||
|
for (const ts of ["2026-04-01T10:00:00Z", "2026-04-15T10:00:00Z", "2026-04-30T10:00:00Z", "2026-05-01T10:00:00Z", "2026-05-10T10:00:00Z"]) {
|
||||||
|
tMkdirSync(testJoin(dotOcp, `upgrade-snapshot-${ts}`));
|
||||||
|
}
|
||||||
|
// keepCount=1 but keepDays=15 means anything from after 2026-04-26 is kept too
|
||||||
|
const result = gcSnapshots(root, { keepCount: 1, keepDays: 15, now: new Date("2026-05-11T00:00:00Z") });
|
||||||
|
// Kept: 2026-04-30 (within 15 days), 2026-05-01 (within 15 days), 2026-05-10 (within 15 days)
|
||||||
|
assert.ok(result.kept.length >= 3);
|
||||||
|
// Removed: 2026-04-01, 2026-04-15
|
||||||
|
assert.ok(result.removed.some(s => s.name.includes("2026-04-01")));
|
||||||
|
});
|
||||||
|
|
||||||
|
test("gcSnapshots never deletes the most recent snapshot", () => {
|
||||||
|
const root = mkdtempSync(testJoin(tmpdir(), "ocp-gc-recent-"));
|
||||||
|
const dotOcp = testJoin(root, ".ocp");
|
||||||
|
tMkdirSync(dotOcp, { recursive: true });
|
||||||
|
tMkdirSync(testJoin(dotOcp, "upgrade-snapshot-2026-01-01T10:00:00Z"));
|
||||||
|
// Even with keepCount=0 and keepDays=0, the most recent must survive
|
||||||
|
const result = gcSnapshots(root, { keepCount: 0, keepDays: 0, now: new Date("2026-05-11T00:00:00Z") });
|
||||||
|
assert.equal(result.kept.length, 1);
|
||||||
|
assert.equal(result.removed.length, 0);
|
||||||
|
rmSync(root, { recursive: true, force: true });
|
||||||
|
});
|
||||||
|
|
||||||
|
test("gcSnapshots --dry-run reports plan without deleting", () => {
|
||||||
|
const root = mkdtempSync(testJoin(tmpdir(), "ocp-gc-dryrun-"));
|
||||||
|
const dotOcp = testJoin(root, ".ocp");
|
||||||
|
tMkdirSync(dotOcp, { recursive: true });
|
||||||
|
for (const ts of ["2026-04-01T10:00:00Z", "2026-04-15T10:00:00Z", "2026-05-10T10:00:00Z"]) {
|
||||||
|
tMkdirSync(testJoin(dotOcp, `upgrade-snapshot-${ts}`));
|
||||||
|
}
|
||||||
|
const result = gcSnapshots(root, { keepCount: 1, keepDays: 0, dryRun: true, now: new Date("2026-05-11T00:00:00Z") });
|
||||||
|
assert.equal(result.dryRun, true);
|
||||||
|
assert.equal(result.removed.length, 2);
|
||||||
|
// Files still exist
|
||||||
|
assert.ok(testExistsSync(testJoin(dotOcp, "upgrade-snapshot-2026-04-01T10:00:00Z")));
|
||||||
|
rmSync(root, { recursive: true, force: true });
|
||||||
|
});
|
||||||
|
|
||||||
|
// ── Doctor --check oauth fast path tests ──
|
||||||
|
console.log("\nDoctor --check oauth:");
|
||||||
|
|
||||||
|
await asyncTest("doctor --check oauth runs only oauth check (skips version/from-version)", async () => {
|
||||||
|
const result = await runDoctor({
|
||||||
|
checkOnly: "oauth",
|
||||||
|
mockVersion: "v3.10.0",
|
||||||
|
mockLatest: "v3.14.0",
|
||||||
|
mockHealth: { status: 200, body: { auth: { ok: true, message: "authenticated" } } }
|
||||||
|
});
|
||||||
|
// Should still produce a valid result object
|
||||||
|
assert.equal(result.schema_version, "1");
|
||||||
|
// checks[] should only contain oauth_ok (no current_version, no from_version_supported)
|
||||||
|
const ids = result.checks.map(c => c.id);
|
||||||
|
assert.deepEqual(ids, ["oauth_ok"]);
|
||||||
|
assert.equal(result.next_action.kind, "noop");
|
||||||
|
});
|
||||||
|
|
||||||
|
await asyncTest("doctor --check oauth + OAuth FAIL → fix_oauth", async () => {
|
||||||
|
const result = await runDoctor({
|
||||||
|
checkOnly: "oauth",
|
||||||
|
mockHealth: { status: 200, body: { auth: { ok: false, message: "ENOEXEC" } } }
|
||||||
|
});
|
||||||
|
const ids = result.checks.map(c => c.id);
|
||||||
|
assert.deepEqual(ids, ["oauth_ok"]);
|
||||||
|
assert.equal(result.next_action.kind, "fix_oauth");
|
||||||
|
assert.equal(result.fail_count, 1);
|
||||||
|
});
|
||||||
|
|
||||||
|
await asyncTest("doctor --check oauth + service down → fix_service", async () => {
|
||||||
|
const result = await runDoctor({
|
||||||
|
checkOnly: "oauth",
|
||||||
|
mockHealth: { error: "ECONNREFUSED" }
|
||||||
|
});
|
||||||
|
const ids = result.checks.map(c => c.id);
|
||||||
|
assert.deepEqual(ids, ["oauth_ok"]);
|
||||||
|
assert.equal(result.next_action.kind, "fix_service");
|
||||||
|
assert.equal(result.fail_count, 1);
|
||||||
|
});
|
||||||
|
|
||||||
|
await asyncTest("doctor --check oauth + 200 with null body → fix_service", async () => {
|
||||||
|
const result = await runDoctor({
|
||||||
|
checkOnly: "oauth",
|
||||||
|
mockHealth: { status: 200, body: null }
|
||||||
|
});
|
||||||
|
const ids = result.checks.map(c => c.id);
|
||||||
|
assert.deepEqual(ids, ["oauth_ok"]);
|
||||||
|
assert.equal(result.next_action.kind, "fix_service");
|
||||||
|
assert.equal(result.fail_count, 1);
|
||||||
|
});
|
||||||
|
|
||||||
|
// ── Stream-JSON parser tests ──────────────────────────────────────────────
|
||||||
|
// MIRRORS server.mjs parseStreamJsonLines/parseStreamJsonEvent — keep in sync.
|
||||||
|
// Copied verbatim to avoid importing server.mjs (top-level server.listen() would
|
||||||
|
// start a live HTTP server). The logEvent stub silences observability side-effects.
|
||||||
|
console.log("\nStream-JSON parsers:");
|
||||||
|
|
||||||
|
function logEvent() {} // stub — observability side-effect not needed in tests
|
||||||
|
|
||||||
|
function parseStreamJsonLines(buffered) {
|
||||||
|
const lines = buffered.split("\n");
|
||||||
|
const remainder = lines.pop(); // last element is the incomplete trailing line
|
||||||
|
const events = [];
|
||||||
|
for (const line of lines) {
|
||||||
|
const trimmed = line.trim();
|
||||||
|
if (trimmed === "") continue;
|
||||||
|
try {
|
||||||
|
events.push(JSON.parse(trimmed));
|
||||||
|
} catch {
|
||||||
|
console.error("[claude] NDJSON parse error on line:", trimmed.slice(0, 120));
|
||||||
|
events.push({ type: "parse_error", raw: trimmed });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return { events, remainder: remainder ?? "" };
|
||||||
|
}
|
||||||
|
|
||||||
|
function parseStreamJsonEvent(event, isFirstDelta) {
|
||||||
|
const t = event?.type;
|
||||||
|
|
||||||
|
// system/* — first-event init + other system meta (api_retry etc.)
|
||||||
|
if (t === "system") return null;
|
||||||
|
// user — echo of user message; consumed
|
||||||
|
if (t === "user") return null;
|
||||||
|
|
||||||
|
// stream_event — contains nested content_block_delta
|
||||||
|
if (t === "stream_event") {
|
||||||
|
const inner = event.event ?? event;
|
||||||
|
if (inner?.type === "content_block_delta" && inner.delta?.type === "text_delta") {
|
||||||
|
return { text: inner.delta.text ?? "" };
|
||||||
|
}
|
||||||
|
// Other stream_event sub-types (content_block_start, message_delta, etc.) — consumed
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
// assistant — aggregate message (fallback when no prior content_block_delta seen)
|
||||||
|
// Empirically (claude CLI without --include-partial-messages, verified v2.1.104 through v2.1.158): fast/short
|
||||||
|
// responses may emit ONLY the aggregate assistant event, no content_block_delta events.
|
||||||
|
// If isFirstDelta is true, extract text here; otherwise it's a duplicate, ignore.
|
||||||
|
// Reference: OLP commit 65f945c (assistant-aggregate fallback, fold-in).
|
||||||
|
if (t === "assistant") {
|
||||||
|
if (isFirstDelta) {
|
||||||
|
const blocks = event.message?.content;
|
||||||
|
if (Array.isArray(blocks)) {
|
||||||
|
const text = blocks
|
||||||
|
.filter(b => b && b.type === "text" && typeof b.text === "string")
|
||||||
|
.map(b => b.text)
|
||||||
|
.join("");
|
||||||
|
if (text) return { text };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
// result — terminal event
|
||||||
|
if (t === "result") {
|
||||||
|
if (event.is_error === true) {
|
||||||
|
return { error: event.error_message ?? event.result ?? "claude returned is_error" };
|
||||||
|
}
|
||||||
|
return { stop: true };
|
||||||
|
}
|
||||||
|
|
||||||
|
// rate_limit_event / usage — log for observability, don't forward
|
||||||
|
if (t === "rate_limit_event" || t === "usage") {
|
||||||
|
logEvent("info", "claude_stream_event", { type: t, data: JSON.stringify(event).slice(0, 200) });
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
// control_request — per Anthropic stream-json docs
|
||||||
|
if (t === "control_request") {
|
||||||
|
console.error("[claude] stream_json control_request event (ignored):", JSON.stringify(event).slice(0, 120));
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
// parse_error — already logged by parseStreamJsonLines
|
||||||
|
if (t === "parse_error") return null;
|
||||||
|
|
||||||
|
// Unknown event type — log + skip; future-proof for new claude CLI events
|
||||||
|
if (t !== undefined) {
|
||||||
|
console.error("[claude] unknown stream_json event type:", t);
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
// (a) content_block_delta deltas + assistant-aggregate fallback → assembled text with NO double-count
|
||||||
|
test("parseStreamJsonEvent: stream_event content_block_delta yields text", () => {
|
||||||
|
const event = {
|
||||||
|
type: "stream_event",
|
||||||
|
event: { type: "content_block_delta", delta: { type: "text_delta", text: "Hello" } }
|
||||||
|
};
|
||||||
|
const result = parseStreamJsonEvent(event, true);
|
||||||
|
assert.deepEqual(result, { text: "Hello" });
|
||||||
|
});
|
||||||
|
|
||||||
|
test("parseStreamJsonEvent: assistant-aggregate used when isFirstDelta=true (no prior delta)", () => {
|
||||||
|
const event = {
|
||||||
|
type: "assistant",
|
||||||
|
message: { content: [{ type: "text", text: "Short answer." }] }
|
||||||
|
};
|
||||||
|
const result = parseStreamJsonEvent(event, true);
|
||||||
|
assert.deepEqual(result, { text: "Short answer." });
|
||||||
|
});
|
||||||
|
|
||||||
|
test("parseStreamJsonEvent: assistant-aggregate skipped when isFirstDelta=false (no double-count)", () => {
|
||||||
|
const event = {
|
||||||
|
type: "assistant",
|
||||||
|
message: { content: [{ type: "text", text: "Short answer." }] }
|
||||||
|
};
|
||||||
|
const result = parseStreamJsonEvent(event, false);
|
||||||
|
assert.equal(result, null);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("parseStreamJsonEvent: stream_event + assistant → assembled without double-count", () => {
|
||||||
|
// Simulate receiving a content_block_delta first, then an assistant aggregate
|
||||||
|
const delta = {
|
||||||
|
type: "stream_event",
|
||||||
|
event: { type: "content_block_delta", delta: { type: "text_delta", text: "Streaming text." } }
|
||||||
|
};
|
||||||
|
const agg = {
|
||||||
|
type: "assistant",
|
||||||
|
message: { content: [{ type: "text", text: "Streaming text." }] }
|
||||||
|
};
|
||||||
|
// First event: isFirstDelta=true → yields text
|
||||||
|
const r1 = parseStreamJsonEvent(delta, true);
|
||||||
|
assert.deepEqual(r1, { text: "Streaming text." });
|
||||||
|
// Second event (aggregate): isFirstDelta is now false (content already emitted) → null
|
||||||
|
const r2 = parseStreamJsonEvent(agg, false);
|
||||||
|
assert.equal(r2, null);
|
||||||
|
});
|
||||||
|
|
||||||
|
// (b) aggregate-only short response → assembles correctly
|
||||||
|
test("parseStreamJsonEvent: aggregate-only multi-block response assembles all text blocks", () => {
|
||||||
|
const event = {
|
||||||
|
type: "assistant",
|
||||||
|
message: {
|
||||||
|
content: [
|
||||||
|
{ type: "text", text: "Part one." },
|
||||||
|
{ type: "tool_use", id: "x" }, // non-text block — should be filtered
|
||||||
|
{ type: "text", text: " Part two." }
|
||||||
|
]
|
||||||
|
}
|
||||||
|
};
|
||||||
|
const result = parseStreamJsonEvent(event, true);
|
||||||
|
assert.deepEqual(result, { text: "Part one. Part two." });
|
||||||
|
});
|
||||||
|
|
||||||
|
// (c) JSON line split across two parseStreamJsonLines calls → partial-line buffering
|
||||||
|
test("parseStreamJsonLines: partial line carried as remainder", () => {
|
||||||
|
const chunk1 = '{"type":"system","subtype":"init"}\n{"type":"stream_ev';
|
||||||
|
const { events: ev1, remainder: rem1 } = parseStreamJsonLines(chunk1);
|
||||||
|
assert.equal(ev1.length, 1);
|
||||||
|
assert.equal(ev1[0].type, "system");
|
||||||
|
assert.equal(rem1, '{"type":"stream_ev');
|
||||||
|
|
||||||
|
const chunk2 = rem1 + 'ent","event":{"type":"content_block_delta","delta":{"type":"text_delta","text":"Hi"}}}\n';
|
||||||
|
const { events: ev2, remainder: rem2 } = parseStreamJsonLines(chunk2);
|
||||||
|
assert.equal(ev2.length, 1);
|
||||||
|
assert.equal(ev2[0].type, "stream_event");
|
||||||
|
assert.equal(rem2, "");
|
||||||
|
// Verify the reassembled event parses through parseStreamJsonEvent correctly
|
||||||
|
const parsed = parseStreamJsonEvent(ev2[0], true);
|
||||||
|
assert.deepEqual(parsed, { text: "Hi" });
|
||||||
|
});
|
||||||
|
|
||||||
|
test("parseStreamJsonLines: empty input returns no events and empty remainder", () => {
|
||||||
|
const { events, remainder } = parseStreamJsonLines("");
|
||||||
|
assert.equal(events.length, 0);
|
||||||
|
assert.equal(remainder, "");
|
||||||
|
});
|
||||||
|
|
||||||
|
// (d) is_error result event → surfaces the error
|
||||||
|
test("parseStreamJsonEvent: result is_error=true surfaces error_message", () => {
|
||||||
|
const event = { type: "result", is_error: true, error_message: "Rate limit hit" };
|
||||||
|
const result = parseStreamJsonEvent(event, false);
|
||||||
|
assert.deepEqual(result, { error: "Rate limit hit" });
|
||||||
|
});
|
||||||
|
|
||||||
|
test("parseStreamJsonEvent: result is_error=true falls back to result field when no error_message", () => {
|
||||||
|
const event = { type: "result", is_error: true, result: "error detail" };
|
||||||
|
const result = parseStreamJsonEvent(event, false);
|
||||||
|
assert.deepEqual(result, { error: "error detail" });
|
||||||
|
});
|
||||||
|
|
||||||
|
test("parseStreamJsonEvent: result is_error=true falls back to default string when no detail", () => {
|
||||||
|
const event = { type: "result", is_error: true };
|
||||||
|
const result = parseStreamJsonEvent(event, false);
|
||||||
|
assert.deepEqual(result, { error: "claude returned is_error" });
|
||||||
|
});
|
||||||
|
|
||||||
|
test("parseStreamJsonEvent: result is_error=false yields stop", () => {
|
||||||
|
const event = { type: "result", is_error: false, result: "success" };
|
||||||
|
const result = parseStreamJsonEvent(event, false);
|
||||||
|
assert.deepEqual(result, { stop: true });
|
||||||
|
});
|
||||||
|
|
||||||
|
// (e) malformed/non-JSON line → skipped without throwing
|
||||||
|
test("parseStreamJsonLines: malformed JSON line becomes parse_error event without throwing", () => {
|
||||||
|
const input = '{"type":"system"}\nnot-valid-json\n{"type":"result","is_error":false}\n';
|
||||||
|
const { events, remainder } = parseStreamJsonLines(input);
|
||||||
|
assert.equal(events.length, 3);
|
||||||
|
assert.equal(events[0].type, "system");
|
||||||
|
assert.equal(events[1].type, "parse_error");
|
||||||
|
assert.equal(events[1].raw, "not-valid-json");
|
||||||
|
assert.equal(events[2].type, "result");
|
||||||
|
});
|
||||||
|
|
||||||
|
test("parseStreamJsonEvent: parse_error event returns null without throwing", () => {
|
||||||
|
const event = { type: "parse_error", raw: "garbage" };
|
||||||
|
const result = parseStreamJsonEvent(event, false);
|
||||||
|
assert.equal(result, null);
|
||||||
|
});
|
||||||
|
|
||||||
|
// Additional edge cases
|
||||||
|
test("parseStreamJsonEvent: system event returns null", () => {
|
||||||
|
const result = parseStreamJsonEvent({ type: "system", subtype: "init" }, true);
|
||||||
|
assert.equal(result, null);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("parseStreamJsonEvent: user event returns null", () => {
|
||||||
|
const result = parseStreamJsonEvent({ type: "user", message: {} }, true);
|
||||||
|
assert.equal(result, null);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("parseStreamJsonEvent: stream_event non-text-delta (content_block_start) returns null", () => {
|
||||||
|
const event = { type: "stream_event", event: { type: "content_block_start", index: 0 } };
|
||||||
|
const result = parseStreamJsonEvent(event, true);
|
||||||
|
assert.equal(result, null);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("parseStreamJsonEvent: unknown event type returns null", () => {
|
||||||
|
const result = parseStreamJsonEvent({ type: "future_event_type" }, false);
|
||||||
|
assert.equal(result, null);
|
||||||
|
});
|
||||||
|
|
||||||
// ── Cleanup ──
|
// ── Cleanup ──
|
||||||
closeDb();
|
closeDb();
|
||||||
|
|
||||||
|
|||||||
+1
-1
@@ -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
|
||||||
|
|||||||
Reference in New Issue
Block a user