@iaziz786/cmd-footer 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 +21 -0
- package/README.md +93 -0
- package/package.json +25 -0
- package/src/cmd-footer.ts +449 -0
package/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Mohammad Aziz
|
|
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
ADDED
|
@@ -0,0 +1,93 @@
|
|
|
1
|
+
# cmd-footer
|
|
2
|
+
|
|
3
|
+
[Command Code](https://commandcode.ai) mod that puts cache hit ratio and decode
|
|
4
|
+
throughput in the footer, next to the input panel.
|
|
5
|
+
|
|
6
|
+
The footer segment looks like:
|
|
7
|
+
|
|
8
|
+
```text
|
|
9
|
+
cache last 99.5% | avg 97.8% | 250 t/s
|
|
10
|
+
```
|
|
11
|
+
|
|
12
|
+
| Segment | Meaning |
|
|
13
|
+
|---|---|
|
|
14
|
+
| `cache last` | Cache hit ratio of the last request: cache reads over prompt tokens |
|
|
15
|
+
| `avg` | Session-wide ratio, weighted by tokens rather than averaged per request |
|
|
16
|
+
| `t/s` | Decode rate of the last reply: output tokens over the stream window |
|
|
17
|
+
|
|
18
|
+
`cache last` turns red when a request re-billed tokens that should have been
|
|
19
|
+
cache reads, even when the ratio still looks healthy. A 99% request that
|
|
20
|
+
re-reads 3k tokens shows red, because those tokens cost full price.
|
|
21
|
+
|
|
22
|
+
Type `/cache` for the detailed line: token counts, request count, decode rate,
|
|
23
|
+
and whether the last turn re-billed.
|
|
24
|
+
|
|
25
|
+
## Install
|
|
26
|
+
|
|
27
|
+
```bash
|
|
28
|
+
cmd mods add npm:@iaziz786/cmd-footer
|
|
29
|
+
```
|
|
30
|
+
|
|
31
|
+
Or try it without installing:
|
|
32
|
+
|
|
33
|
+
```bash
|
|
34
|
+
cmd --mod ./src/cmd-footer.ts
|
|
35
|
+
```
|
|
36
|
+
|
|
37
|
+
## How the numbers are measured
|
|
38
|
+
|
|
39
|
+
**Cache ratio.** `cacheReadTokens / inputTokens`. The AI SDK reports
|
|
40
|
+
`inputTokens` as the whole prompt, with cache reads and writes already inside
|
|
41
|
+
it, so the ratio is the cached share of that total.
|
|
42
|
+
|
|
43
|
+
**Decode rate.** Output tokens over the span from the first streamed delta to
|
|
44
|
+
the end of the response, so the wait for the first token is excluded. Three
|
|
45
|
+
conditions make a measurement untrustworthy and the previous reading is kept
|
|
46
|
+
instead:
|
|
47
|
+
|
|
48
|
+
- fewer than 4 stream deltas, or a window shorter than 300ms: delivery could
|
|
49
|
+
not be observed as a stream
|
|
50
|
+
- a rate above 400 t/s: a proxy buffered the reply and flushed it in one burst,
|
|
51
|
+
which compresses the window and invents impossible throughput
|
|
52
|
+
|
|
53
|
+
**Miss detection.** A request re-billed when the prompt of the previous request
|
|
54
|
+
was not read back from cache. Re-bills at or below 1024 tokens are treated as
|
|
55
|
+
cache breakpoint granularity noise. A compaction clears the baseline, so
|
|
56
|
+
freshly compacted content is not mistaken for a re-bill. Providers that never
|
|
57
|
+
report caching never show a miss.
|
|
58
|
+
|
|
59
|
+
The flag covers the whole run rather than a single request, because one prompt
|
|
60
|
+
can take several model calls and an early call can miss while the last one
|
|
61
|
+
hits.
|
|
62
|
+
|
|
63
|
+
## Tuning
|
|
64
|
+
|
|
65
|
+
The thresholds are exported constants in `src/cmd-footer.ts`:
|
|
66
|
+
|
|
67
|
+
| Constant | Default | Effect |
|
|
68
|
+
|---|---|---|
|
|
69
|
+
| `MAX_PLAUSIBLE_RATE` | 400 | Raise it on faster hardware, or the rate stops updating |
|
|
70
|
+
| `MISS_NOISE_FLOOR_TOKENS` | 1024 | Lower it to flag smaller re-bills |
|
|
71
|
+
| `MIN_RATE_SPAN_MS` | 300 | Shortest window that can resolve a rate |
|
|
72
|
+
| `MIN_RATE_CHUNKS` | 4 | Fewest deltas that count as a stream |
|
|
73
|
+
|
|
74
|
+
## Persistence
|
|
75
|
+
|
|
76
|
+
Totals survive `/reload` and resuming a session. One entry per run is written
|
|
77
|
+
through the session API, so a restored session continues its average instead of
|
|
78
|
+
starting over. A new session starts at zero.
|
|
79
|
+
|
|
80
|
+
## Develop
|
|
81
|
+
|
|
82
|
+
```bash
|
|
83
|
+
bun test
|
|
84
|
+
```
|
|
85
|
+
|
|
86
|
+
## Publish
|
|
87
|
+
|
|
88
|
+
Publishing runs on version tags. The tag must match `package.json`.
|
|
89
|
+
|
|
90
|
+
```bash
|
|
91
|
+
npm version patch
|
|
92
|
+
git push --follow-tags
|
|
93
|
+
```
|
package/package.json
ADDED
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@iaziz786/cmd-footer",
|
|
3
|
+
"version": "0.1.0",
|
|
4
|
+
"description": "Command Code mod that puts cache hit ratio and decode throughput in the footer.",
|
|
5
|
+
"keywords": ["command-code", "cmd", "mod", "footer", "cache", "tokens", "tps"],
|
|
6
|
+
"type": "module",
|
|
7
|
+
"license": "MIT",
|
|
8
|
+
"author": "Mohammad Aziz",
|
|
9
|
+
"homepage": "https://github.com/iAziz786/cmd-footer#readme",
|
|
10
|
+
"repository": {
|
|
11
|
+
"type": "git",
|
|
12
|
+
"url": "git+ssh://git@github.com/iAziz786/cmd-footer.git"
|
|
13
|
+
},
|
|
14
|
+
"files": ["src", "README.md", "LICENSE"],
|
|
15
|
+
"engines": {
|
|
16
|
+
"node": ">=20.0.0"
|
|
17
|
+
},
|
|
18
|
+
"scripts": {
|
|
19
|
+
"test": "bun test",
|
|
20
|
+
"prepublishOnly": "bun test"
|
|
21
|
+
},
|
|
22
|
+
"commandcode": {
|
|
23
|
+
"mods": ["./src/cmd-footer.ts"]
|
|
24
|
+
}
|
|
25
|
+
}
|
|
@@ -0,0 +1,449 @@
|
|
|
1
|
+
import type {ModApi} from '@commandcode/harness';
|
|
2
|
+
|
|
3
|
+
// Namespaced because session.getCustomEntries filters by customType alone, so
|
|
4
|
+
// the type is the identity - it is not scoped per mod.
|
|
5
|
+
export const CUSTOM_TYPE = 'cache-ratio/usage';
|
|
6
|
+
|
|
7
|
+
export const DIM = '\u001b[2m';
|
|
8
|
+
export const GREEN = '\u001b[32m';
|
|
9
|
+
export const YELLOW = '\u001b[33m';
|
|
10
|
+
export const RED = '\u001b[31m';
|
|
11
|
+
export const CYAN = '\u001b[36m';
|
|
12
|
+
export const RESET = '\u001b[0m';
|
|
13
|
+
|
|
14
|
+
/** Fewer deltas than this over the window means delivery was bursty. */
|
|
15
|
+
export const MIN_RATE_CHUNKS = 4;
|
|
16
|
+
|
|
17
|
+
/** Spans shorter than this cannot resolve a decode rate. */
|
|
18
|
+
export const MIN_RATE_SPAN_MS = 300;
|
|
19
|
+
|
|
20
|
+
/**
|
|
21
|
+
* Public decode ceilings run a few hundred t/s; anything above is suspect.
|
|
22
|
+
* Raise this if a genuinely fast model starts showing a stale rate.
|
|
23
|
+
*/
|
|
24
|
+
export const MAX_PLAUSIBLE_RATE = 400;
|
|
25
|
+
|
|
26
|
+
export interface RateWindow {
|
|
27
|
+
/** model_request_start timestamp: post-headers, pre-first-chunk. */
|
|
28
|
+
readonly startTs: number;
|
|
29
|
+
/** model_request_end timestamp. */
|
|
30
|
+
readonly endTs: number;
|
|
31
|
+
/** First delta timestamp; falls back to startTs when absent. */
|
|
32
|
+
readonly firstDeltaTs?: number;
|
|
33
|
+
/** Streamed delta events observed (text + thinking). */
|
|
34
|
+
readonly chunkCount: number;
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
interface UsageLike {
|
|
38
|
+
// AI SDK semantics: inputTokens is the prompt TOTAL. Cache read and cache
|
|
39
|
+
// write tokens are subsets of it, not additions to it.
|
|
40
|
+
readonly inputTokens?: number;
|
|
41
|
+
readonly cacheReadTokens?: number;
|
|
42
|
+
readonly cacheWriteTokens?: number;
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
/**
|
|
46
|
+
* Re-billed prompt tokens at or below this are cache breakpoint granularity
|
|
47
|
+
* noise, not a miss. Same floor pi's footer uses.
|
|
48
|
+
*/
|
|
49
|
+
export const MISS_NOISE_FLOOR_TOKENS = 1024;
|
|
50
|
+
|
|
51
|
+
/** Previous request, the baseline a miss is judged against. */
|
|
52
|
+
export interface PreviousRequest {
|
|
53
|
+
readonly promptTokens: number;
|
|
54
|
+
readonly reportedCache: boolean;
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
interface CacheTotals {
|
|
58
|
+
readonly cachedTokens: number;
|
|
59
|
+
readonly promptTokens: number;
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
interface CacheStat extends CacheTotals {
|
|
63
|
+
readonly ratio: number | null;
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
interface Snapshot {
|
|
67
|
+
readonly last: number | null;
|
|
68
|
+
readonly average: number | null;
|
|
69
|
+
readonly requests: number;
|
|
70
|
+
readonly cachedTokens: number;
|
|
71
|
+
readonly promptTokens: number;
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
interface CacheState {
|
|
75
|
+
readonly last: CacheStat | null;
|
|
76
|
+
readonly totals: CacheTotals;
|
|
77
|
+
readonly requests: number;
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
// The on-disk shape. Flat numbers only: the session file is append-only JSONL,
|
|
81
|
+
// so a snapshot must survive being read back by a later process.
|
|
82
|
+
interface PersistedState {
|
|
83
|
+
readonly cachedTokens: number;
|
|
84
|
+
readonly promptTokens: number;
|
|
85
|
+
readonly requests: number;
|
|
86
|
+
readonly lastCachedTokens: number;
|
|
87
|
+
readonly lastPromptTokens: number;
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
export function tokenCount(value: number | undefined): number {
|
|
91
|
+
return typeof value === 'number' && Number.isFinite(value) ? value : 0;
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
export function ratioOf(totals: CacheTotals): number | null {
|
|
95
|
+
return totals.promptTokens > 0
|
|
96
|
+
? totals.cachedTokens / totals.promptTokens
|
|
97
|
+
: null;
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
export function cacheStat(usage: UsageLike): CacheStat {
|
|
101
|
+
const cachedTokens = tokenCount(usage.cacheReadTokens);
|
|
102
|
+
const promptTokens = tokenCount(usage.inputTokens);
|
|
103
|
+
return {cachedTokens, promptTokens, ratio: ratioOf({cachedTokens, promptTokens})};
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
export function combine(totals: CacheTotals, next: CacheTotals): CacheTotals {
|
|
107
|
+
return {
|
|
108
|
+
cachedTokens: totals.cachedTokens + next.cachedTokens,
|
|
109
|
+
promptTokens: totals.promptTokens + next.promptTokens,
|
|
110
|
+
};
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
export function emptyState(): CacheState {
|
|
114
|
+
return {last: null, totals: {cachedTokens: 0, promptTokens: 0}, requests: 0};
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
export function applyRequest(state: CacheState, stat: CacheStat): CacheState {
|
|
118
|
+
return {
|
|
119
|
+
last: stat,
|
|
120
|
+
totals: combine(state.totals, stat),
|
|
121
|
+
requests: state.requests + 1,
|
|
122
|
+
};
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
export function snapshotOf(state: CacheState): Snapshot {
|
|
126
|
+
return {
|
|
127
|
+
last: state.last?.ratio ?? null,
|
|
128
|
+
average: ratioOf(state.totals),
|
|
129
|
+
requests: state.requests,
|
|
130
|
+
cachedTokens: state.last?.cachedTokens ?? 0,
|
|
131
|
+
promptTokens: state.last?.promptTokens ?? 0,
|
|
132
|
+
};
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
export function serializeState(state: CacheState): PersistedState {
|
|
136
|
+
return {
|
|
137
|
+
cachedTokens: state.totals.cachedTokens,
|
|
138
|
+
promptTokens: state.totals.promptTokens,
|
|
139
|
+
requests: state.requests,
|
|
140
|
+
lastCachedTokens: state.last?.cachedTokens ?? 0,
|
|
141
|
+
lastPromptTokens: state.last?.promptTokens ?? 0,
|
|
142
|
+
};
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
function nonNegative(value: unknown): number | null {
|
|
146
|
+
return typeof value === 'number' && Number.isFinite(value) && value >= 0
|
|
147
|
+
? value
|
|
148
|
+
: null;
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
// Restore is the one place that reads data written by an older process, so it
|
|
152
|
+
// validates instead of trusting the shape.
|
|
153
|
+
export function parseState(data: unknown): CacheState | null {
|
|
154
|
+
if (typeof data !== 'object' || data === null) return null;
|
|
155
|
+
const raw = data as Record<string, unknown>;
|
|
156
|
+
|
|
157
|
+
const cachedTokens = nonNegative(raw.cachedTokens);
|
|
158
|
+
const promptTokens = nonNegative(raw.promptTokens);
|
|
159
|
+
const requests = nonNegative(raw.requests);
|
|
160
|
+
const lastCached = nonNegative(raw.lastCachedTokens);
|
|
161
|
+
const lastPrompt = nonNegative(raw.lastPromptTokens);
|
|
162
|
+
if (
|
|
163
|
+
cachedTokens === null ||
|
|
164
|
+
promptTokens === null ||
|
|
165
|
+
requests === null ||
|
|
166
|
+
lastCached === null ||
|
|
167
|
+
lastPrompt === null
|
|
168
|
+
) {
|
|
169
|
+
return null;
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
const last: CacheStat | null =
|
|
173
|
+
lastPrompt > 0
|
|
174
|
+
? {
|
|
175
|
+
cachedTokens: lastCached,
|
|
176
|
+
promptTokens: lastPrompt,
|
|
177
|
+
ratio: ratioOf({cachedTokens: lastCached, promptTokens: lastPrompt}),
|
|
178
|
+
}
|
|
179
|
+
: null;
|
|
180
|
+
|
|
181
|
+
return {last, totals: {cachedTokens, promptTokens}, requests};
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
export function formatPercent(ratio: number | null): string {
|
|
185
|
+
return ratio === null ? 'n/a' : `${(ratio * 100).toFixed(1)}%`;
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
export function formatTokens(count: number): string {
|
|
189
|
+
if (count < 1000) return String(count);
|
|
190
|
+
if (count < 1_000_000) return `${(count / 1000).toFixed(1)}k`;
|
|
191
|
+
return `${(count / 1_000_000).toFixed(1)}M`;
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
export function colorFor(ratio: number | null): string {
|
|
195
|
+
if (ratio === null) return DIM;
|
|
196
|
+
if (ratio >= 0.8) return GREEN;
|
|
197
|
+
if (ratio >= 0.5) return YELLOW;
|
|
198
|
+
return RED;
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
/**
|
|
202
|
+
* Decode throughput: output tokens over the span from the first streamed delta
|
|
203
|
+
* to the end of the response, so the wait for the first token is excluded.
|
|
204
|
+
*
|
|
205
|
+
* The span is only trustworthy when delivery was genuinely incremental. A proxy
|
|
206
|
+
* that buffers the whole reply and flushes it in one burst compresses the span
|
|
207
|
+
* toward zero and reports impossible rates, so those measurements are rejected
|
|
208
|
+
* instead of shown.
|
|
209
|
+
*/
|
|
210
|
+
export function computeTokenRate(
|
|
211
|
+
outputTokens: number,
|
|
212
|
+
window: RateWindow,
|
|
213
|
+
): number | undefined {
|
|
214
|
+
if (outputTokens <= 0) return undefined;
|
|
215
|
+
|
|
216
|
+
const rateStartTs = window.firstDeltaTs ?? window.startTs;
|
|
217
|
+
const spanMs = window.endTs - rateStartTs;
|
|
218
|
+
if (window.chunkCount < MIN_RATE_CHUNKS || spanMs < MIN_RATE_SPAN_MS) {
|
|
219
|
+
return undefined;
|
|
220
|
+
}
|
|
221
|
+
|
|
222
|
+
const rate = outputTokens / (spanMs / 1000);
|
|
223
|
+
return Number.isFinite(rate) && rate <= MAX_PLAUSIBLE_RATE ? rate : undefined;
|
|
224
|
+
}
|
|
225
|
+
|
|
226
|
+
export function formatRate(rate: number | undefined): string | null {
|
|
227
|
+
return rate === undefined ? null : `${Math.round(rate)} t/s`;
|
|
228
|
+
}
|
|
229
|
+
|
|
230
|
+
/**
|
|
231
|
+
* A miss is previous-prompt tokens that should have been cache reads but were
|
|
232
|
+
* re-billed. Ported from pi's footer with one adaptation: there prompt tokens
|
|
233
|
+
* are `input + cacheRead + cacheWrite`, here `inputTokens` is already the total.
|
|
234
|
+
*
|
|
235
|
+
* Hits, the first request, and providers that never report caching are not
|
|
236
|
+
* misses. Small re-bills are noise, not misses.
|
|
237
|
+
*/
|
|
238
|
+
export function detectMiss(
|
|
239
|
+
previous: PreviousRequest | undefined,
|
|
240
|
+
usage: UsageLike,
|
|
241
|
+
): boolean {
|
|
242
|
+
const promptTokens = tokenCount(usage.inputTokens);
|
|
243
|
+
if (!previous || promptTokens <= 0) return false;
|
|
244
|
+
|
|
245
|
+
const cacheRead = tokenCount(usage.cacheReadTokens);
|
|
246
|
+
const cacheWrite = tokenCount(usage.cacheWriteTokens);
|
|
247
|
+
// A zero-cache request only counts when caching was seen before: on a
|
|
248
|
+
// cache-read-only provider that is a total miss, while on a provider that
|
|
249
|
+
// has never reported caching it means nothing.
|
|
250
|
+
if (cacheRead + cacheWrite === 0 && !previous.reportedCache) return false;
|
|
251
|
+
|
|
252
|
+
// min() guards a shrunk context: a prompt that got smaller cannot have been
|
|
253
|
+
// re-billed beyond its own size.
|
|
254
|
+
return Math.min(previous.promptTokens, promptTokens) - cacheRead > MISS_NOISE_FLOOR_TOKENS;
|
|
255
|
+
}
|
|
256
|
+
|
|
257
|
+
export function nextPrevious(
|
|
258
|
+
usage: UsageLike,
|
|
259
|
+
reportedCache: boolean,
|
|
260
|
+
): PreviousRequest | undefined {
|
|
261
|
+
const promptTokens = tokenCount(usage.inputTokens);
|
|
262
|
+
if (promptTokens <= 0) return undefined;
|
|
263
|
+
return {
|
|
264
|
+
promptTokens,
|
|
265
|
+
reportedCache:
|
|
266
|
+
reportedCache ||
|
|
267
|
+
tokenCount(usage.cacheReadTokens) + tokenCount(usage.cacheWriteTokens) > 0,
|
|
268
|
+
};
|
|
269
|
+
}
|
|
270
|
+
|
|
271
|
+
/** A miss outranks the ratio: a healthy percentage can still hide re-billed tokens. */
|
|
272
|
+
export function lastColor(ratio: number | null, missed: boolean): string {
|
|
273
|
+
return missed ? RED : colorFor(ratio);
|
|
274
|
+
}
|
|
275
|
+
|
|
276
|
+
// The rate segment is omitted, not shown as 0, while no measurement exists:
|
|
277
|
+
// "0 t/s" would claim the model is stalled rather than unknown.
|
|
278
|
+
function rateSuffix(rate: number | undefined, color: boolean): string {
|
|
279
|
+
const text = formatRate(rate);
|
|
280
|
+
if (text === null) return '';
|
|
281
|
+
return color ? ` ${DIM}|${RESET} ${CYAN}${text}${RESET}` : ` | ${text}`;
|
|
282
|
+
}
|
|
283
|
+
|
|
284
|
+
export function plainStatus(
|
|
285
|
+
snapshot: Snapshot,
|
|
286
|
+
rate: number | undefined,
|
|
287
|
+
missed: boolean,
|
|
288
|
+
): string {
|
|
289
|
+
return (
|
|
290
|
+
`cache last ${formatPercent(snapshot.last)} | avg ${formatPercent(snapshot.average)}` +
|
|
291
|
+
rateSuffix(rate, false) +
|
|
292
|
+
(missed ? ' | miss' : '')
|
|
293
|
+
);
|
|
294
|
+
}
|
|
295
|
+
|
|
296
|
+
// The footer renders through Ink's <Text>, which normalizes these codes instead
|
|
297
|
+
// of stripping them. Headless hosts print notices to a log, so they get the
|
|
298
|
+
// plain variant.
|
|
299
|
+
export function statusLine(
|
|
300
|
+
snapshot: Snapshot,
|
|
301
|
+
rate: number | undefined,
|
|
302
|
+
missed: boolean,
|
|
303
|
+
): string {
|
|
304
|
+
return (
|
|
305
|
+
`${DIM}cache${RESET} last ${lastColor(snapshot.last, missed)}${formatPercent(snapshot.last)}${RESET}` +
|
|
306
|
+
` ${DIM}| avg${RESET} ${colorFor(snapshot.average)}${formatPercent(snapshot.average)}${RESET}` +
|
|
307
|
+
rateSuffix(rate, true)
|
|
308
|
+
);
|
|
309
|
+
}
|
|
310
|
+
|
|
311
|
+
export function detailLine(
|
|
312
|
+
snapshot: Snapshot,
|
|
313
|
+
rate: number | undefined,
|
|
314
|
+
missed: boolean,
|
|
315
|
+
): string {
|
|
316
|
+
if (snapshot.requests === 0) return 'No request yet this session.';
|
|
317
|
+
const speed = formatRate(rate);
|
|
318
|
+
return (
|
|
319
|
+
`cache hit last ${formatPercent(snapshot.last)} ` +
|
|
320
|
+
`(${formatTokens(snapshot.cachedTokens)}/${formatTokens(snapshot.promptTokens)} prompt tokens) | ` +
|
|
321
|
+
`avg ${formatPercent(snapshot.average)} over ${snapshot.requests} request(s)` +
|
|
322
|
+
(speed === null ? '' : ` | ${speed} decode`) +
|
|
323
|
+
(missed ? ' | cache miss in last turn' : '')
|
|
324
|
+
);
|
|
325
|
+
}
|
|
326
|
+
|
|
327
|
+
export default function (cmd: ModApi): void {
|
|
328
|
+
let state = emptyState();
|
|
329
|
+
let restored = false;
|
|
330
|
+
let dirty = false;
|
|
331
|
+
|
|
332
|
+
// Decode window for the request in flight. cmd streams text and thinking
|
|
333
|
+
// deltas; tool-call arguments arrive whole, so they contribute no deltas.
|
|
334
|
+
let stream: {startTs: number; firstDeltaTs?: number; chunkCount: number} | null = null;
|
|
335
|
+
let rate: number | undefined;
|
|
336
|
+
|
|
337
|
+
// Miss tracking. A run is cmd's user turn: one prompt, several requests. An
|
|
338
|
+
// early request can miss while the last one hits, so the flag lives for the
|
|
339
|
+
// run rather than per request.
|
|
340
|
+
let previous: PreviousRequest | undefined;
|
|
341
|
+
let missedThisRun = false;
|
|
342
|
+
|
|
343
|
+
const repaint = (): void => {
|
|
344
|
+
const snapshot = snapshotOf(state);
|
|
345
|
+
if (cmd.ui.capabilities.status) cmd.ui.setStatus(statusLine(snapshot, rate, missedThisRun));
|
|
346
|
+
else cmd.ui.notify(plainStatus(snapshot, rate, missedThisRun));
|
|
347
|
+
};
|
|
348
|
+
|
|
349
|
+
// Seeding is idempotent: session_start covers the normal path, and the first
|
|
350
|
+
// request covers a host that binds without firing it. Restoring must happen
|
|
351
|
+
// before any request is counted, or the totals restart from zero.
|
|
352
|
+
const restore = (): void => {
|
|
353
|
+
if (restored) return;
|
|
354
|
+
const store = cmd.session;
|
|
355
|
+
if (!store) return;
|
|
356
|
+
restored = true;
|
|
357
|
+
|
|
358
|
+
const entries = store.getCustomEntries({customType: CUSTOM_TYPE});
|
|
359
|
+
const parsed = parseState(entries.at(-1)?.data);
|
|
360
|
+
if (!parsed) return;
|
|
361
|
+
|
|
362
|
+
state = parsed;
|
|
363
|
+
// Rebuild the miss baseline so a reload can still judge the next request.
|
|
364
|
+
// reportedCache is approximated from the last ratio: a request with cache
|
|
365
|
+
// reads proves the provider reports caching.
|
|
366
|
+
if (state.last) {
|
|
367
|
+
previous = {
|
|
368
|
+
promptTokens: state.last.promptTokens,
|
|
369
|
+
reportedCache: state.last.cachedTokens > 0,
|
|
370
|
+
};
|
|
371
|
+
}
|
|
372
|
+
if (cmd.ui.capabilities.status) {
|
|
373
|
+
cmd.ui.setStatus(statusLine(snapshotOf(state), rate, missedThisRun));
|
|
374
|
+
}
|
|
375
|
+
};
|
|
376
|
+
|
|
377
|
+
cmd.on('session_start', restore);
|
|
378
|
+
|
|
379
|
+
cmd.on('run_start', () => {
|
|
380
|
+
missedThisRun = false;
|
|
381
|
+
});
|
|
382
|
+
|
|
383
|
+
// Context legitimately changed, so the next request is new content being
|
|
384
|
+
// billed, not the old prompt being re-billed.
|
|
385
|
+
cmd.on('compaction_done', () => {
|
|
386
|
+
previous = undefined;
|
|
387
|
+
});
|
|
388
|
+
|
|
389
|
+
cmd.on('model_request_start', () => {
|
|
390
|
+
stream = {startTs: Date.now(), chunkCount: 0};
|
|
391
|
+
});
|
|
392
|
+
|
|
393
|
+
const noteDelta = (): void => {
|
|
394
|
+
if (!stream) return;
|
|
395
|
+
stream.firstDeltaTs ??= Date.now();
|
|
396
|
+
stream.chunkCount += 1;
|
|
397
|
+
};
|
|
398
|
+
|
|
399
|
+
cmd.on('text_delta', noteDelta);
|
|
400
|
+
cmd.on('thinking_delta', noteDelta);
|
|
401
|
+
|
|
402
|
+
cmd.on('model_request_end', event => {
|
|
403
|
+
const usage = (event as {usage?: UsageLike}).usage;
|
|
404
|
+
if (!usage) return;
|
|
405
|
+
restore();
|
|
406
|
+
|
|
407
|
+
const open = stream;
|
|
408
|
+
stream = null;
|
|
409
|
+
|
|
410
|
+
if (detectMiss(previous, usage)) missedThisRun = true;
|
|
411
|
+
previous = nextPrevious(usage, previous?.reportedCache ?? false) ?? previous;
|
|
412
|
+
|
|
413
|
+
state = applyRequest(state, cacheStat(usage));
|
|
414
|
+
dirty = true;
|
|
415
|
+
|
|
416
|
+
// A window that cannot be trusted keeps the previous reading: dropping
|
|
417
|
+
// to nothing would read as a stall, when the truth is "unmeasurable".
|
|
418
|
+
if (open) {
|
|
419
|
+
const measured = computeTokenRate(tokenCount(usage.outputTokens), {
|
|
420
|
+
startTs: open.startTs,
|
|
421
|
+
endTs: Date.now(),
|
|
422
|
+
...open.firstDeltaTs === undefined ? {} : {firstDeltaTs: open.firstDeltaTs},
|
|
423
|
+
chunkCount: open.chunkCount,
|
|
424
|
+
});
|
|
425
|
+
if (measured !== undefined) rate = measured;
|
|
426
|
+
}
|
|
427
|
+
|
|
428
|
+
repaint();
|
|
429
|
+
});
|
|
430
|
+
|
|
431
|
+
// One entry per run, not per request: the latest snapshot is all a restore
|
|
432
|
+
// needs, and the session tree grows one row per message instead of one row
|
|
433
|
+
// per model call.
|
|
434
|
+
cmd.on('run_end', () => {
|
|
435
|
+
const store = cmd.session;
|
|
436
|
+
if (!store || !dirty) return;
|
|
437
|
+
dirty = false;
|
|
438
|
+
store.appendCustomEntry({
|
|
439
|
+
customType: CUSTOM_TYPE,
|
|
440
|
+
data: serializeState(state),
|
|
441
|
+
});
|
|
442
|
+
});
|
|
443
|
+
|
|
444
|
+
cmd.addCommand({
|
|
445
|
+
name: 'cache',
|
|
446
|
+
description: 'Cache hit ratio and decode rate: last request and session average',
|
|
447
|
+
handler: () => ({message: detailLine(snapshotOf(state), rate, missedThisRun)}),
|
|
448
|
+
});
|
|
449
|
+
}
|