@tinoy/pi-deepseek-cost 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 +30 -0
- package/deepseek-cost.ts +749 -0
- package/package.json +40 -0
package/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Tinoy Thomas
|
|
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,30 @@
|
|
|
1
|
+
# @tinoy/pi-deepseek-cost
|
|
2
|
+
|
|
3
|
+
Price the session footer from the configured house tariff, per message timestamp.
|
|
4
|
+
|
|
5
|
+
```bash
|
|
6
|
+
pi install npm:@tinoy/pi-deepseek-cost
|
|
7
|
+
```
|
|
8
|
+
|
|
9
|
+
## What it needs at call time
|
|
10
|
+
|
|
11
|
+
A configured tariff table: without one, pricing is disabled and the reason names the file to write.
|
|
12
|
+
|
|
13
|
+
## Registers
|
|
14
|
+
|
|
15
|
+
no tool
|
|
16
|
+
|
|
17
|
+
## Works better with
|
|
18
|
+
|
|
19
|
+
| Neighbour | You gain | You lose without it | Install |
|
|
20
|
+
| --- | --- | --- | --- |
|
|
21
|
+
| `tariff.json` | the rates this machine is billed at | pricing is disabled and the footer prints no figure | not a package |
|
|
22
|
+
|
|
23
|
+
## Dependencies
|
|
24
|
+
|
|
25
|
+
pi-supplied imports (`@earendil-works/pi-coding-agent`) are peer dependencies with a `*` range and are never
|
|
26
|
+
bundled. Plain dependencies: `@tinoy/pi-ext-lib`, `@tinoy/pi-tariff`.
|
|
27
|
+
|
|
28
|
+
## Licence
|
|
29
|
+
|
|
30
|
+
MIT — see the repository [LICENSE](../../LICENSE).
|
package/deepseek-cost.ts
ADDED
|
@@ -0,0 +1,749 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* deepseek-cost: a CNY session cost that stays true across peak and valley.
|
|
3
|
+
*
|
|
4
|
+
* pi prices a model with ONE flat `cost` object, so it cannot express either a
|
|
5
|
+
* currency (its footer always prints `$`) or DeepSeek's peak/valley tariff. This
|
|
6
|
+
* extension owns the authoritative number instead:
|
|
7
|
+
*
|
|
8
|
+
* - every assistant message is priced at the tariff in force AT ITS OWN
|
|
9
|
+
* timestamp, so a session that runs across a window boundary — or across
|
|
10
|
+
* several — accumulates correctly; a RESUMED session is restored in full at
|
|
11
|
+
* session_start by pricing its own restored entries once, each at its own
|
|
12
|
+
* timestamp, so the footer figure survives a restart;
|
|
13
|
+
* - SUBAGENT sessions are included: pi-subagents persists each child's own
|
|
14
|
+
* session jsonl under `<parent session dir>/<childRunId>/run-<n>/`, and the
|
|
15
|
+
* child's assistant messages carry the same usage + timestamp shape as the
|
|
16
|
+
* parent's, so they are priced with the SAME tariff model and at the
|
|
17
|
+
* child's own timestamps, and summed into the SAME total: the footer figure
|
|
18
|
+
* is the whole account — this session plus every child it spawned — with no
|
|
19
|
+
* separate child figure and no marker to explain;
|
|
20
|
+
* - the running total is shown in the footer in BOTH currencies with the
|
|
21
|
+
* window it is currently in, next to pi's own `$` figure.
|
|
22
|
+
*
|
|
23
|
+
* The rates are not in this file: `@tinoy/pi-tariff` owns the table and reads it
|
|
24
|
+
* from `tariff.json` in this directory, and the module compiles no rate of its
|
|
25
|
+
* own — a machine with no such file prices NOTHING and gets a refusal naming the
|
|
26
|
+
* file to write (see the header of `@tinoy/pi-tariff`). The footer then stays
|
|
27
|
+
* empty, rather than showing a figure derived from the module's example table.
|
|
28
|
+
* Peak = Beijing time, Monday–Friday 09:00–12:00 and 14:00–18:00; everything
|
|
29
|
+
* else (including all weekend) is valley. V4 Pro is priced by its own entry in
|
|
30
|
+
* models.json and is not handled here.
|
|
31
|
+
*
|
|
32
|
+
* Subagent sources and why they are not used: the async run receipts
|
|
33
|
+
* pi-subagents writes (status.json, events.jsonl, recovery-descriptor)
|
|
34
|
+
* carry a run TOTAL per child (input/output/cacheRead/cacheWrite) but no
|
|
35
|
+
* per-request timestamps, so they cannot be priced at the tariff in force when
|
|
36
|
+
* each request was made — and the child session jsonl they point at carries
|
|
37
|
+
* exactly those messages, so a receipt is redundant. Only session files are
|
|
38
|
+
* read; each file is priced once (incremental byte offset), so a child that has
|
|
39
|
+
* both a session file and a receipt can never be counted twice.
|
|
40
|
+
*
|
|
41
|
+
* Cosmetic-by-contract at the edges: any failure leaves the footer untouched.
|
|
42
|
+
*/
|
|
43
|
+
|
|
44
|
+
import type { Dirent } from "node:fs";
|
|
45
|
+
import { closeSync, openSync, readdirSync, readSync, statSync } from "node:fs";
|
|
46
|
+
import { join } from "node:path";
|
|
47
|
+
import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
|
|
48
|
+
import { hookLog } from "@tinoy/pi-ext-lib";
|
|
49
|
+
import { liveTariff, loadTariff, type TariffTable, type Window } from "@tinoy/pi-tariff";
|
|
50
|
+
|
|
51
|
+
/** Rendered label per price window — SYMBOL ONLY; the internal names
|
|
52
|
+
* (`valley` = discounted/off-peak, `peak` = full price) stay for all logic.
|
|
53
|
+
* Both glyphs are single-cell in the terminal's Nerd Font (JetBrainsMono Nerd
|
|
54
|
+
* Font Mono: advance 1.00 em, verified), so the footer column never shifts.
|
|
55
|
+
* Swap the pair by editing this one line:
|
|
56
|
+
* \ue30d = nf-weather day-sunny (peak) · \ue390 = nf-weather thick crescent moon (off-peak)
|
|
57
|
+
* Alternatives: emoji "\u2600\ufe0f"/"\U0001f319" (double-width in kitty — shifts the column),
|
|
58
|
+
* or plain text arrows "\u2191"/"\u2193" (single-width, no Nerd Font needed). */
|
|
59
|
+
const WINDOW_GLYPH: Record<Window, string> = { peak: "\ue30d", valley: "\ue390" };
|
|
60
|
+
|
|
61
|
+
/** Peak windows in Beijing local time, as [startHour, endHour) pairs. */
|
|
62
|
+
const PEAK_WINDOWS: Array<[number, number]> = [
|
|
63
|
+
[9, 12],
|
|
64
|
+
[14, 18],
|
|
65
|
+
];
|
|
66
|
+
|
|
67
|
+
const BJT = new Intl.DateTimeFormat("en-US", {
|
|
68
|
+
timeZone: "Asia/Shanghai",
|
|
69
|
+
year: "numeric",
|
|
70
|
+
month: "2-digit",
|
|
71
|
+
day: "2-digit",
|
|
72
|
+
weekday: "short",
|
|
73
|
+
hour: "2-digit",
|
|
74
|
+
minute: "2-digit",
|
|
75
|
+
hour12: false,
|
|
76
|
+
});
|
|
77
|
+
|
|
78
|
+
/** Which tariff applies at `at` (defaults to now). */
|
|
79
|
+
export function windowAt(at: Date = new Date()): Window {
|
|
80
|
+
try {
|
|
81
|
+
const parts = BJT.formatToParts(at);
|
|
82
|
+
const get = (t: string) => parts.find((p) => p.type === t)?.value ?? "";
|
|
83
|
+
const day = get("weekday");
|
|
84
|
+
if (day === "Sat" || day === "Sun") return "valley";
|
|
85
|
+
const hour = Number(get("hour"));
|
|
86
|
+
const minute = Number(get("minute"));
|
|
87
|
+
if (Number.isNaN(hour)) return "valley";
|
|
88
|
+
const h = hour + minute / 60;
|
|
89
|
+
for (const [from, to] of PEAK_WINDOWS) if (h >= from && h < to) return "peak";
|
|
90
|
+
return "valley";
|
|
91
|
+
} catch {
|
|
92
|
+
return "valley"; // fail low: never overstate a bill
|
|
93
|
+
}
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
/**
|
|
97
|
+
* Time remaining in the CURRENT window, from the SAME logic and timezone basis
|
|
98
|
+
* the window label itself uses (no second definition): inside a peak window it
|
|
99
|
+
* is the next peak boundary, inside valley it is the START of the next peak
|
|
100
|
+
* window — weekend-aware, so a Friday-evening valley legitimately reads in days.
|
|
101
|
+
* Rounded DOWN at every magnitude (a remainder never overstates), and rendered
|
|
102
|
+
* as a fixed 3-character field beside the glyph so the footer never jitters.
|
|
103
|
+
*/
|
|
104
|
+
const BJT_OFFSET_MS = 8 * 3_600_000;
|
|
105
|
+
|
|
106
|
+
function bjtParts(at: Date): { y: number; mo: number; d: number; weekday: string; hour: number } {
|
|
107
|
+
const p = BJT.formatToParts(at);
|
|
108
|
+
const get = (t: string) => p.find((x) => x.type === t)?.value ?? "";
|
|
109
|
+
return {
|
|
110
|
+
y: Number(get("year")),
|
|
111
|
+
mo: Number(get("month")),
|
|
112
|
+
d: Number(get("day")),
|
|
113
|
+
weekday: get("weekday"),
|
|
114
|
+
hour: Number(get("hour")),
|
|
115
|
+
};
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
/** A BJT wall-clock instant → epoch ms (Asia/Shanghai is a fixed UTC+8, no DST). */
|
|
119
|
+
function bjtWallToEpoch(y: number, mo: number, d: number, h: number): number {
|
|
120
|
+
return Date.UTC(y, mo - 1, d, h, 0, 0, 0) - BJT_OFFSET_MS;
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
/** The instant the window in force at `at` ends. */
|
|
124
|
+
export function windowEndAt(at: Date = new Date()): Date {
|
|
125
|
+
const now = at.getTime();
|
|
126
|
+
const p = bjtParts(at);
|
|
127
|
+
if (windowAt(at) === "peak")
|
|
128
|
+
return new Date(bjtWallToEpoch(p.y, p.mo, p.d, p.hour < 12 ? 12 : 18));
|
|
129
|
+
// Valley: the next weekday peak START (09:00 or 14:00 BJT), scanning up to a week.
|
|
130
|
+
for (let k = 0; k <= 7; k++) {
|
|
131
|
+
const day = new Date(bjtWallToEpoch(p.y, p.mo, p.d, 12) + k * 86_400_000);
|
|
132
|
+
const dp = bjtParts(day);
|
|
133
|
+
if (dp.weekday === "Sat" || dp.weekday === "Sun") continue;
|
|
134
|
+
for (const h of [9, 14]) {
|
|
135
|
+
const cand = bjtWallToEpoch(dp.y, dp.mo, dp.d, h);
|
|
136
|
+
if (cand > now) return new Date(cand);
|
|
137
|
+
}
|
|
138
|
+
}
|
|
139
|
+
return new Date(now);
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
/** The remaining time, floored: "2d" | "5h" | "47m" | "9s". */
|
|
143
|
+
export function remainingLabel(at: Date = new Date()): string {
|
|
144
|
+
const s = Math.floor(Math.max(0, windowEndAt(at).getTime() - at.getTime()) / 1000);
|
|
145
|
+
if (s >= 86_400) return `${Math.floor(s / 86_400)}d`;
|
|
146
|
+
if (s >= 3_600) return `${Math.floor(s / 3_600)}h`;
|
|
147
|
+
if (s >= 60) return `${Math.floor(s / 60)}m`;
|
|
148
|
+
return `${s}s`;
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
/** The footer field: the glyph (1 cell) + a right-aligned 3-char remainder. */
|
|
152
|
+
export function windowLabel(w: Window, at: Date = new Date()): string {
|
|
153
|
+
return `${WINDOW_GLYPH[w]} ${remainingLabel(at).padStart(3)}`;
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
export interface Usage {
|
|
157
|
+
input?: number;
|
|
158
|
+
output?: number;
|
|
159
|
+
cacheRead?: number;
|
|
160
|
+
cacheWrite?: number;
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
/** Cost for one message's usage, at the tariff in force at `at`. A table passed
|
|
164
|
+
* in is used as given; otherwise the CONFIGURED table is used, and a machine
|
|
165
|
+
* with none gets `liveTariff()`'s refusal instead of a price. */
|
|
166
|
+
export function costOf(usage: Usage, at: Date = new Date(), table?: TariffTable): number {
|
|
167
|
+
const r = (table ?? liveTariff().cny)[windowAt(at)];
|
|
168
|
+
const per = (tokens: number | undefined, rate: number) => ((tokens ?? 0) / 1_000_000) * rate;
|
|
169
|
+
// DeepSeek bills a cache write as cache-miss input; there is no separate rate.
|
|
170
|
+
return (
|
|
171
|
+
per(usage.input, r.cacheMiss) +
|
|
172
|
+
per(usage.cacheWrite, r.cacheMiss) +
|
|
173
|
+
per(usage.cacheRead, r.cacheHit) +
|
|
174
|
+
per(usage.output, r.output)
|
|
175
|
+
);
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
/** The same figure in USD. */
|
|
179
|
+
export function costOfUsd(usage: Usage, at: Date = new Date(), table?: TariffTable): number {
|
|
180
|
+
return costOf(usage, at, table ?? liveTariff().usd);
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
const isDeepseekFlash = (model: unknown): boolean => {
|
|
184
|
+
const id = typeof model === "string" ? model : ((model as { id?: string } | undefined)?.id ?? "");
|
|
185
|
+
return /deepseek-flash/i.test(id);
|
|
186
|
+
};
|
|
187
|
+
|
|
188
|
+
/**
|
|
189
|
+
* The child session files of a parent session.
|
|
190
|
+
*
|
|
191
|
+
* pi-subagents persists each child as
|
|
192
|
+
* `<parent session file without .jsonl>/<childRunId>/run-<n>/session.jsonl`
|
|
193
|
+
* (a resumed child appends a new `run-<n>` dir). Nested children would sit under
|
|
194
|
+
* their own parent's session dir, so the walk descends a few levels.
|
|
195
|
+
*
|
|
196
|
+
* Unusable alternatives: the async run receipts pi-subagents writes per run
|
|
197
|
+
* (status.json, events.jsonl, recovery-descriptor.json, subagent-log) record a
|
|
198
|
+
* per-child run TOTAL without per-request timestamps — no peak/valley window can
|
|
199
|
+
* be applied to them — and a launch ledger records a spawned process with a
|
|
200
|
+
* session path but nothing priceable.
|
|
201
|
+
*/
|
|
202
|
+
function childSessionFiles(sessionFile: string): string[] {
|
|
203
|
+
const dir = sessionFile.endsWith(".jsonl") ? sessionFile.slice(0, -".jsonl".length) : sessionFile;
|
|
204
|
+
const found: string[] = [];
|
|
205
|
+
const walk = (at: string, depth: number): void => {
|
|
206
|
+
if (depth > CHILD_SCAN_DEPTH) return;
|
|
207
|
+
let entries: Dirent[];
|
|
208
|
+
try {
|
|
209
|
+
entries = readdirSync(at, { withFileTypes: true });
|
|
210
|
+
} catch {
|
|
211
|
+
return; // absent/unreadable dir = no children
|
|
212
|
+
}
|
|
213
|
+
for (const entry of entries) {
|
|
214
|
+
const full = join(at, entry.name);
|
|
215
|
+
if (entry.isDirectory()) walk(full, depth + 1);
|
|
216
|
+
else if (entry.name === "session.jsonl") found.push(full);
|
|
217
|
+
}
|
|
218
|
+
};
|
|
219
|
+
walk(dir, 0);
|
|
220
|
+
return found;
|
|
221
|
+
}
|
|
222
|
+
|
|
223
|
+
/** How deep a child of a child's session dir is looked for. */
|
|
224
|
+
const CHILD_SCAN_DEPTH = 6;
|
|
225
|
+
|
|
226
|
+
/** How often the child sessions are re-priced while this session is open. */
|
|
227
|
+
const SUBAGENT_SCAN_MS = 2000;
|
|
228
|
+
|
|
229
|
+
/** Largest chunk priced from one child file per scan (a live child appends in
|
|
230
|
+
* small pieces; a huge backlog is spread over the following scans). */
|
|
231
|
+
const CHILD_READ_CHUNK = 4 * 1024 * 1024;
|
|
232
|
+
|
|
233
|
+
/** What one child session file has contributed to the totals so far. */
|
|
234
|
+
interface ChildFileState {
|
|
235
|
+
/** Bytes already priced (a partial trailing line is left for the next scan). */
|
|
236
|
+
offset: number;
|
|
237
|
+
cny: number;
|
|
238
|
+
usd: number;
|
|
239
|
+
messages: number;
|
|
240
|
+
}
|
|
241
|
+
|
|
242
|
+
/** The footer UI surface (theme is optional so a bare ctx.ui still renders). */
|
|
243
|
+
interface FootUi {
|
|
244
|
+
setStatus?: (key: string, value: string | undefined) => void;
|
|
245
|
+
theme?: { fg?: (color: string, text: string) => string };
|
|
246
|
+
}
|
|
247
|
+
|
|
248
|
+
// ── Cache-miss notice ──
|
|
249
|
+
//
|
|
250
|
+
// pi itself renders a "Cache miss" transcript notice (core: addCacheMissNotice),
|
|
251
|
+
// but its cost comes from `missedCost`, derived from the message's `usage.cost` —
|
|
252
|
+
// which pi fills from the model's `cost` metadata. models.json's Flash entry carries
|
|
253
|
+
// no cost fields, so pi's flat `$` figure never competes with this extension's tariff
|
|
254
|
+
// and that notice prints no cost at all.
|
|
255
|
+
//
|
|
256
|
+
// The notice here is the same signal, costed by THIS extension: the re-billed
|
|
257
|
+
// tokens are charged the cache-miss rate minus the cache-hit rate they would
|
|
258
|
+
// otherwise have cost, in BOTH currencies, at the tariff in force for the
|
|
259
|
+
// REQUEST'S OWN timestamp. It never reads model metadata.
|
|
260
|
+
|
|
261
|
+
/** pi's noise floor for counting a miss at all (core: NOISE_FLOOR_TOKENS). */
|
|
262
|
+
const MISS_NOISE_FLOOR_TOKENS = 1024;
|
|
263
|
+
/** pi's significance gate for showing a notice (core: missedTokens < 20000 &&
|
|
264
|
+
* missedCost < 0.1 returns early). The cost half is ours, in USD. */
|
|
265
|
+
const MISS_NOTICE_TOKENS = 20_000;
|
|
266
|
+
const MISS_NOTICE_COST_USD = 0.1;
|
|
267
|
+
/** pi's prompt-cache TTL (core: CACHE_TTL_MS) — the idle-miss label. */
|
|
268
|
+
const CACHE_TTL_MS = 5 * 60 * 1000;
|
|
269
|
+
|
|
270
|
+
/** One significant prompt-cache miss, priced by this extension. */
|
|
271
|
+
export interface CacheMiss {
|
|
272
|
+
/** Prompt tokens that were re-billed instead of being cache hits. */
|
|
273
|
+
tokens: number;
|
|
274
|
+
usd: number;
|
|
275
|
+
cny: number;
|
|
276
|
+
window: Window;
|
|
277
|
+
label: string;
|
|
278
|
+
/** The message's own timestamp (ms). */
|
|
279
|
+
at: number;
|
|
280
|
+
}
|
|
281
|
+
|
|
282
|
+
/** Compact token count, the same shape pi prints (`12k`, `1.1M`). */
|
|
283
|
+
export function formatTokenCount(count: number): string {
|
|
284
|
+
if (count < 1000) return count.toString();
|
|
285
|
+
if (count < 1e4) return `${(count / 1000).toFixed(1)}k`;
|
|
286
|
+
if (count < 1e6) return `${Math.round(count / 1000)}k`;
|
|
287
|
+
if (count < 1e7) return `${(count / 1e6).toFixed(1)}M`;
|
|
288
|
+
return `${Math.round(count / 1e6)}M`;
|
|
289
|
+
}
|
|
290
|
+
|
|
291
|
+
/** The inline alert line: same presentation rules as the footer (`$x.xxxx
|
|
292
|
+
* ¥x.xxxx window`), plus the token count pi reports. */
|
|
293
|
+
export function cacheMissNoticeText(m: CacheMiss): string {
|
|
294
|
+
return `${m.label}: ${formatTokenCount(m.tokens)} tokens re-billed ~$${m.usd.toFixed(4)} ¥${m.cny.toFixed(4)} ${windowLabel(m.window, new Date(m.at))}`;
|
|
295
|
+
}
|
|
296
|
+
|
|
297
|
+
/**
|
|
298
|
+
* SUPPRESSION OF PI CORE'S COST-LESS CACHE-MISS NOTICE — why this patch exists.
|
|
299
|
+
*
|
|
300
|
+
* Core renders its own "Cache miss: <N> tokens re-billed" line by appending a raw
|
|
301
|
+
* Spacer + Text to the chat container (`addCacheMissNotice`). That is NOT a session entry, so
|
|
302
|
+
* `pi.registerEntryRenderer` (custom entries only) cannot reach it, and
|
|
303
|
+
* `registerMessageRenderer` / `registerMarkdownTransformer` see messages / markdown only —
|
|
304
|
+
* `docs/extensions.md` documents no notice or chat-container surface at all. The ONE documented
|
|
305
|
+
* gate is the shared `showCacheMissNotices` setting (docs/settings.md:35), which also silences
|
|
306
|
+
* compaction/branch-summary usage notices and provider-recovery diagnostics — not a targeted
|
|
307
|
+
* option. This extension already emits the same event WITH its cost (USD + CNY + window glyph +
|
|
308
|
+
* remainder), so the duplicate is removed here by no-op'ing that single method.
|
|
309
|
+
*
|
|
310
|
+
* Deliberately narrow: one bounded lookup from the TUI handle the `setFooter` callback provides,
|
|
311
|
+
* one assignment. FAIL SOFT — if the shape is not found (pi updated, renamed, no UI) nothing
|
|
312
|
+
* happens and exactly one log line says so: a missing patch degrades to two notices, never to a
|
|
313
|
+
* broken TUI. RE-CHECK after every pi upgrade; the log line is the tell. Written against
|
|
314
|
+
* pi 0.85.1 (`pi --version`).
|
|
315
|
+
*/
|
|
316
|
+
export function suppressCoreCacheMissNotice(
|
|
317
|
+
tui: unknown,
|
|
318
|
+
report: (message: string) => void,
|
|
319
|
+
): { applied: boolean; reason: string } {
|
|
320
|
+
try {
|
|
321
|
+
const seen = new Set<unknown>();
|
|
322
|
+
const queue: Array<{ node: unknown; depth: number }> = [{ node: tui, depth: 0 }];
|
|
323
|
+
let visited = 0;
|
|
324
|
+
while (queue.length > 0 && visited < 200) {
|
|
325
|
+
const { node, depth } = queue.shift() as { node: unknown; depth: number };
|
|
326
|
+
if (!node || typeof node !== "object" || seen.has(node)) continue;
|
|
327
|
+
seen.add(node);
|
|
328
|
+
visited++;
|
|
329
|
+
const holder = node as Record<string, unknown>;
|
|
330
|
+
if (typeof holder.addCacheMissNotice === "function") {
|
|
331
|
+
// One assignment: an own property shadows the prototype method.
|
|
332
|
+
holder.addCacheMissNotice = (): void => {};
|
|
333
|
+
report(
|
|
334
|
+
"[deepseek-cost] core cache-miss notice suppressed (no documented API covers it; see the comment above)",
|
|
335
|
+
);
|
|
336
|
+
return { applied: true, reason: "addCacheMissNotice found and no-op'ed" };
|
|
337
|
+
}
|
|
338
|
+
if (depth >= 3) continue;
|
|
339
|
+
const children = holder.children;
|
|
340
|
+
if (Array.isArray(children))
|
|
341
|
+
for (const child of children) queue.push({ node: child, depth: depth + 1 });
|
|
342
|
+
}
|
|
343
|
+
report(
|
|
344
|
+
"[deepseek-cost] cache-miss suppression NOT applied: no addCacheMissNotice in the TUI tree (pi version drift?) — two notice lines will show",
|
|
345
|
+
);
|
|
346
|
+
return { applied: false, reason: "addCacheMissNotice not found" };
|
|
347
|
+
} catch (e) {
|
|
348
|
+
report(
|
|
349
|
+
`[deepseek-cost] cache-miss suppression NOT applied: ${String(e)} — two notice lines will show`,
|
|
350
|
+
);
|
|
351
|
+
return { applied: false, reason: String(e) };
|
|
352
|
+
}
|
|
353
|
+
}
|
|
354
|
+
|
|
355
|
+
/** TUI-only: borrow the TUI object through the documented footer callback, then
|
|
356
|
+
* restore the built-in footer in the same tick so nothing user-visible changes. */
|
|
357
|
+
function attachCacheMissSuppression(ctx: { ui?: unknown; mode?: string }): void {
|
|
358
|
+
if (ctx?.mode !== "tui") return;
|
|
359
|
+
const ui = ctx.ui as { setFooter?: (cb?: unknown) => void } | undefined;
|
|
360
|
+
if (typeof ui?.setFooter !== "function") return;
|
|
361
|
+
let tui: unknown = null;
|
|
362
|
+
try {
|
|
363
|
+
ui.setFooter((handle: unknown) => {
|
|
364
|
+
tui = handle;
|
|
365
|
+
return { render: () => [], invalidate: () => {} };
|
|
366
|
+
});
|
|
367
|
+
} catch (e) {
|
|
368
|
+
reportSuppression(
|
|
369
|
+
`[deepseek-cost] cache-miss suppression NOT applied: setFooter failed (${String(e)})`,
|
|
370
|
+
);
|
|
371
|
+
return;
|
|
372
|
+
} finally {
|
|
373
|
+
try {
|
|
374
|
+
ui.setFooter(undefined); // documented: restore the built-in footer
|
|
375
|
+
} catch {
|
|
376
|
+
/* the built-in footer stays if this fails; the patch below is cosmetic either way */
|
|
377
|
+
}
|
|
378
|
+
}
|
|
379
|
+
if (!tui) {
|
|
380
|
+
reportSuppression(
|
|
381
|
+
"[deepseek-cost] cache-miss suppression NOT applied: no TUI handle from setFooter — two notice lines will show",
|
|
382
|
+
);
|
|
383
|
+
return;
|
|
384
|
+
}
|
|
385
|
+
suppressCoreCacheMissNotice(tui, reportSuppression);
|
|
386
|
+
}
|
|
387
|
+
|
|
388
|
+
/** One quiet log line is the whole contract of the fail-soft path. */
|
|
389
|
+
function reportSuppression(message: string): void {
|
|
390
|
+
try {
|
|
391
|
+
hookLog("deepseek-cost", "suppressed", { message });
|
|
392
|
+
} catch {
|
|
393
|
+
/* logging must never throw into the session */
|
|
394
|
+
}
|
|
395
|
+
}
|
|
396
|
+
|
|
397
|
+
export default function (pi: ExtensionAPI): void {
|
|
398
|
+
// No configured table, no pricing: this extension registers nothing at all
|
|
399
|
+
// rather than show a footer figure derived from the module's example table, and
|
|
400
|
+
// the one line it logs names the file to write. The read happens here, never in
|
|
401
|
+
// the package's module body.
|
|
402
|
+
const tariff = loadTariff();
|
|
403
|
+
if (!tariff.ok) {
|
|
404
|
+
reportSuppression(`[deepseek-cost] pricing disabled — ${tariff.reason}`);
|
|
405
|
+
return;
|
|
406
|
+
}
|
|
407
|
+
const table = tariff.table;
|
|
408
|
+
let totalCny = 0;
|
|
409
|
+
let totalUsd = 0;
|
|
410
|
+
let pricedMessages = 0;
|
|
411
|
+
let lastWindow: Window | null = null;
|
|
412
|
+
let lastPricedMs = 0;
|
|
413
|
+
|
|
414
|
+
// ── subagent usage (child session files of this session) ──
|
|
415
|
+
let subCny = 0;
|
|
416
|
+
let subUsd = 0;
|
|
417
|
+
let subMessages = 0;
|
|
418
|
+
const childFiles = new Map<string, ChildFileState>();
|
|
419
|
+
/** Timestamps the session_start seed has already billed — the boundary that
|
|
420
|
+
* keeps a replayed restored message out of the live pricing path. */
|
|
421
|
+
const seededMessages = new Set<number>();
|
|
422
|
+
let sessionFile: string | null = null;
|
|
423
|
+
let scanTimer: ReturnType<typeof setInterval> | null = null;
|
|
424
|
+
let footUi: FootUi | null = null;
|
|
425
|
+
/** The previous assistant request of THIS session — the baseline pi's own
|
|
426
|
+
* cache-miss detector compares against (input+cacheRead+cacheWrite, the
|
|
427
|
+
* model key, the timestamp and whether it reported any cache activity). */
|
|
428
|
+
let prevRequest: {
|
|
429
|
+
promptTokens: number;
|
|
430
|
+
modelKey: string;
|
|
431
|
+
timestamp: number;
|
|
432
|
+
reportedCache: boolean;
|
|
433
|
+
} | null = null;
|
|
434
|
+
|
|
435
|
+
/** Price one significant cache miss and append it to the trajectory. Mirrors
|
|
436
|
+
* pi's detectMiss thresholds so the alert appears exactly when pi's own
|
|
437
|
+
* notice would; every number comes from this extension's tariff. */
|
|
438
|
+
const noteCacheMiss = (msg: {
|
|
439
|
+
usage?: Usage;
|
|
440
|
+
model?: string;
|
|
441
|
+
provider?: string;
|
|
442
|
+
timestamp?: number;
|
|
443
|
+
}): void => {
|
|
444
|
+
const prev = prevRequest;
|
|
445
|
+
const u = msg.usage;
|
|
446
|
+
if (!prev || !u) return;
|
|
447
|
+
const input = u.input ?? 0;
|
|
448
|
+
const cacheRead = u.cacheRead ?? 0;
|
|
449
|
+
const cacheWrite = u.cacheWrite ?? 0;
|
|
450
|
+
const promptTokens = input + cacheRead + cacheWrite;
|
|
451
|
+
if (promptTokens <= 0) return;
|
|
452
|
+
if (cacheRead + cacheWrite === 0 && !prev.reportedCache) return;
|
|
453
|
+
const tokens = Math.min(prev.promptTokens, promptTokens) - cacheRead;
|
|
454
|
+
if (tokens <= MISS_NOISE_FLOOR_TOKENS) return;
|
|
455
|
+
const at = msg.timestamp ? new Date(msg.timestamp) : new Date();
|
|
456
|
+
const w = windowAt(at);
|
|
457
|
+
// What the re-billed tokens cost ABOVE the cache-hit rate: both currencies,
|
|
458
|
+
// at the tariff in force for this request's own timestamp.
|
|
459
|
+
const usd = (tokens * (table.usd[w].cacheMiss - table.usd[w].cacheHit)) / 1_000_000;
|
|
460
|
+
const cny = (tokens * (table.cny[w].cacheMiss - table.cny[w].cacheHit)) / 1_000_000;
|
|
461
|
+
if (tokens < MISS_NOTICE_TOKENS && usd < MISS_NOTICE_COST_USD) return;
|
|
462
|
+
const idleMs = Math.max(0, (msg.timestamp ?? 0) - prev.timestamp);
|
|
463
|
+
const modelKey = `${msg.provider ?? ""}/${msg.model ?? ""}`;
|
|
464
|
+
const label =
|
|
465
|
+
modelKey !== prev.modelKey
|
|
466
|
+
? "Cache miss after model switch"
|
|
467
|
+
: idleMs >= CACHE_TTL_MS
|
|
468
|
+
? `Cache miss after ${Math.round(idleMs / 60_000)}m idle`
|
|
469
|
+
: "Cache miss";
|
|
470
|
+
pi.appendEntry("deepseek-cost-miss", {
|
|
471
|
+
tokens,
|
|
472
|
+
usd,
|
|
473
|
+
cny,
|
|
474
|
+
window: w,
|
|
475
|
+
label,
|
|
476
|
+
at: msg.timestamp ?? 0,
|
|
477
|
+
} satisfies CacheMiss);
|
|
478
|
+
};
|
|
479
|
+
|
|
480
|
+
// Register the ALWAYS-CHECKED editor. The renderer uses the theme passed to
|
|
481
|
+
// it and returns a minimal text component (pi-tui is bundled inside pi and is
|
|
482
|
+
// not importable from an extension), so a failure can only drop the line.
|
|
483
|
+
pi.registerEntryRenderer("deepseek-cost-miss", ((
|
|
484
|
+
entry: { data?: unknown },
|
|
485
|
+
_opts: unknown,
|
|
486
|
+
theme: { fg?: (c: string, s: string) => string },
|
|
487
|
+
) => {
|
|
488
|
+
const data = entry?.data as CacheMiss | undefined;
|
|
489
|
+
if (!data) return undefined;
|
|
490
|
+
const text = cacheMissNoticeText(data);
|
|
491
|
+
const themed = theme?.fg ? theme.fg("warning", text) : text;
|
|
492
|
+
return {
|
|
493
|
+
render: (width: number) => [themed.slice(0, Math.max(1, width))],
|
|
494
|
+
invalidate: () => {},
|
|
495
|
+
};
|
|
496
|
+
}) as never);
|
|
497
|
+
|
|
498
|
+
/** The footer text, or undefined when nothing has been priced yet. */
|
|
499
|
+
const renderText = (): string | undefined => {
|
|
500
|
+
if (!pricedMessages && !subMessages) return undefined;
|
|
501
|
+
const w = lastWindow ?? windowAt();
|
|
502
|
+
// ONE total in both currencies: the child sessions are billed to the same
|
|
503
|
+
// account, so they are summed in rather than shown as a second figure.
|
|
504
|
+
const total = `$${(totalUsd + subUsd).toFixed(4)} ¥${(totalCny + subCny).toFixed(4)} ${windowLabel(w)}`;
|
|
505
|
+
return total;
|
|
506
|
+
};
|
|
507
|
+
|
|
508
|
+
const renderWith = (ui: FootUi | null): void => {
|
|
509
|
+
try {
|
|
510
|
+
const text = renderText();
|
|
511
|
+
if (text === undefined) {
|
|
512
|
+
ui?.setStatus?.("cost-cny", undefined);
|
|
513
|
+
return;
|
|
514
|
+
}
|
|
515
|
+
const themed = ui?.theme?.fg ? ui.theme.fg("muted", text) : text;
|
|
516
|
+
ui?.setStatus?.("cost-cny", themed);
|
|
517
|
+
} catch {
|
|
518
|
+
/* footer is cosmetic */
|
|
519
|
+
}
|
|
520
|
+
};
|
|
521
|
+
|
|
522
|
+
const render = (ctx: { ui?: FootUi }): void => {
|
|
523
|
+
if (ctx?.ui) footUi = ctx.ui;
|
|
524
|
+
renderWith(ctx?.ui ?? footUi);
|
|
525
|
+
};
|
|
526
|
+
|
|
527
|
+
/** Price the bytes one child session file has appended since the last scan.
|
|
528
|
+
* A child is priced exactly once: the byte offset advances past every
|
|
529
|
+
* complete line consumed, and a file that got shorter (rewritten) has its
|
|
530
|
+
* already-counted contribution removed before it is read again. */
|
|
531
|
+
const priceChildFile = (file: string): void => {
|
|
532
|
+
let state = childFiles.get(file);
|
|
533
|
+
if (!state) {
|
|
534
|
+
state = { offset: 0, cny: 0, usd: 0, messages: 0 };
|
|
535
|
+
childFiles.set(file, state);
|
|
536
|
+
}
|
|
537
|
+
const size = statSync(file).size;
|
|
538
|
+
if (size < state.offset) {
|
|
539
|
+
subCny -= state.cny;
|
|
540
|
+
subUsd -= state.usd;
|
|
541
|
+
subMessages -= state.messages;
|
|
542
|
+
state.offset = 0;
|
|
543
|
+
state.cny = 0;
|
|
544
|
+
state.usd = 0;
|
|
545
|
+
state.messages = 0;
|
|
546
|
+
}
|
|
547
|
+
if (size <= state.offset) return;
|
|
548
|
+
const want = Math.min(size - state.offset, CHILD_READ_CHUNK);
|
|
549
|
+
const buf = Buffer.allocUnsafe(want);
|
|
550
|
+
const fd = openSync(file, "r");
|
|
551
|
+
let read = 0;
|
|
552
|
+
try {
|
|
553
|
+
read = readSync(fd, buf, 0, want, state.offset);
|
|
554
|
+
} finally {
|
|
555
|
+
closeSync(fd);
|
|
556
|
+
}
|
|
557
|
+
if (read <= 0) return;
|
|
558
|
+
const chunk = buf.subarray(0, read);
|
|
559
|
+
// Only complete lines are consumed: a child mid-write leaves its last,
|
|
560
|
+
// unterminated line for the next scan (it is re-read then, once).
|
|
561
|
+
const lastNl = chunk.lastIndexOf(0x0a);
|
|
562
|
+
if (lastNl < 0) return;
|
|
563
|
+
for (const line of chunk.subarray(0, lastNl).toString("utf8").split("\n")) {
|
|
564
|
+
if (!line.trim()) continue;
|
|
565
|
+
let entry: { type?: string; message?: unknown };
|
|
566
|
+
try {
|
|
567
|
+
entry = JSON.parse(line);
|
|
568
|
+
} catch {
|
|
569
|
+
continue; // a corrupt line is skipped, never fatal
|
|
570
|
+
}
|
|
571
|
+
if (entry?.type !== "message") continue;
|
|
572
|
+
const msg = entry.message as
|
|
573
|
+
| { role?: string; model?: string; timestamp?: number; usage?: Usage }
|
|
574
|
+
| undefined;
|
|
575
|
+
if (msg?.role !== "assistant" || !msg.usage) continue;
|
|
576
|
+
// Price only the models this extension owns; a child running another
|
|
577
|
+
// model (or one that declares none) is left to pi's own accounting.
|
|
578
|
+
if (!isDeepseekFlash(msg.model)) continue;
|
|
579
|
+
const at = msg.timestamp ? new Date(msg.timestamp) : new Date();
|
|
580
|
+
const cny = costOf(msg.usage, at, table.cny);
|
|
581
|
+
const usd = costOfUsd(msg.usage, at, table.usd);
|
|
582
|
+
subCny += cny;
|
|
583
|
+
subUsd += usd;
|
|
584
|
+
subMessages++;
|
|
585
|
+
state.cny += cny;
|
|
586
|
+
state.usd += usd;
|
|
587
|
+
state.messages++;
|
|
588
|
+
if (at.getTime() >= lastPricedMs) {
|
|
589
|
+
lastPricedMs = at.getTime();
|
|
590
|
+
lastWindow = windowAt(at);
|
|
591
|
+
}
|
|
592
|
+
}
|
|
593
|
+
state.offset += lastNl + 1;
|
|
594
|
+
};
|
|
595
|
+
|
|
596
|
+
/** Price every child session file that has new bytes. Never throws: a missing
|
|
597
|
+
* or partial child file must not disturb the footer or the turn. */
|
|
598
|
+
const scanSubagents = (): void => {
|
|
599
|
+
try {
|
|
600
|
+
if (!sessionFile) return;
|
|
601
|
+
for (const file of childSessionFiles(sessionFile)) {
|
|
602
|
+
try {
|
|
603
|
+
priceChildFile(file);
|
|
604
|
+
} catch {
|
|
605
|
+
/* unreadable child file — skip it, keep the rest */
|
|
606
|
+
}
|
|
607
|
+
}
|
|
608
|
+
} catch {
|
|
609
|
+
/* scanning is cosmetic */
|
|
610
|
+
}
|
|
611
|
+
};
|
|
612
|
+
|
|
613
|
+
pi.on("message_end", (event, ctx) => {
|
|
614
|
+
try {
|
|
615
|
+
const msg = event.message as {
|
|
616
|
+
role?: string;
|
|
617
|
+
model?: string;
|
|
618
|
+
provider?: string;
|
|
619
|
+
timestamp?: number;
|
|
620
|
+
usage?: Usage;
|
|
621
|
+
};
|
|
622
|
+
if (msg?.role !== "assistant" || !msg.usage) return;
|
|
623
|
+
// A restored message is never priced twice: every timestamp the
|
|
624
|
+
// session_start seed billed is a boundary, not new work.
|
|
625
|
+
if (msg.timestamp !== undefined && seededMessages.has(msg.timestamp)) return;
|
|
626
|
+
// Cache-miss bookkeeping runs for EVERY assistant message (pi's own
|
|
627
|
+
// detector compares against the previous request whatever its model),
|
|
628
|
+
// then the baseline moves to this message.
|
|
629
|
+
noteCacheMiss(msg);
|
|
630
|
+
const input = msg.usage.input ?? 0;
|
|
631
|
+
const cacheRead = msg.usage.cacheRead ?? 0;
|
|
632
|
+
const cacheWrite = msg.usage.cacheWrite ?? 0;
|
|
633
|
+
prevRequest = {
|
|
634
|
+
promptTokens: input + cacheRead + cacheWrite,
|
|
635
|
+
modelKey: `${msg.provider ?? ""}/${msg.model ?? ""}`,
|
|
636
|
+
timestamp: msg.timestamp ?? 0,
|
|
637
|
+
reportedCache: cacheRead + cacheWrite > 0,
|
|
638
|
+
};
|
|
639
|
+
const active = (ctx as { model?: { id?: string } }).model;
|
|
640
|
+
// Price on the MESSAGE's own model when it declares one — a batch can
|
|
641
|
+
// carry another model's message (subagent, mid-session switch) and the
|
|
642
|
+
// session's active model must not be used to price it. The active model
|
|
643
|
+
// is the fallback only when the message says nothing.
|
|
644
|
+
const modelId = msg.model ?? active?.id ?? "";
|
|
645
|
+
if (!isDeepseekFlash(modelId)) return;
|
|
646
|
+
const at = msg.timestamp ? new Date(msg.timestamp) : new Date();
|
|
647
|
+
const w = windowAt(at);
|
|
648
|
+
totalCny += costOf(msg.usage, at, table.cny);
|
|
649
|
+
totalUsd += costOfUsd(msg.usage, at, table.usd);
|
|
650
|
+
if (at.getTime() >= lastPricedMs) lastPricedMs = at.getTime();
|
|
651
|
+
lastWindow = w;
|
|
652
|
+
pricedMessages++;
|
|
653
|
+
scanSubagents();
|
|
654
|
+
render(ctx as never);
|
|
655
|
+
} catch {
|
|
656
|
+
/* a pricing bug must never disturb the turn */
|
|
657
|
+
}
|
|
658
|
+
});
|
|
659
|
+
|
|
660
|
+
pi.on("session_start", (_event, ctx) => {
|
|
661
|
+
attachCacheMissSuppression(ctx as { ui?: unknown; mode?: string });
|
|
662
|
+
totalCny = 0;
|
|
663
|
+
totalUsd = 0;
|
|
664
|
+
pricedMessages = 0;
|
|
665
|
+
lastWindow = null;
|
|
666
|
+
lastPricedMs = 0;
|
|
667
|
+
subCny = 0;
|
|
668
|
+
subUsd = 0;
|
|
669
|
+
subMessages = 0;
|
|
670
|
+
childFiles.clear();
|
|
671
|
+
seededMessages.clear();
|
|
672
|
+
if (ctx?.ui) footUi = ctx.ui as FootUi;
|
|
673
|
+
const sm = (
|
|
674
|
+
ctx as {
|
|
675
|
+
sessionManager?: {
|
|
676
|
+
getSessionFile?: () => string | null;
|
|
677
|
+
getEntries?: () => Array<{
|
|
678
|
+
type?: string;
|
|
679
|
+
message?: {
|
|
680
|
+
role?: string;
|
|
681
|
+
model?: string;
|
|
682
|
+
provider?: string;
|
|
683
|
+
timestamp?: number;
|
|
684
|
+
usage?: Usage;
|
|
685
|
+
};
|
|
686
|
+
}>;
|
|
687
|
+
};
|
|
688
|
+
}
|
|
689
|
+
)?.sessionManager;
|
|
690
|
+
sessionFile = sm?.getSessionFile?.() ?? null;
|
|
691
|
+
// A resumed session reopens with its whole transcript, and pi restores its
|
|
692
|
+
// own totals from the entries — so this extension's are restored the same
|
|
693
|
+
// way: every restored assistant message is priced ONCE here, at its own
|
|
694
|
+
// timestamp, and its timestamp is recorded as the boundary that keeps a
|
|
695
|
+
// replay out of the live path. This is the ONLY place history is priced;
|
|
696
|
+
// the tick and message_end price only what arrives after the seed. The same
|
|
697
|
+
// walk seeds the cache-miss baseline (pi does the same when it rebuilds its
|
|
698
|
+
// own miss list for a resumed session).
|
|
699
|
+
prevRequest = null;
|
|
700
|
+
try {
|
|
701
|
+
for (const entry of sm?.getEntries?.() ?? []) {
|
|
702
|
+
const m = entry?.message;
|
|
703
|
+
if (entry?.type !== "message" || m?.role !== "assistant" || !m.usage) continue;
|
|
704
|
+
const input = m.usage.input ?? 0;
|
|
705
|
+
const cacheRead = m.usage.cacheRead ?? 0;
|
|
706
|
+
const cacheWrite = m.usage.cacheWrite ?? 0;
|
|
707
|
+
if (isDeepseekFlash(m.model)) {
|
|
708
|
+
const at = m.timestamp ? new Date(m.timestamp) : new Date();
|
|
709
|
+
totalCny += costOf(m.usage, at, table.cny);
|
|
710
|
+
totalUsd += costOfUsd(m.usage, at, table.usd);
|
|
711
|
+
pricedMessages++;
|
|
712
|
+
if (at.getTime() >= lastPricedMs) {
|
|
713
|
+
lastPricedMs = at.getTime();
|
|
714
|
+
lastWindow = windowAt(at);
|
|
715
|
+
}
|
|
716
|
+
if (m.timestamp) seededMessages.add(m.timestamp);
|
|
717
|
+
}
|
|
718
|
+
prevRequest = {
|
|
719
|
+
promptTokens: input + cacheRead + cacheWrite,
|
|
720
|
+
modelKey: `${m.provider ?? ""}/${m.model ?? ""}`,
|
|
721
|
+
timestamp: m.timestamp ?? 0,
|
|
722
|
+
reportedCache: cacheRead + cacheWrite > 0,
|
|
723
|
+
};
|
|
724
|
+
}
|
|
725
|
+
} catch {
|
|
726
|
+
/* a session without a readable entry list starts empty, like a fresh one */
|
|
727
|
+
}
|
|
728
|
+
// A child can run for minutes while the parent waits: re-price on a slow
|
|
729
|
+
// tick so its usage reaches the footer without a parent message.
|
|
730
|
+
if (scanTimer) clearInterval(scanTimer);
|
|
731
|
+
scanTimer = sessionFile
|
|
732
|
+
? setInterval(() => {
|
|
733
|
+
scanSubagents();
|
|
734
|
+
renderWith(footUi);
|
|
735
|
+
}, SUBAGENT_SCAN_MS)
|
|
736
|
+
: null;
|
|
737
|
+
});
|
|
738
|
+
|
|
739
|
+
pi.on("session_shutdown", (_event, ctx) => {
|
|
740
|
+
try {
|
|
741
|
+
if (scanTimer) clearInterval(scanTimer);
|
|
742
|
+
scanTimer = null;
|
|
743
|
+
const ui = ((ctx as { ui?: FootUi } | undefined)?.ui ?? footUi) as FootUi | null;
|
|
744
|
+
ui?.setStatus?.("cost-cny", undefined);
|
|
745
|
+
} catch {
|
|
746
|
+
/* nothing to clean */
|
|
747
|
+
}
|
|
748
|
+
});
|
|
749
|
+
}
|
package/package.json
ADDED
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@tinoy/pi-deepseek-cost",
|
|
3
|
+
"version": "0.1.0",
|
|
4
|
+
"description": "Price the session footer from the configured house tariff, per message timestamp.",
|
|
5
|
+
"license": "MIT",
|
|
6
|
+
"repository": {
|
|
7
|
+
"type": "git",
|
|
8
|
+
"url": "git+https://github.com/tinoy1336/pi-extensions.git",
|
|
9
|
+
"directory": "packages/deepseek-cost"
|
|
10
|
+
},
|
|
11
|
+
"homepage": "https://github.com/tinoy1336/pi-extensions/tree/main/packages/deepseek-cost#readme",
|
|
12
|
+
"bugs": {
|
|
13
|
+
"url": "https://github.com/tinoy1336/pi-extensions/issues"
|
|
14
|
+
},
|
|
15
|
+
"type": "module",
|
|
16
|
+
"main": "deepseek-cost.ts",
|
|
17
|
+
"keywords": [
|
|
18
|
+
"pi-package"
|
|
19
|
+
],
|
|
20
|
+
"files": [
|
|
21
|
+
"deepseek-cost.ts",
|
|
22
|
+
"README.md",
|
|
23
|
+
"LICENSE"
|
|
24
|
+
],
|
|
25
|
+
"pi": {
|
|
26
|
+
"extensions": [
|
|
27
|
+
"./deepseek-cost.ts"
|
|
28
|
+
]
|
|
29
|
+
},
|
|
30
|
+
"engines": {
|
|
31
|
+
"node": ">=22"
|
|
32
|
+
},
|
|
33
|
+
"peerDependencies": {
|
|
34
|
+
"@earendil-works/pi-coding-agent": "*"
|
|
35
|
+
},
|
|
36
|
+
"dependencies": {
|
|
37
|
+
"@tinoy/pi-ext-lib": "*",
|
|
38
|
+
"@tinoy/pi-tariff": "*"
|
|
39
|
+
}
|
|
40
|
+
}
|