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/?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.

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. 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 the done event. 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 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 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.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
  • 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 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"}, including a header with a non-ASCII byte (through v0.75.2 that was a bare 500, because the comparison raised). /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. On a loopback bind, GET /?token=<token> with the right token sets an HttpOnly; SameSite=Strict; Path=/ cookie named soup_ui_session_<port> and answers 303 to the same URL without token (other query parameters are kept). The cookie holds a random session id that the server keeps in memory only, never the token, so every soup ui restart ends every session; it has no expiry (it ends with the browser session) and no Secure flag, because soup ui serves plain HTTP. A wrong token sets nothing and loads the page as before. A browser that blocks cookies for 127.0.0.1 gets 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 whole Authorization: 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 a GET or HEAD must also carry an Origin naming this exact server (scheme, host and port), or it is refused with 403 Origin not allowed. The bearer header works exactly as before and needs no Origin. A cookie set without a Domain belongs to the hostname you opened, so signing in at 127.0.0.1 does not sign in localhost.
  • 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 bearer token is the server-side gate, and the server still does not check the Host header (a request with Host: evil.example was served at v0.75.2; no Host check was added in v0.76.0) or reject a foreign Origin on a bearer-authenticated request. The one exception is the session cookie above: a cookie-authenticated request that is not a GET or HEAD needs an Origin naming this exact server, or it is 403. 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.
  • The session cookie is not isolated by port. Any other server on 127.0.0.1 that your browser sends a same-site request to also receives the cookie. Such a server can replay it outside a browser with any Origin, because the Origin check only stops browsers, and act as you in the UI, including starting training, until soup ui restarts. (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-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.
  • 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-form and /api/chat/send, 8 KiB on /api/data/inspect. The server checks Content-Length and 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 by SOUP_DB_PATH.

Page, health and tickets

MethodPathPurposeAuthNotes
GET/The single-page UInoneServes 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 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, val_reward, lr, grad_norm, speed, gpu_mem and timestamp. val_loss is 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 every training.eval_steps optimizer 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, ppo and online_dpo evaluate 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 a task: grpo run, which records it instead of val_loss, and is null for every other task. grad_norm is stored as null on a step that did not measure one, where an earlier release could store 0.0 (the tracker's log_metrics now defaults val_loss, val_reward and grad_norm to None).

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.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/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, and the settings v0.76.0 refuses at load (see Settings refused at load) come back the same way, as valid: false with the loader's message. 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, val_reward, 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", "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 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 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. path is the resolved absolute path. format is detected from up to the first 100 rows (unknown for an empty file), and keys is the union of keys across the returned sample; a row that is not an object is skipped in keys. When detection refuses (no row in that prefix matches a known shape, or the file mixes shapes) the answer is still 200: format is unknown and format_error carries the detector's message, which names what it found; otherwise format_error is null. Through v0.75.2 these cases answered 500.

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 the number of messages, but the whole request body may not exceed 1 MiB). 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.