Compare commits

...
Author SHA1 Message Date
dtzp555-maxGitHubtaodengClaude <claude-opus-4-8> <noreply@anthropic.com>
bdb6662c7c chore(release): v3.24.0 — OpenAI multimodal + structured outputs, SPOT prompt budget, agentic fix, OCP_LOCAL_TOOLS (#186)
* chore(release): v3.24.0 — OpenAI multimodal vision + structured outputs, SPOT prompt budget, agentic-turn fix, OCP_LOCAL_TOOLS

Consolidates #179/#153/#154/#183/#181/#182 (merged since the v3.23.0 tag).
3.23.0 → 3.24.0.

Minor: four user-facing features (multimodal vision #154, structured outputs
#153, SPOT-derived prompt budget #179, OCP_LOCAL_TOOLS #182) + two fixes
(#183 agentic final-answer, #181 deep-reply-500). No breaking change; no new
endpoint; no new cli.js wire behavior. Two features from @vvlasy-openclaw.

Release-kit walk: new env vars all documented in-PR (CLAUDE_IMAGE_ALLOW_URL /
CLAUDE_MAX_IMAGE_* #154, OCP_STRUCTURED_MAX_ATTEMPTS #153, OCP_LOCAL_TOOLS #182 —
grepped present in README env table); ADR 0009 (#179) + ADR 0006 (B.1, #153/#154)
indexed; version from package.json only (no stale refs); the CHANGELOG Unreleased
section (which held #182) is retitled to v3.24.0 with the other five entries added.

Tag push v3.24.0 at this squash commit triggers release.yml.

Co-Authored-By: Claude <claude-opus-4-8> <noreply@anthropic.com>

* docs(changelog): correct contributor count — four of six PRs are @vvlasy-openclaw's, not two (release reviewer)

---------

Co-authored-by: dtzp555 <dtzp555@gmail.com>
Co-authored-by: Claude <claude-opus-4-8> <noreply@anthropic.com>
2026-07-21 21:19:13 +10:00
4f9e2ff281 feat(server): OCP_LOCAL_TOOLS — positive local-tools system-prompt wrapper (single-user, default off) (#182)
Motivation (the OpenClaw case). OCP's `-p` path prepends OCP_SYSTEM_PROMPT_WRAPPER,
which tells the model it has NO local filesystem/shell/env access. Correct for a
shared/multi-tenant gateway. But an OpenClaw agent pointed at its own local OCP runs
the model SERVER-SIDE via `claude -p`, which already passes --allowedTools and has the
CLI's built-in tools — and on a loopback instance the OCP host IS the operator's
machine, so those are local tools. The wrapper gags them: the agent replies "I don't
have filesystem access" for tools it actually holds. OCP_LOCAL_TOOLS=1 swaps in a
positive wrapper for that case. (It does NOT enable client-side tool_calls for
OpenClaw/Cline — that remains unsupported by design; OCP is a text-prompt bridge.)

Safety: changes ONLY the system-prompt text, never the tool surface. Tools are governed
solely by --allowedTools/--disallowedTools; AUTH_MODE=multi still --disallowedTools the
whole FS/web/agent surface regardless of the wrapper. Fail-closed boot gate mirroring
OCP_TUI_FULL_TOOLS (ADR 0007): refuse to start when =1 is combined with
CLAUDE_AUTH_MODE=multi, a non-loopback bind, or PROXY_ANONYMOUS_KEY.

Scope/alignment: no new endpoint/header/field/wire operation. The wrapper text is
OCP-owned prompt composition (same class as OCP_SYSTEM_PROMPT_WRAPPER and
CLAUDE_SYSTEM_PROMPT), passed via the already-cited `claude --system-prompt` flag
(unchanged). ALIGNMENT.md Rule 2: nothing invented on the wire.

- lib/prompt.mjs: pure selectPromptWrapper() + localToolsSafetyError() (unit-tested).
- server.mjs: single hoisted flag LOCAL_TOOLS_ACTIVE = OCP_LOCAL_TOOLS && !TUI_MODE
  (the wrapper is only applied on the -p path; TUI composes its own prompt, so the flag
  is inert under TUI — announced with a warning rather than a misleading "ON"). Wrapper
  selection, boot gate, and the CONFIG_EPOCH fold all key off it, so toggling the flag +
  restarting invalidates the standard response cache (#177). Default path byte-for-byte
  unchanged.
- README env-var row + tool-model note; CHANGELOG Unreleased entry.

Tests (+14): unit-test both ternary branches and every gate condition; plus an
INTEGRATION harness that boots real server.mjs with a fake `claude` capturing the
--system-prompt — asserting the POSITIVE wrapper reaches a request under =1 and the
EXACT negative wrapper when unset, the boot gate refuses all three unsafe configs, the
safe config boots, TUI announces inert, and toggling the flag re-spawns (cache
invalidated). Mutation-verified: reverting the wiring / neutering the gate / reverting
the epoch fold each turns a test RED. 443 passed, 0 failed.

Noticed but scoped out (Iron Rule 11): the structured-output cache path
(handleChatCompletions, the response_format branch) does not fold CONFIG_EPOCH at all —
a pre-existing gap from #177/#153, independent of this flag. Happy to fix in a follow-up.

Co-authored-by: vvlasy-openclaw <vvlasy@gmail.com>
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-21 21:05:38 +10:00
dtzp555-maxGitHubtaodengClaude <claude-opus-4-8> <noreply@anthropic.com>
45c5717aea fix(structured): crash-safe validation façade — deep model reply → refusal not 500 (closes #181) (#184)
* fix(structured): crash-safe validation façade — deep model reply → refusal, not a 500 (closes #181)

#153's cyclic-$ref guard caps the REF-chain depth but not the DATA depth:
validateJsonSchema recurses on the value's nesting (properties/items/additionalProps),
so a model reply nested ~2000+ levels overflowed the stack with a RangeError, which
handleChatCompletions caught as a generic HTTP 500 instead of the spec-correct refusal.
(Found in the #153 final review, filed as #181; ≤1 spawn, no crash, no client-only
trigger — the value always comes from the model reply.)

New exported validateJsonSchemaSafe() wraps the validator: ANY throw (the deep-data
RangeError, or any future recursion hazard) becomes a single validation error, so the
structured-output retry loop treats a pathological reply as "did not validate" →
refusal. A well-formed reply is byte-identical (passes the inner errors through).
runStructuredCompletion calls the safe façade.

Chose the wrapper over threading a data-depth counter through six recursive call
sites: it protects every internal path at once (impossible to miss one) and stays
deterministically testable — a 6000-deep fixture reliably overflows on any platform.

Tests: +2, mutation-proven (revert the wrapper to a bare call → the deep test throws
RED). Suite 431/0. Rule 2: OCP-internal validation, no wire change, no cli.js citation.

Closes #181

Co-Authored-By: Claude <claude-opus-4-8> <noreply@anthropic.com>

* fix(structured): narrow validateJsonSchemaSafe to RangeError-only + drop unused import (review fold-in)

Reviewer of #184: the catch-all would silently mask a future genuine bug (e.g. a
TypeError from a malformed schema) as a validation miss. Narrowed to
`if (e instanceof RangeError) return [...]; throw e;` so only the #181 deep-nesting
overflow becomes a refusal; any other throw surfaces at error level as before. +1
test proving a non-RangeError (required:42 → TypeError) re-throws. Dropped the now-
unused raw `validateJsonSchema` import from server.mjs. Merged current main (incl.
#183) so CI runs the true post-merge tree. Suite 433/0.

Co-Authored-By: Claude <claude-opus-4-8> <noreply@anthropic.com>

---------

Co-authored-by: dtzp555 <dtzp555@gmail.com>
Co-authored-by: Claude <claude-opus-4-8> <noreply@anthropic.com>
2026-07-21 20:05:50 +10:00
47e324b68f fix(server): assemble every assistant message so agentic turns return the final answer (#183)
* fix(server): assemble every assistant message so agentic turns return the final answer

`/v1/chat/completions` returned only the agent's opening preamble ("I'll find the
repo…") and silently dropped the post-tool-use final answer on every tool-using
(agentic) turn. The work still ran; OpenAI-compat clients (OpenClaw via ocp-connect,
OpenAI-SDK scripts) just received the preamble — the turn looked like it "did nothing."

Root cause: `parseStreamJsonEvent` extracted aggregate `assistant` text only when
`isFirstDelta` was true, and `isFirstDelta` flips false after the first text. Run
without `--include-partial-messages` the claude CLI emits NO content_block_delta
events — each assistant message arrives as its own aggregate `assistant` event — and
an agentic turn emits SEVERAL (preamble → one per tool round → final answer). So only
the first message's text survived; every later message, including the final answer,
was discarded.

Fix: guard on `sawTextDelta` (set only by a real content_block_delta) instead of
`isFirstDelta`. In aggregate mode (no deltas) accumulate the text of EVERY `assistant`
event, joined with a blank line; the delta+aggregate double-count case is still deduped
(a delta was seen ⇒ ignore the aggregate). Applied to both the buffered (`-p`) and
SSE-streaming paths, plus the mirrored parser + tests in test-features.mjs. 430 passed,
0 failed; added a multi-message agentic regression test.

Evidence — verified live, claude CLI 2.1.206 (`-p --output-format stream-json --verbose
--allowedTools Bash`): a preamble+tool+answer turn emits four `assistant` events, two
carrying text — #2 "Starting the task now." (preamble) and #4 "It printed
AGG_EVIDENCE_99." (final answer, after the Bash tool). Old code returned only #2; new
code returns both.

Endpoint class: B.1 (OpenAI-compatibility surface, `/v1/chat/completions`).
Specification: OpenAI chat/completions — the assistant `message.content` (and the
concatenation of streamed `choices[].delta.content`) carries the model's full response
text (https://platform.openai.com/docs/api-reference/chat/create).
Authorizing ADR: ADR 0006 — OpenAI shim scope. cli.js does NOT perform this operation
(it speaks Anthropic's protocol, not OpenAI's); scope is justified under ADR 0006 Class
B.1. No new endpoint, header, request field, or response field — this only fixes how
OCP assembles claude CLI stream-json output into the existing `content` field. No Class A
(cli.js-mirror) surface is touched.

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

* Merge origin/main into #183 + align streaming-path separator to the buffered guard (reviewer LOW-cosmetic parity)

---------

Co-authored-by: vvlasy-openclaw <vvlasy@gmail.com>
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Co-authored-by: dtzp555 <dtzp555@gmail.com>
2026-07-21 19:58:09 +10:00
788cbbcd99 feat(server): honor OpenAI response_format for structured-output clients (#153)
* feat(server): honor OpenAI response_format for structured-output clients

`/v1/chat/completions` advertises OpenAI compatibility but ignored
`response_format`, so clients requiring machine-parseable JSON (Home Assistant
AI Tasks, Honcho, OpenAI-SDK scripts) received free-form assistant prose —
markdown tables, ```json fences, trailing commentary — that fails JSON.parse.

This honors the OpenAI `response_format` contract on the `-p` path:

- New `lib/structured-output.mjs` (pure, unit-tested): `detectStructuredOutput`
  (json_schema / json_object), `structuredSystemInstruction` (strict JSON-only
  steering, escalated on retry), `extractJsonPayload` (string-aware balanced
  slice that unwraps fences/prose), and a minimal JSON-Schema `validateJsonSchema`
  (types, required, enum, const, additionalProperties, nullability, items,
  min/maxItems).
- `server.mjs`: `runStructuredCompletion` retries up to
  `OCP_STRUCTURED_MAX_ATTEMPTS` (default 3), returns the canonical JSON string as
  `message.content`, and yields HTTP 422 (`invalid_response_error`) if no valid
  JSON can be produced. Structured requests take their own path (bypass the cache,
  which does not key on response_format). Non-structured requests are byte-for-byte
  unchanged, streaming included.
- Nullability precedence: a `null` value is accepted whenever the schema permits
  null (`type:["x","null"]` / `nullable:true`), even if a bare `enum` omits null —
  matches OpenAI behaviour and fixes real Home Assistant schemas
  (`type:["string","null"], enum:["Loxone"]`) that otherwise 422 on null.
- README: Structured Outputs section + `OCP_STRUCTURED_MAX_ATTEMPTS` env row.
- 18 new unit tests (281 passed, 0 failed).

Endpoint class: B.1 (OpenAI-compatibility surface, `/v1/chat/completions`).
Specification: OpenAI chat/completions `response_format`
(https://platform.openai.com/docs/api-reference/chat/create#chat-create-response_format).
Authorizing ADR: ADR 0006 — OpenAI shim scope. cli.js does NOT perform this
operation (it speaks Anthropic's protocol, not OpenAI's); scope is justified under
ADR 0006 Class B.1 (OpenAI spec as protocol authority). Revives closed PR #99.

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

* feat(server): structured-output caching + json_mode alias

Follow-up on the response_format path, closing the two gaps vs closed PR #99:

- **Validated caching (improves on #99).** Structured responses now use the OCP
  cache when CLAUDE_CACHE_TTL>0, on a structured-keyed hash: cacheHash gains an
  `structured` marker folding the detected response_format/schema into the key,
  so a JSON reply never collides with the conversational answer to the same
  prompt and different schemas never share a slot. Only a *validated* result is
  written back — a 422 is never cached. (#99 cached the fence-stripped but
  *unvalidated* output; this caches only schema-valid JSON.) The marker is absent
  for normal requests, so existing cache hashes are byte-identical.
- **json_mode alias.** Honor the non-standard top-level `json_mode: true` flag as
  a json_object alias, matching #99's activation set. Disclosed as non-spec.
- README: json_mode shape + caching note. +2 unit tests (283 passed, 0 failed).

Endpoint class: B.1 (/v1/chat/completions), ADR 0006. json_mode is a non-OpenAI
convenience alias (disclosed); everything else stays within OpenAI's response_format spec.

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

* fix(server): address PR #153 review — $ref/strict, extraction safety, refusal, singleflight

Remediates the maintainer's merge-blocking findings on the structured-output PR, and
rebases onto current main (the one-hunk test-features.mjs conflict — both sides
appended tests — resolved by keeping both blocks). Class B.1 (OpenAI-compat): spec
authority is OpenAI chat/completions `response_format`
(https://platform.openai.com/docs/api-reference/chat/create#chat-create-response_format),
authorized by ADR 0006. No cli.js analogue (claude -p has no native response_format);
scope is the B.1 shim, not a Class A forward.

Finding 1 (correctness gate) — strict:true + $ref/$defs rejected valid objects 100%
of the time. `noExtra = addl === false || (strict && addl === undefined)` treated a
nested {$ref:"#/$defs/step"} as an empty-properties object and, under strict, rejected
every real key as "additional property not allowed" — exactly the shape the OpenAI SDK
emits (zodResponseFormat / client.beta.chat.completions.parse) and OpenAI's own docs
example. Fix: validateJsonSchema now resolves same-document $ref against the root
$defs/definitions, handles allOf/anyOf/oneOf composition, and only infers
additionalProperties:false from strict when the object actually declares its own
non-empty properties and is not a composite. Explicit additionalProperties:false is
always honoured, so validation is not weakened (tests prove an extra key and a missing
required key still fail under strict).

Finding 2 (correctness gate) — the extractor served JSON the model did not mean.
json_object mode had no validation at all: a refusal like `I can't. The schema is
{"type":"object"}` returned the embedded object as the answer. Now json_object requires
the WHOLE reply to parse as a single JSON value, and schema mode rejects a reply
carrying more than one top-level JSON value (Schema:{}/Answer:{}, Option A/Option B)
rather than silently picking the first. The schema-validated value is still returned;
nothing unvalidated is served.

Finding 3 — replaced the invented `invalid_response_error` 422 with OpenAI's assistant
`refusal` field (200, content:null, refusal:<reason>, finish_reason:"stop"), streaming
and non-streaming, so SDK clients take their refusal branch instead of throwing an
opaque UnprocessableEntityError.

Finding 5 — runStructuredCompletion no longer bypasses stampede protection. Identical
concurrent one-off structured requests now share one singleflight (independent of cache
enablement), so N callers no longer cost N × up-to-3 spawns. Cache read/write still
gated on CLAUDE_CACHE_TTL; refusals are never cached.

Docs: README structured-output § updated (refusal field, $ref/composition support,
whole-reply json_object rule, ambiguous-multi-value rejection) plus a Caching & cost
paragraph stating the post-2026-06-15 model, the up-to-N-spawn worst case, the
singleflight + validated-cache guards, and the OCP_STRUCTURED_MAX_ATTEMPTS=1 / per-key
quota levers.

Tests: +11 (all pure-module) — OpenAI's doc $ref/$defs schema under strict:true accepts
a conforming reply and still rejects extra/missing keys; anyOf/allOf; unresolvable $ref
skipped; json_object refusal-embedded-json rejected; >1-top-level-value rejected. 360
passed, 0 failed.

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

* fix(server): address PR #153 review round 2 — cyclic-$ref guard + NaN attempts guard

Remediates the two remaining merge-blocking findings from the round-2 review. Class B.1
(OpenAI-compat): spec authority is OpenAI chat/completions `response_format`
(https://platform.openai.com/docs/api-reference/chat/create#chat-create-response_format),
authorized by ADR 0006. No cli.js analogue (claude -p has no native response_format, and
the retry cap OCP_STRUCTURED_MAX_ATTEMPTS is OCP's own coercion loop, not a cli.js
operation); scope stays the B.1 shim, not a Class A forward.

BLOCKER — cyclic $ref stack-overflowed the validator. resolveRef + the $ref branch of
validateJsonSchema had no cycle detection: a pure ref→ref cycle
({$defs:{a:{$ref:b},b:{$ref:a}},$ref:a}) recursed independent of the data and threw
RangeError for ANY reply value (even `5`), caught upstream as a 500 but only after 1–3
metered spawns — a request-controlled cost-amplification / grief vector on an authed
path. Fix: validateJsonSchema now threads a `refChain` of the $ref pointers resolved on
the current path WITHOUT consuming data (a $ref hop, or an allOf/anyOf/oneOf branch — all
re-validate the same value); a pointer reappearing on that chain fails closed with a
`cyclic $ref detected` error. Data-consuming recursion (properties/items/
additionalProperties) deliberately resets the chain, because a JSON value is a finite
tree so those always terminate — a legitimately recursive schema (Node→child:Node) must
NOT be flagged. A REF_DEPTH_CAP backstops any threading mistake.

MUST-FIX — OCP_STRUCTURED_MAX_ATTEMPTS NaN guard was broken. `Math.max(1, parseInt(env
||"3",10))` === `Math.max(1, NaN)` === NaN for a non-integer value, so the retry loop
`attempt < NaN` never ran → 0 spawns, every structured request silently refused (fails
closed on cost but bricks the feature and ignores the intended floor). Fix: extracted a
pure fail-closed resolveMaxAttempts() into lib/structured-output.mjs — rejects
NaN/non-finite/<1, keeps the documented default of 3, and warns at startup. server.mjs
now derives STRUCTURED_MAX_ATTEMPTS through it.

Tests: +8 (all pure-module) — a→b→a and self (a→a) cyclic $ref fail closed without
overflowing the stack; a cycle routed through anyOf; a legitimate recursive Node schema
is NOT flagged; resolveMaxAttempts honors valid integers, defaults on unset/empty/null,
and fails closed (not NaN, not 0) on abc/0/-1/NaN/Infinity/blank with a startup warn.
368 passed, 0 failed.

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

---------

Co-authored-by: vvlasy-openclaw <vvlasy@gmail.com>
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Co-authored-by: dtzp555 <dtzp555@gmail.com>
2026-07-20 07:55:40 +10:00
ac81badda1 feat(chat): forward OpenAI image_url parts to Claude (multimodal vision) (#154)
* feat(chat): forward OpenAI image_url parts to Claude (multimodal vision)

`POST /v1/chat/completions` previously flattened every message to plain text
via contentToText(), replacing image_url parts with "[non-text content
omitted]" — so images were silently dropped (issue #110). This adds real
multimodal support: OpenAI `image_url` content parts are translated to
Anthropic image blocks and fed to the Claude CLI over
`--input-format stream-json`, keeping subscription auth (the reason OCP routes
through the CLI rather than the API).

Class B.1 (OpenAI-compatibility surface), authorized by ADR 0006. Request
shape follows OpenAI's published vision / chat-completions spec
(https://platform.openai.com/docs/guides/vision and the chat/completions
`content` image_url part) — no field is introduced beyond OpenAI's shape. The
CLI's `--input-format stream-json` is the transport for this Class B endpoint,
not a forwarded cli.js operation, so there is no Class A cli.js citation to
make; scope is justified under the ALIGNMENT.md Class B mapping of Rule 2
(no invention beyond the cited OpenAI spec).

Mechanism (verified empirically against the installed CLI, v2.1.206): a user
message whose `content` is an Anthropic block array including
`{type:"image",source:{type:"base64",media_type,data}}` fed to
`claude -p --input-format stream-json` is correctly described by the model.
Confirmed live end-to-end through this endpoint (a base64 PNG returns the
correct color).

Design:
- New pure module `lib/multimodal.mjs` (mirrors the lib/*.mjs pattern; unit-
  testable without a live server): hasImageContent, buildImageBlocks,
  buildStreamJsonInput, MultimodalError.
- server.mjs: text path is byte-for-byte unchanged. Only when a request carries
  an image_url part does spawnClaudeProcess switch stdin to a stream-json user
  envelope and buildCliArgs add `--input-format stream-json`. Image parsing runs
  before any stats mutation so a validation failure never leaks counters/slots.
- Images bypass the text char budget (CLAUDE_MAX_PROMPT_CHARS) and are bounded
  by explicit byte/count caps with clear 4xx errors (413 for size/count, 400
  for malformed/unsupported/disabled-remote), never a silent drop.

Scope decisions (v1):
- Base64 data URIs supported by default (image/jpeg,png,gif,webp).
- Remote http(s) image URLs OFF by default behind CLAUDE_IMAGE_ALLOW_URL; when
  enabled they are passed through as an Anthropic url-source (OCP never fetches
  the URL itself, so no OCP-side SSRF surface).
- Audio/file parts deferred: existing placeholder behavior preserved.
- Images anywhere in multi-turn history, not just the last message.

New env vars (documented in README Environment Variables table):
CLAUDE_IMAGE_ALLOW_URL, CLAUDE_MAX_IMAGE_BYTES, CLAUDE_MAX_IMAGES,
CLAUDE_MAX_IMAGE_TOTAL_BYTES, CLAUDE_MAX_BODY_SIZE (now configurable; default
5 MB unchanged).

Tests: 26 unit tests in test-features.mjs covering data-URI parse, multiple
images, text/image ordering, multi-turn history images, malformed/oversized/
too-many handling, remote-URL policy, and text-path parity. `npm test` green
(289 passed). `node --check` clean. No alignment-blacklist tokens added.

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

* fix(chat): address PR #154 review blockers — TUI guard, fail-closed caps, text budget

Remediates the three merge-blocking findings from the maintainer's review of the
multimodal vision PR. Class B.1 (OpenAI-compat surface): request shape per OpenAI
vision spec (image_url content parts), authorized by ADR 0006. No new wire shape
and no cli.js surface change — the stream-json image contract is the CLI's native
input format (already cited in the base commit); these are correctness fixes on the
OCP-owned validation/dispatch layer.

F1 — TUI mode silently dropped images and returned 200. callClaudeTui() renders
every non-text part as "[non-text content omitted]", so a vision request in
CLAUDE_TUI_MODE=true was answered about an image the model never saw. Now
handleChatCompletions fails loudly with 400 images_unsupported_in_tui_mode instead
of a silent drop (ALIGNMENT.md forbids serving text the model did not mean).
Documented in README § Images / Multimodal.

F3 — NaN env parsing failed open. CLAUDE_MAX_BODY_SIZE=unlimited -> NaN ->
`body.length > NaN` always false -> body cap gone (OOM DoS); =5MB -> 5 bytes ->
proxy bricked; same on CLAUDE_MAX_IMAGES / _IMAGE_BYTES / _IMAGE_TOTAL_BYTES.
Added lib/env.mjs parsePositiveInt (pure, fail-closed) + a thin parseIntEnv warn
wrapper; a malformed cap now keeps the safe default and warns at startup.

F2 — images let unbounded text bypass MAX_PROMPT_CHARS. buildImageBlocks only
counted textChars and never truncated; the budget was never passed in. Threaded
maxTextChars (= MAX_PROMPT_CHARS) into the multimodal transform, which now
truncates text tail-first (mirroring messagesToPrompt) while preserving image
blocks, and logs prompt_truncated.

Tests: +11 in test-features.mjs (all pure-module, per the repo's no-server-import
pattern) covering the text-budget enforcement and the fail-closed cap parsing,
including the exact F2 (500k chars + 1 image) and F3 (unlimited/5MB/0/20.5)
scenarios. 300 passed, 0 failed.

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

* fix(server): address PR #154 review round 2 — MAX_PROMPT_CHARS fail-closed + system-only image guard

Closes the two residual gaps from the round-2 review, both traced to the same root as the
already-fixed blockers. Class B.1 (OpenAI-compat vision): authorized by ADR 0006; request
shape is the OpenAI `image_url` content part. No new wire shape — the Anthropic image block
over `--input-format stream-json` is the CLI's native contract (cli.js buildStreamJsonInput
path, verified live in round 1). MAX_PROMPT_CHARS is OCP's own truncation guard, not a cli.js
operation.

Gap (a) — MAX_PROMPT_CHARS was left on the raw parseInt while every other cap moved to the
fail-closed helper. `let MAX_PROMPT_CHARS = parseInt(env||"150000",10)` sat five lines above
the parseIntEnv helper this PR added, so CLAUDE_MAX_PROMPT_CHARS=unlimited → NaN →
enforceTextBudget's `!(NaN > 0)` early-return → 500k chars passed unbounded, truncated:false,
silently defeating F2's text-budget guarantee under a plausible operator config. Fix: hoist
parseIntEnv above the declaration and derive MAX_PROMPT_CHARS through it (keeps `let` for the
settings API). A misconfigured value now keeps the 150k default and warns, like the other caps.

Gap (b) — an image present ONLY in a system message silently dropped in non-TUI mode.
Detection runs on the full message list, but extraction/spawn filter role==="system" out, so a
system-only image was detected as multimodal, survived no filter, fell to the text path, and
rendered as "[non-text content omitted]" → 200 with a hallucinated answer — the one silent-drop
outcome F1 exists to forbid. Fix: after filtering, if hasImageContent(full) is true but no image
survives, return `400 images_unsupported_in_system_messages`. Narrow (OpenAI disallows images in
the system role) so no legitimate request is rejected. Documented in README § Images.

Tests: +4 (all pure-module) — parsePositiveInt('unlimited') keeps the 150k default (gap a) and a
valid override is honored; hasImageContent proves the guard predicate fires for a system-only
image (true on full list, false after the system filter) and does NOT fire for a user-message
image. 304 passed, 0 failed.

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

---------

Co-authored-by: vvlasy-openclaw <vvlasy-openclaw@users.noreply.github.com>
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Co-authored-by: vvlasy-openclaw <vvlasy@gmail.com>
Co-authored-by: dtzp555 <dtzp555@gmail.com>
2026-07-20 07:50:17 +10:00
dtzp555-maxGitHubtaodengClaude <claude-opus-4-8> <noreply@anthropic.com>
fe12419386 feat(server): SPOT-derived prompt budget — MAX_PROMPT_CHARS follows models.json (ADR 0009) (#179)
* feat(server): SPOT-derived prompt budget — MAX_PROMPT_CHARS default follows models.json (ADR 0009)

Maintainer directive (2026-07-18): the hand-set 150,000-char default (~37.5k English
tokens) is obsolete in the long-context era. Instead of a new constant that would rot
the same way, the default now derives from the SPOT:

  MAX_PROMPT_CHARS (default) = max(models.json contextWindow) x 3 chars/token
                             = 200000 x 3 = 600,000 chars today (~150-200k tokens)

x3 is the CJK-safe multiplier: English runs ~4 chars/token, CJK ~1-1.5, so a
1M-token-derived char cap would let CJK text sail past the model's real window into
an upstream rejection; at x3 the cap fires at roughly the model's true window and OCP
truncates gracefully (tail-first) instead. Pure derivePromptCharBudget() in
lib/prompt.mjs with a 150k floor guarding degenerate SPOT states (empty models[],
absent contextWindow) - a zero budget would truncate every request to nothing.

CLAUDE_MAX_PROMPT_CHARS (env) and the runtime settings API remain ABSOLUTE overrides;
derivation applies only when neither is set. If models.json ever advertises a larger
window (e.g. 1M for the 1M-native models), the budget scales automatically - that
advertisement is a separate deliberate decision (quota burn, OpenClaw compaction, TUI
paste limits) explicitly NOT made here; see ADR 0009.

Behavior change (intended): requests between 150k and 600k chars previously truncated
now pass through whole - longer TTFT + higher quota use for those requests. Truncation
mechanism/logging unchanged; only the default's provenance changed.

ALIGNMENT.md Rule 2: no cli.js citation applies - the truncation guard is OCP-internal
prompt shaping; no endpoint, header, or wire field changes.

Tests: +4, doubly mutation-proven (max->min fails the largest-window test; dropping
the floor fails the floor test). Suite 347 passed / 0 failed. ADR 0009 + index row +
README env-table row included (release_kit: env var default change documented).

Co-Authored-By: Claude <claude-opus-4-8> <noreply@anthropic.com>

* fix(server): empty CLAUDE_MAX_PROMPT_CHARS falls back to derived default (PR #179 review)

Reviewer regression: `!= null` treated an EMPTY env value ("CLAUDE_MAX_PROMPT_CHARS="
in an EnvironmentFile/.env) as explicit -> parseInt("") = NaN -> guard disabled + a
false "[System] Note: 0 older messages were truncated" injected into every prompt.
Extracted resolvePromptCharBudget() (truthiness contract, matching the old
`parseInt(env || default)` behavior) into lib/prompt.mjs so the semantics are
mutation-tested: switching back to != null fails the empty-string test. +2 tests, 349/0.

Co-Authored-By: Claude <claude-opus-4-8> <noreply@anthropic.com>

---------

Co-authored-by: dtzp555 <dtzp555@gmail.com>
Co-authored-by: Claude <claude-opus-4-8> <noreply@anthropic.com>
2026-07-18 10:11:38 +10:00
dtzp555-maxGitHubtaodengClaude <claude-opus-4-8> <noreply@anthropic.com>
17038b56e6 chore(release): v3.23.0 — sonnet-5 default, upgrade reliability, CLAUDE_SYSTEM_PROMPT, README restructure (#178)
* chore(release): v3.23.0 — sonnet-5 default, upgrade reliability, CLAUDE_SYSTEM_PROMPT, README restructure

Consolidates #167/#168/#170-#177 (merged since the v3.22.1 tag). 3.22.1 → 3.23.0.

Minor because #168 changes the default model for every request that omits
`model` (sonnet alias 4-6 → 5) and #175 makes CLAUDE_SYSTEM_PROMPT functional —
behavior changes and a newly-working env var; not major because no API surface
breaks and pinning restores the old default.

Release-kit walk: CLAUDE_SYSTEM_PROMPT README row added in #175 (incl. cache
caveat); Available Models table updated in #168 (sonnet-5 = default); no new
endpoint; models.json alias change is the SPOT edit; new docs/ files indexed in
Repository Layout (#172); bootstrap quirks retained in README §Troubleshooting.
Version sourced from package.json only (no stale refs — grepped).

Tag push v3.23.0 at this squash commit triggers release.yml.

Co-Authored-By: Claude <claude-opus-4-8> <noreply@anthropic.com>

* docs(readme): CLAUDE_SYSTEM_PROMPT cache caveat updated — #177 made the flush obsolete (release reviewer F1)

---------

Co-authored-by: dtzp555 <dtzp555@gmail.com>
Co-authored-by: Claude <claude-opus-4-8> <noreply@anthropic.com>
2026-07-17 19:40:02 +10:00
dtzp555-maxGitHubtaodengClaude <claude-opus-4-8> <noreply@anthropic.com>
73314e6698 fix(cache): fold a boot-config epoch into the response-cache key (#176) (#177)
The cache key hashed model + keyId + sampling params + raw messages, but the
ANSWER also depends on boot-time server config that shapes the composed prompt
and tool surface: CLAUDE_SYSTEM_PROMPT (newly load-bearing since #175),
OCP_SYSTEM_PROMPT_WRAPPER, CLAUDE_ALLOWED_TOOLS, and CLAUDE_NO_CONTEXT. The
cache store is SQLite-backed and survives restarts, so an operator who changed
any of these and restarted kept serving answers composed under the OLD config
until TTL expiry (found by the #175 independent reviewer).

Fix: server.mjs computes CONFIG_EPOCH once at boot — a 16-hex sha256 digest of
the four values — and passes it to cacheHash, which folds `ce:<epoch>|` into the
key. Any config change = instant whole-cache invalidation (the honest behavior).
Callers that omit configEpoch (tests, any older path) hash byte-identically to
before — asserted by test. Runtime-mutable settings (maxPromptChars via the
settings API) are deliberately excluded: a const epoch cannot track them, and
truncation drops context rather than changing the instruction set (noted in the
code comment).

One-time side effect on upgrade: existing cache entries no longer match (keys
now carry the epoch). Cache is off by default and TTL-bounded; a one-time miss
storm is the cost of never serving stale-config answers again.

ALIGNMENT.md Rule 2: no cli.js citation applies — cache-key composition is
OCP-internal (Class B); no endpoint, header, or wire field changes.

Tests: +2, mutation-proven (dropping the fold fails both). Suite 343/0.

Closes #176

Co-authored-by: dtzp555 <dtzp555@gmail.com>
Co-authored-by: Claude <claude-opus-4-8> <noreply@anthropic.com>
2026-07-17 19:31:31 +10:00
dtzp555-maxGitHubtaodengClaude <claude-opus-4-8> <noreply@anthropic.com>
e6f1a6aac1 fix(server): wire CLAUDE_SYSTEM_PROMPT (dead since APPEND_SYSTEM_PROMPT retirement) (#175)
* fix(server): wire CLAUDE_SYSTEM_PROMPT into the composed system prompt (was dead since APPEND_SYSTEM_PROMPT retirement)

The var was read (SYSTEM_PROMPT, server.mjs), documented in the file header as
"appended to all requests", and echoed on /health.systemPrompt — but nothing on
the request path consumed it: extractSystemPrompt() composed only the wrapper +
client system messages. The wiring was lost when APPEND_SYSTEM_PROMPT was
retired, leaving the header comment and the buildCliArgs comment describing
behavior that did not exist (caught by the PR #170 independent reviewer).

Wire, not delete, because:
- the /health `systemPrompt` field is part of the grandfathered B.2 contract
  (ADR 0006, frozen at v3.16.4) — removal would need an ADR; wiring keeps the
  shape and makes the field honest;
- fleet check: no deployment sets the var (Mac prod plist, Oracle systemd unit,
  PI231 /etc/ocp/ocp.env all clean), so wiring changes behavior for NOBODY today;
- with the var unset, appendOperatorPrompt returns its input string unchanged —
  the default path is byte-for-byte identical.

Mechanics: new pure lib/prompt.mjs `appendOperatorPrompt(base, operatorAppend)`
(trimmed; whitespace-only treated as unset so a stray space in a service unit
cannot inject "\n\n " into every request), applied as the LAST segment in both
extractSystemPrompt branches — an operator-wide directive reads as the final
instruction, after client system messages. TUI-mode is untouched (panes keep the
interactive CLI's own system prompt); documented in the README row.

ALIGNMENT.md Rule 2: no cli.js citation applies. No endpoint, header, or wire
field is added or altered; the change affects only the CONTENT OCP passes to the
already-established `--system-prompt` flag (file header § verified v2.1.104),
i.e. OCP-owned prompt composition. /health shape unchanged.

Tests: +3, doubly mutation-proven (unconditional-base revert → 2 failures;
trim removal → 2 failures). Suite 341 passed / 0 failed.

Co-Authored-By: Claude <claude-opus-4-8> <noreply@anthropic.com>

* docs(readme): cache-staleness caveat on CLAUDE_SYSTEM_PROMPT row (reviewer advisory)

---------

Co-authored-by: dtzp555 <dtzp555@gmail.com>
Co-authored-by: Claude <claude-opus-4-8> <noreply@anthropic.com>
2026-07-17 19:18:57 +10:00
dtzp555-maxGitHubtaodengClaude <claude-opus-4-8> <noreply@anthropic.com>
f14f4ec754 fix(upgrade): doctor fetches tags before deciding latest + post-flight asserts served version (#173) (#174)
Two fixes for the two halves of issue #173, both from live incidents during the
2026-07-17 fleet update:

1. scripts/doctor.mjs — `git show origin/main:package.json` reads the LOCALLY
   CACHED remote ref; without a fetch first, any machine that hadn't pulled since
   the last release saw latest == current and reported "Already at latest" (live
   repro: Oracle VM at 3.21.1 with v3.22.1 released). The doctor now runs
   `git fetch --tags --quiet` (15s timeout) before comparing, gated on
   !opts.skipNetwork; on failure (offline/auth) it falls through to the cached
   ref — the pre-existing behavior. All existing doctor tests pass mockLatest +
   skipNetwork, so no test touches the network.

2. scripts/upgrade.mjs — post-flight accepted any healthy /health (auth.ok only),
   so a stale process holding the port passed post-flight while still serving the
   OLD version (live repro: a Jul-7 nohup-fallback orphan held :3456; upgrade
   "succeeded", /health kept serving 3.21.1). New exported predicate
   postFlightOk(body, target): auth.ok AND /health.version === target (leading-v
   tolerant; empty target degrades to the auth-only check, never blocks). The
   failure message now reports the last-seen version and points at the
   stale-process diagnosis (`ss -ltnp` / `lsof -i`).

Tests: +4, mutation-proven — reverting the predicate to auth-only fails the
"orphan case" test (337/1). Full suite 338 passed / 0 failed.

No server.mjs change — scripts layer only; no cli.js operation involved, so no
citation applies.

Closes #173

Co-authored-by: dtzp555 <dtzp555@gmail.com>
Co-authored-by: Claude <claude-opus-4-8> <noreply@anthropic.com>
2026-07-17 18:52:45 +10:00
bafad077ff fix(upgrade): make snapshot paths Windows-safe (#167)
* fix(upgrade): make snapshot paths Windows-safe

Use a filesystem-safe UTC timestamp for new upgrade snapshot directories while retaining legacy ISO timestamp parsing. Normalize test paths with node:path so the Windows suite exercises the same behavior.

Co-Authored-By: claude-flow <ruv@ruv.net>

* fix(upgrade): sort snapshots by parsed timestamp

Order legacy colon and Windows-safe dash snapshot names chronologically so rollback and retention keep the actual newest snapshot across the migration boundary.

Co-Authored-By: claude-flow <ruv@ruv.net>

---------

Co-authored-by: nyxst4ck <nyxst4ck@users.noreply.github.com>
Co-authored-by: claude-flow <ruv@ruv.net>
Co-authored-by: nyxst4ck <289980115+nyxst4ck@users.noreply.github.com>
2026-07-17 18:39:09 +10:00
dtzp555-maxGitHubtaodengClaude <claude-opus-4-8> <noreply@anthropic.com>
b038d3ceac docs: restructure README (1205 → 497 lines) — ops-manual content moves to docs/ (#172)
Maintainer-approved P2 restructure. Principle: what a new user needs in the first
10 minutes stays in README; the operations manual moves to docs/. Content MOVED,
not rewritten — an independent verifier swept all 20 original sections, 60+
distinctive facts, and all table rows against the new corpus: zero content loss.

New files (verbatim moves + two mandated dedup merges):
- docs/lan-mode.md    (396) — LAN setup, key management, quotas, anonymous access,
                              deployment/security model + honest limits, client connect
- docs/tui-mode.md    (196) — full TUI section + the four giant env-cell essays as
                              prose subsections; opens with the single-user SECURITY
                              warning + the PAUSED billing-split status banner
- docs/troubleshooting.md (136) — full troubleshooting; canonical 401/credential-
                              isolation explanation (union of the 4 prior copies)
- docs/upgrading.md   (79)  — upgrade paths, snapshots, rollback, auto-sync

README keeps: pitch (byte-identical), new TOC, 62-line Quickstart, How It Works
verbatim (incl. #171 billing-status note + workload fit), the three release_kit-
pinned tables (Available Models / API Endpoints / all 37 Environment Variables
rows — 4 giant TUI cells now one-line pointers), All Commands, slim Troubleshooting
(bootstrap quirks retained per release_kit bootstrap_quirk_policy), summary stubs
linking each moved doc, Repository Layout (+4 doc rows), Governance, Support.

Dedup (canonical copies): sdk-cli vs subscription-pool table → docs/tui-mode.md;
credential-isolated-home / permanent-401 → docs/troubleshooting.md#tui-401.
Link retargets: docs/runbooks/615-canary.md, docs/runbooks/tui-flip-rollback.md,
setup.mjs (one banner string; node --check clean). 126 links across the touched
files verified resolving; repo-wide grep shows no reference to a removed anchor.

npm test: 332 passed / 0 failed.

Co-authored-by: dtzp555 <dtzp555@gmail.com>
Co-authored-by: Claude <claude-opus-4-8> <noreply@anthropic.com>
2026-07-17 14:28:36 +10:00
dtzp555-maxGitHubtaodengClaude <claude-opus-4-8> <noreply@anthropic.com>
faea02d951 docs(readme): date-stamp the billing-split status (PAUSED) + scope LAN mode to chat-class workloads (#171)
P0 from the README audit, direction set by the maintainer's history lesson: the
2026-06-15 -p billing split was announced (2026-05-14) but PAUSED by Anthropic on
the effective date — officially: "For now, nothing has changed: Claude Agent SDK,
claude -p, and third-party app usage still draw from your subscription's usage
limits" (support.claude.com article 15036540). The README asserted the split as
in-force in five places while the top-of-README "$0 / no API billing" pitch said
the opposite — a self-contradiction where the PITCH was the currently-true half.

- How It Works: added the dated billing-policy status note (official quote + link);
  the "$0" claims stand, now anchored to it.
- TUI § "What it is and why": status banner; the cc_entrypoint table relabeled
  "announced regime, currently paused"; TUI-mode reframed as the ready-made HEDGE
  for if/when a reworked change lands (advance notice promised).
- "2026-06-15 operator checklist" -> "Operator checklist for the (paused) billing
  split": nothing needs flipping while the pause holds; checklist retained verbatim
  as the re-landing runbook.
- OCP_SKIP_AUTH_TEST env row + /health tui-block prose: conditioned on the paused
  regime instead of asserting it.
- Client-tools boundary: new "workload fit" paragraph (maintainer-approved
  positioning): LAN/multi-device OCP = chat-class workloads; client-machine
  coding agents are architecturally out of scope (tools execute on the OCP host)
  — run claude/OCP on the machine where the code lives.

Lesson encoded: policy-dependent claims carry dates and the official source, so
the next policy swing is an edit, not an archaeology dig.

Co-authored-by: dtzp555 <dtzp555@gmail.com>
Co-authored-by: Claude <claude-opus-4-8> <noreply@anthropic.com>
2026-07-17 13:54:05 +10:00
dtzp555-maxGitHubtaodengClaude <claude-opus-4-8> <noreply@anthropic.com>
6f18613f9d docs(readme): staleness sweep — model count/examples, phantom ocp stop, undocumented env vars, ocp-connect claim (#170)
* docs(readme): sweep stale content — 6 models, drop phantom `ocp stop`, document 2 live env vars, fix ocp-connect claim

P1 findings from a full README-vs-code staleness audit (each verified against the
tree at 0c3e42b):

- "5 models" x4 (L124/151/174/323) -> 6; the /v1/models curl example (L244) and
  ocp-connect sample output (L353) now include claude-sonnet-5 (added #152).
- Troubleshooting told users to run `ocp stop`, which has never existed (the ocp
  case table has no stop) -> replaced with the real launchctl/systemctl commands
  + a note that stopping goes through the service manager.
- CLAUDE_SYSTEM_PROMPT (server.mjs:326, applied at :1084) and CLAUDE_MCP_CONFIG
  (server.mjs:1124 -p path; lib/tui/session.mjs:447 FULL_TOOLS panes) are live
  config with no env-table row -> added both rows.
- "ocp-connect detects and configures Claude Code, Cursor, ..." over-claimed:
  it auto-configures OpenClaw only, prints hints for Cursor/Cline/Continue/opencode,
  and has no Claude Code logic at all (OCP exposes an OpenAI-compat surface;
  Claude Code speaks the Anthropic protocol) -> reworded L28 + removed Claude Code
  from the client-connect prompt template (L160).
- Upgrade examples presented v3.14 as the frontier -> refreshed to current-era
  numbers (v3.21.0->v3.21.1 patch, v3.18->v3.22 cross-minor). Historical feature
  attributions ("as of v3.14.0", bootstrap-quirk notes) kept — they are facts.

Docs only; no code change. P0 (billing status note) and P2 (structure) are
tracked separately.

Co-Authored-By: Claude <claude-opus-4-8> <noreply@anthropic.com>

* docs(readme): drop the CLAUDE_SYSTEM_PROMPT row (dead env var) + fix systemd stop to --user

Reviewer traced consumers: SYSTEM_PROMPT (server.mjs:326) is only echoed on
/health (:2906) and the startup log (:3270) — extractSystemPrompt() never reads
it, so the row documented behavior that does not exist. The dead var + the two
stale in-code comments (server.mjs:19, :1084) go on the backlog: wire it or
remove it (a server.mjs change, out of this docs PR's scope).

Also: setup.mjs installs the systemd unit under --user (:514), so the
Troubleshooting stop line loses the sudo and gains --user.

Co-Authored-By: Claude <claude-opus-4-8> <noreply@anthropic.com>

---------

Co-authored-by: dtzp555 <dtzp555@gmail.com>
Co-authored-by: Claude <claude-opus-4-8> <noreply@anthropic.com>
2026-07-17 09:30:57 +10:00
0c3e42b2e4 feat(models): repoint default sonnet alias to claude-sonnet-5 (#168)
* feat(models): add Claude Sonnet 5 to models.json SPOT

Adds `claude-sonnet-5` (the latest Sonnet, supported by claude CLI >= 2.1.206)
to models.json — the single source of truth (ADR 0003). Both the /v1/models
endpoint and setup.mjs OpenClaw registration derive from it automatically.

- New model entry `claude-sonnet-5` (reasoning, 200k ctx, 16k max tokens),
  mirroring the existing Sonnet entry shape.
- Point the `sonnet` alias at `claude-sonnet-5` (newest Sonnet), consistent
  with `opus` -> `claude-opus-4-8`. Previous `claude-sonnet-4-6` is retained
  for pinning.
- README "Available Models" table updated (release-kit 5.3).
- Update the aliases.sonnet SPOT test to the new default.

Endpoint class: B.1 (/v1/models), data-only via the models.json SPOT.
Authorized by ADR 0006 (OpenAI shim scope) + ADR 0003 (models.json SPOT).
Verified: `claude --model claude-sonnet-5 -p` returns a valid response on a
current subscription CLI (2.1.206); npm test green.

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

* fix(models): make PR #152 purely additive + close ocp-connect drift + real SPOT test

Addresses the maintainer's review. Rescopes this PR to the additive change only —
adding claude-sonnet-5 to models.json — and defers the `sonnet` alias repoint to
its own PR per Iron Rule 11 (the alias is the default for every request that omits
`model`; repointing it is a behavior change that deserves separate review + a
CHANGELOG entry). No server.mjs change, so no cli.js citation required.

Metadata confirmed unchanged: contextWindow 200000 / maxTokens 16384 stay, per the
maintainer's correction (OCP truncates at MAX_PROMPT_CHARS, and contextWindow feeds
OpenClaw's compaction budget — advertising a larger window than OCP delivers just
makes OpenClaw overshoot).

Fixes vs review:

1. Reverted `aliases.sonnet` back to claude-sonnet-4-6 — this PR only *adds* the
   model; the repoint ships separately. README updated to match (5 is available by
   full ID; 4-6 remains the alias default).

2. Replaced the tautological SPOT test. The old assertion read a literal out of
   models.json and asserted it equalled the same literal — it passed even with a
   dangling alias. Added referential-integrity tests: every aliases/legacyAliases
   value must resolve to a real models[].id, plus an explicit assertion that
   claude-sonnet-5 exists in models[]. This is the guard that actually catches an
   alias pointing at a non-existent model (VALID_MODELS keys on alias names, never
   targets, so nothing else checks this).

3. Fixed ocp-connect classification drift. Its prefix table pinned "claude-sonnet-4",
   which misses "claude-sonnet-5" and falls through to the non-reasoning / 8k-output
   default. Broadened both the model_meta and alias_prefixes tables to family
   prefixes (claude-opus / claude-sonnet / claude-haiku) so any future versioned ID
   classifies correctly with no per-model edit. /v1/models does not expose
   reasoning/maxTokens (OpenAI /v1/models schema has no such fields — adding them
   would be a Rule 2 invention), so family classification stays in ocp-connect.
   primary_model stays claude-sonnet-4-6, matching the (unchanged) sonnet alias — it
   moves with the alias in the repoint PR.

Tests: 266 passed, 0 failed.

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

* feat(models): repoint default `sonnet` alias to claude-sonnet-5

Split out from #152 per Iron Rule 11: the additive model entry (#152) lands the
claude-sonnet-5 metadata; this PR makes the behavior change — moving the default
`sonnet` alias from claude-sonnet-4-6 to claude-sonnet-5.

`aliases.sonnet` is the model used for every /v1/chat/completions request that omits
`model` (server.mjs default) and, via ocp-connect, OpenClaw's OCP primary. Repointing
it changes behavior for every such client, so it gets its own PR + CHANGELOG entry
separate from the additive entry.

- models.json: aliases.sonnet -> claude-sonnet-5 (claude-sonnet-4-6 kept by full ID
  for pinning). Both are pricing tier_3_15 — no cost regression.
- ocp-connect: primary_model now prefers claude-sonnet-5 (falls back to 4-6, then
  first model), tracking the alias default so OpenClaw's primary matches.
- README: swap the "default for sonnet alias" annotation onto claude-sonnet-5.
- CHANGELOG: Unreleased § Changed entry documenting the default change + how to pin.
- test: SPOT assertion updated to claude-sonnet-5; referential-integrity tests from
  #152 continue to guard that the alias target actually exists in models[].

No server.mjs change, so no cli.js citation required.

Depends on #152 (needs the claude-sonnet-5 models[] entry to exist, else the
referential-integrity test fails). Rebase/merge after #152 lands.

Tests: 266 passed, 0 failed.

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

---------

Co-authored-by: vvlasy-openclaw <vvlasy@gmail.com>
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Co-authored-by: dtzp555 <dtzp555@gmail.com>
2026-07-17 08:10:33 +10:00
dtzp555-maxGitHubtaodengClaude <claude-opus-4-8> <noreply@anthropic.com>
0fc8d6973b chore(release): v3.22.1 — retitle unpublished v3.22.0 + fold in #161 (Windows resolve) and #152 (Sonnet 5) (#169)
v3.22.0 (#166) was merged but never tagged; #161 and #152 then landed on main,
so the prepared release no longer matched HEAD. Owner opted to renumber: the
v3.22.0 CHANGELOG section becomes v3.22.1 (with an explicit version note),
gains entries for #161 and #152, and the tag will be cut at this release
commit — no tagging of historical commits needed. package.json 3.22.0 → 3.22.1.

Semver note: still a minor-family bump from 3.21.1 (features: #156/#158/#159,
plus #152's new model entry); 3.22.0 is simply skipped — semver requires
increasing versions, not contiguous ones.

Release-kit walk (delta vs #166's walk): #152 → README "Available Models"
table row already present (added in #152 itself) + models.json is the SPOT;
#161 → no new env var/endpoint; README §Windows guidance unchanged (Windows
support deliberately NOT advertised until #167 lands + real-Windows E2E).

Co-authored-by: dtzp555 <dtzp555@gmail.com>
Co-authored-by: Claude <claude-opus-4-8> <noreply@anthropic.com>
2026-07-17 07:59:45 +10:00
24 changed files with 3406 additions and 1000 deletions
+44 -2
View File
@@ -1,8 +1,50 @@
# Changelog
## v3.22.0 — 2026-07-16
## v3.24.0 — 2026-07-21
Minor release: TUI-mode latency and streaming features — **all opt-in and off by default**, so the default request path (`-p` / `--output-format stream-json`) is byte-for-byte unchanged — plus hardening from an independent (Codex) re-review of the streaming work. No new `cli.js` wire behavior and no new endpoint; the new surface is entirely OCP-owned TUI-mode configuration (env vars) and `/health` observation. Every code PR carried a fresh-context reviewer (Iron Rule 10).
Minor release. Headline: two long-requested **OpenAI-compat features** land — **multimodal vision** (`image_url` parts) and **structured outputs** (`response_format` / JSON schema). Also: the prompt-char budget now derives from the model SPOT instead of a hand-set constant, an agentic-turn bug that dropped the model's final answer is fixed, and `OCP_LOCAL_TOOLS` supports the OpenClaw-backend use case. Four of the six landed from external contributors (@vvlasy-openclaw). Every code PR carried a fresh-context reviewer (Iron Rule 10); no new endpoint, no new `cli.js` wire behavior.
### Added
- **Multimodal vision — OpenAI `image_url` parts (#154, contributed by @vvlasy-openclaw).** `/v1/chat/completions` forwards OpenAI `image_url` content parts to `claude` as native Anthropic image blocks via `--input-format stream-json` (the CLI's own contract — no invented wire shape, verified live). Base64 `data:` URIs by default; remote `http(s)` URLs are off unless `CLAUDE_IMAGE_ALLOW_URL=1` (and even then OCP never fetches them — no SSRF surface). Byte/count caps (`CLAUDE_MAX_IMAGE_BYTES`, `CLAUDE_MAX_IMAGES`, `CLAUDE_MAX_IMAGE_TOTAL_BYTES`), all fail-closed on a misconfigured value. TUI mode returns `400 images_unsupported_in_tui_mode` (it can't carry image blocks) and an image present only in a `system` message returns `400` rather than being silently dropped. README § "Images / Multimodal".
- **Structured outputs — OpenAI `response_format` (#153, contributed by @vvlasy-openclaw).** `/v1/chat/completions` honors `response_format: { type: "json_schema" | "json_object" }` so OpenAI-SDK clients (Home Assistant AI Tasks, Honcho, scripts) get machine-parseable JSON in `content`. Validates against the schema (incl. `$ref`/`$defs` + `allOf`/`anyOf`/`oneOf` — the shapes the OpenAI SDK emits), retries with a stronger instruction up to `OCP_STRUCTURED_MAX_ATTEMPTS` (default 3, fail-closed), and on exhaustion returns OpenAI's own `refusal` field (200/`content:null`) rather than an invented error. Cyclic-`$ref` schemas fail closed (no stack overflow); a pathologically deep model reply returns a refusal, not a 500 (#181). Single-flight dedup + structured-keyed cache bound the cost. Class B.1 (ADR 0006). README § "Structured Outputs".
- **SPOT-derived prompt-char budget (#179, ADR 0009).** `MAX_PROMPT_CHARS` default now derives from `max(models.json contextWindow) × 3 chars/token` (600,000 today) instead of the hand-set 150,000 (~37.5k tokens) that silently under-delivered the advertised window ~5×. `CLAUDE_MAX_PROMPT_CHARS` and the settings API remain absolute overrides; a garbage value fails closed to the derived default.
- **`OCP_LOCAL_TOOLS` — positive local-tools system-prompt wrapper (single-user, loopback only; default off) (#182, contributed by @vvlasy-openclaw).** The `-p` path prepends a wrapper telling the model it has no local filesystem/shell access — correct for a shared gateway, but it makes a personal instance's model (e.g. an OpenClaw agent on its own local OCP) refuse to use the server-side `claude` tools it legitimately has. `=1` swaps in a positive wrapper. Changes **only the prompt**, never the tool surface (`--allowedTools`/`--disallowedTools` untouched; multi-tenant still disallows the FS surface); it does **not** enable client-side `tool_calls` (still unsupported by design). Fail-closed boot gate mirroring `OCP_TUI_FULL_TOOLS` (ADR 0007): refuses to start under `CLAUDE_AUTH_MODE=multi`, a non-loopback bind, or `PROXY_ANONYMOUS_KEY`. Inert (and logged as such) in TUI mode. The active wrapper is folded into the config epoch so toggling it invalidates the standard response cache. No new `cli.js` wire behavior (reuses the already-cited `--system-prompt` flag).
### Fixed
- **Agentic turns dropped the model's final answer (#183, contributed by @vvlasy-openclaw).** On a tool-using turn, `/v1/chat/completions` returned only the opening preamble ("I'll find the repo…") and silently discarded the post-tool-use final answer: aggregate-`assistant` extraction was gated on `isFirstDelta` (which flips false after the first text), and OCP runs pure-aggregate mode (no `--include-partial-messages`), so each of an agentic turn's several assistant messages after the first was lost. Now guards on `sawTextDelta` and accumulates every assistant message (streaming and buffered paths assemble byte-identically).
- **Deep structured reply returned a 500 instead of a refusal (#181 / #184).** `validateJsonSchema` recurses on the model reply's nesting depth; a ~2000-level-deep reply overflowed the stack → caught `RangeError` → generic 500. A crash-safe façade converts that (only) into a validation miss → refusal; any other throw still surfaces.
## v3.23.0 — 2026-07-17
Minor release. Headline: **the default `sonnet` alias now resolves to Claude Sonnet 5** — a behavior change for every request that omits `model` (pin `claude-sonnet-4-6` by full ID to keep the previous default). Also: Windows-safe upgrade snapshots, two upgrade-system reliability fixes from a live fleet update, the `CLAUDE_SYSTEM_PROMPT` env var made functional, cache-key honesty for config changes, a billing-policy status correction (the 2026-06-15 `-p` split is PAUSED by Anthropic), and a major README restructure. No new endpoint; no new `cli.js` wire behavior. Every code PR carried a fresh-context reviewer (Iron Rule 10).
### Changed
- **Default `sonnet` alias → `claude-sonnet-5` (#168, contributed by @vvlasy-openclaw).** The `sonnet` alias (the model used for every `/v1/chat/completions` request that omits `model`, and OpenClaw's OCP primary via `ocp-connect`) now resolves to `claude-sonnet-5` instead of `claude-sonnet-4-6`. `claude-sonnet-4-6` remains available by full ID for pinning. Mirrors the shipped Claude CLI's own `latest_per_family` mapping (`sonnet → claude-sonnet-5`, verified from binary 2.1.211). Split out from the additive model entry (#152) per Iron Rule 11.
- **`CLAUDE_SYSTEM_PROMPT` is now functional (#175).** The var was read, documented, and echoed on `/health.systemPrompt` but never reached a request (dead since the `APPEND_SYSTEM_PROMPT` retirement). It is now appended (last, trimmed) to the composed system prompt on the default `-p` path via the new pure `lib/prompt.mjs`; TUI-mode panes are unaffected. Unset ⇒ byte-identical composition to before. README § Environment Variables documents it, including the cache caveat below.
### Fixed
- **Windows-safe upgrade snapshot paths (#167, contributed by @nyxst4ck).** Snapshot directory timestamps now use `-` instead of `:` (Windows forbids `:` in names); legacy colon-named snapshots keep parsing, and `listSnapshots` now orders by **parsed timestamp** (with a deterministic name tie-breaker) so mixed legacy/new names sort chronologically — the initial revision's raw-string sort could delete the newest recovery snapshot at the format boundary and was caught in review; regression tests pin the same-hour mixed-format case.
- **`ocp update` reliability — two live-incident fixes (#174, closes #173).** (1) The doctor now runs `git fetch --tags` (offline-tolerant) before computing `latest_version` — previously it compared against the locally cached `origin/main`, so machines that hadn't pulled since a release reported "Already at latest" forever. (2) Post-flight now asserts `/health.version` equals the upgrade target (new `postFlightOk` predicate) instead of accepting any `auth.ok` — a stale orphan process holding the port used to pass post-flight while still serving the old version; the failure message now reports the last-seen version and points at `ss -ltnp`/`lsof -i`.
- **Response-cache key now carries a boot-config epoch (#177, closes #176).** The persistent cache keyed on model+key+params+messages but not on server config that shapes answers (`CLAUDE_SYSTEM_PROMPT`, wrapper text, `CLAUDE_ALLOWED_TOOLS`, `CLAUDE_NO_CONTEXT`) — changing any of these and restarting could serve stale-config answers until TTL expiry. A sha256 config-epoch is folded into every key; any config change is an instant whole-cache invalidation. One-time side effect: existing cache entries miss once after this upgrade.
### Docs
- **Billing-policy status corrected (#171).** Anthropic **paused** the announced 2026-06-15 `claude -p` billing split on its effective date (official help-article citation in README § How It Works): the default `-p` path currently bills the subscription, and TUI-mode is reframed as the ready-made **hedge** for if/when a reworked change lands. All in-force assertions of the split are now date-stamped and conditioned.
- **LAN mode scoped to chat-class workloads (#171).** New "workload fit" paragraph: multi-device OCP is for text-in/text-out workloads; client-machine coding agents are architecturally out of scope (tools execute on the OCP host).
- **README restructured, 1205 → ~500 lines (#172).** Operations-manual content moved to `docs/lan-mode.md`, `docs/tui-mode.md`, `docs/troubleshooting.md`, `docs/upgrading.md` (verbatim moves + two canonical dedups; zero content loss verified section-by-section). README keeps the quickstart, the release-kit-pinned reference tables, and summary stubs with links. Plus a staleness sweep (#170): 6-model examples, removal of the never-existed `ocp stop` command, `ocp-connect` claims corrected, current version examples.
## v3.22.1 — 2026-07-17
Minor release: TUI-mode latency and streaming features — **all opt-in and off by default**, so the default request path (`-p` / `--output-format stream-json`) is byte-for-byte unchanged — plus hardening from an independent (Codex) re-review of the streaming work, Windows `claude.exe` startup resolution, and the Claude Sonnet 5 model entry. No new `cli.js` wire behavior and no new endpoint; the new surface is entirely OCP-owned TUI-mode configuration (env vars), startup binary discovery, model metadata, and `/health` observation. Every code PR carried a fresh-context reviewer (Iron Rule 10). (Version note: v3.22.0 was prepared but never tagged; its contents ship here as v3.22.1 together with the additions below.)
### Added
- **Claude Sonnet 5 in the model SPOT (#152, contributed by @vvlasy-openclaw)** — `claude-sonnet-5` added to `models.json` (`contextWindow` 200000 / `maxTokens` 16384 / `reasoning` true, consistent with existing entries), exposed via `/v1/models` and the OpenClaw sync. Purely additive: the `sonnet` alias still resolves to `claude-sonnet-4-6` (the repoint is tracked separately in #168). `ocp-connect`'s model classifier now matches on the model *family* prefix (`claude-sonnet`/`claude-opus`/`claude-haiku`) instead of version-pinned prefixes, so current and future versioned IDs register with correct `reasoning`/`maxTokens` metadata. New referential-integrity tests guard that every alias target exists in `models[]`.
- **Windows `claude.exe` startup resolution (#161, contributed by @nyxst4ck, diagnosis credit #147 @Justinsato)** — on Windows, `resolveClaude()` now discovers a native `claude.exe` (`%USERPROFILE%\.local\bin`, WinGet Links, WindowsApps, then `where.exe`) and rejects npm `.cmd`/`.bat`/`.ps1` shims, which cannot be spawned without a shell — previously startup resolved a shim and failed. A non-`.exe` `CLAUDE_BIN` on Windows is a fatal error with an actionable hint. The macOS/Linux path is byte-for-byte unchanged. Note: this is startup binary resolution only — full Windows support is not yet claimed (snapshot-path portability is tracked in #167).
### Added — TUI mode (all opt-in, default off)
+313 -895
View File
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,54 @@
# ADR 0009 — Prompt-char budget derives from the models.json SPOT
Date: 2026-07-18
Status: Accepted (maintainer directive, 2026-07-18: "37.5k 截断未免太短了吧 … 这个在现在还适用吗")
## Context
`MAX_PROMPT_CHARS` (the tail-first truncation guard in `messagesToPrompt`) defaulted to a
hand-set constant of 150,000 chars ≈ 37.5k English tokens — set in the 200k-window era as a
runaway-context guard. Meanwhile `models.json` advertises `contextWindow: 200000` for every
model (and the underlying CLI registry carries 1M native windows for Opus 4.8 / Sonnet 5), and
`scripts/sync-openclaw.mjs` feeds that 200k into OpenClaw's compaction budget. The result was
a standing dishonesty identified in the PR #152 review: **no advertised contextWindow value was
true**, because the proxy silently guillotined every request at ~37.5k tokens — roughly 5×
below the advertised window — logging only a server-side warning the client never sees.
Raising the constant to another hand-set number would rot the same way. Following the model's
native 1M directly is also wrong: chars ≠ tokens (CJK runs ~11.5 chars/token vs ~4 for
English, so a 1M-token char cap would let CJK text sail past the model's real window into an
upstream rejection), single near-window requests can consume a large fraction of a 5-hour
subscription quota window, and the TUI paste path is untested at megabyte scale.
## Decision
The default budget **derives from the SPOT** instead of being a constant:
```
MAX_PROMPT_CHARS (default) = max(models.json models[].contextWindow) × 3 chars/token
= 200000 × 3 = 600,000 chars today
```
Implemented as the pure `derivePromptCharBudget(models, {charsPerToken = 3, floor = 150000})`
in `lib/prompt.mjs` (unit-tested; floor guards degenerate SPOT states). The multiplier ×3 is
deliberately conservative: full window for English, and CJK text reaches the model's real
window at roughly the same point the cap fires — so OCP truncates gracefully (tail-first)
instead of the upstream rejecting outright.
`CLAUDE_MAX_PROMPT_CHARS` (env) and the runtime settings API remain **absolute overrides**;
the derivation applies only when neither is set.
## Consequences
- The advertised `contextWindow: 200000` becomes honest: the proxy now actually accepts
prompts of that order (English ≈150200k tokens) before truncating.
- If `models.json` ever advertises a larger window (e.g. 1M for the 1M-native models), the
budget scales automatically — no code change. Whether to advertise 1M is a **separate,
deliberate decision** (quota burn per request, OpenClaw compaction memory, TUI paste
limits) and is explicitly NOT made by this ADR; the current recommendation is to keep
200000 advertised until a real >200k use case appears.
- One-time behavior change: requests between 150k and 600k chars that were previously
truncated now pass through whole — longer TTFT and higher quota consumption for those
requests, by design.
- The truncation mechanism, logging, and the multimodal-path budget threading (PR #154's F2,
pending) are unchanged — only the default value's provenance changed.
+1
View File
@@ -25,6 +25,7 @@ New ADRs increment from the highest existing number. Filenames are
| [0006](0006-openai-shim-scope.md) | OpenAI Shim Scope | The Class A / Class B taxonomy. Class A endpoints (`cli.js`-mirror) keep Rules 15 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. |
| [0007](0007-tui-interactive-mode.md) | TUI Interactive Mode | Why TUI-mode spawns an interactive `claude` in a tmux pane (no `-p`) to reach the **subscription** billing pool (`cc_entrypoint=cli`) rather than the metered Agent SDK pool. Owns the TUI spawn machinery: entrypoint labeling, credential-isolated home, MCP hard-disable, session namespace + defunct-session reaping, the independent concurrency bound, and the `/health` `tui` block. **Single-user only** — hard FATAL on multi-user configs. |
| [0008](0008-tui-warm-pane-pool.md) | TUI Warm Pane Pool | Why `OCP_TUI_POOL_SIZE` pre-boots **single-use** `claude` panes (one turn each, own `--session-id`) — and why reuse is forbidden (`transcript.mjs` returns the last assistant entry in the file, so a reused session leaks the earlier turn's text). Measured 41% end-to-end. Defines the pool↔reaper invariant (exemption by exact name from a live registry; drain before every sweep so `kill-server` zombie reaping survives) and the standing idle-process cost. Extends ADR 0007. |
| [0009](0009-spot-derived-prompt-budget.md) | SPOT-Derived Prompt Budget | Why `MAX_PROMPT_CHARS`'s default is `max(models.json contextWindow) × 3 chars/token` (600k chars today) instead of a hand-set constant — the old 150k silently under-delivered the advertised window ~5×. ×3 is the CJK-safe multiplier; env/settings stay absolute overrides; whether to advertise 1M windows is explicitly a separate decision. |
## When to write a new ADR
+396
View File
@@ -0,0 +1,396 @@
Part of [OCP](../README.md) — LAN & multi-user: server setup, client connect, API-key management, per-key quotas, anonymous access, and the deployment/security model (including the honest limits of sharing).
# LAN & multi-user
OCP has two roles: **Server** (runs the proxy, needs Claude CLI) and **Client** (connects to a server, zero dependencies).
```
┌─ Server (always-on device) ─────────────────────────────┐
│ Mac mini / NAS / Raspberry Pi / Desktop │
│ Claude CLI + OCP server → bound to 0.0.0.0:3456 │
└───────────────────────┬─────────────────────────────────┘
│ LAN
┌───────────────────┼───────────────────┐
▼ ▼ ▼
Laptop Phone/Tablet Pi / Server
(client) (browser) (client)
```
## Server Setup
> **Recommended:** Install OCP on a device that stays powered on — Mac mini, NAS, Raspberry Pi, or a desktop that doesn't sleep. This ensures all clients always have access.
**Prerequisites:**
- macOS or Linux (Windows is not supported — `setup.mjs` installs launchd / systemd auto-start)
- Node.js 22.5+ (Node 23+ recommended — `node:sqlite` is fully stable without flags from 23.0; on 22.522.x it works behind `--experimental-sqlite`)
- `git`
- [Claude CLI](https://docs.anthropic.com/en/docs/claude-cli) — install and authenticate:
```bash
npm install -g @anthropic-ai/claude-code
claude auth login # prints a URL + code — open URL on any browser, sign in, paste code back
```
Headless servers (Pi / NAS / VPS without a desktop browser): see [Headless install notes](#headless-install-notes) below.
```bash
# 1. Clone and run setup
git clone https://github.com/dtzp555-max/ocp.git
cd ocp
node setup.mjs
```
The setup script will:
1. Verify Claude CLI is installed and authenticated
2. Start the proxy on port 3456
3. Install auto-start (launchd on macOS, systemd on Linux)
After install the `ocp` CLI lives at `~/ocp/ocp`. To put it on your PATH, either symlink it manually (`ln -sf ~/ocp/ocp ~/.local/bin/ocp` if `~/.local/bin` is on your PATH, or `sudo ln -sf ~/ocp/ocp /usr/local/bin/ocp` for a system-wide symlink) or add an alias (`alias ocp=~/ocp/ocp`). Otherwise invoke it as `~/ocp/ocp <subcommand>`. The rest of this document assumes `ocp` is on your PATH.
> **Cloud/Linux servers:** If `ocp: command not found` after a cloud install, the binary isn't in PATH. Full path in that layout: `~/.openclaw/projects/ocp/ocp`
**Single-machine use** — just set your IDE to use the proxy:
```bash
export OPENAI_BASE_URL=http://127.0.0.1:3456/v1
```
**LAN mode** — reach OCP from your own devices on the network (Claude Pro/Max are per-user accounts — see [Sharing with family / a team — honest limits](#deployment-model--security-read-this) before extending access to other people):
```bash
# Enable LAN access with per-user auth (recommended)
node setup.mjs --bind 0.0.0.0 --auth-mode multi
```
Then create API keys for each person/device:
```bash
# Generate a strong admin key (one-time — save it for later key management):
export OCP_ADMIN_KEY=$(openssl rand -base64 32)
# Add the same export line to ~/.zshrc or ~/.bashrc so it persists.
ocp keys add wife-laptop
# ✓ Key created for "wife-laptop"
# API Key: ocp_example12345abcde...
# Copy this key now — you won't see it again.
ocp keys add son-ipad
ocp keys add pi-server
```
Run `ocp lan` to see your IP and ready-to-share instructions.
**Verify:**
```bash
curl http://127.0.0.1:3456/v1/models
# Returns: claude-opus-4-8, claude-opus-4-7, claude-opus-4-6, claude-sonnet-5, claude-sonnet-4-6, claude-haiku-4-5-20251001
```
### Headless install notes
OCP is designed for always-on devices that often don't have a desktop browser — Mac mini, NAS, Raspberry Pi, cloud VPS. The Claude CLI auth flow still works headless:
**Option 1 — interactive OAuth over SSH (one-shot).** `claude auth login` prints a URL + 8-digit code. Open the URL on **any** device with a browser (your laptop, phone), sign in to your Anthropic account, and paste the code back into the SSH session. No browser needed on the server itself.
**Option 2 — long-lived token (auth once, no re-prompts).**
```bash
claude setup-token # subscription-backed long-lived token
```
Same Claude subscription as Option 1; the token is stored in Claude CLI's normal config location. Useful when you'd rather not redo the OAuth flow when sessions expire.
If `claude auth login` errors out with something like `cannot open browser`, you've hit the same case — fall back to either option above.
## AI-assisted install prompts
If you've got Claude Code, Cursor, or any other AI coding assistant on this machine, you can copy-paste one of these prompts and let the AI walk through the install for you. Each prompt pins the AI to the right README section, names the verification step, and forbids silent retries — so you stay in the loop.
**Single-machine use** — install OCP for IDEs on this same machine only:
```text
I want to install OCP on this machine to use my Claude Pro/Max subscription
as an OpenAI-compatible API for local IDEs.
Please follow https://github.com/dtzp555-max/ocp/blob/main/README.md
§Quickstart (single-machine install):
1. Verify prerequisites: macOS or Linux, Node.js 22.5+, git, Claude CLI
installed and logged in (`claude auth status`). Install missing pieces
using my system's package manager.
2. git clone the repo, cd in, and run `node setup.mjs`.
3. Verify with `curl http://127.0.0.1:3456/v1/models` (should list 6 models).
4. Add `export OPENAI_BASE_URL=http://127.0.0.1:3456/v1` to my shell rc.
5. Tell me to reload my shell and try a tool like Cline / Continue / Cursor.
Before each step, tell me what you'll run and wait for confirmation.
On any error, diagnose first — don't auto-retry.
```
**LAN mode (server)** — install OCP as a server so your own devices on the LAN can reach it (Claude Pro/Max are per-user accounts — review Anthropic's Usage Policy before extending access to other people):
```text
I want to install OCP on this device as a LAN server so my own devices on the
network can reach my Claude Pro/Max subscription through a local
OpenAI-compatible endpoint.
Please follow https://github.com/dtzp555-max/ocp/blob/main/docs/lan-mode.md
"Server Setup" → "LAN mode" path:
1. Verify prerequisites: macOS or Linux (Windows not supported), Node.js
22.5+, git, Claude CLI installed and authenticated.
2. Generate a strong admin key with `openssl rand -base64 32`. Save it —
I'll need it to manage per-user keys later.
3. git clone https://github.com/dtzp555-max/ocp.git && cd ocp
4. Run `node setup.mjs --bind 0.0.0.0 --auth-mode multi`.
5. Add OCP_ADMIN_KEY to my shell rc (~/.zshrc or ~/.bashrc).
6. Run `ocp lan` to show me the LAN IP and connect command.
7. Optionally create example keys: `ocp keys add laptop`, `ocp keys add tablet`.
8. Verify: `curl http://127.0.0.1:3456/v1/models` returns 6 models.
Tell me each step before running it. On error, diagnose before retrying.
```
**Client connect** — configure this device to use an existing OCP server on your LAN:
```text
There's an OCP server at <SERVER_IP> on my LAN. Configure this machine to
use it for any local IDEs (Cursor, Cline, Continue.dev, OpenCode, OpenClaw).
Server IP: <SERVER_IP>
API key (leave blank if the server has anonymous mode enabled): <OPTIONAL_KEY>
Please follow https://github.com/dtzp555-max/ocp/blob/main/docs/lan-mode.md
"Client Setup" path:
1. Download ocp-connect:
curl -fsSL https://raw.githubusercontent.com/dtzp555-max/ocp/main/ocp-connect -o ocp-connect
chmod +x ocp-connect
2. Run `./ocp-connect <SERVER_IP>` (add `--key <KEY>` if you have one).
3. Follow any IDE-specific manual hints it prints.
4. Verify: `curl http://<SERVER_IP>:3456/v1/models` returns 6 models.
5. Tell me to reload my shell + restart any IDE that was already running.
Don't auto-retry on error. Tell me the failure mode first.
```
## Client Setup
> Clients do **not** need to install Node.js, Claude CLI, or the OCP repo. Only `curl` and `python3` are required (pre-installed on most Linux/Mac systems).
>
> **Find the server's LAN IP** by running `ocp lan` on the server machine — it prints both the IP and a ready-to-share connect command.
**One-command setup** — download the lightweight `ocp-connect` script:
```bash
curl -fsSL https://raw.githubusercontent.com/dtzp555-max/ocp/main/ocp-connect -o ocp-connect
chmod +x ocp-connect
./ocp-connect <server-ip>
```
**Zero-config** — when the server admin has set `PROXY_ANONYMOUS_KEY` *and* opted in with `PROXY_ADVERTISE_ANON_KEY=1` (see [Anonymous Access](#anonymous-access-optional) below), just pass the server IP and nothing else. `ocp-connect` reads the anonymous key from `/health` and uses it automatically. Without the opt-in, `/health` does not expose the key (issue #109); pass `--key` or rely on anonymous access instead:
```bash
./ocp-connect <server-ip>
```
If the server requires a key, pass it with `--key`:
```bash
./ocp-connect <server-ip> --key <your-api-key>
```
Or as a one-liner (no file saved):
```bash
curl -fsSL https://raw.githubusercontent.com/dtzp555-max/ocp/main/ocp-connect | bash -s -- <server-ip>
```
Example:
```
$ ./ocp-connect 192.168.1.100
OCP Connect v1.3.0
─────────────────────────────────────
Remote: http://192.168.1.100:3456
Checking connectivity...
✓ Connected
Remote OCP v3.11.0 (auth: multi)
ⓘ Using server-advertised anonymous key: ocp_publ...n_v1
(set by admin via PROXY_ANONYMOUS_KEY; see issue #12 §14 Path A)
Testing API access...
✓ API accessible (6 models available)
Shell config:
✓ .bashrc
✓ .zshrc
OPENAI_BASE_URL=http://192.168.1.100:3456/v1
System-level (launchctl):
✓ OPENAI_BASE_URL set for GUI apps and daemons
IDE Configuration
─────────────────────────────────────
Detected: OpenClaw (~/.openclaw/openclaw.json)
Configure OpenClaw to use this OCP? [Y/n] y
Provider name (models show as <name>/model-id) [ocp]: ocp
How should OCP models be configured?
1) Primary — use OCP by default, keep existing models as backup
2) Backup — keep current primary, add OCP as additional option
Choice [1]: 1
Writing OpenClaw config...
✓ Per-agent auth profile seeded (2):
• ~/.openclaw/agents/main/agent/auth-profiles.json
• ~/.openclaw/agents/macbook_bot/agent/auth-profiles.json
✓ OpenClaw configured
Provider: ocp
Models:
• ocp/claude-opus-4-8
• ocp/claude-opus-4-7
• ocp/claude-opus-4-6
• ocp/claude-sonnet-5
• ocp/claude-sonnet-4-6
• ocp/claude-haiku-4-5-20251001
Priority: PRIMARY (default model)
Restart OpenClaw to apply: openclaw gateway restart
Running smoke test...
✓ Smoke test passed: OK
Note: smoke test only verifies OCP is reachable and the key is valid.
It does not verify your IDE/agent end-to-end. To verify OpenClaw works,
restart it (`openclaw gateway restart`) and send a test message to your bot.
Done. Reload your shell to apply:
source ~/.zshrc
```
The script automatically:
- Writes env vars to all relevant shell rc files (`.bashrc`, `.zshrc`)
- Sets system-level env vars (`launchctl setenv` on macOS, `environment.d` on Linux)
- **Auto-discovers anonymous key** from `/health.anonymousKey` when no `--key` given (v1.3.0+, requires server v3.10.0+; server must also set `PROXY_ADVERTISE_ANON_KEY=1` — see [Anonymous Access](#anonymous-access-optional))
- Configures OpenClaw automatically (including per-agent `auth-profiles.json` for multi-agent setups)
- Detects Cline, Continue.dev, Cursor, and opencode, and prints setup hints (manual configuration required for these IDEs)
On macOS, `launchctl setenv` vars reset on reboot — re-run `ocp-connect` after restart.
**Manual setup** — if you prefer not to use the script:
```bash
export OPENAI_BASE_URL=http://<server-ip>:3456/v1
export OPENAI_API_KEY=ocp_<your-key>
```
Add these lines to `~/.bashrc` or `~/.zshrc` to persist across sessions.
## Monitoring (Server-side)
```bash
# Per-key usage stats
ocp usage --by-key
# Key Reqs OK Err Avg Time
# wife-laptop 5 5 0 8.0s
# son-ipad 3 3 0 6.2s
# Manage keys
ocp keys # List all keys
ocp keys revoke son-ipad # Revoke a key
```
**Web Dashboard:** Open `http://<server-ip>:3456/dashboard` in any browser for real-time monitoring — per-key usage, request history, plan utilization, and system health.
![OCP Dashboard](images/dashboard.png)
## Auth Modes
| Mode | Env | Use Case |
|------|-----|----------|
| `none` | `CLAUDE_AUTH_MODE=none` | Trusted home network, no auth needed |
| `shared` | `CLAUDE_AUTH_MODE=shared` + `PROXY_API_KEY=xxx` | Everyone shares one key |
| `multi` | `CLAUDE_AUTH_MODE=multi` + `OCP_ADMIN_KEY=xxx` | Per-person keys for usage tracking + quotas (trusted users only — see Deployment model below) |
> **Usage scope (v3.14.0+):** `/api/usage` returns the caller's own rows by default. Admin callers must pass `?all=true` to retrieve data for all keys; doing so emits an audit log line.
## Deployment model & security (read this)
**What OCP is built for today: single-user, multi-IDE.** Run OCP as a server on one machine and point all of *your own* IDEs/devices at it — one Claude Pro/Max subscription, used everywhere. This is the primary, solid use case.
**Sharing with family / a team — honest limits.** You *can* share OCP on a LAN, but be clear about what the auth modes do and don't give you:
- The per-key modes (`shared` / `multi`) give per-key **usage tracking, quotas, and cache separation** — useful for seeing who used what and capping budgets.
- They do **not** give a **security isolation boundary**. The spawned `claude` runs with the **operator's filesystem access** and is *not* sandboxed per key. **Only share with people you fully trust, on a trusted network.**
- For simple trusted family sharing, the easiest setup is a single shared **anonymous key** (see [Anonymous Access](#anonymous-access-optional)) — no per-person separation, same trust assumption.
- **Account terms and ToS — read before sharing with others.** Claude Pro/Max are *per-user* accounts. Pooling a single subscription across **multiple distinct people** may violate Anthropic's Consumer Terms of Service and risk account suspension by the abuse classifier. The defensible framing is **"one person, your own devices"** — sharing with friends or a team is not. OCP does not change your account terms, and whether any particular sharing setup complies with the ToS is the account holder's responsibility. Review Anthropic's Usage Policy before extending access to other people.
**Real per-user isolation (sandboxed, multi-tenant-safe) is planned for after 2026-06-15** — per-key ephemeral home + tool lockdown + an OS sandbox. Until then, treat a multi-user OCP as a *trusted-group convenience*, not a security boundary. (This is also why `CLAUDE_TUI_MODE` is single-user-only — see [Subscription-pool (TUI) mode](tui-mode.md#subscription-pool-tui-mode).)
## Anonymous Access (optional)
In `multi` mode, the admin can designate a single well-known "anonymous" key that bypasses `validateKey()` and grants public read/write access. This is useful for letting LAN users (or clients like OpenClaw multi-agent setups) connect without individual per-user keys.
**Enable**:
The anonymous key is wired into the service unit (launchd plist on macOS, systemd unit on Linux) at install time. Export `PROXY_ANONYMOUS_KEY` in your shell before running `setup.mjs`, and `setup.mjs` will write it into the service unit env so the auto-started proxy picks it up:
```bash
export PROXY_ANONYMOUS_KEY=ocp_public_anon # or any string of your choice
node setup.mjs --bind 0.0.0.0 --auth-mode multi
```
If OCP is already installed without it, re-export the env var and re-run `node setup.mjs` (the installer is idempotent — it refreshes the service unit). Then `ocp restart` so the running proxy picks up the new env. Setting `PROXY_ANONYMOUS_KEY` only in your interactive shell **does not** affect the auto-started proxy — the service unit is the source of truth for its environment.
**Client side**: the anonymous key value is exposed via `GET /health` as the field `anonymousKey` (null when not set) **only to localhost callers** or when the admin has also set `PROXY_ADVERTISE_ANON_KEY=1` (default off — see issue #109). With that opt-in, clients like `ocp-connect` can auto-discover and use it, so the end user doesn't need to get a personal key from the admin.
**Security note**: setting this env var is an **opt-in** to public access — anyone who can reach your OCP endpoint can use it, up to any rate limits you configure. Don't enable this on internet-exposed OCP instances without additional protection.
**Not a secret**: because `/health` is an unauthenticated endpoint, the anonymous key is **publicly readable** by anyone who can reach the server. That is intentional — the key exists so clients can self-configure without out-of-band coordination. Treat it as a convenience handle, not as an access credential.
## Per-Key Quota (Budget Control)
Prevent any single user from exhausting your subscription. Set daily, weekly, or monthly request limits per API key:
```bash
# Set a daily limit of 50 requests for a key
curl -X PATCH http://127.0.0.1:3456/api/keys/wife-laptop/quota \
-H "Authorization: Bearer $OCP_ADMIN_KEY" \
-d '{"daily": 50}'
# Set multiple limits at once
curl -X PATCH http://127.0.0.1:3456/api/keys/son-ipad/quota \
-H "Authorization: Bearer $OCP_ADMIN_KEY" \
-d '{"daily": 20, "weekly": 100}'
# Check current quota + usage
curl http://127.0.0.1:3456/api/keys/wife-laptop/quota
# → { "daily": { "limit": 50, "used": 12 }, "weekly": { "limit": null, "used": 34 }, ... }
# Remove a limit (set to null)
curl -X PATCH http://127.0.0.1:3456/api/keys/wife-laptop/quota \
-d '{"daily": null}'
```
When a key exceeds its quota, OCP returns HTTP 429 with a structured error:
```json
{
"error": {
"message": "Quota exceeded: 50/50 requests (daily). Resets 6h 12m.",
"type": "quota_exceeded",
"quota": { "period": "daily", "limit": 50, "used": 50, "resetsIn": "6h 12m" }
}
}
```
- `null` = unlimited (default for all keys)
- Only successful requests count toward quota
- Admin and anonymous users are never subject to quotas
- PATCH is a partial update — omitted fields are left unchanged
> **Note:** quotas are best-effort. Under concurrent bursts a key can exceed its cap by up to the server's max-concurrency (default 8), and cache hits are not counted toward quota. They cap budgets for cooperative family use, not adversarial abuse.
## Important Notes
- All users share your Claude Pro/Max **rate limits** (5h session + 7d weekly)
- `ocp usage` shows how much quota remains
- Keys are stored in `~/.ocp/ocp.db` (SQLite, zero external dependencies)
- Admin key is required for key management API endpoints
- The dashboard (`/dashboard`) and health check (`/health`) are always public
- File modes for `~/.ocp` (0700), `admin-key` + `ocp.db` (0600) are auto-tightened at server startup as of v3.14.0
+2 -2
View File
@@ -8,7 +8,7 @@ The billing classifier reading `cli` is **necessary but NOT sufficient** proof.
## Prerequisites
- `CLAUDE_TUI_MODE=true` already set and OCP restarted (see [TUI-mode setup in README](../../README.md#enabling-tui-mode-opt-in))
- `CLAUDE_TUI_MODE=true` already set and OCP restarted (see [TUI-mode setup](../tui-mode.md#enabling-tui-mode-opt-in))
- `tmux` installed on the host
- No other OCP traffic during the canary (quiesce — see below)
- Access to your Anthropic account billing page (manual step — see below)
@@ -148,4 +148,4 @@ Run this after any major `claude` CLI upgrade. The `auto` mode lets the CLI's ow
- [Flip/rollback runbook](./tui-flip-rollback.md) — how to set and unset `CLAUDE_TUI_MODE` on systemd and launchd hosts
- [ADR 0007](../adr/0007-tui-interactive-mode.md) — TUI-mode architecture and governing rules
- README § [Subscription-pool (TUI) mode](../../README.md#subscription-pool-tui-mode)
- [Subscription-pool (TUI) mode](../tui-mode.md#subscription-pool-tui-mode)
+1 -1
View File
@@ -176,5 +176,5 @@ If you want to continue using OCP without TUI-mode after 2026-06-15, budget for
- [615-canary runbook](./615-canary.md) — how to verify billing pool routing after a flip
- [ADR 0007](../adr/0007-tui-interactive-mode.md) — TUI-mode architecture; Kill-switch section
- README § [Subscription-pool (TUI) mode](../../README.md#subscription-pool-tui-mode)
- [Subscription-pool (TUI) mode](../tui-mode.md#subscription-pool-tui-mode)
- README § [Environment Variables](../../README.md#environment-variables) — `CLAUDE_TUI_MODE`, `OCP_TUI_ALLOW_LAN=1`
+136
View File
@@ -0,0 +1,136 @@
Part of [OCP](../README.md) — full troubleshooting manual. The README keeps a slim version with the most common issues and the one-time bootstrap quirks; everything else lives here.
# Troubleshooting
The simplest path: ask your AI.
Paste this prompt:
```
Run `ocp doctor` and follow its `next_action`. Tell me if you hit
anything that needs human input.
```
The doctor produces a JSON `next_action` with `ai_executable[]` (commands
the agent runs verbatim) and `human_required[]` (steps that need you,
typically just OAuth).
## Manual debugging
### Setup fails with "claude: command not found"
`setup.mjs` requires the Claude CLI to be on `PATH`. Install it via the [official guide](https://docs.anthropic.com/en/docs/claude-cli), confirm with `which claude`, then run `claude auth login` before re-running `node setup.mjs`.
### Setup fails with "EADDRINUSE: port 3456 already in use"
Something else is already bound to port 3456 — usually an old OCP instance. Check what:
```bash
lsof -nP -iTCP:3456 -sTCP:LISTEN
```
If it's an old OCP process, stop it before re-running setup:
```bash
launchctl bootout gui/$(id -u)/dev.ocp.proxy # macOS launchd
systemctl --user stop ocp-proxy # Linux systemd (installed as a --user unit)
```
(There is no `ocp stop` subcommand — the proxy runs as a service, so stopping it goes through the service manager above. `ocp restart` exists for the bounce case.)
### Setup fails with "node: command not found" or version error
OCP requires Node.js 22.5+. Install:
```bash
brew install node # macOS
# Linux: see https://nodejs.org/en/download for current install commands
```
Confirm with `node --version` (should be ≥ v22.5).
### Requests fail or agents stuck
```bash
# Clear sessions and restart
ocp clear
ocp restart
# If using OpenClaw gateway
openclaw gateway restart
```
### Env var change (e.g. `CLAUDE_BIND`, `CLAUDE_CODE_OAUTH_TOKEN`) doesn't take effect after restart
On **macOS**, `ocp restart` does a full `launchctl bootout` + `bootstrap` of the agent, which **re-reads the plist `EnvironmentVariables`** — so an env change you made (in `~/Library/LaunchAgents/dev.ocp.proxy.plist`) actually takes effect:
```bash
ocp restart
```
This is deliberate: the older `launchctl kickstart -k` only re-execs the process and **reuses launchd's cached environment**, so plist env edits would be silently ignored. If you ever restart the agent by hand, use bootout+bootstrap, not `kickstart -k`:
```bash
launchctl bootout gui/$(id -u)/dev.ocp.proxy 2>/dev/null
launchctl bootstrap gui/$(id -u) ~/Library/LaunchAgents/dev.ocp.proxy.plist
```
Verify the new value reached the running process:
```bash
ps -E -p "$(launchctl print gui/$(id -u)/dev.ocp.proxy 2>/dev/null | awk '/pid =/{print $3}')" | tr ' ' '\n' | grep CLAUDE_
```
On **Linux**, `systemctl --user restart` already re-reads the unit's `EnvironmentFile`, so no special handling is needed.
### Usage shows "unknown"
Usually caused by an expired Claude CLI session. Fix:
```bash
claude auth login
ocp restart
```
### Startup log warns "OpenClaw registry out of sync"
On boot, OCP compares OpenClaw's registered models against [`models.json`](../models.json) and warns if they drift. Cause: someone (or an OpenClaw upgrade) modified `~/.openclaw/openclaw.json` and removed entries OCP expects. Fix:
```bash
node ~/ocp/scripts/sync-openclaw.mjs
```
This is read-only at startup; the warning never blocks the gateway from running.
### A TUI session vanished right after upgrading OCP
If you ran a pre-3.21.1 OCP instance and a post-3.21.1 instance on the same host at the same time during an upgrade, the new instance's one-time boot reap can, once, kill an old-format (`ocp-tui-<8hex>`) live TUI session belonging to the still-running old instance — restart the affected session (`ocp restart` or re-run your TUI turn) and it will come back under the new instance's port-scoped naming.
### OpenClaw shows old models after `ocp update` (v3.10→v3.11 only)
One-time bootstrap quirk for the v3.10.0 → v3.11.0 jump only — the running shell had the old `cmd_update` cached. Run once manually:
```bash
node ~/ocp/scripts/sync-openclaw.mjs
openclaw gateway restart # so OpenClaw re-reads the config
```
Future `ocp update` invocations sync automatically.
<a id="tui-401"></a>
### TUI-mode returns a permanent `Please run /login` 401 (re-login doesn't stick)
A long-running TUI-mode host can get stuck returning a permanent 401 (`Please run /login · API Error: 401`) that re-login cannot fix.
**Root cause (two layers):** interactive `claude` **prefers `~/.claude/.credentials.json` over the `CLAUDE_CODE_OAUTH_TOKEN` env var** (this is *unlike* the `-p` path, where the env token wins). So (a) a stale/corrupt `credentials.json` **shadows** the env token — passing the token is not enough on its own; and (b) when claude does use `credentials.json`, its single-use OAuth refresh token can be corrupted (ending up an empty string) by the per-request spawn + `kill-session` teardown racing claude's token rotation. Re-login writes a fresh token, but the next spawn re-corrupts it. Proven live on PI231: *env token passed + broken `credentials.json` present → 401; env token passed + `credentials.json` moved aside → works.*
**Fix:** set `CLAUDE_CODE_OAUTH_TOKEN` on the OCP host and leave `OCP_TUI_HOME` **unset**. OCP then runs the TUI `claude` in a **credential-isolated home** (`$HOME/.ocp-tui/home`) that has **no `credentials.json`** at all, so the env token is the only credential (authoritative — nothing shadows it) and claude never runs the refresh path (so the single-use token can't be corrupted). Then restart — on systemd `daemon-reload`, on launchd `bootout`+`bootstrap`; `kickstart -k` does **not** reload env. Verify the env reached the process and the boot log shows the isolated home:
```bash
# Linux (systemd): confirm the token is in the service env
tr '\0' '\n' < /proc/$(pgrep -f server.mjs | head -1)/environ | grep CLAUDE_CODE_OAUTH_TOKEN
# Boot log should read: TUI-mode: ON home=$HOME/.ocp-tui/home ... auth=env-token (credential-isolated home — no credentials.json)
```
> If you previously set `OCP_TUI_HOME` to the real home (or any home that contains a `credentials.json`), **unset it** so the credential-isolated default takes effect — otherwise the shadowing `credentials.json` remains in play.
See [Subscription-pool (TUI) mode](tui-mode.md#subscription-pool-tui-mode) and ADR 0007 PR-C / PR-D amendments.
+196
View File
@@ -0,0 +1,196 @@
Part of [OCP](../README.md) — subscription-pool (TUI) mode: serve requests through interactive `claude` so they bill the Pro/Max subscription pool instead of the metered Agent SDK path.
# Subscription-pool (TUI) mode
> **SECURITY — read before enabling.**
> TUI-mode is **single-user / single-operator only**. `claude` runs with the OCP process owner's filesystem access regardless of `HOME` setting. If OCP serves multiple users or guest API keys, a guest prompt could exfiltrate files or exhaust the subscription. **Never enable `CLAUDE_TUI_MODE=true` on a multi-user OCP.**
## What it is and why
> **⚠️ Status (as of 2026-07): the billing split below is PAUSED.** Anthropic announced it for 2026-06-15, then paused it on the effective date — *"For now, nothing has changed: Claude Agent SDK, `claude -p`, and third-party app usage still draw from your subscription's usage limits"* ([official help article](https://support.claude.com/en/articles/15036540-use-the-claude-agent-sdk-with-your-claude-plan)). While the pause holds, OCP's default `-p` path bills the subscription and **TUI-mode is a hedge, not a necessity**. The table describes the *announced* regime, kept here because Anthropic says a reworked change will return (with advance notice) — everything in this section is ready to flip on that day.
The announced routing keys `claude` invocations by `cc_entrypoint`:
| Launch method | `cc_entrypoint` | Billing pool (announced regime, currently paused) |
|---------------|-----------------|-------------|
| `claude -p` / `--output-format` (OCP default) | `sdk-cli` | Agent SDK credit pool (~$20/mo on Pro) |
| Interactive `claude` (no flags) | `cli` | Pro/Max subscription pool |
TUI-mode lets OCP serve requests via the interactive path so they bill against the subscription pool under that regime. The response is read from claude's native JSONL session transcript once the turn is complete, then replayed to the caller as a normal OpenAI completion or chunked SSE response.
<a id="tui-entrypoint"></a>
## Billing-classifier labeling (`OCP_TUI_ENTRYPOINT`)
`OCP_TUI_ENTRYPOINT` (default `cli`) controls how `CLAUDE_CODE_ENTRYPOINT` is set on the spawn
environment. The default (`cli`) pins the value deterministically — immune to a stray inherited
env var or a future stdout-redirect bug silently flipping it to `sdk-cli`. This label is honest
**only** when the spawn is a genuine interactive PTY (tmux pane, no `-p`, stdout not redirected,
and `tmux new-session` verified to succeed). If you need to observe the raw TTY-derived value, set
`OCP_TUI_ENTRYPOINT=auto`. See ADR 0007 for the full rationale and governing rule.
## Enabling TUI-mode (opt-in)
```bash
# Prerequisites
mkdir -p ~/.ocp-tui/work # one-time scratch cwd setup
# tmux must be installed: brew install tmux / apt install tmux
# Enable
export CLAUDE_TUI_MODE=true
# STRONGLY RECOMMENDED on a TUI host — authenticate via the long-lived OAuth token.
# With this set (and OCP_TUI_HOME left UNSET), OCP runs the interactive claude in a
# credential-isolated home ($HOME/.ocp-tui/home, no credentials.json), so the env token
# is the only credential and is authoritative. This both stops a stale credentials.json
# from shadowing the token AND ends the refresh-token corruption that caused a permanent
# "Please run /login" 401 (no credentials file → claude never runs the refresh path).
# See the auth note below + ADR 0007 PR-D.
export CLAUDE_CODE_OAUTH_TOKEN=sk-ant-oat01-...
# Optionally tune:
export CLAUDE_TUI_WALLCLOCK_MS=180000 # 3 min cap for long Opus turns
export OCP_TUI_CWD=$HOME/.ocp-tui/work # default; override if needed
export OCP_TUI_ENTRYPOINT=cli # default; use 'auto' to observe TTY-derived value
# Do NOT set OCP_TUI_HOME for the recommended setup — leaving it unset is what enables
# the credential-isolated home. Set it only to opt into the legacy symlinked-creds mode.
```
Then restart OCP. At boot you will see (with the env token set, isolated home auto-selected):
```
⚠️ TUI-mode ON — single-user only; do NOT enable on a multi-user OCP ...
TUI-mode: ON home=/home/user/.ocp-tui/home cwd=/home/user/.ocp-tui/work auth=env-token (credential-isolated home — no credentials.json) wallclock=120000ms maxConcurrent=2
```
## What changes / what doesn't
- **Callers see no API change.** The response is a normal OpenAI completion object or chunked SSE — identical wire format.
- **Real streaming is opt-in (`OCP_TUI_STREAM=1`), and off by default.** By default TUI-mode buffers the full response and replays it as chunked SSE — you see a delay, then the complete response. Set `OCP_TUI_STREAM=1` and `stream:true` turns emit real SSE `delta.content` chunks as `claude` renders them, sourced from `claude`'s own `MessageDisplay` hook (byte-faithful raw markdown, on the subscription pool, no `-p`). Two honest caveats: granularity is **block-level** — the hook fires once per rendered block, so a handful of chunks per answer, scaling with length, not token-by-token; and it moves the **first** byte, not the last, so a consumer that must parse a complete reply gains nothing. The transcript stays authoritative: every streamed turn is asserted against it at the end, and a turn whose stream disagrees is **failed rather than served** (watch `tui.streamDivergences` on `/health`). Evidence: [`plans/2026-07-13-tui-latency/streaming-spike.md`](plans/2026-07-13-tui-latency/streaming-spike.md).
- **Cache and singleflight work normally.** TUI-mode writes the buffered response to the cache on success; cache-hits skip the interactive turn entirely.
- **The host's `CLAUDE.md` / auto-memory is never injected.** OCP is a proxy — the proxied client (OpenClaw / your IDE) owns its own context and memory. TUI-mode always runs `claude` with `CLAUDE_CODE_DISABLE_CLAUDE_MDS` + `CLAUDE_CODE_DISABLE_AUTO_MEMORY`, so a `CLAUDE.md` on the OCP host can never leak into proxied turns (verified live; see #4). Built-in tool schemas + the interactive system prompt remain (the inherent ~2035K context floor of interactive mode); MCP is hard-disabled.
- **Authenticate via `CLAUDE_CODE_OAUTH_TOKEN` in a credential-isolated home (recommended).** tmux does not forward the parent process's env to the pane, so OCP sets the token explicitly on the spawned `claude` when `CLAUDE_CODE_OAUTH_TOKEN` is present. With the env token set and `OCP_TUI_HOME` unset, OCP runs claude in a **credential-isolated home** (`$HOME/.ocp-tui/home`) that has **no `credentials.json`** — so the env token is the only credential and is authoritative, and claude never runs the token-refresh path. This both stops a stale `credentials.json` from shadowing the token and ends the refresh-token corruption behind the permanent `Please run /login · API Error: 401` (full two-layer root cause, live proof, and fix in [Troubleshooting § the permanent TUI-mode 401](troubleshooting.md#tui-401)). Transcripts land under the same isolated home, so the answer-reader is unaffected. Without the env token, claude falls back to the real home's `credentials.json` (byte-for-byte the previous behaviour). (The token is visible in `ps` on the pane command — acceptable for the single-user A-path; the multi-user B-path is refused at boot.) See ADR 0007 PR-C / PR-D amendments.
- **Stale tmux sessions are reaped.** The pane's `claude` is a child of the tmux server (not OCP), so OCP cannot reap it directly; `claude` zombies can otherwise accumulate as `<defunct>` over a long-running host. OCP reaps them at boot and on a 15-min idle sweep by issuing `tmux kill-server` — but **only when no foreign tmux session remains** (it never disrupts a co-hosted `olp-tui-*` instance). See ADR 0007 PR-C amendment.
- **Default path unchanged.** Unset `CLAUDE_TUI_MODE` and restart → `callClaude` / `callClaudeStreaming` are used again, byte-for-byte identical to today.
- **Concurrency is bounded separately.** TUI turns are heavy (per-request cold-boot + long wallclock), so the TUI path has its own limiter — `OCP_TUI_MAX_CONCURRENT` (default `2`), independent of `CLAUDE_MAX_CONCURRENT`. Excess turns queue; a full queue returns a 503. Tune it up only on a host that can run more interactive `claude` sessions at once.
- **Optional warm pane pool (`OCP_TUI_POOL_SIZE`, default off).** Pre-boots panes so a request skips the cold boot — measured p50 `10.17s``6.00s` (41%). Pooled panes are **single-use** (one turn, then killed and replaced in the background), each carrying its own fresh `--session-id`, so one session still means one exchange and no earlier-turn text can leak into a later answer. They are named `ocp-tui-<port>-p<hex>` and coexist with the reaper by design: the sweep **drains the pool first**, then reaps (so `kill-server` still flushes `<defunct>` zombies), then the pool refills in the background. Drain→reap→resume is synchronous, so no request can land mid-sweep; a request arriving while the pool is still re-booting simply misses it and cold-boots. A live pooled pane is never reaped — **including one that is still booting**, whose tmux session already exists — while an *orphaned* one (left by a previous process generation) still is.
## ⚠️ Latency: TUI mode has a ~6-second floor, and it is immovable
**TUI mode cannot serve real-time or interactive-latency consumers.** This is a hard property of the
path, stated plainly so you can rule it out before building on it:
| | measured |
|---|---|
| **TTFT floor (first token)** | **≈ 6 s** — immovable |
| cold boot → input bar ready | ~1 s (per request; not the bottleneck) |
| OCP's own overhead above the CLI | ~4 s (n=1 same-turn decomposition) |
| direct Anthropic API, same prompt (for scale) | 0.841.64 s |
The ~6 s floor is the `claude` CLI itself: it always injects the full Claude Code system prompt plus
its tool definitions before your prompt, on every turn, no matter what you ask. No flag removes it
(`--exclude-dynamic-system-prompt-sections` was measured: **no effect** on the floor). Extended
thinking is *not* the cause — `OCP_TUI_EFFORT` already defaults to `low`, which is what cuts a
formerly-inherited `xhigh` down to this floor and collapses its variance.
On top of the floor you pay the model's generation time (a function of output length). Progressive
output is not wired up **yet** (see "No real token streaming" above — it is achievable and planned),
so today a turn returns as one blob once generation completes. Note that streaming, when it lands,
will move the *first* byte earlier — it does **not** shorten the turn, and a consumer that needs the
complete answer gains nothing from it.
**Use TUI mode for**: batch, background, and latency-insensitive work where the subscription pool is
the point. **Do not use it for**: anything a person is waiting on interactively, or any consumer with
a sub-5-second budget. Full measurements and methodology:
[`plans/2026-07-13-tui-latency/`](plans/2026-07-13-tui-latency/).
## Monitoring drift via `/health`
`GET /health` includes a `tui` block so you can poll for a silent billing-pool drift (the top risk under the announced split, if it re-lands — a lost TTY flipping `cc_entrypoint` from `cli` to `sdk-cli` would still return answers but land in the metered pool). The block is **always present** (with `enabled:false` when TUI-mode is off):
```jsonc
"tui": {
"enabled": true, // CLAUDE_TUI_MODE === "true"
"entrypointMode": "cli", // OCP_TUI_ENTRYPOINT (cli | auto | off)
"lastEntrypoint": "cli", // last cc_entrypoint observed in a transcript, or null
"entrypointMismatches": 0, // count of cli-expected-but-got-other turns — ALERT if this climbs
"inflight": 1, // TUI turns running right now
"queued": 0, // TUI turns waiting for a concurrency slot
"maxConcurrent": 2, // OCP_TUI_MAX_CONCURRENT
"pool": { // warm pane pool — null when OCP_TUI_POOL_SIZE=0 (the default)
"size": 2, // target warm panes (OCP_TUI_POOL_SIZE)
"warm": 2, // panes ready right now — each is a LIVE idle claude process
"booting": 0, // replacement panes currently pre-booting
"model": "claude-sonnet-4-6", // the model being warmed (the most recently requested one)
"hits": 12, // requests served by a warm pane
"misses": 1, // requests that fell back to the cold boot (the 1st is always one)
"boots": 14, // panes successfully pre-booted
"bootFailures": 0, // pre-boots that genuinely never reached the input bar — WATCH this
"cancelled": 4, // in-flight boots OCP killed on purpose (drain / model switch) — not faults
"dropped": 8 // panes discarded unused (drain sweep / expired / unhealthy)
}
}
```
Alert on `entrypointMismatches > 0` (or `lastEntrypoint !== "cli"`): it means a turn drew from the metered Agent SDK pool instead of the subscription. `inflight` / `queued` show how close the TUI path is to its concurrency cap.
With the pool on, `hits` / `misses` is the hit rate (a steady single-model consumer should sit near 100% after the first request), and `warm` is your standing idle-process cost. A climbing `bootFailures` means panes are not reaching their input bar — the pool then degrades safely to the cold path, but latency reverts to the un-pooled numbers. `cancelled` counts boots OCP killed *on purpose* (a drain, a model switch) and is **not** a fault signal — do not alert on it. A steadily climbing `dropped` is likewise normal: the 15-min reap sweep drains and re-boots the pool on every tick so `kill-server` can still flush `<defunct>` zombies.
## Kill-switch
```bash
unset CLAUDE_TUI_MODE
# restart OCP
```
The stream-json path is restored immediately. No other change is needed.
## Operator checklist for the (paused) billing split
> **Status:** the 2026-06-15 split never took effect — Anthropic paused it on the effective date (see the status note at the top of this section). **Nothing needs flipping while the pause holds.** The checklist is retained verbatim as the runbook for if/when a reworked change lands (Anthropic has promised advance notice).
Under the announced regime, every host serving traffic must be flipped to TUI-mode **and** canary-verified before the effective date, or it will bill the metered Agent SDK credit pool instead of the subscription.
- **[Flip/rollback runbook](runbooks/tui-flip-rollback.md)** — how to set `CLAUDE_TUI_MODE=true` on systemd (Linux) and launchd (macOS) hosts. Covers the `daemon-reload` requirement (systemd) and the `bootout`+`bootstrap` cycle requirement (launchd — `launchctl kickstart -k` does not reload plist env).
- **[615-canary runbook](runbooks/615-canary.md)** — after each flip, run one quiesced request and compare the Agent SDK credit balance before and after. `entrypoint:cli` in the transcript (the `cc_entrypoint` billing classifier) is necessary but not sufficient — only a stable credit balance confirms the subscription pool is being used. Balance check is a manual step (no known programmatic API for the Agent SDK credit pool balance).
## Architecture and design decisions
See [`adr/0007-tui-interactive-mode.md`](adr/0007-tui-interactive-mode.md) for the full rationale, home-strategy options, MCP-disable mechanism, coexistence rules, and the B-path (multi-tenant isolation) roadmap.
## TUI-mode environment variables
The README [Environment Variables](../README.md#environment-variables) table lists these as one-line pointers; the full behaviour of each lives here.
<a id="ocp-tui-stream"></a>
### `OCP_TUI_STREAM` — real SSE streaming (opt-in)
`OCP_TUI_STREAM` default `0` (off). When `=1`, `stream:true` requests emit **real SSE `delta.content` chunks as `claude` generates them**, instead of buffering the turn and replaying it. Deltas come from `claude`'s own `MessageDisplay` hook (registered with `--settings` on the ordinary interactive spawn — banner-verified to stay on the subscription pool, `· Claude Max`). Granularity is **block-level**, not token-level. The transcript remains authoritative: the streamed text is asserted equal to it at end-of-turn, the auth-banner and truncation gates still run before anything is committed, and only the transcript text is cached. A turn whose stream cannot be reconciled with the transcript is **refused** (SSE error frame, not cached) and counted as `tui.streamDivergences` on `/health`. A total hook failure (e.g. `--settings` stops registering it after a `claude` version bump) is a *different, silent* failure mode — every streamed turn still succeeds, fully buffered, with no divergence and no error — so it is counted separately as `tui.streamZeroDeltaTurns` (streamed turns where the hook fired **zero** times) and logged as `tui_stream_zero_deltas`; watch it alongside `streamDivergences`. Default off — the buffered path is unchanged and remains the stable default. ⚠️ **Tool-using turns:** the transcript keeps only the model's **last** assistant message, so if the model narrates before calling a tool ("I'll check that file…") and that narration exceeds `OCP_TUI_STREAM_HOLDBACK`, it has already been streamed and cannot be retracted — the turn is then **refused** rather than served (measured live: Opus narrated 475 chars before a `Bash` call). If your deployment lets the model use tools (the TUI default, and anything with `OCP_TUI_FULL_TOOLS=1`), either raise `OCP_TUI_STREAM_HOLDBACK` above the typical narration length — the narration then stays held back and is correctly discarded, at the cost of a later first chunk — or leave streaming off. Streaming is best suited to tool-light chat proxying. See ADR 0007 (2026-07-13 amendment).
Two related streaming knobs:
- **`OCP_TUI_STREAM_DIR`** (default `$HOME/.ocp-tui/stream`) — directory holding the static `MessageDisplay` hook script + settings file, and the per-session delta sink (`<session-id>.jsonl`, removed at turn teardown). One sink **per session-id** — this is what keeps concurrent TUI turns (`OCP_TUI_MAX_CONCURRENT` ≥ 2) from interleaving one client's deltas into another's stream.
- **`OCP_TUI_STREAM_POLL_MS`** (default `100`) — interval at which OCP drains the delta sink. The hook fires at block granularity (seconds apart), so a finer poll buys nothing.
<a id="ocp-tui-stream-holdback"></a>
### `OCP_TUI_STREAM_HOLDBACK`
`OCP_TUI_STREAM_HOLDBACK` default `100`. (TUI-mode, streaming) Characters withheld before the first chunk reaches the client. Two jobs. (1) It keeps the **auth-banner gate** alive under streaming, via a guarantee with two required halves: (i) nothing is emitted for a message until its trimmed accumulation exceeds 100 chars — past the default banner detector's reach, since real banners are ≤100 chars — and (ii) once a message boundary follows an emit, nothing further is ever emitted for the rest of the turn, and the turn is refused outright. Half (i) alone only covers a turn's first message; half (ii) is what covers an error banner rendered as a *later* message (e.g. after tool-using prose). Raise the holdback if you replace the detector via `CLAUDE_TUI_ERROR_PATTERNS` with patterns that can match longer messages — that only affects half (i); OCP warns at boot if you do. (2) It is the knob for **tool-using turns** — see the `OCP_TUI_STREAM` caveat above. Answers shorter than the holdback are simply delivered whole at end-of-turn, exactly as the buffered path does.
<a id="ocp-tui-pool-size"></a>
### `OCP_TUI_POOL_SIZE` — warm pane pool
`OCP_TUI_POOL_SIZE` default `0` (off). Number of **pre-booted warm `claude` panes** kept ready, so a request does not pay the cold boot. `0` disables the pool entirely — the request path is then exactly the cold-boot path. Max `4`; an unparseable value disables it rather than guessing. **Measured on a Mac mini (Sonnet 4.6, `--effort low`): end-to-end p50 `10.17s` (n=6, pool off) → `6.00s` (n=12 warm hits) — 4.2 s / 41%** — the pool recovers both the ~1.2 s boot *and* ~2.9 s of post-input-bar init that a pane which has been idle a moment has already finished. **Cost:** each warm pane is a *live idle `claude` process* held whether or not a request ever arrives (peak processes ≈ pool size + `OCP_TUI_MAX_CONCURRENT` + 1 booting replacement) — which is why it is opt-in. Panes are **single-use**: one turn, then killed and replaced in the background. The **first request after start (and after any model switch) is always a cold miss** — the pool warms the most recently requested model, since OCP cannot know which model the next caller wants. See [`plans/2026-07-13-tui-latency/`](plans/2026-07-13-tui-latency/).
<a id="ocp-tui-full-tools"></a>
### `OCP_TUI_FULL_TOOLS` — full tool surface (single-user only)
`OCP_TUI_FULL_TOOLS` default *(unset)*. (TUI-mode, **single-user only**) When `=1`, grant the interactive session the **same tool surface as the `-p` path**`--allowedTools` (+ optional `--mcp-config`, read from `CLAUDE_ALLOWED_TOOLS` / `CLAUDE_MCP_CONFIG`) — instead of the default MCP-walled, built-in-tools-only set. Lets a trusted single-operator TUI deployment run a **tool-using / MCP agent** (e.g. an OpenClaw assistant) on the subscription pool. Safe because TUI **refuses to boot under `AUTH_MODE=multi`** (hard exit) — no guest key can ever reach the TUI path, so this gate cannot expose tools to an untrusted caller. (Under `AUTH_MODE=shared` + `OCP_TUI_ALLOW_LAN=1`, anyone holding the single shared key reaches it — that is the existing TUI trust model, unchanged.) Note: `--dangerously-skip-permissions` / `CLAUDE_SKIP_PERMISSIONS` is **not** supported for TUI — claude v2.1.x shows an interactive bypass-acceptance screen in headless tmux that cannot be answered, bricking the pane. Use scratch-home `settings.json` `additionalDirectories` instead. See ADR 0007.
<a id="tui-other-vars"></a>
### Other TUI-mode variables
- **`OCP_TUI_MAX_CONCURRENT`** (default `2`) — Max concurrent interactive TUI turns. **Independent** of `CLAUDE_MAX_CONCURRENT` (which bounds the `-p`/stream-json path; TUI never uses it). A TUI turn is heavy (per-request cold-boot of tmux+claude + up to `CLAUDE_TUI_WALLCLOCK_MS` wallclock), so the default is low to keep small hosts (e.g. a Pi 4) alive under a burst. Excess turns **queue** (bounded); a full queue yields a 503. See ADR 0007 PR-B amendment.
- **`OCP_TUI_ENTRYPOINT`** (default `cli`) — Billing-classifier labeling: `cli` (default) pins `cc_entrypoint=cli` deterministically; `auto` lets claude self-classify via TTY detection; `off` leaves the inherited env untouched. Honest only when the spawn is a genuine interactive PTY — see the "Billing-classifier labeling" section above and ADR 0007.
- **`OCP_TUI_EFFORT`** (default `low`) — Effort level passed to the interactive `claude` as an explicit `--effort` flag: `low` (default), `medium`, `high`, `xhigh`, `max`, or `inherit` to omit the flag (the pre-flag behaviour: the pane inherits a HOME-dependent effort — the operator's `~/.claude/settings.json` `effortLevel` in real-home mode, claude's built-in default in env-token scratch mode). Explicit `low` cuts measured TTFT p50 by ~40% and collapses run-to-run variance ~15× versus an inherited `xhigh` (see [`plans/2026-07-13-tui-latency/`](plans/2026-07-13-tui-latency/)); proxied requests rarely benefit from extended thinking. Banner-verified to stay on the subscription pool (`· Claude Max`). An invalid value logs a warning and falls back to `low`.
- **`OCP_TUI_HOME`** (default *(auto)*) — `HOME` claude runs under. **When unset, OCP picks it for you:** if `CLAUDE_CODE_OAUTH_TOKEN` is set → a **credential-isolated** scratch home `$HOME/.ocp-tui/home` (no `credentials.json`, env-token auth — **recommended**); if no env token → the operator's real home (legacy shared `credentials.json`). Setting this to an **explicit** path overrides the auto-default. The credential handling at that path still follows the env token: **with** the env token it is credential-free (env-token auth, no `credentials.json` written); **without** the env token (and the path ≠ real home) it uses the legacy symlinked-credentials scratch mode, which carries the credential-fork caveat — see ADR 0007. If you previously set this to the real home (or any home containing a `credentials.json`) and hit a permanent 401, unset it — see [Troubleshooting § the permanent TUI-mode 401](troubleshooting.md#tui-401).
- **`CLAUDE_TUI_WALLCLOCK_MS`** (default `120000`) — Maximum time in ms to wait for the native transcript to signal turn completion. Increase for long Opus thinking turns.
- **`OCP_TUI_CWD`** (default `$HOME/.ocp-tui/work`) — Scratch working directory where interactive claude sessions run. Transcripts land under `<HOME>/.claude/projects/<encoded-cwd>/`. Created automatically.
- **`CLAUDE_CODE_OAUTH_TOKEN`** — the recommended TUI credential; when set (and `OCP_TUI_HOME` unset) it selects the credential-isolated home. Full precedence and the 401 root cause it prevents are in [Troubleshooting § the permanent TUI-mode 401](troubleshooting.md#tui-401).
+79
View File
@@ -0,0 +1,79 @@
Part of [OCP](../README.md) — the full upgrade manual (`ocp update` paths, manual flags, rollback, and OpenClaw auto-sync). The README keeps a short stub with the one-liner.
# Upgrading
The simplest path: ask your AI.
Paste this prompt:
```
Upgrade my OCP. Run `ocp update` and follow whatever it says.
If it tells me to run `claude auth login`, I'll do that.
```
What `ocp update` does:
- **Patch bump** (e.g. `v3.21.0 → v3.21.1`):
light path (git pull + npm install + restart).
- **Cross-minor** (e.g. `v3.18 → v3.22`):
full path: pre-flight check, snapshot, `setup.mjs` (with plist env-merge),
service restart, post-flight `/health` and `/v1/models` verification.
- **Old version** (< v3.4.0):
fresh-install. Pre-v3.4 lacked admin-key/usage-db, so there is nothing to
migrate. Your OAuth token (managed by the Claude Code CLI, not OCP) is
preserved; you do not need to re-OAuth unless your token expired
separately.
Snapshots are saved to `~/.ocp/upgrade-snapshot-<ISO-ts>/` and never
auto-deleted. Clean old ones with `rm -rf ~/.ocp/upgrade-snapshot-*` once
you're confident the upgrade is stable.
## Manual upgrade — same command, no AI
```bash
ocp update # smart-pick path
ocp update --check # show available updates, don't apply
ocp update --dry-run # preview plan
ocp update --target v3.13.0 # pin a specific version
ocp update --rollback --yes # restore most recent snapshot (--yes confirms)
ocp update --rollback --list # list snapshots, no mutation
ocp update --rollback --dry-run # preview rollback plan
```
## When upgrade fails
`ocp update` prints a recovery line on failure. To restore from the snapshot:
```bash
ocp update --rollback --yes # --yes confirms the destructive restore
ocp doctor
```
If `ocp doctor` still reports problems after rollback, open a GitHub issue
with the snapshot path and the doctor JSON output (`ocp doctor --json`).
## OpenClaw Auto-Sync (v3.11.0+)
Whenever the model list in [`models.json`](../models.json) changes, `ocp update` automatically reconciles your OpenClaw config so the model dropdown stays in sync — no more "I upgraded OCP but my Telegram bot still shows the old models" surprises.
**What gets synced** (and only this — all other config keys are preserved):
- `models.providers."claude-local".models` in `~/.openclaw/openclaw.json`
- `agents.defaults.models["claude-local/*"]` aliases
**Safety**:
- Timestamped backup written before every change: `~/.openclaw/openclaw.json.bak.<ms>`
- Idempotent — already-in-sync runs are a no-op (no backup, no rewrite)
- Non-fatal — sync failure does NOT abort `ocp update`; `/v1/models` still works
- Skips silently if OpenClaw is not installed (`~/.openclaw/openclaw.json` missing)
**Manual trigger** (e.g. after fixing a hand-edited config, or for the one-time v3.10.0→v3.11.0 bootstrap quirk):
```bash
node ~/ocp/scripts/sync-openclaw.mjs
node ~/ocp/scripts/sync-openclaw.mjs --quiet # silent unless changes
```
**Opt-out**: `ocp update` only invokes the sync if `node` and `scripts/sync-openclaw.mjs` are both present. Removing the script disables auto-sync; the rest of `ocp update` still works.
**One-time bootstrap caveat (v3.10.0 → v3.11.0 only)**: the first `ocp update` to v3.11.0 runs the *old* `cmd_update` already loaded into your shell, so the new sync hook does NOT fire on this single jump. Run `node ~/ocp/scripts/sync-openclaw.mjs` once manually. Every future update from v3.11.0+ syncs automatically. (Also captured in the README Troubleshooting section as a bootstrap quirk.)
**Other IDEs** (Cline / Aider / Cursor / opencode) query `/v1/models` live, so they pick up new models on the next request — no sync needed. Continue.dev users edit their own `config.json` model id manually.
+10
View File
@@ -355,6 +355,16 @@ export function cacheHash(model, messages, opts = {}) {
if (opts.temperature != null) h.update(`t:${opts.temperature}`);
if (opts.max_tokens != null) h.update(`mt:${opts.max_tokens}`);
if (opts.top_p != null) h.update(`tp:${opts.top_p}`);
// #176: fold the server's boot-config epoch into the key, so a config change that shapes
// answers (operator system prompt, wrapper text, allowed tools, NO_CONTEXT) invalidates
// the persistent cache instead of serving answers composed under the old config. Callers
// that omit it (older paths, tests) hash byte-identically to before.
if (opts.configEpoch != null) h.update(`ce:${opts.configEpoch}|`);
// Structured-output (OpenAI response_format / json_mode) requests must never share a cache slot
// with the conversational answer to the same prompt, nor with a different schema — the steering
// instruction and validated JSON payload differ. Keying on the detected descriptor isolates them.
// Absent for normal requests → hashes are byte-identical to pre-change.
if (opts.structured != null) h.update(`s:${JSON.stringify(opts.structured)}`);
for (const m of messages) {
h.update(m.role || "");
h.update(typeof m.content === "string" ? m.content : JSON.stringify(m.content));
+29
View File
@@ -0,0 +1,29 @@
// OCP env-var parsing helpers.
//
// Fail-closed positive-integer parsing for numeric caps (body size, image
// byte/count limits). A misconfigured cap must NEVER silently disable a guard:
// `parseInt("unlimited", 10)` is NaN and `x > NaN` is always false, so a naive
// parse of CLAUDE_MAX_BODY_SIZE=unlimited would remove the body-size limit
// entirely (unbounded body → OOM). Likewise CLAUDE_MAX_BODY_SIZE=5MB naively
// parses to 5 (bytes) and bricks the proxy. So a present-but-invalid value is
// REJECTED (default kept, caller warns), not accepted. (PR #154 review F3.)
//
// Pure (no env access, no IO) so it is unit-testable without a live server.
// Parse `raw` as a strictly-positive base-10 integer of bytes/count (no unit
// suffix). Returns { value, ok, reason }:
// - missing/empty → { value: def, ok: true } (use default)
// - valid positive int → { value: n, ok: true }
// - anything else → { value: def, ok: false, reason } (fail closed)
// Rejects: NaN ("unlimited"), non-positive ("0", "-1"), unit-suffixed ("5MB"),
// and fractional/ambiguous ("20.5", "0x10") values — String(n) !== trimmed catches
// any input parseInt only partially consumed.
export function parsePositiveInt(raw, def) {
if (raw === undefined || raw === null || raw === "") return { value: def, ok: true };
const trimmed = String(raw).trim();
const n = parseInt(trimmed, 10);
if (!Number.isFinite(n) || n <= 0 || String(n) !== trimmed) {
return { value: def, ok: false, reason: "not a strictly-positive integer (bytes/count, no unit suffix)" };
}
return { value: n, ok: true };
}
+278
View File
@@ -0,0 +1,278 @@
// OCP multimodal helpers — OpenAI `image_url` content parts → Anthropic image
// blocks fed to `claude -p --input-format stream-json`. (issue #110)
//
// Class B.1 (OpenAI-compatibility surface). Protocol authority is OpenAI's
// chat/completions spec — the multimodal `content` parts shape
// (https://platform.openai.com/docs/guides/vision and
// https://platform.openai.com/docs/api-reference/chat/create#chat-create-messages,
// `image_url` part with `image_url.url` = data URI or http(s) URL). Authorized
// by ADR 0006. This module introduces NO field beyond OpenAI's published shape:
// the OpenAI-side vocabulary read here is `type:"image_url"` +
// `image_url:{url, detail?}`; the Anthropic-side vocabulary written here
// (`type:"image", source:{type:"base64"|"url", ...}`) is the CLI's native
// stream-json input contract, not an OCP invention.
//
// Kept as a pure module (no I/O, no network, no process state) mirroring the
// lib/*.mjs pattern so it is unit-testable without a live server. server.mjs is
// the only consumer; it owns spawning, caps configuration, and HTTP status.
// Anthropic vision-supported image media types. A data URI whose media type is
// outside this set is rejected with a clear 4xx rather than forwarded (the API
// would reject it anyway; failing early gives a better error).
export const SUPPORTED_IMAGE_TYPES = new Set([
"image/jpeg",
"image/png",
"image/gif",
"image/webp",
]);
// Default caps. server.mjs overrides these from env; they live here so the
// pure transform is self-contained and testable.
export const DEFAULT_MULTIMODAL_OPTS = {
allowRemoteUrl: false, // http(s) image URLs are OFF by default (v1: data URIs only)
maxImageBytes: 5 * 1024 * 1024, // per-image decoded-byte cap
maxImages: 20, // max image parts across the whole request
maxTotalImageBytes: 20 * 1024 * 1024, // aggregate decoded-byte cap
maxTextChars: Infinity, // text-char budget (server passes MAX_PROMPT_CHARS); Infinity = no truncation
};
// Typed error so server.mjs can map to the right HTTP status + OpenAI-shaped
// error body. `status` is the HTTP code; `type` is the OpenAI error `type`.
export class MultimodalError extends Error {
constructor(code, status, message) {
super(message);
this.name = "MultimodalError";
this.code = code;
this.status = status;
this.type = "invalid_request_error"; // OpenAI error `type` for 4xx client errors
}
}
// True if any message carries an OpenAI `image_url` content part. Cheap guard so
// the byte-for-byte text path is only left when an image is genuinely present.
export function hasImageContent(messages) {
if (!Array.isArray(messages)) return false;
for (const m of messages) {
if (m && Array.isArray(m.content)) {
for (const part of m.content) {
if (part && part.type === "image_url") return true;
}
}
}
return false;
}
// Extract the URL string from an OpenAI image_url part. Spec form is
// `{type:"image_url", image_url:{url, detail?}}`; many OpenAI-compatible clients
// also send `image_url` as a bare string. Accept both (input leniency — no new
// output field). `detail` (auto|low|high) is OpenAI-only and has no Anthropic
// analogue, so it is read-and-ignored.
function imageUrlOf(part) {
const iu = part.image_url;
if (typeof iu === "string") return iu;
if (iu && typeof iu.url === "string") return iu.url;
return null;
}
// Parse a base64 data URI: `data:[<media_type>][;base64],<data>`.
// Returns { mediaType, data (base64), bytes (decoded size) } or throws MultimodalError.
function parseDataUri(uri) {
const comma = uri.indexOf(",");
if (comma === -1) {
throw new MultimodalError("invalid_data_uri", 400, "Malformed image data URI (no comma).");
}
const meta = uri.slice(5, comma); // strip leading "data:"
const segs = meta.split(";");
const mediaType = (segs[0] || "").trim().toLowerCase();
const isBase64 = segs.slice(1).some((s) => s.trim().toLowerCase() === "base64");
if (!isBase64) {
throw new MultimodalError("invalid_data_uri", 400, "Only base64-encoded image data URIs are supported.");
}
if (!SUPPORTED_IMAGE_TYPES.has(mediaType)) {
throw new MultimodalError(
"unsupported_image_type",
400,
`Unsupported image media type '${mediaType || "(none)"}'. Supported: ${[...SUPPORTED_IMAGE_TYPES].join(", ")}.`
);
}
// Strip incidental whitespace/newlines some encoders insert into data URIs.
const data = uri.slice(comma + 1).replace(/\s/g, "");
if (data.length === 0 || !/^[A-Za-z0-9+/]+={0,2}$/.test(data)) {
throw new MultimodalError("invalid_data_uri", 400, "Image data URI payload is not valid base64.");
}
// Decoded size from base64 length (minus padding); avoids decoding the buffer
// just to measure it.
const padding = data.endsWith("==") ? 2 : data.endsWith("=") ? 1 : 0;
const bytes = Math.floor((data.length * 3) / 4) - padding;
return { mediaType, data, bytes };
}
// Convert a single OpenAI image_url part to an Anthropic image block, enforcing
// caps via the mutable `acc` accumulator ({ images, bytes }). Throws MultimodalError.
function imagePartToBlock(part, opts, acc) {
const url = imageUrlOf(part);
if (!url) {
throw new MultimodalError("invalid_image_url", 400, "image_url part is missing a URL.");
}
acc.images += 1;
if (acc.images > opts.maxImages) {
throw new MultimodalError("too_many_images", 413, `Too many images in request (max ${opts.maxImages}).`);
}
if (url.startsWith("data:")) {
const { mediaType, data, bytes } = parseDataUri(url);
if (bytes > opts.maxImageBytes) {
throw new MultimodalError("image_too_large", 413, `Image exceeds per-image size limit (${opts.maxImageBytes} bytes).`);
}
acc.bytes += bytes;
if (acc.bytes > opts.maxTotalImageBytes) {
throw new MultimodalError("images_too_large", 413, `Total image payload exceeds limit (${opts.maxTotalImageBytes} bytes).`);
}
return { type: "image", source: { type: "base64", media_type: mediaType, data } };
}
if (/^https?:\/\//i.test(url)) {
if (!opts.allowRemoteUrl) {
throw new MultimodalError(
"remote_url_disabled",
400,
"Remote image URLs are disabled. Enable CLAUDE_IMAGE_ALLOW_URL=1 to allow http(s) image URLs, or pass the image as a base64 data URI."
);
}
// Passthrough as an Anthropic url-source block. OCP does NOT fetch the URL
// itself (no OCP-side SSRF surface); the fetch is performed upstream by the
// Anthropic API. Best-effort: unreachable/blocked URLs surface as an API error.
return { type: "image", source: { type: "url", url } };
}
throw new MultimodalError("unsupported_url_scheme", 400, "image_url must be a base64 data URI or an http(s) URL.");
}
// Role prefix mirrors messagesToPrompt()'s text-path labeling so a multi-turn
// conversation reads the same whether or not it carries images. System messages
// are handled by the caller via --system-prompt and never reach here.
function rolePrefix(role) {
if (role === "assistant") return "[Assistant] ";
return ""; // user / tool / anything else: verbatim, as in the text path
}
// Build the Anthropic content-block array for a single stream-json user
// envelope. Mirrors the text path's "collapse the whole conversation into one
// turn passed via stdin" model (OCP runs stateless, full context per spawn), but
// preserves image position relative to text and keeps images out of the text
// char budget entirely. Returns { blocks, stats } or throws MultimodalError.
export function buildImageBlocks(messages, opts = {}) {
const o = { ...DEFAULT_MULTIMODAL_OPTS, ...opts };
const blocks = [];
const acc = { images: 0, bytes: 0 };
let textChars = 0;
let firstMessage = true;
const pushText = (text) => {
if (!text) return;
blocks.push({ type: "text", text });
textChars += text.length;
};
for (const m of messages) {
const prefix = rolePrefix(m.role);
// Separate messages with a blank line, matching messagesToPrompt's "\n\n" join.
const sep = firstMessage ? "" : "\n\n";
firstMessage = false;
let prefixEmitted = false;
const emitPrefixWith = (t) => {
if (prefixEmitted) return t;
prefixEmitted = true;
return sep + prefix + t;
};
if (typeof m.content === "string") {
pushText(emitPrefixWith(m.content));
continue;
}
if (!Array.isArray(m.content)) {
// null / object content: mirror contentToText's fallback.
const t = m.content == null ? "" : JSON.stringify(m.content);
pushText(emitPrefixWith(t));
continue;
}
for (const part of m.content) {
if (part && part.type === "text" && typeof part.text === "string") {
pushText(emitPrefixWith(part.text));
} else if (part && part.type === "image_url") {
// Ensure the role prefix isn't lost when a message leads with an image.
if (!prefixEmitted && prefix) pushText(emitPrefixWith(""));
blocks.push(imagePartToBlock(part, o, acc));
} else {
// audio / file / unknown parts: preserve the existing placeholder
// behavior (issue #110) — deferred to a future version.
pushText(emitPrefixWith("[non-text content omitted]"));
}
}
}
// Defensive: a stream-json user turn must have at least one content block.
if (blocks.length === 0) blocks.push({ type: "text", text: "" });
// Enforce the text-char budget (PR #154 review F2). Without this, attaching a
// single tiny image would let unbounded text bypass the gateway's runaway-context
// guard entirely — messagesToPrompt truncates the text path, so the multimodal
// path must too. Image blocks are preserved and are NOT counted (they are bounded
// by the byte/count caps above).
const budgeted = enforceTextBudget(blocks, o.maxTextChars);
return {
blocks: budgeted.blocks,
stats: {
imageCount: acc.images,
totalImageBytes: acc.bytes,
textChars: budgeted.textChars,
originalTextChars: budgeted.originalTextChars,
truncated: budgeted.truncated,
},
};
}
// Enforce a text-char budget over already-built content blocks, mirroring
// messagesToPrompt's "keep the tail, drop the oldest" truncation. Image blocks are
// preserved in place; only text blocks count against the budget. A truncation note
// is prepended when anything is dropped. Returns
// { blocks, truncated, originalTextChars, textChars }.
function enforceTextBudget(blocks, maxTextChars) {
let originalTextChars = 0;
for (const b of blocks) if (b.type === "text") originalTextChars += b.text.length;
if (!(maxTextChars > 0) || originalTextChars <= maxTextChars) {
return { blocks, truncated: false, originalTextChars, textChars: originalTextChars };
}
// Keep the most recent text up to the budget; trim within the boundary block and
// drop older text blocks. Non-text (image) blocks always survive, in order.
let budget = maxTextChars;
const out = [];
for (let i = blocks.length - 1; i >= 0; i--) {
const b = blocks[i];
if (b.type !== "text") { out.unshift(b); continue; }
if (budget <= 0) continue; // older text fully dropped
if (b.text.length <= budget) {
out.unshift(b);
budget -= b.text.length;
} else {
out.unshift({ type: "text", text: b.text.slice(b.text.length - budget) });
budget = 0;
}
}
out.unshift({ type: "text", text: "[System] Note: older text content was truncated to fit the context limit." });
let textChars = 0;
for (const b of out) if (b.type === "text") textChars += b.text.length;
return { blocks: out, truncated: true, originalTextChars, textChars };
}
// Serialize the non-system conversation to a single newline-terminated
// stream-json user message for `claude -p --input-format stream-json` stdin.
// Returns { payload, stats } or throws MultimodalError.
export function buildStreamJsonInput(messages, opts = {}) {
const { blocks, stats } = buildImageBlocks(messages, opts);
const envelope = { type: "user", message: { role: "user", content: blocks } };
return { payload: JSON.stringify(envelope) + "\n", stats };
}
+89
View File
@@ -0,0 +1,89 @@
// lib/prompt.mjs — pure operator-append step for the system prompt.
//
// Extracted so the rule is unit-testable (the suite never imports server.mjs — it
// boots a listener). server.mjs composes wrapper + client system messages exactly as
// before, then passes the result through this. With CLAUDE_SYSTEM_PROMPT unset the
// return is the INPUT STRING UNCHANGED — the default path stays byte-for-byte
// identical, which is the repo's bar for touching a request-shaping function.
//
// The operator prompt goes LAST deliberately: a server-wide directive ("answer in
// Chinese") should read as the final instruction, not something a client system
// message overrides by coming later. Whitespace-only values are treated as unset —
// a stray space in a service unit's Environment= line must not inject "\n\n " into
// every request.
export function appendOperatorPrompt(base, operatorAppend) {
const op = typeof operatorAppend === "string" ? operatorAppend.trim() : "";
return op ? `${base}\n\n${op}` : base;
}
// Derive the default prompt-char budget from the models.json SPOT (ADR 0009).
//
// The old default was a hand-set constant (150000 chars ≈ 37.5k English tokens) from the
// 200k-window era — silently far below what the advertised contextWindow promises. Instead
// of picking a new constant that will also rot, the default now FOLLOWS the SPOT:
//
// budget = max(models[].contextWindow) × charsPerToken
//
// charsPerToken = 3 is deliberately conservative: English runs ~4 chars/token, CJK ~11.5.
// At ×3, a 200k-token window yields 600,000 chars — full window for English, and CJK text
// hits the model's real window at roughly the same point the cap fires, so we truncate
// (graceful, tail-first) rather than let the upstream reject the request outright.
//
// The floor guards the degenerate cases (empty/missing models[], absent contextWindow):
// fall back to the historical constant rather than 0 — a zero budget would truncate every
// request to nothing, which is fail-OPEN in the "serve garbage" sense. CLAUDE_MAX_PROMPT_CHARS
// remains an absolute operator override at the call site (server.mjs); this function is only
// the unset-env default.
export function derivePromptCharBudget(models, { charsPerToken = 3, floor = 150000 } = {}) {
const windows = (Array.isArray(models) ? models : [])
.map(m => m?.contextWindow)
.filter(w => Number.isFinite(w) && w > 0);
if (windows.length === 0) return floor;
return Math.max(floor, Math.max(...windows) * charsPerToken);
}
// Resolve the effective budget from the env var + SPOT. TRUTHINESS (not != null) on the env
// value deliberately: an EMPTY value ("CLAUDE_MAX_PROMPT_CHARS=" in a systemd EnvironmentFile
// or .env) must mean "use the default" — exactly the old `parseInt(env || "150000")` contract.
// Treating "" as explicit gives parseInt("") = NaN, and a NaN cap silently DISABLES the
// runaway-context guard while injecting a false "[System] Note: 0 older messages were
// truncated" line into every prompt (caught in PR #179 review). Non-empty garbage still
// parses to NaN — the pre-existing class, slated for parseIntEnv routing in PR #154.
export function resolvePromptCharBudget(rawEnv, models, opts) {
return rawEnv ? parseInt(rawEnv, 10) : derivePromptCharBudget(models, opts);
}
// OCP_LOCAL_TOOLS system-prompt wrapper selection (pure).
//
// OCP's `-p` path prepends a fixed wrapper to every request's system prompt. The DEFAULT wrapper
// tells the model it has NO local filesystem/shell/env access — the right posture for a shared or
// multi-tenant gateway. But a single-user, loopback-bound instance (e.g. an OpenClaw agent talking
// to its own local OCP) DOES legitimately have tools — the `-p` path already passes `--allowedTools`
// and the CLI's built-in tools are available — so the default wrapper actively gaslights the model
// into refusing to use tools it holds. `OCP_LOCAL_TOOLS=1` swaps in a positive wrapper for that case.
//
// This does NOT expand the tool surface: tools are governed solely by `--allowedTools` /
// `--disallowedTools` (multi-tenant mode `--disallowedTools` the whole FS surface regardless of the
// wrapper). It only changes the PROMPT the operator's own model reads. Pure so it is unit-testable.
export function selectPromptWrapper(localToolsEnabled, negativeWrapper, positiveWrapper) {
return localToolsEnabled ? positiveWrapper : negativeWrapper;
}
// Boot-time safety gate for OCP_LOCAL_TOOLS, mirroring the OCP_TUI_FULL_TOOLS model (ADR 0007): a
// positive "you may use local tools" wrapper must never reach an untrusted caller. Returns a fatal
// message string when the flag is enabled in an unsafe deployment, or null when it is safe/disabled.
// Fail-closed: any of multi-tenant auth, a non-loopback bind, or an anonymous key is refused. Pure —
// the caller does the process.exit so this stays testable.
export function localToolsSafetyError({ enabled, authMode, loopbackBind, anonymousKey }) {
if (!enabled) return null;
if (authMode === "multi") {
return "OCP_LOCAL_TOOLS=1 is incompatible with CLAUDE_AUTH_MODE=multi — a guest/anonymous prompt would be told it may drive the operator's filesystem/shell. Single-user only.";
}
if (!loopbackBind) {
return "OCP_LOCAL_TOOLS=1 requires a loopback bind (127.0.0.1/::1) — a network-exposed positive-tools wrapper could reach an untrusted peer. Bind to loopback, or leave OCP_LOCAL_TOOLS off.";
}
if (anonymousKey) {
return "OCP_LOCAL_TOOLS=1 is unsafe with PROXY_ANONYMOUS_KEY set — anonymous callers could reach the local-tools-enabled model without a named key. Remove PROXY_ANONYMOUS_KEY, or leave OCP_LOCAL_TOOLS off.";
}
return null;
}
+318
View File
@@ -0,0 +1,318 @@
// ── OpenAI Structured Outputs (response_format) — pure helpers ───────────────
//
// OCP's `/v1/chat/completions` (Class B.1, ADR 0006) advertises OpenAI compatibility but forwards
// to `claude -p`, which has no native `response_format`. Asked for JSON, the coding-assistant CLI
// typically replies with prose, a Markdown table, or a ```json fenced block — none of which is
// `JSON.parse`-able. These helpers implement the OpenAI `response_format` contract on top of that:
// detect the request, build a strict JSON-only steering instruction, then extract and validate the
// JSON the model returns. All functions here are pure (no I/O) so they are unit-tested directly;
// the retry loop that calls the model lives in server.mjs (runStructuredCompletion).
//
// Spec authority (B.1, ADR 0006): OpenAI chat/completions `response_format`
// https://platform.openai.com/docs/api-reference/chat/create#chat-create-response_format
// No field or behaviour beyond that published shape is introduced.
export class StructuredOutputError extends Error {
constructor(reason, raw) {
super(`structured output could not be produced: ${reason}`);
this.name = "StructuredOutputError";
this.reason = reason;
this.raw = raw;
}
}
// Fail-closed parse of the OCP_STRUCTURED_MAX_ATTEMPTS retry cap. `Math.max(1, parseInt("abc",10))`
// === `Math.max(1, NaN)` === NaN, and a retry loop bounded by `attempt < NaN` never runs → 0 spawns,
// every structured request silently refuses. So any non-integer / non-finite / <1 value keeps the
// documented default instead (and warns), rather than bricking the feature. (PR #153 review round 2.)
export function resolveMaxAttempts(raw, { fallback = 3, warn } = {}) {
if (raw === undefined || raw === null || raw === "") return fallback;
const n = parseInt(raw, 10);
if (!Number.isFinite(n) || n < 1) {
if (typeof warn === "function") {
warn(`Ignoring invalid OCP_STRUCTURED_MAX_ATTEMPTS="${raw}" (want integer >= 1); using default ${fallback}.`);
}
return fallback;
}
return n;
}
// Returns { mode: "schema", schema, name?, strict } | { mode: "json_object" } | null.
// Supports the OpenAI shapes: response_format:{type:"json_schema",json_schema:{schema,strict,name}}
// and response_format:{type:"json_object"}, a lenient response_format:{schema} fallback, and the
// widely-used (non-standard) top-level `json_mode: true` flag as a json_object alias.
export function detectStructuredOutput(parsed) {
const rf = parsed?.response_format;
if (rf && typeof rf === "object") {
if (rf.type === "json_schema") {
const js = (rf.json_schema && typeof rf.json_schema === "object") ? rf.json_schema : {};
const schema = js.schema || rf.schema || null;
return { mode: "schema", schema, name: js.name, strict: js.strict === true };
}
if (rf.type === "json_object") return { mode: "json_object" };
if (rf.schema && typeof rf.schema === "object") {
return { mode: "schema", schema: rf.schema, strict: rf.strict === true };
}
}
// Non-standard convenience alias honored by several OpenAI-compatible clients.
if (parsed?.json_mode === true) return { mode: "json_object" };
return null;
}
export function jsonTypeOf(v) {
if (v === null) return "null";
if (Array.isArray(v)) return "array";
return typeof v; // object | string | number | boolean
}
export function jsonTypeMatches(t, value) {
switch (t) {
case "string": return typeof value === "string";
case "number": return typeof value === "number" && Number.isFinite(value);
case "integer": return typeof value === "number" && Number.isInteger(value);
case "boolean": return typeof value === "boolean";
case "object": return value !== null && typeof value === "object" && !Array.isArray(value);
case "array": return Array.isArray(value);
case "null": return value === null;
default: return true; // unknown type keyword → do not fail on it
}
}
const jsonDeepEqual = (a, b) => JSON.stringify(a) === JSON.stringify(b);
// Resolve a local JSON-Pointer `$ref` (e.g. "#/$defs/Step" or "#/definitions/Step") against the
// document root. Only same-document refs are supported (that is all the OpenAI SDK emits); a remote
// or unresolvable ref returns null and the caller skips validation for it rather than failing.
function resolveRef(ref, root) {
if (typeof ref !== "string" || !ref.startsWith("#/") || !root) return null;
const parts = ref.slice(2).split("/").map(p => p.replace(/~1/g, "/").replace(/~0/g, "~"));
let cur = root;
for (const p of parts) {
if (cur && typeof cur === "object" && Object.prototype.hasOwnProperty.call(cur, p)) cur = cur[p];
else return null;
}
return (cur && typeof cur === "object") ? cur : null;
}
// Minimal JSON-Schema validator: covers the subset OpenAI structured outputs use — type (incl.
// arrays of types / integer), required, properties, additionalProperties (no invented keys), items
// (list + tuple), enum, const, nullability (type:["x","null"] or nullable:true), min/maxItems, and
// $ref/$defs + allOf/anyOf/oneOf composition (which the official OpenAI SDK emits heavily via
// zodResponseFormat / client.beta.chat.completions.parse). `root` carries the top-level schema so
// same-document $refs resolve. Returns error strings ([] = valid).
// `refChain` tracks the $ref pointers resolved on the CURRENT path WITHOUT consuming data (a $ref
// hop, or an allOf/anyOf/oneOf branch, all re-validate the same value). A pointer reappearing on
// that chain is a pure ref→ref (or ref→composition→ref) cycle that recurses forever independent of
// the data — we fail closed on it. Data-consuming recursion (properties/items/additionalProperties)
// deliberately resets the chain (default []): it always terminates because a JSON value is a finite
// tree, so a legitimately recursive schema (Node→child:Node) must NOT be flagged as a cycle. A depth
// cap backstops any threading mistake. (PR #153 review round 2, cyclic-$ref blocker.)
const REF_DEPTH_CAP = 512;
export function validateJsonSchema(value, schema, path = "$", strict = false, root = schema, refChain = []) {
const errors = [];
if (!schema || typeof schema !== "object") return errors;
if (refChain.length > REF_DEPTH_CAP) { // defensive backstop; refChain cycle-check below is primary
errors.push(`${path}: $ref resolution too deep (possible cycle)`);
return errors;
}
// $ref: resolve against the document root ($defs / definitions) and validate the target. Without
// this a nested {$ref:"#/$defs/Step"} presents as {no type, no properties} — and under strict that
// used to wrongly reject every real key as "additional property not allowed" (the flagship OpenAI
// SDK shape). Sibling keywords alongside $ref (rare) are merged over the resolved target.
if (typeof schema.$ref === "string") {
if (refChain.includes(schema.$ref)) { // cyclic $ref (a→b→a, or self a→a) — fail closed.
errors.push(`${path}: cyclic $ref detected (${schema.$ref})`);
return errors;
}
const resolved = resolveRef(schema.$ref, root);
if (!resolved) return errors; // unresolvable ref → cannot validate; do not fail
const { $ref, ...siblings } = schema;
return validateJsonSchema(value, { ...resolved, ...siblings }, path, strict, root, [...refChain, schema.$ref]);
}
// Composition. allOf: every branch must pass. anyOf: at least one. oneOf: exactly one.
// These re-validate the SAME value → thread refChain so a ref cycle routed through a branch is caught.
if (Array.isArray(schema.allOf)) {
for (const sub of schema.allOf) errors.push(...validateJsonSchema(value, sub, path, strict, root, refChain));
}
if (Array.isArray(schema.anyOf)) {
if (!schema.anyOf.some(sub => validateJsonSchema(value, sub, path, strict, root, refChain).length === 0)) {
errors.push(`${path}: does not match any of the allowed schemas (anyOf)`);
}
}
if (Array.isArray(schema.oneOf)) {
const matches = schema.oneOf.filter(sub => validateJsonSchema(value, sub, path, strict, root, refChain).length === 0).length;
if (matches !== 1) errors.push(`${path}: must match exactly one allowed schema (oneOf), matched ${matches}`);
}
// Nullability takes precedence: a null value is valid whenever the schema permits null (its type
// union includes "null", or nullable:true), regardless of enum/const. This mirrors OpenAI
// structured-output behaviour — nullable fields accept null even when a bare enum (as generated by
// Home Assistant's extended_openai_conversation) omits null from its value list.
const allowsNull = schema.nullable === true
|| (Array.isArray(schema.type) ? schema.type.includes("null") : schema.type === "null");
if (value === null && allowsNull) return errors;
if (Array.isArray(schema.enum) && !schema.enum.some(e => jsonDeepEqual(e, value))) {
errors.push(`${path}: not one of the allowed enum values`);
}
if ("const" in schema && !jsonDeepEqual(schema.const, value)) {
errors.push(`${path}: does not equal the required const value`);
}
if (schema.type !== undefined) {
const types = Array.isArray(schema.type) ? schema.type : [schema.type];
const nullable = schema.nullable === true || types.includes("null");
const ok = types.some(t => jsonTypeMatches(t, value)) || (value === null && nullable);
if (!ok) {
errors.push(`${path}: expected ${types.join("|")}${schema.nullable ? "|null" : ""}, got ${jsonTypeOf(value)}`);
return errors; // type mismatch — deeper checks are meaningless
}
}
if (value === null) return errors;
const vt = jsonTypeOf(value);
if (vt === "object") {
const props = schema.properties || {};
for (const r of (schema.required || [])) {
if (!Object.prototype.hasOwnProperty.call(value, r)) errors.push(`${path}.${r}: required property missing`);
}
const addl = schema.additionalProperties;
// Only treat strict as implying "no additional properties" when this object actually declares
// its own `properties` and is NOT a composite (allOf/anyOf/oneOf put the real keys in sub-schemas,
// which are validated separately above). Inferring closure from an EMPTY properties map — the
// shape an unresolved $ref or a pure-composition node presents — would reject every real key.
// An explicit additionalProperties:false is always honoured. (PR #153 review, finding 1.)
const isComposite = Array.isArray(schema.allOf) || Array.isArray(schema.anyOf) || Array.isArray(schema.oneOf);
const noExtra = addl === false || (strict && addl === undefined && Object.keys(props).length > 0 && !isComposite);
for (const k of Object.keys(value)) {
if (props[k]) {
errors.push(...validateJsonSchema(value[k], props[k], `${path}.${k}`, strict, root));
} else if (isComposite) {
// key may be defined in an allOf/anyOf/oneOf branch — already validated there; don't reject.
} else if (noExtra) {
errors.push(`${path}.${k}: additional property not allowed`);
} else if (addl && typeof addl === "object") {
errors.push(...validateJsonSchema(value[k], addl, `${path}.${k}`, strict, root));
}
}
} else if (vt === "array" && schema.items) {
if (Array.isArray(schema.items)) {
schema.items.forEach((s, i) => { if (i < value.length) errors.push(...validateJsonSchema(value[i], s, `${path}[${i}]`, strict, root)); });
} else {
value.forEach((item, i) => errors.push(...validateJsonSchema(item, schema.items, `${path}[${i}]`, strict, root)));
}
if (typeof schema.minItems === "number" && value.length < schema.minItems) errors.push(`${path}: fewer items than minItems ${schema.minItems}`);
if (typeof schema.maxItems === "number" && value.length > schema.maxItems) errors.push(`${path}: more items than maxItems ${schema.maxItems}`);
}
return errors;
}
// Crash-safe façade over validateJsonSchema (issue #181). The validator recurses on the DATA's
// nesting depth (properties/items/additionalProperties), which the REF_DEPTH_CAP does NOT bound —
// only the ref-chain is. A model reply nested ~2000 levels deep therefore overflowed the stack with
// a RangeError, which the request handler caught as a generic HTTP 500 instead of the spec-correct
// `refusal`. This wrapper converts ANY throw (the deep-data RangeError, or any future recursion
// hazard) into a single validation error, so the structured-output retry loop treats a pathological
// reply as "did not validate" → refusal — never a 500, never a crash. A well-formed reply is
// unaffected: the inner validator returns and this just passes its errors through.
export function validateJsonSchemaSafe(value, schema, path = "$", strict = false, root = schema) {
try {
return validateJsonSchema(value, schema, path, strict, root);
} catch (e) {
// Catch ONLY the deep-nesting stack overflow (the #181 vector) and turn it into a validation
// miss → retry → refusal, never a 500. Any OTHER throw is a genuine bug: re-throw it so it
// surfaces at error level instead of being silently masked as "did not validate" (reviewer
// finding — a catch-all would hide a future TypeError behind a warn-level structured_retry).
if (e instanceof RangeError) return [`${path}: schema validation aborted (value nesting too deep)`];
throw e;
}
}
function tryJsonParse(s) {
try { return { ok: true, value: JSON.parse(s) }; } catch { return { ok: false }; }
}
// Find the next brace-balanced JSON span at or after `from`. String-aware: brackets inside quoted
// strings are ignored. Returns { text, start, end } for the first complete top-level span, or null.
function balancedSlice(s, from) {
let start = -1;
for (let i = from; i < s.length; i++) { if (s[i] === "{" || s[i] === "[") { start = i; break; } }
if (start === -1) return null;
let depth = 0, inStr = false, esc = false;
for (let i = start; i < s.length; i++) {
const c = s[i];
if (inStr) {
if (esc) esc = false;
else if (c === "\\") esc = true;
else if (c === '"') inStr = false;
continue;
}
if (c === '"') { inStr = true; continue; }
if (c === "{" || c === "[") depth++;
else if (c === "}" || c === "]") { depth--; if (depth === 0) return { text: s.slice(start, i + 1), start, end: i }; }
}
return null;
}
// Extract a single JSON value from model text. Two modes (PR #153 review, finding 2):
//
// opts.whole === true (json_object mode): the ENTIRE reply, after trimming and stripping one code
// fence, must parse as a single JSON value. json_object has no schema to validate against, so
// this whole-reply parse is its ONLY guard — a reply like `I can't. The schema is {"type":"object"}`
// must NOT parse-and-serve the embedded object as if it were the answer.
//
// default (schema mode): prose-wrapped JSON is tolerated (models often add a sentence), BUT a reply
// containing MORE THAN ONE top-level JSON value is rejected rather than silently serving the first
// — "Schema: {...}\n\nAnswer: {...}" or "Option A: {...} Option B: {...}" is ambiguous, not an
// answer. The extracted value is still schema-validated by the caller.
//
// Returns { ok:true, value } | { ok:false, reason? }.
export function extractJsonPayload(text, opts = {}) {
if (typeof text !== "string") return { ok: false };
let s = text.trim();
const fence = s.match(/^```(?:json)?\s*([\s\S]*?)\s*```$/i);
if (fence) s = fence[1].trim();
if (opts.whole) {
const whole = tryJsonParse(s);
return whole.ok ? whole : { ok: false, reason: "reply was not a single JSON value" };
}
const direct = tryJsonParse(s);
if (direct.ok) return direct;
const first = balancedSlice(s, 0);
if (!first) return { ok: false };
const parsedFirst = tryJsonParse(first.text);
if (!parsedFirst.ok) return { ok: false };
// Reject ambiguity: a second parseable top-level JSON value means we cannot know which is the answer.
const second = balancedSlice(s, first.end + 1);
if (second && tryJsonParse(second.text).ok) {
return { ok: false, reason: "reply contained more than one JSON value" };
}
return parsedFirst;
}
// The strict JSON-only system instruction appended to the request (attempt 0), escalated with the
// prior failure reason on retries.
export function structuredSystemInstruction(structured, attempt, lastErr) {
const schemaBlock = (structured.mode === "schema" && structured.schema)
? `Your JSON MUST validate EXACTLY against this JSON Schema:\n${JSON.stringify(structured.schema)}\n`
+ `- Include every required property.\n`
+ `- Do NOT add any property that is not defined in the schema.\n`
+ `- Respect all declared types, enums, and nullability.`
: `Respond with a single valid JSON value.`;
let text =
`You are a strict JSON generator. Output a SINGLE JSON value and NOTHING else.
- The response MUST begin with { or [ and end with the matching } or ].
- Do NOT wrap the JSON in Markdown or code fences (no \`\`\`).
- Do NOT include any prose, explanation, heading, comment, reasoning, or XML only the raw JSON.
${schemaBlock}`;
if (attempt > 0) {
text = `YOUR PREVIOUS RESPONSE WAS REJECTED (${lastErr}). Output ONLY the corrected raw JSON now, with no other text.\n\n` + text;
}
return text;
}
+1 -1
View File
@@ -53,7 +53,7 @@
],
"aliases": {
"opus": "claude-opus-4-8",
"sonnet": "claude-sonnet-4-6",
"sonnet": "claude-sonnet-5",
"haiku": "claude-haiku-4-5-20251001"
},
"legacyAliases": {
+7 -2
View File
@@ -202,8 +202,13 @@ for mid in model_ids:
# Handle primary/backup
if priority == "1":
# OCP as primary — pick the best model (prefer sonnet for daily use)
primary_model = provider_name + "/claude-sonnet-4-6" if "claude-sonnet-4-6" in model_ids else provider_name + "/" + model_ids[0]
# OCP as primary — pick the best model (prefer the latest Sonnet for daily use,
# tracking the `sonnet` alias default in models.json; fall back across versions).
_sonnet_pref = ["claude-sonnet-5", "claude-sonnet-4-6"]
primary_model = next(
(provider_name + "/" + m for m in _sonnet_pref if m in model_ids),
provider_name + "/" + model_ids[0],
)
config["agents"]["defaults"].setdefault("model", {})
config["agents"]["defaults"]["model"]["primary"] = primary_model
# Keep existing fallbacks
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "open-claude-proxy",
"version": "3.22.0",
"version": "3.24.0",
"description": "OCP (Open Claude Proxy) — use your Claude Pro/Max subscription as an OpenAI-compatible API for any IDE. Works with Cline, OpenCode, Aider, Continue.dev, OpenClaw, and more.",
"type": "module",
"bin": {
+10
View File
@@ -59,6 +59,16 @@ export async function runDoctor(opts = {}) {
// of recommending a downgrade against a stale hardcoded value.
let latestVersion = opts.mockLatest;
if (!latestVersion) {
// Issue #173: `git show origin/main:...` reads the LOCALLY CACHED remote ref. Without a
// fetch first, a machine that hasn't pulled since the last release sees latest == current
// and reports noop — new releases were invisible everywhere except the machine that cut
// the tag (live repro: Oracle VM, 2026-07-17). Fetch before comparing; on failure
// (offline, auth, timeout) fall through to the cached ref — the pre-existing behavior.
if (!opts.skipNetwork) {
try {
execSync(`git -C ${ocpDir} fetch --tags --quiet`, { stdio: ["pipe", "pipe", "pipe"], timeout: 15000 });
} catch { /* offline → compare against cached origin/main, as before */ }
}
try {
const out = execSync(`git -C ${ocpDir} show origin/main:package.json 2>/dev/null`, { stdio: ["pipe", "pipe", "pipe"] }).toString();
const remotePkg = JSON.parse(out);
+19 -4
View File
@@ -2,7 +2,7 @@ import { mkdirSync, writeFileSync, readFileSync, copyFileSync, existsSync, readd
import { join } from "node:path";
export function writeSnapshot({ homeDir, fromCommit, fromVersion, toVersion, extraFiles = [] }) {
const ts = new Date().toISOString().replace(/\.\d+Z$/, "Z");
const ts = formatSnapshotTimestamp(new Date());
const root = join(homeDir, ".ocp", `upgrade-snapshot-${ts}`);
mkdirSync(root, { recursive: true });
@@ -48,7 +48,10 @@ export function listSnapshots(homeDir) {
return readdirSync(root)
.filter(name => name.startsWith("upgrade-snapshot-"))
.map(name => ({ name, path: join(root, name), mtime: statSync(join(root, name)).mtimeMs }))
.sort((a, b) => a.name.localeCompare(b.name));
.sort((a, b) => {
const chronological = parseSnapshotTimestamp(a.name) - parseSnapshotTimestamp(b.name);
return chronological || a.name.localeCompare(b.name);
});
}
/**
@@ -107,9 +110,21 @@ export function gcSnapshots(homeDir, opts = {}) {
}
function parseSnapshotTimestamp(name) {
// Both legacy ISO names and Windows-safe names are supported.
// upgrade-snapshot-2026-05-11T08:30:00Z → epoch ms
// 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;
const raw = m[1];
const t = Date.parse(raw);
if (Number.isFinite(t)) return t;
const iso = raw.replace(/(T\d{2})-(\d{2})-(\d{2})Z$/, "$1:$2:$3Z");
const portable = Date.parse(iso);
return Number.isFinite(portable) ? portable : 0;
}
function formatSnapshotTimestamp(date) {
// Windows forbids ':' in directory names. Replacing only the time separators
// preserves chronological lexical order and keeps the timestamp readable.
return date.toISOString().replace(/\.\d+Z$/, "Z").replace(/:/g, "-");
}
+22 -2
View File
@@ -17,6 +17,20 @@ import { existsSync, copyFileSync } from "node:fs";
import { writeSnapshot, listSnapshots, readSnapshot, gcSnapshots } from "./lib/snapshot.mjs";
import { DEFAULT_PORT } from "../lib/constants.mjs";
// Post-flight acceptance predicate (issue #173). A health probe passes ONLY when the server
// is authed AND actually serving the TARGET version. auth.ok alone is not enough: a stale
// process holding the port answers auth.ok=true while still running the OLD code — exactly
// what a nohup-fallback orphan did on 2026-07-17 (upgrade "succeeded", /health kept serving
// 3.21.1). Comparing /health.version to the checkout target catches orphan-holds-port,
// restart-didn't-take, and wrong-unit-restarted alike. `target` tolerates a leading "v"
// (doctor reports "v3.22.1"; /health reports "3.22.1"); an empty/unknown target degrades to
// the old auth-only check rather than blocking an otherwise-good upgrade.
export function postFlightOk(body, target) {
if (body?.auth?.ok !== true) return false;
const want = String(target || "").replace(/^v/, "");
return !want || body?.version === want;
}
export async function runUpgrade(opts = {}) {
const dryRun = !!opts.dryRun;
const yes = !!opts.yes;
@@ -137,16 +151,22 @@ async function runFullUpgrade({ doctor, opts }) {
if (!opts.mockExec) {
const port = process.env.CLAUDE_PROXY_PORT || String(DEFAULT_PORT);
let ok = false;
let lastSeen = null;
for (let i = 0; i < 10; i++) {
try {
const out = execSync(`curl -sf --max-time 2 http://127.0.0.1:${port}/health`).toString();
const body = JSON.parse(out);
if (body.auth?.ok === true) { ok = true; break; }
lastSeen = body.version;
if (postFlightOk(body, doctor.latest_version)) { ok = true; break; }
} catch { /* retry */ }
await new Promise(r => setTimeout(r, 1000));
}
if (!ok) {
phases.push({ name: "post-flight", status: "fail", message: "health did not return auth.ok=true within 10s" });
phases.push({
name: "post-flight", status: "fail",
message: `health did not return auth.ok=true AND version=${doctor.latest_version} within 10s`
+ (lastSeen ? ` (last saw version=${lastSeen} — a stale process may still hold the port; check \`ss -ltnp\` / \`lsof -i\`)` : ""),
});
throw new Error("post-flight failed");
}
execSync(`curl -sf --max-time 3 http://127.0.0.1:${port}/v1/models > /dev/null`);
+387 -44
View File
@@ -35,13 +35,14 @@
*/
import { createServer } from "node:http";
import { spawn, execFileSync, spawnSync } from "node:child_process";
import { randomUUID, timingSafeEqual } from "node:crypto";
import { randomUUID, timingSafeEqual, createHash as cryptoCreateHash } from "node:crypto";
import { readFileSync, readdirSync, accessSync, existsSync, constants, chmodSync, statSync, mkdirSync, writeFileSync, rmSync } from "node:fs";
import { fileURLToPath } from "node:url";
import { dirname, join } from "node:path";
import { homedir } from "node:os";
import { validateKey, recordUsage, getUsageByKey, getUsageTimeline, getRecentUsage, createKey, listKeys, revokeKey, closeDb, checkQuota, updateKeyQuota, getKeyQuota, findKey, cacheHash, getCachedResponse, setCachedResponse, clearCache, getCacheStats, hasCacheControl, singleflight, getInflightStats } from "./keys.mjs";
import { DEFAULT_PORT } from "./lib/constants.mjs";
import { StructuredOutputError, detectStructuredOutput, validateJsonSchemaSafe, extractJsonPayload, structuredSystemInstruction, resolveMaxAttempts } from "./lib/structured-output.mjs";
import { isLoopbackBind } from "./lib/net.mjs";
import { runTuiTurn, reapStaleTuiSessions, resolveTuiHome, bootTuiPane, tuiPaneHealthy, poolPaneName, POOL_BOOT_MS } from "./lib/tui/session.mjs";
import { detectTuiUpstreamError } from "./lib/tui/transcript.mjs";
@@ -49,6 +50,9 @@ import { TuiSemaphore, SemaphoreAbortError, recordTuiEntrypoint, buildTuiHealthB
import { TuiPanePool, resolvePoolSize, POOL_MAX_SIZE } from "./lib/tui/pool.mjs";
import { TuiDeltaAssembler, DEFAULT_HOLDBACK_CHARS, resolveStreamHoldback } from "./lib/tui/stream.mjs";
import { createSerialMutex, createTtlCache, isTokenExpiring, orderLabelsLastGoodFirst } from "./lib/spawn-auth.mjs";
import { hasImageContent, buildImageBlocks, buildStreamJsonInput, MultimodalError } from "./lib/multimodal.mjs";
import { parsePositiveInt } from "./lib/env.mjs";
import { appendOperatorPrompt, derivePromptCharBudget, selectPromptWrapper, localToolsSafetyError } from "./lib/prompt.mjs";
const __dirname = dirname(fileURLToPath(import.meta.url));
const _pkg = JSON.parse(readFileSync(join(__dirname, "package.json"), "utf8"));
@@ -194,18 +198,40 @@ function resolveClaude() {
// 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".
// Positive counterpart used only when OCP_LOCAL_TOOLS=1 — a single-user, loopback-bound instance
// where the operator's own model legitimately has tools (the `-p` path passes --allowedTools). Tells
// the model it MAY use them instead of disclaiming access it actually holds. Off by default; the
// default wrapper above is byte-for-byte unchanged. Selecting the positive wrapper does NOT expand
// the tool surface (governed independently by --allowedTools/--disallowedTools) — it only changes the
// prompt — and is boot-gated below (multi/non-loopback/anon → refuse) mirroring OCP_TUI_FULL_TOOLS.
const OCP_LOCAL_TOOLS_WRAPPER = `You are accessed via the OCP HTTP proxy running on the operator's own machine. You have full access to the local filesystem, working directory, and shell through your available tools (Bash, Read, Write, Edit, Glob, Grep, etc.). Use them as needed to complete the operator's requests.`;
// OCP_LOCAL_TOOLS is inert in TUI mode: the interactive (non-`-p`) path composes its own prompt via
// callClaudeTui/messagesToPrompt and never calls extractSystemPrompt, so the wrapper is only ever
// applied on the `-p` path. LOCAL_TOOLS_ACTIVE is the single source of truth (hoisted once, house
// style) used by the wrapper selection, the boot gate, and the startup notice — so the flag is
// enabled/announced/gated in exactly the mode where it has an effect. (TUI tool surface is governed
// by OCP_TUI_FULL_TOOLS instead.)
const LOCAL_TOOLS = process.env.OCP_LOCAL_TOOLS === "1";
const LOCAL_TOOLS_ACTIVE = LOCAL_TOOLS && process.env.CLAUDE_TUI_MODE !== "true";
// The wrapper actually prepended to each request's system prompt, chosen once at startup.
const SYSTEM_PROMPT_WRAPPER = selectPromptWrapper(LOCAL_TOOLS_ACTIVE, OCP_SYSTEM_PROMPT_WRAPPER, OCP_LOCAL_TOOLS_WRAPPER);
// Build the full system-prompt string: SYSTEM_PROMPT_WRAPPER prepended,
// then any system-role messages from the request appended (separated by blank line),
// then the operator-wide CLAUDE_SYSTEM_PROMPT appended LAST (lib/prompt.mjs — a
// no-op returning the same string when the var is unset, so the default path is
// byte-for-byte unchanged). 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;
return appendOperatorPrompt(SYSTEM_PROMPT_WRAPPER, SYSTEM_PROMPT);
}
const clientContent = systemMessages.map(m =>
contentToText(m.content)
).join("\n\n");
return `${OCP_SYSTEM_PROMPT_WRAPPER}\n\n${clientContent}`;
return appendOperatorPrompt(`${SYSTEM_PROMPT_WRAPPER}\n\n${clientContent}`, SYSTEM_PROMPT);
}
// ── NDJSON line buffer parser (Phase 6c port) ─────────────────────────────
@@ -245,8 +271,8 @@ function parseStreamJsonLines(buffered) {
// 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) {
// @param {boolean} sawTextDelta — true if a streaming content_block_delta text was already seen
function parseStreamJsonEvent(event, sawTextDelta) {
const t = event?.type;
// system/* — first-event init + other system meta (api_retry etc.)
@@ -258,19 +284,23 @@ function parseStreamJsonEvent(event, isFirstDelta) {
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 ?? "" };
return { text: inner.delta.text ?? "", fromDelta: true };
}
// 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.
// assistant — aggregate message. claude CLI without --include-partial-messages emits NO
// content_block_delta events; each assistant message arrives as its own aggregate `assistant`
// event. An agentic/tool-using turn has SEVERAL (preamble + one per tool round + final answer),
// so we must accumulate the text of EVERY such event. The prior `isFirstDelta` guard kept only
// the FIRST message's text and dropped the rest — silently losing the post-tool-use final answer
// on every tool-using turn (verified v2.1.104 through v2.1.211; see PR body capture).
// The only real hazard is the delta+aggregate DOUBLE-COUNT: if streaming deltas were already
// seen (sawTextDelta), the aggregate duplicates them — ignore it.
// Reference: OLP commit 65f945c (assistant-aggregate fallback, fold-in).
if (t === "assistant") {
if (isFirstDelta) {
if (!sawTextDelta) {
const blocks = event.message?.content;
if (Array.isArray(blocks)) {
const text = blocks
@@ -324,6 +354,16 @@ const ALLOWED_TOOLS = (process.env.CLAUDE_ALLOWED_TOOLS ||
"Bash,Read,Write,Edit,Glob,Grep,WebSearch,WebFetch,Agent"
).split(",").map(s => s.trim()).filter(Boolean);
const SYSTEM_PROMPT = process.env.CLAUDE_SYSTEM_PROMPT || "";
// Max attempts (initial + retries) to coerce a valid structured-output (OpenAI response_format)
// JSON response out of the model before rejecting. See runStructuredCompletion.
// Fail closed on a non-numeric value via resolveMaxAttempts(): the old `Math.max(1, parseInt("abc",10))`
// === `Math.max(1, NaN)` === NaN, which made the retry loop `attempt < NaN` never execute → 0 spawns,
// every structured request silently refused. The helper rejects NaN/non-finite/<1 and keeps the
// documented default of 3. (PR #153 review round 2, NaN-guard must-fix.)
const STRUCTURED_MAX_ATTEMPTS = resolveMaxAttempts(
process.env.OCP_STRUCTURED_MAX_ATTEMPTS,
{ fallback: 3, warn: (m) => console.warn(`[init] ${m}`) },
);
const MCP_CONFIG = process.env.CLAUDE_MCP_CONFIG || "";
let SESSION_TTL = parseInt(process.env.CLAUDE_SESSION_TTL || "3600000", 10);
let MAX_CONCURRENT = parseInt(process.env.CLAUDE_MAX_CONCURRENT || "8", 10);
@@ -343,6 +383,19 @@ const BREAKER_HALF_OPEN_MAX = parseInt(process.env.CLAUDE_BREAKER_HALF_OPEN_MAX
const HEARTBEAT_INTERVAL = parseInt(process.env.CLAUDE_HEARTBEAT_INTERVAL || "0", 10);
const BIND_ADDRESS = process.env.CLAUDE_BIND || "127.0.0.1";
const NO_CONTEXT = process.env.CLAUDE_NO_CONTEXT === "true";
// Config epoch for the response cache (issue #176). The cache key hashes model + messages +
// sampling params, but the ANSWER also depends on boot-time server config that shapes the
// composed prompt / tool surface: the operator system prompt (#175), the OCP wrapper text,
// the allowed-tools set, and NO_CONTEXT. The cache store is SQLite-backed and survives
// restarts, so without this an operator who changes any of these and restarts keeps serving
// answers composed under the OLD config until TTL expiry. Folding a digest of the four into
// every cache key makes any change an instant, whole-cache invalidation — the honest behavior.
// Deliberately boot-time-only: runtime-mutable settings (e.g. maxPromptChars via the settings
// API) are excluded because a const epoch cannot track them; truncation also only drops
// context rather than changing the instruction set.
const CONFIG_EPOCH = cryptoCreateHash("sha256")
.update(JSON.stringify([SYSTEM_PROMPT, SYSTEM_PROMPT_WRAPPER, ALLOWED_TOOLS, NO_CONTEXT]))
.digest("hex").slice(0, 16);
// Kill-switch for the FIX-③ default-path spawn-home isolation (see resolveSpawnHome /
// spawnHomeMode below). When "1", the -p/stream-json spawn always runs in the operator's
// real HOME with no cwd override — byte-for-byte the pre-isolation behaviour — even if an
@@ -779,6 +832,21 @@ if (TUI_MODE && PROXY_ANONYMOUS_KEY) {
process.exit(1);
}
// OCP_LOCAL_TOOLS safety gate (mirrors the OCP_TUI_FULL_TOOLS model, ADR 0007): the positive
// "you may use local tools" system-prompt wrapper is single-user only, so refuse to boot if it
// could reach an untrusted caller. Fail-closed on multi-tenant auth, a non-loopback bind, or an
// anonymous key. The pure predicate lives in lib/prompt.mjs (unit-tested); the exit stays here.
const _localToolsBootError = localToolsSafetyError({
enabled: LOCAL_TOOLS_ACTIVE,
authMode: AUTH_MODE,
loopbackBind: isLoopbackBind(BIND_ADDRESS),
anonymousKey: !!PROXY_ANONYMOUS_KEY,
});
if (_localToolsBootError) {
console.error(`FATAL: ${_localToolsBootError}\n See README § "Environment Variables" (OCP_LOCAL_TOOLS) and docs/adr/0007-tui-interactive-mode.md. Refusing to start.`);
process.exit(1);
}
if (PROXY_ANONYMOUS_KEY && AUTH_MODE !== "multi") {
console.warn("WARNING: PROXY_ANONYMOUS_KEY is set but AUTH_MODE is not 'multi' — anonymous key will be ignored");
}
@@ -1084,7 +1152,7 @@ const authCheckInterval = setInterval(checkAuth, 600000);
// 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) {
function buildCliArgs(cliModel, systemPrompt, opts = {}) {
const args = [
"--model", cliModel,
"--output-format", "stream-json",
@@ -1093,6 +1161,14 @@ function buildCliArgs(cliModel, systemPrompt) {
"--system-prompt", systemPrompt,
];
// Multimodal path (issue #110): images are fed as Anthropic content blocks over
// a stream-json stdin stream. `--input-format stream-json` (§ --input-format,
// choices text|stream-json; realtime streaming input) is added ONLY when the
// request carries an image part; the default (text) input path is untouched.
if (opts.streamJsonInput) {
args.push("--input-format", "stream-json");
}
// Permissions
// ADR 0007 B-path: in multi-tenant mode, suppress operator-FS tools so a guest
// prompt cannot drive Bash/Read/Write/Edit/etc. on the operator's filesystem.
@@ -1128,11 +1204,45 @@ function buildCliArgs(cliModel, systemPrompt) {
return args;
}
// Thin env wrapper over parsePositiveInt (lib/env.mjs): resolve `name` from the
// environment fail-closed, warning on a present-but-invalid value. Keeps the pure
// parse in a unit-testable module. (PR #154 review F3)
function parseIntEnv(name, def) {
const { value, ok } = parsePositiveInt(process.env[name], def);
if (!ok) console.warn(`${name}="${process.env[name]}" is not a valid positive integer (bytes/count, no unit suffix); ignoring and using default ${def}.`);
return value;
}
// ── Format messages to prompt text ──────────────────────────────────────
// Truncation guard: if total chars exceed MAX_PROMPT_CHARS, keep the system
// message(s) + first user message + last N messages, dropping the middle.
// This prevents runaway context from gateway-side conversation accumulation.
let MAX_PROMPT_CHARS = parseInt(process.env.CLAUDE_MAX_PROMPT_CHARS || "150000", 10);
// Routed through parseIntEnv so a misconfigured cap fails CLOSED to the default rather than
// NaN — CLAUDE_MAX_PROMPT_CHARS=unlimited previously → NaN → enforceTextBudget's `!(NaN > 0)`
// early-return → 500k chars passed unbounded, silently defeating F2's text-budget guarantee
// (PR #154 round 2, gap (a)). The default itself is SPOT-DERIVED (ADR 0009, PR #179):
// max(models.json contextWindow) × 3 chars/token — currently 600,000 — so the two fixes
// compose: parseIntEnv guards a SET-but-garbage value, the derivation supplies the honest
// default when unset/empty. `let` is kept for the settings API.
let MAX_PROMPT_CHARS = parseIntEnv("CLAUDE_MAX_PROMPT_CHARS", derivePromptCharBudget(modelsConfig.models));
// ── Multimodal image caps (issue #110) ──────────────────────────────────
// OpenAI `image_url` parts are forwarded to claude as Anthropic image blocks via
// `--input-format stream-json`. Images deliberately BYPASS the text char budget
// (MAX_PROMPT_CHARS) — they are bounded by these byte/count caps instead, and by
// MAX_BODY_SIZE at the HTTP layer. Data URIs are supported by default; remote
// http(s) image URLs are OFF unless CLAUDE_IMAGE_ALLOW_URL is set (v1: data URIs
// only). See docs/adr/0006-openai-shim-scope.md (Class B.1) and README § "Images".
const IMAGE_ALLOW_URL = /^(1|true|yes|on)$/i.test(process.env.CLAUDE_IMAGE_ALLOW_URL || "");
const MAX_IMAGE_BYTES = parseIntEnv("CLAUDE_MAX_IMAGE_BYTES", 5 * 1024 * 1024);
const MAX_IMAGES = parseIntEnv("CLAUDE_MAX_IMAGES", 20);
const MAX_IMAGE_TOTAL_BYTES = parseIntEnv("CLAUDE_MAX_IMAGE_TOTAL_BYTES", 20 * 1024 * 1024);
const MULTIMODAL_OPTS = {
allowRemoteUrl: IMAGE_ALLOW_URL,
maxImageBytes: MAX_IMAGE_BYTES,
maxImages: MAX_IMAGES,
maxTotalImageBytes: MAX_IMAGE_TOTAL_BYTES,
};
// Flatten OpenAI content (string | array of parts) to plain text for the prompt.
// Array content: concatenate text parts; replace non-text parts (e.g. image_url)
@@ -1224,25 +1334,51 @@ function spawnClaudeProcess(model, messages, conversationId, keyName, releaseSlo
// Circuit breaker: disabled (see comment at top of breaker section)
stats.activeRequests++;
stats.totalRequests++;
// Phase 6c: always serialize full conversation via stdin (no session resume).
// System messages are extracted and passed via --system-prompt; the remaining
// messages (user/assistant/tool) are serialized by messagesToPrompt.
// messages (user/assistant/tool) are serialized for stdin.
const systemPrompt = extractSystemPrompt(messages);
// messagesToPrompt skips system messages now that they go via --system-prompt.
// Filter them out before calling to avoid double-injection.
// messagesToPrompt / buildStreamJsonInput skip system messages (they go via
// --system-prompt). Filter them out first to avoid double-injection.
const nonSystemMessages = messages.filter(m => m.role !== "system");
const prompt = messagesToPrompt(nonSystemMessages);
stats.oneOffRequests++;
if (conversationId) {
console.log(`[session] stateless conv=${conversationId.slice(0, 12)}... key=${keyName || "anon"} msgs=${messages.length} prompt_chars=${prompt.length}`);
// Multimodal (issue #110): when any message carries an OpenAI image_url part,
// feed the conversation as Anthropic content blocks over --input-format
// stream-json (images preserved and kept OUT of the text char budget).
// Otherwise the text path is byte-for-byte unchanged. buildStreamJsonInput may
// throw MultimodalError on an invalid/oversized image; it runs BEFORE any stats
// mutation so a validation failure never leaks counters or the concurrency slot
// (handleChatCompletions validates first, so in practice it will not throw here).
const useStreamJson = hasImageContent(nonSystemMessages);
let stdinPayload, promptChars;
if (useStreamJson) {
// Pass MAX_PROMPT_CHARS so the multimodal text is bounded by the same
// runaway-context guard as the text path (PR #154 review F2). Images bypass it.
const built = buildStreamJsonInput(nonSystemMessages, { ...MULTIMODAL_OPTS, maxTextChars: MAX_PROMPT_CHARS });
stdinPayload = built.payload;
promptChars = built.stats.textChars;
if (built.stats.truncated) {
logEvent("warn", "prompt_truncated", {
originalChars: built.stats.originalTextChars,
maxChars: MAX_PROMPT_CHARS,
keptChars: built.stats.textChars,
path: "multimodal",
});
}
} else {
stdinPayload = messagesToPrompt(nonSystemMessages);
promptChars = stdinPayload.length;
}
const cliArgs = buildCliArgs(cliModel, systemPrompt);
stats.activeRequests++;
stats.totalRequests++;
stats.oneOffRequests++;
if (conversationId) {
console.log(`[session] stateless conv=${conversationId.slice(0, 12)}... key=${keyName || "anon"} msgs=${messages.length} prompt_chars=${promptChars}`);
}
const cliArgs = buildCliArgs(cliModel, systemPrompt, { streamJsonInput: useStreamJson });
const env = { ...process.env };
delete env.CLAUDECODE;
@@ -1328,12 +1464,13 @@ function spawnClaudeProcess(model, messages, conversationId, keyName, releaseSlo
// the spawned process, NOT on the stdin Writable — it does not catch this.
proc.stdin.on("error", (e) => logEvent("warn", "stdin_write_error", { error: e.message }));
// Write prompt to stdin immediately
proc.stdin.write(prompt);
// Write the serialized turn to stdin immediately. Text path: the flat prompt.
// Multimodal path: a single newline-terminated stream-json user envelope.
proc.stdin.write(stdinPayload);
proc.stdin.end();
recordModelRequest(cliModel, prompt.length);
logEvent("info", "claude_spawned", { model: cliModel, promptChars: prompt.length, timeout: TIMEOUT, tier: getModelTier(cliModel), session: conversationId ? conversationId.slice(0, 12) + "..." : "none" });
recordModelRequest(cliModel, promptChars);
logEvent("info", "claude_spawned", { model: cliModel, promptChars, inputFormat: useStreamJson ? "stream-json" : "text", timeout: TIMEOUT, tier: getModelTier(cliModel), session: conversationId ? conversationId.slice(0, 12) + "..." : "none" });
// Single request timeout — no separate first-byte timer.
// Claude tool-use causes long pauses in the token stream (30s-5min),
@@ -1403,7 +1540,7 @@ async function callClaude(model, messages, conversationId, keyName, res) {
const { proc, cliModel, conversationId: convId, t0, cleanup, handleSessionFailure, markFirstByte } = ctx;
let lineBuffer = "";
let assembledText = "";
let isFirstDelta = true;
let sawTextDelta = false;
let resultEventSeen = false;
let stderr = "";
@@ -1413,11 +1550,18 @@ async function callClaude(model, messages, conversationId, keyName, res) {
const { events, remainder } = parseStreamJsonLines(lineBuffer);
lineBuffer = remainder;
for (const event of events) {
const parsed = parseStreamJsonEvent(event, isFirstDelta);
const parsed = parseStreamJsonEvent(event, sawTextDelta);
if (!parsed) continue;
if (parsed.text !== undefined) {
assembledText += parsed.text;
isFirstDelta = false;
if (parsed.fromDelta) {
assembledText += parsed.text;
sawTextDelta = true;
} else {
// aggregate assistant message — separate successive messages so the preamble and
// the post-tool-use final answer don't run together.
if (assembledText && !assembledText.endsWith("\n")) assembledText += "\n\n";
assembledText += parsed.text;
}
} else if (parsed.stop) {
resultEventSeen = true;
} else if (parsed.error) {
@@ -1839,9 +1983,10 @@ async function callClaudeStreaming(model, messages, conversationId, res, authInf
let stderr = "";
let headersSent = false;
let totalChars = 0;
let streamEndsWithNewline = false; // tracks whether emitted text ends in "\n" — see the separator guard below
let cachedContent = ""; // accumulate for cache write-back
let lineBuffer = "";
let isFirstDelta = true;
let sawTextDelta = false;
let resultEventSeen = false;
// Separate flag for is_error result — must NOT be conflated with resultEventSeen.
// If errored===true the close handler must not cache the response or record success
@@ -1878,15 +2023,26 @@ async function callClaudeStreaming(model, messages, conversationId, res, authInf
lineBuffer = remainder;
for (const event of events) {
const parsed = parseStreamJsonEvent(event, isFirstDelta);
const parsed = parseStreamJsonEvent(event, sawTextDelta);
if (!parsed) continue;
if (parsed.text !== undefined) {
// content_block_delta text — forward as SSE delta
const text = parsed.text;
// Streamed delta, or an aggregate assistant-message text (agentic turns emit several).
// For an aggregate message after earlier text, prepend a separator so the preamble and
// the post-tool-use final answer don't run together in the forwarded stream.
let text = parsed.text;
if (parsed.fromDelta) {
sawTextDelta = true;
} else if (totalChars > 0 && !streamEndsWithNewline) {
// Mirror the buffered path's guard (assembledText.endsWith("\n")): only inject the
// blank-line separator when the already-emitted text doesn't already end in a newline,
// so a message ending in "\n" doesn't produce a triple newline here while the buffered
// path produces a single. Keeps the two assembly paths byte-identical. (PR #183 review.)
text = "\n\n" + text;
}
streamEndsWithNewline = text.endsWith("\n");
totalChars += text.length;
if (CACHE_TTL > 0) cachedContent += text;
isFirstDelta = false;
if (!ensureHeaders()) continue;
sendSSE(res, {
@@ -2056,6 +2212,31 @@ function completionResponse(res, id, model, content) {
});
}
// OpenAI's designated mechanism for "the model would not produce the required output" is the
// assistant `refusal` field (content:null, refusal:<text>, finish_reason:"stop") — NOT an invented
// error type. Structured-output exhaustion emits this so SDK clients take their written `refusal`
// branch instead of throwing an opaque UnprocessableEntityError. (PR #153 review, finding 3.)
function refusalResponse(res, id, model, refusal) {
jsonResponse(res, 200, {
id, object: "chat.completion",
created: Math.floor(Date.now() / 1000),
model,
choices: [{ index: 0, message: { role: "assistant", content: null, refusal }, finish_reason: "stop" }],
usage: { prompt_tokens: 0, completion_tokens: 0, total_tokens: 0 },
});
}
// Streaming form of refusalResponse: a role chunk, a `refusal` delta, then the stop chunk.
function streamRefusalAsSSE(res, id, model, refusal) {
const created = Math.floor(Date.now() / 1000);
res.writeHead(200, { "Content-Type": "text/event-stream", "Cache-Control": "no-cache", "Connection": "keep-alive", "X-Accel-Buffering": "no" });
sendSSE(res, { id, object: "chat.completion.chunk", created, model, choices: [{ index: 0, delta: { role: "assistant" }, finish_reason: null }] });
sendSSE(res, { id, object: "chat.completion.chunk", created, model, choices: [{ index: 0, delta: { refusal }, finish_reason: null }] });
sendSSE(res, { id, object: "chat.completion.chunk", created, model, choices: [{ index: 0, delta: {}, finish_reason: "stop" }] });
res.write("data: [DONE]\n\n");
res.end();
}
// Replay a complete string as a chunked SSE stream (80 codepoints/chunk).
// Used by: (a) cache-hit replay on the streaming path; (b) TUI-mode streaming
// (buffered response replayed as SSE so clients get the same wire format).
@@ -2557,18 +2738,58 @@ async function handleSettings(req, res) {
}
// ── Handle chat completions ─────────────────────────────────────────────
const MAX_BODY_SIZE = 5 * 1024 * 1024; // 5 MB
// Default 5 MB, byte-for-byte unchanged unless CLAUDE_MAX_BODY_SIZE is set. Base64
// image payloads inflate ~33%; operators enabling large images (issue #110) can
// raise this to admit bigger requests. Parsed fail-closed (PR #154 review F3): a
// bad value (`unlimited` → NaN, `5MB` → 5) must not disable the body cap or brick
// the proxy — parseIntEnv keeps the 5 MB default and warns instead.
const MAX_BODY_SIZE = parseIntEnv("CLAUDE_MAX_BODY_SIZE", 5 * 1024 * 1024);
const MAX_BODY_SIZE_LABEL = `${Math.round(MAX_BODY_SIZE / (1024 * 1024))}MB`;
// Set of all valid model identifiers (canonical IDs + aliases)
const VALID_MODELS = new Set(Object.keys(MODEL_MAP));
// Drive the model to a valid structured-output (OpenAI response_format) JSON string, retrying up to
// STRUCTURED_MAX_ATTEMPTS. Appends a strict JSON-only steering instruction, extracts + validates the
// reply (pure helpers in lib/structured-output.mjs), and escalates the instruction on failure.
// Returns the canonical JSON string (message.content) or throws StructuredOutputError.
async function runStructuredCompletion(upstreamCall, model, messages, conversationId, keyName, res, structured) {
let lastErr = "no valid JSON produced";
let lastRaw = "";
for (let attempt = 0; attempt < STRUCTURED_MAX_ATTEMPTS; attempt++) {
const augmented = [...messages, { role: "system", content: structuredSystemInstruction(structured, attempt, lastErr) }];
const raw = await upstreamCall(model, augmented, conversationId, keyName, res);
lastRaw = raw;
const extracted = extractJsonPayload(raw, { whole: structured.mode === "json_object" });
if (!extracted.ok) {
lastErr = extracted.reason || "response was not parseable as JSON";
logEvent("warn", "structured_retry", { attempt, reason: extracted.reason || "unparseable" });
continue;
}
if (structured.mode === "schema" && structured.schema) {
// validateJsonSchemaSafe (#181): a pathologically deep model reply overflows the value-depth
// recursion; the safe façade turns that into a validation miss (→ retry → refusal) instead of
// a caught RangeError surfacing as a generic 500.
const errs = validateJsonSchemaSafe(extracted.value, structured.schema, "$", structured.strict);
if (errs.length) {
lastErr = "schema validation failed: " + errs.slice(0, 5).join("; ");
logEvent("warn", "structured_retry", { attempt, reason: "schema", errors: errs.slice(0, 5) });
continue;
}
}
if (attempt > 0) logEvent("info", "structured_recovered", { attempt });
return JSON.stringify(extracted.value); // canonical, fence-free, prose-free
}
throw new StructuredOutputError(lastErr, lastRaw);
}
async function handleChatCompletions(req, res) {
let body = "";
try {
for await (const chunk of req) {
body += chunk;
if (body.length > MAX_BODY_SIZE) {
return jsonResponse(res, 413, { error: { message: "Request body too large (max 5MB)", type: "invalid_request_error" } });
return jsonResponse(res, 413, { error: { message: `Request body too large (max ${MAX_BODY_SIZE_LABEL})`, type: "invalid_request_error" } });
}
}
} catch (e) {
@@ -2597,6 +2818,57 @@ async function handleChatCompletions(req, res) {
return jsonResponse(res, 400, { error: { message: "'messages' must be a non-empty array", type: "invalid_request_error" } });
}
// Multimodal validation (issue #110): when a request carries OpenAI `image_url`
// content parts, validate/parse them now so an invalid, unsupported, or oversized
// image returns a clean 4xx BEFORE the cache/spawn path (rather than a silent drop
// or an opaque 500). The stream-json transform itself runs at spawn time
// (spawnClaudeProcess → buildStreamJsonInput). Class B.1: authorized by ADR 0006;
// request shape per OpenAI vision spec (image_url content parts). buildImageBlocks
// validates without stringifying, so this early pass is cheap.
if (hasImageContent(messages)) {
// F1 (PR #154 review): the TUI path (callClaudeTui → messagesToPrompt) cannot
// carry image blocks — it renders every non-text part as "[non-text content
// omitted]". Forwarding here would let the model answer about an image it never
// saw and return 200, which is strictly worse than an honest error (the one
// outcome ALIGNMENT.md forbids: silently serving text the model did not mean).
// Stream-json image input requires the `claude -p` path, so in TUI_MODE we fail
// loudly instead of dropping. Documented in README § "Images".
if (TUI_MODE) {
return jsonResponse(res, 400, {
error: {
message: "Image inputs are not supported in TUI mode (CLAUDE_TUI_MODE=true). Images require the default -p spawn path; remove images or run OCP without TUI mode.",
type: "invalid_request_error",
code: "images_unsupported_in_tui_mode",
},
});
}
// Detection runs on the FULL message list, but extraction/spawn drop system messages
// (system role carries no image blocks to the CLI). So an image present ONLY in a
// system message would be detected as multimodal, survive no filter, fall to the text
// path, and render as "[non-text content omitted]" → 200 with a hallucinated answer —
// the exact silent-drop this guard exists to forbid. Fail loudly instead. OpenAI
// disallows images in the system role anyway, so no legitimate request is rejected.
// (PR #154 review round 2, gap (b).)
const nonSystem = messages.filter(m => m.role !== "system");
if (!hasImageContent(nonSystem)) {
return jsonResponse(res, 400, {
error: {
message: "Image inputs are only supported in user/assistant messages, not in system messages. Move the image_url part to a user message.",
type: "invalid_request_error",
code: "images_unsupported_in_system_messages",
},
});
}
try {
buildImageBlocks(nonSystem, { ...MULTIMODAL_OPTS, maxTextChars: MAX_PROMPT_CHARS });
} catch (e) {
if (e instanceof MultimodalError) {
return jsonResponse(res, e.status, { error: { message: e.message, type: e.type, code: e.code } });
}
throw e;
}
}
// NOTE: quota is best-effort / eventually-consistent. The gate reads the recorded count
// at entry and records only after the upstream completes, so concurrent requests at the
// boundary can overshoot the cap by up to MAX_CONCURRENT, and cache hits (served before
@@ -2618,6 +2890,74 @@ async function handleChatCompletions(req, res) {
}
}
// Structured output (OpenAI response_format / json_mode): its own path — the response must be
// schema-valid JSON, so it never shares the conversational cache slot. When caching is enabled it
// uses a structured-keyed hash (isolated via cacheHash's `structured` marker) and writes back ONLY
// a validated result (never a 422). Always validates on a miss.
const structured = detectStructuredOutput(parsed);
if (structured) {
const t0s = Date.now();
const promptCharsS = messages.reduce((a, m) => a + contentToText(m.content).length, 0);
let structuredHash = null;
if (CACHE_TTL > 0 && !conversationId && !hasCacheControl(messages)) {
structuredHash = cacheHash(model, messages, { keyId: req._authKeyId, temperature: parsed.temperature, max_tokens: parsed.max_tokens, top_p: parsed.top_p, structured });
try {
const cached = getCachedResponse(structuredHash, CACHE_TTL);
if (cached) {
logEvent("info", "cache_hit", { model, hash: structuredHash.slice(0, 12), hits: cached.hits, structured: true });
const id = `chatcmpl-${randomUUID()}`;
if (stream) streamStringAsSSE(res, id, model, cached.response);
else completionResponse(res, id, model, cached.response);
return;
}
} catch (e) { logEvent("error", "cache_check_failed", { error: e.message }); }
}
const upstreamCall = TUI_MODE ? callClaudeTui : callClaude;
// Stampede protection (PR #153 review, finding 5): a structured request can cost up to
// STRUCTURED_MAX_ATTEMPTS metered spawns, so N identical concurrent requests (Home Assistant
// firing several AI Tasks at once) must NOT each pay N× — they share one flight. We dedup every
// one-off structured request (not stateful sessions / client-side prompt caching), independent of
// whether OCP response caching is enabled; when caching IS on, the same key gates cache read/write.
const dedupKey = (!conversationId && !hasCacheControl(messages))
? cacheHash(model, messages, { keyId: req._authKeyId, temperature: parsed.temperature, max_tokens: parsed.max_tokens, top_p: parsed.top_p, structured })
: null;
const runStructured = async () => {
const c = await runStructuredCompletion(upstreamCall, model, messages, conversationId, req._authKeyName, res, structured);
if (structuredHash) { try { setCachedResponse(structuredHash, model, c); } catch (e) { logEvent("error", "cache_write_failed", { error: e.message }); } }
return c;
};
try {
const content = dedupKey
? await singleflight(dedupKey, async () => {
// A follower that raced in after the leader populated the cache re-reads it here.
if (structuredHash) { const rc = getCachedResponse(structuredHash, CACHE_TTL); if (rc) return rc.response; }
return runStructured();
}, (err) => err instanceof RequestDisconnectedError && !res.destroyed)
: await runStructured();
const id = `chatcmpl-${randomUUID()}`;
if (stream) streamStringAsSSE(res, id, model, content);
else completionResponse(res, id, model, content);
try { recordUsage({ keyId: req._authKeyId, keyName: req._authKeyName, model, promptChars: promptCharsS, responseChars: content.length, elapsedMs: Date.now() - t0s, success: true }); } catch (e) { logEvent("error", "usage_record_failed", { error: e.message }); }
return;
} catch (err) {
if (err instanceof RequestDisconnectedError) { try { res.end(); } catch {} return; }
try { recordUsage({ keyId: req._authKeyId, keyName: req._authKeyName, model, promptChars: promptCharsS, responseChars: 0, elapsedMs: Date.now() - t0s, success: false }); } catch {}
if (res.headersSent || res.writableEnded || res.destroyed) { try { res.end(); } catch {} return; }
if (err instanceof StructuredOutputError) {
// OpenAI's spec mechanism for "model would not produce the required output" is the assistant
// `refusal` field (200, content:null, finish_reason:"stop"), NOT an invented 422 error type —
// so SDK clients take their written refusal branch. (PR #153 review, finding 3.)
logEvent("warn", "structured_failed", { reason: err.reason });
const id = `chatcmpl-${randomUUID()}`;
const refusal = `Could not produce a response matching the requested response_format after ${STRUCTURED_MAX_ATTEMPTS} attempts (${sanitizeError(err.reason)}).`;
if (stream) streamRefusalAsSSE(res, id, model, refusal);
else refusalResponse(res, id, model, refusal);
return;
}
return respondUpstreamError(res, err);
}
}
// Cache check (only when cache is enabled and no active conversation/session)
if (CACHE_TTL > 0 && !conversationId) {
// D2: skip OCP cache entirely when messages carry cache_control annotations;
@@ -2626,8 +2966,9 @@ async function handleChatCompletions(req, res) {
req._cacheHash = null;
logEvent("info", "cache_skipped", { reason: "cache_control_present" });
} else {
// D1: include keyId in hash to isolate per-key cache pools (v2 format)
const hash = cacheHash(model, messages, { keyId: req._authKeyId, temperature: parsed.temperature, max_tokens: parsed.max_tokens, top_p: parsed.top_p });
// D1: include keyId in hash to isolate per-key cache pools (v2 format).
// configEpoch (#176): any boot-config change that shapes answers invalidates the cache.
const hash = cacheHash(model, messages, { keyId: req._authKeyId, temperature: parsed.temperature, max_tokens: parsed.max_tokens, top_p: parsed.top_p, configEpoch: CONFIG_EPOCH });
req._cacheHash = hash; // store for later write-back
try {
const cached = getCachedResponse(hash, CACHE_TTL);
@@ -3272,6 +3613,8 @@ server.listen(PORT, BIND_ADDRESS, () => {
console.log(`Auth: ${PROXY_API_KEY ? "enabled (PROXY_API_KEY set)" : "disabled (no PROXY_API_KEY)"}`);
console.log(`Auth mode: ${AUTH_MODE}${AUTH_MODE === "shared" ? " (PROXY_API_KEY)" : AUTH_MODE === "multi" ? " (per-user keys)" : " (open)"}`);
console.log(`Bind: ${BIND_ADDRESS}${BIND_ADDRESS === "0.0.0.0" ? " ⚠ LAN-accessible" : ""}`);
if (LOCAL_TOOLS_ACTIVE) console.log(`Local tools: ON (OCP_LOCAL_TOOLS=1) — model told it may use local tools; single-user/loopback only`);
else if (LOCAL_TOOLS) console.warn(`⚠ OCP_LOCAL_TOOLS=1 is ignored in TUI mode (the -p system-prompt wrapper is not used). The TUI tool surface is governed by OCP_TUI_FULL_TOOLS.`);
if (NO_CONTEXT) console.log(`Context: suppressed (CLAUDE_NO_CONTEXT=true — no CLAUDE.md, no auto-memory)`);
if (CACHE_TTL > 0) console.log(`Cache: enabled (TTL=${CACHE_TTL / 1000}s)`);
else console.log(`Cache: disabled (set CLAUDE_CACHE_TTL to enable)`);
+1 -1
View File
@@ -334,7 +334,7 @@ if (OPENCLAW_PRESENT) {
`║ Aider / OpenClaw) at: ║`,
`║ http://${BIND_ADDRESS}:${String(PORT)}/v1${" ".repeat(Math.max(0, 47 - BIND_ADDRESS.length - String(PORT).length))}`,
`║ ║`,
`║ See README § "Client Setup" for per-IDE instructions.`,
`║ See docs/lan-mode.md for per-IDE client setup. `,
`║ ║`,
);
}
+1012 -45
View File
File diff suppressed because it is too large Load Diff