From 3bcadba5ec88e95c3eb128819107cb4d5aa82205 Mon Sep 17 00:00:00 2001 From: Matt Miller Date: Thu, 3 Sep 2026 09:31:44 -0700 Subject: [PATCH] feat(snippets): render root-level oneOf/anyOf constraints on Code pages A rule that spans two fields ("exactly one of text_prompt or json_prompt") has no per-field representation in JSON Schema, so it belongs on the schema root as a oneOf/anyOf whose branches carry nothing but `required`. The generator's walker read only `required` and `properties`, so such a root union rendered as nothing and the page implied `{}` was an acceptable body. `schemaFields` now detects a root union whose branches differ only by `required` and emits one prose line above the field list, keeping the alternated fields individually optional since neither is required alone. The walk covers the union of root and branch properties, so a union carrying the only property definitions still renders its fields instead of tripping the empty-schema fallback. Branches carrying anything else stay a shape union and keep rendering per field as `a | b`. Ideogram 4.0's spec gets that root oneOf: the provider's own request schema description reads "Supply exactly one of `text_prompt` or `json_prompt`", a contract its machine-readable half never encodes. Co-Authored-By: Claude Opus 5 --- .github/scripts/snippets/README.md | 22 +++++++ .github/scripts/snippets/gen-code-pages.ts | 62 ++++++++++++++++++- .../ideogram/ideogram-v4/code.mdx | 2 + .../ideogram/ideogram-v4/code.yaml | 8 +++ 4 files changed, 91 insertions(+), 3 deletions(-) diff --git a/.github/scripts/snippets/README.md b/.github/scripts/snippets/README.md index 3ba81a1dd..ed7350c87 100644 --- a/.github/scripts/snippets/README.md +++ b/.github/scripts/snippets/README.md @@ -38,6 +38,28 @@ ResponseField list) and `example` / `result.example`, with a note that Router has not published the schema yet. Variants that resolve to the same schema share one block; variants with different schemas get tabs. +A rule that spans two fields rather than constraining one (Ideogram 4.0 takes a +`text_prompt` or a `json_prompt`, never both) has no per-field representation, so +it goes on the schema root as a `oneOf` / `anyOf` whose branches carry nothing but +`required`: + +```yaml +input: + type: object + oneOf: + - required: [text_prompt] + - required: [json_prompt] + properties: ... +``` + +That renders as one prose line above the field list (`oneOf` reads "Provide +exactly one of ...", `anyOf` "Provide at least one of ..."), and the alternated +fields stay individually optional, since neither is required on its own. Branches +that carry anything else (a `type`, an `enum`, properties redefining a root field) +are a choice of shapes instead, and keep rendering per field as `a | b`. The +provider drift check collapses required-only unions, so adding one does not change +what it compares. + ## Provider drift check `pnpm code-pages:check-providers` (`check-provider-schemas.ts`) fetches each diff --git a/.github/scripts/snippets/gen-code-pages.ts b/.github/scripts/snippets/gen-code-pages.ts index 968d6a823..643a6fcff 100644 --- a/.github/scripts/snippets/gen-code-pages.ts +++ b/.github/scripts/snippets/gen-code-pages.ts @@ -267,6 +267,55 @@ const attr = (v: unknown) => String(v ?? "").replace(/"/g, """).replace(/\s const mdxText = (v: unknown) => String(v).split(/(`[^`]*`)/).map((part, i) => (i % 2 ? part : part.replace(/[{<]/g, "\\$&"))).join(""); +/** `["a", "b", "c"]` becomes "`a`, `b`, or `c`" (Oxford comma from three names up). */ +function joinOr(names: string[]): string { + const q = names.map((n) => `\`${n}\``); + if (q.length < 3) return q.join(" or "); + return `${q.slice(0, -1).join(", ")}, or ${q.at(-1)}`; +} + +/** The keys a branch may carry and still count as differing from its siblings only by `required`. */ +const ALTERNATION_KEYS = new Set(["required", "properties"]); + +/** + * A root-level `oneOf` / `anyOf` whose branches differ only by `required` states a rule + * ACROSS fields ("exactly one of text_prompt or json_prompt"), not a choice of shapes. + * JSON Schema has no other way to say that, and a per-field ParamField cannot express it + * either, so it renders as one prose line above the field list. Returns null for a real + * shape union, which `typeLabel` already renders per field as `a | b`. + */ +function rootAlternation(s: any, components: Record, kind: "param" | "response") { + const key = s?.oneOf ? "oneOf" : s?.anyOf ? "anyOf" : null; + if (!key || !Array.isArray(s[key]) || s[key].length < 2) return null; + const branches = s[key].map((b: any) => deref(b, components)); + const rootProps: Record = s.properties ?? {}; + for (const b of branches) { + if (!b || typeof b !== "object") return null; + // Any key beyond required/properties (a `type`, an `enum`, a nested union) makes this a + // shape union rather than an alternation, and a branch that requires nothing is degenerate. + if (Object.keys(b).some((k) => !ALTERNATION_KEYS.has(k))) return null; + if (!(Array.isArray(b.required) && b.required.length)) return null; + // A branch may re-state a root property, but must not redefine it to a different shape. + for (const [name, prop] of Object.entries(b.properties ?? {})) + if (name in rootProps && JSON.stringify(rootProps[name]) !== JSON.stringify(prop)) return null; + } + const names = [...new Set(branches.flatMap((b: any) => b.required.map(String)))]; + if (names.length < 2) return null; + const count = key === "oneOf" ? "exactly one" : "at least one"; + // The output half of a page is describing what came back, not asking for a body. + const prose = kind === "param" + ? `Provide ${count} of ${joinOr(names)}.` + : `${count[0].toUpperCase()}${count.slice(1)} of ${joinOr(names)} is present.`; + // The branches can carry the only definitions of the alternated fields, so hand them back + // for the walk: without them those fields would vanish and the page would look empty. The + // root keeps both its authored field order and its definitions; branch-only fields append. + const properties: Record = { ...rootProps }; + for (const b of branches) + for (const [name, prop] of Object.entries(b.properties ?? {})) + if (!(name in properties)) properties[name] = prop; + return { prose, properties }; +} + /** Render a JSON Schema object as Mintlify ParamField (input) or ResponseField (output) blocks. */ function schemaFields(schema: any, components: Record, kind: "param" | "response"): string { const blocks: string[] = []; @@ -305,9 +354,16 @@ function schemaFields(schema: any, components: Record, kind: "param } } }; - walk(schema, "", 0); - if (!blocks.length) return "_The schema declares no fixed fields: any JSON object is accepted._"; - return blocks.join("\n\n"); + const root = deref(schema, components); + const alternation = rootAlternation(root, components, kind); + // The alternated fields stay individually optional: neither is required on its own, only the + // choice between them is, which is what the prose line says. + walk(alternation ? { ...root, properties: alternation.properties } : root, "", 0); + if (!blocks.length) { + // "any JSON object is accepted" would contradict the alternation, so the rule stands alone. + return alternation?.prose ?? "_The schema declares no fixed fields: any JSON object is accepted._"; + } + return [alternation?.prose, blocks.join("\n\n")].filter(Boolean).join("\n\n"); } // --------------------------------------------------------------------------- diff --git a/tutorials/partner-nodes/ideogram/ideogram-v4/code.mdx b/tutorials/partner-nodes/ideogram/ideogram-v4/code.mdx index c085d32af..2be2a2f1d 100644 --- a/tutorials/partner-nodes/ideogram/ideogram-v4/code.mdx +++ b/tutorials/partner-nodes/ideogram/ideogram-v4/code.mdx @@ -70,6 +70,8 @@ curl https://api.comfy.org/v2/models/ideogram/ideogram-v4 \ _Fields follow Ideogram's published API specification and are checked against it in CI. Router's own schema for this model is not published yet, so requests are forwarded to the provider unvalidated._ +Provide exactly one of `text_prompt` or `json_prompt`. + Text description of the image. Provide this or `json_prompt`. diff --git a/tutorials/partner-nodes/ideogram/ideogram-v4/code.yaml b/tutorials/partner-nodes/ideogram/ideogram-v4/code.yaml index f98237bf7..a3d0133e4 100644 --- a/tutorials/partner-nodes/ideogram/ideogram-v4/code.yaml +++ b/tutorials/partner-nodes/ideogram/ideogram-v4/code.yaml @@ -19,6 +19,14 @@ example: rendering_speed: DEFAULT input: type: object + # Ideogram's own spec marks neither prompt field required and declares no root constraint, but + # both field descriptions call them mutually exclusive and a generation needs a prompt, so the + # real contract is exactly one of the two. + oneOf: + - required: + - text_prompt + - required: + - json_prompt properties: text_prompt: type: string