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