@ychris12138/dsh-usage-stats 0.2.10 → 0.3.1

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: {
@@ -45,6 +47,28 @@ const SCHEMES = {
45
47
  };
46
48
  }
47
49
  },
50
+ /** OrcaRouter: wallet balance with an OpenAI-compatible billing fallback. */
51
+ orcarouter: {
52
+ // OrcaRouter exposes these endpoints under the public /v1 prefix. Keep the
53
+ // configured origin/path so the normal pinned-network policy still applies
54
+ // and a provider profile never causes a cross-origin request.
55
+ balanceURL: (baseURL) => orcaBillingURL(baseURL, "/balance"),
56
+ subscriptionURL: (baseURL) => orcaBillingURL(baseURL, "/dashboard/billing/subscription"),
57
+ usageURL: (baseURL) => orcaBillingURL(baseURL, "/dashboard/billing/usage"),
58
+ query: async (baseURL, apiKey, timeoutMs, fetchImpl) => {
59
+ // Current deployments expose the wallet's paid/free/promo balance. Older
60
+ // deployments may not have it, so retain the documented OpenAI-shaped
61
+ // subscription + usage fallback for compatibility.
62
+ try {
63
+ return parseOrcaRouterWallet(await requestJSON(orcaBillingURL(baseURL, "/balance"), apiKey, timeoutMs, fetchImpl));
64
+ } catch (error) {
65
+ if (error?.providerStatus !== "unsupported") throw error;
66
+ }
67
+ const subscription = await requestJSON(orcaBillingURL(baseURL, "/dashboard/billing/subscription"), apiKey, timeoutMs, fetchImpl);
68
+ const usage = await requestJSON(orcaBillingURL(baseURL, "/dashboard/billing/usage"), apiKey, timeoutMs, fetchImpl);
69
+ return parseOrcaRouter(subscription, usage);
70
+ }
71
+ },
48
72
  /** Moonshot / Kimi: GET {origin}/v1/users/me/balance — available/cash/voucher. */
49
73
  moonshot: {
50
74
  url: (baseURL) => new URL("/v1/users/me/balance", baseURL).href,
@@ -80,6 +104,74 @@ const SCHEMES = {
80
104
  }
81
105
  };
82
106
 
107
+ function orcaBillingURL(baseURL, path) {
108
+ const base = new URL(baseURL);
109
+ const pathname = base.pathname.replace(/\/+$/, "");
110
+ const prefix = pathname === "" ? "/v1" : pathname.endsWith("/v1") ? pathname : `${pathname}/v1`;
111
+ return new URL(`${prefix}${path}`, base.origin).href;
112
+ }
113
+
114
+ function parseOrcaRouter(subscription, usage) {
115
+ const total = numberOrNull(subscription?.hard_limit_usd ?? subscription?.soft_limit_usd);
116
+ // OpenAI-compatible dashboard usage is reported in cents. Keep the unit
117
+ // conversion in this adapter so `remaining`, `used`, and `limit` share one
118
+ // consistent currency basis.
119
+ const usageCents = numberOrNull(usage?.total_usage);
120
+ if (total === null || usageCents === null || total < 0 || usageCents < 0) {
121
+ throw providerError("invalid-response", "OrcaRouter billing response is missing numeric quota data");
122
+ }
123
+ const used = usageCents / 100;
124
+ const unlimited = total === 100000000
125
+ && numberOrNull(subscription?.soft_limit_usd) === total
126
+ && numberOrNull(subscription?.system_hard_limit_usd) === total;
127
+ return {
128
+ isAvailable: unlimited || total - used > 0,
129
+ currency: "USD",
130
+ total: unlimited ? total : total - used,
131
+ used,
132
+ limit: unlimited ? void 0 : total,
133
+ unlimited,
134
+ granted: void 0,
135
+ toppedUp: void 0
136
+ };
137
+ }
138
+
139
+ function creditArrayTotal(value, currency, label) {
140
+ if (value === void 0 || value === null) return 0;
141
+ if (!Array.isArray(value)) throw providerError("invalid-response", `OrcaRouter ${label} credits are invalid`);
142
+ let total = 0;
143
+ for (const entry of value) {
144
+ if (entry === null || typeof entry !== "object" || Array.isArray(entry)) throw providerError("invalid-response", `OrcaRouter ${label} credits are invalid`);
145
+ const entryCurrency = typeof entry.unit === "string" && entry.unit.trim() !== "" ? entry.unit.trim().toUpperCase() : currency;
146
+ if (entryCurrency !== currency) throw providerError("invalid-response", `OrcaRouter ${label} credits use a different currency`);
147
+ const amount = numberOrNull(entry.balance_usd ?? entry.balance);
148
+ if (amount === null || amount < 0) throw providerError("invalid-response", `OrcaRouter ${label} credits are missing a numeric balance`);
149
+ total += amount;
150
+ }
151
+ return total;
152
+ }
153
+
154
+ function parseOrcaRouterWallet(body) {
155
+ if (body === null || typeof body !== "object" || Array.isArray(body)) throw providerError("invalid-response", "OrcaRouter wallet response is invalid");
156
+ const currency = typeof body.unit === "string" && body.unit.trim() !== "" ? body.unit.trim().toUpperCase() : null;
157
+ if (currency === null) throw providerError("invalid-response", "OrcaRouter wallet response is missing currency");
158
+ const paid = numberOrNull(body.paid_balance);
159
+ if (paid === null || paid < 0) throw providerError("invalid-response", "OrcaRouter wallet response is missing paid balance");
160
+ const remaining = paid
161
+ + creditArrayTotal(body.free_credit, currency, "free")
162
+ + creditArrayTotal(body.promo_credits, currency, "promo");
163
+ return {
164
+ isAvailable: remaining > 0,
165
+ currency,
166
+ total: remaining,
167
+ used: void 0,
168
+ limit: void 0,
169
+ unlimited: false,
170
+ granted: void 0,
171
+ toppedUp: void 0
172
+ };
173
+ }
174
+
83
175
  function providerError(status, message, httpStatus) {
84
176
  const error = new Error(message);
85
177
  error.providerStatus = status;
@@ -90,34 +182,43 @@ function providerError(status, message, httpStatus) {
90
182
  function responseStatus(status) {
91
183
  if (status === 401 || status === 403) return "unauthorized";
92
184
  if (status === 429) return "rate-limited";
185
+ if (status === 404 || status === 405) return "unsupported";
93
186
  return status >= 500 ? "unavailable" : "invalid-response";
94
187
  }
95
188
 
96
- /** Map a provider id (dsh adapter id or pi-ai route) to a balance scheme id. */
97
- 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";
189
+ function numberOrNull(value) {
190
+ if (typeof value === "number" && Number.isFinite(value)) return value;
191
+ if (typeof value === "string" && value.trim() !== "") {
192
+ const parsed = Number(value);
193
+ if (Number.isFinite(parsed)) return parsed;
194
+ }
102
195
  return null;
103
196
  }
104
197
 
105
- /** Query one provider's balance. Throws on transport/HTTP errors. */
106
- export async function queryBalance(scheme, baseURL, apiKey, timeoutMs = 15000, fetchImpl = fetch) {
107
- const spec = SCHEMES[scheme];
108
- if (spec === void 0) throw new Error(`no balance scheme "${scheme}"`);
109
- const response = await fetchImpl(spec.url(baseURL), {
110
- headers: { authorization: `Bearer ${apiKey}` },
198
+ async function requestJSON(url, apiKey, timeoutMs, fetchImpl) {
199
+ const response = await fetchImpl(url, {
200
+ headers: { authorization: `Bearer ${apiKey}`, accept: "application/json" },
111
201
  signal: AbortSignal.timeout(timeoutMs)
112
202
  });
113
203
  if (!response.ok) throw providerError(responseStatus(response.status), `balance API returned HTTP ${response.status}`, response.status);
114
- let body;
115
204
  try {
116
- body = await response.json();
205
+ return await response.json();
117
206
  } catch {
118
207
  throw providerError("invalid-response", "balance API returned invalid JSON");
119
208
  }
120
- return spec.parse(body);
209
+ }
210
+
211
+ /** Map a provider id (dsh adapter id or pi-ai route) to a balance scheme id. */
212
+ export function balanceSchemeOf(providerId) {
213
+ return balanceSchemeForProviderId(providerId);
214
+ }
215
+
216
+ /** Query one provider's balance. Throws on transport/HTTP errors. */
217
+ export async function queryBalance(scheme, baseURL, apiKey, timeoutMs = 15000, fetchImpl = fetch) {
218
+ const spec = SCHEMES[scheme];
219
+ if (spec === void 0) throw new Error(`no balance scheme "${scheme}"`);
220
+ if (typeof spec.query === "function") return spec.query(baseURL, apiKey, timeoutMs, fetchImpl);
221
+ return spec.parse(await requestJSON(spec.url(baseURL), apiKey, timeoutMs, fetchImpl));
121
222
  }
122
223
 
123
224
  /** Scheme ids with built-in support (for docs/tests). */
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
+ }