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:
openclaw.vvlasy.cz
2026-07-20 07:55:40 +10:00
committed by GitHub
co-authored by vvlasy-openclaw Claude Opus 4.8 taodeng
parent ac81badda1
commit 788cbbcd99
5 changed files with 690 additions and 1 deletions
+297
View File
@@ -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;
}