Build it in your project
Paste this line into your agent (Claude Code, Codex, Cursor, OpenCode, Cline). It sets up your key, then builds the pipeline in your project:Add Runcrate's Qwen3 VL 30B A3B to this project: run curl -fsSL "https://runcrate.ai/agents.md?model=Qwen%2FQwen3-VL-30B-A3B-Instruct&intent=build" and follow it. Then build: a script that moderates a folder of uploads: Nemotron Content Safety 3.5 (Llama Guard 4 as fallback) for a verdict, Qwen3-VL 30B boxes for faces, license plates, ID documents and screens, ffmpeg blur, an append-only audit.jsonl and a signed webhook on every reject.
The code
One file per language. Both readRUNCRATE_API_KEY from the environment; nothing secret is in the code.
"""Moderate uploads before they go live: a safety check, privacy redaction (faces, plates, IDs, screens), and an audit log.
pip install openai requests (and ffmpeg on PATH: it does the blurring)
RUNCRATE_API_KEY=... python image_moderation.py ./uploads
Optional: WEBHOOK_URL and WEBHOOK_SECRET get a signed POST for every rejected image.
"""
import base64, datetime as dt, hashlib, hmac, json, os, re, subprocess, sys, time
from pathlib import Path
import requests
from openai import OpenAI, RateLimitError
client = OpenAI(base_url="https://api.runcrate.ai/v1", api_key=os.environ["RUNCRATE_API_KEY"], max_retries=4, timeout=120)
SAFETY = ["nvidia/Nemotron-Content-Safety-3.5", "meta-llama/Llama-Guard-4-12B"] # second is the fallback when the first is busy
REDACT = "Qwen/Qwen3-VL-30B-A3B-Instruct"
OUT, AUDIT = Path("out"), Path("audit.jsonl")
OUT.mkdir(exist_ok=True)
PROMPT = ("Redact this image for privacy. Return a box for EACH of these you can see: every human face (including small or partial faces, "
"and faces in photos or on screens), every vehicle license plate, every ID card or passport, every screen showing readable personal text. "
"Count the people first. Boxes are [x1,y1,x2,y2] on a 0-1000 grid of the whole image.")
SCHEMA = {"type": "object", "additionalProperties": False, "required": ["people_visible", "regions"], "properties": {
"people_visible": {"type": "integer"},
"regions": {"type": "array", "items": {"type": "object", "additionalProperties": False, "required": ["kind", "box"], "properties": {
"kind": {"type": "string", "enum": ["face", "license_plate", "id_document", "screen_text", "other_personal"]},
"box": {"type": "array", "items": {"type": "integer"}}}}}}}
def data_url(b: bytes) -> str:
mime = "image/png" if b[:4] == b"\x89PNG" else "image/webp" if b[8:12] == b"WEBP" else "image/jpeg"
return f"data:{mime};base64,{base64.b64encode(b).decode()}"
def safety(img: bytes) -> tuple[str, str, str, float]:
"""Returns (verdict, categories, model, cost). Tries each safety model in turn."""
for model in SAFETY:
try:
raw = client.chat.completions.with_raw_response.create(model=model, max_tokens=60, messages=[{"role": "user", "content": [
{"type": "image_url", "image_url": {"url": data_url(img)}}, {"type": "text", "text": "Is this image safe to publish?"}]}])
except RateLimitError:
continue
text = raw.parse().choices[0].message.content or ""
verdict = "unsafe" if re.search(r"\bunsafe\b", text, re.I) else "safe"
cats = (re.search(r"(?:Categories|S\d+).*", text) or [""])[0]
return verdict, cats, model, float(raw.headers.get("x-llm-cost", 0))
raise RuntimeError("every safety model is busy; retry the batch later")
def blur(src: Path, dst: Path, regions: list[dict]) -> None:
w, h = map(int, subprocess.run(["ffprobe", "-v", "error", "-select_streams", "v:0", "-show_entries", "stream=width,height", "-of", "csv=p=0", str(src)],
capture_output=True, text=True).stdout.strip().split(","))
graph, last = [f"[0]split={len(regions) + 1}[base]" + "".join(f"[c{i}]" for i in range(len(regions)))], "base"
for i, r in enumerate(regions):
x1, y1, x2, y2 = r["box"]
pad = 0.1 * max(x2 - x1, y2 - y1) # 10% margin around each box
x, y = int(max(0, x1 - pad) * w / 1000), int(max(0, y1 - pad) * h / 1000)
bw, bh = max(8, int(min(1000, x2 + pad) * w / 1000) - x), max(8, int(min(1000, y2 + pad) * h / 1000) - y)
graph.append(f"[c{i}]crop={bw}:{bh}:{x}:{y},boxblur={max(4, min(bw, bh) // 6)}:2[b{i}]")
graph.append(f"[{last}][b{i}]overlay={x}:{y}[o{i}]"); last = f"o{i}"
subprocess.run(["ffmpeg", "-y", "-loglevel", "error", "-i", str(src), "-filter_complex", ";".join(graph), "-map", f"[{last}]", "-q:v", "3", str(dst)], check=True)
def reject_webhook(entry: dict) -> int:
if not os.environ.get("WEBHOOK_URL"):
return 0
body = json.dumps(entry).encode()
sig = hmac.new(os.environ.get("WEBHOOK_SECRET", "").encode(), body, hashlib.sha256).hexdigest()
return requests.post(os.environ["WEBHOOK_URL"], data=body, timeout=10, headers={"Content-Type": "application/json", "X-Signature-SHA256": sig}).status_code
def main(folder: str) -> None:
rows = []
for src in sorted(p for p in Path(folder).iterdir() if p.suffix.lower() in {".jpg", ".jpeg", ".png", ".webp"}):
t0, img = time.time(), src.read_bytes()
verdict, cats, safety_model, cost = safety(img)
entry = {"at": dt.datetime.now(dt.timezone.utc).isoformat(), "file": src.name, "sha256": hashlib.sha256(img).hexdigest(),
"safety": {"model": safety_model, "verdict": verdict, "categories": cats}}
if verdict == "unsafe":
entry["decision"] = "reject"
entry["webhook"] = reject_webhook(entry)
else:
raw = client.chat.completions.with_raw_response.create(model=REDACT, temperature=0, messages=[{"role": "user", "content": [
{"type": "image_url", "image_url": {"url": data_url(img)}}, {"type": "text", "text": PROMPT}]}],
response_format={"type": "json_schema", "json_schema": {"name": "redact", "strict": True, "schema": SCHEMA}})
cost += float(raw.headers.get("x-llm-cost", 0))
found = json.loads(raw.parse().choices[0].message.content)
regions = [r for r in found["regions"] if len(r["box"]) == 4 and r["box"][2] > r["box"][0] and r["box"][3] > r["box"][1]]
dst = OUT / f"{src.stem}.jpg"
if regions:
blur(src, dst, regions)
else:
dst.write_bytes(img)
entry |= {"decision": "publish_redacted" if regions else "publish", "redact_model": REDACT, "people_visible": found["people_visible"],
"regions": regions, "output": str(dst), "output_sha256": hashlib.sha256(dst.read_bytes()).hexdigest()}
entry |= {"seconds": round(time.time() - t0, 2), "cost_usd": cost}
with AUDIT.open("a") as f: # append-only: one line per decision
f.write(json.dumps(entry) + "\n")
rows.append(entry)
kinds = ", ".join(sorted({r["kind"] for r in entry.get("regions", [])})) or "-"
print(f"{src.name}: {entry['decision']:<17} safety {verdict:<6} ({safety_model.split('/')[1]}) redacted: {kinds} {entry['seconds']}s ${cost:.6f}")
Path("run.json").write_text(json.dumps({"images": rows, "total_usd": sum(r["cost_usd"] for r in rows)}, indent=2))
print(f"total ${sum(r['cost_usd'] for r in rows):.6f}")
if __name__ == "__main__":
main(sys.argv[1] if len(sys.argv) > 1 else "uploads")
// Moderate uploads before they go live: a safety check, privacy redaction (faces, plates, IDs, screens), and an audit log.
//
// npm install openai (and ffmpeg on PATH: it does the blurring)
// RUNCRATE_API_KEY=... npx tsx image-moderation.ts ./uploads
// Optional: WEBHOOK_URL and WEBHOOK_SECRET get a signed POST for every rejected image.
import { execFileSync } from "node:child_process";
import { createHash, createHmac } from "node:crypto";
import { appendFileSync, mkdirSync, readdirSync, readFileSync, writeFileSync } from "node:fs";
import { extname, join, parse } from "node:path";
import OpenAI from "openai";
const client = new OpenAI({ baseURL: "https://api.runcrate.ai/v1", apiKey: process.env.RUNCRATE_API_KEY, maxRetries: 4, timeout: 120_000 });
const SAFETY = ["nvidia/Nemotron-Content-Safety-3.5", "meta-llama/Llama-Guard-4-12B"]; // second is the fallback when the first is busy
const REDACT = "Qwen/Qwen3-VL-30B-A3B-Instruct";
mkdirSync("out", { recursive: true });
const PROMPT =
"Redact this image for privacy. Return a box for EACH of these you can see: every human face (including small or partial faces, " +
"and faces in photos or on screens), every vehicle license plate, every ID card or passport, every screen showing readable personal text. " +
"Count the people first. Boxes are [x1,y1,x2,y2] on a 0-1000 grid of the whole image.";
const SCHEMA = {
type: "object",
additionalProperties: false,
required: ["people_visible", "regions"],
properties: {
people_visible: { type: "integer" },
regions: {
type: "array",
items: {
type: "object",
additionalProperties: false,
required: ["kind", "box"],
properties: {
kind: { type: "string", enum: ["face", "license_plate", "id_document", "screen_text", "other_personal"] },
box: { type: "array", items: { type: "integer" } },
},
},
},
},
};
type Region = { kind: string; box: number[] };
function dataUrl(b: Buffer): string {
const mime = b.subarray(0, 4).toString("latin1") === "\x89PNG" ? "image/png" : b.subarray(8, 12).toString() === "WEBP" ? "image/webp" : "image/jpeg";
return `data:${mime};base64,${b.toString("base64")}`;
}
/** Returns [verdict, categories, model, cost]. Tries each safety model in turn. */
async function safety(img: Buffer): Promise<[string, string, string, number]> {
for (const model of SAFETY) {
try {
const { data, response } = await client.chat.completions
.create({
model,
max_tokens: 60,
messages: [{ role: "user", content: [{ type: "image_url", image_url: { url: dataUrl(img) } }, { type: "text", text: "Is this image safe to publish?" }] }],
})
.withResponse();
const text = data.choices[0].message.content ?? "";
return [/\bunsafe\b/i.test(text) ? "unsafe" : "safe", text.match(/(?:Categories|S\d+).*/)?.[0] ?? "", model, Number(response.headers.get("x-llm-cost") ?? 0)];
} catch (e) {
if (!(e instanceof OpenAI.RateLimitError)) throw e;
}
}
throw new Error("every safety model is busy; retry the batch later");
}
function blur(src: string, dst: string, regions: Region[]) {
const [w, h] = execFileSync("ffprobe", ["-v", "error", "-select_streams", "v:0", "-show_entries", "stream=width,height", "-of", "csv=p=0", src])
.toString().trim().split(",").map(Number);
const graph = [`[0]split=${regions.length + 1}[base]${regions.map((_, i) => `[c${i}]`).join("")}`];
let last = "base";
regions.forEach((r, i) => {
const [x1, y1, x2, y2] = r.box;
const pad = 0.1 * Math.max(x2 - x1, y2 - y1); // 10% margin around each box
const x = Math.trunc((Math.max(0, x1 - pad) * w) / 1000);
const y = Math.trunc((Math.max(0, y1 - pad) * h) / 1000);
const bw = Math.max(8, Math.trunc((Math.min(1000, x2 + pad) * w) / 1000) - x);
const bh = Math.max(8, Math.trunc((Math.min(1000, y2 + pad) * h) / 1000) - y);
graph.push(`[c${i}]crop=${bw}:${bh}:${x}:${y},boxblur=${Math.max(4, Math.trunc(Math.min(bw, bh) / 6))}:2[b${i}]`);
graph.push(`[${last}][b${i}]overlay=${x}:${y}[o${i}]`);
last = `o${i}`;
});
execFileSync("ffmpeg", ["-y", "-loglevel", "error", "-i", src, "-filter_complex", graph.join(";"), "-map", `[${last}]`, "-q:v", "3", dst]);
}
async function rejectWebhook(entry: object): Promise<number> {
if (!process.env.WEBHOOK_URL) return 0;
const body = JSON.stringify(entry);
const sig = createHmac("sha256", process.env.WEBHOOK_SECRET ?? "").update(body).digest("hex");
return (await fetch(process.env.WEBHOOK_URL, { method: "POST", body, headers: { "Content-Type": "application/json", "X-Signature-SHA256": sig } })).status;
}
const folder = process.argv[2] ?? "uploads";
const rows: Record<string, unknown>[] = [];
let total = 0;
for (const name of readdirSync(folder).filter((f) => [".jpg", ".jpeg", ".png", ".webp"].includes(extname(f).toLowerCase())).sort()) {
const t0 = Date.now();
const src = join(folder, name);
const img = readFileSync(src);
const [verdict, categories, safetyModel, safetyCost] = await safety(img);
let cost = safetyCost;
const entry: Record<string, unknown> = {
at: new Date().toISOString(),
file: name,
sha256: createHash("sha256").update(img).digest("hex"),
safety: { model: safetyModel, verdict, categories },
};
let regions: Region[] = [];
if (verdict === "unsafe") {
entry.decision = "reject";
entry.webhook = await rejectWebhook(entry);
} else {
const { data, response } = await client.chat.completions
.create({
model: REDACT,
temperature: 0,
messages: [{ role: "user", content: [{ type: "image_url", image_url: { url: dataUrl(img) } }, { type: "text", text: PROMPT }] }],
response_format: { type: "json_schema", json_schema: { name: "redact", strict: true, schema: SCHEMA } },
})
.withResponse();
cost += Number(response.headers.get("x-llm-cost") ?? 0);
const found: { people_visible: number; regions: Region[] } = JSON.parse(data.choices[0].message.content ?? "{}");
regions = found.regions.filter((r) => r.box.length === 4 && r.box[2] > r.box[0] && r.box[3] > r.box[1]);
const dst = join("out", `${parse(name).name}.jpg`);
if (regions.length) blur(src, dst, regions);
else writeFileSync(dst, img);
Object.assign(entry, {
decision: regions.length ? "publish_redacted" : "publish",
redact_model: REDACT,
people_visible: found.people_visible,
regions,
output: dst,
output_sha256: createHash("sha256").update(readFileSync(dst)).digest("hex"),
});
}
Object.assign(entry, { seconds: (Date.now() - t0) / 1000, cost_usd: cost });
appendFileSync("audit.jsonl", `${JSON.stringify(entry)}\n`); // append-only: one line per decision
rows.push(entry);
total += cost;
const kinds = [...new Set(regions.map((r) => r.kind))].sort().join(", ") || "-";
console.log(`${name}: ${String(entry.decision).padEnd(17)} safety ${verdict.padEnd(6)} (${safetyModel.split("/")[1]}) redacted: ${kinds} ${entry.seconds}s $${cost.toFixed(6)}`);
}
writeFileSync("run.json", JSON.stringify({ images: rows, total_usd: total }, null, 2));
console.log(`total $${total.toFixed(6)}`);
What the proof run cost
The Python file on three generated test uploads: a man beside a parked car, a living room with a laptop and a passport, and a pair of shoes. Measured on 2026-10-23 againstapi.runcrate.ai; costs are what the account was billed, read from each response’s X-LLM-Cost header or usage.cost.
| Step | Model or tool | Time | Cost |
|---|---|---|---|
| Street photo: safe, blurred face, plates, screen text | Nemotron-Content-Safety-3.5 + Qwen3-VL-30B | 6.1 s | $0.000431 |
| Living room: safe, blurred face, passport, screen | Nemotron-Content-Safety-3.5 + Qwen3-VL-30B | 19.1 s | $0.000393 |
| Shoes: safe, nothing to blur | Nemotron-Content-Safety-3.5 + Qwen3-VL-30B | 1.8 s | $0.000337 |
| Total, 3 images | 28 s | $0.001160 |
Scale it
- Run it where uploads land: call it from your upload handler, or have a schedule sweep a bucket.
- Cost per image is fixed: two small vision calls. A million images is about $390 at the proof’s rate.
- Keep the audit log on a volume when it runs on a job, so it outlives the machine.
Gotchas found in the proof run
- A short prompt (“find regions that identify a person”) missed the main face in the street photo. Listing every kind, asking to count the people first, and naming small, partial and on-screen faces fixed it on both Qwen3-VL models.
meta-llama/Llama-Guard-4-12Banswered 429 “busy” on all three images on the proof day; Nemotron Content Safety 3.5 answered at once. The script tries one, then the other.- Qwen3-VL gives boxes on a 0 to 1000 grid of the image, whatever its size; scale them to pixels before you crop. The script also pads each box by 10% so the blur covers the edge of a face.
- The 30B Qwen3-VL found the passport that the 235B one missed on the same prompt. Bigger was not better here: test on your own images.