From 49d7728612f077bce165f308af4fb54906cc8fa3 Mon Sep 17 00:00:00 2001 From: Matt Miller Date: Thu, 3 Sep 2026 06:20:31 -0700 Subject: [PATCH 1/3] feat(snippets): guard generated Code-page snippets on result.absent_when Google's v1beta GenerateContent response omits `candidates` entirely when the prompt itself is blocked, returning only `promptFeedback.blockReason`. The generated Python and TypeScript Quick-start snippets indexed straight into the result path, so a reader pasting them hit a bare `KeyError` / a throw on `undefined` that hid the provider's actual reason. Add an opt-in `result.absent_when: {path, label}` key to the code.yaml spec. When set, the Python and TypeScript emitters check that path first and exit non-zero with the object printed; the TypeScript `Result` type gains the matching optional member. cURL is unchanged: it never indexes the result. Only the four Google specs set it. BFL moderation surfaces as a non-2xx error from Router, and Ideogram has no documented empty-success shape. --- .github/scripts/snippets/README.md | 5 +- .github/scripts/snippets/gen-code-pages.ts | 85 ++++++++++++++++--- .../partner-nodes/google/gemini/code.mdx | 28 +++++- .../partner-nodes/google/gemini/code.yaml | 3 + .../google/nano-banana-2-lite/code.mdx | 7 +- .../google/nano-banana-2-lite/code.yaml | 3 + .../google/nano-banana-2/code.mdx | 7 +- .../google/nano-banana-2/code.yaml | 3 + .../google/nano-banana-pro/code.mdx | 7 +- .../google/nano-banana-pro/code.yaml | 3 + 10 files changed, 133 insertions(+), 18 deletions(-) diff --git a/.github/scripts/snippets/README.md b/.github/scripts/snippets/README.md index 3ba81a1dd..68ec10dab 100644 --- a/.github/scripts/snippets/README.md +++ b/.github/scripts/snippets/README.md @@ -59,7 +59,10 @@ fallback schemas are tested rather than trusted until Router publishes its own. 4. Put the smallest request body that produces a result in `example`. A value of `"@file:"` is read from disk and base64 encoded by every snippet. 5. Set `result.path` to where the output lives in the provider's native - response and `result.example` to a representative response. + response and `result.example` to a representative response. Optional + `result.absent_when: {path, label}` names a field whose presence means the + provider legitimately returned no result; the Python/TypeScript snippets + check it first and exit non-zero with the object printed. 6. Run `pnpm code-pages:gen`, add the page to the model's group in `docs.json`, and link it from the overview's "Use it" cards. diff --git a/.github/scripts/snippets/gen-code-pages.ts b/.github/scripts/snippets/gen-code-pages.ts index 968d6a823..3d765a6c2 100644 --- a/.github/scripts/snippets/gen-code-pages.ts +++ b/.github/scripts/snippets/gen-code-pages.ts @@ -32,7 +32,7 @@ type Spec = { input?: any; output?: any; provider_spec?: { url: string }; - result: { path: string; label: string; example: unknown; note?: string }; + result: { path: string; label: string; example: unknown; note?: string; absent_when?: { path: string; label: string } }; summary: string; intro?: string; }; @@ -77,14 +77,53 @@ function tsPath(path: string): string { return pathSegments(path).map((p) => (typeof p === "number" ? `[${p}]` : `.${p}`)).join(""); } +/** + * Segments of a `result.absent_when.path`. Index segments are rejected so the emitted guard stays + * one expression (`.get(a, {}).get(b)` / `a?.b`); none of the current specs needs one. + */ +function absentSegments(path: string): string[] { + const segs = pathSegments(path); + if (segs.some((p) => typeof p === "number")) throw new Error("bad absent_when path: index segments unsupported"); + return segs as string[]; +} + +/** `promptFeedback.blockReason` -> `result.get("promptFeedback", {}).get("blockReason")`. */ +function pySafeGet(path: string): string { + const segs = absentSegments(path); + return `result${segs.map((p, i) => `.get(${JSON.stringify(p)}${i < segs.length - 1 ? ", {}" : ""})`).join("")}`; +} + +/** `promptFeedback.blockReason` -> `data.promptFeedback?.blockReason`. */ +function tsSafeGet(path: string): string { + return `data.${absentSegments(path).join("?.")}`; +} + +/** `promptFeedback.blockReason` -> `promptFeedback?: { blockReason?: string }` (a `Result` member). */ +function tsAbsentType(path: string): string { + const segs = absentSegments(path); + let t = "string"; + for (let i = segs.length - 1; i >= 1; i--) t = `{ ${segs[i]}?: ${t} }`; + return `${segs[0]}?: ${t}`; +} + +/** + * A Python string literal quoted with `'`, so it can sit inside the double-quoted f-string the + * guard emits. Reusing `"` there would need PEP 701 (Python 3.12+) and is a syntax error on 3.11 + * and older, which a reader pasting the snippet would hit. + */ +function pyKeyLiteral(key: string): string { + return `'${key.replace(/\\/g, "\\\\").replace(/'/g, "\\'")}'`; +} + +/** The result type's object BODY (no braces), so `absent_when` can splice an extra member in. */ function tsResultType(path: string): string { const segs = pathSegments(path); let t = "string"; - for (let i = segs.length - 1; i >= 0; i--) { + for (let i = segs.length - 1; i >= 1; i--) { const p = segs[i]; t = typeof p === "number" ? `${t}[]` : `{ ${p}: ${t} }`; } - return t; + return `${segs[0]}: ${t}`; } /** JSON value -> Python literal, multi-line, at the given indent. */ @@ -118,13 +157,18 @@ function tsLiteral(v: unknown, indent: number, files: FileInput[], topKey?: stri return `{\n${entries.map(([k, x]) => `${pad} ${key(k)}: ${tsLiteral(x, indent + 2, files)},`).join("\n")}\n${pad}}`; } -function pythonSnippet(model: string, example: Record, files: FileInput[], resultPath: string, label: string): string { +function pythonSnippet(model: string, example: Record, files: FileInput[], resultPath: string, label: string, absent?: { path: string; label: string }): string { const reads = files .map((f) => `with open(${JSON.stringify(f.path)}, "rb") as f:\n ${f.varName} = base64.b64encode(f.read()).decode()`) .join("\n\n"); const body = Object.entries(example) .map(([k, v]) => ` ${JSON.stringify(k)}: ${pyLiteral(v, 12, files, k)},`) .join("\n"); + // The provider can legitimately answer with no result (a blocked prompt). Check that first: + // indexing into the absent result path would raise KeyError/IndexError and hide the reason. + const guard = absent + ? `if ${pySafeGet(absent.path)}:\n raise SystemExit(f"${absent.label}: {result[${pyKeyLiteral(absentSegments(absent.path)[0])}]}")\n\n` + : ""; return `${files.length ? "import base64\n\n" : ""}from comfy_sdk import Comfy ${reads ? `\n${reads}\n` : ""} # Reads COMFY_API_KEY from the environment. Each call sends a fresh @@ -137,10 +181,10 @@ ${body} }, ) -print("${label}:", result${pyPath(resultPath)})`; +${guard}print("${label}:", result${pyPath(resultPath)})`; } -function typescriptSnippet(model: string, example: Record, files: FileInput[], resultPath: string, label: string): string { +function typescriptSnippet(model: string, example: Record, files: FileInput[], resultPath: string, label: string, absent?: { path: string; label: string }): string { const imports = `import { comfy } from "@comfyorg/sdk";\n${files.length ? `import { readFile } from "node:fs/promises";\n` : ""}`; const reads = files .map((f) => `const ${camel(f.varName)} = (await readFile(${JSON.stringify(f.path)})).toString("base64");`) @@ -148,15 +192,21 @@ function typescriptSnippet(model: string, example: Record, file const body = Object.entries(example) .map(([k, v]) => ` ${/^[a-zA-Z_$][\w$]*$/.test(k) ? k : JSON.stringify(k)}: ${tsLiteral(v, 2, files, k)},`) .join("\n"); + const members = [absent ? tsAbsentType(absent.path) : null, tsResultType(resultPath)].filter(Boolean).join("; "); + // Same guard as the Python snippet: reading through the absent result path would throw on + // `undefined` and lose the provider's reason. + const guard = absent + ? `if (${tsSafeGet(absent.path)}) throw new Error(\`${absent.label}: \${JSON.stringify(data.${absentSegments(absent.path)[0]})}\`);\n\n` + : ""; return `${imports} ${reads ? `${reads}\n\n` : ""}// Reads COMFY_API_KEY from the environment. Each call sends a fresh // Idempotency-Key and waits up to 10 minutes for the finished result. -type Result = ${tsResultType(resultPath)}; +type Result = { ${members} }; const { data } = await comfy.models.run("${model}", { ${body} }); -console.log("${label}:", data${tsPath(resultPath)});`; +${guard}console.log("${label}:", data${tsPath(resultPath)});`; } function curlSnippet(model: string, example: Record, files: FileInput[]): string { @@ -359,11 +409,11 @@ function quickStart(v: Variant, spec: Spec): string { \`\`\`python Python -${pythonSnippet(v.model, example, files, spec.result.path, label)} +${pythonSnippet(v.model, example, files, spec.result.path, label, spec.result.absent_when)} \`\`\` \`\`\`typescript TypeScript -${typescriptSnippet(v.model, example, files, spec.result.path, label)} +${typescriptSnippet(v.model, example, files, spec.result.path, label, spec.result.absent_when)} \`\`\` \`\`\`bash cURL @@ -492,6 +542,21 @@ for (const specPath of glob.scanSync({ cwd: ROOT })) { for (const key of ["name", "provider", "description", "summary", "variants", "example", "result"] as const) { if (spec[key] === undefined) problems.push(`${specPath}: missing required key \`${key}\``); } + // `absent_when.path` uses the same dotted syntax as `result.path`, minus index segments. Report a + // bad one against this spec, like the missing-key checks above, rather than throwing out of the run. + const absentWhen = spec.result?.absent_when; + if (absentWhen) { + for (const key of ["path", "label"] as const) { + // Both are interpolated straight into the emitted guard, where a missing one would ship as + // the literal `undefined` in a snippet that still compiles. + if (absentWhen[key] === undefined) problems.push(`${specPath}: missing required key \`result.absent_when.${key}\``); + } + try { + if (absentWhen.path !== undefined) absentSegments(absentWhen.path); + } catch (e) { + problems.push(`${specPath}: result.absent_when: ${(e as Error).message}`); + } + } if (problems.some((m) => m.startsWith(specPath))) continue; const dir = dirname(specPath); const out = join(ROOT, dir, "code.mdx"); diff --git a/tutorials/partner-nodes/google/gemini/code.mdx b/tutorials/partner-nodes/google/gemini/code.mdx index ec364b076..f0b7926db 100644 --- a/tutorials/partner-nodes/google/gemini/code.mdx +++ b/tutorials/partner-nodes/google/gemini/code.mdx @@ -52,6 +52,9 @@ with Comfy() as client: }, ) +if result.get("promptFeedback", {}).get("blockReason"): + raise SystemExit(f"prompt blocked: {result['promptFeedback']}") + print("text:", result["candidates"][0]["content"]["parts"][0]["text"]) ``` @@ -60,7 +63,7 @@ import { comfy } from "@comfyorg/sdk"; // Reads COMFY_API_KEY from the environment. Each call sends a fresh // Idempotency-Key and waits up to 10 minutes for the finished result. -type Result = { candidates: { content: { parts: { text: string }[] } }[] }; +type Result = { promptFeedback?: { blockReason?: string }; candidates: { content: { parts: { text: string }[] } }[] }; const { data } = await comfy.models.run("vertexai/gemini-3.1-pro-preview", { contents: [ { @@ -78,6 +81,8 @@ const { data } = await comfy.models.run("vertexai/gemini-3.1-pro-preview }, }); +if (data.promptFeedback?.blockReason) throw new Error(`prompt blocked: ${JSON.stringify(data.promptFeedback)}`); + console.log("text:", data.candidates[0].content.parts[0].text); ``` @@ -122,6 +127,9 @@ with Comfy() as client: }, ) +if result.get("promptFeedback", {}).get("blockReason"): + raise SystemExit(f"prompt blocked: {result['promptFeedback']}") + print("text:", result["candidates"][0]["content"]["parts"][0]["text"]) ``` @@ -130,7 +138,7 @@ import { comfy } from "@comfyorg/sdk"; // Reads COMFY_API_KEY from the environment. Each call sends a fresh // Idempotency-Key and waits up to 10 minutes for the finished result. -type Result = { candidates: { content: { parts: { text: string }[] } }[] }; +type Result = { promptFeedback?: { blockReason?: string }; candidates: { content: { parts: { text: string }[] } }[] }; const { data } = await comfy.models.run("vertexai/gemini-3.5-flash", { contents: [ { @@ -148,6 +156,8 @@ const { data } = await comfy.models.run("vertexai/gemini-3.5-flash", { }, }); +if (data.promptFeedback?.blockReason) throw new Error(`prompt blocked: ${JSON.stringify(data.promptFeedback)}`); + console.log("text:", data.candidates[0].content.parts[0].text); ``` @@ -192,6 +202,9 @@ with Comfy() as client: }, ) +if result.get("promptFeedback", {}).get("blockReason"): + raise SystemExit(f"prompt blocked: {result['promptFeedback']}") + print("text:", result["candidates"][0]["content"]["parts"][0]["text"]) ``` @@ -200,7 +213,7 @@ import { comfy } from "@comfyorg/sdk"; // Reads COMFY_API_KEY from the environment. Each call sends a fresh // Idempotency-Key and waits up to 10 minutes for the finished result. -type Result = { candidates: { content: { parts: { text: string }[] } }[] }; +type Result = { promptFeedback?: { blockReason?: string }; candidates: { content: { parts: { text: string }[] } }[] }; const { data } = await comfy.models.run("vertexai/gemini-2.5-pro", { contents: [ { @@ -218,6 +231,8 @@ const { data } = await comfy.models.run("vertexai/gemini-2.5-pro", { }, }); +if (data.promptFeedback?.blockReason) throw new Error(`prompt blocked: ${JSON.stringify(data.promptFeedback)}`); + console.log("text:", data.candidates[0].content.parts[0].text); ``` @@ -262,6 +277,9 @@ with Comfy() as client: }, ) +if result.get("promptFeedback", {}).get("blockReason"): + raise SystemExit(f"prompt blocked: {result['promptFeedback']}") + print("text:", result["candidates"][0]["content"]["parts"][0]["text"]) ``` @@ -270,7 +288,7 @@ import { comfy } from "@comfyorg/sdk"; // Reads COMFY_API_KEY from the environment. Each call sends a fresh // Idempotency-Key and waits up to 10 minutes for the finished result. -type Result = { candidates: { content: { parts: { text: string }[] } }[] }; +type Result = { promptFeedback?: { blockReason?: string }; candidates: { content: { parts: { text: string }[] } }[] }; const { data } = await comfy.models.run("vertexai/gemini-2.5-flash", { contents: [ { @@ -288,6 +306,8 @@ const { data } = await comfy.models.run("vertexai/gemini-2.5-flash", { }, }); +if (data.promptFeedback?.blockReason) throw new Error(`prompt blocked: ${JSON.stringify(data.promptFeedback)}`); + console.log("text:", data.candidates[0].content.parts[0].text); ``` diff --git a/tutorials/partner-nodes/google/gemini/code.yaml b/tutorials/partner-nodes/google/gemini/code.yaml index 7a568dcfb..9a0bfa344 100644 --- a/tutorials/partner-nodes/google/gemini/code.yaml +++ b/tutorials/partner-nodes/google/gemini/code.yaml @@ -200,6 +200,9 @@ output: result: path: candidates[0].content.parts[0].text label: text + absent_when: + path: promptFeedback.blockReason + label: prompt blocked example: candidates: - content: diff --git a/tutorials/partner-nodes/google/nano-banana-2-lite/code.mdx b/tutorials/partner-nodes/google/nano-banana-2-lite/code.mdx index 8e2b1cc49..f7e9e1edc 100644 --- a/tutorials/partner-nodes/google/nano-banana-2-lite/code.mdx +++ b/tutorials/partner-nodes/google/nano-banana-2-lite/code.mdx @@ -50,6 +50,9 @@ with Comfy() as client: }, ) +if result.get("promptFeedback", {}).get("blockReason"): + raise SystemExit(f"prompt blocked: {result['promptFeedback']}") + print("image (base64):", result["candidates"][0]["content"]["parts"][0]["inlineData"]["data"]) ``` @@ -58,7 +61,7 @@ import { comfy } from "@comfyorg/sdk"; // Reads COMFY_API_KEY from the environment. Each call sends a fresh // Idempotency-Key and waits up to 10 minutes for the finished result. -type Result = { candidates: { content: { parts: { inlineData: { data: string } }[] } }[] }; +type Result = { promptFeedback?: { blockReason?: string }; candidates: { content: { parts: { inlineData: { data: string } }[] } }[] }; const { data } = await comfy.models.run("vertexai/gemini-3.1-flash-lite-image", { contents: [ { @@ -78,6 +81,8 @@ const { data } = await comfy.models.run("vertexai/gemini-3.1-flash-lite- }, }); +if (data.promptFeedback?.blockReason) throw new Error(`prompt blocked: ${JSON.stringify(data.promptFeedback)}`); + console.log("image (base64):", data.candidates[0].content.parts[0].inlineData.data); ``` diff --git a/tutorials/partner-nodes/google/nano-banana-2-lite/code.yaml b/tutorials/partner-nodes/google/nano-banana-2-lite/code.yaml index 0013eb811..79354f1c9 100644 --- a/tutorials/partner-nodes/google/nano-banana-2-lite/code.yaml +++ b/tutorials/partner-nodes/google/nano-banana-2-lite/code.yaml @@ -172,6 +172,9 @@ output: result: path: candidates[0].content.parts[0].inlineData.data label: image (base64) + absent_when: + path: promptFeedback.blockReason + label: prompt blocked example: candidates: - content: diff --git a/tutorials/partner-nodes/google/nano-banana-2/code.mdx b/tutorials/partner-nodes/google/nano-banana-2/code.mdx index 228a11574..5a168f586 100644 --- a/tutorials/partner-nodes/google/nano-banana-2/code.mdx +++ b/tutorials/partner-nodes/google/nano-banana-2/code.mdx @@ -50,6 +50,9 @@ with Comfy() as client: }, ) +if result.get("promptFeedback", {}).get("blockReason"): + raise SystemExit(f"prompt blocked: {result['promptFeedback']}") + print("image (base64):", result["candidates"][0]["content"]["parts"][0]["inlineData"]["data"]) ``` @@ -58,7 +61,7 @@ import { comfy } from "@comfyorg/sdk"; // Reads COMFY_API_KEY from the environment. Each call sends a fresh // Idempotency-Key and waits up to 10 minutes for the finished result. -type Result = { candidates: { content: { parts: { inlineData: { data: string } }[] } }[] }; +type Result = { promptFeedback?: { blockReason?: string }; candidates: { content: { parts: { inlineData: { data: string } }[] } }[] }; const { data } = await comfy.models.run("vertexai/gemini-3.1-flash-image", { contents: [ { @@ -78,6 +81,8 @@ const { data } = await comfy.models.run("vertexai/gemini-3.1-flash-image }, }); +if (data.promptFeedback?.blockReason) throw new Error(`prompt blocked: ${JSON.stringify(data.promptFeedback)}`); + console.log("image (base64):", data.candidates[0].content.parts[0].inlineData.data); ``` diff --git a/tutorials/partner-nodes/google/nano-banana-2/code.yaml b/tutorials/partner-nodes/google/nano-banana-2/code.yaml index 4e57279d5..beaa695a5 100644 --- a/tutorials/partner-nodes/google/nano-banana-2/code.yaml +++ b/tutorials/partner-nodes/google/nano-banana-2/code.yaml @@ -172,6 +172,9 @@ output: result: path: candidates[0].content.parts[0].inlineData.data label: image (base64) + absent_when: + path: promptFeedback.blockReason + label: prompt blocked example: candidates: - content: diff --git a/tutorials/partner-nodes/google/nano-banana-pro/code.mdx b/tutorials/partner-nodes/google/nano-banana-pro/code.mdx index 64bf9a2ff..1fa3a27b8 100644 --- a/tutorials/partner-nodes/google/nano-banana-pro/code.mdx +++ b/tutorials/partner-nodes/google/nano-banana-pro/code.mdx @@ -50,6 +50,9 @@ with Comfy() as client: }, ) +if result.get("promptFeedback", {}).get("blockReason"): + raise SystemExit(f"prompt blocked: {result['promptFeedback']}") + print("image (base64):", result["candidates"][0]["content"]["parts"][0]["inlineData"]["data"]) ``` @@ -58,7 +61,7 @@ import { comfy } from "@comfyorg/sdk"; // Reads COMFY_API_KEY from the environment. Each call sends a fresh // Idempotency-Key and waits up to 10 minutes for the finished result. -type Result = { candidates: { content: { parts: { inlineData: { data: string } }[] } }[] }; +type Result = { promptFeedback?: { blockReason?: string }; candidates: { content: { parts: { inlineData: { data: string } }[] } }[] }; const { data } = await comfy.models.run("vertexai/gemini-3-pro-image", { contents: [ { @@ -78,6 +81,8 @@ const { data } = await comfy.models.run("vertexai/gemini-3-pro-image", { }, }); +if (data.promptFeedback?.blockReason) throw new Error(`prompt blocked: ${JSON.stringify(data.promptFeedback)}`); + console.log("image (base64):", data.candidates[0].content.parts[0].inlineData.data); ``` diff --git a/tutorials/partner-nodes/google/nano-banana-pro/code.yaml b/tutorials/partner-nodes/google/nano-banana-pro/code.yaml index 678592963..d4e658d1a 100644 --- a/tutorials/partner-nodes/google/nano-banana-pro/code.yaml +++ b/tutorials/partner-nodes/google/nano-banana-pro/code.yaml @@ -179,6 +179,9 @@ output: result: path: candidates[0].content.parts[0].inlineData.data label: image (base64) + absent_when: + path: promptFeedback.blockReason + label: prompt blocked example: candidates: - content: From c0e12084b143bf1d8840af9f5550a16656f4a30c Mon Sep 17 00:00:00 2001 From: Matt Miller Date: Thu, 3 Sep 2026 08:17:14 -0700 Subject: [PATCH 2/3] fix(snippets): reject an absent_when path that shares result.path's root key Splicing the absent_when member alongside the result member in `type Result` emits a duplicate identifier when the two share a root key. That is a TS type error, and `--validate` only transpiles (`bun build --no-bundle`), so it would have shipped into a generated page unnoticed. Report it per-spec next to the other `absent_when` checks. No generated page changes. --- .github/scripts/snippets/gen-code-pages.ts | 10 +++++++++- 1 file changed, 9 insertions(+), 1 deletion(-) diff --git a/.github/scripts/snippets/gen-code-pages.ts b/.github/scripts/snippets/gen-code-pages.ts index 3d765a6c2..bd880cf30 100644 --- a/.github/scripts/snippets/gen-code-pages.ts +++ b/.github/scripts/snippets/gen-code-pages.ts @@ -552,7 +552,15 @@ for (const specPath of glob.scanSync({ cwd: ROOT })) { if (absentWhen[key] === undefined) problems.push(`${specPath}: missing required key \`result.absent_when.${key}\``); } try { - if (absentWhen.path !== undefined) absentSegments(absentWhen.path); + if (absentWhen.path !== undefined) { + // `type Result` splices the absent_when member alongside the result member, so sharing a + // root key emits a duplicate identifier. That is a TS *type* error, and `--validate` only + // transpiles (`bun build --no-bundle`), so it would ship into the page unnoticed. + const root = absentSegments(absentWhen.path)[0]; + if (root === spec.result.path?.split(/[.[]/)[0]) { + problems.push(`${specPath}: result.absent_when.path root \`${root}\` collides with \`result.path\``); + } + } } catch (e) { problems.push(`${specPath}: result.absent_when: ${(e as Error).message}`); } From e8b7ab61cc1a964380f8c724820a6fa3021db56b Mon Sep 17 00:00:00 2001 From: Matt Miller Date: Thu, 3 Sep 2026 09:22:30 -0700 Subject: [PATCH 3/3] fix(snippets): quote non-identifier path segments and escape labels Two CodeRabbit findings on the absent_when guard, both confirmed by reproduction rather than taken on the report. Non-identifier path segments. A provider field name need not be a TypeScript identifier -- `prompt-feedback` is a legal JSON key -- and dot access on one is not a syntax error, which is what makes it dangerous: `data.prompt-feedback?.blockReason` transpiles clean as `data.prompt - feedback?.blockReason`, reading the wrong property and subtracting. Only the emitted *type* member broke the build, pointing at the wrong cause. `tsAccess`/`tsKey` now bracket and quote such a segment at every TypeScript emission site. The Python emitters already quoted every segment, so rejecting the shape would have denied one language what the other already supported; `result.path` had the identical hole and is fixed with the same helpers. Label escaping. Labels are spec-controlled but land inside emitted string literals, and three of the four sites fail *silently* rather than as the syntax errors `--validate` catches: a `{` in a Python f-string is an interpolation (NameError at run time), and a backtick or `${` in a TypeScript template literal alters or executes the emitted expression -- both confirmed to compile and then misbehave. Each site now escapes for its own quoting rules. Both are no-ops on every spec shipping today: all nine pages regenerate byte-identical and `--check --validate` stays green. --- .github/scripts/snippets/README.md | 4 +- .github/scripts/snippets/gen-code-pages.ts | 73 +++++++++++++++++++--- 2 files changed, 66 insertions(+), 11 deletions(-) diff --git a/.github/scripts/snippets/README.md b/.github/scripts/snippets/README.md index 68ec10dab..a3bdc4a82 100644 --- a/.github/scripts/snippets/README.md +++ b/.github/scripts/snippets/README.md @@ -62,7 +62,9 @@ fallback schemas are tested rather than trusted until Router publishes its own. response and `result.example` to a representative response. Optional `result.absent_when: {path, label}` names a field whose presence means the provider legitimately returned no result; the Python/TypeScript snippets - check it first and exit non-zero with the object printed. + check it first and exit non-zero with the object printed. Its segments may + not be `[0]` indexes, so the emitted guard stays a single expression; segments + that are not identifiers (`prompt-feedback`) are quoted and bracketed. 6. Run `pnpm code-pages:gen`, add the page to the model's group in `docs.json`, and link it from the overview's "Use it" cards. diff --git a/.github/scripts/snippets/gen-code-pages.ts b/.github/scripts/snippets/gen-code-pages.ts index bd880cf30..8e33d721d 100644 --- a/.github/scripts/snippets/gen-code-pages.ts +++ b/.github/scripts/snippets/gen-code-pages.ts @@ -74,7 +74,7 @@ function pyPath(path: string): string { } function tsPath(path: string): string { - return pathSegments(path).map((p) => (typeof p === "number" ? `[${p}]` : `.${p}`)).join(""); + return pathSegments(path).map((p) => (typeof p === "number" ? `[${p}]` : tsAccess(p, false))).join(""); } /** @@ -87,6 +87,28 @@ function absentSegments(path: string): string[] { return segs as string[]; } +/** + * A provider field name is not always a TypeScript identifier -- `prompt-feedback` is a legal JSON + * key. Dot access on one is not a syntax error, which is what makes it dangerous: + * `data.prompt-feedback?.blockReason` transpiles clean as `data.prompt - feedback?.blockReason`, + * reading the wrong property and subtracting. So every TypeScript emission site below brackets and + * quotes a non-identifier segment. The Python emitters already quote every segment + * (`pySafeGet`, `pyKeyLiteral`), so they needed no change. + */ +const TS_IDENTIFIER = /^[A-Za-z_$][\w$]*$/; + +/** A member access on an existing expression: `.foo`, `?.foo`, `["a-b"]`, `?.["a-b"]`. */ +function tsAccess(seg: string, optional: boolean): string { + // Bracket access carries its own delimiter, so it takes a dot only when it is also optional. + if (TS_IDENTIFIER.test(seg)) return `${optional ? "?." : "."}${seg}`; + return `${optional ? "?." : ""}[${JSON.stringify(seg)}]`; +} + +/** A key as it appears in a type literal or an object literal: `foo` or `"a-b"`. */ +function tsKey(seg: string): string { + return TS_IDENTIFIER.test(seg) ? seg : JSON.stringify(seg); +} + /** `promptFeedback.blockReason` -> `result.get("promptFeedback", {}).get("blockReason")`. */ function pySafeGet(path: string): string { const segs = absentSegments(path); @@ -95,15 +117,20 @@ function pySafeGet(path: string): string { /** `promptFeedback.blockReason` -> `data.promptFeedback?.blockReason`. */ function tsSafeGet(path: string): string { - return `data.${absentSegments(path).join("?.")}`; + return `data${absentSegments(path).map((p, i) => tsAccess(p, i > 0)).join("")}`; +} + +/** The `absent_when` root read for the error payload: `data.promptFeedback` / `data["a-b"]`. */ +function tsAbsentRoot(path: string): string { + return `data${tsAccess(absentSegments(path)[0], false)}`; } /** `promptFeedback.blockReason` -> `promptFeedback?: { blockReason?: string }` (a `Result` member). */ function tsAbsentType(path: string): string { const segs = absentSegments(path); let t = "string"; - for (let i = segs.length - 1; i >= 1; i--) t = `{ ${segs[i]}?: ${t} }`; - return `${segs[0]}?: ${t}`; + for (let i = segs.length - 1; i >= 1; i--) t = `{ ${tsKey(segs[i])}?: ${t} }`; + return `${tsKey(segs[0])}?: ${t}`; } /** @@ -115,15 +142,41 @@ function pyKeyLiteral(key: string): string { return `'${key.replace(/\\/g, "\\\\").replace(/'/g, "\\'")}'`; } +// Labels are spec-controlled but land inside emitted string literals, where three of the four +// sites are *silent* failures rather than the syntax errors `--validate` would catch: a `{` in a +// Python f-string is an interpolation (NameError at run time), and a backtick or `${` in a +// TypeScript template literal alters or executes the emitted expression. Each helper below escapes +// for one site's quoting rules; all four are the identity function on the labels shipping today. + +/** Inside a double-quoted Python string literal. JSON's escapes are a subset of Python's. */ +function pyEscape(s: string): string { + return JSON.stringify(s).slice(1, -1); +} + +/** Inside a double-quoted Python *f-string*, where a literal brace must be doubled. */ +function pyFEscape(s: string): string { + return pyEscape(s).replace(/[{}]/g, (c) => c + c); +} + +/** Inside a double-quoted TypeScript string literal. */ +function tsEscape(s: string): string { + return JSON.stringify(s).slice(1, -1); +} + +/** Inside a TypeScript template literal, where a backtick or `${` would end or interpolate it. */ +function tsTemplateEscape(s: string): string { + return s.replace(/[\\`]/g, (c) => `\\${c}`).replace(/\$\{/g, "\\${"); +} + /** The result type's object BODY (no braces), so `absent_when` can splice an extra member in. */ function tsResultType(path: string): string { const segs = pathSegments(path); let t = "string"; for (let i = segs.length - 1; i >= 1; i--) { const p = segs[i]; - t = typeof p === "number" ? `${t}[]` : `{ ${p}: ${t} }`; + t = typeof p === "number" ? `${t}[]` : `{ ${tsKey(p)}: ${t} }`; } - return `${segs[0]}: ${t}`; + return `${tsKey(segs[0] as string)}: ${t}`; } /** JSON value -> Python literal, multi-line, at the given indent. */ @@ -167,7 +220,7 @@ function pythonSnippet(model: string, example: Record, files: F // The provider can legitimately answer with no result (a blocked prompt). Check that first: // indexing into the absent result path would raise KeyError/IndexError and hide the reason. const guard = absent - ? `if ${pySafeGet(absent.path)}:\n raise SystemExit(f"${absent.label}: {result[${pyKeyLiteral(absentSegments(absent.path)[0])}]}")\n\n` + ? `if ${pySafeGet(absent.path)}:\n raise SystemExit(f"${pyFEscape(absent.label)}: {result[${pyKeyLiteral(absentSegments(absent.path)[0])}]}")\n\n` : ""; return `${files.length ? "import base64\n\n" : ""}from comfy_sdk import Comfy ${reads ? `\n${reads}\n` : ""} @@ -181,7 +234,7 @@ ${body} }, ) -${guard}print("${label}:", result${pyPath(resultPath)})`; +${guard}print("${pyEscape(label)}:", result${pyPath(resultPath)})`; } function typescriptSnippet(model: string, example: Record, files: FileInput[], resultPath: string, label: string, absent?: { path: string; label: string }): string { @@ -196,7 +249,7 @@ function typescriptSnippet(model: string, example: Record, file // Same guard as the Python snippet: reading through the absent result path would throw on // `undefined` and lose the provider's reason. const guard = absent - ? `if (${tsSafeGet(absent.path)}) throw new Error(\`${absent.label}: \${JSON.stringify(data.${absentSegments(absent.path)[0]})}\`);\n\n` + ? `if (${tsSafeGet(absent.path)}) throw new Error(\`${tsTemplateEscape(absent.label)}: \${JSON.stringify(${tsAbsentRoot(absent.path)})}\`);\n\n` : ""; return `${imports} ${reads ? `${reads}\n\n` : ""}// Reads COMFY_API_KEY from the environment. Each call sends a fresh @@ -206,7 +259,7 @@ const { data } = await comfy.models.run("${model}", { ${body} }); -${guard}console.log("${label}:", data${tsPath(resultPath)});`; +${guard}console.log("${tsEscape(label)}:", data${tsPath(resultPath)});`; } function curlSnippet(model: string, example: Record, files: FileInput[]): string {