studio-worker 0.4.9

Pull-based image-generation worker for the minis.gg studio.
Documentation
# Local image API

The worker exposes an always-on local HTTP API so you can generate images
(e.g. Z-Image) **without the studio**. The daemon (`studio-worker run`, which the tray UI
starts when none runs) serves it, before the studio-registration gate, so it
works even when the worker is not registered with any studio.  The tray UI is
itself a client of this API (see [daemon control](#daemon-control)).

- Bind: `127.0.0.1` only.
- Auth: every route except `GET /healthz` requires
  `Authorization: Bearer <token>`.  The token is generated once per
  install and published — together with the bound URL — in the
  owner-only discovery file `<config dir>/local-api.json`
  (`~/.config/minis-studio-worker/local-api.json` on Linux), so local
  clients can pick both up without parsing logs.  Requests with a
  non-loopback `Host` or `Origin` header are rejected with `403`
  (DNS-rebinding / CSRF guards — loopback alone is not enough against
  a hostile web page).
- Port: `4787` by default. Override with `STUDIO_WORKER_LOCAL_API_PORT`
  (or `local_api_port` in `config.toml`; the env var wins); if the
  preferred port is taken the worker falls back to an ephemeral port and logs
  the chosen URL (also published in the tray UI's Jobs tab and the discovery file).
- Request bodies are capped at 1 MiB (`413` beyond that).
- Synchronous: `POST /image` blocks until the engine finishes and returns the
  image bytes. Each job is recorded in the in-app **Local queue**.

## Endpoints

| Method | Path            | Auth | Body / params                              | Returns |
| ------ | --------------- | ---- | ------------------------------------------ | ------- |
| POST   | `/image`        | yes  | JSON image request (below)                 | image bytes (`image/webp` etc.) |
| POST   | `/v1/chat/completions` | yes | OpenAI-compatible chat body (`model?`, `messages`, `max_tokens?`, `temperature?`, `top_p?`, `stop?`, `chat_template_kwargs?`) | `chat.completion` JSON |
| POST   | `/tts`          | yes  | `{text, model?, voice?, speed?, language?, ext?}` | audio bytes (`audio/wav` etc.) |
| POST   | `/stt`          | yes  | `{inputUrl, model?, language?}`            | transcript JSON |
| POST   | `/video`        | yes  | `{prompt, model?, negativePrompt?, seconds?, width?, height?, ext?}` | video bytes (`video/mp4` etc.) |
| GET    | `/models`       | yes  | —                                          | catalog as JSON array, each entry with `state`, `resident`, `since`, `loadable` (+ `error` when failed) |
| GET    | `/models/:id/state` | yes | —                                        | `{id, state, resident, since, error?}` |
| POST   | `/models/:id/load`  | yes | —                                        | `202` loading / `200` loaded; marks it resident |
| POST   | `/models/:id/unload`| yes | —                                        | `202` unloading / `200` unloaded; clears residency |
| POST   | `/models`       | yes  | a catalog model (same `ModelSource` shape) | `{"ok":true}` |
| DELETE | `/models/:id`   | yes  | —                                          | `{"ok":true}` / 404; unloads it first |
| GET    | `/jobs`         | yes  | —                                          | recent local jobs as JSON |
| GET    | `/jobs/:id/log` | yes  | —                                          | the job's captured log (`404 unknown_job`) |
| GET    | `/jobs/:id/thumbnail` | yes | —                                     | `image/png` thumbnail of an image job (`404 no_thumbnail`) |
| GET    | `/daemon/status` | yes | —                                          | the tray UI's snapshot (below) |
| GET    | `/daemon/logs`  | yes  | `?after=<seq>`                             | `{entries, seq}`: worker log entries newer than `seq` |
| POST   | `/daemon/pause`, `/daemon/resume` | yes | —                      | `{paused}` |
| GET / PUT | `/daemon/config` | yes | PUT: the editable config              | the editable config (`400 invalid_config`, `500 config_not_saved`) |
| POST   | `/daemon/registration/reset` | yes | —                             | `202` / `409 not_rejected` |
| POST   | `/daemon/shutdown` | yes | —                                        | `202`; the daemon stops gracefully |
| POST   | `/stream-tokens` | yes | `{model, ttlSecs?}`                     | `{token, model, expiresAt, port, path}` for the LAN stream listener |
| GET    | `/healthz`      | no   | —                                          | runtime snapshot (below) |

### Model lifecycle

Models can be kept loaded (resident) for warm answers and unloaded to free device memory; see
[model lifecycle](runtime/model-lifecycle.md) for states, residency and admission.

Lifecycle routes answer JSON. Errors carry a stable `error` code plus a `message`:

| Status | `error` | When |
| --- | --- | --- |
| 404 | `unknown_model` | no catalogue model with that id |
| 400 | `model_disabled` | the model is disabled in the catalogue |
| 409 | `insufficient_memory` | admission refused the load; carries `neededGib`, `freeGib`, `marginGib` |
| 500 | `residency_not_saved` | the residency file could not be written; nothing changed |

```bash
curl -s -X POST "$(jq -r .url $DISCOVERY)/models/qwen3.5-0.8b/load" \
  -H "authorization: Bearer $(jq -r .token $DISCOVERY)"
# {"id":"qwen3.5-0.8b","state":"loading","resident":true,"since":"..."}
```

`loadable` is `false` for engines without an in-process loader (sd-cpp, ONNX,
synthetic): those models run per job and cannot be kept loaded.

### Daemon control

The tray UI (`studio-worker ui`) is a client of the daemon over these routes;
design in [daemon and tray UI](runtime/daemon-and-tray.md).  They carry the same
Host / Origin / token guards as every other route.  Errors answer
`{"error": <code>, "message": <text>}`.

`GET /daemon/status` answers everything the UI shows except the logs; it never
carries a credential:

```jsonc
{
  "version": "0.4.8", "pid": 4242, "configPath": "/home/you/.config/minis-studio-worker/config.toml",
  "paused": false,
  "busy": false,                       // the one-job gate is taken
  "registered": true, "workerId": "w-…",
  "registration": { "state": "approved" },   // pristine | pending{requestId,since} | approved | rejected{reason}
  "config": { "apiBaseUrl": "…", "vramThresholdGb": 12.0, "startMinimised": true,
              "autoUpdateEnabled": true, "autoUpdateIntervalSecs": 1800,
              "autoUpdateFeed": "…", "autoUpdatePrerelease": false, "modelsRoot": "…" },
  "session": { "state": "connected" },       // waiting_for_approval | connecting | connected | reconnecting{attempt} | auth_failed{reason} | fatal{reason} | stopped
  "heartbeat": { "outcome": "ok", "lastAttemptAt": "…" },
  "gpuRuntime": { "ok": true, "detail": "GPU runtime available" },
  "vramTotalGb": 24.0,
  "localApiUrl": "http://127.0.0.1:4787",
  "currentJobId": null,                      // the studio job the heartbeat reports
  "activeJobs": [ /* running jobs */ ],
  "recentJobs": [ /* finished studio jobs, newest first, up to 50 */ ],
  "localJobs":  [ /* finished local jobs, newest first, up to 50 */ ],
  "logsSeq": 1234                            // newest worker log entry
}
```

A job: `{jobId, kind, model, prompt, source: studio|local|lane|stream,
status: running|completed|failed, reason?, startedAt, finishedAt?, hasThumbnail}`.

`GET /daemon/logs?after=<seq>` answers the worker log entries (`{ts, level,
category, message, jobId?}`) newer than `seq` and the newest `seq`; pass it back
next time.  An `after` beyond the newest (the daemon restarted) answers the
whole ring (1 000 entries).

`GET /jobs/:id/log` answers `{lines: [{ts, level, target, message}], dropped}`:
the events emitted while the job ran (400 lines per job, the 128 most recent
jobs).  `GET /jobs/:id/thumbnail` answers a PNG of at most 192 px (the 100 most
recent image jobs).

`PUT /daemon/config` takes the `config` object above; the daemon validates it
(http(s) URLs, a threshold of 0 or more, an interval of at least 60 s, a
non-empty models root), saves it, then applies it.  A changed `modelsRoot`
applies to engines built after a restart.

### Streaming speech-to-text

Loaded streaming speech models (`engine: "parakeet"`) are served on a second
listener that binds the **LAN** (`0.0.0.0:4798`; `stream_port` in config or
`STUDIO_WORKER_STREAM_PORT`), so a phone on the same network can stream to it.
It accepts only short-lived **stream tokens**, minted here with the install
token, so the install token never leaves the host:

```bash
curl -s -X POST "$(jq -r .url $DISCOVERY)/stream-tokens" \
  -H "authorization: Bearer $(jq -r .token $DISCOVERY)" \
  -H 'content-type: application/json' -d '{"model":"nemotron-3.5-stream"}'
# {"token":"…","model":"nemotron-3.5-stream","expiresAt":"…","port":4798,"path":"/transcribe"}
```

A token lives 10 minutes by default (`ttlSecs`, clamped to 30 s–1 h); the holder
mints a fresh one before it expires.  Errors: `404 unknown_model`,
`400 not_a_stream_model`, `503 stream_listener_down`.

Then `ws://<host>:4798/transcribe?token=<token>`:

| Direction | Frame | Meaning |
| --- | --- | --- |
| client -> worker | binary | 16 kHz mono s16le PCM |
| client -> worker | text `end` | finalise: flush, send the final, close |
| client -> worker | text `cancel` | close without a final |
| worker -> client | `{"partial":true,"text":…}` | the transcript so far |
| worker -> client | `{"final":true,"text":…}` | the settled transcript, then close |
| worker -> client | `{"error":…}` | e.g. not loaded, busy, model unloaded |

After speech, 1.5 s of silence finalises by itself (energy VAD, RMS 0.018 over
200 ms windows).  One session per model at a time; a second gets
`busy`.  Unloading the model ends a session with `model unloaded`.  A bad or
expired token is refused at the handshake (401); any other path is 404.
Every session is recorded in the local queue.

### Health snapshot

`GET /healthz` is unauthenticated (liveness + a read-only snapshot, no
secrets or prompts) and answers even while a generation is in flight
(requests are served on a small worker pool):

```jsonc
{
  "ok": true,
  "version": "0.4.9",
  "busy": false,                 // true while a job (studio or local) runs
  "engine": "multi",
  "modelsRoot": "/home/you/models",
  "modelsRootFreeBytes": 812345678900
}
```

### Image request

```jsonc
{
  "prompt": "a red fox in snow",
  "model": "z-image-turbo-q4_k_m.gguf", // optional; default image model if omitted
  "negativePrompt": "blurry",           // optional
  "width": 1024, "height": 1024,         // optional; fall back to the model's cliDefaults
  "steps": 8,                            // optional
  "seed": 42,                            // optional
  "ext": "webp"                          // optional; webp/png/jpg/...
}
```

Example (reading the URL + token from the discovery file with `jq`):

```bash
DISCOVERY=~/.config/minis-studio-worker/local-api.json
curl -s "$(jq -r .url $DISCOVERY)/image" \
  -H "authorization: Bearer $(jq -r .token $DISCOVERY)" \
  -H 'content-type: application/json' \
  -d '{"prompt":"a red fox in snow"}' --output fox.webp
```

Errors: unknown / wrong-kind model or a bad request body return `400`; a
missing/wrong token returns `401`; a non-loopback `Host`/`Origin` returns
`403`; a body over 1 MiB returns `413`; a busy worker (a studio or local
job already running) returns `503` with `Retry-After`; an engine failure
returns `500`.

### Studio-synced models

When the worker is registered with a studio and claims a job, the
model on that offer is mirrored into the local catalog (marked
`origin: "studio"`), so a model a studio admin adds from Hugging Face
becomes usable through the local API too. Your own entries
(`origin: "local"`, the default for anything you `POST /models` or edit
by hand) are never overwritten by this sync.

### Non-image kinds

Every endpoint resolves a catalog model of the matching kind (an
explicit `model`, else the first enabled model of that kind).  Add
per-kind models the same way as image models via `POST /models`; a
request with no model of that kind in the catalog returns `400`.  The
`/v1/chat/completions` endpoint returns the engine's JSON verbatim, so
an OpenAI-style client can point straight at
`http://127.0.0.1:4787/v1/chat/completions` with the bearer token.

Chat renders the model's own chat template.  `chat_template_kwargs` (as in
llama-server) overrides the model's `chatTemplateKwargs`, e.g.
`{"enable_thinking": false}` for a hybrid-reasoning model; a `<think>` block
in the answer is returned as `reasoning_content`.  `usage` carries real token
counts, and `finish_reason` is `length` when the budget ran out.  A prompt that
does not fit the model's `contextSize` is refused, never truncated.

A **loaded** model answers on its own lane, next to any running job and
without the one-job gate; an unloaded model runs as a transient job (loaded
for the request, freed after).  Keep a chat model loaded
(`POST /models/:id/load`) for warm answers.  GPU offload needs a build with
the `cuda` feature; release builds run LLMs on the CPU.

## Local model catalog

Models live in a local catalog at `<config dir>/models.json`
(`~/.config/minis-studio-worker/models.json` on Linux). It mirrors the studio's
model registry: each entry carries the same `ModelSource` (engine + files +
`cliDefaults`) the studio would send on a job. The catalog is **seeded** with
Z-Image-Turbo (image), Qwen3.5 0.8B (small LLM, reasoning off, 32K context),
Nemotron 3.5 streaming and Parakeet EOU (streaming speech, one loaded at a time)
— seeds a catalogue lacks are added at startup, except ones the operator deleted
(`dismissedSeeds`) — and the files are downloaded on demand into
`models_root` (`~/models`) the first time a model is used — exactly as a
studio-driven job would.

Add a model the same way the studio does (a `ModelSource` plus a little
metadata), either by editing `models.json` or via the API:

```bash
curl -s http://127.0.0.1:4787/models \
  -H "authorization: Bearer $(jq -r .token ~/.config/minis-studio-worker/local-api.json)" \
  -H 'content-type: application/json' \
  -d '{
    "id": "my-model.gguf",
    "displayName": "My Model",
    "kind": "image",
    "vramGbEstimate": 8,
    "source": {
      "engine": "sd-cpp",
      "files": [
        {"role":"diffusion-model","url":"https://.../model.gguf","filename":"model.gguf"}
      ],
      "cliDefaults": {"cfgScale":1.0,"steps":8,"width":1024,"height":1024,"samplingMethod":"euler"}
    },
    "enabled": true
  }'
```

## Local queue in the app

Local jobs are kept in their own ring (`WorkerObservers::local_jobs`), separate
from studio-claimed jobs, and shown under **Local queue** in the tray UI's
Jobs tab alongside the API URL, each with its log and, for images, a thumbnail.
Chats served on a loaded model's lane (`source: lane`) and streaming speech
sessions (`source: stream`) are local jobs too.

## Notes

- The local API runs on its own thread and never blocks the studio session
  loop. Heavy generation (sd.cpp) runs the same engine path as studio jobs.
- It does not serialise GPU access with the studio session; if you both run
  studio jobs and call the local API on the same box, avoid overlapping heavy
  generations to stay within VRAM.