@ychris12138/dsh-usage-stats 0.2.10 → 0.3.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/lib/balance.js CHANGED
@@ -11,6 +11,8 @@
11
11
  * @module dsh-usage-stats/balance
12
12
  */
13
13
 
14
+ import { balanceSchemeForProviderId } from "./provider-identity.js";
15
+
14
16
  const SCHEMES = {
15
17
  /** DeepSeek: GET {origin}/user/balance — CNY balance_infos entry. */
16
18
  deepseek: {
@@ -95,11 +97,7 @@ function responseStatus(status) {
95
97
 
96
98
  /** Map a provider id (dsh adapter id or pi-ai route) to a balance scheme id. */
97
99
  export function balanceSchemeOf(providerId) {
98
- if (providerId === "deepseek-official" || providerId === "deepseek") return "deepseek";
99
- if (providerId === "openrouter") return "openrouter";
100
- if (providerId === "moonshotai" || providerId === "moonshotai-cn" || providerId === "kimi" || providerId === "kimi-coding") return "moonshot";
101
- if (providerId === "zai" || providerId === "zai-coding-cn") return "zai";
102
- return null;
100
+ return balanceSchemeForProviderId(providerId);
103
101
  }
104
102
 
105
103
  /** Query one provider's balance. Throws on transport/HTTP errors. */
package/lib/billing.js ADDED
@@ -0,0 +1,319 @@
1
+ /**
2
+ * Pure billing derivation over provider-reported usage samples.
3
+ *
4
+ * Token accounting remains owned by usage.js/DSH. This module only carries
5
+ * monetary contributions produced by pricing.js, their provenance, and
6
+ * fail-closed budget state.
7
+ *
8
+ * @module dsh-usage-stats/billing
9
+ */
10
+
11
+ import { estimateTokenCost, PRICING_RULES } from "./pricing.js";
12
+ import { PROVIDER_IDENTITY_POLICY_VERSION, resolveProviderIdentity } from "./provider-identity.js";
13
+
14
+ const ISO_CURRENCY = /^[A-Z]{3}$/;
15
+
16
+ export const DEFAULT_BUDGET_CONFIG = Object.freeze({
17
+ currency: "USD",
18
+ daily: null,
19
+ monthly: null
20
+ });
21
+
22
+ function optionalLimit(value, label) {
23
+ if (value === void 0 || value === null) return null;
24
+ if (typeof value !== "number" || !Number.isFinite(value) || value <= 0) {
25
+ throw new Error(`${label} must be a positive finite number or null`);
26
+ }
27
+ return value;
28
+ }
29
+
30
+ /** Validate the public, secret-free daily/monthly budget configuration. */
31
+ export function validateBudgetConfig(raw = {}) {
32
+ if (raw === null || typeof raw !== "object" || Array.isArray(raw)) throw new Error("budgets must be an object");
33
+ const currency = raw.currency ?? DEFAULT_BUDGET_CONFIG.currency;
34
+ if (typeof currency !== "string" || !ISO_CURRENCY.test(currency)) throw new Error("budgets.currency must be an uppercase three-letter currency code");
35
+ return {
36
+ currency,
37
+ daily: optionalLimit(raw.daily, "budgets.daily"),
38
+ monthly: optionalLimit(raw.monthly, "budgets.monthly")
39
+ };
40
+ }
41
+
42
+ function safeBaseURLState(baseURL) {
43
+ if (typeof baseURL !== "string" || baseURL.trim() === "") return { state: "absent" };
44
+ try {
45
+ const hostname = new URL(baseURL).hostname.toLowerCase().replace(/\.$/, "");
46
+ return hostname === "" ? { state: "malformed" } : { state: "hostname", hostname };
47
+ } catch {
48
+ return { state: "malformed" };
49
+ }
50
+ }
51
+
52
+ function compareProjection(left, right) {
53
+ const a = JSON.stringify(left);
54
+ const b = JSON.stringify(right);
55
+ return a < b ? -1 : a > b ? 1 : 0;
56
+ }
57
+
58
+ /** Secret-free provider facts that can change pricing eligibility. */
59
+ export function providerPricingIdentityProjection(providers = [], config = { monitors: {} }) {
60
+ return (Array.isArray(providers) ? providers : []).map((provider) => {
61
+ const identity = resolveProviderIdentity(provider, config);
62
+ return {
63
+ routeId: identity.routeId,
64
+ providerFamily: identity.providerFamily,
65
+ pricingFamily: identity.pricingFamily,
66
+ confidence: identity.confidence,
67
+ baseURL: safeBaseURLState(identity.baseURL)
68
+ };
69
+ }).sort(compareProjection);
70
+ }
71
+
72
+ /** Exact pricing catalog + runtime provider-identity fingerprint for cached costs. */
73
+ export function pricingFingerprint({ providers = [], config = { monitors: {} }, rules = PRICING_RULES } = {}) {
74
+ return JSON.stringify({
75
+ schemaVersion: 1,
76
+ providerIdentityPolicy: PROVIDER_IDENTITY_POLICY_VERSION,
77
+ providerPricingIdentities: providerPricingIdentityProjection(providers, config),
78
+ pricingCatalog: rules
79
+ });
80
+ }
81
+
82
+ function providerProjectionByRoute(fingerprint) {
83
+ try {
84
+ const parsed = JSON.parse(fingerprint);
85
+ if (parsed?.schemaVersion !== 1 || !Array.isArray(parsed.providerPricingIdentities)) return null;
86
+ const byRoute = new Map();
87
+ for (const projection of parsed.providerPricingIdentities) {
88
+ if (projection === null || typeof projection !== "object" || typeof projection.routeId !== "string") return null;
89
+ const entries = byRoute.get(projection.routeId) ?? [];
90
+ entries.push(projection);
91
+ byRoute.set(projection.routeId, entries);
92
+ }
93
+ for (const entries of byRoute.values()) entries.sort(compareProjection);
94
+ return byRoute;
95
+ } catch {
96
+ return null;
97
+ }
98
+ }
99
+
100
+ /**
101
+ * Changed route ids, or null when an old fingerprint cannot prove its provider
102
+ * identity. Callers must treat null as a global fail-closed transition.
103
+ */
104
+ export function changedProviderPricingRoutes(previousFingerprint, nextFingerprint) {
105
+ const previous = providerProjectionByRoute(previousFingerprint);
106
+ const next = providerProjectionByRoute(nextFingerprint);
107
+ if (previous === null || next === null) return null;
108
+ const routeIds = new Set([...previous.keys(), ...next.keys()]);
109
+ return [...routeIds].filter((routeId) => (
110
+ JSON.stringify(previous.get(routeId) ?? []) !== JSON.stringify(next.get(routeId) ?? [])
111
+ )).sort();
112
+ }
113
+
114
+ /** Empty additive monetary accumulator. */
115
+ export function createCostAccumulator() {
116
+ return {
117
+ pricedSamples: 0,
118
+ incompleteSamples: 0,
119
+ currencies: new Map(),
120
+ rules: new Map()
121
+ };
122
+ }
123
+
124
+ function tokenCount(buckets) {
125
+ if (buckets === null || typeof buckets !== "object") return 0;
126
+ return ["inputTokens", "outputTokens", "cacheReadTokens", "cacheWriteTokens"]
127
+ .reduce((sum, key) => sum + (typeof buckets[key] === "number" && Number.isFinite(buckets[key]) && buckets[key] > 0 ? buckets[key] : 0), 0);
128
+ }
129
+
130
+ function safeSource(source) {
131
+ if (source === null || typeof source !== "object" || Array.isArray(source)) return null;
132
+ const kind = typeof source.kind === "string" ? source.kind : null;
133
+ const provider = typeof source.provider === "string" ? source.provider : null;
134
+ const url = typeof source.url === "string" ? source.url : null;
135
+ return kind === null || provider === null || url === null ? null : { kind, provider, url };
136
+ }
137
+
138
+ /**
139
+ * Convert one pricing result into an additive contribution. Positive token
140
+ * usage with no trustworthy estimate is explicitly incomplete.
141
+ */
142
+ export function costSampleOf(estimate, buckets) {
143
+ if (tokenCount(buckets) === 0) return { counted: false };
144
+ if (estimate === null || typeof estimate !== "object"
145
+ || typeof estimate.amount !== "number" || !Number.isFinite(estimate.amount) || estimate.amount < 0
146
+ || typeof estimate.currency !== "string" || !ISO_CURRENCY.test(estimate.currency)) {
147
+ return { counted: true, complete: false };
148
+ }
149
+ return {
150
+ counted: true,
151
+ complete: true,
152
+ amount: estimate.amount,
153
+ currency: estimate.currency,
154
+ ruleId: typeof estimate.ruleId === "string" && estimate.ruleId !== "" ? estimate.ruleId : null,
155
+ source: safeSource(estimate.source),
156
+ updatedAt: typeof estimate.updatedAt === "string" ? estimate.updatedAt : null
157
+ };
158
+ }
159
+
160
+ function adjustCount(map, key, amount, countDelta) {
161
+ const previous = map.get(key) ?? { amount: 0, count: 0 };
162
+ const next = { amount: previous.amount + amount, count: previous.count + countDelta };
163
+ if (next.count <= 0) map.delete(key);
164
+ else map.set(key, next);
165
+ }
166
+
167
+ /** Add (`direction=1`) or subtract (`direction=-1`) one cost contribution. */
168
+ export function applyCostSample(target, sample, direction = 1) {
169
+ if (sample?.counted !== true) return target;
170
+ if (direction !== 1 && direction !== -1) throw new Error("cost sample direction must be 1 or -1");
171
+ if (sample.complete !== true) {
172
+ target.incompleteSamples = Math.max(0, target.incompleteSamples + direction);
173
+ return target;
174
+ }
175
+ target.pricedSamples = Math.max(0, target.pricedSamples + direction);
176
+ adjustCount(target.currencies, sample.currency, direction * sample.amount, direction);
177
+ if (sample.ruleId !== null) {
178
+ const previous = target.rules.get(sample.ruleId) ?? { count: 0, source: sample.source, updatedAt: sample.updatedAt };
179
+ const count = previous.count + direction;
180
+ if (count <= 0) target.rules.delete(sample.ruleId);
181
+ else target.rules.set(sample.ruleId, {
182
+ count,
183
+ source: previous.source ?? sample.source,
184
+ updatedAt: previous.updatedAt ?? sample.updatedAt
185
+ });
186
+ }
187
+ return target;
188
+ }
189
+
190
+ /** Merge one complete accumulator into another. */
191
+ export function mergeCostAccumulator(target, source) {
192
+ target.pricedSamples += source.pricedSamples;
193
+ target.incompleteSamples += source.incompleteSamples;
194
+ for (const [currency, entry] of source.currencies) adjustCount(target.currencies, currency, entry.amount, entry.count);
195
+ for (const [ruleId, entry] of source.rules) {
196
+ const previous = target.rules.get(ruleId);
197
+ target.rules.set(ruleId, previous === void 0 ? { ...entry } : {
198
+ count: previous.count + entry.count,
199
+ source: previous.source ?? entry.source,
200
+ updatedAt: previous.updatedAt ?? entry.updatedAt
201
+ });
202
+ }
203
+ return target;
204
+ }
205
+
206
+ function provenanceOf(accumulator) {
207
+ const entries = [...accumulator.rules.entries()].filter(([, entry]) => entry.count > 0).sort(([a], [b]) => a.localeCompare(b));
208
+ const sources = new Map();
209
+ let updatedAt = null;
210
+ for (const [, entry] of entries) {
211
+ if (entry.source !== null) sources.set(JSON.stringify(entry.source), entry.source);
212
+ if (typeof entry.updatedAt === "string" && (updatedAt === null || entry.updatedAt > updatedAt)) updatedAt = entry.updatedAt;
213
+ }
214
+ return {
215
+ ruleIds: entries.map(([ruleId]) => ruleId),
216
+ source: sources.size === 1 ? [...sources.values()][0] : null,
217
+ updatedAt
218
+ };
219
+ }
220
+
221
+ /** Render an accumulator; any unpriced sample or mixed currency fails closed. */
222
+ export function renderCost(accumulator) {
223
+ const sampleCount = accumulator.pricedSamples + accumulator.incompleteSamples;
224
+ const complete = sampleCount > 0 && accumulator.incompleteSamples === 0 && accumulator.currencies.size === 1;
225
+ const [currency, currencyEntry] = complete ? [...accumulator.currencies.entries()][0] : [null, null];
226
+ return {
227
+ estimatedCost: complete ? currencyEntry.amount : null,
228
+ currency,
229
+ costComplete: complete,
230
+ pricing: provenanceOf(accumulator)
231
+ };
232
+ }
233
+
234
+ /** JSON-safe cost accumulator for the incremental cache. */
235
+ export function serializeCostAccumulator(accumulator) {
236
+ return {
237
+ pricedSamples: accumulator.pricedSamples,
238
+ incompleteSamples: accumulator.incompleteSamples,
239
+ currencies: Object.fromEntries([...accumulator.currencies].map(([currency, entry]) => [currency, { ...entry }])),
240
+ rules: Object.fromEntries([...accumulator.rules].map(([ruleId, entry]) => [ruleId, { ...entry }]))
241
+ };
242
+ }
243
+
244
+ /** Lenient cache restore; invalid fields degrade to an empty/incomplete-safe state. */
245
+ export function parseCostAccumulator(raw) {
246
+ const accumulator = createCostAccumulator();
247
+ if (raw === null || typeof raw !== "object" || Array.isArray(raw)) return accumulator;
248
+ accumulator.pricedSamples = Number.isSafeInteger(raw.pricedSamples) && raw.pricedSamples >= 0 ? raw.pricedSamples : 0;
249
+ accumulator.incompleteSamples = Number.isSafeInteger(raw.incompleteSamples) && raw.incompleteSamples >= 0 ? raw.incompleteSamples : 0;
250
+ for (const [currency, entry] of Object.entries(raw.currencies ?? {})) {
251
+ if (!ISO_CURRENCY.test(currency) || entry === null || typeof entry !== "object"
252
+ || typeof entry.amount !== "number" || !Number.isFinite(entry.amount)
253
+ || !Number.isSafeInteger(entry.count) || entry.count <= 0) continue;
254
+ accumulator.currencies.set(currency, { amount: entry.amount, count: entry.count });
255
+ }
256
+ for (const [ruleId, entry] of Object.entries(raw.rules ?? {})) {
257
+ if (ruleId === "" || entry === null || typeof entry !== "object" || !Number.isSafeInteger(entry.count) || entry.count <= 0) continue;
258
+ accumulator.rules.set(ruleId, {
259
+ count: entry.count,
260
+ source: safeSource(entry.source),
261
+ updatedAt: typeof entry.updatedAt === "string" ? entry.updatedAt : null
262
+ });
263
+ }
264
+ return accumulator;
265
+ }
266
+
267
+ /** Build the only provider-aware bridge from session route identity to pricing.js. */
268
+ export function createUsageCostEstimator(providers, config = { monitors: {} }, currency = "USD", rules = PRICING_RULES) {
269
+ const byId = new Map((Array.isArray(providers) ? providers : []).map((provider) => [provider.id, provider]));
270
+ return ({ providerId, model, timestamp, buckets }) => {
271
+ const provider = byId.get(providerId) ?? { id: providerId, displayName: providerId };
272
+ const identity = resolveProviderIdentity(provider, config);
273
+ return estimateTokenCost({ identity, model, timestamp, currency, buckets }, rules);
274
+ };
275
+ }
276
+
277
+ /** Budget threshold policy: 80% warns, 100% is critical. */
278
+ export function budgetLevel(percent) {
279
+ if (typeof percent !== "number" || !Number.isFinite(percent) || percent < 0) return "unknown";
280
+ if (percent >= 100) return "critical";
281
+ if (percent >= 80) return "warning";
282
+ return "normal";
283
+ }
284
+
285
+ function budgetWindow(limit, currency, accumulator) {
286
+ const sampleCount = accumulator.pricedSamples + accumulator.incompleteSamples;
287
+ const rendered = renderCost(accumulator);
288
+ const knownEmpty = sampleCount === 0;
289
+ const compatible = knownEmpty || rendered.costComplete && rendered.currency === currency;
290
+ const estimatedSpend = compatible ? (knownEmpty ? 0 : rendered.estimatedCost) : null;
291
+ const percent = limit === null || estimatedSpend === null ? null : estimatedSpend / limit * 100;
292
+ return {
293
+ limit,
294
+ currency,
295
+ estimatedSpend,
296
+ percent,
297
+ costComplete: compatible,
298
+ level: limit === null ? "disabled" : budgetLevel(percent)
299
+ };
300
+ }
301
+
302
+ function localDayKey(timeMs) {
303
+ const date = new Date(timeMs);
304
+ return `${date.getFullYear()}-${String(date.getMonth() + 1).padStart(2, "0")}-${String(date.getDate()).padStart(2, "0")}`;
305
+ }
306
+
307
+ /** Render current local-day and local-month budget state from day costs. */
308
+ export function renderBudgetSummary(dayCosts, config = DEFAULT_BUDGET_CONFIG, now = Date.now()) {
309
+ const day = localDayKey(now);
310
+ const month = day.slice(0, 7);
311
+ const daily = dayCosts.get(day)?.total ?? createCostAccumulator();
312
+ const monthly = createCostAccumulator();
313
+ for (const [date, entry] of dayCosts) if (date.startsWith(month)) mergeCostAccumulator(monthly, entry.total);
314
+ return {
315
+ currency: config.currency,
316
+ daily: budgetWindow(config.daily, config.currency, daily),
317
+ monthly: budgetWindow(config.monthly, config.currency, monthly)
318
+ };
319
+ }