iobroker.ai-usage 0.13.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 +37 -26
- 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/error-text.js +48 -0
- package/build/lib/error-text.js.map +7 -0
- package/build/lib/http.js +71 -10
- package/build/lib/http.js.map +2 -2
- package/build/lib/poll-engine.js +112 -16
- 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/sign-in-manager.js +4 -3
- package/build/lib/sign-in-manager.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 +69 -20
- package/build/main.js.map +2 -2
- package/io-package.json +27 -27
- 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,36 @@ 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.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
|
+
|
|
168
|
+
### 0.14.0 (2026-09-15)
|
|
169
|
+
|
|
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
|
|
171
|
+
- 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
|
|
172
|
+
- Fixed: The last-update stamp moved forward on a tolerated connection failure, dating values the round had never fetched
|
|
173
|
+
- 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
|
|
174
|
+
- 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
|
|
175
|
+
- Fixed: A connection failure counter that a throttle or a rejected sign-in had interrupted no longer adds up to "not reachable"
|
|
176
|
+
- 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
|
|
177
|
+
- 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
|
|
178
|
+
- 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
|
|
179
|
+
- Improved: Where a provider sends a reason of its own, `info.error` now says it ("invalid API key") instead of a bare status number
|
|
180
|
+
- 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
|
|
181
|
+
|
|
182
|
+
Only the Claude subscription runs against a real account here. The token-file fix, the early-refresh
|
|
183
|
+
retry and the sign-out behaviour are covered by tests but were not seen on a real ChatGPT, Google,
|
|
184
|
+
OpenRouter, DeepSeek, OpenAI or Anthropic account.
|
|
185
|
+
|
|
154
186
|
### 0.13.0 (2026-09-12)
|
|
155
187
|
|
|
156
188
|
- 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
|
|
@@ -192,27 +224,6 @@ so instead of pretending to be connected; signing in again is all it takes.
|
|
|
192
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
|
|
193
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
|
|
194
226
|
|
|
195
|
-
### 0.11.0 (2026-09-05)
|
|
196
|
-
|
|
197
|
-
- 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
|
|
198
|
-
- 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
|
|
199
|
-
- 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
|
|
200
|
-
- Fixed: A stored credential whose name sorts high in the alphabet is no longer missing from the account list in the instance settings
|
|
201
|
-
- Fixed: The settings page falls back to English for a browser language the adapter does not ship, instead of passing that language on unchecked
|
|
202
|
-
- Improved: All object names are now available in eleven languages instead of English only, and a renamed object reaches installations that already exist
|
|
203
|
-
- Improved: ChatGPT usage is read with the identity that endpoint expects, the way the Claude query already did — fewer rejected requests on that account
|
|
204
|
-
- 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
|
-
- Changed: "Highest account utilisation" says what it always measured — the fullest limit window **or** the account's remaining budget
|
|
206
|
-
|
|
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
227
|
[Older changelogs can be found there](CHANGELOG_OLD.md)
|
|
217
228
|
|
|
218
229
|
## 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
|
@@ -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,29 +23,90 @@ __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",
|
|
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");
|
|
64
|
+
throw new import_provider.FetchError("network", (0, import_error_text.errorText)(e));
|
|
40
65
|
}
|
|
41
66
|
if (!response.ok) {
|
|
42
|
-
|
|
67
|
+
const detail = await errorDetail(response);
|
|
68
|
+
if (response.status === 401 || response.status === 403 || authOn400 && response.status === 400) {
|
|
69
|
+
throw new import_provider.FetchError("auth", `HTTP ${response.status}${detail}`);
|
|
70
|
+
}
|
|
71
|
+
if (response.status === 429) {
|
|
72
|
+
throw new import_provider.FetchError("rate-limit", `HTTP 429${detail}`);
|
|
73
|
+
}
|
|
74
|
+
throw new import_provider.FetchError("service", `HTTP ${response.status}${detail}`);
|
|
43
75
|
}
|
|
76
|
+
let text;
|
|
44
77
|
try {
|
|
45
|
-
|
|
78
|
+
text = await readCappedText(response);
|
|
46
79
|
} catch (e) {
|
|
47
|
-
throw new import_provider.FetchError("service", `
|
|
80
|
+
throw e instanceof import_provider.FetchError ? e : new import_provider.FetchError("service", `unreadable body: ${(0, import_error_text.errorText)(e)}`);
|
|
81
|
+
}
|
|
82
|
+
try {
|
|
83
|
+
return JSON.parse(text);
|
|
84
|
+
} catch (e) {
|
|
85
|
+
throw new import_provider.FetchError("service", `invalid JSON: ${(0, import_error_text.errorText)(e)}`);
|
|
86
|
+
}
|
|
87
|
+
}
|
|
88
|
+
const MAX_DETAIL_CHARS = 200;
|
|
89
|
+
async function errorDetail(response) {
|
|
90
|
+
var _a, _b;
|
|
91
|
+
let text;
|
|
92
|
+
try {
|
|
93
|
+
text = await readCappedText(response);
|
|
94
|
+
} catch {
|
|
95
|
+
return "";
|
|
96
|
+
}
|
|
97
|
+
let message;
|
|
98
|
+
try {
|
|
99
|
+
const body = JSON.parse(text);
|
|
100
|
+
const error = body.error;
|
|
101
|
+
message = typeof error === "string" ? error : (_b = (_a = error == null ? void 0 : error.message) != null ? _a : body.message) != null ? _b : void 0;
|
|
102
|
+
} catch {
|
|
103
|
+
message = text.trim().length > 0 && text.trim().length <= MAX_DETAIL_CHARS ? text.trim() : void 0;
|
|
104
|
+
}
|
|
105
|
+
if (typeof message !== "string" || message.trim().length === 0) {
|
|
106
|
+
return "";
|
|
48
107
|
}
|
|
108
|
+
const trimmed = message.trim();
|
|
109
|
+
return ` \u2014 ${trimmed.length > MAX_DETAIL_CHARS ? `${trimmed.slice(0, MAX_DETAIL_CHARS)}\u2026` : trimmed}`;
|
|
49
110
|
}
|
|
50
111
|
async function getJson(url, headers) {
|
|
51
112
|
return request(url, { headers });
|
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
|
|
5
|
-
"mappings": ";;;;;;;;;;;;;;;;;;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA,sBAA2B;AAG3B,MAAM,qBAAqB;
|
|
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
|
}
|