# API reference
Base URL (dev): `http://localhost:8000`. All responses are JSON. CORS is
restricted to the local dev origin (`http://localhost:3000` — configurable via
`TOKENPRINT_FRONTEND_PORT`) and the deployed frontend origins
(`https://sudharsanselvaraj.github.io`, `https://tokenprint.in`, and
subdomains).
---
## `GET /health`
Liveness + whether the model is loaded.
```json
{ "status": "ok", "model_loaded": true }
```
## `GET /model-info`
```json
{
"model": "Qwen/Qwen2.5-0.5B-Instruct",
"device": "mps",
"num_layers": 24,
"num_heads": 14,
"hidden_size": 896,
"attn_implementation": "eager",
"max_tokens": 40,
"ready": true
}
```
## `GET /architecture`
Real architecture metadata + tensor list from the loaded model
(`named_parameters()` + `config`). No forward pass.
```jsonc
{
"source": "model",
"model": "Qwen/Qwen2.5-0.5B-Instruct",
"device": "mps",
"metadata": {
"architecture": "qwen2",
"total_params": 494032768,
"num_layers": 24, "hidden_size": 896,
"num_heads": 14, "num_kv_heads": 2, "head_dim": 64,
"ffn_size": 4864, "vocab_size": 151936,
"context_length": 32768, "rope_theta": 1000000.0,
"tie_word_embeddings": true, "torch_dtype": "float32"
},
"tensor_count": 290,
"tensors": [
{ "name": "model.embed_tokens.weight", "shape": [151936, 896],
"dtype": "float32", "n_params": 136134656 }
// …
]
}
```
The GGUF drag-and-drop path (client-side) produces the **same shape** with
`"source": "gguf"` and extra metadata (`quantization`, `gguf_version`,
`file_size`, `expert_count`).
## `POST /analyze`
Body: `{ "sentence": "The cat sat on the mat." }` (capped ~40 tokens; returns
`400` if longer).
```jsonc
{
"sentence": "The cat sat on the mat.",
"model": "Qwen/Qwen2.5-0.5B-Instruct", "device": "mps",
"num_layers": 24, "num_heads": 14, "hidden_size": 896,
"tokens": [ { "index": 0, "text": "The", "piece": "The", "id": 785, "is_special": false } ],
// real softmax attention, rounded to 3 dp, sub-0.01 zeroed:
"attention": [ /* [layer][head][from][to] */ ],
// PCA projections of real hidden states:
"embeddings_3d": [ [x, y, z] ],
"hidden_states_3d": { "0": [[x,y,z]], "1": [[x,y,z]] /* … */ },
"embedding_norms": [ 0.53 ],
"projection": { "method": "PCA", "note": "3D projection; distances approximate",
"embedding_explained_variance": [0.36, 0.24, 0.18] }
}
```
## `POST /rag/analyze`
RAG attribution reduction on top of the existing real attention tensor from
`ModelEngine.analyze()`. The backend composes:
```
Context:
...
...
...
```
and maps chunk/query text spans to token spans using tokenizer offset mappings.
**Body:**
```json
{
"query": "What year was the company founded?",
"chunks": [
{ "id": "0", "text": "The company was founded in 2018 in Chennai." },
{ "id": "1", "text": "Its first product launched in 2020." }
],
"reduction_mode": "both",
"ungrounded_threshold": 0.1
}
```
**Response additions (on top of `/analyze`):**
```jsonc
{
"query": "What year was the company founded?",
"chunk_spans": { "0": [12, 20], "1": [24, 31] },
"query_span": [36, 45],
"attribution_chunk_ids": ["0", "1"],
"attribution": [ [0.42, 0.08], [0.45, 0.07] ],
"attribution_all_layers_mean": [ [0.42, 0.08], [0.45, 0.07] ],
"attribution_last_layer": [ [0.51, 0.04], [0.48, 0.05] ],
"query_self_attribution": [0.31, 0.29],
"ungrounded": [false, false]
}
```
`attribution` shape is `[query_token][chunk_id]`. Values are attention mass from
each query-token row onto each chunk span.
> **Note**
> Attention mass is a correlate of influence, not a proof of causal influence.
> TokenPrint reports it as an attribution signal, not a causality claim.
> **Tip**
> Set `reduction_mode: "both"` so the UI can switch between all-layer and
> last-layer views without recomputing.
## `WS /ws/generate`
A real streamed greedy generation.
**Client → server (first message):**
```json
{ "prompt": "Name one primary color.", "max_new_tokens": 40, "top_k": 10, "trace": true }
```
**Server → client frames:**
```jsonc
// 1) meta (once). With trace:true it also carries the op catalog.
{ "type": "meta", "model": "…", "architecture": "qwen2",
"num_layers": 24, "num_layer_stats": 25, "prompt_tokens": ["…"],
"prompt_len": 36, "max_new_tokens": 40, "top_k": 10, "decoding": "greedy",
"uses_kv_cache": true, // the decode loop threads real past_key_values
"op_catalog": [
{ "index": 0, "op_key": "embedding", "label": "Token Embedding", "layer": null,
"param_count": 136134656, "cumulative_params": 136134656,
"in_dim": 896, "out_dim": 151936, "bias_dim": null,
"weight_preview": [[/* up to 8×8 real weights */]] }
// … 219 ops total for this model
] }
// 2) one per generated token
{ "type": "token", "step": 0,
"chosen": { "id": 6893, "text": "Red", "logprob": -0.04 },
"topk": [ { "id": 6893, "text": "Red", "logit": 18.4, "prob": 0.957 } ],
"layer_stats": [ /* 25 mean |activation| values */ ],
"eos": false,
// real KV-cache accounting for this step:
"phase": "prefill", // "prefill" (step 0) | "decode" (later steps)
"n_positions": 39, // tokens actually computed this step (prompt_len, then 1)
"cache_len": 0 } // cached positions reused this step (0, then prompt_len + n)
// 3) done
{ "type": "done", "generated_text": "Red", "total_steps": 2 }
```
Payloads are bounded on purpose: the op catalog is sent **once** and token frames
reference it by index; `topk` is 8–12 entries (not the full vocab); weight
previews are ≤ 8×8 slices.
### KV-cache phase (real, not decorative)
The decode loop runs `model(..., use_cache=True)` and threads `past_key_values`
step to step, so the **pre-fill vs decode** distinction is mechanically real and
reported per frame:
- **step 0 — `prefill`**: processes the whole prompt at once (`n_positions =
prompt_len`, `cache_len = 0`), building the cache.
- **steps ≥ 1 — `decode`**: process a single new token (`n_positions = 1`) and
reuse the cached prefix (`cache_len = prompt_len + step − 1`).
The frontend only shows this distinction when `meta.uses_kv_cache` is true —
never faked.
---
## `GET /trace` · `POST /trace/replay`
Record & replay. When a generation request sets `record_trace: true`, the full
WebSocket stream is tee'd into an in-memory trace (prompt, model metadata, every
token/layer frame in order, timings). `GET /trace` returns the last recorded run
as a downloadable `.tokenprint.json`; `POST /trace/replay` accepts an uploaded
trace and replays it back to the caller.
The schema is **versioned from day one** (`trace_version: 1`, see
`backend/app/trace.py`) — replay code will outlive the current stream shape, and
`parse_trace()` rejects an unknown version rather than silently mis-reading it.
A trace is a real forward pass frozen to disk: replaying one is not a simulation,
it is the same numbers the model produced, re-emitted with their original pacing.
This is what lets the static demo show genuine data with no GPU and no backend.
## `GET /debug/ops` · `POST /debug/analyze`
Stepped inspection. `/debug/ops` returns the real op catalog (index, label,
`op_key`, layer, `in_dim`/`out_dim`, `param_count`) so the frontend can set
breakpoints against true op indices. `/debug/analyze` runs a prompt and returns
the per-layer state needed to inspect a paused position.
## `POST /ablate/analyze`
Interventions. Accepts a sentence plus the heads and/or layers to zero, installs
forward hooks that null those components' contribution, and re-runs a **real**
forward pass. The response has the same shape as `/analyze`, so the frontend can
diff the two logit-lens tables position by position and report exactly how many
predictions changed.
This works on **raw architectural components** — any head, any block, on whatever
model is loaded — with no sparse autoencoder, transcoder, or other pre-trained
artifact required. That is the deliberate difference from feature-level
intervention tools.
---
## GGUF engine (`/gguf/*`)
The backend can host a resident `.gguf` execution engine (see
[`backend/requirements-gguf.txt`](../backend/requirements-gguf.txt)). Models are
held in an **LRU cache with explicit memory reclamation** so repeated loads are
fast without leaking memory.
- `GET /gguf/list` — available GGUF files in the watched directory.
- `POST /gguf/upload` — upload a `.gguf` file for the engine.
- `POST /gguf/open` — load (or return from the LRU cache) a resident model.
- `POST /gguf/unload` — **explicitly unload** a resident engine and release its
memory. Body: `{ "path": "/abs/path/to/model.gguf" }`. Returns
`{ "ok": true, "name": ..., "unloaded": true|false }`.
- `GET /gguf/cache` — resident cache statistics, limits, and eviction counters
(from the LRU state).
See [GGUF format](gguf-format.md) for what the *client-side* parser reads and
the capability boundary between the two paths.