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 +27 -14
- package/admin/i18n/de.json +0 -1
- package/admin/i18n/en.json +0 -1
- package/admin/i18n/es.json +0 -1
- package/admin/i18n/fr.json +0 -1
- package/admin/i18n/it.json +0 -1
- package/admin/i18n/nl.json +0 -1
- package/admin/i18n/pl.json +0 -1
- package/admin/i18n/pt.json +0 -1
- package/admin/i18n/ru.json +0 -1
- package/admin/i18n/uk.json +0 -1
- package/admin/i18n/zh-cn.json +0 -1
- package/build/lib/http.js +31 -7
- package/build/lib/http.js.map +2 -2
- package/build/lib/poll-engine.js +66 -9
- package/build/lib/poll-engine.js.map +2 -2
- package/build/lib/provider.js.map +1 -1
- package/build/lib/providers/auth-retry.js +41 -0
- package/build/lib/providers/auth-retry.js.map +7 -0
- package/build/lib/providers/chatgpt-sub.js +30 -15
- package/build/lib/providers/chatgpt-sub.js.map +2 -2
- package/build/lib/providers/claude-sub.js +26 -15
- package/build/lib/providers/claude-sub.js.map +2 -2
- package/build/lib/providers/gemini-sub.js +11 -3
- package/build/lib/providers/gemini-sub.js.map +2 -2
- package/build/lib/pure-helpers.js +3 -3
- package/build/lib/pure-helpers.js.map +2 -2
- package/build/lib/snapshot-tree.js +9 -2
- package/build/lib/snapshot-tree.js.map +2 -2
- package/build/main.js +30 -3
- package/build/main.js.map +2 -2
- package/io-package.json +14 -14
- package/package.json +2 -2
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 —
|
|
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
|
|
104
|
-
switch it off at once, an unreachable service after three
|
|
105
|
-
flap. `info.error` names the cause whenever the provider gave
|
|
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
|
package/admin/i18n/de.json
CHANGED
|
@@ -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)",
|
package/admin/i18n/en.json
CHANGED
|
@@ -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)",
|
package/admin/i18n/es.json
CHANGED
|
@@ -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)",
|
package/admin/i18n/fr.json
CHANGED
|
@@ -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)",
|
package/admin/i18n/it.json
CHANGED
|
@@ -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)",
|
package/admin/i18n/nl.json
CHANGED
|
@@ -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)",
|
package/admin/i18n/pl.json
CHANGED
|
@@ -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)",
|
package/admin/i18n/pt.json
CHANGED
|
@@ -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)",
|
package/admin/i18n/ru.json
CHANGED
|
@@ -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": "Неделя (все модели)",
|
package/admin/i18n/uk.json
CHANGED
|
@@ -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": "Тиждень (усі моделі)",
|
package/admin/i18n/zh-cn.json
CHANGED
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
|
-
|
|
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
|
}
|
package/build/lib/http.js.map
CHANGED
|
@@ -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
|
|
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,
|
|
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
|
}
|
package/build/lib/poll-engine.js
CHANGED
|
@@ -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
|
-
|
|
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.
|
|
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
|
-
|
|
302
|
-
|
|
303
|
-
|
|
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 (
|
|
520
|
+
if (delivered) {
|
|
464
521
|
this.deps.setState(`${config.id}.info.lastUpdate`, new Date(this.deps.now()).toISOString());
|
|
465
522
|
}
|
|
466
523
|
}
|