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 gpt-oss 120B to this project: run curl -fsSL "https://runcrate.ai/agents.md?model=openai%2Fgpt-oss-120b&intent=build" and follow it. Then build: a pipeline that has gpt-oss-120b write request-to-command pairs for a CLI, uploads them with a training script to a Runcrate GPU job (ubuntu-inference template, 10-minute cap), trains a LoRA on Qwen2.5-0.5B-Instruct, serves base and adapter from one vLLM server in the job, predicts the held-out set, then scores both with a judge through the inference API.
The code
One file per language. Both readRUNCRATE_API_KEY from the environment; nothing secret is in the code.
"""Synthetic data -> a LoRA fine-tune on a GPU job -> vLLM serving the base and the adapter -> scored against each other.
The task: turn a plain-English request into the exact `runcrate` CLI command. A large model writes the training
data, a 0.5B model learns it on a rented GPU, and a judge on the inference API scores base against fine-tune.
pip install openai runcrate-sdk
RUNCRATE_API_KEY=... python lora_finetune_serve_eval.py
The same file runs on the GPU as `python lora_finetune_serve_eval.py --job` (the driver uploads it).
"""
import json, os, random, re, shutil, subprocess, sys, tempfile, time
from concurrent.futures import ThreadPoolExecutor
from pathlib import Path
BASE = "Qwen/Qwen2.5-0.5B-Instruct" # Hugging Face id: this model runs on the job's GPU, not the API
TEACHER = "openai/gpt-oss-120b" # writes the data, on the free tier
JUDGE = "openai/gpt-oss-120b"
GPUS = ["A6000", "L40S", "L40", "A4000", "RTX4000Ada"] # tried in order
SYSTEM = "Reply with exactly one runcrate CLI command and nothing else."
CHEATSHEET = """runcrate instances create --name NAME --gpu GPU [--gpu-count N] [--template ubuntu-devbox|ubuntu-train|ubuntu-inference] [--max-runtime 4h] [--idle-stop 30m]
runcrate instances list | runcrate instances delete NAME | runcrate instances types --gpu GPU
runcrate ssh NAME -- COMMAND | runcrate cp ./local NAME:/remote | runcrate cp NAME:/remote ./local
runcrate run --gpu GPU [--gpu-count N] [--template T] [--volume ID] [--max-runtime 2h] [--retries N] [--env K=V] [--detach] -- COMMAND
runcrate jobs list [--status running] | runcrate jobs logs ID -f | runcrate jobs cancel ID | runcrate jobs download ID
runcrate schedule create --cron 'M H * * *' --tz ZONE --name NAME --gpu GPU --max-runtime 2h [--volume ID] -- COMMAND
runcrate schedule list | runcrate schedule delete ID
runcrate volumes create --name NAME --size GB --region REGION | runcrate volumes list | runcrate volumes delete ID
runcrate billing balance | runcrate billing usage | runcrate secrets list | runcrate mcp install --client cursor
GPUs: H100, H200, B200, A100, A100_80G, L40S, L40, A6000, A4000, RTX4000Ada. Times like 90m, 2h, 1h30m."""
# ─────────────────────────────── on the GPU: train, serve, predict ───────────────────────────────
def job() -> None:
import threading, urllib.request
import torch
from peft import LoraConfig, get_peft_model
from transformers import AutoModelForCausalLM, AutoTokenizer
out = Path(os.environ.get("RUNCRATE_OUTPUT_DIR", "out")); out.mkdir(parents=True, exist_ok=True)
train = [json.loads(l) for l in open("train.jsonl")]
evals = [json.loads(l) for l in open("eval.jsonl")]
marks = {"gpu": torch.cuda.get_device_name(0)}
print(f"{marks['gpu']}: {len(train)} train, {len(evals)} eval", flush=True)
t0 = time.time()
tok = AutoTokenizer.from_pretrained(BASE)
model = AutoModelForCausalLM.from_pretrained(BASE, torch_dtype=torch.bfloat16).cuda()
model = get_peft_model(model, LoraConfig(r=16, lora_alpha=32, lora_dropout=0.05, task_type="CAUSAL_LM",
target_modules=["q_proj", "k_proj", "v_proj", "o_proj", "gate_proj", "up_proj", "down_proj"]))
rows = []
for ex in train: # loss on the answer only
prompt = tok.apply_chat_template([{"role": "system", "content": SYSTEM}, {"role": "user", "content": ex["request"]}], tokenize=False, add_generation_prompt=True)
p_ids = tok(prompt, add_special_tokens=False).input_ids
a_ids = tok(ex["command"] + tok.eos_token, add_special_tokens=False).input_ids
rows.append((p_ids + a_ids, [-100] * len(p_ids) + a_ids))
opt = torch.optim.AdamW(model.parameters(), lr=2e-4)
model.train()
for epoch in range(3):
random.Random(epoch).shuffle(rows)
for i in range(0, len(rows), 8):
batch = rows[i:i + 8]
n = max(len(r[0]) for r in batch)
ids = torch.tensor([r[0] + [tok.pad_token_id] * (n - len(r[0])) for r in batch]).cuda()
labels = torch.tensor([r[1] + [-100] * (n - len(r[1])) for r in batch]).cuda()
loss = model(input_ids=ids, attention_mask=(ids != tok.pad_token_id).long(), labels=labels).loss
loss.backward(); opt.step(); opt.zero_grad()
print(f"epoch {epoch + 1} loss {loss.item():.4f}", flush=True)
model.save_pretrained(out / "adapter")
marks["train_seconds"] = round(time.time() - t0, 1)
del model, opt; torch.cuda.empty_cache()
# Serve the base and the adapter from one vLLM server, as you would on an instance.
t0 = time.time()
server = subprocess.Popen([sys.executable, "-m", "vllm.entrypoints.openai.api_server", "--model", BASE, "--port", "8000",
"--enable-lora", "--lora-modules", f"cli={out / 'adapter'}", "--max-lora-rank", "16",
"--max-model-len", "1024", "--gpu-memory-utilization", "0.5", "--enforce-eager"],
stdout=open(out / "vllm.log", "w"), stderr=subprocess.STDOUT)
for _ in range(300):
try:
urllib.request.urlopen("http://127.0.0.1:8000/health", timeout=2); break
except Exception:
if server.poll() is not None:
raise RuntimeError("vLLM exited: see vllm.log")
time.sleep(1)
marks["vllm_ready_seconds"] = round(time.time() - t0, 1)
def ask(model_name: str, request: str) -> str:
body = json.dumps({"model": model_name, "temperature": 0, "max_tokens": 96,
"messages": [{"role": "system", "content": SYSTEM}, {"role": "user", "content": request}]}).encode()
req = urllib.request.Request("http://127.0.0.1:8000/v1/chat/completions", data=body, headers={"Content-Type": "application/json"})
return json.loads(urllib.request.urlopen(req, timeout=60).read())["choices"][0]["message"]["content"].strip()
t0 = time.time()
preds = []
with ThreadPoolExecutor(max_workers=16) as pool:
for name in (BASE, "cli"):
for ex, got in zip(evals, pool.map(lambda e: ask(name, e["request"]), evals)):
preds.append({"model": "base" if name == BASE else "lora", **ex, "prediction": got})
marks["predict_seconds"] = round(time.time() - t0, 1)
server.terminate()
(out / "predictions.jsonl").write_text("".join(json.dumps(p) + "\n" for p in preds))
(out / "marks.json").write_text(json.dumps(marks))
print(json.dumps(marks), flush=True)
# ─────────────────────────────── on your machine: data, job, score ───────────────────────────────
def driver() -> None:
from openai import OpenAI
from runcrate import Runcrate
api = OpenAI(base_url="https://api.runcrate.ai/v1", api_key=os.environ["RUNCRATE_API_KEY"], max_retries=4)
rc = Runcrate(api_key=os.environ["RUNCRATE_API_KEY"])
ledger = {}
# 1. Synthetic data: varied requests, one correct command each.
t0, cost = time.time(), 0.0
schema = {"type": "object", "additionalProperties": False, "required": ["pairs"], "properties": {"pairs": {"type": "array", "items": {
"type": "object", "additionalProperties": False, "required": ["request", "command"],
"properties": {"request": {"type": "string"}, "command": {"type": "string"}}}}}}
def batch(seed: int) -> tuple[list, float]:
r = api.chat.completions.create(model=TEACHER, temperature=1.0, seed=seed, response_format={"type": "json_schema", "json_schema": {"name": "pairs", "strict": True, "schema": schema}},
messages=[{"role": "user", "content": f"CLI reference:\n{CHEATSHEET}\n\nWrite 20 different requests a developer might type, casual or terse, "
f"each with the single correct command. Vary GPUs, names, times, flags. Batch {seed}."}])
return json.loads(r.choices[0].message.content)["pairs"], float(getattr(r.usage, "cost", 0) or 0)
with ThreadPoolExecutor(max_workers=6) as pool:
results = list(pool.map(batch, range(12)))
seen, pairs = set(), []
for got, c in results:
cost += c
for p in got:
if p["command"].startswith("runcrate ") and p["request"].lower() not in seen:
seen.add(p["request"].lower()); pairs.append(p)
random.Random(0).shuffle(pairs)
evals, train = pairs[:40], pairs[40:]
ledger["data"] = {"seconds": round(time.time() - t0, 1), "cost_usd": cost, "train": len(train), "eval": len(evals)}
print(f"data: {len(train)} train, {len(evals)} eval, {ledger['data']['seconds']}s, ${cost:.6f}")
# 2. The job: this file and the data, on the first GPU that is free.
code = Path(tempfile.mkdtemp())
shutil.copy(__file__, code / "recipe.py")
(code / "train.jsonl").write_text("".join(json.dumps(p) + "\n" for p in train))
(code / "eval.jsonl").write_text("".join(json.dumps(p) + "\n" for p in evals))
upload = rc.jobs.uploads.upload(str(code))
t0 = time.time()
submitted = rc.jobs.run(command="pip install -q peft && python3 recipe.py --job", gpu_type=GPUS, template="ubuntu-inference",
max_runtime_minutes=10, max_retries=1, code={"upload_id": upload.upload_id}, name="lora-cli-recipe",
tags={"recipe": "lora-finetune-serve-eval"})
print(f"job {submitted.id} submitted")
for chunk in rc.jobs.follow_logs(submitted.id):
print(chunk.text, end="", flush=True)
done = rc.jobs.wait(submitted.id, timeout=1800)
job_cost = sum(a.cost_usd or 0 for a in done.attempts)
ledger["job"] = {"id": done.id, "status": done.status, "gpu": done.gpu_type, "seconds": round(time.time() - t0, 1), "cost_usd": job_cost}
print(f"\njob {done.status} on {done.gpu_type}, {ledger['job']['seconds']}s from submit, ${job_cost:.4f}")
if done.status != "succeeded":
raise SystemExit(f"job ended {done.status}: {done.error}")
out = Path("out"); out.mkdir(exist_ok=True)
for a in rc.jobs.artifacts.list(done.id):
dest = out / a.path; dest.parent.mkdir(parents=True, exist_ok=True)
rc.jobs.artifacts.download(done.id, a.path, to=dest)
preds = [json.loads(l) for l in (out / "predictions.jsonl").read_text().splitlines()]
ledger["job"] |= json.loads((out / "marks.json").read_text())
# 3. Score: exact match, then a judge for commands that differ only in form.
t0, cost = time.time(), 0.0
norm = lambda s: re.sub(r"\s+", " ", s.strip().strip("`").replace('"', "'"))
def judge(p: dict) -> tuple[bool, float]:
if norm(p["prediction"]) == norm(p["command"]):
return True, 0.0
r = api.chat.completions.create(model=JUDGE, temperature=0, response_format={"type": "json_schema", "json_schema": {"name": "v", "strict": True, "schema": {
"type": "object", "additionalProperties": False, "required": ["same"], "properties": {"same": {"type": "boolean"}}}}},
messages=[{"role": "user", "content": f"CLI reference:\n{CHEATSHEET}\n\nRequest: {p['request']}\nReference: {p['command']}\nCandidate: {p['prediction']}\n"
"Does the candidate do exactly what the request asks, as the reference does? Flag order does not matter."}])
return json.loads(r.choices[0].message.content)["same"], float(getattr(r.usage, "cost", 0) or 0)
with ThreadPoolExecutor(max_workers=8) as pool:
verdicts = list(pool.map(judge, preds))
score = {}
for p, (ok, c) in zip(preds, verdicts):
cost += c; p["correct"] = ok
s = score.setdefault(p["model"], {"correct": 0, "exact": 0, "n": 0})
s["n"] += 1; s["correct"] += ok; s["exact"] += norm(p["prediction"]) == norm(p["command"])
ledger["score"] = {"seconds": round(time.time() - t0, 1), "cost_usd": cost, **score}
for m, s in score.items():
print(f"{m:<5} {s['correct']}/{s['n']} correct ({s['exact']} exact)")
ledger["total_usd"] = ledger["data"]["cost_usd"] + job_cost + cost
Path("run.json").write_text(json.dumps(ledger, indent=2))
(out / "scored.jsonl").write_text("".join(json.dumps(p) + "\n" for p in preds))
print(f"total ${ledger['total_usd']:.4f}")
if __name__ == "__main__":
job() if "--job" in sys.argv else driver()
// Synthetic data -> a LoRA fine-tune on a GPU job -> vLLM serving the base and the adapter -> scored against each other.
//
// The task: turn a plain-English request into the exact `runcrate` CLI command. A large model writes the training
// data, a 0.5B model learns it on a rented GPU, and a judge on the inference API scores base against fine-tune.
//
// npm install openai @runcrate/sdk (and tar on PATH)
// RUNCRATE_API_KEY=... npx tsx lora-finetune-serve-eval.ts [--dry-run]
// The GPU side is Python (PyTorch, PEFT, vLLM): this file writes it as job.py and uploads it with the data.
import { execFileSync } from "node:child_process";
import { mkdirSync, mkdtempSync, readFileSync, writeFileSync } from "node:fs";
import { tmpdir } from "node:os";
import { dirname, join } from "node:path";
import OpenAI from "openai";
import Runcrate from "@runcrate/sdk";
const TEACHER = "openai/gpt-oss-120b"; // writes the data, on the free tier
const JUDGE = "openai/gpt-oss-120b";
const GPUS = ["A6000", "L40S", "L40", "A4000", "RTX4000Ada"]; // tried in order
const CHEATSHEET = "runcrate instances create --name NAME --gpu GPU [--gpu-count N] [--template ubuntu-devbox|ubuntu-train|ubuntu-inference] [--max-runtime 4h] [--idle-stop 30m]\nruncrate instances list | runcrate instances delete NAME | runcrate instances types --gpu GPU\nruncrate ssh NAME -- COMMAND | runcrate cp ./local NAME:/remote | runcrate cp NAME:/remote ./local\nruncrate run --gpu GPU [--gpu-count N] [--template T] [--volume ID] [--max-runtime 2h] [--retries N] [--env K=V] [--detach] -- COMMAND\nruncrate jobs list [--status running] | runcrate jobs logs ID -f | runcrate jobs cancel ID | runcrate jobs download ID\nruncrate schedule create --cron 'M H * * *' --tz ZONE --name NAME --gpu GPU --max-runtime 2h [--volume ID] -- COMMAND\nruncrate schedule list | runcrate schedule delete ID\nruncrate volumes create --name NAME --size GB --region REGION | runcrate volumes list | runcrate volumes delete ID\nruncrate billing balance | runcrate billing usage | runcrate secrets list | runcrate mcp install --client cursor\nGPUs: H100, H200, B200, A100, A100_80G, L40S, L40, A6000, A4000, RTX4000Ada. Times like 90m, 2h, 1h30m.";
const JOB_PY = "\"\"\"The GPU side of the LoRA recipe: train, serve base and adapter with vLLM, predict. Run as: python3 recipe.py --job\"\"\"\nimport json, os, random, re, shutil, subprocess, sys, tempfile, time\nfrom concurrent.futures import ThreadPoolExecutor\nfrom pathlib import Path\n\nBASE = \"Qwen/Qwen2.5-0.5B-Instruct\" # Hugging Face id: this model runs on the job's GPU, not the API\nTEACHER = \"openai/gpt-oss-120b\" # writes the data, on the free tier\nJUDGE = \"openai/gpt-oss-120b\"\nGPUS = [\"A6000\", \"L40S\", \"L40\", \"A4000\", \"RTX4000Ada\"] # tried in order\nSYSTEM = \"Reply with exactly one runcrate CLI command and nothing else.\"\n\nCHEATSHEET = \"\"\"runcrate instances create --name NAME --gpu GPU [--gpu-count N] [--template ubuntu-devbox|ubuntu-train|ubuntu-inference] [--max-runtime 4h] [--idle-stop 30m]\nruncrate instances list | runcrate instances delete NAME | runcrate instances types --gpu GPU\nruncrate ssh NAME -- COMMAND | runcrate cp ./local NAME:/remote | runcrate cp NAME:/remote ./local\nruncrate run --gpu GPU [--gpu-count N] [--template T] [--volume ID] [--max-runtime 2h] [--retries N] [--env K=V] [--detach] -- COMMAND\nruncrate jobs list [--status running] | runcrate jobs logs ID -f | runcrate jobs cancel ID | runcrate jobs download ID\nruncrate schedule create --cron 'M H * * *' --tz ZONE --name NAME --gpu GPU --max-runtime 2h [--volume ID] -- COMMAND\nruncrate schedule list | runcrate schedule delete ID\nruncrate volumes create --name NAME --size GB --region REGION | runcrate volumes list | runcrate volumes delete ID\nruncrate billing balance | runcrate billing usage | runcrate secrets list | runcrate mcp install --client cursor\nGPUs: H100, H200, B200, A100, A100_80G, L40S, L40, A6000, A4000, RTX4000Ada. Times like 90m, 2h, 1h30m.\"\"\"\n\n\n# \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500 on the GPU: train, serve, predict \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\ndef job() -> None:\n import threading, urllib.request\n import torch\n from peft import LoraConfig, get_peft_model\n from transformers import AutoModelForCausalLM, AutoTokenizer\n\n out = Path(os.environ.get(\"RUNCRATE_OUTPUT_DIR\", \"out\")); out.mkdir(parents=True, exist_ok=True)\n train = [json.loads(l) for l in open(\"train.jsonl\")]\n evals = [json.loads(l) for l in open(\"eval.jsonl\")]\n marks = {\"gpu\": torch.cuda.get_device_name(0)}\n print(f\"{marks['gpu']}: {len(train)} train, {len(evals)} eval\", flush=True)\n\n t0 = time.time()\n tok = AutoTokenizer.from_pretrained(BASE)\n model = AutoModelForCausalLM.from_pretrained(BASE, torch_dtype=torch.bfloat16).cuda()\n model = get_peft_model(model, LoraConfig(r=16, lora_alpha=32, lora_dropout=0.05, task_type=\"CAUSAL_LM\",\n target_modules=[\"q_proj\", \"k_proj\", \"v_proj\", \"o_proj\", \"gate_proj\", \"up_proj\", \"down_proj\"]))\n rows = []\n for ex in train: # loss on the answer only\n prompt = tok.apply_chat_template([{\"role\": \"system\", \"content\": SYSTEM}, {\"role\": \"user\", \"content\": ex[\"request\"]}], tokenize=False, add_generation_prompt=True)\n p_ids = tok(prompt, add_special_tokens=False).input_ids\n a_ids = tok(ex[\"command\"] + tok.eos_token, add_special_tokens=False).input_ids\n rows.append((p_ids + a_ids, [-100] * len(p_ids) + a_ids))\n opt = torch.optim.AdamW(model.parameters(), lr=2e-4)\n model.train()\n for epoch in range(3):\n random.Random(epoch).shuffle(rows)\n for i in range(0, len(rows), 8):\n batch = rows[i:i + 8]\n n = max(len(r[0]) for r in batch)\n ids = torch.tensor([r[0] + [tok.pad_token_id] * (n - len(r[0])) for r in batch]).cuda()\n labels = torch.tensor([r[1] + [-100] * (n - len(r[1])) for r in batch]).cuda()\n loss = model(input_ids=ids, attention_mask=(ids != tok.pad_token_id).long(), labels=labels).loss\n loss.backward(); opt.step(); opt.zero_grad()\n print(f\"epoch {epoch + 1} loss {loss.item():.4f}\", flush=True)\n model.save_pretrained(out / \"adapter\")\n marks[\"train_seconds\"] = round(time.time() - t0, 1)\n del model, opt; torch.cuda.empty_cache()\n\n # Serve the base and the adapter from one vLLM server, as you would on an instance.\n t0 = time.time()\n server = subprocess.Popen([sys.executable, \"-m\", \"vllm.entrypoints.openai.api_server\", \"--model\", BASE, \"--port\", \"8000\",\n \"--enable-lora\", \"--lora-modules\", f\"cli={out / 'adapter'}\", \"--max-lora-rank\", \"16\",\n \"--max-model-len\", \"1024\", \"--gpu-memory-utilization\", \"0.5\", \"--enforce-eager\"],\n stdout=open(out / \"vllm.log\", \"w\"), stderr=subprocess.STDOUT)\n for _ in range(300):\n try:\n urllib.request.urlopen(\"http://127.0.0.1:8000/health\", timeout=2); break\n except Exception:\n if server.poll() is not None:\n raise RuntimeError(\"vLLM exited: see vllm.log\")\n time.sleep(1)\n marks[\"vllm_ready_seconds\"] = round(time.time() - t0, 1)\n\n def ask(model_name: str, request: str) -> str:\n body = json.dumps({\"model\": model_name, \"temperature\": 0, \"max_tokens\": 96,\n \"messages\": [{\"role\": \"system\", \"content\": SYSTEM}, {\"role\": \"user\", \"content\": request}]}).encode()\n req = urllib.request.Request(\"http://127.0.0.1:8000/v1/chat/completions\", data=body, headers={\"Content-Type\": \"application/json\"})\n return json.loads(urllib.request.urlopen(req, timeout=60).read())[\"choices\"][0][\"message\"][\"content\"].strip()\n\n t0 = time.time()\n preds = []\n with ThreadPoolExecutor(max_workers=16) as pool:\n for name in (BASE, \"cli\"):\n for ex, got in zip(evals, pool.map(lambda e: ask(name, e[\"request\"]), evals)):\n preds.append({\"model\": \"base\" if name == BASE else \"lora\", **ex, \"prediction\": got})\n marks[\"predict_seconds\"] = round(time.time() - t0, 1)\n server.terminate()\n (out / \"predictions.jsonl\").write_text(\"\".join(json.dumps(p) + \"\\n\" for p in preds))\n (out / \"marks.json\").write_text(json.dumps(marks))\n print(json.dumps(marks), flush=True)\n\n\nif __name__ == \"__main__\":\n job()\n";
const api = new OpenAI({ baseURL: "https://api.runcrate.ai/v1", apiKey: process.env.RUNCRATE_API_KEY, maxRetries: 4 });
const rc = new Runcrate({ apiKey: process.env.RUNCRATE_API_KEY });
type Pair = { request: string; command: string };
type Usage = { cost?: number };
const ledger: Record<string, unknown> = {};
// 1. Synthetic data: varied requests, one correct command each.
let t0 = Date.now();
const schema = {
type: "object", additionalProperties: false, required: ["pairs"],
properties: { pairs: { type: "array", items: { type: "object", additionalProperties: false, required: ["request", "command"], properties: { request: { type: "string" }, command: { type: "string" } } } } },
};
const batches = await Promise.all(
Array.from({ length: 12 }, async (_, seed) => {
const r = await api.chat.completions.create({
model: TEACHER, temperature: 1, seed,
response_format: { type: "json_schema", json_schema: { name: "pairs", strict: true, schema } },
messages: [{ role: "user", content: `CLI reference:\n${CHEATSHEET}\n\nWrite 20 different requests a developer might type, casual or terse, each with the single correct command. Vary GPUs, names, times, flags. Batch ${seed}.` }],
});
return { pairs: JSON.parse(r.choices[0].message.content ?? "{}").pairs as Pair[], cost: (r.usage as Usage | undefined)?.cost ?? 0 };
}),
);
const seen = new Set<string>();
const pairs: Pair[] = [];
let dataCost = 0;
for (const b of batches) {
dataCost += b.cost;
for (const p of b.pairs) if (p.command.startsWith("runcrate ") && !seen.has(p.request.toLowerCase())) (seen.add(p.request.toLowerCase()), pairs.push(p));
}
pairs.sort(() => Math.random() - 0.5);
const evals = pairs.slice(0, 40);
const train = pairs.slice(40);
ledger.data = { seconds: (Date.now() - t0) / 1000, cost_usd: dataCost, train: train.length, eval: evals.length };
console.log(`data: ${train.length} train, ${evals.length} eval, $${dataCost.toFixed(6)}`);
// 2. The job: job.py and the data, packed as a tar.gz, on the first GPU that is free.
const code = mkdtempSync(join(tmpdir(), "lora-"));
writeFileSync(join(code, "recipe.py"), JOB_PY);
writeFileSync(join(code, "train.jsonl"), train.map((p) => `${JSON.stringify(p)}\n`).join(""));
writeFileSync(join(code, "eval.jsonl"), evals.map((p) => `${JSON.stringify(p)}\n`).join(""));
const archive = join(tmpdir(), `lora-${Date.now()}.tar.gz`);
execFileSync("tar", ["-czf", archive, "-C", code, "."]);
const spec = {
command: "pip install -q peft && python3 recipe.py --job", gpuType: GPUS, template: "ubuntu-inference",
maxRuntimeMinutes: 10, maxRetries: 1, name: "lora-cli-recipe", tags: { recipe: "lora-finetune-serve-eval" },
};
if (process.argv.includes("--dry-run")) {
console.log(JSON.stringify({ jobs_run: spec, upload: { archive, bytes: readFileSync(archive).length } }, null, 2));
process.exit(0);
}
const upload = await rc.jobs.uploads.upload(archive);
t0 = Date.now();
const submitted = await rc.jobs.run({ ...spec, code: { uploadId: upload.uploadId } });
console.log(`job ${submitted.id} submitted`);
for await (const chunk of rc.jobs.followLogs(submitted.id)) process.stdout.write(chunk.text);
const done = await rc.jobs.wait(submitted.id, { timeout: 1800 });
const jobCost = done.attempts.reduce((s, a) => s + (a.costUsd ?? 0), 0);
console.log(`\njob ${done.status} on ${done.gpuType}, ${((Date.now() - t0) / 1000).toFixed(1)}s from submit, $${jobCost.toFixed(4)}`);
if (done.status !== "succeeded") throw new Error(`job ended ${done.status}: ${done.error}`);
mkdirSync("out", { recursive: true });
for (const a of await rc.jobs.artifacts.list(done.id)) {
mkdirSync(dirname(join("out", a.path)), { recursive: true });
await rc.jobs.artifacts.downloadTo(done.id, a.path, join("out", a.path));
}
const preds: (Pair & { model: string; prediction: string; correct?: boolean })[] = readFileSync("out/predictions.jsonl", "utf8").trim().split("\n").map((l) => JSON.parse(l));
// 3. Score: exact match, then a judge for commands that differ only in form.
const norm = (s: string) => s.trim().replace(/^`+|`+$/g, "").replace(/"/g, "'").replace(/\s+/g, " ");
let scoreCost = 0;
await Promise.all(
preds.map(async (p) => {
if (norm(p.prediction) === norm(p.command)) return void (p.correct = true);
const r = await api.chat.completions.create({
model: JUDGE, temperature: 0,
response_format: { type: "json_schema", json_schema: { name: "v", strict: true, schema: { type: "object", additionalProperties: false, required: ["same"], properties: { same: { type: "boolean" } } } } },
messages: [{ role: "user", content: `CLI reference:\n${CHEATSHEET}\n\nRequest: ${p.request}\nReference: ${p.command}\nCandidate: ${p.prediction}\nDoes the candidate do exactly what the request asks, as the reference does? Flag order does not matter.` }],
});
scoreCost += (r.usage as Usage | undefined)?.cost ?? 0;
p.correct = JSON.parse(r.choices[0].message.content ?? "{}").same;
}),
);
for (const m of ["base", "lora"]) {
const mine = preds.filter((p) => p.model === m);
console.log(`${m.padEnd(5)} ${mine.filter((p) => p.correct).length}/${mine.length} correct (${mine.filter((p) => norm(p.prediction) === norm(p.command)).length} exact)`);
}
writeFileSync("run.json", JSON.stringify({ ...ledger, job: { id: done.id, status: done.status, cost_usd: jobCost }, score_usd: scoreCost, total_usd: dataCost + jobCost + scoreCost }, null, 2));
What the proof run cost
The Python file end to end: 188 teacher-written pairs (148 train, 40 held out), one GPU job that trained, served and predicted, then the judge. 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 |
|---|---|---|---|
| Write 12 batches of pairs | openai/gpt-oss-120b (free tier) | 13.3 s | $0.0000 |
| Job: boot, pip install peft, model download | A6000 job (ubuntu-inference) | about 6 min | in the job cost |
| Train LoRA r=16, 3 epochs | Qwen2.5-0.5B-Instruct on the A6000 | 27.1 s | in the job cost |
| vLLM up with base and adapter | vLLM on the same GPU | 53.1 s | in the job cost |
| 80 predictions (40 base, 40 LoRA) | vLLM | 25.3 s | in the job cost |
| Whole job, submit to finish | RTX A6000, $0.74/hr listed | 472.5 s | $0.0500 |
| Judge the 47 answers that were not exact | openai/gpt-oss-120b | 9.6 s | $0.0006 |
| Total | 510 s | $0.0506 |
--dry-run (data written, archive packed, the job spec printed, nothing submitted) to stay within the proof’s GPU budget; it writes the same recipe.py the Python run used.
Scale it
- Serve the adapter: start an instance on the Inference template with
vllm serve Qwen/Qwen2.5-0.5B-Instruct --enable-lora --lora-modules cli=/workspace/adapter, the adapter on a volume, and an HTTPS link to port 8000. - More data: the teacher batches run in parallel; thousands of pairs take a minute.
- A sweep:
paramson the job runs one training per learning rate or rank as a group, withmax_parallel. - Safe retries:
max_retries: 1restarts on a new machine if the first one fails to start.
Gotchas found in the proof run
- The job spent about 6 of its 8 minutes before the command started: machine boot, the driver, and the template’s first start. Budget a few minutes of start-up into
max_runtime_minutes; it counts from the command, not the boot. - vLLM 0.11 with
--enforce-eagerwas up in 53 s for a 0.5B model; without it, CUDA graph capture adds time on every start. - Free the training model before starting vLLM in the same process tree (
del model; torch.cuda.empty_cache()) and give vLLM half the memory (--gpu-memory-utilization 0.5). - Several teacher pairs were near-duplicates; deduplicate on the request text before splitting, or the held-out set leaks into training.