@itookit/dsht 0.3.2 → 0.3.3

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.
Files changed (41) hide show
  1. package/README.i18n.yaml +2 -2
  2. package/README.md +16 -15
  3. package/README.zh.md +18 -17
  4. package/dist/cli/index.js +9 -12
  5. package/dist/controller/controller.d.ts +10 -1
  6. package/dist/controller/controller.js +12 -2
  7. package/dist/cost/config.d.ts +17 -0
  8. package/dist/cost/config.js +68 -0
  9. package/dist/cost/controller.d.ts +9 -2
  10. package/dist/cost/controller.js +10 -2
  11. package/dist/cost/index.d.ts +5 -4
  12. package/dist/cost/index.js +4 -3
  13. package/dist/cost/ledger-files.d.ts +4 -8
  14. package/dist/cost/ledger-files.js +46 -71
  15. package/dist/cost/ledger.d.ts +33 -20
  16. package/dist/cost/ledger.js +78 -68
  17. package/dist/cost/pricing.d.ts +55 -17
  18. package/dist/cost/pricing.js +126 -44
  19. package/dist/cost/records.js +18 -5
  20. package/dist/cost/types.d.ts +34 -18
  21. package/dist/cost/types.js +4 -0
  22. package/dist/session/navigation.d.ts +26 -0
  23. package/dist/session/navigation.js +48 -0
  24. package/dist/session/transcript.d.ts +48 -6
  25. package/dist/session/transcript.js +117 -14
  26. package/dist/storage/files.d.ts +8 -0
  27. package/dist/storage/files.js +17 -0
  28. package/dist/storage/heap-snapshot.d.ts +19 -0
  29. package/dist/storage/heap-snapshot.js +29 -0
  30. package/dist/storage/index.d.ts +2 -1
  31. package/dist/storage/index.js +2 -1
  32. package/dist/ui/app.js +68 -4
  33. package/dist/ui/chat/history-view.d.ts +5 -1
  34. package/dist/ui/chat/history-view.js +9 -4
  35. package/dist/ui/chat/status.d.ts +16 -4
  36. package/dist/ui/chat/status.js +100 -25
  37. package/dist/ui/commands/parse.d.ts +3 -0
  38. package/dist/ui/commands/parse.js +5 -0
  39. package/dist/ui/commands/registry.js +1 -0
  40. package/dist/ui/dialogs/cost.js +3 -3
  41. package/package.json +2 -2
@@ -1,48 +1,86 @@
1
1
  import { type PriceDecision, type PriceVersion, type Rates, type Usage } from './types.ts';
2
2
  export declare const DEFAULT_PRICES: PriceVersion[];
3
+ /** Revision of the pricing decision rules, recorded with the totals they decided.
4
+ *
5
+ * Bump it whenever the rules change what an amount would be — the matching of a model name, the
6
+ * token buckets an amount covers, or the timestamp it is priced at — so a stored total can be traced
7
+ * to the rules that produced it. Version 1 matched a model by the substring `pro` and priced a
8
+ * request with no settlement time at the cheapest off-peak rate.
9
+ */
10
+ export declare const PRICING_ENGINE_VERSION = 2;
11
+ /** Revision of the shipped table, recorded beside a seeded file so a correction can replace it. */
12
+ export declare const PRICES_REVISION = "2026-09-12";
13
+ /** Whether a table is the seed an earlier revision wrote, which a corrected ship must replace.
14
+ *
15
+ * `prices.json` overrides the shipped table, so an install seeded before the Flash rates were
16
+ * corrected keeps charging 1.5x for input and 2.5x for cache reads for as long as that file lives.
17
+ * Only an exact match to the superseded revision qualifies, so a rate the user chose is never rewritten.
18
+ * @param prices - Table loaded from the configuration file.
19
+ * @returns True when every entry carries the superseded revision's rates.
20
+ */
21
+ export declare function isUncorrectedSeed(prices: readonly PriceVersion[]): boolean;
3
22
  /** Validate user-maintained price versions, rejecting ambiguous overlapping intervals.
4
23
  * @param value - Parsed prices.json array.
5
24
  * @returns Price versions with validated rates and schedules.
6
25
  */
7
26
  export declare function pricesFrom(value: unknown): PriceVersion[];
8
- /** Return the calendar date used by both daily and three-calendar-day summaries.
27
+ /** Return the Beijing calendar date a request is attributed to.
9
28
  * @param time - Epoch milliseconds.
10
29
  * @returns Beijing calendar date, YYYY-MM-DD.
11
30
  */
12
31
  export declare function costDay(time: number): string;
13
- /** Select a price by event time, applying half-open local peak windows.
32
+ /** Canonical form of a model name before matching.
33
+ *
34
+ * The host reports names that differ from the published table by width, case, surrounding space, or
35
+ * the CJK full stop a display path can substitute for a period. Normalizing here keeps the table
36
+ * readable and keeps a name variant from silently missing its entry. NFKC does not fold the CJK
37
+ * stops, so they are mapped explicitly.
38
+ * @param model - Model name exactly as the recorded request reported it.
39
+ * @returns The name in the form the table is matched against.
40
+ */
41
+ export declare function canonicalModel(model: string): string;
42
+ /** Candidate versions for one request, in the order the table is searched: the exact model, then
43
+ * the aliases a version declares. A name the table does not cover stays unpriced rather than
44
+ * falling back to a family guess, because guessing a rate is indistinguishable from a wrong one.
14
45
  * @param prices - Validated versions.
15
46
  * @param provider - Provider identity from the recorded request.
16
- * @param model - Recorded model name; official DeepSeek aliases fall back to Pro when containing pro, otherwise Flash.
17
- * @param time - Recorded settlement timestamp used as a billing-time estimate.
18
- * @returns Matching price version and per-million-token rates, if known.
47
+ * @param model - Recorded model name.
48
+ * @returns Matching versions, each with the rule that matched it.
19
49
  */
20
- export declare function priceAt(prices: PriceVersion[], provider: string, model: string, time: number): {
50
+ export declare function candidates(prices: PriceVersion[], provider: string, model: string): {
21
51
  price: PriceVersion;
22
- rates: Rates;
23
- } | undefined;
24
- /** Select a rate without a settlement time, so an unattributable request still enters the total.
25
- * The cheapest candidate off-peak rate is a floor: it never overstates, and the charge stays
26
- * marked as estimated.
52
+ matchedBy: 'exact' | 'alias';
53
+ }[];
54
+ /** Stable identity of a loaded price table.
55
+ *
56
+ * The identity a price version carries can be edited in place while keeping its `id` — which is how
57
+ * a corrected table once kept charging superseded rates under one id — so a stored total records a
58
+ * digest of the whole table it was decided from, not only the version names.
59
+ * @param prices - Price versions loaded for this process.
60
+ * @returns Short digest of the table.
61
+ */
62
+ export declare function pricesDigest(prices: readonly PriceVersion[]): string;
63
+ /** Select a price by event time, applying half-open local peak windows.
27
64
  * @param prices - Validated versions.
28
65
  * @param provider - Provider identity from the recorded request.
29
66
  * @param model - Recorded model name.
30
- * @returns The candidate version with the lowest off-peak input rate and its rates, if any.
67
+ * @param time - Recorded settlement timestamp used as the billing instant.
68
+ * @returns Matching price version, the rule that matched it, and its per-million-token rates.
31
69
  */
32
- export declare function lowestPrice(prices: PriceVersion[], provider: string, model: string): {
70
+ export declare function priceAt(prices: PriceVersion[], provider: string, model: string, time: number): {
33
71
  price: PriceVersion;
34
72
  rates: Rates;
73
+ matchedBy: 'exact' | 'alias';
35
74
  } | undefined;
36
75
  /** Decide the amount for one request sample using the table loaded at decision time.
37
76
  *
38
- * The returned decision is recorded once and never revisited: a later `prices.json` change must
39
- * not move a historical amount. Only a sample with no usable usage (`missing usage`) is left
40
- * undecided, because its request has not finished reporting tokens yet.
77
+ * A decision is a number or a reason; nothing about the rates that produced it is kept, because the
78
+ * ledger stores totals rather than requests and the next scan decides the sample again.
41
79
  * @param prices - Validated versions currently loaded.
42
80
  * @param provider - Provider identity from the recorded request.
43
81
  * @param model - Recorded model name.
44
82
  * @param time - Recorded settlement timestamp, when the host logged one.
45
83
  * @param usage - Disjoint token buckets, when the host reported valid counts.
46
- * @returns The selected price identity, the amount, and the reason when no amount exists.
84
+ * @returns The amount, or the reason no amount exists.
47
85
  */
48
86
  export declare function chargeFor(prices: PriceVersion[], provider: string, model: string, time: number | undefined, usage: Usage | undefined): PriceDecision;
@@ -1,8 +1,9 @@
1
1
  /** Versioned CNY price tables and the price decision taken for one request sample. */
2
+ import { createHash } from 'node:crypto';
2
3
  import { object } from "../transport/wire.js";
3
- import { MISSING_USAGE } from "./types.js";
4
+ import { MISSING_TIME, MISSING_USAGE, UNSUPPORTED_USAGE } from "./types.js";
4
5
  const clocks = new Map();
5
- /** Published rates verified on 2026-09-10; preceding dates require historical configuration.
6
+ /** Published rates verified on 2026-09-12; preceding dates require historical configuration.
6
7
  * Flash and Pro are priced independently, and a separate cache write uses the cache-miss input rate.
7
8
  */
8
9
  const OFFICIAL_PRICING = 'https://api-docs.deepseek.com/zh-cn/quick_start/pricing/';
@@ -12,17 +13,63 @@ const FLASH_RATES = { peak: { input: 2, cacheRead: 0.04, cacheWrite: 2, output:
12
13
  const PRO_RATES = { peak: { input: 9, cacheRead: 0.3, cacheWrite: 9, output: 27 },
13
14
  offPeak: { input: 4.5, cacheRead: 0.15, cacheWrite: 4.5, output: 13.5 } };
14
15
  export const DEFAULT_PRICES = [
16
+ // The published table states that superseded Flash names stay callable and are served by
17
+ // V4.1-Flash at Flash rates, so every name the host can report is listed instead of guessed at.
15
18
  { id: 'deepseek-2026-09-10-flash', provider: 'deepseek-official', model: 'deepseek-flash',
19
+ aliases: ['deepseek-v4-flash', 'deepseek-v4-flash-vision-exp', 'deepseek-v4-flash*', 'deepseek-v4.1-flash*', 'deepseek-v4.1-flash'],
16
20
  from: '2026-09-10T00:00:00+08:00', currency: 'CNY', source: OFFICIAL_PRICING, timezone: 'Asia/Shanghai',
17
21
  ...PEAK_SCHEDULE, ...FLASH_RATES },
22
+ // The published table keeps V4 Pro available after 2026-09-14 at these rates, so the interval
23
+ // stays open until a later page names an end.
18
24
  { id: 'deepseek-2026-09-10-pro', provider: 'deepseek-official', model: 'deepseek-v4-pro',
19
- from: '2026-09-10T00:00:00+08:00', until: '2026-09-14T12:00:00+08:00', currency: 'CNY',
20
- source: OFFICIAL_PRICING, timezone: 'Asia/Shanghai', ...PEAK_SCHEDULE, ...PRO_RATES },
21
- // The provider bills `deepseek-v4-pro` requests at Flash rates once V4 Pro is retired.
22
- { id: 'deepseek-2026-09-14-pro-served-by-flash', provider: 'deepseek-official', model: 'deepseek-v4-pro',
23
- from: '2026-09-14T12:00:00+08:00', currency: 'CNY', source: OFFICIAL_PRICING, timezone: 'Asia/Shanghai',
24
- ...PEAK_SCHEDULE, ...FLASH_RATES },
25
+ // No `deepseek-pro*`: a prefix that broad would also claim `deepseek-proxy-*` or `deepseek-prompt-*`.
26
+ aliases: ['deepseek-v4-pro', 'deepseek-v4-pro*', 'deepseek-v4.1-pro*'],
27
+ from: '2026-09-10T00:00:00+08:00', currency: 'CNY', source: OFFICIAL_PRICING, timezone: 'Asia/Shanghai',
28
+ ...PEAK_SCHEDULE, ...PRO_RATES },
25
29
  ];
30
+ /** Revision of the pricing decision rules, recorded with the totals they decided.
31
+ *
32
+ * Bump it whenever the rules change what an amount would be — the matching of a model name, the
33
+ * token buckets an amount covers, or the timestamp it is priced at — so a stored total can be traced
34
+ * to the rules that produced it. Version 1 matched a model by the substring `pro` and priced a
35
+ * request with no settlement time at the cheapest off-peak rate.
36
+ */
37
+ export const PRICING_ENGINE_VERSION = 2;
38
+ /** Revision of the shipped table, recorded beside a seeded file so a correction can replace it. */
39
+ export const PRICES_REVISION = '2026-09-12';
40
+ /** Rates the first published revision charged, rebuilt with the same arithmetic so the values compare equal.
41
+ * It only recognizes that seed; it never prices a request.
42
+ */
43
+ const UNCORRECTED_SEED_RATES = Object.fromEntries(['deepseek-v4-flash', 'deepseek-v4-pro', 'deepseek-v4-flash-vision-exp'].map(model => {
44
+ const scale = model === 'deepseek-v4-pro' ? 3 : 1;
45
+ return [`deepseek-2026-09-10-${model}`, {
46
+ peak: { input: 3 * scale, cacheRead: 0.1 * scale, cacheWrite: 3 * scale, output: 9 * scale },
47
+ offPeak: { input: 1.5 * scale, cacheRead: 0.05 * scale, cacheWrite: 1.5 * scale, output: 4.5 * scale },
48
+ }];
49
+ }));
50
+ /** Whether a table is the seed an earlier revision wrote, which a corrected ship must replace.
51
+ *
52
+ * `prices.json` overrides the shipped table, so an install seeded before the Flash rates were
53
+ * corrected keeps charging 1.5x for input and 2.5x for cache reads for as long as that file lives.
54
+ * Only an exact match to the superseded revision qualifies, so a rate the user chose is never rewritten.
55
+ * @param prices - Table loaded from the configuration file.
56
+ * @returns True when every entry carries the superseded revision's rates.
57
+ */
58
+ export function isUncorrectedSeed(prices) {
59
+ const superseded = Object.keys(UNCORRECTED_SEED_RATES);
60
+ return prices.length === superseded.length && prices.every(price => {
61
+ const legacy = UNCORRECTED_SEED_RATES[price.id];
62
+ return legacy !== undefined && sameRates(price.peak, legacy.peak) && sameRates(price.offPeak, legacy.offPeak);
63
+ });
64
+ }
65
+ /** Compare two rate sets, allowing the representation error a JSON round trip can introduce.
66
+ * @param a - One rate set.
67
+ * @param b - The other rate set.
68
+ * @returns True when every rate agrees.
69
+ */
70
+ function sameRates(a, b) {
71
+ return ['input', 'cacheRead', 'cacheWrite', 'output'].every(bucket => Math.abs(a[bucket] - b[bucket]) < 1e-9);
72
+ }
26
73
  /** Validate user-maintained price versions, rejecting ambiguous overlapping intervals.
27
74
  * @param value - Parsed prices.json array.
28
75
  * @returns Price versions with validated rates and schedules.
@@ -39,6 +86,8 @@ export function pricesFrom(value) {
39
86
  if (ids.has(String(p.id)))
40
87
  throw new Error('Duplicate price id');
41
88
  ids.add(String(p.id));
89
+ if (p.aliases !== undefined && (!Array.isArray(p.aliases) || p.aliases.some(alias => typeof alias !== 'string' || alias === '')))
90
+ throw new Error('Invalid price aliases');
42
91
  const from = Date.parse(String(p.from));
43
92
  const until = p.until === undefined ? Infinity : Date.parse(String(p.until));
44
93
  if (!Number.isFinite(from) || !(until > from) || p.currency !== 'CNY')
@@ -63,31 +112,72 @@ export function pricesFrom(value) {
63
112
  }
64
113
  return prices;
65
114
  }
66
- /** Return the calendar date used by both daily and three-calendar-day summaries.
115
+ /** Return the Beijing calendar date a request is attributed to.
67
116
  * @param time - Epoch milliseconds.
68
117
  * @returns Beijing calendar date, YYYY-MM-DD.
69
118
  */
70
119
  export function costDay(time) { return new Date(time + 8 * 3600_000).toISOString().slice(0, 10); }
71
- /** Price family used when a recorded model name has no exact entry. */
72
- function priceFamily(model) { return model.toLowerCase().includes('pro') ? 'deepseek-v4-pro' : 'deepseek-flash'; }
73
- /** Candidate versions for one request: its exact model first, then the official model family. */
74
- function candidates(prices, provider, model) {
75
- const exact = prices.filter(p => p.provider === provider && p.model === model);
120
+ /** Canonical form of a model name before matching.
121
+ *
122
+ * The host reports names that differ from the published table by width, case, surrounding space, or
123
+ * the CJK full stop a display path can substitute for a period. Normalizing here keeps the table
124
+ * readable and keeps a name variant from silently missing its entry. NFKC does not fold the CJK
125
+ * stops, so they are mapped explicitly.
126
+ * @param model - Model name exactly as the recorded request reported it.
127
+ * @returns The name in the form the table is matched against.
128
+ */
129
+ export function canonicalModel(model) {
130
+ return model.normalize('NFKC').replace(/[\u3002\uff0e\uff61]/g, '.').trim().toLowerCase();
131
+ }
132
+ /** Whether an alias matches a canonical model name, treating a trailing `*` as a prefix.
133
+ * @param alias - Alias declared by a price version.
134
+ * @param model - Canonical model name.
135
+ * @returns True when the alias covers the name.
136
+ */
137
+ function aliasMatches(alias, model) {
138
+ const canonical = canonicalModel(alias);
139
+ return canonical.endsWith('*') ? model.startsWith(canonical.slice(0, -1)) : model === canonical;
140
+ }
141
+ /** Candidate versions for one request, in the order the table is searched: the exact model, then
142
+ * the aliases a version declares. A name the table does not cover stays unpriced rather than
143
+ * falling back to a family guess, because guessing a rate is indistinguishable from a wrong one.
144
+ * @param prices - Validated versions.
145
+ * @param provider - Provider identity from the recorded request.
146
+ * @param model - Recorded model name.
147
+ * @returns Matching versions, each with the rule that matched it.
148
+ */
149
+ export function candidates(prices, provider, model) {
150
+ const canonical = canonicalModel(model);
151
+ const exact = prices.filter(p => p.provider === provider && canonicalModel(p.model) === canonical);
76
152
  if (exact.length)
77
- return exact;
78
- return provider === 'deepseek-official' ? prices.filter(p => p.model === priceFamily(model)) : [];
153
+ return exact.map(price => ({ price, matchedBy: 'exact' }));
154
+ return prices.filter(p => p.provider === provider && (p.aliases ?? []).some(alias => aliasMatches(alias, canonical)))
155
+ .map(price => ({ price, matchedBy: 'alias' }));
156
+ }
157
+ /** Stable identity of a loaded price table.
158
+ *
159
+ * The identity a price version carries can be edited in place while keeping its `id` — which is how
160
+ * a corrected table once kept charging superseded rates under one id — so a stored total records a
161
+ * digest of the whole table it was decided from, not only the version names.
162
+ * @param prices - Price versions loaded for this process.
163
+ * @returns Short digest of the table.
164
+ */
165
+ export function pricesDigest(prices) {
166
+ return createHash('sha256').update(JSON.stringify(prices)).digest('hex').slice(0, 12);
79
167
  }
80
168
  /** Select a price by event time, applying half-open local peak windows.
81
169
  * @param prices - Validated versions.
82
170
  * @param provider - Provider identity from the recorded request.
83
- * @param model - Recorded model name; official DeepSeek aliases fall back to Pro when containing pro, otherwise Flash.
84
- * @param time - Recorded settlement timestamp used as a billing-time estimate.
85
- * @returns Matching price version and per-million-token rates, if known.
171
+ * @param model - Recorded model name.
172
+ * @param time - Recorded settlement timestamp used as the billing instant.
173
+ * @returns Matching price version, the rule that matched it, and its per-million-token rates.
86
174
  */
87
175
  export function priceAt(prices, provider, model, time) {
88
- const price = candidates(prices, provider, model).find(p => Date.parse(p.from) <= time && (p.until === undefined || time < Date.parse(p.until)));
89
- if (!price)
176
+ const candidate = candidates(prices, provider, model)
177
+ .find(({ price }) => Date.parse(price.from) <= time && (price.until === undefined || time < Date.parse(price.until)));
178
+ if (candidate === undefined)
90
179
  return;
180
+ const { price, matchedBy } = candidate;
91
181
  let clock = clocks.get(price.timezone);
92
182
  if (!clock) {
93
183
  clock = new Intl.DateTimeFormat('en-US', { timeZone: price.timezone, weekday: 'short', hour: '2-digit', minute: '2-digit', hourCycle: 'h23' });
@@ -97,45 +187,37 @@ export function priceAt(prices, provider, model, time) {
97
187
  const part = (name) => parts.find(p => p.type === name).value;
98
188
  const day = ['Sun', 'Mon', 'Tue', 'Wed', 'Thu', 'Fri', 'Sat'].indexOf(part('weekday'));
99
189
  const minute = Number(part('hour')) * 60 + Number(part('minute'));
100
- return { price, rates: price.weekdays.includes(day) && price.windows.some(([a, b]) => minute >= a && minute < b) ? price.peak : price.offPeak };
101
- }
102
- /** Select a rate without a settlement time, so an unattributable request still enters the total.
103
- * The cheapest candidate off-peak rate is a floor: it never overstates, and the charge stays
104
- * marked as estimated.
105
- * @param prices - Validated versions.
106
- * @param provider - Provider identity from the recorded request.
107
- * @param model - Recorded model name.
108
- * @returns The candidate version with the lowest off-peak input rate and its rates, if any.
109
- */
110
- export function lowestPrice(prices, provider, model) {
111
- let best;
112
- for (const price of candidates(prices, provider, model)) {
113
- if (best === undefined || price.offPeak.input < best.rates.input)
114
- best = { price, rates: price.offPeak };
115
- }
116
- return best;
190
+ return { price, matchedBy, rates: price.weekdays.includes(day) && price.windows.some(([a, b]) => minute >= a && minute < b) ? price.peak : price.offPeak };
117
191
  }
118
192
  /** Decide the amount for one request sample using the table loaded at decision time.
119
193
  *
120
- * The returned decision is recorded once and never revisited: a later `prices.json` change must
121
- * not move a historical amount. Only a sample with no usable usage (`missing usage`) is left
122
- * undecided, because its request has not finished reporting tokens yet.
194
+ * A decision is a number or a reason; nothing about the rates that produced it is kept, because the
195
+ * ledger stores totals rather than requests and the next scan decides the sample again.
123
196
  * @param prices - Validated versions currently loaded.
124
197
  * @param provider - Provider identity from the recorded request.
125
198
  * @param model - Recorded model name.
126
199
  * @param time - Recorded settlement timestamp, when the host logged one.
127
200
  * @param usage - Disjoint token buckets, when the host reported valid counts.
128
- * @returns The selected price identity, the amount, and the reason when no amount exists.
201
+ * @returns The amount, or the reason no amount exists.
129
202
  */
130
203
  export function chargeFor(prices, provider, model, time, usage) {
131
204
  if (!usage)
132
205
  return { reason: MISSING_USAGE };
133
- const selected = time === undefined ? lowestPrice(prices, provider, model) : priceAt(prices, provider, model, time);
206
+ // The published DeepSeek table prices cache hits, cache misses and output; a fourth bucket means
207
+ // the usage mapping is wrong, and inventing a rate for it would hide that.
208
+ if (provider === 'deepseek-official' && usage.cacheWrite !== 0)
209
+ return { reason: UNSUPPORTED_USAGE };
210
+ // No settlement time means the host did not say when the request was billed, and the two peak
211
+ // bands differ by a factor of two: a floor amount would enter the total while belonging to no day,
212
+ // so the request is reported unresolved instead of guessed.
213
+ if (time === undefined)
214
+ return { reason: MISSING_TIME };
215
+ const selected = priceAt(prices, provider, model, time);
134
216
  if (!selected)
135
217
  return { reason: 'no price version' };
136
218
  const amount = (usage.input * selected.rates.input + usage.output * selected.rates.output
137
219
  + usage.cacheRead * selected.rates.cacheRead + usage.cacheWrite * selected.rates.cacheWrite) / 1e6;
138
220
  if (!Number.isFinite(amount))
139
221
  return { reason: 'invalid estimate' };
140
- return { priceId: selected.price.id, amount, ...(time === undefined ? { estimated: true } : {}) };
222
+ return { amount };
141
223
  }
@@ -1,19 +1,31 @@
1
1
  /** Fold host history records into per-request samples; conversation text never enters the ledger. */
2
2
  import { array, object } from "../transport/wire.js";
3
- /** Host event types that carry billing-relevant usage or route context. */
4
- const BILLING_EVENTS = new Set(['request/context', 'assistant/message', 'assistant/attempt', 'llm/retry-started', 'session/end-seed']);
3
+ /** Host event types that carry billing-relevant usage or route context.
4
+ *
5
+ * `compaction/summary` is a provider request like any other — it reads the whole context to write the
6
+ * summary — and it records its own provider, model and usage instead of appearing as an assistant
7
+ * message, so a fold without it under-reports the most expensive requests in a session.
8
+ */
9
+ const BILLING_EVENTS = new Set(['request/context', 'assistant/message', 'assistant/attempt', 'llm/retry-started', 'session/end-seed', 'compaction/summary']);
5
10
  /** Keep only billing-relevant fields; prompts, tool bodies, cookies and keys never enter the ledger.
6
11
  * @param records - One HTTP history page's records.
7
12
  * @returns Minimal durable events for a deterministic usage fold.
8
13
  */
9
14
  export function costRecords(records) {
10
- return array(records).map(raw => object(object(raw).event)).filter(e => BILLING_EVENTS.has(String(e.type))).map(e => {
15
+ return array(records).map(raw => object(object(raw).event)).filter(e => BILLING_EVENTS.has(String(e.type)))
16
+ // A summary written without a model call — an unmarked template or remote summarizer — has no
17
+ // usage and no cost, so it is not a billable sample at all.
18
+ .filter(e => e.type !== 'compaction/summary' || object(e.data).usage !== undefined).map(e => {
11
19
  const d = object(e.data);
12
20
  const m = object(d.message ?? {});
13
21
  const stream = array(d.stream ?? []).map(r => object(object(r).chunk ?? {})).filter(c => c.type === 'usage');
22
+ // A summary names its own provider and model, and carries no turn or step to fold against, so
23
+ // its route travels as the source a message would carry rather than as folded context.
24
+ const source = e.type === 'compaction/summary' ? { provider: d.provider ?? null, model: d.model ?? null } : m.source ?? null;
14
25
  return { seq: e.seq ?? null, time: e.time ?? null, type: e.type, data: {
15
- inherited: d.inherited ?? false, turn: d.turn ?? null, step: d.step ?? null, provider: d.provider ?? null, model: d.model ?? null,
16
- source: m.source ?? null, usage: d.usage ?? stream.at(-1)?.usage ?? null,
26
+ inherited: d.inherited ?? false, turn: d.turn ?? null, step: d.step ?? null,
27
+ provider: d.provider ?? null, model: d.model ?? null,
28
+ source, usage: d.usage ?? stream.at(-1)?.usage ?? null,
17
29
  } };
18
30
  });
19
31
  }
@@ -43,6 +55,7 @@ export function foldSamples(events) {
43
55
  last = undefined;
44
56
  continue;
45
57
  }
58
+ // A message or summary carries its own route; only the remaining events inherit the folded one.
46
59
  const source = object(d.source ?? {});
47
60
  const provider = String(source.provider ?? route.provider ?? '');
48
61
  const model = String(source.model ?? route.model ?? '');
@@ -11,6 +11,8 @@ export interface PriceVersion {
11
11
  id: string;
12
12
  provider: string;
13
13
  model: string;
14
+ /** Further model names this version prices; a trailing `*` matches a prefix. Never a guess. */
15
+ aliases?: string[];
14
16
  from: string;
15
17
  until?: string;
16
18
  currency: 'CNY';
@@ -28,7 +30,7 @@ export interface Usage {
28
30
  cacheRead: number;
29
31
  cacheWrite: number;
30
32
  }
31
- /** One folded request sample before a price decision is attached. */
33
+ /** One folded request sample: the attempt identity and the facts a price decision needs. */
32
34
  export interface ChargeSample {
33
35
  key: string;
34
36
  time?: number;
@@ -36,34 +38,48 @@ export interface ChargeSample {
36
38
  model: string;
37
39
  usage?: Usage;
38
40
  }
39
- /** The price decision recorded the first time a request sample was evaluated. */
41
+ /** What the table decides for one request sample: an amount, or the reason it has none. */
40
42
  export interface PriceDecision {
41
- priceId?: string;
42
43
  amount?: number;
43
- estimated?: true;
44
44
  reason?: string;
45
45
  }
46
- /** One ledger entry: a request sample plus the price decision that seals it. */
47
- export interface Charge extends ChargeSample, PriceDecision {
48
- }
49
- /** One session's persisted ledger slice. */
50
- export interface SavedCost {
51
- version: 2;
52
- sessionId: string;
53
- cut: number;
54
- charges: Charge[];
55
- }
56
- /** Summary retains the known subtotal, the records it could not price, and the coarse estimates.
57
- * `unknown` counts records with no amount at all; `estimated` counts records that only have a
58
- * floor amount, including dated requests whose timestamp cannot place them inside the range.
46
+ /** Summary retains the known subtotal and the records it could not price.
47
+ * `unknown` counts records with no amount; `records` counts every request the range covers, so a
48
+ * subtotal is never read as complete without them.
59
49
  */
60
50
  export interface CostTotal {
61
51
  amount: number;
62
52
  unknown: number;
63
- estimated: number;
64
53
  records: number;
65
54
  }
55
+ /** One Beijing calendar day's requests, kept only for the day a scan ran on. */
56
+ export interface DayTotal extends CostTotal {
57
+ day: string;
58
+ }
59
+ /** One session's persisted ledger slice.
60
+ *
61
+ * The host log and the loaded price table are the only inputs, so the slice stores the totals one
62
+ * scan folded them into and no per-request detail: the next scan reads the session's history again
63
+ * and decides every sample with the table loaded then. `engine` and `catalog` name the decision
64
+ * rules and the table behind these totals, so a process holding an older one cannot overwrite them.
65
+ */
66
+ export interface SavedCost {
67
+ version: 3;
68
+ sessionId: string;
69
+ /** Durable sequence the fold reached; a scan that opened an older cut may not replace this slice. */
70
+ cut: number;
71
+ engine: number;
72
+ catalog: string;
73
+ total: CostTotal;
74
+ day: DayTotal;
75
+ /** Distinct reasons an amount is missing, bounded, so a panel can say what makes a total inexact. */
76
+ unpriced: string[];
77
+ }
66
78
  /** How much of the visible history the cached ledger currently covers. */
67
79
  export type Coverage = 'complete' | 'scanning' | 'partial';
68
80
  /** Reason recorded when a sample carries no usable token counts. */
69
81
  export declare const MISSING_USAGE = "missing usage";
82
+ /** Reason recorded when the host reported a cache-write bucket the published table does not price. */
83
+ export declare const UNSUPPORTED_USAGE = "unsupported usage";
84
+ /** Reason recorded when the request has no settlement time to place it in a peak band or a day. */
85
+ export declare const MISSING_TIME = "missing time";
@@ -1,3 +1,7 @@
1
1
  /** Cost-domain types shared by pricing, record folding, storage and the in-memory ledger. */
2
2
  /** Reason recorded when a sample carries no usable token counts. */
3
3
  export const MISSING_USAGE = 'missing usage';
4
+ /** Reason recorded when the host reported a cache-write bucket the published table does not price. */
5
+ export const UNSUPPORTED_USAGE = 'unsupported usage';
6
+ /** Reason recorded when the request has no settlement time to place it in a peak band or a day. */
7
+ export const MISSING_TIME = 'missing time';
@@ -9,3 +9,29 @@ export declare function navigationCommand(value: string): {
9
9
  export declare function sessionLabel(session: ObjectValue): string;
10
10
  /** Match an exact ID or name before a unique ID prefix; never choose an ambiguous target. */
11
11
  export declare function resolveTarget(items: ObjectValue[], query: string, id: string, names: (item: ObjectValue) => string[]): ObjectValue;
12
+ /** Activity a session summary reports on its own, without loading that session's history. */
13
+ export type SessionState = 'running' | 'idle' | 'blank';
14
+ /** Classify one session from the host's list summary; nothing is inferred from silence.
15
+ * @param session - Session summary from `session/list`.
16
+ * @returns Running while its agent works, blank before its first turn, otherwise idle.
17
+ */
18
+ export declare function sessionState(session: ObjectValue): SessionState;
19
+ /** Leading marker per state: a working clock, a filled idle dot, and an empty unused circle. */
20
+ export declare const SESSION_MARKERS: Record<SessionState, string>;
21
+ /** Coarse age of a session's last activity, so the column stays steady between list refreshes.
22
+ * @param time - Epoch milliseconds of the last activity, when the summary reported one.
23
+ * @param now - Current epoch milliseconds.
24
+ * @returns `now`, minutes, hours or days.
25
+ */
26
+ export declare function activityAge(time: number | undefined, now: number): string;
27
+ /** Status cell for one session row: its state marker and the age of its last activity.
28
+ * @param session - Session summary from `session/list`.
29
+ * @param now - Current epoch milliseconds.
30
+ * @returns Marker with an optional age, without a trailing space when unknown.
31
+ */
32
+ export declare function sessionStatus(session: ObjectValue, now: number): string;
33
+ /** Count the sessions of one workspace by the state each reports.
34
+ * @param sessions - Sessions whose `sessionIds` belong to the workspace.
35
+ * @returns One `marker count` cell per state that occurs, running first, or an empty string.
36
+ */
37
+ export declare function workspaceStatus(sessions: readonly ObjectValue[]): string;
@@ -34,3 +34,51 @@ export function resolveTarget(items, query, id, names) {
34
34
  throw new Error(`Ambiguous target: ${target}. Use a full ID.`);
35
35
  throw new Error(`Target not found: ${target}`);
36
36
  }
37
+ /** Classify one session from the host's list summary; nothing is inferred from silence.
38
+ * @param session - Session summary from `session/list`.
39
+ * @returns Running while its agent works, blank before its first turn, otherwise idle.
40
+ */
41
+ export function sessionState(session) {
42
+ if (session.running === true)
43
+ return 'running';
44
+ return session.blank === true ? 'blank' : 'idle';
45
+ }
46
+ /** Leading marker per state: a working clock, a filled idle dot, and an empty unused circle. */
47
+ export const SESSION_MARKERS = { running: '◐', idle: '●', blank: '○' };
48
+ /** Coarse age of a session's last activity, so the column stays steady between list refreshes.
49
+ * @param time - Epoch milliseconds of the last activity, when the summary reported one.
50
+ * @param now - Current epoch milliseconds.
51
+ * @returns `now`, minutes, hours or days.
52
+ */
53
+ export function activityAge(time, now) {
54
+ if (time === undefined || !Number.isFinite(time))
55
+ return '';
56
+ const seconds = Math.max(0, Math.floor((now - time) / 1000));
57
+ if (seconds < 60)
58
+ return 'now';
59
+ const minutes = Math.floor(seconds / 60);
60
+ if (minutes < 60)
61
+ return `${minutes}m`;
62
+ const hours = Math.floor(minutes / 60);
63
+ return hours < 24 ? `${hours}h` : `${Math.floor(hours / 24)}d`;
64
+ }
65
+ /** Status cell for one session row: its state marker and the age of its last activity.
66
+ * @param session - Session summary from `session/list`.
67
+ * @param now - Current epoch milliseconds.
68
+ * @returns Marker with an optional age, without a trailing space when unknown.
69
+ */
70
+ export function sessionStatus(session, now) {
71
+ const age = activityAge(typeof session.updatedAt === 'number' ? session.updatedAt : undefined, now);
72
+ return `${SESSION_MARKERS[sessionState(session)]}${age === '' ? '' : ` ${age}`}`;
73
+ }
74
+ /** Count the sessions of one workspace by the state each reports.
75
+ * @param sessions - Sessions whose `sessionIds` belong to the workspace.
76
+ * @returns One `marker count` cell per state that occurs, running first, or an empty string.
77
+ */
78
+ export function workspaceStatus(sessions) {
79
+ const counts = { running: 0, idle: 0, blank: 0 };
80
+ for (const session of sessions)
81
+ counts[sessionState(session)] += 1;
82
+ return ['running', 'idle', 'blank'].filter(state => counts[state] > 0)
83
+ .map(state => `${SESSION_MARKERS[state]} ${counts[state]}`).join(' ');
84
+ }