llmp 0.1.0

Measures how well a local LLM setup uses its model: a recording proxy and a prompt-reuse analysis
Documentation
# llmp formats

llmp has two file formats: the **recording** (what `llmp proxy` writes)
and the **report** (what `llmp analyze --json` prints). Both are
versioned, and they are the contract: a tool in any language can produce
or consume them without linking the crate.

Rules that hold everywhere:

- **`null` means "not measured".** It never means zero. A field that
  could be absent is always written, as `null`.
- **Numbers keep their source.** Any count or duration that could come
  from more than one place is a *measured value* (below).
- **Unknown fields are ignored** by readers of the same major version.
  A change that removes or re-types a field bumps `v`. This format
  version is separate from the crate's version: it changes only when the
  files change.

## Measured values

```json
{ "value": 1234, "source": "server" }
```

| `source` | Meaning |
|---|---|
| `server` | Reported by the model server in its response. |
| `tokenizer` | Counted by the server's tokenize endpoint. |
| `proxy` | Measured by the proxy's own clock. |
| `estimate` | Characters ÷ 4, rounded up, at least 1 for non-empty text. |

The sources are listed from strongest to weakest. A total built from
values with different sources carries the weakest one.

## Hashes

Hashes are FNV-1a 64 over UTF-8 bytes, written as 16 lowercase hex
digits. They are stable across platforms and llmp versions.

When a JSON value is hashed, it is first written as **canonical JSON**:
no whitespace, object keys sorted by their UTF-8 bytes at every depth,
strings escaped as `serde_json` does, numbers as they were parsed.

## Recording (format version 1)

JSON Lines, UTF-8, one record per line, `\n` after every line. A record
is written when its exchange ends, so lines can be out of `id` order:
**readers sort by `id`**. Blank lines are skipped. A line that doesn't
parse, or has a `v` other than `1`, is reported as a bad line and the
rest of the file is still read.

```json
{
  "v": 1,
  "id": 17,
  "conn": 4,
  "t_start_ms": 1788595135123,
  "t_first_byte_ms": 1788595136548,
  "t_end_ms": 1788595141002,
  "request": {
    "method": "POST",
    "path": "/v1/chat/completions",
    "headers": { "content-type": "application/json", "user-agent": "…" },
    "body_bytes": 214512,
    "body": { "model": "…", "messages": [] },
    "body_text": null,
    "digest": null
  },
  "response": {
    "status": 200,
    "headers": { "content-type": "text/event-stream" },
    "stream": "sse",
    "chunks": 412,
    "bad_chunks": 0,
    "body_bytes": 90211,
    "truncated": false,
    "body": { "choices": [], "usage": {}, "timings": {} },
    "body_text": null,
    "digest": null
  },
  "error": null,
  "tokens": null
}
```

### Record

| Field | Type | Rule |
|---|---|---|
| `v` | integer | Always `1`. |
| `id` | integer | Starts at 1 and increases per proxy process, in arrival order. |
| `conn` | integer | Id of the client's TCP connection, per proxy process. |
| `t_start_ms` | integer | Unix milliseconds when the request arrived. |
| `t_first_byte_ms` | integer or `null` | When the first response body byte arrived from upstream. `null` if none did. |
| `t_end_ms` | integer | When the exchange ended. |
| `request` | object | Always set. |
| `response` | object or `null` | `null` when upstream never sent a response head. |
| `error` | string or `null` | `null`, or one of `upstream_unreachable`, `upstream_reset`, `client_disconnected`, `timeout`, `body_too_large`. With an error, `response` may be missing or partly filled. |
| `tokens` | object or `null` | Token counts added after the capture by `llmp analyze --tokenize` (see below). `null` in a fresh recording. |

`error` values:

| Value | Meaning |
|---|---|
| `upstream_unreachable` | The proxy could not connect to the server. `response` is `null`. |
| `timeout` | Connecting to the server took more than 10 s. `response` is `null`. |
| `upstream_reset` | The server closed the connection or failed before the response ended. `response` is `null` if no response head arrived, otherwise it holds what arrived. |
| `client_disconnected` | The client went away before it had the whole response. |
| `body_too_large` | The request body was larger than the recording limit. It was forwarded unchanged, but not stored: `body`, `body_text` and `digest` are `null`. |

### Request

| Field | Type | Rule |
|---|---|---|
| `method` | string | HTTP method. |
| `path` | string | Path and query as sent by the client. |
| `headers` | object | Only `user-agent`, `content-type`, `content-length`, `transfer-encoding`, lower-cased. Never auth headers. |
| `body_bytes` | integer | Size of the body as received. |
| `body` | any or `null` | The body, when the body mode is full and the body parses as JSON. |
| `body_text` | string or `null` | A non-JSON body as UTF-8 (invalid bytes replaced), in full mode. |
| `digest` | object or `null` | In digest mode (below). |

At most one of `body`, `body_text` and `digest` is set. All three are
`null` for an empty body, and in digest mode for a body the digest does
not cover (`body_bytes` still says how big it was).

### Response

| Field | Type | Rule |
|---|---|---|
| `status` | integer | HTTP status from upstream. |
| `headers` | object | Same rule as the request. |
| `stream` | `"sse"`, `"ndjson"` or `null` | How the body was streamed. |
| `chunks` | integer or `null` | Number of stream events. `null` when not streamed. |
| `bad_chunks` | integer or `null` | Stream events that didn't parse and were skipped when merging. `null` when not streamed. |
| `body_bytes` | integer | Size of the body as sent to the client. |
| `truncated` | boolean | `true` when the body was longer than the recording limit. The client still received all of it. |
| `body` / `body_text` / `digest` | | Same rules as the request. |

A streamed body is stored **merged into one object with the shape of the
non-streamed response**:

- **SSE** (`data: {…}` lines, ending with `data: [DONE]`):
  `delta.content`, `delta.reasoning_content` (or `delta.reasoning`) are
  concatenated, `delta.tool_calls` are merged by index, and the last
  `usage` and `timings` seen are kept.
- **NDJSON** (Ollama): `message.content` and `message.thinking` are
  concatenated, and the counters come from the final object
  (`done: true`).

### Digest mode

In digest mode the proxy keeps what the analysis needs and no message
text. Only chat requests (`/v1/chat/completions`, `/api/chat`) are
digested, and only 2xx responses to them. Every other body is dropped.

**Request digest:**

```json
{
  "model": "qwen3.6-35b",
  "params": { "max_tokens": 8, "response_format": false, "grammar": false, "stream": true },
  "messages": [
    { "role": "system", "hash": "9f2c…", "chars": 24011, "head": "a1b2…", "tail": "c3d4…",
      "tool_calls": [], "tool_call_id": null, "name": null }
  ],
  "tools": [ { "name": "read_file", "hash": "…", "chars": 812 } ],
  "tools_hash": "…"
}
```

| Field | Rule |
|---|---|
| `model` | The request's `model`, or `null`. |
| `params.max_tokens` | The first non-negative integer among `max_tokens`, `max_completion_tokens`, `n_predict`, `options.num_predict`; otherwise `null`. |
| `params.response_format` | `response_format` is set and its `type` is not `"text"`, or Ollama's `format` is set. |
| `params.grammar` | `grammar` or `json_schema` is set. |
| `params.stream` | The request's `stream`. When absent: `true` for `/api/chat`, `false` otherwise. |
| `messages` | One message digest per message, in order. |
| `tools` | One entry per tool definition: `name` (`function.name`, or `name`), `hash` and `chars` of its canonical JSON. |
| `tools_hash` | Hash of the canonical JSON of the whole `tools` array. `null` when there are no tools. |

"Set" means present, not `null`, and not an empty string.

**Message digest:**

| Field | Rule |
|---|---|
| `role` | The message role. |
| `hash` | Identity of the message: the hash of the canonical JSON array `[role, content, tool_calls, name, tool_call_id]`, with each missing value as `null`, and an empty `tool_calls` array counted as missing. |
| `chars` | Characters (Unicode scalar values) in the message **text**. |
| `head` / `tail` | Hash of the first / last 256 characters of the text (the whole text when shorter). |
| `tool_calls` | `[{ "id": …, "name": … }]` for each tool call. `id` is `null` when the server gives none. |
| `tool_call_id` | As in the message. |
| `name` | `name`, or Ollama's `tool_name`. |

The **text** of a message is its content (a string, or the `text` parts
of a content array joined without separator), followed by the canonical
JSON of its `tool_calls` when there are any.

In full mode, the analysis computes the same digest from the body, so a
full recording and a digest recording of the same traffic give the same
hashes.

**Response digest:**

```json
{
  "usage": { "usage": { "prompt_tokens": 1000 }, "timings": { "cache_n": 900 } },
  "output": { "content_chars": 311, "reasoning_chars": 2044, "tool_calls": [ { "id": "c1", "name": "read_file" } ] }
}
```

| Field | Rule |
|---|---|
| `usage` | Every top-level field of the (merged) response except `choices` and `message`, verbatim. This keeps the server's counters (`usage`, `timings`, Ollama's `prompt_eval_count` …) exactly as they came. |
| `output.content_chars` | Characters in the reply's content. |
| `output.reasoning_chars` | Characters in `reasoning_content`, `reasoning` or `thinking`, the first one present. |
| `output.tool_calls` | The reply's tool calls, as in a message digest. |

The reply is `choices[0].message`, or Ollama's `message`.

### Token counts

```json
"tokens": { "messages": { "9f2c…": 812 }, "tools": { "a0b1…": 230 } }
```

Counts from the server's tokenize endpoint, keyed by message hash and by
tool hash. They have source `tokenizer`. Only full recordings can be
enriched. A message is counted from its text (content plus the
canonical JSON of its tool calls), a tool from its canonical JSON,
without the chat template's own tokens.

## Report (format version 1)

```json
{
  "v": 1,
  "window": { "start_ms": 0, "end_ms": 0, "days": 3.0 },
  "coverage": {
    "records": 0, "analysed": 0,
    "skipped": { "not_chat": 0, "error": 0, "non_2xx": 0, "unparsed": 0 },
    "paths": { "/v1/models": 12 },
    "bad_lines": 0,
    "turns": 0, "conversations": 0, "turns_with_reuse": 0, "turns_with_timing": 0
  },
  "servers": [ { "api": "openai_chat", "fields": ["reused_tokens", "evaluated_tokens", "prefill_ms"] } ],
  "time": {
    "prefill_ms": { "value": 0, "source": "server" },
    "lost_ms": { "value": 0, "source": "server" },
    "lost_tokens": 0,
    "by_cause": { "side_call": { "tokens": 0, "ms": { "value": 0, "source": "server" }, "rows": 0 } }
  },
  "composition": { "system": 0, "tools": 0, "history": 0, "new_content": 0 },
  "clients": [ { "client": "", "user_agent": "", "requests": 0, "conversations": 0, "tools": [] } ],
  "tools": [ { "client": "", "name": "", "schema_tokens": 0, "offered": 0, "called": 0 } ],
  "findings": [],
  "rows": []
}
```

| Field | Rule |
|---|---|
| `window` | First and last request time, and the span in days. `null` when there are no records. |
| `coverage.records` | Records read. |
| `coverage.analysed` | Records that became rows. |
| `coverage.skipped` | Records that did not, by reason. |
| `coverage.paths` | Request count per path that is not a chat API. |
| `coverage.bad_lines` | Lines of the recording that could not be read. |
| `coverage.turns*` | Rows of kind `turn`, and how many of them had server reuse counters, and timing. |
| `coverage.conversations` | Conversations found (see Conversations and clients). |
| `servers` | Per API seen, the `Counts` fields that server actually reported in this recording. |
| `time.prefill_ms` | Total prefill time. `null` if nothing was timed. |
| `time.lost_ms` | Time lost to re-processing. `null` when no turn could be timed. |
| `time.lost_tokens` | Tokens re-processed that could have been reused. |
| `time.by_cause` | The loss by cause (see Loss causes). |
| `composition` | Tokens across all rows, by part of the prompt. |
| `clients` | Per client, in order of first request: its user-agent (`null` when none was sent), requests, conversations, and the tools it offered. |
| `tools` | Per client and tool offered: schema size, requests that offered it, calls. |
| `findings` | See Findings. |
| `rows` | The per-request table. Every other number in the report is computed from it. |

`api` is `openai_chat`, `ollama_chat` or `other`.

### Conversations and clients

Several agents, subagents or harnesses can share one proxy, so their
requests interleave. Each turn is linked (`linked_to`) to the earlier
turn of its own conversation: the one it continues, or failing that the
one it shares the longest start with, on the same model, with the same
user-agent, in the same session. A turn that shares only the system
prompt with an earlier one starts a new conversation
(`new_conversation: true`): the server could reuse that system prompt,
but nothing is charged to the client.

A **client** is labelled by its user-agent (`unknown` when none was
sent). When several tool sets that are not subsets of one another come
with the same user-agent (two harnesses using the same SDK), each gets
its own client, `<user-agent> #1`, `#2`…, in order of first request; a
subagent whose tools are a subset of its parent's stays with it. A
request without tools under such a shared user-agent keeps the plain
user-agent as its client, since nothing tells which harness sent it.

### Row

| Field | Type |
|---|---|
| `id` | integer: the record `id` |
| `api` | string |
| `model` | string |
| `client` | string: see Conversations and clients |
| `t_start_ms` | integer |
| `kind` | `turn`, `side_call` or `stateless` |
| `side_call_shape` | `summarise`, `structured`, `oneshot` or `null` |
| `session` | integer |
| `conversation` | integer, from 1, or `null` for side-calls and stateless rows |
| `turn` | integer within the conversation, from 1, or `null` |
| `counts` | object: `prompt_tokens`, `completion_tokens`, `reused_tokens`, `evaluated_tokens`, `prefill_ms`, `decode_ms`, `load_ms`, each a measured value or `null` |
| `composition` | object: `system`, `tools`, `history`, `new_content`, each a measured value |
| `linked_to` | row id of the earlier turn this one was compared with, or `null` |
| `new_conversation` | boolean: linked only to share a system prompt |
| `prefix_tokens` | integer or `null` |
| `break_cause` | `tools_changed`, `system_changed`, `history_rewritten` or `null` |
| `compaction` | boolean |
| `predicted_reuse` / `observed_reuse` | number (0–1) or `null` |
| `cause` | `structural`, `side_call`, `other_conversation`, `other_model`, `server_reset`, `undetermined` or `null` |
| `lost_client` / `lost_external` | `{ "cause", "tokens", "ms" }` or `null` |
| `side_calls_between` / `other_conversations_between` / `other_domains_between` | arrays of row ids: same-model side-calls, same-model turns of other conversations, and other models' requests that ran since `linked_to` |
| `loss_measured` | boolean: every counter the loss needs was known. When `false`, a `null` loss means "not measured"; when `true`, it means nothing was lost. |

### Loss causes

`lost_client.cause` is a `break_cause`. `lost_external.cause` is
`side_call`, `other_conversation`, `other_model`, or, for server-side losses, one of
`server_empty`, `server_head_only`, `server_rollback`. The same strings
are the keys of `time.by_cause`.

### Findings

```json
{
  "kind": "prefix_break",
  "model": "qwen",
  "client": "aider/0.86",
  "rows": [3, 5, 9],
  "lost_tokens": 12000,
  "lost_ms": { "value": 8100.0, "source": "server" },
  "context_tokens": null,
  "confidence": "high",
  "evidence": { "break_cause": "system_changed" }
}
```

| `kind` | `evidence` |
|---|---|
| `side_call_on_conversation_model` | `shapes` (count per shape), `side_calls`, `turns_after_side_calls`, `clients` |
| `prefix_break` | `break_cause`, `links`, `broken`, `share` |
| `server_cache_loss` | `kind`: `server_empty`, `server_head_only` or `server_rollback` |
| `other_conversation_eviction` | `other_conversations` (count), `clients` involved |
| `other_model_eviction` | `other_models` |
| `unused_tool` | `tool`, `offered`, `days`, `sessions` |
| `large_tool_schema` | `tool`, `schema_tokens`, `threshold` |
| `large_system_prompt` | `system_tokens`, `threshold` |

A finding sized by a loss also has `untimed_rows` when some of its rows
could not be timed. `lost_tokens` is `null` when none of its rows had a
measured loss. `prefix_break`, `large_system_prompt` and the tool
findings are about one client and name it in `client`; the others are
about the server and have `client: null`. The tool findings have
`model: null` and `rows: []`.

`confidence` is `high`, `medium` or `low`. Findings are sorted by
confidence, then `lost_ms`, then `context_tokens`. They are data: the
host decides how to word them.