iobroker.ai-usage 0.2.0 → 0.4.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 (63) hide show
  1. package/README.md +135 -24
  2. package/admin/ai-usage.svg +12 -17
  3. package/admin/custom/assets/Components-Bq1fGAd7.js +1 -0
  4. package/admin/custom/assets/ConfigPanel-DGMF4_qT.js +1 -0
  5. package/admin/custom/assets/{_virtual_mf-localSharedImportMap___mfe_internal__AiUsageComponentSet__mf_owner__1-CXmnAfNY.js → _virtual_mf-localSharedImportMap___mfe_internal__AiUsageComponentSet__mf_owner__1-DKcsNxEw.js} +1 -1
  6. package/admin/custom/assets/{_virtual_mf___mfe_internal__AiUsageComponentSet__mf_owner__1__loadShare___mf_0_iobroker_mf_1_gui_mf_2_components__loadShare__.js-DwHNIjSR.js → _virtual_mf___mfe_internal__AiUsageComponentSet__mf_owner__1__loadShare___mf_0_iobroker_mf_1_gui_mf_2_components__loadShare__.js-DzPBJWNQ.js} +1 -1
  7. package/admin/custom/assets/{_virtual_mf___mfe_internal__AiUsageComponentSet__mf_owner__1__loadShare___mf_0_iobroker_mf_1_json_mf_2_config__loadShare__.js-qu1s07FY.js → _virtual_mf___mfe_internal__AiUsageComponentSet__mf_owner__1__loadShare___mf_0_iobroker_mf_1_json_mf_2_config__loadShare__.js-DEdJInQG.js} +3 -3
  8. package/admin/custom/assets/{_virtual_mf___mfe_internal__AiUsageComponentSet__mf_owner__1__loadShare__react_mf_2_dom_mf_1_client__loadShare__.js-C8-GjKo4.js → _virtual_mf___mfe_internal__AiUsageComponentSet__mf_owner__1__loadShare__react_mf_2_dom_mf_1_client__loadShare__.js-CFn_lBXc.js} +1 -1
  9. package/admin/custom/assets/{_virtual_mf___mfe_internal__AiUsageComponentSet__mf_owner__1__prebuild___mf_0_iobroker_mf_1_gui_mf_2_components__prebuild__-CQxWwR2r.js → _virtual_mf___mfe_internal__AiUsageComponentSet__mf_owner__1__prebuild___mf_0_iobroker_mf_1_gui_mf_2_components__prebuild__-DeLUTSLT.js} +1 -1
  10. package/admin/custom/assets/{hostInit-RHMRa462.js → hostInit-DoGxgbHj.js} +1 -1
  11. package/admin/custom/assets/index-CKngiVu3.js +1 -0
  12. package/admin/custom/assets/{lib-ByF76TCN.js → lib-MY71LxhX.js} +1 -1
  13. package/admin/custom/assets/{mf-entry-bootstrap-0-e070fa3e.js → mf-entry-bootstrap-0-eec08a33.js} +2 -2
  14. package/admin/custom/assets/virtualExposes-Dc9myzsk.js +1 -0
  15. package/admin/custom/assets/{virtual_mf-REMOTE_ENTRY_ID___mfe_internal__AiUsageComponentSet__customComponents_js-l7jtElkM.js → virtual_mf-REMOTE_ENTRY_ID___mfe_internal__AiUsageComponentSet__customComponents_js-CP4YA3u3.js} +1 -1
  16. package/admin/custom/assets/virtual_mf-exposes-ssr___mfe_internal__AiUsageComponentSet__customComponents_js-DlNU_Z38.js +2 -0
  17. package/admin/custom/assets/{virtual_mf-exposes___mfe_internal__AiUsageComponentSet__customComponents_js-BZ-CVpVo.js → virtual_mf-exposes___mfe_internal__AiUsageComponentSet__customComponents_js-CEfffc0D.js} +2 -2
  18. package/admin/custom/customComponents.js +1 -1
  19. package/admin/custom/i18n/de.json +24 -15
  20. package/admin/custom/i18n/en.json +25 -16
  21. package/admin/custom/i18n/es.json +25 -16
  22. package/admin/custom/i18n/fr.json +25 -16
  23. package/admin/custom/i18n/it.json +25 -16
  24. package/admin/custom/i18n/nl.json +25 -16
  25. package/admin/custom/i18n/pl.json +25 -16
  26. package/admin/custom/i18n/pt.json +25 -16
  27. package/admin/custom/i18n/ru.json +25 -16
  28. package/admin/custom/i18n/uk.json +25 -16
  29. package/admin/custom/i18n/zh-cn.json +25 -16
  30. package/admin/custom/mf-manifest.json +1 -1
  31. package/build/lib/http.js +33 -4
  32. package/build/lib/http.js.map +2 -2
  33. package/build/lib/jwt.js +59 -0
  34. package/build/lib/jwt.js.map +7 -0
  35. package/build/lib/poll-engine.js +86 -6
  36. package/build/lib/poll-engine.js.map +2 -2
  37. package/build/lib/provider.js.map +2 -2
  38. package/build/lib/providers/chatgpt-auth.js +125 -0
  39. package/build/lib/providers/chatgpt-auth.js.map +7 -0
  40. package/build/lib/providers/chatgpt-sub.js +122 -0
  41. package/build/lib/providers/chatgpt-sub.js.map +7 -0
  42. package/build/lib/providers/claude-auth.js.map +2 -2
  43. package/build/lib/providers/claude-sub.js +9 -5
  44. package/build/lib/providers/claude-sub.js.map +2 -2
  45. package/build/lib/providers/gemini-auth.js +135 -0
  46. package/build/lib/providers/gemini-auth.js.map +7 -0
  47. package/build/lib/providers/gemini-sub.js +147 -0
  48. package/build/lib/providers/gemini-sub.js.map +7 -0
  49. package/build/lib/pure-helpers.js +38 -6
  50. package/build/lib/pure-helpers.js.map +2 -2
  51. package/build/lib/sign-in.js +38 -0
  52. package/build/lib/sign-in.js.map +7 -0
  53. package/build/lib/snapshot-tree.js +21 -9
  54. package/build/lib/snapshot-tree.js.map +2 -2
  55. package/build/main.js +283 -151
  56. package/build/main.js.map +2 -2
  57. package/io-package.json +46 -12
  58. package/package.json +1 -1
  59. package/admin/custom/assets/Components-Bd545388.js +0 -1
  60. package/admin/custom/assets/ConfigPanel-qivVprTj.js +0 -1
  61. package/admin/custom/assets/index-B0J1NbOp.js +0 -1
  62. package/admin/custom/assets/virtualExposes-BZWQ_oUV.js +0 -1
  63. package/admin/custom/assets/virtual_mf-exposes-ssr___mfe_internal__AiUsageComponentSet__customComponents_js-CRK-nAdE.js +0 -2
package/build/main.js CHANGED
@@ -38,16 +38,25 @@ var import_node_path = require("node:path");
38
38
  var import_http = require("./lib/http");
39
39
  var import_poll_engine = require("./lib/poll-engine");
40
40
  var import_pure_helpers = require("./lib/pure-helpers");
41
+ var import_sign_in = require("./lib/sign-in");
41
42
  var import_claude_auth = require("./lib/providers/claude-auth");
42
- var import_anthropic_api = require("./lib/providers/anthropic-api");
43
43
  var import_claude_sub = require("./lib/providers/claude-sub");
44
+ var import_chatgpt_auth = require("./lib/providers/chatgpt-auth");
45
+ var import_chatgpt_sub = require("./lib/providers/chatgpt-sub");
46
+ var import_gemini_auth = require("./lib/providers/gemini-auth");
47
+ var import_gemini_sub = require("./lib/providers/gemini-sub");
48
+ var import_anthropic_api = require("./lib/providers/anthropic-api");
44
49
  var import_deepseek = require("./lib/providers/deepseek");
45
50
  var import_openai = require("./lib/providers/openai");
46
51
  var import_openrouter = require("./lib/providers/openrouter");
47
52
  class AiUsageAdapter extends utils.Adapter {
48
53
  engine = null;
49
- /** Pending Claude sign-in attempts, keyed by account id (PKCE lives only in memory). */
50
- pendingClaudeAuth = /* @__PURE__ */ new Map();
54
+ /** Running sign-in attempts, keyed by provider kind. */
55
+ attempts = /* @__PURE__ */ new Map();
56
+ /** Last failure reason per provider, shown in the admin row. */
57
+ signInErrors = /* @__PURE__ */ new Map();
58
+ /** Device-code pollers, so they can be stopped on unload. */
59
+ devicePollers = /* @__PURE__ */ new Map();
51
60
  /**
52
61
  * @param options the adapter options
53
62
  */
@@ -57,48 +66,28 @@ class AiUsageAdapter extends utils.Adapter {
57
66
  this.on("message", this.onMessage.bind(this));
58
67
  this.on("unload", this.onUnload.bind(this));
59
68
  }
69
+ // ---------------------------------------------------------------- sign-in
60
70
  /**
61
- * Handle admin messages — the guided Claude subscription sign-in.
71
+ * Handle admin messages: the three sign-in flows plus their status.
62
72
  *
63
73
  * @param obj the message
64
74
  */
65
75
  async onMessage(obj) {
66
- var _a;
67
76
  try {
77
+ const provider = this.providerFrom(obj.message);
68
78
  switch (obj.command) {
69
- case "claudeAuthStart": {
70
- const accountId = this.claudeAccountIdFrom(obj.message);
71
- if (!accountId) {
72
- this.respond(obj, { error: "Save the settings with a Claude subscription account first" });
73
- return;
74
- }
75
- const url = await this.publishClaudeSignInUrl(accountId);
76
- this.respond(obj, { url });
79
+ case "signInStart":
80
+ this.respond(obj, provider ? await this.startSignIn(provider) : { error: "unknown provider" });
77
81
  return;
78
- }
79
- case "claudeAuthCode": {
80
- const accountId = this.claudeAccountIdFrom(obj.message);
81
- const code = typeof ((_a = obj.message) == null ? void 0 : _a.code) === "string" ? obj.message.code : "";
82
- const pkce = accountId ? this.pendingClaudeAuth.get(accountId) : void 0;
83
- if (!accountId || !pkce) {
84
- this.respond(obj, { error: "Save the settings with a Claude subscription account first" });
85
- return;
86
- }
87
- if (!code.trim()) {
88
- this.respond(obj, { error: "Paste the code from the Anthropic page first" });
89
- return;
90
- }
91
- try {
92
- const tokens = await (0, import_claude_auth.exchangeCode)(code, pkce, import_http.postJson, Date.now());
93
- await this.claudeTokenStore(accountId).save(tokens);
94
- this.pendingClaudeAuth.delete(accountId);
95
- await this.setClaudeAuthStates(accountId, "", true);
96
- this.respond(obj, { result: "ok" });
97
- } catch (e) {
98
- this.respond(obj, { error: `Sign-in failed: ${e instanceof Error ? e.message : String(e)}` });
99
- }
82
+ case "signInSubmit":
83
+ this.respond(obj, provider ? await this.submitSignIn(provider, obj.message) : { error: "unknown provider" });
84
+ return;
85
+ case "signInStatus":
86
+ this.respond(obj, provider ? await this.signInState(provider) : { error: "unknown provider" });
87
+ return;
88
+ case "signOut":
89
+ this.respond(obj, provider ? await this.signOut(provider) : { error: "unknown provider" });
100
90
  return;
101
- }
102
91
  default:
103
92
  this.respond(obj, { error: `Unknown command: ${obj.command}` });
104
93
  }
@@ -119,140 +108,213 @@ class AiUsageAdapter extends utils.Adapter {
119
108
  }
120
109
  }
121
110
  /**
122
- * Resolve the account id for a Claude sign-in message: the given name must match
123
- * a claude-sub row of the accounts table.
111
+ * The provider kind named in a message, if it is a subscription we know.
124
112
  *
125
- * @param message the message payload ({ account })
126
- * @returns the id-safe account id, or undefined
113
+ * @param message the message payload ({ provider })
114
+ * @returns the provider kind, or undefined
127
115
  */
128
- claudeAccountIdFrom(message) {
129
- const name = typeof (message == null ? void 0 : message.account) === "string" ? message.account : "";
130
- const id = (0, import_pure_helpers.sanitizeId)(name);
131
- if (!id) {
132
- return void 0;
133
- }
134
- const accounts = (0, import_pure_helpers.parseAccounts)(this.config.accounts);
135
- return accounts.some((account) => account.id === id && account.provider === "claude-sub") ? id : void 0;
116
+ providerFrom(message) {
117
+ const value = typeof (message == null ? void 0 : message.provider) === "string" ? message.provider : "";
118
+ return import_sign_in.SIGN_IN_FLOWS[value] ? value : void 0;
136
119
  }
137
120
  /**
138
- * Create the `auth.<id>` states of one Claude account and publish a FRESH
139
- * sign-in link (the adapter owns the secret; the link stays valid until it is
140
- * regenerated or redeemed — the admin card only displays it).
121
+ * Begin a sign-in: build the link (Claude/Google) or fetch a device code (ChatGPT).
141
122
  *
142
- * @param accountId the id-safe account id
143
- * @returns the sign-in URL
123
+ * @param provider the subscription kind
124
+ * @returns what the admin panel has to show
144
125
  */
145
- async publishClaudeSignInUrl(accountId) {
146
- const pkce = (0, import_claude_auth.generatePkce)();
147
- this.pendingClaudeAuth.set(accountId, pkce);
148
- const url = (0, import_claude_auth.buildAuthorizeUrl)(pkce);
149
- await this.setClaudeAuthStates(accountId, url, false);
150
- return url;
126
+ async startSignIn(provider) {
127
+ this.signInErrors.delete(provider);
128
+ this.stopDevicePoller(provider);
129
+ const flow = import_sign_in.SIGN_IN_FLOWS[provider];
130
+ const now = Date.now();
131
+ try {
132
+ if (flow === "paste-code") {
133
+ const pkce = (0, import_claude_auth.generatePkce)();
134
+ const url = (0, import_claude_auth.buildAuthorizeUrl)(pkce);
135
+ this.attempts.set(provider, { flow, pkce, url, expiresAt: now + 15 * 6e4 });
136
+ return { status: "awaiting-paste", url, flow };
137
+ }
138
+ if (flow === "paste-url") {
139
+ const pkce = (0, import_gemini_auth.generateGeminiPkce)();
140
+ const url = (0, import_gemini_auth.buildGeminiAuthorizeUrl)(pkce);
141
+ this.attempts.set(provider, { flow, pkce, url, expiresAt: now + 15 * 6e4 });
142
+ return { status: "awaiting-paste", url, flow };
143
+ }
144
+ const start = await (0, import_chatgpt_auth.startDeviceCode)(import_http.postJson, now);
145
+ this.attempts.set(provider, { flow: "device-code", start });
146
+ this.armDevicePoller(provider, start);
147
+ return {
148
+ status: "awaiting-device",
149
+ userCode: start.userCode,
150
+ verificationUrl: "https://auth.openai.com/codex/device",
151
+ expiresAt: start.expiresAt
152
+ };
153
+ } catch (e) {
154
+ const reason = e instanceof Error ? e.message : String(e);
155
+ this.signInErrors.set(provider, reason);
156
+ return { status: "failed", reason };
157
+ }
151
158
  }
152
159
  /**
153
- * Create (once) and write the two `auth.<id>` states of one Claude account.
160
+ * Finish a paste-based sign-in (Claude code, Google address).
154
161
  *
155
- * @param accountId the id-safe account id
156
- * @param url the current sign-in URL ("" when signed in)
157
- * @param signedIn whether a usable sign-in exists
162
+ * @param provider the subscription kind
163
+ * @param message the message payload ({ value })
164
+ * @returns the resulting state
158
165
  */
159
- async setClaudeAuthStates(accountId, url, signedIn) {
160
- await this.extendObject("auth", {
161
- type: "folder",
162
- common: { name: { en: "Sign-in", de: "Anmeldung" } },
163
- native: {}
164
- });
165
- await this.extendObject(`auth.${accountId}`, {
166
- type: "channel",
167
- common: { name: accountId },
168
- native: {}
169
- });
170
- await this.extendObject(`auth.${accountId}.signInUrl`, {
171
- type: "state",
172
- common: {
173
- name: { en: "Sign-in link", de: "Anmelde-Link" },
174
- type: "string",
175
- role: "url",
176
- read: true,
177
- write: false,
178
- def: ""
179
- },
180
- native: {}
181
- });
182
- await this.extendObject(`auth.${accountId}.signedIn`, {
183
- type: "state",
184
- common: {
185
- name: { en: "Signed in", de: "Angemeldet" },
186
- type: "boolean",
187
- role: "indicator",
188
- read: true,
189
- write: false,
190
- def: false
191
- },
192
- native: {}
193
- });
194
- await this.setState(`auth.${accountId}.signInUrl`, { val: url, ack: true });
195
- await this.setState(`auth.${accountId}.signedIn`, { val: signedIn, ack: true });
166
+ async submitSignIn(provider, message) {
167
+ const attempt = this.attempts.get(provider);
168
+ const value = typeof (message == null ? void 0 : message.value) === "string" ? message.value.trim() : "";
169
+ if (!attempt || attempt.flow === "device-code") {
170
+ return { status: "failed", reason: "start the sign-in first" };
171
+ }
172
+ if (!value) {
173
+ return { status: "failed", reason: "nothing pasted" };
174
+ }
175
+ try {
176
+ const now = Date.now();
177
+ const tokens = attempt.flow === "paste-code" ? await (0, import_claude_auth.exchangeCode)(value, attempt.pkce, import_http.postJson, now) : await (0, import_gemini_auth.exchangeGeminiCode)((0, import_gemini_auth.extractGeminiCode)(value, attempt.pkce.state), attempt.pkce, import_http.postForm, now);
178
+ await this.finishSignIn(provider, tokens);
179
+ return { status: "signed-in" };
180
+ } catch (e) {
181
+ const reason = e instanceof Error ? e.message : String(e);
182
+ this.signInErrors.set(provider, reason);
183
+ return { status: "failed", reason };
184
+ }
196
185
  }
197
186
  /**
198
- * Prepare the guided sign-in of every Claude subscription account: publish the
199
- * live status, and for accounts without a usable sign-in a stable sign-in link.
187
+ * Store fresh tokens, mark the account signed in and poll it right away — waiting
188
+ * up to a full interval after a successful sign-in reads as "it did not work".
200
189
  *
201
- * @param accounts the validated account configs
190
+ * @param provider the subscription kind
191
+ * @param tokens the token set
202
192
  */
203
- async prepareClaudeAuth(accounts) {
204
- const claudeAccounts = accounts.filter((a) => a.provider === "claude-sub");
205
- try {
206
- const wanted = new Set(claudeAccounts.map((a) => a.id));
207
- const objects = await this.getAdapterObjectsAsync();
208
- const stale = /* @__PURE__ */ new Set();
209
- for (const id of Object.keys(objects)) {
210
- const relative = id.substring(this.namespace.length + 1);
211
- const [root, child] = relative.split(".");
212
- if (root === "auth" && child && !wanted.has(child)) {
213
- stale.add(child);
214
- }
215
- }
216
- for (const child of stale) {
217
- await this.delObjectAsync(`auth.${child}`, { recursive: true });
218
- }
219
- } catch {
193
+ async finishSignIn(provider, tokens) {
194
+ var _a;
195
+ await this.tokenStore(provider).save(tokens);
196
+ this.attempts.delete(provider);
197
+ this.signInErrors.delete(provider);
198
+ this.stopDevicePoller(provider);
199
+ const id = import_pure_helpers.SUBSCRIPTION_IDS[provider];
200
+ if (id) {
201
+ await this.setSignedInState(id, true);
202
+ await ((_a = this.engine) == null ? void 0 : _a.pollNow(id));
220
203
  }
221
- for (const account of claudeAccounts) {
204
+ this.log.info(`${provider}: signed in`);
205
+ }
206
+ /**
207
+ * Poll the device-code endpoint until the user confirmed, the window closed or
208
+ * the adapter stops. The handle lives in memory only — a restart mid-flow just
209
+ * means the user starts again, which is cheaper than persisting a 15-minute secret.
210
+ *
211
+ * @param provider the subscription kind
212
+ * @param start the device-code handle
213
+ */
214
+ armDevicePoller(provider, start) {
215
+ const tick = async () => {
222
216
  try {
223
- const tokens = await this.claudeTokenStore(account.id).load();
224
- if (tokens) {
225
- await this.setClaudeAuthStates(account.id, "", true);
226
- } else {
227
- await this.publishClaudeSignInUrl(account.id);
217
+ if ((0, import_sign_in.attemptExpired)(start.expiresAt, Date.now())) {
218
+ this.stopDevicePoller(provider);
219
+ this.attempts.delete(provider);
220
+ this.signInErrors.set(provider, "the code expired \u2014 start the sign-in again");
221
+ return;
222
+ }
223
+ const result = await (0, import_chatgpt_auth.pollDeviceCode)(start, import_http.postJson);
224
+ if (result.status === "ready") {
225
+ this.stopDevicePoller(provider);
226
+ const tokens = await (0, import_chatgpt_auth.exchangeDeviceCode)(result.code, result.codeVerifier, import_http.postForm, Date.now());
227
+ await this.finishSignIn(provider, tokens);
228
228
  }
229
229
  } catch (e) {
230
- this.log.warn(`${account.name}: cannot prepare sign-in (${e instanceof Error ? e.message : String(e)})`);
230
+ this.stopDevicePoller(provider);
231
+ this.attempts.delete(provider);
232
+ this.signInErrors.set(provider, e instanceof Error ? e.message : String(e));
231
233
  }
234
+ };
235
+ this.devicePollers.set(provider, {
236
+ kind: "interval",
237
+ handle: this.setInterval(() => void tick(), Math.max(start.intervalSec, 1) * 1e3)
238
+ });
239
+ }
240
+ /**
241
+ * Stop a running device-code poller.
242
+ *
243
+ * @param provider the subscription kind
244
+ */
245
+ stopDevicePoller(provider) {
246
+ const handle = this.devicePollers.get(provider);
247
+ if ((handle == null ? void 0 : handle.kind) === "interval") {
248
+ this.clearInterval(handle.handle);
232
249
  }
250
+ this.devicePollers.delete(provider);
233
251
  }
234
252
  /**
235
- * The persistent token storage for one Claude account: an encrypted JSON file in
236
- * the instance data directory (a `native` write would restart the instance).
253
+ * The current sign-in state of one subscription, for the admin row.
237
254
  *
238
- * @param accountId the id-safe account id
255
+ * @param provider the subscription kind
256
+ * @returns the state
257
+ */
258
+ async signInState(provider) {
259
+ const failure = this.signInErrors.get(provider);
260
+ const attempt = this.attempts.get(provider);
261
+ if ((attempt == null ? void 0 : attempt.flow) === "device-code") {
262
+ return {
263
+ status: "awaiting-device",
264
+ userCode: attempt.start.userCode,
265
+ verificationUrl: "https://auth.openai.com/codex/device",
266
+ expiresAt: attempt.start.expiresAt
267
+ };
268
+ }
269
+ if (attempt) {
270
+ return { status: "awaiting-paste", url: attempt.url, flow: attempt.flow };
271
+ }
272
+ if (failure) {
273
+ return { status: "failed", reason: failure };
274
+ }
275
+ return await this.tokenStore(provider).load() ? { status: "signed-in" } : { status: "signed-out" };
276
+ }
277
+ /**
278
+ * Forget the tokens of one subscription.
279
+ *
280
+ * @param provider the subscription kind
281
+ * @returns the resulting state
282
+ */
283
+ async signOut(provider) {
284
+ await this.tokenStore(provider).clear();
285
+ this.attempts.delete(provider);
286
+ this.signInErrors.delete(provider);
287
+ this.stopDevicePoller(provider);
288
+ const id = import_pure_helpers.SUBSCRIPTION_IDS[provider];
289
+ if (id) {
290
+ await this.setSignedInState(id, false);
291
+ }
292
+ return { status: "signed-out" };
293
+ }
294
+ // ------------------------------------------------------------ token files
295
+ /**
296
+ * Where one subscription's tokens live: an encrypted file in the instance data
297
+ * directory, named after the PROVIDER. Keying by provider (not by account name)
298
+ * keeps a sign-in alive when an account is renamed — the previous scheme lost it.
299
+ *
300
+ * @param provider the subscription kind
239
301
  * @returns the store
240
302
  */
241
- claudeTokenStore(accountId) {
303
+ tokenStore(provider) {
242
304
  const dir = utils.getAbsoluteInstanceDataDir(this);
243
- const file = (0, import_node_path.join)(dir, `claude-tokens-${accountId}.json`);
305
+ const file = (0, import_node_path.join)(dir, `tokens-${provider}.json`);
244
306
  return {
245
307
  load: async () => {
246
308
  try {
247
- const encrypted = await (0, import_promises.readFile)(file, "utf8");
248
- const parsed = JSON.parse(this.decrypt(encrypted));
309
+ const parsed = JSON.parse(this.decrypt(await (0, import_promises.readFile)(file, "utf8")));
249
310
  if (typeof parsed.accessToken !== "string" || typeof parsed.refreshToken !== "string") {
250
311
  return null;
251
312
  }
252
313
  return {
253
314
  accessToken: parsed.accessToken,
254
315
  refreshToken: parsed.refreshToken,
255
- expiresAt: Number(parsed.expiresAt) || 0
316
+ expiresAt: Number(parsed.expiresAt) || 0,
317
+ accountRef: typeof parsed.accountRef === "string" ? parsed.accountRef : void 0
256
318
  };
257
319
  } catch {
258
320
  return null;
@@ -261,16 +323,43 @@ class AiUsageAdapter extends utils.Adapter {
261
323
  save: async (tokens) => {
262
324
  await (0, import_promises.mkdir)(dir, { recursive: true });
263
325
  await (0, import_promises.writeFile)(file, this.encrypt(JSON.stringify(tokens)), "utf8");
326
+ },
327
+ clear: async () => {
328
+ await (0, import_promises.unlink)(file).catch(() => {
329
+ });
264
330
  }
265
331
  };
266
332
  }
267
- /** Validate the configuration, clean up stale account trees and start the engine. */
333
+ /**
334
+ * Carry a sign-in from the pre-0.3.0 layout over: tokens used to be stored per
335
+ * ACCOUNT NAME (`claude-tokens-<name>.json`). Without this the rename to fixed
336
+ * account ids would silently sign the user out.
337
+ */
338
+ async migrateTokenFiles() {
339
+ const dir = utils.getAbsoluteInstanceDataDir(this);
340
+ const target = (0, import_node_path.join)(dir, "tokens-claude-sub.json");
341
+ try {
342
+ await (0, import_promises.readFile)(target, "utf8");
343
+ return;
344
+ } catch {
345
+ }
346
+ for (const legacy of ["claude-tokens-Claude.json", "claude-tokens-claude.json"]) {
347
+ try {
348
+ await (0, import_promises.rename)((0, import_node_path.join)(dir, legacy), target);
349
+ this.log.info("Carried the existing Claude sign-in over to the new layout");
350
+ return;
351
+ } catch {
352
+ }
353
+ }
354
+ }
355
+ // ------------------------------------------------------------- life cycle
356
+ /** Validate the configuration, clean up stale objects and start the engine. */
268
357
  async onReady() {
269
358
  try {
270
359
  const accounts = (0, import_pure_helpers.parseAccounts)(this.config.accounts);
271
360
  const interval = (0, import_pure_helpers.clampPollInterval)(this.config.pollInterval);
272
- await this.cleanupStaleAccounts();
273
- await this.prepareClaudeAuth(accounts);
361
+ await this.migrateTokenFiles();
362
+ await this.cleanupStaleObjects();
274
363
  if (accounts.length === 0) {
275
364
  this.log.info("No AI accounts configured \u2014 add accounts in the instance settings");
276
365
  await this.setState("info.connection", { val: false, ack: true });
@@ -313,15 +402,14 @@ class AiUsageAdapter extends utils.Adapter {
313
402
  ) : void 0
314
403
  });
315
404
  await this.engine.start();
405
+ await this.publishSignedInStates(accounts);
316
406
  this.log.info(`Monitoring ${providers.size} of ${accounts.length} AI account(s), polling every ${interval} s`);
317
407
  } catch (e) {
318
408
  this.log.error(`Startup failed: ${e instanceof Error ? e.message : String(e)}`);
319
409
  }
320
410
  }
321
411
  /**
322
- * Build the provider for one account, resolving its credential from the central
323
- * storage. Accounts whose provider is not implemented yet, or whose credential
324
- * cannot be read, are skipped (the engine logs the skip).
412
+ * Build the provider for one account.
325
413
  *
326
414
  * @param account the validated account config
327
415
  * @returns the provider, or undefined to skip the account
@@ -329,7 +417,11 @@ class AiUsageAdapter extends utils.Adapter {
329
417
  async makeProvider(account) {
330
418
  switch (account.provider) {
331
419
  case "claude-sub":
332
- return (0, import_claude_sub.claudeSubProvider)(this.claudeTokenStore(account.id), void 0, import_http.postJson);
420
+ return (0, import_claude_sub.claudeSubProvider)(this.tokenStore(account.provider), void 0, import_http.postJson);
421
+ case "chatgpt-sub":
422
+ return (0, import_chatgpt_sub.chatgptSubProvider)(this.tokenStore(account.provider), void 0, import_http.postJson);
423
+ case "gemini-sub":
424
+ return (0, import_gemini_sub.geminiSubProvider)(this.tokenStore(account.provider), import_http.postJson, import_http.postForm);
333
425
  case "openrouter": {
334
426
  const key = await this.resolveKey(account);
335
427
  return key ? (0, import_openrouter.openRouterProvider)(key) : void 0;
@@ -377,32 +469,69 @@ class AiUsageAdapter extends utils.Adapter {
377
469
  }
378
470
  }
379
471
  /**
380
- * Delete the object trees of accounts that are no longer in the table. Disabled
381
- * rows keep their tree (they are only paused); an EMPTY table deletes nothing —
382
- * the guard against wiping everything through an accidental clear.
472
+ * Create and write `<account>.info.signedIn` for every subscription in the table.
473
+ *
474
+ * @param accounts the configured accounts
475
+ */
476
+ async publishSignedInStates(accounts) {
477
+ for (const account of accounts) {
478
+ if (!import_sign_in.SIGN_IN_FLOWS[account.provider]) {
479
+ continue;
480
+ }
481
+ const tokens = await this.tokenStore(account.provider).load();
482
+ await this.setSignedInState(account.id, !!tokens);
483
+ }
484
+ }
485
+ /**
486
+ * Write the sign-in indicator INSIDE the account node — the pre-0.3.0 layout kept
487
+ * a second `auth.<name>` branch, which showed every subscription twice in the tree.
488
+ *
489
+ * @param accountId the account's object id
490
+ * @param signedIn whether a usable sign-in exists
491
+ */
492
+ async setSignedInState(accountId, signedIn) {
493
+ await this.extendObject(`${accountId}.info.signedIn`, {
494
+ type: "state",
495
+ common: {
496
+ name: { en: "Signed in", de: "Angemeldet" },
497
+ type: "boolean",
498
+ role: "indicator",
499
+ read: true,
500
+ write: false,
501
+ def: false
502
+ },
503
+ native: {}
504
+ });
505
+ await this.setState(`${accountId}.info.signedIn`, { val: signedIn, ack: true });
506
+ }
507
+ /**
508
+ * Delete object trees that no longer belong to a configured account, plus the
509
+ * `auth` branch of the pre-0.3.0 layout. An EMPTY table deletes nothing — the
510
+ * guard against wiping everything through an accidental clear.
383
511
  */
384
- async cleanupStaleAccounts() {
512
+ async cleanupStaleObjects() {
385
513
  const keepIds = (0, import_pure_helpers.validAccountIds)(this.config.accounts);
386
514
  if (keepIds.length === 0) {
387
515
  return;
388
516
  }
389
- const keep = /* @__PURE__ */ new Set([...keepIds, "info", "total", "auth"]);
517
+ const keep = /* @__PURE__ */ new Set([...keepIds, "info", "total"]);
390
518
  try {
391
519
  const objects = await this.getAdapterObjectsAsync();
392
520
  const roots = /* @__PURE__ */ new Set();
393
521
  for (const id of Object.keys(objects)) {
394
- const relative = id.substring(this.namespace.length + 1);
395
- const root = relative.split(".")[0];
522
+ const root = id.substring(this.namespace.length + 1).split(".")[0];
396
523
  if (root && !keep.has(root)) {
397
524
  roots.add(root);
398
525
  }
399
526
  }
400
527
  for (const root of roots) {
401
- this.log.info(`Removing objects of no longer configured account "${root}"`);
528
+ this.log.info(
529
+ root === "auth" ? "Removing the old sign-in branch \u2014 the sign-in state now lives inside each account" : `Removing objects of no longer configured account "${root}"`
530
+ );
402
531
  await this.delObjectAsync(root, { recursive: true });
403
532
  }
404
533
  } catch (e) {
405
- this.log.warn(`Cleanup of stale accounts failed: ${e instanceof Error ? e.message : String(e)}`);
534
+ this.log.warn(`Cleanup of stale objects failed: ${e instanceof Error ? e.message : String(e)}`);
406
535
  }
407
536
  }
408
537
  /**
@@ -414,6 +543,9 @@ class AiUsageAdapter extends utils.Adapter {
414
543
  onUnload(callback) {
415
544
  var _a;
416
545
  try {
546
+ for (const provider of [...this.devicePollers.keys()]) {
547
+ this.stopDevicePoller(provider);
548
+ }
417
549
  (_a = this.engine) == null ? void 0 : _a.stop();
418
550
  this.engine = null;
419
551
  void this.setState("info.connection", { val: false, ack: true });