Skip to content

Add custom train-recipe support (describe schema/template, train_recipe on create_training, CLI) - #510

Merged
leeclemnet merged 7 commits into
mainfrom
feat/train-recipe-v2-trainings
Jul 23, 2026
Merged

Add custom train-recipe support (describe schema/template, train_recipe on create_training, CLI)#510
leeclemnet merged 7 commits into
mainfrom
feat/train-recipe-v2-trainings

Conversation

@leeclemnet

@leeclemnet leeclemnet commented Jul 17, 2026

Copy link
Copy Markdown
Contributor

What does this PR do?

Adds SDK + CLI support for training with a custom trainRecipe (hyperparameter/augmentation sweeps) against the v2 trainings API. The surface is deliberately consolidated on train_recipe as the single recipe input: describe → edit the server template → submit.

  • Adapters (roboflow/adapters/rfapi.py): get_train_recipe (GET .../v2/trainings/recipe?modelType=) is new; the canonical v2 create adapter from Feature: Multiple Models Per Version #497, create_training_v2, gains train_recipe (camelCase trainRecipe body key). Listing/fetching runs uses Feature: Multiple Models Per Version #497's list_trainings_for_version / get_training unchanged.
  • SDK (roboflow/core/version.py): Version.describe_train_recipe(model_type) (schema + ready-to-submit template + availability) is new; Feature: Multiple Models Per Version #497's canonical Version.create_training(...) gains train_recipe (returns a Training object — poll via .refresh(), list runs via Version.trainings()). The model_type guard and epochs fold run before any network call. The blocking Version.train() is untouched.
  • Epochs correctness (roboflow/util/train_recipe.py): fold_epochs_into_recipe — the server dense-fills recipe hyperparameters and resolves recipe epochs ahead of body epochs, so a provided epochs= is folded into the submitted recipe unless the recipe sets its own (explicit recipe value wins).
  • CLI: roboflow train recipe -p <project> -v <N> -m <model_type> (prints schema+template JSON) and --train-recipe '<json>' on roboflow train start (v2 path, prints the new trainingId; non-object JSON fails fast with a structured error before any network call).

Related Issue(s): Companion to roboflow/roboflow#13699 (the backend describe endpoint — describe_train_recipe/train recipe require it; deploy order: platform first, then release this). MCP companion (same consolidated shape): roboflow/roboflow-mcp#120. Rebased on #497 (the canonical MMPV SDK surface): this PR now adds only the recipe capabilities on top — no duplicated trainings plumbing. Note one adopted behavior change: training creation returns a Training object rather than a raw dict.

Type of Change

  • New feature (non-breaking change that adds functionality)

Testing

  • I have tested this change locally
  • I have added/updated tests for this change

Test details:

  • Unit tests
  • localapi tests
uv run roboflow train --help
warning: No `requires-python` value found in the workspace. Defaulting to `>=3.14`.

 Usage: roboflow train [OPTIONS] [COMMAND] [ARGS]...

 Train a model

╭─ Options ────────────────────────────────────────────────────────────────────────────────────────────────────────────╮
│ --checkpoint            TEXT     Checkpoint to resume training from                                                  │
│ --epochs                INTEGER  Number of training epochs                                                           │
│ --project       -p      TEXT     Project ID to train                                                                 │
│ --speed                 TEXT     Training speed preset                                                               │
│ --type          -t      TEXT     Model type (e.g. rfdetr-nano, yolov8n)                                              │
│ --train-recipe          TEXT     Full trainRecipe as inline JSON or @path/to/file.json (see 'roboflow train          │
│                                  recipe'); --epochs is folded into its hyperparameters unless the recipe already     │
│                                  sets epochs                                                                         │
│ --version       -v      INTEGER  Version number to train                                                             │
│ --help          -h               Show this message and exit.                                                         │
╰──────────────────────────────────────────────────────────────────────────────────────────────────────────────────────╯
╭─ Commands ───────────────────────────────────────────────────────────────────────────────────────────────────────────╮
│ cancel   Cancel an in-flight training run.                                                                           │
│ recipe   Show the training recipe schema and template for a model type.                                              │
│ results  Run-level training results bundle.                                                                          │
│ start    Start training for a dataset version.                                                                       │
│ stop     Request a graceful early-stop on an in-flight training run.                                                 │
╰──────────────────────────────────────────────────────────────────────────────────────────────────────────────────────╯
uv run roboflow train recipe --help
warning: No `requires-python` value found in the workspace. Defaulting to `>=3.14`.

 Usage: roboflow train recipe [OPTIONS]

 Show the training recipe schema and template for a model type.

 Prints the tunable hyperparameter schema, the allowed online
 augmentation/preprocessing steps, and a ready-to-submit ``template``
 that can be edited and passed to ``roboflow train start --train-recipe``.

╭─ Options ────────────────────────────────────────────────────────────────────────────────────────────────────────────╮
│ *  --model-type,--type  -m,-t      TEXT     Model type to describe (e.g. rfdetr-medium) [required]                   │
│ *  --project            -p         TEXT     Project ID [required]                                                    │
│ *  --version            -v         INTEGER  Version number [required]                                                │
│    --help               -h                  Show this message and exit.                                              │
╰──────────────────────────────────────────────────────────────────────────────────────────────────────────────────────╯
uv run roboflow train start --help
warning: No `requires-python` value found in the workspace. Defaulting to `>=3.14`.

 Usage: roboflow train start [OPTIONS]

 Start training for a dataset version.

 With --train-recipe, the training is created via the v2 trainings API
 and the new trainingId is printed. Start from the ``template`` field of
 ``roboflow train recipe`` output, edit it (hyperparameters, online
 augmentation), and pass it inline or as ``@path/to/file.json``; --epochs is folded into its
 hyperparameters unless the recipe already sets epochs.

╭─ Options ────────────────────────────────────────────────────────────────────────────────────────────────────────────╮
│ *  --project       -p      TEXT     Project ID to train [required]                                                   │
│ *  --version       -v      INTEGER  Version number to train [required]                                               │
│    --checkpoint            TEXT     Checkpoint to resume training from                                               │
│    --epochs                INTEGER  Number of training epochs                                                        │
│    --speed                 TEXT     Training speed preset                                                            │
│    --type          -t      TEXT     Model type (e.g. rfdetr-nano, yolov8n)                                           │
│    --train-recipe          TEXT     Full trainRecipe as inline JSON or @path/to/file.json (see 'roboflow train       │
│                                     recipe'); --epochs is folded into its hyperparameters unless the recipe already  │
│                                     sets epochs                                                                      │
│    --help          -h               Show this message and exit.                                                      │
╰──────────────────────────────────────────────────────────────────────────────────────────────────────────────────────╯
API_URL="https://localapi.roboflow.one" \
ROBOFLOW_API_KEY=$API_KEY \
REQUESTS_CA_BUNDLE="$HOME/Library/Application Support/mkcert/rootCA.pem" \
uv run roboflow train recipe -p lee-sandbox/chess-pieces-fmhpz -v 110 -m rfdetr-medium
Staging evidence: roboflow train recipe -p lee-sandbox/chess-pieces-fmhpz -v 110 -m rfdetr-medium CLI output
warning: No `requires-python` value found in the workspace. Defaulting to `>=3.14`.
{
  "modelType": "rfdetr-medium",
  "canonicalModelType": "rfdetr-medium",
  "family": "rf-detr",
  "taskType": "object-detection",
  "availability": {
    "entitled": true,
    "runtimeEnabled": true
  },
  "schema": {
    "hyperparameters": [
      {
        "key": "epochs",
        "type": "int",
        "valid": {
          "ge": 1
        },
        "advice": {
          "default": 100,
          "low": 50,
          "high": 150
        }
      },
      {
        "key": "lr",
        "type": "float",
        "valid": {
          "gt": 0
        },
        "advice": {
          "default": 0.0001,
          "low": 1e-05,
          "high": 0.01
        }
      },
      {
        "key": "lr_encoder",
        "type": "float",
        "valid": {
          "gt": 0
        },
        "advice": {
          "default": 0.00015,
          "low": 1e-05,
          "high": 0.01
        }
      },
      {
        "key": "bbox_loss_coef",
        "type": "float",
        "valid": {
          "ge": 0
        },
        "advice": {
          "default": 5,
          "low": 0.5,
          "high": 20
        }
      },
      {
        "key": "giou_loss_coef",
        "type": "float",
        "valid": {
          "ge": 0
        },
        "advice": {
          "default": 2,
          "low": 0.5,
          "high": 20
        }
      },
      {
        "key": "set_cost_class",
        "type": "float",
        "valid": {
          "ge": 0
        },
        "advice": {
          "default": 2,
          "low": 0.5,
          "high": 20
        }
      },
      {
        "key": "set_cost_bbox",
        "type": "float",
        "valid": {
          "ge": 0
        },
        "advice": {
          "default": 5,
          "low": 0.5,
          "high": 20
        }
      },
      {
        "key": "set_cost_giou",
        "type": "float",
        "valid": {
          "ge": 0
        },
        "advice": {
          "default": 2,
          "low": 0.5,
          "high": 20
        }
      },
      {
        "key": "cls_loss_coef",
        "type": "float",
        "valid": {
          "ge": 0
        },
        "advice": {
          "default": 1,
          "low": 0.1,
          "high": 10
        }
      }
    ],
    "online_augmentation": {
      "splits": [
        "train"
      ],
      "steps": [
        {
          "name": "flip",
          "params": {
            "horizontal": {
              "type": "boolean",
              "required": true
            },
            "vertical": {
              "type": "boolean",
              "required": true
            }
          },
          "atLeastOne": [
            "horizontal",
            "vertical"
          ],
          "probability": true
        },
        {
          "name": "ninety",
          "params": {
            "clockwise": {
              "type": "boolean",
              "required": true
            },
            "counter-clockwise": {
              "type": "boolean",
              "required": true
            },
            "upside-down": {
              "type": "boolean",
              "required": true
            }
          },
          "atLeastOne": [
            "clockwise",
            "counter-clockwise",
            "upside-down"
          ],
          "probability": true
        },
        {
          "name": "rotate",
          "params": {
            "degrees": {
              "type": "integer",
              "min": 0,
              "max": 45,
              "required": true
            }
          },
          "probability": true
        },
        {
          "name": "brightness",
          "params": {
            "percent": {
              "type": "integer",
              "min": 0,
              "max": 100,
              "required": true
            },
            "brighten": {
              "type": "boolean",
              "required": true
            },
            "darken": {
              "type": "boolean",
              "required": true
            }
          },
          "atLeastOne": [
            "brighten",
            "darken"
          ],
          "probability": true
        },
        {
          "name": "exposure",
          "params": {
            "percent": {
              "type": "integer",
              "min": 0,
              "max": 100,
              "required": true
            }
          },
          "probability": true
        },
        {
          "name": "blur",
          "params": {
            "pixels": {
              "type": "number",
              "min": 0,
              "max": 25,
              "required": true
            }
          },
          "probability": true
        },
        {
          "name": "noise",
          "params": {
            "percent": {
              "type": "number",
              "min": 0,
              "max": 100,
              "required": true
            }
          },
          "probability": true
        },
        {
          "name": "crop",
          "params": {
            "min": {
              "type": "integer",
              "min": 0,
              "max": 100,
              "required": true
            },
            "percent": {
              "type": "integer",
              "min": 0,
              "max": 100,
              "required": true
            }
          },
          "ordered": [
            "min",
            "percent"
          ],
          "probability": true
        },
        {
          "name": "cutout",
          "params": {
            "count": {
              "type": "integer",
              "min": 1,
              "max": 25,
              "required": true
            },
            "percent": {
              "type": "integer",
              "min": 0,
              "max": 100,
              "required": true
            }
          },
          "probability": true
        },
        {
          "name": "mosaic",
          "params": {},
          "probability": true
        },
        {
          "name": "shear",
          "params": {
            "horizontal": {
              "type": "integer",
              "min": 0,
              "max": 45,
              "required": true
            },
            "vertical": {
              "type": "integer",
              "min": 0,
              "max": 45,
              "required": true
            }
          },
          "probability": true
        },
        {
          "name": "saturation",
          "params": {
            "percent": {
              "type": "integer",
              "min": 0,
              "max": 100,
              "required": true
            }
          },
          "probability": true
        },
        {
          "name": "hue",
          "params": {
            "degrees": {
              "type": "integer",
              "min": 0,
              "max": 180,
              "required": true
            }
          },
          "probability": true
        },
        {
          "name": "rgrayscale",
          "params": {
            "percent": {
              "type": "integer",
              "min": 0,
              "max": 100,
              "required": true
            }
          },
          "probability": true
        },
        {
          "name": "motion-blur",
          "params": {
            "pixels": {
              "type": "integer",
              "min": 1,
              "max": 1000,
              "required": true
            },
            "angle": {
              "type": "number",
              "min": 0,
              "max": 90,
              "required": true
            },
            "hdr_frames": {
              "type": "integer",
              "min": 1,
              "max": 5,
              "required": true
            }
          },
          "probability": true
        },
        {
          "name": "camera-gain",
          "params": {
            "amount": {
              "type": "integer",
              "min": 1,
              "max": 1,
              "required": false
            },
            "variance_scale": {
              "type": "number",
              "min": 0.01,
              "max": 0.5,
              "required": true
            }
          },
          "probability": true
        },
        {
          "name": "bbflip",
          "params": {
            "horizontal": {
              "type": "boolean",
              "required": true
            },
            "vertical": {
              "type": "boolean",
              "required": true
            }
          },
          "atLeastOne": [
            "horizontal",
            "vertical"
          ],
          "probability": true
        },
        {
          "name": "bbninety",
          "params": {
            "clockwise": {
              "type": "boolean",
              "required": true
            },
            "counter-clockwise": {
              "type": "boolean",
              "required": true
            },
            "upside-down": {
              "type": "boolean",
              "required": true
            }
          },
          "atLeastOne": [
            "clockwise",
            "counter-clockwise",
            "upside-down"
          ],
          "probability": true
        },
        {
          "name": "bbrotate",
          "params": {
            "degrees": {
              "type": "integer",
              "min": 0,
              "max": 45,
              "required": true
            }
          },
          "probability": true
        },
        {
          "name": "bbcrop",
          "params": {
            "min": {
              "type": "integer",
              "min": 0,
              "max": 100,
              "required": true
            },
            "percent": {
              "type": "integer",
              "min": 0,
              "max": 100,
              "required": true
            }
          },
          "ordered": [
            "min",
            "percent"
          ],
          "probability": true
        },
        {
          "name": "bbshear",
          "params": {
            "horizontal": {
              "type": "integer",
              "min": 0,
              "max": 45,
              "required": true
            },
            "vertical": {
              "type": "integer",
              "min": 0,
              "max": 45,
              "required": true
            }
          },
          "probability": true
        },
        {
          "name": "bbbrightness",
          "params": {
            "percent": {
              "type": "integer",
              "min": 0,
              "max": 100,
              "required": true
            },
            "brighten": {
              "type": "boolean",
              "required": true
            },
            "darken": {
              "type": "boolean",
              "required": true
            }
          },
          "atLeastOne": [
            "brighten",
            "darken"
          ],
          "probability": true
        },
        {
          "name": "bbexposure",
          "params": {
            "percent": {
              "type": "integer",
              "min": 0,
              "max": 100,
              "required": true
            }
          },
          "probability": true
        },
        {
          "name": "bbblur",
          "params": {
            "pixels": {
              "type": "number",
              "min": 0,
              "max": 25,
              "required": true
            }
          },
          "probability": true
        },
        {
          "name": "bbnoise",
          "params": {
            "percent": {
              "type": "number",
              "min": 0,
              "max": 100,
              "required": true
            }
          },
          "probability": true
        },
        {
          "name": "bbmotion-blur",
          "params": {
            "pixels": {
              "type": "integer",
              "min": 1,
              "max": 1000,
              "required": true
            },
            "angle": {
              "type": "number",
              "min": 0,
              "max": 90,
              "required": true
            },
            "hdr_frames": {
              "type": "integer",
              "min": 1,
              "max": 5,
              "required": true
            }
          },
          "probability": true
        },
        {
          "name": "bbcamera-gain",
          "params": {
            "amount": {
              "type": "integer",
              "min": 1,
              "max": 1,
              "required": false
            },
            "variance_scale": {
              "type": "number",
              "min": 0.01,
              "max": 0.5,
              "required": true
            }
          },
          "probability": true
        }
      ]
    },
    "online_preprocessing": {
      "steps": [
        "resize",
        "grayscale",
        "contrast"
      ]
    },
    "input": {
      "onlineResizeAllowed": true,
      "resolutionRounding": "Width, height, and resolution are rounded to the nearest multiple of 32, with a minimum of 32."
    }
  },
  "template": {
    "schema_version": 1,
    "input": {
      "width": 640,
      "height": 640,
      "resize": {
        "enabled": true,
        "format": "Stretch to",
        "width": 640,
        "height": 640
      },
      "resolution": 640,
      "splits": [
        "train",
        "valid",
        "test"
      ]
    },
    "online_preprocessing": [],
    "online_augmentation": {
      "splits": [
        "train"
      ],
      "steps": []
    },
    "source_version": {
      "dataset_id": "7vSK9kc3i6eFm700KWFt",
      "version_id": "110",
      "baked_preprocessing": {
        "auto-orient": {
          "enabled": true
        }
      },
      "baked_augmentation": {},
      "assertions": {
        "resize_baked": false,
        "augmentation_baked": false
      }
    },
    "hyperparameters": {}
  },
  "usage": "The template is ready to submit verbatim as `trainRecipe` on a POST to this project/version's /v2/trainings path alongside `modelType`. To tune training, set a sparse subset of the keys listed in schema.hyperparameters under template.hyperparameters \u2014 any key you omit is filled with its advice.default server-side. advice.low/high is the recommended sweep range; valid.* are hard bounds the server enforces. Add augmentation steps from schema.online_augmentation.steps to template.online_augmentation.steps; each step is submitted as {name, params: {...}} \u2014 params is required, {} when a step has no fields \u2014 with the step's listed params plus an optional params.probability between 0 and 1. Submitting a trainRecipe requires the workspace's online-augmentation training entitlement (the train request is rejected with a 403 without it) and requires the deployment's online-train-recipe runtime to be enabled; the availability field reports both."
}
API_URL="https://localapi.roboflow.one" \
ROBOFLOW_API_KEY=$API_KEY \
REQUESTS_CA_BUNDLE="$HOME/Library/Application Support/mkcert/rootCA.pem" \
uv run roboflow train start -p lee-sandbox/chess-pieces-fmhpz -v 110 -t rfdetr-medium --train-recipe @train_recipe.json
warning: No `requires-python` value found in the workspace. Defaulting to `>=3.14`.
Exporting version in coco format (required for rfdetr-medium)...
  Export complete.
Training created for chess-pieces-fmhpz version 110. trainingId: eee271d1bffe1781c021

train_recipe.json

{
  "schema_version": 1,
  "input": {
    "width": 640,
    "height": 640,
    "resize": {
      "enabled": true,
      "format": "Stretch to",
      "width": 640,
      "height": 640
    },
    "resolution": 640,
    "splits": [
      "train",
      "valid",
      "test"
    ]
  },
  "online_preprocessing": [
    {
      "name": "grayscale"
    }
  ],
  "online_augmentation": {
    "splits": [
      "train"
    ],
    "steps": [
      {
        "name": "rotate",
        "params": {
          "degrees": 15,
          "probability": 0.5
        }
      }
    ]
  },
  "source_version": {
    "dataset_id": "7vSK9kc3i6eFm700KWFt",
    "version_id": "110",
    "baked_preprocessing": {
      "auto-orient": {
        "enabled": true
      }
    },
    "baked_augmentation": {},
    "assertions": {
      "resize_baked": false,
      "augmentation_baked": false
    }
  },
  "hyperparameters": {
    "lr": 0.0003
  }
}
image

Checklist

  • My code follows the style guidelines of this project
  • I have performed a self-review of my own code
  • I have commented my code where necessary, particularly in hard-to-understand areas
  • My changes generate no new warnings or errors
  • I have updated the documentation accordingly (if applicable) — CLI-COMMANDS.md + CHANGELOG.md updated; docs.roboflow.com reference is a tracked follow-up

Additional Context

Sweep usage the PR enables:

v = rf.workspace().project("safety-vests").version(5)
template = v.describe_train_recipe("rfdetr-nano")["template"]
ids = []
for lr in (1e-5, 1e-4, 1e-3):
    recipe = copy.deepcopy(template)
    recipe["hyperparameters"] = {"lr": lr}   # sparse: omitted keys keep server defaults
    ids.append(v.create_training(model_type="rfdetr-nano", train_recipe=recipe).training_id)

Recipe-based training requires the workspace's online-augmentation training entitlement (403 otherwise); the describe response's availability field reports both gates up front.

🤖 Generated with Claude Code

leeclemnet added a commit that referenced this pull request Jul 17, 2026
…explicit recipes

Addresses two Jarbas review warnings on #510 plus trivia:

Non-object JSON flag values (e.g. --hyperparameters '5',
--train-recipe '[1]') previously passed the syntax-only parse and
either crashed with a raw TypeError in build_train_recipe (after a
wasted recipe fetch) or shipped a malformed trainRecipe to the server.
_parse_json_flag now requires a JSON object and exits with a
structured error before any network call.

epochs was still silently ignored when combined with an explicit
train_recipe: the server dense-fills recipe hyperparameters, so recipe
epochs always beats body epochs, and a template-derived recipe +
--epochs 5 smoke run would train the full default epochs at GPU cost.
Explicit recipes are now run through the same build_train_recipe fold
(setdefault semantics: an epochs set inside the recipe still wins;
the fold creates the hyperparameters key when a hand-written recipe
omits it). Docstrings and --train-recipe help updated from
"recipe epochs takes precedence" to the fold semantics.

Trivia: sweep docstring example now imports time (it calls
time.sleep); CHANGELOG.md gains the Unreleased entry for the v2
trainings SDK methods, CLI command, and flags (#510).

Tests: +7 (util fold-creates-missing-key; Version explicit-recipe
fold / recipe-epochs-wins / missing-hyperparameters-key; CLI
non-object --hyperparameters and --train-recipe assert structured
error with no adapter call, --train-recipe + --epochs asserts merged
body). Full suite 916 passing; ruff + mypy clean.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
leeclemnet added a commit that referenced this pull request Jul 21, 2026
…explicit recipes

Addresses two Jarbas review warnings on #510 plus trivia:

Non-object JSON flag values (e.g. --hyperparameters '5',
--train-recipe '[1]') previously passed the syntax-only parse and
either crashed with a raw TypeError in build_train_recipe (after a
wasted recipe fetch) or shipped a malformed trainRecipe to the server.
_parse_json_flag now requires a JSON object and exits with a
structured error before any network call.

epochs was still silently ignored when combined with an explicit
train_recipe: the server dense-fills recipe hyperparameters, so recipe
epochs always beats body epochs, and a template-derived recipe +
--epochs 5 smoke run would train the full default epochs at GPU cost.
Explicit recipes are now run through the same build_train_recipe fold
(setdefault semantics: an epochs set inside the recipe still wins;
the fold creates the hyperparameters key when a hand-written recipe
omits it). Docstrings and --train-recipe help updated from
"recipe epochs takes precedence" to the fold semantics.

Trivia: sweep docstring example now imports time (it calls
time.sleep); CHANGELOG.md gains the Unreleased entry for the v2
trainings SDK methods, CLI command, and flags (#510).

Tests: +7 (util fold-creates-missing-key; Version explicit-recipe
fold / recipe-epochs-wins / missing-hyperparameters-key; CLI
non-object --hyperparameters and --train-recipe assert structured
error with no adapter call, --train-recipe + --epochs asserts merged
body). Full suite 916 passing; ruff + mypy clean.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
@leeclemnet
leeclemnet force-pushed the feat/train-recipe-v2-trainings branch from b4dea8a to f625a3b Compare July 21, 2026 18:28
leeclemnet added a commit that referenced this pull request Jul 21, 2026
Per review direction on #510: no need for both hyperparameters and
train_recipe — one way to do it. The template-fetch-and-merge path is
removed; recipe training is now describe -> edit -> submit:

    template = version.describe_train_recipe("rfdetr-medium")["template"]
    recipe = copy.deepcopy(template)
    recipe["hyperparameters"] = {"lr": lr}
    version.start_training(model_type="rfdetr-medium", train_recipe=recipe)

Version.start_training drops the hyperparameters and
online_augmentation kwargs, the describe-and-merge branch, and both
now-dead ValueError guards (both-given; knobs-without-model_type).
The CLI drops --hyperparameters from `train start` and the bare
`train` callback; _start_v2 no longer fetches the template.

Kept: the `roboflow train recipe` describe command and the
get_train_recipe adapter (describe stays a first-class capability);
--train-recipe with its non-object JSON validation; and the epochs
fold into an explicit recipe (correctness guard for the pre-existing
epochs param — the server resolves dense-filled recipe epochs ahead
of the body value). build_train_recipe shrinks accordingly and is
renamed fold_epochs_into_recipe (deepcopy + setdefault + creates a
missing hyperparameters key + never mutates input).

Docs: CLI-COMMANDS.md shows the describe -> edit -> --train-recipe
flow; CHANGELOG Unreleased entry updated; start_training docstring
sweep example rewritten around the template (import copy + import
time included for copy-paste).

Tests: merge-path, knob-validation, and --hyperparameters cases
removed; epochs-fold and non-object --train-recipe validation kept.
Full suite 902 passing; ruff + mypy clean.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
@leeclemnet
leeclemnet force-pushed the feat/train-recipe-v2-trainings branch from 4660e81 to 540ebcf Compare July 21, 2026 20:10
@leeclemnet
leeclemnet marked this pull request as ready for review July 22, 2026 14:35
leeclemnet added a commit that referenced this pull request Jul 22, 2026
…explicit recipes

Addresses two Jarbas review warnings on #510 plus trivia:

Non-object JSON flag values (e.g. --hyperparameters '5',
--train-recipe '[1]') previously passed the syntax-only parse and
either crashed with a raw TypeError in build_train_recipe (after a
wasted recipe fetch) or shipped a malformed trainRecipe to the server.
_parse_json_flag now requires a JSON object and exits with a
structured error before any network call.

epochs was still silently ignored when combined with an explicit
train_recipe: the server dense-fills recipe hyperparameters, so recipe
epochs always beats body epochs, and a template-derived recipe +
--epochs 5 smoke run would train the full default epochs at GPU cost.
Explicit recipes are now run through the same build_train_recipe fold
(setdefault semantics: an epochs set inside the recipe still wins;
the fold creates the hyperparameters key when a hand-written recipe
omits it). Docstrings and --train-recipe help updated from
"recipe epochs takes precedence" to the fold semantics.

Trivia: sweep docstring example now imports time (it calls
time.sleep); CHANGELOG.md gains the Unreleased entry for the v2
trainings SDK methods, CLI command, and flags (#510).

Tests: +7 (util fold-creates-missing-key; Version explicit-recipe
fold / recipe-epochs-wins / missing-hyperparameters-key; CLI
non-object --hyperparameters and --train-recipe assert structured
error with no adapter call, --train-recipe + --epochs asserts merged
body). Full suite 916 passing; ruff + mypy clean.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
leeclemnet added a commit that referenced this pull request Jul 22, 2026
Per review direction on #510: no need for both hyperparameters and
train_recipe — one way to do it. The template-fetch-and-merge path is
removed; recipe training is now describe -> edit -> submit:

    template = version.describe_train_recipe("rfdetr-medium")["template"]
    recipe = copy.deepcopy(template)
    recipe["hyperparameters"] = {"lr": lr}
    version.start_training(model_type="rfdetr-medium", train_recipe=recipe)

Version.start_training drops the hyperparameters and
online_augmentation kwargs, the describe-and-merge branch, and both
now-dead ValueError guards (both-given; knobs-without-model_type).
The CLI drops --hyperparameters from `train start` and the bare
`train` callback; _start_v2 no longer fetches the template.

Kept: the `roboflow train recipe` describe command and the
get_train_recipe adapter (describe stays a first-class capability);
--train-recipe with its non-object JSON validation; and the epochs
fold into an explicit recipe (correctness guard for the pre-existing
epochs param — the server resolves dense-filled recipe epochs ahead
of the body value). build_train_recipe shrinks accordingly and is
renamed fold_epochs_into_recipe (deepcopy + setdefault + creates a
missing hyperparameters key + never mutates input).

Docs: CLI-COMMANDS.md shows the describe -> edit -> --train-recipe
flow; CHANGELOG Unreleased entry updated; start_training docstring
sweep example rewritten around the template (import copy + import
time included for copy-paste).

Tests: merge-path, knob-validation, and --hyperparameters cases
removed; epochs-fold and non-object --train-recipe validation kept.
Full suite 902 passing; ruff + mypy clean.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
@leeclemnet
leeclemnet force-pushed the feat/train-recipe-v2-trainings branch from 7af393d to 8d15b56 Compare July 22, 2026 14:40
@leeclemnet

Copy link
Copy Markdown
Contributor Author

/jarbas review, considering https://github.com/roboflow/roboflow/pull/13699 and https://github.com/roboflow/roboflow-mcp/pull/120 as well. Ensure you read the PR descriptions as they contain the bulk of the test evidence.

@leeclemnet

Copy link
Copy Markdown
Contributor Author

leeclemnet and others added 3 commits July 23, 2026 14:19
Rebased #510 onto the merged MMPV surface (#497), deduplicating against its
canonical v2 trainings plumbing instead of carrying parallel implementations:

- rfapi.get_train_recipe — GET .../v2/trainings/recipe (schema + editable
  template) for a model type. New; no #497 equivalent.
- rfapi.create_training_v2 gains train_recipe (camelCase trainRecipe body
  key) and business_context; replaces this branch's former parallel
  rfapi.create_training. rfapi.list_version_trainings/get_version_training
  are dropped in favor of #497's list_trainings_for_version/get_training.
- Version.describe_train_recipe(model_type) — SDK wrapper for the describe
  endpoint.
- Version.create_training gains train_recipe/business_context; replaces the
  former Version.start_training. Keeps the pre-network guard (train_recipe
  requires model_type — recipes are minted per model type) and folds a
  top-level epochs into the recipe's hyperparameters via
  util.train_recipe.fold_epochs_into_recipe (recipe epochs wins; missing
  hyperparameters key is created), because the server resolves dense-filled
  recipe epochs ahead of the body value. Version.list_trainings/get_training
  are dropped: #497's trainings()/Training.refresh() cover them.
- Tests re-homed onto the canonical functions; #497's exact-kwargs create
  assertion extended with the two new pass-through kwargs.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
- roboflow train recipe -p <project> -v <N> -m <model_type> — print the
  recipe schema and ready-to-edit template as JSON (both output modes).
- --train-recipe on train start (and the bare train callback): inline JSON
  or a curl-style @file reference. Routes through the canonical
  rfapi.create_training_v2 (from #497) and prints the new trainingId
  instead of blocking. Structured errors, all before any network call:
  unreadable @file, invalid JSON, non-object JSON, and a missing model
  type (recipes are minted per model type).
- --epochs is folded into the recipe's hyperparameters unless the recipe
  already sets epochs (the server resolves recipe epochs ahead of the
  body value).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
CHANGELOG entry reconciled with #497's 1.4.0 MMPV section: #510 now
describes only what it adds on top of the canonical surface — the recipe
describe endpoint, train_recipe/business_context on
Version.create_training, the epochs fold, and the CLI recipe surface.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
@leeclemnet
leeclemnet force-pushed the feat/train-recipe-v2-trainings branch from 0d308a6 to b047ebe Compare July 23, 2026 16:50
@leeclemnet
leeclemnet requested a review from mkaic July 23, 2026 16:56
@jarbas-roboflow

Copy link
Copy Markdown

Status: ❌ Request Changes — Gist: https://gist.github.com/jarbasrf/9f37f34dfe806a002ab789e6885d414a. Resumo: An explicitly empty --train-recipe bypasses validation and can silently start legacy training instead. The adapter and SDK design are otherwise sound with substantial test coverage.

An empty supplied value (classic unset shell variable expansion) passed
the truthiness check and fell through to the legacy training endpoint,
silently starting a different, billable training. Presence is now
'is not None', so the empty string reaches the JSON validator and fails
with a structured error before any network call.

Addresses external review feedback on PR #510.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
@leeclemnet leeclemnet changed the title Add v2 trainings + train-recipe support (describe, non-blocking start_training, CLI) Add custom train-recipe support (describe schema/template, train_recipe on create_training, CLI) Jul 23, 2026
@jarbas-roboflow jarbas-roboflow Bot added the LGTJarb 🤖 Approved by Jarbas automated PR review label Jul 23, 2026
@jarbas-roboflow

Copy link
Copy Markdown

Status: ✅ Approve — Gist: https://gist.github.com/jarbasrf/c90908d109b3044acdda67b4e193ffce. Resumo: The prior empty --train-recipe issue is fully resolved with targeted no-network regression coverage. One minor malformed-input error-reporting edge case remains, but it is not merge-blocking.

leeclemnet and others added 2 commits July 23, 2026 14:43
Unrelated to recipes: it is a pre-existing platform field the MCP tool
already exposes, and it slipped into this PR as payload parity rather
than a considered scope decision. If SDK parity is wanted it can ride
its own follow-up.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
1.4.0 already shipped; this PR's additions ride the next release.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
probicheaux
probicheaux previously approved these changes Jul 23, 2026
mkaic
mkaic previously approved these changes Jul 23, 2026

@mkaic mkaic left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

LGTM!

I think your new _start_v2 function has some duplicated logic from the new MMPV Version.create_training, but after chatting with Fable I think the right thing to do would be to make that method use your code, not the other way around, I think the way you implemented things is potentially more idiomatic. Anyhow, not really in-scope for this PR, so approving, and I can always open a consolidation/cleanup PR myself later.

@leeclemnet
leeclemnet dismissed stale reviews from mkaic and probicheaux via a9b040b July 23, 2026 19:08
@leeclemnet
leeclemnet requested review from mkaic and probicheaux July 23, 2026 19:08
@leeclemnet
leeclemnet merged commit c731b4e into main Jul 23, 2026
15 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

LGTJarb 🤖 Approved by Jarbas automated PR review

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants