LizzyDocs
Docs navigation Training

Stage 04 · Plan and train

Validate a plan and start training

Generate a recipe for one dataset version, validate it without compute, approve the plan hash, and monitor the resulting run.

Know which call starts compute

POST /distill/recipes/propose analyzes a ready version and returns an editable plan. It requires distill:read and starts no compute. A run create requiresdistill:write. A dry run executes the workflow with stub artifacts and no GPU; a live run can spend the submitted budget immediately.

Public REST

Your client owns approval

A live write token sends the mutation directly to the run service.

No implicit Pilot pause
Pilot in Lizzy

Pilot prepares a proposal

The person reviews the exact live action before Pilot executes it.

Approval is part of the Pilot workflow
Test mode

Every run is forced dry

Setting dry_run false with a test token still produces a test-mode dry run.

Test artifacts cannot authorize live work

New integrations should always prove live work with validated_dry_run andplan_hash. The service retains a compatibility path for older clients that omit those fields, but that path does not give an operator evidence that the submitted plan was rehearsed.

Propose a version-scoped recipe

Wait until the dataset version is ready and has rows. Supply exactly one immutable version, one objective from cost, quality, orlatency, and the reward IDs you intend to optimize. Choosedeployable_student only when the run should include the publish stage.

bashrequest
curl --fail-with-body -sS -X POST "https://lizzy.albinilabs.com/v1/distill/recipes/propose" \
  -H "Authorization: Bearer $LIZZY_API_TOKEN" \
  -H "Content-Type: application/json" \
  --data '{
    "dataset_version": "dsv_...",
    "objective": "quality",
    "max_cost_usd": 25,
    "rewards": ["rwd_..."],
    "outcome": "deployable_student"
  }'
jsonexample response
{
  "object": "distill_recipe",
  "case": "single_call",
  "student_model": "qwen3-8b",
  "stages": [
    { "kind": "sft", "config": { "epochs": 2, "lora_rank": 32, "learning_rate": 0.0001, "max_seq_len": 8192 } },
    { "kind": "eval", "config": { "rewards": ["rwd_..."], "judge_model": "claude", "sample": 500 } },
    { "kind": "publish", "config": {} }
  ],
  "rewards": ["rwd_..."],
  "estimate": { "gpu_seconds": 2400, "cost_usd": 18.40, "wall_clock_minutes": 47 },
  "dataset_version": "dsv_...",
  "outcome": "deployable_student",
  "plan_hash": "<proposal-hash>"
}

For a stable executable payload, retain only student_model,stages, and rewards from the proposal. Review every stage. The canonical stage order is materialize, sft, grpo,eval, then publish; materialize is implicit.

typescriptextract executable recipe
const proposal = await response.json();
const recipe = {
  student_model: proposal.student_model,
  stages: proposal.stages,
  rewards: proposal.rewards,
};


// Preserve this exact object for dry and live requests.
const budget = { max_cost_usd: 25 };
const compute = { profile: "serverless" };

Validate the exact plan in live mode

Submit the intended dataset version, recipe, budget, and compute withdry_run: true. Use the same live credential intended for training. A test-mode dry run lives in a separate resource partition and cannot be referenced by a live run.

bashcreate dry run
DRY_BODY='{
  "dataset_version": "dsv_...",
  "student_model_name": "support-student-v1",
  "name": "Support student rehearsal",
  "recipe": {
    "student_model": "qwen3-8b",
    "stages": [
      { "kind": "sft", "config": { "epochs": 2, "lora_rank": 32, "learning_rate": 0.0001, "max_seq_len": 8192 } },
      { "kind": "eval", "config": { "rewards": ["rwd_..."], "judge_model": "claude", "sample": 500 } },
      { "kind": "publish", "config": {} }
    ],
    "rewards": ["rwd_..."]
  },
  "budget": { "max_cost_usd": 25 },
  "compute": { "profile": "serverless" },
  "dry_run": true
}'


curl --fail-with-body -sS -X POST "https://lizzy.albinilabs.com/v1/distill/runs" \
  -H "Authorization: Bearer $LIZZY_API_TOKEN" \
  -H "Content-Type: application/json" \
  -H "Idempotency-Key: support-plan-dry-v1" \
  --data "$DRY_BODY"
jsonexample 201 response
{
  "id": "run_dry...",
  "object": "distill_run",
  "livemode": true,
  "status": "queued",
  "dry_run": true,
  "dataset_version": "dsv_...",
  "plan_hash": "9b4d...<64 hex characters>",
  "validated_dry_run": null,
  "compute": { "profile": "serverless", "machine": null, "spot": false, "gpu": null },
  "totals": { "gpu_seconds": 0, "judge_tokens": 0, "cost_usd": 0.0 },
  "budget": { "max_cost_usd": 25 },
  "report": null,
  "student_model": null,
  "current_stage": { "seq": 1, "kind": "materialize", "status": "pending" },
  "stages": [
    { "seq": 1, "kind": "materialize", "status": "pending", "attempt": 0 },
    { "seq": 2, "kind": "sft", "status": "pending", "attempt": 0 },
    { "seq": 3, "kind": "grpo", "status": "skipped", "attempt": 0 },
    { "seq": 4, "kind": "eval", "status": "pending", "attempt": 0 },
    { "seq": 5, "kind": "publish", "status": "pending", "attempt": 0 }
  ]
}

The real response includes each stage's config, timestamps, metrics, error, and log availability. A dry run walks the same five-stage timeline, but it uses stubs, spends no GPU, skips student publication, and never creates a deployable student. Wait forsucceeded, then persist its ID and returned plan_hash.

Start only the approved live plan

  1. 1

    Review the rehearsal

    Check its version, rewards, stages, compute profile, estimate, and hard budget.

  2. 2

    Record the human decision

    Store the approver, timestamp, dry-run ID, plan hash, and exact serialized plan.

  3. 3

    Send the matching plan

    Change dry_run to false, attach the proof fields, and use a new idempotency key.

bashapproved live request
LIVE_BODY='{
  "dataset_version": "dsv_...",
  "student_model_name": "support-student-v1",
  "name": "Support student live v1",
  "recipe": {
    "student_model": "qwen3-8b",
    "stages": [
      { "kind": "sft", "config": { "epochs": 2, "lora_rank": 32, "learning_rate": 0.0001, "max_seq_len": 8192 } },
      { "kind": "eval", "config": { "rewards": ["rwd_..."], "judge_model": "claude", "sample": 500 } },
      { "kind": "publish", "config": {} }
    ],
    "rewards": ["rwd_..."]
  },
  "budget": { "max_cost_usd": 25 },
  "compute": { "profile": "serverless" },
  "dry_run": false,
  "validated_dry_run": "run_dry...",
  "plan_hash": "9b4d...<64 hex characters>"
}'


curl --fail-with-body -sS -X POST "https://lizzy.albinilabs.com/v1/distill/runs" \
  -H "Authorization: Bearer $LIZZY_API_TOKEN" \
  -H "Content-Type: application/json" \
  -H "Idempotency-Key: support-plan-live-v1" \
  --data "$LIVE_BODY"

Keep the idempotency key and byte-identical LIVE_BODY together. After a timeout, resend that pair. idempotency_key_in_flight means wait and retry the same operation; idempotency_key_reused means that key was paired with a different body and must not be treated as a replay.

Monitor the run by ID

  1. 1
    queued

    The run and all five stage rows have been created.

  2. 2
    validating

    The worker validates and materializes frozen inputs.

  3. 3
    running

    An SFT, GRPO, or publish stage is active.

  4. 4
    evaluating

    The holdout evaluation stage is active.

  5. 5
    terminal

    Succeeded, failed, or canceled. Read the final resource before deciding next steps.

GET/v1/distill/runs/{run_id}

Source of truth for status, current stage, all stage attempts, totals, report, student, and structured errors.

GET/v1/distill/runs/{run_id}/metrics

Metric series for active or completed training stages. Responses cap the number of points.

GET/v1/distill/runs/{run_id}/logs?stage=2&after={cursor}&limit=100

Sanitized log windows. Keep the returned cursor instead of replaying an unbounded stream.

typescriptpolling with a deadline
const terminal = new Set(["succeeded", "failed", "canceled"]);


export async function waitForRun(runId: string, attempts = 24) {
  for (let attempt = 0; attempt < attempts; attempt += 1) {
    const response = await fetch(
      "https://lizzy.albinilabs.com/v1/distill/runs/" + encodeURIComponent(runId),
      { headers: { Authorization: "Bearer " + process.env.LIZZY_API_TOKEN } },
    );
    if (!response.ok) throw await response.json();
    const run = await response.json();
    if (terminal.has(run.status)) return run;
    await new Promise((resolve) =>
      setTimeout(resolve, Math.min(1_000 * 2 ** attempt, 15_000)),
    );
  }
  throw new Error("Watch window ended. Resume with the same run ID: " + runId);
}

Webhooks such as distill.run.succeeded anddistill.run.failed can wake your worker, but the subsequent GET remains the source of truth. Subscribe to distill.report.ready for the evaluation artifact, or use distill.* when one endpoint handles the complete workflow.

Diagnose before retrying

dataset_version_not_readyPoll the existing version to ready or failed. Do not create another run yet.
reward_not_found / reward_disabledFix the selected reward IDs, retest the signal, and propose again.
invalid_recipeUse error.param to fix the named stage, order, config, or missing GRPO reward.
dry_run_not_validatedWait for a live-mode dry run to succeed, then reference that run ID.
plan_hash_mismatchThe submitted plan differs from its proof. Dry-run the exact new payload.
run_budget_exceededInspect completed artifacts and totals. Raising the budget is a new human choice.
insufficient_balance / no_capacityAdd funds or wait for compatible capacity. Keep the known run or request IDs.
failed runRead run.error, the failed stage error, logs, and metrics. A retry accepts an integerfrom_stage and may spend again, so require another approval.
POST/v1/distill/runs/{run_id}/cancel

Cancel a non-terminal run to stop future work. Re-read it to observe the final canceled state.

POST/v1/distill/runs/{run_id}/retry

Retry a failed or canceled run with an optional integer stage sequence and replacement budget.

When the run finishes, continue to the results guide to resolve its report and student model.