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),

555 lines • 23.9 kB
import { config } from "../config.js"; import { ModelError, ValidationError } from "../utils/errors.js"; import { logger } from "../utils/logger.js"; import { htmlToMarkdown } from "../utils/html-to-markdown.js"; import { civitaiDisabled, CIVITAI_DISABLED_MESSAGE } from "./model-resolver.js"; const CIVITAI_API_BASE = "https://civitai.com/api/v1"; function authHeaders() { const headers = {}; // Captured ONCE: the credential getters resolve from the canonical store on // every access, so testing one read and interpolating another can send a // different token — or `Bearer undefined` — if the store is rewritten in // between (codex gate, round 6, finding 3). const civitaiToken = config.civitaiApiToken; if (civitaiToken) { headers["Authorization"] = `Bearer ${civitaiToken}`; } return headers; } /** * Ceiling on any single CivitAI request. Without it a hung connection wedges * the tool forever — the worst outcome of all (neither a surfaced error nor an * honest empty). Mirrors the 10s guard the provenance hash-lookup already uses; * a touch longer here because search/model reads can be heavier. */ const CIVITAI_TIMEOUT_MS = 15000; /** * Translate a raw (non-HTTP) transport failure into a DISTINCT, actionable * ModelError, distinguishing our own timeout/abort from a connection-level * network error. Shared by the initial fetch AND the body read, because an * AbortError/connection reset can fire at EITHER point (after headers arrive * but before the body is drained) and must be labelled the same way — never * misread as a bot-gate/non-JSON page. * Returns null when `err` is not a transport failure (e.g. a JSON SyntaxError), * so the caller can fall through to its own classification. */ function transportError(err, url) { const name = err instanceof Error ? err.name : ""; if (name === "TimeoutError" || name === "AbortError") { return new ModelError(`CivitAI request timed out after ${CIVITAI_TIMEOUT_MS / 1000}s ` + `(civitai.com slow or unreachable) — try again shortly.`, { url, timeout: true }); } if (err instanceof TypeError) { // A malformed Authorization header (e.g. CIVITAI_API_TOKEN pasted with // non-ASCII characters) ALSO rejects as TypeError, but it is a LOCAL // credential-config fault, not a connectivity fault. Labelling it // "unreachable / check connectivity" sends the caller down the wrong // diagnostic path. if (/ByteString|greater than 255|Invalid header|invalid header/i.test(err.message)) { return new ModelError(`CivitAI credential is invalid: the API token contains characters that ` + `cannot be sent in an HTTP header (non-ASCII). Re-enter CIVITAI_API_TOKEN as the raw ASCII ` + `token — no quotes, no "Bearer " prefix, no label or translated text. (raw: ${err.message})`, { url, credential: true }); } return new ModelError(`CivitAI is unreachable (network error: ${err.message}) — ` + `check connectivity and try again.`, { url, network: true }); } return null; } /** * Perform the fetch and translate every non-HTTP failure mode (DNS/connection * down, TLS, or our own timeout abort) into a DISTINCT, actionable ModelError. * A bare `fetch` rejects with an opaque `TypeError: fetch failed` (or a * `TimeoutError`) that, once caught upstream, is indistinguishable from any * other crash — exactly the "silently swallowed" failure WS-6 set out to kill. */ async function civitaiFetch(url, headers) { try { return await fetch(url, { headers, signal: AbortSignal.timeout(CIVITAI_TIMEOUT_MS), }); } catch (err) { throw (transportError(err, url) ?? new ModelError(`CivitAI is unreachable (network error: ` + `${err instanceof Error ? err.message : String(err)}) — ` + `check connectivity and try again.`, { url, network: true })); } } /** * Turn a non-OK HTTP status into a DISTINCT, actionable ModelError so an * auth/rate-limit/upstream failure is never conflated with each other — or, * worse, with a genuine empty result. Callers handle 404 themselves (a 404 is * "resource absent", a definitive answer, not a fault). * * `tokenSent` reflects whether THIS request actually carried a bearer token — * NOT whether one is configured globally. The tRPC leaderboard deliberately * sends none, so a bot-gate 401 there must read as "auth required", never * "your configured token is invalid". */ async function throwForCivitaiStatus(res, url, tokenSent) { // unknown-ok: "" is interpolated into an ERROR MESSAGE and nothing else — the // HTTP status is reported either way, so an unreadable body costs detail in the // text, never a wrong conclusion. Verified there is no branch on this value. const body = await res.text().catch(() => ""); const status = res.status; let message; if (status === 401) { message = tokenSent ? `CivitAI rejected the API token (401 Unauthorized) — it is invalid or ` + `expired; refresh CIVITAI_API_TOKEN.` : `CivitAI requires authentication for this request (401 Unauthorized) — ` + `set CIVITAI_API_TOKEN.`; } else if (status === 403) { message = `CivitAI refused the request (403 Forbidden) — the token lacks ` + `permission, or access is region-blocked.`; } else if (status === 429) { message = `CivitAI rate-limited the request (429 Too Many Requests) — ` + `wait a moment and retry.`; } else if (status >= 500) { message = `CivitAI upstream error (${status} ${res.statusText}) — the site is ` + `having trouble; this is transient, retry shortly.`; } else { message = `CivitAI API ${status}: ${res.statusText}`; } throw new ModelError(message, { url, status, body }); } /** * Parse a response body as JSON, converting a non-JSON 2xx (e.g. a Cloudflare * challenge or HTML interstitial served with a 200) into a distinct error * rather than an opaque SyntaxError that reads as an empty/garbage result. */ async function parseCivitaiJson(res, url) { try { return (await res.json()); } catch (err) { // An abort/timeout or connection reset can surface HERE (headers arrived, // body drain failed). Classify it as the transport failure it is — only a // genuine parse failure (SyntaxError) is the bot-gate/non-JSON case. const transport = transportError(err, url); if (transport) throw transport; throw new ModelError(`CivitAI returned a non-JSON response (HTTP ${res.status}) — likely a ` + `bot-gate or interstitial page rather than the API; try again shortly.`, { url, status: res.status, nonJson: true }); } } async function civitaiGet(path) { // User-initiated Civitai actions fail FAST with the config explanation when // the kill-switch is set (issue #127) — never a hang against a blocked host. if (civitaiDisabled()) { throw new ModelError(CIVITAI_DISABLED_MESSAGE); } const url = `${CIVITAI_API_BASE}${path}`; logger.debug("CivitAI API request", { url }); const headers = authHeaders(); const res = await civitaiFetch(url, headers); if (res.status === 404) { throw new ModelError(`CivitAI resource not found: ${path}`, { url, status: 404, }); } if (!res.ok) { await throwForCivitaiStatus(res, url, "Authorization" in headers); } return parseCivitaiJson(res, url); } /** Pick the best file from a version's file list: primary first, else the first. */ function pickFile(version) { const files = version.files ?? []; return files.find((f) => f.primary) ?? files[0]; } /** Max example generations captured into the sidecar (those carrying params first). */ const MAX_EXAMPLES = 8; function buildExamples(version) { const imgs = version.images ?? []; // Prefer examples that actually carry generation params (a usable recipe). const withMeta = imgs.filter((i) => i.meta && Object.keys(i.meta).length > 0); const ordered = [...withMeta, ...imgs.filter((i) => !withMeta.includes(i))]; return ordered.slice(0, MAX_EXAMPLES).map((i) => ({ url: i.url, type: i.type, width: i.width, height: i.height, nsfwLevel: i.nsfwLevel, meta: i.meta ?? null, })); } function buildMetadata(version, file, model) { const modelId = model?.id; const sourceUrl = modelId ? `https://civitai.com/models/${modelId}?modelVersionId=${version.id}` : `https://civitai.com/model-versions/${version.id}`; return { modelId, modelName: model?.name ?? version.model?.name, modelType: model?.type ?? version.model?.type, creator: model?.creator?.username, tags: model?.tags, modelDescriptionHtml: model?.description, versionId: version.id, versionName: version.name, versionDescriptionHtml: version.description, baseModel: version.baseModel, trainedWords: version.trainedWords ?? [], fileName: file?.name, fileSizeKB: file?.sizeKB, sourceUrl, examples: buildExamples(version), }; } function resolveFromVersion(version, model) { const file = pickFile(version); const downloadUrl = file?.downloadUrl ?? version.downloadUrl ?? `https://civitai.com/api/download/models/${version.id}`; return { downloadUrl, filename: file?.name, versionId: version.id, modelName: model?.name ?? version.model?.name, metadata: buildMetadata(version, file, model), }; } /** * Resolve a CivitAI model-version id directly to a download URL. * Uses GET /api/v1/model-versions/{id}. */ export async function resolveCivitaiModelVersion(versionId) { const version = await civitaiGet(`/model-versions/${versionId}`); return resolveFromVersion(version); } /** * Render a CivitAI metadata bundle as agent-readable Markdown for the sidecar. * Usage docs (trigger words, base model, descriptions) up top; a compact * "example recipes" section from the gallery's generation params below. */ export function buildCivitaiMarkdown(m) { const lines = []; lines.push(`# ${m.modelName ?? "CivitAI model"}`); const facts = []; if (m.modelType) facts.push(`**Type:** ${m.modelType}`); if (m.baseModel) facts.push(`**Base model:** ${m.baseModel}`); if (m.versionName) facts.push(`**Version:** ${m.versionName}`); if (m.creator) facts.push(`**Creator:** ${m.creator}`); if (facts.length) lines.push("", facts.join(" \n")); lines.push("", `Source: ${m.sourceUrl}`); if (m.trainedWords.length) { lines.push("", "## Trigger words", ""); lines.push(m.trainedWords.map((w) => `\`${w}\``).join(", ")); } const modelMd = htmlToMarkdown(m.modelDescriptionHtml); if (modelMd) lines.push("", "## Description", "", modelMd); const versionMd = htmlToMarkdown(m.versionDescriptionHtml); if (versionMd) lines.push("", "## Version notes", "", versionMd); const recipes = m.examples.filter((e) => e.meta && Object.keys(e.meta).length > 0); if (recipes.length) { lines.push("", "## Example recipes", ""); lines.push("Generation params pulled from the model's gallery — reuse the seed to replicate, or vary it to remix."); recipes.forEach((e, i) => { lines.push("", `### Example ${i + 1}`); if (e.url) lines.push(`Image: ${e.url}`); lines.push("", "```", formatMeta(e.meta), "```"); }); } return lines.join("\n") + "\n"; } /** Flatten a CivitAI image `meta` object into readable `key: value` lines, * surfacing the params that matter for reproduction first. */ function formatMeta(meta) { const order = [ "prompt", "negativePrompt", "seed", "steps", "sampler", "cfgScale", "Size", "Model", "Denoising strength", "Clip skip", ]; const out = []; const seen = new Set(); const push = (k, v) => { if (v === undefined || v === null || v === "") return; if (typeof v === "object") return; // skip nested (resources/hashes) here out.push(`${k}: ${v}`); seen.add(k); }; for (const k of order) if (k in meta) push(k, meta[k]); for (const [k, v] of Object.entries(meta)) if (!seen.has(k)) push(k, v); return out.join("\n"); } /** * Resolve a CivitAI model id to a download URL. * Uses GET /api/v1/models/{id} and picks a model version. * If `versionId` is supplied, that specific version is used; otherwise the * latest (first listed) version is chosen. */ export async function resolveCivitaiModel(modelId, versionId) { const model = await civitaiGet(`/models/${modelId}`); const versions = model.modelVersions ?? []; if (versions.length === 0) { throw new ModelError(`CivitAI model ${modelId} has no downloadable versions.`, { modelId }); } let version; if (versionId !== undefined) { version = versions.find((v) => v.id === versionId); if (!version) { throw new ValidationError(`Model ${modelId} has no version with id ${versionId}. ` + `Available versions: ${versions.map((v) => v.id).join(", ")}.`); } } else { version = versions[0]; } return resolveFromVersion(version, model); } const CIVITAI_SORTS = ["Highest Rated", "Most Downloaded", "Newest"]; /** * CivitAI `nsfwLevel` ladder: 1 None · 2 Soft (PG-13) · 4 Mature · 8 Explicit · * 16 X · 32 Blocked. SFW search surfaces nothing above PG-13. */ const MAX_SFW_NSFW_LEVEL = 2; /** * SFW classification of one version — "clean" requires POSITIVE evidence: * a version-level rating at/below PG-13, or at least one preview image all of * which are at/below PG-13. A version with NO rating and NO previews is * UNCLASSIFIED, not clean: treating an empty `images` array as safe let SFW * search vouch an explicit version's trigger words (#664 gate). */ function versionSfwClass(v) { if (typeof v.nsfwLevel === "number") { return v.nsfwLevel <= MAX_SFW_NSFW_LEVEL ? "clean" : "explicit"; } const imgs = v.images ?? []; if (imgs.length === 0) return "unclassified"; return imgs.every((img) => (img.nsfwLevel ?? 1) <= MAX_SFW_NSFW_LEVEL) ? "clean" : "explicit"; } function toSearchHit(m, opts = {}) { const versions = m.modelVersions ?? []; // The API gates only the MODEL-level nsfw flag — a model flagged SFW can // still front a version with mature/explicit previews and adult trigger // words (#664). In SFW mode prefer the newest version PROVEN clean; an // unclassified version (no rating, no previews) is never vouched, and when // nothing is proven clean the latest version still goes out for the // download handoff but its trigger words are omitted. const clean = opts.nsfw ? undefined : versions.find((v) => versionSfwClass(v) === "clean"); const v = clean ?? versions[0]; const file = v ? pickFile(v) : undefined; const sizeKb = file?.sizeKB; return { model_id: m.id, name: m.name ?? `model ${m.id}`, type: m.type, creator: m.creator?.username, downloads: m.stats?.downloadCount, thumbs_up: m.stats?.thumbsUpCount, nsfw: m.nsfw, version_id: v?.id, version_name: v?.name, base_model: v?.baseModel, trained_words: opts.nsfw || clean ? v?.trainedWords?.slice(0, 6) : undefined, ...(sizeKb ? { size_mb: Math.round(sizeKb / 1024) } : {}), }; } /** Creator+keyword mode: pages of 100 scanned client-side, and how many pages * to follow before giving up (4 → up to 400 of the creator's models). */ const CREATOR_SCAN_PAGE_SIZE = 100; const CREATOR_SCAN_MAX_PAGES = 4; export async function searchCivitaiModels(query, opts = {}) { const creator = opts.creator?.trim(); const keyword = query.trim(); if (!creator && !keyword) { throw new ValidationError("Provide a query, a creator, or both."); } const limit = Math.min(Math.max(opts.limit ?? 10, 1), 25); const params = new URLSearchParams(); params.set("sort", opts.sort ?? "Highest Rated"); // Civitai defaults to including NSFW for authed accounts — pin it explicitly // so results are SFW unless the caller opted in. params.set("nsfw", opts.nsfw ? "true" : "false"); for (const t of opts.types ?? []) params.append("types", t); for (const b of opts.baseModels ?? []) params.append("baseModels", b); if (!creator || !keyword) { // Single-page modes: a plain keyword search, or ALL of a creator's models. if (creator) { params.set("username", creator); } else { params.set("query", keyword); } params.set("limit", String(limit)); const data = await civitaiGet(`/models?${params.toString()}`); return { hits: (data.items ?? []).slice(0, limit).map((m) => toSearchHit(m, opts)) }; } // Creator + keyword: `query` + `username` together return an EMPTY page // (live-verified), so send only `username` and apply the keyword client-side // — following cursor pagination (bounded) so a prolific creator's matching // model past the first page is still found. params.set("username", creator); params.set("limit", String(CREATOR_SCAN_PAGE_SIZE)); const q = keyword.toLowerCase(); const matches = []; const seenIds = new Set(); // Every cursor already followed — refuses ANY cycle (immediate repeat AND // longer A→B→A loops), not just the previous cursor. const seenCursors = new Set(); let scanned = 0; let cursor; // True when the scan stopped with pages plausibly unseen: the page cap was // hit with a next cursor remaining, or the API handed back a degenerate // (cycling) cursor we refuse to follow. let stoppedEarly = false; // Set when a page AFTER the first fails: we keep the partial matches gathered // so far but flag them as non-definitive. A FIRST-page failure is left to // throw (no partial data exists yet), so a broken scan never looks empty. let scanError; for (let page = 0; page < CREATOR_SCAN_MAX_PAGES; page++) { if (cursor !== undefined) params.set("cursor", cursor); let data; try { data = await civitaiGet(`/models?${params.toString()}`); } catch (err) { if (page === 0) throw err; // nothing gathered yet — surface the failure scanError = err instanceof Error ? err.message : String(err); stoppedEarly = true; // pages remained unscanned → the result is partial break; } // Dedupe across pages by model id — a misbehaving cursor that replays a // page must not double-count `scanned` or fill `limit` with duplicates. const items = (data.items ?? []).filter((m) => !seenIds.has(m.id)); for (const m of items) seenIds.add(m.id); scanned += items.length; matches.push(...items.filter((m) => (m.name ?? "").toLowerCase().includes(q) || (m.tags ?? []).some((t) => t.toLowerCase().includes(q)))); // Falsy (missing OR empty-string) cursor → the catalog is exhausted; a // cursor we ALREADY followed (immediate repeat or a longer cycle) would // page in circles forever → treat as stuck. const next = data.metadata?.nextCursor || undefined; if (!next) break; if (seenCursors.has(next)) { stoppedEarly = true; // cycling cursor: pages may remain unseen break; } seenCursors.add(next); cursor = next; if (matches.length >= limit) break; if (page === CREATOR_SCAN_MAX_PAGES - 1) stoppedEarly = true; // page cap, more remained } return { hits: matches.slice(0, limit).map((m) => toSearchHit(m, opts)), scanned, scanCapped: stoppedEarly && matches.length < limit, ...(scanError ? { scanError } : {}), }; } const CIVITAI_LEADERBOARDS = [ "overall", "overall_90", "overall_nsfw", "new_creators", ]; function creatorProfileUrl(username) { return `https://civitai.com/user/${encodeURIComponent(username)}`; } /** * Search CivitAI creators by (partial) username via GET /api/v1/creators. * Returns matching creators plus the API's total match count. */ export async function searchCivitaiCreators(query, opts = {}) { const params = new URLSearchParams(); params.set("query", query); params.set("limit", String(Math.min(Math.max(opts.limit ?? 10, 1), 50))); const data = await civitaiGet(`/creators?${params.toString()}`); const hits = (data.items ?? []) .filter((c) => !!c.username) .map((c) => ({ username: c.username, profile_url: creatorProfileUrl(c.username), model_count: c.modelCount ?? 0, })); return { hits, total: data.metadata?.totalItems }; } /** Browser-shaped headers for the tRPC leaderboard request (see section note). */ const TRPC_BROWSER_HEADERS = { "User-Agent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 " + "(KHTML, like Gecko) Chrome/130.0.0.0 Safari/537.36", Referer: "https://civitai.com/", }; /** * Fetch a CivitAI creator leaderboard (the site's civitai.com/leaderboard * ranking). `board` picks which one: "overall" (default), "overall_90", * "overall_nsfw", or "new_creators". */ export async function fetchCivitaiTopCreators(opts = {}) { if (civitaiDisabled()) { throw new ModelError(CIVITAI_DISABLED_MESSAGE); } const board = opts.board ?? "overall"; const limit = Math.min(Math.max(opts.limit ?? 10, 1), 100); const input = encodeURIComponent(JSON.stringify({ json: { id: board } })); const url = `https://civitai.com/api/trpc/leaderboard.getLeaderboard?input=${input}`; logger.debug("CivitAI leaderboard request", { url }); // No bearer token here: the leaderboard is public and the API token belongs // to the documented v1 surface, not the site's tRPC endpoints. Shares the // timeout + distinct network/status/non-JSON error surfacing of civitaiGet. // tokenSent: false — the leaderboard request intentionally carries no bearer // token, so a bot-gate 401 must NOT blame the configured v1 API token. const res = await civitaiFetch(url, TRPC_BROWSER_HEADERS); if (!res.ok) { await throwForCivitaiStatus(res, url, false); } const data = await parseCivitaiJson(res, url); const entries = data.result?.data?.json ?? []; const metric = (e, type) => e.metrics?.find((m) => m.type === type)?.value; return entries .filter((e) => e.user?.username && !e.user.deletedAt) .slice(0, limit) .map((e) => ({ username: e.user.username, profile_url: creatorProfileUrl(e.user.username), position: e.position, score: e.score, downloads: metric(e, "downloadCount"), thumbs_up: metric(e, "thumbsUpCount"), generations: metric(e, "generationCount"), entries: metric(e, "entries"), })); } //# sourceMappingURL=civitai-resolver.js.map