/** * EXT-88 — the **`withStructuredOutput` boundary**. Every `withStructuredOutput` call in this * project goes through {@link structuredOutputBoundary}: `rateShellCommand` (the approvals rater), * {@link @gaunt-sloth/core!runtime/askStructured.askStructured | askStructured} (arbitrary caller schemas, including `gth workflow` scripts) and * `@gaunt-sloth/batch`'s eval judge. Review's rating step is deliberately not one of them — it * reaches its schema through a bound TOOL, which is a different path with different rules. * * ## The problem it exists to solve * * A single Zod object normally does two jobs at once — it is converted into the JSON Schema the * provider is sent, and it validates the answer that comes back. Those two jobs want **opposite** * things from an optional field, and Zod has only one knob for both: * * - **On the wire**, OpenAI's strict `json_schema` rule is *"`required` must be supplied and must * include every key in `properties`"*. A `.optional()` field is left out of `required`, and the * OpenAI API rejects the whole request with `400 Invalid schema for response_format`. Optionality * there is spelled as a **nullable type on a required key**, not as an absent key. * - **On the way back**, providers do not agree. Some (ChatGroq) rewrite the schema themselves so * every property is required-and-nullable, and their models answer `null`. Others (Anthropic, * Google GenAI, Ollama) leave the field genuinely optional and their models may simply omit it. * Zod's `.optional()` admits `undefined` and **not** `null`, so the first group's answer is * rejected by the very schema that asked for it — and the caller sees a parse failure that looks * like a bad model rather than a self-contradicting request. * * ## The shape that satisfies both * * For every field the caller declared `.optional()`, the boundary sends * `inner.nullable().prefault(null)` instead. That one node is the wire/validation twin: * * - **Wire** — `prefault` does not change the parsed *output* type, so the key lands in `required` * and its type is `["string","null"]` / `anyOf: [T, null]`. Strict mode is satisfied, and the * model is given a legal way to say "nothing here". Unlike `.default()`, `prefault` emits **no * `default` keyword** into the JSON Schema, so nothing extra is added to what the provider sees. * - **Validation** — the same node accepts the value, accepts `null`, and accepts the key being * **missing** (which is what `prefault` supplies the `null` for). All three arms are needed: the * third is what keeps the providers that do *not* hoist working. * * The `null` never escapes: {@link StructuredOutputBoundary.safeParse} strips it back to the key * being **absent** and then validates with the caller's **original** schema, so the parsed type is * exactly what the caller declared — an optional string stays `string | undefined` and never * becomes `string | null | undefined`. * * ## Two properties that must not be lost in a "simplification" * * - **Every `.describe()` survives into the emitted JSON Schema.** The descriptions are the only * place the model is told what the fields mean. Expressing the null-to-absent step with * `.transform()` on the field is the obvious-looking alternative and it costs the field its * description — worse, the conversion OpenAI's path uses refuses to represent a transform at all. * Normalization therefore happens in **code**, after the parse, never in the schema. * - **A genuinely malformed answer still fails.** This boundary removes a *false* parse failure; it * is not a blanket "accept anything". A wrong type in an optional field is still rejected, because * the final validation is the caller's own untouched schema. In particular `.catch()` must not be * used to express any of this — it would swallow real failures. * * ## Coverage, and the deliberate limits * * The rewrite descends through objects, arrays, tuples, records and the single-child wrappers * (`nullable`, `readonly`, `default`, `prefault`, `nonoptional`), so an optional nested inside an * object inside an array is handled. * * It deliberately stops at **unions** (including discriminated unions), **intersections**, `lazy`, * `map`, `set`, `pipe`/`transform` and `custom`, leaving those subtrees exactly as the caller wrote * them, and it leaves a **recursive** schema alone in its entirety. The reason is one rule: the * rewrite and the normalization must cover **precisely the same set**. At a union the incoming value * gives no reliable answer to *which branch was taken*, so a `null` could not be stripped back out; * rewriting there while being unable to normalize would manufacture the very parse failure this * module removes. Recursion is refused for the neighbouring reason — a rewrite that stopped at the * cycle would make a key required at one depth and optional at the next, which satisfies no * provider's strict rule and is harder to reason about than the caller's own schema. * * An optional inside one of those constructs keeps today's behaviour — correct everywhere it is * correct today, and still rejected by OpenAI's strict rule, which is a visible error rather than a * silent one. * * @module */ import * as z from 'zod'; /** * The two halves of one structured-output call: what to send, and how to read the answer. * * @typeParam T The caller's own parsed type — unchanged by the boundary. */ export interface StructuredOutputBoundary { /** * The schema to hand to `model.withStructuredOutput(...)`. Identical (by reference) to the * caller's schema when it contains no optional field, so a schema that never had the problem is * sent byte-for-byte as it is today. */ wireSchema: z.ZodType>; /** * Validate a model answer: normalize every `null` that stands for "no value" back to the key being * absent, then validate with the caller's **original** schema. Never throws — a malformed answer * comes back as `success: false`, exactly as `schema.safeParse` would. */ safeParse(raw: unknown): z.ZodSafeParseResult; } /** * Build the {@link StructuredOutputBoundary} for a schema — the one entry point every * `withStructuredOutput` call in this project goes through. See the module doc for what it does and * why. * * @param schema The caller's schema, used unchanged for the final validation. */ export declare function structuredOutputBoundary(schema: z.ZodType): StructuredOutputBoundary;