iobroker.ai-usage 0.7.1 → 0.9.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.
Files changed (45) hide show
  1. package/README.md +20 -15
  2. package/admin/custom/assets/Components-C4wFRka3.js +1 -0
  3. package/admin/custom/assets/ConfigPanel-CGCj6Crq.js +1 -0
  4. package/admin/custom/assets/{index-CJLGptkU.js → index-DKkCKYc8.js} +1 -1
  5. package/admin/custom/assets/{mf-entry-bootstrap-0-fadc1b6e.js → mf-entry-bootstrap-0-d02c5f06.js} +1 -1
  6. package/admin/custom/assets/virtualExposes-CdmVMFlA.js +1 -0
  7. package/admin/custom/assets/{virtual_mf-REMOTE_ENTRY_ID___mfe_internal__AiUsageComponentSet__customComponents_js-DblFyTyg.js → virtual_mf-REMOTE_ENTRY_ID___mfe_internal__AiUsageComponentSet__customComponents_js-BjDe1IXR.js} +1 -1
  8. package/admin/custom/assets/virtual_mf-exposes-ssr___mfe_internal__AiUsageComponentSet__customComponents_js-DA9gAGbr.js +2 -0
  9. package/admin/custom/assets/{virtual_mf-exposes___mfe_internal__AiUsageComponentSet__customComponents_js-B9kw23ao.js → virtual_mf-exposes___mfe_internal__AiUsageComponentSet__customComponents_js-Wtw6jqAg.js} +2 -2
  10. package/admin/custom/customComponents.js +1 -1
  11. package/admin/custom/mf-manifest.json +1 -1
  12. package/build/lib/http.js +20 -53
  13. package/build/lib/http.js.map +2 -2
  14. package/build/lib/poll-engine.js +117 -22
  15. package/build/lib/poll-engine.js.map +2 -2
  16. package/build/lib/providers/anthropic-api.js +5 -20
  17. package/build/lib/providers/anthropic-api.js.map +2 -2
  18. package/build/lib/providers/chatgpt-sub.js +9 -10
  19. package/build/lib/providers/chatgpt-sub.js.map +2 -2
  20. package/build/lib/providers/claude-sub.js +7 -8
  21. package/build/lib/providers/claude-sub.js.map +2 -2
  22. package/build/lib/providers/deepseek.js +4 -7
  23. package/build/lib/providers/deepseek.js.map +2 -2
  24. package/build/lib/providers/gemini-sub.js +12 -12
  25. package/build/lib/providers/gemini-sub.js.map +2 -2
  26. package/build/lib/providers/openai.js +5 -20
  27. package/build/lib/providers/openai.js.map +2 -2
  28. package/build/lib/providers/openrouter.js +5 -11
  29. package/build/lib/providers/openrouter.js.map +2 -2
  30. package/build/lib/providers/report-utils.js +17 -0
  31. package/build/lib/providers/report-utils.js.map +2 -2
  32. package/build/lib/pure-helpers.js +14 -28
  33. package/build/lib/pure-helpers.js.map +2 -2
  34. package/build/lib/snapshot-tree.js +23 -8
  35. package/build/lib/snapshot-tree.js.map +2 -2
  36. package/build/lib/totals.js +7 -9
  37. package/build/lib/totals.js.map +2 -2
  38. package/build/main.js +134 -34
  39. package/build/main.js.map +2 -2
  40. package/io-package.json +27 -30
  41. package/package.json +1 -1
  42. package/admin/custom/assets/Components-52mNLgTp.js +0 -1
  43. package/admin/custom/assets/ConfigPanel-Balb6rXz.js +0 -1
  44. package/admin/custom/assets/virtualExposes-B7ft9DTx.js +0 -1
  45. package/admin/custom/assets/virtual_mf-exposes-ssr___mfe_internal__AiUsageComponentSet__customComponents_js-DpYtjEm8.js +0 -2
@@ -38,6 +38,7 @@ class PollEngine {
38
38
  constructor(accounts, providers, intervalSec, deps) {
39
39
  this.intervalSec = intervalSec;
40
40
  this.deps = deps;
41
+ this.configuredAccounts = accounts.length;
41
42
  for (const config of accounts) {
42
43
  const provider = providers.get(config.id);
43
44
  if (!provider) {
@@ -56,7 +57,11 @@ class PollEngine {
56
57
  state: "no-connection",
57
58
  error: "waiting for the first query",
58
59
  createdObjects: /* @__PURE__ */ new Set(),
59
- firstPollDone: false
60
+ firstPollDone: false,
61
+ polling: false,
62
+ pollAgain: false,
63
+ deliveredIds: null,
64
+ staticIds: []
60
65
  });
61
66
  }
62
67
  }
@@ -66,21 +71,32 @@ class PollEngine {
66
71
  handles = [];
67
72
  stopped = false;
68
73
  firstRoundReported = false;
74
+ /**
75
+ * How many accounts the user switched on — including those the adapter cannot
76
+ * poll because their credential is missing. `total.accounts` is what the user
77
+ * configured, not what happened to work out.
78
+ */
79
+ configuredAccounts;
69
80
  /** Create the static per-account and totals objects, then arm the poll cycles. */
70
81
  async start() {
71
82
  for (const runtime of this.runtimes) {
72
83
  await this.createAccountSkeleton(runtime);
73
84
  }
74
85
  await this.createTotalsSkeleton();
75
- await this.writeTotals();
86
+ this.writeTotals();
76
87
  if (this.runtimes.length === 0) {
77
88
  this.reportFirstRoundOnce();
78
89
  return;
79
90
  }
80
91
  this.runtimes.forEach((runtime, index) => {
81
92
  this.handles.push(
82
- this.deps.scheduleOnce(() => void this.pollAccount(runtime), index * STAGGER_MS),
83
- this.deps.schedule(() => void this.pollAccount(runtime), this.intervalSec * 1e3)
93
+ this.deps.scheduleOnce(() => {
94
+ if (this.stopped) {
95
+ return;
96
+ }
97
+ this.handles.push(this.deps.schedule(() => void this.pollAccount(runtime), this.intervalSec * 1e3));
98
+ void this.pollAccount(runtime);
99
+ }, index * STAGGER_MS)
84
100
  );
85
101
  });
86
102
  }
@@ -92,9 +108,33 @@ class PollEngine {
92
108
  }
93
109
  this.handles.length = 0;
94
110
  }
95
- /** The account ids the engine drives (for the stale-object cleanup). */
96
- get accountIds() {
97
- return this.runtimes.map((runtime) => runtime.config.id);
111
+ /**
112
+ * Say that no account is delivering any more — for shutdown.
113
+ *
114
+ * A stopped adapter reads nothing, so it must not leave every account claiming to
115
+ * be online: `info.unreach` is what colours the account in the admin's object tree
116
+ * and the badge in the settings, and on its last value it stays green for as long
117
+ * as the instance is switched off.
118
+ *
119
+ * The returned promise is what makes this WORK. Measured on the live server
120
+ * 2026-08-27: issued fire-and-forget and followed by an immediate `callback()`,
121
+ * not one of these writes ever reached the database — the process was gone first.
122
+ * The caller has to wait for this (with its own time limit) before saying it is
123
+ * done.
124
+ *
125
+ * @returns resolves once every write has been acknowledged
126
+ */
127
+ async markAllOffline() {
128
+ const writes = [];
129
+ for (const runtime of this.runtimes) {
130
+ writes.push(this.deps.setStateChanged(`${runtime.config.id}.info.unreach`, true));
131
+ writes.push(
132
+ this.deps.setStateChanged(`${runtime.config.id}.info.error`, "The adapter is stopped \u2014 nothing is being read")
133
+ );
134
+ }
135
+ writes.push(this.deps.setStateChanged("total.accountsReachable", 0));
136
+ writes.push(this.deps.setStateChanged("info.connection", false));
137
+ await Promise.all(writes);
98
138
  }
99
139
  /**
100
140
  * Poll one account immediately, by id. Used after a successful sign-in: waiting
@@ -113,12 +153,38 @@ class PollEngine {
113
153
  /**
114
154
  * Poll one account now (also used by the staggered first run).
115
155
  *
156
+ * Never two at once for the same account: a sign-in triggers an immediate poll,
157
+ * which can land on top of a scheduled one — and two token refreshes in parallel
158
+ * on a rotating refresh token sign each other out. A request that arrives while
159
+ * one is running is remembered and runs right after, so nothing is lost.
160
+ *
116
161
  * @param runtime the account's runtime
117
162
  */
118
163
  async pollAccount(runtime) {
119
164
  if (this.stopped) {
120
165
  return;
121
166
  }
167
+ if (runtime.polling) {
168
+ runtime.pollAgain = true;
169
+ return;
170
+ }
171
+ runtime.polling = true;
172
+ try {
173
+ await this.pollOnce(runtime);
174
+ } finally {
175
+ runtime.polling = false;
176
+ }
177
+ if (runtime.pollAgain && !this.stopped) {
178
+ runtime.pollAgain = false;
179
+ await this.pollAccount(runtime);
180
+ }
181
+ }
182
+ /**
183
+ * One poll of one account: fetch, classify, write.
184
+ *
185
+ * @param runtime the account's runtime
186
+ */
187
+ async pollOnce(runtime) {
122
188
  const { config } = runtime;
123
189
  if (this.deps.now() < runtime.skipUntil) {
124
190
  this.deps.log.debug(`${config.name}: in rate-limit backoff \u2014 poll skipped`);
@@ -141,7 +207,7 @@ class PollEngine {
141
207
  this.handleFailure(runtime, e);
142
208
  }
143
209
  this.writeAccountInfo(runtime);
144
- await this.writeTotals();
210
+ this.writeTotals();
145
211
  runtime.firstPollDone = true;
146
212
  this.reportFirstRoundOnce();
147
213
  }
@@ -164,7 +230,7 @@ class PollEngine {
164
230
  async applySnapshot(runtime, snapshot) {
165
231
  var _a, _b, _c;
166
232
  const { config } = runtime;
167
- const { objects, writes } = (0, import_snapshot_tree.mapSnapshot)(config.id, config.name, config.provider, snapshot);
233
+ const { objects, writes } = (0, import_snapshot_tree.mapSnapshot)(config.id, snapshot);
168
234
  for (const object of objects) {
169
235
  if (!runtime.createdObjects.has(object.id)) {
170
236
  await this.deps.upsertObject(object);
@@ -174,12 +240,16 @@ class PollEngine {
174
240
  for (const write of writes) {
175
241
  this.deps.setState(write.id, write.value);
176
242
  }
243
+ await this.removeVanished(
244
+ runtime,
245
+ writes.map((write) => write.id)
246
+ );
177
247
  const driver = (0, import_snapshot_tree.limitingWindow)(snapshot);
178
248
  const percent = (_a = driver == null ? void 0 : driver.percent) != null ? _a : 0;
179
249
  const wasWarning = runtime.status.warning;
180
250
  runtime.status.warning = percent >= config.warnThreshold;
181
- this.deps.setStateChanged(`${config.id}.warning`, runtime.status.warning);
182
- this.deps.setStateChanged(`${config.id}.limitReached`, percent >= 100);
251
+ void this.deps.setStateChanged(`${config.id}.warning`, runtime.status.warning);
252
+ void this.deps.setStateChanged(`${config.id}.limitReached`, percent >= 100);
183
253
  if (runtime.status.warning && !wasWarning) {
184
254
  const window = driver ? `${driver.label} ` : "";
185
255
  const message = `${config.name}: ${window}at ${Math.round(percent)} % (threshold ${config.warnThreshold} %)`;
@@ -187,6 +257,26 @@ class PollEngine {
187
257
  (_c = (_b = this.deps).notify) == null ? void 0 : _c.call(_b, config.name, message);
188
258
  }
189
259
  }
260
+ /**
261
+ * Delete what this account no longer delivers.
262
+ *
263
+ * The first round after a start compares against the DATABASE, so a window or
264
+ * model that disappeared while the adapter was stopped is caught as well; every
265
+ * round after that compares against the previous snapshot, which costs nothing.
266
+ *
267
+ * @param runtime the account's runtime
268
+ * @param delivered the state ids this snapshot wrote
269
+ */
270
+ async removeVanished(runtime, delivered) {
271
+ var _a;
272
+ const known = (_a = runtime.deliveredIds) != null ? _a : await this.deps.listStateIds(runtime.config.id);
273
+ for (const id of (0, import_snapshot_tree.orphanObjectIds)(known, delivered, runtime.staticIds)) {
274
+ await this.deps.deleteObject(id);
275
+ runtime.createdObjects.delete(id);
276
+ this.deps.log.info(`${runtime.config.name}: removed "${id}" \u2014 the provider no longer reports it`);
277
+ }
278
+ runtime.deliveredIds = delivered;
279
+ }
190
280
  /**
191
281
  * Classify a fetch failure.
192
282
  *
@@ -258,7 +348,7 @@ class PollEngine {
258
348
  */
259
349
  writeAccountInfo(runtime) {
260
350
  const { config } = runtime;
261
- this.writeAccountStatus(runtime);
351
+ void this.writeAccountStatus(runtime);
262
352
  if (runtime.status.reachable) {
263
353
  this.deps.setState(`${config.id}.info.lastUpdate`, new Date(this.deps.now()).toISOString());
264
354
  }
@@ -275,25 +365,29 @@ class PollEngine {
275
365
  *
276
366
  * @param runtime the account's runtime
277
367
  */
278
- writeAccountStatus(runtime) {
368
+ async writeAccountStatus(runtime) {
279
369
  const { config } = runtime;
280
370
  const delivering = runtime.state === "ok" || runtime.state === "rate-limited";
281
- this.deps.setStateChanged(`${config.id}.info.unreach`, !delivering);
282
- this.deps.setStateChanged(`${config.id}.info.error`, runtime.error);
371
+ await Promise.all([
372
+ this.deps.setStateChanged(`${config.id}.info.unreach`, !delivering),
373
+ this.deps.setStateChanged(`${config.id}.info.error`, runtime.error)
374
+ ]);
283
375
  }
284
376
  /** Recompute and write the totals + info.connection. */
285
- async writeTotals() {
286
- const totals = (0, import_totals.computeTotals)(this.runtimes.map((runtime) => runtime.status));
377
+ writeTotals() {
378
+ const totals = (0, import_totals.computeTotals)(
379
+ this.runtimes.map((runtime) => runtime.status),
380
+ this.configuredAccounts
381
+ );
287
382
  this.deps.setState("total.costs.today", totals.costsToday);
288
383
  this.deps.setState("total.costs.month", totals.costsMonth);
289
384
  this.deps.setState("total.costs.projectedMonth", totals.costsProjectedMonth);
290
385
  this.deps.setState("total.maxLimitPercent", totals.maxLimitPercent);
291
386
  this.deps.setState("total.warningsActive", totals.warningsActive);
292
- this.deps.setStateChanged("total.limitReached", totals.limitReached);
387
+ void this.deps.setStateChanged("total.limitReached", totals.limitReached);
293
388
  this.deps.setState("total.accountsReachable", totals.accountsReachable);
294
- this.deps.setState("total.accounts", totals.accounts);
295
- this.deps.setStateChanged("info.connection", totals.accountsReachable > 0);
296
- return Promise.resolve();
389
+ void this.deps.setStateChanged("total.accounts", totals.accounts);
390
+ void this.deps.setStateChanged("info.connection", totals.accountsReachable > 0);
297
391
  }
298
392
  /**
299
393
  * The static per-account objects that exist regardless of what the source delivers.
@@ -363,7 +457,8 @@ class PollEngine {
363
457
  await this.deps.upsertObject(def);
364
458
  runtime.createdObjects.add(def.id);
365
459
  }
366
- this.writeAccountStatus(runtime);
460
+ runtime.staticIds = defs.filter((def) => def.type === "state").map((def) => def.id);
461
+ void this.writeAccountStatus(runtime);
367
462
  }
368
463
  /** The totals skeleton (channel + states). */
369
464
  async createTotalsSkeleton() {
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "version": 3,
3
3
  "sources": ["../../src/lib/poll-engine.ts"],
4
- "sourcesContent": ["import type { AccountConfig } from \"./pure-helpers\";\nimport { FetchError, type UsageProvider, type UsageSnapshot } from \"./provider\";\nimport { limitingWindow, mapSnapshot, type ObjectDef } from \"./snapshot-tree\";\nimport { computeTotals, type AccountStatus } from \"./totals\";\n\n/**\n * What the adapter currently knows about one account, in one word.\n *\n * `ok` and `rate-limited` mean the AI service is up and talking to us,\n * `unauthorized` means it is up but rejects our sign-in, `service-down` means the\n * service itself answered with a fault, `no-connection` means we never reached it.\n */\nexport type AccountState = \"ok\" | \"unauthorized\" | \"rate-limited\" | \"service-down\" | \"no-connection\";\n\n/** Consecutive network failures after which an account is judged unreachable. */\nconst MAX_NETWORK_FAILURES = 3;\n/** First backoff after a rate-limit answer (ms); doubles per repeat. */\nconst BACKOFF_START_MS = 10 * 60 * 1000;\n/** Backoff ceiling (ms). */\nconst BACKOFF_MAX_MS = 60 * 60 * 1000;\n/** Stagger between the accounts' first polls (ms) so they never fire in one burst. */\nconst STAGGER_MS = 3000;\n\n/** The adapter callbacks the engine drives \u2014 narrow, so tests need no adapter mock. */\nexport interface EngineDeps {\n /** Create or update an object. */\n upsertObject(def: ObjectDef): Promise<void>;\n /** Write a state value with ack \u2014 for MEASUREMENTS, where every cycle carries information. */\n setState(id: string, value: boolean | number | string): void;\n /**\n * Write a state only when the value differs from what the database holds \u2014 for\n * INDICATORS. js-controller does the comparison (`setStateChangedAsync`), which is\n * what the rest of the fleet uses; a hand-rolled cache would only know what this\n * process wrote and would still write blindly after a restart.\n */\n setStateChanged(id: string, value: boolean | number | string): void;\n /** Schedule a repeating callback; returns a cancel handle. */\n schedule(cb: () => void, ms: number): unknown;\n /** Schedule a one-shot callback; returns a cancel handle. */\n scheduleOnce(cb: () => void, ms: number): unknown;\n /** Cancel a handle from schedule/scheduleOnce. */\n cancel(handle: unknown): void;\n /** Current time (ms since epoch) \u2014 injected for tests. */\n now(): number;\n /** Adapter log. */\n log: { debug(m: string): void; info(m: string): void; warn(m: string): void; error(m: string): void };\n /** Raise a user-facing notification (threshold crossing, broken credentials). */\n notify?(accountName: string, message: string): void;\n /**\n * Called once, when every account has finished its FIRST poll.\n *\n * The first round is staggered on purpose, so the adapter cannot report what the\n * object tree gained until the last account has been through. A config change\n * restarts the instance, so this is also the only moment a user needs the report.\n */\n afterFirstRound?(): void;\n}\n\n/** One account's runtime state inside the engine. */\ninterface AccountRuntime {\n config: AccountConfig;\n provider: UsageProvider;\n status: AccountStatus;\n /** Consecutive network failures. */\n failCount: number;\n /** Skip polls until this time (rate-limit backoff). */\n skipUntil: number;\n /** Current backoff length (ms). */\n backoffMs: number;\n /** Whether the auth-broken notification has been raised (reset on success). */\n authNotified: boolean;\n /** Whether the AI service itself answered on the last attempt. */\n serviceOnline: boolean;\n /** The account's one-word state. */\n state: AccountState;\n /** Plain-text reason shown in `info.error`; empty while everything works. */\n error: string;\n /** Object ids already created for this account (create-once cache). */\n createdObjects: Set<string>;\n /** Whether this account has been through its first poll. */\n firstPollDone: boolean;\n}\n\n/**\n * Drives the polling of all configured accounts: staggered starts, one independent\n * cycle per account, typed failure handling (auth = immediate + one notification,\n * rate-limit = backoff keeping last values, service = reported down at once,\n * network = tolerated three times), warn-threshold transitions on the PLAN-WIDE\n * windows only, and the adapter-wide totals. Pure orchestration \u2014 all IO is injected.\n */\nexport class PollEngine {\n private readonly runtimes: AccountRuntime[] = [];\n private readonly handles: unknown[] = [];\n private stopped = false;\n private firstRoundReported = false;\n\n /**\n * @param accounts the validated account configs\n * @param providers each account id's provider (accounts without one are skipped)\n * @param intervalSec the poll interval in seconds\n * @param deps the injected adapter callbacks\n */\n public constructor(\n accounts: readonly AccountConfig[],\n providers: ReadonlyMap<string, UsageProvider>,\n private readonly intervalSec: number,\n private readonly deps: EngineDeps,\n ) {\n for (const config of accounts) {\n const provider = providers.get(config.id);\n if (!provider) {\n deps.log.warn(`${config.name}: provider \"${config.provider}\" is not available \u2014 account skipped`);\n continue;\n }\n this.runtimes.push({\n config,\n provider,\n status: { reachable: false, warning: false },\n failCount: 0,\n skipUntil: 0,\n backoffMs: BACKOFF_START_MS,\n authNotified: false,\n serviceOnline: false,\n state: \"no-connection\",\n error: \"waiting for the first query\",\n createdObjects: new Set(),\n firstPollDone: false,\n });\n }\n }\n\n /** Create the static per-account and totals objects, then arm the poll cycles. */\n public async start(): Promise<void> {\n for (const runtime of this.runtimes) {\n await this.createAccountSkeleton(runtime);\n }\n await this.createTotalsSkeleton();\n await this.writeTotals();\n if (this.runtimes.length === 0) {\n // Nothing will ever poll \u2014 report right away, the cleanup may still have changed something.\n this.reportFirstRoundOnce();\n return;\n }\n this.runtimes.forEach((runtime, index) => {\n this.handles.push(\n this.deps.scheduleOnce(() => void this.pollAccount(runtime), index * STAGGER_MS),\n this.deps.schedule(() => void this.pollAccount(runtime), this.intervalSec * 1000),\n );\n });\n }\n\n /** Cancel every timer. Synchronous \u2014 safe from onUnload. */\n public stop(): void {\n this.stopped = true;\n for (const handle of this.handles) {\n this.deps.cancel(handle);\n }\n this.handles.length = 0;\n }\n\n /** The account ids the engine drives (for the stale-object cleanup). */\n public get accountIds(): string[] {\n return this.runtimes.map(runtime => runtime.config.id);\n }\n\n /**\n * Poll one account immediately, by id. Used after a successful sign-in: waiting\n * up to a full interval there reads as \"the sign-in did not work\".\n *\n * @param accountId the account's object id\n */\n public async pollNow(accountId: string): Promise<void> {\n const runtime = this.runtimes.find(entry => entry.config.id === accountId);\n if (runtime) {\n // A fresh sign-in clears a previous auth failure and any backoff.\n runtime.authNotified = false;\n runtime.skipUntil = 0;\n await this.pollAccount(runtime);\n }\n }\n\n /**\n * Poll one account now (also used by the staggered first run).\n *\n * @param runtime the account's runtime\n */\n private async pollAccount(runtime: AccountRuntime): Promise<void> {\n if (this.stopped) {\n return;\n }\n const { config } = runtime;\n if (this.deps.now() < runtime.skipUntil) {\n this.deps.log.debug(`${config.name}: in rate-limit backoff \u2014 poll skipped`);\n // Still counts as \"been through\": otherwise an account that starts inside a\n // backoff would hold the first-round report back forever.\n runtime.firstPollDone = true;\n this.reportFirstRoundOnce();\n return;\n }\n try {\n const snapshot = await runtime.provider.fetch();\n runtime.failCount = 0;\n runtime.backoffMs = BACKOFF_START_MS;\n runtime.authNotified = false;\n runtime.status.snapshot = snapshot;\n runtime.status.reachable = true;\n runtime.serviceOnline = true;\n runtime.state = \"ok\";\n runtime.error = \"\";\n await this.applySnapshot(runtime, snapshot);\n } catch (e) {\n this.handleFailure(runtime, e);\n }\n this.writeAccountInfo(runtime);\n await this.writeTotals();\n runtime.firstPollDone = true;\n this.reportFirstRoundOnce();\n }\n\n /** Fire the first-round hook exactly once, when no account is still pending. */\n private reportFirstRoundOnce(): void {\n if (this.firstRoundReported || this.runtimes.some(runtime => !runtime.firstPollDone)) {\n return;\n }\n this.firstRoundReported = true;\n this.deps.afterFirstRound?.();\n }\n\n /**\n * Write a successful snapshot: upsert new objects (create-once cache), write the\n * values, and run the warn-threshold transition.\n *\n * @param runtime the account's runtime\n * @param snapshot the fetched snapshot\n */\n private async applySnapshot(runtime: AccountRuntime, snapshot: UsageSnapshot): Promise<void> {\n const { config } = runtime;\n const { objects, writes } = mapSnapshot(config.id, config.name, config.provider, snapshot);\n for (const object of objects) {\n if (!runtime.createdObjects.has(object.id)) {\n await this.deps.upsertObject(object);\n runtime.createdObjects.add(object.id);\n }\n }\n for (const write of writes) {\n this.deps.setState(write.id, write.value);\n }\n // Only PLAN-WIDE windows speak for the account \u2014 a per-model bucket at 100 %\n // must not read as \"this AI is full\" (krobi 2026-08-26).\n const driver = limitingWindow(snapshot);\n const percent = driver?.percent ?? 0;\n const wasWarning = runtime.status.warning;\n runtime.status.warning = percent >= config.warnThreshold;\n // Indicators go through the changed-write, measurements through the normal one.\n this.deps.setStateChanged(`${config.id}.warning`, runtime.status.warning);\n this.deps.setStateChanged(`${config.id}.limitReached`, percent >= 100);\n if (runtime.status.warning && !wasWarning) {\n // Always name the window: \"usage at 100 %\" without it was misleading whenever\n // several windows existed.\n const window = driver ? `${driver.label} ` : \"\";\n const message = `${config.name}: ${window}at ${Math.round(percent)} % (threshold ${config.warnThreshold} %)`;\n this.deps.log.warn(message);\n this.deps.notify?.(config.name, message);\n }\n }\n\n /**\n * Classify a fetch failure.\n *\n * The split matters for the online indicator: with `auth` and `rate-limit` the AI\n * service ANSWERED \u2014 it is online, it just said no \u2014 so only our own access is\n * broken. `service` means the service answered with a fault of its own and is\n * reported as down at once (it told us, that is not a flake). A `network` failure\n * is tolerated MAX_NETWORK_FAILURES times before we call the connection gone, so\n * a single hiccup does not make the indicator flap.\n *\n * @param runtime the account's runtime\n * @param error the thrown error\n */\n private handleFailure(runtime: AccountRuntime, error: unknown): void {\n const { config } = runtime;\n const message = error instanceof Error ? error.message : String(error);\n if (error instanceof FetchError && error.kind === \"auth\") {\n runtime.status.reachable = false;\n runtime.serviceOnline = true;\n runtime.state = \"unauthorized\";\n runtime.error = `Sign-in rejected \u2014 ${message}`;\n if (!runtime.authNotified) {\n runtime.authNotified = true;\n const text = `${config.name}: credentials rejected \u2014 ${message}`;\n this.deps.log.warn(text);\n this.deps.notify?.(config.name, text);\n }\n return;\n }\n if (error instanceof FetchError && error.kind === \"rate-limit\") {\n runtime.serviceOnline = true;\n runtime.state = \"rate-limited\";\n runtime.error = `Throttled by the provider \u2014 retrying in ${Math.round(runtime.backoffMs / 60000)} min, last values kept`;\n runtime.skipUntil = this.deps.now() + runtime.backoffMs;\n this.deps.log.warn(\n `${config.name}: rate-limited \u2014 backing off for ${Math.round(runtime.backoffMs / 60000)} min, keeping last values`,\n );\n runtime.backoffMs = Math.min(BACKOFF_MAX_MS, runtime.backoffMs * 2);\n return;\n }\n if (error instanceof FetchError && error.kind === \"service\") {\n runtime.status.reachable = false;\n runtime.failCount = 0;\n if (runtime.serviceOnline) {\n this.deps.log.warn(`${config.name}: the service reports a fault (${message}) \u2014 values kept`);\n }\n runtime.serviceOnline = false;\n runtime.state = \"service-down\";\n runtime.error = `The AI service reports a fault \u2014 ${message}`;\n return;\n }\n runtime.failCount++;\n this.deps.log.debug(`${config.name}: fetch failed (${message}), attempt ${runtime.failCount}`);\n if (runtime.failCount >= MAX_NETWORK_FAILURES) {\n runtime.status.reachable = false;\n if (runtime.serviceOnline) {\n this.deps.log.warn(`${config.name}: not reachable after ${runtime.failCount} attempts (${message})`);\n }\n runtime.serviceOnline = false;\n runtime.state = \"no-connection\";\n runtime.error = `Not reachable after ${runtime.failCount} attempts \u2014 ${message}`;\n }\n }\n\n /**\n * Write one account's info states (offline marker, error text, last update).\n *\n * @param runtime the account's runtime\n */\n private writeAccountInfo(runtime: AccountRuntime): void {\n const { config } = runtime;\n this.writeAccountStatus(runtime);\n if (runtime.status.reachable) {\n this.deps.setState(`${config.id}.info.lastUpdate`, new Date(this.deps.now()).toISOString());\n }\n }\n\n /**\n * Write the two status states.\n *\n * `unreach` drives the connection icon the admin draws next to the account, so it\n * has to mean what a user reads into that icon: green while the account delivers.\n * A throttle keeps the last values and the service is fine, so it stays green and\n * only fills the error text; a dead sign-in, a broken service or no connection at\n * all turn it off. Both go through the changed-write: an indicator rewritten every\n * cycle floods the history and hides the real transition.\n *\n * @param runtime the account's runtime\n */\n private writeAccountStatus(runtime: AccountRuntime): void {\n const { config } = runtime;\n const delivering = runtime.state === \"ok\" || runtime.state === \"rate-limited\";\n this.deps.setStateChanged(`${config.id}.info.unreach`, !delivering);\n this.deps.setStateChanged(`${config.id}.info.error`, runtime.error);\n }\n\n /** Recompute and write the totals + info.connection. */\n private async writeTotals(): Promise<void> {\n const totals = computeTotals(this.runtimes.map(runtime => runtime.status));\n this.deps.setState(\"total.costs.today\", totals.costsToday);\n this.deps.setState(\"total.costs.month\", totals.costsMonth);\n this.deps.setState(\"total.costs.projectedMonth\", totals.costsProjectedMonth);\n this.deps.setState(\"total.maxLimitPercent\", totals.maxLimitPercent);\n this.deps.setState(\"total.warningsActive\", totals.warningsActive);\n this.deps.setStateChanged(\"total.limitReached\", totals.limitReached);\n this.deps.setState(\"total.accountsReachable\", totals.accountsReachable);\n this.deps.setState(\"total.accounts\", totals.accounts);\n this.deps.setStateChanged(\"info.connection\", totals.accountsReachable > 0);\n return Promise.resolve();\n }\n\n /**\n * The static per-account objects that exist regardless of what the source delivers.\n *\n * @param runtime the account's runtime\n */\n private async createAccountSkeleton(runtime: AccountRuntime): Promise<void> {\n const { config } = runtime;\n const defs: ObjectDef[] = [\n {\n id: config.id,\n type: \"device\",\n common: {\n name: `${config.name} (${config.provider})`,\n // The admin's object tree draws its connection icon from this link and\n // from nothing else \u2014 govee, beszel, homewizard and nut2 all do the same.\n statusStates: { offlineId: \"info.unreach\" },\n },\n },\n { id: `${config.id}.info`, type: \"channel\", common: { name: \"Info\" } },\n {\n // The two slots ioBroker itself provides for this \u2014 measured against\n // @iobroker/type-detector 6.0.0: `unreach` is the offline marker every\n // device type carries (`indicator.reachable` is deprecated there), and\n // `indicator.error` is the standard home for the reason.\n id: `${config.id}.info.unreach`,\n type: \"state\",\n common: {\n name: \"AI service not reachable\",\n type: \"boolean\",\n role: \"indicator.maintenance.unreach\",\n read: true,\n write: false,\n },\n },\n {\n // NOT `indicator.error`: the two official sources disagree \u2014 the type-detector\n // lists that role as a String, the repochecker's validity whitelist allows\n // boolean only (E1009). Validity wins, so the message rides on `text`.\n id: `${config.id}.info.error`,\n type: \"state\",\n common: { name: \"Last error\", type: \"string\", role: \"text\", read: true, write: false },\n },\n {\n id: `${config.id}.info.lastUpdate`,\n type: \"state\",\n common: { name: \"Last successful update\", type: \"string\", role: \"date\", read: true, write: false },\n },\n {\n id: `${config.id}.warning`,\n type: \"state\",\n common: { name: \"Above warn threshold\", type: \"boolean\", role: \"indicator\", read: true, write: false },\n },\n {\n id: `${config.id}.limitReached`,\n type: \"state\",\n common: {\n name: \"A plan-wide limit window is full\",\n type: \"boolean\",\n role: \"indicator\",\n read: true,\n write: false,\n },\n },\n ];\n for (const def of defs) {\n await this.deps.upsertObject(def);\n runtime.createdObjects.add(def.id);\n }\n this.writeAccountStatus(runtime);\n }\n\n /** The totals skeleton (channel + states). */\n private async createTotalsSkeleton(): Promise<void> {\n const defs: ObjectDef[] = [\n { id: \"total.costs\", type: \"channel\", common: { name: \"Costs (USD accounts)\" } },\n {\n id: \"total.costs.today\",\n type: \"state\",\n common: {\n name: \"Costs today (all accounts)\",\n type: \"number\",\n role: \"value\",\n read: true,\n write: false,\n unit: \"USD\",\n },\n },\n {\n id: \"total.costs.month\",\n type: \"state\",\n common: {\n name: \"Costs this month (all accounts)\",\n type: \"number\",\n role: \"value\",\n read: true,\n write: false,\n unit: \"USD\",\n },\n },\n {\n id: \"total.costs.projectedMonth\",\n type: \"state\",\n common: {\n name: \"Costs projected month-end (computed)\",\n type: \"number\",\n role: \"value\",\n read: true,\n write: false,\n unit: \"USD\",\n },\n },\n {\n id: \"total.maxLimitPercent\",\n type: \"state\",\n common: {\n name: \"Highest plan-wide utilisation of any account\",\n type: \"number\",\n role: \"value\",\n read: true,\n write: false,\n unit: \"%\",\n },\n },\n {\n id: \"total.warningsActive\",\n type: \"state\",\n common: {\n name: \"Accounts above their warn threshold\",\n type: \"number\",\n role: \"value\",\n read: true,\n write: false,\n },\n },\n {\n id: \"total.limitReached\",\n type: \"state\",\n common: {\n name: \"Any plan-wide limit window full\",\n type: \"boolean\",\n role: \"indicator\",\n read: true,\n write: false,\n },\n },\n {\n id: \"total.accountsReachable\",\n type: \"state\",\n common: { name: \"Reachable accounts\", type: \"number\", role: \"value\", read: true, write: false },\n },\n {\n id: \"total.accounts\",\n type: \"state\",\n common: { name: \"Configured accounts\", type: \"number\", role: \"value\", read: true, write: false },\n },\n ];\n for (const def of defs) {\n await this.deps.upsertObject(def);\n }\n }\n}\n"],
5
- "mappings": "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",
4
+ "sourcesContent": ["import type { AccountConfig } from \"./pure-helpers\";\nimport { FetchError, type UsageProvider, type UsageSnapshot } from \"./provider\";\nimport { limitingWindow, mapSnapshot, orphanObjectIds, type ObjectDef } from \"./snapshot-tree\";\nimport { computeTotals, type AccountStatus } from \"./totals\";\n\n/**\n * What the adapter currently knows about one account, in one word.\n *\n * `ok` and `rate-limited` mean the AI service is up and talking to us,\n * `unauthorized` means it is up but rejects our sign-in, `service-down` means the\n * service itself answered with a fault, `no-connection` means we never reached it.\n */\nexport type AccountState = \"ok\" | \"unauthorized\" | \"rate-limited\" | \"service-down\" | \"no-connection\";\n\n/** Consecutive network failures after which an account is judged unreachable. */\nconst MAX_NETWORK_FAILURES = 3;\n/** First backoff after a rate-limit answer (ms); doubles per repeat. */\nconst BACKOFF_START_MS = 10 * 60 * 1000;\n/** Backoff ceiling (ms). */\nconst BACKOFF_MAX_MS = 60 * 60 * 1000;\n/** Stagger between the accounts' first polls (ms) so they never fire in one burst. */\nconst STAGGER_MS = 3000;\n\n/** The adapter callbacks the engine drives \u2014 narrow, so tests need no adapter mock. */\nexport interface EngineDeps {\n /** Create or update an object. */\n upsertObject(def: ObjectDef): Promise<void>;\n /** Delete an object and everything below it. */\n deleteObject(id: string): Promise<void>;\n /** Every state id that currently exists below `prefix` (relative to the instance). */\n listStateIds(prefix: string): Promise<string[]>;\n /** Write a state value with ack \u2014 for MEASUREMENTS, where every cycle carries information. */\n setState(id: string, value: boolean | number | string): void;\n /**\n * Write a state only when the value differs from what the database holds \u2014 for\n * INDICATORS. js-controller does the comparison (`setStateChangedAsync`), which is\n * what the rest of the fleet uses; a hand-rolled cache would only know what this\n * process wrote and would still write blindly after a restart.\n *\n * Returns a promise so the shutdown path can WAIT for its writes; everywhere else\n * it is deliberately ignored \u2014 a poll cycle must not be held up by the database.\n */\n setStateChanged(id: string, value: boolean | number | string): Promise<void>;\n /** Schedule a repeating callback; returns a cancel handle. */\n schedule(cb: () => void, ms: number): unknown;\n /** Schedule a one-shot callback; returns a cancel handle. */\n scheduleOnce(cb: () => void, ms: number): unknown;\n /** Cancel a handle from schedule/scheduleOnce. */\n cancel(handle: unknown): void;\n /** Current time (ms since epoch) \u2014 injected for tests. */\n now(): number;\n /** Adapter log. */\n log: { debug(m: string): void; info(m: string): void; warn(m: string): void; error(m: string): void };\n /** Raise a user-facing notification (threshold crossing, broken credentials). */\n notify?(accountName: string, message: string): void;\n /**\n * Called once, when every account has finished its FIRST poll.\n *\n * The first round is staggered on purpose, so the adapter cannot report what the\n * object tree gained until the last account has been through. A config change\n * restarts the instance, so this is also the only moment a user needs the report.\n */\n afterFirstRound?(): void;\n}\n\n/** One account's runtime state inside the engine. */\ninterface AccountRuntime {\n config: AccountConfig;\n provider: UsageProvider;\n status: AccountStatus;\n /** Consecutive network failures. */\n failCount: number;\n /** Skip polls until this time (rate-limit backoff). */\n skipUntil: number;\n /** Current backoff length (ms). */\n backoffMs: number;\n /** Whether the auth-broken notification has been raised (reset on success). */\n authNotified: boolean;\n /** Whether the AI service itself answered on the last attempt. */\n serviceOnline: boolean;\n /** The account's one-word state. */\n state: AccountState;\n /** Plain-text reason shown in `info.error`; empty while everything works. */\n error: string;\n /** Object ids already created for this account (create-once cache). */\n createdObjects: Set<string>;\n /** Whether this account has been through its first poll. */\n firstPollDone: boolean;\n /** True while a poll of this account is in flight \u2014 a second one must not overlap. */\n polling: boolean;\n /** Set when a poll was requested while one was running; runs once the current one ends. */\n pollAgain: boolean;\n /**\n * The dynamic state ids the last snapshot delivered, or null until the first\n * reconcile \u2014 which reads the database, so a datapoint that vanished while the\n * adapter was stopped is caught too.\n */\n deliveredIds: string[] | null;\n /** The skeleton's own state ids \u2014 they never expire. */\n staticIds: string[];\n}\n\n/**\n * Drives the polling of all configured accounts: staggered starts, one independent\n * cycle per account, typed failure handling (auth = immediate + one notification,\n * rate-limit = backoff keeping last values, service = reported down at once,\n * network = tolerated three times), warn-threshold transitions on the PLAN-WIDE\n * windows only, and the adapter-wide totals. Pure orchestration \u2014 all IO is injected.\n */\nexport class PollEngine {\n private readonly runtimes: AccountRuntime[] = [];\n private readonly handles: unknown[] = [];\n private stopped = false;\n private firstRoundReported = false;\n /**\n * How many accounts the user switched on \u2014 including those the adapter cannot\n * poll because their credential is missing. `total.accounts` is what the user\n * configured, not what happened to work out.\n */\n private readonly configuredAccounts: number;\n\n /**\n * @param accounts the validated account configs\n * @param providers each account id's provider (accounts without one are skipped)\n * @param intervalSec the poll interval in seconds\n * @param deps the injected adapter callbacks\n */\n public constructor(\n accounts: readonly AccountConfig[],\n providers: ReadonlyMap<string, UsageProvider>,\n private readonly intervalSec: number,\n private readonly deps: EngineDeps,\n ) {\n this.configuredAccounts = accounts.length;\n for (const config of accounts) {\n const provider = providers.get(config.id);\n if (!provider) {\n deps.log.warn(`${config.name}: provider \"${config.provider}\" is not available \u2014 account skipped`);\n continue;\n }\n this.runtimes.push({\n config,\n provider,\n status: { reachable: false, warning: false },\n failCount: 0,\n skipUntil: 0,\n backoffMs: BACKOFF_START_MS,\n authNotified: false,\n serviceOnline: false,\n state: \"no-connection\",\n error: \"waiting for the first query\",\n createdObjects: new Set(),\n firstPollDone: false,\n polling: false,\n pollAgain: false,\n deliveredIds: null,\n staticIds: [],\n });\n }\n }\n\n /** Create the static per-account and totals objects, then arm the poll cycles. */\n public async start(): Promise<void> {\n for (const runtime of this.runtimes) {\n await this.createAccountSkeleton(runtime);\n }\n await this.createTotalsSkeleton();\n this.writeTotals();\n if (this.runtimes.length === 0) {\n // Nothing will ever poll \u2014 report right away, the cleanup may still have changed something.\n this.reportFirstRoundOnce();\n return;\n }\n this.runtimes.forEach((runtime, index) => {\n // The repeating timer is armed INSIDE the staggered first poll, not next to\n // it: armed here it would start counting for every account in the same\n // instant, and from the second round on all of them would fire together \u2014\n // exactly the burst the stagger exists to prevent, against providers that\n // answer a burst by locking the whole account for a day.\n this.handles.push(\n this.deps.scheduleOnce(() => {\n if (this.stopped) {\n return;\n }\n this.handles.push(this.deps.schedule(() => void this.pollAccount(runtime), this.intervalSec * 1000));\n void this.pollAccount(runtime);\n }, index * STAGGER_MS),\n );\n });\n }\n\n /** Cancel every timer. Synchronous \u2014 safe from onUnload. */\n public stop(): void {\n this.stopped = true;\n for (const handle of this.handles) {\n this.deps.cancel(handle);\n }\n this.handles.length = 0;\n }\n\n /**\n * Say that no account is delivering any more \u2014 for shutdown.\n *\n * A stopped adapter reads nothing, so it must not leave every account claiming to\n * be online: `info.unreach` is what colours the account in the admin's object tree\n * and the badge in the settings, and on its last value it stays green for as long\n * as the instance is switched off.\n *\n * The returned promise is what makes this WORK. Measured on the live server\n * 2026-08-27: issued fire-and-forget and followed by an immediate `callback()`,\n * not one of these writes ever reached the database \u2014 the process was gone first.\n * The caller has to wait for this (with its own time limit) before saying it is\n * done.\n *\n * @returns resolves once every write has been acknowledged\n */\n public async markAllOffline(): Promise<void> {\n const writes: Promise<void>[] = [];\n for (const runtime of this.runtimes) {\n writes.push(this.deps.setStateChanged(`${runtime.config.id}.info.unreach`, true));\n writes.push(\n this.deps.setStateChanged(`${runtime.config.id}.info.error`, \"The adapter is stopped \u2014 nothing is being read\"),\n );\n }\n // The same lie one level up: \"accounts currently delivering data\" is zero.\n writes.push(this.deps.setStateChanged(\"total.accountsReachable\", 0));\n writes.push(this.deps.setStateChanged(\"info.connection\", false));\n await Promise.all(writes);\n }\n\n /**\n * Poll one account immediately, by id. Used after a successful sign-in: waiting\n * up to a full interval there reads as \"the sign-in did not work\".\n *\n * @param accountId the account's object id\n */\n public async pollNow(accountId: string): Promise<void> {\n const runtime = this.runtimes.find(entry => entry.config.id === accountId);\n if (runtime) {\n // A fresh sign-in clears a previous auth failure and any backoff.\n runtime.authNotified = false;\n runtime.skipUntil = 0;\n await this.pollAccount(runtime);\n }\n }\n\n /**\n * Poll one account now (also used by the staggered first run).\n *\n * Never two at once for the same account: a sign-in triggers an immediate poll,\n * which can land on top of a scheduled one \u2014 and two token refreshes in parallel\n * on a rotating refresh token sign each other out. A request that arrives while\n * one is running is remembered and runs right after, so nothing is lost.\n *\n * @param runtime the account's runtime\n */\n private async pollAccount(runtime: AccountRuntime): Promise<void> {\n if (this.stopped) {\n return;\n }\n if (runtime.polling) {\n runtime.pollAgain = true;\n return;\n }\n runtime.polling = true;\n try {\n await this.pollOnce(runtime);\n } finally {\n runtime.polling = false;\n }\n if (runtime.pollAgain && !this.stopped) {\n runtime.pollAgain = false;\n await this.pollAccount(runtime);\n }\n }\n\n /**\n * One poll of one account: fetch, classify, write.\n *\n * @param runtime the account's runtime\n */\n private async pollOnce(runtime: AccountRuntime): Promise<void> {\n const { config } = runtime;\n if (this.deps.now() < runtime.skipUntil) {\n this.deps.log.debug(`${config.name}: in rate-limit backoff \u2014 poll skipped`);\n // Still counts as \"been through\": otherwise an account that starts inside a\n // backoff would hold the first-round report back forever.\n runtime.firstPollDone = true;\n this.reportFirstRoundOnce();\n return;\n }\n try {\n const snapshot = await runtime.provider.fetch();\n runtime.failCount = 0;\n runtime.backoffMs = BACKOFF_START_MS;\n runtime.authNotified = false;\n runtime.status.snapshot = snapshot;\n runtime.status.reachable = true;\n runtime.serviceOnline = true;\n runtime.state = \"ok\";\n runtime.error = \"\";\n await this.applySnapshot(runtime, snapshot);\n } catch (e) {\n this.handleFailure(runtime, e);\n }\n this.writeAccountInfo(runtime);\n this.writeTotals();\n runtime.firstPollDone = true;\n this.reportFirstRoundOnce();\n }\n\n /** Fire the first-round hook exactly once, when no account is still pending. */\n private reportFirstRoundOnce(): void {\n if (this.firstRoundReported || this.runtimes.some(runtime => !runtime.firstPollDone)) {\n return;\n }\n this.firstRoundReported = true;\n this.deps.afterFirstRound?.();\n }\n\n /**\n * Write a successful snapshot: upsert new objects (create-once cache), write the\n * values, and run the warn-threshold transition.\n *\n * @param runtime the account's runtime\n * @param snapshot the fetched snapshot\n */\n private async applySnapshot(runtime: AccountRuntime, snapshot: UsageSnapshot): Promise<void> {\n const { config } = runtime;\n const { objects, writes } = mapSnapshot(config.id, snapshot);\n for (const object of objects) {\n if (!runtime.createdObjects.has(object.id)) {\n await this.deps.upsertObject(object);\n runtime.createdObjects.add(object.id);\n }\n }\n for (const write of writes) {\n this.deps.setState(write.id, write.value);\n }\n await this.removeVanished(\n runtime,\n writes.map(write => write.id),\n );\n // Only PLAN-WIDE windows speak for the account \u2014 a per-model bucket at 100 %\n // must not read as \"this AI is full\" (krobi 2026-08-26).\n const driver = limitingWindow(snapshot);\n const percent = driver?.percent ?? 0;\n const wasWarning = runtime.status.warning;\n runtime.status.warning = percent >= config.warnThreshold;\n // Indicators go through the changed-write, measurements through the normal one.\n void this.deps.setStateChanged(`${config.id}.warning`, runtime.status.warning);\n void this.deps.setStateChanged(`${config.id}.limitReached`, percent >= 100);\n if (runtime.status.warning && !wasWarning) {\n // Always name the window: \"usage at 100 %\" without it was misleading whenever\n // several windows existed.\n const window = driver ? `${driver.label} ` : \"\";\n const message = `${config.name}: ${window}at ${Math.round(percent)} % (threshold ${config.warnThreshold} %)`;\n this.deps.log.warn(message);\n this.deps.notify?.(config.name, message);\n }\n }\n\n /**\n * Delete what this account no longer delivers.\n *\n * The first round after a start compares against the DATABASE, so a window or\n * model that disappeared while the adapter was stopped is caught as well; every\n * round after that compares against the previous snapshot, which costs nothing.\n *\n * @param runtime the account's runtime\n * @param delivered the state ids this snapshot wrote\n */\n private async removeVanished(runtime: AccountRuntime, delivered: string[]): Promise<void> {\n const known = runtime.deliveredIds ?? (await this.deps.listStateIds(runtime.config.id));\n for (const id of orphanObjectIds(known, delivered, runtime.staticIds)) {\n await this.deps.deleteObject(id);\n runtime.createdObjects.delete(id);\n this.deps.log.info(`${runtime.config.name}: removed \"${id}\" \u2014 the provider no longer reports it`);\n }\n runtime.deliveredIds = delivered;\n }\n\n /**\n * Classify a fetch failure.\n *\n * The split matters for the online indicator: with `auth` and `rate-limit` the AI\n * service ANSWERED \u2014 it is online, it just said no \u2014 so only our own access is\n * broken. `service` means the service answered with a fault of its own and is\n * reported as down at once (it told us, that is not a flake). A `network` failure\n * is tolerated MAX_NETWORK_FAILURES times before we call the connection gone, so\n * a single hiccup does not make the indicator flap.\n *\n * @param runtime the account's runtime\n * @param error the thrown error\n */\n private handleFailure(runtime: AccountRuntime, error: unknown): void {\n const { config } = runtime;\n const message = error instanceof Error ? error.message : String(error);\n if (error instanceof FetchError && error.kind === \"auth\") {\n runtime.status.reachable = false;\n runtime.serviceOnline = true;\n runtime.state = \"unauthorized\";\n runtime.error = `Sign-in rejected \u2014 ${message}`;\n if (!runtime.authNotified) {\n runtime.authNotified = true;\n const text = `${config.name}: credentials rejected \u2014 ${message}`;\n this.deps.log.warn(text);\n this.deps.notify?.(config.name, text);\n }\n return;\n }\n if (error instanceof FetchError && error.kind === \"rate-limit\") {\n runtime.serviceOnline = true;\n runtime.state = \"rate-limited\";\n runtime.error = `Throttled by the provider \u2014 retrying in ${Math.round(runtime.backoffMs / 60000)} min, last values kept`;\n runtime.skipUntil = this.deps.now() + runtime.backoffMs;\n this.deps.log.warn(\n `${config.name}: rate-limited \u2014 backing off for ${Math.round(runtime.backoffMs / 60000)} min, keeping last values`,\n );\n runtime.backoffMs = Math.min(BACKOFF_MAX_MS, runtime.backoffMs * 2);\n return;\n }\n if (error instanceof FetchError && error.kind === \"service\") {\n runtime.status.reachable = false;\n runtime.failCount = 0;\n if (runtime.serviceOnline) {\n this.deps.log.warn(`${config.name}: the service reports a fault (${message}) \u2014 values kept`);\n }\n runtime.serviceOnline = false;\n runtime.state = \"service-down\";\n runtime.error = `The AI service reports a fault \u2014 ${message}`;\n return;\n }\n runtime.failCount++;\n this.deps.log.debug(`${config.name}: fetch failed (${message}), attempt ${runtime.failCount}`);\n if (runtime.failCount >= MAX_NETWORK_FAILURES) {\n runtime.status.reachable = false;\n if (runtime.serviceOnline) {\n this.deps.log.warn(`${config.name}: not reachable after ${runtime.failCount} attempts (${message})`);\n }\n runtime.serviceOnline = false;\n runtime.state = \"no-connection\";\n runtime.error = `Not reachable after ${runtime.failCount} attempts \u2014 ${message}`;\n }\n }\n\n /**\n * Write one account's info states (offline marker, error text, last update).\n *\n * @param runtime the account's runtime\n */\n private writeAccountInfo(runtime: AccountRuntime): void {\n const { config } = runtime;\n void this.writeAccountStatus(runtime);\n if (runtime.status.reachable) {\n this.deps.setState(`${config.id}.info.lastUpdate`, new Date(this.deps.now()).toISOString());\n }\n }\n\n /**\n * Write the two status states.\n *\n * `unreach` drives the connection icon the admin draws next to the account, so it\n * has to mean what a user reads into that icon: green while the account delivers.\n * A throttle keeps the last values and the service is fine, so it stays green and\n * only fills the error text; a dead sign-in, a broken service or no connection at\n * all turn it off. Both go through the changed-write: an indicator rewritten every\n * cycle floods the history and hides the real transition.\n *\n * @param runtime the account's runtime\n */\n private async writeAccountStatus(runtime: AccountRuntime): Promise<void> {\n const { config } = runtime;\n const delivering = runtime.state === \"ok\" || runtime.state === \"rate-limited\";\n await Promise.all([\n this.deps.setStateChanged(`${config.id}.info.unreach`, !delivering),\n this.deps.setStateChanged(`${config.id}.info.error`, runtime.error),\n ]);\n }\n\n /** Recompute and write the totals + info.connection. */\n private writeTotals(): void {\n const totals = computeTotals(\n this.runtimes.map(runtime => runtime.status),\n this.configuredAccounts,\n );\n this.deps.setState(\"total.costs.today\", totals.costsToday);\n this.deps.setState(\"total.costs.month\", totals.costsMonth);\n this.deps.setState(\"total.costs.projectedMonth\", totals.costsProjectedMonth);\n this.deps.setState(\"total.maxLimitPercent\", totals.maxLimitPercent);\n this.deps.setState(\"total.warningsActive\", totals.warningsActive);\n void this.deps.setStateChanged(\"total.limitReached\", totals.limitReached);\n this.deps.setState(\"total.accountsReachable\", totals.accountsReachable);\n // The configured count only ever changes with the configuration, which restarts\n // the instance \u2014 rewriting it every cycle would be pure noise in a recording.\n void this.deps.setStateChanged(\"total.accounts\", totals.accounts);\n void this.deps.setStateChanged(\"info.connection\", totals.accountsReachable > 0);\n }\n\n /**\n * The static per-account objects that exist regardless of what the source delivers.\n *\n * @param runtime the account's runtime\n */\n private async createAccountSkeleton(runtime: AccountRuntime): Promise<void> {\n const { config } = runtime;\n const defs: ObjectDef[] = [\n {\n id: config.id,\n type: \"device\",\n common: {\n name: `${config.name} (${config.provider})`,\n // The admin's object tree draws its connection icon from this link and\n // from nothing else \u2014 govee, beszel, homewizard and nut2 all do the same.\n statusStates: { offlineId: \"info.unreach\" },\n },\n },\n { id: `${config.id}.info`, type: \"channel\", common: { name: \"Info\" } },\n {\n // The two slots ioBroker itself provides for this \u2014 measured against\n // @iobroker/type-detector 6.0.0: `unreach` is the offline marker every\n // device type carries (`indicator.reachable` is deprecated there), and\n // `indicator.error` is the standard home for the reason.\n id: `${config.id}.info.unreach`,\n type: \"state\",\n common: {\n name: \"AI service not reachable\",\n type: \"boolean\",\n role: \"indicator.maintenance.unreach\",\n read: true,\n write: false,\n },\n },\n {\n // NOT `indicator.error`: the two official sources disagree \u2014 the type-detector\n // lists that role as a String, the repochecker's validity whitelist allows\n // boolean only (E1009). Validity wins, so the message rides on `text`.\n id: `${config.id}.info.error`,\n type: \"state\",\n common: { name: \"Last error\", type: \"string\", role: \"text\", read: true, write: false },\n },\n {\n id: `${config.id}.info.lastUpdate`,\n type: \"state\",\n common: { name: \"Last successful update\", type: \"string\", role: \"date\", read: true, write: false },\n },\n {\n id: `${config.id}.warning`,\n type: \"state\",\n common: { name: \"Above warn threshold\", type: \"boolean\", role: \"indicator\", read: true, write: false },\n },\n {\n id: `${config.id}.limitReached`,\n type: \"state\",\n common: {\n name: \"A plan-wide limit window is full\",\n type: \"boolean\",\n role: \"indicator\",\n read: true,\n write: false,\n },\n },\n ];\n for (const def of defs) {\n await this.deps.upsertObject(def);\n runtime.createdObjects.add(def.id);\n }\n runtime.staticIds = defs.filter(def => def.type === \"state\").map(def => def.id);\n // Mark it as not delivering right away, before anything has been asked.\n //\n // This looks pessimistic and is the honest state: nothing has been read yet. It\n // also carries the whole weight of \"the instance was off\". Whatever the previous\n // run left behind stands until someone overwrites it \u2014 after a hard kill, after\n // a crash, after an unclean shutdown the account would otherwise sit there green\n // and claim to deliver while no process exists at all. nut2 marks its devices\n // unreachable on start for exactly this reason.\n //\n // The window is short: the first poll follows within seconds and writes the real\n // state, success or failure.\n void this.writeAccountStatus(runtime);\n }\n\n /** The totals skeleton (channel + states). */\n private async createTotalsSkeleton(): Promise<void> {\n const defs: ObjectDef[] = [\n { id: \"total.costs\", type: \"channel\", common: { name: \"Costs (USD accounts)\" } },\n {\n id: \"total.costs.today\",\n type: \"state\",\n common: {\n name: \"Costs today (all accounts)\",\n type: \"number\",\n role: \"value\",\n read: true,\n write: false,\n unit: \"USD\",\n },\n },\n {\n id: \"total.costs.month\",\n type: \"state\",\n common: {\n name: \"Costs this month (all accounts)\",\n type: \"number\",\n role: \"value\",\n read: true,\n write: false,\n unit: \"USD\",\n },\n },\n {\n id: \"total.costs.projectedMonth\",\n type: \"state\",\n common: {\n name: \"Costs projected month-end (computed)\",\n type: \"number\",\n role: \"value\",\n read: true,\n write: false,\n unit: \"USD\",\n },\n },\n {\n id: \"total.maxLimitPercent\",\n type: \"state\",\n common: {\n name: \"Highest plan-wide utilisation of any account\",\n type: \"number\",\n role: \"value\",\n read: true,\n write: false,\n unit: \"%\",\n },\n },\n {\n id: \"total.warningsActive\",\n type: \"state\",\n common: {\n name: \"Accounts above their warn threshold\",\n type: \"number\",\n role: \"value\",\n read: true,\n write: false,\n },\n },\n {\n id: \"total.limitReached\",\n type: \"state\",\n common: {\n name: \"Any plan-wide limit window full\",\n type: \"boolean\",\n role: \"indicator\",\n read: true,\n write: false,\n },\n },\n {\n id: \"total.accountsReachable\",\n type: \"state\",\n common: { name: \"Reachable accounts\", type: \"number\", role: \"value\", read: true, write: false },\n },\n {\n id: \"total.accounts\",\n type: \"state\",\n common: { name: \"Configured accounts\", type: \"number\", role: \"value\", read: true, write: false },\n },\n ];\n for (const def of defs) {\n await this.deps.upsertObject(def);\n }\n }\n}\n"],
5
+ "mappings": "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",
6
6
  "names": []
7
7
  }
@@ -23,23 +23,9 @@ __export(anthropic_api_exports, {
23
23
  });
24
24
  module.exports = __toCommonJS(anthropic_api_exports);
25
25
  var import_http = require("../http");
26
+ var import_pure_helpers = require("../pure-helpers");
26
27
  var import_report_utils = require("./report-utils");
27
28
  const BASE = "https://api.anthropic.com/v1/organizations";
28
- async function fetchAllPages(url, headers, fetchJson) {
29
- const buckets = [];
30
- let page;
31
- for (let i = 0; i < 12; i++) {
32
- const body = await fetchJson(page ? `${url}&page=${encodeURIComponent(page)}` : url, headers);
33
- if (Array.isArray(body == null ? void 0 : body.data)) {
34
- buckets.push(...body.data);
35
- }
36
- if ((body == null ? void 0 : body.has_more) !== true || typeof (body == null ? void 0 : body.next_page) !== "string" || !body.next_page) {
37
- break;
38
- }
39
- page = body.next_page;
40
- }
41
- return buckets;
42
- }
43
29
  function parseAnthropicReports(usageBuckets, costBuckets, nowMs) {
44
30
  var _a, _b;
45
31
  let costMonth = 0;
@@ -82,11 +68,10 @@ function parseAnthropicReports(usageBuckets, costBuckets, nowMs) {
82
68
  }
83
69
  }
84
70
  }
85
- const round = (value) => Math.round(value * 100) / 100;
86
71
  const snapshot = {
87
72
  costs: {
88
- today: round(costToday),
89
- month: round(costMonth),
73
+ today: (0, import_pure_helpers.round2)(costToday),
74
+ month: (0, import_pure_helpers.round2)(costMonth),
90
75
  projectedMonth: (0, import_report_utils.projectMonth)(costMonth, nowMs),
91
76
  currency: "USD"
92
77
  }
@@ -102,12 +87,12 @@ function anthropicApiProvider(adminKey, fetchJson = import_http.getJson, now = D
102
87
  fetch: async () => {
103
88
  const headers = { "x-api-key": adminKey, "anthropic-version": "2023-06-01" };
104
89
  const start = encodeURIComponent((0, import_report_utils.monthStartIso)(now()));
105
- const usage = await fetchAllPages(
90
+ const usage = await (0, import_report_utils.fetchAllPages)(
106
91
  `${BASE}/usage_report/messages?starting_at=${start}&bucket_width=1d`,
107
92
  headers,
108
93
  fetchJson
109
94
  );
110
- const costs = await fetchAllPages(`${BASE}/cost_report?starting_at=${start}&bucket_width=1d`, headers, fetchJson);
95
+ const costs = await (0, import_report_utils.fetchAllPages)(`${BASE}/cost_report?starting_at=${start}&bucket_width=1d`, headers, fetchJson);
111
96
  return parseAnthropicReports(usage, costs, now());
112
97
  }
113
98
  };
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "version": 3,
3
3
  "sources": ["../../../src/lib/providers/anthropic-api.ts"],
4
- "sourcesContent": ["import { getJson, type JsonFetch } from \"../http\";\nimport type { UsageProvider, UsageSnapshot } from \"../provider\";\nimport { isToday, monthStartIso, projectMonth } from \"./report-utils\";\n\n/**\n * Anthropic organization Usage + Cost reports (official Admin API; needs an\n * organization ADMIN key). Source-verified against dbpecka/hacs-claude-stats:\n * `GET /v1/organizations/usage_report/messages` and `/v1/organizations/cost_report`\n * with `x-api-key` + `anthropic-version: 2023-06-01`, params starting_at/ending_at\n * (ISO) + bucket_width=1d, pagination via has_more/next_page (param `page`).\n * Usage results carry `uncached_input_tokens`/`output_tokens`; cost results carry\n * `amount` as a decimal STRING (USD).\n */\nconst BASE = \"https://api.anthropic.com/v1/organizations\";\n\n/**\n * Fetch all pages of a bucketed report.\n *\n * @param url the report URL without the page parameter\n * @param headers request headers\n * @param fetchJson the JSON-GET seam\n * @returns all bucket entries\n */\nasync function fetchAllPages(url: string, headers: Record<string, string>, fetchJson: JsonFetch): Promise<unknown[]> {\n const buckets: unknown[] = [];\n let page: string | undefined;\n for (let i = 0; i < 12; i++) {\n const body = (await fetchJson(page ? `${url}&page=${encodeURIComponent(page)}` : url, headers)) as {\n data?: unknown;\n has_more?: unknown;\n next_page?: unknown;\n } | null;\n if (Array.isArray(body?.data)) {\n buckets.push(...body.data);\n }\n if (body?.has_more !== true || typeof body?.next_page !== \"string\" || !body.next_page) {\n break;\n }\n page = body.next_page;\n }\n return buckets;\n}\n\n/**\n * Parse the two bucket lists into a snapshot: month/today costs (+ projection) and\n * today's input/output tokens.\n *\n * @param usageBuckets the usage report buckets\n * @param costBuckets the cost report buckets\n * @param nowMs current time (ms)\n * @returns the snapshot\n */\nexport function parseAnthropicReports(usageBuckets: unknown[], costBuckets: unknown[], nowMs: number): UsageSnapshot {\n let costMonth = 0;\n let costToday = 0;\n for (const bucket of costBuckets) {\n const entry = bucket as { starting_at?: unknown; start_time?: unknown; results?: unknown };\n if (!Array.isArray(entry?.results)) {\n continue;\n }\n let sum = 0;\n for (const result of entry.results) {\n const amount = Number((result as { amount?: unknown })?.amount);\n if (Number.isFinite(amount)) {\n sum += amount;\n }\n }\n costMonth += sum;\n if (isToday(entry.starting_at ?? entry.start_time, nowMs)) {\n costToday += sum;\n }\n }\n\n let inputToday = 0;\n let outputToday = 0;\n let sawUsageToday = false;\n for (const bucket of usageBuckets) {\n const entry = bucket as { starting_at?: unknown; start_time?: unknown; results?: unknown };\n if (!Array.isArray(entry?.results) || !isToday(entry.starting_at ?? entry.start_time, nowMs)) {\n continue;\n }\n sawUsageToday = true;\n for (const result of entry.results) {\n const data = result as { uncached_input_tokens?: unknown; output_tokens?: unknown };\n const input = Number(data.uncached_input_tokens);\n const output = Number(data.output_tokens);\n if (Number.isFinite(input)) {\n inputToday += input;\n }\n if (Number.isFinite(output)) {\n outputToday += output;\n }\n }\n }\n\n const round = (value: number): number => Math.round(value * 100) / 100;\n const snapshot: UsageSnapshot = {\n costs: {\n today: round(costToday),\n month: round(costMonth),\n projectedMonth: projectMonth(costMonth, nowMs),\n currency: \"USD\",\n },\n };\n if (sawUsageToday) {\n snapshot.tokens = { inputToday, outputToday };\n }\n return snapshot;\n}\n\n/**\n * The Anthropic API provider (organization accounts).\n *\n * @param adminKey the organization ADMIN key\n * @param fetchJson the JSON-GET seam\n * @param now clock (ms) \u2014 injected for tests\n * @returns the provider\n */\nexport function anthropicApiProvider(\n adminKey: string,\n fetchJson: JsonFetch = getJson,\n now: () => number = Date.now,\n): UsageProvider {\n return {\n kind: \"anthropic-api\",\n fetch: async (): Promise<UsageSnapshot> => {\n const headers = { \"x-api-key\": adminKey, \"anthropic-version\": \"2023-06-01\" };\n const start = encodeURIComponent(monthStartIso(now()));\n const usage = await fetchAllPages(\n `${BASE}/usage_report/messages?starting_at=${start}&bucket_width=1d`,\n headers,\n fetchJson,\n );\n const costs = await fetchAllPages(`${BASE}/cost_report?starting_at=${start}&bucket_width=1d`, headers, fetchJson);\n return parseAnthropicReports(usage, costs, now());\n },\n };\n}\n"],
5
- "mappings": ";;;;;;;;;;;;;;;;;;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA,kBAAwC;AAExC,0BAAqD;AAWrD,MAAM,OAAO;AAUb,eAAe,cAAc,KAAa,SAAiC,WAA0C;AACnH,QAAM,UAAqB,CAAC;AAC5B,MAAI;AACJ,WAAS,IAAI,GAAG,IAAI,IAAI,KAAK;AAC3B,UAAM,OAAQ,MAAM,UAAU,OAAO,GAAG,GAAG,SAAS,mBAAmB,IAAI,CAAC,KAAK,KAAK,OAAO;AAK7F,QAAI,MAAM,QAAQ,6BAAM,IAAI,GAAG;AAC7B,cAAQ,KAAK,GAAG,KAAK,IAAI;AAAA,IAC3B;AACA,SAAI,6BAAM,cAAa,QAAQ,QAAO,6BAAM,eAAc,YAAY,CAAC,KAAK,WAAW;AACrF;AAAA,IACF;AACA,WAAO,KAAK;AAAA,EACd;AACA,SAAO;AACT;AAWO,SAAS,sBAAsB,cAAyB,aAAwB,OAA8B;AApDrH;AAqDE,MAAI,YAAY;AAChB,MAAI,YAAY;AAChB,aAAW,UAAU,aAAa;AAChC,UAAM,QAAQ;AACd,QAAI,CAAC,MAAM,QAAQ,+BAAO,OAAO,GAAG;AAClC;AAAA,IACF;AACA,QAAI,MAAM;AACV,eAAW,UAAU,MAAM,SAAS;AAClC,YAAM,SAAS,OAAQ,iCAAiC,MAAM;AAC9D,UAAI,OAAO,SAAS,MAAM,GAAG;AAC3B,eAAO;AAAA,MACT;AAAA,IACF;AACA,iBAAa;AACb,YAAI,8BAAQ,WAAM,gBAAN,YAAqB,MAAM,YAAY,KAAK,GAAG;AACzD,mBAAa;AAAA,IACf;AAAA,EACF;AAEA,MAAI,aAAa;AACjB,MAAI,cAAc;AAClB,MAAI,gBAAgB;AACpB,aAAW,UAAU,cAAc;AACjC,UAAM,QAAQ;AACd,QAAI,CAAC,MAAM,QAAQ,+BAAO,OAAO,KAAK,KAAC,8BAAQ,WAAM,gBAAN,YAAqB,MAAM,YAAY,KAAK,GAAG;AAC5F;AAAA,IACF;AACA,oBAAgB;AAChB,eAAW,UAAU,MAAM,SAAS;AAClC,YAAM,OAAO;AACb,YAAM,QAAQ,OAAO,KAAK,qBAAqB;AAC/C,YAAM,SAAS,OAAO,KAAK,aAAa;AACxC,UAAI,OAAO,SAAS,KAAK,GAAG;AAC1B,sBAAc;AAAA,MAChB;AACA,UAAI,OAAO,SAAS,MAAM,GAAG;AAC3B,uBAAe;AAAA,MACjB;AAAA,IACF;AAAA,EACF;AAEA,QAAM,QAAQ,CAAC,UAA0B,KAAK,MAAM,QAAQ,GAAG,IAAI;AACnE,QAAM,WAA0B;AAAA,IAC9B,OAAO;AAAA,MACL,OAAO,MAAM,SAAS;AAAA,MACtB,OAAO,MAAM,SAAS;AAAA,MACtB,oBAAgB,kCAAa,WAAW,KAAK;AAAA,MAC7C,UAAU;AAAA,IACZ;AAAA,EACF;AACA,MAAI,eAAe;AACjB,aAAS,SAAS,EAAE,YAAY,YAAY;AAAA,EAC9C;AACA,SAAO;AACT;AAUO,SAAS,qBACd,UACA,YAAuB,qBACvB,MAAoB,KAAK,KACV;AACf,SAAO;AAAA,IACL,MAAM;AAAA,IACN,OAAO,YAAoC;AACzC,YAAM,UAAU,EAAE,aAAa,UAAU,qBAAqB,aAAa;AAC3E,YAAM,QAAQ,uBAAmB,mCAAc,IAAI,CAAC,CAAC;AACrD,YAAM,QAAQ,MAAM;AAAA,QAClB,GAAG,IAAI,sCAAsC,KAAK;AAAA,QAClD;AAAA,QACA;AAAA,MACF;AACA,YAAM,QAAQ,MAAM,cAAc,GAAG,IAAI,4BAA4B,KAAK,oBAAoB,SAAS,SAAS;AAChH,aAAO,sBAAsB,OAAO,OAAO,IAAI,CAAC;AAAA,IAClD;AAAA,EACF;AACF;",
4
+ "sourcesContent": ["import { getJson, type JsonFetch } from \"../http\";\nimport type { UsageProvider, UsageSnapshot } from \"../provider\";\nimport { round2 } from \"../pure-helpers\";\nimport { fetchAllPages, isToday, monthStartIso, projectMonth } from \"./report-utils\";\n\n/**\n * Anthropic organization Usage + Cost reports (official Admin API; needs an\n * organization ADMIN key). Source-verified against dbpecka/hacs-claude-stats:\n * `GET /v1/organizations/usage_report/messages` and `/v1/organizations/cost_report`\n * with `x-api-key` + `anthropic-version: 2023-06-01`, params starting_at/ending_at\n * (ISO) + bucket_width=1d, pagination via has_more/next_page (param `page`).\n * Usage results carry `uncached_input_tokens`/`output_tokens`; cost results carry\n * `amount` as a decimal STRING (USD).\n */\nconst BASE = \"https://api.anthropic.com/v1/organizations\";\n\n/**\n * Parse the two bucket lists into a snapshot: month/today costs (+ projection) and\n * today's input/output tokens.\n *\n * @param usageBuckets the usage report buckets\n * @param costBuckets the cost report buckets\n * @param nowMs current time (ms)\n * @returns the snapshot\n */\nexport function parseAnthropicReports(usageBuckets: unknown[], costBuckets: unknown[], nowMs: number): UsageSnapshot {\n let costMonth = 0;\n let costToday = 0;\n for (const bucket of costBuckets) {\n const entry = bucket as { starting_at?: unknown; start_time?: unknown; results?: unknown };\n if (!Array.isArray(entry?.results)) {\n continue;\n }\n let sum = 0;\n for (const result of entry.results) {\n const amount = Number((result as { amount?: unknown })?.amount);\n if (Number.isFinite(amount)) {\n sum += amount;\n }\n }\n costMonth += sum;\n if (isToday(entry.starting_at ?? entry.start_time, nowMs)) {\n costToday += sum;\n }\n }\n\n let inputToday = 0;\n let outputToday = 0;\n let sawUsageToday = false;\n for (const bucket of usageBuckets) {\n const entry = bucket as { starting_at?: unknown; start_time?: unknown; results?: unknown };\n if (!Array.isArray(entry?.results) || !isToday(entry.starting_at ?? entry.start_time, nowMs)) {\n continue;\n }\n sawUsageToday = true;\n for (const result of entry.results) {\n const data = result as { uncached_input_tokens?: unknown; output_tokens?: unknown };\n const input = Number(data.uncached_input_tokens);\n const output = Number(data.output_tokens);\n if (Number.isFinite(input)) {\n inputToday += input;\n }\n if (Number.isFinite(output)) {\n outputToday += output;\n }\n }\n }\n\n const snapshot: UsageSnapshot = {\n costs: {\n today: round2(costToday),\n month: round2(costMonth),\n projectedMonth: projectMonth(costMonth, nowMs),\n currency: \"USD\",\n },\n };\n if (sawUsageToday) {\n snapshot.tokens = { inputToday, outputToday };\n }\n return snapshot;\n}\n\n/**\n * The Anthropic API provider (organization accounts).\n *\n * @param adminKey the organization ADMIN key\n * @param fetchJson the JSON-GET seam\n * @param now clock (ms) \u2014 injected for tests\n * @returns the provider\n */\nexport function anthropicApiProvider(\n adminKey: string,\n fetchJson: JsonFetch = getJson,\n now: () => number = Date.now,\n): UsageProvider {\n return {\n kind: \"anthropic-api\",\n fetch: async (): Promise<UsageSnapshot> => {\n const headers = { \"x-api-key\": adminKey, \"anthropic-version\": \"2023-06-01\" };\n const start = encodeURIComponent(monthStartIso(now()));\n const usage = await fetchAllPages(\n `${BASE}/usage_report/messages?starting_at=${start}&bucket_width=1d`,\n headers,\n fetchJson,\n );\n const costs = await fetchAllPages(`${BASE}/cost_report?starting_at=${start}&bucket_width=1d`, headers, fetchJson);\n return parseAnthropicReports(usage, costs, now());\n },\n };\n}\n"],
5
+ "mappings": ";;;;;;;;;;;;;;;;;;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA,kBAAwC;AAExC,0BAAuB;AACvB,0BAAoE;AAWpE,MAAM,OAAO;AAWN,SAAS,sBAAsB,cAAyB,aAAwB,OAA8B;AAzBrH;AA0BE,MAAI,YAAY;AAChB,MAAI,YAAY;AAChB,aAAW,UAAU,aAAa;AAChC,UAAM,QAAQ;AACd,QAAI,CAAC,MAAM,QAAQ,+BAAO,OAAO,GAAG;AAClC;AAAA,IACF;AACA,QAAI,MAAM;AACV,eAAW,UAAU,MAAM,SAAS;AAClC,YAAM,SAAS,OAAQ,iCAAiC,MAAM;AAC9D,UAAI,OAAO,SAAS,MAAM,GAAG;AAC3B,eAAO;AAAA,MACT;AAAA,IACF;AACA,iBAAa;AACb,YAAI,8BAAQ,WAAM,gBAAN,YAAqB,MAAM,YAAY,KAAK,GAAG;AACzD,mBAAa;AAAA,IACf;AAAA,EACF;AAEA,MAAI,aAAa;AACjB,MAAI,cAAc;AAClB,MAAI,gBAAgB;AACpB,aAAW,UAAU,cAAc;AACjC,UAAM,QAAQ;AACd,QAAI,CAAC,MAAM,QAAQ,+BAAO,OAAO,KAAK,KAAC,8BAAQ,WAAM,gBAAN,YAAqB,MAAM,YAAY,KAAK,GAAG;AAC5F;AAAA,IACF;AACA,oBAAgB;AAChB,eAAW,UAAU,MAAM,SAAS;AAClC,YAAM,OAAO;AACb,YAAM,QAAQ,OAAO,KAAK,qBAAqB;AAC/C,YAAM,SAAS,OAAO,KAAK,aAAa;AACxC,UAAI,OAAO,SAAS,KAAK,GAAG;AAC1B,sBAAc;AAAA,MAChB;AACA,UAAI,OAAO,SAAS,MAAM,GAAG;AAC3B,uBAAe;AAAA,MACjB;AAAA,IACF;AAAA,EACF;AAEA,QAAM,WAA0B;AAAA,IAC9B,OAAO;AAAA,MACL,WAAO,4BAAO,SAAS;AAAA,MACvB,WAAO,4BAAO,SAAS;AAAA,MACvB,oBAAgB,kCAAa,WAAW,KAAK;AAAA,MAC7C,UAAU;AAAA,IACZ;AAAA,EACF;AACA,MAAI,eAAe;AACjB,aAAS,SAAS,EAAE,YAAY,YAAY;AAAA,EAC9C;AACA,SAAO;AACT;AAUO,SAAS,qBACd,UACA,YAAuB,qBACvB,MAAoB,KAAK,KACV;AACf,SAAO;AAAA,IACL,MAAM;AAAA,IACN,OAAO,YAAoC;AACzC,YAAM,UAAU,EAAE,aAAa,UAAU,qBAAqB,aAAa;AAC3E,YAAM,QAAQ,uBAAmB,mCAAc,IAAI,CAAC,CAAC;AACrD,YAAM,QAAQ,UAAM;AAAA,QAClB,GAAG,IAAI,sCAAsC,KAAK;AAAA,QAClD;AAAA,QACA;AAAA,MACF;AACA,YAAM,QAAQ,UAAM,mCAAc,GAAG,IAAI,4BAA4B,KAAK,oBAAoB,SAAS,SAAS;AAChH,aAAO,sBAAsB,OAAO,OAAO,IAAI,CAAC;AAAA,IAClD;AAAA,EACF;AACF;",
6
6
  "names": []
7
7
  }
@@ -88,25 +88,24 @@ function parseChatgptUsage(body) {
88
88
  }
89
89
  return snapshot;
90
90
  }
91
- function chatgptSubProvider(store, fetchJson = import_http.getJson, postJson, now = Date.now) {
92
- let cached = null;
91
+ function chatgptSubProvider(store, postJson, fetchJson = import_http.getJson, now = Date.now) {
93
92
  return {
94
93
  kind: "chatgpt-sub",
95
94
  fetch: async () => {
96
- cached != null ? cached : cached = await store.load();
97
- if (!cached) {
95
+ let tokens = await store.load();
96
+ if (!tokens) {
98
97
  throw new import_provider.FetchError("auth", "not signed in \u2014 start the ChatGPT sign-in in the instance settings");
99
98
  }
100
- if (now() >= cached.expiresAt - 6e4) {
101
- cached = await (0, import_chatgpt_auth.refreshChatgptTokens)(cached, postJson, now());
102
- await store.save(cached);
99
+ if (now() >= tokens.expiresAt - 6e4) {
100
+ tokens = await (0, import_chatgpt_auth.refreshChatgptTokens)(tokens, postJson, now());
101
+ await store.save(tokens);
103
102
  }
104
103
  const headers = {
105
- Authorization: `Bearer ${cached.accessToken}`,
104
+ Authorization: `Bearer ${tokens.accessToken}`,
106
105
  "User-Agent": "ioBroker.ai-usage"
107
106
  };
108
- if (cached.accountRef) {
109
- headers["ChatGPT-Account-Id"] = cached.accountRef;
107
+ if (tokens.accountRef) {
108
+ headers["ChatGPT-Account-Id"] = tokens.accountRef;
110
109
  }
111
110
  return parseChatgptUsage(await fetchJson(CHATGPT_USAGE_URL, headers));
112
111
  }
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "version": 3,
3
3
  "sources": ["../../../src/lib/providers/chatgpt-sub.ts"],
4
- "sourcesContent": ["import { getJson, type JsonFetch } from \"../http\";\nimport {\n FetchError,\n type LimitWindow,\n type TokenSet,\n type TokenStore,\n type UsageProvider,\n type UsageSnapshot,\n} from \"../provider\";\nimport { CHATGPT_OAUTH, refreshChatgptTokens, type JsonPost } from \"./chatgpt-auth\";\n\n/** Where the subscription usage lives (the endpoint OpenAI's own Codex client uses). */\nexport const CHATGPT_USAGE_URL = \"https://chatgpt.com/backend-api/wham/usage\";\n\n/**\n * Read one rate-limit window.\n *\n * Both windows may be null when they do not apply to the account, and `reset_at`\n * is in Unix SECONDS \u2014 a value that already looks like milliseconds is passed\n * through, so a future format change cannot produce a year-58000 timestamp.\n *\n * @param raw the window object\n * @param name the object id segment\n * @param label the human-readable label\n * @returns the window, or undefined when unusable\n */\nfunction readWindow(raw: unknown, name: string, label: string): LimitWindow | undefined {\n if (typeof raw !== \"object\" || raw === null) {\n return undefined;\n }\n const entry = raw as Record<string, unknown>;\n const percent = Number(entry.used_percent);\n if (!Number.isFinite(percent)) {\n return undefined;\n }\n const window: LimitWindow = { name, label, percent };\n const resetAt = Number(entry.reset_at);\n if (Number.isFinite(resetAt) && resetAt > 0) {\n const ms = resetAt > 1e12 ? resetAt : resetAt * 1000;\n window.resetAt = new Date(ms).toISOString();\n }\n return window;\n}\n\n/**\n * Parse a `/wham/usage` answer into a snapshot.\n *\n * Fields follow OpenAI's own generated models: `plan_type`, `rate_limit` with a\n * 5-hour `primary_window` and a weekly `secondary_window`, plus `credits` whose\n * balance arrives as a string in the schema but as a number in practice.\n *\n * @param body the parsed answer\n * @returns the snapshot\n */\nexport function parseChatgptUsage(body: unknown): UsageSnapshot {\n if (typeof body !== \"object\" || body === null) {\n throw new FetchError(\"network\", \"unexpected usage response\");\n }\n const raw = body as Record<string, unknown>;\n const limits: LimitWindow[] = [];\n const rateLimit = (raw.rate_limit ?? {}) as Record<string, unknown>;\n const session = readWindow(rateLimit.primary_window, \"session\", \"Session (5 h)\");\n const week = readWindow(rateLimit.secondary_window, \"week\", \"Week\");\n if (session) {\n limits.push(session);\n }\n if (week) {\n limits.push(week);\n }\n for (const extra of Array.isArray(raw.additional_rate_limits) ? raw.additional_rate_limits : []) {\n if (typeof extra !== \"object\" || extra === null) {\n continue;\n }\n const entry = extra as Record<string, unknown>;\n const label = typeof entry.limit_name === \"string\" ? entry.limit_name : \"\";\n // The name becomes an object-id segment, so it must survive sanitizing and must\n // not collide with a window already collected \u2014 a name of \"Session\" would\n // otherwise overwrite the 5-hour window with an unrelated counter.\n const name = label\n .toLowerCase()\n .replace(/[^a-z0-9]+/g, \"-\")\n .replace(/^-+|-+$/g, \"\");\n if (!name || limits.some(window => window.name === name)) {\n continue;\n }\n const window = readWindow(entry.rate_limit, name, label);\n if (window) {\n // These sit next to the plan-wide session/week windows and cover one model\n // each \u2014 reported, but never the reason for a warning (LimitWindow.scoped).\n window.scoped = true;\n limits.push(window);\n }\n }\n\n const snapshot: UsageSnapshot = {};\n if (limits.length) {\n snapshot.limits = limits;\n }\n\n const credits = (raw.credits ?? {}) as Record<string, unknown>;\n const balance = Number(credits.balance);\n if (Number.isFinite(balance) && credits.unlimited !== true) {\n snapshot.credits = { remaining: balance, currency: \"USD\" };\n }\n return snapshot;\n}\n\n/**\n * The ChatGPT/Codex subscription provider.\n *\n * @param store where the tokens live (keyed by provider, shared by no one else)\n * @param fetchJson the JSON-GET seam\n * @param postJson the JSON-POST seam (token refresh)\n * @param now clock (ms)\n * @returns the provider\n */\nexport function chatgptSubProvider(\n store: TokenStore,\n fetchJson: JsonFetch = getJson,\n postJson: JsonPost,\n now: () => number = Date.now,\n): UsageProvider {\n let cached: TokenSet | null = null;\n return {\n kind: \"chatgpt-sub\",\n fetch: async (): Promise<UsageSnapshot> => {\n cached ??= await store.load();\n if (!cached) {\n throw new FetchError(\"auth\", \"not signed in \u2014 start the ChatGPT sign-in in the instance settings\");\n }\n if (now() >= cached.expiresAt - 60_000) {\n cached = await refreshChatgptTokens(cached, postJson, now());\n await store.save(cached);\n }\n const headers: Record<string, string> = {\n Authorization: `Bearer ${cached.accessToken}`,\n \"User-Agent\": \"ioBroker.ai-usage\",\n };\n // Only send the account id when we have one \u2014 an empty header is rejected.\n if (cached.accountRef) {\n headers[\"ChatGPT-Account-Id\"] = cached.accountRef;\n }\n return parseChatgptUsage(await fetchJson(CHATGPT_USAGE_URL, headers));\n },\n };\n}\n\nexport { CHATGPT_OAUTH };\n"],
5
- "mappings": ";;;;;;;;;;;;;;;;;;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA,kBAAwC;AACxC,sBAOO;AACP,0BAAmE;AAG5D,MAAM,oBAAoB;AAcjC,SAAS,WAAW,KAAc,MAAc,OAAwC;AACtF,MAAI,OAAO,QAAQ,YAAY,QAAQ,MAAM;AAC3C,WAAO;AAAA,EACT;AACA,QAAM,QAAQ;AACd,QAAM,UAAU,OAAO,MAAM,YAAY;AACzC,MAAI,CAAC,OAAO,SAAS,OAAO,GAAG;AAC7B,WAAO;AAAA,EACT;AACA,QAAM,SAAsB,EAAE,MAAM,OAAO,QAAQ;AACnD,QAAM,UAAU,OAAO,MAAM,QAAQ;AACrC,MAAI,OAAO,SAAS,OAAO,KAAK,UAAU,GAAG;AAC3C,UAAM,KAAK,UAAU,OAAO,UAAU,UAAU;AAChD,WAAO,UAAU,IAAI,KAAK,EAAE,EAAE,YAAY;AAAA,EAC5C;AACA,SAAO;AACT;AAYO,SAAS,kBAAkB,MAA8B;AAtDhE;AAuDE,MAAI,OAAO,SAAS,YAAY,SAAS,MAAM;AAC7C,UAAM,IAAI,2BAAW,WAAW,2BAA2B;AAAA,EAC7D;AACA,QAAM,MAAM;AACZ,QAAM,SAAwB,CAAC;AAC/B,QAAM,aAAa,SAAI,eAAJ,YAAkB,CAAC;AACtC,QAAM,UAAU,WAAW,UAAU,gBAAgB,WAAW,eAAe;AAC/E,QAAM,OAAO,WAAW,UAAU,kBAAkB,QAAQ,MAAM;AAClE,MAAI,SAAS;AACX,WAAO,KAAK,OAAO;AAAA,EACrB;AACA,MAAI,MAAM;AACR,WAAO,KAAK,IAAI;AAAA,EAClB;AACA,aAAW,SAAS,MAAM,QAAQ,IAAI,sBAAsB,IAAI,IAAI,yBAAyB,CAAC,GAAG;AAC/F,QAAI,OAAO,UAAU,YAAY,UAAU,MAAM;AAC/C;AAAA,IACF;AACA,UAAM,QAAQ;AACd,UAAM,QAAQ,OAAO,MAAM,eAAe,WAAW,MAAM,aAAa;AAIxE,UAAM,OAAO,MACV,YAAY,EACZ,QAAQ,eAAe,GAAG,EAC1B,QAAQ,YAAY,EAAE;AACzB,QAAI,CAAC,QAAQ,OAAO,KAAK,CAAAA,YAAUA,QAAO,SAAS,IAAI,GAAG;AACxD;AAAA,IACF;AACA,UAAM,SAAS,WAAW,MAAM,YAAY,MAAM,KAAK;AACvD,QAAI,QAAQ;AAGV,aAAO,SAAS;AAChB,aAAO,KAAK,MAAM;AAAA,IACpB;AAAA,EACF;AAEA,QAAM,WAA0B,CAAC;AACjC,MAAI,OAAO,QAAQ;AACjB,aAAS,SAAS;AAAA,EACpB;AAEA,QAAM,WAAW,SAAI,YAAJ,YAAe,CAAC;AACjC,QAAM,UAAU,OAAO,QAAQ,OAAO;AACtC,MAAI,OAAO,SAAS,OAAO,KAAK,QAAQ,cAAc,MAAM;AAC1D,aAAS,UAAU,EAAE,WAAW,SAAS,UAAU,MAAM;AAAA,EAC3D;AACA,SAAO;AACT;AAWO,SAAS,mBACd,OACA,YAAuB,qBACvB,UACA,MAAoB,KAAK,KACV;AACf,MAAI,SAA0B;AAC9B,SAAO;AAAA,IACL,MAAM;AAAA,IACN,OAAO,YAAoC;AACzC,yCAAW,MAAM,MAAM,KAAK;AAC5B,UAAI,CAAC,QAAQ;AACX,cAAM,IAAI,2BAAW,QAAQ,yEAAoE;AAAA,MACnG;AACA,UAAI,IAAI,KAAK,OAAO,YAAY,KAAQ;AACtC,iBAAS,UAAM,0CAAqB,QAAQ,UAAU,IAAI,CAAC;AAC3D,cAAM,MAAM,KAAK,MAAM;AAAA,MACzB;AACA,YAAM,UAAkC;AAAA,QACtC,eAAe,UAAU,OAAO,WAAW;AAAA,QAC3C,cAAc;AAAA,MAChB;AAEA,UAAI,OAAO,YAAY;AACrB,gBAAQ,oBAAoB,IAAI,OAAO;AAAA,MACzC;AACA,aAAO,kBAAkB,MAAM,UAAU,mBAAmB,OAAO,CAAC;AAAA,IACtE;AAAA,EACF;AACF;",
4
+ "sourcesContent": ["import { getJson, type JsonFetch } from \"../http\";\nimport {\n FetchError,\n type LimitWindow,\n type TokenSet,\n type TokenStore,\n type UsageProvider,\n type UsageSnapshot,\n} from \"../provider\";\nimport { CHATGPT_OAUTH, refreshChatgptTokens, type JsonPost } from \"./chatgpt-auth\";\n\n/** Where the subscription usage lives (the endpoint OpenAI's own Codex client uses). */\nexport const CHATGPT_USAGE_URL = \"https://chatgpt.com/backend-api/wham/usage\";\n\n/**\n * Read one rate-limit window.\n *\n * Both windows may be null when they do not apply to the account, and `reset_at`\n * is in Unix SECONDS \u2014 a value that already looks like milliseconds is passed\n * through, so a future format change cannot produce a year-58000 timestamp.\n *\n * @param raw the window object\n * @param name the object id segment\n * @param label the human-readable label\n * @returns the window, or undefined when unusable\n */\nfunction readWindow(raw: unknown, name: string, label: string): LimitWindow | undefined {\n if (typeof raw !== \"object\" || raw === null) {\n return undefined;\n }\n const entry = raw as Record<string, unknown>;\n const percent = Number(entry.used_percent);\n if (!Number.isFinite(percent)) {\n return undefined;\n }\n const window: LimitWindow = { name, label, percent };\n const resetAt = Number(entry.reset_at);\n if (Number.isFinite(resetAt) && resetAt > 0) {\n const ms = resetAt > 1e12 ? resetAt : resetAt * 1000;\n window.resetAt = new Date(ms).toISOString();\n }\n return window;\n}\n\n/**\n * Parse a `/wham/usage` answer into a snapshot.\n *\n * Fields follow OpenAI's own generated models: `plan_type`, `rate_limit` with a\n * 5-hour `primary_window` and a weekly `secondary_window`, plus `credits` whose\n * balance arrives as a string in the schema but as a number in practice.\n *\n * @param body the parsed answer\n * @returns the snapshot\n */\nexport function parseChatgptUsage(body: unknown): UsageSnapshot {\n if (typeof body !== \"object\" || body === null) {\n throw new FetchError(\"network\", \"unexpected usage response\");\n }\n const raw = body as Record<string, unknown>;\n const limits: LimitWindow[] = [];\n const rateLimit = (raw.rate_limit ?? {}) as Record<string, unknown>;\n const session = readWindow(rateLimit.primary_window, \"session\", \"Session (5 h)\");\n const week = readWindow(rateLimit.secondary_window, \"week\", \"Week\");\n if (session) {\n limits.push(session);\n }\n if (week) {\n limits.push(week);\n }\n for (const extra of Array.isArray(raw.additional_rate_limits) ? raw.additional_rate_limits : []) {\n if (typeof extra !== \"object\" || extra === null) {\n continue;\n }\n const entry = extra as Record<string, unknown>;\n const label = typeof entry.limit_name === \"string\" ? entry.limit_name : \"\";\n // The name becomes an object-id segment, so it must survive sanitizing and must\n // not collide with a window already collected \u2014 a name of \"Session\" would\n // otherwise overwrite the 5-hour window with an unrelated counter.\n const name = label\n .toLowerCase()\n .replace(/[^a-z0-9]+/g, \"-\")\n .replace(/^-+|-+$/g, \"\");\n if (!name || limits.some(window => window.name === name)) {\n continue;\n }\n const window = readWindow(entry.rate_limit, name, label);\n if (window) {\n // These sit next to the plan-wide session/week windows and cover one model\n // each \u2014 reported, but never the reason for a warning (LimitWindow.scoped).\n window.scoped = true;\n limits.push(window);\n }\n }\n\n const snapshot: UsageSnapshot = {};\n if (limits.length) {\n snapshot.limits = limits;\n }\n\n const credits = (raw.credits ?? {}) as Record<string, unknown>;\n const balance = Number(credits.balance);\n if (Number.isFinite(balance) && credits.unlimited !== true) {\n snapshot.credits = { remaining: balance, currency: \"USD\" };\n }\n return snapshot;\n}\n\n/**\n * The ChatGPT/Codex subscription provider.\n *\n * The tokens come from the store on every round and are never held here \u2014 see\n * {@link claudeSubProvider} for why that matters when the user signs out.\n *\n * @param store where the tokens live (keyed by provider, shared by no one else)\n * @param postJson the JSON-POST seam (token refresh)\n * @param fetchJson the JSON-GET seam\n * @param now clock (ms)\n * @returns the provider\n */\nexport function chatgptSubProvider(\n store: TokenStore,\n postJson: JsonPost,\n fetchJson: JsonFetch = getJson,\n now: () => number = Date.now,\n): UsageProvider {\n return {\n kind: \"chatgpt-sub\",\n fetch: async (): Promise<UsageSnapshot> => {\n let tokens: TokenSet | null = await store.load();\n if (!tokens) {\n throw new FetchError(\"auth\", \"not signed in \u2014 start the ChatGPT sign-in in the instance settings\");\n }\n if (now() >= tokens.expiresAt - 60_000) {\n tokens = await refreshChatgptTokens(tokens, postJson, now());\n await store.save(tokens);\n }\n const headers: Record<string, string> = {\n Authorization: `Bearer ${tokens.accessToken}`,\n \"User-Agent\": \"ioBroker.ai-usage\",\n };\n // Only send the account id when we have one \u2014 an empty header is rejected.\n if (tokens.accountRef) {\n headers[\"ChatGPT-Account-Id\"] = tokens.accountRef;\n }\n return parseChatgptUsage(await fetchJson(CHATGPT_USAGE_URL, headers));\n },\n };\n}\n\nexport { CHATGPT_OAUTH };\n"],
5
+ "mappings": "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",
6
6
  "names": ["window"]
7
7
  }
@@ -137,21 +137,20 @@ function applyExtraUsage(raw, snapshot) {
137
137
  }
138
138
  }
139
139
  }
140
- function claudeSubProvider(store, fetchJson = import_http.getJson, postJson, now = Date.now) {
141
- let cached = null;
140
+ function claudeSubProvider(store, postJson, fetchJson = import_http.getJson, now = Date.now) {
142
141
  return {
143
142
  kind: "claude-sub",
144
143
  fetch: async () => {
145
- cached != null ? cached : cached = await store.load();
146
- if (!cached) {
144
+ let tokens = await store.load();
145
+ if (!tokens) {
147
146
  throw new import_provider.FetchError("auth", "not signed in \u2014 run the Claude sign-in in the instance settings");
148
147
  }
149
- if (now() >= cached.expiresAt - 6e4) {
150
- cached = await (0, import_claude_auth.refreshTokens)(cached, postJson, now());
151
- await store.save(cached);
148
+ if (now() >= tokens.expiresAt - 6e4) {
149
+ tokens = await (0, import_claude_auth.refreshTokens)(tokens, postJson, now());
150
+ await store.save(tokens);
152
151
  }
153
152
  const body = await fetchJson(import_claude_auth.CLAUDE_OAUTH.usageUrl, {
154
- Authorization: `Bearer ${cached.accessToken}`,
153
+ Authorization: `Bearer ${tokens.accessToken}`,
155
154
  "anthropic-beta": import_claude_auth.CLAUDE_OAUTH.betaHeader,
156
155
  // Identify ourselves — an unset/odd user agent lands in a harder-throttled
157
156
  // bucket of this endpoint (community-measured; sources in the concept doc).