Replicate Nodes Migration — Implementation Plan

For agentic workers: REQUIRED: Use superpowers:subagent-driven-development (if subagents available) or superpowers:executing-plans to implement this plan. Steps use checkbox (- [ ]) syntax for tracking.

Goal: Port the 158 Replicate AI model nodes to the TS monorepo using a configuration-driven code generator, reusing the same architecture as fal-codegen/fal-nodes.

Architecture: A new replicate-nodes runtime package holds the base class (replicate-base.ts) and generated node files. A new replicate-codegen package reads node configs + Replicate’s OpenAPI API, then generates TypeScript node classes. The code generator follows the exact same types.ts/NodeSpec/NodeConfig/ModuleConfig pattern as fal-codegen, so a future unification is straightforward. The Replicate API is called with plain fetch() (create prediction → poll status → return output) — no SDK dependency.

Tech Stack: TypeScript, fetch(), @nodetool/node-sdk (BaseNode, @prop)


File Structure

packages/replicate-codegen/ — Code generation tool

packages/replicate-codegen/
├── package.json
├── tsconfig.json
├── src/
│   ├── types.ts              # Re-export from fal-codegen types (NodeSpec, FieldDef, etc.)
│   ├── schema-fetcher.ts     # Fetch OpenAPI schema from Replicate API
│   ├── schema-parser.ts      # Parse Replicate OpenAPI → NodeSpec
│   ├── node-generator.ts     # Generate TS node classes (Replicate variant)
│   ├── generate.ts           # CLI entry point
│   └── configs/
│       ├── index.ts           # Export all module configs
│       ├── image-generate.ts  # Image generation model configs
│       ├── image-upscale.ts   # Image upscaling configs
│       ├── image-enhance.ts   # Image enhancement configs
│       ├── image-analyze.ts   # Image analysis configs
│       ├── image-face.ts      # Face manipulation configs
│       ├── image-process.ts   # Image processing configs
│       ├── image-ocr.ts       # OCR configs
│       ├── video-generate.ts  # Video generation configs
│       ├── video-enhance.ts   # Video enhancement configs
│       ├── audio-generate.ts  # Audio generation configs
│       ├── audio-enhance.ts   # Audio enhancement configs
│       ├── audio-separate.ts  # Audio separation configs
│       ├── audio-transcribe.ts # Audio transcription configs
│       └── text-generate.ts   # Text/LLM generation configs

packages/replicate-nodes/ — Runtime package

packages/replicate-nodes/
├── package.json
├── tsconfig.json
├── src/
│   ├── replicate-base.ts     # API key, submit/poll, output conversion, cost calc
│   ├── index.ts              # Register all nodes, export REPLICATE_NODES
│   └── generated/            # Auto-generated by replicate-codegen
│       ├── image-generate.ts
│       ├── image-upscale.ts
│       ├── image-enhance.ts
│       ├── image-analyze.ts
│       ├── image-face.ts
│       ├── image-process.ts
│       ├── image-ocr.ts
│       ├── video-generate.ts
│       ├── video-enhance.ts
│       ├── audio-generate.ts
│       ├── audio-enhance.ts
│       ├── audio-separate.ts
│       ├── audio-transcribe.ts
│       └── text-generate.ts
└── tests/
    ├── replicate-base.test.ts
    └── generated-nodes.test.ts

Chunk 1: Runtime Package — replicate-nodes

Task 1: Package scaffolding

Files:

{
  "name": "@nodetool/replicate-nodes",
  "type": "module",
  "version": "0.1.0",
  "description": "Replicate AI model nodes for the TS workflow runner",
  "main": "dist/index.js",
  "types": "dist/index.d.ts",
  "scripts": {
    "build": "node -e \"require('node:fs').rmSync('dist', { recursive: true, force: true })\" && tsc",
    "test": "vitest run",
    "test:watch": "vitest",
    "lint": "tsc --noEmit"
  },
  "dependencies": {
    "@nodetool/node-sdk": "*"
  },
  "devDependencies": {
    "@types/node": "^20.0.0",
    "typescript": "^5.4.0",
    "vitest": "^1.6.1"
  }
}

Copy from packages/fal-nodes/tsconfig.json, adjust outDir and rootDir.

Run: cd packages/replicate-nodes && npm install && npm run build Expected: Empty build succeeds (no source files yet)

git add packages/replicate-nodes/
git commit -m "feat: scaffold replicate-nodes package"

Task 2: replicate-base.ts — API key, submit/poll, output conversion

Files:

This is the core runtime. All generated nodes call these helpers. The Replicate API is simple HTTP:

// tests/replicate-base.test.ts
import { describe, it, expect } from "vitest";
import { getReplicateApiKey, removeNulls } from "../src/replicate-base.js";

describe("getReplicateApiKey", () => {
  it("reads from _secrets", () => {
    const key = getReplicateApiKey({
      _secrets: { REPLICATE_API_TOKEN: "test-key" },
    });
    expect(key).toBe("test-key");
  });

  it("throws when no key configured", () => {
    expect(() => getReplicateApiKey({})).toThrow("REPLICATE_API_TOKEN");
  });
});

describe("removeNulls", () => {
  it("removes null and undefined values", () => {
    const obj = { a: 1, b: null, c: undefined, d: "ok" };
    removeNulls(obj);
    expect(obj).toEqual({ a: 1, d: "ok" });
  });
});

Run: npx vitest run tests/replicate-base.test.ts Expected: FAIL — module not found

// src/replicate-base.ts
/**
 * Shared Replicate API utilities.
 * Uses plain fetch() — no SDK dependency.
 */

const REPLICATE_API_BASE = "https://api.replicate.com/v1";

// ---------------------------------------------------------------------------
// API Key extraction
// ---------------------------------------------------------------------------

export function getReplicateApiKey(inputs: Record<string, unknown>): string {
  const key =
    (inputs._secrets as Record<string, string>)?.REPLICATE_API_TOKEN ||
    process.env.REPLICATE_API_TOKEN ||
    "";
  if (!key) throw new Error("REPLICATE_API_TOKEN is not configured");
  return key;
}

// ---------------------------------------------------------------------------
// Remove null/undefined from args before sending to API
// ---------------------------------------------------------------------------

export function removeNulls(obj: Record<string, unknown>): void {
  for (const key of Object.keys(obj)) {
    if (obj[key] === null || obj[key] === undefined) {
      delete obj[key];
    }
  }
}

// ---------------------------------------------------------------------------
// Check if an asset ref has actual content
// ---------------------------------------------------------------------------

export function isRefSet(
  ref: Record<string, unknown> | null | undefined
): boolean {
  if (!ref || typeof ref !== "object") return false;
  return Boolean(ref.uri || ref.data);
}

// ---------------------------------------------------------------------------
// Convert an asset ref to a URL string for the Replicate API
// ---------------------------------------------------------------------------

export async function assetToUrl(
  ref: Record<string, unknown>
): Promise<string | null> {
  if (!ref) return null;
  if (typeof ref.uri === "string" && ref.uri) return ref.uri;
  if (typeof ref.data === "string" && ref.data.startsWith("data:"))
    return ref.data;
  return null;
}

// ---------------------------------------------------------------------------
// Submit prediction and poll until complete
// ---------------------------------------------------------------------------

export interface ReplicatePrediction {
  id: string;
  status: string;
  output: unknown;
  error: string | null;
  logs: string;
  metrics?: Record<string, number>;
}

export async function replicateSubmit(
  apiKey: string,
  modelVersion: string,
  input: Record<string, unknown>
): Promise<ReplicatePrediction> {
  // Create prediction
  const createRes = await fetch(`${REPLICATE_API_BASE}/predictions`, {
    method: "POST",
    headers: {
      Authorization: `Bearer ${apiKey}`,
      "Content-Type": "application/json",
      Prefer: "wait",
    },
    body: JSON.stringify({ version: modelVersion, input }),
  });

  if (!createRes.ok) {
    const text = await createRes.text();
    throw new Error(
      `Replicate API error (${createRes.status}): ${text}`
    );
  }

  let prediction = (await createRes.json()) as ReplicatePrediction;

  // If "Prefer: wait" returned a completed prediction, return immediately
  if (
    prediction.status === "succeeded" ||
    prediction.status === "failed" ||
    prediction.status === "canceled"
  ) {
    if (prediction.status === "failed") {
      throw new Error(prediction.error || "Prediction failed");
    }
    return prediction;
  }

  // Poll for completion (max 30 minutes, 2s interval)
  for (let i = 0; i < 900; i++) {
    await new Promise((r) => setTimeout(r, 2000));

    const pollRes = await fetch(
      `${REPLICATE_API_BASE}/predictions/${prediction.id}`,
      {
        headers: { Authorization: `Bearer ${apiKey}` },
      }
    );
    if (!pollRes.ok) continue;

    prediction = (await pollRes.json()) as ReplicatePrediction;

    if (prediction.status === "failed" || prediction.status === "canceled") {
      throw new Error(prediction.error || "Prediction failed");
    }
    if (prediction.status === "succeeded") {
      return prediction;
    }
  }

  throw new Error("Replicate prediction timed out after 30 minutes");
}

// ---------------------------------------------------------------------------
// Extract version hash from model ID "owner/name:version"
// ---------------------------------------------------------------------------

export function extractVersion(modelId: string): string {
  const colonIdx = modelId.indexOf(":");
  if (colonIdx < 0) {
    throw new Error(
      `Invalid Replicate model ID: ${modelId}. Expected format: owner/name:version`
    );
  }
  return modelId.slice(colonIdx + 1);
}

// ---------------------------------------------------------------------------
// Convert Replicate output to asset ref
// ---------------------------------------------------------------------------

export function outputToImageRef(output: unknown): Record<string, unknown> {
  if (typeof output === "string") return { type: "image", uri: output };
  if (Array.isArray(output) && output.length > 0)
    return { type: "image", uri: String(output[0]) };
  return { type: "image", uri: "" };
}

export function outputToVideoRef(output: unknown): Record<string, unknown> {
  if (typeof output === "string") return { type: "video", uri: output };
  if (Array.isArray(output) && output.length > 0)
    return { type: "video", uri: String(output[0]) };
  return { type: "video", uri: "" };
}

export function outputToAudioRef(output: unknown): Record<string, unknown> {
  if (typeof output === "string") return { type: "audio", uri: output };
  if (Array.isArray(output) && output.length > 0)
    return { type: "audio", uri: String(output[0]) };
  return { type: "audio", uri: "" };
}

export function outputToString(output: unknown): string {
  if (typeof output === "string") return output;
  if (Array.isArray(output)) return output.join("");
  return String(output ?? "");
}

Run: npx vitest run tests/replicate-base.test.ts Expected: PASS

git add packages/replicate-nodes/
git commit -m "feat: add replicate-base.ts with API helpers"

Task 3: Stub index.ts and verify build

Files:

// src/index.ts
import type { NodeClass } from "@nodetool/node-sdk";
import { NodeRegistry } from "@nodetool/node-sdk";

// Generated node arrays will be imported here after codegen runs.
// For now, export an empty array.
export const REPLICATE_NODES: readonly NodeClass[] = [];

export function registerReplicateNodes(registry: NodeRegistry): void {
  for (const nodeClass of REPLICATE_NODES) {
    registry.register(nodeClass);
  }
}

Run: cd packages/replicate-nodes && npm run build Expected: Build succeeds

git add packages/replicate-nodes/src/index.ts
git commit -m "feat: add replicate-nodes index stub"

Chunk 2: Code Generator — replicate-codegen

Task 4: Package scaffolding

Files:

{
  "name": "@nodetool/replicate-codegen",
  "type": "module",
  "version": "0.1.0",
  "private": true,
  "description": "Code generator for Replicate AI nodes",
  "main": "dist/index.js",
  "types": "dist/index.d.ts",
  "scripts": {
    "build": "node -e \"require('node:fs').rmSync('dist', { recursive: true, force: true })\" && tsc",
    "generate": "tsx src/generate.ts",
    "test": "vitest run",
    "lint": "tsc --noEmit"
  },
  "devDependencies": {
    "@nodetool/node-sdk": "*",
    "@types/node": "^20.0.0",
    "tsx": "^4.0.0",
    "typescript": "^5.4.0",
    "vitest": "^1.6.1"
  }
}

Copy from packages/fal-codegen/tsconfig.json.

git add packages/replicate-codegen/
git commit -m "feat: scaffold replicate-codegen package"

Task 5: Types and schema fetcher

Files:

The types reuse the exact same NodeSpec, FieldDef, EnumDef, NodeConfig, ModuleConfig interfaces from fal-codegen.

Copy packages/fal-codegen/src/types.ts verbatim. These types are provider-agnostic.

Fetches model metadata from Replicate’s API: GET https://api.replicate.com/v1/models/{owner}/{name}/versions/{version} returns OpenAPI-style schema with input/output property definitions.

// src/schema-fetcher.ts
import { readFile, writeFile, mkdir } from "node:fs/promises";
import { existsSync } from "node:fs";
import { join } from "node:path";

const REPLICATE_API = "https://api.replicate.com/v1";

export interface ReplicateSchema {
  modelId: string;       // "owner/name:version"
  owner: string;
  name: string;
  version: string;
  description: string;
  inputSchema: Record<string, unknown>;
  outputSchema: Record<string, unknown>;
  hardware?: string;
}

export class SchemaFetcher {
  private cacheDir = join(process.cwd(), ".schema-cache", "replicate");

  async fetchSchema(
    modelId: string,
    useCache = true
  ): Promise<ReplicateSchema> {
    const cacheKey = modelId.replace(/[/:]/g, "_");
    const cachePath = join(this.cacheDir, `${cacheKey}.json`);

    if (useCache && existsSync(cachePath)) {
      const raw = await readFile(cachePath, "utf8");
      return JSON.parse(raw) as ReplicateSchema;
    }

    const colonIdx = modelId.indexOf(":");
    const ownerName = colonIdx > 0 ? modelId.slice(0, colonIdx) : modelId;
    const [owner, name] = ownerName.split("/");

    // Fetch latest version if no version specified
    const token = process.env.REPLICATE_API_TOKEN ?? "";
    const headers: Record<string, string> = {};
    if (token) headers.Authorization = `Bearer ${token}`;

    let version: string;
    let description = "";
    let inputSchema: Record<string, unknown> = {};
    let outputSchema: Record<string, unknown> = {};

    if (colonIdx > 0) {
      version = modelId.slice(colonIdx + 1);
    } else {
      // Fetch model to get latest version
      const modelRes = await fetch(
        `${REPLICATE_API}/models/${owner}/${name}`,
        { headers }
      );
      if (!modelRes.ok) throw new Error(`Failed to fetch model ${modelId}`);
      const modelData = (await modelRes.json()) as Record<string, unknown>;
      const latestVersion = modelData.latest_version as Record<string, unknown>;
      version = latestVersion?.id as string ?? "";
      description = (modelData.description as string) ?? "";
    }

    // Fetch version details with schema
    const versionRes = await fetch(
      `${REPLICATE_API}/models/${owner}/${name}/versions/${version}`,
      { headers }
    );
    if (!versionRes.ok)
      throw new Error(`Failed to fetch version for ${modelId}`);
    const versionData = (await versionRes.json()) as Record<string, unknown>;

    const openApiSchema = versionData.openapi_schema as Record<string, unknown>;
    if (openApiSchema) {
      const components = openApiSchema.components as Record<string, unknown>;
      const schemas = components?.schemas as Record<string, unknown>;
      if (schemas) {
        inputSchema = (schemas.Input as Record<string, unknown>) ?? {};
        outputSchema = (schemas.Output as Record<string, unknown>) ?? {};
      }
    }

    const result: ReplicateSchema = {
      modelId: `${owner}/${name}:${version}`,
      owner,
      name,
      version,
      description: description || (versionData.description as string) ?? "",
      inputSchema,
      outputSchema,
    };

    // Cache
    if (useCache) {
      await mkdir(this.cacheDir, { recursive: true });
      await writeFile(cachePath, JSON.stringify(result, null, 2));
    }

    return result;
  }
}
git add packages/replicate-codegen/src/
git commit -m "feat: add replicate-codegen types and schema fetcher"

Task 6: Schema parser — Replicate OpenAPI → NodeSpec

Files:

Parses Replicate’s OpenAPI Input schema properties into FieldDef[] and EnumDef[].

Key mappings from Replicate OpenAPI types to TS prop types:

The parser should:

  1. Walk inputSchema.properties
  2. Convert each property to a FieldDef
  3. Extract enum values into EnumDef[]
  4. Build a NodeSpec with endpointId = modelId
git add packages/replicate-codegen/src/schema-parser.ts
git commit -m "feat: add Replicate schema parser"

Task 7: Node generator — Replicate variant

Files:

This follows the fal-codegen pattern but generates Replicate-specific process() methods:

The generator produces classes like:

export class FluxSchnell extends ReplicateNode {
  static readonly nodeType = "replicate.image_generate.FluxSchnell";
  static readonly title = "Flux Schnell";
  static readonly description = "...";
  static readonly requiredSettings = ["REPLICATE_API_TOKEN"];

  @prop({ type: "str", default: "", description: "..." })
  declare prompt: any;

  async process(inputs: Record<string, unknown>): Promise<Record<string, unknown>> {
    const apiKey = getReplicateApiKey(inputs);
    const prompt = String(inputs.prompt ?? this.prompt ?? "");
    const args: Record<string, unknown> = { prompt };
    // ... handle asset inputs ...
    removeNulls(args);
    const res = await replicateSubmit(apiKey, extractVersion("owner/name:version"), args);
    return { output: outputToImageRef(res.output) };
  }
}

Key differences from fal generator:

git add packages/replicate-codegen/src/node-generator.ts
git commit -m "feat: add Replicate node generator"

Task 8: Node configs — port from Python gencode.py

Files:

Port the 158 node configurations from /Users/mg/workspace/nodetool-replicate/src/nodetool/nodes/replicate/gencode.py replicate_nodes list to TypeScript ModuleConfig format.

Each Python entry like:

{
    "node_name": "FluxSchnell",
    "namespace": "image.generate",
    "model_id": "black-forest-labs/flux-schnell",
    "return_type": ImageRef,
    "overrides": {"image": ImageRef},
}

Becomes a TS config entry:

"black-forest-labs/flux-schnell": {
  className: "FluxSchnell",
  returnType: "image",
  fieldOverrides: {
    image: { propType: "image" },
  },
}

Group by namespace into separate config files matching the Python directory structure.

Port all 158 entries from gencode.py, grouped by namespace:

Reference: /Users/mg/workspace/nodetool-replicate/src/nodetool/nodes/replicate/gencode.py lines 18-1100+

// src/configs/index.ts
import type { ModuleConfig } from "../types.js";
import { imageGenerateConfig } from "./image-generate.js";
import { imageUpscaleConfig } from "./image-upscale.js";
// ... all other configs

export const allConfigs: Record<string, ModuleConfig> = {
  "image-generate": imageGenerateConfig,
  "image-upscale": imageUpscaleConfig,
  // ... all modules
};
git add packages/replicate-codegen/src/configs/
git commit -m "feat: port 158 Replicate node configs from Python"

Task 9: CLI entry point — generate.ts

Files:

Same pattern as fal-codegen/src/generate.ts: reads configs, fetches schemas, generates TS files.

#!/usr/bin/env node
/**
 * CLI for generating Replicate node TypeScript classes.
 *
 * Usage:
 *   npx tsx src/generate.ts --all
 *   npx tsx src/generate.ts --module image-generate
 *   npx tsx src/generate.ts --all --no-cache
 */

import { parseArgs } from "node:util";
import { mkdir, writeFile } from "node:fs/promises";
import { join } from "node:path";
import { SchemaFetcher } from "./schema-fetcher.js";
import { SchemaParser } from "./schema-parser.js";
import { NodeGenerator } from "./node-generator.js";
import { allConfigs } from "./configs/index.js";
import type { ModuleConfig } from "./types.js";

// ... same structure as fal-codegen generate.ts
// Output dir defaults to ../replicate-nodes/src/generated
git add packages/replicate-codegen/src/generate.ts
git commit -m "feat: add Replicate codegen CLI"

Chunk 3: Run Code Generation and Wire Up

Task 10: Generate all Replicate nodes

Run: cd packages/replicate-codegen && REPLICATE_API_TOKEN=$REPLICATE_API_TOKEN npx tsx src/generate.ts --all

This will:

  1. Fetch OpenAPI schemas for all 158 models from Replicate API
  2. Parse each schema into NodeSpec
  3. Apply config overrides (className, returnType, fieldOverrides)
  4. Generate TypeScript files into packages/replicate-nodes/src/generated/

Run: ls packages/replicate-nodes/src/generated/ Expected: 14 .ts files, one per namespace

git add packages/replicate-nodes/src/generated/
git commit -m "feat: generate 158 Replicate node classes"

Task 11: Update index.ts to import and export all generated nodes

Files:

Import all generated arrays and compose REPLICATE_NODES:

import type { NodeClass } from "@nodetool/node-sdk";
import { NodeRegistry } from "@nodetool/node-sdk";

import { REPLICATE_IMAGE_GENERATE_NODES } from "./generated/image-generate.js";
import { REPLICATE_IMAGE_UPSCALE_NODES } from "./generated/image-upscale.js";
// ... all generated modules

export const REPLICATE_NODES: readonly NodeClass[] = [
  ...REPLICATE_IMAGE_GENERATE_NODES,
  ...REPLICATE_IMAGE_UPSCALE_NODES,
  // ... all
];

export function registerReplicateNodes(registry: NodeRegistry): void {
  for (const nodeClass of REPLICATE_NODES) {
    registry.register(nodeClass);
  }
}

Run: cd packages/replicate-nodes && npm run build Expected: Build succeeds with all 158 nodes

git add packages/replicate-nodes/src/index.ts
git commit -m "feat: wire up all generated Replicate nodes in index"

Task 12: Register in websocket server

Files:

Add "@nodetool/replicate-nodes": "*" to packages/websocket/package.json dependencies.

Add alongside existing registerFalNodes(registry):

import { registerReplicateNodes } from "@nodetool/replicate-nodes";
// ...
registerReplicateNodes(registry);

Run: cd packages/websocket && npm install && npm run build Expected: Build succeeds

git add packages/websocket/
git commit -m "feat: register Replicate nodes in websocket server"

Task 13: Basic tests for generated nodes

Files:

import { describe, it, expect } from "vitest";
import { REPLICATE_NODES } from "../src/index.js";

describe("Generated Replicate nodes", () => {
  it("exports at least 150 nodes", () => {
    expect(REPLICATE_NODES.length).toBeGreaterThanOrEqual(150);
  });

  it("all nodes have valid nodeType", () => {
    for (const node of REPLICATE_NODES) {
      expect(node.nodeType).toMatch(/^replicate\./);
      expect(node.title).toBeTruthy();
    }
  });

  it("all nodes have requiredSettings with REPLICATE_API_TOKEN", () => {
    for (const node of REPLICATE_NODES) {
      expect(node.requiredSettings).toContain("REPLICATE_API_TOKEN");
    }
  });

  it("all nodes can be instantiated", () => {
    for (const NodeClass of REPLICATE_NODES) {
      const instance = new NodeClass({});
      expect(instance).toBeDefined();
      expect(typeof instance.process).toBe("function");
    }
  });
});

Run: cd packages/replicate-nodes && npx vitest run Expected: PASS

git add packages/replicate-nodes/tests/
git commit -m "test: add basic tests for generated Replicate nodes"