mirror of
https://github.com/dtzp555-max/ocp.git
synced 2026-07-22 13:35:08 +00:00
Compare commits
7
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
9df622c7f8 | ||
|
|
2ad8a5245d | ||
|
|
47e324b68f | ||
|
|
4143993e3b | ||
|
|
788cbbcd99 | ||
|
|
ac81badda1 | ||
|
|
fe12419386 |
@@ -27,7 +27,7 @@ One proxy. Multiple IDEs. All models. **$0 API cost.**
|
||||
- [How It Works](#how-it-works)
|
||||
- Reference: [Available Models](#available-models) · [API Endpoints](#api-endpoints) · [Environment Variables](#environment-variables)
|
||||
- Modes & operations: [LAN & multi-user](#lan--multi-user) → [`docs/lan-mode.md`](docs/lan-mode.md) · [Subscription-pool (TUI) mode](#subscription-pool-tui-mode) → [`docs/tui-mode.md`](docs/tui-mode.md) · [Upgrading](#upgrading) → [`docs/upgrading.md`](docs/upgrading.md)
|
||||
- [Built-in Usage Monitoring](#built-in-usage-monitoring) · [Response Cache](#response-cache) · [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)
|
||||
- [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_QUEUE` | `16` | Max requests **waiting** for a `-p` concurrency slot. Beyond `CLAUDE_MAX_CONCURRENT`, requests queue (up to this cap) instead of being rejected; when the queue is **also** full, the request gets `HTTP 429` + `Retry-After` (not an opaque 500). Surfaced on `/health.concurrency` + `/health.stats.queueRejections`. |
|
||||
| `CLAUDE_QUEUE_RETRY_AFTER` | `5` | Seconds advertised in the `Retry-After` header on a `-p` concurrency-overflow `429`. |
|
||||
| `CLAUDE_MAX_PROMPT_CHARS` | `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** (≈150–200k 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_CACHE_TTL` | `0` | Response cache TTL (ms, 0 = disabled). Set to e.g. `300000` for 5-min cache. See [Response Cache](#response-cache). |
|
||||
| `CLAUDE_ALLOWED_TOOLS` | `Bash,Read,...,Agent` | Comma-separated tools to pre-approve |
|
||||
| `CLAUDE_SKIP_PERMISSIONS` | `false` | Bypass all permission checks |
|
||||
| `CLAUDE_MCP_CONFIG` | *(unset)* | Path to an MCP server config JSON, passed to the spawned `claude` as `--mcp-config` (both the `-p` path and TUI `OCP_TUI_FULL_TOOLS` panes) |
|
||||
| `CLAUDE_MAX_BODY_SIZE` | `5242880` | Max request body size (bytes, default 5 MB). Base64 image payloads inflate ~33%; raise this to admit larger multimodal requests. Fail-closed parsing: a garbage value keeps the default. |
|
||||
| `CLAUDE_IMAGE_ALLOW_URL` | `false` | Allow remote `http(s)` image URLs in `image_url` parts. **Off by default** (v1 supports base64 `data:` URIs only). When on, the URL is passed through to Anthropic as a `url` image source — **OCP does not fetch it** (no OCP-side SSRF surface); unreachable/blocked URLs surface as an API error. |
|
||||
| `CLAUDE_MAX_IMAGE_BYTES` | `5242880` | Per-image decoded-byte cap (default 5 MB). Over-cap images get `HTTP 413`. |
|
||||
| `CLAUDE_MAX_IMAGES` | `20` | Max image parts per request. Over-cap gets `HTTP 413`. |
|
||||
| `CLAUDE_MAX_IMAGE_TOTAL_BYTES` | `20971520` | Aggregate decoded-byte cap across all images in a request (default 20 MB). Over-cap gets `HTTP 413`. |
|
||||
| `CLAUDE_SYSTEM_PROMPT` | *(unset)* | Operator-wide system-prompt text appended (last) to every request's composed system prompt on the default `-p` path. TUI-mode panes are unaffected (they keep the interactive CLI's own system prompt). Echoed truncated on `/health.systemPrompt`. Note: changing this value and restarting auto-invalidates the response cache (the key carries a boot-config epoch, #177). |
|
||||
| `CLAUDE_NO_CONTEXT` | `false` | Suppress CLAUDE.md and auto-memory injection (pure API mode) |
|
||||
| `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.
|
||||
|
||||
## Structured Outputs (OpenAI `response_format`)
|
||||
|
||||
`/v1/chat/completions` honors OpenAI's [`response_format`](https://platform.openai.com/docs/api-reference/chat/create#chat-create-response_format) parameter so OpenAI-SDK clients that require machine-parseable JSON (Home Assistant AI Tasks, Honcho, BYO scripts) get JSON in `choices[].message.content` — not prose.
|
||||
|
||||
Supported shapes:
|
||||
|
||||
- `response_format: { "type": "json_schema", "json_schema": { "name", "strict", "schema" } }`
|
||||
- `response_format: { "type": "json_object" }`
|
||||
- `json_mode: true` — non-standard top-level alias honored by several OpenAI-compatible clients; treated as `json_object`.
|
||||
|
||||
When a structured request is detected, OCP:
|
||||
|
||||
1. Appends a strict JSON-only steering instruction to the request (no Markdown, no fences, no prose, must begin with `{` or `[`).
|
||||
2. Extracts the JSON from the model reply (unwraps a stray code fence / prose via a string-aware balanced slice).
|
||||
3. For `json_schema`, validates the result against the supplied schema (types, `required`, `enum`, `const`, `additionalProperties`, nullability, `items`, `min/maxItems`, and `$ref`/`$defs` + `allOf`/`anyOf`/`oneOf` composition — the shapes the official OpenAI SDK emits via `zodResponseFormat` / `client.beta.chat.completions.parse`). For `json_object`, the whole reply must parse as a single JSON value (a stray brace inside prose is not served as the answer).
|
||||
4. On a parse/validation miss, retries with a stronger instruction that names the failure, up to `OCP_STRUCTURED_MAX_ATTEMPTS` (default 3).
|
||||
5. If no valid JSON can be produced, returns OpenAI's assistant **`refusal`** field (`HTTP 200`, `message.content: null`, `message.refusal: "<reason>"`, `finish_reason: "stop"`) — the spec's own mechanism for "the model would not produce the required output" — rather than an invented error type or passing prose through. SDK clients take their written `refusal` branch.
|
||||
|
||||
A reply that carries **more than one** top-level JSON value (e.g. `Schema: {…}` then `Answer: {…}`) is rejected as ambiguous rather than silently serving the first — OCP never serves an unvalidated or arbitrarily-chosen extraction.
|
||||
|
||||
`message.content` for a structured request is the raw JSON string only — no fences, no reasoning, no wrapper. Non-structured requests are completely unaffected (normal conversational behaviour, streaming included). This is a Class B.1 endpoint extension authorized by ADR 0006; the pure logic lives in [`lib/structured-output.mjs`](./lib/structured-output.mjs) and is unit-tested in `test-features.mjs`.
|
||||
|
||||
**Caching & cost.** A structured request can cost up to `OCP_STRUCTURED_MAX_ATTEMPTS` metered `claude` spawns — each retry is a fresh spawn, burning subscription-window quota today and metered credits if the (currently **paused**) 2026-06-15 billing split re-lands (see the billing-policy status note in [How It Works](#how-it-works)) — so this feature adds cost-attack surface. Two guards bound it: (a) identical **concurrent** structured requests share one flight (single-flight dedup, so N callers ≠ N× spawns), and (b) when `CLAUDE_CACHE_TTL > 0`, a **validated** result is cached on a **structured-keyed** hash (the `response_format`/schema is folded into the key, so a JSON reply never collides with the conversational answer and different schemas never share a slot). A refusal is never cached. Operators concerned about cost can lower `OCP_STRUCTURED_MAX_ATTEMPTS` to `1` (no retries) or gate the surface behind per-key quotas (`/api/keys/:id/quota`).
|
||||
## Images / Multimodal (Vision)
|
||||
|
||||
`POST /v1/chat/completions` accepts OpenAI-style multimodal `content` parts, so a
|
||||
message can carry images alongside text and Claude will actually see them. This
|
||||
follows OpenAI's [vision](https://platform.openai.com/docs/guides/vision) /
|
||||
[chat-completions `image_url`](https://platform.openai.com/docs/api-reference/chat/create#chat-create-messages)
|
||||
request shape — no OCP-invented fields. (Class B.1 endpoint; see ADR 0006.)
|
||||
|
||||
Under the hood, when a request carries an image OCP feeds the conversation to the
|
||||
Claude CLI as Anthropic image blocks over `--input-format stream-json`. Text-only
|
||||
requests are completely unaffected (unchanged code path).
|
||||
|
||||
### Supported input
|
||||
|
||||
- **Base64 data URIs** (default, recommended):
|
||||
`data:image/png;base64,<...>`. Media types: `image/jpeg`, `image/png`,
|
||||
`image/gif`, `image/webp`.
|
||||
- **Remote `http(s)` URLs** — **off by default**. Set `CLAUDE_IMAGE_ALLOW_URL=1`
|
||||
to enable; the URL is passed through to Anthropic (OCP never fetches it itself,
|
||||
so there is no OCP-side SSRF surface).
|
||||
- Images may appear in **any** message in the history (multi-turn), not just the
|
||||
last one.
|
||||
- Non-image, non-text parts (audio, files) are **not** yet supported and are
|
||||
replaced with a `[non-text content omitted]` placeholder (deferred to a future
|
||||
version).
|
||||
|
||||
### Example (base64 data URI)
|
||||
|
||||
```bash
|
||||
curl -X POST http://127.0.0.1:3456/v1/chat/completions \
|
||||
-H 'Content-Type: application/json' \
|
||||
-d '{
|
||||
"model": "claude-sonnet-4-6",
|
||||
"messages": [{
|
||||
"role": "user",
|
||||
"content": [
|
||||
{ "type": "text", "text": "What is in this image?" },
|
||||
{ "type": "image_url",
|
||||
"image_url": { "url": "data:image/png;base64,iVBORw0KGgoAAA..." } }
|
||||
]
|
||||
}]
|
||||
}'
|
||||
```
|
||||
|
||||
### Not supported in TUI mode
|
||||
|
||||
Multimodal images require the default `-p` spawn path. In **TUI / subscription-pool
|
||||
mode** (`CLAUDE_TUI_MODE=true`) the CLI is driven interactively and cannot carry
|
||||
image blocks, so a request with an `image_url` part returns **`400
|
||||
images_unsupported_in_tui_mode`** rather than silently dropping the image and
|
||||
answering about something the model never saw. Remove the images, or run OCP
|
||||
without TUI mode, to use vision.
|
||||
|
||||
Images must also live in a **user or assistant** message, not a `system` message
|
||||
(system content is not forwarded to the CLI as image blocks). An `image_url` part
|
||||
present only in a system message returns **`400 images_unsupported_in_system_messages`**
|
||||
for the same reason — fail loudly rather than answer about an unseen image. This matches
|
||||
the OpenAI vision spec, which does not place images in the system role.
|
||||
|
||||
### Limits
|
||||
|
||||
Images bypass the text `CLAUDE_MAX_PROMPT_CHARS` budget and are instead bounded by
|
||||
their own byte/count caps. The **text** in a multimodal request is still subject to
|
||||
`CLAUDE_MAX_PROMPT_CHARS` (older text is truncated exactly as on the text-only
|
||||
path — only the image bytes are exempt). All numeric caps are parsed **fail-closed**:
|
||||
a malformed value (e.g. `CLAUDE_MAX_BODY_SIZE=unlimited` or `=5MB`) is rejected with
|
||||
a startup warning and the safe default is kept — a misconfigured cap can never
|
||||
silently disable the guard. Requests that violate a cap get a clear `4xx` (never a
|
||||
silent drop):
|
||||
|
||||
| Cap | Env var | Default | Error |
|
||||
|-----|---------|---------|-------|
|
||||
| Request body | `CLAUDE_MAX_BODY_SIZE` | 5 MB | `413` request body too large |
|
||||
| Per-image bytes | `CLAUDE_MAX_IMAGE_BYTES` | 5 MB | `413` `image_too_large` |
|
||||
| Total image bytes | `CLAUDE_MAX_IMAGE_TOTAL_BYTES` | 20 MB | `413` `images_too_large` |
|
||||
| Image count | `CLAUDE_MAX_IMAGES` | 20 | `413` `too_many_images` |
|
||||
| Unsupported media type | — | — | `400` `unsupported_image_type` |
|
||||
| Malformed data URI | — | — | `400` `invalid_data_uri` |
|
||||
| Remote URL while disabled | `CLAUDE_IMAGE_ALLOW_URL` | off | `400` `remote_url_disabled` |
|
||||
|
||||
Base64 payloads are large: a 5 MB image is ~6.7 MB as a data URI, so raise
|
||||
`CLAUDE_MAX_BODY_SIZE` (and, if needed, `CLAUDE_MAX_IMAGE_BYTES`) to admit big
|
||||
images. Vision support depends on the target model — request a current
|
||||
vision-capable Claude model.
|
||||
|
||||
## OpenClaw Integration
|
||||
|
||||
OCP was originally built for [OpenClaw](https://github.com/openclaw/openclaw) and includes deep integration:
|
||||
|
||||
@@ -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 ~1–1.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 ≈150–200k 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.
|
||||
@@ -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 1–5 verbatim; Class B endpoints (OCP-owned compatibility surface — `/v1/chat/completions`, `/v1/models`, admin endpoints) are anchored to OpenAI's spec (B.1) or to an authorizing ADR (B.2). Triggered by PR #99 (external `response_format` honoring). Grandfathers the existing B.2 inventory at v3.16.4. |
|
||||
| [0007](0007-tui-interactive-mode.md) | TUI Interactive Mode | Why TUI-mode spawns an interactive `claude` in a tmux pane (no `-p`) to reach the **subscription** billing pool (`cc_entrypoint=cli`) rather than the metered Agent SDK pool. Owns the TUI spawn machinery: entrypoint labeling, credential-isolated home, MCP hard-disable, session namespace + defunct-session reaping, the independent concurrency bound, and the `/health` `tui` block. **Single-user only** — hard FATAL on multi-user configs. |
|
||||
| [0008](0008-tui-warm-pane-pool.md) | TUI Warm Pane Pool | Why `OCP_TUI_POOL_SIZE` pre-boots **single-use** `claude` panes (one turn each, own `--session-id`) — and why reuse is forbidden (`transcript.mjs` returns the last assistant entry in the file, so a reused session leaks the earlier turn's text). Measured −41% end-to-end. Defines the pool↔reaper invariant (exemption by exact name from a live registry; drain before every sweep so `kill-server` zombie reaping survives) and the standing idle-process cost. Extends ADR 0007. |
|
||||
| [0009](0009-spot-derived-prompt-budget.md) | SPOT-Derived Prompt Budget | Why `MAX_PROMPT_CHARS`'s default is `max(models.json contextWindow) × 3 chars/token` (600k chars today) instead of a hand-set constant — the old 150k silently under-delivered the advertised window ~5×. ×3 is the CJK-safe multiplier; env/settings stay absolute overrides; whether to advertise 1M windows is explicitly a separate decision. |
|
||||
|
||||
## When to write a new ADR
|
||||
|
||||
|
||||
@@ -360,6 +360,11 @@ export function cacheHash(model, messages, opts = {}) {
|
||||
// the persistent cache instead of serving answers composed under the old config. Callers
|
||||
// that omit it (older paths, tests) hash byte-identically to before.
|
||||
if (opts.configEpoch != null) h.update(`ce:${opts.configEpoch}|`);
|
||||
// Structured-output (OpenAI response_format / json_mode) requests must never share a cache slot
|
||||
// with the conversational answer to the same prompt, nor with a different schema — the steering
|
||||
// instruction and validated JSON payload differ. Keying on the detected descriptor isolates them.
|
||||
// Absent for normal requests → hashes are byte-identical to pre-change.
|
||||
if (opts.structured != null) h.update(`s:${JSON.stringify(opts.structured)}`);
|
||||
for (const m of messages) {
|
||||
h.update(m.role || "");
|
||||
h.update(typeof m.content === "string" ? m.content : JSON.stringify(m.content));
|
||||
|
||||
+29
@@ -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 };
|
||||
}
|
||||
@@ -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 };
|
||||
}
|
||||
@@ -15,3 +15,40 @@ export function appendOperatorPrompt(base, operatorAppend) {
|
||||
const op = typeof operatorAppend === "string" ? operatorAppend.trim() : "";
|
||||
return op ? `${base}\n\n${op}` : base;
|
||||
}
|
||||
|
||||
// Derive the default prompt-char budget from the models.json SPOT (ADR 0009).
|
||||
//
|
||||
// The old default was a hand-set constant (150000 chars ≈ 37.5k English tokens) from the
|
||||
// 200k-window era — silently far below what the advertised contextWindow promises. Instead
|
||||
// of picking a new constant that will also rot, the default now FOLLOWS the SPOT:
|
||||
//
|
||||
// budget = max(models[].contextWindow) × charsPerToken
|
||||
//
|
||||
// charsPerToken = 3 is deliberately conservative: English runs ~4 chars/token, CJK ~1–1.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);
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
+325
-36
@@ -42,6 +42,7 @@ import { dirname, join } from "node:path";
|
||||
import { homedir } from "node:os";
|
||||
import { validateKey, recordUsage, getUsageByKey, getUsageTimeline, getRecentUsage, createKey, listKeys, revokeKey, closeDb, checkQuota, updateKeyQuota, getKeyQuota, findKey, cacheHash, getCachedResponse, setCachedResponse, clearCache, getCacheStats, hasCacheControl, singleflight, getInflightStats } from "./keys.mjs";
|
||||
import { DEFAULT_PORT } from "./lib/constants.mjs";
|
||||
import { StructuredOutputError, detectStructuredOutput, validateJsonSchemaSafe, extractJsonPayload, structuredSystemInstruction, resolveMaxAttempts } from "./lib/structured-output.mjs";
|
||||
import { isLoopbackBind } from "./lib/net.mjs";
|
||||
import { runTuiTurn, reapStaleTuiSessions, resolveTuiHome, bootTuiPane, tuiPaneHealthy, poolPaneName, POOL_BOOT_MS } from "./lib/tui/session.mjs";
|
||||
import { detectTuiUpstreamError } from "./lib/tui/transcript.mjs";
|
||||
@@ -49,7 +50,9 @@ import { TuiSemaphore, SemaphoreAbortError, recordTuiEntrypoint, buildTuiHealthB
|
||||
import { TuiPanePool, resolvePoolSize, POOL_MAX_SIZE } from "./lib/tui/pool.mjs";
|
||||
import { TuiDeltaAssembler, DEFAULT_HOLDBACK_CHARS, resolveStreamHoldback } from "./lib/tui/stream.mjs";
|
||||
import { createSerialMutex, createTtlCache, isTokenExpiring, orderLabelsLastGoodFirst } from "./lib/spawn-auth.mjs";
|
||||
import { 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 _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).
|
||||
//
|
||||
// @param {object} event — parsed NDJSON event
|
||||
// @param {boolean} isFirstDelta — true if no content has been yielded yet
|
||||
function parseStreamJsonEvent(event, isFirstDelta) {
|
||||
// @param {boolean} sawTextDelta — true if a streaming content_block_delta text was already seen
|
||||
function parseStreamJsonEvent(event, sawTextDelta) {
|
||||
const t = event?.type;
|
||||
|
||||
// system/* — first-event init + other system meta (api_retry etc.)
|
||||
@@ -261,19 +264,23 @@ function parseStreamJsonEvent(event, isFirstDelta) {
|
||||
if (t === "stream_event") {
|
||||
const inner = event.event ?? event;
|
||||
if (inner?.type === "content_block_delta" && inner.delta?.type === "text_delta") {
|
||||
return { text: inner.delta.text ?? "" };
|
||||
return { text: inner.delta.text ?? "", fromDelta: true };
|
||||
}
|
||||
// Other stream_event sub-types (content_block_start, message_delta, etc.) — consumed
|
||||
return null;
|
||||
}
|
||||
|
||||
// assistant — aggregate message (fallback when no prior content_block_delta seen)
|
||||
// Empirically (claude CLI without --include-partial-messages, verified v2.1.104 through v2.1.158): fast/short
|
||||
// responses may emit ONLY the aggregate assistant event, no content_block_delta events.
|
||||
// If isFirstDelta is true, extract text here; otherwise it's a duplicate, ignore.
|
||||
// assistant — aggregate message. claude CLI without --include-partial-messages emits NO
|
||||
// content_block_delta events; each assistant message arrives as its own aggregate `assistant`
|
||||
// event. An agentic/tool-using turn has SEVERAL (preamble + one per tool round + final answer),
|
||||
// so we must accumulate the text of EVERY such event. The prior `isFirstDelta` guard kept only
|
||||
// the FIRST message's text and dropped the rest — silently losing the post-tool-use final answer
|
||||
// on every tool-using turn (verified v2.1.104 through v2.1.211; see PR body capture).
|
||||
// The only real hazard is the delta+aggregate DOUBLE-COUNT: if streaming deltas were already
|
||||
// seen (sawTextDelta), the aggregate duplicates them — ignore it.
|
||||
// Reference: OLP commit 65f945c (assistant-aggregate fallback, fold-in).
|
||||
if (t === "assistant") {
|
||||
if (isFirstDelta) {
|
||||
if (!sawTextDelta) {
|
||||
const blocks = event.message?.content;
|
||||
if (Array.isArray(blocks)) {
|
||||
const text = blocks
|
||||
@@ -327,6 +334,16 @@ const ALLOWED_TOOLS = (process.env.CLAUDE_ALLOWED_TOOLS ||
|
||||
"Bash,Read,Write,Edit,Glob,Grep,WebSearch,WebFetch,Agent"
|
||||
).split(",").map(s => s.trim()).filter(Boolean);
|
||||
const SYSTEM_PROMPT = process.env.CLAUDE_SYSTEM_PROMPT || "";
|
||||
// Max attempts (initial + retries) to coerce a valid structured-output (OpenAI response_format)
|
||||
// JSON response out of the model before rejecting. See runStructuredCompletion.
|
||||
// Fail closed on a non-numeric value via resolveMaxAttempts(): the old `Math.max(1, parseInt("abc",10))`
|
||||
// === `Math.max(1, NaN)` === NaN, which made the retry loop `attempt < NaN` never execute → 0 spawns,
|
||||
// every structured request silently refused. The helper rejects NaN/non-finite/<1 and keeps the
|
||||
// documented default of 3. (PR #153 review round 2, NaN-guard must-fix.)
|
||||
const STRUCTURED_MAX_ATTEMPTS = resolveMaxAttempts(
|
||||
process.env.OCP_STRUCTURED_MAX_ATTEMPTS,
|
||||
{ fallback: 3, warn: (m) => console.warn(`[init] ${m}`) },
|
||||
);
|
||||
const MCP_CONFIG = process.env.CLAUDE_MCP_CONFIG || "";
|
||||
let SESSION_TTL = parseInt(process.env.CLAUDE_SESSION_TTL || "3600000", 10);
|
||||
let MAX_CONCURRENT = parseInt(process.env.CLAUDE_MAX_CONCURRENT || "8", 10);
|
||||
@@ -1100,7 +1117,7 @@ const authCheckInterval = setInterval(checkAuth, 600000);
|
||||
// CLAUDE_SYSTEM_PROMPT env var is absorbed into the system prompt via
|
||||
// extractSystemPrompt() at the caller level; APPEND_SYSTEM_PROMPT no longer used.
|
||||
// Note: ALLOWED_TOOLS / SKIP_PERMISSIONS / MCP_CONFIG are preserved as before.
|
||||
function buildCliArgs(cliModel, systemPrompt) {
|
||||
function buildCliArgs(cliModel, systemPrompt, opts = {}) {
|
||||
const args = [
|
||||
"--model", cliModel,
|
||||
"--output-format", "stream-json",
|
||||
@@ -1109,6 +1126,14 @@ function buildCliArgs(cliModel, systemPrompt) {
|
||||
"--system-prompt", systemPrompt,
|
||||
];
|
||||
|
||||
// Multimodal path (issue #110): images are fed as Anthropic content blocks over
|
||||
// a stream-json stdin stream. `--input-format stream-json` (§ --input-format,
|
||||
// choices text|stream-json; realtime streaming input) is added ONLY when the
|
||||
// request carries an image part; the default (text) input path is untouched.
|
||||
if (opts.streamJsonInput) {
|
||||
args.push("--input-format", "stream-json");
|
||||
}
|
||||
|
||||
// Permissions
|
||||
// ADR 0007 B-path: in multi-tenant mode, suppress operator-FS tools so a guest
|
||||
// prompt cannot drive Bash/Read/Write/Edit/etc. on the operator's filesystem.
|
||||
@@ -1144,11 +1169,45 @@ function buildCliArgs(cliModel, systemPrompt) {
|
||||
return args;
|
||||
}
|
||||
|
||||
// Thin env wrapper over parsePositiveInt (lib/env.mjs): resolve `name` from the
|
||||
// environment fail-closed, warning on a present-but-invalid value. Keeps the pure
|
||||
// parse in a unit-testable module. (PR #154 review F3)
|
||||
function parseIntEnv(name, def) {
|
||||
const { value, ok } = parsePositiveInt(process.env[name], def);
|
||||
if (!ok) console.warn(`⚠ ${name}="${process.env[name]}" is not a valid positive integer (bytes/count, no unit suffix); ignoring and using default ${def}.`);
|
||||
return value;
|
||||
}
|
||||
|
||||
// ── Format messages to prompt text ──────────────────────────────────────
|
||||
// Truncation guard: if total chars exceed MAX_PROMPT_CHARS, keep the system
|
||||
// message(s) + first user message + last N messages, dropping the middle.
|
||||
// This prevents runaway context from gateway-side conversation accumulation.
|
||||
let MAX_PROMPT_CHARS = parseInt(process.env.CLAUDE_MAX_PROMPT_CHARS || "150000", 10);
|
||||
// Routed through parseIntEnv so a misconfigured cap fails CLOSED to the default rather than
|
||||
// NaN — CLAUDE_MAX_PROMPT_CHARS=unlimited previously → NaN → enforceTextBudget's `!(NaN > 0)`
|
||||
// early-return → 500k chars passed unbounded, silently defeating F2's text-budget guarantee
|
||||
// (PR #154 round 2, gap (a)). The default itself is SPOT-DERIVED (ADR 0009, PR #179):
|
||||
// max(models.json contextWindow) × 3 chars/token — currently 600,000 — so the two fixes
|
||||
// compose: parseIntEnv guards a SET-but-garbage value, the derivation supplies the honest
|
||||
// default when unset/empty. `let` is kept for the settings API.
|
||||
let MAX_PROMPT_CHARS = parseIntEnv("CLAUDE_MAX_PROMPT_CHARS", derivePromptCharBudget(modelsConfig.models));
|
||||
|
||||
// ── Multimodal image caps (issue #110) ──────────────────────────────────
|
||||
// OpenAI `image_url` parts are forwarded to claude as Anthropic image blocks via
|
||||
// `--input-format stream-json`. Images deliberately BYPASS the text char budget
|
||||
// (MAX_PROMPT_CHARS) — they are bounded by these byte/count caps instead, and by
|
||||
// MAX_BODY_SIZE at the HTTP layer. Data URIs are supported by default; remote
|
||||
// http(s) image URLs are OFF unless CLAUDE_IMAGE_ALLOW_URL is set (v1: data URIs
|
||||
// only). See docs/adr/0006-openai-shim-scope.md (Class B.1) and README § "Images".
|
||||
const IMAGE_ALLOW_URL = /^(1|true|yes|on)$/i.test(process.env.CLAUDE_IMAGE_ALLOW_URL || "");
|
||||
const MAX_IMAGE_BYTES = parseIntEnv("CLAUDE_MAX_IMAGE_BYTES", 5 * 1024 * 1024);
|
||||
const MAX_IMAGES = parseIntEnv("CLAUDE_MAX_IMAGES", 20);
|
||||
const MAX_IMAGE_TOTAL_BYTES = parseIntEnv("CLAUDE_MAX_IMAGE_TOTAL_BYTES", 20 * 1024 * 1024);
|
||||
const MULTIMODAL_OPTS = {
|
||||
allowRemoteUrl: IMAGE_ALLOW_URL,
|
||||
maxImageBytes: MAX_IMAGE_BYTES,
|
||||
maxImages: MAX_IMAGES,
|
||||
maxTotalImageBytes: MAX_IMAGE_TOTAL_BYTES,
|
||||
};
|
||||
|
||||
// Flatten OpenAI content (string | array of parts) to plain text for the prompt.
|
||||
// Array content: concatenate text parts; replace non-text parts (e.g. image_url)
|
||||
@@ -1240,25 +1299,51 @@ function spawnClaudeProcess(model, messages, conversationId, keyName, releaseSlo
|
||||
|
||||
// Circuit breaker: disabled (see comment at top of breaker section)
|
||||
|
||||
stats.activeRequests++;
|
||||
stats.totalRequests++;
|
||||
|
||||
// Phase 6c: always serialize full conversation via stdin (no session resume).
|
||||
// System messages are extracted and passed via --system-prompt; the remaining
|
||||
// messages (user/assistant/tool) are serialized by messagesToPrompt.
|
||||
// messages (user/assistant/tool) are serialized for stdin.
|
||||
const systemPrompt = extractSystemPrompt(messages);
|
||||
|
||||
// messagesToPrompt skips system messages now that they go via --system-prompt.
|
||||
// Filter them out before calling to avoid double-injection.
|
||||
// messagesToPrompt / buildStreamJsonInput skip system messages (they go via
|
||||
// --system-prompt). Filter them out first to avoid double-injection.
|
||||
const nonSystemMessages = messages.filter(m => m.role !== "system");
|
||||
const prompt = messagesToPrompt(nonSystemMessages);
|
||||
|
||||
stats.oneOffRequests++;
|
||||
if (conversationId) {
|
||||
console.log(`[session] stateless conv=${conversationId.slice(0, 12)}... key=${keyName || "anon"} msgs=${messages.length} prompt_chars=${prompt.length}`);
|
||||
// Multimodal (issue #110): when any message carries an OpenAI image_url part,
|
||||
// feed the conversation as Anthropic content blocks over --input-format
|
||||
// stream-json (images preserved and kept OUT of the text char budget).
|
||||
// Otherwise the text path is byte-for-byte unchanged. buildStreamJsonInput may
|
||||
// throw MultimodalError on an invalid/oversized image; it runs BEFORE any stats
|
||||
// mutation so a validation failure never leaks counters or the concurrency slot
|
||||
// (handleChatCompletions validates first, so in practice it will not throw here).
|
||||
const useStreamJson = hasImageContent(nonSystemMessages);
|
||||
let stdinPayload, promptChars;
|
||||
if (useStreamJson) {
|
||||
// Pass MAX_PROMPT_CHARS so the multimodal text is bounded by the same
|
||||
// runaway-context guard as the text path (PR #154 review F2). Images bypass it.
|
||||
const built = buildStreamJsonInput(nonSystemMessages, { ...MULTIMODAL_OPTS, maxTextChars: MAX_PROMPT_CHARS });
|
||||
stdinPayload = built.payload;
|
||||
promptChars = built.stats.textChars;
|
||||
if (built.stats.truncated) {
|
||||
logEvent("warn", "prompt_truncated", {
|
||||
originalChars: built.stats.originalTextChars,
|
||||
maxChars: MAX_PROMPT_CHARS,
|
||||
keptChars: built.stats.textChars,
|
||||
path: "multimodal",
|
||||
});
|
||||
}
|
||||
} else {
|
||||
stdinPayload = messagesToPrompt(nonSystemMessages);
|
||||
promptChars = stdinPayload.length;
|
||||
}
|
||||
|
||||
const cliArgs = buildCliArgs(cliModel, systemPrompt);
|
||||
stats.activeRequests++;
|
||||
stats.totalRequests++;
|
||||
stats.oneOffRequests++;
|
||||
if (conversationId) {
|
||||
console.log(`[session] stateless conv=${conversationId.slice(0, 12)}... key=${keyName || "anon"} msgs=${messages.length} prompt_chars=${promptChars}`);
|
||||
}
|
||||
|
||||
const cliArgs = buildCliArgs(cliModel, systemPrompt, { streamJsonInput: useStreamJson });
|
||||
|
||||
const env = { ...process.env };
|
||||
delete env.CLAUDECODE;
|
||||
@@ -1344,12 +1429,13 @@ function spawnClaudeProcess(model, messages, conversationId, keyName, releaseSlo
|
||||
// the spawned process, NOT on the stdin Writable — it does not catch this.
|
||||
proc.stdin.on("error", (e) => logEvent("warn", "stdin_write_error", { error: e.message }));
|
||||
|
||||
// Write prompt to stdin immediately
|
||||
proc.stdin.write(prompt);
|
||||
// Write the serialized turn to stdin immediately. Text path: the flat prompt.
|
||||
// Multimodal path: a single newline-terminated stream-json user envelope.
|
||||
proc.stdin.write(stdinPayload);
|
||||
proc.stdin.end();
|
||||
|
||||
recordModelRequest(cliModel, prompt.length);
|
||||
logEvent("info", "claude_spawned", { model: cliModel, promptChars: prompt.length, timeout: TIMEOUT, tier: getModelTier(cliModel), session: conversationId ? conversationId.slice(0, 12) + "..." : "none" });
|
||||
recordModelRequest(cliModel, promptChars);
|
||||
logEvent("info", "claude_spawned", { model: cliModel, promptChars, inputFormat: useStreamJson ? "stream-json" : "text", timeout: TIMEOUT, tier: getModelTier(cliModel), session: conversationId ? conversationId.slice(0, 12) + "..." : "none" });
|
||||
|
||||
// Single request timeout — no separate first-byte timer.
|
||||
// Claude tool-use causes long pauses in the token stream (30s-5min),
|
||||
@@ -1419,7 +1505,7 @@ async function callClaude(model, messages, conversationId, keyName, res) {
|
||||
const { proc, cliModel, conversationId: convId, t0, cleanup, handleSessionFailure, markFirstByte } = ctx;
|
||||
let lineBuffer = "";
|
||||
let assembledText = "";
|
||||
let isFirstDelta = true;
|
||||
let sawTextDelta = false;
|
||||
let resultEventSeen = false;
|
||||
let stderr = "";
|
||||
|
||||
@@ -1429,11 +1515,18 @@ async function callClaude(model, messages, conversationId, keyName, res) {
|
||||
const { events, remainder } = parseStreamJsonLines(lineBuffer);
|
||||
lineBuffer = remainder;
|
||||
for (const event of events) {
|
||||
const parsed = parseStreamJsonEvent(event, isFirstDelta);
|
||||
const parsed = parseStreamJsonEvent(event, sawTextDelta);
|
||||
if (!parsed) continue;
|
||||
if (parsed.text !== undefined) {
|
||||
if (parsed.fromDelta) {
|
||||
assembledText += parsed.text;
|
||||
isFirstDelta = false;
|
||||
sawTextDelta = true;
|
||||
} else {
|
||||
// aggregate assistant message — separate successive messages so the preamble and
|
||||
// the post-tool-use final answer don't run together.
|
||||
if (assembledText && !assembledText.endsWith("\n")) assembledText += "\n\n";
|
||||
assembledText += parsed.text;
|
||||
}
|
||||
} else if (parsed.stop) {
|
||||
resultEventSeen = true;
|
||||
} else if (parsed.error) {
|
||||
@@ -1855,9 +1948,10 @@ async function callClaudeStreaming(model, messages, conversationId, res, authInf
|
||||
let stderr = "";
|
||||
let headersSent = false;
|
||||
let totalChars = 0;
|
||||
let streamEndsWithNewline = false; // tracks whether emitted text ends in "\n" — see the separator guard below
|
||||
let cachedContent = ""; // accumulate for cache write-back
|
||||
let lineBuffer = "";
|
||||
let isFirstDelta = true;
|
||||
let sawTextDelta = false;
|
||||
let resultEventSeen = false;
|
||||
// Separate flag for is_error result — must NOT be conflated with resultEventSeen.
|
||||
// If errored===true the close handler must not cache the response or record success
|
||||
@@ -1894,15 +1988,26 @@ async function callClaudeStreaming(model, messages, conversationId, res, authInf
|
||||
lineBuffer = remainder;
|
||||
|
||||
for (const event of events) {
|
||||
const parsed = parseStreamJsonEvent(event, isFirstDelta);
|
||||
const parsed = parseStreamJsonEvent(event, sawTextDelta);
|
||||
if (!parsed) continue;
|
||||
|
||||
if (parsed.text !== undefined) {
|
||||
// content_block_delta text — forward as SSE delta
|
||||
const text = parsed.text;
|
||||
// Streamed delta, or an aggregate assistant-message text (agentic turns emit several).
|
||||
// For an aggregate message after earlier text, prepend a separator so the preamble and
|
||||
// the post-tool-use final answer don't run together in the forwarded stream.
|
||||
let text = parsed.text;
|
||||
if (parsed.fromDelta) {
|
||||
sawTextDelta = true;
|
||||
} else if (totalChars > 0 && !streamEndsWithNewline) {
|
||||
// Mirror the buffered path's guard (assembledText.endsWith("\n")): only inject the
|
||||
// blank-line separator when the already-emitted text doesn't already end in a newline,
|
||||
// so a message ending in "\n" doesn't produce a triple newline here while the buffered
|
||||
// path produces a single. Keeps the two assembly paths byte-identical. (PR #183 review.)
|
||||
text = "\n\n" + text;
|
||||
}
|
||||
streamEndsWithNewline = text.endsWith("\n");
|
||||
totalChars += text.length;
|
||||
if (CACHE_TTL > 0) cachedContent += text;
|
||||
isFirstDelta = false;
|
||||
|
||||
if (!ensureHeaders()) continue;
|
||||
sendSSE(res, {
|
||||
@@ -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).
|
||||
// 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).
|
||||
@@ -2573,18 +2703,58 @@ async function handleSettings(req, res) {
|
||||
}
|
||||
|
||||
// ── Handle chat completions ─────────────────────────────────────────────
|
||||
const MAX_BODY_SIZE = 5 * 1024 * 1024; // 5 MB
|
||||
// Default 5 MB, byte-for-byte unchanged unless CLAUDE_MAX_BODY_SIZE is set. Base64
|
||||
// image payloads inflate ~33%; operators enabling large images (issue #110) can
|
||||
// raise this to admit bigger requests. Parsed fail-closed (PR #154 review F3): a
|
||||
// bad value (`unlimited` → NaN, `5MB` → 5) must not disable the body cap or brick
|
||||
// the proxy — parseIntEnv keeps the 5 MB default and warns instead.
|
||||
const MAX_BODY_SIZE = parseIntEnv("CLAUDE_MAX_BODY_SIZE", 5 * 1024 * 1024);
|
||||
const MAX_BODY_SIZE_LABEL = `${Math.round(MAX_BODY_SIZE / (1024 * 1024))}MB`;
|
||||
|
||||
// Set of all valid model identifiers (canonical IDs + aliases)
|
||||
const VALID_MODELS = new Set(Object.keys(MODEL_MAP));
|
||||
|
||||
// Drive the model to a valid structured-output (OpenAI response_format) JSON string, retrying up to
|
||||
// STRUCTURED_MAX_ATTEMPTS. Appends a strict JSON-only steering instruction, extracts + validates the
|
||||
// reply (pure helpers in lib/structured-output.mjs), and escalates the instruction on failure.
|
||||
// Returns the canonical JSON string (message.content) or throws StructuredOutputError.
|
||||
async function runStructuredCompletion(upstreamCall, model, messages, conversationId, keyName, res, structured) {
|
||||
let lastErr = "no valid JSON produced";
|
||||
let lastRaw = "";
|
||||
for (let attempt = 0; attempt < STRUCTURED_MAX_ATTEMPTS; attempt++) {
|
||||
const augmented = [...messages, { role: "system", content: structuredSystemInstruction(structured, attempt, lastErr) }];
|
||||
const raw = await upstreamCall(model, augmented, conversationId, keyName, res);
|
||||
lastRaw = raw;
|
||||
const extracted = extractJsonPayload(raw, { whole: structured.mode === "json_object" });
|
||||
if (!extracted.ok) {
|
||||
lastErr = extracted.reason || "response was not parseable as JSON";
|
||||
logEvent("warn", "structured_retry", { attempt, reason: extracted.reason || "unparseable" });
|
||||
continue;
|
||||
}
|
||||
if (structured.mode === "schema" && structured.schema) {
|
||||
// validateJsonSchemaSafe (#181): a pathologically deep model reply overflows the value-depth
|
||||
// recursion; the safe façade turns that into a validation miss (→ retry → refusal) instead of
|
||||
// a caught RangeError surfacing as a generic 500.
|
||||
const errs = validateJsonSchemaSafe(extracted.value, structured.schema, "$", structured.strict);
|
||||
if (errs.length) {
|
||||
lastErr = "schema validation failed: " + errs.slice(0, 5).join("; ");
|
||||
logEvent("warn", "structured_retry", { attempt, reason: "schema", errors: errs.slice(0, 5) });
|
||||
continue;
|
||||
}
|
||||
}
|
||||
if (attempt > 0) logEvent("info", "structured_recovered", { attempt });
|
||||
return JSON.stringify(extracted.value); // canonical, fence-free, prose-free
|
||||
}
|
||||
throw new StructuredOutputError(lastErr, lastRaw);
|
||||
}
|
||||
|
||||
async function handleChatCompletions(req, res) {
|
||||
let body = "";
|
||||
try {
|
||||
for await (const chunk of req) {
|
||||
body += chunk;
|
||||
if (body.length > MAX_BODY_SIZE) {
|
||||
return jsonResponse(res, 413, { error: { message: "Request body too large (max 5MB)", type: "invalid_request_error" } });
|
||||
return jsonResponse(res, 413, { error: { message: `Request body too large (max ${MAX_BODY_SIZE_LABEL})`, type: "invalid_request_error" } });
|
||||
}
|
||||
}
|
||||
} catch (e) {
|
||||
@@ -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" } });
|
||||
}
|
||||
|
||||
// Multimodal validation (issue #110): when a request carries OpenAI `image_url`
|
||||
// content parts, validate/parse them now so an invalid, unsupported, or oversized
|
||||
// image returns a clean 4xx BEFORE the cache/spawn path (rather than a silent drop
|
||||
// or an opaque 500). The stream-json transform itself runs at spawn time
|
||||
// (spawnClaudeProcess → buildStreamJsonInput). Class B.1: authorized by ADR 0006;
|
||||
// request shape per OpenAI vision spec (image_url content parts). buildImageBlocks
|
||||
// validates without stringifying, so this early pass is cheap.
|
||||
if (hasImageContent(messages)) {
|
||||
// F1 (PR #154 review): the TUI path (callClaudeTui → messagesToPrompt) cannot
|
||||
// carry image blocks — it renders every non-text part as "[non-text content
|
||||
// omitted]". Forwarding here would let the model answer about an image it never
|
||||
// saw and return 200, which is strictly worse than an honest error (the one
|
||||
// outcome ALIGNMENT.md forbids: silently serving text the model did not mean).
|
||||
// Stream-json image input requires the `claude -p` path, so in TUI_MODE we fail
|
||||
// loudly instead of dropping. Documented in README § "Images".
|
||||
if (TUI_MODE) {
|
||||
return jsonResponse(res, 400, {
|
||||
error: {
|
||||
message: "Image inputs are not supported in TUI mode (CLAUDE_TUI_MODE=true). Images require the default -p spawn path; remove images or run OCP without TUI mode.",
|
||||
type: "invalid_request_error",
|
||||
code: "images_unsupported_in_tui_mode",
|
||||
},
|
||||
});
|
||||
}
|
||||
// Detection runs on the FULL message list, but extraction/spawn drop system messages
|
||||
// (system role carries no image blocks to the CLI). So an image present ONLY in a
|
||||
// system message would be detected as multimodal, survive no filter, fall to the text
|
||||
// path, and render as "[non-text content omitted]" → 200 with a hallucinated answer —
|
||||
// the exact silent-drop this guard exists to forbid. Fail loudly instead. OpenAI
|
||||
// disallows images in the system role anyway, so no legitimate request is rejected.
|
||||
// (PR #154 review round 2, gap (b).)
|
||||
const nonSystem = messages.filter(m => m.role !== "system");
|
||||
if (!hasImageContent(nonSystem)) {
|
||||
return jsonResponse(res, 400, {
|
||||
error: {
|
||||
message: "Image inputs are only supported in user/assistant messages, not in system messages. Move the image_url part to a user message.",
|
||||
type: "invalid_request_error",
|
||||
code: "images_unsupported_in_system_messages",
|
||||
},
|
||||
});
|
||||
}
|
||||
try {
|
||||
buildImageBlocks(nonSystem, { ...MULTIMODAL_OPTS, maxTextChars: MAX_PROMPT_CHARS });
|
||||
} catch (e) {
|
||||
if (e instanceof MultimodalError) {
|
||||
return jsonResponse(res, e.status, { error: { message: e.message, type: e.type, code: e.code } });
|
||||
}
|
||||
throw e;
|
||||
}
|
||||
}
|
||||
|
||||
// NOTE: quota is best-effort / eventually-consistent. The gate reads the recorded count
|
||||
// at entry and records only after the upstream completes, so concurrent requests at the
|
||||
// boundary can overshoot the cap by up to MAX_CONCURRENT, and cache hits (served before
|
||||
@@ -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)
|
||||
if (CACHE_TTL > 0 && !conversationId) {
|
||||
// D2: skip OCP cache entirely when messages carry cache_control annotations;
|
||||
|
||||
+682
-28
@@ -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
|
||||
// return `base` unconditionally and the first test fails; make it stop trimming
|
||||
// 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:");
|
||||
|
||||
@@ -1313,7 +1355,7 @@ function parseStreamJsonLines(buffered) {
|
||||
return { events, remainder: remainder ?? "" };
|
||||
}
|
||||
|
||||
function parseStreamJsonEvent(event, isFirstDelta) {
|
||||
function parseStreamJsonEvent(event, sawTextDelta) {
|
||||
const t = event?.type;
|
||||
|
||||
// system/* — first-event init + other system meta (api_retry etc.)
|
||||
@@ -1325,19 +1367,19 @@ function parseStreamJsonEvent(event, isFirstDelta) {
|
||||
if (t === "stream_event") {
|
||||
const inner = event.event ?? event;
|
||||
if (inner?.type === "content_block_delta" && inner.delta?.type === "text_delta") {
|
||||
return { text: inner.delta.text ?? "" };
|
||||
return { text: inner.delta.text ?? "", fromDelta: true };
|
||||
}
|
||||
// Other stream_event sub-types (content_block_start, message_delta, etc.) — consumed
|
||||
return null;
|
||||
}
|
||||
|
||||
// assistant — aggregate message (fallback when no prior content_block_delta seen)
|
||||
// Empirically (claude CLI without --include-partial-messages, verified v2.1.104 through v2.1.158): fast/short
|
||||
// responses may emit ONLY the aggregate assistant event, no content_block_delta events.
|
||||
// If isFirstDelta is true, extract text here; otherwise it's a duplicate, ignore.
|
||||
// assistant — aggregate message. Without --include-partial-messages each assistant message
|
||||
// arrives as its own aggregate event; an agentic turn emits several (preamble + tool rounds +
|
||||
// final answer), so accumulate EVERY one. Only guard the delta+aggregate double-count case:
|
||||
// if streaming deltas were already seen (sawTextDelta), the aggregate duplicates them.
|
||||
// Reference: OLP commit 65f945c (assistant-aggregate fallback, fold-in).
|
||||
if (t === "assistant") {
|
||||
if (isFirstDelta) {
|
||||
if (!sawTextDelta) {
|
||||
const blocks = event.message?.content;
|
||||
if (Array.isArray(blocks)) {
|
||||
const text = blocks
|
||||
@@ -1386,25 +1428,25 @@ test("parseStreamJsonEvent: stream_event content_block_delta yields text", () =>
|
||||
type: "stream_event",
|
||||
event: { type: "content_block_delta", delta: { type: "text_delta", text: "Hello" } }
|
||||
};
|
||||
const result = parseStreamJsonEvent(event, true);
|
||||
assert.deepEqual(result, { text: "Hello" });
|
||||
const result = parseStreamJsonEvent(event, false);
|
||||
assert.deepEqual(result, { text: "Hello", fromDelta: true });
|
||||
});
|
||||
|
||||
test("parseStreamJsonEvent: assistant-aggregate used when isFirstDelta=true (no prior delta)", () => {
|
||||
const event = {
|
||||
type: "assistant",
|
||||
message: { content: [{ type: "text", text: "Short answer." }] }
|
||||
};
|
||||
const result = parseStreamJsonEvent(event, true);
|
||||
assert.deepEqual(result, { text: "Short answer." });
|
||||
});
|
||||
|
||||
test("parseStreamJsonEvent: assistant-aggregate skipped when isFirstDelta=false (no double-count)", () => {
|
||||
test("parseStreamJsonEvent: assistant-aggregate used when no delta seen (sawTextDelta=false)", () => {
|
||||
const event = {
|
||||
type: "assistant",
|
||||
message: { content: [{ type: "text", text: "Short answer." }] }
|
||||
};
|
||||
const result = parseStreamJsonEvent(event, false);
|
||||
assert.deepEqual(result, { text: "Short answer." });
|
||||
});
|
||||
|
||||
test("parseStreamJsonEvent: assistant-aggregate skipped when a delta was seen (sawTextDelta=true, no double-count)", () => {
|
||||
const event = {
|
||||
type: "assistant",
|
||||
message: { content: [{ type: "text", text: "Short answer." }] }
|
||||
};
|
||||
const result = parseStreamJsonEvent(event, true);
|
||||
assert.equal(result, null);
|
||||
});
|
||||
|
||||
@@ -1418,14 +1460,39 @@ test("parseStreamJsonEvent: stream_event + assistant → assembled without doubl
|
||||
type: "assistant",
|
||||
message: { content: [{ type: "text", text: "Streaming text." }] }
|
||||
};
|
||||
// First event: isFirstDelta=true → yields text
|
||||
const r1 = parseStreamJsonEvent(delta, true);
|
||||
assert.deepEqual(r1, { text: "Streaming text." });
|
||||
// Second event (aggregate): isFirstDelta is now false (content already emitted) → null
|
||||
const r2 = parseStreamJsonEvent(agg, false);
|
||||
// First event: no delta seen yet → yields text and marks fromDelta
|
||||
const r1 = parseStreamJsonEvent(delta, false);
|
||||
assert.deepEqual(r1, { text: "Streaming text.", fromDelta: true });
|
||||
// Second event (aggregate): a delta was seen (sawTextDelta=true) → duplicate, null
|
||||
const r2 = parseStreamJsonEvent(agg, true);
|
||||
assert.equal(r2, null);
|
||||
});
|
||||
|
||||
// REGRESSION (agentic turns): without --include-partial-messages a tool-using turn emits SEVERAL
|
||||
// aggregate `assistant` events (preamble, then the final answer after tool use) and NO deltas.
|
||||
// Every one must be captured — the old first-only guard dropped the final answer.
|
||||
test("parseStreamJsonEvent: multi-message agentic turn captures preamble AND final answer", () => {
|
||||
const preamble = {
|
||||
type: "assistant",
|
||||
message: { content: [
|
||||
{ type: "text", text: "I'll find the homepage repo and remove the calendar." },
|
||||
{ type: "tool_use", id: "t1", name: "Bash" },
|
||||
] }
|
||||
};
|
||||
const toolResult = { type: "user", message: { content: [{ type: "tool_result", content: "ok" }] } };
|
||||
const finalMsg = {
|
||||
type: "assistant",
|
||||
message: { content: [{ type: "text", text: "Done — removed the calendar widget and pushed." }] }
|
||||
};
|
||||
// No deltas are ever emitted in aggregate mode, so sawTextDelta stays false throughout.
|
||||
const r1 = parseStreamJsonEvent(preamble, false);
|
||||
assert.deepEqual(r1, { text: "I'll find the homepage repo and remove the calendar." });
|
||||
const r2 = parseStreamJsonEvent(toolResult, false); // user/tool_result echo — consumed
|
||||
assert.equal(r2, null);
|
||||
const r3 = parseStreamJsonEvent(finalMsg, false); // <- old code returned null here (bug)
|
||||
assert.deepEqual(r3, { text: "Done — removed the calendar widget and pushed." });
|
||||
});
|
||||
|
||||
// (b) aggregate-only short response → assembles correctly
|
||||
test("parseStreamJsonEvent: aggregate-only multi-block response assembles all text blocks", () => {
|
||||
const event = {
|
||||
@@ -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." });
|
||||
});
|
||||
|
||||
@@ -1456,8 +1523,8 @@ test("parseStreamJsonLines: partial line carried as remainder", () => {
|
||||
assert.equal(ev2[0].type, "stream_event");
|
||||
assert.equal(rem2, "");
|
||||
// Verify the reassembled event parses through parseStreamJsonEvent correctly
|
||||
const parsed = parseStreamJsonEvent(ev2[0], true);
|
||||
assert.deepEqual(parsed, { text: "Hi" });
|
||||
const parsed = parseStreamJsonEvent(ev2[0], false);
|
||||
assert.deepEqual(parsed, { text: "Hi", fromDelta: true });
|
||||
});
|
||||
|
||||
test("parseStreamJsonLines: empty input returns no events and empty remainder", () => {
|
||||
@@ -3371,6 +3438,337 @@ test("contentToText: null returns empty string", () => {
|
||||
assert.equal(contentToText(null), "");
|
||||
});
|
||||
|
||||
// ── multimodal image transform (issue #110) ──────────────────────────────────
|
||||
// OpenAI image_url parts → Anthropic image blocks for `claude -p --input-format
|
||||
// stream-json`. lib/multimodal.mjs is a PURE module (no server.listen()), so it is
|
||||
// imported directly here. Class B.1: shape per OpenAI vision spec, authorized by
|
||||
// ADR 0006. Mechanism verified live: a base64 PNG fed as an Anthropic image block
|
||||
// via --input-format stream-json is correctly described by the model.
|
||||
import {
|
||||
hasImageContent as mmHasImageContent,
|
||||
buildImageBlocks as mmBuildImageBlocks,
|
||||
buildStreamJsonInput as mmBuildStreamJsonInput,
|
||||
MultimodalError as MmError,
|
||||
SUPPORTED_IMAGE_TYPES as MM_SUPPORTED,
|
||||
} from "./lib/multimodal.mjs";
|
||||
import { parsePositiveInt } from "./lib/env.mjs";
|
||||
|
||||
console.log("\nmultimodal image transform (issue #110):");
|
||||
|
||||
// A short, valid base64 string (charset-valid; not decoded by the transform).
|
||||
const MM_B64 = "iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAAAAAA6fptVAAAACklEQVR4nGP4DwABAQEAG7buVgAAAABJRU5ErkJggg==";
|
||||
const dataUri = (mt = "image/png") => `data:${mt};base64,${MM_B64}`;
|
||||
const imgPart = (mt) => ({ type: "image_url", image_url: { url: dataUri(mt) } });
|
||||
const txtPart = (t) => ({ type: "text", text: t });
|
||||
|
||||
test("hasImageContent: plain string message → false (text path preserved)", () => {
|
||||
assert.equal(mmHasImageContent([{ role: "user", content: "hello" }]), false);
|
||||
});
|
||||
|
||||
test("hasImageContent: array of text-only parts → false", () => {
|
||||
assert.equal(mmHasImageContent([{ role: "user", content: [txtPart("a"), txtPart("b")] }]), false);
|
||||
});
|
||||
|
||||
test("hasImageContent: message with an image_url part → true", () => {
|
||||
assert.equal(mmHasImageContent([{ role: "user", content: [txtPart("q"), imgPart()] }]), true);
|
||||
});
|
||||
|
||||
test("hasImageContent: image anywhere in history (not just last) → true", () => {
|
||||
const msgs = [
|
||||
{ role: "user", content: [txtPart("look"), imgPart()] },
|
||||
{ role: "assistant", content: "ok" },
|
||||
{ role: "user", content: "and now?" },
|
||||
];
|
||||
assert.equal(mmHasImageContent(msgs), true);
|
||||
});
|
||||
|
||||
// ── PR #154 review round 2, gap (b): image ONLY in a system message must not silently drop ──
|
||||
// The handler detects multimodal on the FULL list but extraction/spawn filter system messages out.
|
||||
// The guard fires exactly when the full list has an image but the non-system list does not — proven
|
||||
// here against the same predicate the guard uses, so a system-only image is rejected (400) rather
|
||||
// than falling to the text path and returning a 200 hallucinated answer.
|
||||
test("hasImageContent: image ONLY in a system message → true on full list, false after system filter (guard fires)", () => {
|
||||
const msgs = [
|
||||
{ role: "system", content: [txtPart("context"), imgPart()] },
|
||||
{ role: "user", content: "describe it" },
|
||||
];
|
||||
assert.equal(mmHasImageContent(msgs), true, "detected as multimodal on the full list");
|
||||
assert.equal(mmHasImageContent(msgs.filter(m => m.role !== "system")), false, "no image survives the system filter → guard must 400");
|
||||
});
|
||||
test("hasImageContent: image in a USER message survives the system filter (legitimate request not rejected)", () => {
|
||||
const msgs = [
|
||||
{ role: "system", content: "you are helpful" },
|
||||
{ role: "user", content: [txtPart("describe it"), imgPart()] },
|
||||
];
|
||||
assert.equal(mmHasImageContent(msgs.filter(m => m.role !== "system")), true, "user image survives → normal multimodal path");
|
||||
});
|
||||
|
||||
test("buildImageBlocks: data-URI parsed into an Anthropic base64 image block", () => {
|
||||
const { blocks, stats } = mmBuildImageBlocks([{ role: "user", content: [txtPart("what is this?"), imgPart("image/png")] }]);
|
||||
assert.equal(blocks.length, 2);
|
||||
assert.equal(blocks[0].type, "text");
|
||||
assert.equal(blocks[0].text, "what is this?");
|
||||
assert.deepEqual(blocks[1], { type: "image", source: { type: "base64", media_type: "image/png", data: MM_B64 } });
|
||||
assert.equal(stats.imageCount, 1);
|
||||
assert.ok(stats.totalImageBytes > 0);
|
||||
});
|
||||
|
||||
test("buildImageBlocks: media_type carried through (jpeg/gif/webp)", () => {
|
||||
for (const mt of ["image/jpeg", "image/gif", "image/webp"]) {
|
||||
const { blocks } = mmBuildImageBlocks([{ role: "user", content: [imgPart(mt)] }]);
|
||||
assert.equal(blocks.find(b => b.type === "image").source.media_type, mt);
|
||||
}
|
||||
});
|
||||
|
||||
test("buildImageBlocks: multiple images in one message both emitted", () => {
|
||||
const { blocks, stats } = mmBuildImageBlocks([{ role: "user", content: [txtPart("compare"), imgPart(), imgPart()] }]);
|
||||
const imgs = blocks.filter(b => b.type === "image");
|
||||
assert.equal(imgs.length, 2);
|
||||
assert.equal(stats.imageCount, 2);
|
||||
});
|
||||
|
||||
test("buildImageBlocks: text/image/text ordering preserved", () => {
|
||||
const { blocks } = mmBuildImageBlocks([{ role: "user", content: [txtPart("A"), imgPart(), txtPart("B")] }]);
|
||||
assert.deepEqual(blocks.map(b => (b.type === "text" ? b.text : "IMG")), ["A", "IMG", "B"]);
|
||||
});
|
||||
|
||||
test("buildImageBlocks: image-first message keeps ordering (image before text)", () => {
|
||||
const { blocks } = mmBuildImageBlocks([{ role: "user", content: [imgPart(), txtPart("caption")] }]);
|
||||
assert.deepEqual(blocks.map(b => (b.type === "text" ? b.text : "IMG")), ["IMG", "caption"]);
|
||||
});
|
||||
|
||||
test("buildImageBlocks: multi-turn history — role prefixes + separators preserved", () => {
|
||||
const msgs = [
|
||||
{ role: "user", content: "first q" },
|
||||
{ role: "assistant", content: "prior answer" },
|
||||
{ role: "user", content: [txtPart("now this"), imgPart()] },
|
||||
];
|
||||
const { blocks } = mmBuildImageBlocks(msgs);
|
||||
assert.equal(blocks[0].text, "first q");
|
||||
assert.equal(blocks[1].text, "\n\n[Assistant] prior answer");
|
||||
assert.equal(blocks[2].text, "\n\nnow this");
|
||||
assert.equal(blocks[3].type, "image");
|
||||
});
|
||||
|
||||
test("buildImageBlocks: image in an EARLIER turn is carried (history image)", () => {
|
||||
const msgs = [
|
||||
{ role: "user", content: [txtPart("here"), imgPart()] },
|
||||
{ role: "assistant", content: "got it" },
|
||||
{ role: "user", content: "thanks" },
|
||||
];
|
||||
const { blocks, stats } = mmBuildImageBlocks(msgs);
|
||||
assert.equal(stats.imageCount, 1);
|
||||
assert.equal(blocks.filter(b => b.type === "image").length, 1);
|
||||
});
|
||||
|
||||
test("buildImageBlocks: image_url as bare string is accepted (client leniency)", () => {
|
||||
const { blocks } = mmBuildImageBlocks([{ role: "user", content: [{ type: "image_url", image_url: dataUri() }] }]);
|
||||
assert.equal(blocks.find(b => b.type === "image").source.data, MM_B64);
|
||||
});
|
||||
|
||||
test("buildStreamJsonInput: emits one newline-terminated user envelope", () => {
|
||||
const { payload } = mmBuildStreamJsonInput([{ role: "user", content: [txtPart("hi"), imgPart()] }]);
|
||||
assert.ok(payload.endsWith("\n"));
|
||||
const env = JSON.parse(payload.trim());
|
||||
assert.equal(env.type, "user");
|
||||
assert.equal(env.message.role, "user");
|
||||
assert.equal(env.message.content[1].type, "image");
|
||||
});
|
||||
|
||||
// ── malformed / policy / oversized handling (clean 4xx, never a silent drop) ──
|
||||
test("buildImageBlocks: unsupported media type → 400 unsupported_image_type", () => {
|
||||
assert.throws(
|
||||
() => mmBuildImageBlocks([{ role: "user", content: [imgPart("image/tiff")] }]),
|
||||
(e) => e instanceof MmError && e.code === "unsupported_image_type" && e.status === 400
|
||||
);
|
||||
});
|
||||
|
||||
test("buildImageBlocks: non-base64 data URI → 400 invalid_data_uri", () => {
|
||||
assert.throws(
|
||||
() => mmBuildImageBlocks([{ role: "user", content: [{ type: "image_url", image_url: { url: "data:image/png,notbase64" } }] }]),
|
||||
(e) => e instanceof MmError && e.code === "invalid_data_uri" && e.status === 400
|
||||
);
|
||||
});
|
||||
|
||||
test("buildImageBlocks: malformed data URI (no comma) → 400 invalid_data_uri", () => {
|
||||
assert.throws(
|
||||
() => mmBuildImageBlocks([{ role: "user", content: [{ type: "image_url", image_url: { url: "data:image/png;base64" } }] }]),
|
||||
(e) => e instanceof MmError && e.code === "invalid_data_uri"
|
||||
);
|
||||
});
|
||||
|
||||
test("buildImageBlocks: image_url part missing a URL → 400 invalid_image_url", () => {
|
||||
assert.throws(
|
||||
() => mmBuildImageBlocks([{ role: "user", content: [{ type: "image_url", image_url: {} }] }]),
|
||||
(e) => e instanceof MmError && e.code === "invalid_image_url"
|
||||
);
|
||||
});
|
||||
|
||||
test("buildImageBlocks: oversized single image → 413 image_too_large", () => {
|
||||
assert.throws(
|
||||
() => mmBuildImageBlocks([{ role: "user", content: [imgPart()] }], { maxImageBytes: 4 }),
|
||||
(e) => e instanceof MmError && e.code === "image_too_large" && e.status === 413
|
||||
);
|
||||
});
|
||||
|
||||
test("buildImageBlocks: too many images → 413 too_many_images", () => {
|
||||
const many = Array.from({ length: 3 }, () => imgPart());
|
||||
assert.throws(
|
||||
() => mmBuildImageBlocks([{ role: "user", content: many }], { maxImages: 2 }),
|
||||
(e) => e instanceof MmError && e.code === "too_many_images" && e.status === 413
|
||||
);
|
||||
});
|
||||
|
||||
test("buildImageBlocks: aggregate image bytes over cap → 413 images_too_large", () => {
|
||||
assert.throws(
|
||||
() => mmBuildImageBlocks([{ role: "user", content: [imgPart(), imgPart()] }], { maxTotalImageBytes: 100, maxImageBytes: 1000 }),
|
||||
(e) => e instanceof MmError && e.code === "images_too_large" && e.status === 413
|
||||
);
|
||||
});
|
||||
|
||||
test("buildImageBlocks: remote http(s) URL disabled by default → 400 remote_url_disabled", () => {
|
||||
assert.throws(
|
||||
() => mmBuildImageBlocks([{ role: "user", content: [{ type: "image_url", image_url: { url: "https://example.com/a.png" } }] }]),
|
||||
(e) => e instanceof MmError && e.code === "remote_url_disabled" && e.status === 400
|
||||
);
|
||||
});
|
||||
|
||||
test("buildImageBlocks: remote URL passthrough when allowRemoteUrl=true (url source, OCP does not fetch)", () => {
|
||||
const { blocks } = mmBuildImageBlocks(
|
||||
[{ role: "user", content: [{ type: "image_url", image_url: { url: "https://example.com/a.png" } }] }],
|
||||
{ allowRemoteUrl: true }
|
||||
);
|
||||
assert.deepEqual(blocks.find(b => b.type === "image").source, { type: "url", url: "https://example.com/a.png" });
|
||||
});
|
||||
|
||||
test("buildImageBlocks: unsupported URL scheme → 400 unsupported_url_scheme", () => {
|
||||
assert.throws(
|
||||
() => mmBuildImageBlocks([{ role: "user", content: [{ type: "image_url", image_url: { url: "ftp://x/y.png" } }] }], { allowRemoteUrl: true }),
|
||||
(e) => e instanceof MmError && e.code === "unsupported_url_scheme"
|
||||
);
|
||||
});
|
||||
|
||||
test("buildImageBlocks: non-image parts (audio/file) fall back to placeholder text", () => {
|
||||
const { blocks } = mmBuildImageBlocks([{ role: "user", content: [txtPart("hear this"), { type: "input_audio", input_audio: {} }] }]);
|
||||
assert.deepEqual(blocks.map(b => b.text), ["hear this", "[non-text content omitted]"]);
|
||||
});
|
||||
|
||||
test("SUPPORTED_IMAGE_TYPES: exactly the four Anthropic vision types", () => {
|
||||
assert.deepEqual([...MM_SUPPORTED].sort(), ["image/gif", "image/jpeg", "image/png", "image/webp"]);
|
||||
});
|
||||
|
||||
test("buildImageBlocks: pure-text conversation still yields text blocks (untouched-path parity)", () => {
|
||||
// hasImageContent would be false for this input in server.mjs (text path taken),
|
||||
// but the transform must still be well-defined for a text-only turn.
|
||||
const { blocks, stats } = mmBuildImageBlocks([{ role: "user", content: "just text" }]);
|
||||
assert.deepEqual(blocks, [{ type: "text", text: "just text" }]);
|
||||
assert.equal(stats.imageCount, 0);
|
||||
assert.equal(stats.truncated, false);
|
||||
});
|
||||
|
||||
// ── F2 (PR #154 review): text char budget is enforced on the multimodal path ──
|
||||
// Regression guard: without maxTextChars, attaching one tiny image let unbounded
|
||||
// text bypass MAX_PROMPT_CHARS entirely (the text path truncates; the image path
|
||||
// did not). server.mjs passes maxTextChars: MAX_PROMPT_CHARS into this transform.
|
||||
console.log("\nmultimodal text-budget enforcement (PR #154 F2):");
|
||||
|
||||
test("buildImageBlocks: text under budget → not truncated, blocks unchanged", () => {
|
||||
const { blocks, stats } = mmBuildImageBlocks(
|
||||
[{ role: "user", content: [txtPart("short"), imgPart()] }],
|
||||
{ maxTextChars: 1000 }
|
||||
);
|
||||
assert.equal(stats.truncated, false);
|
||||
assert.equal(stats.textChars, "short".length);
|
||||
assert.equal(blocks.filter(b => b.type === "image").length, 1);
|
||||
});
|
||||
|
||||
test("buildImageBlocks: text over budget → truncated, keeps most-recent tail + note", () => {
|
||||
const big = "A".repeat(300) + "TAIL_MARKER";
|
||||
const { blocks, stats } = mmBuildImageBlocks(
|
||||
[{ role: "user", content: [txtPart(big)] }],
|
||||
{ maxTextChars: 50 }
|
||||
);
|
||||
assert.equal(stats.truncated, true);
|
||||
assert.equal(stats.originalTextChars, big.length);
|
||||
// The most recent characters (the tail) survive; the oldest 'A's are dropped.
|
||||
const joined = blocks.filter(b => b.type === "text").map(b => b.text).join("");
|
||||
assert.ok(joined.includes("TAIL_MARKER"), "tail text must be kept");
|
||||
assert.ok(joined.includes("truncated to fit"), "a truncation note must be present");
|
||||
assert.ok(stats.originalTextChars > stats.textChars, "post-truncation text is smaller");
|
||||
});
|
||||
|
||||
test("buildImageBlocks: F2 exact scenario — 500k chars + one image → text bounded, image preserved", () => {
|
||||
const { blocks, stats } = mmBuildImageBlocks(
|
||||
[{ role: "user", content: [txtPart("Z".repeat(500000)), imgPart()] }],
|
||||
{ maxTextChars: 150000 }
|
||||
);
|
||||
assert.equal(stats.truncated, true);
|
||||
assert.ok(stats.textChars <= 150000 + 200, "text char count is bounded by the budget (+note)");
|
||||
// The image bypasses the text budget and is NOT dropped by truncation.
|
||||
assert.equal(blocks.filter(b => b.type === "image").length, 1);
|
||||
});
|
||||
|
||||
test("buildImageBlocks: default (no maxTextChars) never truncates — pure module standalone", () => {
|
||||
const { stats } = mmBuildImageBlocks([{ role: "user", content: [txtPart("x".repeat(10000))] }]);
|
||||
assert.equal(stats.truncated, false);
|
||||
assert.equal(stats.textChars, 10000);
|
||||
});
|
||||
|
||||
// ── F3 (PR #154 review): fail-closed positive-int env parsing ────────────────
|
||||
// A misconfigured numeric cap must NEVER silently disable a guard (`x > NaN` is
|
||||
// always false) or brick the proxy with a nonsense value. parsePositiveInt keeps
|
||||
// the default and reports ok:false so the caller can warn.
|
||||
console.log("\nfail-closed env-cap parsing (PR #154 F3):");
|
||||
|
||||
test("parsePositiveInt: missing/empty → default, ok", () => {
|
||||
assert.deepEqual(parsePositiveInt(undefined, 42), { value: 42, ok: true });
|
||||
assert.deepEqual(parsePositiveInt("", 42), { value: 42, ok: true });
|
||||
});
|
||||
|
||||
test("parsePositiveInt: valid positive integer → parsed value", () => {
|
||||
assert.equal(parsePositiveInt("5000000", 42).value, 5000000);
|
||||
assert.equal(parsePositiveInt("5000000", 42).ok, true);
|
||||
});
|
||||
|
||||
test("parsePositiveInt: 'unlimited' → NaN rejected, default kept (would drop the cap)", () => {
|
||||
const r = parsePositiveInt("unlimited", 5 * 1024 * 1024);
|
||||
assert.equal(r.value, 5 * 1024 * 1024);
|
||||
assert.equal(r.ok, false);
|
||||
});
|
||||
|
||||
test("parsePositiveInt: '5MB' → unit suffix rejected (naive parseInt would give 5 bytes)", () => {
|
||||
const r = parsePositiveInt("5MB", 5 * 1024 * 1024);
|
||||
assert.equal(r.value, 5 * 1024 * 1024);
|
||||
assert.equal(r.ok, false);
|
||||
});
|
||||
|
||||
test("parsePositiveInt: '0' and '-1' → non-positive rejected", () => {
|
||||
assert.equal(parsePositiveInt("0", 20).ok, false);
|
||||
assert.equal(parsePositiveInt("0", 20).value, 20);
|
||||
assert.equal(parsePositiveInt("-1", 20).ok, false);
|
||||
});
|
||||
|
||||
test("parsePositiveInt: '20.5' → fractional/ambiguous rejected", () => {
|
||||
assert.equal(parsePositiveInt("20.5", 20).ok, false);
|
||||
});
|
||||
|
||||
test("parsePositiveInt: surrounding whitespace tolerated", () => {
|
||||
assert.deepEqual(parsePositiveInt(" 20 ", 5), { value: 20, ok: true });
|
||||
});
|
||||
|
||||
// ── PR #154 review round 2, gap (a): MAX_PROMPT_CHARS must fail closed like the other caps ──
|
||||
// server.mjs now derives MAX_PROMPT_CHARS via parseIntEnv → parsePositiveInt (was a raw parseInt).
|
||||
// CLAUDE_MAX_PROMPT_CHARS=unlimited previously → NaN → enforceTextBudget's `!(NaN > 0)` early-return
|
||||
// → 500k chars passed unbounded, defeating F2's text-budget guarantee. The default must be kept.
|
||||
test("parsePositiveInt: CLAUDE_MAX_PROMPT_CHARS='unlimited' → default kept, cap not lost to NaN (gap a)", () => {
|
||||
const r = parsePositiveInt("unlimited", 150000);
|
||||
assert.equal(r.ok, false);
|
||||
assert.equal(r.value, 150000, "the 150k text budget must survive a bad config, not become NaN");
|
||||
});
|
||||
test("parsePositiveInt: CLAUDE_MAX_PROMPT_CHARS valid override honored", () => {
|
||||
assert.deepEqual(parsePositiveInt("200000", 150000), { value: 200000, ok: true });
|
||||
});
|
||||
|
||||
// ── messages guard predicate truth-table (issue #110) ────────────────────────
|
||||
// Mirrors the guard at server.mjs line ~1650: Array.isArray(x) && x.length > 0
|
||||
console.log("\nmessages guard predicate (issue #110):");
|
||||
@@ -3957,6 +4355,262 @@ test("stream: /health block is additive and exposes the divergence counter", ()
|
||||
assert.equal(legacy.streamDivergences, 0);
|
||||
});
|
||||
|
||||
// ── OpenAI Structured Outputs (response_format) — lib/structured-output.mjs ──
|
||||
import { detectStructuredOutput, validateJsonSchema, validateJsonSchemaSafe, extractJsonPayload, structuredSystemInstruction, StructuredOutputError, resolveMaxAttempts } from "./lib/structured-output.mjs";
|
||||
|
||||
test("detectStructuredOutput: json_schema shape", () => {
|
||||
const d = detectStructuredOutput({ response_format: { type: "json_schema", json_schema: { name: "x", strict: true, schema: { type: "object" } } } });
|
||||
assert.equal(d.mode, "schema"); assert.equal(d.strict, true); assert.deepEqual(d.schema, { type: "object" });
|
||||
});
|
||||
test("detectStructuredOutput: json_object shape", () => {
|
||||
assert.deepEqual(detectStructuredOutput({ response_format: { type: "json_object" } }), { mode: "json_object" });
|
||||
});
|
||||
test("detectStructuredOutput: json_mode:true alias → json_object", () => {
|
||||
assert.deepEqual(detectStructuredOutput({ json_mode: true }), { mode: "json_object" });
|
||||
});
|
||||
test("detectStructuredOutput: absent → null (non-structured untouched)", () => {
|
||||
assert.equal(detectStructuredOutput({ messages: [] }), null);
|
||||
assert.equal(detectStructuredOutput({ response_format: "nonsense" }), null);
|
||||
assert.equal(detectStructuredOutput({ json_mode: false }), null);
|
||||
});
|
||||
test("cacheHash: structured marker isolates JSON requests from the conversational slot", () => {
|
||||
const msgs = [{ role: "user", content: "list 3 fruits" }];
|
||||
const plain = cacheHash("m", msgs, { keyId: "k" });
|
||||
const asJson = cacheHash("m", msgs, { keyId: "k", structured: { mode: "json_object" } });
|
||||
const asSchema = cacheHash("m", msgs, { keyId: "k", structured: { mode: "schema", schema: { type: "array" } } });
|
||||
assert.notEqual(plain, asJson); // JSON vs prose never collide
|
||||
assert.notEqual(asJson, asSchema); // different schema → different slot
|
||||
assert.equal(plain, cacheHash("m", msgs, { keyId: "k" })); // unchanged for normal requests
|
||||
});
|
||||
|
||||
// ── validateJsonSchemaSafe (#181): deep value must NOT crash the handler ─────
|
||||
// A recursive schema + a model reply nested ~thousands deep overflows the value-
|
||||
// depth recursion → RangeError → the handler used to surface a generic 500. The
|
||||
// safe façade turns it into a validation miss (→ retry → refusal). Mutation-proof:
|
||||
// replace the wrapper body with a bare `validateJsonSchema(...)` call and the deep
|
||||
// test throws instead of returning errors.
|
||||
test("validateJsonSchemaSafe: pathologically deep value → errors, never throws", () => {
|
||||
const schema = { $defs: { node: { type: "object", properties: { child: { $ref: "#/$defs/node" } } } }, $ref: "#/$defs/node" };
|
||||
let deep = {};
|
||||
let cur = deep;
|
||||
for (let i = 0; i < 6000; i++) { cur.child = {}; cur = cur.child; } // way past any stack limit
|
||||
let out;
|
||||
assert.doesNotThrow(() => { out = validateJsonSchemaSafe(deep, schema, "$", true); }, "must not throw a RangeError out to the handler");
|
||||
assert.ok(Array.isArray(out) && out.length > 0, "returns a non-empty validation error, so the retry loop yields a refusal not a 500");
|
||||
});
|
||||
|
||||
test("validateJsonSchemaSafe: well-formed value passes through unchanged (byte-identical to the raw validator)", () => {
|
||||
const schema = { type: "object", required: ["name", "age"], properties: { name: { type: "string" }, age: { type: "integer" } } };
|
||||
assert.deepEqual(validateJsonSchemaSafe({ name: "a", age: 3 }, schema), validateJsonSchema({ name: "a", age: 3 }, schema));
|
||||
assert.deepEqual(validateJsonSchemaSafe({ name: "a" }, schema), validateJsonSchema({ name: "a" }, schema)); // error case matches too
|
||||
});
|
||||
|
||||
test("validateJsonSchemaSafe: re-throws a non-RangeError so genuine bugs aren't masked as a validation miss", () => {
|
||||
// A schema whose `required` is a non-iterable makes the inner validator throw a TypeError — that's
|
||||
// a real bug, not a deep-value overflow, and must surface (not be swallowed as "did not validate").
|
||||
assert.throws(() => validateJsonSchemaSafe({ x: 1 }, { type: "object", required: 42 }), (e) => !(e instanceof RangeError));
|
||||
});
|
||||
|
||||
test("validateJsonSchema: valid object passes", () => {
|
||||
assert.deepEqual(validateJsonSchema({ name: "a", age: 3 }, { type: "object", required: ["name", "age"], properties: { name: { type: "string" }, age: { type: "integer" } } }), []);
|
||||
});
|
||||
test("validateJsonSchema: missing required property flagged", () => {
|
||||
assert.ok(validateJsonSchema({ name: "a" }, { type: "object", required: ["name", "age"], properties: {} }).some(e => /age.*required/.test(e)));
|
||||
});
|
||||
test("validateJsonSchema: additionalProperties:false rejects extra keys", () => {
|
||||
assert.ok(validateJsonSchema({ a: 1, b: 2 }, { type: "object", additionalProperties: false, properties: { a: { type: "integer" } } }).some(e => /b.*additional/.test(e)));
|
||||
});
|
||||
test("validateJsonSchema: enum rejects non-null value not in list", () => {
|
||||
assert.ok(validateJsonSchema("maybe", { type: "string", enum: ["yes", "no"] }).length > 0);
|
||||
});
|
||||
test("validateJsonSchema: NULLABLE enum accepts null even when null not in enum (HA regression)", () => {
|
||||
// type:["string","null"] + enum:["Loxone"] — a null value must be accepted (nullability > enum).
|
||||
assert.deepEqual(validateJsonSchema(null, { type: ["string", "null"], enum: ["Loxone"] }), []);
|
||||
});
|
||||
test("validateJsonSchema: nullable enum still enforces non-null values against the enum", () => {
|
||||
assert.ok(validateJsonSchema("Other", { type: ["string", "null"], enum: ["Loxone"] }).length > 0);
|
||||
});
|
||||
test("validateJsonSchema: type mismatch flagged", () => {
|
||||
assert.ok(validateJsonSchema("str", { type: "integer" }).length > 0);
|
||||
});
|
||||
test("validateJsonSchema: array items + minItems", () => {
|
||||
assert.deepEqual(validateJsonSchema([1, 2, 3], { type: "array", items: { type: "integer" }, minItems: 3 }), []);
|
||||
assert.ok(validateJsonSchema([1], { type: "array", items: { type: "integer" }, minItems: 3 }).some(e => /minItems/.test(e)));
|
||||
});
|
||||
|
||||
test("extractJsonPayload: clean JSON", () => {
|
||||
const r = extractJsonPayload('{"a":1}'); assert.ok(r.ok); assert.deepEqual(r.value, { a: 1 });
|
||||
});
|
||||
test("extractJsonPayload: fenced ```json block", () => {
|
||||
const r = extractJsonPayload('```json\n{"a":1}\n```'); assert.ok(r.ok); assert.deepEqual(r.value, { a: 1 });
|
||||
});
|
||||
test("extractJsonPayload: prose-wrapped, string-aware balanced slice", () => {
|
||||
const r = extractJsonPayload('Sure! Here you go: {"note":"has } and { inside"} — hope that helps.');
|
||||
assert.ok(r.ok); assert.deepEqual(r.value, { note: "has } and { inside" });
|
||||
});
|
||||
test("extractJsonPayload: array payload", () => {
|
||||
const r = extractJsonPayload('[1,2,3]'); assert.ok(r.ok); assert.deepEqual(r.value, [1, 2, 3]);
|
||||
});
|
||||
test("extractJsonPayload: no JSON → ok:false", () => {
|
||||
assert.equal(extractJsonPayload("I cannot help with that.").ok, false);
|
||||
});
|
||||
|
||||
test("structuredSystemInstruction: embeds schema, forbids fences, escalates on retry", () => {
|
||||
const first = structuredSystemInstruction({ mode: "schema", schema: { type: "object" } }, 0, "");
|
||||
assert.ok(/code fences/.test(first) && /JSON Schema/.test(first));
|
||||
const retry = structuredSystemInstruction({ mode: "schema", schema: { type: "object" } }, 1, "bad enum");
|
||||
assert.ok(/REJECTED \(bad enum\)/.test(retry));
|
||||
});
|
||||
test("StructuredOutputError carries reason", () => {
|
||||
const e = new StructuredOutputError("schema validation failed", "raw");
|
||||
assert.equal(e.reason, "schema validation failed"); assert.ok(e instanceof Error);
|
||||
});
|
||||
|
||||
// ── PR #153 review round 2, MUST-FIX: OCP_STRUCTURED_MAX_ATTEMPTS NaN guard must fail closed ──
|
||||
// The old `Math.max(1, parseInt(env||"3",10))` returned NaN for a non-integer value → the retry loop
|
||||
// `attempt < NaN` never ran → 0 spawns, every structured request refused. resolveMaxAttempts keeps
|
||||
// the default instead of silently bricking the feature.
|
||||
test("resolveMaxAttempts: valid integer honored", () => {
|
||||
assert.equal(resolveMaxAttempts("5"), 5);
|
||||
assert.equal(resolveMaxAttempts("1"), 1);
|
||||
});
|
||||
test("resolveMaxAttempts: unset/empty → default", () => {
|
||||
assert.equal(resolveMaxAttempts(undefined), 3);
|
||||
assert.equal(resolveMaxAttempts(""), 3);
|
||||
assert.equal(resolveMaxAttempts(null), 3);
|
||||
});
|
||||
test("resolveMaxAttempts: non-integer / non-finite / <1 fails CLOSED to the default (not NaN, not 0)", () => {
|
||||
let warned = 0; const warn = () => { warned++; };
|
||||
for (const bad of ["abc", "0", "-1", "NaN", "Infinity", " "]) {
|
||||
const v = resolveMaxAttempts(bad, { fallback: 3, warn });
|
||||
assert.equal(v, 3, `bad input ${JSON.stringify(bad)} must fall back to 3, got ${v}`);
|
||||
assert.ok(Number.isFinite(v) && v >= 1, "result is always a usable positive integer");
|
||||
}
|
||||
assert.ok(warned > 0, "invalid values emit a startup warning");
|
||||
});
|
||||
test("resolveMaxAttempts: the retry loop is never bounded by NaN (regression: 0 spawns / silent refuse)", () => {
|
||||
const attempts = resolveMaxAttempts("abc");
|
||||
let ran = 0;
|
||||
for (let attempt = 0; attempt < attempts; attempt++) ran++;
|
||||
assert.ok(ran >= 1, "loop must execute at least once — pre-fix it ran 0 times");
|
||||
});
|
||||
|
||||
// ── PR #153 review finding 1: $ref/$defs + strict:true must accept conforming objects ──
|
||||
// The flagship shape the OpenAI SDK emits (zodResponseFormat / client.beta.chat.completions.parse)
|
||||
// and OpenAI's own structured-outputs docs example: nested {$ref:"#/$defs/step"} + strict:true.
|
||||
// Before the fix, strict inferred additionalProperties:false on the unresolved $ref (empty props) and
|
||||
// rejected every real key. This is the exact regression the PR must not ship.
|
||||
const OPENAI_DOC_SCHEMA = {
|
||||
type: "object",
|
||||
properties: {
|
||||
steps: { type: "array", items: { $ref: "#/$defs/step" } },
|
||||
final_answer: { type: "string" },
|
||||
},
|
||||
$defs: {
|
||||
step: {
|
||||
type: "object",
|
||||
properties: { explanation: { type: "string" }, output: { type: "string" } },
|
||||
required: ["explanation", "output"],
|
||||
additionalProperties: false,
|
||||
},
|
||||
},
|
||||
required: ["steps", "final_answer"],
|
||||
additionalProperties: false,
|
||||
};
|
||||
|
||||
test("validateJsonSchema: OpenAI doc schema ($ref/$defs) + strict:true accepts a conforming reply", () => {
|
||||
const conforming = { steps: [{ explanation: "add", output: "4" }, { explanation: "done", output: "4" }], final_answer: "4" };
|
||||
assert.deepEqual(validateJsonSchema(conforming, OPENAI_DOC_SCHEMA, "$", true), []);
|
||||
});
|
||||
|
||||
test("validateJsonSchema: $ref + strict:true still REJECTS a genuinely-extra key (fix didn't disable validation)", () => {
|
||||
const extra = { steps: [{ explanation: "add", output: "4", bogus: 1 }], final_answer: "4" };
|
||||
const errs = validateJsonSchema(extra, OPENAI_DOC_SCHEMA, "$", true);
|
||||
assert.ok(errs.some(e => /bogus.*additional property not allowed/.test(e)), `expected the extra key rejected, got: ${JSON.stringify(errs)}`);
|
||||
});
|
||||
|
||||
test("validateJsonSchema: $ref + strict:true still catches a missing required property", () => {
|
||||
const missing = { steps: [{ explanation: "add" }], final_answer: "4" };
|
||||
assert.ok(validateJsonSchema(missing, OPENAI_DOC_SCHEMA, "$", true).some(e => /output.*required/.test(e)));
|
||||
});
|
||||
|
||||
test("validateJsonSchema: anyOf accepts a value matching one branch, rejects a value matching none", () => {
|
||||
const schema = { anyOf: [{ type: "string" }, { type: "integer" }] };
|
||||
assert.deepEqual(validateJsonSchema("hi", schema), []);
|
||||
assert.deepEqual(validateJsonSchema(3, schema), []);
|
||||
assert.ok(validateJsonSchema(true, schema).length > 0);
|
||||
});
|
||||
|
||||
test("validateJsonSchema: allOf requires every branch to pass", () => {
|
||||
const schema = { allOf: [{ type: "object", properties: { a: { type: "integer" } }, required: ["a"] }, { type: "object", properties: { b: { type: "string" } }, required: ["b"] }] };
|
||||
assert.deepEqual(validateJsonSchema({ a: 1, b: "x" }, schema), []);
|
||||
assert.ok(validateJsonSchema({ a: 1 }, schema).some(e => /b.*required/.test(e)));
|
||||
});
|
||||
|
||||
test("validateJsonSchema: unresolvable $ref is skipped, not failed", () => {
|
||||
assert.deepEqual(validateJsonSchema({ anything: 1 }, { $ref: "#/$defs/missing" }), []);
|
||||
});
|
||||
|
||||
// ── PR #153 review round 2, BLOCKER: cyclic $ref must fail closed, not stack-overflow ──
|
||||
// A pure ref→ref cycle recurses independent of the data — before the fix ANY reply value (even `5`)
|
||||
// threw `RangeError: Maximum call stack size exceeded`, caught upstream as a 500 but only after
|
||||
// 1–3 metered spawns → a request-controlled cost-amplification / grief vector on an authed path.
|
||||
test("validateJsonSchema: a→b→a cyclic $ref fails closed (no stack overflow) for any value", () => {
|
||||
const schema = { $defs: { a: { $ref: "#/$defs/b" }, b: { $ref: "#/$defs/a" } }, $ref: "#/$defs/a" };
|
||||
let errs;
|
||||
assert.doesNotThrow(() => { errs = validateJsonSchema(5, schema, "$", true); }, "cyclic $ref must not overflow the stack");
|
||||
assert.ok(errs.some(e => /cyclic \$ref/.test(e)), `expected a cyclic-$ref error, got: ${JSON.stringify(errs)}`);
|
||||
});
|
||||
test("validateJsonSchema: self-referential $ref (a→a) fails closed", () => {
|
||||
const schema = { $defs: { a: { $ref: "#/$defs/a" } }, $ref: "#/$defs/a" };
|
||||
let errs;
|
||||
assert.doesNotThrow(() => { errs = validateJsonSchema({ x: 1 }, schema, "$", true); });
|
||||
assert.ok(errs.some(e => /cyclic \$ref/.test(e)));
|
||||
});
|
||||
test("validateJsonSchema: cycle routed through anyOf fails closed", () => {
|
||||
const schema = { $defs: { a: { anyOf: [{ $ref: "#/$defs/a" }] } }, $ref: "#/$defs/a" };
|
||||
assert.doesNotThrow(() => validateJsonSchema({ x: 1 }, schema, "$", true));
|
||||
});
|
||||
test("validateJsonSchema: a LEGITIMATE recursive schema (Node→child:Node) is NOT flagged as a cycle", () => {
|
||||
// Data is a finite tree, so data-consuming recursion terminates — the cycle guard must not
|
||||
// false-positive here (refChain resets across properties/items).
|
||||
const schema = {
|
||||
$defs: { node: { type: "object", properties: { v: { type: "integer" }, child: { $ref: "#/$defs/node" } }, required: ["v"], additionalProperties: false } },
|
||||
$ref: "#/$defs/node",
|
||||
};
|
||||
const tree = { v: 1, child: { v: 2, child: { v: 3 } } };
|
||||
assert.deepEqual(validateJsonSchema(tree, schema, "$", true), []);
|
||||
});
|
||||
|
||||
// ── PR #153 review finding 2: never serve an unvalidated / ambiguous extraction ──
|
||||
test("extractJsonPayload: json_object mode rejects a refusal that merely CONTAINS json", () => {
|
||||
const reply = 'I can\'t do that. For reference the schema looks like {"type":"object"} — sorry.';
|
||||
const r = extractJsonPayload(reply, { whole: true });
|
||||
assert.equal(r.ok, false);
|
||||
});
|
||||
|
||||
test("extractJsonPayload: json_object mode accepts a whole-reply JSON value", () => {
|
||||
const r = extractJsonPayload(' {"temp":21} ', { whole: true });
|
||||
assert.ok(r.ok); assert.deepEqual(r.value, { temp: 21 });
|
||||
});
|
||||
|
||||
test("extractJsonPayload: schema mode rejects >1 top-level JSON value (Schema:{} Answer:{})", () => {
|
||||
const reply = 'Schema: {"type":"object"}\n\nAnswer: {"temp":21}';
|
||||
const r = extractJsonPayload(reply);
|
||||
assert.equal(r.ok, false);
|
||||
assert.ok(/more than one/.test(r.reason || ""));
|
||||
});
|
||||
|
||||
test("extractJsonPayload: schema mode rejects two competing options rather than silently picking one", () => {
|
||||
const r = extractJsonPayload('Option A:\n{"a":1}\nOption B:\n{"b":2}');
|
||||
assert.equal(r.ok, false);
|
||||
});
|
||||
|
||||
test("extractJsonPayload: single prose-wrapped value still accepted in schema mode", () => {
|
||||
const r = extractJsonPayload('Sure, here you go: {"a":1} — done.');
|
||||
assert.ok(r.ok); assert.deepEqual(r.value, { a: 1 });
|
||||
});
|
||||
|
||||
// ── Cleanup ──
|
||||
// Settle the async-bodied tests registered through the sync `test()` helper BEFORE summarizing —
|
||||
// otherwise their pass/fail is not reflected in the counts (see the `pendingAsync` comment above).
|
||||
|
||||
Reference in New Issue
Block a user