UNPKG

comfyui-mcp

Version:

Local-first, agent-native control plane for ComfyUI — MCP server + autonomous sidebar agent that drives your live graph in natural language on ANY LLM: Claude/ChatGPT/Gemini on your subscription (no API key), free local models via Ollama (fully offline),

624 lines • 36.9 kB
// RunPod connector — a thin client over RunPod's GraphQL API for managing a live // pod (status / start / stop / troubleshoot / connect), plus the referral deploy // link so a user creating their own pod credits our account. // // Auth: RUNPOD_API_KEY (loaded from ~/.comfyui-mcp/.env into process.env by // src/config.ts). The key is sent as an `Authorization: Bearer` header — NEVER // in the URL (query strings leak into proxy/server logs) and NEVER logged. // // Referral: RunPod's referral attaches at signup/deploy via a `?ref=` link, NOT as // a per-pod API parameter — so we surface the deploy link for pod CREATION while // managing EXISTING pods over the API. Configurable via RUNPOD_TEMPLATE_ID / // RUNPOD_REF_CODE for other deployments; defaults are this project's. import { createHash } from "node:crypto"; import { z } from "zod"; import { freshSecretValue } from "../env-file.js"; const RUNPOD_GRAPHQL_ENDPOINT = "https://api.runpod.io/graphql"; /** The RunPod template a fresh pod deploys from (our comfyui-mcp image). */ const DEFAULT_TEMPLATE_ID = "bnqtkvcer3"; export const RUNPOD_TEMPLATE_ID = process.env.RUNPOD_TEMPLATE_ID?.trim() || DEFAULT_TEMPLATE_ID; /** Our RunPod referral code — a user deploying via the link below credits us. */ export const RUNPOD_REF_CODE = process.env.RUNPOD_REF_CODE?.trim() || "dkx71w9b"; /** ComfyUI's external port on a RunPod pod. RunPod ComfyUI templates front ComfyUI * via nginx on port 3000 BY CONVENTION (ComfyUI itself runs on 3001 inside; 3000 is * the proxied entrypoint) — NOT 8188. RunPod HTTP-proxies it at <podId>-<port>. * Override with RUNPOD_COMFYUI_PORT for a template that uses a different port. */ export const RUNPOD_COMFYUI_PORT = (() => { const v = Number(process.env.RUNPOD_COMFYUI_PORT); return Number.isInteger(v) && v > 0 ? v : 3000; })(); /** Attribution — this connector's pod-lifecycle + idle-auto-stop UX is modeled on * gpu-cli (https://gpu-cli.sh), a great cloud-GPU CLI. Surfaced in the control * panels + deploy flow to credit them and send traffic their way. */ export const GPU_CLI_URL = "https://gpu-cli.sh"; export const GPU_CLI_CREDIT = `Pod control inspired by gpu-cli.sh (${GPU_CLI_URL}) — a cloud-GPU CLI worth checking out.`; /** The referral deploy link — hand this to a user who needs to CREATE a pod so * their signup/spend credits our referral. Carries the template + ref code. */ export function runpodDeployLink() { return `https://console.runpod.io/deploy?template=${RUNPOD_TEMPLATE_ID}&ref=${RUNPOD_REF_CODE}`; } /** The public HTTPS URL RunPod proxies a pod's HTTP port at. For ComfyUI (3000) * this is the URL to point comfyui-mcp at. Only reachable while the pod RUNS and * the port is exposed as an HTTP port on the pod/template. */ export function runpodProxyUrl(podId, port = RUNPOD_COMFYUI_PORT) { return `https://${podId}-${port}.proxy.runpod.net`; } /** Thrown when RUNPOD_API_KEY is absent — the caller turns this into a clear, * actionable tool error (how to set it) rather than a raw crash. */ export class RunpodAuthError extends Error { constructor(message = "RUNPOD_API_KEY is not set. Add it to ~/.comfyui-mcp/.env (get a key at console.runpod.io → Settings → API Keys) so the RunPod connector can manage your pods.") { super(message); this.name = "RunpodAuthError"; } } function getApiKey() { // Resolved at ACCESS time from the canonical ~/.comfyui-mcp/.env (#826): a key // the panel saved AFTER this process started is otherwise invisible for the // whole life of the process, so every RunPod call keeps reporting "not set" // while a valid key sits on disk. const key = freshSecretValue("RUNPOD_API_KEY")?.trim(); if (!key) throw new RunpodAuthError(); return key; } /** Per-request timeout for RunPod API calls — a hung network must not pile up * poller ticks or wedge a tool call forever. Override with RUNPOD_HTTP_TIMEOUT_MS. */ export const RUNPOD_HTTP_TIMEOUT_MS = (() => { const v = Number(process.env.RUNPOD_HTTP_TIMEOUT_MS); return Number.isFinite(v) && v > 0 ? v : 10_000; })(); /** POST a GraphQL query to RunPod. Throws RunpodAuthError on 401, a descriptive * Error on GraphQL/HTTP errors. The api_key never appears in thrown messages. */ export async function runpodGql(query, variables) { const key = getApiKey(); let res; try { res = await fetch(RUNPOD_GRAPHQL_ENDPOINT, { method: "POST", headers: { "content-type": "application/json", // Bearer header, NOT `?api_key=` in the URL — URLs land in logs. authorization: `Bearer ${key}`, }, body: JSON.stringify({ query, variables: variables ?? {} }), signal: AbortSignal.timeout(RUNPOD_HTTP_TIMEOUT_MS), }); } catch (err) { const msg = err instanceof Error ? err.message : String(err); const timedOut = err instanceof Error && (err.name === "TimeoutError" || err.name === "AbortError"); throw new Error(timedOut ? `RunPod API request timed out after ${RUNPOD_HTTP_TIMEOUT_MS / 1000}s (${RUNPOD_GRAPHQL_ENDPOINT}).` : `Could not reach RunPod (${RUNPOD_GRAPHQL_ENDPOINT}): ${msg}`); } if (res.status === 401) throw new RunpodAuthError("RunPod rejected the API key (401). Check RUNPOD_API_KEY in ~/.comfyui-mcp/.env."); const body = (await res.json().catch(() => ({}))); if (body.errors) { // RunPod returns a generic message for an unknown podId; surface it plainly. throw new Error(`RunPod API error: ${JSON.stringify(body.errors)}`); } if (!res.ok) throw new Error(`RunPod API HTTP ${res.status}`); return body.data; } // ── Response validation (#269) ────────────────────────────────────────────── // GraphQL responses used to be blind-cast (`as T`): a RunPod shape change then // silently produced WRONG values (a null uptime reading as "idle", a renamed // status field disabling auto-stop). Parse what we read instead — a mismatch // throws loudly at the boundary. Schemas are LOOSE (extra fields pass through) // so additive API changes never break us; only changes to fields we USE do. const PortSchema = z.looseObject({ // Every leaf nullable: RunPod's GraphQL declares these WITHOUT `!` — a null // port field or GPU reading is API-VALID and must not throw (#269 r2). ip: z.string().nullable(), isIpPublic: z.boolean().nullable(), privatePort: z.number().nullable(), publicPort: z.number().nullable(), type: z.string().nullable(), }); const GpuRuntimeSchema = z.looseObject({ id: z.string().nullable(), gpuUtilPercent: z.number().nullable(), memoryUtilPercent: z.number().nullable(), }); const PodSchema = z.looseObject({ id: z.string(), name: z.string().nullable(), desiredStatus: z.string(), costPerHr: z.number().nullable(), machine: z.looseObject({ gpuDisplayName: z.string().nullable() }).nullable(), // Absent on fresh deploys (still booting) → optional as well as nullable. runtime: z .looseObject({ uptimeInSeconds: z.number().nullable(), ports: z.array(PortSchema).nullable(), gpus: z.array(GpuRuntimeSchema).nullable(), }) .nullish(), }); const PodStatusSchema = z.looseObject({ id: z.string(), desiredStatus: z.string() }); /** Parse `data` against `schema`; on mismatch throw an error that NAMES the * operation and shows the first few issues, instead of returning garbage. */ function validate(schema, data, what) { const r = schema.safeParse(data); if (r.success) return r.data; const issues = r.error.issues .slice(0, 3) .map((i) => `${i.path.join(".") || "(root)"}: ${i.message}`) .join("; "); throw new Error(`RunPod API returned an unexpected shape for ${what} — the API may have changed (${issues}).`); } const POD_FIELDS = ` id name desiredStatus costPerHr machine { gpuDisplayName } runtime { uptimeInSeconds ports { ip isIpPublic privatePort publicPort type } gpus { id gpuUtilPercent memoryUtilPercent } } `; /** Fetch one pod by id. Returns null when RunPod has no such pod on this account. */ export async function getPod(podId) { const data = await runpodGql(`query Pod($input: PodFilter!) { pod(input: $input) { ${POD_FIELDS} } }`, { input: { podId } }); validate(z.looseObject({ pod: PodSchema.nullable() }), data, `getPod(${podId})`); return data.pod ?? null; } /** List every pod on the account (for "which pod?" when no id is given). */ export async function listPods() { const data = await runpodGql(`query { myself { pods { ${POD_FIELDS} } } }`); // Nullable-but-REQUIRED wrappers: an ABSENT property is an API shape change // and must throw (never read as "no such pod" — codex r2), while an explicit // null is a legitimate answer. validate(z.looseObject({ myself: z.looseObject({ pods: z.array(PodSchema) }).nullable() }), data, "listPods"); return data.myself?.pods ?? []; } /** Resume (start) a stopped/exited pod. gpuCount defaults to 1. Returns the pod's * new desiredStatus (RUNNING) — the container still needs time to boot after. * * NO CUDA FILTER HERE, DELIBERATELY (#2016). A resumed pod can be placed on * different hardware than it was created on, so the CUDA floor createPod * enforces does NOT carry across a stop/start — but RunPod's API exposes no * lever to re-assert it: `PodResumeInput` has NO CUDA field under any name. * Probed against the live api.runpod.io schema on 2026-08-21 by sending each * candidate with a wrong-typed `podId`, so variable coercion failed and the * resolver never ran (no pod was resumed): * * allowedCudaVersions → Field "allowedCudaVersions" is not defined by type "PodResumeInput". * cudaVersion / cudaVersions / allowedCudaVersion / minCudaVersion → same * gpuCount → accepted (control: a KNOWN field draws no such error) * * runpod-python's `generate_pod_resume_mutation` agrees — it sends only podId * and gpuCount. docs.runpod.io/sdks/graphql/manage-pods shows an example with * `allowedCudaVersions` on podResume; that example is WRONG, and copying it * turns every start into a GraphQL validation failure. Adding the field here * BREAKS `runpod action:"start"` outright — the test * "sends NO CUDA filter on resume — PodResumeInput has no such field" pins it. */ export async function resumePod(podId, gpuCount = 1) { const data = await runpodGql(`mutation Resume($input: PodResumeInput!) { podResume(input: $input) { id desiredStatus } }`, { input: { podId, gpuCount } }); validate(z.looseObject({ podResume: PodStatusSchema }), data, `resumePod(${podId})`); return data.podResume; } /** Stop a running pod (keeps the pod + its disk; billing for GPU-time stops). * Returns the new desiredStatus (EXITED). */ export async function stopPod(podId) { const data = await runpodGql(`mutation Stop($input: PodStopInput!) { podStop(input: $input) { id desiredStatus } }`, { input: { podId } }); validate(z.looseObject({ podStop: PodStatusSchema }), data, `stopPod(${podId})`); return data.podStop; } /** True when the pod exposes ComfyUI's port as an HTTP proxy port and is running. */ export function comfyuiPortExposed(pod) { const ports = pod.runtime?.ports ?? []; return ports.some((p) => p.privatePort === RUNPOD_COMFYUI_PORT && p.type === "http"); } // ── Dead-man switch (#269) ─────────────────────────────────────────────────── // The in-process idle auto-stop dies with the orchestrator: a crash (or a closed // laptop) used to leave a pod RUNNING — and billing — forever. Pods we CREATE // now carry a pod-side watchdog (docker/runpod/deadman_*): a heartbeat server on // DEADMAN_PORT plus a loop that stops the pod itself when our heartbeats stop. // Semantics: while comfyui-mcp minds a pod it beats every poll; if it vanishes // (crash/offline) the pod STOPS ITSELF after the beat grace — a managed pod is // never orphaned into infinite billing. // CREDENTIALS: the watchdog stops the pod with the POD-SCOPED RunPod API key // that RunPod auto-injects into every pod (docs.runpod.io/pods/templates/ // environment-variables) — the owner's account-wide key NEVER leaves the // orchestrator. No key injection, nothing to opt out of on credential grounds; // `deadman:false` / RUNPOD_DEADMAN=0 / pod env DEADMAN_DISABLE=1 simply leave // the watchdog unarmed. /** The pod's heartbeat-server port (exposed as an HTTP proxy port at create). */ export const RUNPOD_DEADMAN_PORT = 8189; /** Default for the deadman create option; RUNPOD_DEADMAN=0/false/off opts out. */ export const RUNPOD_DEADMAN_DEFAULT = (() => { const v = process.env.RUNPOD_DEADMAN?.trim().toLowerCase(); return !(v === "0" || v === "false" || v === "off"); })(); function graceEnv(name, fallback) { const v = Number(process.env[name]); return Number.isFinite(v) && v >= 60 ? Math.floor(v) : fallback; } /** Seconds a fresh pod may run with NO heartbeat before self-stopping (must * cover boot + the user's connect window; beats only start once we manage it). */ export const DEADMAN_BOOT_GRACE_S = graceEnv("RUNPOD_DEADMAN_BOOT_GRACE_S", 2700); /** Seconds without a heartbeat after which a MANAGED pod self-stops. Poll is * 15s by default, so 20min tolerates long network outages + restarts. */ export const DEADMAN_BEAT_GRACE_S = graceEnv("RUNPOD_DEADMAN_BEAT_GRACE_S", 1200); /** The heartbeat token for a pod. Derived from the API key + pod NAME (which * createPod makes unique per deploy) so it is per-deploy unguessable yet needs * NO local storage — any process holding the key (watcher after a restart, a * respawned child) recomputes it. It authorizes heartbeats ONLY (not the API); * it rides the proxy URL's query string, which is acceptable for exactly that * reason — it is not the API key and cannot stop the pod. */ export function deadmanToken(podName) { return createHash("sha256").update(`deadman:${getApiKey()}:${podName}`).digest("hex"); } /** Feed a pod's dead-man watchdog. BEST-EFFORT: returns false on any failure * (pod has no watchdog — old image/console deploy — proxy down, etc.); the * caller must never let a failed beat break the poll path it rides on. */ export async function beatDeadman(pod) { if (!pod.name) return false; try { const res = await fetch(`${runpodProxyUrl(pod.id, RUNPOD_DEADMAN_PORT)}/heartbeat?token=${deadmanToken(pod.name)}`, { signal: AbortSignal.timeout(3_000), }); return res.ok; } catch { return false; } } // ── Pod creation (deploy our template via the API) ─────────────────────────── // The referral in runpodDeployLink() attaches a NEW signup to our account; once // a user is a referred signup, EVERY pod they create — API or console — credits // us. So a user with a RunPod account + API key can one-tap deploy our template // here, while runpod action:"deploy_link" onboards brand-new users. GPU availability is // spotty on-demand, so createPod tries a list of GPU types in order until one // deploys (all 24GB+, enough for krea2 etc.). /** GPU types tried in order by createPod (24GB+, community-common). Override via * RUNPOD_GPU_TYPES (comma-separated) for other budgets/regions. */ export const RUNPOD_DEFAULT_GPU_TYPES = (process.env.RUNPOD_GPU_TYPES?.split(",").map((s) => s.trim()).filter(Boolean) || [ // Ordered by rough preference; createPod tries each (× COMMUNITY then SECURE) // until one has capacity, so a crunch on one card falls through to the next. "NVIDIA GeForce RTX 4090", "NVIDIA RTX A6000", "NVIDIA RTX PRO 4500 Blackwell", "NVIDIA A40", "NVIDIA RTX A5000", ]); // ── Stock-image deploy floors (#2014, #2016) ───────────────────────────────── // Both of these describe OUR image, so both apply only when the deploy targets // the STOCK template — same scoping rule the dead-man switch uses below. A user // pointing RUNPOD_TEMPLATE_ID / opts.templateId at their own image may // legitimately want a smaller disk or a different CUDA floor, and we cannot // verify what their image needs. /** Container disk (GB) a STOCK-template deploy gets. docker/runpod/README.md's * "Deploy on RunPod" table requires **>= 30 GB** ("the baked ComfyUI + venv + * cu128 torch + any runtime-installed nodes/caches, which are ephemeral"). * We used to send 20 — matching the RunPod template's own setting, which is * ALSO below the documented floor — and the pod was created, billed, and never * came up: desiredStatus RUNNING, `runtime` null, no ports, unreachable. */ export const RUNPOD_STOCK_CONTAINER_DISK_GB = 30; /** Container disk (GB) sent for a NON-stock template. Unchanged from the value * every deploy used before #2014 — raising it for images we did not build * would silently bill those users for disk they may not need. */ export const RUNPOD_DEFAULT_CONTAINER_DISK_GB = 20; /** The host CUDA floor the STOCK image needs: driver >= 570 == CUDA 12.8. Set * by docker/runpod/Dockerfile (`MIN_DRIVER_DEFAULT=570`, cu128 torch) and * enforced pod-side by docker/runpod/post_start.sh's driver preflight, which * refuses to launch and holds the pod open on an older host. */ const STOCK_MIN_CUDA_MAJOR = 12; const STOCK_MIN_CUDA_MINOR = 8; /** RunPod matches `allowedCudaVersions` as an ALLOW-LIST of exact "major.minor" * strings against the host's CUDA version — there is no ">=" form and no way * to enumerate what the fleet reports (GraphQL introspection is disabled; * Query/GpuType/PodMachineInfo expose no CUDA field). So the list is GENERATED * from the floor rather than pinned to the versions that happen to exist today. * * Why generate past 13.x: an OVER-NARROW list is the dangerous direction. Too * broad reproduces #2016 (a pod on an incompatible host); too narrow matches * NO host, every GPU/cloud slot comes back "no capacity", and one-tap deploy * stops working entirely the day RunPod's fleet moves to a CUDA we did not * foresee. Version strings that do not exist are inert, not an error — probed * against the live schema, `allowedCudaVersions` is a free-form [String] that * accepts "banana" without complaint, and an unmatched entry simply never * selects a host. Padding forward therefore costs nothing and buys durability. */ const CUDA_ALLOWLIST_MAX_MAJOR = 14; function cudaVersionsAtLeast(minMajor, minMinor) { const out = []; for (let major = minMajor; major <= CUDA_ALLOWLIST_MAX_MAJOR; major++) { // CUDA has never shipped a minor above 9 (12.0-12.9, then 13.0). for (let minor = major === minMajor ? minMinor : 0; minor <= 9; minor++) out.push(`${major}.${minor}`); } return out; } /** Host CUDA versions a STOCK-template deploy will accept (>= 12.8). */ export const RUNPOD_STOCK_ALLOWED_CUDA_VERSIONS = cudaVersionsAtLeast(STOCK_MIN_CUDA_MAJOR, STOCK_MIN_CUDA_MINOR); /** What a CONSOLE deploy must set BY HAND, for anywhere we hand out * runpodDeployLink() (codex gate, #2014/#2016). * * createPod sends both floors on the API path, but the console path does not * go through createPod: the user fills RunPod's own deploy form, seeded by the * template — and the template itself is still registered at containerDiskInGb * 20 with no CUDA constraint, so following the link with its defaults * reproduces BOTH defects on a pod the user pays for. RunPod documents no * query parameters that would let the link carry overrides, and the template * lives on the RunPod account rather than in this repo, so the only thing that * closes the gap from here is saying so. Built from the SAME constants the API * path sends, so the advice can never drift from the behaviour. */ export function runpodDeployRequirements() { // Scoped to the stock template exactly like the API-path defaults are: the // link carries RUNPOD_TEMPLATE_ID, and if that points at someone else's // image these are not our floors to state. Empty string = nothing to add; // callers must not render a stray blank block for it. if (RUNPOD_TEMPLATE_ID !== DEFAULT_TEMPLATE_ID) return ""; return (`Two settings on that page are NOT optional for this image — the template's own defaults ` + `are below both, and a pod that misses either boots into an unusable state you still pay for:\n` + ` • Container disk: at least ${RUNPOD_STOCK_CONTAINER_DISK_GB} GB (the template still defaults to ` + `${RUNPOD_DEFAULT_CONTAINER_DISK_GB} GB).\n` + ` • CUDA version: ${RUNPOD_STOCK_ALLOWED_CUDA_VERSIONS[0]} or newer — filter by "CUDA Version" on the ` + `deploy screen. The image is a cu128 build and refuses to start on an older host.`); } /** Escape hatch for a fleet whose CUDA strings we guessed wrong, and the only * way to widen/narrow the filter without a code change. Comma-separated * ("12.8,12.9"); set it EMPTY to send no filter at all. Unset = use the * stock default above (and no filter for a custom template). An explicit * value applies to custom templates too — the user is asserting it. */ export const RUNPOD_ALLOWED_CUDA_VERSIONS = process.env.RUNPOD_ALLOWED_CUDA_VERSIONS === undefined ? undefined : process.env.RUNPOD_ALLOWED_CUDA_VERSIONS.split(",").map((s) => s.trim()).filter(Boolean); /** Which template a deploy targets — the stock-only defaults key off this. */ function targetTemplateId(opts) { return opts.templateId ?? RUNPOD_TEMPLATE_ID; } /** Container disk (GB) this deploy will request (#2014). ONE source of truth: * deployOnce sends it and the tests assert against it. */ function effectiveContainerDiskGb(opts) { if (opts.containerDiskInGb !== undefined) return opts.containerDiskInGb; return targetTemplateId(opts) === DEFAULT_TEMPLATE_ID ? RUNPOD_STOCK_CONTAINER_DISK_GB : RUNPOD_DEFAULT_CONTAINER_DISK_GB; } /** The CUDA allow-list this deploy will send, or undefined/[] for no filter * (#2016). ONE source of truth: deployOnce sends it AND createPod's * everything-failed error explains it, so the two can never disagree. */ function effectiveAllowedCudaVersions(opts) { if (opts.allowedCudaVersions !== undefined) return opts.allowedCudaVersions; if (RUNPOD_ALLOWED_CUDA_VERSIONS !== undefined) return RUNPOD_ALLOWED_CUDA_VERSIONS; return targetTemplateId(opts) === DEFAULT_TEMPLATE_ID ? RUNPOD_STOCK_ALLOWED_CUDA_VERSIONS : undefined; } async function deployOnce(cloudType, gpuTypeId, opts) { const podName = opts.name ?? "comfyui-mcp"; const templateId = opts.templateId ?? RUNPOD_TEMPLATE_ID; // Dead-man defaults ON only for OUR stock template — a template override // (RUNPOD_TEMPLATE_ID / opts.templateId) points at an image we can't verify // SHIPS the watchdog (arming one without it would silently grant nothing). // Overrides need an explicit deadman:true — the caller is asserting their // image ships the watchdog. const deadman = opts.deadman ?? (templateId === DEFAULT_TEMPLATE_ID && RUNPOD_DEADMAN_DEFAULT); const containerDiskInGb = effectiveContainerDiskGb(opts); const allowedCudaVersions = effectiveAllowedCudaVersions(opts); const data = await runpodGql(`mutation Deploy($input: PodFindAndDeployOnDemandInput!) { podFindAndDeployOnDemand(input: $input) { id name desiredStatus costPerHr machine { gpuDisplayName } } }`, { input: { cloudType, gpuCount: opts.gpuCount ?? 1, gpuTypeId, templateId, name: podName, containerDiskInGb, volumeInGb: opts.volumeInGb ?? 60, volumeMountPath: "/workspace", // Keep RunPod off hosts the image cannot run on (#2016). Omitted // entirely when empty — sending `allowedCudaVersions: []` would match // no host and make the pod unschedulable. ...(allowedCudaVersions?.length ? { allowedCudaVersions } : {}), // Guarantee ComfyUI is reachable through RunPod's HTTP proxy even if // the template's port config drifts — AND expose ssh (22/tcp) so the // pod-native trainer can drive ai-toolkit over it (codex finding: // one-tap pods had no SSH endpoint and rejected every pod job). // The deadman port only matters on images that ship the watchdog. ports: `${RUNPOD_COMFYUI_PORT}/http,22/tcp${deadman ? `,${RUNPOD_DEADMAN_PORT}/http` : ""}`, env: [ // SSH auth for the trainer: the template injects this at boot. ...(opts.publicKey ? [{ key: "PUBLIC_KEY", value: opts.publicKey }] : []), // Dead-man switch (#269): heartbeat token + grace knobs. NO API key // here — the watchdog self-stops with the POD-SCOPED key RunPod // auto-injects; the owner's account key never leaves this process. ...(deadman ? [ { key: "DEADMAN_TOKEN", value: deadmanToken(podName) }, { key: "DEADMAN_BOOT_GRACE_S", value: String(DEADMAN_BOOT_GRACE_S) }, { key: "DEADMAN_BEAT_GRACE_S", value: String(DEADMAN_BEAT_GRACE_S) }, ] : []), ], }, }); // Nullable-but-REQUIRED: explicit null = "no capacity for this slot" (try // the next), but an ABSENT property is a shape change → validate THROWS → // createPod's generic catch treats the create as AMBIGUOUS and reconciles // instead of falling through to another billed attempt (codex r2). validate(z.looseObject({ podFindAndDeployOnDemand: PodSchema.nullable() }), data, "createPod(deploy)"); const pod = data.podFindAndDeployOnDemand; return pod?.id ? { ...pod, runtime: pod.runtime ?? null } : null; } /** Does this createPod failure PROVE RunPod did NOT create (and bill) a pod? * Only an explicit capacity/availability/quota rejection from RunPod's own * GraphQL layer qualifies — the server processed the mutation and refused it. * Everything else (network drop, timeout, 5xx, parse failure, rate limit) is * AMBIGUOUS: the billed mutation may have landed even though we never saw the * response, so blindly retrying could spawn a second billable pod. */ export function isProvablyNotCreatedError(err) { const msg = err instanceof Error ? err.message : String(err); return NOT_CREATED_RE.test(msg); } // CONSERVATIVE by design: match ONLY RunPod's explicit capacity/availability/ // quota REJECTION clauses. An error we don't specifically recognize is treated // as AMBIGUOUS (a pod MAY have been created) → the caller must NOT retry. // // The "…capacity" clauses carry a negative lookahead that EXCLUDES the "capacity // information / details / data / status" wording used by AMBIGUOUS "status // unknown" messages — e.g. "response contained no capacity information, so // creation status is unknown" must NOT be read as a capacity rejection, or a // lost-but-landed create would be retried and double-bill. Likewise the // availability clauses require the concrete "instances available" phrasing, not // any sentence containing "there are no …". const CAP_NOISE = "(?!\\s+(?:information|informations|data|details?|info|status|unknown))"; const NOT_CREATED_RE = new RegExp([ "no longer any instances? available", "there are no (?:longer any )?(?:gpu )?instances? available", "no (?:gpu )?instances? (?:currently )?available", `\\bno capacity${CAP_NOISE}`, `(?:not enough|insufficient) (?:gpu )?capacity${CAP_NOISE}`, "(?:not enough|insufficient) (?:gpu instances|gpus|instances)", "out of stock", "quota (?:exceeded|reached)", ].join("|"), "i"); /** Reconcile after an AMBIGUOUS create failure: did a NEW pod with the requested * name appear (i.e. did the lost mutation actually land)? `priorIds` is the set * of same-named pod ids that existed BEFORE this createPod call — null when we * couldn't snapshot them, in which case we cannot tell new from pre-existing. * * CONCURRENCY HAZARD: pod name is NOT unique, so two concurrent createPod calls * for the same default name ("comfyui-mcp") each see the OTHER's pod as "new". * We therefore only attribute a pod to THIS call when EXACTLY ONE new same-named * pod appeared; if several did, we fail closed (kind:"ambiguous") rather than * risk claiming (and then managing/auto-stopping) a pod a different caller made. * * RESIDUAL LIMITATION (mis-attribution, NOT extra billing): the single-new-pod * path can still claim a pod created by a CONCURRENT caller if THIS call failed * before creating anything AND, in the same window, exactly one other same-named * pod appeared. The claimed pod exists regardless (no extra spend); the only * harm is that the wrong caller manages/auto-stops it. A fully correct fix needs * a per-create idempotency token or a unique deploy name (a RunPod-API design * change owned by the connector author). Tracked in issue #276 — see the note at * the createPod call site (~line 400). Not blocking: the common single-caller * path is correct and the multi-new-pod case is already fail-closed. */ async function reconcileCreatedPod(name, priorIds) { if (!priorIds) return { kind: "unknown" }; let created; try { const pods = await listPods(); created = pods.filter((p) => p.name === name && !priorIds.has(p.id)); } catch { return { kind: "unknown" }; // reconcile list failed — never retry blindly } if (created.length === 0) return { kind: "none" }; if (created.length === 1) return { kind: "one", pod: created[0] }; return { kind: "ambiguous", count: created.length }; } /** Deploy a fresh on-demand pod from our template. Community GPU capacity is * spotty, so unless a cloud type is pinned we try COMMUNITY (cheap) across each * GPU type, then SECURE (reliable, pricier) — the first slot with capacity wins, * so one-tap deploy survives community supply constraints. Throws a descriptive * error listing what RunPod rejected if nothing is available. The returned pod * is fresh (runtime null — still booting; follow with getPod / runpod action:"connect"). * * BILLING SAFETY: podFindAndDeployOnDemand is a non-idempotent, BILLED mutation. * We only move on to the next cloud/GPU slot when RunPod EXPLICITLY rejected the * deploy (capacity/quota — provably nothing was created). On any AMBIGUOUS * failure (network/timeout/5xx/parse — the pod may exist even though the * response was lost) we reconcile by listing pods under the requested name: if * this call's pod appeared, return it; otherwise fail WITHOUT retrying so a * lost-response create can never fan out into extra billable pods. Auth errors * surface immediately. */ export async function createPod(opts = {}) { const gpuTypeIds = opts.gpuTypeIds?.length ? opts.gpuTypeIds : RUNPOD_DEFAULT_GPU_TYPES; const cloudTypes = opts.cloudType ? [opts.cloudType] : ["COMMUNITY", "SECURE"]; // Mis-attribution mitigation (#276): pod name is NOT unique, so a shared // default ("comfyui-mcp") let a lost-response reconcile claim a CONCURRENT // caller's same-named pod. RunPod exposes no per-create idempotency token, so // instead we default to a UNIQUE deploy name — then reconcileCreatedPod's // `name` filter attributes exactly THIS call's pod and concurrent creates no // longer collide. An explicitly-passed name keeps its old (shared) semantics: // the caller opted in. const name = opts.name ?? `comfyui-mcp-${Date.now().toString(36)}${Math.random().toString(36).slice(2, 6)}`; // Ensure deployOnce sends the SAME name we reconcile against (its own default // would otherwise be the shared "comfyui-mcp"). opts = { ...opts, name }; // Snapshot the ids of pods ALREADY carrying the requested name, so post-failure // reconciliation can tell a pod THIS call created apart from a pre-existing one. let priorIds = null; try { priorIds = new Set((await listPods()).filter((p) => p.name === name).map((p) => p.id)); } catch { priorIds = null; // snapshot unavailable → ambiguous failures become terminal } const attempts = []; for (const cloudType of cloudTypes) { for (const gpuTypeId of gpuTypeIds) { try { const pod = await deployOnce(cloudType, gpuTypeId, opts); if (pod) return pod; attempts.push(`${cloudType}/${gpuTypeId}: no capacity available`); } catch (err) { const msg = err instanceof Error ? err.message : String(err); // Auth rejection happens before any pod is created — surface it directly // (retrying other slots with the same bad key is pointless). if (err instanceof RunpodAuthError) throw err; if (isProvablyNotCreatedError(err)) { // RunPod itself said "no capacity/quota" → nothing was created; the // next cloud/GPU slot is safe to try. attempts.push(`${cloudType}/${gpuTypeId}: ${msg}`); continue; } // AMBIGUOUS failure: the billed mutation may have landed. Reconcile first. const rec = await reconcileCreatedPod(name, priorIds); // Exactly one new same-named pod → attribute it to this call. NOTE: a // concurrent same-name create can make this mis-attribute (harm = // wrong owner manages the pod, NOT extra billing); a proper fix needs a // per-create idempotency key / unique deploy name — tracked in issue #276. if (rec.kind === "one") return rec.pod; const suffix = attempts.length ? `\nEarlier attempts:\n${attempts.map((a) => ` • ${a}`).join("\n")}` : ""; if (rec.kind === "ambiguous") { throw new Error(`RunPod pod creation on ${cloudType}/${gpuTypeId} failed ambiguously (${msg}) and ` + `${rec.count} new pods named "${name}" appeared — likely a concurrent create raced ` + `this one, so which pod belongs to this call can't be determined. NOT retrying and ` + `NOT claiming any of them automatically (a wrong guess could auto-stop someone else's ` + `pod or leak a billable one). Reconcile manually at console.runpod.io (or ` + `runpod action:"status").` + suffix); } throw new Error(`RunPod pod creation failed on ${cloudType}/${gpuTypeId} and it could not be confirmed ` + `whether a pod was created (${msg}). NOT retrying automatically — a retry could create ` + `a second billable pod. Check your pods at console.runpod.io (or runpod action:"status") ` + `before trying again.` + suffix); } } } // Name the CUDA filter when one was applied (#2016). It restricts which hosts // can match, so "no capacity everywhere" has a second possible cause now — and // an unschedulable pod would otherwise look exactly like a capacity crunch. const cuda = effectiveAllowedCudaVersions(opts); const cudaNote = cuda?.length ? `\nThis deploy also required host CUDA ${cuda[0]}+ (allowedCudaVersions), which our image needs — ` + `if RunPod reports a CUDA version outside [${cuda.join(", ")}], set RUNPOD_ALLOWED_CUDA_VERSIONS ` + `to the versions your fleet offers (empty to send no filter).` : ""; throw new Error(`Could not deploy a pod on any of [${gpuTypeIds.join(", ")}] in ${cloudTypes.join("/")}. RunPod reported:\n` + attempts.map((a) => ` • ${a}`).join("\n") + `\nTry again shortly (capacity fluctuates), set RUNPOD_GPU_TYPES to other GPUs, ` + `or deploy from the console with runpod action:"deploy_link".` + cudaNote); } //# sourceMappingURL=runpod-client.js.map