Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 6 additions & 1 deletion .github/scripts/snippets/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -59,7 +59,12 @@ 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:<path>"` 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. 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.

Expand Down
150 changes: 138 additions & 12 deletions .github/scripts/snippets/gen-code-pages.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
};
Expand Down Expand Up @@ -74,17 +74,109 @@ 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("");
}

/**
* 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[];
}

/**
* 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);
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).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 = `{ ${tsKey(segs[i])}?: ${t} }`;
return `${tsKey(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, "\\'")}'`;
}

// 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 >= 0; i--) {
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 t;
return `${tsKey(segs[0] as string)}: ${t}`;
}

/** JSON value -> Python literal, multi-line, at the given indent. */
Expand Down Expand Up @@ -118,13 +210,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<string, unknown>, files: FileInput[], resultPath: string, label: string): string {
function pythonSnippet(model: string, example: Record<string, unknown>, 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"${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` : ""}
# Reads COMFY_API_KEY from the environment. Each call sends a fresh
Expand All @@ -137,26 +234,32 @@ ${body}
},
)

print("${label}:", result${pyPath(resultPath)})`;
${guard}print("${pyEscape(label)}:", result${pyPath(resultPath)})`;
}

function typescriptSnippet(model: string, example: Record<string, unknown>, files: FileInput[], resultPath: string, label: string): string {
function typescriptSnippet(model: string, example: Record<string, unknown>, 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");`)
.join("\n");
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(\`${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
// 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<Result>("${model}", {
${body}
});

console.log("${label}:", data${tsPath(resultPath)});`;
${guard}console.log("${tsEscape(label)}:", data${tsPath(resultPath)});`;
}

function curlSnippet(model: string, example: Record<string, unknown>, files: FileInput[]): string {
Expand Down Expand Up @@ -359,11 +462,11 @@ function quickStart(v: Variant, spec: Spec): string {

<CodeGroup>
\`\`\`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
Expand Down Expand Up @@ -492,6 +595,29 @@ 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) {
// `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}`);
}
}
if (problems.some((m) => m.startsWith(specPath))) continue;
const dir = dirname(specPath);
const out = join(ROOT, dir, "code.mdx");
Expand Down
28 changes: 24 additions & 4 deletions tutorials/partner-nodes/google/gemini/code.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -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"])
```

Expand All @@ -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 }[] } }[] };
Comment thread
coderabbitai[bot] marked this conversation as resolved.
const { data } = await comfy.models.run<Result>("vertexai/gemini-3.1-pro-preview", {
contents: [
{
Expand All @@ -78,6 +81,8 @@ const { data } = await comfy.models.run<Result>("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);
```

Expand Down Expand Up @@ -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"])
```

Expand All @@ -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<Result>("vertexai/gemini-3.5-flash", {
contents: [
{
Expand All @@ -148,6 +156,8 @@ const { data } = await comfy.models.run<Result>("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);
```

Expand Down Expand Up @@ -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"])
```

Expand All @@ -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<Result>("vertexai/gemini-2.5-pro", {
contents: [
{
Expand All @@ -218,6 +231,8 @@ const { data } = await comfy.models.run<Result>("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);
```

Expand Down Expand Up @@ -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"])
```

Expand All @@ -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<Result>("vertexai/gemini-2.5-flash", {
contents: [
{
Expand All @@ -288,6 +306,8 @@ const { data } = await comfy.models.run<Result>("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);
```

Expand Down
3 changes: 3 additions & 0 deletions tutorials/partner-nodes/google/gemini/code.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down
7 changes: 6 additions & 1 deletion tutorials/partner-nodes/google/nano-banana-2-lite/code.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -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"])
```

Expand All @@ -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<Result>("vertexai/gemini-3.1-flash-lite-image", {
contents: [
{
Expand All @@ -78,6 +81,8 @@ const { data } = await comfy.models.run<Result>("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);
```

Expand Down
3 changes: 3 additions & 0 deletions tutorials/partner-nodes/google/nano-banana-2-lite/code.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down
Loading
Loading