Skip to main content
internal/sidecar/training_jobs.go, internal/sidecar/training_events.go, internal/api/training/jobs.go, internal/api/training/monitor.go, internal/db/relational/model/training_job_run.go, sidecar/ai_train/api/models.py, admin_ui/src/training.ts How the Go server starts a training job through the sidecar, follows its progress and keeps track of it. The Go server talks only to the sidecar; the sidecar proxies each call to the Modal web API, so every sidecar answer below is Modal’s, passed on unchanged. The overview shows the flow.

The TrainingJob struct

The sidecar reports a started job as a TrainingJob; the Go client decodes the same fields into its own struct. The two definitions:
  • Python, sidecar/ai_train/api/models.py: the pydantic model TrainingJob. job_id has a minimum length of 1, num_train_steps must be greater than 0, and a naive started_at is treated as local time and converted to UTC.
  • Go, internal/sidecar/training_jobs.go: sidecar.TrainingJob, with the JSON tags above (StartedAt is a time.Time).
Example answer of POST /training/job_start:

Starting a job

POST /v1/gb/training/jobs[?db=<name>], part of the API (served whether or not the admin UI is on; the paths were /v1/gb/admin/api/training/... before gligabao 0.30.0). db picks the database the job is recorded in (the first one, standard, without it). The body is optional JSON; every field is optional too: An empty body starts a 4b job of 100 steps on the default dataset. A field left out is not sent, so the defaults are the sidecar’s (sidecar.DefaultNumTrainSteps, sidecar.DefaultModelSize in Go).
The answer is 201 with the stored row:
Every job the sidecar reports as started is a new row, even if an identical one was started a minute before. A start that fails records nothing. The row is written even if the HTTP client disconnects while the job is being started, because by then the job is already running. Once it is written, the training monitor starts following the job.

Listing jobs and datasets

Training progress

A job reports as it runs. The training container writes events (status changes, and the step and loss of every trainer log) and the sidecar streams them at GET /training/job_events. The training monitor of the Go server (internal/api/training/monitor.go) follows that stream for every job started through the start endpoint:
  • it stores each event in gb_training_job_events and updates the job’s row: status follows the status events, last_step and last_loss the progress events, and the end sets final_loss (completed), error (failed, cancelled) and finished_at;
  • it forwards each event to the SSE feed;
  • when the sidecar ends the stream without the job being over (the sidecar limits one response to a few minutes) or the connection breaks, it reconnects from the next event it has not stored, waiting longer between attempts (1 second, doubling up to 30) while nothing arrives;
  • when the server starts, it resumes following every job that is not completed, failed or cancelled, from the last stored event;
  • if the sidecar reports no end within 3 hours of the job’s start (the provider cancels a job after SIDECAR_TRAIN_TIMEOUT_S, 2 hours by default, and a container that dies writes nothing), it marks the job failed with an explanation and a final event.

The SSE feed

GET /v1/gb/training/events[?db=<name>][&run_id=<id>] is a Server-Sent Events stream. Each message has event: status or event: progress and a data: JSON object with the sidecar’s event fields plus db, run_id, job_id and seq (the event’s index in the job):
  • With run_id: the stream of that job. It first replays the events already stored (all of them, or those after the Last-Event-ID that a reconnecting EventSource sends, or from from=<n>), then sends the live ones. Each message has id: <seq>. The stream stays open after the job’s last event; the client closes it. 404 for an unknown run.
  • Without run_id: only the live events of every job of the database, no history, no ids. The admin UI uses it to keep the job list current.
A client that cannot keep up (more than 256 events behind) is disconnected; it reconnects and is caught up from the database. A : keepalive comment is sent every 15 seconds.

The training view

The training view of the admin UI puts this together. The Run button posts to the start endpoint with the chosen model size, dataset (picked from the uploaded datasets) and steps. The table lists the jobs (status, model, dataset, last_step / num_train_steps, loss, start time) and is updated from the feed without polling. Selecting a job shows its event feed and a graph of its loss against the step, built from the stored events and then the live ones. The view also uploads datasets (a file input and a table of the uploaded ones with their status). It opens its two SSE connections only while it is shown.

The gb_training_job_runs table

One row per training job started. Created with the rest of the schema, on both SQLite and PostgreSQL.

Trained models

A trained model is a set of weights: for the LoRA fine-tuning the sidecar does, the few files of an adapter. Every job that completes makes a new trained model, a row of the gb_trained_models table, so you can train many models of the same type with other data, steps or size, and each stays available to run inference on. A cancelled or failed job saves nothing. The job saves its adapter in /models/trained/<job_id> of the models volume and reports it in its completed event; the monitor records it. Where the files are, how the row is made, how inference picks a model and how to add an inference endpoint are on Trained models. The training view of the admin UI lists the models; clicking one shows its settings and files and a form that runs a prompt through it. From Go (relational package): RecordTrainedModel(ctx, db, model) stores a TrainedModel and returns its ID and whether it is new (a job that already has a model keeps it); GetTrainedModel(ctx, db, id) reads one, or returns relational.ErrNotFound; ListTrainedModels(ctx, db, limit) lists them, newest first. The Go client has Infer(ctx, sidecar.InferRequest).

The gb_training_job_events table

One row per event the sidecar reported for a job, so a job’s feed and loss graph can be shown after it finished. Timestamps follow the usual convention of the backend (see Database): RFC 3339 UTC text with second precision on SQLite, TIMESTAMPTZ on PostgreSQL. From Go (relational package): RecordTrainingJobRun(ctx, db, run) stores a TrainingJobRun and returns its ID (id, created_at and the fields that only events set are ignored); GetTrainingJobRun(ctx, db, id) reads one, or returns relational.ErrNotFound; ListTrainingJobRuns(ctx, db, limit, unfinished) lists them (newest first, or only the unfinished ones, oldest first); UpdateTrainingJobRun(ctx, db, id, update) writes the fields that are set in a TrainingJobRunUpdate; RecordTrainingJobEvent, ListTrainingJobEvents and NextTrainingJobEventSeq handle the events. A job that outlives its timeout (SIDECAR_TRAIN_TIMEOUT_S, default 7200 seconds) is cancelled by the provider. If its container could not report that, the monitor marks the job failed after 3 hours (see Training progress). Database files created by an earlier version are not migrated: delete the SQLite file (or drop gb_training_job_runs on PostgreSQL) and the server recreates the tables.

The Go client

internal/sidecar is the only package that knows the sidecar’s HTTP API (see The Go client).
Infer(ctx, sidecar.InferRequest{Prompt, ModelSize, ModelPath, ...}) calls POST /infer/query and waits as long as the context allows (a cold start takes minutes); a generation that fails on the sidecar is a *sidecar.InferError. CancelTrainingJob(ctx, jobID) calls POST /training/job_cancel (a *sidecar.StatusError with 404 or 409 for an unknown or finished job). StartTrainingJob returns an error for a negative NumTrainSteps, for any status other than 200 from the sidecar (a *sidecar.StatusError), for a body that is not the expected JSON, and for an answer without a job_id. StreamTrainingJobEvents returns nil when the sidecar ends the stream (after the job’s last event, or after the sidecar’s time limit; reconnect with from = the last Seq + 1), fn’s error if fn fails, the context’s error when it is done, and an error for a failed connection, a bad status or an event that is not JSON. The optional fields of sidecar.TrainingEvent are pointers, nil when the event does not have them. There are no dataset calls: the sidecar has no dataset endpoints, and a dataset travels in DatasetContent (see Training datasets). The tests (internal/sidecar/*_test.go, internal/api/training/*_test.go) run against an httptest server and a fake sidecar, so they need neither Python nor a cloud account.