Compare commits

...
Author SHA1 Message Date
taodengandClaude <claude-opus-4-8> <noreply@anthropic.com> 9df622c7f8 fix(structured): narrow validateJsonSchemaSafe to RangeError-only + drop unused import (review fold-in)
Reviewer of #184: the catch-all would silently mask a future genuine bug (e.g. a
TypeError from a malformed schema) as a validation miss. Narrowed to
`if (e instanceof RangeError) return [...]; throw e;` so only the #181 deep-nesting
overflow becomes a refusal; any other throw surfaces at error level as before. +1
test proving a non-RangeError (required:42 → TypeError) re-throws. Dropped the now-
unused raw `validateJsonSchema` import from server.mjs. Merged current main (incl.
#183) so CI runs the true post-merge tree. Suite 433/0.

Co-Authored-By: Claude <claude-opus-4-8> <noreply@anthropic.com>
2026-07-21 20:04:57 +10:00
taodeng 2ad8a5245d Merge remote-tracking branch 'origin/main' into fix/structured-data-depth-cap 2026-07-21 20:03:34 +10:00
47e324b68f fix(server): assemble every assistant message so agentic turns return the final answer (#183)
* fix(server): assemble every assistant message so agentic turns return the final answer

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

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

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

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

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

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

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

---------

Co-authored-by: vvlasy-openclaw <vvlasy@gmail.com>
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Co-authored-by: dtzp555 <dtzp555@gmail.com>
2026-07-21 19:58:09 +10:00
taodengandClaude <claude-opus-4-8> <noreply@anthropic.com> 4143993e3b fix(structured): crash-safe validation façade — deep model reply → refusal, not a 500 (closes #181)
#153's cyclic-$ref guard caps the REF-chain depth but not the DATA depth:
validateJsonSchema recurses on the value's nesting (properties/items/additionalProps),
so a model reply nested ~2000+ levels overflowed the stack with a RangeError, which
handleChatCompletions caught as a generic HTTP 500 instead of the spec-correct refusal.
(Found in the #153 final review, filed as #181; ≤1 spawn, no crash, no client-only
trigger — the value always comes from the model reply.)

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

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

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

Closes #181

Co-Authored-By: Claude <claude-opus-4-8> <noreply@anthropic.com>
2026-07-21 19:53:34 +10:00
788cbbcd99 feat(server): honor OpenAI response_format for structured-output clients (#153)
* feat(server): honor OpenAI response_format for structured-output clients

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

---------

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

---------

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

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

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

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

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

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

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

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

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

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

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

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

---------

Co-authored-by: dtzp555 <dtzp555@gmail.com>
Co-authored-by: Claude <claude-opus-4-8> <noreply@anthropic.com>
2026-07-18 10:11:38 +10:00
10 changed files with 1846 additions and 67 deletions
+116 -2
View File
@@ -27,7 +27,7 @@ One proxy. Multiple IDEs. All models. **$0 API cost.**
- [How It Works](#how-it-works) - [How It Works](#how-it-works)
- Reference: [Available Models](#available-models) · [API Endpoints](#api-endpoints) · [Environment Variables](#environment-variables) - 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) - 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) · [OpenClaw Integration](#openclaw-integration) - [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)
- [Troubleshooting](#troubleshooting) → [`docs/troubleshooting.md`](docs/troubleshooting.md) - [Troubleshooting](#troubleshooting) → [`docs/troubleshooting.md`](docs/troubleshooting.md)
- [Repository Layout](#repository-layout) · [Security](#security) · [Governance](#governance) · [Support OCP](#support-ocp) · [License](#license) - [Repository Layout](#repository-layout) · [Security](#security) · [Governance](#governance) · [Support OCP](#support-ocp) · [License](#license)
@@ -222,12 +222,18 @@ 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_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_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_QUEUE_RETRY_AFTER` | `5` | Seconds advertised in the `Retry-After` header on a `-p` concurrency-overflow `429`. |
| `CLAUDE_MAX_PROMPT_CHARS` | `150000` | Prompt truncation limit (chars) | | `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_SESSION_TTL` | `3600000` | Session expiry (ms, default: 1 hour) | | `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_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_ALLOWED_TOOLS` | `Bash,Read,...,Agent` | Comma-separated tools to pre-approve |
| `CLAUDE_SKIP_PERMISSIONS` | `false` | Bypass all permission checks | | `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_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: changing this value and restarting auto-invalidates the response cache (the key carries a boot-config epoch, #177). |
| `CLAUDE_NO_CONTEXT` | `false` | Suppress CLAUDE.md and auto-memory injection (pure API mode) | | `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_API_KEY` | *(unset)* | Bearer token for shared-mode authentication |
@@ -382,6 +388,114 @@ 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. 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 ## OpenClaw Integration
OCP was originally built for [OpenClaw](https://github.com/openclaw/openclaw) and includes deep integration: OCP was originally built for [OpenClaw](https://github.com/openclaw/openclaw) and includes deep integration:
@@ -0,0 +1,54 @@
# ADR 0009 — Prompt-char budget derives from the models.json SPOT
Date: 2026-07-18
Status: Accepted (maintainer directive, 2026-07-18: "37.5k 截断未免太短了吧 … 这个在现在还适用吗")
## Context
`MAX_PROMPT_CHARS` (the tail-first truncation guard in `messagesToPrompt`) defaulted to a
hand-set constant of 150,000 chars ≈ 37.5k English tokens — set in the 200k-window era as a
runaway-context guard. Meanwhile `models.json` advertises `contextWindow: 200000` for every
model (and the underlying CLI registry carries 1M native windows for Opus 4.8 / Sonnet 5), and
`scripts/sync-openclaw.mjs` feeds that 200k into OpenClaw's compaction budget. The result was
a standing dishonesty identified in the PR #152 review: **no advertised contextWindow value was
true**, because the proxy silently guillotined every request at ~37.5k tokens — roughly 5×
below the advertised window — logging only a server-side warning the client never sees.
Raising the constant to another hand-set number would rot the same way. Following the model's
native 1M directly is also wrong: chars ≠ tokens (CJK runs ~11.5 chars/token vs ~4 for
English, so a 1M-token char cap would let CJK text sail past the model's real window into an
upstream rejection), single near-window requests can consume a large fraction of a 5-hour
subscription quota window, and the TUI paste path is untested at megabyte scale.
## Decision
The default budget **derives from the SPOT** instead of being a constant:
```
MAX_PROMPT_CHARS (default) = max(models.json models[].contextWindow) × 3 chars/token
= 200000 × 3 = 600,000 chars today
```
Implemented as the pure `derivePromptCharBudget(models, {charsPerToken = 3, floor = 150000})`
in `lib/prompt.mjs` (unit-tested; floor guards degenerate SPOT states). The multiplier ×3 is
deliberately conservative: full window for English, and CJK text reaches the model's real
window at roughly the same point the cap fires — so OCP truncates gracefully (tail-first)
instead of the upstream rejecting outright.
`CLAUDE_MAX_PROMPT_CHARS` (env) and the runtime settings API remain **absolute overrides**;
the derivation applies only when neither is set.
## Consequences
- The advertised `contextWindow: 200000` becomes honest: the proxy now actually accepts
prompts of that order (English ≈150200k tokens) before truncating.
- If `models.json` ever advertises a larger window (e.g. 1M for the 1M-native models), the
budget scales automatically — no code change. Whether to advertise 1M is a **separate,
deliberate decision** (quota burn per request, OpenClaw compaction memory, TUI paste
limits) and is explicitly NOT made by this ADR; the current recommendation is to keep
200000 advertised until a real >200k use case appears.
- One-time behavior change: requests between 150k and 600k chars that were previously
truncated now pass through whole — longer TTFT and higher quota consumption for those
requests, by design.
- The truncation mechanism, logging, and the multimodal-path budget threading (PR #154's F2,
pending) are unchanged — only the default value's provenance changed.
+1
View File
@@ -25,6 +25,7 @@ New ADRs increment from the highest existing number. Filenames are
| [0006](0006-openai-shim-scope.md) | OpenAI Shim Scope | The Class A / Class B taxonomy. Class A endpoints (`cli.js`-mirror) keep Rules 15 verbatim; Class B endpoints (OCP-owned compatibility surface — `/v1/chat/completions`, `/v1/models`, admin endpoints) are anchored to OpenAI's spec (B.1) or to an authorizing ADR (B.2). Triggered by PR #99 (external `response_format` honoring). Grandfathers the existing B.2 inventory at v3.16.4. | | [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. | | [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. | | [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 ## When to write a new ADR
+5
View File
@@ -360,6 +360,11 @@ export function cacheHash(model, messages, opts = {}) {
// the persistent cache instead of serving answers composed under the old config. Callers // the persistent cache instead of serving answers composed under the old config. Callers
// that omit it (older paths, tests) hash byte-identically to before. // that omit it (older paths, tests) hash byte-identically to before.
if (opts.configEpoch != null) h.update(`ce:${opts.configEpoch}|`); 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) { for (const m of messages) {
h.update(m.role || ""); h.update(m.role || "");
h.update(typeof m.content === "string" ? m.content : JSON.stringify(m.content)); h.update(typeof m.content === "string" ? m.content : JSON.stringify(m.content));
+29
View File
@@ -0,0 +1,29 @@
// OCP env-var parsing helpers.
//
// Fail-closed positive-integer parsing for numeric caps (body size, image
// byte/count limits). A misconfigured cap must NEVER silently disable a guard:
// `parseInt("unlimited", 10)` is NaN and `x > NaN` is always false, so a naive
// parse of CLAUDE_MAX_BODY_SIZE=unlimited would remove the body-size limit
// entirely (unbounded body → OOM). Likewise CLAUDE_MAX_BODY_SIZE=5MB naively
// parses to 5 (bytes) and bricks the proxy. So a present-but-invalid value is
// REJECTED (default kept, caller warns), not accepted. (PR #154 review F3.)
//
// Pure (no env access, no IO) so it is unit-testable without a live server.
// Parse `raw` as a strictly-positive base-10 integer of bytes/count (no unit
// suffix). Returns { value, ok, reason }:
// - missing/empty → { value: def, ok: true } (use default)
// - valid positive int → { value: n, ok: true }
// - anything else → { value: def, ok: false, reason } (fail closed)
// Rejects: NaN ("unlimited"), non-positive ("0", "-1"), unit-suffixed ("5MB"),
// and fractional/ambiguous ("20.5", "0x10") values — String(n) !== trimmed catches
// any input parseInt only partially consumed.
export function parsePositiveInt(raw, def) {
if (raw === undefined || raw === null || raw === "") return { value: def, ok: true };
const trimmed = String(raw).trim();
const n = parseInt(trimmed, 10);
if (!Number.isFinite(n) || n <= 0 || String(n) !== trimmed) {
return { value: def, ok: false, reason: "not a strictly-positive integer (bytes/count, no unit suffix)" };
}
return { value: n, ok: true };
}
+278
View File
@@ -0,0 +1,278 @@
// OCP multimodal helpers — OpenAI `image_url` content parts → Anthropic image
// blocks fed to `claude -p --input-format stream-json`. (issue #110)
//
// Class B.1 (OpenAI-compatibility surface). Protocol authority is OpenAI's
// chat/completions spec — the multimodal `content` parts shape
// (https://platform.openai.com/docs/guides/vision and
// https://platform.openai.com/docs/api-reference/chat/create#chat-create-messages,
// `image_url` part with `image_url.url` = data URI or http(s) URL). Authorized
// by ADR 0006. This module introduces NO field beyond OpenAI's published shape:
// the OpenAI-side vocabulary read here is `type:"image_url"` +
// `image_url:{url, detail?}`; the Anthropic-side vocabulary written here
// (`type:"image", source:{type:"base64"|"url", ...}`) is the CLI's native
// stream-json input contract, not an OCP invention.
//
// Kept as a pure module (no I/O, no network, no process state) mirroring the
// lib/*.mjs pattern so it is unit-testable without a live server. server.mjs is
// the only consumer; it owns spawning, caps configuration, and HTTP status.
// Anthropic vision-supported image media types. A data URI whose media type is
// outside this set is rejected with a clear 4xx rather than forwarded (the API
// would reject it anyway; failing early gives a better error).
export const SUPPORTED_IMAGE_TYPES = new Set([
"image/jpeg",
"image/png",
"image/gif",
"image/webp",
]);
// Default caps. server.mjs overrides these from env; they live here so the
// pure transform is self-contained and testable.
export const DEFAULT_MULTIMODAL_OPTS = {
allowRemoteUrl: false, // http(s) image URLs are OFF by default (v1: data URIs only)
maxImageBytes: 5 * 1024 * 1024, // per-image decoded-byte cap
maxImages: 20, // max image parts across the whole request
maxTotalImageBytes: 20 * 1024 * 1024, // aggregate decoded-byte cap
maxTextChars: Infinity, // text-char budget (server passes MAX_PROMPT_CHARS); Infinity = no truncation
};
// Typed error so server.mjs can map to the right HTTP status + OpenAI-shaped
// error body. `status` is the HTTP code; `type` is the OpenAI error `type`.
export class MultimodalError extends Error {
constructor(code, status, message) {
super(message);
this.name = "MultimodalError";
this.code = code;
this.status = status;
this.type = "invalid_request_error"; // OpenAI error `type` for 4xx client errors
}
}
// True if any message carries an OpenAI `image_url` content part. Cheap guard so
// the byte-for-byte text path is only left when an image is genuinely present.
export function hasImageContent(messages) {
if (!Array.isArray(messages)) return false;
for (const m of messages) {
if (m && Array.isArray(m.content)) {
for (const part of m.content) {
if (part && part.type === "image_url") return true;
}
}
}
return false;
}
// Extract the URL string from an OpenAI image_url part. Spec form is
// `{type:"image_url", image_url:{url, detail?}}`; many OpenAI-compatible clients
// also send `image_url` as a bare string. Accept both (input leniency — no new
// output field). `detail` (auto|low|high) is OpenAI-only and has no Anthropic
// analogue, so it is read-and-ignored.
function imageUrlOf(part) {
const iu = part.image_url;
if (typeof iu === "string") return iu;
if (iu && typeof iu.url === "string") return iu.url;
return null;
}
// Parse a base64 data URI: `data:[<media_type>][;base64],<data>`.
// Returns { mediaType, data (base64), bytes (decoded size) } or throws MultimodalError.
function parseDataUri(uri) {
const comma = uri.indexOf(",");
if (comma === -1) {
throw new MultimodalError("invalid_data_uri", 400, "Malformed image data URI (no comma).");
}
const meta = uri.slice(5, comma); // strip leading "data:"
const segs = meta.split(";");
const mediaType = (segs[0] || "").trim().toLowerCase();
const isBase64 = segs.slice(1).some((s) => s.trim().toLowerCase() === "base64");
if (!isBase64) {
throw new MultimodalError("invalid_data_uri", 400, "Only base64-encoded image data URIs are supported.");
}
if (!SUPPORTED_IMAGE_TYPES.has(mediaType)) {
throw new MultimodalError(
"unsupported_image_type",
400,
`Unsupported image media type '${mediaType || "(none)"}'. Supported: ${[...SUPPORTED_IMAGE_TYPES].join(", ")}.`
);
}
// Strip incidental whitespace/newlines some encoders insert into data URIs.
const data = uri.slice(comma + 1).replace(/\s/g, "");
if (data.length === 0 || !/^[A-Za-z0-9+/]+={0,2}$/.test(data)) {
throw new MultimodalError("invalid_data_uri", 400, "Image data URI payload is not valid base64.");
}
// Decoded size from base64 length (minus padding); avoids decoding the buffer
// just to measure it.
const padding = data.endsWith("==") ? 2 : data.endsWith("=") ? 1 : 0;
const bytes = Math.floor((data.length * 3) / 4) - padding;
return { mediaType, data, bytes };
}
// Convert a single OpenAI image_url part to an Anthropic image block, enforcing
// caps via the mutable `acc` accumulator ({ images, bytes }). Throws MultimodalError.
function imagePartToBlock(part, opts, acc) {
const url = imageUrlOf(part);
if (!url) {
throw new MultimodalError("invalid_image_url", 400, "image_url part is missing a URL.");
}
acc.images += 1;
if (acc.images > opts.maxImages) {
throw new MultimodalError("too_many_images", 413, `Too many images in request (max ${opts.maxImages}).`);
}
if (url.startsWith("data:")) {
const { mediaType, data, bytes } = parseDataUri(url);
if (bytes > opts.maxImageBytes) {
throw new MultimodalError("image_too_large", 413, `Image exceeds per-image size limit (${opts.maxImageBytes} bytes).`);
}
acc.bytes += bytes;
if (acc.bytes > opts.maxTotalImageBytes) {
throw new MultimodalError("images_too_large", 413, `Total image payload exceeds limit (${opts.maxTotalImageBytes} bytes).`);
}
return { type: "image", source: { type: "base64", media_type: mediaType, data } };
}
if (/^https?:\/\//i.test(url)) {
if (!opts.allowRemoteUrl) {
throw new MultimodalError(
"remote_url_disabled",
400,
"Remote image URLs are disabled. Enable CLAUDE_IMAGE_ALLOW_URL=1 to allow http(s) image URLs, or pass the image as a base64 data URI."
);
}
// Passthrough as an Anthropic url-source block. OCP does NOT fetch the URL
// itself (no OCP-side SSRF surface); the fetch is performed upstream by the
// Anthropic API. Best-effort: unreachable/blocked URLs surface as an API error.
return { type: "image", source: { type: "url", url } };
}
throw new MultimodalError("unsupported_url_scheme", 400, "image_url must be a base64 data URI or an http(s) URL.");
}
// Role prefix mirrors messagesToPrompt()'s text-path labeling so a multi-turn
// conversation reads the same whether or not it carries images. System messages
// are handled by the caller via --system-prompt and never reach here.
function rolePrefix(role) {
if (role === "assistant") return "[Assistant] ";
return ""; // user / tool / anything else: verbatim, as in the text path
}
// Build the Anthropic content-block array for a single stream-json user
// envelope. Mirrors the text path's "collapse the whole conversation into one
// turn passed via stdin" model (OCP runs stateless, full context per spawn), but
// preserves image position relative to text and keeps images out of the text
// char budget entirely. Returns { blocks, stats } or throws MultimodalError.
export function buildImageBlocks(messages, opts = {}) {
const o = { ...DEFAULT_MULTIMODAL_OPTS, ...opts };
const blocks = [];
const acc = { images: 0, bytes: 0 };
let textChars = 0;
let firstMessage = true;
const pushText = (text) => {
if (!text) return;
blocks.push({ type: "text", text });
textChars += text.length;
};
for (const m of messages) {
const prefix = rolePrefix(m.role);
// Separate messages with a blank line, matching messagesToPrompt's "\n\n" join.
const sep = firstMessage ? "" : "\n\n";
firstMessage = false;
let prefixEmitted = false;
const emitPrefixWith = (t) => {
if (prefixEmitted) return t;
prefixEmitted = true;
return sep + prefix + t;
};
if (typeof m.content === "string") {
pushText(emitPrefixWith(m.content));
continue;
}
if (!Array.isArray(m.content)) {
// null / object content: mirror contentToText's fallback.
const t = m.content == null ? "" : JSON.stringify(m.content);
pushText(emitPrefixWith(t));
continue;
}
for (const part of m.content) {
if (part && part.type === "text" && typeof part.text === "string") {
pushText(emitPrefixWith(part.text));
} else if (part && part.type === "image_url") {
// Ensure the role prefix isn't lost when a message leads with an image.
if (!prefixEmitted && prefix) pushText(emitPrefixWith(""));
blocks.push(imagePartToBlock(part, o, acc));
} else {
// audio / file / unknown parts: preserve the existing placeholder
// behavior (issue #110) — deferred to a future version.
pushText(emitPrefixWith("[non-text content omitted]"));
}
}
}
// Defensive: a stream-json user turn must have at least one content block.
if (blocks.length === 0) blocks.push({ type: "text", text: "" });
// Enforce the text-char budget (PR #154 review F2). Without this, attaching a
// single tiny image would let unbounded text bypass the gateway's runaway-context
// guard entirely — messagesToPrompt truncates the text path, so the multimodal
// path must too. Image blocks are preserved and are NOT counted (they are bounded
// by the byte/count caps above).
const budgeted = enforceTextBudget(blocks, o.maxTextChars);
return {
blocks: budgeted.blocks,
stats: {
imageCount: acc.images,
totalImageBytes: acc.bytes,
textChars: budgeted.textChars,
originalTextChars: budgeted.originalTextChars,
truncated: budgeted.truncated,
},
};
}
// Enforce a text-char budget over already-built content blocks, mirroring
// messagesToPrompt's "keep the tail, drop the oldest" truncation. Image blocks are
// preserved in place; only text blocks count against the budget. A truncation note
// is prepended when anything is dropped. Returns
// { blocks, truncated, originalTextChars, textChars }.
function enforceTextBudget(blocks, maxTextChars) {
let originalTextChars = 0;
for (const b of blocks) if (b.type === "text") originalTextChars += b.text.length;
if (!(maxTextChars > 0) || originalTextChars <= maxTextChars) {
return { blocks, truncated: false, originalTextChars, textChars: originalTextChars };
}
// Keep the most recent text up to the budget; trim within the boundary block and
// drop older text blocks. Non-text (image) blocks always survive, in order.
let budget = maxTextChars;
const out = [];
for (let i = blocks.length - 1; i >= 0; i--) {
const b = blocks[i];
if (b.type !== "text") { out.unshift(b); continue; }
if (budget <= 0) continue; // older text fully dropped
if (b.text.length <= budget) {
out.unshift(b);
budget -= b.text.length;
} else {
out.unshift({ type: "text", text: b.text.slice(b.text.length - budget) });
budget = 0;
}
}
out.unshift({ type: "text", text: "[System] Note: older text content was truncated to fit the context limit." });
let textChars = 0;
for (const b of out) if (b.type === "text") textChars += b.text.length;
return { blocks: out, truncated: true, originalTextChars, textChars };
}
// Serialize the non-system conversation to a single newline-terminated
// stream-json user message for `claude -p --input-format stream-json` stdin.
// Returns { payload, stats } or throws MultimodalError.
export function buildStreamJsonInput(messages, opts = {}) {
const { blocks, stats } = buildImageBlocks(messages, opts);
const envelope = { type: "user", message: { role: "user", content: blocks } };
return { payload: JSON.stringify(envelope) + "\n", stats };
}
+37
View File
@@ -15,3 +15,40 @@ export function appendOperatorPrompt(base, operatorAppend) {
const op = typeof operatorAppend === "string" ? operatorAppend.trim() : ""; const op = typeof operatorAppend === "string" ? operatorAppend.trim() : "";
return op ? `${base}\n\n${op}` : base; 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
@@ -0,0 +1,318 @@
// ── OpenAI Structured Outputs (response_format) — pure helpers ───────────────
//
// OCP's `/v1/chat/completions` (Class B.1, ADR 0006) advertises OpenAI compatibility but forwards
// to `claude -p`, which has no native `response_format`. Asked for JSON, the coding-assistant CLI
// typically replies with prose, a Markdown table, or a ```json fenced block — none of which is
// `JSON.parse`-able. These helpers implement the OpenAI `response_format` contract on top of that:
// detect the request, build a strict JSON-only steering instruction, then extract and validate the
// JSON the model returns. All functions here are pure (no I/O) so they are unit-tested directly;
// the retry loop that calls the model lives in server.mjs (runStructuredCompletion).
//
// Spec authority (B.1, ADR 0006): OpenAI chat/completions `response_format`
// https://platform.openai.com/docs/api-reference/chat/create#chat-create-response_format
// No field or behaviour beyond that published shape is introduced.
export class StructuredOutputError extends Error {
constructor(reason, raw) {
super(`structured output could not be produced: ${reason}`);
this.name = "StructuredOutputError";
this.reason = reason;
this.raw = raw;
}
}
// Fail-closed parse of the OCP_STRUCTURED_MAX_ATTEMPTS retry cap. `Math.max(1, parseInt("abc",10))`
// === `Math.max(1, NaN)` === NaN, and a retry loop bounded by `attempt < NaN` never runs → 0 spawns,
// every structured request silently refuses. So any non-integer / non-finite / <1 value keeps the
// documented default instead (and warns), rather than bricking the feature. (PR #153 review round 2.)
export function resolveMaxAttempts(raw, { fallback = 3, warn } = {}) {
if (raw === undefined || raw === null || raw === "") return fallback;
const n = parseInt(raw, 10);
if (!Number.isFinite(n) || n < 1) {
if (typeof warn === "function") {
warn(`Ignoring invalid OCP_STRUCTURED_MAX_ATTEMPTS="${raw}" (want integer >= 1); using default ${fallback}.`);
}
return fallback;
}
return n;
}
// Returns { mode: "schema", schema, name?, strict } | { mode: "json_object" } | null.
// Supports the OpenAI shapes: response_format:{type:"json_schema",json_schema:{schema,strict,name}}
// and response_format:{type:"json_object"}, a lenient response_format:{schema} fallback, and the
// widely-used (non-standard) top-level `json_mode: true` flag as a json_object alias.
export function detectStructuredOutput(parsed) {
const rf = parsed?.response_format;
if (rf && typeof rf === "object") {
if (rf.type === "json_schema") {
const js = (rf.json_schema && typeof rf.json_schema === "object") ? rf.json_schema : {};
const schema = js.schema || rf.schema || null;
return { mode: "schema", schema, name: js.name, strict: js.strict === true };
}
if (rf.type === "json_object") return { mode: "json_object" };
if (rf.schema && typeof rf.schema === "object") {
return { mode: "schema", schema: rf.schema, strict: rf.strict === true };
}
}
// Non-standard convenience alias honored by several OpenAI-compatible clients.
if (parsed?.json_mode === true) return { mode: "json_object" };
return null;
}
export function jsonTypeOf(v) {
if (v === null) return "null";
if (Array.isArray(v)) return "array";
return typeof v; // object | string | number | boolean
}
export function jsonTypeMatches(t, value) {
switch (t) {
case "string": return typeof value === "string";
case "number": return typeof value === "number" && Number.isFinite(value);
case "integer": return typeof value === "number" && Number.isInteger(value);
case "boolean": return typeof value === "boolean";
case "object": return value !== null && typeof value === "object" && !Array.isArray(value);
case "array": return Array.isArray(value);
case "null": return value === null;
default: return true; // unknown type keyword → do not fail on it
}
}
const jsonDeepEqual = (a, b) => JSON.stringify(a) === JSON.stringify(b);
// Resolve a local JSON-Pointer `$ref` (e.g. "#/$defs/Step" or "#/definitions/Step") against the
// document root. Only same-document refs are supported (that is all the OpenAI SDK emits); a remote
// or unresolvable ref returns null and the caller skips validation for it rather than failing.
function resolveRef(ref, root) {
if (typeof ref !== "string" || !ref.startsWith("#/") || !root) return null;
const parts = ref.slice(2).split("/").map(p => p.replace(/~1/g, "/").replace(/~0/g, "~"));
let cur = root;
for (const p of parts) {
if (cur && typeof cur === "object" && Object.prototype.hasOwnProperty.call(cur, p)) cur = cur[p];
else return null;
}
return (cur && typeof cur === "object") ? cur : null;
}
// Minimal JSON-Schema validator: covers the subset OpenAI structured outputs use — type (incl.
// arrays of types / integer), required, properties, additionalProperties (no invented keys), items
// (list + tuple), enum, const, nullability (type:["x","null"] or nullable:true), min/maxItems, and
// $ref/$defs + allOf/anyOf/oneOf composition (which the official OpenAI SDK emits heavily via
// zodResponseFormat / client.beta.chat.completions.parse). `root` carries the top-level schema so
// same-document $refs resolve. Returns error strings ([] = valid).
// `refChain` tracks the $ref pointers resolved on the CURRENT path WITHOUT consuming data (a $ref
// hop, or an allOf/anyOf/oneOf branch, all re-validate the same value). A pointer reappearing on
// that chain is a pure ref→ref (or ref→composition→ref) cycle that recurses forever independent of
// the data — we fail closed on it. Data-consuming recursion (properties/items/additionalProperties)
// deliberately resets the chain (default []): it always terminates because a JSON value is a finite
// tree, so a legitimately recursive schema (Node→child:Node) must NOT be flagged as a cycle. A depth
// cap backstops any threading mistake. (PR #153 review round 2, cyclic-$ref blocker.)
const REF_DEPTH_CAP = 512;
export function validateJsonSchema(value, schema, path = "$", strict = false, root = schema, refChain = []) {
const errors = [];
if (!schema || typeof schema !== "object") return errors;
if (refChain.length > REF_DEPTH_CAP) { // defensive backstop; refChain cycle-check below is primary
errors.push(`${path}: $ref resolution too deep (possible cycle)`);
return errors;
}
// $ref: resolve against the document root ($defs / definitions) and validate the target. Without
// this a nested {$ref:"#/$defs/Step"} presents as {no type, no properties} — and under strict that
// used to wrongly reject every real key as "additional property not allowed" (the flagship OpenAI
// SDK shape). Sibling keywords alongside $ref (rare) are merged over the resolved target.
if (typeof schema.$ref === "string") {
if (refChain.includes(schema.$ref)) { // cyclic $ref (a→b→a, or self a→a) — fail closed.
errors.push(`${path}: cyclic $ref detected (${schema.$ref})`);
return errors;
}
const resolved = resolveRef(schema.$ref, root);
if (!resolved) return errors; // unresolvable ref → cannot validate; do not fail
const { $ref, ...siblings } = schema;
return validateJsonSchema(value, { ...resolved, ...siblings }, path, strict, root, [...refChain, schema.$ref]);
}
// Composition. allOf: every branch must pass. anyOf: at least one. oneOf: exactly one.
// These re-validate the SAME value → thread refChain so a ref cycle routed through a branch is caught.
if (Array.isArray(schema.allOf)) {
for (const sub of schema.allOf) errors.push(...validateJsonSchema(value, sub, path, strict, root, refChain));
}
if (Array.isArray(schema.anyOf)) {
if (!schema.anyOf.some(sub => validateJsonSchema(value, sub, path, strict, root, refChain).length === 0)) {
errors.push(`${path}: does not match any of the allowed schemas (anyOf)`);
}
}
if (Array.isArray(schema.oneOf)) {
const matches = schema.oneOf.filter(sub => validateJsonSchema(value, sub, path, strict, root, refChain).length === 0).length;
if (matches !== 1) errors.push(`${path}: must match exactly one allowed schema (oneOf), matched ${matches}`);
}
// Nullability takes precedence: a null value is valid whenever the schema permits null (its type
// union includes "null", or nullable:true), regardless of enum/const. This mirrors OpenAI
// structured-output behaviour — nullable fields accept null even when a bare enum (as generated by
// Home Assistant's extended_openai_conversation) omits null from its value list.
const allowsNull = schema.nullable === true
|| (Array.isArray(schema.type) ? schema.type.includes("null") : schema.type === "null");
if (value === null && allowsNull) return errors;
if (Array.isArray(schema.enum) && !schema.enum.some(e => jsonDeepEqual(e, value))) {
errors.push(`${path}: not one of the allowed enum values`);
}
if ("const" in schema && !jsonDeepEqual(schema.const, value)) {
errors.push(`${path}: does not equal the required const value`);
}
if (schema.type !== undefined) {
const types = Array.isArray(schema.type) ? schema.type : [schema.type];
const nullable = schema.nullable === true || types.includes("null");
const ok = types.some(t => jsonTypeMatches(t, value)) || (value === null && nullable);
if (!ok) {
errors.push(`${path}: expected ${types.join("|")}${schema.nullable ? "|null" : ""}, got ${jsonTypeOf(value)}`);
return errors; // type mismatch — deeper checks are meaningless
}
}
if (value === null) return errors;
const vt = jsonTypeOf(value);
if (vt === "object") {
const props = schema.properties || {};
for (const r of (schema.required || [])) {
if (!Object.prototype.hasOwnProperty.call(value, r)) errors.push(`${path}.${r}: required property missing`);
}
const addl = schema.additionalProperties;
// Only treat strict as implying "no additional properties" when this object actually declares
// its own `properties` and is NOT a composite (allOf/anyOf/oneOf put the real keys in sub-schemas,
// which are validated separately above). Inferring closure from an EMPTY properties map — the
// shape an unresolved $ref or a pure-composition node presents — would reject every real key.
// An explicit additionalProperties:false is always honoured. (PR #153 review, finding 1.)
const isComposite = Array.isArray(schema.allOf) || Array.isArray(schema.anyOf) || Array.isArray(schema.oneOf);
const noExtra = addl === false || (strict && addl === undefined && Object.keys(props).length > 0 && !isComposite);
for (const k of Object.keys(value)) {
if (props[k]) {
errors.push(...validateJsonSchema(value[k], props[k], `${path}.${k}`, strict, root));
} else if (isComposite) {
// key may be defined in an allOf/anyOf/oneOf branch — already validated there; don't reject.
} else if (noExtra) {
errors.push(`${path}.${k}: additional property not allowed`);
} else if (addl && typeof addl === "object") {
errors.push(...validateJsonSchema(value[k], addl, `${path}.${k}`, strict, root));
}
}
} else if (vt === "array" && schema.items) {
if (Array.isArray(schema.items)) {
schema.items.forEach((s, i) => { if (i < value.length) errors.push(...validateJsonSchema(value[i], s, `${path}[${i}]`, strict, root)); });
} else {
value.forEach((item, i) => errors.push(...validateJsonSchema(item, schema.items, `${path}[${i}]`, strict, root)));
}
if (typeof schema.minItems === "number" && value.length < schema.minItems) errors.push(`${path}: fewer items than minItems ${schema.minItems}`);
if (typeof schema.maxItems === "number" && value.length > schema.maxItems) errors.push(`${path}: more items than maxItems ${schema.maxItems}`);
}
return errors;
}
// Crash-safe façade over validateJsonSchema (issue #181). The validator recurses on the DATA's
// nesting depth (properties/items/additionalProperties), which the REF_DEPTH_CAP does NOT bound —
// only the ref-chain is. A model reply nested ~2000 levels deep therefore overflowed the stack with
// a RangeError, which the request handler caught as a generic HTTP 500 instead of the spec-correct
// `refusal`. This wrapper converts ANY throw (the deep-data RangeError, or any future recursion
// hazard) into a single validation error, so the structured-output retry loop treats a pathological
// reply as "did not validate" → refusal — never a 500, never a crash. A well-formed reply is
// unaffected: the inner validator returns and this just passes its errors through.
export function validateJsonSchemaSafe(value, schema, path = "$", strict = false, root = schema) {
try {
return validateJsonSchema(value, schema, path, strict, root);
} catch (e) {
// Catch ONLY the deep-nesting stack overflow (the #181 vector) and turn it into a validation
// miss → retry → refusal, never a 500. Any OTHER throw is a genuine bug: re-throw it so it
// surfaces at error level instead of being silently masked as "did not validate" (reviewer
// finding — a catch-all would hide a future TypeError behind a warn-level structured_retry).
if (e instanceof RangeError) return [`${path}: schema validation aborted (value nesting too deep)`];
throw e;
}
}
function tryJsonParse(s) {
try { return { ok: true, value: JSON.parse(s) }; } catch { return { ok: false }; }
}
// Find the next brace-balanced JSON span at or after `from`. String-aware: brackets inside quoted
// strings are ignored. Returns { text, start, end } for the first complete top-level span, or null.
function balancedSlice(s, from) {
let start = -1;
for (let i = from; i < s.length; i++) { if (s[i] === "{" || s[i] === "[") { start = i; break; } }
if (start === -1) return null;
let depth = 0, inStr = false, esc = false;
for (let i = start; i < s.length; i++) {
const c = s[i];
if (inStr) {
if (esc) esc = false;
else if (c === "\\") esc = true;
else if (c === '"') inStr = false;
continue;
}
if (c === '"') { inStr = true; continue; }
if (c === "{" || c === "[") depth++;
else if (c === "}" || c === "]") { depth--; if (depth === 0) return { text: s.slice(start, i + 1), start, end: i }; }
}
return null;
}
// Extract a single JSON value from model text. Two modes (PR #153 review, finding 2):
//
// opts.whole === true (json_object mode): the ENTIRE reply, after trimming and stripping one code
// fence, must parse as a single JSON value. json_object has no schema to validate against, so
// this whole-reply parse is its ONLY guard — a reply like `I can't. The schema is {"type":"object"}`
// must NOT parse-and-serve the embedded object as if it were the answer.
//
// default (schema mode): prose-wrapped JSON is tolerated (models often add a sentence), BUT a reply
// containing MORE THAN ONE top-level JSON value is rejected rather than silently serving the first
// — "Schema: {...}\n\nAnswer: {...}" or "Option A: {...} Option B: {...}" is ambiguous, not an
// answer. The extracted value is still schema-validated by the caller.
//
// Returns { ok:true, value } | { ok:false, reason? }.
export function extractJsonPayload(text, opts = {}) {
if (typeof text !== "string") return { ok: false };
let s = text.trim();
const fence = s.match(/^```(?:json)?\s*([\s\S]*?)\s*```$/i);
if (fence) s = fence[1].trim();
if (opts.whole) {
const whole = tryJsonParse(s);
return whole.ok ? whole : { ok: false, reason: "reply was not a single JSON value" };
}
const direct = tryJsonParse(s);
if (direct.ok) return direct;
const first = balancedSlice(s, 0);
if (!first) return { ok: false };
const parsedFirst = tryJsonParse(first.text);
if (!parsedFirst.ok) return { ok: false };
// Reject ambiguity: a second parseable top-level JSON value means we cannot know which is the answer.
const second = balancedSlice(s, first.end + 1);
if (second && tryJsonParse(second.text).ok) {
return { ok: false, reason: "reply contained more than one JSON value" };
}
return parsedFirst;
}
// The strict JSON-only system instruction appended to the request (attempt 0), escalated with the
// prior failure reason on retries.
export function structuredSystemInstruction(structured, attempt, lastErr) {
const schemaBlock = (structured.mode === "schema" && structured.schema)
? `Your JSON MUST validate EXACTLY against this JSON Schema:\n${JSON.stringify(structured.schema)}\n`
+ `- Include every required property.\n`
+ `- Do NOT add any property that is not defined in the schema.\n`
+ `- Respect all declared types, enums, and nullability.`
: `Respond with a single valid JSON value.`;
let text =
`You are a strict JSON generator. Output a SINGLE JSON value and NOTHING else.
- The response MUST begin with { or [ and end with the matching } or ].
- Do NOT wrap the JSON in Markdown or code fences (no \`\`\`).
- Do NOT include any prose, explanation, heading, comment, reasoning, or XML — only the raw JSON.
${schemaBlock}`;
if (attempt > 0) {
text = `YOUR PREVIOUS RESPONSE WAS REJECTED (${lastErr}). Output ONLY the corrected raw JSON now, with no other text.\n\n` + text;
}
return text;
}
+326 -37
View File
@@ -42,6 +42,7 @@ import { dirname, join } from "node:path";
import { homedir } from "node:os"; import { homedir } from "node:os";
import { validateKey, recordUsage, getUsageByKey, getUsageTimeline, getRecentUsage, createKey, listKeys, revokeKey, closeDb, checkQuota, updateKeyQuota, getKeyQuota, findKey, cacheHash, getCachedResponse, setCachedResponse, clearCache, getCacheStats, hasCacheControl, singleflight, getInflightStats } from "./keys.mjs"; import { validateKey, recordUsage, getUsageByKey, getUsageTimeline, getRecentUsage, createKey, listKeys, revokeKey, closeDb, checkQuota, updateKeyQuota, getKeyQuota, findKey, cacheHash, getCachedResponse, setCachedResponse, clearCache, getCacheStats, hasCacheControl, singleflight, getInflightStats } from "./keys.mjs";
import { DEFAULT_PORT } from "./lib/constants.mjs"; 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 { isLoopbackBind } from "./lib/net.mjs";
import { runTuiTurn, reapStaleTuiSessions, resolveTuiHome, bootTuiPane, tuiPaneHealthy, poolPaneName, POOL_BOOT_MS } from "./lib/tui/session.mjs"; import { runTuiTurn, reapStaleTuiSessions, resolveTuiHome, bootTuiPane, tuiPaneHealthy, poolPaneName, POOL_BOOT_MS } from "./lib/tui/session.mjs";
import { detectTuiUpstreamError } from "./lib/tui/transcript.mjs"; import { detectTuiUpstreamError } from "./lib/tui/transcript.mjs";
@@ -49,7 +50,9 @@ import { TuiSemaphore, SemaphoreAbortError, recordTuiEntrypoint, buildTuiHealthB
import { TuiPanePool, resolvePoolSize, POOL_MAX_SIZE } from "./lib/tui/pool.mjs"; import { TuiPanePool, resolvePoolSize, POOL_MAX_SIZE } from "./lib/tui/pool.mjs";
import { TuiDeltaAssembler, DEFAULT_HOLDBACK_CHARS, resolveStreamHoldback } from "./lib/tui/stream.mjs"; import { TuiDeltaAssembler, DEFAULT_HOLDBACK_CHARS, resolveStreamHoldback } from "./lib/tui/stream.mjs";
import { createSerialMutex, createTtlCache, isTokenExpiring, orderLabelsLastGoodFirst } from "./lib/spawn-auth.mjs"; import { createSerialMutex, createTtlCache, isTokenExpiring, orderLabelsLastGoodFirst } from "./lib/spawn-auth.mjs";
import { appendOperatorPrompt } from "./lib/prompt.mjs"; import { hasImageContent, buildImageBlocks, buildStreamJsonInput, MultimodalError } from "./lib/multimodal.mjs";
import { parsePositiveInt } from "./lib/env.mjs";
import { appendOperatorPrompt, derivePromptCharBudget } from "./lib/prompt.mjs";
const __dirname = dirname(fileURLToPath(import.meta.url)); const __dirname = dirname(fileURLToPath(import.meta.url));
const _pkg = JSON.parse(readFileSync(join(__dirname, "package.json"), "utf8")); const _pkg = JSON.parse(readFileSync(join(__dirname, "package.json"), "utf8"));
@@ -248,8 +251,8 @@ function parseStreamJsonLines(buffered) {
// Reference: OLP lib/providers/anthropic.mjs anthropicStreamJsonEventToIR (commit 97e7d16). // Reference: OLP lib/providers/anthropic.mjs anthropicStreamJsonEventToIR (commit 97e7d16).
// //
// @param {object} event — parsed NDJSON event // @param {object} event — parsed NDJSON event
// @param {boolean} isFirstDelta — true if no content has been yielded yet // @param {boolean} sawTextDelta — true if a streaming content_block_delta text was already seen
function parseStreamJsonEvent(event, isFirstDelta) { function parseStreamJsonEvent(event, sawTextDelta) {
const t = event?.type; const t = event?.type;
// system/* — first-event init + other system meta (api_retry etc.) // system/* — first-event init + other system meta (api_retry etc.)
@@ -261,19 +264,23 @@ function parseStreamJsonEvent(event, isFirstDelta) {
if (t === "stream_event") { if (t === "stream_event") {
const inner = event.event ?? event; const inner = event.event ?? event;
if (inner?.type === "content_block_delta" && inner.delta?.type === "text_delta") { if (inner?.type === "content_block_delta" && inner.delta?.type === "text_delta") {
return { text: inner.delta.text ?? "" }; return { text: inner.delta.text ?? "", fromDelta: true };
} }
// Other stream_event sub-types (content_block_start, message_delta, etc.) — consumed // Other stream_event sub-types (content_block_start, message_delta, etc.) — consumed
return null; return null;
} }
// assistant — aggregate message (fallback when no prior content_block_delta seen) // assistant — aggregate message. claude CLI without --include-partial-messages emits NO
// Empirically (claude CLI without --include-partial-messages, verified v2.1.104 through v2.1.158): fast/short // content_block_delta events; each assistant message arrives as its own aggregate `assistant`
// responses may emit ONLY the aggregate assistant event, no content_block_delta events. // event. An agentic/tool-using turn has SEVERAL (preamble + one per tool round + final answer),
// If isFirstDelta is true, extract text here; otherwise it's a duplicate, ignore. // so we must accumulate the text of EVERY such event. The prior `isFirstDelta` guard kept only
// the FIRST message's text and dropped the rest — silently losing the post-tool-use final answer
// on every tool-using turn (verified v2.1.104 through v2.1.211; see PR body capture).
// The only real hazard is the delta+aggregate DOUBLE-COUNT: if streaming deltas were already
// seen (sawTextDelta), the aggregate duplicates them — ignore it.
// Reference: OLP commit 65f945c (assistant-aggregate fallback, fold-in). // Reference: OLP commit 65f945c (assistant-aggregate fallback, fold-in).
if (t === "assistant") { if (t === "assistant") {
if (isFirstDelta) { if (!sawTextDelta) {
const blocks = event.message?.content; const blocks = event.message?.content;
if (Array.isArray(blocks)) { if (Array.isArray(blocks)) {
const text = blocks const text = blocks
@@ -327,6 +334,16 @@ const ALLOWED_TOOLS = (process.env.CLAUDE_ALLOWED_TOOLS ||
"Bash,Read,Write,Edit,Glob,Grep,WebSearch,WebFetch,Agent" "Bash,Read,Write,Edit,Glob,Grep,WebSearch,WebFetch,Agent"
).split(",").map(s => s.trim()).filter(Boolean); ).split(",").map(s => s.trim()).filter(Boolean);
const SYSTEM_PROMPT = process.env.CLAUDE_SYSTEM_PROMPT || ""; 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 || ""; const MCP_CONFIG = process.env.CLAUDE_MCP_CONFIG || "";
let SESSION_TTL = parseInt(process.env.CLAUDE_SESSION_TTL || "3600000", 10); let SESSION_TTL = parseInt(process.env.CLAUDE_SESSION_TTL || "3600000", 10);
let MAX_CONCURRENT = parseInt(process.env.CLAUDE_MAX_CONCURRENT || "8", 10); let MAX_CONCURRENT = parseInt(process.env.CLAUDE_MAX_CONCURRENT || "8", 10);
@@ -1100,7 +1117,7 @@ const authCheckInterval = setInterval(checkAuth, 600000);
// CLAUDE_SYSTEM_PROMPT env var is absorbed into the system prompt via // CLAUDE_SYSTEM_PROMPT env var is absorbed into the system prompt via
// extractSystemPrompt() at the caller level; APPEND_SYSTEM_PROMPT no longer used. // extractSystemPrompt() at the caller level; APPEND_SYSTEM_PROMPT no longer used.
// Note: ALLOWED_TOOLS / SKIP_PERMISSIONS / MCP_CONFIG are preserved as before. // Note: ALLOWED_TOOLS / SKIP_PERMISSIONS / MCP_CONFIG are preserved as before.
function buildCliArgs(cliModel, systemPrompt) { function buildCliArgs(cliModel, systemPrompt, opts = {}) {
const args = [ const args = [
"--model", cliModel, "--model", cliModel,
"--output-format", "stream-json", "--output-format", "stream-json",
@@ -1109,6 +1126,14 @@ function buildCliArgs(cliModel, systemPrompt) {
"--system-prompt", systemPrompt, "--system-prompt", systemPrompt,
]; ];
// Multimodal path (issue #110): images are fed as Anthropic content blocks over
// a stream-json stdin stream. `--input-format stream-json` (§ --input-format,
// choices text|stream-json; realtime streaming input) is added ONLY when the
// request carries an image part; the default (text) input path is untouched.
if (opts.streamJsonInput) {
args.push("--input-format", "stream-json");
}
// Permissions // Permissions
// ADR 0007 B-path: in multi-tenant mode, suppress operator-FS tools so a guest // 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. // prompt cannot drive Bash/Read/Write/Edit/etc. on the operator's filesystem.
@@ -1144,11 +1169,45 @@ function buildCliArgs(cliModel, systemPrompt) {
return args; 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 ────────────────────────────────────── // ── Format messages to prompt text ──────────────────────────────────────
// Truncation guard: if total chars exceed MAX_PROMPT_CHARS, keep the system // Truncation guard: if total chars exceed MAX_PROMPT_CHARS, keep the system
// message(s) + first user message + last N messages, dropping the middle. // message(s) + first user message + last N messages, dropping the middle.
// This prevents runaway context from gateway-side conversation accumulation. // This prevents runaway context from gateway-side conversation accumulation.
let MAX_PROMPT_CHARS = parseInt(process.env.CLAUDE_MAX_PROMPT_CHARS || "150000", 10); // Routed through parseIntEnv so a misconfigured cap fails CLOSED to the default rather than
// NaN — CLAUDE_MAX_PROMPT_CHARS=unlimited previously → NaN → enforceTextBudget's `!(NaN > 0)`
// early-return → 500k chars passed unbounded, silently defeating F2's text-budget guarantee
// (PR #154 round 2, gap (a)). The default itself is SPOT-DERIVED (ADR 0009, PR #179):
// max(models.json contextWindow) × 3 chars/token — currently 600,000 — so the two fixes
// compose: parseIntEnv guards a SET-but-garbage value, the derivation supplies the honest
// default when unset/empty. `let` is kept for the settings API.
let MAX_PROMPT_CHARS = parseIntEnv("CLAUDE_MAX_PROMPT_CHARS", derivePromptCharBudget(modelsConfig.models));
// ── Multimodal image caps (issue #110) ──────────────────────────────────
// OpenAI `image_url` parts are forwarded to claude as Anthropic image blocks via
// `--input-format stream-json`. Images deliberately BYPASS the text char budget
// (MAX_PROMPT_CHARS) — they are bounded by these byte/count caps instead, and by
// MAX_BODY_SIZE at the HTTP layer. Data URIs are supported by default; remote
// http(s) image URLs are OFF unless CLAUDE_IMAGE_ALLOW_URL is set (v1: data URIs
// only). See docs/adr/0006-openai-shim-scope.md (Class B.1) and README § "Images".
const IMAGE_ALLOW_URL = /^(1|true|yes|on)$/i.test(process.env.CLAUDE_IMAGE_ALLOW_URL || "");
const MAX_IMAGE_BYTES = parseIntEnv("CLAUDE_MAX_IMAGE_BYTES", 5 * 1024 * 1024);
const MAX_IMAGES = parseIntEnv("CLAUDE_MAX_IMAGES", 20);
const MAX_IMAGE_TOTAL_BYTES = parseIntEnv("CLAUDE_MAX_IMAGE_TOTAL_BYTES", 20 * 1024 * 1024);
const MULTIMODAL_OPTS = {
allowRemoteUrl: IMAGE_ALLOW_URL,
maxImageBytes: MAX_IMAGE_BYTES,
maxImages: MAX_IMAGES,
maxTotalImageBytes: MAX_IMAGE_TOTAL_BYTES,
};
// Flatten OpenAI content (string | array of parts) to plain text for the prompt. // 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) // Array content: concatenate text parts; replace non-text parts (e.g. image_url)
@@ -1240,25 +1299,51 @@ function spawnClaudeProcess(model, messages, conversationId, keyName, releaseSlo
// Circuit breaker: disabled (see comment at top of breaker section) // Circuit breaker: disabled (see comment at top of breaker section)
stats.activeRequests++;
stats.totalRequests++;
// Phase 6c: always serialize full conversation via stdin (no session resume). // Phase 6c: always serialize full conversation via stdin (no session resume).
// System messages are extracted and passed via --system-prompt; the remaining // System messages are extracted and passed via --system-prompt; the remaining
// messages (user/assistant/tool) are serialized by messagesToPrompt. // messages (user/assistant/tool) are serialized for stdin.
const systemPrompt = extractSystemPrompt(messages); const systemPrompt = extractSystemPrompt(messages);
// messagesToPrompt skips system messages now that they go via --system-prompt. // messagesToPrompt / buildStreamJsonInput skip system messages (they go via
// Filter them out before calling to avoid double-injection. // --system-prompt). Filter them out first to avoid double-injection.
const nonSystemMessages = messages.filter(m => m.role !== "system"); const nonSystemMessages = messages.filter(m => m.role !== "system");
const prompt = messagesToPrompt(nonSystemMessages);
stats.oneOffRequests++; // Multimodal (issue #110): when any message carries an OpenAI image_url part,
if (conversationId) { // feed the conversation as Anthropic content blocks over --input-format
console.log(`[session] stateless conv=${conversationId.slice(0, 12)}... key=${keyName || "anon"} msgs=${messages.length} prompt_chars=${prompt.length}`); // stream-json (images preserved and kept OUT of the text char budget).
// Otherwise the text path is byte-for-byte unchanged. buildStreamJsonInput may
// throw MultimodalError on an invalid/oversized image; it runs BEFORE any stats
// mutation so a validation failure never leaks counters or the concurrency slot
// (handleChatCompletions validates first, so in practice it will not throw here).
const useStreamJson = hasImageContent(nonSystemMessages);
let stdinPayload, promptChars;
if (useStreamJson) {
// Pass MAX_PROMPT_CHARS so the multimodal text is bounded by the same
// runaway-context guard as the text path (PR #154 review F2). Images bypass it.
const built = buildStreamJsonInput(nonSystemMessages, { ...MULTIMODAL_OPTS, maxTextChars: MAX_PROMPT_CHARS });
stdinPayload = built.payload;
promptChars = built.stats.textChars;
if (built.stats.truncated) {
logEvent("warn", "prompt_truncated", {
originalChars: built.stats.originalTextChars,
maxChars: MAX_PROMPT_CHARS,
keptChars: built.stats.textChars,
path: "multimodal",
});
}
} else {
stdinPayload = messagesToPrompt(nonSystemMessages);
promptChars = stdinPayload.length;
} }
const cliArgs = buildCliArgs(cliModel, systemPrompt); stats.activeRequests++;
stats.totalRequests++;
stats.oneOffRequests++;
if (conversationId) {
console.log(`[session] stateless conv=${conversationId.slice(0, 12)}... key=${keyName || "anon"} msgs=${messages.length} prompt_chars=${promptChars}`);
}
const cliArgs = buildCliArgs(cliModel, systemPrompt, { streamJsonInput: useStreamJson });
const env = { ...process.env }; const env = { ...process.env };
delete env.CLAUDECODE; delete env.CLAUDECODE;
@@ -1344,12 +1429,13 @@ function spawnClaudeProcess(model, messages, conversationId, keyName, releaseSlo
// the spawned process, NOT on the stdin Writable — it does not catch this. // 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 })); proc.stdin.on("error", (e) => logEvent("warn", "stdin_write_error", { error: e.message }));
// Write prompt to stdin immediately // Write the serialized turn to stdin immediately. Text path: the flat prompt.
proc.stdin.write(prompt); // Multimodal path: a single newline-terminated stream-json user envelope.
proc.stdin.write(stdinPayload);
proc.stdin.end(); proc.stdin.end();
recordModelRequest(cliModel, prompt.length); recordModelRequest(cliModel, promptChars);
logEvent("info", "claude_spawned", { model: cliModel, promptChars: prompt.length, timeout: TIMEOUT, tier: getModelTier(cliModel), session: conversationId ? conversationId.slice(0, 12) + "..." : "none" }); logEvent("info", "claude_spawned", { model: cliModel, promptChars, inputFormat: useStreamJson ? "stream-json" : "text", timeout: TIMEOUT, tier: getModelTier(cliModel), session: conversationId ? conversationId.slice(0, 12) + "..." : "none" });
// Single request timeout — no separate first-byte timer. // Single request timeout — no separate first-byte timer.
// Claude tool-use causes long pauses in the token stream (30s-5min), // Claude tool-use causes long pauses in the token stream (30s-5min),
@@ -1419,7 +1505,7 @@ async function callClaude(model, messages, conversationId, keyName, res) {
const { proc, cliModel, conversationId: convId, t0, cleanup, handleSessionFailure, markFirstByte } = ctx; const { proc, cliModel, conversationId: convId, t0, cleanup, handleSessionFailure, markFirstByte } = ctx;
let lineBuffer = ""; let lineBuffer = "";
let assembledText = ""; let assembledText = "";
let isFirstDelta = true; let sawTextDelta = false;
let resultEventSeen = false; let resultEventSeen = false;
let stderr = ""; let stderr = "";
@@ -1429,11 +1515,18 @@ async function callClaude(model, messages, conversationId, keyName, res) {
const { events, remainder } = parseStreamJsonLines(lineBuffer); const { events, remainder } = parseStreamJsonLines(lineBuffer);
lineBuffer = remainder; lineBuffer = remainder;
for (const event of events) { for (const event of events) {
const parsed = parseStreamJsonEvent(event, isFirstDelta); const parsed = parseStreamJsonEvent(event, sawTextDelta);
if (!parsed) continue; if (!parsed) continue;
if (parsed.text !== undefined) { if (parsed.text !== undefined) {
assembledText += parsed.text; if (parsed.fromDelta) {
isFirstDelta = false; assembledText += parsed.text;
sawTextDelta = true;
} else {
// aggregate assistant message — separate successive messages so the preamble and
// the post-tool-use final answer don't run together.
if (assembledText && !assembledText.endsWith("\n")) assembledText += "\n\n";
assembledText += parsed.text;
}
} else if (parsed.stop) { } else if (parsed.stop) {
resultEventSeen = true; resultEventSeen = true;
} else if (parsed.error) { } else if (parsed.error) {
@@ -1855,9 +1948,10 @@ async function callClaudeStreaming(model, messages, conversationId, res, authInf
let stderr = ""; let stderr = "";
let headersSent = false; let headersSent = false;
let totalChars = 0; 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 cachedContent = ""; // accumulate for cache write-back
let lineBuffer = ""; let lineBuffer = "";
let isFirstDelta = true; let sawTextDelta = false;
let resultEventSeen = false; let resultEventSeen = false;
// Separate flag for is_error result — must NOT be conflated with resultEventSeen. // 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 // If errored===true the close handler must not cache the response or record success
@@ -1894,15 +1988,26 @@ async function callClaudeStreaming(model, messages, conversationId, res, authInf
lineBuffer = remainder; lineBuffer = remainder;
for (const event of events) { for (const event of events) {
const parsed = parseStreamJsonEvent(event, isFirstDelta); const parsed = parseStreamJsonEvent(event, sawTextDelta);
if (!parsed) continue; if (!parsed) continue;
if (parsed.text !== undefined) { if (parsed.text !== undefined) {
// content_block_delta text — forward as SSE delta // Streamed delta, or an aggregate assistant-message text (agentic turns emit several).
const text = parsed.text; // For an aggregate message after earlier text, prepend a separator so the preamble and
// the post-tool-use final answer don't run together in the forwarded stream.
let text = parsed.text;
if (parsed.fromDelta) {
sawTextDelta = true;
} else if (totalChars > 0 && !streamEndsWithNewline) {
// Mirror the buffered path's guard (assembledText.endsWith("\n")): only inject the
// blank-line separator when the already-emitted text doesn't already end in a newline,
// so a message ending in "\n" doesn't produce a triple newline here while the buffered
// path produces a single. Keeps the two assembly paths byte-identical. (PR #183 review.)
text = "\n\n" + text;
}
streamEndsWithNewline = text.endsWith("\n");
totalChars += text.length; totalChars += text.length;
if (CACHE_TTL > 0) cachedContent += text; if (CACHE_TTL > 0) cachedContent += text;
isFirstDelta = false;
if (!ensureHeaders()) continue; if (!ensureHeaders()) continue;
sendSSE(res, { sendSSE(res, {
@@ -2072,6 +2177,31 @@ function completionResponse(res, id, model, content) {
}); });
} }
// OpenAI's designated mechanism for "the model would not produce the required output" is the
// assistant `refusal` field (content:null, refusal:<text>, finish_reason:"stop") — NOT an invented
// error type. Structured-output exhaustion emits this so SDK clients take their written `refusal`
// branch instead of throwing an opaque UnprocessableEntityError. (PR #153 review, finding 3.)
function refusalResponse(res, id, model, refusal) {
jsonResponse(res, 200, {
id, object: "chat.completion",
created: Math.floor(Date.now() / 1000),
model,
choices: [{ index: 0, message: { role: "assistant", content: null, refusal }, finish_reason: "stop" }],
usage: { prompt_tokens: 0, completion_tokens: 0, total_tokens: 0 },
});
}
// Streaming form of refusalResponse: a role chunk, a `refusal` delta, then the stop chunk.
function streamRefusalAsSSE(res, id, model, refusal) {
const created = Math.floor(Date.now() / 1000);
res.writeHead(200, { "Content-Type": "text/event-stream", "Cache-Control": "no-cache", "Connection": "keep-alive", "X-Accel-Buffering": "no" });
sendSSE(res, { id, object: "chat.completion.chunk", created, model, choices: [{ index: 0, delta: { role: "assistant" }, finish_reason: null }] });
sendSSE(res, { id, object: "chat.completion.chunk", created, model, choices: [{ index: 0, delta: { refusal }, finish_reason: null }] });
sendSSE(res, { id, object: "chat.completion.chunk", created, model, choices: [{ index: 0, delta: {}, finish_reason: "stop" }] });
res.write("data: [DONE]\n\n");
res.end();
}
// Replay a complete string as a chunked SSE stream (80 codepoints/chunk). // 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 // 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). // (buffered response replayed as SSE so clients get the same wire format).
@@ -2573,18 +2703,58 @@ async function handleSettings(req, res) {
} }
// ── Handle chat completions ───────────────────────────────────────────── // ── Handle chat completions ─────────────────────────────────────────────
const MAX_BODY_SIZE = 5 * 1024 * 1024; // 5 MB // Default 5 MB, byte-for-byte unchanged unless CLAUDE_MAX_BODY_SIZE is set. Base64
// image payloads inflate ~33%; operators enabling large images (issue #110) can
// raise this to admit bigger requests. Parsed fail-closed (PR #154 review F3): a
// bad value (`unlimited` → NaN, `5MB` → 5) must not disable the body cap or brick
// the proxy — parseIntEnv keeps the 5 MB default and warns instead.
const MAX_BODY_SIZE = parseIntEnv("CLAUDE_MAX_BODY_SIZE", 5 * 1024 * 1024);
const MAX_BODY_SIZE_LABEL = `${Math.round(MAX_BODY_SIZE / (1024 * 1024))}MB`;
// Set of all valid model identifiers (canonical IDs + aliases) // Set of all valid model identifiers (canonical IDs + aliases)
const VALID_MODELS = new Set(Object.keys(MODEL_MAP)); 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) { async function handleChatCompletions(req, res) {
let body = ""; let body = "";
try { try {
for await (const chunk of req) { for await (const chunk of req) {
body += chunk; body += chunk;
if (body.length > MAX_BODY_SIZE) { if (body.length > MAX_BODY_SIZE) {
return jsonResponse(res, 413, { error: { message: "Request body too large (max 5MB)", type: "invalid_request_error" } }); return jsonResponse(res, 413, { error: { message: `Request body too large (max ${MAX_BODY_SIZE_LABEL})`, type: "invalid_request_error" } });
} }
} }
} catch (e) { } catch (e) {
@@ -2613,6 +2783,57 @@ async function handleChatCompletions(req, res) {
return jsonResponse(res, 400, { error: { message: "'messages' must be a non-empty array", type: "invalid_request_error" } }); 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 // 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 // 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 // boundary can overshoot the cap by up to MAX_CONCURRENT, and cache hits (served before
@@ -2634,6 +2855,74 @@ async function handleChatCompletions(req, res) {
} }
} }
// Structured output (OpenAI response_format / json_mode): its own path — the response must be
// schema-valid JSON, so it never shares the conversational cache slot. When caching is enabled it
// uses a structured-keyed hash (isolated via cacheHash's `structured` marker) and writes back ONLY
// a validated result (never a 422). Always validates on a miss.
const structured = detectStructuredOutput(parsed);
if (structured) {
const t0s = Date.now();
const promptCharsS = messages.reduce((a, m) => a + contentToText(m.content).length, 0);
let structuredHash = null;
if (CACHE_TTL > 0 && !conversationId && !hasCacheControl(messages)) {
structuredHash = cacheHash(model, messages, { keyId: req._authKeyId, temperature: parsed.temperature, max_tokens: parsed.max_tokens, top_p: parsed.top_p, structured });
try {
const cached = getCachedResponse(structuredHash, CACHE_TTL);
if (cached) {
logEvent("info", "cache_hit", { model, hash: structuredHash.slice(0, 12), hits: cached.hits, structured: true });
const id = `chatcmpl-${randomUUID()}`;
if (stream) streamStringAsSSE(res, id, model, cached.response);
else completionResponse(res, id, model, cached.response);
return;
}
} catch (e) { logEvent("error", "cache_check_failed", { error: e.message }); }
}
const upstreamCall = TUI_MODE ? callClaudeTui : callClaude;
// Stampede protection (PR #153 review, finding 5): a structured request can cost up to
// STRUCTURED_MAX_ATTEMPTS metered spawns, so N identical concurrent requests (Home Assistant
// firing several AI Tasks at once) must NOT each pay N× — they share one flight. We dedup every
// one-off structured request (not stateful sessions / client-side prompt caching), independent of
// whether OCP response caching is enabled; when caching IS on, the same key gates cache read/write.
const dedupKey = (!conversationId && !hasCacheControl(messages))
? cacheHash(model, messages, { keyId: req._authKeyId, temperature: parsed.temperature, max_tokens: parsed.max_tokens, top_p: parsed.top_p, structured })
: null;
const runStructured = async () => {
const c = await runStructuredCompletion(upstreamCall, model, messages, conversationId, req._authKeyName, res, structured);
if (structuredHash) { try { setCachedResponse(structuredHash, model, c); } catch (e) { logEvent("error", "cache_write_failed", { error: e.message }); } }
return c;
};
try {
const content = dedupKey
? await singleflight(dedupKey, async () => {
// A follower that raced in after the leader populated the cache re-reads it here.
if (structuredHash) { const rc = getCachedResponse(structuredHash, CACHE_TTL); if (rc) return rc.response; }
return runStructured();
}, (err) => err instanceof RequestDisconnectedError && !res.destroyed)
: await runStructured();
const id = `chatcmpl-${randomUUID()}`;
if (stream) streamStringAsSSE(res, id, model, content);
else completionResponse(res, id, model, content);
try { recordUsage({ keyId: req._authKeyId, keyName: req._authKeyName, model, promptChars: promptCharsS, responseChars: content.length, elapsedMs: Date.now() - t0s, success: true }); } catch (e) { logEvent("error", "usage_record_failed", { error: e.message }); }
return;
} catch (err) {
if (err instanceof RequestDisconnectedError) { try { res.end(); } catch {} return; }
try { recordUsage({ keyId: req._authKeyId, keyName: req._authKeyName, model, promptChars: promptCharsS, responseChars: 0, elapsedMs: Date.now() - t0s, success: false }); } catch {}
if (res.headersSent || res.writableEnded || res.destroyed) { try { res.end(); } catch {} return; }
if (err instanceof StructuredOutputError) {
// OpenAI's spec mechanism for "model would not produce the required output" is the assistant
// `refusal` field (200, content:null, finish_reason:"stop"), NOT an invented 422 error type —
// so SDK clients take their written refusal branch. (PR #153 review, finding 3.)
logEvent("warn", "structured_failed", { reason: err.reason });
const id = `chatcmpl-${randomUUID()}`;
const refusal = `Could not produce a response matching the requested response_format after ${STRUCTURED_MAX_ATTEMPTS} attempts (${sanitizeError(err.reason)}).`;
if (stream) streamRefusalAsSSE(res, id, model, refusal);
else refusalResponse(res, id, model, refusal);
return;
}
return respondUpstreamError(res, err);
}
}
// Cache check (only when cache is enabled and no active conversation/session) // Cache check (only when cache is enabled and no active conversation/session)
if (CACHE_TTL > 0 && !conversationId) { if (CACHE_TTL > 0 && !conversationId) {
// D2: skip OCP cache entirely when messages carry cache_control annotations; // D2: skip OCP cache entirely when messages carry cache_control annotations;
+682 -28
View File
@@ -844,7 +844,49 @@ test("doctor falls back to currentVersion when origin/main unreachable (no stale
// contract lives in lib/prompt.mjs. Mutation-proof: make appendOperatorPrompt // contract lives in lib/prompt.mjs. Mutation-proof: make appendOperatorPrompt
// return `base` unconditionally and the first test fails; make it stop trimming // return `base` unconditionally and the first test fails; make it stop trimming
// and the whitespace test fails. // and the whitespace test fails.
import { appendOperatorPrompt } from "./lib/prompt.mjs"; 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");
});
console.log("\nSystem-prompt operator append:"); console.log("\nSystem-prompt operator append:");
@@ -1313,7 +1355,7 @@ function parseStreamJsonLines(buffered) {
return { events, remainder: remainder ?? "" }; return { events, remainder: remainder ?? "" };
} }
function parseStreamJsonEvent(event, isFirstDelta) { function parseStreamJsonEvent(event, sawTextDelta) {
const t = event?.type; const t = event?.type;
// system/* — first-event init + other system meta (api_retry etc.) // system/* — first-event init + other system meta (api_retry etc.)
@@ -1325,19 +1367,19 @@ function parseStreamJsonEvent(event, isFirstDelta) {
if (t === "stream_event") { if (t === "stream_event") {
const inner = event.event ?? event; const inner = event.event ?? event;
if (inner?.type === "content_block_delta" && inner.delta?.type === "text_delta") { if (inner?.type === "content_block_delta" && inner.delta?.type === "text_delta") {
return { text: inner.delta.text ?? "" }; return { text: inner.delta.text ?? "", fromDelta: true };
} }
// Other stream_event sub-types (content_block_start, message_delta, etc.) — consumed // Other stream_event sub-types (content_block_start, message_delta, etc.) — consumed
return null; return null;
} }
// assistant — aggregate message (fallback when no prior content_block_delta seen) // assistant — aggregate message. Without --include-partial-messages each assistant message
// Empirically (claude CLI without --include-partial-messages, verified v2.1.104 through v2.1.158): fast/short // arrives as its own aggregate event; an agentic turn emits several (preamble + tool rounds +
// responses may emit ONLY the aggregate assistant event, no content_block_delta events. // final answer), so accumulate EVERY one. Only guard the delta+aggregate double-count case:
// If isFirstDelta is true, extract text here; otherwise it's a duplicate, ignore. // if streaming deltas were already seen (sawTextDelta), the aggregate duplicates them.
// Reference: OLP commit 65f945c (assistant-aggregate fallback, fold-in). // Reference: OLP commit 65f945c (assistant-aggregate fallback, fold-in).
if (t === "assistant") { if (t === "assistant") {
if (isFirstDelta) { if (!sawTextDelta) {
const blocks = event.message?.content; const blocks = event.message?.content;
if (Array.isArray(blocks)) { if (Array.isArray(blocks)) {
const text = blocks const text = blocks
@@ -1386,25 +1428,25 @@ test("parseStreamJsonEvent: stream_event content_block_delta yields text", () =>
type: "stream_event", type: "stream_event",
event: { type: "content_block_delta", delta: { type: "text_delta", text: "Hello" } } event: { type: "content_block_delta", delta: { type: "text_delta", text: "Hello" } }
}; };
const result = parseStreamJsonEvent(event, true); const result = parseStreamJsonEvent(event, false);
assert.deepEqual(result, { text: "Hello" }); assert.deepEqual(result, { text: "Hello", fromDelta: true });
}); });
test("parseStreamJsonEvent: assistant-aggregate used when isFirstDelta=true (no prior delta)", () => { 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, true);
assert.deepEqual(result, { text: "Short answer." });
});
test("parseStreamJsonEvent: assistant-aggregate skipped when isFirstDelta=false (no double-count)", () => {
const event = { const event = {
type: "assistant", type: "assistant",
message: { content: [{ type: "text", text: "Short answer." }] } message: { content: [{ type: "text", text: "Short answer." }] }
}; };
const result = parseStreamJsonEvent(event, false); 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)", () => {
const event = {
type: "assistant",
message: { content: [{ type: "text", text: "Short answer." }] }
};
const result = parseStreamJsonEvent(event, true);
assert.equal(result, null); assert.equal(result, null);
}); });
@@ -1418,14 +1460,39 @@ test("parseStreamJsonEvent: stream_event + assistant → assembled without doubl
type: "assistant", type: "assistant",
message: { content: [{ type: "text", text: "Streaming text." }] } message: { content: [{ type: "text", text: "Streaming text." }] }
}; };
// First event: isFirstDelta=true → yields text // First event: no delta seen yet → yields text and marks fromDelta
const r1 = parseStreamJsonEvent(delta, true); const r1 = parseStreamJsonEvent(delta, false);
assert.deepEqual(r1, { text: "Streaming text." }); assert.deepEqual(r1, { text: "Streaming text.", fromDelta: true });
// Second event (aggregate): isFirstDelta is now false (content already emitted) → null // Second event (aggregate): a delta was seen (sawTextDelta=true) → duplicate, null
const r2 = parseStreamJsonEvent(agg, false); const r2 = parseStreamJsonEvent(agg, true);
assert.equal(r2, null); 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 // (b) aggregate-only short response → assembles correctly
test("parseStreamJsonEvent: aggregate-only multi-block response assembles all text blocks", () => { test("parseStreamJsonEvent: aggregate-only multi-block response assembles all text blocks", () => {
const event = { const event = {
@@ -1438,7 +1505,7 @@ test("parseStreamJsonEvent: aggregate-only multi-block response assembles all te
] ]
} }
}; };
const result = parseStreamJsonEvent(event, true); const result = parseStreamJsonEvent(event, false);
assert.deepEqual(result, { text: "Part one. Part two." }); assert.deepEqual(result, { text: "Part one. Part two." });
}); });
@@ -1456,8 +1523,8 @@ test("parseStreamJsonLines: partial line carried as remainder", () => {
assert.equal(ev2[0].type, "stream_event"); assert.equal(ev2[0].type, "stream_event");
assert.equal(rem2, ""); assert.equal(rem2, "");
// Verify the reassembled event parses through parseStreamJsonEvent correctly // Verify the reassembled event parses through parseStreamJsonEvent correctly
const parsed = parseStreamJsonEvent(ev2[0], true); const parsed = parseStreamJsonEvent(ev2[0], false);
assert.deepEqual(parsed, { text: "Hi" }); assert.deepEqual(parsed, { text: "Hi", fromDelta: true });
}); });
test("parseStreamJsonLines: empty input returns no events and empty remainder", () => { test("parseStreamJsonLines: empty input returns no events and empty remainder", () => {
@@ -3371,6 +3438,337 @@ test("contentToText: null returns empty string", () => {
assert.equal(contentToText(null), ""); 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) ──────────────────────── // ── messages guard predicate truth-table (issue #110) ────────────────────────
// Mirrors the guard at server.mjs line ~1650: Array.isArray(x) && x.length > 0 // Mirrors the guard at server.mjs line ~1650: Array.isArray(x) && x.length > 0
console.log("\nmessages guard predicate (issue #110):"); console.log("\nmessages guard predicate (issue #110):");
@@ -3957,6 +4355,262 @@ test("stream: /health block is additive and exposes the divergence counter", ()
assert.equal(legacy.streamDivergences, 0); 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 ── // ── Cleanup ──
// Settle the async-bodied tests registered through the sync `test()` helper BEFORE summarizing — // 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). // otherwise their pass/fail is not reflected in the counts (see the `pendingAsync` comment above).