mirror of
https://github.com/dtzp555-max/ocp.git
synced 2026-07-22 21:45:08 +00:00
Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
a48910191b | ||
|
|
c377219c1c |
+2
-17
@@ -1,25 +1,10 @@
|
|||||||
# Changelog
|
# Changelog
|
||||||
|
|
||||||
## v3.23.0 — 2026-07-17
|
## Unreleased
|
||||||
|
|
||||||
Minor release. Headline: **the default `sonnet` alias now resolves to Claude Sonnet 5** — a behavior change for every request that omits `model` (pin `claude-sonnet-4-6` by full ID to keep the previous default). Also: Windows-safe upgrade snapshots, two upgrade-system reliability fixes from a live fleet update, the `CLAUDE_SYSTEM_PROMPT` env var made functional, cache-key honesty for config changes, a billing-policy status correction (the 2026-06-15 `-p` split is PAUSED by Anthropic), and a major README restructure. No new endpoint; no new `cli.js` wire behavior. Every code PR carried a fresh-context reviewer (Iron Rule 10).
|
|
||||||
|
|
||||||
### Changed
|
### Changed
|
||||||
|
|
||||||
- **Default `sonnet` alias → `claude-sonnet-5` (#168, contributed by @vvlasy-openclaw).** The `sonnet` alias (the model used for every `/v1/chat/completions` request that omits `model`, and OpenClaw's OCP primary via `ocp-connect`) now resolves to `claude-sonnet-5` instead of `claude-sonnet-4-6`. `claude-sonnet-4-6` remains available by full ID for pinning. Mirrors the shipped Claude CLI's own `latest_per_family` mapping (`sonnet → claude-sonnet-5`, verified from binary 2.1.211). Split out from the additive model entry (#152) per Iron Rule 11.
|
- **Default `sonnet` alias → `claude-sonnet-5`.** The `sonnet` alias (the model used for every `/v1/chat/completions` request that omits `model`, and OpenClaw's OCP primary via `ocp-connect`) now resolves to `claude-sonnet-5` instead of `claude-sonnet-4-6`. `claude-sonnet-4-6` remains available by full ID for pinning. This is a behavior change for clients relying on the default — pin `claude-sonnet-4-6` explicitly to retain the previous model. Split out from the additive `claude-sonnet-5` model entry (#152) per Iron Rule 11.
|
||||||
- **`CLAUDE_SYSTEM_PROMPT` is now functional (#175).** The var was read, documented, and echoed on `/health.systemPrompt` but never reached a request (dead since the `APPEND_SYSTEM_PROMPT` retirement). It is now appended (last, trimmed) to the composed system prompt on the default `-p` path via the new pure `lib/prompt.mjs`; TUI-mode panes are unaffected. Unset ⇒ byte-identical composition to before. README § Environment Variables documents it, including the cache caveat below.
|
|
||||||
|
|
||||||
### Fixed
|
|
||||||
|
|
||||||
- **Windows-safe upgrade snapshot paths (#167, contributed by @nyxst4ck).** Snapshot directory timestamps now use `-` instead of `:` (Windows forbids `:` in names); legacy colon-named snapshots keep parsing, and `listSnapshots` now orders by **parsed timestamp** (with a deterministic name tie-breaker) so mixed legacy/new names sort chronologically — the initial revision's raw-string sort could delete the newest recovery snapshot at the format boundary and was caught in review; regression tests pin the same-hour mixed-format case.
|
|
||||||
- **`ocp update` reliability — two live-incident fixes (#174, closes #173).** (1) The doctor now runs `git fetch --tags` (offline-tolerant) before computing `latest_version` — previously it compared against the locally cached `origin/main`, so machines that hadn't pulled since a release reported "Already at latest" forever. (2) Post-flight now asserts `/health.version` equals the upgrade target (new `postFlightOk` predicate) instead of accepting any `auth.ok` — a stale orphan process holding the port used to pass post-flight while still serving the old version; the failure message now reports the last-seen version and points at `ss -ltnp`/`lsof -i`.
|
|
||||||
- **Response-cache key now carries a boot-config epoch (#177, closes #176).** The persistent cache keyed on model+key+params+messages but not on server config that shapes answers (`CLAUDE_SYSTEM_PROMPT`, wrapper text, `CLAUDE_ALLOWED_TOOLS`, `CLAUDE_NO_CONTEXT`) — changing any of these and restarting could serve stale-config answers until TTL expiry. A sha256 config-epoch is folded into every key; any config change is an instant whole-cache invalidation. One-time side effect: existing cache entries miss once after this upgrade.
|
|
||||||
|
|
||||||
### Docs
|
|
||||||
|
|
||||||
- **Billing-policy status corrected (#171).** Anthropic **paused** the announced 2026-06-15 `claude -p` billing split on its effective date (official help-article citation in README § How It Works): the default `-p` path currently bills the subscription, and TUI-mode is reframed as the ready-made **hedge** for if/when a reworked change lands. All in-force assertions of the split are now date-stamped and conditioned.
|
|
||||||
- **LAN mode scoped to chat-class workloads (#171).** New "workload fit" paragraph: multi-device OCP is for text-in/text-out workloads; client-machine coding agents are architecturally out of scope (tools execute on the OCP host).
|
|
||||||
- **README restructured, 1205 → ~500 lines (#172).** Operations-manual content moved to `docs/lan-mode.md`, `docs/tui-mode.md`, `docs/troubleshooting.md`, `docs/upgrading.md` (verbatim moves + two canonical dedups; zero content loss verified section-by-section). README keeps the quickstart, the release-kit-pinned reference tables, and summary stubs with links. Plus a staleness sweep (#170): 6-model examples, removal of the never-existed `ocp stop` command, `ocp-connect` claims corrected, current version examples.
|
|
||||||
|
|
||||||
## v3.22.1 — 2026-07-17
|
## v3.22.1 — 2026-07-17
|
||||||
|
|
||||||
|
|||||||
@@ -27,7 +27,7 @@ One proxy. Multiple IDEs. All models. **$0 API cost.**
|
|||||||
- [How It Works](#how-it-works)
|
- [How It Works](#how-it-works)
|
||||||
- Reference: [Available Models](#available-models) · [API Endpoints](#api-endpoints) · [Environment Variables](#environment-variables)
|
- Reference: [Available Models](#available-models) · [API Endpoints](#api-endpoints) · [Environment Variables](#environment-variables)
|
||||||
- Modes & operations: [LAN & multi-user](#lan--multi-user) → [`docs/lan-mode.md`](docs/lan-mode.md) · [Subscription-pool (TUI) mode](#subscription-pool-tui-mode) → [`docs/tui-mode.md`](docs/tui-mode.md) · [Upgrading](#upgrading) → [`docs/upgrading.md`](docs/upgrading.md)
|
- Modes & operations: [LAN & multi-user](#lan--multi-user) → [`docs/lan-mode.md`](docs/lan-mode.md) · [Subscription-pool (TUI) mode](#subscription-pool-tui-mode) → [`docs/tui-mode.md`](docs/tui-mode.md) · [Upgrading](#upgrading) → [`docs/upgrading.md`](docs/upgrading.md)
|
||||||
- [Built-in Usage Monitoring](#built-in-usage-monitoring) · [Response Cache](#response-cache) · [Structured Outputs](#structured-outputs-openai-response_format) · [Images / Multimodal](#images--multimodal-vision) · [OpenClaw Integration](#openclaw-integration)
|
- [Built-in Usage Monitoring](#built-in-usage-monitoring) · [Response Cache](#response-cache) · [OpenClaw Integration](#openclaw-integration)
|
||||||
- [Troubleshooting](#troubleshooting) → [`docs/troubleshooting.md`](docs/troubleshooting.md)
|
- [Troubleshooting](#troubleshooting) → [`docs/troubleshooting.md`](docs/troubleshooting.md)
|
||||||
- [Repository Layout](#repository-layout) · [Security](#security) · [Governance](#governance) · [Support OCP](#support-ocp) · [License](#license)
|
- [Repository Layout](#repository-layout) · [Security](#security) · [Governance](#governance) · [Support OCP](#support-ocp) · [License](#license)
|
||||||
|
|
||||||
@@ -222,19 +222,13 @@ The canonical list lives in [`models.json`](./models.json) — the single source
|
|||||||
| `CLAUDE_MAX_CONCURRENT` | `8` | Max concurrent claude processes (`-p`/stream-json path) |
|
| `CLAUDE_MAX_CONCURRENT` | `8` | Max concurrent claude processes (`-p`/stream-json path) |
|
||||||
| `CLAUDE_MAX_QUEUE` | `16` | Max requests **waiting** for a `-p` concurrency slot. Beyond `CLAUDE_MAX_CONCURRENT`, requests queue (up to this cap) instead of being rejected; when the queue is **also** full, the request gets `HTTP 429` + `Retry-After` (not an opaque 500). Surfaced on `/health.concurrency` + `/health.stats.queueRejections`. |
|
| `CLAUDE_MAX_QUEUE` | `16` | Max requests **waiting** for a `-p` concurrency slot. Beyond `CLAUDE_MAX_CONCURRENT`, requests queue (up to this cap) instead of being rejected; when the queue is **also** full, the request gets `HTTP 429` + `Retry-After` (not an opaque 500). Surfaced on `/health.concurrency` + `/health.stats.queueRejections`. |
|
||||||
| `CLAUDE_QUEUE_RETRY_AFTER` | `5` | Seconds advertised in the `Retry-After` header on a `-p` concurrency-overflow `429`. |
|
| `CLAUDE_QUEUE_RETRY_AFTER` | `5` | Seconds advertised in the `Retry-After` header on a `-p` concurrency-overflow `429`. |
|
||||||
| `CLAUDE_MAX_PROMPT_CHARS` | *(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)). |
|
| `CLAUDE_MAX_PROMPT_CHARS` | `150000` | Prompt truncation limit (chars) |
|
||||||
| `OCP_STRUCTURED_MAX_ATTEMPTS` | `3` | Max attempts (initial + retries) to coerce a schema-valid JSON reply when a request uses OpenAI `response_format`. Fail-closed: a non-numeric value keeps the default. See [Structured Outputs](#structured-outputs-openai-response_format). |
|
|
||||||
| `CLAUDE_SESSION_TTL` | `3600000` | Session expiry (ms, default: 1 hour) |
|
| `CLAUDE_SESSION_TTL` | `3600000` | Session expiry (ms, default: 1 hour) |
|
||||||
| `CLAUDE_CACHE_TTL` | `0` | Response cache TTL (ms, 0 = disabled). Set to e.g. `300000` for 5-min cache. See [Response Cache](#response-cache). |
|
| `CLAUDE_CACHE_TTL` | `0` | Response cache TTL (ms, 0 = disabled). Set to e.g. `300000` for 5-min cache. See [Response Cache](#response-cache). |
|
||||||
| `CLAUDE_ALLOWED_TOOLS` | `Bash,Read,...,Agent` | Comma-separated tools to pre-approve |
|
| `CLAUDE_ALLOWED_TOOLS` | `Bash,Read,...,Agent` | Comma-separated tools to pre-approve |
|
||||||
| `CLAUDE_SKIP_PERMISSIONS` | `false` | Bypass all permission checks |
|
| `CLAUDE_SKIP_PERMISSIONS` | `false` | Bypass all permission checks |
|
||||||
| `CLAUDE_MCP_CONFIG` | *(unset)* | Path to an MCP server config JSON, passed to the spawned `claude` as `--mcp-config` (both the `-p` path and TUI `OCP_TUI_FULL_TOOLS` panes) |
|
| `CLAUDE_MCP_CONFIG` | *(unset)* | Path to an MCP server config JSON, passed to the spawned `claude` as `--mcp-config` (both the `-p` path and TUI `OCP_TUI_FULL_TOOLS` panes) |
|
||||||
| `CLAUDE_MAX_BODY_SIZE` | `5242880` | Max request body size (bytes, default 5 MB). Base64 image payloads inflate ~33%; raise this to admit larger multimodal requests. Fail-closed parsing: a garbage value keeps the default. |
|
| `CLAUDE_SYSTEM_PROMPT` | *(unset)* | Operator-wide system-prompt text appended (last) to every request's composed system prompt on the default `-p` path. TUI-mode panes are unaffected (they keep the interactive CLI's own system prompt). Echoed truncated on `/health.systemPrompt`. Note: the response cache key does not include server config — after changing this value, flush the cache (`ocp clear`) or let TTL expire. |
|
||||||
| `CLAUDE_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) |
|
| `CLAUDE_NO_CONTEXT` | `false` | Suppress CLAUDE.md and auto-memory injection (pure API mode) |
|
||||||
| `PROXY_API_KEY` | *(unset)* | Bearer token for shared-mode authentication |
|
| `PROXY_API_KEY` | *(unset)* | Bearer token for shared-mode authentication |
|
||||||
| `PROXY_ANONYMOUS_KEY` | *(unset)* | Well-known anonymous key (multi mode) — this exact string bypasses `validateKey()` and grants public access. Exposed via `/health.anonymousKey` only to localhost, or to all callers when `PROXY_ADVERTISE_ANON_KEY=1`. Full setup + security notes: [docs/lan-mode.md § Anonymous Access](docs/lan-mode.md#anonymous-access-optional). |
|
| `PROXY_ANONYMOUS_KEY` | *(unset)* | Well-known anonymous key (multi mode) — this exact string bypasses `validateKey()` and grants public access. Exposed via `/health.anonymousKey` only to localhost, or to all callers when `PROXY_ADVERTISE_ANON_KEY=1`. Full setup + security notes: [docs/lan-mode.md § Anonymous Access](docs/lan-mode.md#anonymous-access-optional). |
|
||||||
@@ -388,114 +382,6 @@ ocp settings cacheTTL 0 # disable at runtime
|
|||||||
|
|
||||||
Cache is **disabled by default** (`CLAUDE_CACHE_TTL=0`). All data is stored locally in `~/.ocp/ocp.db`. **Hash format upgrade in v3.13.0:** legacy `v1` cache rows don't match new `v2`-format lookups; they orphan and are reaped by the TTL cleanup interval within one window — no migration script required.
|
Cache is **disabled by default** (`CLAUDE_CACHE_TTL=0`). All data is stored locally in `~/.ocp/ocp.db`. **Hash format upgrade in v3.13.0:** legacy `v1` cache rows don't match new `v2`-format lookups; they orphan and are reaped by the TTL cleanup interval within one window — no migration script required.
|
||||||
|
|
||||||
## Structured Outputs (OpenAI `response_format`)
|
|
||||||
|
|
||||||
`/v1/chat/completions` honors OpenAI's [`response_format`](https://platform.openai.com/docs/api-reference/chat/create#chat-create-response_format) parameter so OpenAI-SDK clients that require machine-parseable JSON (Home Assistant AI Tasks, Honcho, BYO scripts) get JSON in `choices[].message.content` — not prose.
|
|
||||||
|
|
||||||
Supported shapes:
|
|
||||||
|
|
||||||
- `response_format: { "type": "json_schema", "json_schema": { "name", "strict", "schema" } }`
|
|
||||||
- `response_format: { "type": "json_object" }`
|
|
||||||
- `json_mode: true` — non-standard top-level alias honored by several OpenAI-compatible clients; treated as `json_object`.
|
|
||||||
|
|
||||||
When a structured request is detected, OCP:
|
|
||||||
|
|
||||||
1. Appends a strict JSON-only steering instruction to the request (no Markdown, no fences, no prose, must begin with `{` or `[`).
|
|
||||||
2. Extracts the JSON from the model reply (unwraps a stray code fence / prose via a string-aware balanced slice).
|
|
||||||
3. For `json_schema`, validates the result against the supplied schema (types, `required`, `enum`, `const`, `additionalProperties`, nullability, `items`, `min/maxItems`, and `$ref`/`$defs` + `allOf`/`anyOf`/`oneOf` composition — the shapes the official OpenAI SDK emits via `zodResponseFormat` / `client.beta.chat.completions.parse`). For `json_object`, the whole reply must parse as a single JSON value (a stray brace inside prose is not served as the answer).
|
|
||||||
4. On a parse/validation miss, retries with a stronger instruction that names the failure, up to `OCP_STRUCTURED_MAX_ATTEMPTS` (default 3).
|
|
||||||
5. If no valid JSON can be produced, returns OpenAI's assistant **`refusal`** field (`HTTP 200`, `message.content: null`, `message.refusal: "<reason>"`, `finish_reason: "stop"`) — the spec's own mechanism for "the model would not produce the required output" — rather than an invented error type or passing prose through. SDK clients take their written `refusal` branch.
|
|
||||||
|
|
||||||
A reply that carries **more than one** top-level JSON value (e.g. `Schema: {…}` then `Answer: {…}`) is rejected as ambiguous rather than silently serving the first — OCP never serves an unvalidated or arbitrarily-chosen extraction.
|
|
||||||
|
|
||||||
`message.content` for a structured request is the raw JSON string only — no fences, no reasoning, no wrapper. Non-structured requests are completely unaffected (normal conversational behaviour, streaming included). This is a Class B.1 endpoint extension authorized by ADR 0006; the pure logic lives in [`lib/structured-output.mjs`](./lib/structured-output.mjs) and is unit-tested in `test-features.mjs`.
|
|
||||||
|
|
||||||
**Caching & cost.** A structured request can cost up to `OCP_STRUCTURED_MAX_ATTEMPTS` metered `claude` spawns — each retry is a fresh spawn, burning subscription-window quota today and metered credits if the (currently **paused**) 2026-06-15 billing split re-lands (see the billing-policy status note in [How It Works](#how-it-works)) — so this feature adds cost-attack surface. Two guards bound it: (a) identical **concurrent** structured requests share one flight (single-flight dedup, so N callers ≠ N× spawns), and (b) when `CLAUDE_CACHE_TTL > 0`, a **validated** result is cached on a **structured-keyed** hash (the `response_format`/schema is folded into the key, so a JSON reply never collides with the conversational answer and different schemas never share a slot). A refusal is never cached. Operators concerned about cost can lower `OCP_STRUCTURED_MAX_ATTEMPTS` to `1` (no retries) or gate the surface behind per-key quotas (`/api/keys/:id/quota`).
|
|
||||||
## Images / Multimodal (Vision)
|
|
||||||
|
|
||||||
`POST /v1/chat/completions` accepts OpenAI-style multimodal `content` parts, so a
|
|
||||||
message can carry images alongside text and Claude will actually see them. This
|
|
||||||
follows OpenAI's [vision](https://platform.openai.com/docs/guides/vision) /
|
|
||||||
[chat-completions `image_url`](https://platform.openai.com/docs/api-reference/chat/create#chat-create-messages)
|
|
||||||
request shape — no OCP-invented fields. (Class B.1 endpoint; see ADR 0006.)
|
|
||||||
|
|
||||||
Under the hood, when a request carries an image OCP feeds the conversation to the
|
|
||||||
Claude CLI as Anthropic image blocks over `--input-format stream-json`. Text-only
|
|
||||||
requests are completely unaffected (unchanged code path).
|
|
||||||
|
|
||||||
### Supported input
|
|
||||||
|
|
||||||
- **Base64 data URIs** (default, recommended):
|
|
||||||
`data:image/png;base64,<...>`. Media types: `image/jpeg`, `image/png`,
|
|
||||||
`image/gif`, `image/webp`.
|
|
||||||
- **Remote `http(s)` URLs** — **off by default**. Set `CLAUDE_IMAGE_ALLOW_URL=1`
|
|
||||||
to enable; the URL is passed through to Anthropic (OCP never fetches it itself,
|
|
||||||
so there is no OCP-side SSRF surface).
|
|
||||||
- Images may appear in **any** message in the history (multi-turn), not just the
|
|
||||||
last one.
|
|
||||||
- Non-image, non-text parts (audio, files) are **not** yet supported and are
|
|
||||||
replaced with a `[non-text content omitted]` placeholder (deferred to a future
|
|
||||||
version).
|
|
||||||
|
|
||||||
### Example (base64 data URI)
|
|
||||||
|
|
||||||
```bash
|
|
||||||
curl -X POST http://127.0.0.1:3456/v1/chat/completions \
|
|
||||||
-H 'Content-Type: application/json' \
|
|
||||||
-d '{
|
|
||||||
"model": "claude-sonnet-4-6",
|
|
||||||
"messages": [{
|
|
||||||
"role": "user",
|
|
||||||
"content": [
|
|
||||||
{ "type": "text", "text": "What is in this image?" },
|
|
||||||
{ "type": "image_url",
|
|
||||||
"image_url": { "url": "data:image/png;base64,iVBORw0KGgoAAA..." } }
|
|
||||||
]
|
|
||||||
}]
|
|
||||||
}'
|
|
||||||
```
|
|
||||||
|
|
||||||
### Not supported in TUI mode
|
|
||||||
|
|
||||||
Multimodal images require the default `-p` spawn path. In **TUI / subscription-pool
|
|
||||||
mode** (`CLAUDE_TUI_MODE=true`) the CLI is driven interactively and cannot carry
|
|
||||||
image blocks, so a request with an `image_url` part returns **`400
|
|
||||||
images_unsupported_in_tui_mode`** rather than silently dropping the image and
|
|
||||||
answering about something the model never saw. Remove the images, or run OCP
|
|
||||||
without TUI mode, to use vision.
|
|
||||||
|
|
||||||
Images must also live in a **user or assistant** message, not a `system` message
|
|
||||||
(system content is not forwarded to the CLI as image blocks). An `image_url` part
|
|
||||||
present only in a system message returns **`400 images_unsupported_in_system_messages`**
|
|
||||||
for the same reason — fail loudly rather than answer about an unseen image. This matches
|
|
||||||
the OpenAI vision spec, which does not place images in the system role.
|
|
||||||
|
|
||||||
### Limits
|
|
||||||
|
|
||||||
Images bypass the text `CLAUDE_MAX_PROMPT_CHARS` budget and are instead bounded by
|
|
||||||
their own byte/count caps. The **text** in a multimodal request is still subject to
|
|
||||||
`CLAUDE_MAX_PROMPT_CHARS` (older text is truncated exactly as on the text-only
|
|
||||||
path — only the image bytes are exempt). All numeric caps are parsed **fail-closed**:
|
|
||||||
a malformed value (e.g. `CLAUDE_MAX_BODY_SIZE=unlimited` or `=5MB`) is rejected with
|
|
||||||
a startup warning and the safe default is kept — a misconfigured cap can never
|
|
||||||
silently disable the guard. Requests that violate a cap get a clear `4xx` (never a
|
|
||||||
silent drop):
|
|
||||||
|
|
||||||
| Cap | Env var | Default | Error |
|
|
||||||
|-----|---------|---------|-------|
|
|
||||||
| Request body | `CLAUDE_MAX_BODY_SIZE` | 5 MB | `413` request body too large |
|
|
||||||
| Per-image bytes | `CLAUDE_MAX_IMAGE_BYTES` | 5 MB | `413` `image_too_large` |
|
|
||||||
| Total image bytes | `CLAUDE_MAX_IMAGE_TOTAL_BYTES` | 20 MB | `413` `images_too_large` |
|
|
||||||
| Image count | `CLAUDE_MAX_IMAGES` | 20 | `413` `too_many_images` |
|
|
||||||
| Unsupported media type | — | — | `400` `unsupported_image_type` |
|
|
||||||
| Malformed data URI | — | — | `400` `invalid_data_uri` |
|
|
||||||
| Remote URL while disabled | `CLAUDE_IMAGE_ALLOW_URL` | off | `400` `remote_url_disabled` |
|
|
||||||
|
|
||||||
Base64 payloads are large: a 5 MB image is ~6.7 MB as a data URI, so raise
|
|
||||||
`CLAUDE_MAX_BODY_SIZE` (and, if needed, `CLAUDE_MAX_IMAGE_BYTES`) to admit big
|
|
||||||
images. Vision support depends on the target model — request a current
|
|
||||||
vision-capable Claude model.
|
|
||||||
|
|
||||||
## OpenClaw Integration
|
## OpenClaw Integration
|
||||||
|
|
||||||
OCP was originally built for [OpenClaw](https://github.com/openclaw/openclaw) and includes deep integration:
|
OCP was originally built for [OpenClaw](https://github.com/openclaw/openclaw) and includes deep integration:
|
||||||
|
|||||||
@@ -1,54 +0,0 @@
|
|||||||
# ADR 0009 — Prompt-char budget derives from the models.json SPOT
|
|
||||||
|
|
||||||
Date: 2026-07-18
|
|
||||||
Status: Accepted (maintainer directive, 2026-07-18: "37.5k 截断未免太短了吧 … 这个在现在还适用吗")
|
|
||||||
|
|
||||||
## Context
|
|
||||||
|
|
||||||
`MAX_PROMPT_CHARS` (the tail-first truncation guard in `messagesToPrompt`) defaulted to a
|
|
||||||
hand-set constant of 150,000 chars ≈ 37.5k English tokens — set in the 200k-window era as a
|
|
||||||
runaway-context guard. Meanwhile `models.json` advertises `contextWindow: 200000` for every
|
|
||||||
model (and the underlying CLI registry carries 1M native windows for Opus 4.8 / Sonnet 5), and
|
|
||||||
`scripts/sync-openclaw.mjs` feeds that 200k into OpenClaw's compaction budget. The result was
|
|
||||||
a standing dishonesty identified in the PR #152 review: **no advertised contextWindow value was
|
|
||||||
true**, because the proxy silently guillotined every request at ~37.5k tokens — roughly 5×
|
|
||||||
below the advertised window — logging only a server-side warning the client never sees.
|
|
||||||
|
|
||||||
Raising the constant to another hand-set number would rot the same way. Following the model's
|
|
||||||
native 1M directly is also wrong: chars ≠ tokens (CJK runs ~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,7 +25,6 @@ New ADRs increment from the highest existing number. Filenames are
|
|||||||
| [0006](0006-openai-shim-scope.md) | OpenAI Shim Scope | The Class A / Class B taxonomy. Class A endpoints (`cli.js`-mirror) keep Rules 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. |
|
| [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. |
|
| [0007](0007-tui-interactive-mode.md) | TUI Interactive Mode | Why TUI-mode spawns an interactive `claude` in a tmux pane (no `-p`) to reach the **subscription** billing pool (`cc_entrypoint=cli`) rather than the metered Agent SDK pool. Owns the TUI spawn machinery: entrypoint labeling, credential-isolated home, MCP hard-disable, session namespace + defunct-session reaping, the independent concurrency bound, and the `/health` `tui` block. **Single-user only** — hard FATAL on multi-user configs. |
|
||||||
| [0008](0008-tui-warm-pane-pool.md) | TUI Warm Pane Pool | Why `OCP_TUI_POOL_SIZE` pre-boots **single-use** `claude` panes (one turn each, own `--session-id`) — and why reuse is forbidden (`transcript.mjs` returns the last assistant entry in the file, so a reused session leaks the earlier turn's text). Measured −41% end-to-end. Defines the pool↔reaper invariant (exemption by exact name from a live registry; drain before every sweep so `kill-server` zombie reaping survives) and the standing idle-process cost. Extends ADR 0007. |
|
| [0008](0008-tui-warm-pane-pool.md) | TUI Warm Pane Pool | Why `OCP_TUI_POOL_SIZE` pre-boots **single-use** `claude` panes (one turn each, own `--session-id`) — and why reuse is forbidden (`transcript.mjs` returns the last assistant entry in the file, so a reused session leaks the earlier turn's text). Measured −41% end-to-end. Defines the pool↔reaper invariant (exemption by exact name from a live registry; drain before every sweep so `kill-server` zombie reaping survives) and the standing idle-process cost. Extends ADR 0007. |
|
||||||
| [0009](0009-spot-derived-prompt-budget.md) | SPOT-Derived Prompt Budget | Why `MAX_PROMPT_CHARS`'s default is `max(models.json contextWindow) × 3 chars/token` (600k chars today) instead of a hand-set constant — the old 150k silently under-delivered the advertised window ~5×. ×3 is the CJK-safe multiplier; env/settings stay absolute overrides; whether to advertise 1M windows is explicitly a separate decision. |
|
|
||||||
|
|
||||||
## When to write a new ADR
|
## When to write a new ADR
|
||||||
|
|
||||||
|
|||||||
@@ -355,16 +355,6 @@ export function cacheHash(model, messages, opts = {}) {
|
|||||||
if (opts.temperature != null) h.update(`t:${opts.temperature}`);
|
if (opts.temperature != null) h.update(`t:${opts.temperature}`);
|
||||||
if (opts.max_tokens != null) h.update(`mt:${opts.max_tokens}`);
|
if (opts.max_tokens != null) h.update(`mt:${opts.max_tokens}`);
|
||||||
if (opts.top_p != null) h.update(`tp:${opts.top_p}`);
|
if (opts.top_p != null) h.update(`tp:${opts.top_p}`);
|
||||||
// #176: fold the server's boot-config epoch into the key, so a config change that shapes
|
|
||||||
// answers (operator system prompt, wrapper text, allowed tools, NO_CONTEXT) invalidates
|
|
||||||
// the persistent cache instead of serving answers composed under the old config. Callers
|
|
||||||
// that omit it (older paths, tests) hash byte-identically to before.
|
|
||||||
if (opts.configEpoch != null) h.update(`ce:${opts.configEpoch}|`);
|
|
||||||
// Structured-output (OpenAI response_format / json_mode) requests must never share a cache slot
|
|
||||||
// with the conversational answer to the same prompt, nor with a different schema — the steering
|
|
||||||
// instruction and validated JSON payload differ. Keying on the detected descriptor isolates them.
|
|
||||||
// Absent for normal requests → hashes are byte-identical to pre-change.
|
|
||||||
if (opts.structured != null) h.update(`s:${JSON.stringify(opts.structured)}`);
|
|
||||||
for (const m of messages) {
|
for (const m of messages) {
|
||||||
h.update(m.role || "");
|
h.update(m.role || "");
|
||||||
h.update(typeof m.content === "string" ? m.content : JSON.stringify(m.content));
|
h.update(typeof m.content === "string" ? m.content : JSON.stringify(m.content));
|
||||||
|
|||||||
-29
@@ -1,29 +0,0 @@
|
|||||||
// OCP env-var parsing helpers.
|
|
||||||
//
|
|
||||||
// Fail-closed positive-integer parsing for numeric caps (body size, image
|
|
||||||
// byte/count limits). A misconfigured cap must NEVER silently disable a guard:
|
|
||||||
// `parseInt("unlimited", 10)` is NaN and `x > NaN` is always false, so a naive
|
|
||||||
// parse of CLAUDE_MAX_BODY_SIZE=unlimited would remove the body-size limit
|
|
||||||
// entirely (unbounded body → OOM). Likewise CLAUDE_MAX_BODY_SIZE=5MB naively
|
|
||||||
// parses to 5 (bytes) and bricks the proxy. So a present-but-invalid value is
|
|
||||||
// REJECTED (default kept, caller warns), not accepted. (PR #154 review F3.)
|
|
||||||
//
|
|
||||||
// Pure (no env access, no IO) so it is unit-testable without a live server.
|
|
||||||
|
|
||||||
// Parse `raw` as a strictly-positive base-10 integer of bytes/count (no unit
|
|
||||||
// suffix). Returns { value, ok, reason }:
|
|
||||||
// - missing/empty → { value: def, ok: true } (use default)
|
|
||||||
// - valid positive int → { value: n, ok: true }
|
|
||||||
// - anything else → { value: def, ok: false, reason } (fail closed)
|
|
||||||
// Rejects: NaN ("unlimited"), non-positive ("0", "-1"), unit-suffixed ("5MB"),
|
|
||||||
// and fractional/ambiguous ("20.5", "0x10") values — String(n) !== trimmed catches
|
|
||||||
// any input parseInt only partially consumed.
|
|
||||||
export function parsePositiveInt(raw, def) {
|
|
||||||
if (raw === undefined || raw === null || raw === "") return { value: def, ok: true };
|
|
||||||
const trimmed = String(raw).trim();
|
|
||||||
const n = parseInt(trimmed, 10);
|
|
||||||
if (!Number.isFinite(n) || n <= 0 || String(n) !== trimmed) {
|
|
||||||
return { value: def, ok: false, reason: "not a strictly-positive integer (bytes/count, no unit suffix)" };
|
|
||||||
}
|
|
||||||
return { value: n, ok: true };
|
|
||||||
}
|
|
||||||
@@ -1,278 +0,0 @@
|
|||||||
// OCP multimodal helpers — OpenAI `image_url` content parts → Anthropic image
|
|
||||||
// blocks fed to `claude -p --input-format stream-json`. (issue #110)
|
|
||||||
//
|
|
||||||
// Class B.1 (OpenAI-compatibility surface). Protocol authority is OpenAI's
|
|
||||||
// chat/completions spec — the multimodal `content` parts shape
|
|
||||||
// (https://platform.openai.com/docs/guides/vision and
|
|
||||||
// https://platform.openai.com/docs/api-reference/chat/create#chat-create-messages,
|
|
||||||
// `image_url` part with `image_url.url` = data URI or http(s) URL). Authorized
|
|
||||||
// by ADR 0006. This module introduces NO field beyond OpenAI's published shape:
|
|
||||||
// the OpenAI-side vocabulary read here is `type:"image_url"` +
|
|
||||||
// `image_url:{url, detail?}`; the Anthropic-side vocabulary written here
|
|
||||||
// (`type:"image", source:{type:"base64"|"url", ...}`) is the CLI's native
|
|
||||||
// stream-json input contract, not an OCP invention.
|
|
||||||
//
|
|
||||||
// Kept as a pure module (no I/O, no network, no process state) mirroring the
|
|
||||||
// lib/*.mjs pattern so it is unit-testable without a live server. server.mjs is
|
|
||||||
// the only consumer; it owns spawning, caps configuration, and HTTP status.
|
|
||||||
|
|
||||||
// Anthropic vision-supported image media types. A data URI whose media type is
|
|
||||||
// outside this set is rejected with a clear 4xx rather than forwarded (the API
|
|
||||||
// would reject it anyway; failing early gives a better error).
|
|
||||||
export const SUPPORTED_IMAGE_TYPES = new Set([
|
|
||||||
"image/jpeg",
|
|
||||||
"image/png",
|
|
||||||
"image/gif",
|
|
||||||
"image/webp",
|
|
||||||
]);
|
|
||||||
|
|
||||||
// Default caps. server.mjs overrides these from env; they live here so the
|
|
||||||
// pure transform is self-contained and testable.
|
|
||||||
export const DEFAULT_MULTIMODAL_OPTS = {
|
|
||||||
allowRemoteUrl: false, // http(s) image URLs are OFF by default (v1: data URIs only)
|
|
||||||
maxImageBytes: 5 * 1024 * 1024, // per-image decoded-byte cap
|
|
||||||
maxImages: 20, // max image parts across the whole request
|
|
||||||
maxTotalImageBytes: 20 * 1024 * 1024, // aggregate decoded-byte cap
|
|
||||||
maxTextChars: Infinity, // text-char budget (server passes MAX_PROMPT_CHARS); Infinity = no truncation
|
|
||||||
};
|
|
||||||
|
|
||||||
// Typed error so server.mjs can map to the right HTTP status + OpenAI-shaped
|
|
||||||
// error body. `status` is the HTTP code; `type` is the OpenAI error `type`.
|
|
||||||
export class MultimodalError extends Error {
|
|
||||||
constructor(code, status, message) {
|
|
||||||
super(message);
|
|
||||||
this.name = "MultimodalError";
|
|
||||||
this.code = code;
|
|
||||||
this.status = status;
|
|
||||||
this.type = "invalid_request_error"; // OpenAI error `type` for 4xx client errors
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// True if any message carries an OpenAI `image_url` content part. Cheap guard so
|
|
||||||
// the byte-for-byte text path is only left when an image is genuinely present.
|
|
||||||
export function hasImageContent(messages) {
|
|
||||||
if (!Array.isArray(messages)) return false;
|
|
||||||
for (const m of messages) {
|
|
||||||
if (m && Array.isArray(m.content)) {
|
|
||||||
for (const part of m.content) {
|
|
||||||
if (part && part.type === "image_url") return true;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Extract the URL string from an OpenAI image_url part. Spec form is
|
|
||||||
// `{type:"image_url", image_url:{url, detail?}}`; many OpenAI-compatible clients
|
|
||||||
// also send `image_url` as a bare string. Accept both (input leniency — no new
|
|
||||||
// output field). `detail` (auto|low|high) is OpenAI-only and has no Anthropic
|
|
||||||
// analogue, so it is read-and-ignored.
|
|
||||||
function imageUrlOf(part) {
|
|
||||||
const iu = part.image_url;
|
|
||||||
if (typeof iu === "string") return iu;
|
|
||||||
if (iu && typeof iu.url === "string") return iu.url;
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Parse a base64 data URI: `data:[<media_type>][;base64],<data>`.
|
|
||||||
// Returns { mediaType, data (base64), bytes (decoded size) } or throws MultimodalError.
|
|
||||||
function parseDataUri(uri) {
|
|
||||||
const comma = uri.indexOf(",");
|
|
||||||
if (comma === -1) {
|
|
||||||
throw new MultimodalError("invalid_data_uri", 400, "Malformed image data URI (no comma).");
|
|
||||||
}
|
|
||||||
const meta = uri.slice(5, comma); // strip leading "data:"
|
|
||||||
const segs = meta.split(";");
|
|
||||||
const mediaType = (segs[0] || "").trim().toLowerCase();
|
|
||||||
const isBase64 = segs.slice(1).some((s) => s.trim().toLowerCase() === "base64");
|
|
||||||
if (!isBase64) {
|
|
||||||
throw new MultimodalError("invalid_data_uri", 400, "Only base64-encoded image data URIs are supported.");
|
|
||||||
}
|
|
||||||
if (!SUPPORTED_IMAGE_TYPES.has(mediaType)) {
|
|
||||||
throw new MultimodalError(
|
|
||||||
"unsupported_image_type",
|
|
||||||
400,
|
|
||||||
`Unsupported image media type '${mediaType || "(none)"}'. Supported: ${[...SUPPORTED_IMAGE_TYPES].join(", ")}.`
|
|
||||||
);
|
|
||||||
}
|
|
||||||
// Strip incidental whitespace/newlines some encoders insert into data URIs.
|
|
||||||
const data = uri.slice(comma + 1).replace(/\s/g, "");
|
|
||||||
if (data.length === 0 || !/^[A-Za-z0-9+/]+={0,2}$/.test(data)) {
|
|
||||||
throw new MultimodalError("invalid_data_uri", 400, "Image data URI payload is not valid base64.");
|
|
||||||
}
|
|
||||||
// Decoded size from base64 length (minus padding); avoids decoding the buffer
|
|
||||||
// just to measure it.
|
|
||||||
const padding = data.endsWith("==") ? 2 : data.endsWith("=") ? 1 : 0;
|
|
||||||
const bytes = Math.floor((data.length * 3) / 4) - padding;
|
|
||||||
return { mediaType, data, bytes };
|
|
||||||
}
|
|
||||||
|
|
||||||
// Convert a single OpenAI image_url part to an Anthropic image block, enforcing
|
|
||||||
// caps via the mutable `acc` accumulator ({ images, bytes }). Throws MultimodalError.
|
|
||||||
function imagePartToBlock(part, opts, acc) {
|
|
||||||
const url = imageUrlOf(part);
|
|
||||||
if (!url) {
|
|
||||||
throw new MultimodalError("invalid_image_url", 400, "image_url part is missing a URL.");
|
|
||||||
}
|
|
||||||
|
|
||||||
acc.images += 1;
|
|
||||||
if (acc.images > opts.maxImages) {
|
|
||||||
throw new MultimodalError("too_many_images", 413, `Too many images in request (max ${opts.maxImages}).`);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (url.startsWith("data:")) {
|
|
||||||
const { mediaType, data, bytes } = parseDataUri(url);
|
|
||||||
if (bytes > opts.maxImageBytes) {
|
|
||||||
throw new MultimodalError("image_too_large", 413, `Image exceeds per-image size limit (${opts.maxImageBytes} bytes).`);
|
|
||||||
}
|
|
||||||
acc.bytes += bytes;
|
|
||||||
if (acc.bytes > opts.maxTotalImageBytes) {
|
|
||||||
throw new MultimodalError("images_too_large", 413, `Total image payload exceeds limit (${opts.maxTotalImageBytes} bytes).`);
|
|
||||||
}
|
|
||||||
return { type: "image", source: { type: "base64", media_type: mediaType, data } };
|
|
||||||
}
|
|
||||||
|
|
||||||
if (/^https?:\/\//i.test(url)) {
|
|
||||||
if (!opts.allowRemoteUrl) {
|
|
||||||
throw new MultimodalError(
|
|
||||||
"remote_url_disabled",
|
|
||||||
400,
|
|
||||||
"Remote image URLs are disabled. Enable CLAUDE_IMAGE_ALLOW_URL=1 to allow http(s) image URLs, or pass the image as a base64 data URI."
|
|
||||||
);
|
|
||||||
}
|
|
||||||
// Passthrough as an Anthropic url-source block. OCP does NOT fetch the URL
|
|
||||||
// itself (no OCP-side SSRF surface); the fetch is performed upstream by the
|
|
||||||
// Anthropic API. Best-effort: unreachable/blocked URLs surface as an API error.
|
|
||||||
return { type: "image", source: { type: "url", url } };
|
|
||||||
}
|
|
||||||
|
|
||||||
throw new MultimodalError("unsupported_url_scheme", 400, "image_url must be a base64 data URI or an http(s) URL.");
|
|
||||||
}
|
|
||||||
|
|
||||||
// Role prefix mirrors messagesToPrompt()'s text-path labeling so a multi-turn
|
|
||||||
// conversation reads the same whether or not it carries images. System messages
|
|
||||||
// are handled by the caller via --system-prompt and never reach here.
|
|
||||||
function rolePrefix(role) {
|
|
||||||
if (role === "assistant") return "[Assistant] ";
|
|
||||||
return ""; // user / tool / anything else: verbatim, as in the text path
|
|
||||||
}
|
|
||||||
|
|
||||||
// Build the Anthropic content-block array for a single stream-json user
|
|
||||||
// envelope. Mirrors the text path's "collapse the whole conversation into one
|
|
||||||
// turn passed via stdin" model (OCP runs stateless, full context per spawn), but
|
|
||||||
// preserves image position relative to text and keeps images out of the text
|
|
||||||
// char budget entirely. Returns { blocks, stats } or throws MultimodalError.
|
|
||||||
export function buildImageBlocks(messages, opts = {}) {
|
|
||||||
const o = { ...DEFAULT_MULTIMODAL_OPTS, ...opts };
|
|
||||||
const blocks = [];
|
|
||||||
const acc = { images: 0, bytes: 0 };
|
|
||||||
let textChars = 0;
|
|
||||||
let firstMessage = true;
|
|
||||||
|
|
||||||
const pushText = (text) => {
|
|
||||||
if (!text) return;
|
|
||||||
blocks.push({ type: "text", text });
|
|
||||||
textChars += text.length;
|
|
||||||
};
|
|
||||||
|
|
||||||
for (const m of messages) {
|
|
||||||
const prefix = rolePrefix(m.role);
|
|
||||||
// Separate messages with a blank line, matching messagesToPrompt's "\n\n" join.
|
|
||||||
const sep = firstMessage ? "" : "\n\n";
|
|
||||||
firstMessage = false;
|
|
||||||
let prefixEmitted = false;
|
|
||||||
const emitPrefixWith = (t) => {
|
|
||||||
if (prefixEmitted) return t;
|
|
||||||
prefixEmitted = true;
|
|
||||||
return sep + prefix + t;
|
|
||||||
};
|
|
||||||
|
|
||||||
if (typeof m.content === "string") {
|
|
||||||
pushText(emitPrefixWith(m.content));
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
if (!Array.isArray(m.content)) {
|
|
||||||
// null / object content: mirror contentToText's fallback.
|
|
||||||
const t = m.content == null ? "" : JSON.stringify(m.content);
|
|
||||||
pushText(emitPrefixWith(t));
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
|
|
||||||
for (const part of m.content) {
|
|
||||||
if (part && part.type === "text" && typeof part.text === "string") {
|
|
||||||
pushText(emitPrefixWith(part.text));
|
|
||||||
} else if (part && part.type === "image_url") {
|
|
||||||
// Ensure the role prefix isn't lost when a message leads with an image.
|
|
||||||
if (!prefixEmitted && prefix) pushText(emitPrefixWith(""));
|
|
||||||
blocks.push(imagePartToBlock(part, o, acc));
|
|
||||||
} else {
|
|
||||||
// audio / file / unknown parts: preserve the existing placeholder
|
|
||||||
// behavior (issue #110) — deferred to a future version.
|
|
||||||
pushText(emitPrefixWith("[non-text content omitted]"));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Defensive: a stream-json user turn must have at least one content block.
|
|
||||||
if (blocks.length === 0) blocks.push({ type: "text", text: "" });
|
|
||||||
|
|
||||||
// Enforce the text-char budget (PR #154 review F2). Without this, attaching a
|
|
||||||
// single tiny image would let unbounded text bypass the gateway's runaway-context
|
|
||||||
// guard entirely — messagesToPrompt truncates the text path, so the multimodal
|
|
||||||
// path must too. Image blocks are preserved and are NOT counted (they are bounded
|
|
||||||
// by the byte/count caps above).
|
|
||||||
const budgeted = enforceTextBudget(blocks, o.maxTextChars);
|
|
||||||
|
|
||||||
return {
|
|
||||||
blocks: budgeted.blocks,
|
|
||||||
stats: {
|
|
||||||
imageCount: acc.images,
|
|
||||||
totalImageBytes: acc.bytes,
|
|
||||||
textChars: budgeted.textChars,
|
|
||||||
originalTextChars: budgeted.originalTextChars,
|
|
||||||
truncated: budgeted.truncated,
|
|
||||||
},
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
// Enforce a text-char budget over already-built content blocks, mirroring
|
|
||||||
// messagesToPrompt's "keep the tail, drop the oldest" truncation. Image blocks are
|
|
||||||
// preserved in place; only text blocks count against the budget. A truncation note
|
|
||||||
// is prepended when anything is dropped. Returns
|
|
||||||
// { blocks, truncated, originalTextChars, textChars }.
|
|
||||||
function enforceTextBudget(blocks, maxTextChars) {
|
|
||||||
let originalTextChars = 0;
|
|
||||||
for (const b of blocks) if (b.type === "text") originalTextChars += b.text.length;
|
|
||||||
if (!(maxTextChars > 0) || originalTextChars <= maxTextChars) {
|
|
||||||
return { blocks, truncated: false, originalTextChars, textChars: originalTextChars };
|
|
||||||
}
|
|
||||||
// Keep the most recent text up to the budget; trim within the boundary block and
|
|
||||||
// drop older text blocks. Non-text (image) blocks always survive, in order.
|
|
||||||
let budget = maxTextChars;
|
|
||||||
const out = [];
|
|
||||||
for (let i = blocks.length - 1; i >= 0; i--) {
|
|
||||||
const b = blocks[i];
|
|
||||||
if (b.type !== "text") { out.unshift(b); continue; }
|
|
||||||
if (budget <= 0) continue; // older text fully dropped
|
|
||||||
if (b.text.length <= budget) {
|
|
||||||
out.unshift(b);
|
|
||||||
budget -= b.text.length;
|
|
||||||
} else {
|
|
||||||
out.unshift({ type: "text", text: b.text.slice(b.text.length - budget) });
|
|
||||||
budget = 0;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
out.unshift({ type: "text", text: "[System] Note: older text content was truncated to fit the context limit." });
|
|
||||||
let textChars = 0;
|
|
||||||
for (const b of out) if (b.type === "text") textChars += b.text.length;
|
|
||||||
return { blocks: out, truncated: true, originalTextChars, textChars };
|
|
||||||
}
|
|
||||||
|
|
||||||
// Serialize the non-system conversation to a single newline-terminated
|
|
||||||
// stream-json user message for `claude -p --input-format stream-json` stdin.
|
|
||||||
// Returns { payload, stats } or throws MultimodalError.
|
|
||||||
export function buildStreamJsonInput(messages, opts = {}) {
|
|
||||||
const { blocks, stats } = buildImageBlocks(messages, opts);
|
|
||||||
const envelope = { type: "user", message: { role: "user", content: blocks } };
|
|
||||||
return { payload: JSON.stringify(envelope) + "\n", stats };
|
|
||||||
}
|
|
||||||
@@ -15,40 +15,3 @@ export function appendOperatorPrompt(base, operatorAppend) {
|
|||||||
const op = typeof operatorAppend === "string" ? operatorAppend.trim() : "";
|
const op = typeof operatorAppend === "string" ? operatorAppend.trim() : "";
|
||||||
return op ? `${base}\n\n${op}` : base;
|
return op ? `${base}\n\n${op}` : base;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Derive the default prompt-char budget from the models.json SPOT (ADR 0009).
|
|
||||||
//
|
|
||||||
// The old default was a hand-set constant (150000 chars ≈ 37.5k English tokens) from the
|
|
||||||
// 200k-window era — silently far below what the advertised contextWindow promises. Instead
|
|
||||||
// of picking a new constant that will also rot, the default now FOLLOWS the SPOT:
|
|
||||||
//
|
|
||||||
// budget = max(models[].contextWindow) × charsPerToken
|
|
||||||
//
|
|
||||||
// charsPerToken = 3 is deliberately conservative: English runs ~4 chars/token, CJK ~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);
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -1,318 +0,0 @@
|
|||||||
// ── OpenAI Structured Outputs (response_format) — pure helpers ───────────────
|
|
||||||
//
|
|
||||||
// OCP's `/v1/chat/completions` (Class B.1, ADR 0006) advertises OpenAI compatibility but forwards
|
|
||||||
// to `claude -p`, which has no native `response_format`. Asked for JSON, the coding-assistant CLI
|
|
||||||
// typically replies with prose, a Markdown table, or a ```json fenced block — none of which is
|
|
||||||
// `JSON.parse`-able. These helpers implement the OpenAI `response_format` contract on top of that:
|
|
||||||
// detect the request, build a strict JSON-only steering instruction, then extract and validate the
|
|
||||||
// JSON the model returns. All functions here are pure (no I/O) so they are unit-tested directly;
|
|
||||||
// the retry loop that calls the model lives in server.mjs (runStructuredCompletion).
|
|
||||||
//
|
|
||||||
// Spec authority (B.1, ADR 0006): OpenAI chat/completions `response_format`
|
|
||||||
// https://platform.openai.com/docs/api-reference/chat/create#chat-create-response_format
|
|
||||||
// No field or behaviour beyond that published shape is introduced.
|
|
||||||
|
|
||||||
export class StructuredOutputError extends Error {
|
|
||||||
constructor(reason, raw) {
|
|
||||||
super(`structured output could not be produced: ${reason}`);
|
|
||||||
this.name = "StructuredOutputError";
|
|
||||||
this.reason = reason;
|
|
||||||
this.raw = raw;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Fail-closed parse of the OCP_STRUCTURED_MAX_ATTEMPTS retry cap. `Math.max(1, parseInt("abc",10))`
|
|
||||||
// === `Math.max(1, NaN)` === NaN, and a retry loop bounded by `attempt < NaN` never runs → 0 spawns,
|
|
||||||
// every structured request silently refuses. So any non-integer / non-finite / <1 value keeps the
|
|
||||||
// documented default instead (and warns), rather than bricking the feature. (PR #153 review round 2.)
|
|
||||||
export function resolveMaxAttempts(raw, { fallback = 3, warn } = {}) {
|
|
||||||
if (raw === undefined || raw === null || raw === "") return fallback;
|
|
||||||
const n = parseInt(raw, 10);
|
|
||||||
if (!Number.isFinite(n) || n < 1) {
|
|
||||||
if (typeof warn === "function") {
|
|
||||||
warn(`Ignoring invalid OCP_STRUCTURED_MAX_ATTEMPTS="${raw}" (want integer >= 1); using default ${fallback}.`);
|
|
||||||
}
|
|
||||||
return fallback;
|
|
||||||
}
|
|
||||||
return n;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Returns { mode: "schema", schema, name?, strict } | { mode: "json_object" } | null.
|
|
||||||
// Supports the OpenAI shapes: response_format:{type:"json_schema",json_schema:{schema,strict,name}}
|
|
||||||
// and response_format:{type:"json_object"}, a lenient response_format:{schema} fallback, and the
|
|
||||||
// widely-used (non-standard) top-level `json_mode: true` flag as a json_object alias.
|
|
||||||
export function detectStructuredOutput(parsed) {
|
|
||||||
const rf = parsed?.response_format;
|
|
||||||
if (rf && typeof rf === "object") {
|
|
||||||
if (rf.type === "json_schema") {
|
|
||||||
const js = (rf.json_schema && typeof rf.json_schema === "object") ? rf.json_schema : {};
|
|
||||||
const schema = js.schema || rf.schema || null;
|
|
||||||
return { mode: "schema", schema, name: js.name, strict: js.strict === true };
|
|
||||||
}
|
|
||||||
if (rf.type === "json_object") return { mode: "json_object" };
|
|
||||||
if (rf.schema && typeof rf.schema === "object") {
|
|
||||||
return { mode: "schema", schema: rf.schema, strict: rf.strict === true };
|
|
||||||
}
|
|
||||||
}
|
|
||||||
// Non-standard convenience alias honored by several OpenAI-compatible clients.
|
|
||||||
if (parsed?.json_mode === true) return { mode: "json_object" };
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
export function jsonTypeOf(v) {
|
|
||||||
if (v === null) return "null";
|
|
||||||
if (Array.isArray(v)) return "array";
|
|
||||||
return typeof v; // object | string | number | boolean
|
|
||||||
}
|
|
||||||
|
|
||||||
export function jsonTypeMatches(t, value) {
|
|
||||||
switch (t) {
|
|
||||||
case "string": return typeof value === "string";
|
|
||||||
case "number": return typeof value === "number" && Number.isFinite(value);
|
|
||||||
case "integer": return typeof value === "number" && Number.isInteger(value);
|
|
||||||
case "boolean": return typeof value === "boolean";
|
|
||||||
case "object": return value !== null && typeof value === "object" && !Array.isArray(value);
|
|
||||||
case "array": return Array.isArray(value);
|
|
||||||
case "null": return value === null;
|
|
||||||
default: return true; // unknown type keyword → do not fail on it
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const jsonDeepEqual = (a, b) => JSON.stringify(a) === JSON.stringify(b);
|
|
||||||
|
|
||||||
// Resolve a local JSON-Pointer `$ref` (e.g. "#/$defs/Step" or "#/definitions/Step") against the
|
|
||||||
// document root. Only same-document refs are supported (that is all the OpenAI SDK emits); a remote
|
|
||||||
// or unresolvable ref returns null and the caller skips validation for it rather than failing.
|
|
||||||
function resolveRef(ref, root) {
|
|
||||||
if (typeof ref !== "string" || !ref.startsWith("#/") || !root) return null;
|
|
||||||
const parts = ref.slice(2).split("/").map(p => p.replace(/~1/g, "/").replace(/~0/g, "~"));
|
|
||||||
let cur = root;
|
|
||||||
for (const p of parts) {
|
|
||||||
if (cur && typeof cur === "object" && Object.prototype.hasOwnProperty.call(cur, p)) cur = cur[p];
|
|
||||||
else return null;
|
|
||||||
}
|
|
||||||
return (cur && typeof cur === "object") ? cur : null;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Minimal JSON-Schema validator: covers the subset OpenAI structured outputs use — type (incl.
|
|
||||||
// arrays of types / integer), required, properties, additionalProperties (no invented keys), items
|
|
||||||
// (list + tuple), enum, const, nullability (type:["x","null"] or nullable:true), min/maxItems, and
|
|
||||||
// $ref/$defs + allOf/anyOf/oneOf composition (which the official OpenAI SDK emits heavily via
|
|
||||||
// zodResponseFormat / client.beta.chat.completions.parse). `root` carries the top-level schema so
|
|
||||||
// same-document $refs resolve. Returns error strings ([] = valid).
|
|
||||||
// `refChain` tracks the $ref pointers resolved on the CURRENT path WITHOUT consuming data (a $ref
|
|
||||||
// hop, or an allOf/anyOf/oneOf branch, all re-validate the same value). A pointer reappearing on
|
|
||||||
// that chain is a pure ref→ref (or ref→composition→ref) cycle that recurses forever independent of
|
|
||||||
// the data — we fail closed on it. Data-consuming recursion (properties/items/additionalProperties)
|
|
||||||
// deliberately resets the chain (default []): it always terminates because a JSON value is a finite
|
|
||||||
// tree, so a legitimately recursive schema (Node→child:Node) must NOT be flagged as a cycle. A depth
|
|
||||||
// cap backstops any threading mistake. (PR #153 review round 2, cyclic-$ref blocker.)
|
|
||||||
const REF_DEPTH_CAP = 512;
|
|
||||||
export function validateJsonSchema(value, schema, path = "$", strict = false, root = schema, refChain = []) {
|
|
||||||
const errors = [];
|
|
||||||
if (!schema || typeof schema !== "object") return errors;
|
|
||||||
if (refChain.length > REF_DEPTH_CAP) { // defensive backstop; refChain cycle-check below is primary
|
|
||||||
errors.push(`${path}: $ref resolution too deep (possible cycle)`);
|
|
||||||
return errors;
|
|
||||||
}
|
|
||||||
|
|
||||||
// $ref: resolve against the document root ($defs / definitions) and validate the target. Without
|
|
||||||
// this a nested {$ref:"#/$defs/Step"} presents as {no type, no properties} — and under strict that
|
|
||||||
// used to wrongly reject every real key as "additional property not allowed" (the flagship OpenAI
|
|
||||||
// SDK shape). Sibling keywords alongside $ref (rare) are merged over the resolved target.
|
|
||||||
if (typeof schema.$ref === "string") {
|
|
||||||
if (refChain.includes(schema.$ref)) { // cyclic $ref (a→b→a, or self a→a) — fail closed.
|
|
||||||
errors.push(`${path}: cyclic $ref detected (${schema.$ref})`);
|
|
||||||
return errors;
|
|
||||||
}
|
|
||||||
const resolved = resolveRef(schema.$ref, root);
|
|
||||||
if (!resolved) return errors; // unresolvable ref → cannot validate; do not fail
|
|
||||||
const { $ref, ...siblings } = schema;
|
|
||||||
return validateJsonSchema(value, { ...resolved, ...siblings }, path, strict, root, [...refChain, schema.$ref]);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Composition. allOf: every branch must pass. anyOf: at least one. oneOf: exactly one.
|
|
||||||
// These re-validate the SAME value → thread refChain so a ref cycle routed through a branch is caught.
|
|
||||||
if (Array.isArray(schema.allOf)) {
|
|
||||||
for (const sub of schema.allOf) errors.push(...validateJsonSchema(value, sub, path, strict, root, refChain));
|
|
||||||
}
|
|
||||||
if (Array.isArray(schema.anyOf)) {
|
|
||||||
if (!schema.anyOf.some(sub => validateJsonSchema(value, sub, path, strict, root, refChain).length === 0)) {
|
|
||||||
errors.push(`${path}: does not match any of the allowed schemas (anyOf)`);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if (Array.isArray(schema.oneOf)) {
|
|
||||||
const matches = schema.oneOf.filter(sub => validateJsonSchema(value, sub, path, strict, root, refChain).length === 0).length;
|
|
||||||
if (matches !== 1) errors.push(`${path}: must match exactly one allowed schema (oneOf), matched ${matches}`);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Nullability takes precedence: a null value is valid whenever the schema permits null (its type
|
|
||||||
// union includes "null", or nullable:true), regardless of enum/const. This mirrors OpenAI
|
|
||||||
// structured-output behaviour — nullable fields accept null even when a bare enum (as generated by
|
|
||||||
// Home Assistant's extended_openai_conversation) omits null from its value list.
|
|
||||||
const allowsNull = schema.nullable === true
|
|
||||||
|| (Array.isArray(schema.type) ? schema.type.includes("null") : schema.type === "null");
|
|
||||||
if (value === null && allowsNull) return errors;
|
|
||||||
|
|
||||||
if (Array.isArray(schema.enum) && !schema.enum.some(e => jsonDeepEqual(e, value))) {
|
|
||||||
errors.push(`${path}: not one of the allowed enum values`);
|
|
||||||
}
|
|
||||||
if ("const" in schema && !jsonDeepEqual(schema.const, value)) {
|
|
||||||
errors.push(`${path}: does not equal the required const value`);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (schema.type !== undefined) {
|
|
||||||
const types = Array.isArray(schema.type) ? schema.type : [schema.type];
|
|
||||||
const nullable = schema.nullable === true || types.includes("null");
|
|
||||||
const ok = types.some(t => jsonTypeMatches(t, value)) || (value === null && nullable);
|
|
||||||
if (!ok) {
|
|
||||||
errors.push(`${path}: expected ${types.join("|")}${schema.nullable ? "|null" : ""}, got ${jsonTypeOf(value)}`);
|
|
||||||
return errors; // type mismatch — deeper checks are meaningless
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if (value === null) return errors;
|
|
||||||
|
|
||||||
const vt = jsonTypeOf(value);
|
|
||||||
if (vt === "object") {
|
|
||||||
const props = schema.properties || {};
|
|
||||||
for (const r of (schema.required || [])) {
|
|
||||||
if (!Object.prototype.hasOwnProperty.call(value, r)) errors.push(`${path}.${r}: required property missing`);
|
|
||||||
}
|
|
||||||
const addl = schema.additionalProperties;
|
|
||||||
// Only treat strict as implying "no additional properties" when this object actually declares
|
|
||||||
// its own `properties` and is NOT a composite (allOf/anyOf/oneOf put the real keys in sub-schemas,
|
|
||||||
// which are validated separately above). Inferring closure from an EMPTY properties map — the
|
|
||||||
// shape an unresolved $ref or a pure-composition node presents — would reject every real key.
|
|
||||||
// An explicit additionalProperties:false is always honoured. (PR #153 review, finding 1.)
|
|
||||||
const isComposite = Array.isArray(schema.allOf) || Array.isArray(schema.anyOf) || Array.isArray(schema.oneOf);
|
|
||||||
const noExtra = addl === false || (strict && addl === undefined && Object.keys(props).length > 0 && !isComposite);
|
|
||||||
for (const k of Object.keys(value)) {
|
|
||||||
if (props[k]) {
|
|
||||||
errors.push(...validateJsonSchema(value[k], props[k], `${path}.${k}`, strict, root));
|
|
||||||
} else if (isComposite) {
|
|
||||||
// key may be defined in an allOf/anyOf/oneOf branch — already validated there; don't reject.
|
|
||||||
} else if (noExtra) {
|
|
||||||
errors.push(`${path}.${k}: additional property not allowed`);
|
|
||||||
} else if (addl && typeof addl === "object") {
|
|
||||||
errors.push(...validateJsonSchema(value[k], addl, `${path}.${k}`, strict, root));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
} else if (vt === "array" && schema.items) {
|
|
||||||
if (Array.isArray(schema.items)) {
|
|
||||||
schema.items.forEach((s, i) => { if (i < value.length) errors.push(...validateJsonSchema(value[i], s, `${path}[${i}]`, strict, root)); });
|
|
||||||
} else {
|
|
||||||
value.forEach((item, i) => errors.push(...validateJsonSchema(item, schema.items, `${path}[${i}]`, strict, root)));
|
|
||||||
}
|
|
||||||
if (typeof schema.minItems === "number" && value.length < schema.minItems) errors.push(`${path}: fewer items than minItems ${schema.minItems}`);
|
|
||||||
if (typeof schema.maxItems === "number" && value.length > schema.maxItems) errors.push(`${path}: more items than maxItems ${schema.maxItems}`);
|
|
||||||
}
|
|
||||||
return errors;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Crash-safe façade over validateJsonSchema (issue #181). The validator recurses on the DATA's
|
|
||||||
// nesting depth (properties/items/additionalProperties), which the REF_DEPTH_CAP does NOT bound —
|
|
||||||
// only the ref-chain is. A model reply nested ~2000 levels deep therefore overflowed the stack with
|
|
||||||
// a RangeError, which the request handler caught as a generic HTTP 500 instead of the spec-correct
|
|
||||||
// `refusal`. This wrapper converts ANY throw (the deep-data RangeError, or any future recursion
|
|
||||||
// hazard) into a single validation error, so the structured-output retry loop treats a pathological
|
|
||||||
// reply as "did not validate" → refusal — never a 500, never a crash. A well-formed reply is
|
|
||||||
// unaffected: the inner validator returns and this just passes its errors through.
|
|
||||||
export function validateJsonSchemaSafe(value, schema, path = "$", strict = false, root = schema) {
|
|
||||||
try {
|
|
||||||
return validateJsonSchema(value, schema, path, strict, root);
|
|
||||||
} catch (e) {
|
|
||||||
// Catch ONLY the deep-nesting stack overflow (the #181 vector) and turn it into a validation
|
|
||||||
// miss → retry → refusal, never a 500. Any OTHER throw is a genuine bug: re-throw it so it
|
|
||||||
// surfaces at error level instead of being silently masked as "did not validate" (reviewer
|
|
||||||
// finding — a catch-all would hide a future TypeError behind a warn-level structured_retry).
|
|
||||||
if (e instanceof RangeError) return [`${path}: schema validation aborted (value nesting too deep)`];
|
|
||||||
throw e;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function tryJsonParse(s) {
|
|
||||||
try { return { ok: true, value: JSON.parse(s) }; } catch { return { ok: false }; }
|
|
||||||
}
|
|
||||||
|
|
||||||
// Find the next brace-balanced JSON span at or after `from`. String-aware: brackets inside quoted
|
|
||||||
// strings are ignored. Returns { text, start, end } for the first complete top-level span, or null.
|
|
||||||
function balancedSlice(s, from) {
|
|
||||||
let start = -1;
|
|
||||||
for (let i = from; i < s.length; i++) { if (s[i] === "{" || s[i] === "[") { start = i; break; } }
|
|
||||||
if (start === -1) return null;
|
|
||||||
let depth = 0, inStr = false, esc = false;
|
|
||||||
for (let i = start; i < s.length; i++) {
|
|
||||||
const c = s[i];
|
|
||||||
if (inStr) {
|
|
||||||
if (esc) esc = false;
|
|
||||||
else if (c === "\\") esc = true;
|
|
||||||
else if (c === '"') inStr = false;
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
if (c === '"') { inStr = true; continue; }
|
|
||||||
if (c === "{" || c === "[") depth++;
|
|
||||||
else if (c === "}" || c === "]") { depth--; if (depth === 0) return { text: s.slice(start, i + 1), start, end: i }; }
|
|
||||||
}
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Extract a single JSON value from model text. Two modes (PR #153 review, finding 2):
|
|
||||||
//
|
|
||||||
// opts.whole === true (json_object mode): the ENTIRE reply, after trimming and stripping one code
|
|
||||||
// fence, must parse as a single JSON value. json_object has no schema to validate against, so
|
|
||||||
// this whole-reply parse is its ONLY guard — a reply like `I can't. The schema is {"type":"object"}`
|
|
||||||
// must NOT parse-and-serve the embedded object as if it were the answer.
|
|
||||||
//
|
|
||||||
// default (schema mode): prose-wrapped JSON is tolerated (models often add a sentence), BUT a reply
|
|
||||||
// containing MORE THAN ONE top-level JSON value is rejected rather than silently serving the first
|
|
||||||
// — "Schema: {...}\n\nAnswer: {...}" or "Option A: {...} Option B: {...}" is ambiguous, not an
|
|
||||||
// answer. The extracted value is still schema-validated by the caller.
|
|
||||||
//
|
|
||||||
// Returns { ok:true, value } | { ok:false, reason? }.
|
|
||||||
export function extractJsonPayload(text, opts = {}) {
|
|
||||||
if (typeof text !== "string") return { ok: false };
|
|
||||||
let s = text.trim();
|
|
||||||
const fence = s.match(/^```(?:json)?\s*([\s\S]*?)\s*```$/i);
|
|
||||||
if (fence) s = fence[1].trim();
|
|
||||||
|
|
||||||
if (opts.whole) {
|
|
||||||
const whole = tryJsonParse(s);
|
|
||||||
return whole.ok ? whole : { ok: false, reason: "reply was not a single JSON value" };
|
|
||||||
}
|
|
||||||
|
|
||||||
const direct = tryJsonParse(s);
|
|
||||||
if (direct.ok) return direct;
|
|
||||||
|
|
||||||
const first = balancedSlice(s, 0);
|
|
||||||
if (!first) return { ok: false };
|
|
||||||
const parsedFirst = tryJsonParse(first.text);
|
|
||||||
if (!parsedFirst.ok) return { ok: false };
|
|
||||||
|
|
||||||
// Reject ambiguity: a second parseable top-level JSON value means we cannot know which is the answer.
|
|
||||||
const second = balancedSlice(s, first.end + 1);
|
|
||||||
if (second && tryJsonParse(second.text).ok) {
|
|
||||||
return { ok: false, reason: "reply contained more than one JSON value" };
|
|
||||||
}
|
|
||||||
return parsedFirst;
|
|
||||||
}
|
|
||||||
|
|
||||||
// The strict JSON-only system instruction appended to the request (attempt 0), escalated with the
|
|
||||||
// prior failure reason on retries.
|
|
||||||
export function structuredSystemInstruction(structured, attempt, lastErr) {
|
|
||||||
const schemaBlock = (structured.mode === "schema" && structured.schema)
|
|
||||||
? `Your JSON MUST validate EXACTLY against this JSON Schema:\n${JSON.stringify(structured.schema)}\n`
|
|
||||||
+ `- Include every required property.\n`
|
|
||||||
+ `- Do NOT add any property that is not defined in the schema.\n`
|
|
||||||
+ `- Respect all declared types, enums, and nullability.`
|
|
||||||
: `Respond with a single valid JSON value.`;
|
|
||||||
let text =
|
|
||||||
`You are a strict JSON generator. Output a SINGLE JSON value and NOTHING else.
|
|
||||||
- The response MUST begin with { or [ and end with the matching } or ].
|
|
||||||
- Do NOT wrap the JSON in Markdown or code fences (no \`\`\`).
|
|
||||||
- Do NOT include any prose, explanation, heading, comment, reasoning, or XML — only the raw JSON.
|
|
||||||
${schemaBlock}`;
|
|
||||||
if (attempt > 0) {
|
|
||||||
text = `YOUR PREVIOUS RESPONSE WAS REJECTED (${lastErr}). Output ONLY the corrected raw JSON now, with no other text.\n\n` + text;
|
|
||||||
}
|
|
||||||
return text;
|
|
||||||
}
|
|
||||||
+1
-1
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "open-claude-proxy",
|
"name": "open-claude-proxy",
|
||||||
"version": "3.23.0",
|
"version": "3.22.1",
|
||||||
"description": "OCP (Open Claude Proxy) — use your Claude Pro/Max subscription as an OpenAI-compatible API for any IDE. Works with Cline, OpenCode, Aider, Continue.dev, OpenClaw, and more.",
|
"description": "OCP (Open Claude Proxy) — use your Claude Pro/Max subscription as an OpenAI-compatible API for any IDE. Works with Cline, OpenCode, Aider, Continue.dev, OpenClaw, and more.",
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"bin": {
|
"bin": {
|
||||||
|
|||||||
+42
-345
@@ -35,14 +35,13 @@
|
|||||||
*/
|
*/
|
||||||
import { createServer } from "node:http";
|
import { createServer } from "node:http";
|
||||||
import { spawn, execFileSync, spawnSync } from "node:child_process";
|
import { spawn, execFileSync, spawnSync } from "node:child_process";
|
||||||
import { randomUUID, timingSafeEqual, createHash as cryptoCreateHash } from "node:crypto";
|
import { randomUUID, timingSafeEqual } from "node:crypto";
|
||||||
import { readFileSync, readdirSync, accessSync, existsSync, constants, chmodSync, statSync, mkdirSync, writeFileSync, rmSync } from "node:fs";
|
import { readFileSync, readdirSync, accessSync, existsSync, constants, chmodSync, statSync, mkdirSync, writeFileSync, rmSync } from "node:fs";
|
||||||
import { fileURLToPath } from "node:url";
|
import { fileURLToPath } from "node:url";
|
||||||
import { dirname, join } from "node:path";
|
import { dirname, join } from "node:path";
|
||||||
import { homedir } from "node:os";
|
import { homedir } from "node:os";
|
||||||
import { validateKey, recordUsage, getUsageByKey, getUsageTimeline, getRecentUsage, createKey, listKeys, revokeKey, closeDb, checkQuota, updateKeyQuota, getKeyQuota, findKey, cacheHash, getCachedResponse, setCachedResponse, clearCache, getCacheStats, hasCacheControl, singleflight, getInflightStats } from "./keys.mjs";
|
import { validateKey, recordUsage, getUsageByKey, getUsageTimeline, getRecentUsage, createKey, listKeys, revokeKey, closeDb, checkQuota, updateKeyQuota, getKeyQuota, findKey, cacheHash, getCachedResponse, setCachedResponse, clearCache, getCacheStats, hasCacheControl, singleflight, getInflightStats } from "./keys.mjs";
|
||||||
import { DEFAULT_PORT } from "./lib/constants.mjs";
|
import { DEFAULT_PORT } from "./lib/constants.mjs";
|
||||||
import { StructuredOutputError, detectStructuredOutput, validateJsonSchemaSafe, extractJsonPayload, structuredSystemInstruction, resolveMaxAttempts } from "./lib/structured-output.mjs";
|
|
||||||
import { isLoopbackBind } from "./lib/net.mjs";
|
import { isLoopbackBind } from "./lib/net.mjs";
|
||||||
import { runTuiTurn, reapStaleTuiSessions, resolveTuiHome, bootTuiPane, tuiPaneHealthy, poolPaneName, POOL_BOOT_MS } from "./lib/tui/session.mjs";
|
import { runTuiTurn, reapStaleTuiSessions, resolveTuiHome, bootTuiPane, tuiPaneHealthy, poolPaneName, POOL_BOOT_MS } from "./lib/tui/session.mjs";
|
||||||
import { detectTuiUpstreamError } from "./lib/tui/transcript.mjs";
|
import { detectTuiUpstreamError } from "./lib/tui/transcript.mjs";
|
||||||
@@ -50,9 +49,7 @@ import { TuiSemaphore, SemaphoreAbortError, recordTuiEntrypoint, buildTuiHealthB
|
|||||||
import { TuiPanePool, resolvePoolSize, POOL_MAX_SIZE } from "./lib/tui/pool.mjs";
|
import { TuiPanePool, resolvePoolSize, POOL_MAX_SIZE } from "./lib/tui/pool.mjs";
|
||||||
import { TuiDeltaAssembler, DEFAULT_HOLDBACK_CHARS, resolveStreamHoldback } from "./lib/tui/stream.mjs";
|
import { TuiDeltaAssembler, DEFAULT_HOLDBACK_CHARS, resolveStreamHoldback } from "./lib/tui/stream.mjs";
|
||||||
import { createSerialMutex, createTtlCache, isTokenExpiring, orderLabelsLastGoodFirst } from "./lib/spawn-auth.mjs";
|
import { createSerialMutex, createTtlCache, isTokenExpiring, orderLabelsLastGoodFirst } from "./lib/spawn-auth.mjs";
|
||||||
import { hasImageContent, buildImageBlocks, buildStreamJsonInput, MultimodalError } from "./lib/multimodal.mjs";
|
import { appendOperatorPrompt } from "./lib/prompt.mjs";
|
||||||
import { parsePositiveInt } from "./lib/env.mjs";
|
|
||||||
import { appendOperatorPrompt, derivePromptCharBudget } from "./lib/prompt.mjs";
|
|
||||||
|
|
||||||
const __dirname = dirname(fileURLToPath(import.meta.url));
|
const __dirname = dirname(fileURLToPath(import.meta.url));
|
||||||
const _pkg = JSON.parse(readFileSync(join(__dirname, "package.json"), "utf8"));
|
const _pkg = JSON.parse(readFileSync(join(__dirname, "package.json"), "utf8"));
|
||||||
@@ -251,8 +248,8 @@ function parseStreamJsonLines(buffered) {
|
|||||||
// Reference: OLP lib/providers/anthropic.mjs anthropicStreamJsonEventToIR (commit 97e7d16).
|
// Reference: OLP lib/providers/anthropic.mjs anthropicStreamJsonEventToIR (commit 97e7d16).
|
||||||
//
|
//
|
||||||
// @param {object} event — parsed NDJSON event
|
// @param {object} event — parsed NDJSON event
|
||||||
// @param {boolean} sawTextDelta — true if a streaming content_block_delta text was already seen
|
// @param {boolean} isFirstDelta — true if no content has been yielded yet
|
||||||
function parseStreamJsonEvent(event, sawTextDelta) {
|
function parseStreamJsonEvent(event, isFirstDelta) {
|
||||||
const t = event?.type;
|
const t = event?.type;
|
||||||
|
|
||||||
// system/* — first-event init + other system meta (api_retry etc.)
|
// system/* — first-event init + other system meta (api_retry etc.)
|
||||||
@@ -264,23 +261,19 @@ function parseStreamJsonEvent(event, sawTextDelta) {
|
|||||||
if (t === "stream_event") {
|
if (t === "stream_event") {
|
||||||
const inner = event.event ?? event;
|
const inner = event.event ?? event;
|
||||||
if (inner?.type === "content_block_delta" && inner.delta?.type === "text_delta") {
|
if (inner?.type === "content_block_delta" && inner.delta?.type === "text_delta") {
|
||||||
return { text: inner.delta.text ?? "", fromDelta: true };
|
return { text: inner.delta.text ?? "" };
|
||||||
}
|
}
|
||||||
// Other stream_event sub-types (content_block_start, message_delta, etc.) — consumed
|
// Other stream_event sub-types (content_block_start, message_delta, etc.) — consumed
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
// assistant — aggregate message. claude CLI without --include-partial-messages emits NO
|
// assistant — aggregate message (fallback when no prior content_block_delta seen)
|
||||||
// content_block_delta events; each assistant message arrives as its own aggregate `assistant`
|
// Empirically (claude CLI without --include-partial-messages, verified v2.1.104 through v2.1.158): fast/short
|
||||||
// event. An agentic/tool-using turn has SEVERAL (preamble + one per tool round + final answer),
|
// responses may emit ONLY the aggregate assistant event, no content_block_delta events.
|
||||||
// so we must accumulate the text of EVERY such event. The prior `isFirstDelta` guard kept only
|
// If isFirstDelta is true, extract text here; otherwise it's a duplicate, ignore.
|
||||||
// the FIRST message's text and dropped the rest — silently losing the post-tool-use final answer
|
|
||||||
// on every tool-using turn (verified v2.1.104 through v2.1.211; see PR body capture).
|
|
||||||
// The only real hazard is the delta+aggregate DOUBLE-COUNT: if streaming deltas were already
|
|
||||||
// seen (sawTextDelta), the aggregate duplicates them — ignore it.
|
|
||||||
// Reference: OLP commit 65f945c (assistant-aggregate fallback, fold-in).
|
// Reference: OLP commit 65f945c (assistant-aggregate fallback, fold-in).
|
||||||
if (t === "assistant") {
|
if (t === "assistant") {
|
||||||
if (!sawTextDelta) {
|
if (isFirstDelta) {
|
||||||
const blocks = event.message?.content;
|
const blocks = event.message?.content;
|
||||||
if (Array.isArray(blocks)) {
|
if (Array.isArray(blocks)) {
|
||||||
const text = blocks
|
const text = blocks
|
||||||
@@ -334,16 +327,6 @@ const ALLOWED_TOOLS = (process.env.CLAUDE_ALLOWED_TOOLS ||
|
|||||||
"Bash,Read,Write,Edit,Glob,Grep,WebSearch,WebFetch,Agent"
|
"Bash,Read,Write,Edit,Glob,Grep,WebSearch,WebFetch,Agent"
|
||||||
).split(",").map(s => s.trim()).filter(Boolean);
|
).split(",").map(s => s.trim()).filter(Boolean);
|
||||||
const SYSTEM_PROMPT = process.env.CLAUDE_SYSTEM_PROMPT || "";
|
const SYSTEM_PROMPT = process.env.CLAUDE_SYSTEM_PROMPT || "";
|
||||||
// Max attempts (initial + retries) to coerce a valid structured-output (OpenAI response_format)
|
|
||||||
// JSON response out of the model before rejecting. See runStructuredCompletion.
|
|
||||||
// Fail closed on a non-numeric value via resolveMaxAttempts(): the old `Math.max(1, parseInt("abc",10))`
|
|
||||||
// === `Math.max(1, NaN)` === NaN, which made the retry loop `attempt < NaN` never execute → 0 spawns,
|
|
||||||
// every structured request silently refused. The helper rejects NaN/non-finite/<1 and keeps the
|
|
||||||
// documented default of 3. (PR #153 review round 2, NaN-guard must-fix.)
|
|
||||||
const STRUCTURED_MAX_ATTEMPTS = resolveMaxAttempts(
|
|
||||||
process.env.OCP_STRUCTURED_MAX_ATTEMPTS,
|
|
||||||
{ fallback: 3, warn: (m) => console.warn(`[init] ${m}`) },
|
|
||||||
);
|
|
||||||
const MCP_CONFIG = process.env.CLAUDE_MCP_CONFIG || "";
|
const MCP_CONFIG = process.env.CLAUDE_MCP_CONFIG || "";
|
||||||
let SESSION_TTL = parseInt(process.env.CLAUDE_SESSION_TTL || "3600000", 10);
|
let SESSION_TTL = parseInt(process.env.CLAUDE_SESSION_TTL || "3600000", 10);
|
||||||
let MAX_CONCURRENT = parseInt(process.env.CLAUDE_MAX_CONCURRENT || "8", 10);
|
let MAX_CONCURRENT = parseInt(process.env.CLAUDE_MAX_CONCURRENT || "8", 10);
|
||||||
@@ -363,19 +346,6 @@ const BREAKER_HALF_OPEN_MAX = parseInt(process.env.CLAUDE_BREAKER_HALF_OPEN_MAX
|
|||||||
const HEARTBEAT_INTERVAL = parseInt(process.env.CLAUDE_HEARTBEAT_INTERVAL || "0", 10);
|
const HEARTBEAT_INTERVAL = parseInt(process.env.CLAUDE_HEARTBEAT_INTERVAL || "0", 10);
|
||||||
const BIND_ADDRESS = process.env.CLAUDE_BIND || "127.0.0.1";
|
const BIND_ADDRESS = process.env.CLAUDE_BIND || "127.0.0.1";
|
||||||
const NO_CONTEXT = process.env.CLAUDE_NO_CONTEXT === "true";
|
const NO_CONTEXT = process.env.CLAUDE_NO_CONTEXT === "true";
|
||||||
// Config epoch for the response cache (issue #176). The cache key hashes model + messages +
|
|
||||||
// sampling params, but the ANSWER also depends on boot-time server config that shapes the
|
|
||||||
// composed prompt / tool surface: the operator system prompt (#175), the OCP wrapper text,
|
|
||||||
// the allowed-tools set, and NO_CONTEXT. The cache store is SQLite-backed and survives
|
|
||||||
// restarts, so without this an operator who changes any of these and restarts keeps serving
|
|
||||||
// answers composed under the OLD config until TTL expiry. Folding a digest of the four into
|
|
||||||
// every cache key makes any change an instant, whole-cache invalidation — the honest behavior.
|
|
||||||
// Deliberately boot-time-only: runtime-mutable settings (e.g. maxPromptChars via the settings
|
|
||||||
// API) are excluded because a const epoch cannot track them; truncation also only drops
|
|
||||||
// context rather than changing the instruction set.
|
|
||||||
const CONFIG_EPOCH = cryptoCreateHash("sha256")
|
|
||||||
.update(JSON.stringify([SYSTEM_PROMPT, OCP_SYSTEM_PROMPT_WRAPPER, ALLOWED_TOOLS, NO_CONTEXT]))
|
|
||||||
.digest("hex").slice(0, 16);
|
|
||||||
// Kill-switch for the FIX-③ default-path spawn-home isolation (see resolveSpawnHome /
|
// Kill-switch for the FIX-③ default-path spawn-home isolation (see resolveSpawnHome /
|
||||||
// spawnHomeMode below). When "1", the -p/stream-json spawn always runs in the operator's
|
// spawnHomeMode below). When "1", the -p/stream-json spawn always runs in the operator's
|
||||||
// real HOME with no cwd override — byte-for-byte the pre-isolation behaviour — even if an
|
// real HOME with no cwd override — byte-for-byte the pre-isolation behaviour — even if an
|
||||||
@@ -1117,7 +1087,7 @@ const authCheckInterval = setInterval(checkAuth, 600000);
|
|||||||
// CLAUDE_SYSTEM_PROMPT env var is absorbed into the system prompt via
|
// CLAUDE_SYSTEM_PROMPT env var is absorbed into the system prompt via
|
||||||
// extractSystemPrompt() at the caller level; APPEND_SYSTEM_PROMPT no longer used.
|
// extractSystemPrompt() at the caller level; APPEND_SYSTEM_PROMPT no longer used.
|
||||||
// Note: ALLOWED_TOOLS / SKIP_PERMISSIONS / MCP_CONFIG are preserved as before.
|
// Note: ALLOWED_TOOLS / SKIP_PERMISSIONS / MCP_CONFIG are preserved as before.
|
||||||
function buildCliArgs(cliModel, systemPrompt, opts = {}) {
|
function buildCliArgs(cliModel, systemPrompt) {
|
||||||
const args = [
|
const args = [
|
||||||
"--model", cliModel,
|
"--model", cliModel,
|
||||||
"--output-format", "stream-json",
|
"--output-format", "stream-json",
|
||||||
@@ -1126,14 +1096,6 @@ function buildCliArgs(cliModel, systemPrompt, opts = {}) {
|
|||||||
"--system-prompt", systemPrompt,
|
"--system-prompt", systemPrompt,
|
||||||
];
|
];
|
||||||
|
|
||||||
// Multimodal path (issue #110): images are fed as Anthropic content blocks over
|
|
||||||
// a stream-json stdin stream. `--input-format stream-json` (§ --input-format,
|
|
||||||
// choices text|stream-json; realtime streaming input) is added ONLY when the
|
|
||||||
// request carries an image part; the default (text) input path is untouched.
|
|
||||||
if (opts.streamJsonInput) {
|
|
||||||
args.push("--input-format", "stream-json");
|
|
||||||
}
|
|
||||||
|
|
||||||
// Permissions
|
// Permissions
|
||||||
// ADR 0007 B-path: in multi-tenant mode, suppress operator-FS tools so a guest
|
// ADR 0007 B-path: in multi-tenant mode, suppress operator-FS tools so a guest
|
||||||
// prompt cannot drive Bash/Read/Write/Edit/etc. on the operator's filesystem.
|
// prompt cannot drive Bash/Read/Write/Edit/etc. on the operator's filesystem.
|
||||||
@@ -1169,45 +1131,11 @@ function buildCliArgs(cliModel, systemPrompt, opts = {}) {
|
|||||||
return args;
|
return args;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Thin env wrapper over parsePositiveInt (lib/env.mjs): resolve `name` from the
|
|
||||||
// environment fail-closed, warning on a present-but-invalid value. Keeps the pure
|
|
||||||
// parse in a unit-testable module. (PR #154 review F3)
|
|
||||||
function parseIntEnv(name, def) {
|
|
||||||
const { value, ok } = parsePositiveInt(process.env[name], def);
|
|
||||||
if (!ok) console.warn(`⚠ ${name}="${process.env[name]}" is not a valid positive integer (bytes/count, no unit suffix); ignoring and using default ${def}.`);
|
|
||||||
return value;
|
|
||||||
}
|
|
||||||
|
|
||||||
// ── Format messages to prompt text ──────────────────────────────────────
|
// ── Format messages to prompt text ──────────────────────────────────────
|
||||||
// Truncation guard: if total chars exceed MAX_PROMPT_CHARS, keep the system
|
// Truncation guard: if total chars exceed MAX_PROMPT_CHARS, keep the system
|
||||||
// message(s) + first user message + last N messages, dropping the middle.
|
// message(s) + first user message + last N messages, dropping the middle.
|
||||||
// This prevents runaway context from gateway-side conversation accumulation.
|
// This prevents runaway context from gateway-side conversation accumulation.
|
||||||
// Routed through parseIntEnv so a misconfigured cap fails CLOSED to the default rather than
|
let MAX_PROMPT_CHARS = parseInt(process.env.CLAUDE_MAX_PROMPT_CHARS || "150000", 10);
|
||||||
// NaN — CLAUDE_MAX_PROMPT_CHARS=unlimited previously → NaN → enforceTextBudget's `!(NaN > 0)`
|
|
||||||
// early-return → 500k chars passed unbounded, silently defeating F2's text-budget guarantee
|
|
||||||
// (PR #154 round 2, gap (a)). The default itself is SPOT-DERIVED (ADR 0009, PR #179):
|
|
||||||
// max(models.json contextWindow) × 3 chars/token — currently 600,000 — so the two fixes
|
|
||||||
// compose: parseIntEnv guards a SET-but-garbage value, the derivation supplies the honest
|
|
||||||
// default when unset/empty. `let` is kept for the settings API.
|
|
||||||
let MAX_PROMPT_CHARS = parseIntEnv("CLAUDE_MAX_PROMPT_CHARS", derivePromptCharBudget(modelsConfig.models));
|
|
||||||
|
|
||||||
// ── Multimodal image caps (issue #110) ──────────────────────────────────
|
|
||||||
// OpenAI `image_url` parts are forwarded to claude as Anthropic image blocks via
|
|
||||||
// `--input-format stream-json`. Images deliberately BYPASS the text char budget
|
|
||||||
// (MAX_PROMPT_CHARS) — they are bounded by these byte/count caps instead, and by
|
|
||||||
// MAX_BODY_SIZE at the HTTP layer. Data URIs are supported by default; remote
|
|
||||||
// http(s) image URLs are OFF unless CLAUDE_IMAGE_ALLOW_URL is set (v1: data URIs
|
|
||||||
// only). See docs/adr/0006-openai-shim-scope.md (Class B.1) and README § "Images".
|
|
||||||
const IMAGE_ALLOW_URL = /^(1|true|yes|on)$/i.test(process.env.CLAUDE_IMAGE_ALLOW_URL || "");
|
|
||||||
const MAX_IMAGE_BYTES = parseIntEnv("CLAUDE_MAX_IMAGE_BYTES", 5 * 1024 * 1024);
|
|
||||||
const MAX_IMAGES = parseIntEnv("CLAUDE_MAX_IMAGES", 20);
|
|
||||||
const MAX_IMAGE_TOTAL_BYTES = parseIntEnv("CLAUDE_MAX_IMAGE_TOTAL_BYTES", 20 * 1024 * 1024);
|
|
||||||
const MULTIMODAL_OPTS = {
|
|
||||||
allowRemoteUrl: IMAGE_ALLOW_URL,
|
|
||||||
maxImageBytes: MAX_IMAGE_BYTES,
|
|
||||||
maxImages: MAX_IMAGES,
|
|
||||||
maxTotalImageBytes: MAX_IMAGE_TOTAL_BYTES,
|
|
||||||
};
|
|
||||||
|
|
||||||
// Flatten OpenAI content (string | array of parts) to plain text for the prompt.
|
// Flatten OpenAI content (string | array of parts) to plain text for the prompt.
|
||||||
// Array content: concatenate text parts; replace non-text parts (e.g. image_url)
|
// Array content: concatenate text parts; replace non-text parts (e.g. image_url)
|
||||||
@@ -1299,51 +1227,25 @@ function spawnClaudeProcess(model, messages, conversationId, keyName, releaseSlo
|
|||||||
|
|
||||||
// Circuit breaker: disabled (see comment at top of breaker section)
|
// Circuit breaker: disabled (see comment at top of breaker section)
|
||||||
|
|
||||||
// Phase 6c: always serialize full conversation via stdin (no session resume).
|
|
||||||
// System messages are extracted and passed via --system-prompt; the remaining
|
|
||||||
// messages (user/assistant/tool) are serialized for stdin.
|
|
||||||
const systemPrompt = extractSystemPrompt(messages);
|
|
||||||
|
|
||||||
// messagesToPrompt / buildStreamJsonInput skip system messages (they go via
|
|
||||||
// --system-prompt). Filter them out first to avoid double-injection.
|
|
||||||
const nonSystemMessages = messages.filter(m => m.role !== "system");
|
|
||||||
|
|
||||||
// Multimodal (issue #110): when any message carries an OpenAI image_url part,
|
|
||||||
// feed the conversation as Anthropic content blocks over --input-format
|
|
||||||
// stream-json (images preserved and kept OUT of the text char budget).
|
|
||||||
// Otherwise the text path is byte-for-byte unchanged. buildStreamJsonInput may
|
|
||||||
// throw MultimodalError on an invalid/oversized image; it runs BEFORE any stats
|
|
||||||
// mutation so a validation failure never leaks counters or the concurrency slot
|
|
||||||
// (handleChatCompletions validates first, so in practice it will not throw here).
|
|
||||||
const useStreamJson = hasImageContent(nonSystemMessages);
|
|
||||||
let stdinPayload, promptChars;
|
|
||||||
if (useStreamJson) {
|
|
||||||
// Pass MAX_PROMPT_CHARS so the multimodal text is bounded by the same
|
|
||||||
// runaway-context guard as the text path (PR #154 review F2). Images bypass it.
|
|
||||||
const built = buildStreamJsonInput(nonSystemMessages, { ...MULTIMODAL_OPTS, maxTextChars: MAX_PROMPT_CHARS });
|
|
||||||
stdinPayload = built.payload;
|
|
||||||
promptChars = built.stats.textChars;
|
|
||||||
if (built.stats.truncated) {
|
|
||||||
logEvent("warn", "prompt_truncated", {
|
|
||||||
originalChars: built.stats.originalTextChars,
|
|
||||||
maxChars: MAX_PROMPT_CHARS,
|
|
||||||
keptChars: built.stats.textChars,
|
|
||||||
path: "multimodal",
|
|
||||||
});
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
stdinPayload = messagesToPrompt(nonSystemMessages);
|
|
||||||
promptChars = stdinPayload.length;
|
|
||||||
}
|
|
||||||
|
|
||||||
stats.activeRequests++;
|
stats.activeRequests++;
|
||||||
stats.totalRequests++;
|
stats.totalRequests++;
|
||||||
|
|
||||||
|
// Phase 6c: always serialize full conversation via stdin (no session resume).
|
||||||
|
// System messages are extracted and passed via --system-prompt; the remaining
|
||||||
|
// messages (user/assistant/tool) are serialized by messagesToPrompt.
|
||||||
|
const systemPrompt = extractSystemPrompt(messages);
|
||||||
|
|
||||||
|
// messagesToPrompt skips system messages now that they go via --system-prompt.
|
||||||
|
// Filter them out before calling to avoid double-injection.
|
||||||
|
const nonSystemMessages = messages.filter(m => m.role !== "system");
|
||||||
|
const prompt = messagesToPrompt(nonSystemMessages);
|
||||||
|
|
||||||
stats.oneOffRequests++;
|
stats.oneOffRequests++;
|
||||||
if (conversationId) {
|
if (conversationId) {
|
||||||
console.log(`[session] stateless conv=${conversationId.slice(0, 12)}... key=${keyName || "anon"} msgs=${messages.length} prompt_chars=${promptChars}`);
|
console.log(`[session] stateless conv=${conversationId.slice(0, 12)}... key=${keyName || "anon"} msgs=${messages.length} prompt_chars=${prompt.length}`);
|
||||||
}
|
}
|
||||||
|
|
||||||
const cliArgs = buildCliArgs(cliModel, systemPrompt, { streamJsonInput: useStreamJson });
|
const cliArgs = buildCliArgs(cliModel, systemPrompt);
|
||||||
|
|
||||||
const env = { ...process.env };
|
const env = { ...process.env };
|
||||||
delete env.CLAUDECODE;
|
delete env.CLAUDECODE;
|
||||||
@@ -1429,13 +1331,12 @@ function spawnClaudeProcess(model, messages, conversationId, keyName, releaseSlo
|
|||||||
// the spawned process, NOT on the stdin Writable — it does not catch this.
|
// the spawned process, NOT on the stdin Writable — it does not catch this.
|
||||||
proc.stdin.on("error", (e) => logEvent("warn", "stdin_write_error", { error: e.message }));
|
proc.stdin.on("error", (e) => logEvent("warn", "stdin_write_error", { error: e.message }));
|
||||||
|
|
||||||
// Write the serialized turn to stdin immediately. Text path: the flat prompt.
|
// Write prompt to stdin immediately
|
||||||
// Multimodal path: a single newline-terminated stream-json user envelope.
|
proc.stdin.write(prompt);
|
||||||
proc.stdin.write(stdinPayload);
|
|
||||||
proc.stdin.end();
|
proc.stdin.end();
|
||||||
|
|
||||||
recordModelRequest(cliModel, promptChars);
|
recordModelRequest(cliModel, prompt.length);
|
||||||
logEvent("info", "claude_spawned", { model: cliModel, promptChars, inputFormat: useStreamJson ? "stream-json" : "text", timeout: TIMEOUT, tier: getModelTier(cliModel), session: conversationId ? conversationId.slice(0, 12) + "..." : "none" });
|
logEvent("info", "claude_spawned", { model: cliModel, promptChars: prompt.length, timeout: TIMEOUT, tier: getModelTier(cliModel), session: conversationId ? conversationId.slice(0, 12) + "..." : "none" });
|
||||||
|
|
||||||
// Single request timeout — no separate first-byte timer.
|
// Single request timeout — no separate first-byte timer.
|
||||||
// Claude tool-use causes long pauses in the token stream (30s-5min),
|
// Claude tool-use causes long pauses in the token stream (30s-5min),
|
||||||
@@ -1505,7 +1406,7 @@ async function callClaude(model, messages, conversationId, keyName, res) {
|
|||||||
const { proc, cliModel, conversationId: convId, t0, cleanup, handleSessionFailure, markFirstByte } = ctx;
|
const { proc, cliModel, conversationId: convId, t0, cleanup, handleSessionFailure, markFirstByte } = ctx;
|
||||||
let lineBuffer = "";
|
let lineBuffer = "";
|
||||||
let assembledText = "";
|
let assembledText = "";
|
||||||
let sawTextDelta = false;
|
let isFirstDelta = true;
|
||||||
let resultEventSeen = false;
|
let resultEventSeen = false;
|
||||||
let stderr = "";
|
let stderr = "";
|
||||||
|
|
||||||
@@ -1515,18 +1416,11 @@ async function callClaude(model, messages, conversationId, keyName, res) {
|
|||||||
const { events, remainder } = parseStreamJsonLines(lineBuffer);
|
const { events, remainder } = parseStreamJsonLines(lineBuffer);
|
||||||
lineBuffer = remainder;
|
lineBuffer = remainder;
|
||||||
for (const event of events) {
|
for (const event of events) {
|
||||||
const parsed = parseStreamJsonEvent(event, sawTextDelta);
|
const parsed = parseStreamJsonEvent(event, isFirstDelta);
|
||||||
if (!parsed) continue;
|
if (!parsed) continue;
|
||||||
if (parsed.text !== undefined) {
|
if (parsed.text !== undefined) {
|
||||||
if (parsed.fromDelta) {
|
assembledText += parsed.text;
|
||||||
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) {
|
} else if (parsed.stop) {
|
||||||
resultEventSeen = true;
|
resultEventSeen = true;
|
||||||
} else if (parsed.error) {
|
} else if (parsed.error) {
|
||||||
@@ -1948,10 +1842,9 @@ async function callClaudeStreaming(model, messages, conversationId, res, authInf
|
|||||||
let stderr = "";
|
let stderr = "";
|
||||||
let headersSent = false;
|
let headersSent = false;
|
||||||
let totalChars = 0;
|
let totalChars = 0;
|
||||||
let streamEndsWithNewline = false; // tracks whether emitted text ends in "\n" — see the separator guard below
|
|
||||||
let cachedContent = ""; // accumulate for cache write-back
|
let cachedContent = ""; // accumulate for cache write-back
|
||||||
let lineBuffer = "";
|
let lineBuffer = "";
|
||||||
let sawTextDelta = false;
|
let isFirstDelta = true;
|
||||||
let resultEventSeen = false;
|
let resultEventSeen = false;
|
||||||
// Separate flag for is_error result — must NOT be conflated with resultEventSeen.
|
// Separate flag for is_error result — must NOT be conflated with resultEventSeen.
|
||||||
// If errored===true the close handler must not cache the response or record success
|
// If errored===true the close handler must not cache the response or record success
|
||||||
@@ -1988,26 +1881,15 @@ async function callClaudeStreaming(model, messages, conversationId, res, authInf
|
|||||||
lineBuffer = remainder;
|
lineBuffer = remainder;
|
||||||
|
|
||||||
for (const event of events) {
|
for (const event of events) {
|
||||||
const parsed = parseStreamJsonEvent(event, sawTextDelta);
|
const parsed = parseStreamJsonEvent(event, isFirstDelta);
|
||||||
if (!parsed) continue;
|
if (!parsed) continue;
|
||||||
|
|
||||||
if (parsed.text !== undefined) {
|
if (parsed.text !== undefined) {
|
||||||
// Streamed delta, or an aggregate assistant-message text (agentic turns emit several).
|
// content_block_delta text — forward as SSE delta
|
||||||
// For an aggregate message after earlier text, prepend a separator so the preamble and
|
const text = parsed.text;
|
||||||
// the post-tool-use final answer don't run together in the forwarded stream.
|
|
||||||
let text = parsed.text;
|
|
||||||
if (parsed.fromDelta) {
|
|
||||||
sawTextDelta = true;
|
|
||||||
} else if (totalChars > 0 && !streamEndsWithNewline) {
|
|
||||||
// Mirror the buffered path's guard (assembledText.endsWith("\n")): only inject the
|
|
||||||
// blank-line separator when the already-emitted text doesn't already end in a newline,
|
|
||||||
// so a message ending in "\n" doesn't produce a triple newline here while the buffered
|
|
||||||
// path produces a single. Keeps the two assembly paths byte-identical. (PR #183 review.)
|
|
||||||
text = "\n\n" + text;
|
|
||||||
}
|
|
||||||
streamEndsWithNewline = text.endsWith("\n");
|
|
||||||
totalChars += text.length;
|
totalChars += text.length;
|
||||||
if (CACHE_TTL > 0) cachedContent += text;
|
if (CACHE_TTL > 0) cachedContent += text;
|
||||||
|
isFirstDelta = false;
|
||||||
|
|
||||||
if (!ensureHeaders()) continue;
|
if (!ensureHeaders()) continue;
|
||||||
sendSSE(res, {
|
sendSSE(res, {
|
||||||
@@ -2177,31 +2059,6 @@ function completionResponse(res, id, model, content) {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
// OpenAI's designated mechanism for "the model would not produce the required output" is the
|
|
||||||
// assistant `refusal` field (content:null, refusal:<text>, finish_reason:"stop") — NOT an invented
|
|
||||||
// error type. Structured-output exhaustion emits this so SDK clients take their written `refusal`
|
|
||||||
// branch instead of throwing an opaque UnprocessableEntityError. (PR #153 review, finding 3.)
|
|
||||||
function refusalResponse(res, id, model, refusal) {
|
|
||||||
jsonResponse(res, 200, {
|
|
||||||
id, object: "chat.completion",
|
|
||||||
created: Math.floor(Date.now() / 1000),
|
|
||||||
model,
|
|
||||||
choices: [{ index: 0, message: { role: "assistant", content: null, refusal }, finish_reason: "stop" }],
|
|
||||||
usage: { prompt_tokens: 0, completion_tokens: 0, total_tokens: 0 },
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
// Streaming form of refusalResponse: a role chunk, a `refusal` delta, then the stop chunk.
|
|
||||||
function streamRefusalAsSSE(res, id, model, refusal) {
|
|
||||||
const created = Math.floor(Date.now() / 1000);
|
|
||||||
res.writeHead(200, { "Content-Type": "text/event-stream", "Cache-Control": "no-cache", "Connection": "keep-alive", "X-Accel-Buffering": "no" });
|
|
||||||
sendSSE(res, { id, object: "chat.completion.chunk", created, model, choices: [{ index: 0, delta: { role: "assistant" }, finish_reason: null }] });
|
|
||||||
sendSSE(res, { id, object: "chat.completion.chunk", created, model, choices: [{ index: 0, delta: { refusal }, finish_reason: null }] });
|
|
||||||
sendSSE(res, { id, object: "chat.completion.chunk", created, model, choices: [{ index: 0, delta: {}, finish_reason: "stop" }] });
|
|
||||||
res.write("data: [DONE]\n\n");
|
|
||||||
res.end();
|
|
||||||
}
|
|
||||||
|
|
||||||
// Replay a complete string as a chunked SSE stream (80 codepoints/chunk).
|
// Replay a complete string as a chunked SSE stream (80 codepoints/chunk).
|
||||||
// Used by: (a) cache-hit replay on the streaming path; (b) TUI-mode streaming
|
// Used by: (a) cache-hit replay on the streaming path; (b) TUI-mode streaming
|
||||||
// (buffered response replayed as SSE so clients get the same wire format).
|
// (buffered response replayed as SSE so clients get the same wire format).
|
||||||
@@ -2703,58 +2560,18 @@ async function handleSettings(req, res) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// ── Handle chat completions ─────────────────────────────────────────────
|
// ── Handle chat completions ─────────────────────────────────────────────
|
||||||
// Default 5 MB, byte-for-byte unchanged unless CLAUDE_MAX_BODY_SIZE is set. Base64
|
const MAX_BODY_SIZE = 5 * 1024 * 1024; // 5 MB
|
||||||
// image payloads inflate ~33%; operators enabling large images (issue #110) can
|
|
||||||
// raise this to admit bigger requests. Parsed fail-closed (PR #154 review F3): a
|
|
||||||
// bad value (`unlimited` → NaN, `5MB` → 5) must not disable the body cap or brick
|
|
||||||
// the proxy — parseIntEnv keeps the 5 MB default and warns instead.
|
|
||||||
const MAX_BODY_SIZE = parseIntEnv("CLAUDE_MAX_BODY_SIZE", 5 * 1024 * 1024);
|
|
||||||
const MAX_BODY_SIZE_LABEL = `${Math.round(MAX_BODY_SIZE / (1024 * 1024))}MB`;
|
|
||||||
|
|
||||||
// Set of all valid model identifiers (canonical IDs + aliases)
|
// Set of all valid model identifiers (canonical IDs + aliases)
|
||||||
const VALID_MODELS = new Set(Object.keys(MODEL_MAP));
|
const VALID_MODELS = new Set(Object.keys(MODEL_MAP));
|
||||||
|
|
||||||
// Drive the model to a valid structured-output (OpenAI response_format) JSON string, retrying up to
|
|
||||||
// STRUCTURED_MAX_ATTEMPTS. Appends a strict JSON-only steering instruction, extracts + validates the
|
|
||||||
// reply (pure helpers in lib/structured-output.mjs), and escalates the instruction on failure.
|
|
||||||
// Returns the canonical JSON string (message.content) or throws StructuredOutputError.
|
|
||||||
async function runStructuredCompletion(upstreamCall, model, messages, conversationId, keyName, res, structured) {
|
|
||||||
let lastErr = "no valid JSON produced";
|
|
||||||
let lastRaw = "";
|
|
||||||
for (let attempt = 0; attempt < STRUCTURED_MAX_ATTEMPTS; attempt++) {
|
|
||||||
const augmented = [...messages, { role: "system", content: structuredSystemInstruction(structured, attempt, lastErr) }];
|
|
||||||
const raw = await upstreamCall(model, augmented, conversationId, keyName, res);
|
|
||||||
lastRaw = raw;
|
|
||||||
const extracted = extractJsonPayload(raw, { whole: structured.mode === "json_object" });
|
|
||||||
if (!extracted.ok) {
|
|
||||||
lastErr = extracted.reason || "response was not parseable as JSON";
|
|
||||||
logEvent("warn", "structured_retry", { attempt, reason: extracted.reason || "unparseable" });
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
if (structured.mode === "schema" && structured.schema) {
|
|
||||||
// validateJsonSchemaSafe (#181): a pathologically deep model reply overflows the value-depth
|
|
||||||
// recursion; the safe façade turns that into a validation miss (→ retry → refusal) instead of
|
|
||||||
// a caught RangeError surfacing as a generic 500.
|
|
||||||
const errs = validateJsonSchemaSafe(extracted.value, structured.schema, "$", structured.strict);
|
|
||||||
if (errs.length) {
|
|
||||||
lastErr = "schema validation failed: " + errs.slice(0, 5).join("; ");
|
|
||||||
logEvent("warn", "structured_retry", { attempt, reason: "schema", errors: errs.slice(0, 5) });
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if (attempt > 0) logEvent("info", "structured_recovered", { attempt });
|
|
||||||
return JSON.stringify(extracted.value); // canonical, fence-free, prose-free
|
|
||||||
}
|
|
||||||
throw new StructuredOutputError(lastErr, lastRaw);
|
|
||||||
}
|
|
||||||
|
|
||||||
async function handleChatCompletions(req, res) {
|
async function handleChatCompletions(req, res) {
|
||||||
let body = "";
|
let body = "";
|
||||||
try {
|
try {
|
||||||
for await (const chunk of req) {
|
for await (const chunk of req) {
|
||||||
body += chunk;
|
body += chunk;
|
||||||
if (body.length > MAX_BODY_SIZE) {
|
if (body.length > MAX_BODY_SIZE) {
|
||||||
return jsonResponse(res, 413, { error: { message: `Request body too large (max ${MAX_BODY_SIZE_LABEL})`, type: "invalid_request_error" } });
|
return jsonResponse(res, 413, { error: { message: "Request body too large (max 5MB)", type: "invalid_request_error" } });
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
@@ -2783,57 +2600,6 @@ async function handleChatCompletions(req, res) {
|
|||||||
return jsonResponse(res, 400, { error: { message: "'messages' must be a non-empty array", type: "invalid_request_error" } });
|
return jsonResponse(res, 400, { error: { message: "'messages' must be a non-empty array", type: "invalid_request_error" } });
|
||||||
}
|
}
|
||||||
|
|
||||||
// Multimodal validation (issue #110): when a request carries OpenAI `image_url`
|
|
||||||
// content parts, validate/parse them now so an invalid, unsupported, or oversized
|
|
||||||
// image returns a clean 4xx BEFORE the cache/spawn path (rather than a silent drop
|
|
||||||
// or an opaque 500). The stream-json transform itself runs at spawn time
|
|
||||||
// (spawnClaudeProcess → buildStreamJsonInput). Class B.1: authorized by ADR 0006;
|
|
||||||
// request shape per OpenAI vision spec (image_url content parts). buildImageBlocks
|
|
||||||
// validates without stringifying, so this early pass is cheap.
|
|
||||||
if (hasImageContent(messages)) {
|
|
||||||
// F1 (PR #154 review): the TUI path (callClaudeTui → messagesToPrompt) cannot
|
|
||||||
// carry image blocks — it renders every non-text part as "[non-text content
|
|
||||||
// omitted]". Forwarding here would let the model answer about an image it never
|
|
||||||
// saw and return 200, which is strictly worse than an honest error (the one
|
|
||||||
// outcome ALIGNMENT.md forbids: silently serving text the model did not mean).
|
|
||||||
// Stream-json image input requires the `claude -p` path, so in TUI_MODE we fail
|
|
||||||
// loudly instead of dropping. Documented in README § "Images".
|
|
||||||
if (TUI_MODE) {
|
|
||||||
return jsonResponse(res, 400, {
|
|
||||||
error: {
|
|
||||||
message: "Image inputs are not supported in TUI mode (CLAUDE_TUI_MODE=true). Images require the default -p spawn path; remove images or run OCP without TUI mode.",
|
|
||||||
type: "invalid_request_error",
|
|
||||||
code: "images_unsupported_in_tui_mode",
|
|
||||||
},
|
|
||||||
});
|
|
||||||
}
|
|
||||||
// Detection runs on the FULL message list, but extraction/spawn drop system messages
|
|
||||||
// (system role carries no image blocks to the CLI). So an image present ONLY in a
|
|
||||||
// system message would be detected as multimodal, survive no filter, fall to the text
|
|
||||||
// path, and render as "[non-text content omitted]" → 200 with a hallucinated answer —
|
|
||||||
// the exact silent-drop this guard exists to forbid. Fail loudly instead. OpenAI
|
|
||||||
// disallows images in the system role anyway, so no legitimate request is rejected.
|
|
||||||
// (PR #154 review round 2, gap (b).)
|
|
||||||
const nonSystem = messages.filter(m => m.role !== "system");
|
|
||||||
if (!hasImageContent(nonSystem)) {
|
|
||||||
return jsonResponse(res, 400, {
|
|
||||||
error: {
|
|
||||||
message: "Image inputs are only supported in user/assistant messages, not in system messages. Move the image_url part to a user message.",
|
|
||||||
type: "invalid_request_error",
|
|
||||||
code: "images_unsupported_in_system_messages",
|
|
||||||
},
|
|
||||||
});
|
|
||||||
}
|
|
||||||
try {
|
|
||||||
buildImageBlocks(nonSystem, { ...MULTIMODAL_OPTS, maxTextChars: MAX_PROMPT_CHARS });
|
|
||||||
} catch (e) {
|
|
||||||
if (e instanceof MultimodalError) {
|
|
||||||
return jsonResponse(res, e.status, { error: { message: e.message, type: e.type, code: e.code } });
|
|
||||||
}
|
|
||||||
throw e;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// NOTE: quota is best-effort / eventually-consistent. The gate reads the recorded count
|
// NOTE: quota is best-effort / eventually-consistent. The gate reads the recorded count
|
||||||
// at entry and records only after the upstream completes, so concurrent requests at the
|
// at entry and records only after the upstream completes, so concurrent requests at the
|
||||||
// boundary can overshoot the cap by up to MAX_CONCURRENT, and cache hits (served before
|
// boundary can overshoot the cap by up to MAX_CONCURRENT, and cache hits (served before
|
||||||
@@ -2855,74 +2621,6 @@ async function handleChatCompletions(req, res) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Structured output (OpenAI response_format / json_mode): its own path — the response must be
|
|
||||||
// schema-valid JSON, so it never shares the conversational cache slot. When caching is enabled it
|
|
||||||
// uses a structured-keyed hash (isolated via cacheHash's `structured` marker) and writes back ONLY
|
|
||||||
// a validated result (never a 422). Always validates on a miss.
|
|
||||||
const structured = detectStructuredOutput(parsed);
|
|
||||||
if (structured) {
|
|
||||||
const t0s = Date.now();
|
|
||||||
const promptCharsS = messages.reduce((a, m) => a + contentToText(m.content).length, 0);
|
|
||||||
let structuredHash = null;
|
|
||||||
if (CACHE_TTL > 0 && !conversationId && !hasCacheControl(messages)) {
|
|
||||||
structuredHash = cacheHash(model, messages, { keyId: req._authKeyId, temperature: parsed.temperature, max_tokens: parsed.max_tokens, top_p: parsed.top_p, structured });
|
|
||||||
try {
|
|
||||||
const cached = getCachedResponse(structuredHash, CACHE_TTL);
|
|
||||||
if (cached) {
|
|
||||||
logEvent("info", "cache_hit", { model, hash: structuredHash.slice(0, 12), hits: cached.hits, structured: true });
|
|
||||||
const id = `chatcmpl-${randomUUID()}`;
|
|
||||||
if (stream) streamStringAsSSE(res, id, model, cached.response);
|
|
||||||
else completionResponse(res, id, model, cached.response);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
} catch (e) { logEvent("error", "cache_check_failed", { error: e.message }); }
|
|
||||||
}
|
|
||||||
const upstreamCall = TUI_MODE ? callClaudeTui : callClaude;
|
|
||||||
// Stampede protection (PR #153 review, finding 5): a structured request can cost up to
|
|
||||||
// STRUCTURED_MAX_ATTEMPTS metered spawns, so N identical concurrent requests (Home Assistant
|
|
||||||
// firing several AI Tasks at once) must NOT each pay N× — they share one flight. We dedup every
|
|
||||||
// one-off structured request (not stateful sessions / client-side prompt caching), independent of
|
|
||||||
// whether OCP response caching is enabled; when caching IS on, the same key gates cache read/write.
|
|
||||||
const dedupKey = (!conversationId && !hasCacheControl(messages))
|
|
||||||
? cacheHash(model, messages, { keyId: req._authKeyId, temperature: parsed.temperature, max_tokens: parsed.max_tokens, top_p: parsed.top_p, structured })
|
|
||||||
: null;
|
|
||||||
const runStructured = async () => {
|
|
||||||
const c = await runStructuredCompletion(upstreamCall, model, messages, conversationId, req._authKeyName, res, structured);
|
|
||||||
if (structuredHash) { try { setCachedResponse(structuredHash, model, c); } catch (e) { logEvent("error", "cache_write_failed", { error: e.message }); } }
|
|
||||||
return c;
|
|
||||||
};
|
|
||||||
try {
|
|
||||||
const content = dedupKey
|
|
||||||
? await singleflight(dedupKey, async () => {
|
|
||||||
// A follower that raced in after the leader populated the cache re-reads it here.
|
|
||||||
if (structuredHash) { const rc = getCachedResponse(structuredHash, CACHE_TTL); if (rc) return rc.response; }
|
|
||||||
return runStructured();
|
|
||||||
}, (err) => err instanceof RequestDisconnectedError && !res.destroyed)
|
|
||||||
: await runStructured();
|
|
||||||
const id = `chatcmpl-${randomUUID()}`;
|
|
||||||
if (stream) streamStringAsSSE(res, id, model, content);
|
|
||||||
else completionResponse(res, id, model, content);
|
|
||||||
try { recordUsage({ keyId: req._authKeyId, keyName: req._authKeyName, model, promptChars: promptCharsS, responseChars: content.length, elapsedMs: Date.now() - t0s, success: true }); } catch (e) { logEvent("error", "usage_record_failed", { error: e.message }); }
|
|
||||||
return;
|
|
||||||
} catch (err) {
|
|
||||||
if (err instanceof RequestDisconnectedError) { try { res.end(); } catch {} return; }
|
|
||||||
try { recordUsage({ keyId: req._authKeyId, keyName: req._authKeyName, model, promptChars: promptCharsS, responseChars: 0, elapsedMs: Date.now() - t0s, success: false }); } catch {}
|
|
||||||
if (res.headersSent || res.writableEnded || res.destroyed) { try { res.end(); } catch {} return; }
|
|
||||||
if (err instanceof StructuredOutputError) {
|
|
||||||
// OpenAI's spec mechanism for "model would not produce the required output" is the assistant
|
|
||||||
// `refusal` field (200, content:null, finish_reason:"stop"), NOT an invented 422 error type —
|
|
||||||
// so SDK clients take their written refusal branch. (PR #153 review, finding 3.)
|
|
||||||
logEvent("warn", "structured_failed", { reason: err.reason });
|
|
||||||
const id = `chatcmpl-${randomUUID()}`;
|
|
||||||
const refusal = `Could not produce a response matching the requested response_format after ${STRUCTURED_MAX_ATTEMPTS} attempts (${sanitizeError(err.reason)}).`;
|
|
||||||
if (stream) streamRefusalAsSSE(res, id, model, refusal);
|
|
||||||
else refusalResponse(res, id, model, refusal);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
return respondUpstreamError(res, err);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Cache check (only when cache is enabled and no active conversation/session)
|
// Cache check (only when cache is enabled and no active conversation/session)
|
||||||
if (CACHE_TTL > 0 && !conversationId) {
|
if (CACHE_TTL > 0 && !conversationId) {
|
||||||
// D2: skip OCP cache entirely when messages carry cache_control annotations;
|
// D2: skip OCP cache entirely when messages carry cache_control annotations;
|
||||||
@@ -2931,9 +2629,8 @@ async function handleChatCompletions(req, res) {
|
|||||||
req._cacheHash = null;
|
req._cacheHash = null;
|
||||||
logEvent("info", "cache_skipped", { reason: "cache_control_present" });
|
logEvent("info", "cache_skipped", { reason: "cache_control_present" });
|
||||||
} else {
|
} else {
|
||||||
// D1: include keyId in hash to isolate per-key cache pools (v2 format).
|
// D1: include keyId in hash to isolate per-key cache pools (v2 format)
|
||||||
// configEpoch (#176): any boot-config change that shapes answers invalidates the cache.
|
const hash = cacheHash(model, messages, { keyId: req._authKeyId, temperature: parsed.temperature, max_tokens: parsed.max_tokens, top_p: parsed.top_p });
|
||||||
const hash = cacheHash(model, messages, { keyId: req._authKeyId, temperature: parsed.temperature, max_tokens: parsed.max_tokens, top_p: parsed.top_p, configEpoch: CONFIG_EPOCH });
|
|
||||||
req._cacheHash = hash; // store for later write-back
|
req._cacheHash = hash; // store for later write-back
|
||||||
try {
|
try {
|
||||||
const cached = getCachedResponse(hash, CACHE_TTL);
|
const cached = getCachedResponse(hash, CACHE_TTL);
|
||||||
|
|||||||
+28
-699
@@ -204,23 +204,6 @@ test("cacheHash includes temperature in hash", () => {
|
|||||||
assert.notEqual(h2, h3);
|
assert.notEqual(h2, h3);
|
||||||
});
|
});
|
||||||
|
|
||||||
// ── configEpoch (#176): a boot-config change must invalidate the persistent cache ──
|
|
||||||
// Mutation-proof: drop the `ce:` fold in keys.mjs and the first test goes green-to-red.
|
|
||||||
test("cacheHash: different configEpoch → different key (config change invalidates)", () => {
|
|
||||||
const h1 = cacheHash("sonnet", msgs1, { configEpoch: "aaaa000011112222" });
|
|
||||||
const h2 = cacheHash("sonnet", msgs1, { configEpoch: "bbbb000011112222" });
|
|
||||||
assert.notEqual(h1, h2);
|
|
||||||
});
|
|
||||||
|
|
||||||
test("cacheHash: same configEpoch is stable; absent epoch hashes byte-identically to pre-#176", () => {
|
|
||||||
const e1 = cacheHash("sonnet", msgs1, { configEpoch: "aaaa000011112222" });
|
|
||||||
const e2 = cacheHash("sonnet", msgs1, { configEpoch: "aaaa000011112222" });
|
|
||||||
assert.equal(e1, e2);
|
|
||||||
// absent-epoch calls (older callers, all pre-existing tests) must not change behavior
|
|
||||||
assert.equal(cacheHash("sonnet", msgs1, {}), cacheHash("sonnet", msgs1));
|
|
||||||
assert.notEqual(e1, cacheHash("sonnet", msgs1), "epoch-carrying key differs from legacy key");
|
|
||||||
});
|
|
||||||
|
|
||||||
test("cacheHash includes max_tokens in hash", () => {
|
test("cacheHash includes max_tokens in hash", () => {
|
||||||
const h1 = cacheHash("sonnet", msgs1, {});
|
const h1 = cacheHash("sonnet", msgs1, {});
|
||||||
const h2 = cacheHash("sonnet", msgs1, { max_tokens: 100 });
|
const h2 = cacheHash("sonnet", msgs1, { max_tokens: 100 });
|
||||||
@@ -844,49 +827,7 @@ test("doctor falls back to currentVersion when origin/main unreachable (no stale
|
|||||||
// contract lives in lib/prompt.mjs. Mutation-proof: make appendOperatorPrompt
|
// contract lives in lib/prompt.mjs. Mutation-proof: make appendOperatorPrompt
|
||||||
// return `base` unconditionally and the first test fails; make it stop trimming
|
// return `base` unconditionally and the first test fails; make it stop trimming
|
||||||
// and the whitespace test fails.
|
// and the whitespace test fails.
|
||||||
import { appendOperatorPrompt, derivePromptCharBudget, resolvePromptCharBudget } from "./lib/prompt.mjs";
|
import { appendOperatorPrompt } from "./lib/prompt.mjs";
|
||||||
|
|
||||||
console.log("\nPrompt-char budget (ADR 0009 — SPOT-derived):");
|
|
||||||
|
|
||||||
// Mutation-proof: drop the ×charsPerToken and the first test fails; drop the
|
|
||||||
// Math.max floor guard and the floor tests fail; use min() instead of max() over
|
|
||||||
// windows and the largest-window test fails.
|
|
||||||
test("derivePromptCharBudget: LARGEST contextWindow × 3 chars/token", () => {
|
|
||||||
const models = [{ contextWindow: 200000 }, { contextWindow: 100000 }];
|
|
||||||
assert.equal(derivePromptCharBudget(models), 600000);
|
|
||||||
});
|
|
||||||
|
|
||||||
test("derivePromptCharBudget: matches the live models.json SPOT (200k → 600k today)", () => {
|
|
||||||
const spot = JSON.parse(tuiReadFileSync(new URL("./models.json", import.meta.url), "utf8"));
|
|
||||||
assert.equal(derivePromptCharBudget(spot.models), 600000);
|
|
||||||
});
|
|
||||||
|
|
||||||
test("derivePromptCharBudget: floor wins over a tiny/absent window; empty input → floor", () => {
|
|
||||||
assert.equal(derivePromptCharBudget([{ contextWindow: 1000 }]), 150000, "3k chars would truncate everything — floor guards it");
|
|
||||||
assert.equal(derivePromptCharBudget([]), 150000);
|
|
||||||
assert.equal(derivePromptCharBudget(undefined), 150000);
|
|
||||||
assert.equal(derivePromptCharBudget([{ id: "x" }, { contextWindow: "junk" }, { contextWindow: -5 }]), 150000);
|
|
||||||
});
|
|
||||||
|
|
||||||
test("derivePromptCharBudget: charsPerToken and floor are tunable parameters", () => {
|
|
||||||
assert.equal(derivePromptCharBudget([{ contextWindow: 1000000 }], { charsPerToken: 3 }), 3000000);
|
|
||||||
assert.equal(derivePromptCharBudget([], { floor: 42 }), 42);
|
|
||||||
});
|
|
||||||
|
|
||||||
// PR #179 review regression: EMPTY env value must mean "use the default" (the old
|
|
||||||
// `parseInt(env || "150000")` contract). Mutation-proof: switch the resolver's
|
|
||||||
// truthiness check to `!= null` and the empty-string test fails (NaN ≠ 600000).
|
|
||||||
test("resolvePromptCharBudget: empty/unset env → SPOT-derived default, never NaN", () => {
|
|
||||||
const models = [{ contextWindow: 200000 }];
|
|
||||||
assert.equal(resolvePromptCharBudget("", models), 600000, "CLAUDE_MAX_PROMPT_CHARS= (empty) must fall back to derived");
|
|
||||||
assert.equal(resolvePromptCharBudget(undefined, models), 600000);
|
|
||||||
});
|
|
||||||
|
|
||||||
test("resolvePromptCharBudget: a set env value overrides the derivation absolutely", () => {
|
|
||||||
const models = [{ contextWindow: 200000 }];
|
|
||||||
assert.equal(resolvePromptCharBudget("300000", models), 300000);
|
|
||||||
assert.equal(resolvePromptCharBudget("150000", models), 150000, "explicit legacy value wins over the bigger derived default");
|
|
||||||
});
|
|
||||||
|
|
||||||
console.log("\nSystem-prompt operator append:");
|
console.log("\nSystem-prompt operator append:");
|
||||||
|
|
||||||
@@ -1355,7 +1296,7 @@ function parseStreamJsonLines(buffered) {
|
|||||||
return { events, remainder: remainder ?? "" };
|
return { events, remainder: remainder ?? "" };
|
||||||
}
|
}
|
||||||
|
|
||||||
function parseStreamJsonEvent(event, sawTextDelta) {
|
function parseStreamJsonEvent(event, isFirstDelta) {
|
||||||
const t = event?.type;
|
const t = event?.type;
|
||||||
|
|
||||||
// system/* — first-event init + other system meta (api_retry etc.)
|
// system/* — first-event init + other system meta (api_retry etc.)
|
||||||
@@ -1367,19 +1308,19 @@ function parseStreamJsonEvent(event, sawTextDelta) {
|
|||||||
if (t === "stream_event") {
|
if (t === "stream_event") {
|
||||||
const inner = event.event ?? event;
|
const inner = event.event ?? event;
|
||||||
if (inner?.type === "content_block_delta" && inner.delta?.type === "text_delta") {
|
if (inner?.type === "content_block_delta" && inner.delta?.type === "text_delta") {
|
||||||
return { text: inner.delta.text ?? "", fromDelta: true };
|
return { text: inner.delta.text ?? "" };
|
||||||
}
|
}
|
||||||
// Other stream_event sub-types (content_block_start, message_delta, etc.) — consumed
|
// Other stream_event sub-types (content_block_start, message_delta, etc.) — consumed
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
// assistant — aggregate message. Without --include-partial-messages each assistant message
|
// assistant — aggregate message (fallback when no prior content_block_delta seen)
|
||||||
// arrives as its own aggregate event; an agentic turn emits several (preamble + tool rounds +
|
// Empirically (claude CLI without --include-partial-messages, verified v2.1.104 through v2.1.158): fast/short
|
||||||
// final answer), so accumulate EVERY one. Only guard the delta+aggregate double-count case:
|
// responses may emit ONLY the aggregate assistant event, no content_block_delta events.
|
||||||
// if streaming deltas were already seen (sawTextDelta), the aggregate duplicates them.
|
// If isFirstDelta is true, extract text here; otherwise it's a duplicate, ignore.
|
||||||
// Reference: OLP commit 65f945c (assistant-aggregate fallback, fold-in).
|
// Reference: OLP commit 65f945c (assistant-aggregate fallback, fold-in).
|
||||||
if (t === "assistant") {
|
if (t === "assistant") {
|
||||||
if (!sawTextDelta) {
|
if (isFirstDelta) {
|
||||||
const blocks = event.message?.content;
|
const blocks = event.message?.content;
|
||||||
if (Array.isArray(blocks)) {
|
if (Array.isArray(blocks)) {
|
||||||
const text = blocks
|
const text = blocks
|
||||||
@@ -1428,25 +1369,25 @@ test("parseStreamJsonEvent: stream_event content_block_delta yields text", () =>
|
|||||||
type: "stream_event",
|
type: "stream_event",
|
||||||
event: { type: "content_block_delta", delta: { type: "text_delta", text: "Hello" } }
|
event: { type: "content_block_delta", delta: { type: "text_delta", text: "Hello" } }
|
||||||
};
|
};
|
||||||
const result = parseStreamJsonEvent(event, false);
|
const result = parseStreamJsonEvent(event, true);
|
||||||
assert.deepEqual(result, { text: "Hello", fromDelta: true });
|
assert.deepEqual(result, { text: "Hello" });
|
||||||
});
|
});
|
||||||
|
|
||||||
test("parseStreamJsonEvent: assistant-aggregate used when no delta seen (sawTextDelta=false)", () => {
|
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, false);
|
|
||||||
assert.deepEqual(result, { text: "Short answer." });
|
|
||||||
});
|
|
||||||
|
|
||||||
test("parseStreamJsonEvent: assistant-aggregate skipped when a delta was seen (sawTextDelta=true, no double-count)", () => {
|
|
||||||
const event = {
|
const event = {
|
||||||
type: "assistant",
|
type: "assistant",
|
||||||
message: { content: [{ type: "text", text: "Short answer." }] }
|
message: { content: [{ type: "text", text: "Short answer." }] }
|
||||||
};
|
};
|
||||||
const result = parseStreamJsonEvent(event, true);
|
const result = parseStreamJsonEvent(event, true);
|
||||||
|
assert.deepEqual(result, { text: "Short answer." });
|
||||||
|
});
|
||||||
|
|
||||||
|
test("parseStreamJsonEvent: assistant-aggregate skipped when isFirstDelta=false (no double-count)", () => {
|
||||||
|
const event = {
|
||||||
|
type: "assistant",
|
||||||
|
message: { content: [{ type: "text", text: "Short answer." }] }
|
||||||
|
};
|
||||||
|
const result = parseStreamJsonEvent(event, false);
|
||||||
assert.equal(result, null);
|
assert.equal(result, null);
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -1460,39 +1401,14 @@ test("parseStreamJsonEvent: stream_event + assistant → assembled without doubl
|
|||||||
type: "assistant",
|
type: "assistant",
|
||||||
message: { content: [{ type: "text", text: "Streaming text." }] }
|
message: { content: [{ type: "text", text: "Streaming text." }] }
|
||||||
};
|
};
|
||||||
// First event: no delta seen yet → yields text and marks fromDelta
|
// First event: isFirstDelta=true → yields text
|
||||||
const r1 = parseStreamJsonEvent(delta, false);
|
const r1 = parseStreamJsonEvent(delta, true);
|
||||||
assert.deepEqual(r1, { text: "Streaming text.", fromDelta: true });
|
assert.deepEqual(r1, { text: "Streaming text." });
|
||||||
// Second event (aggregate): a delta was seen (sawTextDelta=true) → duplicate, null
|
// Second event (aggregate): isFirstDelta is now false (content already emitted) → null
|
||||||
const r2 = parseStreamJsonEvent(agg, true);
|
const r2 = parseStreamJsonEvent(agg, false);
|
||||||
assert.equal(r2, null);
|
assert.equal(r2, null);
|
||||||
});
|
});
|
||||||
|
|
||||||
// REGRESSION (agentic turns): without --include-partial-messages a tool-using turn emits SEVERAL
|
|
||||||
// aggregate `assistant` events (preamble, then the final answer after tool use) and NO deltas.
|
|
||||||
// Every one must be captured — the old first-only guard dropped the final answer.
|
|
||||||
test("parseStreamJsonEvent: multi-message agentic turn captures preamble AND final answer", () => {
|
|
||||||
const preamble = {
|
|
||||||
type: "assistant",
|
|
||||||
message: { content: [
|
|
||||||
{ type: "text", text: "I'll find the homepage repo and remove the calendar." },
|
|
||||||
{ type: "tool_use", id: "t1", name: "Bash" },
|
|
||||||
] }
|
|
||||||
};
|
|
||||||
const toolResult = { type: "user", message: { content: [{ type: "tool_result", content: "ok" }] } };
|
|
||||||
const finalMsg = {
|
|
||||||
type: "assistant",
|
|
||||||
message: { content: [{ type: "text", text: "Done — removed the calendar widget and pushed." }] }
|
|
||||||
};
|
|
||||||
// No deltas are ever emitted in aggregate mode, so sawTextDelta stays false throughout.
|
|
||||||
const r1 = parseStreamJsonEvent(preamble, false);
|
|
||||||
assert.deepEqual(r1, { text: "I'll find the homepage repo and remove the calendar." });
|
|
||||||
const r2 = parseStreamJsonEvent(toolResult, false); // user/tool_result echo — consumed
|
|
||||||
assert.equal(r2, null);
|
|
||||||
const r3 = parseStreamJsonEvent(finalMsg, false); // <- old code returned null here (bug)
|
|
||||||
assert.deepEqual(r3, { text: "Done — removed the calendar widget and pushed." });
|
|
||||||
});
|
|
||||||
|
|
||||||
// (b) aggregate-only short response → assembles correctly
|
// (b) aggregate-only short response → assembles correctly
|
||||||
test("parseStreamJsonEvent: aggregate-only multi-block response assembles all text blocks", () => {
|
test("parseStreamJsonEvent: aggregate-only multi-block response assembles all text blocks", () => {
|
||||||
const event = {
|
const event = {
|
||||||
@@ -1505,7 +1421,7 @@ test("parseStreamJsonEvent: aggregate-only multi-block response assembles all te
|
|||||||
]
|
]
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
const result = parseStreamJsonEvent(event, false);
|
const result = parseStreamJsonEvent(event, true);
|
||||||
assert.deepEqual(result, { text: "Part one. Part two." });
|
assert.deepEqual(result, { text: "Part one. Part two." });
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -1523,8 +1439,8 @@ test("parseStreamJsonLines: partial line carried as remainder", () => {
|
|||||||
assert.equal(ev2[0].type, "stream_event");
|
assert.equal(ev2[0].type, "stream_event");
|
||||||
assert.equal(rem2, "");
|
assert.equal(rem2, "");
|
||||||
// Verify the reassembled event parses through parseStreamJsonEvent correctly
|
// Verify the reassembled event parses through parseStreamJsonEvent correctly
|
||||||
const parsed = parseStreamJsonEvent(ev2[0], false);
|
const parsed = parseStreamJsonEvent(ev2[0], true);
|
||||||
assert.deepEqual(parsed, { text: "Hi", fromDelta: true });
|
assert.deepEqual(parsed, { text: "Hi" });
|
||||||
});
|
});
|
||||||
|
|
||||||
test("parseStreamJsonLines: empty input returns no events and empty remainder", () => {
|
test("parseStreamJsonLines: empty input returns no events and empty remainder", () => {
|
||||||
@@ -3438,337 +3354,6 @@ test("contentToText: null returns empty string", () => {
|
|||||||
assert.equal(contentToText(null), "");
|
assert.equal(contentToText(null), "");
|
||||||
});
|
});
|
||||||
|
|
||||||
// ── multimodal image transform (issue #110) ──────────────────────────────────
|
|
||||||
// OpenAI image_url parts → Anthropic image blocks for `claude -p --input-format
|
|
||||||
// stream-json`. lib/multimodal.mjs is a PURE module (no server.listen()), so it is
|
|
||||||
// imported directly here. Class B.1: shape per OpenAI vision spec, authorized by
|
|
||||||
// ADR 0006. Mechanism verified live: a base64 PNG fed as an Anthropic image block
|
|
||||||
// via --input-format stream-json is correctly described by the model.
|
|
||||||
import {
|
|
||||||
hasImageContent as mmHasImageContent,
|
|
||||||
buildImageBlocks as mmBuildImageBlocks,
|
|
||||||
buildStreamJsonInput as mmBuildStreamJsonInput,
|
|
||||||
MultimodalError as MmError,
|
|
||||||
SUPPORTED_IMAGE_TYPES as MM_SUPPORTED,
|
|
||||||
} from "./lib/multimodal.mjs";
|
|
||||||
import { parsePositiveInt } from "./lib/env.mjs";
|
|
||||||
|
|
||||||
console.log("\nmultimodal image transform (issue #110):");
|
|
||||||
|
|
||||||
// A short, valid base64 string (charset-valid; not decoded by the transform).
|
|
||||||
const MM_B64 = "iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAAAAAA6fptVAAAACklEQVR4nGP4DwABAQEAG7buVgAAAABJRU5ErkJggg==";
|
|
||||||
const dataUri = (mt = "image/png") => `data:${mt};base64,${MM_B64}`;
|
|
||||||
const imgPart = (mt) => ({ type: "image_url", image_url: { url: dataUri(mt) } });
|
|
||||||
const txtPart = (t) => ({ type: "text", text: t });
|
|
||||||
|
|
||||||
test("hasImageContent: plain string message → false (text path preserved)", () => {
|
|
||||||
assert.equal(mmHasImageContent([{ role: "user", content: "hello" }]), false);
|
|
||||||
});
|
|
||||||
|
|
||||||
test("hasImageContent: array of text-only parts → false", () => {
|
|
||||||
assert.equal(mmHasImageContent([{ role: "user", content: [txtPart("a"), txtPart("b")] }]), false);
|
|
||||||
});
|
|
||||||
|
|
||||||
test("hasImageContent: message with an image_url part → true", () => {
|
|
||||||
assert.equal(mmHasImageContent([{ role: "user", content: [txtPart("q"), imgPart()] }]), true);
|
|
||||||
});
|
|
||||||
|
|
||||||
test("hasImageContent: image anywhere in history (not just last) → true", () => {
|
|
||||||
const msgs = [
|
|
||||||
{ role: "user", content: [txtPart("look"), imgPart()] },
|
|
||||||
{ role: "assistant", content: "ok" },
|
|
||||||
{ role: "user", content: "and now?" },
|
|
||||||
];
|
|
||||||
assert.equal(mmHasImageContent(msgs), true);
|
|
||||||
});
|
|
||||||
|
|
||||||
// ── PR #154 review round 2, gap (b): image ONLY in a system message must not silently drop ──
|
|
||||||
// The handler detects multimodal on the FULL list but extraction/spawn filter system messages out.
|
|
||||||
// The guard fires exactly when the full list has an image but the non-system list does not — proven
|
|
||||||
// here against the same predicate the guard uses, so a system-only image is rejected (400) rather
|
|
||||||
// than falling to the text path and returning a 200 hallucinated answer.
|
|
||||||
test("hasImageContent: image ONLY in a system message → true on full list, false after system filter (guard fires)", () => {
|
|
||||||
const msgs = [
|
|
||||||
{ role: "system", content: [txtPart("context"), imgPart()] },
|
|
||||||
{ role: "user", content: "describe it" },
|
|
||||||
];
|
|
||||||
assert.equal(mmHasImageContent(msgs), true, "detected as multimodal on the full list");
|
|
||||||
assert.equal(mmHasImageContent(msgs.filter(m => m.role !== "system")), false, "no image survives the system filter → guard must 400");
|
|
||||||
});
|
|
||||||
test("hasImageContent: image in a USER message survives the system filter (legitimate request not rejected)", () => {
|
|
||||||
const msgs = [
|
|
||||||
{ role: "system", content: "you are helpful" },
|
|
||||||
{ role: "user", content: [txtPart("describe it"), imgPart()] },
|
|
||||||
];
|
|
||||||
assert.equal(mmHasImageContent(msgs.filter(m => m.role !== "system")), true, "user image survives → normal multimodal path");
|
|
||||||
});
|
|
||||||
|
|
||||||
test("buildImageBlocks: data-URI parsed into an Anthropic base64 image block", () => {
|
|
||||||
const { blocks, stats } = mmBuildImageBlocks([{ role: "user", content: [txtPart("what is this?"), imgPart("image/png")] }]);
|
|
||||||
assert.equal(blocks.length, 2);
|
|
||||||
assert.equal(blocks[0].type, "text");
|
|
||||||
assert.equal(blocks[0].text, "what is this?");
|
|
||||||
assert.deepEqual(blocks[1], { type: "image", source: { type: "base64", media_type: "image/png", data: MM_B64 } });
|
|
||||||
assert.equal(stats.imageCount, 1);
|
|
||||||
assert.ok(stats.totalImageBytes > 0);
|
|
||||||
});
|
|
||||||
|
|
||||||
test("buildImageBlocks: media_type carried through (jpeg/gif/webp)", () => {
|
|
||||||
for (const mt of ["image/jpeg", "image/gif", "image/webp"]) {
|
|
||||||
const { blocks } = mmBuildImageBlocks([{ role: "user", content: [imgPart(mt)] }]);
|
|
||||||
assert.equal(blocks.find(b => b.type === "image").source.media_type, mt);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
test("buildImageBlocks: multiple images in one message both emitted", () => {
|
|
||||||
const { blocks, stats } = mmBuildImageBlocks([{ role: "user", content: [txtPart("compare"), imgPart(), imgPart()] }]);
|
|
||||||
const imgs = blocks.filter(b => b.type === "image");
|
|
||||||
assert.equal(imgs.length, 2);
|
|
||||||
assert.equal(stats.imageCount, 2);
|
|
||||||
});
|
|
||||||
|
|
||||||
test("buildImageBlocks: text/image/text ordering preserved", () => {
|
|
||||||
const { blocks } = mmBuildImageBlocks([{ role: "user", content: [txtPart("A"), imgPart(), txtPart("B")] }]);
|
|
||||||
assert.deepEqual(blocks.map(b => (b.type === "text" ? b.text : "IMG")), ["A", "IMG", "B"]);
|
|
||||||
});
|
|
||||||
|
|
||||||
test("buildImageBlocks: image-first message keeps ordering (image before text)", () => {
|
|
||||||
const { blocks } = mmBuildImageBlocks([{ role: "user", content: [imgPart(), txtPart("caption")] }]);
|
|
||||||
assert.deepEqual(blocks.map(b => (b.type === "text" ? b.text : "IMG")), ["IMG", "caption"]);
|
|
||||||
});
|
|
||||||
|
|
||||||
test("buildImageBlocks: multi-turn history — role prefixes + separators preserved", () => {
|
|
||||||
const msgs = [
|
|
||||||
{ role: "user", content: "first q" },
|
|
||||||
{ role: "assistant", content: "prior answer" },
|
|
||||||
{ role: "user", content: [txtPart("now this"), imgPart()] },
|
|
||||||
];
|
|
||||||
const { blocks } = mmBuildImageBlocks(msgs);
|
|
||||||
assert.equal(blocks[0].text, "first q");
|
|
||||||
assert.equal(blocks[1].text, "\n\n[Assistant] prior answer");
|
|
||||||
assert.equal(blocks[2].text, "\n\nnow this");
|
|
||||||
assert.equal(blocks[3].type, "image");
|
|
||||||
});
|
|
||||||
|
|
||||||
test("buildImageBlocks: image in an EARLIER turn is carried (history image)", () => {
|
|
||||||
const msgs = [
|
|
||||||
{ role: "user", content: [txtPart("here"), imgPart()] },
|
|
||||||
{ role: "assistant", content: "got it" },
|
|
||||||
{ role: "user", content: "thanks" },
|
|
||||||
];
|
|
||||||
const { blocks, stats } = mmBuildImageBlocks(msgs);
|
|
||||||
assert.equal(stats.imageCount, 1);
|
|
||||||
assert.equal(blocks.filter(b => b.type === "image").length, 1);
|
|
||||||
});
|
|
||||||
|
|
||||||
test("buildImageBlocks: image_url as bare string is accepted (client leniency)", () => {
|
|
||||||
const { blocks } = mmBuildImageBlocks([{ role: "user", content: [{ type: "image_url", image_url: dataUri() }] }]);
|
|
||||||
assert.equal(blocks.find(b => b.type === "image").source.data, MM_B64);
|
|
||||||
});
|
|
||||||
|
|
||||||
test("buildStreamJsonInput: emits one newline-terminated user envelope", () => {
|
|
||||||
const { payload } = mmBuildStreamJsonInput([{ role: "user", content: [txtPart("hi"), imgPart()] }]);
|
|
||||||
assert.ok(payload.endsWith("\n"));
|
|
||||||
const env = JSON.parse(payload.trim());
|
|
||||||
assert.equal(env.type, "user");
|
|
||||||
assert.equal(env.message.role, "user");
|
|
||||||
assert.equal(env.message.content[1].type, "image");
|
|
||||||
});
|
|
||||||
|
|
||||||
// ── malformed / policy / oversized handling (clean 4xx, never a silent drop) ──
|
|
||||||
test("buildImageBlocks: unsupported media type → 400 unsupported_image_type", () => {
|
|
||||||
assert.throws(
|
|
||||||
() => mmBuildImageBlocks([{ role: "user", content: [imgPart("image/tiff")] }]),
|
|
||||||
(e) => e instanceof MmError && e.code === "unsupported_image_type" && e.status === 400
|
|
||||||
);
|
|
||||||
});
|
|
||||||
|
|
||||||
test("buildImageBlocks: non-base64 data URI → 400 invalid_data_uri", () => {
|
|
||||||
assert.throws(
|
|
||||||
() => mmBuildImageBlocks([{ role: "user", content: [{ type: "image_url", image_url: { url: "data:image/png,notbase64" } }] }]),
|
|
||||||
(e) => e instanceof MmError && e.code === "invalid_data_uri" && e.status === 400
|
|
||||||
);
|
|
||||||
});
|
|
||||||
|
|
||||||
test("buildImageBlocks: malformed data URI (no comma) → 400 invalid_data_uri", () => {
|
|
||||||
assert.throws(
|
|
||||||
() => mmBuildImageBlocks([{ role: "user", content: [{ type: "image_url", image_url: { url: "data:image/png;base64" } }] }]),
|
|
||||||
(e) => e instanceof MmError && e.code === "invalid_data_uri"
|
|
||||||
);
|
|
||||||
});
|
|
||||||
|
|
||||||
test("buildImageBlocks: image_url part missing a URL → 400 invalid_image_url", () => {
|
|
||||||
assert.throws(
|
|
||||||
() => mmBuildImageBlocks([{ role: "user", content: [{ type: "image_url", image_url: {} }] }]),
|
|
||||||
(e) => e instanceof MmError && e.code === "invalid_image_url"
|
|
||||||
);
|
|
||||||
});
|
|
||||||
|
|
||||||
test("buildImageBlocks: oversized single image → 413 image_too_large", () => {
|
|
||||||
assert.throws(
|
|
||||||
() => mmBuildImageBlocks([{ role: "user", content: [imgPart()] }], { maxImageBytes: 4 }),
|
|
||||||
(e) => e instanceof MmError && e.code === "image_too_large" && e.status === 413
|
|
||||||
);
|
|
||||||
});
|
|
||||||
|
|
||||||
test("buildImageBlocks: too many images → 413 too_many_images", () => {
|
|
||||||
const many = Array.from({ length: 3 }, () => imgPart());
|
|
||||||
assert.throws(
|
|
||||||
() => mmBuildImageBlocks([{ role: "user", content: many }], { maxImages: 2 }),
|
|
||||||
(e) => e instanceof MmError && e.code === "too_many_images" && e.status === 413
|
|
||||||
);
|
|
||||||
});
|
|
||||||
|
|
||||||
test("buildImageBlocks: aggregate image bytes over cap → 413 images_too_large", () => {
|
|
||||||
assert.throws(
|
|
||||||
() => mmBuildImageBlocks([{ role: "user", content: [imgPart(), imgPart()] }], { maxTotalImageBytes: 100, maxImageBytes: 1000 }),
|
|
||||||
(e) => e instanceof MmError && e.code === "images_too_large" && e.status === 413
|
|
||||||
);
|
|
||||||
});
|
|
||||||
|
|
||||||
test("buildImageBlocks: remote http(s) URL disabled by default → 400 remote_url_disabled", () => {
|
|
||||||
assert.throws(
|
|
||||||
() => mmBuildImageBlocks([{ role: "user", content: [{ type: "image_url", image_url: { url: "https://example.com/a.png" } }] }]),
|
|
||||||
(e) => e instanceof MmError && e.code === "remote_url_disabled" && e.status === 400
|
|
||||||
);
|
|
||||||
});
|
|
||||||
|
|
||||||
test("buildImageBlocks: remote URL passthrough when allowRemoteUrl=true (url source, OCP does not fetch)", () => {
|
|
||||||
const { blocks } = mmBuildImageBlocks(
|
|
||||||
[{ role: "user", content: [{ type: "image_url", image_url: { url: "https://example.com/a.png" } }] }],
|
|
||||||
{ allowRemoteUrl: true }
|
|
||||||
);
|
|
||||||
assert.deepEqual(blocks.find(b => b.type === "image").source, { type: "url", url: "https://example.com/a.png" });
|
|
||||||
});
|
|
||||||
|
|
||||||
test("buildImageBlocks: unsupported URL scheme → 400 unsupported_url_scheme", () => {
|
|
||||||
assert.throws(
|
|
||||||
() => mmBuildImageBlocks([{ role: "user", content: [{ type: "image_url", image_url: { url: "ftp://x/y.png" } }] }], { allowRemoteUrl: true }),
|
|
||||||
(e) => e instanceof MmError && e.code === "unsupported_url_scheme"
|
|
||||||
);
|
|
||||||
});
|
|
||||||
|
|
||||||
test("buildImageBlocks: non-image parts (audio/file) fall back to placeholder text", () => {
|
|
||||||
const { blocks } = mmBuildImageBlocks([{ role: "user", content: [txtPart("hear this"), { type: "input_audio", input_audio: {} }] }]);
|
|
||||||
assert.deepEqual(blocks.map(b => b.text), ["hear this", "[non-text content omitted]"]);
|
|
||||||
});
|
|
||||||
|
|
||||||
test("SUPPORTED_IMAGE_TYPES: exactly the four Anthropic vision types", () => {
|
|
||||||
assert.deepEqual([...MM_SUPPORTED].sort(), ["image/gif", "image/jpeg", "image/png", "image/webp"]);
|
|
||||||
});
|
|
||||||
|
|
||||||
test("buildImageBlocks: pure-text conversation still yields text blocks (untouched-path parity)", () => {
|
|
||||||
// hasImageContent would be false for this input in server.mjs (text path taken),
|
|
||||||
// but the transform must still be well-defined for a text-only turn.
|
|
||||||
const { blocks, stats } = mmBuildImageBlocks([{ role: "user", content: "just text" }]);
|
|
||||||
assert.deepEqual(blocks, [{ type: "text", text: "just text" }]);
|
|
||||||
assert.equal(stats.imageCount, 0);
|
|
||||||
assert.equal(stats.truncated, false);
|
|
||||||
});
|
|
||||||
|
|
||||||
// ── F2 (PR #154 review): text char budget is enforced on the multimodal path ──
|
|
||||||
// Regression guard: without maxTextChars, attaching one tiny image let unbounded
|
|
||||||
// text bypass MAX_PROMPT_CHARS entirely (the text path truncates; the image path
|
|
||||||
// did not). server.mjs passes maxTextChars: MAX_PROMPT_CHARS into this transform.
|
|
||||||
console.log("\nmultimodal text-budget enforcement (PR #154 F2):");
|
|
||||||
|
|
||||||
test("buildImageBlocks: text under budget → not truncated, blocks unchanged", () => {
|
|
||||||
const { blocks, stats } = mmBuildImageBlocks(
|
|
||||||
[{ role: "user", content: [txtPart("short"), imgPart()] }],
|
|
||||||
{ maxTextChars: 1000 }
|
|
||||||
);
|
|
||||||
assert.equal(stats.truncated, false);
|
|
||||||
assert.equal(stats.textChars, "short".length);
|
|
||||||
assert.equal(blocks.filter(b => b.type === "image").length, 1);
|
|
||||||
});
|
|
||||||
|
|
||||||
test("buildImageBlocks: text over budget → truncated, keeps most-recent tail + note", () => {
|
|
||||||
const big = "A".repeat(300) + "TAIL_MARKER";
|
|
||||||
const { blocks, stats } = mmBuildImageBlocks(
|
|
||||||
[{ role: "user", content: [txtPart(big)] }],
|
|
||||||
{ maxTextChars: 50 }
|
|
||||||
);
|
|
||||||
assert.equal(stats.truncated, true);
|
|
||||||
assert.equal(stats.originalTextChars, big.length);
|
|
||||||
// The most recent characters (the tail) survive; the oldest 'A's are dropped.
|
|
||||||
const joined = blocks.filter(b => b.type === "text").map(b => b.text).join("");
|
|
||||||
assert.ok(joined.includes("TAIL_MARKER"), "tail text must be kept");
|
|
||||||
assert.ok(joined.includes("truncated to fit"), "a truncation note must be present");
|
|
||||||
assert.ok(stats.originalTextChars > stats.textChars, "post-truncation text is smaller");
|
|
||||||
});
|
|
||||||
|
|
||||||
test("buildImageBlocks: F2 exact scenario — 500k chars + one image → text bounded, image preserved", () => {
|
|
||||||
const { blocks, stats } = mmBuildImageBlocks(
|
|
||||||
[{ role: "user", content: [txtPart("Z".repeat(500000)), imgPart()] }],
|
|
||||||
{ maxTextChars: 150000 }
|
|
||||||
);
|
|
||||||
assert.equal(stats.truncated, true);
|
|
||||||
assert.ok(stats.textChars <= 150000 + 200, "text char count is bounded by the budget (+note)");
|
|
||||||
// The image bypasses the text budget and is NOT dropped by truncation.
|
|
||||||
assert.equal(blocks.filter(b => b.type === "image").length, 1);
|
|
||||||
});
|
|
||||||
|
|
||||||
test("buildImageBlocks: default (no maxTextChars) never truncates — pure module standalone", () => {
|
|
||||||
const { stats } = mmBuildImageBlocks([{ role: "user", content: [txtPart("x".repeat(10000))] }]);
|
|
||||||
assert.equal(stats.truncated, false);
|
|
||||||
assert.equal(stats.textChars, 10000);
|
|
||||||
});
|
|
||||||
|
|
||||||
// ── F3 (PR #154 review): fail-closed positive-int env parsing ────────────────
|
|
||||||
// A misconfigured numeric cap must NEVER silently disable a guard (`x > NaN` is
|
|
||||||
// always false) or brick the proxy with a nonsense value. parsePositiveInt keeps
|
|
||||||
// the default and reports ok:false so the caller can warn.
|
|
||||||
console.log("\nfail-closed env-cap parsing (PR #154 F3):");
|
|
||||||
|
|
||||||
test("parsePositiveInt: missing/empty → default, ok", () => {
|
|
||||||
assert.deepEqual(parsePositiveInt(undefined, 42), { value: 42, ok: true });
|
|
||||||
assert.deepEqual(parsePositiveInt("", 42), { value: 42, ok: true });
|
|
||||||
});
|
|
||||||
|
|
||||||
test("parsePositiveInt: valid positive integer → parsed value", () => {
|
|
||||||
assert.equal(parsePositiveInt("5000000", 42).value, 5000000);
|
|
||||||
assert.equal(parsePositiveInt("5000000", 42).ok, true);
|
|
||||||
});
|
|
||||||
|
|
||||||
test("parsePositiveInt: 'unlimited' → NaN rejected, default kept (would drop the cap)", () => {
|
|
||||||
const r = parsePositiveInt("unlimited", 5 * 1024 * 1024);
|
|
||||||
assert.equal(r.value, 5 * 1024 * 1024);
|
|
||||||
assert.equal(r.ok, false);
|
|
||||||
});
|
|
||||||
|
|
||||||
test("parsePositiveInt: '5MB' → unit suffix rejected (naive parseInt would give 5 bytes)", () => {
|
|
||||||
const r = parsePositiveInt("5MB", 5 * 1024 * 1024);
|
|
||||||
assert.equal(r.value, 5 * 1024 * 1024);
|
|
||||||
assert.equal(r.ok, false);
|
|
||||||
});
|
|
||||||
|
|
||||||
test("parsePositiveInt: '0' and '-1' → non-positive rejected", () => {
|
|
||||||
assert.equal(parsePositiveInt("0", 20).ok, false);
|
|
||||||
assert.equal(parsePositiveInt("0", 20).value, 20);
|
|
||||||
assert.equal(parsePositiveInt("-1", 20).ok, false);
|
|
||||||
});
|
|
||||||
|
|
||||||
test("parsePositiveInt: '20.5' → fractional/ambiguous rejected", () => {
|
|
||||||
assert.equal(parsePositiveInt("20.5", 20).ok, false);
|
|
||||||
});
|
|
||||||
|
|
||||||
test("parsePositiveInt: surrounding whitespace tolerated", () => {
|
|
||||||
assert.deepEqual(parsePositiveInt(" 20 ", 5), { value: 20, ok: true });
|
|
||||||
});
|
|
||||||
|
|
||||||
// ── PR #154 review round 2, gap (a): MAX_PROMPT_CHARS must fail closed like the other caps ──
|
|
||||||
// server.mjs now derives MAX_PROMPT_CHARS via parseIntEnv → parsePositiveInt (was a raw parseInt).
|
|
||||||
// CLAUDE_MAX_PROMPT_CHARS=unlimited previously → NaN → enforceTextBudget's `!(NaN > 0)` early-return
|
|
||||||
// → 500k chars passed unbounded, defeating F2's text-budget guarantee. The default must be kept.
|
|
||||||
test("parsePositiveInt: CLAUDE_MAX_PROMPT_CHARS='unlimited' → default kept, cap not lost to NaN (gap a)", () => {
|
|
||||||
const r = parsePositiveInt("unlimited", 150000);
|
|
||||||
assert.equal(r.ok, false);
|
|
||||||
assert.equal(r.value, 150000, "the 150k text budget must survive a bad config, not become NaN");
|
|
||||||
});
|
|
||||||
test("parsePositiveInt: CLAUDE_MAX_PROMPT_CHARS valid override honored", () => {
|
|
||||||
assert.deepEqual(parsePositiveInt("200000", 150000), { value: 200000, ok: true });
|
|
||||||
});
|
|
||||||
|
|
||||||
// ── messages guard predicate truth-table (issue #110) ────────────────────────
|
// ── messages guard predicate truth-table (issue #110) ────────────────────────
|
||||||
// Mirrors the guard at server.mjs line ~1650: Array.isArray(x) && x.length > 0
|
// Mirrors the guard at server.mjs line ~1650: Array.isArray(x) && x.length > 0
|
||||||
console.log("\nmessages guard predicate (issue #110):");
|
console.log("\nmessages guard predicate (issue #110):");
|
||||||
@@ -4355,262 +3940,6 @@ test("stream: /health block is additive and exposes the divergence counter", ()
|
|||||||
assert.equal(legacy.streamDivergences, 0);
|
assert.equal(legacy.streamDivergences, 0);
|
||||||
});
|
});
|
||||||
|
|
||||||
// ── OpenAI Structured Outputs (response_format) — lib/structured-output.mjs ──
|
|
||||||
import { detectStructuredOutput, validateJsonSchema, validateJsonSchemaSafe, extractJsonPayload, structuredSystemInstruction, StructuredOutputError, resolveMaxAttempts } from "./lib/structured-output.mjs";
|
|
||||||
|
|
||||||
test("detectStructuredOutput: json_schema shape", () => {
|
|
||||||
const d = detectStructuredOutput({ response_format: { type: "json_schema", json_schema: { name: "x", strict: true, schema: { type: "object" } } } });
|
|
||||||
assert.equal(d.mode, "schema"); assert.equal(d.strict, true); assert.deepEqual(d.schema, { type: "object" });
|
|
||||||
});
|
|
||||||
test("detectStructuredOutput: json_object shape", () => {
|
|
||||||
assert.deepEqual(detectStructuredOutput({ response_format: { type: "json_object" } }), { mode: "json_object" });
|
|
||||||
});
|
|
||||||
test("detectStructuredOutput: json_mode:true alias → json_object", () => {
|
|
||||||
assert.deepEqual(detectStructuredOutput({ json_mode: true }), { mode: "json_object" });
|
|
||||||
});
|
|
||||||
test("detectStructuredOutput: absent → null (non-structured untouched)", () => {
|
|
||||||
assert.equal(detectStructuredOutput({ messages: [] }), null);
|
|
||||||
assert.equal(detectStructuredOutput({ response_format: "nonsense" }), null);
|
|
||||||
assert.equal(detectStructuredOutput({ json_mode: false }), null);
|
|
||||||
});
|
|
||||||
test("cacheHash: structured marker isolates JSON requests from the conversational slot", () => {
|
|
||||||
const msgs = [{ role: "user", content: "list 3 fruits" }];
|
|
||||||
const plain = cacheHash("m", msgs, { keyId: "k" });
|
|
||||||
const asJson = cacheHash("m", msgs, { keyId: "k", structured: { mode: "json_object" } });
|
|
||||||
const asSchema = cacheHash("m", msgs, { keyId: "k", structured: { mode: "schema", schema: { type: "array" } } });
|
|
||||||
assert.notEqual(plain, asJson); // JSON vs prose never collide
|
|
||||||
assert.notEqual(asJson, asSchema); // different schema → different slot
|
|
||||||
assert.equal(plain, cacheHash("m", msgs, { keyId: "k" })); // unchanged for normal requests
|
|
||||||
});
|
|
||||||
|
|
||||||
// ── validateJsonSchemaSafe (#181): deep value must NOT crash the handler ─────
|
|
||||||
// A recursive schema + a model reply nested ~thousands deep overflows the value-
|
|
||||||
// depth recursion → RangeError → the handler used to surface a generic 500. The
|
|
||||||
// safe façade turns it into a validation miss (→ retry → refusal). Mutation-proof:
|
|
||||||
// replace the wrapper body with a bare `validateJsonSchema(...)` call and the deep
|
|
||||||
// test throws instead of returning errors.
|
|
||||||
test("validateJsonSchemaSafe: pathologically deep value → errors, never throws", () => {
|
|
||||||
const schema = { $defs: { node: { type: "object", properties: { child: { $ref: "#/$defs/node" } } } }, $ref: "#/$defs/node" };
|
|
||||||
let deep = {};
|
|
||||||
let cur = deep;
|
|
||||||
for (let i = 0; i < 6000; i++) { cur.child = {}; cur = cur.child; } // way past any stack limit
|
|
||||||
let out;
|
|
||||||
assert.doesNotThrow(() => { out = validateJsonSchemaSafe(deep, schema, "$", true); }, "must not throw a RangeError out to the handler");
|
|
||||||
assert.ok(Array.isArray(out) && out.length > 0, "returns a non-empty validation error, so the retry loop yields a refusal not a 500");
|
|
||||||
});
|
|
||||||
|
|
||||||
test("validateJsonSchemaSafe: well-formed value passes through unchanged (byte-identical to the raw validator)", () => {
|
|
||||||
const schema = { type: "object", required: ["name", "age"], properties: { name: { type: "string" }, age: { type: "integer" } } };
|
|
||||||
assert.deepEqual(validateJsonSchemaSafe({ name: "a", age: 3 }, schema), validateJsonSchema({ name: "a", age: 3 }, schema));
|
|
||||||
assert.deepEqual(validateJsonSchemaSafe({ name: "a" }, schema), validateJsonSchema({ name: "a" }, schema)); // error case matches too
|
|
||||||
});
|
|
||||||
|
|
||||||
test("validateJsonSchemaSafe: re-throws a non-RangeError so genuine bugs aren't masked as a validation miss", () => {
|
|
||||||
// A schema whose `required` is a non-iterable makes the inner validator throw a TypeError — that's
|
|
||||||
// a real bug, not a deep-value overflow, and must surface (not be swallowed as "did not validate").
|
|
||||||
assert.throws(() => validateJsonSchemaSafe({ x: 1 }, { type: "object", required: 42 }), (e) => !(e instanceof RangeError));
|
|
||||||
});
|
|
||||||
|
|
||||||
test("validateJsonSchema: valid object passes", () => {
|
|
||||||
assert.deepEqual(validateJsonSchema({ name: "a", age: 3 }, { type: "object", required: ["name", "age"], properties: { name: { type: "string" }, age: { type: "integer" } } }), []);
|
|
||||||
});
|
|
||||||
test("validateJsonSchema: missing required property flagged", () => {
|
|
||||||
assert.ok(validateJsonSchema({ name: "a" }, { type: "object", required: ["name", "age"], properties: {} }).some(e => /age.*required/.test(e)));
|
|
||||||
});
|
|
||||||
test("validateJsonSchema: additionalProperties:false rejects extra keys", () => {
|
|
||||||
assert.ok(validateJsonSchema({ a: 1, b: 2 }, { type: "object", additionalProperties: false, properties: { a: { type: "integer" } } }).some(e => /b.*additional/.test(e)));
|
|
||||||
});
|
|
||||||
test("validateJsonSchema: enum rejects non-null value not in list", () => {
|
|
||||||
assert.ok(validateJsonSchema("maybe", { type: "string", enum: ["yes", "no"] }).length > 0);
|
|
||||||
});
|
|
||||||
test("validateJsonSchema: NULLABLE enum accepts null even when null not in enum (HA regression)", () => {
|
|
||||||
// type:["string","null"] + enum:["Loxone"] — a null value must be accepted (nullability > enum).
|
|
||||||
assert.deepEqual(validateJsonSchema(null, { type: ["string", "null"], enum: ["Loxone"] }), []);
|
|
||||||
});
|
|
||||||
test("validateJsonSchema: nullable enum still enforces non-null values against the enum", () => {
|
|
||||||
assert.ok(validateJsonSchema("Other", { type: ["string", "null"], enum: ["Loxone"] }).length > 0);
|
|
||||||
});
|
|
||||||
test("validateJsonSchema: type mismatch flagged", () => {
|
|
||||||
assert.ok(validateJsonSchema("str", { type: "integer" }).length > 0);
|
|
||||||
});
|
|
||||||
test("validateJsonSchema: array items + minItems", () => {
|
|
||||||
assert.deepEqual(validateJsonSchema([1, 2, 3], { type: "array", items: { type: "integer" }, minItems: 3 }), []);
|
|
||||||
assert.ok(validateJsonSchema([1], { type: "array", items: { type: "integer" }, minItems: 3 }).some(e => /minItems/.test(e)));
|
|
||||||
});
|
|
||||||
|
|
||||||
test("extractJsonPayload: clean JSON", () => {
|
|
||||||
const r = extractJsonPayload('{"a":1}'); assert.ok(r.ok); assert.deepEqual(r.value, { a: 1 });
|
|
||||||
});
|
|
||||||
test("extractJsonPayload: fenced ```json block", () => {
|
|
||||||
const r = extractJsonPayload('```json\n{"a":1}\n```'); assert.ok(r.ok); assert.deepEqual(r.value, { a: 1 });
|
|
||||||
});
|
|
||||||
test("extractJsonPayload: prose-wrapped, string-aware balanced slice", () => {
|
|
||||||
const r = extractJsonPayload('Sure! Here you go: {"note":"has } and { inside"} — hope that helps.');
|
|
||||||
assert.ok(r.ok); assert.deepEqual(r.value, { note: "has } and { inside" });
|
|
||||||
});
|
|
||||||
test("extractJsonPayload: array payload", () => {
|
|
||||||
const r = extractJsonPayload('[1,2,3]'); assert.ok(r.ok); assert.deepEqual(r.value, [1, 2, 3]);
|
|
||||||
});
|
|
||||||
test("extractJsonPayload: no JSON → ok:false", () => {
|
|
||||||
assert.equal(extractJsonPayload("I cannot help with that.").ok, false);
|
|
||||||
});
|
|
||||||
|
|
||||||
test("structuredSystemInstruction: embeds schema, forbids fences, escalates on retry", () => {
|
|
||||||
const first = structuredSystemInstruction({ mode: "schema", schema: { type: "object" } }, 0, "");
|
|
||||||
assert.ok(/code fences/.test(first) && /JSON Schema/.test(first));
|
|
||||||
const retry = structuredSystemInstruction({ mode: "schema", schema: { type: "object" } }, 1, "bad enum");
|
|
||||||
assert.ok(/REJECTED \(bad enum\)/.test(retry));
|
|
||||||
});
|
|
||||||
test("StructuredOutputError carries reason", () => {
|
|
||||||
const e = new StructuredOutputError("schema validation failed", "raw");
|
|
||||||
assert.equal(e.reason, "schema validation failed"); assert.ok(e instanceof Error);
|
|
||||||
});
|
|
||||||
|
|
||||||
// ── PR #153 review round 2, MUST-FIX: OCP_STRUCTURED_MAX_ATTEMPTS NaN guard must fail closed ──
|
|
||||||
// The old `Math.max(1, parseInt(env||"3",10))` returned NaN for a non-integer value → the retry loop
|
|
||||||
// `attempt < NaN` never ran → 0 spawns, every structured request refused. resolveMaxAttempts keeps
|
|
||||||
// the default instead of silently bricking the feature.
|
|
||||||
test("resolveMaxAttempts: valid integer honored", () => {
|
|
||||||
assert.equal(resolveMaxAttempts("5"), 5);
|
|
||||||
assert.equal(resolveMaxAttempts("1"), 1);
|
|
||||||
});
|
|
||||||
test("resolveMaxAttempts: unset/empty → default", () => {
|
|
||||||
assert.equal(resolveMaxAttempts(undefined), 3);
|
|
||||||
assert.equal(resolveMaxAttempts(""), 3);
|
|
||||||
assert.equal(resolveMaxAttempts(null), 3);
|
|
||||||
});
|
|
||||||
test("resolveMaxAttempts: non-integer / non-finite / <1 fails CLOSED to the default (not NaN, not 0)", () => {
|
|
||||||
let warned = 0; const warn = () => { warned++; };
|
|
||||||
for (const bad of ["abc", "0", "-1", "NaN", "Infinity", " "]) {
|
|
||||||
const v = resolveMaxAttempts(bad, { fallback: 3, warn });
|
|
||||||
assert.equal(v, 3, `bad input ${JSON.stringify(bad)} must fall back to 3, got ${v}`);
|
|
||||||
assert.ok(Number.isFinite(v) && v >= 1, "result is always a usable positive integer");
|
|
||||||
}
|
|
||||||
assert.ok(warned > 0, "invalid values emit a startup warning");
|
|
||||||
});
|
|
||||||
test("resolveMaxAttempts: the retry loop is never bounded by NaN (regression: 0 spawns / silent refuse)", () => {
|
|
||||||
const attempts = resolveMaxAttempts("abc");
|
|
||||||
let ran = 0;
|
|
||||||
for (let attempt = 0; attempt < attempts; attempt++) ran++;
|
|
||||||
assert.ok(ran >= 1, "loop must execute at least once — pre-fix it ran 0 times");
|
|
||||||
});
|
|
||||||
|
|
||||||
// ── PR #153 review finding 1: $ref/$defs + strict:true must accept conforming objects ──
|
|
||||||
// The flagship shape the OpenAI SDK emits (zodResponseFormat / client.beta.chat.completions.parse)
|
|
||||||
// and OpenAI's own structured-outputs docs example: nested {$ref:"#/$defs/step"} + strict:true.
|
|
||||||
// Before the fix, strict inferred additionalProperties:false on the unresolved $ref (empty props) and
|
|
||||||
// rejected every real key. This is the exact regression the PR must not ship.
|
|
||||||
const OPENAI_DOC_SCHEMA = {
|
|
||||||
type: "object",
|
|
||||||
properties: {
|
|
||||||
steps: { type: "array", items: { $ref: "#/$defs/step" } },
|
|
||||||
final_answer: { type: "string" },
|
|
||||||
},
|
|
||||||
$defs: {
|
|
||||||
step: {
|
|
||||||
type: "object",
|
|
||||||
properties: { explanation: { type: "string" }, output: { type: "string" } },
|
|
||||||
required: ["explanation", "output"],
|
|
||||||
additionalProperties: false,
|
|
||||||
},
|
|
||||||
},
|
|
||||||
required: ["steps", "final_answer"],
|
|
||||||
additionalProperties: false,
|
|
||||||
};
|
|
||||||
|
|
||||||
test("validateJsonSchema: OpenAI doc schema ($ref/$defs) + strict:true accepts a conforming reply", () => {
|
|
||||||
const conforming = { steps: [{ explanation: "add", output: "4" }, { explanation: "done", output: "4" }], final_answer: "4" };
|
|
||||||
assert.deepEqual(validateJsonSchema(conforming, OPENAI_DOC_SCHEMA, "$", true), []);
|
|
||||||
});
|
|
||||||
|
|
||||||
test("validateJsonSchema: $ref + strict:true still REJECTS a genuinely-extra key (fix didn't disable validation)", () => {
|
|
||||||
const extra = { steps: [{ explanation: "add", output: "4", bogus: 1 }], final_answer: "4" };
|
|
||||||
const errs = validateJsonSchema(extra, OPENAI_DOC_SCHEMA, "$", true);
|
|
||||||
assert.ok(errs.some(e => /bogus.*additional property not allowed/.test(e)), `expected the extra key rejected, got: ${JSON.stringify(errs)}`);
|
|
||||||
});
|
|
||||||
|
|
||||||
test("validateJsonSchema: $ref + strict:true still catches a missing required property", () => {
|
|
||||||
const missing = { steps: [{ explanation: "add" }], final_answer: "4" };
|
|
||||||
assert.ok(validateJsonSchema(missing, OPENAI_DOC_SCHEMA, "$", true).some(e => /output.*required/.test(e)));
|
|
||||||
});
|
|
||||||
|
|
||||||
test("validateJsonSchema: anyOf accepts a value matching one branch, rejects a value matching none", () => {
|
|
||||||
const schema = { anyOf: [{ type: "string" }, { type: "integer" }] };
|
|
||||||
assert.deepEqual(validateJsonSchema("hi", schema), []);
|
|
||||||
assert.deepEqual(validateJsonSchema(3, schema), []);
|
|
||||||
assert.ok(validateJsonSchema(true, schema).length > 0);
|
|
||||||
});
|
|
||||||
|
|
||||||
test("validateJsonSchema: allOf requires every branch to pass", () => {
|
|
||||||
const schema = { allOf: [{ type: "object", properties: { a: { type: "integer" } }, required: ["a"] }, { type: "object", properties: { b: { type: "string" } }, required: ["b"] }] };
|
|
||||||
assert.deepEqual(validateJsonSchema({ a: 1, b: "x" }, schema), []);
|
|
||||||
assert.ok(validateJsonSchema({ a: 1 }, schema).some(e => /b.*required/.test(e)));
|
|
||||||
});
|
|
||||||
|
|
||||||
test("validateJsonSchema: unresolvable $ref is skipped, not failed", () => {
|
|
||||||
assert.deepEqual(validateJsonSchema({ anything: 1 }, { $ref: "#/$defs/missing" }), []);
|
|
||||||
});
|
|
||||||
|
|
||||||
// ── PR #153 review round 2, BLOCKER: cyclic $ref must fail closed, not stack-overflow ──
|
|
||||||
// A pure ref→ref cycle recurses independent of the data — before the fix ANY reply value (even `5`)
|
|
||||||
// threw `RangeError: Maximum call stack size exceeded`, caught upstream as a 500 but only after
|
|
||||||
// 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 ──
|
// ── Cleanup ──
|
||||||
// Settle the async-bodied tests registered through the sync `test()` helper BEFORE summarizing —
|
// Settle the async-bodied tests registered through the sync `test()` helper BEFORE summarizing —
|
||||||
// otherwise their pass/fail is not reflected in the counts (see the `pendingAsync` comment above).
|
// otherwise their pass/fail is not reflected in the counts (see the `pendingAsync` comment above).
|
||||||
|
|||||||
Reference in New Issue
Block a user