@rayadesu/dsh-llm-billing 0.1.0-rc.10

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 WilliamLIiii
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.
@@ -0,0 +1,6 @@
1
+ # Bilingual-pair consistency record: the git blob hash of each side as of the
2
+ # last confirmed-consistent state. Both languages carry equal authority; after
3
+ # editing either side, bring the other along and re-record both hashes with:
4
+ # git hash-object README.md README.zh.md
5
+ README.md: 655288ebdb37cee4ecd3edfad8b3953e3a4e87ee
6
+ README.zh.md: e8ac4a2716ffe57200bb9757e1f0c6dabaf77c2f
package/README.md ADDED
@@ -0,0 +1,44 @@
1
+ # @rayadesu/dsh-llm-billing
2
+
3
+ English | [中文](README.zh.md)
4
+
5
+ Standalone host plugin that turns the DeepSeek account balance and per-session spend into a `billing` Remote. It owns the `/user/balance` transport, the peak/off-peak pricing table, and the per-session spend pricing, so a deployment can surface "how much is left, and what this session cost" without coupling that to the chat-completions adapter. The browser half is [`dsh-client-ui-billing`](../ui-billing/README.md).
6
+
7
+ ## Install
8
+
9
+ Add the plugin to a composition (a `cordis.yml` row) and give it a credential. It resolves the API key from the credential seam (or the environment variable named by `apiKeyEnv`) and the endpoint from `baseURL`, then `$DEEPSEEK_BASE_URL`, then the public API.
10
+
11
+ ```yaml
12
+ - id: llm-billing
13
+ name: '@rayadesu/dsh-llm-billing'
14
+ config:
15
+ # apiKeyEnv: DEEPSEEK_API_KEY # default
16
+ # baseURL: https://api.deepseek.com
17
+ ```
18
+
19
+ The plugin registers the `billing` Remote with three methods: `getBalance()` (the parsed `/user/balance` snapshot), `getSessionSpend(sessionId)` (one session's billed cost), and `getTodaySpend()` (every session's billed cost on the current Beijing-time calendar day). The spend prices each `assistant/message` event's billed tokens (cache-hit input, cache-miss input including cache writes, and output including reasoning) at the official rate of the event's own Beijing-time peak/off-peak hour, then sums per model.
20
+
21
+ ## Configuration
22
+
23
+ | Field | Default | Meaning |
24
+ | --- | --- | --- |
25
+ | `apiKeyEnv` | `DEEPSEEK_API_KEY` | Credential-reference (environment-variable) name resolved per call. |
26
+ | `baseURL` | `$DEEPSEEK_BASE_URL` then `https://api.deepseek.com` | Endpoint base; `/user/balance` is appended. |
27
+ | `models` | V4 Flash + V4 Pro + V4 Flash Vision Exp | Advisory display rows, in presentation order. |
28
+ | `billing.peakHours` | 09:00–12:00, 14:00–18:00 (Beijing) | Peak-hour windows; all other hours are off-peak. |
29
+ | `billing.models` | Published V4 rates | Per-model peak/off-peak price rows (`cacheHitInput`, `cacheMissInput`, `output`, in CNY per 1M tokens). |
30
+
31
+ Override one model without dropping the others by supplying a non-empty `billing.models` list; an empty or omitted list falls back to the published defaults.
32
+
33
+ ## Model Experience
34
+
35
+ None, as this package is a read-only Remote projection of provider and session facts and touches no prompt, message, schema, stream, or tool result.
36
+
37
+ #### KV Cache effect
38
+
39
+ None; its only provider call is a credential-authenticated `/user/balance` read, which is not cached in the provider KV store.
40
+
41
+ ## Known Limitations and Deferred Work
42
+
43
+ - **Priced rows only** — the session and today spends only price models that have a `billing.models` row; a model without a rate row is omitted.
44
+ - **On-demand read** — the session spend reads the session's full event log on each call rather than maintaining an incremental aggregate, so cost grows with the per-session log size; `getTodaySpend()` reads every session's log, and a session whose log cannot be read is skipped with a warning instead of failing the whole day's total.
package/README.zh.md ADDED
@@ -0,0 +1,44 @@
1
+ # @rayadesu/dsh-llm-billing
2
+
3
+ [English](README.md) | 中文
4
+
5
+ 独立的宿主插件,把 DeepSeek 账户余额与会话花费变成 `billing` Remote。它拥有 `/user/balance` 传输、峰谷计价表与每会话的计费花费计价,因此部署可以在不把这个能力与聊天补全适配器耦合的前提下,展示「还剩多少、这个会话花了多少」。浏览器侧是 [`dsh-client-ui-billing`](../ui-billing/README.md)。
6
+
7
+ ## 安装
8
+
9
+ 把插件加进组合(一个 `cordis.yml` 行)并给它一个凭据。它先从凭据 seam(或 `apiKeyEnv` 指定的环境变量)解析 API key,再从 `baseURL`、其次 `$DEEPSEEK_BASE_URL`、最后公共 API 解析端点。
10
+
11
+ ```yaml
12
+ - id: llm-billing
13
+ name: '@rayadesu/dsh-llm-billing'
14
+ config:
15
+ # apiKeyEnv: DEEPSEEK_API_KEY # default
16
+ # baseURL: https://api.deepseek.com
17
+ ```
18
+
19
+ 插件注册 `billing` Remote,含三个方法:`getBalance()`(解析后的 `/user/balance` 快照)、`getSessionSpend(sessionId)`(单个会话的计费花费)与 `getTodaySpend()`(当前北京时间自然日内所有会话的计费花费合计)。会话花费把每条 `assistant/message` 事件的计费 token(缓存命中输入、含缓存写入的未命中输入、含推理的输出)按事件自身发生时刻(北京时间)所在的峰/谷单价计价,再按模型汇总。
20
+
21
+ ## 配置
22
+
23
+ | 字段 | 默认 | 含义 |
24
+ | --- | --- | --- |
25
+ | `apiKeyEnv` | `DEEPSEEK_API_KEY` | 每次调用时解析的凭据引用(环境变量)名。 |
26
+ | `baseURL` | `$DEEPSEEK_BASE_URL`,其次 `https://api.deepseek.com` | 端点基础地址;会追加 `/user/balance`。 |
27
+ | `models` | V4 Flash + V4 Pro + V4 Flash Vision Exp | 展示用的模型行,按展示顺序。 |
28
+ | `billing.peakHours` | 09:00–12:00、14:00–18:00(北京) | 高峰时段窗口;其余时段为低谷。 |
29
+ | `billing.models` | 官方 V4 费率 | 每个模型的峰/谷单价行(`cacheHitInput`、`cacheMissInput`、`output`,单位:元/百万 token)。 |
30
+
31
+ 只想覆盖某个模型而不丢其它,就提供一个非空的 `billing.models` 列表;空或省略则回退到官方默认费率。
32
+
33
+ ## 模型体验
34
+
35
+ 无,因为本包是 provider 与会话事实的只读 Remote 投影,不触及 prompt、消息、schema、流或工具结果。
36
+
37
+ #### KV Cache effect
38
+
39
+ 无;它唯一的 provider 调用是一次带凭据的 `/user/balance` 读取,不落在 provider 的 KV 缓存里。
40
+
41
+ ## 已知限制与暂缓事项
42
+
43
+ - **有费率行才计价** —— 会话花费与今日花费只统计价目表(`billing.models`)里有的模型;没有费率行的模型不计入。
44
+ - **按需读取** —— 会话花费在每次调用时读取该会话的完整事件日志,而非维护增量聚合,因此成本随单会话日志大小增长;`getTodaySpend()` 会读取每个会话的日志,日志无法读取的会话带警告跳过,而不是让整日合计失败。
package/lib/index.js ADDED
@@ -0,0 +1,562 @@
1
+ import z from "@deepseek-ai/schemastery";
2
+ import { LlmError, assertUsableApiKey } from "@deepseek-ai/dsh-llm";
3
+ import { credentialRef } from "@deepseek-ai/dsh-credentials";
4
+ import { launchEnvironmentOf } from "@deepseek-ai/dsh-launch-environment";
5
+ import { Remote, TypertRemoteService } from "@deepseek-ai/dsh-typert-protocol";
6
+ //#region lib/types/balance.js
7
+ /**
8
+ * DeepSeek account-balance capability: the `GET /user/balance` transport and
9
+ * the Remote gateway that exposes one snapshot to trusted clients. The fetch
10
+ * takes an already-resolved endpoint and bearer token so the registering
11
+ * plugin stays the one owner of credential policy; the gateway carries only a
12
+ * `fetchBalance` thunk for the same reason.
13
+ * @module @rayadesu/dsh-llm-billing/balance
14
+ */
15
+ var __runInitializers = function(thisArg, initializers, value) {
16
+ var useValue = arguments.length > 2;
17
+ for (var i = 0; i < initializers.length; i++) value = useValue ? initializers[i].call(thisArg, value) : initializers[i].call(thisArg);
18
+ return useValue ? value : void 0;
19
+ };
20
+ var __esDecorate = function(ctor, descriptorIn, decorators, contextIn, initializers, extraInitializers) {
21
+ function accept(f) {
22
+ if (f !== void 0 && typeof f !== "function") throw new TypeError("Function expected");
23
+ return f;
24
+ }
25
+ var kind = contextIn.kind, key = kind === "getter" ? "get" : kind === "setter" ? "set" : "value";
26
+ var target = !descriptorIn && ctor ? contextIn["static"] ? ctor : ctor.prototype : null;
27
+ var descriptor = descriptorIn || (target ? Object.getOwnPropertyDescriptor(target, contextIn.name) : {});
28
+ var _, done = false;
29
+ for (var i = decorators.length - 1; i >= 0; i--) {
30
+ var context = {};
31
+ for (var p in contextIn) context[p] = p === "access" ? {} : contextIn[p];
32
+ for (var p in contextIn.access) context.access[p] = contextIn.access[p];
33
+ context.addInitializer = function(f) {
34
+ if (done) throw new TypeError("Cannot add initializers after decoration has completed");
35
+ extraInitializers.push(accept(f || null));
36
+ };
37
+ var result = (0, decorators[i])(kind === "accessor" ? {
38
+ get: descriptor.get,
39
+ set: descriptor.set
40
+ } : descriptor[key], context);
41
+ if (kind === "accessor") {
42
+ if (result === void 0) continue;
43
+ if (result === null || typeof result !== "object") throw new TypeError("Object expected");
44
+ if (_ = accept(result.get)) descriptor.get = _;
45
+ if (_ = accept(result.set)) descriptor.set = _;
46
+ if (_ = accept(result.init)) initializers.unshift(_);
47
+ } else if (_ = accept(result)) if (kind === "field") initializers.unshift(_);
48
+ else descriptor[key] = _;
49
+ }
50
+ if (target) Object.defineProperty(target, contextIn.name, descriptor);
51
+ done = true;
52
+ };
53
+ /** Map a balance HTTP status to a stable LlmError code. */
54
+ function httpErrorCode(status) {
55
+ if (status === 401 || status === 403) return "AUTH";
56
+ if (status === 429) return "RATE_LIMIT";
57
+ if (status >= 500) return "SERVER";
58
+ return `HTTP_${status}`;
59
+ }
60
+ /**
61
+ * Parse and validate the DeepSeek balance response at the wire boundary.
62
+ * @param body - decoded JSON response body.
63
+ * @returns the validated, detached balance snapshot.
64
+ * @throws {@link LlmError} with code `TRANSPORT` when the body is malformed.
65
+ */
66
+ function parseDeepSeekBalance(body) {
67
+ if (typeof body !== "object" || body === null || Array.isArray(body)) throw new LlmError("DeepSeek balance response was not a JSON object", "TRANSPORT");
68
+ const response = body;
69
+ const isAvailable = response["is_available"];
70
+ const infos = response["balance_infos"];
71
+ if (typeof isAvailable !== "boolean" || !Array.isArray(infos)) throw new LlmError("DeepSeek balance response is missing is_available or balance_infos", "TRANSPORT");
72
+ return {
73
+ isAvailable,
74
+ lines: infos.map((info, index) => {
75
+ if (typeof info !== "object" || info === null || Array.isArray(info)) throw new LlmError(`DeepSeek balance line ${index} is malformed`, "TRANSPORT");
76
+ const line = info;
77
+ const currency = line["currency"];
78
+ const total = line["total_balance"];
79
+ const granted = line["granted_balance"];
80
+ const toppedUp = line["topped_up_balance"];
81
+ if (typeof currency !== "string" || currency.length === 0 || typeof total !== "string" || typeof granted !== "string" || typeof toppedUp !== "string") throw new LlmError(`DeepSeek balance line ${index} has missing or invalid fields`, "TRANSPORT");
82
+ return {
83
+ currency,
84
+ total,
85
+ granted,
86
+ toppedUp
87
+ };
88
+ })
89
+ };
90
+ }
91
+ /**
92
+ * Fetch one account-balance snapshot from `{baseURL}/user/balance`.
93
+ * @param baseURL - resolved endpoint base; `/user/balance` is appended.
94
+ * @param apiKey - resolved bearer token for this endpoint.
95
+ * @param signal - optional cancellation.
96
+ * @returns the validated balance snapshot.
97
+ * @throws {@link LlmError} for transport, HTTP, or malformed-response failures.
98
+ */
99
+ async function fetchDeepSeekBalance(baseURL, apiKey, signal) {
100
+ let response;
101
+ try {
102
+ response = await fetch(`${baseURL}/user/balance`, {
103
+ method: "GET",
104
+ headers: {
105
+ "authorization": `Bearer ${apiKey}`,
106
+ "accept": "application/json"
107
+ },
108
+ ...signal === void 0 ? {} : { signal }
109
+ });
110
+ } catch (error) {
111
+ if (signal?.aborted) throw new LlmError("DeepSeek balance request aborted by caller", "ABORTED", { cause: error });
112
+ throw new LlmError(`DeepSeek balance request to ${baseURL} failed`, "TRANSPORT", { cause: error });
113
+ }
114
+ if (!response.ok) throw new LlmError(`DeepSeek balance request failed (HTTP ${response.status})`, httpErrorCode(response.status), { status: response.status });
115
+ let body;
116
+ try {
117
+ body = await response.json();
118
+ } catch (error) {
119
+ throw new LlmError("DeepSeek balance response was not valid JSON", "TRANSPORT", { cause: error });
120
+ }
121
+ return parseDeepSeekBalance(body);
122
+ }
123
+ /**
124
+ * Remote-only service exposing the DeepSeek account balance and session spend.
125
+ * The plugin that owns connection, credential, and session-history resolution
126
+ * constructs it with the matching thunks, so the Remote boundary never sees an
127
+ * endpoint, key, or the session store.
128
+ */
129
+ let DeepSeekBalanceGateway = (() => {
130
+ let _classSuper = TypertRemoteService;
131
+ let _instanceExtraInitializers = [];
132
+ let _getBalance_decorators;
133
+ let _getSessionSpend_decorators;
134
+ let _getTodaySpend_decorators;
135
+ return class DeepSeekBalanceGateway extends _classSuper {
136
+ static {
137
+ const _metadata = typeof Symbol === "function" && Symbol.metadata ? Object.create(_classSuper[Symbol.metadata] ?? null) : void 0;
138
+ _getBalance_decorators = [Remote("getBalance")];
139
+ _getSessionSpend_decorators = [Remote("getSessionSpend")];
140
+ _getTodaySpend_decorators = [Remote("getTodaySpend")];
141
+ __esDecorate(this, null, _getBalance_decorators, {
142
+ kind: "method",
143
+ name: "getBalance",
144
+ static: false,
145
+ private: false,
146
+ access: {
147
+ has: (obj) => "getBalance" in obj,
148
+ get: (obj) => obj.getBalance
149
+ },
150
+ metadata: _metadata
151
+ }, null, _instanceExtraInitializers);
152
+ __esDecorate(this, null, _getSessionSpend_decorators, {
153
+ kind: "method",
154
+ name: "getSessionSpend",
155
+ static: false,
156
+ private: false,
157
+ access: {
158
+ has: (obj) => "getSessionSpend" in obj,
159
+ get: (obj) => obj.getSessionSpend
160
+ },
161
+ metadata: _metadata
162
+ }, null, _instanceExtraInitializers);
163
+ __esDecorate(this, null, _getTodaySpend_decorators, {
164
+ kind: "method",
165
+ name: "getTodaySpend",
166
+ static: false,
167
+ private: false,
168
+ access: {
169
+ has: (obj) => "getTodaySpend" in obj,
170
+ get: (obj) => obj.getTodaySpend
171
+ },
172
+ metadata: _metadata
173
+ }, null, _instanceExtraInitializers);
174
+ if (_metadata) Object.defineProperty(this, Symbol.metadata, {
175
+ enumerable: true,
176
+ configurable: true,
177
+ writable: true,
178
+ value: _metadata
179
+ });
180
+ }
181
+ options = __runInitializers(this, _instanceExtraInitializers);
182
+ /**
183
+ * Register the balance Remote under the `billing` namespace.
184
+ * @param ctx - owning plugin context.
185
+ * @param options - balance and spend thunks bound to the plugin's facts.
186
+ */
187
+ constructor(ctx, options) {
188
+ super(ctx, "billing");
189
+ this.options = options;
190
+ }
191
+ /**
192
+ * Read the current DeepSeek account balance.
193
+ * @returns the validated balance snapshot.
194
+ */
195
+ getBalance() {
196
+ return this.options.fetchBalance();
197
+ }
198
+ /**
199
+ * Read one session's billed spend, priced per event by its peak/off-peak hour.
200
+ * @param sessionId - the session whose spend to compute.
201
+ * @returns the session's total cost plus one row per priced model.
202
+ */
203
+ getSessionSpend(sessionId) {
204
+ return this.options.fetchSessionSpend(sessionId);
205
+ }
206
+ /**
207
+ * Read today's billed spend across every session, priced per event by its
208
+ * Beijing-time calendar day and peak/off-peak hour.
209
+ * @returns today's total cost plus one row per priced model.
210
+ */
211
+ getTodaySpend() {
212
+ return this.options.fetchTodaySpend();
213
+ }
214
+ };
215
+ })();
216
+ //#endregion
217
+ //#region lib/types/billing.js
218
+ /**
219
+ * DeepSeek billing: the peak/off-peak pricing table and the per-session spend
220
+ * pricing. Pure functions over session events and the pricing table, so the
221
+ * Remote gateway stays transport-free and the whole spend is testable without
222
+ * a key.
223
+ * @module @rayadesu/dsh-llm-billing/billing
224
+ */
225
+ /** Published peak-hour windows (Beijing time): 09:00–12:00 and 14:00–18:00. */
226
+ const DEFAULT_PEAK_HOURS = [{
227
+ start: 9,
228
+ end: 12
229
+ }, {
230
+ start: 14,
231
+ end: 18
232
+ }];
233
+ /** Official peak/off-peak rates (CNY per 1M tokens), effective 2026-08-17. */
234
+ const DEFAULT_MODEL_PRICING = [
235
+ {
236
+ model: "deepseek-v4-flash",
237
+ peak: {
238
+ cacheHitInput: .1,
239
+ cacheMissInput: 3,
240
+ output: 9
241
+ },
242
+ offPeak: {
243
+ cacheHitInput: .05,
244
+ cacheMissInput: 1.5,
245
+ output: 4.5
246
+ }
247
+ },
248
+ {
249
+ model: "deepseek-v4-pro",
250
+ peak: {
251
+ cacheHitInput: .3,
252
+ cacheMissInput: 9,
253
+ output: 27
254
+ },
255
+ offPeak: {
256
+ cacheHitInput: .15,
257
+ cacheMissInput: 4.5,
258
+ output: 13.5
259
+ }
260
+ },
261
+ {
262
+ model: "deepseek-v4-flash-vision-exp",
263
+ peak: {
264
+ cacheHitInput: .1,
265
+ cacheMissInput: 3,
266
+ output: 9
267
+ },
268
+ offPeak: {
269
+ cacheHitInput: .05,
270
+ cacheMissInput: 1.5,
271
+ output: 4.5
272
+ }
273
+ }
274
+ ];
275
+ /**
276
+ * Resolve optional configuration to a pricing table, defaulting omitted or
277
+ * empty rows to the published rates. Schemastery materializes an absent
278
+ * `z.array` as `[]` rather than `undefined`, so emptiness — not just absence —
279
+ * selects the defaults. Explicit non-empty rows override the same model; a
280
+ * supplied non-empty `models` list is authoritative.
281
+ * @param config - optional raw billing configuration.
282
+ * @returns the resolved table and peak-hour windows.
283
+ */
284
+ function resolveBilling(config) {
285
+ const peakHours = config?.peakHours !== void 0 && config.peakHours.length > 0 ? config.peakHours : DEFAULT_PEAK_HOURS;
286
+ const rows = config?.models !== void 0 && config.models.length > 0 ? config.models : DEFAULT_MODEL_PRICING;
287
+ const models = /* @__PURE__ */ new Map();
288
+ for (const row of rows) models.set(row.model, {
289
+ peak: row.peak,
290
+ offPeak: row.offPeak
291
+ });
292
+ return {
293
+ peakHours,
294
+ models
295
+ };
296
+ }
297
+ /** The Beijing (Asia/Shanghai, UTC+8, no DST) hour of a timestamp. */
298
+ function beijingHour(now) {
299
+ return new Date(now.getTime() + 8 * 36e5).getUTCHours();
300
+ }
301
+ /** The Beijing (Asia/Shanghai, UTC+8, no DST) calendar-day key of a timestamp. */
302
+ function beijingDayKey(now) {
303
+ return new Date(now.getTime() + 8 * 36e5).toISOString().slice(0, 10);
304
+ }
305
+ /**
306
+ * Whether a timestamp falls inside any peak-hour window (Beijing time).
307
+ * @param billing - resolved pricing with peak-hour windows.
308
+ * @param now - the moment to classify.
309
+ * @returns true during peak hours.
310
+ */
311
+ function isPeak(billing, now) {
312
+ const hour = beijingHour(now);
313
+ return billing.peakHours.some(({ start, end }) => hour >= start && hour < end);
314
+ }
315
+ /**
316
+ * Price a set of billed events at the official per-model rates, applying the
317
+ * peak/off-peak table per event by its Beijing-time hour. Each
318
+ * `assistant/message` event with usage contributes cache-hit input, cache-miss
319
+ * input (uncached input plus cache writes), and output (reasoning included)
320
+ * tokens at the rate of its own timestamp, with the three component costs
321
+ * carried separately; a model with usage but no pricing row is omitted (the
322
+ * published table prices only the two V4 rows).
323
+ * @param events - the events to price.
324
+ * @param billing - resolved pricing with peak-hour windows.
325
+ * @param catalog - model display rows, in presentation order.
326
+ * @returns the total cost plus one row per priced model.
327
+ */
328
+ function priceEvents(events, billing, catalog) {
329
+ const names = new Map(catalog.map((model) => [model.id, model.name]));
330
+ const rows = /* @__PURE__ */ new Map();
331
+ for (const event of events) {
332
+ if (event.type !== "assistant/message") continue;
333
+ const reported = event.data.usage;
334
+ if (reported === void 0) continue;
335
+ const model = event.data.message.source.model;
336
+ const pricing = billing.models.get(model);
337
+ if (pricing === void 0) continue;
338
+ const peak = isPeak(billing, new Date(event.time));
339
+ const price = peak ? pricing.peak : pricing.offPeak;
340
+ const hit = reported.cacheReadTokens ?? 0;
341
+ const miss = reported.inputTokens + (reported.cacheWriteTokens ?? 0);
342
+ const output = reported.outputTokens;
343
+ const hitCost = hit * price.cacheHitInput / 1e6;
344
+ const missCost = miss * price.cacheMissInput / 1e6;
345
+ const outputCost = output * price.output / 1e6;
346
+ const cost = hitCost + missCost + outputCost;
347
+ let row = rows.get(model);
348
+ if (row === void 0) {
349
+ row = {
350
+ cacheHitInputTokens: 0,
351
+ cacheMissInputTokens: 0,
352
+ outputTokens: 0,
353
+ cost: 0,
354
+ peakCost: 0,
355
+ offPeakCost: 0,
356
+ cacheHitInputCost: 0,
357
+ cacheMissInputCost: 0,
358
+ outputCost: 0
359
+ };
360
+ rows.set(model, row);
361
+ }
362
+ row.cacheHitInputTokens += hit;
363
+ row.cacheMissInputTokens += miss;
364
+ row.outputTokens += output;
365
+ row.cost += cost;
366
+ row.cacheHitInputCost += hitCost;
367
+ row.cacheMissInputCost += missCost;
368
+ row.outputCost += outputCost;
369
+ if (peak) row.peakCost += cost;
370
+ else row.offPeakCost += cost;
371
+ }
372
+ const models = [...rows.entries()].map(([model, row]) => ({
373
+ model,
374
+ displayName: names.get(model) ?? model,
375
+ cost: row.cost,
376
+ peakCost: row.peakCost,
377
+ offPeakCost: row.offPeakCost,
378
+ cacheHitInputTokens: row.cacheHitInputTokens,
379
+ cacheMissInputTokens: row.cacheMissInputTokens,
380
+ outputTokens: row.outputTokens,
381
+ cacheHitInputCost: row.cacheHitInputCost,
382
+ cacheMissInputCost: row.cacheMissInputCost,
383
+ outputCost: row.outputCost
384
+ }));
385
+ return {
386
+ total: models.reduce((sum, model) => sum + model.cost, 0),
387
+ models
388
+ };
389
+ }
390
+ /**
391
+ * Price one session's complete event log at the official per-model rates.
392
+ * @param events - one session's complete event log.
393
+ * @param billing - resolved pricing with peak-hour windows.
394
+ * @param catalog - model display rows, in presentation order.
395
+ * @returns the session's total cost plus one row per priced model.
396
+ */
397
+ function computeSessionSpend(events, billing, catalog) {
398
+ return priceEvents(events, billing, catalog);
399
+ }
400
+ /**
401
+ * Price every event whose Beijing-time calendar day is the day of `now`,
402
+ * aggregating across every session's event log. Events from other Beijing
403
+ * days are ignored, so a caller passes the concatenated logs of all sessions.
404
+ * @param events - every session's complete event log, concatenated.
405
+ * @param billing - resolved pricing with peak-hour windows.
406
+ * @param catalog - model display rows, in presentation order.
407
+ * @param now - the reference moment whose Beijing-time calendar day is "today".
408
+ * @returns today's total cost plus one row per priced model.
409
+ */
410
+ function computeTodaySpend(events, billing, catalog, now = /* @__PURE__ */ new Date()) {
411
+ const day = beijingDayKey(now);
412
+ return priceEvents(events.filter((event) => beijingDayKey(new Date(event.time)) === day), billing, catalog);
413
+ }
414
+ //#endregion
415
+ //#region lib/types/index.js
416
+ /**
417
+ * DeepSeek account balance and session-spend provider, as a standalone host
418
+ * plugin. It resolves the DeepSeek endpoint and API key from its own config and
419
+ * the credential/environment seams, prices each session's billed usage with the
420
+ * peak/off-peak table, and exposes the `billing` Remote (`getBalance`, the
421
+ * per-session `getSessionSpend`, and the all-sessions `getTodaySpend`).
422
+ * @module @rayadesu/dsh-llm-billing
423
+ */
424
+ const name = "llm-billing";
425
+ const DEFAULT_API_KEY_ENV = "DEEPSEEK_API_KEY";
426
+ const BASE_URL_ENV = "DEEPSEEK_BASE_URL";
427
+ /** Public API default; deployments may point elsewhere via $DEEPSEEK_BASE_URL. */
428
+ const PUBLIC_BASE_URL = "https://api.deepseek.com";
429
+ const DEFAULT_MODELS = [
430
+ {
431
+ id: "deepseek-v4-flash",
432
+ name: "DeepSeek-V4-Flash"
433
+ },
434
+ {
435
+ id: "deepseek-v4-pro",
436
+ name: "DeepSeek-V4-Pro"
437
+ },
438
+ {
439
+ id: "deepseek-v4-flash-vision-exp",
440
+ name: "DeepSeek-V4-Flash-Vision-Exp"
441
+ }
442
+ ];
443
+ const billingModel = z.object({
444
+ id: z.string().required(),
445
+ name: z.string()
446
+ });
447
+ const tokenPrice = z.object({
448
+ cacheHitInput: z.number().min(0),
449
+ cacheMissInput: z.number().min(0),
450
+ output: z.number().min(0)
451
+ });
452
+ const billingConfig = z.object({
453
+ peakHours: z.array(z.object({
454
+ start: z.number().step(1).min(0).max(23),
455
+ end: z.number().step(1).min(0).max(24)
456
+ })).default(DEFAULT_PEAK_HOURS),
457
+ models: z.array(z.object({
458
+ model: z.string().required(),
459
+ peak: tokenPrice,
460
+ offPeak: tokenPrice
461
+ })).default(DEFAULT_MODEL_PRICING)
462
+ });
463
+ const Config = z.object({
464
+ apiKeyEnv: z.string().role("credential-ref").default(DEFAULT_API_KEY_ENV),
465
+ baseURL: z.string(),
466
+ models: z.array(billingModel).default(DEFAULT_MODELS),
467
+ billing: billingConfig
468
+ });
469
+ /**
470
+ * Read one session's event log: the live SessionStore first, then the
471
+ * persistence backend for a flushed session.
472
+ * @param ctx - plugin context carrying the SessionStore and optional persistence.
473
+ * @param sessionId - the session to read.
474
+ * @returns the session's complete event log.
475
+ * @throws {@link LlmError} with code `NOT_FOUND` when the session is unknown.
476
+ */
477
+ async function sessionEvents(ctx, sessionId) {
478
+ const live = ctx.get("sessions")?.get(sessionId);
479
+ if (live !== void 0) return live.events;
480
+ const persistence = ctx.get("sessionPersistence");
481
+ if (persistence !== void 0) for (const header of await persistence.list()) {
482
+ if (header.id !== sessionId) continue;
483
+ return (await persistence.inspect(sessionId)).events;
484
+ }
485
+ throw new LlmError(`llm-billing: session ${sessionId} not found`, "NOT_FOUND");
486
+ }
487
+ /**
488
+ * Read every session's event log, concatenated: each live SessionStore
489
+ * session first (its log may hold events not yet flushed), then each persisted
490
+ * session that is not live, so no event is counted twice. Events are appended
491
+ * one at a time: spreading a very large log into `push(...)` exceeds the
492
+ * engine's argument limit and throws a stack RangeError.
493
+ * @param ctx - plugin context carrying the SessionStore and optional persistence.
494
+ * @returns every session's complete event log, concatenated.
495
+ */
496
+ async function allSessionEvents(ctx) {
497
+ const events = [];
498
+ const sessions = ctx.get("sessions");
499
+ const liveIds = /* @__PURE__ */ new Set();
500
+ if (sessions !== void 0) for (const session of sessions.list()) {
501
+ liveIds.add(session.id);
502
+ for (const event of session.events) events.push(event);
503
+ }
504
+ const persistence = ctx.get("sessionPersistence");
505
+ if (persistence !== void 0) for (const header of await persistence.list()) {
506
+ if (liveIds.has(header.id)) continue;
507
+ try {
508
+ const inspection = await persistence.inspect(header.id);
509
+ for (const event of inspection.events) events.push(event);
510
+ } catch (error) {
511
+ ctx.logger.warn(`llm-billing: skipping unreadable session ${header.id}: ${String(error)}`);
512
+ }
513
+ }
514
+ return events;
515
+ }
516
+ /**
517
+ * Register the `billing` Remote under the `billing` namespace.
518
+ * @param ctx - owning plugin context.
519
+ * @param config - validated plugin config.
520
+ */
521
+ function apply(ctx, config) {
522
+ const baseURL = () => config.baseURL ?? launchEnvironmentOf(ctx).get(BASE_URL_ENV)?.value ?? "https://api.deepseek.com";
523
+ const apiKeyRef = credentialRef(config.apiKeyEnv ?? DEFAULT_API_KEY_ENV);
524
+ const resolveApiKey = async () => {
525
+ const credentials = ctx.get("credentials");
526
+ if (credentials !== void 0) {
527
+ const hit = await credentials.resolve(apiKeyRef);
528
+ if (hit !== void 0) return assertUsableApiKey(hit.value, "llm-billing", apiKeyRef);
529
+ } else {
530
+ const ambient = launchEnvironmentOf(ctx).get(apiKeyRef);
531
+ if (ambient !== void 0 && ambient.value.length > 0) return assertUsableApiKey(ambient.value, "llm-billing", apiKeyRef);
532
+ }
533
+ throw new LlmError(`llm-billing: no API key; store ${apiKeyRef} through the credentials service or export it`, "MISSING_CREDENTIAL");
534
+ };
535
+ const fetchBalance = async () => {
536
+ const apiKey = await resolveApiKey();
537
+ return fetchDeepSeekBalance(baseURL(), apiKey);
538
+ };
539
+ const fetchSessionSpend = async (sessionId) => {
540
+ const billing = resolveBilling(config.billing);
541
+ const catalog = (config.models ?? DEFAULT_MODELS).map((model) => ({
542
+ id: model.id,
543
+ name: model.name ?? model.id
544
+ }));
545
+ return computeSessionSpend(await sessionEvents(ctx, sessionId), billing, catalog);
546
+ };
547
+ const fetchTodaySpend = async () => {
548
+ const billing = resolveBilling(config.billing);
549
+ const catalog = (config.models ?? DEFAULT_MODELS).map((model) => ({
550
+ id: model.id,
551
+ name: model.name ?? model.id
552
+ }));
553
+ return computeTodaySpend(await allSessionEvents(ctx), billing, catalog);
554
+ };
555
+ new DeepSeekBalanceGateway(ctx, {
556
+ fetchBalance,
557
+ fetchSessionSpend,
558
+ fetchTodaySpend
559
+ });
560
+ }
561
+ //#endregion
562
+ export { Config, DEFAULT_MODEL_PRICING, DEFAULT_PEAK_HOURS, DeepSeekBalanceGateway, PUBLIC_BASE_URL, apply, computeSessionSpend, computeTodaySpend, fetchDeepSeekBalance, isPeak, name, parseDeepSeekBalance, resolveBilling };