Web UI

Soup includes a built-in web dashboard for managing experiments, training, and data.

Launch

bash
pip install "soup-cli[ui]"
soup ui
# Opens http://127.0.0.1:7860 in your browser
# Prints auth token to console

The tab soup ui opens carries no token in its address, and the page has no field to type one into, so its first requests are refused and the dashboard shows Error loading dashboard: Unauthorized. Open http://127.0.0.1:7860/?token=<the printed token> yourself: the page keeps the token for that tab and removes it from the address bar. soup ui --public prints this form of URL with your LAN address, and --auth-token keeps the token, and so the URL, the same across restarts. The mechanics are under Authentication.

bash
# Custom port, don't auto-open browser
soup ui --port 8080 --no-browser

Pages

  • Dashboard — the run table with system info; opening a run shows its configuration, loss, learning-rate, gradient-norm, speed and GPU-memory charts, and its eval results
  • New Training — edit a YAML config (start from a template or a recipe), validate it, start or stop a run, and see its PID while it runs. The page also contains a progress bar and a log panel (step, elapsed time, ETA, streamed output), but at v0.75.2 nothing in the page's script opens them, so they stay hidden after Start Training; the same output is available from GET /api/train/logs for your own client, see the REST API reference
  • Data Explorer — browse and inspect datasets (JSONL, JSON, CSV, Parquet, plain text)
  • Model Chat — chat with a running soup serve inference server: temperature, max tokens, top-p and a system prompt, markdown rendering of replies, a Cancel button, and Export of the conversation as JSON
  • Tool Outputs — the most recent tool calls held in the server's memory, polled every three seconds. A run started from the UI does not fill that memory (see the REST API reference), so expect No tool calls observed yet.

The chat page has an Adapter field, and it does not select an adapter on soup serve: the page sends the value as the request's model field, while the server reads a separate adapter field (see Inference Server). Leave it empty.

Security

The Web UI generates a random auth token at startup and prints it to the console. Every private endpoint requires an Authorization: Bearer <token> header, not only the mutating ones (the three event streams also accept a one-time ticket, below). Until v0.75.0 the read endpoints were open: run configurations, logs, system metrics and the event streams all answered without a token. Only /, /api/health and the page's own /static files stay open, so the dashboard can load.

Until v0.75.1 this page rendered dataset and run content as HTML. The Data Explorer and the run detail view inserted dataset field names, dataset values and stored run configuration straight into the page with no escaping (0.7.0 to 0.75.0), and the UI sent no Content-Security-Policy. Inspecting somebody else's dataset could therefore run script in the origin that holds your bearer token and can start training, which is as bad as it sounds.

Every rendered value now goes through one escaping template, inline handlers are gone, and the UI sends a policy with no unsafe-inline for scripts and no eval, alongside X-Content-Type-Options, Referrer-Policy and X-Frame-Options. Chart.js is pinned with Subresource Integrity, and the interactive API docs stay the one documented exception, served on loopback only.

One limitation upstream publishes with it rather than letting you assume otherwise: the Web UI token still lives in sessionStorage. The sinks that could read it from the page are gone and the policy forbids inline script and eval, but the storage choice itself is unchanged.

The streams authenticate differently, on purpose. An SSE connection cannot carry a header the browser does not let you set, so instead of putting a durable token in a query string, the client exchanges one over an authenticated POST for a short-lived, single-use ticket.

The interactive API docs are loopback-only. /openapi.json, /docs, /docs/oauth2-redirect and /redoc serve on a loopback bind and are absent, 404, on any other, including soup ui --public. Upstream classifies that honestly as reconnaissance rather than disclosure: the schema exposes no run data, configuration or logs, but it does describe every route, parameter and request shape, and on a LAN bind that is free reconnaissance. They are removed rather than gated because /docs is a browser navigation that cannot attach a bearer header, so gating would have broken the page for a developer while leaving /openapi.json readable by any HTTP client. Read the schema from a loopback instance of the same version if you need it.

A non-loopback bind without a valid token exits 2 rather than warning. In practice a token is always present by then: one is generated at startup, and a malformed --auth-token exits 1 first, so this is a guard the command's own options do not reach.

The chat page only forwards to endpoints it can vouch for. Model Chat sends your message through the UI server to whatever endpoint you type, so that endpoint is checked first: plain http only for loopback, https for anything else, and, since v0.75.2, never an IP address in a private, link-local or reserved range, on either scheme. A hostname is accepted and is not resolved. http://0.0.0.0 is no longer treated as local; use localhost. A URL that does not parse answers 400 rather than 500. Reaching the proxy needs a valid bearer token in the first place, and the reasoning behind the v0.75.2 change is on Private addresses refused.

  • CORS is not a wildcard: on a default bind it allows only the served origin, and under a 0.0.0.0 bind (--public) it allows loopback and private-network origins instead (exact rule under Authentication)
  • Data inspection is limited to files under the working directory, by a path check rather than a sandbox
  • Config validation before writing
  • Error bodies from the data and chat routes are generic (details logged server-side); the config routes return the loader's own message on purpose, so you can see which key was refused

REST API reference

Everything the pages above do goes through a JSON API under /api/. At v0.75.2 soup ui serves 24 operations there, plus GET / for the page and /static for its files. This section lists them as they behave at that release: what each takes, what it returns, what it touches on disk or in other processes, and which credential it needs. Every route was exercised against the v0.75.2 app with Starlette's test client in a scratch directory, with the training subprocess and the chat upstream replaced by stubs, and the page's own script was run offline against a stub server. What is said about a real training run, or about what a browser does, comes from reading the code at the tag and was not observed.

What this is, and what it is not

These routes are the Web UI's own backend. Upstream's documentation describes the pages, the token and the content policy; a search of the README, docs/, SECURITY.md and CONTRIBUTING.md at v0.75.2 finds no stability, versioning or compatibility statement for the routes themselves. The OpenAPI document that soup ui serves on loopback reports version: 1.0.0, which is a constant in the app factory, not a compatibility level. Nine of the routes are never reached by the bundled page's script (/api/health, /api/auth/ticket, /api/runs/compare, /api/config/schema, /api/config/from-form, /api/train/progress, /api/train/logs, /api/train/metrics/live and /api/train/stream), so nothing in the shipped UI exercises their shapes. Treat what follows as a description of one release: pin the Soup version if you script against it, and read /openapi.json from a loopback instance of the version you run. For the same jobs from a script, the documented interface is the CLI: soup train, soup runs and soup data inspect.

Authentication

  • One credential, per process. soup ui generates a bearer token at startup (43 URL-safe characters, new on every start) and prints it in its start-up panel. --auth-token replaces it with your own: 16 to 128 characters from A-Z a-z 0-9 _ -, otherwise the command exits 1. --show-token prints the token and exits without serving. On its own it prints a fresh value that belongs to no running server (two runs printed two different tokens), so use it together with --auth-token, or read the token from the panel.
  • A header, never a query string, on the API. Send Authorization: Bearer <token>. It is compared against the whole expected string in constant time, the scheme word is case-sensitive (bearer is refused), and a missing or wrong header is 401 with {"detail": "Unauthorized"} (a header containing a non-ASCII byte is a bare 500 instead, because the comparison raises). /api/runs?token=... is also 401. (soup ui --help describes --public as requiring a token "in the query string"; that is the page URL described below, not the API.)
  • Open routes. GET /, the /static files and GET /api/health. Every other route under /api/ needs the token, and three streaming routes also accept a ticket.
  • Tickets, for streams only. A browser EventSource cannot set a header, so POST /api/auth/ticket with the bearer token returns {"ticket": "..."}. A ticket is valid for 30 seconds, works once, and is accepted only as ?ticket=<value> on /api/train/logs, /api/train/metrics/live and /api/train/stream. On any other route it is a 401. A client that can set headers, such as curl, can send the bearer token to those three routes directly.
  • How the page gets the token. The page script reads ?token= from its own URL, or sessionStorage on later loads, then removes the parameter from the address bar. GET / ignores the parameter and sets no cookie. soup ui itself opens the address without it, which is why the Launch section above tells you to add it.
  • Binding. The default is 127.0.0.1. --public binds 0.0.0.0 when --host is 127.0.0.1 (the default, or typed out), and --host binds whatever you pass, with the same token. Loopback means localhost, any 127.x.x.x address or ::1; off loopback the interactive docs routes do not exist (404).

Where these checks stop.

  • No TLS. soup ui serves plain HTTP and has no certificate option. On loopback the traffic stays on the machine; under --public the token and every request cross the LAN unencrypted, and the phone URL carries the token in a query string.
  • The token is the only server-side gate. The server does not check the Host header (a request with Host: evil.example was served) and does not reject a foreign Origin. CORS is a browser rule: for a page on another origin the Authorization header forces a preflight request, which the server refuses on a default bind, so the browser never sends the real request. It does not apply to curl, a script or any other non-browser client that holds the token.
  • CORS rule. Methods GET, POST and DELETE, headers Authorization and Content-Type. By default exactly one origin is allowed, http://<host>:<port> as the server was started. Under a 0.0.0.0 bind (--public, or --host 0.0.0.0) any localhost, 127.0.0.1, 10.x.x.x, 192.168.x.x or 172.16.x.x to 172.31.x.x origin is allowed, on any port, over http or https.
  • No rate limit, no lockout, no rotation. The middleware stack is CORS plus the security headers, nothing else. The token changes only when the process restarts.
  • One token, every power. There are no scopes and no read-only token. Holding it lets a client read every run's configuration, logs and metrics, delete tracker rows, read any supported data file under the working directory, start a training run from any soup.yaml as the user running soup ui, and send requests through the chat proxy.
  • The content policy is the other half of the story. Every response the app itself produces, 401s, 404s and 422s included, carries X-Content-Type-Options, Referrer-Policy and X-Frame-Options, and the Content-Security-Policy header too except on the loopback-only /docs, /redoc and /docs/oauth2-redirect. The bare 500 Internal Server Error that an unhandled exception produces carries none of them. Scripts are limited to the page's own origin and the pinned Chart.js URL; style-src still allows inline styles.
  • The interactive docs cannot authenticate. The OpenAPI document declares no security scheme, so the Swagger page offers no way to enter the token, and its Try it out calls to protected routes are refused with 401. Use curl.

Start the server with a token you chose, then call it from a second terminal, after setting the same value there (TOKEN=<the value you chose>).

bash
TOKEN="$(python -c 'import secrets; print(secrets.token_urlsafe(24))')"
soup ui --no-browser --auth-token "$TOKEN"
bash
curl -s -H "Authorization: Bearer $TOKEN" "http://127.0.0.1:7860/api/runs?limit=5"
curl -N -H "Authorization: Bearer $TOKEN" "http://127.0.0.1:7860/api/train/logs"
curl -s -X POST -H "Authorization: Bearer $TOKEN" -H "Content-Type: application/json" \
  -d '{"yaml": "base: x"}' "http://127.0.0.1:7860/api/config/validate"

The last call answers 200 with valid set to false and the loader's message, because an invalid config is an answer, not an error.

Conventions

  • Bodies and errors. Requests and responses are JSON. A refusal is {"detail": "<message>"}. A parameter or body of the wrong shape or range is 422 with FastAPI's list-valued detail. An unexpected exception is a bare 500 Internal Server Error.
  • No size caps, no rate limits. No route limits its request body (a 5 MB YAML body was accepted by /api/config/validate), and none limits how often you call it. The v0.75.0 release page lists the same gap for the YAML endpoints.
  • Whose permissions. Every route acts as the user who started soup ui, in the directory where it was started. Run data comes from ~/.soup/experiments.db, or the file named by SOUP_DB_PATH.

Page, health and tickets

MethodPathPurposeAuthNotes
GET/The single-page UInoneServes index.html. A ?token= in the URL is read by the page script, not by the server.
GET/static/*The page's script, styles and logononePlain static files.
GET/api/healthLivenessnoneReturns {"status": "ok"}.
POST/api/auth/ticketExchange the bearer token for a stream ticketBearerNo body. Returns {"ticket": "..."}; valid 30 seconds, single use, stream routes only (see Authentication).

Runs

These read and write the experiment tracker, the same SQLite database that soup runs uses.

MethodPathPurposeAuthNotes
GET/api/runsList runs, newest firstBearerQuery limit: 1 to 500, default 50, otherwise 422. Returns {"runs": [...]}; each item is the whole tracker row, config_json included.
GET/api/runs/compareConfigs and metrics for several runsBearerQuery ids: 1 to 5 comma-separated run ids, otherwise 400. Returns {"runs": [{"run_id", "config", "metrics"}]}. An unknown id is not an error: it comes back with an empty config and empty metrics. A unique prefix finds the config but not the metrics, which are read by exact id, so pass full ids.
GET/api/runs/{run_id}One runBearerThe whole row. Accepts an exact id or a prefix that matches exactly one run; no match, or an ambiguous prefix, is 404.
GET/api/runs/{run_id}/metricsMetric rows for a runBearerReturns {"run_id", "metrics": [...]} ordered by step. The 404 check accepts a unique prefix, but the rows are looked up by the string you sent, so a prefix gives 200 with empty metrics. Use the full id.
GET/api/runs/{run_id}/evalEval results for a runBearerReturns {"run_id", "eval_results": [...]}. Exact id only, and an unknown id is 200 with an empty list.
DELETE/api/runs/{run_id}Delete a run's recordsBearerExact id only (a prefix is 404). Returns {"deleted": true, "run_id": "..."}. See below for what it removes.
  • What DELETE removes. The run's rows in runs, metrics and eval_results. Rows in checkpoint_quality and forgetting_eval are left. Nothing on disk is touched: the output directory, adapters and checkpoints stay. There is no confirmation step and no undo.
  • A GET can write. Listing runs, opening one, reading its metrics or comparing runs rewrites a row whose status is running and whose recorded pid is no longer alive to terminated, in the database, as it reads it. Only rows that carry a pid are affected.
  • Metric rows carry id, run_id, step, epoch, loss, val_loss, lr, grad_norm, speed, gpu_mem and timestamp. val_loss is null on steps where no evaluation ran.

System, templates, recipes and config

MethodPathPurposeAuthNotes
GET/api/systemSoup version and deviceBearerReturns version, device, device_name, gpu_info (memory totals and GPU count) and python_version.
GET/api/templatesThe built-in config templatesBearerReturns {"templates": {"<name>": "<yaml text>"}}: 17 templates at v0.75.2. The four compliance templates that soup init --template offers are not in this list.
GET/api/recipesThe recipe catalogBearerReturns {"recipes": [{"name", "model", "task", "description", "tags", "yaml"}]}, every recipe with its full YAML.
GET/api/config/schemaField metadata for building a formBearerThe top-level fields, with data, training and training.lora as nested objects. Each field is {"type", "required"} plus default, options, ge and le where they apply.
POST/api/config/validateValidate a soup.yamlBearerBody {"yaml": "<text>"}. Empty or missing yaml is 400. Otherwise 200 with {"valid": true, "config": {...}} or {"valid": false, "error": "..."}. Nothing is written or run. Any exception the loader raises comes back as its text, so a non-string yaml returns a Python error message such as 'int' object has no attribute 'read'.
POST/api/config/from-formTurn form values into validated YAMLBearerBody is an object of top-level soup.yaml keys. Returns 200 with {"yaml": "..."} or 200 with {"error": "Invalid configuration: ..."}.
  • /api/config/schema types are hints. The type is guessed from the annotation's text, so it can be wrong: training.batch_size is reported as integer although its default is auto, data.train as string although a list is accepted, and the nested eval and advise blocks as string. The authority is /api/config/validate.
  • /api/config/validate returns the whole config. On success, config is the validated config with every default filled in. A YAML syntax error and an unknown key both come back as valid: false with the message; since v0.75.0 an unknown key is refused by name rather than ignored. Note the field is yaml here, while /api/train/start takes config_yaml.
  • /api/config/from-form drops empty values. Top-level values that are null, "" or {} are removed (nested ones are not), the rest is dumped as YAML in the order given and checked by the same loader as validate. An invalid result is still HTTP 200.

Training

The UI server keeps a handle to at most one training child process, the one it launched itself. The routes below know nothing about a soup train you started from a shell, or from another soup ui.

MethodPathPurposeAuthNotes
POST/api/train/startValidate a config and launch soup trainBearerBody {"config_yaml": "<text>"}. 409 if the child it launched is still alive; 400 Invalid training configuration: <message> if the loader refuses the config. Returns {"started": true, "pid": N}.
GET/api/train/statusIs the launched child aliveBearerReturns {"running", "pid", "config_path"}. config_path is the temp file and is set only while running.
POST/api/train/stopTerminate the launched childBearerReturns {"stopped": true}, or {"stopped": false, "detail": "No training in progress"}. Returns without waiting for the process to exit.
GET/api/train/progressLast logged stepBearerOptional run_id. Returns {"running", "current_step", "run_id"}: the highest step in the tracker for that exact id, and always 0 without run_id. No total and no ETA.
GET/api/train/logsThe child's output (SSE)Bearer or ticketFrames id: N plus data: {"line": "...", "id": N}, ending with event: done.
GET/api/train/metrics/liveNew metric rows for a run (SSE)Bearer or ticketQuery run_id. Sends each new tracker row as a data: frame, then closes. See below: it does not follow a run for long.
GET/api/train/streamTrainer events (SSE)Bearer or ticketFrames data: {"type": "metric", ...} from an in-memory buffer. See below: a run started from the UI does not fill it.

What /api/train/start runs.

  • A fixed command line. <python> -m soup_cli train --config <file> --yes, with the interpreter that is running soup ui. Nothing in the request adds, removes or changes a flag, so --trust-remote-code stays at its default (refused). --yes skips the CLI's confirmation prompts.
  • A temp file. The validated YAML is written to soup_ui_config_*.yaml in the system temp directory, created with tempfile.mkstemp, so it is a new file with an unpredictable name rather than a fixed path someone could pre-place a link at. Nothing deletes it afterwards, not even /api/train/stop, so these files accumulate and hold whatever you put in the YAML.
  • No sandbox of its own. The child inherits the server's whole environment, API keys included, and its working directory, so relative paths in the config resolve where soup ui was started. The config is processed exactly as soup train --config would process it; the Web UI adds validation before the launch and nothing around it.
  • A normal run. The child records itself in the experiment tracker, which is why it then appears in /api/runs. Its stdout and stderr are merged and drained by a background thread into a ring buffer of the last 10,000 lines.
  • Validation is the loader's. The same check as /api/config/validate, so an unknown key is a 400 that names it. A YAML syntax error answers only the generic Invalid training configuration, without the parser's message.

The three streams.

  • /api/train/logs replays the retained lines, then follows the child live. Last-Event-ID resumes after that id. If nothing has been launched since the server started, it answers event: done at once, and launching another run replaces the buffer.
  • /api/train/metrics/live polls the tracker every 0.1 second. With no run_id it sends done at once. It also closes with event: done after three polls (about 0.3 second) that found no new row, even while the run is alive, so it replays what exists and then stops; a new connection, with a new ticket, starts again from the first row, because the route has no resume point. Frames have no SSE id: line; the id inside the JSON is the metrics row's.
  • /api/train/stream sends TrainEvent frames with a type of metric, status, log or eval, and then ts, step, epoch, loss, val_loss, lr, grad_norm, tokens_per_s, ema_loss, p95_step_ms, p99_step_ms, eta_seconds and message, each only when set. It reads a buffer of the last 1,000 events inside the soup ui process. The callbacks that fill that buffer run inside the trainer, and /api/train/start runs the trainer as a separate process, so for a run started from the UI no events arrive: expect :heartbeat lines, then a status frame whose message is done once no launched child is alive, or timeout after about 20 seconds if one is.

Data

MethodPathPurposeAuthNotes
POST/api/data/inspectRead a dataset file and return a sampleBearerBody {"path": "...", "limit": 50}; limit is 1 to 500, default 50, otherwise 422. Returns {"path", "total", "format", "keys", "sample"}. 403 outside the working directory, 404 if missing, 400 if the file cannot be loaded.
  • What the path check covers. The path is resolved to its real location, and that location must be inside the working directory of the soup ui process. .. traversal, an absolute path elsewhere, a sibling directory that shares the prefix (project-secrets next to project) and a symlink inside the directory that points outside are all 403. Where it stops: it is a check made before the file is opened, not a sandbox, and it does not hold the file open between the check and the read. An empty path, a directory, or a path with a NUL byte is a 400.
  • What it reads. .jsonl, .json (a list of objects), .csv, .parquet (needs pandas) and .txt (one {"text": ...} row per non-empty line). Any other extension is 400. The whole file is loaded into memory first; limit only trims sample, and total is the full row count, so there is no size cap. Malformed JSONL lines are skipped with a warning in the server console.
  • What it returns. path is the resolved absolute path. format is detected from the first row only (unknown for an empty file), and keys is the union of keys across the returned sample. A file whose first row matches no known format, or whose returned sample contains a row that is not an object, answers 500 rather than 400, because the detector or the key scan raises.

Chat

MethodPathPurposeAuthNotes
POST/api/chat/sendStream a chat completion from an inference server (SSE)BearerBody below. Frames data: {"delta": "..."}, then data: {"done": true}.
  • Body. messages is a non-empty list of {"role": "...", "content": "..."} (empty is 400; there is no role allowlist and no limit on count or size). endpoint is the server URL. Optional: temperature 0 to 2 (default 0.7), max_tokens 1 to 16384 (default 512), top_p 0 to 1 (default 0.9), adapter (a string or null). A number out of range is 422.
  • Which endpoints are accepted is described under Security above, and the reasoning is on Private addresses refused. A refused endpoint is a 400 before anything is sent; the response never echoes the URL back.
  • What is sent. POST <endpoint>/v1/chat/completions with stream: true, and Content-Type: application/json is the only header the route sets: no Authorization or API key is forwarded, so an endpoint that needs a key cannot be reached through this route. The timeout is 120 seconds for each connect, read, write and pool wait, not a limit on the whole reply. Redirects are not followed (the httpx default). A non-empty adapter goes upstream as the model field, which is why the Adapter box on the chat page does not select an adapter.
  • What comes back. Any failure while talking to the endpoint arrives as data: {"error": "Connection failed"} inside a 200 response. The upstream's HTTP status is not checked, so a 4xx or 5xx from the inference server arrives as an empty reply followed by done.

Tool outputs

MethodPathPurposeAuthNotes
GET/api/tool-outputsRecent tool-call recordsBearerQuery limit: 1 to 1000, default 100, otherwise 422. Returns {"count", "records": [{"name", "started_ts", "duration_ms", "success", "output_preview", "error"}]}.

The records live in an in-memory buffer of the soup ui process (at most 1,000, previews cut to 4,096 characters). They are written by the trainer's callback for tool-calling runs, which runs inside the soup train child, so a run started from the UI leaves this list empty: {"count": 0, "records": []}. While the Tool Outputs page is open the page polls it every three seconds, and it stops when you leave.

Soup is free and Apache-2.0. If it saved you a training run, starring the repo costs nothing and helps most. You can also fund the GPU time behind the work a 4 GB laptop cannot reach.