Web UI
Soup includes a built-in web dashboard for managing experiments, training, and data.
Launch
pip install "soup-cli[ui]"
soup ui
# Opens http://127.0.0.1:7860/?token=<token> in your browser
# Prints the same URL and the token in the start-up panel (also with --no-browser)The URL soup ui opens and prints already carries the token, so the first tab signs in on its own. On a loopback bind the server checks that token, swaps it for an HttpOnly session cookie and redirects to the same address without it, so the address bar ends up clean and a reload or a new tab in that browser stays signed in. Your browser's history still records the URL it was given, and the start-up panel puts the token in your terminal, so treat the printed URL as the token itself. soup ui --public also prints a phone URL with your LAN address, and --auth-token keeps the token, and so the URL, the same across restarts. The mechanics are under Authentication.
(Through v0.75.2, soup ui opened the address without the token and the dashboard showed Error loading dashboard: Unauthorized; on those releases open http://127.0.0.1:7860/?token=<the printed token> yourself. Since v0.76.0 the page also asks for the token with a browser prompt whenever a request is refused with 401, and accepts the bare token or the whole Authorization: Bearer ... line.)
With --public, the URL: line in the panel and the address the browser is sent to are built from the bind address 0.0.0.0. The line headed Phone URL: carries your LAN address instead; use that one, or your machine's own address with the same ?token=, from another device.
# Custom port, don't auto-open browser
soup ui --port 8080 --no-browserPages
- 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. Since v0.76.0, Start Training (and loading the page while a run launched from this server is still alive) opens a Training Logs card that follows
GET /api/train/logs: the server replays the run's log from its first line on every new connection, the card keeps the last 500 lines, and it prints[Training finished]on thedoneevent. That includes a run that exits at once, whose output is the error to read. The Training Progress card (step, elapsed time, ETA) is still never shown: no route reports a total step count for a run this server started, and nothing in the page calls the function that would move its bar. Through v0.75.2 neither card was ever opened. The same output is available fromGET /api/train/logsfor 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 serveinference 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 the bearer token, as an Authorization: Bearer <token> header or, on a loopback bind, the session cookie the page gets from /?token= (see Authentication), 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.
Since v0.76.0 the page does not store the token. It holds it in a closure inside app.js, never in sessionStorage, localStorage, a cookie or a window property, and every request goes through one authFetch wrapper that adds the Authorization header itself and refuses a cross-origin URL, so the header only ever goes to the UI's own origin. Upstream states the limit: this narrows what injected script can take away, it does not stop script already running in the page, and the content policy is the defence there. Through v0.75.2 the token was kept in sessionStorage, which upstream published as a known limitation of v0.75.1. A reload stays signed in only through the session cookie described under Authentication; otherwise the page asks for the token again.
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 not treated as local: since v0.76.0 it answers 400 endpoint 0.0.0.0 is ambiguous; use 127.0.0.1 or localhost. A refused private address now ends with address the server by its hostname. 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.0bind (--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
- Request-size caps on the five POST routes that take a body (since v0.76.0, see Conventions below)
- 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.76.0 soup ui serves 24 operations there (the same count as v0.75.2), 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. The v0.76.0 changes marked in this section (the session cookie, the request-body caps, the /api/data/inspect response, the non-ASCII 401, the log panel and the val_reward column) were read from the code at the tag and were not re-run. 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 found no stability, versioning or compatibility statement for the routes themselves, and the v0.76.0 text was not searched again. The OpenAPI document that soup ui serves on loopback reports version: 1.0.0, which is a constant in the app factory (unchanged at v0.76.0), not a compatibility level. Seven of the routes are never reached by the bundled page's script at v0.76.0 (/api/health, /api/runs/compare, /api/config/schema, /api/config/from-form, /api/train/progress, /api/train/metrics/live and /api/train/stream), so nothing in the shipped UI exercises their shapes. (At v0.75.2 the list also held /api/auth/ticket and /api/train/logs; the page now calls both when it opens the log card.) 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 uigenerates a bearer token at startup (43 URL-safe characters, new on every start) and prints it in its start-up panel.--auth-tokenreplaces it with your own: 16 to 128 characters fromA-Z a-z 0-9 _ -, otherwise the command exits 1.--show-tokenprints 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 (beareris refused), and a missing or wrong header is401with{"detail": "Unauthorized"}, including a header with a non-ASCII byte (through v0.75.2 that was a bare500, because the comparison raised)./api/runs?token=...is also 401. (soup ui --helpdescribes--publicas requiring a token "in the query string"; that is the page URL described below, not the API.) - Open routes.
GET /, the/staticfiles andGET /api/health. Every other route under/api/needs the token, and three streaming routes also accept a ticket. - Tickets, for streams only. A browser
EventSourcecannot set a header, soPOST /api/auth/ticketwith 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/liveand/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. On a loopback bind,
GET /?token=<token>with the right token sets anHttpOnly; SameSite=Strict; Path=/cookie namedsoup_ui_session_<port>and answers303to the same URL withouttoken(other query parameters are kept). The cookie holds a random session id that the server keeps in memory only, never the token, so everysoup uirestart ends every session; it has no expiry (it ends with the browser session) and noSecureflag, becausesoup uiserves plain HTTP. A wrong token sets nothing and loads the page as before. A browser that blocks cookies for127.0.0.1gets the token prompt after the redirect, because the token has already left the URL. On--public, or any other non-loopback--host, no cookie is set or accepted: the page script reads?token=from its own URL, removes it from the address bar and holds the token in page memory only, so a reload asks for it again (or you scan the QR code again). Whenever a request is refused with 401 the page asks for the token with a browser prompt; paste the token, or the wholeAuthorization: Bearer ...line. The cookie authenticates the same routes the bearer token does, the three streams included. A request that carries the cookie and is not aGETorHEADmust also carry anOriginnaming this exact server (scheme, host and port), or it is refused with403Origin not allowed. The bearer header works exactly as before and needs noOrigin. A cookie set without aDomainbelongs to the hostname you opened, so signing in at127.0.0.1does not sign inlocalhost. - Binding. The default is
127.0.0.1.--publicbinds0.0.0.0when--hostis127.0.0.1(the default, or typed out), and--hostbinds whatever you pass, with the same token. Loopback meanslocalhost, any127.x.x.xaddress or::1; off loopback the interactive docs routes do not exist (404).
Where these checks stop.
- No TLS.
soup uiserves plain HTTP and has no certificate option. On loopback the traffic stays on the machine; under--publicthe token and every request cross the LAN unencrypted, and the phone URL carries the token in a query string. - The bearer token is the server-side gate, and the server still does not check the
Hostheader (a request withHost: evil.examplewas served at v0.75.2; noHostcheck was added in v0.76.0) or reject a foreignOriginon a bearer-authenticated request. The one exception is the session cookie above: a cookie-authenticated request that is not aGETorHEADneeds anOriginnaming this exact server, or it is403. CORS is a browser rule: for a page on another origin theAuthorizationheader 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,POSTandDELETE, headersAuthorizationandContent-Type. By default exactly one origin is allowed,http://<host>:<port>as the server was started. Under a0.0.0.0bind (--public, or--host 0.0.0.0) anylocalhost,127.0.0.1,10.x.x.x,192.168.x.xor172.16.x.xto172.31.x.xorigin is allowed, on any port, overhttporhttps. - The session cookie is not isolated by port. Any other server on
127.0.0.1that your browser sends a same-site request to also receives the cookie. Such a server can replay it outside a browser with anyOrigin, because theOrigincheck only stops browsers, and act as you in the UI, including starting training, untilsoup uirestarts. (This is the limit upstream's own documentation states for the cookie: cookies are not isolated by port, and the port in the cookie's name does not change that.) - No rate limit, no lockout, no rotation. The middleware stack is the request-size cap, CORS and 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-PolicyandX-Frame-Options, and theContent-Security-Policyheader too except on the loopback-only/docs,/redocand/docs/oauth2-redirect. The bare500 Internal Server Errorthat an unhandled exception produces carries none of them. Scripts are limited to the page's own origin and the pinned Chart.js URL;style-srcstill 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>).
TOKEN="$(python -c 'import secrets; print(secrets.token_urlsafe(24))')"
soup ui --no-browser --auth-token "$TOKEN"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 is422with FastAPI's list-valueddetail. An unexpected exception is a bare500 Internal Server Error. - Request-size caps since v0.76.0, no rate limits. A POST body over the cap answers
413 {"detail": "Request body too large"}before the JSON is parsed: 1 MiB on/api/config/validate,/api/train/start,/api/config/from-formand/api/chat/send, 8 KiB on/api/data/inspect. The server checksContent-Lengthand also counts the bytes as they arrive, so a chunked request or an understated header does not get past it, and the check runs before the token is read: an oversized POST to one of those five routes answers 413, not 401, with or without a token. No other route has a body to cap, and nothing limits how often you call any route. Through v0.75.2 no route capped its body (a 5 MB YAML body was accepted by/api/config/validate); the v0.75.0 release page lists that gap and the caps closed it (#897, #915, #939, #948). - 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 bySOUP_DB_PATH.
Page, health and tickets
| Method | Path | Purpose | Auth | Notes |
|---|---|---|---|---|
| GET | / | The single-page UI | none | Serves index.html. On a loopback bind, a ?token= that matches is checked by the server, which sets the session cookie and answers 303 to the same URL without it; on any other bind the page script reads it. A wrong token loads the page as before. |
| GET | /static/* | The page's script, styles and logo | none | Plain static files. |
| GET | /api/health | Liveness | none | Returns {"status": "ok"}. |
| POST | /api/auth/ticket | Exchange the bearer token for a stream ticket | Bearer | No 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.
| Method | Path | Purpose | Auth | Notes |
|---|---|---|---|---|
| GET | /api/runs | List runs, newest first | Bearer | Query limit: 1 to 500, default 50, otherwise 422. Returns {"runs": [...]}; each item is the whole tracker row, config_json included. |
| GET | /api/runs/compare | Configs and metrics for several runs | Bearer | Query 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 run | Bearer | The 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}/metrics | Metric rows for a run | Bearer | Returns {"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}/eval | Eval results for a run | Bearer | Returns {"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 records | Bearer | Exact 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,metricsandeval_results. Rows incheckpoint_qualityandforgetting_evalare 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
runningand whose recordedpidis no longer alive toterminated, in the database, as it reads it. Only rows that carry apidare affected. - Metric rows carry
id,run_id,step,epoch,loss,val_loss,val_reward,lr,grad_norm,speed,gpu_memandtimestamp.val_lossis null on steps where no evaluation ran. Since v0.76.0 (#1223) a run that receives a validation split evaluates it at the end of each epoch, or everytraining.eval_stepsoptimizer steps, so the column fills in on those runs; through v0.75.2 the transformers trainers held the split out and never evaluated it.grpo,ppoandonline_dpoevaluate by generating, so by default they neither evaluate nor hold rows out.val_reward(new in v0.76.0) is the held-out reward of atask: grporun, which records it instead ofval_loss, and is null for every other task.grad_normis stored as null on a step that did not measure one, where an earlier release could store0.0(the tracker'slog_metricsnow defaultsval_loss,val_rewardandgrad_normtoNone).
System, templates, recipes and config
| Method | Path | Purpose | Auth | Notes |
|---|---|---|---|---|
| GET | /api/system | Soup version and device | Bearer | Returns version, device, device_name, gpu_info (memory totals and GPU count) and python_version. |
| GET | /api/templates | The built-in config templates | Bearer | Returns {"templates": {"<name>": "<yaml text>"}}: 17 templates at v0.76.0 (17 at v0.75.2 as well). The four compliance templates that soup init --template offers are not in this list. |
| GET | /api/recipes | The recipe catalog | Bearer | Returns {"recipes": [{"name", "model", "task", "description", "tags", "yaml"}]}, every recipe with its full YAML. |
| GET | /api/config/schema | Field metadata for building a form | Bearer | The 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/validate | Validate a soup.yaml | Bearer | Body {"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-form | Turn form values into validated YAML | Bearer | Body is an object of top-level soup.yaml keys. Returns 200 with {"yaml": "..."} or 200 with {"error": "Invalid configuration: ..."}. |
/api/config/schematypes are hints. Thetypeis guessed from the annotation's text, so it can be wrong:training.batch_sizeis reported asintegeralthough its default isauto,data.trainasstringalthough a list is accepted, and the nestedevalandadviseblocks asstring. The authority is/api/config/validate./api/config/validatereturns the whole config. On success,configis the validated config with every default filled in. A YAML syntax error and an unknown key both come back asvalid: falsewith the message; since v0.75.0 an unknown key is refused by name rather than ignored, and the settings v0.76.0 refuses at load (see Settings refused at load) come back the same way, asvalid: falsewith the loader's message. Note the field isyamlhere, while/api/train/starttakesconfig_yaml./api/config/from-formdrops empty values. Top-level values that arenull,""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.
| Method | Path | Purpose | Auth | Notes |
|---|---|---|---|---|
| POST | /api/train/start | Validate a config and launch soup train | Bearer | Body {"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/status | Is the launched child alive | Bearer | Returns {"running", "pid", "config_path"}. config_path is the temp file and is set only while running. |
| POST | /api/train/stop | Terminate the launched child | Bearer | Returns {"stopped": true}, or {"stopped": false, "detail": "No training in progress"}. Returns without waiting for the process to exit. |
| GET | /api/train/progress | Last logged step | Bearer | Optional 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/logs | The child's output (SSE) | Bearer or ticket | Frames id: N plus data: {"line": "...", "id": N}, ending with event: done. |
| GET | /api/train/metrics/live | New metric rows for a run (SSE) | Bearer or ticket | Query 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/stream | Trainer events (SSE) | Bearer or ticket | Frames 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 runningsoup ui. Nothing in the request adds, removes or changes a flag, so--trust-remote-codestays at its default (refused).--yesskips the CLI's confirmation prompts. - A temp file. The validated YAML is written to
soup_ui_config_*.yamlin the system temp directory, created withtempfile.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 uiwas started. The config is processed exactly assoup train --configwould 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 genericInvalid training configuration, without the parser's message.
The three streams.
/api/train/logsreplays the retained lines, then follows the child live.Last-Event-IDresumes after that id. If nothing has been launched since the server started, it answersevent: doneat once, and launching another run replaces the buffer./api/train/metrics/livepolls the tracker every 0.1 second. With norun_idit sendsdoneat once. It also closes withevent: doneafter 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 SSEid:line; theidinside the JSON is the metrics row's./api/train/streamsendsTrainEventframes with atypeofmetric,status,logoreval, and thents,step,epoch,loss,val_loss,val_reward,lr,grad_norm,tokens_per_s,ema_loss,p95_step_ms,p99_step_ms,eta_secondsandmessage, each only when set. It reads a buffer of the last 1,000 events inside thesoup uiprocess. The callbacks that fill that buffer run inside the trainer, and/api/train/startruns the trainer as a separate process, so for a run started from the UI no events arrive: expect:heartbeatlines, then astatusframe whosemessageisdoneonce no launched child is alive, ortimeoutafter about 20 seconds if one is.
Data
| Method | Path | Purpose | Auth | Notes |
|---|---|---|---|---|
| POST | /api/data/inspect | Read a dataset file and return a sample | Bearer | Body {"path": "...", "limit": 50}; limit is 1 to 500, default 50, otherwise 422. Returns {"path", "total", "format", "format_error", "keys", "sample"}. 403 outside the working directory, 404 if missing, 400 if the file cannot be loaded, 413 if the request body is over 8 KiB. |
- 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 uiprocess...traversal, an absolute path elsewhere, a sibling directory that shares the prefix (project-secretsnext toproject) 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;limitonly trimssample, andtotalis the full row count, so there is no size cap on the file (the request body, which carries only the path and the limit, is capped at 8 KiB). Malformed JSONL lines are skipped with a warning in the server console. - What it returns.
pathis the resolved absolute path.formatis detected from up to the first 100 rows (unknownfor an empty file), andkeysis the union of keys across the returned sample; a row that is not an object is skipped inkeys. When detection refuses (no row in that prefix matches a known shape, or the file mixes shapes) the answer is still 200:formatisunknownandformat_errorcarries the detector's message, which names what it found; otherwiseformat_erroris null. Through v0.75.2 these cases answered 500.
Chat
| Method | Path | Purpose | Auth | Notes |
|---|---|---|---|---|
| POST | /api/chat/send | Stream a chat completion from an inference server (SSE) | Bearer | Body below. Frames data: {"delta": "..."}, then data: {"done": true}. |
- Body.
messagesis a non-empty list of{"role": "...", "content": "..."}(empty is 400; there is no role allowlist and no limit on the number of messages, but the whole request body may not exceed 1 MiB).endpointis the server URL. Optional:temperature0 to 2 (default 0.7),max_tokens1 to 16384 (default 512),top_p0 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/completionswithstream: true, andContent-Type: application/jsonis the only header the route sets: noAuthorizationor 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-emptyadaptergoes upstream as themodelfield, 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 a200response. The upstream's HTTP status is not checked, so a 4xx or 5xx from the inference server arrives as an empty reply followed bydone.
Tool outputs
| Method | Path | Purpose | Auth | Notes |
|---|---|---|---|---|
| GET | /api/tool-outputs | Recent tool-call records | Bearer | Query 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.