Compare commits

..
Author SHA1 Message Date
taodengandClaude <claude-opus-4-8> <noreply@anthropic.com> 2144e6769f fix(cache): fold a boot-config epoch into the response-cache key (#176)
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: Claude <claude-opus-4-8> <noreply@anthropic.com>
2026-07-17 19:27:08 +10:00
12 changed files with 73 additions and 1867 deletions
+2 -17
View File
@@ -1,25 +1,10 @@
# Changelog
## 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).
## Unreleased
### 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.
- **Default `sonnet` alias → `claude-sonnet-5`.** 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. This is a behavior change for clients relying on the default — pin `claude-sonnet-4-6` explicitly to retain the previous model. Split out from the additive `claude-sonnet-5` model entry (#152) per Iron Rule 11.
## v3.22.1 — 2026-07-17
+3 -117
View File
@@ -27,7 +27,7 @@ One proxy. Multiple IDEs. All models. **$0 API cost.**
- [How It Works](#how-it-works)
- Reference: [Available Models](#available-models) · [API Endpoints](#api-endpoints) · [Environment Variables](#environment-variables)
- Modes & operations: [LAN & multi-user](#lan--multi-user) → [`docs/lan-mode.md`](docs/lan-mode.md) · [Subscription-pool (TUI) mode](#subscription-pool-tui-mode) → [`docs/tui-mode.md`](docs/tui-mode.md) · [Upgrading](#upgrading) → [`docs/upgrading.md`](docs/upgrading.md)
- [Built-in Usage Monitoring](#built-in-usage-monitoring) · [Response Cache](#response-cache) · [Structured Outputs](#structured-outputs-openai-response_format) · [Images / Multimodal](#images--multimodal-vision) · [OpenClaw Integration](#openclaw-integration)
- [Built-in Usage Monitoring](#built-in-usage-monitoring) · [Response Cache](#response-cache) · [OpenClaw Integration](#openclaw-integration)
- [Troubleshooting](#troubleshooting) → [`docs/troubleshooting.md`](docs/troubleshooting.md)
- [Repository Layout](#repository-layout) · [Security](#security) · [Governance](#governance) · [Support OCP](#support-ocp) · [License](#license)
@@ -222,19 +222,13 @@ The canonical list lives in [`models.json`](./models.json) — the single source
| `CLAUDE_MAX_CONCURRENT` | `8` | Max concurrent claude processes (`-p`/stream-json path) |
| `CLAUDE_MAX_QUEUE` | `16` | Max requests **waiting** for a `-p` concurrency slot. Beyond `CLAUDE_MAX_CONCURRENT`, requests queue (up to this cap) instead of being rejected; when the queue is **also** full, the request gets `HTTP 429` + `Retry-After` (not an opaque 500). Surfaced on `/health.concurrency` + `/health.stats.queueRejections`. |
| `CLAUDE_QUEUE_RETRY_AFTER` | `5` | Seconds advertised in the `Retry-After` header on a `-p` concurrency-overflow `429`. |
| `CLAUDE_MAX_PROMPT_CHARS` | *(derived)* | Prompt truncation limit in chars. Default derives from the models.json SPOT: `max(contextWindow) × 3` — currently **600,000** (≈150200k tokens). Setting this env var (or the runtime settings API) overrides the derivation absolutely. See [ADR 0009](docs/adr/0009-spot-derived-prompt-budget.md). Note: very large prompts burn subscription-window quota quickly and slow TTFT; the TUI-mode paste path is untested beyond ~hundreds of KB. Applies to **text only** — image bytes bypass this budget (see [Images / Multimodal](#images--multimodal-vision)). |
| `OCP_STRUCTURED_MAX_ATTEMPTS` | `3` | Max attempts (initial + retries) to coerce a schema-valid JSON reply when a request uses OpenAI `response_format`. Fail-closed: a non-numeric value keeps the default. See [Structured Outputs](#structured-outputs-openai-response_format). |
| `CLAUDE_MAX_PROMPT_CHARS` | `150000` | Prompt truncation limit (chars) |
| `CLAUDE_SESSION_TTL` | `3600000` | Session expiry (ms, default: 1 hour) |
| `CLAUDE_CACHE_TTL` | `0` | Response cache TTL (ms, 0 = disabled). Set to e.g. `300000` for 5-min cache. See [Response Cache](#response-cache). |
| `CLAUDE_ALLOWED_TOOLS` | `Bash,Read,...,Agent` | Comma-separated tools to pre-approve |
| `CLAUDE_SKIP_PERMISSIONS` | `false` | Bypass all permission checks |
| `CLAUDE_MCP_CONFIG` | *(unset)* | Path to an MCP server config JSON, passed to the spawned `claude` as `--mcp-config` (both the `-p` path and TUI `OCP_TUI_FULL_TOOLS` panes) |
| `CLAUDE_MAX_BODY_SIZE` | `5242880` | Max request body size (bytes, default 5 MB). Base64 image payloads inflate ~33%; raise this to admit larger multimodal requests. Fail-closed parsing: a garbage value keeps the default. |
| `CLAUDE_IMAGE_ALLOW_URL` | `false` | Allow remote `http(s)` image URLs in `image_url` parts. **Off by default** (v1 supports base64 `data:` URIs only). When on, the URL is passed through to Anthropic as a `url` image source — **OCP does not fetch it** (no OCP-side SSRF surface); unreachable/blocked URLs surface as an API error. |
| `CLAUDE_MAX_IMAGE_BYTES` | `5242880` | Per-image decoded-byte cap (default 5 MB). Over-cap images get `HTTP 413`. |
| `CLAUDE_MAX_IMAGES` | `20` | Max image parts per request. Over-cap gets `HTTP 413`. |
| `CLAUDE_MAX_IMAGE_TOTAL_BYTES` | `20971520` | Aggregate decoded-byte cap across all images in a request (default 20 MB). Over-cap gets `HTTP 413`. |
| `CLAUDE_SYSTEM_PROMPT` | *(unset)* | Operator-wide system-prompt text appended (last) to every request's composed system prompt on the default `-p` path. TUI-mode panes are unaffected (they keep the interactive CLI's own system prompt). Echoed truncated on `/health.systemPrompt`. Note: changing this value and restarting auto-invalidates the response cache (the key carries a boot-config epoch, #177). |
| `CLAUDE_SYSTEM_PROMPT` | *(unset)* | Operator-wide system-prompt text appended (last) to every request's composed system prompt on the default `-p` path. TUI-mode panes are unaffected (they keep the interactive CLI's own system prompt). Echoed truncated on `/health.systemPrompt`. Note: the response cache key does not include server config — after changing this value, flush the cache (`ocp clear`) or let TTL expire. |
| `CLAUDE_NO_CONTEXT` | `false` | Suppress CLAUDE.md and auto-memory injection (pure API mode) |
| `PROXY_API_KEY` | *(unset)* | Bearer token for shared-mode authentication |
| `PROXY_ANONYMOUS_KEY` | *(unset)* | Well-known anonymous key (multi mode) — this exact string bypasses `validateKey()` and grants public access. Exposed via `/health.anonymousKey` only to localhost, or to all callers when `PROXY_ADVERTISE_ANON_KEY=1`. Full setup + security notes: [docs/lan-mode.md § Anonymous Access](docs/lan-mode.md#anonymous-access-optional). |
@@ -388,114 +382,6 @@ ocp settings cacheTTL 0 # disable at runtime
Cache is **disabled by default** (`CLAUDE_CACHE_TTL=0`). All data is stored locally in `~/.ocp/ocp.db`. **Hash format upgrade in v3.13.0:** legacy `v1` cache rows don't match new `v2`-format lookups; they orphan and are reaped by the TTL cleanup interval within one window — no migration script required.
## Structured Outputs (OpenAI `response_format`)
`/v1/chat/completions` honors OpenAI's [`response_format`](https://platform.openai.com/docs/api-reference/chat/create#chat-create-response_format) parameter so OpenAI-SDK clients that require machine-parseable JSON (Home Assistant AI Tasks, Honcho, BYO scripts) get JSON in `choices[].message.content` — not prose.
Supported shapes:
- `response_format: { "type": "json_schema", "json_schema": { "name", "strict", "schema" } }`
- `response_format: { "type": "json_object" }`
- `json_mode: true` — non-standard top-level alias honored by several OpenAI-compatible clients; treated as `json_object`.
When a structured request is detected, OCP:
1. Appends a strict JSON-only steering instruction to the request (no Markdown, no fences, no prose, must begin with `{` or `[`).
2. Extracts the JSON from the model reply (unwraps a stray code fence / prose via a string-aware balanced slice).
3. For `json_schema`, validates the result against the supplied schema (types, `required`, `enum`, `const`, `additionalProperties`, nullability, `items`, `min/maxItems`, and `$ref`/`$defs` + `allOf`/`anyOf`/`oneOf` composition — the shapes the official OpenAI SDK emits via `zodResponseFormat` / `client.beta.chat.completions.parse`). For `json_object`, the whole reply must parse as a single JSON value (a stray brace inside prose is not served as the answer).
4. On a parse/validation miss, retries with a stronger instruction that names the failure, up to `OCP_STRUCTURED_MAX_ATTEMPTS` (default 3).
5. If no valid JSON can be produced, returns OpenAI's assistant **`refusal`** field (`HTTP 200`, `message.content: null`, `message.refusal: "<reason>"`, `finish_reason: "stop"`) — the spec's own mechanism for "the model would not produce the required output" — rather than an invented error type or passing prose through. SDK clients take their written `refusal` branch.
A reply that carries **more than one** top-level JSON value (e.g. `Schema: {…}` then `Answer: {…}`) is rejected as ambiguous rather than silently serving the first — OCP never serves an unvalidated or arbitrarily-chosen extraction.
`message.content` for a structured request is the raw JSON string only — no fences, no reasoning, no wrapper. Non-structured requests are completely unaffected (normal conversational behaviour, streaming included). This is a Class B.1 endpoint extension authorized by ADR 0006; the pure logic lives in [`lib/structured-output.mjs`](./lib/structured-output.mjs) and is unit-tested in `test-features.mjs`.
**Caching & cost.** A structured request can cost up to `OCP_STRUCTURED_MAX_ATTEMPTS` metered `claude` spawns — each retry is a fresh spawn, burning subscription-window quota today and metered credits if the (currently **paused**) 2026-06-15 billing split re-lands (see the billing-policy status note in [How It Works](#how-it-works)) — so this feature adds cost-attack surface. Two guards bound it: (a) identical **concurrent** structured requests share one flight (single-flight dedup, so N callers ≠ N× spawns), and (b) when `CLAUDE_CACHE_TTL > 0`, a **validated** result is cached on a **structured-keyed** hash (the `response_format`/schema is folded into the key, so a JSON reply never collides with the conversational answer and different schemas never share a slot). A refusal is never cached. Operators concerned about cost can lower `OCP_STRUCTURED_MAX_ATTEMPTS` to `1` (no retries) or gate the surface behind per-key quotas (`/api/keys/:id/quota`).
## Images / Multimodal (Vision)
`POST /v1/chat/completions` accepts OpenAI-style multimodal `content` parts, so a
message can carry images alongside text and Claude will actually see them. This
follows OpenAI's [vision](https://platform.openai.com/docs/guides/vision) /
[chat-completions `image_url`](https://platform.openai.com/docs/api-reference/chat/create#chat-create-messages)
request shape — no OCP-invented fields. (Class B.1 endpoint; see ADR 0006.)
Under the hood, when a request carries an image OCP feeds the conversation to the
Claude CLI as Anthropic image blocks over `--input-format stream-json`. Text-only
requests are completely unaffected (unchanged code path).
### Supported input
- **Base64 data URIs** (default, recommended):
`data:image/png;base64,<...>`. Media types: `image/jpeg`, `image/png`,
`image/gif`, `image/webp`.
- **Remote `http(s)` URLs** — **off by default**. Set `CLAUDE_IMAGE_ALLOW_URL=1`
to enable; the URL is passed through to Anthropic (OCP never fetches it itself,
so there is no OCP-side SSRF surface).
- Images may appear in **any** message in the history (multi-turn), not just the
last one.
- Non-image, non-text parts (audio, files) are **not** yet supported and are
replaced with a `[non-text content omitted]` placeholder (deferred to a future
version).
### Example (base64 data URI)
```bash
curl -X POST http://127.0.0.1:3456/v1/chat/completions \
-H 'Content-Type: application/json' \
-d '{
"model": "claude-sonnet-4-6",
"messages": [{
"role": "user",
"content": [
{ "type": "text", "text": "What is in this image?" },
{ "type": "image_url",
"image_url": { "url": "data:image/png;base64,iVBORw0KGgoAAA..." } }
]
}]
}'
```
### Not supported in TUI mode
Multimodal images require the default `-p` spawn path. In **TUI / subscription-pool
mode** (`CLAUDE_TUI_MODE=true`) the CLI is driven interactively and cannot carry
image blocks, so a request with an `image_url` part returns **`400
images_unsupported_in_tui_mode`** rather than silently dropping the image and
answering about something the model never saw. Remove the images, or run OCP
without TUI mode, to use vision.
Images must also live in a **user or assistant** message, not a `system` message
(system content is not forwarded to the CLI as image blocks). An `image_url` part
present only in a system message returns **`400 images_unsupported_in_system_messages`**
for the same reason — fail loudly rather than answer about an unseen image. This matches
the OpenAI vision spec, which does not place images in the system role.
### Limits
Images bypass the text `CLAUDE_MAX_PROMPT_CHARS` budget and are instead bounded by
their own byte/count caps. The **text** in a multimodal request is still subject to
`CLAUDE_MAX_PROMPT_CHARS` (older text is truncated exactly as on the text-only
path — only the image bytes are exempt). All numeric caps are parsed **fail-closed**:
a malformed value (e.g. `CLAUDE_MAX_BODY_SIZE=unlimited` or `=5MB`) is rejected with
a startup warning and the safe default is kept — a misconfigured cap can never
silently disable the guard. Requests that violate a cap get a clear `4xx` (never a
silent drop):
| Cap | Env var | Default | Error |
|-----|---------|---------|-------|
| Request body | `CLAUDE_MAX_BODY_SIZE` | 5 MB | `413` request body too large |
| Per-image bytes | `CLAUDE_MAX_IMAGE_BYTES` | 5 MB | `413` `image_too_large` |
| Total image bytes | `CLAUDE_MAX_IMAGE_TOTAL_BYTES` | 20 MB | `413` `images_too_large` |
| Image count | `CLAUDE_MAX_IMAGES` | 20 | `413` `too_many_images` |
| Unsupported media type | — | — | `400` `unsupported_image_type` |
| Malformed data URI | — | — | `400` `invalid_data_uri` |
| Remote URL while disabled | `CLAUDE_IMAGE_ALLOW_URL` | off | `400` `remote_url_disabled` |
Base64 payloads are large: a 5 MB image is ~6.7 MB as a data URI, so raise
`CLAUDE_MAX_BODY_SIZE` (and, if needed, `CLAUDE_MAX_IMAGE_BYTES`) to admit big
images. Vision support depends on the target model — request a current
vision-capable Claude model.
## OpenClaw Integration
OCP was originally built for [OpenClaw](https://github.com/openclaw/openclaw) and includes deep integration:
@@ -1,54 +0,0 @@
# 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,7 +25,6 @@ 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
-5
View File
@@ -360,11 +360,6 @@ export function cacheHash(model, messages, opts = {}) {
// 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
@@ -1,29 +0,0 @@
// 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
@@ -1,278 +0,0 @@
// 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 };
}
-37
View File
@@ -15,40 +15,3 @@ 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);
}
-318
View File
@@ -1,318 +0,0 @@
// ── 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
@@ -1,6 +1,6 @@
{
"name": "open-claude-proxy",
"version": "3.23.0",
"version": "3.22.1",
"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": {
+38 -327
View File
@@ -42,7 +42,6 @@ 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";
@@ -50,9 +49,7 @@ 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 } from "./lib/prompt.mjs";
import { appendOperatorPrompt } from "./lib/prompt.mjs";
const __dirname = dirname(fileURLToPath(import.meta.url));
const _pkg = JSON.parse(readFileSync(join(__dirname, "package.json"), "utf8"));
@@ -251,8 +248,8 @@ function parseStreamJsonLines(buffered) {
// Reference: OLP lib/providers/anthropic.mjs anthropicStreamJsonEventToIR (commit 97e7d16).
//
// @param {object} event — parsed NDJSON event
// @param {boolean} sawTextDelta — true if a streaming content_block_delta text was already seen
function parseStreamJsonEvent(event, sawTextDelta) {
// @param {boolean} isFirstDelta — true if no content has been yielded yet
function parseStreamJsonEvent(event, isFirstDelta) {
const t = event?.type;
// system/* — first-event init + other system meta (api_retry etc.)
@@ -264,23 +261,19 @@ function parseStreamJsonEvent(event, sawTextDelta) {
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 ?? "", fromDelta: true };
return { text: inner.delta.text ?? "" };
}
// Other stream_event sub-types (content_block_start, message_delta, etc.) — consumed
return null;
}
// 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.
// assistant — aggregate message (fallback when no prior content_block_delta seen)
// Empirically (claude CLI without --include-partial-messages, verified v2.1.104 through v2.1.158): fast/short
// responses may emit ONLY the aggregate assistant event, no content_block_delta events.
// If isFirstDelta is true, extract text here; otherwise it's a duplicate, ignore.
// Reference: OLP commit 65f945c (assistant-aggregate fallback, fold-in).
if (t === "assistant") {
if (!sawTextDelta) {
if (isFirstDelta) {
const blocks = event.message?.content;
if (Array.isArray(blocks)) {
const text = blocks
@@ -334,16 +327,6 @@ 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);
@@ -1117,7 +1100,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, opts = {}) {
function buildCliArgs(cliModel, systemPrompt) {
const args = [
"--model", cliModel,
"--output-format", "stream-json",
@@ -1126,14 +1109,6 @@ function buildCliArgs(cliModel, systemPrompt, opts = {}) {
"--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.
@@ -1169,45 +1144,11 @@ function buildCliArgs(cliModel, systemPrompt, opts = {}) {
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.
// 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,
};
let MAX_PROMPT_CHARS = parseInt(process.env.CLAUDE_MAX_PROMPT_CHARS || "150000", 10);
// 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)
@@ -1299,51 +1240,25 @@ function spawnClaudeProcess(model, messages, conversationId, keyName, releaseSlo
// Circuit breaker: disabled (see comment at top of breaker section)
// 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 for stdin.
const systemPrompt = extractSystemPrompt(messages);
// 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");
// 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;
}
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.
const systemPrompt = extractSystemPrompt(messages);
// messagesToPrompt skips system messages now that they go via --system-prompt.
// Filter them out before calling 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=${promptChars}`);
console.log(`[session] stateless conv=${conversationId.slice(0, 12)}... key=${keyName || "anon"} msgs=${messages.length} prompt_chars=${prompt.length}`);
}
const cliArgs = buildCliArgs(cliModel, systemPrompt, { streamJsonInput: useStreamJson });
const cliArgs = buildCliArgs(cliModel, systemPrompt);
const env = { ...process.env };
delete env.CLAUDECODE;
@@ -1429,13 +1344,12 @@ 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 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);
// Write prompt to stdin immediately
proc.stdin.write(prompt);
proc.stdin.end();
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" });
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" });
// Single request timeout — no separate first-byte timer.
// Claude tool-use causes long pauses in the token stream (30s-5min),
@@ -1505,7 +1419,7 @@ async function callClaude(model, messages, conversationId, keyName, res) {
const { proc, cliModel, conversationId: convId, t0, cleanup, handleSessionFailure, markFirstByte } = ctx;
let lineBuffer = "";
let assembledText = "";
let sawTextDelta = false;
let isFirstDelta = true;
let resultEventSeen = false;
let stderr = "";
@@ -1515,18 +1429,11 @@ async function callClaude(model, messages, conversationId, keyName, res) {
const { events, remainder } = parseStreamJsonLines(lineBuffer);
lineBuffer = remainder;
for (const event of events) {
const parsed = parseStreamJsonEvent(event, sawTextDelta);
const parsed = parseStreamJsonEvent(event, isFirstDelta);
if (!parsed) continue;
if (parsed.text !== undefined) {
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;
}
isFirstDelta = false;
} else if (parsed.stop) {
resultEventSeen = true;
} else if (parsed.error) {
@@ -1948,10 +1855,9 @@ 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 sawTextDelta = false;
let isFirstDelta = true;
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
@@ -1988,26 +1894,15 @@ async function callClaudeStreaming(model, messages, conversationId, res, authInf
lineBuffer = remainder;
for (const event of events) {
const parsed = parseStreamJsonEvent(event, sawTextDelta);
const parsed = parseStreamJsonEvent(event, isFirstDelta);
if (!parsed) continue;
if (parsed.text !== undefined) {
// 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");
// content_block_delta text — forward as SSE delta
const text = parsed.text;
totalChars += text.length;
if (CACHE_TTL > 0) cachedContent += text;
isFirstDelta = false;
if (!ensureHeaders()) continue;
sendSSE(res, {
@@ -2177,31 +2072,6 @@ 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).
@@ -2703,58 +2573,18 @@ async function handleSettings(req, res) {
}
// ── Handle chat completions ─────────────────────────────────────────────
// 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`;
const MAX_BODY_SIZE = 5 * 1024 * 1024; // 5 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 ${MAX_BODY_SIZE_LABEL})`, type: "invalid_request_error" } });
return jsonResponse(res, 413, { error: { message: "Request body too large (max 5MB)", type: "invalid_request_error" } });
}
}
} catch (e) {
@@ -2783,57 +2613,6 @@ 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
@@ -2855,74 +2634,6 @@ 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;
+28 -682
View File
@@ -844,49 +844,7 @@ test("doctor falls back to currentVersion when origin/main unreachable (no stale
// contract lives in lib/prompt.mjs. Mutation-proof: make appendOperatorPrompt
// return `base` unconditionally and the first test fails; make it stop trimming
// and the whitespace test fails.
import { appendOperatorPrompt, derivePromptCharBudget, resolvePromptCharBudget } from "./lib/prompt.mjs";
console.log("\nPrompt-char budget (ADR 0009 — SPOT-derived):");
// Mutation-proof: drop the ×charsPerToken and the first test fails; drop the
// Math.max floor guard and the floor tests fail; use min() instead of max() over
// windows and the largest-window test fails.
test("derivePromptCharBudget: LARGEST contextWindow × 3 chars/token", () => {
const models = [{ contextWindow: 200000 }, { contextWindow: 100000 }];
assert.equal(derivePromptCharBudget(models), 600000);
});
test("derivePromptCharBudget: matches the live models.json SPOT (200k → 600k today)", () => {
const spot = JSON.parse(tuiReadFileSync(new URL("./models.json", import.meta.url), "utf8"));
assert.equal(derivePromptCharBudget(spot.models), 600000);
});
test("derivePromptCharBudget: floor wins over a tiny/absent window; empty input → floor", () => {
assert.equal(derivePromptCharBudget([{ contextWindow: 1000 }]), 150000, "3k chars would truncate everything — floor guards it");
assert.equal(derivePromptCharBudget([]), 150000);
assert.equal(derivePromptCharBudget(undefined), 150000);
assert.equal(derivePromptCharBudget([{ id: "x" }, { contextWindow: "junk" }, { contextWindow: -5 }]), 150000);
});
test("derivePromptCharBudget: charsPerToken and floor are tunable parameters", () => {
assert.equal(derivePromptCharBudget([{ contextWindow: 1000000 }], { charsPerToken: 3 }), 3000000);
assert.equal(derivePromptCharBudget([], { floor: 42 }), 42);
});
// PR #179 review regression: EMPTY env value must mean "use the default" (the old
// `parseInt(env || "150000")` contract). Mutation-proof: switch the resolver's
// truthiness check to `!= null` and the empty-string test fails (NaN ≠ 600000).
test("resolvePromptCharBudget: empty/unset env → SPOT-derived default, never NaN", () => {
const models = [{ contextWindow: 200000 }];
assert.equal(resolvePromptCharBudget("", models), 600000, "CLAUDE_MAX_PROMPT_CHARS= (empty) must fall back to derived");
assert.equal(resolvePromptCharBudget(undefined, models), 600000);
});
test("resolvePromptCharBudget: a set env value overrides the derivation absolutely", () => {
const models = [{ contextWindow: 200000 }];
assert.equal(resolvePromptCharBudget("300000", models), 300000);
assert.equal(resolvePromptCharBudget("150000", models), 150000, "explicit legacy value wins over the bigger derived default");
});
import { appendOperatorPrompt } from "./lib/prompt.mjs";
console.log("\nSystem-prompt operator append:");
@@ -1355,7 +1313,7 @@ function parseStreamJsonLines(buffered) {
return { events, remainder: remainder ?? "" };
}
function parseStreamJsonEvent(event, sawTextDelta) {
function parseStreamJsonEvent(event, isFirstDelta) {
const t = event?.type;
// system/* — first-event init + other system meta (api_retry etc.)
@@ -1367,19 +1325,19 @@ function parseStreamJsonEvent(event, sawTextDelta) {
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 ?? "", fromDelta: true };
return { text: inner.delta.text ?? "" };
}
// Other stream_event sub-types (content_block_start, message_delta, etc.) — consumed
return null;
}
// assistant — aggregate message. Without --include-partial-messages each assistant message
// arrives as its own aggregate event; an agentic turn emits several (preamble + tool rounds +
// final answer), so accumulate EVERY one. Only guard the delta+aggregate double-count case:
// if streaming deltas were already seen (sawTextDelta), the aggregate duplicates them.
// assistant — aggregate message (fallback when no prior content_block_delta seen)
// Empirically (claude CLI without --include-partial-messages, verified v2.1.104 through v2.1.158): fast/short
// responses may emit ONLY the aggregate assistant event, no content_block_delta events.
// If isFirstDelta is true, extract text here; otherwise it's a duplicate, ignore.
// Reference: OLP commit 65f945c (assistant-aggregate fallback, fold-in).
if (t === "assistant") {
if (!sawTextDelta) {
if (isFirstDelta) {
const blocks = event.message?.content;
if (Array.isArray(blocks)) {
const text = blocks
@@ -1428,25 +1386,25 @@ test("parseStreamJsonEvent: stream_event content_block_delta yields text", () =>
type: "stream_event",
event: { type: "content_block_delta", delta: { type: "text_delta", text: "Hello" } }
};
const result = parseStreamJsonEvent(event, false);
assert.deepEqual(result, { text: "Hello", fromDelta: true });
const result = parseStreamJsonEvent(event, true);
assert.deepEqual(result, { text: "Hello" });
});
test("parseStreamJsonEvent: assistant-aggregate used when no delta seen (sawTextDelta=false)", () => {
const event = {
type: "assistant",
message: { content: [{ type: "text", text: "Short answer." }] }
};
const result = parseStreamJsonEvent(event, false);
assert.deepEqual(result, { text: "Short answer." });
});
test("parseStreamJsonEvent: assistant-aggregate skipped when a delta was seen (sawTextDelta=true, no double-count)", () => {
test("parseStreamJsonEvent: assistant-aggregate used when isFirstDelta=true (no prior delta)", () => {
const event = {
type: "assistant",
message: { content: [{ type: "text", text: "Short answer." }] }
};
const result = parseStreamJsonEvent(event, true);
assert.deepEqual(result, { text: "Short answer." });
});
test("parseStreamJsonEvent: assistant-aggregate skipped when isFirstDelta=false (no double-count)", () => {
const event = {
type: "assistant",
message: { content: [{ type: "text", text: "Short answer." }] }
};
const result = parseStreamJsonEvent(event, false);
assert.equal(result, null);
});
@@ -1460,39 +1418,14 @@ test("parseStreamJsonEvent: stream_event + assistant → assembled without doubl
type: "assistant",
message: { content: [{ type: "text", text: "Streaming text." }] }
};
// First event: no delta seen yet → yields text and marks fromDelta
const r1 = parseStreamJsonEvent(delta, false);
assert.deepEqual(r1, { text: "Streaming text.", fromDelta: true });
// Second event (aggregate): a delta was seen (sawTextDelta=true) → duplicate, null
const r2 = parseStreamJsonEvent(agg, true);
// First event: isFirstDelta=true → yields text
const r1 = parseStreamJsonEvent(delta, true);
assert.deepEqual(r1, { text: "Streaming text." });
// Second event (aggregate): isFirstDelta is now false (content already emitted) → null
const r2 = parseStreamJsonEvent(agg, false);
assert.equal(r2, null);
});
// REGRESSION (agentic turns): without --include-partial-messages a tool-using turn emits SEVERAL
// aggregate `assistant` events (preamble, then the final answer after tool use) and NO deltas.
// Every one must be captured — the old first-only guard dropped the final answer.
test("parseStreamJsonEvent: multi-message agentic turn captures preamble AND final answer", () => {
const preamble = {
type: "assistant",
message: { content: [
{ type: "text", text: "I'll find the homepage repo and remove the calendar." },
{ type: "tool_use", id: "t1", name: "Bash" },
] }
};
const toolResult = { type: "user", message: { content: [{ type: "tool_result", content: "ok" }] } };
const finalMsg = {
type: "assistant",
message: { content: [{ type: "text", text: "Done — removed the calendar widget and pushed." }] }
};
// No deltas are ever emitted in aggregate mode, so sawTextDelta stays false throughout.
const r1 = parseStreamJsonEvent(preamble, false);
assert.deepEqual(r1, { text: "I'll find the homepage repo and remove the calendar." });
const r2 = parseStreamJsonEvent(toolResult, false); // user/tool_result echo — consumed
assert.equal(r2, null);
const r3 = parseStreamJsonEvent(finalMsg, false); // <- old code returned null here (bug)
assert.deepEqual(r3, { text: "Done — removed the calendar widget and pushed." });
});
// (b) aggregate-only short response → assembles correctly
test("parseStreamJsonEvent: aggregate-only multi-block response assembles all text blocks", () => {
const event = {
@@ -1505,7 +1438,7 @@ test("parseStreamJsonEvent: aggregate-only multi-block response assembles all te
]
}
};
const result = parseStreamJsonEvent(event, false);
const result = parseStreamJsonEvent(event, true);
assert.deepEqual(result, { text: "Part one. Part two." });
});
@@ -1523,8 +1456,8 @@ test("parseStreamJsonLines: partial line carried as remainder", () => {
assert.equal(ev2[0].type, "stream_event");
assert.equal(rem2, "");
// Verify the reassembled event parses through parseStreamJsonEvent correctly
const parsed = parseStreamJsonEvent(ev2[0], false);
assert.deepEqual(parsed, { text: "Hi", fromDelta: true });
const parsed = parseStreamJsonEvent(ev2[0], true);
assert.deepEqual(parsed, { text: "Hi" });
});
test("parseStreamJsonLines: empty input returns no events and empty remainder", () => {
@@ -3438,337 +3371,6 @@ test("contentToText: null returns empty string", () => {
assert.equal(contentToText(null), "");
});
// ── multimodal image transform (issue #110) ──────────────────────────────────
// OpenAI image_url parts → Anthropic image blocks for `claude -p --input-format
// stream-json`. lib/multimodal.mjs is a PURE module (no server.listen()), so it is
// imported directly here. Class B.1: shape per OpenAI vision spec, authorized by
// ADR 0006. Mechanism verified live: a base64 PNG fed as an Anthropic image block
// via --input-format stream-json is correctly described by the model.
import {
hasImageContent as mmHasImageContent,
buildImageBlocks as mmBuildImageBlocks,
buildStreamJsonInput as mmBuildStreamJsonInput,
MultimodalError as MmError,
SUPPORTED_IMAGE_TYPES as MM_SUPPORTED,
} from "./lib/multimodal.mjs";
import { parsePositiveInt } from "./lib/env.mjs";
console.log("\nmultimodal image transform (issue #110):");
// A short, valid base64 string (charset-valid; not decoded by the transform).
const MM_B64 = "iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAAAAAA6fptVAAAACklEQVR4nGP4DwABAQEAG7buVgAAAABJRU5ErkJggg==";
const dataUri = (mt = "image/png") => `data:${mt};base64,${MM_B64}`;
const imgPart = (mt) => ({ type: "image_url", image_url: { url: dataUri(mt) } });
const txtPart = (t) => ({ type: "text", text: t });
test("hasImageContent: plain string message → false (text path preserved)", () => {
assert.equal(mmHasImageContent([{ role: "user", content: "hello" }]), false);
});
test("hasImageContent: array of text-only parts → false", () => {
assert.equal(mmHasImageContent([{ role: "user", content: [txtPart("a"), txtPart("b")] }]), false);
});
test("hasImageContent: message with an image_url part → true", () => {
assert.equal(mmHasImageContent([{ role: "user", content: [txtPart("q"), imgPart()] }]), true);
});
test("hasImageContent: image anywhere in history (not just last) → true", () => {
const msgs = [
{ role: "user", content: [txtPart("look"), imgPart()] },
{ role: "assistant", content: "ok" },
{ role: "user", content: "and now?" },
];
assert.equal(mmHasImageContent(msgs), true);
});
// ── PR #154 review round 2, gap (b): image ONLY in a system message must not silently drop ──
// The handler detects multimodal on the FULL list but extraction/spawn filter system messages out.
// The guard fires exactly when the full list has an image but the non-system list does not — proven
// here against the same predicate the guard uses, so a system-only image is rejected (400) rather
// than falling to the text path and returning a 200 hallucinated answer.
test("hasImageContent: image ONLY in a system message → true on full list, false after system filter (guard fires)", () => {
const msgs = [
{ role: "system", content: [txtPart("context"), imgPart()] },
{ role: "user", content: "describe it" },
];
assert.equal(mmHasImageContent(msgs), true, "detected as multimodal on the full list");
assert.equal(mmHasImageContent(msgs.filter(m => m.role !== "system")), false, "no image survives the system filter → guard must 400");
});
test("hasImageContent: image in a USER message survives the system filter (legitimate request not rejected)", () => {
const msgs = [
{ role: "system", content: "you are helpful" },
{ role: "user", content: [txtPart("describe it"), imgPart()] },
];
assert.equal(mmHasImageContent(msgs.filter(m => m.role !== "system")), true, "user image survives → normal multimodal path");
});
test("buildImageBlocks: data-URI parsed into an Anthropic base64 image block", () => {
const { blocks, stats } = mmBuildImageBlocks([{ role: "user", content: [txtPart("what is this?"), imgPart("image/png")] }]);
assert.equal(blocks.length, 2);
assert.equal(blocks[0].type, "text");
assert.equal(blocks[0].text, "what is this?");
assert.deepEqual(blocks[1], { type: "image", source: { type: "base64", media_type: "image/png", data: MM_B64 } });
assert.equal(stats.imageCount, 1);
assert.ok(stats.totalImageBytes > 0);
});
test("buildImageBlocks: media_type carried through (jpeg/gif/webp)", () => {
for (const mt of ["image/jpeg", "image/gif", "image/webp"]) {
const { blocks } = mmBuildImageBlocks([{ role: "user", content: [imgPart(mt)] }]);
assert.equal(blocks.find(b => b.type === "image").source.media_type, mt);
}
});
test("buildImageBlocks: multiple images in one message both emitted", () => {
const { blocks, stats } = mmBuildImageBlocks([{ role: "user", content: [txtPart("compare"), imgPart(), imgPart()] }]);
const imgs = blocks.filter(b => b.type === "image");
assert.equal(imgs.length, 2);
assert.equal(stats.imageCount, 2);
});
test("buildImageBlocks: text/image/text ordering preserved", () => {
const { blocks } = mmBuildImageBlocks([{ role: "user", content: [txtPart("A"), imgPart(), txtPart("B")] }]);
assert.deepEqual(blocks.map(b => (b.type === "text" ? b.text : "IMG")), ["A", "IMG", "B"]);
});
test("buildImageBlocks: image-first message keeps ordering (image before text)", () => {
const { blocks } = mmBuildImageBlocks([{ role: "user", content: [imgPart(), txtPart("caption")] }]);
assert.deepEqual(blocks.map(b => (b.type === "text" ? b.text : "IMG")), ["IMG", "caption"]);
});
test("buildImageBlocks: multi-turn history — role prefixes + separators preserved", () => {
const msgs = [
{ role: "user", content: "first q" },
{ role: "assistant", content: "prior answer" },
{ role: "user", content: [txtPart("now this"), imgPart()] },
];
const { blocks } = mmBuildImageBlocks(msgs);
assert.equal(blocks[0].text, "first q");
assert.equal(blocks[1].text, "\n\n[Assistant] prior answer");
assert.equal(blocks[2].text, "\n\nnow this");
assert.equal(blocks[3].type, "image");
});
test("buildImageBlocks: image in an EARLIER turn is carried (history image)", () => {
const msgs = [
{ role: "user", content: [txtPart("here"), imgPart()] },
{ role: "assistant", content: "got it" },
{ role: "user", content: "thanks" },
];
const { blocks, stats } = mmBuildImageBlocks(msgs);
assert.equal(stats.imageCount, 1);
assert.equal(blocks.filter(b => b.type === "image").length, 1);
});
test("buildImageBlocks: image_url as bare string is accepted (client leniency)", () => {
const { blocks } = mmBuildImageBlocks([{ role: "user", content: [{ type: "image_url", image_url: dataUri() }] }]);
assert.equal(blocks.find(b => b.type === "image").source.data, MM_B64);
});
test("buildStreamJsonInput: emits one newline-terminated user envelope", () => {
const { payload } = mmBuildStreamJsonInput([{ role: "user", content: [txtPart("hi"), imgPart()] }]);
assert.ok(payload.endsWith("\n"));
const env = JSON.parse(payload.trim());
assert.equal(env.type, "user");
assert.equal(env.message.role, "user");
assert.equal(env.message.content[1].type, "image");
});
// ── malformed / policy / oversized handling (clean 4xx, never a silent drop) ──
test("buildImageBlocks: unsupported media type → 400 unsupported_image_type", () => {
assert.throws(
() => mmBuildImageBlocks([{ role: "user", content: [imgPart("image/tiff")] }]),
(e) => e instanceof MmError && e.code === "unsupported_image_type" && e.status === 400
);
});
test("buildImageBlocks: non-base64 data URI → 400 invalid_data_uri", () => {
assert.throws(
() => mmBuildImageBlocks([{ role: "user", content: [{ type: "image_url", image_url: { url: "data:image/png,notbase64" } }] }]),
(e) => e instanceof MmError && e.code === "invalid_data_uri" && e.status === 400
);
});
test("buildImageBlocks: malformed data URI (no comma) → 400 invalid_data_uri", () => {
assert.throws(
() => mmBuildImageBlocks([{ role: "user", content: [{ type: "image_url", image_url: { url: "data:image/png;base64" } }] }]),
(e) => e instanceof MmError && e.code === "invalid_data_uri"
);
});
test("buildImageBlocks: image_url part missing a URL → 400 invalid_image_url", () => {
assert.throws(
() => mmBuildImageBlocks([{ role: "user", content: [{ type: "image_url", image_url: {} }] }]),
(e) => e instanceof MmError && e.code === "invalid_image_url"
);
});
test("buildImageBlocks: oversized single image → 413 image_too_large", () => {
assert.throws(
() => mmBuildImageBlocks([{ role: "user", content: [imgPart()] }], { maxImageBytes: 4 }),
(e) => e instanceof MmError && e.code === "image_too_large" && e.status === 413
);
});
test("buildImageBlocks: too many images → 413 too_many_images", () => {
const many = Array.from({ length: 3 }, () => imgPart());
assert.throws(
() => mmBuildImageBlocks([{ role: "user", content: many }], { maxImages: 2 }),
(e) => e instanceof MmError && e.code === "too_many_images" && e.status === 413
);
});
test("buildImageBlocks: aggregate image bytes over cap → 413 images_too_large", () => {
assert.throws(
() => mmBuildImageBlocks([{ role: "user", content: [imgPart(), imgPart()] }], { maxTotalImageBytes: 100, maxImageBytes: 1000 }),
(e) => e instanceof MmError && e.code === "images_too_large" && e.status === 413
);
});
test("buildImageBlocks: remote http(s) URL disabled by default → 400 remote_url_disabled", () => {
assert.throws(
() => mmBuildImageBlocks([{ role: "user", content: [{ type: "image_url", image_url: { url: "https://example.com/a.png" } }] }]),
(e) => e instanceof MmError && e.code === "remote_url_disabled" && e.status === 400
);
});
test("buildImageBlocks: remote URL passthrough when allowRemoteUrl=true (url source, OCP does not fetch)", () => {
const { blocks } = mmBuildImageBlocks(
[{ role: "user", content: [{ type: "image_url", image_url: { url: "https://example.com/a.png" } }] }],
{ allowRemoteUrl: true }
);
assert.deepEqual(blocks.find(b => b.type === "image").source, { type: "url", url: "https://example.com/a.png" });
});
test("buildImageBlocks: unsupported URL scheme → 400 unsupported_url_scheme", () => {
assert.throws(
() => mmBuildImageBlocks([{ role: "user", content: [{ type: "image_url", image_url: { url: "ftp://x/y.png" } }] }], { allowRemoteUrl: true }),
(e) => e instanceof MmError && e.code === "unsupported_url_scheme"
);
});
test("buildImageBlocks: non-image parts (audio/file) fall back to placeholder text", () => {
const { blocks } = mmBuildImageBlocks([{ role: "user", content: [txtPart("hear this"), { type: "input_audio", input_audio: {} }] }]);
assert.deepEqual(blocks.map(b => b.text), ["hear this", "[non-text content omitted]"]);
});
test("SUPPORTED_IMAGE_TYPES: exactly the four Anthropic vision types", () => {
assert.deepEqual([...MM_SUPPORTED].sort(), ["image/gif", "image/jpeg", "image/png", "image/webp"]);
});
test("buildImageBlocks: pure-text conversation still yields text blocks (untouched-path parity)", () => {
// hasImageContent would be false for this input in server.mjs (text path taken),
// but the transform must still be well-defined for a text-only turn.
const { blocks, stats } = mmBuildImageBlocks([{ role: "user", content: "just text" }]);
assert.deepEqual(blocks, [{ type: "text", text: "just text" }]);
assert.equal(stats.imageCount, 0);
assert.equal(stats.truncated, false);
});
// ── F2 (PR #154 review): text char budget is enforced on the multimodal path ──
// Regression guard: without maxTextChars, attaching one tiny image let unbounded
// text bypass MAX_PROMPT_CHARS entirely (the text path truncates; the image path
// did not). server.mjs passes maxTextChars: MAX_PROMPT_CHARS into this transform.
console.log("\nmultimodal text-budget enforcement (PR #154 F2):");
test("buildImageBlocks: text under budget → not truncated, blocks unchanged", () => {
const { blocks, stats } = mmBuildImageBlocks(
[{ role: "user", content: [txtPart("short"), imgPart()] }],
{ maxTextChars: 1000 }
);
assert.equal(stats.truncated, false);
assert.equal(stats.textChars, "short".length);
assert.equal(blocks.filter(b => b.type === "image").length, 1);
});
test("buildImageBlocks: text over budget → truncated, keeps most-recent tail + note", () => {
const big = "A".repeat(300) + "TAIL_MARKER";
const { blocks, stats } = mmBuildImageBlocks(
[{ role: "user", content: [txtPart(big)] }],
{ maxTextChars: 50 }
);
assert.equal(stats.truncated, true);
assert.equal(stats.originalTextChars, big.length);
// The most recent characters (the tail) survive; the oldest 'A's are dropped.
const joined = blocks.filter(b => b.type === "text").map(b => b.text).join("");
assert.ok(joined.includes("TAIL_MARKER"), "tail text must be kept");
assert.ok(joined.includes("truncated to fit"), "a truncation note must be present");
assert.ok(stats.originalTextChars > stats.textChars, "post-truncation text is smaller");
});
test("buildImageBlocks: F2 exact scenario — 500k chars + one image → text bounded, image preserved", () => {
const { blocks, stats } = mmBuildImageBlocks(
[{ role: "user", content: [txtPart("Z".repeat(500000)), imgPart()] }],
{ maxTextChars: 150000 }
);
assert.equal(stats.truncated, true);
assert.ok(stats.textChars <= 150000 + 200, "text char count is bounded by the budget (+note)");
// The image bypasses the text budget and is NOT dropped by truncation.
assert.equal(blocks.filter(b => b.type === "image").length, 1);
});
test("buildImageBlocks: default (no maxTextChars) never truncates — pure module standalone", () => {
const { stats } = mmBuildImageBlocks([{ role: "user", content: [txtPart("x".repeat(10000))] }]);
assert.equal(stats.truncated, false);
assert.equal(stats.textChars, 10000);
});
// ── F3 (PR #154 review): fail-closed positive-int env parsing ────────────────
// A misconfigured numeric cap must NEVER silently disable a guard (`x > NaN` is
// always false) or brick the proxy with a nonsense value. parsePositiveInt keeps
// the default and reports ok:false so the caller can warn.
console.log("\nfail-closed env-cap parsing (PR #154 F3):");
test("parsePositiveInt: missing/empty → default, ok", () => {
assert.deepEqual(parsePositiveInt(undefined, 42), { value: 42, ok: true });
assert.deepEqual(parsePositiveInt("", 42), { value: 42, ok: true });
});
test("parsePositiveInt: valid positive integer → parsed value", () => {
assert.equal(parsePositiveInt("5000000", 42).value, 5000000);
assert.equal(parsePositiveInt("5000000", 42).ok, true);
});
test("parsePositiveInt: 'unlimited' → NaN rejected, default kept (would drop the cap)", () => {
const r = parsePositiveInt("unlimited", 5 * 1024 * 1024);
assert.equal(r.value, 5 * 1024 * 1024);
assert.equal(r.ok, false);
});
test("parsePositiveInt: '5MB' → unit suffix rejected (naive parseInt would give 5 bytes)", () => {
const r = parsePositiveInt("5MB", 5 * 1024 * 1024);
assert.equal(r.value, 5 * 1024 * 1024);
assert.equal(r.ok, false);
});
test("parsePositiveInt: '0' and '-1' → non-positive rejected", () => {
assert.equal(parsePositiveInt("0", 20).ok, false);
assert.equal(parsePositiveInt("0", 20).value, 20);
assert.equal(parsePositiveInt("-1", 20).ok, false);
});
test("parsePositiveInt: '20.5' → fractional/ambiguous rejected", () => {
assert.equal(parsePositiveInt("20.5", 20).ok, false);
});
test("parsePositiveInt: surrounding whitespace tolerated", () => {
assert.deepEqual(parsePositiveInt(" 20 ", 5), { value: 20, ok: true });
});
// ── PR #154 review round 2, gap (a): MAX_PROMPT_CHARS must fail closed like the other caps ──
// server.mjs now derives MAX_PROMPT_CHARS via parseIntEnv → parsePositiveInt (was a raw parseInt).
// CLAUDE_MAX_PROMPT_CHARS=unlimited previously → NaN → enforceTextBudget's `!(NaN > 0)` early-return
// → 500k chars passed unbounded, defeating F2's text-budget guarantee. The default must be kept.
test("parsePositiveInt: CLAUDE_MAX_PROMPT_CHARS='unlimited' → default kept, cap not lost to NaN (gap a)", () => {
const r = parsePositiveInt("unlimited", 150000);
assert.equal(r.ok, false);
assert.equal(r.value, 150000, "the 150k text budget must survive a bad config, not become NaN");
});
test("parsePositiveInt: CLAUDE_MAX_PROMPT_CHARS valid override honored", () => {
assert.deepEqual(parsePositiveInt("200000", 150000), { value: 200000, ok: true });
});
// ── messages guard predicate truth-table (issue #110) ────────────────────────
// Mirrors the guard at server.mjs line ~1650: Array.isArray(x) && x.length > 0
console.log("\nmessages guard predicate (issue #110):");
@@ -4355,262 +3957,6 @@ test("stream: /health block is additive and exposes the divergence counter", ()
assert.equal(legacy.streamDivergences, 0);
});
// ── OpenAI Structured Outputs (response_format) — lib/structured-output.mjs ──
import { detectStructuredOutput, validateJsonSchema, validateJsonSchemaSafe, extractJsonPayload, structuredSystemInstruction, StructuredOutputError, resolveMaxAttempts } from "./lib/structured-output.mjs";
test("detectStructuredOutput: json_schema shape", () => {
const d = detectStructuredOutput({ response_format: { type: "json_schema", json_schema: { name: "x", strict: true, schema: { type: "object" } } } });
assert.equal(d.mode, "schema"); assert.equal(d.strict, true); assert.deepEqual(d.schema, { type: "object" });
});
test("detectStructuredOutput: json_object shape", () => {
assert.deepEqual(detectStructuredOutput({ response_format: { type: "json_object" } }), { mode: "json_object" });
});
test("detectStructuredOutput: json_mode:true alias → json_object", () => {
assert.deepEqual(detectStructuredOutput({ json_mode: true }), { mode: "json_object" });
});
test("detectStructuredOutput: absent → null (non-structured untouched)", () => {
assert.equal(detectStructuredOutput({ messages: [] }), null);
assert.equal(detectStructuredOutput({ response_format: "nonsense" }), null);
assert.equal(detectStructuredOutput({ json_mode: false }), null);
});
test("cacheHash: structured marker isolates JSON requests from the conversational slot", () => {
const msgs = [{ role: "user", content: "list 3 fruits" }];
const plain = cacheHash("m", msgs, { keyId: "k" });
const asJson = cacheHash("m", msgs, { keyId: "k", structured: { mode: "json_object" } });
const asSchema = cacheHash("m", msgs, { keyId: "k", structured: { mode: "schema", schema: { type: "array" } } });
assert.notEqual(plain, asJson); // JSON vs prose never collide
assert.notEqual(asJson, asSchema); // different schema → different slot
assert.equal(plain, cacheHash("m", msgs, { keyId: "k" })); // unchanged for normal requests
});
// ── validateJsonSchemaSafe (#181): deep value must NOT crash the handler ─────
// A recursive schema + a model reply nested ~thousands deep overflows the value-
// depth recursion → RangeError → the handler used to surface a generic 500. The
// safe façade turns it into a validation miss (→ retry → refusal). Mutation-proof:
// replace the wrapper body with a bare `validateJsonSchema(...)` call and the deep
// test throws instead of returning errors.
test("validateJsonSchemaSafe: pathologically deep value → errors, never throws", () => {
const schema = { $defs: { node: { type: "object", properties: { child: { $ref: "#/$defs/node" } } } }, $ref: "#/$defs/node" };
let deep = {};
let cur = deep;
for (let i = 0; i < 6000; i++) { cur.child = {}; cur = cur.child; } // way past any stack limit
let out;
assert.doesNotThrow(() => { out = validateJsonSchemaSafe(deep, schema, "$", true); }, "must not throw a RangeError out to the handler");
assert.ok(Array.isArray(out) && out.length > 0, "returns a non-empty validation error, so the retry loop yields a refusal not a 500");
});
test("validateJsonSchemaSafe: well-formed value passes through unchanged (byte-identical to the raw validator)", () => {
const schema = { type: "object", required: ["name", "age"], properties: { name: { type: "string" }, age: { type: "integer" } } };
assert.deepEqual(validateJsonSchemaSafe({ name: "a", age: 3 }, schema), validateJsonSchema({ name: "a", age: 3 }, schema));
assert.deepEqual(validateJsonSchemaSafe({ name: "a" }, schema), validateJsonSchema({ name: "a" }, schema)); // error case matches too
});
test("validateJsonSchemaSafe: re-throws a non-RangeError so genuine bugs aren't masked as a validation miss", () => {
// A schema whose `required` is a non-iterable makes the inner validator throw a TypeError — that's
// a real bug, not a deep-value overflow, and must surface (not be swallowed as "did not validate").
assert.throws(() => validateJsonSchemaSafe({ x: 1 }, { type: "object", required: 42 }), (e) => !(e instanceof RangeError));
});
test("validateJsonSchema: valid object passes", () => {
assert.deepEqual(validateJsonSchema({ name: "a", age: 3 }, { type: "object", required: ["name", "age"], properties: { name: { type: "string" }, age: { type: "integer" } } }), []);
});
test("validateJsonSchema: missing required property flagged", () => {
assert.ok(validateJsonSchema({ name: "a" }, { type: "object", required: ["name", "age"], properties: {} }).some(e => /age.*required/.test(e)));
});
test("validateJsonSchema: additionalProperties:false rejects extra keys", () => {
assert.ok(validateJsonSchema({ a: 1, b: 2 }, { type: "object", additionalProperties: false, properties: { a: { type: "integer" } } }).some(e => /b.*additional/.test(e)));
});
test("validateJsonSchema: enum rejects non-null value not in list", () => {
assert.ok(validateJsonSchema("maybe", { type: "string", enum: ["yes", "no"] }).length > 0);
});
test("validateJsonSchema: NULLABLE enum accepts null even when null not in enum (HA regression)", () => {
// type:["string","null"] + enum:["Loxone"] — a null value must be accepted (nullability > enum).
assert.deepEqual(validateJsonSchema(null, { type: ["string", "null"], enum: ["Loxone"] }), []);
});
test("validateJsonSchema: nullable enum still enforces non-null values against the enum", () => {
assert.ok(validateJsonSchema("Other", { type: ["string", "null"], enum: ["Loxone"] }).length > 0);
});
test("validateJsonSchema: type mismatch flagged", () => {
assert.ok(validateJsonSchema("str", { type: "integer" }).length > 0);
});
test("validateJsonSchema: array items + minItems", () => {
assert.deepEqual(validateJsonSchema([1, 2, 3], { type: "array", items: { type: "integer" }, minItems: 3 }), []);
assert.ok(validateJsonSchema([1], { type: "array", items: { type: "integer" }, minItems: 3 }).some(e => /minItems/.test(e)));
});
test("extractJsonPayload: clean JSON", () => {
const r = extractJsonPayload('{"a":1}'); assert.ok(r.ok); assert.deepEqual(r.value, { a: 1 });
});
test("extractJsonPayload: fenced ```json block", () => {
const r = extractJsonPayload('```json\n{"a":1}\n```'); assert.ok(r.ok); assert.deepEqual(r.value, { a: 1 });
});
test("extractJsonPayload: prose-wrapped, string-aware balanced slice", () => {
const r = extractJsonPayload('Sure! Here you go: {"note":"has } and { inside"} — hope that helps.');
assert.ok(r.ok); assert.deepEqual(r.value, { note: "has } and { inside" });
});
test("extractJsonPayload: array payload", () => {
const r = extractJsonPayload('[1,2,3]'); assert.ok(r.ok); assert.deepEqual(r.value, [1, 2, 3]);
});
test("extractJsonPayload: no JSON → ok:false", () => {
assert.equal(extractJsonPayload("I cannot help with that.").ok, false);
});
test("structuredSystemInstruction: embeds schema, forbids fences, escalates on retry", () => {
const first = structuredSystemInstruction({ mode: "schema", schema: { type: "object" } }, 0, "");
assert.ok(/code fences/.test(first) && /JSON Schema/.test(first));
const retry = structuredSystemInstruction({ mode: "schema", schema: { type: "object" } }, 1, "bad enum");
assert.ok(/REJECTED \(bad enum\)/.test(retry));
});
test("StructuredOutputError carries reason", () => {
const e = new StructuredOutputError("schema validation failed", "raw");
assert.equal(e.reason, "schema validation failed"); assert.ok(e instanceof Error);
});
// ── PR #153 review round 2, MUST-FIX: OCP_STRUCTURED_MAX_ATTEMPTS NaN guard must fail closed ──
// The old `Math.max(1, parseInt(env||"3",10))` returned NaN for a non-integer value → the retry loop
// `attempt < NaN` never ran → 0 spawns, every structured request refused. resolveMaxAttempts keeps
// the default instead of silently bricking the feature.
test("resolveMaxAttempts: valid integer honored", () => {
assert.equal(resolveMaxAttempts("5"), 5);
assert.equal(resolveMaxAttempts("1"), 1);
});
test("resolveMaxAttempts: unset/empty → default", () => {
assert.equal(resolveMaxAttempts(undefined), 3);
assert.equal(resolveMaxAttempts(""), 3);
assert.equal(resolveMaxAttempts(null), 3);
});
test("resolveMaxAttempts: non-integer / non-finite / <1 fails CLOSED to the default (not NaN, not 0)", () => {
let warned = 0; const warn = () => { warned++; };
for (const bad of ["abc", "0", "-1", "NaN", "Infinity", " "]) {
const v = resolveMaxAttempts(bad, { fallback: 3, warn });
assert.equal(v, 3, `bad input ${JSON.stringify(bad)} must fall back to 3, got ${v}`);
assert.ok(Number.isFinite(v) && v >= 1, "result is always a usable positive integer");
}
assert.ok(warned > 0, "invalid values emit a startup warning");
});
test("resolveMaxAttempts: the retry loop is never bounded by NaN (regression: 0 spawns / silent refuse)", () => {
const attempts = resolveMaxAttempts("abc");
let ran = 0;
for (let attempt = 0; attempt < attempts; attempt++) ran++;
assert.ok(ran >= 1, "loop must execute at least once — pre-fix it ran 0 times");
});
// ── PR #153 review finding 1: $ref/$defs + strict:true must accept conforming objects ──
// The flagship shape the OpenAI SDK emits (zodResponseFormat / client.beta.chat.completions.parse)
// and OpenAI's own structured-outputs docs example: nested {$ref:"#/$defs/step"} + strict:true.
// Before the fix, strict inferred additionalProperties:false on the unresolved $ref (empty props) and
// rejected every real key. This is the exact regression the PR must not ship.
const OPENAI_DOC_SCHEMA = {
type: "object",
properties: {
steps: { type: "array", items: { $ref: "#/$defs/step" } },
final_answer: { type: "string" },
},
$defs: {
step: {
type: "object",
properties: { explanation: { type: "string" }, output: { type: "string" } },
required: ["explanation", "output"],
additionalProperties: false,
},
},
required: ["steps", "final_answer"],
additionalProperties: false,
};
test("validateJsonSchema: OpenAI doc schema ($ref/$defs) + strict:true accepts a conforming reply", () => {
const conforming = { steps: [{ explanation: "add", output: "4" }, { explanation: "done", output: "4" }], final_answer: "4" };
assert.deepEqual(validateJsonSchema(conforming, OPENAI_DOC_SCHEMA, "$", true), []);
});
test("validateJsonSchema: $ref + strict:true still REJECTS a genuinely-extra key (fix didn't disable validation)", () => {
const extra = { steps: [{ explanation: "add", output: "4", bogus: 1 }], final_answer: "4" };
const errs = validateJsonSchema(extra, OPENAI_DOC_SCHEMA, "$", true);
assert.ok(errs.some(e => /bogus.*additional property not allowed/.test(e)), `expected the extra key rejected, got: ${JSON.stringify(errs)}`);
});
test("validateJsonSchema: $ref + strict:true still catches a missing required property", () => {
const missing = { steps: [{ explanation: "add" }], final_answer: "4" };
assert.ok(validateJsonSchema(missing, OPENAI_DOC_SCHEMA, "$", true).some(e => /output.*required/.test(e)));
});
test("validateJsonSchema: anyOf accepts a value matching one branch, rejects a value matching none", () => {
const schema = { anyOf: [{ type: "string" }, { type: "integer" }] };
assert.deepEqual(validateJsonSchema("hi", schema), []);
assert.deepEqual(validateJsonSchema(3, schema), []);
assert.ok(validateJsonSchema(true, schema).length > 0);
});
test("validateJsonSchema: allOf requires every branch to pass", () => {
const schema = { allOf: [{ type: "object", properties: { a: { type: "integer" } }, required: ["a"] }, { type: "object", properties: { b: { type: "string" } }, required: ["b"] }] };
assert.deepEqual(validateJsonSchema({ a: 1, b: "x" }, schema), []);
assert.ok(validateJsonSchema({ a: 1 }, schema).some(e => /b.*required/.test(e)));
});
test("validateJsonSchema: unresolvable $ref is skipped, not failed", () => {
assert.deepEqual(validateJsonSchema({ anything: 1 }, { $ref: "#/$defs/missing" }), []);
});
// ── PR #153 review round 2, BLOCKER: cyclic $ref must fail closed, not stack-overflow ──
// A pure ref→ref cycle recurses independent of the data — before the fix ANY reply value (even `5`)
// threw `RangeError: Maximum call stack size exceeded`, caught upstream as a 500 but only after
// 13 metered spawns → a request-controlled cost-amplification / grief vector on an authed path.
test("validateJsonSchema: a→b→a cyclic $ref fails closed (no stack overflow) for any value", () => {
const schema = { $defs: { a: { $ref: "#/$defs/b" }, b: { $ref: "#/$defs/a" } }, $ref: "#/$defs/a" };
let errs;
assert.doesNotThrow(() => { errs = validateJsonSchema(5, schema, "$", true); }, "cyclic $ref must not overflow the stack");
assert.ok(errs.some(e => /cyclic \$ref/.test(e)), `expected a cyclic-$ref error, got: ${JSON.stringify(errs)}`);
});
test("validateJsonSchema: self-referential $ref (a→a) fails closed", () => {
const schema = { $defs: { a: { $ref: "#/$defs/a" } }, $ref: "#/$defs/a" };
let errs;
assert.doesNotThrow(() => { errs = validateJsonSchema({ x: 1 }, schema, "$", true); });
assert.ok(errs.some(e => /cyclic \$ref/.test(e)));
});
test("validateJsonSchema: cycle routed through anyOf fails closed", () => {
const schema = { $defs: { a: { anyOf: [{ $ref: "#/$defs/a" }] } }, $ref: "#/$defs/a" };
assert.doesNotThrow(() => validateJsonSchema({ x: 1 }, schema, "$", true));
});
test("validateJsonSchema: a LEGITIMATE recursive schema (Node→child:Node) is NOT flagged as a cycle", () => {
// Data is a finite tree, so data-consuming recursion terminates — the cycle guard must not
// false-positive here (refChain resets across properties/items).
const schema = {
$defs: { node: { type: "object", properties: { v: { type: "integer" }, child: { $ref: "#/$defs/node" } }, required: ["v"], additionalProperties: false } },
$ref: "#/$defs/node",
};
const tree = { v: 1, child: { v: 2, child: { v: 3 } } };
assert.deepEqual(validateJsonSchema(tree, schema, "$", true), []);
});
// ── PR #153 review finding 2: never serve an unvalidated / ambiguous extraction ──
test("extractJsonPayload: json_object mode rejects a refusal that merely CONTAINS json", () => {
const reply = 'I can\'t do that. For reference the schema looks like {"type":"object"} — sorry.';
const r = extractJsonPayload(reply, { whole: true });
assert.equal(r.ok, false);
});
test("extractJsonPayload: json_object mode accepts a whole-reply JSON value", () => {
const r = extractJsonPayload(' {"temp":21} ', { whole: true });
assert.ok(r.ok); assert.deepEqual(r.value, { temp: 21 });
});
test("extractJsonPayload: schema mode rejects >1 top-level JSON value (Schema:{} Answer:{})", () => {
const reply = 'Schema: {"type":"object"}\n\nAnswer: {"temp":21}';
const r = extractJsonPayload(reply);
assert.equal(r.ok, false);
assert.ok(/more than one/.test(r.reason || ""));
});
test("extractJsonPayload: schema mode rejects two competing options rather than silently picking one", () => {
const r = extractJsonPayload('Option A:\n{"a":1}\nOption B:\n{"b":2}');
assert.equal(r.ok, false);
});
test("extractJsonPayload: single prose-wrapped value still accepted in schema mode", () => {
const r = extractJsonPayload('Sure, here you go: {"a":1} — done.');
assert.ok(r.ok); assert.deepEqual(r.value, { a: 1 });
});
// ── Cleanup ──
// Settle the async-bodied tests registered through the sync `test()` helper BEFORE summarizing —
// otherwise their pass/fail is not reflected in the counts (see the `pendingAsync` comment above).