mirror of
https://github.com/dtzp555-max/ocp.git
synced 2026-07-22 21:45:08 +00:00
Compare commits
9
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
e031df3f99 | ||
|
|
324c206f95 | ||
|
|
1dd6fb9440 | ||
|
|
9e25160527 | ||
|
|
49c6d32e3b | ||
|
|
7766fa0868 | ||
|
|
70faeff067 | ||
|
|
7a69d72886 | ||
|
|
a8601a6d30 |
@@ -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 -->
|
||||||
|
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
+66
-4
@@ -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.
|
||||||
@@ -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.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|||||||
+132
@@ -1,5 +1,137 @@
|
|||||||
# 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
|
## v3.15.1 — 2026-05-10
|
||||||
|
|
||||||
### Fixes
|
### Fixes
|
||||||
|
|||||||
@@ -116,7 +116,7 @@ Please follow https://github.com/dtzp555-max/ocp/blob/main/README.md
|
|||||||
installed and logged in (`claude auth status`). Install missing pieces
|
installed and logged in (`claude auth status`). Install missing pieces
|
||||||
using my system's package manager.
|
using my system's package manager.
|
||||||
2. git clone the repo, cd in, and run `node setup.mjs`.
|
2. git clone the repo, cd in, and run `node setup.mjs`.
|
||||||
3. Verify with `curl http://127.0.0.1:3456/v1/models` (should list 4 models).
|
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.
|
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.
|
5. Tell me to reload my shell and try a tool like Cline / Continue / Cursor.
|
||||||
|
|
||||||
@@ -142,7 +142,7 @@ Please follow https://github.com/dtzp555-max/ocp/blob/main/README.md
|
|||||||
5. Add OCP_ADMIN_KEY to my shell rc (~/.zshrc or ~/.bashrc).
|
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.
|
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`.
|
7. Optionally create example keys: `ocp keys add laptop`, `ocp keys add tablet`.
|
||||||
8. Verify: `curl http://127.0.0.1:3456/v1/models` returns 4 models.
|
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.
|
Tell me each step before running it. On error, diagnose before retrying.
|
||||||
```
|
```
|
||||||
@@ -165,7 +165,7 @@ Please follow https://github.com/dtzp555-max/ocp/blob/main/README.md
|
|||||||
chmod +x ocp-connect
|
chmod +x ocp-connect
|
||||||
2. Run `./ocp-connect <SERVER_IP>` (add `--key <KEY>` if you have one).
|
2. Run `./ocp-connect <SERVER_IP>` (add `--key <KEY>` if you have one).
|
||||||
3. Follow any IDE-specific manual hints it prints.
|
3. Follow any IDE-specific manual hints it prints.
|
||||||
4. Verify: `curl http://<SERVER_IP>:3456/v1/models` returns 4 models.
|
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.
|
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.
|
Don't auto-retry on error. Tell me the failure mode first.
|
||||||
@@ -235,7 +235,7 @@ 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
|
#### Headless install notes
|
||||||
@@ -314,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
|
||||||
@@ -344,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
|
||||||
@@ -674,17 +675,18 @@ Cache is **disabled by default** (`CLAUDE_CACHE_TTL=0`). All data is stored loca
|
|||||||
## 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) |
|
||||||
|
|
||||||
@@ -855,7 +857,8 @@ 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) |
|
||||||
|
|||||||
@@ -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.
|
||||||
@@ -22,6 +22,7 @@ New ADRs increment from the highest existing number. Filenames are
|
|||||||
| [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. |
|
| [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. |
|
| [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. |
|
| [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
|
## When to write a new ADR
|
||||||
|
|
||||||
|
|||||||
@@ -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".
|
||||||
@@ -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}`;
|
||||||
+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"
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -709,6 +709,8 @@ Usage:
|
|||||||
ocp update --rollback --list List available snapshots
|
ocp update --rollback --list List available snapshots
|
||||||
ocp update --rollback <path> Restore a specific snapshot
|
ocp update --rollback <path> Restore a specific snapshot
|
||||||
ocp update --rollback --dry-run Preview rollback plan
|
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
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
+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.12.0",
|
"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
-1
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "open-claude-proxy",
|
"name": "open-claude-proxy",
|
||||||
"version": "3.15.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": {
|
||||||
|
|||||||
+88
-2
@@ -15,6 +15,7 @@ import { readFileSync, existsSync } from "node:fs";
|
|||||||
import { join } from "node:path";
|
import { join } from "node:path";
|
||||||
import { homedir } from "node:os";
|
import { homedir } from "node:os";
|
||||||
import { execSync } from "node:child_process";
|
import { execSync } from "node:child_process";
|
||||||
|
import { DEFAULT_PORT } from "../lib/constants.mjs";
|
||||||
|
|
||||||
const SCHEMA_VERSION = "1";
|
const SCHEMA_VERSION = "1";
|
||||||
|
|
||||||
@@ -37,6 +38,11 @@ export async function runDoctor(opts = {}) {
|
|||||||
const push = (id, level, message, extra = {}) =>
|
const push = (id, level, message, extra = {}) =>
|
||||||
checks.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 ---
|
// --- version detection ---
|
||||||
const ocpDir = opts.ocpDir || join(homedir(), "ocp");
|
const ocpDir = opts.ocpDir || join(homedir(), "ocp");
|
||||||
let currentVersion = opts.mockVersion;
|
let currentVersion = opts.mockVersion;
|
||||||
@@ -76,7 +82,7 @@ export async function runDoctor(opts = {}) {
|
|||||||
health = opts.mockHealth;
|
health = opts.mockHealth;
|
||||||
} else {
|
} else {
|
||||||
try {
|
try {
|
||||||
const port = process.env.CLAUDE_PROXY_PORT || "3478";
|
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();
|
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) };
|
health = { status: 200, body: JSON.parse(out) };
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
@@ -190,6 +196,84 @@ export async function runDoctor(opts = {}) {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
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
|
// CLI entrypoint — use fileURLToPath + realpath to handle symlinked install paths
|
||||||
// (e.g. /tmp/ → /private/tmp/ on macOS would otherwise miss the guard).
|
// (e.g. /tmp/ → /private/tmp/ on macOS would otherwise miss the guard).
|
||||||
import { fileURLToPath } from "node:url";
|
import { fileURLToPath } from "node:url";
|
||||||
@@ -202,7 +286,9 @@ function _isMain() {
|
|||||||
}
|
}
|
||||||
if (_isMain()) {
|
if (_isMain()) {
|
||||||
const wantJson = process.argv.includes("--json");
|
const wantJson = process.argv.includes("--json");
|
||||||
const result = await runDoctor();
|
const checkIdx = process.argv.indexOf("--check");
|
||||||
|
const checkOnly = checkIdx !== -1 ? process.argv[checkIdx + 1] : undefined;
|
||||||
|
const result = await runDoctor({ checkOnly });
|
||||||
if (wantJson) {
|
if (wantJson) {
|
||||||
console.log(JSON.stringify(result, null, 2));
|
console.log(JSON.stringify(result, null, 2));
|
||||||
} else {
|
} else {
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import { mkdirSync, writeFileSync, readFileSync, copyFileSync, existsSync, readdirSync, statSync } from "node:fs";
|
import { mkdirSync, writeFileSync, readFileSync, copyFileSync, existsSync, readdirSync, statSync, rmSync } from "node:fs";
|
||||||
import { join } from "node:path";
|
import { join } from "node:path";
|
||||||
|
|
||||||
export function writeSnapshot({ homeDir, fromCommit, fromVersion, toVersion, extraFiles = [] }) {
|
export function writeSnapshot({ homeDir, fromCommit, fromVersion, toVersion, extraFiles = [] }) {
|
||||||
@@ -50,3 +50,66 @@ export function listSnapshots(homeDir) {
|
|||||||
.map(name => ({ name, path: join(root, name), mtime: statSync(join(root, name)).mtimeMs }))
|
.map(name => ({ name, path: join(root, name), mtime: statSync(join(root, name)).mtimeMs }))
|
||||||
.sort((a, b) => a.name.localeCompare(b.name));
|
.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,
|
||||||
|
|||||||
+24
-3
@@ -14,7 +14,8 @@ import { execSync } from "node:child_process";
|
|||||||
import { homedir } from "node:os";
|
import { homedir } from "node:os";
|
||||||
import { join } from "node:path";
|
import { join } from "node:path";
|
||||||
import { existsSync, copyFileSync } from "node:fs";
|
import { existsSync, copyFileSync } from "node:fs";
|
||||||
import { writeSnapshot, listSnapshots, readSnapshot } from "./lib/snapshot.mjs";
|
import { writeSnapshot, listSnapshots, readSnapshot, gcSnapshots } from "./lib/snapshot.mjs";
|
||||||
|
import { DEFAULT_PORT } from "../lib/constants.mjs";
|
||||||
|
|
||||||
export async function runUpgrade(opts = {}) {
|
export async function runUpgrade(opts = {}) {
|
||||||
const dryRun = !!opts.dryRun;
|
const dryRun = !!opts.dryRun;
|
||||||
@@ -134,7 +135,7 @@ async function runFullUpgrade({ doctor, opts }) {
|
|||||||
|
|
||||||
// phase 6: post-flight (10s budget; skipped under mockExec)
|
// phase 6: post-flight (10s budget; skipped under mockExec)
|
||||||
if (!opts.mockExec) {
|
if (!opts.mockExec) {
|
||||||
const port = process.env.CLAUDE_PROXY_PORT || "3478";
|
const port = process.env.CLAUDE_PROXY_PORT || String(DEFAULT_PORT);
|
||||||
let ok = false;
|
let ok = false;
|
||||||
for (let i = 0; i < 10; i++) {
|
for (let i = 0; i < 10; i++) {
|
||||||
try {
|
try {
|
||||||
@@ -154,6 +155,16 @@ async function runFullUpgrade({ doctor, opts }) {
|
|||||||
phases.push({ name: "post-flight", status: "skipped-mock" });
|
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 };
|
return { path: "upgrade", executed: true, changed: true, snapshotPath, phases };
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
if (snapshotPath && !err.snapshotPath) {
|
if (snapshotPath && !err.snapshotPath) {
|
||||||
@@ -193,6 +204,11 @@ async function runRollback(opts) {
|
|||||||
const homeDir = opts.homeDir || homedir();
|
const homeDir = opts.homeDir || homedir();
|
||||||
const snapshots = opts.mockSnapshots ?? listSnapshots(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) {
|
if (opts.list) {
|
||||||
return { path: "rollback-list", snapshots };
|
return { path: "rollback-list", snapshots };
|
||||||
}
|
}
|
||||||
@@ -295,6 +311,7 @@ if (_isMain()) {
|
|||||||
const yes = args.includes("--yes");
|
const yes = args.includes("--yes");
|
||||||
const rollback = args.includes("--rollback");
|
const rollback = args.includes("--rollback");
|
||||||
const list = args.includes("--list");
|
const list = args.includes("--list");
|
||||||
|
const gc = args.includes("--gc");
|
||||||
const targetIdx = args.indexOf("--target");
|
const targetIdx = args.indexOf("--target");
|
||||||
const target = targetIdx !== -1 ? args[targetIdx + 1] : undefined;
|
const target = targetIdx !== -1 ? args[targetIdx + 1] : undefined;
|
||||||
// First non-flag positional after --rollback is the snapshot path
|
// First non-flag positional after --rollback is the snapshot path
|
||||||
@@ -305,7 +322,7 @@ if (_isMain()) {
|
|||||||
if (cand && !cand.startsWith("--")) snapshotPath = cand;
|
if (cand && !cand.startsWith("--")) snapshotPath = cand;
|
||||||
}
|
}
|
||||||
try {
|
try {
|
||||||
const result = await runUpgrade({ dryRun, yes, rollback, list, snapshotPath, target });
|
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.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.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.steps) for (const s of result.steps) console.log(` ${s.status === "ok" ? "✓" : s.status === "skipped-mock" ? "·" : "✗"} ${s.cmd}`);
|
||||||
@@ -313,6 +330,10 @@ if (_isMain()) {
|
|||||||
console.log(`Found ${result.snapshots.length} snapshots:`);
|
console.log(`Found ${result.snapshots.length} snapshots:`);
|
||||||
for (const s of result.snapshots) console.log(` ${s.name}`);
|
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);
|
process.exit(0);
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
console.error(`✗ ${e.message}`);
|
console.error(`✗ ${e.message}`);
|
||||||
|
|||||||
+292
-97
@@ -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)
|
||||||
@@ -35,6 +35,7 @@ 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, hasCacheControl, singleflight, getInflightStats } from "./keys.mjs";
|
import { validateKey, recordUsage, getUsageByKey, getUsageTimeline, getRecentUsage, createKey, listKeys, revokeKey, closeDb, checkQuota, updateKeyQuota, getKeyQuota, findKey, cacheHash, getCachedResponse, setCachedResponse, clearCache, getCacheStats, hasCacheControl, 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"));
|
||||||
@@ -121,9 +122,140 @@ function resolveClaude() {
|
|||||||
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 || "";
|
||||||
@@ -380,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) {
|
||||||
@@ -399,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);
|
||||||
@@ -474,8 +613,15 @@ 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.
|
||||||
|
//
|
||||||
|
// 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) {
|
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})`);
|
||||||
@@ -488,43 +634,22 @@ function spawnClaudeProcess(model, messages, conversationId, keyName) {
|
|||||||
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.
|
||||||
// sessionKey namespaces the Map key by keyName to prevent cross-caller collision
|
// Filter them out before calling to avoid double-injection.
|
||||||
// when two callers with different API keys share the same conversationId string.
|
const nonSystemMessages = messages.filter(m => m.role !== "system");
|
||||||
const sessionKey = _sessionKey(conversationId, keyName);
|
const prompt = messagesToPrompt(nonSystemMessages);
|
||||||
if (sessionKey && sessions.has(sessionKey)) {
|
|
||||||
const session = sessions.get(sessionKey);
|
|
||||||
session.lastUsed = Date.now();
|
|
||||||
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)}... key=${keyName || "anon"} uuid=${session.uuid.slice(0, 8)}... msgs=${messages.length} prompt_chars=${prompt.length}`);
|
|
||||||
|
|
||||||
} else if (sessionKey) {
|
|
||||||
const uuid = randomUUID();
|
|
||||||
const now = Date.now();
|
|
||||||
sessions.set(sessionKey, { uuid, messageCount: messages.length, firstSeen: now, lastUsed: now, model: cliModel });
|
|
||||||
sessionInfo = { uuid, resume: false };
|
|
||||||
stats.sessionMisses++;
|
|
||||||
prompt = messagesToPrompt(messages);
|
|
||||||
|
|
||||||
console.log(`[session] new conv=${conversationId.slice(0, 12)}... key=${keyName || "anon"} 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;
|
||||||
@@ -562,15 +687,10 @@ function spawnClaudeProcess(model, messages, conversationId, keyName) {
|
|||||||
proc.once("exit", cleanup);
|
proc.once("exit", cleanup);
|
||||||
|
|
||||||
function handleSessionFailure() {
|
function handleSessionFailure() {
|
||||||
if (sessionInfo?.resume && sessionKey) {
|
// 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.
|
||||||
logEvent("warn", "session_failure", { mode: "resume", conversationId: conversationId.slice(0, 12) + "...", action: "deleted" });
|
if (conversationId) {
|
||||||
sessions.delete(sessionKey);
|
logEvent("warn", "session_failure", { mode: "stateless", conversationId: conversationId.slice(0, 12) + "...", action: "none" });
|
||||||
} else if (sessionInfo && !sessionInfo.resume && sessionKey) {
|
|
||||||
// #41 evidence-gathering: session-create failures currently leave a stale entry
|
|
||||||
// in the sessions map. Log without action so the staleness pattern can be
|
|
||||||
// confirmed in /logs before any code change. Do NOT delete here speculatively.
|
|
||||||
logEvent("warn", "session_failure", { mode: "create", conversationId: conversationId.slice(0, 12) + "...", action: "kept" });
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -606,9 +726,14 @@ function spawnClaudeProcess(model, messages, conversationId, keyName) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// ── 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.
|
||||||
|
//
|
||||||
|
// 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) {
|
function callClaude(model, messages, conversationId, keyName) {
|
||||||
return new Promise((resolve, reject) => {
|
return new Promise((resolve, reject) => {
|
||||||
let ctx;
|
let ctx;
|
||||||
@@ -619,12 +744,30 @@ function callClaude(model, messages, conversationId, keyName) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
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));
|
||||||
|
|
||||||
@@ -632,17 +775,19 @@ function callClaude(model, messages, conversationId, keyName) {
|
|||||||
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);
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -684,9 +829,14 @@ function startHeartbeat(res, intervalMs, sessionId) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// ── 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.
|
// 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);
|
||||||
@@ -703,6 +853,9 @@ 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 (res.writableEnded || res.destroyed) return false;
|
if (res.writableEnded || res.destroyed) return false;
|
||||||
@@ -723,27 +876,63 @@ function callClaudeStreaming(model, messages, conversationId, res, authInfo = {}
|
|||||||
}
|
}
|
||||||
|
|
||||||
// D4 (spec 2026-04-25): eagerly send SSE headers post-spawn so the
|
// D4 (spec 2026-04-25): eagerly send SSE headers post-spawn so the
|
||||||
// heartbeat started in the next statement (Task 1.3) covers the
|
// heartbeat started in the next statement covers the pre-first-byte silent window.
|
||||||
// pre-first-byte silent window. Behavior change: the `code !== 0`
|
|
||||||
// before-first-byte branch at server.mjs:610-611 becomes effectively
|
|
||||||
// unreachable in the common case — the post-headers SSE-stop path
|
|
||||||
// (612-619) handles it instead.
|
|
||||||
ensureHeaders();
|
ensureHeaders();
|
||||||
const hb = startHeartbeat(res, HEARTBEAT_INTERVAL, convId);
|
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;
|
||||||
}, hb);
|
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));
|
||||||
@@ -754,7 +943,9 @@ function callClaudeStreaming(model, messages, conversationId, res, authInfo = {}
|
|||||||
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) });
|
||||||
@@ -781,14 +972,18 @@ 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) {
|
||||||
}, hb);
|
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();
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
@@ -1819,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.`);
|
||||||
|
|
||||||
|
|||||||
@@ -3,7 +3,8 @@
|
|||||||
* OCP (Open 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
|
||||||
@@ -18,6 +19,7 @@ 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();
|
||||||
@@ -32,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");
|
||||||
|
|||||||
+352
-2
@@ -719,8 +719,8 @@ test("upgrade full path executes 5 phases", async () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
// ── Snapshot Tests ──
|
// ── Snapshot Tests ──
|
||||||
import { writeSnapshot, readSnapshot, listSnapshots } from "./scripts/lib/snapshot.mjs";
|
import { writeSnapshot, readSnapshot, listSnapshots, gcSnapshots } from "./scripts/lib/snapshot.mjs";
|
||||||
import { mkdtempSync, rmSync, mkdirSync as tMkdirSync, writeFileSync as testWriteFile } from "node:fs";
|
import { mkdtempSync, rmSync, mkdirSync as tMkdirSync, writeFileSync as testWriteFile, existsSync as testExistsSync } from "node:fs";
|
||||||
import { tmpdir } from "node:os";
|
import { tmpdir } from "node:os";
|
||||||
import { join as testJoin } from "node:path";
|
import { join as testJoin } from "node:path";
|
||||||
|
|
||||||
@@ -843,6 +843,356 @@ test("rollback latest snapshot restores files (mockExec)", async () => {
|
|||||||
assert.ok(result.phases.some(p => p.name === "git-checkout"));
|
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();
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user