opencode-usage-insights 0.0.0-stage โ†’ 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Hugo Batista
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/README.md CHANGED
@@ -1,3 +1,320 @@
1
- # Temporary Holding Version
1
+ # opencode-usage-insights ๐Ÿ“Š
2
2
 
3
- This version is a temporary placeholder for this package. An operational version to replace this has been submitted for review and is awaiting a staged release.
3
+ [![GitHub Tag](https://img.shields.io/github/v/tag/hugobatista/opencode-usage-insights?logo=github&label=latest)](https://github.com/hugobatista/opencode-usage-insights/releases)
4
+ [![Test](https://img.shields.io/github/actions/workflow/status/hugobatista/opencode-usage-insights/test.yml?label=Test)](https://github.com/hugobatista/opencode-usage-insights/actions/workflows/test.yml)
5
+ [![Lint](https://img.shields.io/github/actions/workflow/status/hugobatista/opencode-usage-insights/lint.yml?label=Lint)](https://github.com/hugobatista/opencode-usage-insights/actions/workflows/lint.yml)
6
+ [![npm](https://img.shields.io/npm/v/opencode-usage-insights.svg)](https://www.npmjs.com/package/opencode-usage-insights)
7
+
8
+ OpenCode plugin for one question the host cannot answer: **which endpoint is
9
+ actually serving each model** โ€” and what that costs. It shows a telemetry line
10
+ above the composer after every finished turn and records every assistant step
11
+ in a local SQLite file, read back through an interactive `/usage` panel.
12
+
13
+ ![The telemetry line after a turn](docs/images/line.png)
14
+
15
+ Everything stays local: one SQLite file under the OpenCode state directory,
16
+ written by the server-side recorder and read by the TUI.
17
+
18
+ ## What it does
19
+
20
+ - **The line** (`session.composer.top`): end time, the gap since the previous
21
+ prompt, the endpoint that served the turn, turn and session token totals, and
22
+ warnings when the prompt cache or the endpoint identity changed. It belongs to
23
+ finished work, so it stays hidden while a turn is running.
24
+ - **The `/usage` panel**: ten keyboard-driven views โ€” `overview`, `models`,
25
+ `endpoints`, `affinity`, `cache`, `periods`, `latency`, `sessions`, `turns`,
26
+ `db` โ€” over five ranges (`session`, `today`, `7d`, `30d`, `all`), with
27
+ drill-downs, sorting, filters and state remembered between openings.
28
+ `/usage models 7d`, `/usage affinity`, `/usage endpoints 30d`.
29
+ - **The session sidebar**: the session's own totals and its last turns, in the
30
+ same buckets as the line. Three lines per turn โ€” when and through what, the
31
+ prompt volumes, then `hit ยท tok/s ยท cost` โ€” and the session's average
32
+ throughput on its totals:
33
+ `16:54 orcarouter-dsv4.1flash` / `in 1.7k (427.3k) ยท out 1.06k` /
34
+ `hit 99.7% ยท 1.2k/s ยท $0.0006`.
35
+ - **The record** (server entry): every assistant step with tokens, cost,
36
+ timings, finish reason, provider/model, session and parent ids, and the
37
+ endpoint that served it; prompts, session lifecycle and compaction events too.
38
+ - **Portable data** (from a checkout): `bun run report --ndjson` exports the
39
+ whole database, and `--import` merges it back on another machine.
40
+ - **Observational by design**: the plugin never rewrites prompts, requests or
41
+ responses, and a failure inside the recorder is swallowed โ€” telemetry can
42
+ never interrupt a session.
43
+
44
+ ## Requirements
45
+
46
+ - **OpenCode V2** (`>=2.0.0`). The V2 release changed the plugin API (slots,
47
+ panels, event stream).
48
+ - [Bun](https://bun.sh) to install dependencies (development only).
49
+
50
+ | | |
51
+ | --- | --- |
52
+ | **OS** | Linux, macOS and Windows. The database lives in the per-user state directory of each (table below). CI runs the data layer โ€” paths, database, recorder, queries, panel assembly, dump round-trip, file permissions โ€” on all three, and the whole suite (including the TUI render tests) on Linux. `bun run check:platform` runs that same check by hand. |
53
+ | **Runtime** | Bun, the runtime OpenCode ships with. SQLite comes from `bun:sqlite`: there is no native module to build and nothing to compile on install. |
54
+
55
+ ## Install
56
+
57
+ > [!IMPORTANT]
58
+ > **Not on the npm registry yet.** Until the first release is published,
59
+ > `opencode plugin add` and the package-name form below cannot resolve it โ€” use
60
+ > the local checkout, the last form of this section.
61
+
62
+ ```sh
63
+ opencode plugin add opencode-usage-insights
64
+ ```
65
+
66
+ Or add the package to `~/.config/opencode/opencode.jsonc` (server entry) and
67
+ `~/.config/opencode/cli.json` (TUI entry):
68
+
69
+ ```jsonc
70
+ // ~/.config/opencode/opencode.jsonc
71
+ {
72
+ "$schema": "https://opencode.ai/config.json",
73
+ "plugins": ["opencode-usage-insights"]
74
+ }
75
+ ```
76
+
77
+ ```jsonc
78
+ // ~/.config/opencode/cli.json
79
+ {
80
+ "$schema": "https://opencode.ai/v2/cli.json",
81
+ "plugins": ["opencode-usage-insights"]
82
+ }
83
+ ```
84
+
85
+ To pass [options](#configuration), use the object form:
86
+
87
+ ```jsonc
88
+ {
89
+ "plugins": [
90
+ { "package": "opencode-usage-insights", "options": { "identity": "endpoint" } }
91
+ ]
92
+ }
93
+ ```
94
+
95
+ Or register a local checkout. V2 resolves a local *directory* plugin through its
96
+ entry files, so pointing at `src/` loads the server recorder (`src/index.ts`)
97
+ and the TUI contributions (`src/tui.tsx`) from source:
98
+
99
+ ```jsonc
100
+ // ~/.config/opencode/opencode.jsonc
101
+ { "plugins": [{ "package": "/home/you/code/projects/opencode-usage-insights/src" }] }
102
+ ```
103
+
104
+ ```jsonc
105
+ // ~/.config/opencode/cli.json
106
+ { "plugins": [{ "package": "/home/you/code/projects/opencode-usage-insights/src" }] }
107
+ ```
108
+
109
+ Configuration changes reload on their own; after changing the plugin's source,
110
+ restart the background service with `opencode service restart`.
111
+
112
+ Send a prompt to verify: the telemetry line appears above the composer when the
113
+ turn finishes, and `/usage` (alias `/insights`) opens the panel.
114
+
115
+ ## The line
116
+
117
+ ```text
118
+ 14:32:07 ยท ฮ” 3m12s ยท orcarouter-dsv4.1-flash-free ยท in 85.4k (1.62M), cached 1.53M, out 3.7k ยท sess in 2.1M (14.5M), cached 12.4M, out 88k ยท โš  cache expired (โˆ’11.7k)
119
+ ```
120
+
121
+ | Segment | Meaning |
122
+ | --- | --- |
123
+ | `14:32:07` | When the response ended (`completed`, else `streamed`, else `created`). |
124
+ | `ฮ” 3m12s` | Time since the previous prompt of the same session. |
125
+ | `orcarouter-dsv4.1-flash-free` | The endpoint that served the turn (`identity: "endpoint"`, the default): the gateway names it in a response header, or the request host names the operator. Without one the segment falls back to `providerID/modelID` (variant as a trailing word); `identity: "model"` always shows that form. Yellow with `changed` when it differs from the previous prompt. |
126
+ | `in 85.4k (1.62M), cached 1.53M, out 3.7k` | `in` is the uncached part of the prompt (fresh input + cache writes), the parenthesised value the whole prompt โ€” shown only when the two differ โ€” `cached` its cached part, and `out` everything generated (visible output + reasoning). With several steps the segment reads `3 steps ยท โ€ฆ` and reports the step the turn ended with (`latest`); `turnTotals: "sum"` adds every step instead. |
127
+ | `sess in 2.1M (14.5M), cached 12.4M, out 88k` | The session totals reported by the host, in the same buckets. |
128
+ | `โš  cache expired (โˆ’11.7k)` | Yellow. The prompt cache read fell by that many tokens against a comparable previous step; `ร—N` when several steps in the turn lost cache. |
129
+
130
+ The turn segment carries no label by default โ€” the numbers are the turn's, and
131
+ `sess` marks the session's; `labels.turn` (e.g. `"turn"`) brings a prefix back.
132
+
133
+ A turn is every consecutive assistant step after a prompt. Compaction, shell,
134
+ `idle` and info messages do not split a turn; `user`, `synthetic` and `system`
135
+ messages do. From a checkout, `bun run demo` prints one example per case,
136
+ including the streaming one and both turn-total views.
137
+
138
+ ## The panel
139
+
140
+ `/usage [view] [range]` opens the panel (`/usage` alone opens the last view, or
141
+ `overview` over `all` the first time). While the panel is open it owns input, so
142
+ the prompt cannot take the command again: `escape` closes it, `f` toggles full
143
+ screen, and the palette entry (`ctrl+p` โ†’ "Usage insights") toggles it closed.
144
+ With the composer focused, `/usage` with no arguments also toggles it closed,
145
+ and any argument โ€” including an unknown token, which is ignored โ€” keeps it open.
146
+ There is no default key binding โ€” the command is palette-only, so it never
147
+ shadows a key you use.
148
+
149
+ ![The /usage endpoints view](docs/images/panel-endpoints.png)
150
+
151
+ The view bar at the top names every view and brackets the active one; `?` opens
152
+ a help overlay that repeats the keys and defines every number.
153
+
154
+ ### Keys
155
+
156
+ | Key | Does |
157
+ | --- | --- |
158
+ | `โ†‘` `โ†“` / `j` `k` | Move the cursor; the row under it is highlighted. |
159
+ | `pageup` `pagedown` / `home` `end` | Move by ten rows / to the first or last row. |
160
+ | `enter` | Drill into the row: a model โ†’ the endpoints that served it and its affinity; an endpoint โ†’ the models it served and its latency; a session โ†’ its turns; a period โ†’ the models of the period. In `db`, prune the steps recorded before the selected retention's cutoff date (asks first). |
161
+ | `backspace` | Go back from a drill-down. |
162
+ | `0`โ€“`9` / `[` `]` | Pick a view (`0` is `db`) / cycle views. |
163
+ | `t` / `shift+t` | Next / previous range. |
164
+ | `s` / `shift+s` | Cycle the sort column / reverse it. |
165
+ | `b` | `periods` bucket: day โ†’ week โ†’ month. |
166
+ | `a` | `turns` scope: this session โ†” every session. |
167
+ | `m` `e` | Filter by model or by **endpoint** (a picker of the values actually observed). |
168
+ | `x` | Clear the filters. |
169
+ | `r` | Re-read the database. |
170
+ | `v` | `db`: compact the file, giving the space of deleted rows back. |
171
+ | `?` | Help overlay. |
172
+ | `f` / `escape` | Full screen / close. |
173
+
174
+ The view, range, sort, bucket, scope and filters are stored through the host's
175
+ durable storage, so the panel reopens where it was left.
176
+
177
+ ### Views
178
+
179
+ | View | Answers |
180
+ | --- | --- |
181
+ | `overview` | Steps, sessions, prompts, `in`/`cached`/`write`/`out`, `hit`, cost, an activity sparkline, a `this session` block and a **data coverage** block (how many steps lack a model, an endpoint or a completion โ€” a zero elsewhere is only trustworthy when this is complete). |
182
+ | `models` | Every model with its cache (`hit` and the per-step `hit p50`), tokens, cost, the **endpoints that actually served it** and how often its identity changed. `enter` opens the per-endpoint detail. |
183
+ | `endpoints` | **Who served the work**: one row per routing endpoint, with the models it served, steps, share, prompt, cached, `hit`, p50 throughput, wall time and cost. `enter` lists every model it served. A request whose endpoint was never observed is dimmed โ€” the panel does not invent an identity. |
184
+ | `affinity` | How often the step's identity changed against the step before it in the same session โ€” the model and the endpoint โ€” the cache those changes cost, and the most frequent transitions. |
185
+ | `cache` | Reuse by model, losses grouped by reason (`expired`, `compaction`, `unknown`), **cache survival against idle time**, reuse kept per endpoint, the observed TTL bounds (`hits up to` / `drops from`) and the most recent losses. |
186
+ | `periods` | Day, week or month totals โ€” steps, sessions, `in`, `cached`, `write`, `hit`, `out`, cost โ€” with a sparkline per metric and a totals row. `enter` lists the models of a period. |
187
+ | `latency` | Wall time, streaming window and throughput as p50/p90/p99 overall and per model (per endpoint when a model is filtered). `n < 5` is dimmed. |
188
+ | `sessions` | Each session with steps, prompts, tokens, `hit`, cost, its directory and how many models it used; subagent sessions are indented under their parent and rolled up. |
189
+ | `turns` | The per-prompt history: one line per finished turn, rendered exactly like the live line. `a` widens it to every session, titled. |
190
+ | `db` | Where the file lives, what it holds and how much of it is old. The retention table lists, per preset (`30`/`90`/`365` days), how many steps a prune would remove and the cutoff date it prunes from โ€” pick one with `โ†‘`/`โ†“` and `enter` prunes it (a confirmation first); `v` gives the space back. |
191
+
192
+ The panel sums the stored per-step buckets over the selected range, so a number
193
+ read in one place means the same in the other: `prompt` is everything the
194
+ provider saw (`cached + fresh + writes`), `in` the uncached part, `hit` is
195
+ `cached / prompt`, and `out` everything generated (visible output + reasoning).
196
+
197
+ The endpoint is what the gateway names in `x-opencode-endpoint-id`, or the host
198
+ of a per-operator provider. A **gateway host is not an endpoint**: when the
199
+ gateway names no downstream endpoint, the request has no observed endpoint and
200
+ the views say so (`โ€“ (never observed)`) instead of showing the gateway's name as
201
+ if it had run the model. The line and the sidebar then fall back to
202
+ `provider/model`. The line reports the endpoint of the turn it belongs to: a
203
+ turn the gateway did not name falls back even when an earlier turn of the same
204
+ provider and model had an endpoint.
205
+
206
+ `tps`/`wall`/`stream` are produced tokens per streaming second / request to
207
+ completed step / request to the last chunk. The sidebar's rate (per turn, and
208
+ the session average on its totals) is the **weighted** one โ€” produced tokens
209
+ divided by the summed streaming windows โ€” so it answers "how fast did this
210
+ produce"; the `latency` view reports the p50 of the per-step rates instead, which
211
+ answers "how fast is a typical step". A change of identity is not by
212
+ itself a fault โ€” a gateway may re-route deliberately โ€” so `affinity` pairs the
213
+ count with the cache reuse it actually cost. The panel's `?` overlay defines
214
+ every number in place.
215
+
216
+ ## Configuration
217
+
218
+ Options apply to both entries (each side reads the ones it needs):
219
+
220
+ | Option | Default | Meaning |
221
+ | --- | --- | --- |
222
+ | `enabled` | `true` | `false` disables that side completely โ€” no line, no panel, no recording. |
223
+ | `db` | per-OS state path | SQLite path override. Must match between the server and TUI entries. |
224
+ | `turnTotals` | `"latest"` | How the turn segment counts prompt usage: `latest` shows the prompt size the turn ended with; `sum` adds every step of the turn. |
225
+ | `sidebar` | `true` | Show the session summary and the per-prompt history โ€” cache hit and cost per turn โ€” in the session sidebar (the slot is invisible while the sidebar is hidden). |
226
+ | `sidebarTurns` | `5` | How many turns the sidebar history shows. |
227
+ | `panel` | `true` | Show the `/usage` statistics panel and its command. `false` keeps the line and the sidebar. |
228
+ | `identity` | `"endpoint"` | Which identity the line leads with: `endpoint` shows the endpoint that served the turn (falling back to `provider/model`); `model` always shows `provider/model#variant`. |
229
+ | `fields` | see below | Toggle line segments. |
230
+ | `labels` | see below | Override line labels (the plugin is English-only by default). |
231
+
232
+ ### Fields and labels
233
+
234
+ ```jsonc
235
+ {
236
+ "options": {
237
+ "fields": { "clock": true, "delta": true, "model": true, "variant": true, "turn": true, "session": true, "reasoning": false, "cost": false, "cacheExpiry": true },
238
+ "turnTotals": "latest",
239
+ "labels": { "delta": "ฮ”", "turn": "", "session": "sess", "step": "step", "steps": "steps", "input": "in", "cached": "cached", "output": "out", "reasoning": "reason", "cost": "cost", "cacheExpired": "cache expired", "providerChanged": "changed", "modelChanged": "model changed" }
240
+ }
241
+ }
242
+ ```
243
+
244
+ `fields: { "clock": false, "delta": false, "session": false }` turns the line
245
+ into a pure `provider/model ยท turn โ€ฆ` reading.
246
+
247
+ ## Database and privacy
248
+
249
+ The recorder writes to the per-user state directory:
250
+
251
+ | OS | Path |
252
+ | --- | --- |
253
+ | Linux / other | `$XDG_STATE_HOME/opencode/opencode-usage-insights/usage.db` (default `~/.local/state/opencode/opencode-usage-insights/usage.db`) |
254
+ | macOS | `~/Library/Application Support/opencode/opencode-usage-insights/usage.db` |
255
+ | Windows | `%LOCALAPPDATA%\opencode\opencode-usage-insights\usage.db` |
256
+
257
+ `OPENCODE_USAGE_INSIGHTS_DB=/path/to/usage.db` overrides the path, and the `db`
258
+ option overrides both. The file is private to the user: its directory is created
259
+ `0700` and the file `0600` (POSIX), re-applied on every open. The TUI reads
260
+ through a read-only connection, so it can never write, migrate or take the
261
+ recorder's lock.
262
+
263
+ Per step the database holds tokens, cost, timings, finish reason, the
264
+ provider/model it ran as, the endpoint label, and the session title and working
265
+ directory. **Prompts and responses are not stored**, and nothing leaves the
266
+ machine. The schema, the indexes, the endpoint-discovery rules and the security
267
+ notes live in [docs/internals.md](docs/internals.md).
268
+
269
+ ## CLI report
270
+
271
+ From a checkout, the database can be read without opening the TUI:
272
+
273
+ ```sh
274
+ bun run report # overview over all time
275
+ bun run report models 7d # one view, one range
276
+ bun run report all # every view
277
+ bun run report endpoints --model deepseek-v4.1-flash-free
278
+ bun run report periods --granularity month
279
+ bun run report affinity --endpoint orcarouter-dsv4.1flash
280
+ bun run report --json # raw rows and every analysis, for scripting
281
+ bun run report --ndjson > backup.jsonl # export the whole database
282
+ bun run report --import backup.jsonl # merge an export (idempotent)
283
+ bun run report --help
284
+ ```
285
+
286
+ The views come from the same `buildPanel` the TUI renders, so the terminal and
287
+ the panel never drift apart. `--width`, `--granularity`, `--model`,
288
+ `--endpoint`, `--sort` and `--asc` mirror what the panel's keys do.
289
+
290
+ ## Development
291
+
292
+ ```sh
293
+ bun install
294
+ bun run typecheck # tsc --noEmit, strict
295
+ bun test # pure logic + database + TUI harness + render tests
296
+ bun run build # dist/index.js, dist/tui.js and declarations
297
+ bun run demo # print the line and every view from a seeded database
298
+ bun run docs:images # re-render docs/images/*.png (needs ImageMagick)
299
+ ```
300
+
301
+ - `src/core.ts`, `src/line.ts`, `src/panel-data.ts` and `src/stats/` are pure:
302
+ turn accounting, the host-shape adapter, the view models, and the database,
303
+ queries and analysis layer. No host imports, so they are unit-testable without
304
+ a TUI runtime.
305
+ - `src/endpoint.ts`, `src/text.ts` โ€” endpoint discovery and text hygiene.
306
+ - `src/recorder.ts` โ€” the event-stream state machine that fills the database.
307
+ - `src/panel-build.ts`, `src/panel.tsx`, `src/sidebar.tsx` โ€” panel assembly, the
308
+ panel component, and the sidebar contribution.
309
+ - `src/index.ts`, `src/tui.tsx` โ€” the server and TUI entries.
310
+ - `scripts/` โ€” the demo, report, bench, platform-check and documentation-image
311
+ CLIs, plus the two-entry `Bun.build`.
312
+
313
+ See [CONTRIBUTING.md](CONTRIBUTING.md) for the ground rules and review
314
+ expectations, and [docs/internals.md](docs/internals.md) for the database
315
+ design, performance rules and how the TUI side is tested.
316
+
317
+ ## License
318
+
319
+ MIT โ€” see [LICENSE](./LICENSE). Author: Hugo Batista
320
+ (<https://github.com/hugobatista>).
package/dist/core.d.ts ADDED
@@ -0,0 +1,271 @@
1
+ /**
2
+ * Pure turn accounting shared by the TUI line, the server recorder and the
3
+ * stats engine.
4
+ *
5
+ * Everything here operates on structural shapes that mirror the session
6
+ * messages published by OpenCode, so the logic stays testable without a host
7
+ * runtime and without the effect-based schema types.
8
+ */
9
+ export type ModelRefLike = {
10
+ providerID: string;
11
+ id: string;
12
+ variant?: string | undefined;
13
+ };
14
+ export type AssistantMessage = {
15
+ type: "assistant";
16
+ id: string;
17
+ time?: {
18
+ created?: number | undefined;
19
+ streamed?: number | undefined;
20
+ completed?: number | undefined;
21
+ } | undefined;
22
+ model?: ModelRefLike | undefined;
23
+ tokens?: {
24
+ input?: number | undefined;
25
+ output?: number | undefined;
26
+ reasoning?: number | undefined;
27
+ cache?: {
28
+ read?: number | undefined;
29
+ write?: number | undefined;
30
+ } | undefined;
31
+ } | undefined;
32
+ cost?: number | undefined;
33
+ finish?: string | null | undefined;
34
+ rawFinish?: string | null | undefined;
35
+ };
36
+ export type PromptMessage = {
37
+ type: "user" | "synthetic" | "system";
38
+ id?: string | undefined;
39
+ time?: {
40
+ created?: number | undefined;
41
+ } | undefined;
42
+ };
43
+ export type TranscriptMessage = AssistantMessage | PromptMessage | {
44
+ type: string;
45
+ id?: string | undefined;
46
+ time?: {
47
+ created?: number | undefined;
48
+ } | undefined;
49
+ };
50
+ /** Accumulated token usage for a group of assistant steps. */
51
+ export type Totals = {
52
+ input: number;
53
+ cached: number;
54
+ output: number;
55
+ reasoning: number;
56
+ cacheWrite: number;
57
+ cost: number;
58
+ steps: number;
59
+ };
60
+ export type CacheDrop = {
61
+ messageID: string;
62
+ /** Tokens that were expected to hit the prompt cache but did not. */
63
+ tokens: number;
64
+ /** `cache.read` of the step that was used as the reuse reference. */
65
+ prevCached: number;
66
+ /** Time between the reference step's completion and this step's request. */
67
+ gapMs: number | undefined;
68
+ };
69
+ export type TurnStats = {
70
+ /** Wall clock time of the last step's completion. */
71
+ endAt: number | undefined;
72
+ /** Creation time of the first settled step of the turn. */
73
+ startAt: number | undefined;
74
+ /** Creation time of the prompt that opened this turn. */
75
+ promptAt: number | undefined;
76
+ /** Time between the previous prompt and this turn's prompt. */
77
+ promptGapMs: number | undefined;
78
+ providerID: string | undefined;
79
+ modelID: string | undefined;
80
+ variant: string | undefined;
81
+ /** Gateway routing endpoint that served the turn, when a source identifies it. */
82
+ router: string | undefined;
83
+ /**
84
+ * The displayed identity differs from the previous turn's. The TUI fills it
85
+ * in (the transcript carries no routing information); the line warns with the
86
+ * same `changed` marker it uses for a provider switch.
87
+ */
88
+ identityChanged: boolean;
89
+ affinityKey: string | undefined;
90
+ /** Provider ID differs from the previous turn in the same transcript. */
91
+ providerChanged: boolean;
92
+ /** Provider is the same but the model or variant differs. */
93
+ modelChanged: boolean;
94
+ totals: Totals;
95
+ /** Passed through from the caller (session-wide accumulated usage). */
96
+ session: Totals | undefined;
97
+ /**
98
+ * Prompt-side usage of the last finished step: the context the turn ended
99
+ * with. Summing `input`/`cached` over the steps of a turn counts the same
100
+ * re-sent prompt once per step, which is not what a context view means.
101
+ */
102
+ latest: Totals;
103
+ drops: CacheDrop[];
104
+ cacheDropTokens: number;
105
+ cacheExpired: boolean;
106
+ /** Assistant steps in the turn. */
107
+ steps: number;
108
+ /**
109
+ * Message ids of the settled steps of the turn, oldest first. The TUI looks
110
+ * up the endpoint the recorder stored for these steps, so the line reports
111
+ * who served *this* turn: a lookup by provider and model alone would tag a
112
+ * turn with the endpoint of an older one.
113
+ */
114
+ stepIDs: string[];
115
+ };
116
+ export type TurnSegment = {
117
+ assistants: AssistantMessage[];
118
+ prompt: TranscriptMessage | undefined;
119
+ };
120
+ /** Message types that open a turn: a run of assistants belongs to the last one. */
121
+ export declare const PROMPT_BOUNDARIES: ReadonlySet<string>;
122
+ /**
123
+ * Split a transcript into turns. A turn is a run of assistant steps opened by
124
+ * a prompt boundary (`user`, `synthetic`, `system`). Idle notices, info
125
+ * messages, compactions and shell entries keep the current turn open so a
126
+ * single execution is never split in two.
127
+ */
128
+ export declare function transcriptSegments(messages: readonly TranscriptMessage[]): TurnSegment[];
129
+ /**
130
+ * The last `count` segments, found by walking the transcript backwards. The
131
+ * live line only needs the turn that just ended and the one before it, and a
132
+ * long session holds tens of thousands of messages: the tail walk stops as
133
+ * soon as it has them, instead of building a segment for every turn.
134
+ */
135
+ export declare function transcriptTail(messages: readonly TranscriptMessage[], count: number): TurnSegment[];
136
+ export declare function tokenCounts(message: AssistantMessage): {
137
+ input: number;
138
+ output: number;
139
+ reasoning: number;
140
+ read: number;
141
+ write: number;
142
+ };
143
+ export declare function totalize(steps: readonly AssistantMessage[]): Totals;
144
+ export declare function affinityKey(model: ModelRefLike | undefined): string | undefined;
145
+ export declare function cacheRead(message: AssistantMessage | undefined): number;
146
+ export declare function stepCreated(message: AssistantMessage | undefined): number | undefined;
147
+ export declare function stepEnd(message: AssistantMessage | undefined): number | undefined;
148
+ /**
149
+ * Cache reuse loss against a reference step, mirroring OpenCode's own
150
+ * `turn-usage` heuristic: only comparable when the model is identical, and
151
+ * OpenAI's endpoint reports small reads that are not worth flagging.
152
+ */
153
+ export declare function cacheDrop(reference: AssistantMessage | undefined, current: AssistantMessage): number | undefined;
154
+ /** Milliseconds between a reference step's completion and the next request. */
155
+ export declare function stepGap(reference: AssistantMessage | undefined, current: AssistantMessage): number | undefined;
156
+ /**
157
+ * Streaming duration of a step: request start to the last streamed chunk. The
158
+ * host's `streamed` stamp marks the end of streaming, so this is the window a
159
+ * generation rate belongs to โ€” not a time to first token, which the session
160
+ * messages do not record.
161
+ */
162
+ export declare function stepStreamMs(message: AssistantMessage): number | undefined;
163
+ /**
164
+ * Tokens produced per second over a window. For a step the window is the
165
+ * streaming one: request start to the last streamed chunk (`created` โ†’
166
+ * `streamed`) โ€” `completed` marks the end of the step, which for a tool-call
167
+ * step happens after the tool calls were handled. Returns undefined when the
168
+ * window or the produced tokens are unknown. Single definition, shared by the
169
+ * recorder (which stores it per step) and the latency view.
170
+ */
171
+ export declare function tokensPerSecond(produced: number, from: number | undefined, to: number | undefined): number | undefined;
172
+ /** Output-plus-reasoning tokens per second of a step. */
173
+ export declare function stepTps(message: AssistantMessage): number | undefined;
174
+ /**
175
+ * True when a step has final numbers. A step that is still streaming reports
176
+ * partial tokens (usually zero) and no cache read yet, so reading it would
177
+ * render an empty line and invent a cache-expiry warning against it.
178
+ */
179
+ export declare function isSettledStep(message: AssistantMessage): boolean;
180
+ /**
181
+ * Compute the stats of the most recent turn in a transcript, including the
182
+ * comparison against the previous turn (affinity and cache reuse).
183
+ *
184
+ * Returns undefined while the newest turn is still being generated: the line
185
+ * appears when a turn ends, never with the zeroes of an in-flight response.
186
+ */
187
+ export declare function computeTurn(messages: readonly TranscriptMessage[], session?: Totals): TurnStats | undefined;
188
+ /** True when a cache drop is small enough to be endpoint noise. */
189
+ export declare function isNoiseDrop(providerID: string | undefined, drop: number): boolean;
190
+ export type LineTone = "base" | "muted" | "warning";
191
+ export type LineSegment = {
192
+ text: string;
193
+ tone: LineTone;
194
+ };
195
+ export type LineLabels = {
196
+ delta: string;
197
+ turn: string;
198
+ session: string;
199
+ step: string;
200
+ steps: string;
201
+ input: string;
202
+ cached: string;
203
+ output: string;
204
+ reasoning: string;
205
+ cost: string;
206
+ cacheExpired: string;
207
+ providerChanged: string;
208
+ modelChanged: string;
209
+ };
210
+ /**
211
+ * How the turn segment counts prompt usage. `latest` shows the prompt size the
212
+ * turn ended with โ€” the context view, and what OpenCode's own turn-token debug
213
+ * line reports. `sum` adds every step of the turn: the billing view, where a
214
+ * tool-call loop re-sends (and re-reads) the same context once per step.
215
+ */
216
+ export type TurnTotalsView = "latest" | "sum";
217
+ /**
218
+ * Which identity the line leads with. `endpoint` shows the gateway endpoint
219
+ * that served the turn (`orcarouter-dsv4.1flash`) when a recorder learned one,
220
+ * falling back to `provider/model`; `model` always shows `provider/model`.
221
+ * The same provider and model can be routed to different endpoints per request,
222
+ * so the endpoint is the identity a change is measured against.
223
+ */
224
+ export type LineIdentity = "endpoint" | "model";
225
+ export type LineFields = {
226
+ clock: boolean;
227
+ delta: boolean;
228
+ model: boolean;
229
+ variant: boolean;
230
+ turn: boolean;
231
+ session: boolean;
232
+ reasoning: boolean;
233
+ cost: boolean;
234
+ cacheExpiry: boolean;
235
+ };
236
+ export declare const DEFAULT_LABELS: LineLabels;
237
+ export declare const DEFAULT_FIELDS: LineFields;
238
+ export declare function formatClock(ms: number): string;
239
+ export declare function formatDuration(ms: number): string;
240
+ export declare function formatTokens(value: number): string;
241
+ export declare function formatCost(value: number): string;
242
+ /**
243
+ * The buckets the line displays, derived from a totals group. `input` is the
244
+ * *uncached* part of the prompt (fresh input plus cache writes, i.e. what the
245
+ * debug line's `new` counts on the prompt side), `prompt` is the whole prompt
246
+ * of the request and `output` is everything the model generated (visible output
247
+ * + reasoning). OpenCode's own debug line reconciles with these:
248
+ * `new = in + out` and `total = prompt + out`, with `prompt = in + cached`.
249
+ * The stored buckets stay raw; only the display folds.
250
+ */
251
+ export type DisplayTotals = Totals & {
252
+ /** Whole prompt of the request: fresh input + cached reads + cache writes. */
253
+ prompt: number;
254
+ };
255
+ export declare function displayTotals(totals: Totals): DisplayTotals;
256
+ /**
257
+ * Render the telemetry line as toned segments. The caller maps tones onto
258
+ * theme colors; `formatLine` joins the segments as plain text.
259
+ */
260
+ export declare function formatLineSegments(stats: TurnStats, options?: {
261
+ labels?: Partial<LineLabels>;
262
+ fields?: Partial<LineFields>;
263
+ turnTotals?: TurnTotalsView;
264
+ identity?: LineIdentity;
265
+ }): LineSegment[];
266
+ export declare function formatLine(stats: TurnStats, options?: {
267
+ labels?: Partial<LineLabels>;
268
+ fields?: Partial<LineFields>;
269
+ turnTotals?: TurnTotalsView;
270
+ identity?: LineIdentity;
271
+ }): string;
@@ -0,0 +1,46 @@
1
+ /**
2
+ * Endpoint discovery.
3
+ *
4
+ * OpenCode routes a model through a provider, and the endpoint the request was
5
+ * actually sent to is the identity that answers "who ran this inference": a
6
+ * gateway names its own routing endpoint in the response headers, and a
7
+ * per-operator provider is named by the host of its API. The recorder stores
8
+ * that endpoint on the step and nothing else about the route โ€” the operator a
9
+ * header happens to mention is not the endpoint, so it is not kept.
10
+ *
11
+ * Everything here is pure so the discovery can be tested without a network
12
+ * stack.
13
+ */
14
+ /** Header a gateway sets to name the routing endpoint that served the request. */
15
+ export declare const ENDPOINT_HEADER: "x-opencode-endpoint-id";
16
+ /** Longest endpoint id kept: a routing label, not a sentence. */
17
+ export declare const ENDPOINT_MAX = 64;
18
+ export type EndpointInfo = {
19
+ /** Routing endpoint or host that served the request. */
20
+ router?: string | undefined;
21
+ /** Where the finding came from: `header:<name>` or `endpoint:<host>`. */
22
+ source?: string | undefined;
23
+ };
24
+ /**
25
+ * Endpoint the gateway named in the response headers, when it named one.
26
+ *
27
+ * The value is a routing label, so it is validated as a token and lowercased:
28
+ * a gateway that changes the case between responses would otherwise read as a
29
+ * different endpoint and split its own history in two. Anything that is not a
30
+ * token (control characters, spaces, an essay) is not an endpoint id.
31
+ */
32
+ export declare function inferFromHeaders(headers: Headers | Record<string, string | undefined> | undefined): EndpointInfo | undefined;
33
+ /**
34
+ * True when `host` is `suffix`, a subdomain of it (`cdn.opencode.ai`), or a
35
+ * regional host that carries it as a label tail (`us-central1-aiplatform.googleapis.com`).
36
+ * The boundary has to fall on a dot or a dash: a host that merely contains a
37
+ * known domain (`api.anthropic.com.evil.tld`) names nothing.
38
+ *
39
+ * A `*` in the pattern stands for one label, for the hosts whose region sits
40
+ * in the middle (`bedrock-runtime.*.amazonaws.com`).
41
+ */
42
+ export declare function hostMatches(host: string, pattern: string): boolean;
43
+ /** Endpoint named by the host of the request URL. */
44
+ export declare function inferFromUrl(url: string | undefined): EndpointInfo | undefined;
45
+ /** First candidate that named an endpoint, headers before the URL. */
46
+ export declare function mergeEndpoint(...candidates: readonly (EndpointInfo | undefined)[]): EndpointInfo | undefined;