iobroker.ai-usage 0.14.0 → 0.15.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -155,6 +155,16 @@ so instead of pretending to be connected; signing in again is all it takes.
155
155
  Placeholder for the next version (at the beginning of the line):
156
156
  -->
157
157
 
158
+ ### 0.15.0 (2026-09-16)
159
+
160
+ - Fixed: Model channels of an organisation account no longer vanish at the turn of a month — a model with no usage yet was deleted with its history and re-created on its next use
161
+ - Fixed: Stopping the instance right after it started no longer leaves the accounts showing as connected while the instance is switched off
162
+ - Fixed: Failures that reached the log, the `info.error` datapoint and Sentry as `[object Object]` now name the actual error
163
+ - Improved: A provider answer that keeps growing can no longer push the adapter towards running out of memory — it is cut off and reported as a service fault
164
+
165
+ The month-boundary fix concerns OpenAI organisation accounts, which have no real account here; it is
166
+ covered by tests and by the counter-test that limit windows are still cleaned up.
167
+
158
168
  ### 0.14.0 (2026-09-15)
159
169
 
160
170
  - Fixed: A failed write of the token file after a refresh lost the sign-in for good — the provider had already rotated them, so the next poll reported a rejected sign-in
@@ -214,18 +224,6 @@ OpenRouter, DeepSeek, OpenAI or Anthropic account.
214
224
  - Fixed: A per-model folder is now named in your ioBroker language as well, instead of carrying the provider's bare model identifier as its only name
215
225
  - New: The datapoints whose meaning is not obvious from their name now carry a short explanation in eleven languages, shown in the object tree
216
226
 
217
- ### 0.11.0 (2026-09-05)
218
-
219
- - Fixed: Signing in from the instance settings works again — a leftover setting from an earlier version had silently closed the adapter's message channel, so none of the three flows reached it
220
- - Fixed: A subscription whose stored sign-in was rejected no longer claims to be signed in — the row now offers the sign-in again instead of showing a green check next to an error
221
- - Fixed: The status badge of an account no longer blanks out for a moment when a single status read is missed — a hiccup in the settings page is not an account without a status
222
- - Fixed: A stored credential whose name sorts high in the alphabet is no longer missing from the account list in the instance settings
223
- - Fixed: The settings page falls back to English for a browser language the adapter does not ship, instead of passing that language on unchecked
224
- - Improved: All object names are now available in eleven languages instead of English only, and a renamed object reaches installations that already exist
225
- - Improved: ChatGPT usage is read with the identity that endpoint expects, the way the Claude query already did — fewer rejected requests on that account
226
- - Improved: Monthly cost reports can no longer be cut short in silence — a report that does not fit is reported in the log instead of producing a figure that is too low
227
- - Changed: "Highest account utilisation" says what it always measured — the fullest limit window **or** the account's remaining budget
228
-
229
227
  [Older changelogs can be found there](CHANGELOG_OLD.md)
230
228
 
231
229
  ## Support
@@ -0,0 +1,48 @@
1
+ "use strict";
2
+ var __defProp = Object.defineProperty;
3
+ var __getOwnPropDesc = Object.getOwnPropertyDescriptor;
4
+ var __getOwnPropNames = Object.getOwnPropertyNames;
5
+ var __hasOwnProp = Object.prototype.hasOwnProperty;
6
+ var __export = (target, all) => {
7
+ for (var name in all)
8
+ __defProp(target, name, { get: all[name], enumerable: true });
9
+ };
10
+ var __copyProps = (to, from, except, desc) => {
11
+ if (from && typeof from === "object" || typeof from === "function") {
12
+ for (let key of __getOwnPropNames(from))
13
+ if (!__hasOwnProp.call(to, key) && key !== except)
14
+ __defProp(to, key, { get: () => from[key], enumerable: !(desc = __getOwnPropDesc(from, key)) || desc.enumerable });
15
+ }
16
+ return to;
17
+ };
18
+ var __toCommonJS = (mod) => __copyProps(__defProp({}, "__esModule", { value: true }), mod);
19
+ var error_text_exports = {};
20
+ __export(error_text_exports, {
21
+ errorText: () => errorText
22
+ });
23
+ module.exports = __toCommonJS(error_text_exports);
24
+ const MAX_OBJECT_CHARS = 200;
25
+ function errorText(err) {
26
+ if (err instanceof Error) {
27
+ return err.message || err.name || "Error";
28
+ }
29
+ if (typeof err === "string") {
30
+ return err;
31
+ }
32
+ if (err === null || err === void 0 || typeof err !== "object") {
33
+ return String(err);
34
+ }
35
+ let json;
36
+ try {
37
+ json = JSON.stringify(err);
38
+ } catch {
39
+ json = void 0;
40
+ }
41
+ const text = json != null ? json : Object.prototype.toString.call(err);
42
+ return text.length > MAX_OBJECT_CHARS ? `${text.slice(0, MAX_OBJECT_CHARS)}\u2026` : text;
43
+ }
44
+ // Annotate the CommonJS export names for ESM import in node:
45
+ 0 && (module.exports = {
46
+ errorText
47
+ });
48
+ //# sourceMappingURL=error-text.js.map
@@ -0,0 +1,7 @@
1
+ {
2
+ "version": 3,
3
+ "sources": ["../../src/lib/error-text.ts"],
4
+ "sourcesContent": ["/**\n * One readable line for anything a `catch` can hand over.\n *\n * Deliberately without imports: every layer uses it, including `http.ts`, which\n * sits below the helper modules.\n */\n\n/** Longest text a stringified object may contribute before it is cut. */\nconst MAX_OBJECT_CHARS = 200;\n\n/**\n * Turn any thrown value into text a user can read.\n *\n * JavaScript lets code throw anything, and the inline\n * `e instanceof Error ? e.message : String(e)` this replaces rendered every plain\n * object as `[object Object]` \u2014 a rejected `{ code: \"ECONNRESET\" }` or an HTTP\n * client's error object reached the log and Sentry with nothing in it.\n *\n * The object branch is the point of the helper; `String()` already covers strings,\n * numbers and symbols. `JSON.stringify` is guarded twice because it is hostile in\n * exactly the situation this runs in: it THROWS on a circular structure (an error\n * carrying the response it came from) and RETURNS `undefined` for a value it\n * cannot represent at all.\n *\n * @param err the caught value\n * @returns a single-line description, never empty\n */\nexport function errorText(err: unknown): string {\n if (err instanceof Error) {\n // `message` can be empty on a hand-built error \u2014 the name still says something.\n return err.message || err.name || \"Error\";\n }\n if (typeof err === \"string\") {\n return err;\n }\n if (err === null || err === undefined || typeof err !== \"object\") {\n // Numbers, booleans, symbols, bigint: `String()` is the readable form.\n return String(err);\n }\n let json: string | undefined;\n try {\n json = JSON.stringify(err);\n } catch {\n // Circular, or a getter that throws \u2014 fall through to the tag below.\n json = undefined;\n }\n const text = json ?? Object.prototype.toString.call(err);\n return text.length > MAX_OBJECT_CHARS ? `${text.slice(0, MAX_OBJECT_CHARS)}\u2026` : text;\n}\n"],
5
+ "mappings": ";;;;;;;;;;;;;;;;;;AAAA;AAAA;AAAA;AAAA;AAAA;AAQA,MAAM,mBAAmB;AAmBlB,SAAS,UAAU,KAAsB;AAC9C,MAAI,eAAe,OAAO;AAExB,WAAO,IAAI,WAAW,IAAI,QAAQ;AAAA,EACpC;AACA,MAAI,OAAO,QAAQ,UAAU;AAC3B,WAAO;AAAA,EACT;AACA,MAAI,QAAQ,QAAQ,QAAQ,UAAa,OAAO,QAAQ,UAAU;AAEhE,WAAO,OAAO,GAAG;AAAA,EACnB;AACA,MAAI;AACJ,MAAI;AACF,WAAO,KAAK,UAAU,GAAG;AAAA,EAC3B,QAAQ;AAEN,WAAO;AAAA,EACT;AACA,QAAM,OAAO,sBAAQ,OAAO,UAAU,SAAS,KAAK,GAAG;AACvD,SAAO,KAAK,SAAS,mBAAmB,GAAG,KAAK,MAAM,GAAG,gBAAgB,CAAC,WAAM;AAClF;",
6
+ "names": []
7
+ }
package/build/lib/http.js CHANGED
@@ -23,14 +23,45 @@ __export(http_exports, {
23
23
  postJson: () => postJson
24
24
  });
25
25
  module.exports = __toCommonJS(http_exports);
26
+ var import_error_text = require("./error-text");
26
27
  var import_provider = require("./provider");
27
28
  const REQUEST_TIMEOUT_MS = 15e3;
29
+ const MAX_BODY_BYTES = 8 * 1024 * 1024;
30
+ async function readCappedText(response) {
31
+ if (!response.body) {
32
+ return "";
33
+ }
34
+ const reader = response.body.getReader();
35
+ const decoder = new TextDecoder();
36
+ let text = "";
37
+ let bytes = 0;
38
+ try {
39
+ for (; ; ) {
40
+ const { done, value } = await reader.read();
41
+ if (done) {
42
+ break;
43
+ }
44
+ if (!value) {
45
+ continue;
46
+ }
47
+ bytes += value.byteLength;
48
+ if (bytes > MAX_BODY_BYTES) {
49
+ await reader.cancel();
50
+ throw new import_provider.FetchError("service", `response body exceeds ${MAX_BODY_BYTES} bytes`);
51
+ }
52
+ text += decoder.decode(value, { stream: true });
53
+ }
54
+ } finally {
55
+ reader.releaseLock();
56
+ }
57
+ return text + decoder.decode();
58
+ }
28
59
  async function request(url, init, authOn400 = false) {
29
60
  let response;
30
61
  try {
31
62
  response = await fetch(url, { ...init, signal: AbortSignal.timeout(REQUEST_TIMEOUT_MS) });
32
63
  } catch (e) {
33
- throw new import_provider.FetchError("network", e instanceof Error ? e.message : String(e));
64
+ throw new import_provider.FetchError("network", (0, import_error_text.errorText)(e));
34
65
  }
35
66
  if (!response.ok) {
36
67
  const detail = await errorDetail(response);
@@ -42,10 +73,16 @@ async function request(url, init, authOn400 = false) {
42
73
  }
43
74
  throw new import_provider.FetchError("service", `HTTP ${response.status}${detail}`);
44
75
  }
76
+ let text;
77
+ try {
78
+ text = await readCappedText(response);
79
+ } catch (e) {
80
+ throw e instanceof import_provider.FetchError ? e : new import_provider.FetchError("service", `unreadable body: ${(0, import_error_text.errorText)(e)}`);
81
+ }
45
82
  try {
46
- return await response.json();
83
+ return JSON.parse(text);
47
84
  } catch (e) {
48
- throw new import_provider.FetchError("service", `invalid JSON: ${e instanceof Error ? e.message : String(e)}`);
85
+ throw new import_provider.FetchError("service", `invalid JSON: ${(0, import_error_text.errorText)(e)}`);
49
86
  }
50
87
  }
51
88
  const MAX_DETAIL_CHARS = 200;
@@ -53,7 +90,7 @@ async function errorDetail(response) {
53
90
  var _a, _b;
54
91
  let text;
55
92
  try {
56
- text = await response.text();
93
+ text = await readCappedText(response);
57
94
  } catch {
58
95
  return "";
59
96
  }
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "version": 3,
3
3
  "sources": ["../../src/lib/http.ts"],
4
- "sourcesContent": ["import { FetchError } from \"./provider\";\n\n/** Per-request timeout (ms). */\nconst REQUEST_TIMEOUT_MS = 15000;\n\n/** The JSON-GET seam the provider modules use \u2014 injectable for tests. */\nexport type JsonFetch = (url: string, headers: Record<string, string>) => Promise<unknown>;\n\n/**\n * Per-call options of the two POST seams.\n *\n * `authOn400` used to be baked into `postJson`/`postForm`, which made EVERY post\n * read a 400 as a rejected sign-in. That is right for the OAuth token endpoints\n * and wrong everywhere else: the ChatGPT device-code poll takes an auth failure\n * as \"the user has not confirmed yet\" and would have waited out the whole\n * fifteen minutes on a 400, and Google's Code-Assist call skipped its second\n * host because it thought the sign-in was gone. The flag belongs at the call.\n */\nexport interface PostOptions {\n /** Extra request headers. */\n headers?: Record<string, string>;\n /** True only for OAuth token endpoints, which answer a dead code or refresh token with 400. */\n authOn400?: boolean;\n}\n\n/** The JSON-POST seam \u2014 one definition for every provider module. */\nexport type JsonPost = (url: string, body: Record<string, unknown>, options?: PostOptions) => Promise<unknown>;\n\n/** The form-POST seam (OAuth code redemption). */\nexport type FormPost = (url: string, form: Record<string, string>, options?: PostOptions) => Promise<unknown>;\n\n/**\n * Run one request and turn its outcome into the shared failure classification:\n * 401/403 (and 400 where the provider answers a rejected grant that way) become an\n * auth error, 429 a rate-limit error, any other bad status or unparsable body a\n * SERVICE error (the service answered and is broken), and only a throw \u2014 refused\n * connection, DNS failure, timeout \u2014 a network error. The poll engine turns that\n * split into \"the AI service is down\" versus \"this host has no connection\".\n *\n * @param url the request URL\n * @param init the request options (method, headers, body)\n * @param authOn400 true when a 400 means a rejected grant rather than a service fault\n * @returns the parsed JSON body\n */\nasync function request(url: string, init: RequestInit, authOn400 = false): Promise<unknown> {\n let response: Response;\n try {\n response = await fetch(url, { ...init, signal: AbortSignal.timeout(REQUEST_TIMEOUT_MS) });\n } catch (e) {\n throw new FetchError(\"network\", e instanceof Error ? e.message : String(e));\n }\n if (!response.ok) {\n // The status alone reaches the user as `info.error`, and \"HTTP 401\" tells them\n // nothing they can act on. OpenRouter, OpenAI and Anthropic all answer with\n // `{ error: { message } }` \u2014 reading it turns the datapoint into \"invalid API\n // key\". Best effort in the strictest sense: the status decides the class, the\n // body only decorates the text, and a body that cannot be read changes nothing.\n const detail = await errorDetail(response);\n if (response.status === 401 || response.status === 403 || (authOn400 && response.status === 400)) {\n throw new FetchError(\"auth\", `HTTP ${response.status}${detail}`);\n }\n if (response.status === 429) {\n throw new FetchError(\"rate-limit\", `HTTP 429${detail}`);\n }\n // 5xx = the service answered and is broken; anything else unexpected is treated\n // the same way, because the service DID answer \u2014 only a throw above means we\n // never reached it.\n throw new FetchError(\"service\", `HTTP ${response.status}${detail}`);\n }\n try {\n return await response.json();\n } catch (e) {\n throw new FetchError(\"service\", `invalid JSON: ${e instanceof Error ? e.message : String(e)}`);\n }\n}\n\n/** Longest provider message taken over into the error text. */\nconst MAX_DETAIL_CHARS = 200;\n\n/**\n * The provider's own words for a failed request, ready to append.\n *\n * Never throws and never rejects: a body that is missing, unreadable, not JSON or\n * shaped differently simply yields \"\" and the caller keeps the bare status. The\n * body of a failed response is consumed here either way, so nothing is left\n * dangling (measured 2026-09-12: the \"socket leak\" this used to be blamed on does\n * not exist).\n *\n * @param response the failed response\n * @returns \" \u2014 <message>\" or an empty string\n */\nasync function errorDetail(response: Response): Promise<string> {\n let text: string;\n try {\n text = await response.text();\n } catch {\n return \"\";\n }\n let message: unknown;\n try {\n const body = JSON.parse(text) as { error?: unknown; message?: unknown };\n const error = body.error;\n message =\n typeof error === \"string\" ? error : ((error as { message?: unknown })?.message ?? body.message ?? undefined);\n } catch {\n // Not JSON: a short plain-text body is still better than nothing, a long one\n // (an HTML error page from a proxy) is noise.\n message = text.trim().length > 0 && text.trim().length <= MAX_DETAIL_CHARS ? text.trim() : undefined;\n }\n if (typeof message !== \"string\" || message.trim().length === 0) {\n return \"\";\n }\n const trimmed = message.trim();\n return ` \u2014 ${trimmed.length > MAX_DETAIL_CHARS ? `${trimmed.slice(0, MAX_DETAIL_CHARS)}\u2026` : trimmed}`;\n}\n\n/**\n * GET a JSON document.\n *\n * @param url the request URL\n * @param headers request headers (Authorization etc.)\n * @returns the parsed JSON body\n */\nexport async function getJson(url: string, headers: Record<string, string>): Promise<unknown> {\n return request(url, { headers });\n}\n\n/**\n * POST a JSON document.\n *\n * @param url the request URL\n * @param body the JSON body\n * @param options extra headers, and whether a 400 means a rejected grant\n * @returns the parsed JSON response\n */\nexport async function postJson(\n url: string,\n body: Record<string, unknown>,\n options: PostOptions = {},\n): Promise<unknown> {\n return request(\n url,\n {\n method: \"POST\",\n headers: { \"Content-Type\": \"application/json\", ...options.headers },\n body: JSON.stringify(body),\n },\n options.authOn400 === true,\n );\n}\n\n/**\n * POST a form-encoded body and return the parsed JSON answer.\n *\n * OAuth code redemption uses form encoding while token refresh often uses JSON \u2014\n * ChatGPT/Codex needs BOTH, so the two shapes are separate helpers rather than one\n * guessing wrapper.\n *\n * @param url the request URL\n * @param form the form fields\n * @param options extra headers, and whether a 400 means a rejected grant\n * @returns the parsed JSON body\n */\nexport async function postForm(url: string, form: Record<string, string>, options: PostOptions = {}): Promise<unknown> {\n return request(\n url,\n {\n method: \"POST\",\n headers: { \"Content-Type\": \"application/x-www-form-urlencoded\", ...options.headers },\n body: new URLSearchParams(form).toString(),\n },\n options.authOn400 === true,\n );\n}\n"],
5
- "mappings": ";;;;;;;;;;;;;;;;;;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA,sBAA2B;AAG3B,MAAM,qBAAqB;AAyC3B,eAAe,QAAQ,KAAa,MAAmB,YAAY,OAAyB;AAC1F,MAAI;AACJ,MAAI;AACF,eAAW,MAAM,MAAM,KAAK,EAAE,GAAG,MAAM,QAAQ,YAAY,QAAQ,kBAAkB,EAAE,CAAC;AAAA,EAC1F,SAAS,GAAG;AACV,UAAM,IAAI,2BAAW,WAAW,aAAa,QAAQ,EAAE,UAAU,OAAO,CAAC,CAAC;AAAA,EAC5E;AACA,MAAI,CAAC,SAAS,IAAI;AAMhB,UAAM,SAAS,MAAM,YAAY,QAAQ;AACzC,QAAI,SAAS,WAAW,OAAO,SAAS,WAAW,OAAQ,aAAa,SAAS,WAAW,KAAM;AAChG,YAAM,IAAI,2BAAW,QAAQ,QAAQ,SAAS,MAAM,GAAG,MAAM,EAAE;AAAA,IACjE;AACA,QAAI,SAAS,WAAW,KAAK;AAC3B,YAAM,IAAI,2BAAW,cAAc,WAAW,MAAM,EAAE;AAAA,IACxD;AAIA,UAAM,IAAI,2BAAW,WAAW,QAAQ,SAAS,MAAM,GAAG,MAAM,EAAE;AAAA,EACpE;AACA,MAAI;AACF,WAAO,MAAM,SAAS,KAAK;AAAA,EAC7B,SAAS,GAAG;AACV,UAAM,IAAI,2BAAW,WAAW,iBAAiB,aAAa,QAAQ,EAAE,UAAU,OAAO,CAAC,CAAC,EAAE;AAAA,EAC/F;AACF;AAGA,MAAM,mBAAmB;AAczB,eAAe,YAAY,UAAqC;AA3FhE;AA4FE,MAAI;AACJ,MAAI;AACF,WAAO,MAAM,SAAS,KAAK;AAAA,EAC7B,QAAQ;AACN,WAAO;AAAA,EACT;AACA,MAAI;AACJ,MAAI;AACF,UAAM,OAAO,KAAK,MAAM,IAAI;AAC5B,UAAM,QAAQ,KAAK;AACnB,cACE,OAAO,UAAU,WAAW,SAAU,0CAAiC,YAAjC,YAA4C,KAAK,YAAjD,YAA4D;AAAA,EACtG,QAAQ;AAGN,cAAU,KAAK,KAAK,EAAE,SAAS,KAAK,KAAK,KAAK,EAAE,UAAU,mBAAmB,KAAK,KAAK,IAAI;AAAA,EAC7F;AACA,MAAI,OAAO,YAAY,YAAY,QAAQ,KAAK,EAAE,WAAW,GAAG;AAC9D,WAAO;AAAA,EACT;AACA,QAAM,UAAU,QAAQ,KAAK;AAC7B,SAAO,WAAM,QAAQ,SAAS,mBAAmB,GAAG,QAAQ,MAAM,GAAG,gBAAgB,CAAC,WAAM,OAAO;AACrG;AASA,eAAsB,QAAQ,KAAa,SAAmD;AAC5F,SAAO,QAAQ,KAAK,EAAE,QAAQ,CAAC;AACjC;AAUA,eAAsB,SACpB,KACA,MACA,UAAuB,CAAC,GACN;AAClB,SAAO;AAAA,IACL;AAAA,IACA;AAAA,MACE,QAAQ;AAAA,MACR,SAAS,EAAE,gBAAgB,oBAAoB,GAAG,QAAQ,QAAQ;AAAA,MAClE,MAAM,KAAK,UAAU,IAAI;AAAA,IAC3B;AAAA,IACA,QAAQ,cAAc;AAAA,EACxB;AACF;AAcA,eAAsB,SAAS,KAAa,MAA8B,UAAuB,CAAC,GAAqB;AACrH,SAAO;AAAA,IACL;AAAA,IACA;AAAA,MACE,QAAQ;AAAA,MACR,SAAS,EAAE,gBAAgB,qCAAqC,GAAG,QAAQ,QAAQ;AAAA,MACnF,MAAM,IAAI,gBAAgB,IAAI,EAAE,SAAS;AAAA,IAC3C;AAAA,IACA,QAAQ,cAAc;AAAA,EACxB;AACF;",
4
+ "sourcesContent": ["import { errorText } from \"./error-text\";\nimport { FetchError } from \"./provider\";\n\n/** Per-request timeout (ms). */\nconst REQUEST_TIMEOUT_MS = 15000;\n\n/**\n * Largest response body this adapter will hold in memory (bytes).\n *\n * Generous on purpose: the biggest answer any provider sends is a month of daily\n * cost buckets grouped by model, which is orders of magnitude below this. The cap\n * is not a budget, it is a backstop.\n */\nconst MAX_BODY_BYTES = 8 * 1024 * 1024;\n\n/**\n * Read a response body as text, refusing to grow past {@link MAX_BODY_BYTES}.\n *\n * The request timeout bounds how LONG a body may take, not how BIG it may get \u2014 on\n * a fast link fifteen seconds is a lot of memory, and this runs in a process that\n * stays up for months. Counted while reading rather than from `Content-Length`: a\n * chunked answer carries no length at all, and a declared one is the server's claim,\n * not a measurement.\n *\n * @param response the response to read\n * @returns the decoded body\n * @throws {FetchError} `service` once the body passes the cap\n */\nasync function readCappedText(response: Response): Promise<string> {\n if (!response.body) {\n return \"\";\n }\n const reader = response.body.getReader();\n const decoder = new TextDecoder();\n let text = \"\";\n let bytes = 0;\n try {\n for (;;) {\n const { done, value } = await reader.read();\n if (done) {\n break;\n }\n if (!value) {\n continue;\n }\n bytes += value.byteLength;\n if (bytes > MAX_BODY_BYTES) {\n // Stop the transfer instead of draining a body we have already refused.\n await reader.cancel();\n throw new FetchError(\"service\", `response body exceeds ${MAX_BODY_BYTES} bytes`);\n }\n text += decoder.decode(value, { stream: true });\n }\n } finally {\n reader.releaseLock();\n }\n return text + decoder.decode();\n}\n\n/** The JSON-GET seam the provider modules use \u2014 injectable for tests. */\nexport type JsonFetch = (url: string, headers: Record<string, string>) => Promise<unknown>;\n\n/**\n * Per-call options of the two POST seams.\n *\n * `authOn400` used to be baked into `postJson`/`postForm`, which made EVERY post\n * read a 400 as a rejected sign-in. That is right for the OAuth token endpoints\n * and wrong everywhere else: the ChatGPT device-code poll takes an auth failure\n * as \"the user has not confirmed yet\" and would have waited out the whole\n * fifteen minutes on a 400, and Google's Code-Assist call skipped its second\n * host because it thought the sign-in was gone. The flag belongs at the call.\n */\nexport interface PostOptions {\n /** Extra request headers. */\n headers?: Record<string, string>;\n /** True only for OAuth token endpoints, which answer a dead code or refresh token with 400. */\n authOn400?: boolean;\n}\n\n/** The JSON-POST seam \u2014 one definition for every provider module. */\nexport type JsonPost = (url: string, body: Record<string, unknown>, options?: PostOptions) => Promise<unknown>;\n\n/** The form-POST seam (OAuth code redemption). */\nexport type FormPost = (url: string, form: Record<string, string>, options?: PostOptions) => Promise<unknown>;\n\n/**\n * Run one request and turn its outcome into the shared failure classification:\n * 401/403 (and 400 where the provider answers a rejected grant that way) become an\n * auth error, 429 a rate-limit error, any other bad status or unparsable body a\n * SERVICE error (the service answered and is broken), and only a throw \u2014 refused\n * connection, DNS failure, timeout \u2014 a network error. The poll engine turns that\n * split into \"the AI service is down\" versus \"this host has no connection\".\n *\n * @param url the request URL\n * @param init the request options (method, headers, body)\n * @param authOn400 true when a 400 means a rejected grant rather than a service fault\n * @returns the parsed JSON body\n */\nasync function request(url: string, init: RequestInit, authOn400 = false): Promise<unknown> {\n let response: Response;\n try {\n response = await fetch(url, { ...init, signal: AbortSignal.timeout(REQUEST_TIMEOUT_MS) });\n } catch (e) {\n throw new FetchError(\"network\", errorText(e));\n }\n if (!response.ok) {\n // The status alone reaches the user as `info.error`, and \"HTTP 401\" tells them\n // nothing they can act on. OpenRouter, OpenAI and Anthropic all answer with\n // `{ error: { message } }` \u2014 reading it turns the datapoint into \"invalid API\n // key\". Best effort in the strictest sense: the status decides the class, the\n // body only decorates the text, and a body that cannot be read changes nothing.\n const detail = await errorDetail(response);\n if (response.status === 401 || response.status === 403 || (authOn400 && response.status === 400)) {\n throw new FetchError(\"auth\", `HTTP ${response.status}${detail}`);\n }\n if (response.status === 429) {\n throw new FetchError(\"rate-limit\", `HTTP 429${detail}`);\n }\n // 5xx = the service answered and is broken; anything else unexpected is treated\n // the same way, because the service DID answer \u2014 only a throw above means we\n // never reached it.\n throw new FetchError(\"service\", `HTTP ${response.status}${detail}`);\n }\n let text: string;\n try {\n text = await readCappedText(response);\n } catch (e) {\n // The cap's own verdict passes through unchanged; anything else here is a\n // connection that died mid-body, which counts as a service fault exactly as an\n // unreadable body always did.\n throw e instanceof FetchError ? e : new FetchError(\"service\", `unreadable body: ${errorText(e)}`);\n }\n try {\n return JSON.parse(text) as unknown;\n } catch (e) {\n throw new FetchError(\"service\", `invalid JSON: ${errorText(e)}`);\n }\n}\n\n/** Longest provider message taken over into the error text. */\nconst MAX_DETAIL_CHARS = 200;\n\n/**\n * The provider's own words for a failed request, ready to append.\n *\n * Never throws and never rejects: a body that is missing, unreadable, not JSON or\n * shaped differently simply yields \"\" and the caller keeps the bare status. The\n * body of a failed response is consumed here either way, so nothing is left\n * dangling (measured 2026-09-12: the \"socket leak\" this used to be blamed on does\n * not exist).\n *\n * @param response the failed response\n * @returns \" \u2014 <message>\" or an empty string\n */\nasync function errorDetail(response: Response): Promise<string> {\n let text: string;\n try {\n text = await readCappedText(response);\n } catch {\n // Unreadable, or an error page past the cap \u2014 the caller keeps the bare status.\n return \"\";\n }\n let message: unknown;\n try {\n const body = JSON.parse(text) as { error?: unknown; message?: unknown };\n const error = body.error;\n message =\n typeof error === \"string\" ? error : ((error as { message?: unknown })?.message ?? body.message ?? undefined);\n } catch {\n // Not JSON: a short plain-text body is still better than nothing, a long one\n // (an HTML error page from a proxy) is noise.\n message = text.trim().length > 0 && text.trim().length <= MAX_DETAIL_CHARS ? text.trim() : undefined;\n }\n if (typeof message !== \"string\" || message.trim().length === 0) {\n return \"\";\n }\n const trimmed = message.trim();\n return ` \u2014 ${trimmed.length > MAX_DETAIL_CHARS ? `${trimmed.slice(0, MAX_DETAIL_CHARS)}\u2026` : trimmed}`;\n}\n\n/**\n * GET a JSON document.\n *\n * @param url the request URL\n * @param headers request headers (Authorization etc.)\n * @returns the parsed JSON body\n */\nexport async function getJson(url: string, headers: Record<string, string>): Promise<unknown> {\n return request(url, { headers });\n}\n\n/**\n * POST a JSON document.\n *\n * @param url the request URL\n * @param body the JSON body\n * @param options extra headers, and whether a 400 means a rejected grant\n * @returns the parsed JSON response\n */\nexport async function postJson(\n url: string,\n body: Record<string, unknown>,\n options: PostOptions = {},\n): Promise<unknown> {\n return request(\n url,\n {\n method: \"POST\",\n headers: { \"Content-Type\": \"application/json\", ...options.headers },\n body: JSON.stringify(body),\n },\n options.authOn400 === true,\n );\n}\n\n/**\n * POST a form-encoded body and return the parsed JSON answer.\n *\n * OAuth code redemption uses form encoding while token refresh often uses JSON \u2014\n * ChatGPT/Codex needs BOTH, so the two shapes are separate helpers rather than one\n * guessing wrapper.\n *\n * @param url the request URL\n * @param form the form fields\n * @param options extra headers, and whether a 400 means a rejected grant\n * @returns the parsed JSON body\n */\nexport async function postForm(url: string, form: Record<string, string>, options: PostOptions = {}): Promise<unknown> {\n return request(\n url,\n {\n method: \"POST\",\n headers: { \"Content-Type\": \"application/x-www-form-urlencoded\", ...options.headers },\n body: new URLSearchParams(form).toString(),\n },\n options.authOn400 === true,\n );\n}\n"],
5
+ "mappings": "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",
6
6
  "names": []
7
7
  }
@@ -22,6 +22,7 @@ __export(poll_engine_exports, {
22
22
  isDelivering: () => isDelivering
23
23
  });
24
24
  module.exports = __toCommonJS(poll_engine_exports);
25
+ var import_error_text = require("./error-text");
25
26
  var import_i18n = require("./i18n");
26
27
  var import_sign_in = require("./sign-in");
27
28
  var import_provider = require("./provider");
@@ -344,12 +345,52 @@ class PollEngine {
344
345
  async removeVanished(runtime, delivered) {
345
346
  var _a;
346
347
  const known = (_a = runtime.deliveredIds) != null ? _a : await this.deps.listStateIds(runtime.config.id);
347
- for (const id of (0, import_snapshot_tree.orphanObjectIds)(known, delivered, runtime.staticIds)) {
348
+ const reported = this.zeroUnusedModels(known, delivered);
349
+ for (const id of (0, import_snapshot_tree.orphanObjectIds)(known, reported, runtime.staticIds)) {
348
350
  await this.deps.deleteObject(id);
349
351
  runtime.createdObjects.delete(id);
350
352
  this.deps.log.info(`${runtime.config.name}: removed "${id}" \u2014 the provider no longer reports it`);
351
353
  }
352
- runtime.deliveredIds = delivered;
354
+ runtime.deliveredIds = reported;
355
+ }
356
+ /**
357
+ * A model the report does not mention this round is IDLE, not gone — write its 0.
358
+ *
359
+ * The usage report is a statement about a PERIOD, not an inventory: a model is
360
+ * missing from it because nothing ran on it, not because the provider dropped it.
361
+ * The sweep cannot tell those apart, so it used to delete on the weaker reading.
362
+ *
363
+ * Decision 49 closed the daily half of this — the OpenAI model list is built from
364
+ * the whole month, so UTC midnight no longer empties it. The MONTH boundary was
365
+ * the half left open: the report starts over on the 1st, and the moment the first
366
+ * model of the new month reports usage, every other model's channel was swept —
367
+ * history, enum membership and all — and re-created on its next use.
368
+ *
369
+ * Writing the 0 fixes both halves at once. The channel stays (it is in the
370
+ * delivered set, so {@link orphanObjectIds} leaves it alone) and it stops lying:
371
+ * without this, an unreported model would simply freeze on its last count under a
372
+ * name that says "today" — the exact lie decision 49 removed from the block as a
373
+ * whole. `limits.*` keeps the old reading, and rightly so: there the provider
374
+ * reports the PLAN, so a window that stops appearing really is gone.
375
+ *
376
+ * @param known every state id the account had before
377
+ * @param delivered the state ids this snapshot wrote
378
+ * @returns `delivered` plus the idle model states, which now carry a written 0
379
+ */
380
+ zeroUnusedModels(known, delivered) {
381
+ const delivering = new Set(delivered);
382
+ const idle = [];
383
+ for (const id of known) {
384
+ if (delivering.has(id)) {
385
+ continue;
386
+ }
387
+ const parts = id.split(".");
388
+ if (parts.length === 4 && parts[1] === "models" && parts[3] === "tokensToday") {
389
+ this.deps.setState(id, 0);
390
+ idle.push(id);
391
+ }
392
+ }
393
+ return idle.length > 0 ? [...delivered, ...idle] : delivered;
353
394
  }
354
395
  /**
355
396
  * The numbers arrived, the object database did not take them.
@@ -366,7 +407,7 @@ class PollEngine {
366
407
  * @param error the thrown error
367
408
  */
368
409
  handleStorageFailure(runtime, error) {
369
- const message = error instanceof Error ? error.message : String(error);
410
+ const message = (0, import_error_text.errorText)(error);
370
411
  runtime.state = "storage-error";
371
412
  runtime.error = `Values fetched but not stored \u2014 the object database rejected the write (${message})`;
372
413
  if (runtime.storageFailed) {
@@ -391,7 +432,7 @@ class PollEngine {
391
432
  * @param error the thrown error
392
433
  */
393
434
  handleSweepFailure(runtime, error) {
394
- const message = error instanceof Error ? error.message : String(error);
435
+ const message = (0, import_error_text.errorText)(error);
395
436
  if (runtime.sweepFailed) {
396
437
  this.deps.log.debug(`${runtime.config.name}: the cleanup of vanished entries still fails (${message})`);
397
438
  return;
@@ -417,7 +458,7 @@ class PollEngine {
417
458
  handleFailure(runtime, error) {
418
459
  var _a, _b, _c, _d;
419
460
  const { config } = runtime;
420
- const message = error instanceof Error ? error.message : String(error);
461
+ const message = (0, import_error_text.errorText)(error);
421
462
  if (error instanceof import_provider.FetchError && error.kind === "no-credentials") {
422
463
  runtime.failCount = 0;
423
464
  runtime.serviceOnline = true;
@@ -664,9 +705,7 @@ class PollEngine {
664
705
  try {
665
706
  runtime.status.warning = await this.deps.readState(`${config.id}.warning`) === true;
666
707
  } catch (e) {
667
- this.deps.log.debug(
668
- `${config.name}: could not read the previous warning state (${e instanceof Error ? e.message : String(e)})`
669
- );
708
+ this.deps.log.debug(`${config.name}: could not read the previous warning state (${(0, import_error_text.errorText)(e)})`);
670
709
  }
671
710
  }
672
711
  /** The totals skeleton (channel + states). */