mirror of
https://github.com/dtzp555-max/ocp.git
synced 2026-07-22 05:25:08 +00:00
feat(server): honor OpenAI response_format for structured-output clients (#153)
* feat(server): honor OpenAI response_format for structured-output clients `/v1/chat/completions` advertises OpenAI compatibility but ignored `response_format`, so clients requiring machine-parseable JSON (Home Assistant AI Tasks, Honcho, OpenAI-SDK scripts) received free-form assistant prose — markdown tables, ```json fences, trailing commentary — that fails JSON.parse. This honors the OpenAI `response_format` contract on the `-p` path: - New `lib/structured-output.mjs` (pure, unit-tested): `detectStructuredOutput` (json_schema / json_object), `structuredSystemInstruction` (strict JSON-only steering, escalated on retry), `extractJsonPayload` (string-aware balanced slice that unwraps fences/prose), and a minimal JSON-Schema `validateJsonSchema` (types, required, enum, const, additionalProperties, nullability, items, min/maxItems). - `server.mjs`: `runStructuredCompletion` retries up to `OCP_STRUCTURED_MAX_ATTEMPTS` (default 3), returns the canonical JSON string as `message.content`, and yields HTTP 422 (`invalid_response_error`) if no valid JSON can be produced. Structured requests take their own path (bypass the cache, which does not key on response_format). Non-structured requests are byte-for-byte unchanged, streaming included. - Nullability precedence: a `null` value is accepted whenever the schema permits null (`type:["x","null"]` / `nullable:true`), even if a bare `enum` omits null — matches OpenAI behaviour and fixes real Home Assistant schemas (`type:["string","null"], enum:["Loxone"]`) that otherwise 422 on null. - README: Structured Outputs section + `OCP_STRUCTURED_MAX_ATTEMPTS` env row. - 18 new unit tests (281 passed, 0 failed). Endpoint class: B.1 (OpenAI-compatibility surface, `/v1/chat/completions`). Specification: OpenAI chat/completions `response_format` (https://platform.openai.com/docs/api-reference/chat/create#chat-create-response_format). Authorizing ADR: ADR 0006 — OpenAI shim scope. cli.js does NOT perform this operation (it speaks Anthropic's protocol, not OpenAI's); scope is justified under ADR 0006 Class B.1 (OpenAI spec as protocol authority). Revives closed PR #99. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * feat(server): structured-output caching + json_mode alias Follow-up on the response_format path, closing the two gaps vs closed PR #99: - **Validated caching (improves on #99).** Structured responses now use the OCP cache when CLAUDE_CACHE_TTL>0, on a structured-keyed hash: cacheHash gains an `structured` marker folding the detected response_format/schema into the key, so a JSON reply never collides with the conversational answer to the same prompt and different schemas never share a slot. Only a *validated* result is written back — a 422 is never cached. (#99 cached the fence-stripped but *unvalidated* output; this caches only schema-valid JSON.) The marker is absent for normal requests, so existing cache hashes are byte-identical. - **json_mode alias.** Honor the non-standard top-level `json_mode: true` flag as a json_object alias, matching #99's activation set. Disclosed as non-spec. - README: json_mode shape + caching note. +2 unit tests (283 passed, 0 failed). Endpoint class: B.1 (/v1/chat/completions), ADR 0006. json_mode is a non-OpenAI convenience alias (disclosed); everything else stays within OpenAI's response_format spec. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * fix(server): address PR #153 review — $ref/strict, extraction safety, refusal, singleflight Remediates the maintainer's merge-blocking findings on the structured-output PR, and rebases onto current main (the one-hunk test-features.mjs conflict — both sides appended tests — resolved by keeping both blocks). Class B.1 (OpenAI-compat): spec authority is OpenAI chat/completions `response_format` (https://platform.openai.com/docs/api-reference/chat/create#chat-create-response_format), authorized by ADR 0006. No cli.js analogue (claude -p has no native response_format); scope is the B.1 shim, not a Class A forward. Finding 1 (correctness gate) — strict:true + $ref/$defs rejected valid objects 100% of the time. `noExtra = addl === false || (strict && addl === undefined)` treated a nested {$ref:"#/$defs/step"} as an empty-properties object and, under strict, rejected every real key as "additional property not allowed" — exactly the shape the OpenAI SDK emits (zodResponseFormat / client.beta.chat.completions.parse) and OpenAI's own docs example. Fix: validateJsonSchema now resolves same-document $ref against the root $defs/definitions, handles allOf/anyOf/oneOf composition, and only infers additionalProperties:false from strict when the object actually declares its own non-empty properties and is not a composite. Explicit additionalProperties:false is always honoured, so validation is not weakened (tests prove an extra key and a missing required key still fail under strict). Finding 2 (correctness gate) — the extractor served JSON the model did not mean. json_object mode had no validation at all: a refusal like `I can't. The schema is {"type":"object"}` returned the embedded object as the answer. Now json_object requires the WHOLE reply to parse as a single JSON value, and schema mode rejects a reply carrying more than one top-level JSON value (Schema:{}/Answer:{}, Option A/Option B) rather than silently picking the first. The schema-validated value is still returned; nothing unvalidated is served. Finding 3 — replaced the invented `invalid_response_error` 422 with OpenAI's assistant `refusal` field (200, content:null, refusal:<reason>, finish_reason:"stop"), streaming and non-streaming, so SDK clients take their refusal branch instead of throwing an opaque UnprocessableEntityError. Finding 5 — runStructuredCompletion no longer bypasses stampede protection. Identical concurrent one-off structured requests now share one singleflight (independent of cache enablement), so N callers no longer cost N × up-to-3 spawns. Cache read/write still gated on CLAUDE_CACHE_TTL; refusals are never cached. Docs: README structured-output § updated (refusal field, $ref/composition support, whole-reply json_object rule, ambiguous-multi-value rejection) plus a Caching & cost paragraph stating the post-2026-06-15 model, the up-to-N-spawn worst case, the singleflight + validated-cache guards, and the OCP_STRUCTURED_MAX_ATTEMPTS=1 / per-key quota levers. Tests: +11 (all pure-module) — OpenAI's doc $ref/$defs schema under strict:true accepts a conforming reply and still rejects extra/missing keys; anyOf/allOf; unresolvable $ref skipped; json_object refusal-embedded-json rejected; >1-top-level-value rejected. 360 passed, 0 failed. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * fix(server): address PR #153 review round 2 — cyclic-$ref guard + NaN attempts guard Remediates the two remaining merge-blocking findings from the round-2 review. Class B.1 (OpenAI-compat): spec authority is OpenAI chat/completions `response_format` (https://platform.openai.com/docs/api-reference/chat/create#chat-create-response_format), authorized by ADR 0006. No cli.js analogue (claude -p has no native response_format, and the retry cap OCP_STRUCTURED_MAX_ATTEMPTS is OCP's own coercion loop, not a cli.js operation); scope stays the B.1 shim, not a Class A forward. BLOCKER — cyclic $ref stack-overflowed the validator. resolveRef + the $ref branch of validateJsonSchema had no cycle detection: a pure ref→ref cycle ({$defs:{a:{$ref:b},b:{$ref:a}},$ref:a}) recursed independent of the data and threw RangeError for ANY reply value (even `5`), caught upstream as a 500 but only after 1–3 metered spawns — a request-controlled cost-amplification / grief vector on an authed path. Fix: validateJsonSchema now threads a `refChain` of the $ref pointers resolved on the current path WITHOUT consuming data (a $ref hop, or an allOf/anyOf/oneOf branch — all re-validate the same value); a pointer reappearing on that chain fails closed with a `cyclic $ref detected` error. Data-consuming recursion (properties/items/ additionalProperties) deliberately resets the chain, because a JSON value is a finite tree so those always terminate — a legitimately recursive schema (Node→child:Node) must NOT be flagged. A REF_DEPTH_CAP backstops any threading mistake. MUST-FIX — OCP_STRUCTURED_MAX_ATTEMPTS NaN guard was broken. `Math.max(1, parseInt(env ||"3",10))` === `Math.max(1, NaN)` === NaN for a non-integer value, so the retry loop `attempt < NaN` never ran → 0 spawns, every structured request silently refused (fails closed on cost but bricks the feature and ignores the intended floor). Fix: extracted a pure fail-closed resolveMaxAttempts() into lib/structured-output.mjs — rejects NaN/non-finite/<1, keeps the documented default of 3, and warns at startup. server.mjs now derives STRUCTURED_MAX_ATTEMPTS through it. Tests: +8 (all pure-module) — a→b→a and self (a→a) cyclic $ref fail closed without overflowing the stack; a cycle routed through anyOf; a legitimate recursive Node schema is NOT flagged; resolveMaxAttempts honors valid integers, defaults on unset/empty/null, and fails closed (not NaN, not 0) on abc/0/-1/NaN/Infinity/blank with a startup warn. 368 passed, 0 failed. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> --------- Co-authored-by: vvlasy-openclaw <vvlasy@gmail.com> Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com> Co-authored-by: dtzp555 <dtzp555@gmail.com>
This commit is contained in:
@@ -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) · [Images / Multimodal](#images--multimodal-vision) · [OpenClaw Integration](#openclaw-integration)
|
- [Built-in Usage Monitoring](#built-in-usage-monitoring) · [Response Cache](#response-cache) · [Structured Outputs](#structured-outputs-openai-response_format) · [Images / Multimodal](#images--multimodal-vision) · [OpenClaw Integration](#openclaw-integration)
|
||||||
- [Troubleshooting](#troubleshooting) → [`docs/troubleshooting.md`](docs/troubleshooting.md)
|
- [Troubleshooting](#troubleshooting) → [`docs/troubleshooting.md`](docs/troubleshooting.md)
|
||||||
- [Repository Layout](#repository-layout) · [Security](#security) · [Governance](#governance) · [Support OCP](#support-ocp) · [License](#license)
|
- [Repository Layout](#repository-layout) · [Security](#security) · [Governance](#governance) · [Support OCP](#support-ocp) · [License](#license)
|
||||||
|
|
||||||
@@ -223,6 +223,7 @@ The canonical list lives in [`models.json`](./models.json) — the single source
|
|||||||
| `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` | *(derived)* | Prompt truncation limit in chars. Default derives from the models.json SPOT: `max(contextWindow) × 3` — currently **600,000** (≈150–200k tokens). Setting this env var (or the runtime settings API) overrides the derivation absolutely. See [ADR 0009](docs/adr/0009-spot-derived-prompt-budget.md). Note: very large prompts burn subscription-window quota quickly and slow TTFT; the TUI-mode paste path is untested beyond ~hundreds of KB. Applies to **text only** — image bytes bypass this budget (see [Images / Multimodal](#images--multimodal-vision)). |
|
||||||
|
| `OCP_STRUCTURED_MAX_ATTEMPTS` | `3` | Max attempts (initial + retries) to coerce a schema-valid JSON reply when a request uses OpenAI `response_format`. Fail-closed: a non-numeric value keeps the default. See [Structured Outputs](#structured-outputs-openai-response_format). |
|
||||||
| `CLAUDE_SESSION_TTL` | `3600000` | Session expiry (ms, default: 1 hour) |
|
| `CLAUDE_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 |
|
||||||
@@ -387,6 +388,29 @@ 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)
|
## Images / Multimodal (Vision)
|
||||||
|
|
||||||
`POST /v1/chat/completions` accepts OpenAI-style multimodal `content` parts, so a
|
`POST /v1/chat/completions` accepts OpenAI-style multimodal `content` parts, so a
|
||||||
|
|||||||
@@ -360,6 +360,11 @@ export function cacheHash(model, messages, opts = {}) {
|
|||||||
// the persistent cache instead of serving answers composed under the old config. Callers
|
// the persistent cache instead of serving answers composed under the old config. Callers
|
||||||
// that omit it (older paths, tests) hash byte-identically to before.
|
// that omit it (older paths, tests) hash byte-identically to before.
|
||||||
if (opts.configEpoch != null) h.update(`ce:${opts.configEpoch}|`);
|
if (opts.configEpoch != null) h.update(`ce:${opts.configEpoch}|`);
|
||||||
|
// Structured-output (OpenAI response_format / json_mode) requests must never share a cache slot
|
||||||
|
// with the conversational answer to the same prompt, nor with a different schema — the steering
|
||||||
|
// instruction and validated JSON payload differ. Keying on the detected descriptor isolates them.
|
||||||
|
// Absent for normal requests → hashes are byte-identical to pre-change.
|
||||||
|
if (opts.structured != null) h.update(`s:${JSON.stringify(opts.structured)}`);
|
||||||
for (const m of messages) {
|
for (const m of messages) {
|
||||||
h.update(m.role || "");
|
h.update(m.role || "");
|
||||||
h.update(typeof m.content === "string" ? m.content : JSON.stringify(m.content));
|
h.update(typeof m.content === "string" ? m.content : JSON.stringify(m.content));
|
||||||
|
|||||||
@@ -0,0 +1,297 @@
|
|||||||
|
// ── 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;
|
||||||
|
}
|
||||||
|
|
||||||
|
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;
|
||||||
|
}
|
||||||
+135
@@ -42,6 +42,7 @@ import { dirname, join } from "node:path";
|
|||||||
import { homedir } from "node:os";
|
import { homedir } from "node:os";
|
||||||
import { validateKey, recordUsage, getUsageByKey, getUsageTimeline, getRecentUsage, createKey, listKeys, revokeKey, closeDb, checkQuota, updateKeyQuota, getKeyQuota, findKey, cacheHash, getCachedResponse, setCachedResponse, clearCache, getCacheStats, hasCacheControl, singleflight, getInflightStats } from "./keys.mjs";
|
import { validateKey, recordUsage, getUsageByKey, getUsageTimeline, getRecentUsage, createKey, listKeys, revokeKey, closeDb, checkQuota, updateKeyQuota, getKeyQuota, findKey, cacheHash, getCachedResponse, setCachedResponse, clearCache, getCacheStats, hasCacheControl, singleflight, getInflightStats } from "./keys.mjs";
|
||||||
import { DEFAULT_PORT } from "./lib/constants.mjs";
|
import { DEFAULT_PORT } from "./lib/constants.mjs";
|
||||||
|
import { StructuredOutputError, detectStructuredOutput, validateJsonSchema, 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";
|
||||||
@@ -329,6 +330,16 @@ const ALLOWED_TOOLS = (process.env.CLAUDE_ALLOWED_TOOLS ||
|
|||||||
"Bash,Read,Write,Edit,Glob,Grep,WebSearch,WebFetch,Agent"
|
"Bash,Read,Write,Edit,Glob,Grep,WebSearch,WebFetch,Agent"
|
||||||
).split(",").map(s => s.trim()).filter(Boolean);
|
).split(",").map(s => s.trim()).filter(Boolean);
|
||||||
const SYSTEM_PROMPT = process.env.CLAUDE_SYSTEM_PROMPT || "";
|
const SYSTEM_PROMPT = process.env.CLAUDE_SYSTEM_PROMPT || "";
|
||||||
|
// Max attempts (initial + retries) to coerce a valid structured-output (OpenAI response_format)
|
||||||
|
// JSON response out of the model before rejecting. See runStructuredCompletion.
|
||||||
|
// Fail closed on a non-numeric value via resolveMaxAttempts(): the old `Math.max(1, parseInt("abc",10))`
|
||||||
|
// === `Math.max(1, NaN)` === NaN, which made the retry loop `attempt < NaN` never execute → 0 spawns,
|
||||||
|
// every structured request silently refused. The helper rejects NaN/non-finite/<1 and keeps the
|
||||||
|
// documented default of 3. (PR #153 review round 2, NaN-guard must-fix.)
|
||||||
|
const STRUCTURED_MAX_ATTEMPTS = resolveMaxAttempts(
|
||||||
|
process.env.OCP_STRUCTURED_MAX_ATTEMPTS,
|
||||||
|
{ fallback: 3, warn: (m) => console.warn(`[init] ${m}`) },
|
||||||
|
);
|
||||||
const MCP_CONFIG = process.env.CLAUDE_MCP_CONFIG || "";
|
const MCP_CONFIG = process.env.CLAUDE_MCP_CONFIG || "";
|
||||||
let SESSION_TTL = parseInt(process.env.CLAUDE_SESSION_TTL || "3600000", 10);
|
let SESSION_TTL = parseInt(process.env.CLAUDE_SESSION_TTL || "3600000", 10);
|
||||||
let MAX_CONCURRENT = parseInt(process.env.CLAUDE_MAX_CONCURRENT || "8", 10);
|
let MAX_CONCURRENT = parseInt(process.env.CLAUDE_MAX_CONCURRENT || "8", 10);
|
||||||
@@ -2143,6 +2154,31 @@ function completionResponse(res, id, model, content) {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// OpenAI's designated mechanism for "the model would not produce the required output" is the
|
||||||
|
// assistant `refusal` field (content:null, refusal:<text>, finish_reason:"stop") — NOT an invented
|
||||||
|
// error type. Structured-output exhaustion emits this so SDK clients take their written `refusal`
|
||||||
|
// branch instead of throwing an opaque UnprocessableEntityError. (PR #153 review, finding 3.)
|
||||||
|
function refusalResponse(res, id, model, refusal) {
|
||||||
|
jsonResponse(res, 200, {
|
||||||
|
id, object: "chat.completion",
|
||||||
|
created: Math.floor(Date.now() / 1000),
|
||||||
|
model,
|
||||||
|
choices: [{ index: 0, message: { role: "assistant", content: null, refusal }, finish_reason: "stop" }],
|
||||||
|
usage: { prompt_tokens: 0, completion_tokens: 0, total_tokens: 0 },
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Streaming form of refusalResponse: a role chunk, a `refusal` delta, then the stop chunk.
|
||||||
|
function streamRefusalAsSSE(res, id, model, refusal) {
|
||||||
|
const created = Math.floor(Date.now() / 1000);
|
||||||
|
res.writeHead(200, { "Content-Type": "text/event-stream", "Cache-Control": "no-cache", "Connection": "keep-alive", "X-Accel-Buffering": "no" });
|
||||||
|
sendSSE(res, { id, object: "chat.completion.chunk", created, model, choices: [{ index: 0, delta: { role: "assistant" }, finish_reason: null }] });
|
||||||
|
sendSSE(res, { id, object: "chat.completion.chunk", created, model, choices: [{ index: 0, delta: { refusal }, finish_reason: null }] });
|
||||||
|
sendSSE(res, { id, object: "chat.completion.chunk", created, model, choices: [{ index: 0, delta: {}, finish_reason: "stop" }] });
|
||||||
|
res.write("data: [DONE]\n\n");
|
||||||
|
res.end();
|
||||||
|
}
|
||||||
|
|
||||||
// Replay a complete string as a chunked SSE stream (80 codepoints/chunk).
|
// Replay a complete string as a chunked SSE stream (80 codepoints/chunk).
|
||||||
// Used by: (a) cache-hit replay on the streaming path; (b) TUI-mode streaming
|
// Used by: (a) cache-hit replay on the streaming path; (b) TUI-mode streaming
|
||||||
// (buffered response replayed as SSE so clients get the same wire format).
|
// (buffered response replayed as SSE so clients get the same wire format).
|
||||||
@@ -2655,6 +2691,37 @@ 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) {
|
||||||
|
const errs = validateJsonSchema(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 {
|
||||||
@@ -2762,6 +2829,74 @@ async function handleChatCompletions(req, res) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Structured output (OpenAI response_format / json_mode): its own path — the response must be
|
||||||
|
// schema-valid JSON, so it never shares the conversational cache slot. When caching is enabled it
|
||||||
|
// uses a structured-keyed hash (isolated via cacheHash's `structured` marker) and writes back ONLY
|
||||||
|
// a validated result (never a 422). Always validates on a miss.
|
||||||
|
const structured = detectStructuredOutput(parsed);
|
||||||
|
if (structured) {
|
||||||
|
const t0s = Date.now();
|
||||||
|
const promptCharsS = messages.reduce((a, m) => a + contentToText(m.content).length, 0);
|
||||||
|
let structuredHash = null;
|
||||||
|
if (CACHE_TTL > 0 && !conversationId && !hasCacheControl(messages)) {
|
||||||
|
structuredHash = cacheHash(model, messages, { keyId: req._authKeyId, temperature: parsed.temperature, max_tokens: parsed.max_tokens, top_p: parsed.top_p, structured });
|
||||||
|
try {
|
||||||
|
const cached = getCachedResponse(structuredHash, CACHE_TTL);
|
||||||
|
if (cached) {
|
||||||
|
logEvent("info", "cache_hit", { model, hash: structuredHash.slice(0, 12), hits: cached.hits, structured: true });
|
||||||
|
const id = `chatcmpl-${randomUUID()}`;
|
||||||
|
if (stream) streamStringAsSSE(res, id, model, cached.response);
|
||||||
|
else completionResponse(res, id, model, cached.response);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
} catch (e) { logEvent("error", "cache_check_failed", { error: e.message }); }
|
||||||
|
}
|
||||||
|
const upstreamCall = TUI_MODE ? callClaudeTui : callClaude;
|
||||||
|
// Stampede protection (PR #153 review, finding 5): a structured request can cost up to
|
||||||
|
// STRUCTURED_MAX_ATTEMPTS metered spawns, so N identical concurrent requests (Home Assistant
|
||||||
|
// firing several AI Tasks at once) must NOT each pay N× — they share one flight. We dedup every
|
||||||
|
// one-off structured request (not stateful sessions / client-side prompt caching), independent of
|
||||||
|
// whether OCP response caching is enabled; when caching IS on, the same key gates cache read/write.
|
||||||
|
const dedupKey = (!conversationId && !hasCacheControl(messages))
|
||||||
|
? cacheHash(model, messages, { keyId: req._authKeyId, temperature: parsed.temperature, max_tokens: parsed.max_tokens, top_p: parsed.top_p, structured })
|
||||||
|
: null;
|
||||||
|
const runStructured = async () => {
|
||||||
|
const c = await runStructuredCompletion(upstreamCall, model, messages, conversationId, req._authKeyName, res, structured);
|
||||||
|
if (structuredHash) { try { setCachedResponse(structuredHash, model, c); } catch (e) { logEvent("error", "cache_write_failed", { error: e.message }); } }
|
||||||
|
return c;
|
||||||
|
};
|
||||||
|
try {
|
||||||
|
const content = dedupKey
|
||||||
|
? await singleflight(dedupKey, async () => {
|
||||||
|
// A follower that raced in after the leader populated the cache re-reads it here.
|
||||||
|
if (structuredHash) { const rc = getCachedResponse(structuredHash, CACHE_TTL); if (rc) return rc.response; }
|
||||||
|
return runStructured();
|
||||||
|
}, (err) => err instanceof RequestDisconnectedError && !res.destroyed)
|
||||||
|
: await runStructured();
|
||||||
|
const id = `chatcmpl-${randomUUID()}`;
|
||||||
|
if (stream) streamStringAsSSE(res, id, model, content);
|
||||||
|
else completionResponse(res, id, model, content);
|
||||||
|
try { recordUsage({ keyId: req._authKeyId, keyName: req._authKeyName, model, promptChars: promptCharsS, responseChars: content.length, elapsedMs: Date.now() - t0s, success: true }); } catch (e) { logEvent("error", "usage_record_failed", { error: e.message }); }
|
||||||
|
return;
|
||||||
|
} catch (err) {
|
||||||
|
if (err instanceof RequestDisconnectedError) { try { res.end(); } catch {} return; }
|
||||||
|
try { recordUsage({ keyId: req._authKeyId, keyName: req._authKeyName, model, promptChars: promptCharsS, responseChars: 0, elapsedMs: Date.now() - t0s, success: false }); } catch {}
|
||||||
|
if (res.headersSent || res.writableEnded || res.destroyed) { try { res.end(); } catch {} return; }
|
||||||
|
if (err instanceof StructuredOutputError) {
|
||||||
|
// OpenAI's spec mechanism for "model would not produce the required output" is the assistant
|
||||||
|
// `refusal` field (200, content:null, finish_reason:"stop"), NOT an invented 422 error type —
|
||||||
|
// so SDK clients take their written refusal branch. (PR #153 review, finding 3.)
|
||||||
|
logEvent("warn", "structured_failed", { reason: err.reason });
|
||||||
|
const id = `chatcmpl-${randomUUID()}`;
|
||||||
|
const refusal = `Could not produce a response matching the requested response_format after ${STRUCTURED_MAX_ATTEMPTS} attempts (${sanitizeError(err.reason)}).`;
|
||||||
|
if (stream) streamRefusalAsSSE(res, id, model, refusal);
|
||||||
|
else refusalResponse(res, id, model, refusal);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
return respondUpstreamError(res, err);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// Cache check (only when cache is enabled and no active conversation/session)
|
// Cache check (only when cache is enabled and no active conversation/session)
|
||||||
if (CACHE_TTL > 0 && !conversationId) {
|
if (CACHE_TTL > 0 && !conversationId) {
|
||||||
// D2: skip OCP cache entirely when messages carry cache_control annotations;
|
// D2: skip OCP cache entirely when messages carry cache_control annotations;
|
||||||
|
|||||||
@@ -4330,6 +4330,234 @@ 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, 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
|
||||||
|
});
|
||||||
|
|
||||||
|
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