@rayadesu/dsh-llm-billing 0.2.2 → 0.2.4
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/README.i18n.yaml +2 -2
- package/README.md +1 -1
- package/README.zh.md +1 -1
- package/lib/index.js +450 -117
- package/lib/typert.host.js +77 -3
- package/lib/typert.remote-client.d.ts +5 -1
- package/lib/typert.remote-client.js +77 -3
- package/lib/types/balance.d.ts +31 -1
- package/lib/types/balance.js +29 -0
- package/lib/types/billing.d.ts +41 -6
- package/lib/types/billing.js +132 -70
- package/lib/types/index.d.ts +7 -2
- package/lib/types/index.js +37 -5
- package/lib/types/today-spend.d.ts +62 -10
- package/lib/types/today-spend.js +252 -54
- package/lib/types/types.d.ts +23 -0
- package/package.json +1 -1
package/lib/types/billing.js
CHANGED
|
@@ -4,11 +4,12 @@
|
|
|
4
4
|
* Remote gateway stays transport-free and the whole spend is testable without
|
|
5
5
|
* a key.
|
|
6
6
|
*
|
|
7
|
-
* The per-event pricing lives in {@link priceEvent}
|
|
8
|
-
*
|
|
9
|
-
* {@link computeTodaySpend})
|
|
10
|
-
*
|
|
11
|
-
* pricing-table change cannot drift one path
|
|
7
|
+
* The per-event pricing lives in {@link priceEvent} and the fold in the
|
|
8
|
+
* {@link SpendAccumulator}: the events-scan paths ({@link computeSessionSpend},
|
|
9
|
+
* {@link computeTodaySpend}), the session-projection unit (`billingTodaySpend`
|
|
10
|
+
* in projection.ts), and the scanner's single-pass events path all price
|
|
11
|
+
* through the same primitives, so a pricing-table change cannot drift one path
|
|
12
|
+
* from the others.
|
|
12
13
|
* @module @rayadesu/dsh-llm-billing/billing
|
|
13
14
|
*/
|
|
14
15
|
/**
|
|
@@ -61,20 +62,29 @@ export function resolveBilling(config) {
|
|
|
61
62
|
models.set(row.model, { peak: row.peak, offPeak: row.offPeak });
|
|
62
63
|
return { peakHours, models };
|
|
63
64
|
}
|
|
64
|
-
/** The Beijing (Asia/Shanghai, UTC+8, no DST) hour of a timestamp. */
|
|
65
|
-
function beijingHour(now) {
|
|
66
|
-
return new Date(now.getTime() + 8 * 3_600_000).getUTCHours();
|
|
67
|
-
}
|
|
68
65
|
/**
|
|
69
|
-
*
|
|
70
|
-
*
|
|
66
|
+
* Derive the Beijing hour, weekday, and calendar-day key of one timestamp from
|
|
67
|
+
* a single shifted `Date` — every timezone-sensitive read shares this one
|
|
68
|
+
* implementation, so the pieces cannot drift apart.
|
|
69
|
+
* @param time - epoch milliseconds.
|
|
71
70
|
*/
|
|
72
|
-
function
|
|
73
|
-
|
|
71
|
+
function beijingParts(time) {
|
|
72
|
+
const shifted = new Date(time + 8 * 3_600_000);
|
|
73
|
+
return {
|
|
74
|
+
hour: shifted.getUTCHours(),
|
|
75
|
+
weekday: shifted.getUTCDay(),
|
|
76
|
+
dayKey: shifted.toISOString().slice(0, 10),
|
|
77
|
+
};
|
|
74
78
|
}
|
|
75
79
|
/** The Beijing (Asia/Shanghai, UTC+8, no DST) calendar-day key of a timestamp. */
|
|
76
80
|
export function beijingDayKey(now) {
|
|
77
|
-
return
|
|
81
|
+
return beijingParts(now.getTime()).dayKey;
|
|
82
|
+
}
|
|
83
|
+
/** Whether a Beijing (hour, weekday) pair falls inside any peak-hour window. */
|
|
84
|
+
function isPeakParts(billing, hour, weekday) {
|
|
85
|
+
if (weekday === 0 || weekday === 6)
|
|
86
|
+
return false;
|
|
87
|
+
return billing.peakHours.some(({ start, end }) => hour >= start && hour < end);
|
|
78
88
|
}
|
|
79
89
|
/**
|
|
80
90
|
* Whether a timestamp falls inside any peak-hour window (Beijing time,
|
|
@@ -85,11 +95,8 @@ export function beijingDayKey(now) {
|
|
|
85
95
|
* @returns true during a weekday peak hour.
|
|
86
96
|
*/
|
|
87
97
|
export function isPeak(billing, now) {
|
|
88
|
-
const weekday =
|
|
89
|
-
|
|
90
|
-
return false;
|
|
91
|
-
const hour = beijingHour(now);
|
|
92
|
-
return billing.peakHours.some(({ start, end }) => hour >= start && hour < end);
|
|
98
|
+
const { hour, weekday } = beijingParts(now.getTime());
|
|
99
|
+
return isPeakParts(billing, hour, weekday);
|
|
93
100
|
}
|
|
94
101
|
/**
|
|
95
102
|
* Price one event at the official per-model rates, applying the peak/off-peak
|
|
@@ -114,8 +121,8 @@ export function priceEvent(event, billing, names) {
|
|
|
114
121
|
const pricing = billing.models.get(model);
|
|
115
122
|
if (pricing === undefined)
|
|
116
123
|
return undefined;
|
|
117
|
-
const
|
|
118
|
-
const peak =
|
|
124
|
+
const { hour, weekday, dayKey } = beijingParts(event.time);
|
|
125
|
+
const peak = isPeakParts(billing, hour, weekday);
|
|
119
126
|
const price = peak ? pricing.peak : pricing.offPeak;
|
|
120
127
|
const hit = reported.cacheReadTokens ?? 0;
|
|
121
128
|
const miss = reported.inputTokens + (reported.cacheWriteTokens ?? 0);
|
|
@@ -125,7 +132,7 @@ export function priceEvent(event, billing, names) {
|
|
|
125
132
|
const outputCost = (output * price.output) / 1_000_000;
|
|
126
133
|
const cost = hitCost + missCost + outputCost;
|
|
127
134
|
return {
|
|
128
|
-
dayKey
|
|
135
|
+
dayKey,
|
|
129
136
|
model,
|
|
130
137
|
displayName: names.get(model) ?? model,
|
|
131
138
|
cost,
|
|
@@ -159,6 +166,45 @@ function contributionModel(priced) {
|
|
|
159
166
|
outputCost: priced.outputCost,
|
|
160
167
|
};
|
|
161
168
|
}
|
|
169
|
+
/** Sum two model rows of the same model (pure). */
|
|
170
|
+
function mergeModelRows(left, right) {
|
|
171
|
+
return {
|
|
172
|
+
model: left.model,
|
|
173
|
+
displayName: left.displayName,
|
|
174
|
+
cost: left.cost + right.cost,
|
|
175
|
+
peakCost: left.peakCost + right.peakCost,
|
|
176
|
+
offPeakCost: left.offPeakCost + right.offPeakCost,
|
|
177
|
+
cacheHitInputTokens: left.cacheHitInputTokens + right.cacheHitInputTokens,
|
|
178
|
+
cacheMissInputTokens: left.cacheMissInputTokens + right.cacheMissInputTokens,
|
|
179
|
+
outputTokens: left.outputTokens + right.outputTokens,
|
|
180
|
+
cacheHitInputCost: left.cacheHitInputCost + right.cacheHitInputCost,
|
|
181
|
+
cacheMissInputCost: left.cacheMissInputCost + right.cacheMissInputCost,
|
|
182
|
+
outputCost: left.outputCost + right.outputCost,
|
|
183
|
+
};
|
|
184
|
+
}
|
|
185
|
+
/**
|
|
186
|
+
* Mutable model-row accumulator behind every spend fold. Rows keep first-seen
|
|
187
|
+
* model order — the same shape a pure `addEventContribution` chain produces —
|
|
188
|
+
* so the single-pass scan path and the pure public paths cannot diverge. One
|
|
189
|
+
* `Map` lookup per contribution instead of a per-event array copy: the huge
|
|
190
|
+
* event-log folds allocate one row object per model, not one intermediate
|
|
191
|
+
* array per event.
|
|
192
|
+
*/
|
|
193
|
+
export class SpendAccumulator {
|
|
194
|
+
rows = new Map();
|
|
195
|
+
total = 0;
|
|
196
|
+
/** Add one priced contribution. */
|
|
197
|
+
add(priced) {
|
|
198
|
+
const row = contributionModel(priced);
|
|
199
|
+
const existing = this.rows.get(priced.model);
|
|
200
|
+
this.rows.set(priced.model, existing === undefined ? row : mergeModelRows(existing, row));
|
|
201
|
+
this.total += priced.cost;
|
|
202
|
+
}
|
|
203
|
+
/** The folded spend; the accumulator stays usable afterwards. */
|
|
204
|
+
finish() {
|
|
205
|
+
return { total: this.total, models: [...this.rows.values()] };
|
|
206
|
+
}
|
|
207
|
+
}
|
|
162
208
|
/**
|
|
163
209
|
* Merge one priced event's contribution into an accumulator spend (pure:
|
|
164
210
|
* returns a new spend, never mutates its input).
|
|
@@ -167,22 +213,10 @@ function contributionModel(priced) {
|
|
|
167
213
|
* @returns the merged spend.
|
|
168
214
|
*/
|
|
169
215
|
export function addEventContribution(spend, priced) {
|
|
170
|
-
const
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
peakCost: row.peakCost + priced.peakCost,
|
|
175
|
-
offPeakCost: row.offPeakCost + priced.offPeakCost,
|
|
176
|
-
cacheHitInputTokens: row.cacheHitInputTokens + priced.cacheHitInputTokens,
|
|
177
|
-
cacheMissInputTokens: row.cacheMissInputTokens + priced.cacheMissInputTokens,
|
|
178
|
-
outputTokens: row.outputTokens + priced.outputTokens,
|
|
179
|
-
cacheHitInputCost: row.cacheHitInputCost + priced.cacheHitInputCost,
|
|
180
|
-
cacheMissInputCost: row.cacheMissInputCost + priced.cacheMissInputCost,
|
|
181
|
-
outputCost: row.outputCost + priced.outputCost,
|
|
182
|
-
}
|
|
183
|
-
: row);
|
|
184
|
-
if (!rows.some(row => row.model === priced.model))
|
|
185
|
-
rows.push(contributionModel(priced));
|
|
216
|
+
const row = contributionModel(priced);
|
|
217
|
+
const rows = spend.models.map(existing => existing.model === priced.model ? mergeModelRows(existing, row) : existing);
|
|
218
|
+
if (!rows.some(existing => existing.model === priced.model))
|
|
219
|
+
rows.push(row);
|
|
186
220
|
return { total: spend.total + priced.cost, models: rows };
|
|
187
221
|
}
|
|
188
222
|
/**
|
|
@@ -193,24 +227,14 @@ export function addEventContribution(spend, priced) {
|
|
|
193
227
|
* @returns the summed spend.
|
|
194
228
|
*/
|
|
195
229
|
export function mergeTodaySpend(target, source) {
|
|
196
|
-
|
|
230
|
+
const rows = new Map();
|
|
231
|
+
for (const row of target.models)
|
|
232
|
+
rows.set(row.model, row);
|
|
197
233
|
for (const row of source.models) {
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
model: row.model,
|
|
201
|
-
displayName: row.displayName,
|
|
202
|
-
cost: row.cost,
|
|
203
|
-
peakCost: row.peakCost,
|
|
204
|
-
offPeakCost: row.offPeakCost,
|
|
205
|
-
cacheHitInputTokens: row.cacheHitInputTokens,
|
|
206
|
-
cacheMissInputTokens: row.cacheMissInputTokens,
|
|
207
|
-
outputTokens: row.outputTokens,
|
|
208
|
-
cacheHitInputCost: row.cacheHitInputCost,
|
|
209
|
-
cacheMissInputCost: row.cacheMissInputCost,
|
|
210
|
-
outputCost: row.outputCost,
|
|
211
|
-
});
|
|
234
|
+
const existing = rows.get(row.model);
|
|
235
|
+
rows.set(row.model, existing === undefined ? row : mergeModelRows(existing, row));
|
|
212
236
|
}
|
|
213
|
-
return
|
|
237
|
+
return { total: target.total + source.total, models: [...rows.values()] };
|
|
214
238
|
}
|
|
215
239
|
/**
|
|
216
240
|
* Price a set of billed events at the official per-model rates, applying the
|
|
@@ -223,19 +247,19 @@ export function mergeTodaySpend(target, source) {
|
|
|
223
247
|
* published table prices only the two V4 rows).
|
|
224
248
|
* @param events - the events to price.
|
|
225
249
|
* @param billing - resolved pricing with peak-hour windows.
|
|
226
|
-
* @param
|
|
250
|
+
* @param names - model id → display label.
|
|
251
|
+
* @param dayKey - when provided, only events on this Beijing calendar day contribute.
|
|
227
252
|
* @returns the total cost plus one row per priced model.
|
|
228
253
|
*/
|
|
229
|
-
function priceEvents(events, billing,
|
|
230
|
-
const
|
|
231
|
-
let spend = { total: 0, models: [] };
|
|
254
|
+
function priceEvents(events, billing, names, dayKey) {
|
|
255
|
+
const accumulator = new SpendAccumulator();
|
|
232
256
|
for (const event of events) {
|
|
233
257
|
const priced = priceEvent(event, billing, names);
|
|
234
|
-
if (priced === undefined)
|
|
258
|
+
if (priced === undefined || (dayKey !== undefined && priced.dayKey !== dayKey))
|
|
235
259
|
continue;
|
|
236
|
-
|
|
260
|
+
accumulator.add(priced);
|
|
237
261
|
}
|
|
238
|
-
return
|
|
262
|
+
return accumulator.finish();
|
|
239
263
|
}
|
|
240
264
|
/**
|
|
241
265
|
* Price one session's complete event log at the official per-model rates.
|
|
@@ -245,7 +269,52 @@ function priceEvents(events, billing, catalog) {
|
|
|
245
269
|
* @returns the session's total cost plus one row per priced model.
|
|
246
270
|
*/
|
|
247
271
|
export function computeSessionSpend(events, billing, catalog) {
|
|
248
|
-
|
|
272
|
+
const names = new Map(catalog.map(model => [model.id, model.name]));
|
|
273
|
+
return priceEvents(events, billing, names);
|
|
274
|
+
}
|
|
275
|
+
/**
|
|
276
|
+
* Price one completed Turn's billed usage at the official per-model rates,
|
|
277
|
+
* identified by its closing assistant message id. The turn's events are those
|
|
278
|
+
* between its `turn/start` and `turn/end` (both matched by the message's own
|
|
279
|
+
* turn coordinate); each priced event applies the peak/off-peak table by its
|
|
280
|
+
* Beijing-time hour and weekday. A message that cannot be located, a turn
|
|
281
|
+
* without bracketing `turn/start` / `turn/end` events (for example after
|
|
282
|
+
* compaction), or a session with no priced usage prices to zero.
|
|
283
|
+
* @param events - one session's complete event log.
|
|
284
|
+
* @param billing - resolved pricing with peak-hour windows.
|
|
285
|
+
* @param catalog - model display rows, in presentation order.
|
|
286
|
+
* @param messageId - the closing assistant message's durable id.
|
|
287
|
+
* @returns the turn's total cost in CNY.
|
|
288
|
+
*/
|
|
289
|
+
export function computeTurnSpend(events, billing, catalog, messageId) {
|
|
290
|
+
const names = new Map(catalog.map(model => [model.id, model.name]));
|
|
291
|
+
let turn;
|
|
292
|
+
for (const event of events) {
|
|
293
|
+
if (event.type !== 'assistant/message')
|
|
294
|
+
continue;
|
|
295
|
+
if (event.data.message.id !== messageId)
|
|
296
|
+
continue;
|
|
297
|
+
turn = event.data.turn;
|
|
298
|
+
break;
|
|
299
|
+
}
|
|
300
|
+
if (turn === undefined)
|
|
301
|
+
return { total: 0 };
|
|
302
|
+
const accumulator = new SpendAccumulator();
|
|
303
|
+
let active = false;
|
|
304
|
+
for (const event of events) {
|
|
305
|
+
if (event.type === 'turn/start' && event.data.turn === turn) {
|
|
306
|
+
active = true;
|
|
307
|
+
continue;
|
|
308
|
+
}
|
|
309
|
+
if (event.type === 'turn/end' && event.data.turn === turn)
|
|
310
|
+
break;
|
|
311
|
+
if (!active)
|
|
312
|
+
continue;
|
|
313
|
+
const priced = priceEvent(event, billing, names);
|
|
314
|
+
if (priced !== undefined)
|
|
315
|
+
accumulator.add(priced);
|
|
316
|
+
}
|
|
317
|
+
return { total: accumulator.finish().total };
|
|
249
318
|
}
|
|
250
319
|
/**
|
|
251
320
|
* Price every event whose Beijing-time calendar day is the day of `now`,
|
|
@@ -260,13 +329,6 @@ export function computeSessionSpend(events, billing, catalog) {
|
|
|
260
329
|
export function computeTodaySpend(events, billing, catalog, now = new Date()) {
|
|
261
330
|
const day = beijingDayKey(now);
|
|
262
331
|
const names = new Map(catalog.map(model => [model.id, model.name]));
|
|
263
|
-
|
|
264
|
-
for (const event of events) {
|
|
265
|
-
const priced = priceEvent(event, billing, names);
|
|
266
|
-
if (priced === undefined || priced.dayKey !== day)
|
|
267
|
-
continue;
|
|
268
|
-
spend = addEventContribution(spend, priced);
|
|
269
|
-
}
|
|
270
|
-
return spend;
|
|
332
|
+
return priceEvents(events, billing, names, day);
|
|
271
333
|
}
|
|
272
334
|
//# sourceMappingURL=billing.js.map
|
package/lib/types/index.d.ts
CHANGED
|
@@ -13,18 +13,23 @@
|
|
|
13
13
|
* session-projection registry is composed, the plugin additionally registers
|
|
14
14
|
* the `billingTodaySpend` projection unit, which folds each session's spend
|
|
15
15
|
* eagerly and lets cold reads ride the projection-cache ladder.
|
|
16
|
+
*
|
|
17
|
+
* A per-session spend cache makes the badge's turn-settled recompute
|
|
18
|
+
* incremental: session logs are append-only and chronological (the same
|
|
19
|
+
* assumption the projection unit makes), so the spend is reused while the log
|
|
20
|
+
* length is unchanged, and only the appended tail is priced when it grows.
|
|
16
21
|
* @module @rayadesu/dsh-llm-billing
|
|
17
22
|
*/
|
|
18
23
|
import type { Context } from '@deepseek-ai/cordis';
|
|
19
24
|
import z from '@deepseek-ai/schemastery';
|
|
20
25
|
import type { BillingConfig } from './billing.ts';
|
|
21
26
|
export { DeepSeekBalanceGateway, fetchDeepSeekBalance, parseDeepSeekBalance } from './balance.ts';
|
|
22
|
-
export { addEventContribution, beijingDayKey, computeSessionSpend, computeTodaySpend, DEFAULT_MODEL_PRICING, DEFAULT_PEAK_HOURS, emptyTodaySpend, isPeak, mergeTodaySpend, priceEvent, resolveBilling, } from './billing.ts';
|
|
27
|
+
export { addEventContribution, beijingDayKey, computeSessionSpend, computeTodaySpend, computeTurnSpend, DEFAULT_MODEL_PRICING, DEFAULT_PEAK_HOURS, emptyTodaySpend, isPeak, mergeTodaySpend, priceEvent, resolveBilling, SpendAccumulator, } from './billing.ts';
|
|
23
28
|
export type { BillingConfig, BillingConfigModel, BillingEventContribution, DeepSeekModelPricing, DeepSeekTokenPrice, PeakHourWindow, ResolvedBilling, } from './billing.ts';
|
|
24
29
|
export type * from './types.ts';
|
|
25
30
|
export { BILLING_UNIT_KEY, billingTodaySpendDefinition, foldBillingUnit } from './projection.ts';
|
|
26
31
|
export type { BillingUnitState } from './projection.ts';
|
|
27
|
-
export { TodaySpendCache, TodaySpendScanner } from './today-spend.ts';
|
|
32
|
+
export { foldSessionTitle, TodaySpendCache, TodaySpendScanner } from './today-spend.ts';
|
|
28
33
|
export type { ScannerPersistedHeader, ScannerSession, TodaySpendScannerDeps } from './today-spend.ts';
|
|
29
34
|
export declare const name = "llm-billing";
|
|
30
35
|
/** Public API default; deployments may point elsewhere via $DEEPSEEK_BASE_URL. */
|
package/lib/types/index.js
CHANGED
|
@@ -13,6 +13,11 @@
|
|
|
13
13
|
* session-projection registry is composed, the plugin additionally registers
|
|
14
14
|
* the `billingTodaySpend` projection unit, which folds each session's spend
|
|
15
15
|
* eagerly and lets cold reads ride the projection-cache ladder.
|
|
16
|
+
*
|
|
17
|
+
* A per-session spend cache makes the badge's turn-settled recompute
|
|
18
|
+
* incremental: session logs are append-only and chronological (the same
|
|
19
|
+
* assumption the projection unit makes), so the spend is reused while the log
|
|
20
|
+
* length is unchanged, and only the appended tail is priced when it grows.
|
|
16
21
|
* @module @rayadesu/dsh-llm-billing
|
|
17
22
|
*/
|
|
18
23
|
import z from '@deepseek-ai/schemastery';
|
|
@@ -20,13 +25,13 @@ import { assertUsableApiKey, LlmError } from '@deepseek-ai/dsh-llm';
|
|
|
20
25
|
import { credentialRef } from '@deepseek-ai/dsh-credentials';
|
|
21
26
|
import { launchEnvironmentOf } from '@deepseek-ai/dsh-launch-environment';
|
|
22
27
|
import { DeepSeekBalanceGateway, fetchDeepSeekBalance } from "./balance.js";
|
|
23
|
-
import { computeSessionSpend, DEFAULT_MODEL_PRICING, DEFAULT_PEAK_HOURS, resolveBilling, } from "./billing.js";
|
|
28
|
+
import { computeSessionSpend, computeTurnSpend, DEFAULT_MODEL_PRICING, DEFAULT_PEAK_HOURS, mergeTodaySpend, resolveBilling, } from "./billing.js";
|
|
24
29
|
import { billingTodaySpendDefinition } from "./projection.js";
|
|
25
30
|
import { TodaySpendCache, TodaySpendScanner } from "./today-spend.js";
|
|
26
31
|
export { DeepSeekBalanceGateway, fetchDeepSeekBalance, parseDeepSeekBalance } from "./balance.js";
|
|
27
|
-
export { addEventContribution, beijingDayKey, computeSessionSpend, computeTodaySpend, DEFAULT_MODEL_PRICING, DEFAULT_PEAK_HOURS, emptyTodaySpend, isPeak, mergeTodaySpend, priceEvent, resolveBilling, } from "./billing.js";
|
|
32
|
+
export { addEventContribution, beijingDayKey, computeSessionSpend, computeTodaySpend, computeTurnSpend, DEFAULT_MODEL_PRICING, DEFAULT_PEAK_HOURS, emptyTodaySpend, isPeak, mergeTodaySpend, priceEvent, resolveBilling, SpendAccumulator, } from "./billing.js";
|
|
28
33
|
export { BILLING_UNIT_KEY, billingTodaySpendDefinition, foldBillingUnit } from "./projection.js";
|
|
29
|
-
export { TodaySpendCache, TodaySpendScanner } from "./today-spend.js";
|
|
34
|
+
export { foldSessionTitle, TodaySpendCache, TodaySpendScanner } from "./today-spend.js";
|
|
30
35
|
export const name = 'llm-billing';
|
|
31
36
|
const DEFAULT_API_KEY_ENV = 'DEEPSEEK_API_KEY';
|
|
32
37
|
const BASE_URL_ENV = 'DEEPSEEK_BASE_URL';
|
|
@@ -123,8 +128,29 @@ export function apply(ctx, config) {
|
|
|
123
128
|
};
|
|
124
129
|
const billing = resolveBilling(config.billing);
|
|
125
130
|
const catalog = (config.models ?? DEFAULT_MODELS).map(model => ({ id: model.id, name: model.name ?? model.id }));
|
|
131
|
+
// Per-session incremental spend cache: a session log is append-only and
|
|
132
|
+
// chronological (the same assumption the projection unit makes), so a spend
|
|
133
|
+
// computed for `count` events stays valid while the log length is unchanged,
|
|
134
|
+
// and only the appended tail needs pricing when it grows. A pricing-table
|
|
135
|
+
// change does not retroactively reprice (same caveat as the projection
|
|
136
|
+
// path); the map is capped so an unbounded session-id space cannot grow it
|
|
137
|
+
// without bound.
|
|
138
|
+
const sessionSpendCache = new Map();
|
|
126
139
|
const fetchSessionSpend = async (sessionId) => {
|
|
127
|
-
|
|
140
|
+
const events = await sessionEvents(ctx, sessionId);
|
|
141
|
+
const cached = sessionSpendCache.get(sessionId);
|
|
142
|
+
if (cached !== undefined && cached.count === events.length)
|
|
143
|
+
return cached.spend;
|
|
144
|
+
if (cached !== undefined && cached.count < events.length) {
|
|
145
|
+
const spend = mergeTodaySpend(cached.spend, computeSessionSpend(events.slice(cached.count), billing, catalog));
|
|
146
|
+
sessionSpendCache.set(sessionId, { count: events.length, spend });
|
|
147
|
+
return spend;
|
|
148
|
+
}
|
|
149
|
+
const spend = computeSessionSpend(events, billing, catalog);
|
|
150
|
+
if (sessionSpendCache.size >= 1024)
|
|
151
|
+
sessionSpendCache.clear();
|
|
152
|
+
sessionSpendCache.set(sessionId, { count: events.length, spend });
|
|
153
|
+
return spend;
|
|
128
154
|
};
|
|
129
155
|
// Plan C: register the per-session spend projection unit on the projection
|
|
130
156
|
// registry. Registration is lazy — it happens on the first projection-path
|
|
@@ -160,7 +186,13 @@ export function apply(ctx, config) {
|
|
|
160
186
|
catalog,
|
|
161
187
|
});
|
|
162
188
|
const todayCache = new TodaySpendCache(dayKey => scanner.scan(dayKey), TODAY_SPEND_CACHE_MS);
|
|
189
|
+
const todaySessionsCache = new TodaySpendCache(dayKey => scanner.scanSessions(dayKey), TODAY_SPEND_CACHE_MS);
|
|
163
190
|
const fetchTodaySpend = async (force = false) => todayCache.get(force);
|
|
164
|
-
|
|
191
|
+
const fetchTodaySessionsSpend = async (force = false) => todaySessionsCache.get(force);
|
|
192
|
+
const fetchTurnSpend = async (sessionId, messageId) => {
|
|
193
|
+
const events = await sessionEvents(ctx, sessionId);
|
|
194
|
+
return computeTurnSpend(events, billing, catalog, messageId);
|
|
195
|
+
};
|
|
196
|
+
new DeepSeekBalanceGateway(ctx, { fetchBalance, fetchSessionSpend, fetchTodaySpend, fetchTodaySessionsSpend, fetchTurnSpend });
|
|
165
197
|
}
|
|
166
198
|
//# sourceMappingURL=index.js.map
|
|
@@ -9,9 +9,10 @@
|
|
|
9
9
|
* with write-back) or, without the cache service, one detached local fold
|
|
10
10
|
* over a full `inspect`. Persisted revisions gate every cold read, so a
|
|
11
11
|
* session whose log did not change since the last resolution costs nothing.
|
|
12
|
-
* - events path (plans A2/A3): collect only today's events
|
|
13
|
-
* Beijing-day filter during collection) with a hard cap,
|
|
14
|
-
* whose persisted revision is unchanged since the last
|
|
12
|
+
* - events path (plans A2/A3): collect and price only today's events in one
|
|
13
|
+
* pass (per-event Beijing-day filter during collection) with a hard cap,
|
|
14
|
+
* skipping sessions whose persisted revision is unchanged since the last
|
|
15
|
+
* scan.
|
|
15
16
|
*
|
|
16
17
|
* Both strategies run behind the same {@link TodaySpendCache}, so a miss
|
|
17
18
|
* happens at most once per 60 seconds per process, and a manual refresh
|
|
@@ -22,9 +23,18 @@
|
|
|
22
23
|
import type { SessionEvent, SessionId } from '@deepseek-ai/dsh-session';
|
|
23
24
|
import type { SessionPersistenceRevision } from '@deepseek-ai/dsh-session-persistence';
|
|
24
25
|
import type { ResolvedBilling } from './billing.ts';
|
|
25
|
-
import type { DeepSeekTodaySpend } from './types.ts';
|
|
26
|
+
import type { DeepSeekTodaySessionsSpend, DeepSeekTodaySpend } from './types.ts';
|
|
26
27
|
import { BILLING_UNIT_KEY, type BillingUnitState } from './projection.ts';
|
|
27
28
|
import type { ProjectionDefinition } from '@deepseek-ai/dsh-session-projection';
|
|
29
|
+
/**
|
|
30
|
+
* Fold one session's durable display title: the latest `session/title`
|
|
31
|
+
* event's text (last-wins, matching the `title` projection), or `null` before
|
|
32
|
+
* the first title lands. The fold runs over the complete log, so an explicit
|
|
33
|
+
* user rename is picked up as soon as its event commits.
|
|
34
|
+
* @param events - one session's complete event log.
|
|
35
|
+
* @returns the session's current title, or `null` when untitled.
|
|
36
|
+
*/
|
|
37
|
+
export declare function foldSessionTitle(events: readonly SessionEvent[]): string | null;
|
|
28
38
|
/** Structural slice of a live session the scanner reads. */
|
|
29
39
|
export interface ScannerSession {
|
|
30
40
|
readonly id: SessionId;
|
|
@@ -88,8 +98,10 @@ export interface TodaySpendScannerDeps {
|
|
|
88
98
|
* coalesces concurrent misses, and a `force` bypass for the manual refresh
|
|
89
99
|
* path. Cross-day invalidation is automatic (the day key changes); a failed
|
|
90
100
|
* scan leaves the previous value in place and retries on the next call.
|
|
101
|
+
* @typeParam T - the cached aggregate's value shape (the spend or its
|
|
102
|
+
* per-session breakdown).
|
|
91
103
|
*/
|
|
92
|
-
export declare class TodaySpendCache {
|
|
104
|
+
export declare class TodaySpendCache<T = DeepSeekTodaySpend> {
|
|
93
105
|
private readonly scan;
|
|
94
106
|
private readonly ttlMs;
|
|
95
107
|
private readonly now;
|
|
@@ -98,18 +110,18 @@ export declare class TodaySpendCache {
|
|
|
98
110
|
private cachedAt;
|
|
99
111
|
private inFlight;
|
|
100
112
|
/**
|
|
113
|
+
* @param scan - the aggregate computation behind a miss.
|
|
101
114
|
* @param ttlMs - time window in milliseconds (default 60 000).
|
|
102
115
|
* @param now - clock source (injectable for tests).
|
|
103
|
-
* @param scan - the aggregate computation behind a miss.
|
|
104
116
|
*/
|
|
105
|
-
constructor(scan: (dayKey: string) => Promise<
|
|
117
|
+
constructor(scan: (dayKey: string) => Promise<T>, ttlMs?: number, now?: () => Date);
|
|
106
118
|
/**
|
|
107
119
|
* Read today's spend, cached per Beijing day within the TTL window.
|
|
108
120
|
* @param force - bypass the time window (manual refresh); the day-key gate
|
|
109
121
|
* and the in-flight coalescing still apply to non-force callers.
|
|
110
122
|
* @returns today's spend.
|
|
111
123
|
*/
|
|
112
|
-
get(force?: boolean): Promise<
|
|
124
|
+
get(force?: boolean): Promise<T>;
|
|
113
125
|
}
|
|
114
126
|
/**
|
|
115
127
|
* The aggregate computation behind a cache miss. Chooses the projection path
|
|
@@ -119,7 +131,7 @@ export declare class TodaySpendCache {
|
|
|
119
131
|
*/
|
|
120
132
|
export declare class TodaySpendScanner {
|
|
121
133
|
private readonly deps;
|
|
122
|
-
/** Cold sessions resolved on the projection path: id → revision + unit state. */
|
|
134
|
+
/** Cold sessions resolved on the projection path: id → revision + unit state + title. */
|
|
123
135
|
private readonly coldResolved;
|
|
124
136
|
/** Cold sessions resolved on the events path: id → revision (events were collected). */
|
|
125
137
|
private lastEventsScan;
|
|
@@ -130,9 +142,49 @@ export declare class TodaySpendScanner {
|
|
|
130
142
|
* @returns today's spend across every session.
|
|
131
143
|
*/
|
|
132
144
|
scan(dayKey: string): Promise<DeepSeekTodaySpend>;
|
|
145
|
+
/**
|
|
146
|
+
* Compute today's per-session spend for one Beijing day, sorted by cost
|
|
147
|
+
* descending. Sessions with no priced usage on the day are omitted; each
|
|
148
|
+
* row carries the session's durable title folded from its log.
|
|
149
|
+
* @param dayKey - the Beijing-time calendar-day key to aggregate.
|
|
150
|
+
* @returns today's per-session rows, highest first.
|
|
151
|
+
*/
|
|
152
|
+
scanSessions(dayKey: string): Promise<DeepSeekTodaySessionsSpend>;
|
|
153
|
+
/**
|
|
154
|
+
* Resolve one cold session's billing unit state and display title through
|
|
155
|
+
* the projection-cache ladder (cached row first, then a detached local
|
|
156
|
+
* fold over a full inspect). A cache-served value carries no title (the
|
|
157
|
+
* ladder only stores projection values), so such rows report `title: null`
|
|
158
|
+
* until the session is inspected again.
|
|
159
|
+
* @param id - the cold session's id.
|
|
160
|
+
* @returns the resolved state and title, or `undefined` when unreadable.
|
|
161
|
+
*/
|
|
162
|
+
private resolveCold;
|
|
133
163
|
/** Projection path: eager cells for live sessions, revision-gated cold ladder for the rest. */
|
|
134
164
|
private scanProjections;
|
|
135
|
-
/**
|
|
165
|
+
/**
|
|
166
|
+
* Events path: price today's events in a single pass (per-event Beijing-day
|
|
167
|
+
* filter during collection, hard cap), gated by revisions.
|
|
168
|
+
*/
|
|
136
169
|
private scanEvents;
|
|
170
|
+
/**
|
|
171
|
+
* Projection-path per-session scan: eager cells for live sessions (title
|
|
172
|
+
* folded from the live log, so a rename is reflected immediately),
|
|
173
|
+
* revision-gated cold ladder for the rest (title resolved on inspect,
|
|
174
|
+
* `null` when served from the projection cache).
|
|
175
|
+
* @param dayKey - the Beijing-time calendar-day key to aggregate.
|
|
176
|
+
* @returns unsorted per-session rows for the day.
|
|
177
|
+
*/
|
|
178
|
+
private scanSessionsProjections;
|
|
179
|
+
/**
|
|
180
|
+
* Events-path per-session scan: price today's events in a single pass,
|
|
181
|
+
* accumulating per session (per-event Beijing-day filter during collection,
|
|
182
|
+
* hard cap), gated by revisions. Titles fold from each session's complete
|
|
183
|
+
* log — a `session/title` event can predate today — so a rename is reflected
|
|
184
|
+
* as soon as the session's log is re-read.
|
|
185
|
+
* @param dayKey - the Beijing-time calendar-day key to aggregate.
|
|
186
|
+
* @returns unsorted per-session rows for the day.
|
|
187
|
+
*/
|
|
188
|
+
private scanSessionsEvents;
|
|
137
189
|
}
|
|
138
190
|
//# sourceMappingURL=today-spend.d.ts.map
|