iobroker.ai-usage 0.13.0 → 0.14.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
@@ -84,7 +84,7 @@ ai-usage.0.
84
84
  │ ├── limitReached — this account is at 100 % (bool)
85
85
  │ ├── info.unreach — account is not delivering (bool) — drives the connection icon
86
86
  │ ├── info.error — why there is no data; empty while all is well, "Unknown" while the adapter itself has nothing to report
87
- │ ├── info.lastUpdate — time of the last successful read
87
+ │ ├── info.lastUpdate — when the current values were fetched
88
88
  │ ├── limits.<window>.* — percent, reset time and whether this window is the limit in force
89
89
  │ └── credits.* — where the provider reports a balance
90
90
  └── <name>-api — one node per key-based account
@@ -100,9 +100,14 @@ the reset time simply empties while no window is running, and a window or model
100
100
  tree only when the provider stops reporting it entirely.
101
101
 
102
102
  **The connection icon** sits next to each account, green while it delivers. A throttle keeps it
103
- green — the last values stay valid while the adapter waits. A rejected sign-in or a broken service
104
- switch it off at once, an unreachable service after three attempts, so a hiccup does not make it
105
- flap. `info.error` names the cause whenever the provider gave one.
103
+ green — the last values stay valid while the adapter waits. A rejected sign-in, a broken service or
104
+ an answer that cannot be processed switch it off at once, an unreachable service after three
105
+ attempts, so a hiccup does not make it flap. `info.error` names the cause whenever the provider gave
106
+ one, in the provider's own words where it sent any.
107
+
108
+ **Signing out clears that account's alarms.** Its measured values stay in the tree, but `warning`,
109
+ `limitReached` and every total that counts them drop it — an account nobody watches must not keep an
110
+ automation waiting.
106
111
 
107
112
  **Which window is in force** is shown per window: with Claude the provider says so itself, elsewhere it
108
113
  is the window that speaks for the account. It tells you what you will run into next — a model window
@@ -148,9 +153,26 @@ so instead of pretending to be connected; signing in again is all it takes.
148
153
 
149
154
  <!--
150
155
  Placeholder for the next version (at the beginning of the line):
151
- ### **WORK IN PROGRESS**
152
156
  -->
153
157
 
158
+ ### 0.14.0 (2026-09-15)
159
+
160
+ - 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
161
+ - Fixed: Signing out no longer comes undone by itself — a sign-out that landed during a background token renewal could leave the account signed in
162
+ - Fixed: The last-update stamp moved forward on a tolerated connection failure, dating values the round had never fetched
163
+ - Fixed: An answer the adapter cannot process is reported as a service fault at once, instead of claiming for three rounds that the service is unreachable
164
+ - Fixed: A failed cleanup of vanished windows or models no longer discards the round — the values were in the tree, but the account called them unstored and the totals froze
165
+ - Fixed: A connection failure counter that a throttle or a rejected sign-in had interrupted no longer adds up to "not reachable"
166
+ - Fixed: An account you signed out of drops its warning and limit alarms and leaves the adapter-wide totals — its measured values stay in the tree
167
+ - Fixed: The reset time of the Claude session and week windows is filled from the plan-wide block when the window entry itself carries none
168
+ - Improved: Window reset times, the next voucher expiry and the credit ceiling are written only when they change — announced facts, not measurements, so their timestamp stops moving every poll
169
+ - Improved: Where a provider sends a reason of its own, `info.error` now says it ("invalid API key") instead of a bare status number
170
+ - Improved: An access token the provider invalidated early is refreshed once and the request repeated, instead of reporting a rejected sign-in until it would have expired
171
+
172
+ Only the Claude subscription runs against a real account here. The token-file fix, the early-refresh
173
+ retry and the sign-out behaviour are covered by tests but were not seen on a real ChatGPT, Google,
174
+ OpenRouter, DeepSeek, OpenAI or Anthropic account.
175
+
154
176
  ### 0.13.0 (2026-09-12)
155
177
 
156
178
  - Fixed: The costs of an Anthropic organisation account were a hundred times too high — the provider counts them in cents, the adapter read them as dollars
@@ -204,15 +226,6 @@ so instead of pretending to be connected; signing in again is all it takes.
204
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
205
227
  - Changed: "Highest account utilisation" says what it always measured — the fullest limit window **or** the account's remaining budget
206
228
 
207
- ### 0.10.0 (2026-09-01)
208
-
209
- - Fixed: The reset-time datapoint of a limit window no longer disappears and reappears — it stays and simply empties while no window is running
210
- - Fixed: The settings page no longer shows the sign-in screen to a signed-in account, and its rows load without waiting for the credential storage scan
211
- - Improved: Claude usage is read with far fewer rejections — the query now identifies itself the way the endpoint expects
212
- - Changed: New Claude sign-ins request only the profile permission — the stored access can no longer create API keys or run models
213
- - New: ChatGPT accounts show their purchasable limit-reset credits — how many are available and when the next one expires
214
- - Improved: An unreadable provider answer is now reported as a service fault instead of a missing connection
215
-
216
229
  [Older changelogs can be found there](CHANGELOG_OLD.md)
217
230
 
218
231
  ## Support
@@ -51,7 +51,6 @@
51
51
  "nameModels": "Verbrauch je Modell",
52
52
  "nameModel": "Modell %s",
53
53
  "nameModelTokens": "%s Token heute",
54
- "nameModelCosts": "%s Kosten heute",
55
54
  "nameAvailable": "Guthaben reicht für Anfragen",
56
55
  "nameWindowSession": "Sitzung (5 Std.)",
57
56
  "nameWindowWeek": "Woche (alle Modelle)",
@@ -51,7 +51,6 @@
51
51
  "nameModels": "Per-model usage",
52
52
  "nameModel": "Model %s",
53
53
  "nameModelTokens": "%s tokens today",
54
- "nameModelCosts": "%s costs today",
55
54
  "nameAvailable": "Balance sufficient for calls",
56
55
  "nameWindowSession": "Session (5 h)",
57
56
  "nameWindowWeek": "Week (all models)",
@@ -51,7 +51,6 @@
51
51
  "nameModels": "Uso por modelo",
52
52
  "nameModel": "Modelo %s",
53
53
  "nameModelTokens": "%s tokens de hoy",
54
- "nameModelCosts": "%s costes de hoy",
55
54
  "nameAvailable": "Saldo suficiente para las llamadas",
56
55
  "nameWindowSession": "Sesión (5 h)",
57
56
  "nameWindowWeek": "Semana (todos los modelos)",
@@ -51,7 +51,6 @@
51
51
  "nameModels": "Utilisation par modèle",
52
52
  "nameModel": "Modèle %s",
53
53
  "nameModelTokens": "%s jetons aujourd'hui",
54
- "nameModelCosts": "%s coûts aujourd'hui",
55
54
  "nameAvailable": "Solde suffisant pour les appels",
56
55
  "nameWindowSession": "Session (5 h)",
57
56
  "nameWindowWeek": "Semaine (tous les modèles)",
@@ -51,7 +51,6 @@
51
51
  "nameModels": "Utilizzo per modello",
52
52
  "nameModel": "Modello %s",
53
53
  "nameModelTokens": "%s token di oggi",
54
- "nameModelCosts": "%s costi di oggi",
55
54
  "nameAvailable": "Saldo sufficiente per le chiamate",
56
55
  "nameWindowSession": "Sessione (5 h)",
57
56
  "nameWindowWeek": "Settimana (tutti i modelli)",
@@ -51,7 +51,6 @@
51
51
  "nameModels": "Verbruik per model",
52
52
  "nameModel": "Model %s",
53
53
  "nameModelTokens": "%s tokens vandaag",
54
- "nameModelCosts": "%s kosten vandaag",
55
54
  "nameAvailable": "Tegoed voldoende voor aanvragen",
56
55
  "nameWindowSession": "Sessie (5 u)",
57
56
  "nameWindowWeek": "Week (alle modellen)",
@@ -51,7 +51,6 @@
51
51
  "nameModels": "Zużycie na model",
52
52
  "nameModel": "Model %s",
53
53
  "nameModelTokens": "%s tokenów dzisiaj",
54
- "nameModelCosts": "%s koszty dzisiaj",
55
54
  "nameAvailable": "Środki wystarczają na zapytania",
56
55
  "nameWindowSession": "Sesja (5 godz.)",
57
56
  "nameWindowWeek": "Tydzień (wszystkie modele)",
@@ -51,7 +51,6 @@
51
51
  "nameModels": "Utilização por modelo",
52
52
  "nameModel": "Modelo %s",
53
53
  "nameModelTokens": "%s tokens hoje",
54
- "nameModelCosts": "%s custos hoje",
55
54
  "nameAvailable": "Saldo suficiente para chamadas",
56
55
  "nameWindowSession": "Sessão (5 h)",
57
56
  "nameWindowWeek": "Semana (todos os modelos)",
@@ -51,7 +51,6 @@
51
51
  "nameModels": "Использование по моделям",
52
52
  "nameModel": "Модель %s",
53
53
  "nameModelTokens": "%s токенов сегодня",
54
- "nameModelCosts": "%s расходы сегодня",
55
54
  "nameAvailable": "Баланса хватает для запросов",
56
55
  "nameWindowSession": "Сессия (5 ч)",
57
56
  "nameWindowWeek": "Неделя (все модели)",
@@ -51,7 +51,6 @@
51
51
  "nameModels": "Використання за моделями",
52
52
  "nameModel": "Модель %s",
53
53
  "nameModelTokens": "%s токенів сьогодні",
54
- "nameModelCosts": "%s витрати сьогодні",
55
54
  "nameAvailable": "Балансу достатньо для запитів",
56
55
  "nameWindowSession": "Сесія (5 год)",
57
56
  "nameWindowWeek": "Тиждень (усі моделі)",
@@ -51,7 +51,6 @@
51
51
  "nameModels": "各模型用量",
52
52
  "nameModel": "模型 %s",
53
53
  "nameModelTokens": "%s 今日 Token",
54
- "nameModelCosts": "%s 今日费用",
55
54
  "nameAvailable": "余额足以发起调用",
56
55
  "nameWindowSession": "会话(5 小时)",
57
56
  "nameWindowWeek": "一周(所有模型)",
package/build/lib/http.js CHANGED
@@ -32,14 +32,15 @@ async function request(url, init, authOn400 = false) {
32
32
  } catch (e) {
33
33
  throw new import_provider.FetchError("network", e instanceof Error ? e.message : String(e));
34
34
  }
35
- if (response.status === 401 || response.status === 403 || authOn400 && response.status === 400) {
36
- throw new import_provider.FetchError("auth", `HTTP ${response.status}`);
37
- }
38
- if (response.status === 429) {
39
- throw new import_provider.FetchError("rate-limit", "HTTP 429");
40
- }
41
35
  if (!response.ok) {
42
- throw new import_provider.FetchError("service", `HTTP ${response.status}`);
36
+ const detail = await errorDetail(response);
37
+ if (response.status === 401 || response.status === 403 || authOn400 && response.status === 400) {
38
+ throw new import_provider.FetchError("auth", `HTTP ${response.status}${detail}`);
39
+ }
40
+ if (response.status === 429) {
41
+ throw new import_provider.FetchError("rate-limit", `HTTP 429${detail}`);
42
+ }
43
+ throw new import_provider.FetchError("service", `HTTP ${response.status}${detail}`);
43
44
  }
44
45
  try {
45
46
  return await response.json();
@@ -47,6 +48,29 @@ async function request(url, init, authOn400 = false) {
47
48
  throw new import_provider.FetchError("service", `invalid JSON: ${e instanceof Error ? e.message : String(e)}`);
48
49
  }
49
50
  }
51
+ const MAX_DETAIL_CHARS = 200;
52
+ async function errorDetail(response) {
53
+ var _a, _b;
54
+ let text;
55
+ try {
56
+ text = await response.text();
57
+ } catch {
58
+ return "";
59
+ }
60
+ let message;
61
+ try {
62
+ const body = JSON.parse(text);
63
+ const error = body.error;
64
+ message = typeof error === "string" ? error : (_b = (_a = error == null ? void 0 : error.message) != null ? _a : body.message) != null ? _b : void 0;
65
+ } catch {
66
+ message = text.trim().length > 0 && text.trim().length <= MAX_DETAIL_CHARS ? text.trim() : void 0;
67
+ }
68
+ if (typeof message !== "string" || message.trim().length === 0) {
69
+ return "";
70
+ }
71
+ const trimmed = message.trim();
72
+ return ` \u2014 ${trimmed.length > MAX_DETAIL_CHARS ? `${trimmed.slice(0, MAX_DETAIL_CHARS)}\u2026` : trimmed}`;
73
+ }
50
74
  async function getJson(url, headers) {
51
75
  return request(url, { headers });
52
76
  }
@@ -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.status === 401 || response.status === 403 || (authOn400 && response.status === 400)) {\n throw new FetchError(\"auth\", `HTTP ${response.status}`);\n }\n if (response.status === 429) {\n throw new FetchError(\"rate-limit\", \"HTTP 429\");\n }\n if (!response.ok) {\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}`);\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/**\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,SAAS,WAAW,OAAO,SAAS,WAAW,OAAQ,aAAa,SAAS,WAAW,KAAM;AAChG,UAAM,IAAI,2BAAW,QAAQ,QAAQ,SAAS,MAAM,EAAE;AAAA,EACxD;AACA,MAAI,SAAS,WAAW,KAAK;AAC3B,UAAM,IAAI,2BAAW,cAAc,UAAU;AAAA,EAC/C;AACA,MAAI,CAAC,SAAS,IAAI;AAIhB,UAAM,IAAI,2BAAW,WAAW,QAAQ,SAAS,MAAM,EAAE;AAAA,EAC3D;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;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 { 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;",
6
6
  "names": []
7
7
  }
@@ -63,6 +63,7 @@ class PollEngine {
63
63
  authNotified: false,
64
64
  authRejected: false,
65
65
  storageFailed: false,
66
+ sweepFailed: false,
66
67
  serviceOnline: false,
67
68
  state: provider ? "no-connection" : "not-signed-in",
68
69
  // Nothing known until the service says something; the skeleton writes
@@ -216,6 +217,7 @@ class PollEngine {
216
217
  return;
217
218
  }
218
219
  const wasFailing = runtime.state !== "ok";
220
+ let delivered = false;
219
221
  let fetched;
220
222
  try {
221
223
  fetched = await runtime.provider.fetch();
@@ -246,7 +248,8 @@ class PollEngine {
246
248
  }
247
249
  runtime.status.snapshot = fetched;
248
250
  runtime.storageFailed = false;
249
- if (wasFailing) {
251
+ delivered = true;
252
+ if (wasFailing && runtime.firstPollDone) {
250
253
  this.deps.log.info(`${config.name}: delivering again`);
251
254
  }
252
255
  } catch (e) {
@@ -257,7 +260,7 @@ class PollEngine {
257
260
  }
258
261
  }
259
262
  runtime.status.reachable = isDelivering(runtime.state);
260
- this.writeAccountInfo(runtime);
263
+ this.writeAccountInfo(runtime, delivered);
261
264
  this.writeTotals();
262
265
  runtime.firstPollDone = true;
263
266
  this.reportFirstRoundOnce();
@@ -292,16 +295,21 @@ class PollEngine {
292
295
  return;
293
296
  }
294
297
  for (const write of writes) {
295
- if (write.indicator) {
298
+ if (write.compare) {
296
299
  void this.deps.setStateChanged(write.id, write.value);
297
300
  } else {
298
301
  this.deps.setState(write.id, write.value);
299
302
  }
300
303
  }
301
- await this.removeVanished(
302
- runtime,
303
- writes.map((write) => write.id)
304
- );
304
+ try {
305
+ await this.removeVanished(
306
+ runtime,
307
+ writes.map((write) => write.id)
308
+ );
309
+ runtime.sweepFailed = false;
310
+ } catch (e) {
311
+ this.handleSweepFailure(runtime, e);
312
+ }
305
313
  if (this.stopped) {
306
314
  return;
307
315
  }
@@ -370,6 +378,29 @@ class PollEngine {
370
378
  `${runtime.config.name}: the values were fetched but could not be stored \u2014 the object database rejected the write (${message})`
371
379
  );
372
380
  }
381
+ /**
382
+ * The values are in the tree, the cleanup did not run.
383
+ *
384
+ * Not a storage failure: nothing the provider delivered was lost, so the round
385
+ * counts as delivered and the snapshot is adopted. What did not happen is the
386
+ * removal of a window or model the provider no longer reports — it retries next
387
+ * round, because `deliveredIds` is left untouched and the first round after a
388
+ * start compares against the DATABASE by design.
389
+ *
390
+ * @param runtime the account's runtime
391
+ * @param error the thrown error
392
+ */
393
+ handleSweepFailure(runtime, error) {
394
+ const message = error instanceof Error ? error.message : String(error);
395
+ if (runtime.sweepFailed) {
396
+ this.deps.log.debug(`${runtime.config.name}: the cleanup of vanished entries still fails (${message})`);
397
+ return;
398
+ }
399
+ runtime.sweepFailed = true;
400
+ this.deps.log.warn(
401
+ `${runtime.config.name}: the values were stored, but vanished entries could not be cleaned up (${message})`
402
+ );
403
+ }
373
404
  /**
374
405
  * Classify a fetch failure.
375
406
  *
@@ -388,7 +419,15 @@ class PollEngine {
388
419
  const { config } = runtime;
389
420
  const message = error instanceof Error ? error.message : String(error);
390
421
  if (error instanceof import_provider.FetchError && error.kind === "no-credentials") {
422
+ runtime.failCount = 0;
391
423
  runtime.serviceOnline = true;
424
+ if (runtime.state !== "not-signed-in") {
425
+ runtime.status.snapshot = void 0;
426
+ runtime.status.warning = false;
427
+ runtime.locked = false;
428
+ void this.deps.setStateChanged(`${config.id}.warning`, false);
429
+ void this.deps.setStateChanged(`${config.id}.limitReached`, false);
430
+ }
392
431
  runtime.state = "not-signed-in";
393
432
  runtime.error = message;
394
433
  if (!runtime.authNotified) {
@@ -398,6 +437,7 @@ class PollEngine {
398
437
  return;
399
438
  }
400
439
  if (error instanceof import_provider.FetchError && error.kind === "auth") {
440
+ runtime.failCount = 0;
401
441
  runtime.serviceOnline = true;
402
442
  runtime.state = "unauthorized";
403
443
  runtime.error = `Sign-in rejected \u2014 ${message}`;
@@ -414,6 +454,7 @@ class PollEngine {
414
454
  return;
415
455
  }
416
456
  if (error instanceof import_provider.FetchError && error.kind === "rate-limit") {
457
+ runtime.failCount = 0;
417
458
  runtime.serviceOnline = true;
418
459
  runtime.state = "rate-limited";
419
460
  runtime.error = `Throttled by the provider \u2014 retrying in ${Math.round(runtime.backoffMs / 6e4)} min, last values kept`;
@@ -434,6 +475,16 @@ class PollEngine {
434
475
  runtime.error = `The AI service reports a fault \u2014 ${message}`;
435
476
  return;
436
477
  }
478
+ if (!(error instanceof import_provider.FetchError)) {
479
+ runtime.failCount = 0;
480
+ if (runtime.serviceOnline) {
481
+ this.deps.log.warn(`${config.name}: the answer could not be processed (${message}) \u2014 values kept`);
482
+ }
483
+ runtime.serviceOnline = false;
484
+ runtime.state = "service-down";
485
+ runtime.error = `The answer could not be processed \u2014 ${message}`;
486
+ return;
487
+ }
437
488
  runtime.failCount++;
438
489
  this.deps.log.debug(`${config.name}: fetch failed (${message}), attempt ${runtime.failCount}`);
439
490
  if (runtime.failCount >= MAX_NETWORK_FAILURES) {
@@ -455,12 +506,18 @@ class PollEngine {
455
506
  * called "last successful update" must never do (measured 2026-09-07 while
456
507
  * writing its description).
457
508
  *
509
+ * It hung on `runtime.state === "ok"` alone, and the network branch only sets the
510
+ * state on the THIRD strike — so the first two tolerated failures left the state
511
+ * on "ok" and re-dated values the round had never fetched. The caller now says
512
+ * whether this round actually brought a snapshot into the tree.
513
+ *
458
514
  * @param runtime the account's runtime
515
+ * @param delivered whether this round wrote a fetched snapshot
459
516
  */
460
- writeAccountInfo(runtime) {
517
+ writeAccountInfo(runtime, delivered) {
461
518
  const { config } = runtime;
462
519
  void this.writeAccountStatus(runtime);
463
- if (runtime.state === "ok") {
520
+ if (delivered) {
464
521
  this.deps.setState(`${config.id}.info.lastUpdate`, new Date(this.deps.now()).toISOString());
465
522
  }
466
523
  }