815 lines
36 KiB
Markdown
815 lines
36 KiB
Markdown
# Pantheon — HTTP API Reference
|
|
|
|
The server speaks HTTP/1.1 over TCP. Every response carries
|
|
`Access-Control-Allow-Origin: *` and `Connection: close`. No authentication.
|
|
All bodies are JSON unless noted (SSE for streaming).
|
|
|
|
---
|
|
|
|
## Routes
|
|
|
|
| Method | Path | Handler | Audience |
|
|
|--------|------|---------|----------|
|
|
| `GET` | `/` | health | client / operator |
|
|
| `OPTIONS` | `*` | CORS preflight | client |
|
|
| `GET` | `/v1/models` | list models | client |
|
|
| `POST` | `/v1/chat/completions` | chat completion | client |
|
|
| `GET` | `/admin/models` | list live model instances | operator |
|
|
| `POST` | `/admin/models/load` | load a model | operator |
|
|
| `POST` | `/admin/models/unload` | unload a model | operator |
|
|
| `GET` | `/admin/dies` | die status | operator |
|
|
| `POST` | `/admin/session/export` | export a session's state as a blob | operator |
|
|
| `POST` | `/session/restore` | restore a session from a blob | client |
|
|
| `POST` | `/session/release` | free a session's slot | client |
|
|
| `GET` | `/session/estimate` | warm-restore vs re-prefill time estimate | client |
|
|
| `GET` | `/session/watch` | hold a session alive while connected | client |
|
|
|
|
Any other path → `404 {"error":{"message":"unknown route","type":"not_found","code":null}}`.
|
|
|
|
---
|
|
|
|
## GET /
|
|
|
|
Health check. Always 200.
|
|
|
|
**Response:**
|
|
```json
|
|
{"service":"pantheon","status":"ok","backend":"cuda",
|
|
"build":"5d818bfd1c9a","accelerator":true,"dies_visible":8}
|
|
```
|
|
|
|
| Field | Type | Meaning |
|
|
|-------|------|---------|
|
|
| `backend` | string | The accelerator backend this binary was COMPILED with: `cuda`, `tt`, `metal`, `cpu`. Compile-time constant, not a probe. |
|
|
| `build` | string | Commit this binary was built from (`-dirty` when the tree was not clean), or `unknown`. |
|
|
| `accelerator` | bool | False means every forward pass in this process runs on the host CPU, whatever the flags say. |
|
|
| `dies_visible` | int | Dies the die manager holds (`pn_dm_count()`). `0` on a CUDA build means no driver / no card / empty `CUDA_VISIBLE_DEVICES`. |
|
|
|
|
The cheapest route on the server is also the one that answers "what am I
|
|
running?" — see [`GET /v1/models`](#backend--build--what-this-binary-actually-is)
|
|
for why that question needed an endpoint at all.
|
|
|
|
---
|
|
|
|
## OPTIONS \*
|
|
|
|
CORS preflight. Always 200 with `{}` body.
|
|
|
|
---
|
|
|
|
## GET /v1/models
|
|
|
|
**Serve's own endpoint** (`openai_chat.c:oai_handle_models`, `serve_command.c:dispatch`):
|
|
same-name replica slots (multi-die LB copies) are deduplicated — clients see one
|
|
logical id per served name. The response does **not** include `live` or `free_slots`.
|
|
|
|
**Response — 200:**
|
|
```json
|
|
{
|
|
"object": "list",
|
|
"backend": "cuda",
|
|
"build": "5d818bfd1c9a",
|
|
"data": [
|
|
{
|
|
"id": "my-model",
|
|
"object": "model",
|
|
"created": 0,
|
|
"owned_by": "pantheon",
|
|
"max_sessions": 4,
|
|
"max_concurrent": 4,
|
|
"context_window": 32000,
|
|
"tool_choice_required_with_thinking": true,
|
|
"graphs": {"live": 30, "total": 40, "failed": 0,
|
|
"ineligible": 10, "pending": 0},
|
|
"attention": {"ondie": 10, "host": 0, "total": 10}
|
|
}
|
|
]
|
|
}
|
|
```
|
|
|
|
| Field | Type | Meaning |
|
|
|-------|------|---------|
|
|
| `backend` | string | **Top-level.** The accelerator backend this binary was COMPILED with: `cuda`, `tt`, `metal` or `cpu`. Compile-time constant, not a probe. |
|
|
| `build` | string | **Top-level.** The commit this binary was built from (`-dirty` suffixed when the tree was not clean), or `unknown`. |
|
|
| `id` | string | The routing key for `model` on a completion |
|
|
| `max_sessions` | int | RESIDENCY: how many conversations may hold KV at once |
|
|
| `max_concurrent` | int | CONCURRENCY: how many requests this model ACTUALLY decodes at once — the **served** value (`bs_get_load` capacity), not the configured cap. See below. |
|
|
| `context_window` | int | **Per-session context window in tokens** — see below. Omitted when unknown. |
|
|
| `tool_choice_required_with_thinking` | bool | `true` when `tool_choice: "required"` may be sent with thinking on: the think block is unconstrained and the turn ends in a parsed tool call (the think-gated grammar models ORNITH_JSON and QWEN_XML; `false` for MiniCPM's attribute dialect, which no grammar models, and for every model when `PANTHEON_TOOL_GRAMMAR=0`). |
|
|
| `graphs` | object | **CUDA-graph residency** — see below. Omitted when the model has no graph slots. |
|
|
| `attention` | object | **Attention-path residency** — see below. Omitted until the forward has reached an attention layer. |
|
|
|
|
### `backend` / `build` — what this binary actually is
|
|
|
|
Top-level on the response, not per-model, because it is a property of the
|
|
process: the accelerator backend it was **compiled** with (`cuda`, `tt`, `metal`,
|
|
`cpu`) and the commit it came from.
|
|
|
|
Compile-time constant, deliberately — not a runtime probe. A serve whose binary
|
|
has no CUDA compiled in cannot use a GPU whatever the driver reports, and the
|
|
completion text is identical either way, so nothing downstream can detect it
|
|
from the output.
|
|
|
|
The same values are on `GET /` (plus `dies_visible`), and out of band from
|
|
`pantheon --backend`, which prints one lowercase token for a deploy script to
|
|
assert against.
|
|
|
|
### `max_concurrent` — the SERVED number, not the configured cap
|
|
|
|
`max_concurrent` on `/v1/models` is what the model actually decodes at once
|
|
(`bs_get_load`'s capacity), which is **not** always what `--max-concurrent` asked
|
|
for. It reads `1` whenever there is no batch scheduler — no CUDA compiled in,
|
|
`QWEN35_BATCH_SLOTS` off, `bs_create` failed — and it drops below the cap when
|
|
the memory-pressure ladder clamps the slot count.
|
|
|
|
Same argument as `context_window` below: a client sizes its in-flight requests
|
|
from this number, so advertising a cap the serve cannot honour (e.g. `2` with no
|
|
scheduler, which turns every second concurrent chat into a `429`) is worse than
|
|
advertising nothing.
|
|
|
|
Operators still get the configured value: `/admin/models` carries **both**, as
|
|
`max_concurrent` (served) and `max_concurrent_configured` (asked for). Seeing
|
|
them differ is the signal.
|
|
|
|
### `context_window` — the advertised window
|
|
|
|
`context_window` is the window a client may actually plan a prompt against, in
|
|
tokens. It is the **effective** window (`pn_effective_ctx_window`): the operator's
|
|
`--ctx-cap` **after** the reactive memory ladder's runtime clamp — *not* the
|
|
configured cap, and not the model's theoretical maximum.
|
|
|
|
That distinction is the whole point of the field. Publishing the configured cap
|
|
while admission enforces a smaller one hands the client a prefill the serve then
|
|
rejects — on a K80 that rejection lands after minutes of cold-prefill TTFT, and it
|
|
lands mid-task.
|
|
|
|
Two properties clients depend on:
|
|
|
|
- **It is the same number a completion reports as `usage.kv_cap`.** The model list
|
|
is the client's INITIAL plan; `kv_cap` is the LIVE truth on every turn. A client
|
|
that reads both needs no polling and no extra endpoint to notice a mid-session
|
|
clamp.
|
|
- **It is additive and omitted when unknown.** A plain OpenAI-compatible client
|
|
ignores the extra key; a client that reads it never sees a `0` it has to
|
|
special-case. An absent field means *undetected*; a client should fall back to
|
|
a conservative window rather than guess.
|
|
|
|
Field name: Pantheon emits `context_window`. Clients that detect the window from
|
|
other servers' spellings (`context_length`, `max_model_len`, `n_ctx`, ...) need
|
|
to accept this one.
|
|
|
|
### `graphs` — CUDA-graph residency
|
|
|
|
Each layer of the hybrid stack can hold a captured CUDA graph that collapses its
|
|
~13-30 kernel launches into one replay. Across 40 layers those graphs are worth
|
|
about 1.8 ms/step. A capture that **fails** falls back to the direct kernel body
|
|
and produces **correct output** — so losing them costs only speed, silently.
|
|
This object is the only surface that reports it.
|
|
|
|
| Key | Meaning |
|
|
|-----|---------|
|
|
| `live` | Slots holding a captured graph right now |
|
|
| `total` | Slots the forward has actually reached (see below) |
|
|
| `failed` | Attempted and did not stick — **the only value that is a fault** |
|
|
| `ineligible` | Gated out; this model never captures. **Not a fault.** |
|
|
| `pending` | Eligible, warming up, no attempt yet |
|
|
| `gave_up` | Subset of `failed` whose bounded retry budget is spent (omitted when 0) |
|
|
|
|
**`ineligible` is normal.** The captured path requires the grouped MoE kernel,
|
|
which requires Q3_K expert tensors. A Q5_K_M model reports `live 0 / total 40 /
|
|
ineligible 40 / failed 0` — correct behaviour, zero exposure, and also zero of
|
|
the +4%. Read `failed`, not `live/total`, to decide whether something is wrong.
|
|
|
|
**`total` counts only exercised slots.** A serve decoding batched never touches
|
|
its single-token graph slots, so those are not reported as missing graphs.
|
|
|
|
A failed capture is retried on a bounded backoff and only stops after the budget
|
|
is spent, so a transient `failed` on one read that is `live` on the next is the
|
|
system self-healing, not a flapping metric.
|
|
|
|
### `attention` — on-die vs host-softmax residency
|
|
|
|
The same argument as `graphs`, only much sharper. Each full-attention layer
|
|
normally runs the on-die flash kernels. When it cannot it falls back to host-side
|
|
causal GQA softmax on the serve host's CPU — the exact wall this engine was built
|
|
to escape (it was 87.5% of an 8k prefill; moving attention on-die took that
|
|
prefill from 26 minutes to 3.82). The fallback returns the **same tokens**, logs
|
|
nothing per step, and on the depth bench costs **13.02 -> 1.22 tok/s** (K=2,
|
|
depth 4096) — and it worsens with context, because host softmax is O(context)
|
|
per layer per token. Without
|
|
this object a user simply waits, and nothing anywhere says why.
|
|
|
|
| Key | Meaning |
|
|
|-----|---------|
|
|
| `ondie` | Attention layers running the on-die flash kernels right now. |
|
|
| `host` | Layers on the host-softmax fallback — **any non-zero value is a fault** |
|
|
| `total` | Layers the forward has actually reached (same rule as `graphs.total`) |
|
|
| `gave_up` | Layers whose KV-ring allocation spent its retry budget (omitted when 0) |
|
|
| `impossible` | Subset of `gave_up`: the rings cannot fit this die at all (omitted when 0) |
|
|
|
|
`ondie + host == total`. Unlike `graphs`, there is no benign state here: `host >
|
|
0` always means this model is serving degraded, and the `reasons` histogram on
|
|
`/admin/models` says why. The two common causes are a per-slot KV-ring allocation
|
|
that did not fit (lower `--ctx-cap` or `--max-sessions`) and a session that has
|
|
run past the KV window, which wraps the ring.
|
|
|
|
A ring allocation that fails is retried on a bounded backoff, so a `host` reading
|
|
that clears on the next poll is the system self-healing. `gave_up` is the number
|
|
that should page someone.
|
|
|
|
No error cases (returns an empty `data` array when nothing is loaded).
|
|
|
|
**Router's `/v1/models`** (`registry.c:reg_models_json`) is a different shape: it
|
|
aggregates across live serve instances and adds `live` (count of live instances serving
|
|
that id) and `free_slots` (summed across live replicas). Do not conflate the two.
|
|
|
|
The router also emits `context_window`, aggregated the only honest way: the
|
|
**minimum** across that model_id's live replicas (a dispatch may land on any of
|
|
them), and **omitted entirely** if any live replica has not reported one. Serve
|
|
instances report their window on both `/registry/register` and every
|
|
`/registry/heartbeat`, because the ladder can move it at runtime.
|
|
|
|
---
|
|
|
|
## POST /v1/chat/completions
|
|
|
|
OpenAI-compatible chat completion. Supports streaming (SSE) and non-streaming.
|
|
Includes reasoning (`<think>…</think>` split) and tool-call support.
|
|
|
|
### Request headers
|
|
|
|
| Header | Required | Effect |
|
|
|--------|----------|--------|
|
|
| `Content-Type: application/json` | no | Not checked; the body is parsed as JSON regardless. |
|
|
| `X-Session-Id: <sid>` | no | Prefix-cache session key. A body key (`session_id`, `prompt_cache_key`, `promptCacheKey`) takes precedence. opencode sends this header on every request. |
|
|
|
|
### Request body
|
|
|
|
```json
|
|
{
|
|
"model": "my-model",
|
|
"messages": [
|
|
{"role": "system", "content": "..."},
|
|
{"role": "user", "content": "hello"}
|
|
],
|
|
"temperature": 0.7,
|
|
"top_p": 0.95,
|
|
"top_k": 40,
|
|
"max_tokens": 1024,
|
|
"stop": ["<|im_end|>"],
|
|
"stream": false,
|
|
"tools": [...],
|
|
"tool_choice": "auto",
|
|
"session_id": "abc123"
|
|
}
|
|
```
|
|
|
|
| Field | Type | Default | Notes |
|
|
|-------|------|---------|-------|
|
|
| `model` | string | — | Routing key. With one loaded model, any value (or absent) routes to it. With multiple models, must match a served name exactly; unknown → 404. |
|
|
| `messages` | array | *required* | Non-empty array of `{role, content}`. Roles: `system`, `user`, `assistant`, `tool`; a missing role is treated as `user`. `content` must be a string — any other type (e.g. an array of content parts) is read as empty. |
|
|
| `messages[].reasoning_content` | string | unset | Assistant turns only. The reasoning this serve returned for that turn; replayed into the prompt so a session can resume from its prefix cache. |
|
|
| `messages[].tool_calls` | array | unset | Assistant turns only. Rendered back into the prompt in the model's tool dialect. |
|
|
| `temperature` | float | `0.7` | `<= 0` is greedy argmax. |
|
|
| `top_p` | float | `0.95` | Nucleus sampling. |
|
|
| `top_k` | int | `40` | Top-K sampling. |
|
|
| `max_tokens` | int | `1024` | Max completion tokens. `0` or negative → `512`. Clamped to 1048576. |
|
|
| `max_completion_tokens` | int | `1024` | Alias; `max_tokens` wins if both are present. |
|
|
| `stop` | string or array | unset | Stop strings. |
|
|
| `stream` | bool | `false` | `true` → SSE (`text/event-stream`). |
|
|
| `stream_options.include_usage` | bool | `false` | Streaming only: send a terminal `usage` chunk before `[DONE]`. |
|
|
| `seed` | number | unset | Makes sampling reproducible. With `n` > 1, choice *i* uses `seed + i`. |
|
|
| `presence_penalty` | float | `0` | Clamped to [-2, 2]. |
|
|
| `frequency_penalty` | float | `0` | Clamped to [-2, 2]. |
|
|
| `logit_bias` | object | unset | `{"<token id>": bias}`; bias clamped to [-100, 100]. Non-numeric keys or values are skipped. |
|
|
| `logprobs` | bool | `false` | Return per-token log-probabilities (`choices[].logprobs.content[]`, with `token`, `logprob`, `bytes`). |
|
|
| `top_logprobs` | int | `0` | Alternatives per token; clamped to [0, 20]. |
|
|
| `n` | int | `1` | Number of completions. Each is a full separate decode (only the first uses the session's prefix cache). Capped at 8; a larger `n` returns 8 and sets `n_status: "capped_to_8"`. |
|
|
| `response_format` | object | `{"type":"text"}` | `json_object` constrains the answer to valid JSON. `json_schema` gets the same JSON constraint but the schema's shape is **not** enforced. See `response_format_status` below. |
|
|
| `enable_thinking` | bool | unset | Think-style templates: `true` opens a think block, `false` prefills a closed one. Absent → the `PANTHEON_NO_THINK` env decides. |
|
|
| `reasoning_budget` | int | per-model | Force `</think>` after N reasoning tokens. Overrides the model's `--reasoning-budget`; `0` or negative disables it. `max_reasoning_tokens` is an alias (`reasoning_budget` wins). |
|
|
| `tools` | array | unset | OpenAI-format tool definitions. Rendered into a system turn in the model's tool dialect (below). Ignored when `tool_choice` is `"none"`. |
|
|
| `tool_choice` | string or object | `"auto"` | `"none"` disables tools. `"required"` forces a call: the grammar is armed by default (`PANTHEON_TOOL_GRAMMAR=0` opts out). With thinking on the think block is unconstrained and the call is compelled from `</think>` (models advertising `tool_choice_required_with_thinking`). `{"type":"function","function":{"name":"X"}}` is enforced only when `PANTHEON_TOOL_GRAMMAR=1`; otherwise it behaves like `"auto"`. |
|
|
| `session_id` | string | unset | Prefix-cache session key (overrides `X-Session-Id`). Enables delta prefill on later turns of the same session. |
|
|
| `prompt_cache_key` / `promptCacheKey` | string | unset | Aliases for `session_id` (OpenAI's name; opencode's camelCase body field). Order: `session_id`, `prompt_cache_key`, `promptCacheKey`, then the `X-Session-Id` header. |
|
|
|
|
### Tool calling
|
|
|
|
Tools are rendered into a system turn (merged with a leading `system` message)
|
|
in the dialect the routed model emits. The dialect is chosen from the model name
|
|
and architecture (`tool_calling.c:tc_dialect_for_model`), not from the GGUF chat
|
|
template:
|
|
|
|
| Dialect | Used for | Call syntax |
|
|
|---------|----------|-------------|
|
|
| Qwen3-Coder XML | default | `<tool_call><function=NAME><parameter=KEY>VALUE</parameter></function></tool_call>` |
|
|
| MiniCPM XML | Llama-arch models whose template adds BOS (MiniCPM5) | `<function name="NAME"><param name="KEY">VALUE</param>…` |
|
|
| JSON | served names starting `ornith` | fenced or bare `{"function"\|"name": NAME, "arguments": {…}}` |
|
|
|
|
The model's output is parsed into OpenAI `tool_calls`, with arguments typed from
|
|
each tool's JSON schema. `PANTHEON_TOOL_GRAMMAR` decides which requests are
|
|
grammar-constrained:
|
|
|
|
| Value | Constrained requests |
|
|
|-------|----------------------|
|
|
| unset (default) | `tool_choice: "required"` only — after reasoning, the output is one well-formed call to an offered tool, arguments typed and `required` keys present. Everything else decodes **unconstrained**. |
|
|
| `1` | every request with tools: `"auto"` masks once a call opener appears; `"required"` and a named function compel a call. |
|
|
| `0` | none — `"required"` is unconstrained too. |
|
|
|
|
On the Tenstorrent serve a `"required"` turn keeps speculative decoding: drafts
|
|
are checked against the grammar and the output equals non-speculative
|
|
constrained decoding. On a `"required"` turn (every dialect, single-user and
|
|
batched) an end token is refused before the call opens and, inside the call,
|
|
allowed only where the call can still be closed; the serve then appends the
|
|
closing text. Any other call the model started but did not finish is left
|
|
out of `tool_calls` and reported in `tool_call_error` (`code`, optional
|
|
`function`, `parameter`, `message`) on the choice.
|
|
|
|
`tools` and `response_format` cannot both constrain one decode: when the request
|
|
carries tools, tools win and `response_format_status` is `"ignored_tools_present"`.
|
|
|
|
### Non-streaming response — 200
|
|
|
|
```json
|
|
{
|
|
"id": "chatcmpl-a1b2c3d4",
|
|
"object": "chat.completion",
|
|
"created": 1720000000,
|
|
"model": "my-model",
|
|
"choices": [{
|
|
"index": 0,
|
|
"message": {
|
|
"role": "assistant",
|
|
"content": "Hello!",
|
|
"reasoning_content": "...",
|
|
"tool_calls": [...]
|
|
},
|
|
"finish_reason": "stop"
|
|
}],
|
|
"usage": {
|
|
"prompt_tokens": 12,
|
|
"completion_tokens": 7,
|
|
"total_tokens": 19,
|
|
"prompt_tokens_details": {"cached_tokens": 0},
|
|
"kv_used": 12,
|
|
"kv_cap": 32000
|
|
}
|
|
}
|
|
```
|
|
|
|
- `reasoning_content` — the text inside a `<think>…</think>` block; present only
|
|
when non-empty. Only think-style chat templates with thinking on produce it.
|
|
Non-think templates (plain ChatML such as SmolLM2, Mistral, Phi-3) return the
|
|
whole answer as `content`.
|
|
- `tool_calls` — present when a call was parsed; `finish_reason` is then
|
|
`"tool_calls"`. `content` is `null` for a pure tool call.
|
|
- `finish_reason` — `stop`, `length`, `tool_calls`, or `repetition_loop` (the
|
|
decode was stopped because the model was looping; outranks `tool_calls`).
|
|
- `logprobs` — on the choice, when requested.
|
|
- `prompt_tokens_details.cached_tokens` — prompt tokens reused from the prefix
|
|
cache rather than computed. Always present (`0` on a cold turn).
|
|
- `kv_used` — this session's KV occupancy in tokens after this turn.
|
|
- `kv_cap` — the **effective** per-session context window, the same number
|
|
`GET /v1/models` advertises as `context_window`, re-read every turn. If the
|
|
memory ladder moves the window mid-session, the next completion carries the
|
|
new value. Treat the model list as the initial plan and this as authoritative.
|
|
- `session_conflict: true` (inside `usage`) — two conversations are sharing one
|
|
session id, so neither can resume from cache.
|
|
- `response_format_status` (top-level, when the request sent `response_format`)
|
|
— `applied`, `schema_downgraded`, `ignored_tools_present`,
|
|
`ignored_unknown_type`, or `not_enforced` (a constraint was armed but the
|
|
output did not close a complete JSON object).
|
|
- `n_status` (top-level) — why fewer choices came back than `n` asked for:
|
|
`capped_to_8`, `partial_slot_unavailable`, `partial_generation_failed`.
|
|
- `usage` covers the whole request: the prompt is counted once, completion
|
|
tokens are summed across choices.
|
|
|
|
A prompt longer than the effective window is **refused** with
|
|
`context_over_budget` (below); nothing is silently dropped. The
|
|
`context_capped` field the writer supports is never set by the current
|
|
generation path.
|
|
|
|
### Streaming response — 200 (SSE)
|
|
|
|
`Content-Type: text/event-stream`. Each chunk is:
|
|
|
|
```
|
|
data: {"id":"chatcmpl-...", "object":"chat.completion.chunk", "created":...,
|
|
"model":"...", "choices":[{"index":0,"delta":{...},"finish_reason":null}]}
|
|
|
|
```
|
|
|
|
Delta fields emitted in order:
|
|
|
|
1. `{"role":"assistant"}` — first chunk of each choice.
|
|
2. `{"reasoning_content":"<fragment>"}` — inside a think block (think-style templates only).
|
|
3. `{"content":"<fragment>"}` — answer text. `logprobs` rides the chunk carrying those tokens.
|
|
4. Tool-call chunks (when a call was parsed):
|
|
- Opening: `{"tool_calls":[{"index":0,"id":"...","type":"function","function":{"name":"...","arguments":""}}]}`
|
|
- Arguments: `{"tool_calls":[{"index":0,"function":{"arguments":"..."}}]}`
|
|
5. Closing chunk: `{"delta":{},"finish_reason":"..."}`. Carries `tool_call_error`
|
|
on the choice and `response_format_status` top-level when applicable.
|
|
6. With `stream_options.include_usage`: a chunk with `"choices":[]` and `usage`.
|
|
7. Terminal sentinel: `data: [DONE]`
|
|
|
|
With `n` > 1 the choices stream one after another, told apart by `choices[].index`.
|
|
A client that disconnects aborts the turn and frees its slot.
|
|
|
|
Errors detected after the stream opened cannot change the status code:
|
|
`context_over_budget` is sent as a terminal event
|
|
`data: {"error":{"message":"...","type":"context_over_budget","code":null,"kv_used":N,"kv_cap":M}}`
|
|
and the stream ends; other generation failures close the choice with
|
|
`finish_reason: "stop"`.
|
|
|
|
### Error responses
|
|
|
|
| Status | `type` | Condition |
|
|
|--------|--------|-----------|
|
|
| 400 | `invalid_request_error` | Empty body, malformed JSON, body not an object, `messages` missing or empty |
|
|
| 404 | `model_not_found` | Several models loaded and `model` matches none |
|
|
| 422 | `context_over_budget` | Prompt exceeds the effective per-session window. Not transient: shorten the conversation and retry. Non-streaming: `kv_used`/`kv_cap` are in the message text; streaming: they are fields of the error object. |
|
|
| 429 | `server_busy` | Single-user path: a generation is already running on this model. Batched path: all slots busy and the queue full, or 30 s queue wait timed out. `Retry-After` when the scheduler supplies one. |
|
|
| 429 | `model_at_session_capacity` | A new `session_id` while every session slot (`max_sessions`) holds a live session. `Retry-After: 30`; the message estimates the re-prefill time. |
|
|
| 503 | `no_models_loaded` | No model loaded |
|
|
| 503 | `server_memory_constrained` | Host memory pressure: admission refused the prompt, or the queue is full under critical pressure |
|
|
| 503 | `server_shutting_down` | Server is draining for shutdown. `Retry-After: 1` when refused at the route. |
|
|
| 500 | `server_error` | Generation failed / out of memory |
|
|
|
|
Source: `openai_chat.c:oai_handle_chat`, `serve_generation.c:sg_run_completion`,
|
|
`serve_command.c:dispatch`.
|
|
|
|
---
|
|
|
|
## POST /admin/models/load
|
|
|
|
Operator-only. Load a model at runtime (Phase 7). Serialized against other
|
|
uploads via `reg->upload_lock` (one GPU upload in flight at a time).
|
|
|
|
### Request body
|
|
|
|
```json
|
|
{
|
|
"path": "/models/my-model.gguf",
|
|
"name": "my-model",
|
|
"dies": [0, 1],
|
|
"quantize": false,
|
|
"ctx_cap": 32768,
|
|
"max_sessions": 4,
|
|
"max_concurrent": 2,
|
|
"reasoning_budget": 1024
|
|
}
|
|
```
|
|
|
|
| Field | Type | Required | Notes |
|
|
|-------|------|----------|-------|
|
|
| `path` | string | yes | GGUF file path |
|
|
| `name` | string | no | Served id (defaults to file basename) |
|
|
| `dies` | int array | no | Device ordinals (same `CUDA_VISIBLE_DEVICES`-relative semantics as `--dies`). Max `PN_MODEL_MAX_DIES=8`. |
|
|
| `quantize` | bool | no | INT8-quantize at load time |
|
|
| `ctx_cap` | int | no | Per-session context cap in tokens; a prompt past it is refused with `context_over_budget`. `0`/absent = engine default. Mirrors `--ctx-cap`. |
|
|
| `max_sessions` | int | no | **Residency** — how many named sessions may hold KV at once. Over the cap a new session id gets `429 model_at_session_capacity`. `0`/absent = engine default. Mirrors `--max-sessions`. |
|
|
| `max_concurrent` | int | no | **Concurrency** — how many requests may decode simultaneously (batch-scheduler slots). Surplus queues, then `429`/`503`. Distinct from `max_sessions`. `0`/absent = the resolved `max_sessions`. Mirrors `--max-concurrent`. |
|
|
| `reasoning_budget` | int | no | Force `</think>` after N tokens of unclosed reasoning. `0`/absent = unbounded. Mirrors `--reasoning-budget`. |
|
|
|
|
Every per-model option available on the `serve` command line is settable here, so a model
|
|
added at runtime ends up configured identically to one launched with the process.
|
|
|
|
### Response — 200
|
|
|
|
```json
|
|
{
|
|
"id": "my-model",
|
|
"object": "model",
|
|
"path": "/models/my-model.gguf",
|
|
"dies": [0, 1],
|
|
"vram_bytes": 12345678,
|
|
"max_sessions": 4,
|
|
"max_concurrent": 4,
|
|
"context_window": 32000,
|
|
"context_window_configured": 32000,
|
|
"graphs": {
|
|
"live": 30, "total": 40, "failed": 0, "gave_up": 0,
|
|
"ineligible": 10, "pending": 0,
|
|
"captures": 30, "capture_failures": 0,
|
|
"batched_attn": {"live": 10, "total": 10, "failed": 0, "ineligible": 0, "pending": 0},
|
|
"batched_ssm": {"live": 20, "total": 30, "failed": 0, "ineligible": 10, "pending": 0}
|
|
},
|
|
"attention": {
|
|
"ondie": 10, "host": 0, "total": 10,
|
|
"gave_up": 0, "impossible": 0,
|
|
"host_engagements": 0, "ring_alloc_failures": 0
|
|
}
|
|
}
|
|
```
|
|
|
|
The operator surface carries **both** context numbers, because an operator's
|
|
question is not the client's: `context_window` is what sessions actually get right
|
|
now, `context_window_configured` is what `--ctx-cap` asked for. They are equal
|
|
unless the memory ladder is clamping this instant — and seeing them differ is
|
|
exactly the signal that memory pressure is costing context. (`/v1/models`
|
|
advertises only the effective one; a client has no use for a window it cannot
|
|
have.) Both are omitted when unknown.
|
|
|
|
Falls back to `{"status":"loaded"}` if the row cannot be found in the snapshot (race).
|
|
|
|
### Error responses
|
|
|
|
| Status | `type` | Condition |
|
|
|--------|--------|-----------|
|
|
| 400 | `invalid_request_error` | Missing `path`, malformed JSON, too many dies |
|
|
| 400 | `load_failed` | `dies` named on a serve with no accelerator backend compiled in, or with no dies visible. Not retryable — the fix is a different binary (`pantheon --backend`) or an omitted `dies`. |
|
|
| 409 | `load_failed` | Model already loaded / already unloading |
|
|
| 507 | `load_failed` | Admission refused (VRAM or host), die binding conflict, registry full |
|
|
| 404 | `load_failed` | File not found |
|
|
| 500 | `load_failed` | Other load failure |
|
|
|
|
Source: `model_admin.c:ma_handle_load`.
|
|
|
|
---
|
|
|
|
## POST /admin/models/unload
|
|
|
|
Operator-only. Unload a model by served name. Drains any in-flight generation
|
|
before freeing.
|
|
|
|
### Request body
|
|
|
|
```json
|
|
{"name": "my-model"}
|
|
```
|
|
|
|
| Field | Type | Required | Notes |
|
|
|-------|------|----------|-------|
|
|
| `name` | string | yes | Served model id to unload |
|
|
|
|
### Response — 200
|
|
|
|
```json
|
|
{"unloaded": "my-model"}
|
|
```
|
|
|
|
### Error responses
|
|
|
|
| Status | `type` | Condition |
|
|
|--------|--------|-----------|
|
|
| 400 | `invalid_request_error` | Missing `name`, malformed JSON |
|
|
| 404 | `unload_failed` | Name not loaded |
|
|
| 409 | `unload_failed` | Already unloading |
|
|
| 500 | `unload_failed` | Other failure |
|
|
|
|
Source: `model_admin.c:ma_handle_unload`.
|
|
|
|
---
|
|
|
|
## GET /admin/models
|
|
|
|
Operator-only. Every **live instance**, one row each. Unlike `/v1/models` these
|
|
are **not** deduplicated by name: two instances of one model on different dies
|
|
are two rows, because to an operator they are two different things.
|
|
|
|
The row is the same one `POST /admin/models/load` returns — this route exists so
|
|
its operator-only fields can be read at any time rather than only as a side
|
|
effect of loading a model.
|
|
|
|
### Response — 200
|
|
|
|
```json
|
|
{
|
|
"object": "list",
|
|
"data": [
|
|
{
|
|
"id": "my-model",
|
|
"object": "model",
|
|
"path": "/models/my-model.gguf",
|
|
"dies": [2, 3],
|
|
"vram_bytes": 12345678,
|
|
"max_sessions": 4,
|
|
"max_concurrent": 4,
|
|
"max_concurrent_configured": 4,
|
|
"context_window": 32000,
|
|
"context_window_configured": 32000,
|
|
"graphs": { "live": 30, "total": 40, "failed": 0, "gave_up": 0,
|
|
"ineligible": 10, "pending": 0,
|
|
"captures": 30, "capture_failures": 0,
|
|
"batched_attn": {"live": 10, "total": 10, "failed": 0,
|
|
"ineligible": 0, "pending": 0},
|
|
"batched_ssm": {"live": 20, "total": 30, "failed": 0,
|
|
"ineligible": 10, "pending": 0} },
|
|
"attention": { "ondie": 8, "host": 2, "total": 10,
|
|
"gave_up": 2, "impossible": 0,
|
|
"host_engagements": 2, "ring_alloc_failures": 10,
|
|
"reasons": {"ring_alloc_failed": 2} }
|
|
}
|
|
]
|
|
}
|
|
```
|
|
|
|
The operator row carries **both** concurrency numbers: `max_concurrent` is what
|
|
the model serves right now (`bs_get_load` capacity, the same value `/v1/models`
|
|
advertises) and `max_concurrent_configured` is what `--max-concurrent` asked for.
|
|
They differ when there is no batch scheduler at all (no CUDA compiled in,
|
|
`QWEN35_BATCH_SLOTS` off, `bs_create` failed) or when the memory-pressure ladder
|
|
clamped the slot count — the same split, for the same reason, as
|
|
`context_window` vs `context_window_configured`.
|
|
|
|
The operator `graphs` object extends the `/v1/models` one with `gave_up`, the
|
|
lifetime `captures` / `capture_failures` counters, and a per-class split:
|
|
`batched_attn` and `batched_ssm` (the multi-user decode path), `single_ssm` (the
|
|
one-user per-layer path) and `segment` (the per-die full-step graph). A class
|
|
the model has never exercised is omitted rather than reported as an empty
|
|
deficit. Field meanings: see `graphs` under `GET /v1/models`.
|
|
|
|
The operator `attention` object adds `gave_up` / `impossible`, the lifetime
|
|
`host_engagements` / `ring_alloc_failures` counters, and `reasons` — a
|
|
histogram, present only when `host > 0`, naming why each layer is off-die
|
|
(`ring_alloc_failed`, `dec_scratch_alloc_failed`, `kv_geometry_or_ring_wrap`,
|
|
`no_slot_decode_kernel`, `no_flash_kernels`, `no_batch_scratch`,
|
|
`flash_slots_off`). Field meanings: see `attention` under `GET /v1/models`.
|
|
|
|
No error cases (returns an empty `data` array when nothing is loaded).
|
|
|
|
Source: `model_admin.c:ma_handle_models`.
|
|
|
|
---
|
|
|
|
## GET /admin/dies
|
|
|
|
Operator-only. Snapshot of all probed accelerator dies, their VRAM budget/used,
|
|
and which model occupies them.
|
|
|
|
### Response — 200
|
|
|
|
```json
|
|
{
|
|
"object": "list",
|
|
"n_dies": 8,
|
|
"data": [
|
|
{
|
|
"die": 0,
|
|
"card": 0,
|
|
"model": "my-model",
|
|
"n_models": 1,
|
|
"vram_budget": 12884901888,
|
|
"vram_used": 11674869760
|
|
}
|
|
]
|
|
}
|
|
```
|
|
|
|
| Field | Type | Notes |
|
|
|-------|------|-------|
|
|
| `die` | int | Die manager ordinal (0-indexed across all probed dies) |
|
|
| `card` | int | Physical card index (`pn_dm_card_of(die)`) |
|
|
| `model` | string | Name of the model occupying this die, or empty string |
|
|
| `n_models` | int | Number of models on this die (typically 0 or 1) |
|
|
| `vram_budget` | int (bytes) | Die's total VRAM minus `PN_DIE_RESERVE_BYTES` |
|
|
| `vram_used` | int (bytes) | Bytes allocated by the model manager |
|
|
|
|
Source: `model_admin.c:ma_handle_dies`.
|
|
|
|
---
|
|
|
|
## Session routes
|
|
|
|
A session is a live prefix-cache / decode slot keyed by `session_id`. In each
|
|
route below the session id comes from the body's `session_id`, else the
|
|
`X-Session-Id` header (export reads the body only). `name` selects the model;
|
|
absent → the default model. Unknown model → `404 model_not_found`.
|
|
|
|
### POST /admin/session/export
|
|
|
|
Operator. Serialize a live session's state so it can be restored later.
|
|
|
|
Request: `{"name": "my-model", "session_id": "abc123"}` (`session_id` required).
|
|
|
|
Response — 200:
|
|
|
|
```json
|
|
{"session_id":"abc123","weights_version":123,"n_tok":4096,
|
|
"blob_bytes":31457280,"blob_b64":"<base64>","tokens":[...]}
|
|
```
|
|
|
|
`tokens` is the committed history the blob covers; it must be sent back on
|
|
restore.
|
|
|
|
| Status | `type` | Condition |
|
|
|--------|--------|-----------|
|
|
| 400 | `invalid_request_error` | Empty/malformed body, missing `session_id` |
|
|
| 400 | `session_export_unsupported` | The model's architecture has no session snapshot format |
|
|
| 400 | `session_caching_disabled` | The model has no session cache |
|
|
| 404 | `session_not_found` | No live session with that id |
|
|
| 429 | `session_busy` | A turn is in flight on that session; retry after it completes |
|
|
| 500 | `server_error` | Serialize / out-of-memory failure |
|
|
|
|
Source: `model_admin.c:ma_handle_session_export`.
|
|
|
|
### POST /session/restore
|
|
|
|
Install an exported session into a live slot.
|
|
|
|
Request:
|
|
|
|
```json
|
|
{"name":"my-model","session_id":"abc123","blob":"<blob_b64 from export>","tokens":[...]}
|
|
```
|
|
|
|
`session_id`, `blob` (base64) and `tokens` are required. The blob must come from
|
|
the same model architecture and the same weights.
|
|
|
|
Response — 200: `{"restored":true,"session_id":"abc123","n_tok":4096}`
|
|
|
|
| Status | `type` | Condition |
|
|
|--------|--------|-----------|
|
|
| 400 | `invalid_request_error` | Empty/malformed body, missing `session_id`/`blob`/`tokens` |
|
|
| 400 | `session_caching_disabled` | The model has no session cache |
|
|
| 400 | `bad_session_blob` | Not base64, corrupt, wrong architecture, or does not fit this model |
|
|
| 409 | `weights_version_mismatch` | Blob captured under different weights — replay the conversation instead |
|
|
| 429 | `model_at_session_capacity` | No free session slot |
|
|
| 500 | `server_error` | Restore failed |
|
|
|
|
Source: `openai_chat.c:oai_handle_session_restore`.
|
|
|
|
### POST /session/release
|
|
|
|
Free a session's slot now instead of waiting for the idle timeout (12 h default,
|
|
`PN_SESSION_IDLE_TTL_MS`).
|
|
|
|
Request: `{"name": "my-model", "session_id": "abc123"}`
|
|
|
|
Response — 200: `{"released":true,"session_id":"abc123"}`. Idempotent: an unknown
|
|
or already-freed session also returns 200.
|
|
|
|
Errors: `400 invalid_request_error` (empty/malformed body, no session id),
|
|
`404 model_not_found`.
|
|
|
|
Source: `openai_chat.c:oai_handle_session_release`.
|
|
|
|
### GET /session/estimate
|
|
|
|
`GET /session/estimate?tokens=N[&name=M][&blob_bytes=B][&upload_bps=R]`
|
|
|
|
Arithmetic estimate of restoring a session from a blob vs re-prefilling it.
|
|
`tokens` (> 0) is required. Without `blob_bytes`, the blob is estimated at
|
|
16 KiB per token; without `upload_bps` (bytes/s), 12.5 MB/s is assumed.
|
|
|
|
Response — 200:
|
|
|
|
```json
|
|
{"model":"my-model","tokens":4096,"prefill_tok_per_s":17.00,
|
|
"cold_reprefill_s":240.94,"warm_restore_s":5.391,"blob_bytes_est":67108864}
|
|
```
|
|
|
|
`prefill_tok_per_s` is the serve's measured prefill rate (a fixed fallback
|
|
before one is measured). An unknown `name` is not an error; `model` is then `""`.
|
|
|
|
Errors: `400 invalid_request_error` when `tokens` is missing or not positive.
|
|
|
|
Source: `openai_chat.c:oai_handle_session_estimate`.
|
|
|
|
### GET /session/watch
|
|
|
|
`GET /session/watch?session_id=S[&name=M]` (or `X-Session-Id`).
|
|
|
|
Liveness hold. On success the server answers `200` with
|
|
`Content-Type: text/event-stream`, a `: watch established` comment, and keeps the
|
|
connection open. While it is held the session is never reaped for idleness;
|
|
when it closes, the session is released after a grace period (45 s default,
|
|
`PN_SESSION_WATCH_GRACE_MS`) unless a new watch for it arrives first.
|
|
|
|
| Status | `type` | Condition |
|
|
|--------|--------|-----------|
|
|
| 400 | `invalid_request_error` | No session id |
|
|
| 400 | `session_caching_disabled` | The model has no session cache |
|
|
| 404 | `model_not_found` | Unknown model |
|
|
| 503 | `watch_unavailable` | Watch manager not running, or its table is full |
|
|
|
|
Source: `serve_command.c:handle_session_watch`, `session_watch.c`.
|
|
|
|
---
|
|
|
|
## Error body format
|
|
|
|
All error responses use:
|
|
|
|
```json
|
|
{"error":{"message":"<text>","type":"<code>","code":null}}
|
|
```
|
|
|
|
The `code` field is always `null`.
|
|
|
|
---
|
|
|
|
## Concurrency and connection model
|
|
|
|
- One detached pthread per accepted TCP connection. `accept()` never blocks
|
|
behind a generation.
|
|
- On `pthread_create` failure the connection is handled inline (no request
|
|
is dropped).
|
|
- SIGPIPE is ignored so a client disconnect mid-stream cannot kill the server.
|
|
- Generation serialization:
|
|
- **Legacy path** (`sched == NULL`): `gen_lock` trylock per model. Second
|
|
concurrent request to the same model → immediate 429.
|
|
- **Batched path** (`QWEN35_BATCH_SLOTS=1`): the C3 scheduler owns
|
|
concurrency. Up to 8 concurrent users; overflow waits in a per-model FIFO
|
|
queue (default depth 32, `BS_QUEUE_DEPTH`). 30-second queue wait timeout.
|