iobroker.ai-usage 0.7.0 → 0.8.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 +19 -17
  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 +104 -52
  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 +118 -28
  39. package/build/main.js.map +2 -2
  40. package/io-package.json +27 -27
  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,8 +57,11 @@ class PollEngine {
56
57
  state: "no-connection",
57
58
  error: "waiting for the first query",
58
59
  createdObjects: /* @__PURE__ */ new Set(),
59
- written: /* @__PURE__ */ new Map(),
60
- firstPollDone: false
60
+ firstPollDone: false,
61
+ polling: false,
62
+ pollAgain: false,
63
+ deliveredIds: null,
64
+ staticIds: []
61
65
  });
62
66
  }
63
67
  }
@@ -67,23 +71,32 @@ class PollEngine {
67
71
  handles = [];
68
72
  stopped = false;
69
73
  firstRoundReported = false;
70
- /** Last written value of the adapter-wide indicators — keeps history free of repeats. */
71
- writtenTotals = /* @__PURE__ */ new Map();
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;
72
80
  /** Create the static per-account and totals objects, then arm the poll cycles. */
73
81
  async start() {
74
82
  for (const runtime of this.runtimes) {
75
83
  await this.createAccountSkeleton(runtime);
76
84
  }
77
85
  await this.createTotalsSkeleton();
78
- await this.writeTotals();
86
+ this.writeTotals();
79
87
  if (this.runtimes.length === 0) {
80
88
  this.reportFirstRoundOnce();
81
89
  return;
82
90
  }
83
91
  this.runtimes.forEach((runtime, index) => {
84
92
  this.handles.push(
85
- this.deps.scheduleOnce(() => void this.pollAccount(runtime), index * STAGGER_MS),
86
- 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)
87
100
  );
88
101
  });
89
102
  }
@@ -95,9 +108,23 @@ class PollEngine {
95
108
  }
96
109
  this.handles.length = 0;
97
110
  }
98
- /** The account ids the engine drives (for the stale-object cleanup). */
99
- get accountIds() {
100
- 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 (nut2 does the same on its devices).
118
+ *
119
+ * Synchronous, like {@link stop} — the writes go out fire-and-forget, because
120
+ * `onUnload` must not await anything.
121
+ */
122
+ markAllOffline() {
123
+ for (const runtime of this.runtimes) {
124
+ this.deps.setStateChanged(`${runtime.config.id}.info.unreach`, true);
125
+ this.deps.setStateChanged(`${runtime.config.id}.info.error`, "The adapter is stopped \u2014 nothing is being read");
126
+ }
127
+ this.deps.setStateChanged("total.accountsReachable", 0);
101
128
  }
102
129
  /**
103
130
  * Poll one account immediately, by id. Used after a successful sign-in: waiting
@@ -116,12 +143,38 @@ class PollEngine {
116
143
  /**
117
144
  * Poll one account now (also used by the staggered first run).
118
145
  *
146
+ * Never two at once for the same account: a sign-in triggers an immediate poll,
147
+ * which can land on top of a scheduled one — and two token refreshes in parallel
148
+ * on a rotating refresh token sign each other out. A request that arrives while
149
+ * one is running is remembered and runs right after, so nothing is lost.
150
+ *
119
151
  * @param runtime the account's runtime
120
152
  */
121
153
  async pollAccount(runtime) {
122
154
  if (this.stopped) {
123
155
  return;
124
156
  }
157
+ if (runtime.polling) {
158
+ runtime.pollAgain = true;
159
+ return;
160
+ }
161
+ runtime.polling = true;
162
+ try {
163
+ await this.pollOnce(runtime);
164
+ } finally {
165
+ runtime.polling = false;
166
+ }
167
+ if (runtime.pollAgain && !this.stopped) {
168
+ runtime.pollAgain = false;
169
+ await this.pollAccount(runtime);
170
+ }
171
+ }
172
+ /**
173
+ * One poll of one account: fetch, classify, write.
174
+ *
175
+ * @param runtime the account's runtime
176
+ */
177
+ async pollOnce(runtime) {
125
178
  const { config } = runtime;
126
179
  if (this.deps.now() < runtime.skipUntil) {
127
180
  this.deps.log.debug(`${config.name}: in rate-limit backoff \u2014 poll skipped`);
@@ -144,7 +197,7 @@ class PollEngine {
144
197
  this.handleFailure(runtime, e);
145
198
  }
146
199
  this.writeAccountInfo(runtime);
147
- await this.writeTotals();
200
+ this.writeTotals();
148
201
  runtime.firstPollDone = true;
149
202
  this.reportFirstRoundOnce();
150
203
  }
@@ -167,7 +220,7 @@ class PollEngine {
167
220
  async applySnapshot(runtime, snapshot) {
168
221
  var _a, _b, _c;
169
222
  const { config } = runtime;
170
- const { objects, writes } = (0, import_snapshot_tree.mapSnapshot)(config.id, config.name, config.provider, snapshot);
223
+ const { objects, writes } = (0, import_snapshot_tree.mapSnapshot)(config.id, snapshot);
171
224
  for (const object of objects) {
172
225
  if (!runtime.createdObjects.has(object.id)) {
173
226
  await this.deps.upsertObject(object);
@@ -177,12 +230,16 @@ class PollEngine {
177
230
  for (const write of writes) {
178
231
  this.deps.setState(write.id, write.value);
179
232
  }
233
+ await this.removeVanished(
234
+ runtime,
235
+ writes.map((write) => write.id)
236
+ );
180
237
  const driver = (0, import_snapshot_tree.limitingWindow)(snapshot);
181
238
  const percent = (_a = driver == null ? void 0 : driver.percent) != null ? _a : 0;
182
239
  const wasWarning = runtime.status.warning;
183
240
  runtime.status.warning = percent >= config.warnThreshold;
184
- this.setIfChanged(runtime, `${config.id}.warning`, runtime.status.warning);
185
- this.setIfChanged(runtime, `${config.id}.limitReached`, percent >= 100);
241
+ this.deps.setStateChanged(`${config.id}.warning`, runtime.status.warning);
242
+ this.deps.setStateChanged(`${config.id}.limitReached`, percent >= 100);
186
243
  if (runtime.status.warning && !wasWarning) {
187
244
  const window = driver ? `${driver.label} ` : "";
188
245
  const message = `${config.name}: ${window}at ${Math.round(percent)} % (threshold ${config.warnThreshold} %)`;
@@ -190,6 +247,26 @@ class PollEngine {
190
247
  (_c = (_b = this.deps).notify) == null ? void 0 : _c.call(_b, config.name, message);
191
248
  }
192
249
  }
250
+ /**
251
+ * Delete what this account no longer delivers.
252
+ *
253
+ * The first round after a start compares against the DATABASE, so a window or
254
+ * model that disappeared while the adapter was stopped is caught as well; every
255
+ * round after that compares against the previous snapshot, which costs nothing.
256
+ *
257
+ * @param runtime the account's runtime
258
+ * @param delivered the state ids this snapshot wrote
259
+ */
260
+ async removeVanished(runtime, delivered) {
261
+ var _a;
262
+ const known = (_a = runtime.deliveredIds) != null ? _a : await this.deps.listStateIds(runtime.config.id);
263
+ for (const id of (0, import_snapshot_tree.orphanObjectIds)(known, delivered, runtime.staticIds)) {
264
+ await this.deps.deleteObject(id);
265
+ runtime.createdObjects.delete(id);
266
+ this.deps.log.info(`${runtime.config.name}: removed "${id}" \u2014 the provider no longer reports it`);
267
+ }
268
+ runtime.deliveredIds = delivered;
269
+ }
193
270
  /**
194
271
  * Classify a fetch failure.
195
272
  *
@@ -273,57 +350,32 @@ class PollEngine {
273
350
  * has to mean what a user reads into that icon: green while the account delivers.
274
351
  * A throttle keeps the last values and the service is fine, so it stays green and
275
352
  * only fills the error text; a dead sign-in, a broken service or no connection at
276
- * all turn it off. Both states are written on change only — an indicator rewritten
277
- * every cycle floods the history and hides the real transition.
353
+ * all turn it off. Both go through the changed-write: an indicator rewritten every
354
+ * cycle floods the history and hides the real transition.
278
355
  *
279
356
  * @param runtime the account's runtime
280
357
  */
281
358
  writeAccountStatus(runtime) {
282
359
  const { config } = runtime;
283
360
  const delivering = runtime.state === "ok" || runtime.state === "rate-limited";
284
- this.setIfChanged(runtime, `${config.id}.info.unreach`, !delivering);
285
- this.setIfChanged(runtime, `${config.id}.info.error`, runtime.error);
286
- }
287
- /**
288
- * Write a state only when its value actually changed since the last write.
289
- *
290
- * @param runtime the account's runtime (holds the last-written cache)
291
- * @param id the state id
292
- * @param value the value
293
- */
294
- /**
295
- * Write an adapter-wide indicator only when it changed.
296
- *
297
- * @param id the state id
298
- * @param value the value
299
- */
300
- setTotalIfChanged(id, value) {
301
- if (this.writtenTotals.get(id) === value) {
302
- return;
303
- }
304
- this.writtenTotals.set(id, value);
305
- this.deps.setState(id, value);
306
- }
307
- setIfChanged(runtime, id, value) {
308
- if (runtime.written.get(id) === value) {
309
- return;
310
- }
311
- runtime.written.set(id, value);
312
- this.deps.setState(id, value);
361
+ this.deps.setStateChanged(`${config.id}.info.unreach`, !delivering);
362
+ this.deps.setStateChanged(`${config.id}.info.error`, runtime.error);
313
363
  }
314
364
  /** Recompute and write the totals + info.connection. */
315
- async writeTotals() {
316
- const totals = (0, import_totals.computeTotals)(this.runtimes.map((runtime) => runtime.status));
365
+ writeTotals() {
366
+ const totals = (0, import_totals.computeTotals)(
367
+ this.runtimes.map((runtime) => runtime.status),
368
+ this.configuredAccounts
369
+ );
317
370
  this.deps.setState("total.costs.today", totals.costsToday);
318
371
  this.deps.setState("total.costs.month", totals.costsMonth);
319
372
  this.deps.setState("total.costs.projectedMonth", totals.costsProjectedMonth);
320
373
  this.deps.setState("total.maxLimitPercent", totals.maxLimitPercent);
321
374
  this.deps.setState("total.warningsActive", totals.warningsActive);
322
- this.setTotalIfChanged("total.limitReached", totals.limitReached);
375
+ this.deps.setStateChanged("total.limitReached", totals.limitReached);
323
376
  this.deps.setState("total.accountsReachable", totals.accountsReachable);
324
- this.deps.setState("total.accounts", totals.accounts);
325
- this.setTotalIfChanged("info.connection", totals.accountsReachable > 0);
326
- return Promise.resolve();
377
+ this.deps.setStateChanged("total.accounts", totals.accounts);
378
+ this.deps.setStateChanged("info.connection", totals.accountsReachable > 0);
327
379
  }
328
380
  /**
329
381
  * The static per-account objects that exist regardless of what the source delivers.
@@ -393,7 +445,7 @@ class PollEngine {
393
445
  await this.deps.upsertObject(def);
394
446
  runtime.createdObjects.add(def.id);
395
447
  }
396
- this.writeAccountStatus(runtime);
448
+ runtime.staticIds = defs.filter((def) => def.type === "state").map((def) => def.id);
397
449
  }
398
450
  /** The totals skeleton (channel + states). */
399
451
  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. */\n setState(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 /** Last written value per state id \u2014 keeps unchanged values out of the history. */\n written: Map<string, boolean | number | 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 /** Last written value of the adapter-wide indicators \u2014 keeps history free of repeats. */\n private readonly writtenTotals = new Map<string, boolean | number | string>();\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 written: new Map(),\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 on change only \u2014 the same reason as for the status states.\n this.setIfChanged(runtime, `${config.id}.warning`, runtime.status.warning);\n this.setIfChanged(runtime, `${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 states are written on change only \u2014 an indicator rewritten\n * every 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.setIfChanged(runtime, `${config.id}.info.unreach`, !delivering);\n this.setIfChanged(runtime, `${config.id}.info.error`, runtime.error);\n }\n\n /**\n * Write a state only when its value actually changed since the last write.\n *\n * @param runtime the account's runtime (holds the last-written cache)\n * @param id the state id\n * @param value the value\n */\n /**\n * Write an adapter-wide indicator only when it changed.\n *\n * @param id the state id\n * @param value the value\n */\n private setTotalIfChanged(id: string, value: boolean | number | string): void {\n if (this.writtenTotals.get(id) === value) {\n return;\n }\n this.writtenTotals.set(id, value);\n this.deps.setState(id, value);\n }\n\n private setIfChanged(runtime: AccountRuntime, id: string, value: boolean | number | string): void {\n if (runtime.written.get(id) === value) {\n return;\n }\n runtime.written.set(id, value);\n this.deps.setState(id, value);\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.setTotalIfChanged(\"total.limitReached\", totals.limitReached);\n this.deps.setState(\"total.accountsReachable\", totals.accountsReachable);\n this.deps.setState(\"total.accounts\", totals.accounts);\n this.setTotalIfChanged(\"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 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 /** 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 (nut2 does the same on its devices).\n *\n * Synchronous, like {@link stop} \u2014 the writes go out fire-and-forget, because\n * `onUnload` must not await anything.\n */\n public markAllOffline(): void {\n for (const runtime of this.runtimes) {\n this.deps.setStateChanged(`${runtime.config.id}.info.unreach`, true);\n this.deps.setStateChanged(`${runtime.config.id}.info.error`, \"The adapter is stopped \u2014 nothing is being read\");\n }\n // The same lie one level up: \"accounts currently delivering data\" is zero.\n this.deps.setStateChanged(\"total.accountsReachable\", 0);\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 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 * 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 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 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 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 this.deps.setStateChanged(\"total.accounts\", totals.accounts);\n 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 // Deliberately NO status write here. Before the first answer the adapter knows\n // nothing, and writing \"not reachable\" would strike every account through in the\n // object tree and paint a red badge in the settings \u2014 for a full poll interval,\n // over an adapter that is working perfectly. Both surfaces show nothing while\n // the datapoint has no value, which is the honest picture.\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": "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",
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
  }