iobroker.ai-usage 0.3.0 → 0.4.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +63 -13
- package/admin/ai-usage.svg +12 -17
- package/build/lib/http.js +6 -6
- package/build/lib/http.js.map +2 -2
- package/build/lib/poll-engine.js +72 -6
- package/build/lib/poll-engine.js.map +2 -2
- package/build/lib/provider.js.map +2 -2
- package/build/lib/providers/chatgpt-sub.js +1 -0
- package/build/lib/providers/chatgpt-sub.js.map +2 -2
- package/build/lib/providers/claude-sub.js +9 -5
- package/build/lib/providers/claude-sub.js.map +2 -2
- package/build/lib/providers/gemini-sub.js.map +2 -2
- package/build/lib/snapshot-tree.js +21 -9
- package/build/lib/snapshot-tree.js.map +2 -2
- package/io-package.json +19 -1
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -2,7 +2,7 @@
|
|
|
2
2
|
|
|
3
3
|
**Release:** [](https://www.npmjs.com/package/iobroker.ai-usage)   [](https://www.npmjs.com/package/iobroker.ai-usage)
|
|
4
4
|
|
|
5
|
-
**Build:** [](https://github.com/krobipd/ioBroker.ai-usage/actions/workflows/test-and-release.yml)   [](LICENSE)
|
|
5
|
+
**Build:** [](https://github.com/krobipd/ioBroker.ai-usage/actions/workflows/test-and-release.yml)   [](LICENSE) [](https://github.com/ioBroker/plugin-sentry#plugin-sentry)
|
|
6
6
|
|
|
7
7
|
**Support:** [](https://ko-fi.com/krobipd) [](https://paypal.me/krobipd)
|
|
8
8
|
|
|
@@ -21,10 +21,19 @@ accounts — as clean datapoints for dashboards, history and automations ("send
|
|
|
21
21
|
- **Three subscriptions** — Claude, ChatGPT and Google are signed in with your own account; the settings page guides each sign-in step by step
|
|
22
22
|
- **Central credentials** — API keys live in the admin's central credential storage and are shared with the admin AI assistant
|
|
23
23
|
- **Read-only by design** — the adapter only reads usage data; it never calls, configures or writes to any AI service
|
|
24
|
+
- **Service status per account** — one indicator that tells apart "the AI service is down", "your sign-in is rejected" and "this host has no connection"
|
|
24
25
|
- **Throttle-safe polling** — a hard minimum interval and automatic backoff protect your accounts from provider rate-limit lockouts
|
|
25
26
|
|
|
26
27
|
---
|
|
27
28
|
|
|
29
|
+
## Sentry / Error reporting
|
|
30
|
+
|
|
31
|
+
**This adapter uses Sentry libraries to automatically report exceptions and code errors to the developers.** Reporting only happens if you have enabled error reporting in the ioBroker diagnostics (**System settings → Diagnostics and error reporting**). Only an anonymous installation ID is transmitted — no name, e-mail address or IP address.
|
|
32
|
+
|
|
33
|
+
For details and how to disable it, see the [Sentry plugin documentation](https://github.com/ioBroker/plugin-sentry#plugin-sentry). Error reporting requires js-controller 3.0 or newer.
|
|
34
|
+
|
|
35
|
+
---
|
|
36
|
+
|
|
28
37
|
## Requirements
|
|
29
38
|
|
|
30
39
|
- Node.js >= 22
|
|
@@ -81,8 +90,8 @@ Add such a key as its own entry in the credential storage (template "Key") and p
|
|
|
81
90
|
|
|
82
91
|
| Option | Description | Default |
|
|
83
92
|
|--------|-------------|---------|
|
|
84
|
-
| **Warn at %** | Per account: one notification when a limit window crosses this utilisation | 80 |
|
|
85
|
-
| **Poll interval** | How often each account is queried
|
|
93
|
+
| **Warn at %** | Per account: one notification when a plan-wide limit window crosses this utilisation | 80 |
|
|
94
|
+
| **Poll interval** | How often each account is queried, 60–3600 seconds. The floor is deliberate — asking too often gets the account throttled by the provider | 300 |
|
|
86
95
|
| **Notifications** | One notification on threshold crossing or broken credentials | on |
|
|
87
96
|
|
|
88
97
|
---
|
|
@@ -91,32 +100,66 @@ Add such a key as its own entry in the credential storage (template "Key") and p
|
|
|
91
100
|
|
|
92
101
|
```
|
|
93
102
|
ai-usage.0.
|
|
94
|
-
├── info.connection — at least one account
|
|
103
|
+
├── info.connection — at least one account is delivering data (bool)
|
|
95
104
|
├── total. — totals across all accounts
|
|
96
|
-
│ ├── costs
|
|
97
|
-
│ ├── maxLimitPercent — highest
|
|
105
|
+
│ ├── costs.today/month/… — summed real money (same currency only)
|
|
106
|
+
│ ├── maxLimitPercent — highest plan-wide utilisation of any account
|
|
98
107
|
│ ├── warningsActive — number of accounts above their threshold
|
|
99
|
-
│
|
|
108
|
+
│ ├── limitReached — a plan-wide window is full (automation trigger)
|
|
109
|
+
│ ├── accountsReachable — accounts currently delivering data
|
|
110
|
+
│ └── accounts — configured accounts
|
|
100
111
|
├── claude / chatgpt / gemini — one node per subscription
|
|
101
|
-
│ ├──
|
|
112
|
+
│ ├── warning — this account is above its warn threshold (bool)
|
|
113
|
+
│ ├── limitReached — a plan-wide window of this account is full (bool)
|
|
114
|
+
│ ├── info.serviceOnline — the AI service itself answered (bool)
|
|
115
|
+
│ ├── info.state — ok · unauthorized · rate-limited · service-down · no-connection
|
|
116
|
+
│ ├── info.reachable — usage data was read successfully (bool)
|
|
117
|
+
│ ├── info.signedIn — the sign-in is in place (bool)
|
|
118
|
+
│ ├── info.provider — which provider this node speaks to
|
|
119
|
+
│ ├── info.lastUpdate — time of the last successful read
|
|
102
120
|
│ ├── limits.<window>.* — percent + reset time (session, week, per model, …)
|
|
103
121
|
│ └── credits.* — where the provider reports a balance
|
|
104
122
|
└── <name>-api — one node per key-based account
|
|
105
|
-
├──
|
|
123
|
+
├── warning / limitReached — same triggers as above
|
|
124
|
+
├── info.* — same status states as above (no signedIn — it uses a key)
|
|
106
125
|
├── credits.* / costs.* — granted budget and real money
|
|
107
126
|
└── tokens.* — token counters
|
|
108
127
|
```
|
|
109
128
|
|
|
110
129
|
Only what an account's source actually delivers is created.
|
|
111
130
|
|
|
131
|
+
### Is it online?
|
|
132
|
+
|
|
133
|
+
`info.serviceOnline` answers "is the AI service itself up", `info.state` says why not:
|
|
134
|
+
|
|
135
|
+
| State | What happened |
|
|
136
|
+
|-------|---------------|
|
|
137
|
+
| `ok` | Data was read |
|
|
138
|
+
| `unauthorized` | The service answered, but rejects the sign-in or key — the service is **up** |
|
|
139
|
+
| `rate-limited` | The service answered with a throttle; the adapter backs off and keeps the last values |
|
|
140
|
+
| `service-down` | The service answered with a fault of its own (reported immediately) |
|
|
141
|
+
| `no-connection` | The service was not reachable at all — reported after three attempts in a row, so a single hiccup does not flip the indicator |
|
|
142
|
+
|
|
143
|
+
### Which limit raises the warning
|
|
144
|
+
|
|
145
|
+
Only **plan-wide** windows — your session and your week — decide `warning`, `limitReached` and
|
|
146
|
+
`total.maxLimitPercent`, and the warning always names the window it came from. A window that
|
|
147
|
+
belongs to a single model keeps its own datapoints but stays out of it: a model you never use can
|
|
148
|
+
sit at 100 % forever, and an alarm that never clears is worse than none. Google is the exception,
|
|
149
|
+
because there every bucket belongs to a model — those are your quota, so they do count.
|
|
150
|
+
|
|
151
|
+
Want a specific model watched anyway? Build the automation on its own datapoint,
|
|
152
|
+
`limits.<window>.percent`.
|
|
153
|
+
|
|
112
154
|
---
|
|
113
155
|
|
|
114
156
|
## Troubleshooting
|
|
115
157
|
|
|
116
|
-
### An account
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
158
|
+
### An account delivers no data
|
|
159
|
+
Read `info.state` first, it names the cause: `unauthorized` means the sign-in expired (sign in
|
|
160
|
+
again in the settings) or the key is not an admin key (see Configuration); `service-down` and
|
|
161
|
+
`no-connection` are outside your instance and clear up by themselves. The adapter log states the
|
|
162
|
+
same reason once and raises a single notification.
|
|
120
163
|
|
|
121
164
|
### A subscription says "not signed in" although you just signed in
|
|
122
165
|
Save the settings first, then sign in — the row needs a saved account to attach the sign-in to.
|
|
@@ -136,6 +179,13 @@ removed automatically, and an existing Claude sign-in is carried over.
|
|
|
136
179
|
Placeholder for the next version (at the beginning of the line):
|
|
137
180
|
### **WORK IN PROGRESS**
|
|
138
181
|
-->
|
|
182
|
+
### 0.4.0 (2026-08-26)
|
|
183
|
+
|
|
184
|
+
- Fixed: A limit that belongs to a single model no longer reports the whole account as full, and the warning names the window it came from instead of just "usage"
|
|
185
|
+
- New: Each account shows whether the AI service itself is online, telling a service outage apart from a rejected sign-in or a missing internet connection
|
|
186
|
+
- New: Error reporting via Sentry — crashes reach the developer automatically, but only if you enabled diagnostics and error reporting in the ioBroker system settings
|
|
187
|
+
- Changed: New adapter icon — a network of nodes instead of the dark tile, so it reads as AI at a glance and sits cleanly in both the light and the dark admin
|
|
188
|
+
|
|
139
189
|
### 0.3.0 (2026-08-26)
|
|
140
190
|
|
|
141
191
|
- New: ChatGPT and Google/Gemini subscriptions can now be monitored like the Claude one — each with its own guided sign-in that the settings page walks you through step by step
|
package/admin/ai-usage.svg
CHANGED
|
@@ -1,19 +1,14 @@
|
|
|
1
1
|
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 128 128">
|
|
2
|
-
<!-- Theme-fixed colors
|
|
3
|
-
<
|
|
4
|
-
<
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
<
|
|
12
|
-
<
|
|
13
|
-
<
|
|
14
|
-
<!-- Needle -->
|
|
15
|
-
<line x1="64" y1="86" x2="88" y2="50" stroke="#eceff1" stroke-width="6" stroke-linecap="round"/>
|
|
16
|
-
<circle cx="64" cy="86" r="7" fill="#eceff1"/>
|
|
17
|
-
<!-- Spark: the AI -->
|
|
18
|
-
<path d="M 64 100 l 4 8 l 8 4 l -8 4 l -4 8 l -4 -8 l -8 -4 l 8 -4 z" fill="#4fc3f7" transform="translate(0,-4) scale(0.9)" transform-origin="64 112"/>
|
|
2
|
+
<!-- Theme-fixed colors, transparent background — never currentColor (renders black as <img>). -->
|
|
3
|
+
<g stroke="#8fa0ad" stroke-width="7" stroke-linecap="round" opacity="0.75">
|
|
4
|
+
<line x1="28" y1="64" x2="64" y2="32"/>
|
|
5
|
+
<line x1="28" y1="64" x2="64" y2="96"/>
|
|
6
|
+
<line x1="64" y1="32" x2="100" y2="64"/>
|
|
7
|
+
<line x1="64" y1="96" x2="100" y2="64"/>
|
|
8
|
+
<line x1="64" y1="32" x2="64" y2="96"/>
|
|
9
|
+
</g>
|
|
10
|
+
<circle cx="28" cy="64" r="14" fill="#38bdf8"/>
|
|
11
|
+
<circle cx="64" cy="32" r="14" fill="#38bdf8"/>
|
|
12
|
+
<circle cx="64" cy="96" r="14" fill="#0ea5e9"/>
|
|
13
|
+
<circle cx="100" cy="64" r="14" fill="#f59e0b"/>
|
|
19
14
|
</svg>
|
package/build/lib/http.js
CHANGED
|
@@ -39,12 +39,12 @@ async function getJson(url, headers) {
|
|
|
39
39
|
throw new import_provider.FetchError("rate-limit", "HTTP 429");
|
|
40
40
|
}
|
|
41
41
|
if (!response.ok) {
|
|
42
|
-
throw new import_provider.FetchError("
|
|
42
|
+
throw new import_provider.FetchError("service", `HTTP ${response.status}`);
|
|
43
43
|
}
|
|
44
44
|
try {
|
|
45
45
|
return await response.json();
|
|
46
46
|
} catch {
|
|
47
|
-
throw new import_provider.FetchError("
|
|
47
|
+
throw new import_provider.FetchError("service", "invalid JSON response");
|
|
48
48
|
}
|
|
49
49
|
}
|
|
50
50
|
async function postJson(url, body) {
|
|
@@ -66,12 +66,12 @@ async function postJson(url, body) {
|
|
|
66
66
|
throw new import_provider.FetchError("rate-limit", "HTTP 429");
|
|
67
67
|
}
|
|
68
68
|
if (!response.ok) {
|
|
69
|
-
throw new import_provider.FetchError("
|
|
69
|
+
throw new import_provider.FetchError("service", `HTTP ${response.status}`);
|
|
70
70
|
}
|
|
71
71
|
try {
|
|
72
72
|
return await response.json();
|
|
73
73
|
} catch {
|
|
74
|
-
throw new import_provider.FetchError("
|
|
74
|
+
throw new import_provider.FetchError("service", "invalid JSON response");
|
|
75
75
|
}
|
|
76
76
|
}
|
|
77
77
|
async function postForm(url, form, headers = {}) {
|
|
@@ -93,12 +93,12 @@ async function postForm(url, form, headers = {}) {
|
|
|
93
93
|
throw new import_provider.FetchError("rate-limit", "HTTP 429");
|
|
94
94
|
}
|
|
95
95
|
if (!response.ok) {
|
|
96
|
-
throw new import_provider.FetchError("
|
|
96
|
+
throw new import_provider.FetchError("service", `HTTP ${response.status}`);
|
|
97
97
|
}
|
|
98
98
|
try {
|
|
99
99
|
return await response.json();
|
|
100
100
|
} catch (e) {
|
|
101
|
-
throw new import_provider.FetchError("
|
|
101
|
+
throw new import_provider.FetchError("service", `invalid JSON: ${e instanceof Error ? e.message : String(e)}`);
|
|
102
102
|
}
|
|
103
103
|
}
|
|
104
104
|
// Annotate the CommonJS export names for ESM import in node:
|
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 * GET a JSON document with the shared failure classification: 401/403 become an\n * auth error, 429 a rate-limit error,
|
|
5
|
-
"mappings": ";;;;;;;;;;;;;;;;;;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA,sBAA2B;AAG3B,MAAM,qBAAqB;
|
|
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 * GET a JSON document with the shared failure classification: 401/403 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 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 let response: Response;\n try {\n response = await fetch(url, { headers, 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) {\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 {\n throw new FetchError(\"service\", \"invalid JSON response\");\n }\n}\n\n/**\n * POST a JSON document with the same failure classification as {@link getJson}.\n *\n * @param url the request URL\n * @param body the JSON body\n * @returns the parsed JSON response\n */\nexport async function postJson(url: string, body: Record<string, unknown>): Promise<unknown> {\n let response: Response;\n try {\n response = await fetch(url, {\n method: \"POST\",\n headers: { \"Content-Type\": \"application/json\" },\n body: JSON.stringify(body),\n signal: AbortSignal.timeout(REQUEST_TIMEOUT_MS),\n });\n } catch (e) {\n throw new FetchError(\"network\", e instanceof Error ? e.message : String(e));\n }\n if (response.status === 401 || response.status === 403 || 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 {\n throw new FetchError(\"service\", \"invalid JSON response\");\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 headers extra headers\n * @returns the parsed JSON body\n */\nexport async function postForm(\n url: string,\n form: Record<string, string>,\n headers: Record<string, string> = {},\n): Promise<unknown> {\n let response: Response;\n try {\n response = await fetch(url, {\n method: \"POST\",\n headers: { \"Content-Type\": \"application/x-www-form-urlencoded\", ...headers },\n body: new URLSearchParams(form).toString(),\n signal: AbortSignal.timeout(REQUEST_TIMEOUT_MS),\n });\n } catch (e) {\n throw new FetchError(\"network\", e instanceof Error ? e.message : String(e));\n }\n if (response.status === 400 || response.status === 401 || response.status === 403) {\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"],
|
|
5
|
+
"mappings": "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",
|
|
6
6
|
"names": []
|
|
7
7
|
}
|
package/build/lib/poll-engine.js
CHANGED
|
@@ -52,7 +52,10 @@ class PollEngine {
|
|
|
52
52
|
skipUntil: 0,
|
|
53
53
|
backoffMs: BACKOFF_START_MS,
|
|
54
54
|
authNotified: false,
|
|
55
|
-
|
|
55
|
+
serviceOnline: false,
|
|
56
|
+
state: "no-connection",
|
|
57
|
+
createdObjects: /* @__PURE__ */ new Set(),
|
|
58
|
+
written: /* @__PURE__ */ new Map()
|
|
56
59
|
});
|
|
57
60
|
}
|
|
58
61
|
}
|
|
@@ -122,6 +125,8 @@ class PollEngine {
|
|
|
122
125
|
runtime.authNotified = false;
|
|
123
126
|
runtime.status.snapshot = snapshot;
|
|
124
127
|
runtime.status.reachable = true;
|
|
128
|
+
runtime.serviceOnline = true;
|
|
129
|
+
runtime.state = "ok";
|
|
125
130
|
await this.applySnapshot(runtime, snapshot);
|
|
126
131
|
} catch (e) {
|
|
127
132
|
this.handleFailure(runtime, e);
|
|
@@ -149,20 +154,28 @@ class PollEngine {
|
|
|
149
154
|
for (const write of writes) {
|
|
150
155
|
this.deps.setState(write.id, write.value);
|
|
151
156
|
}
|
|
152
|
-
const
|
|
157
|
+
const driver = (0, import_snapshot_tree.limitingWindow)(snapshot);
|
|
158
|
+
const percent = (_a = driver == null ? void 0 : driver.percent) != null ? _a : 0;
|
|
153
159
|
const wasWarning = runtime.status.warning;
|
|
154
160
|
runtime.status.warning = percent >= config.warnThreshold;
|
|
155
161
|
this.deps.setState(`${config.id}.warning`, runtime.status.warning);
|
|
156
162
|
this.deps.setState(`${config.id}.limitReached`, percent >= 100);
|
|
157
163
|
if (runtime.status.warning && !wasWarning) {
|
|
158
|
-
const
|
|
164
|
+
const window = driver ? `${driver.label} ` : "";
|
|
165
|
+
const message = `${config.name}: ${window}at ${Math.round(percent)} % (threshold ${config.warnThreshold} %)`;
|
|
159
166
|
this.deps.log.warn(message);
|
|
160
167
|
(_c = (_b = this.deps).notify) == null ? void 0 : _c.call(_b, config.name, message);
|
|
161
168
|
}
|
|
162
169
|
}
|
|
163
170
|
/**
|
|
164
|
-
* Classify a fetch failure
|
|
165
|
-
*
|
|
171
|
+
* Classify a fetch failure.
|
|
172
|
+
*
|
|
173
|
+
* The split matters for the online indicator: with `auth` and `rate-limit` the AI
|
|
174
|
+
* service ANSWERED — it is online, it just said no — so only our own access is
|
|
175
|
+
* broken. `service` means the service answered with a fault of its own and is
|
|
176
|
+
* reported as down at once (it told us, that is not a flake). A `network` failure
|
|
177
|
+
* is tolerated MAX_NETWORK_FAILURES times before we call the connection gone, so
|
|
178
|
+
* a single hiccup does not make the indicator flap.
|
|
166
179
|
*
|
|
167
180
|
* @param runtime the account's runtime
|
|
168
181
|
* @param error the thrown error
|
|
@@ -173,6 +186,8 @@ class PollEngine {
|
|
|
173
186
|
const message = error instanceof Error ? error.message : String(error);
|
|
174
187
|
if (error instanceof import_provider.FetchError && error.kind === "auth") {
|
|
175
188
|
runtime.status.reachable = false;
|
|
189
|
+
runtime.serviceOnline = true;
|
|
190
|
+
runtime.state = "unauthorized";
|
|
176
191
|
if (!runtime.authNotified) {
|
|
177
192
|
runtime.authNotified = true;
|
|
178
193
|
const text = `${config.name}: credentials rejected \u2014 ${message}`;
|
|
@@ -182,6 +197,8 @@ class PollEngine {
|
|
|
182
197
|
return;
|
|
183
198
|
}
|
|
184
199
|
if (error instanceof import_provider.FetchError && error.kind === "rate-limit") {
|
|
200
|
+
runtime.serviceOnline = true;
|
|
201
|
+
runtime.state = "rate-limited";
|
|
185
202
|
runtime.skipUntil = this.deps.now() + runtime.backoffMs;
|
|
186
203
|
this.deps.log.warn(
|
|
187
204
|
`${config.name}: rate-limited \u2014 backing off for ${Math.round(runtime.backoffMs / 6e4)} min, keeping last values`
|
|
@@ -189,24 +206,55 @@ class PollEngine {
|
|
|
189
206
|
runtime.backoffMs = Math.min(BACKOFF_MAX_MS, runtime.backoffMs * 2);
|
|
190
207
|
return;
|
|
191
208
|
}
|
|
209
|
+
if (error instanceof import_provider.FetchError && error.kind === "service") {
|
|
210
|
+
runtime.status.reachable = false;
|
|
211
|
+
runtime.failCount = 0;
|
|
212
|
+
if (runtime.serviceOnline) {
|
|
213
|
+
this.deps.log.warn(`${config.name}: the service reports a fault (${message}) \u2014 values kept`);
|
|
214
|
+
}
|
|
215
|
+
runtime.serviceOnline = false;
|
|
216
|
+
runtime.state = "service-down";
|
|
217
|
+
return;
|
|
218
|
+
}
|
|
192
219
|
runtime.failCount++;
|
|
193
220
|
this.deps.log.debug(`${config.name}: fetch failed (${message}), attempt ${runtime.failCount}`);
|
|
194
221
|
if (runtime.failCount >= MAX_NETWORK_FAILURES) {
|
|
195
222
|
runtime.status.reachable = false;
|
|
223
|
+
if (runtime.serviceOnline) {
|
|
224
|
+
this.deps.log.warn(`${config.name}: not reachable after ${runtime.failCount} attempts (${message})`);
|
|
225
|
+
}
|
|
226
|
+
runtime.serviceOnline = false;
|
|
227
|
+
runtime.state = "no-connection";
|
|
196
228
|
}
|
|
197
229
|
}
|
|
198
230
|
/**
|
|
199
|
-
* Write one account's info states (
|
|
231
|
+
* Write one account's info states (data received, service online, state, last update).
|
|
200
232
|
*
|
|
201
233
|
* @param runtime the account's runtime
|
|
202
234
|
*/
|
|
203
235
|
writeAccountInfo(runtime) {
|
|
204
236
|
const { config } = runtime;
|
|
205
237
|
this.deps.setState(`${config.id}.info.reachable`, runtime.status.reachable);
|
|
238
|
+
this.setIfChanged(runtime, `${config.id}.info.serviceOnline`, runtime.serviceOnline);
|
|
239
|
+
this.setIfChanged(runtime, `${config.id}.info.state`, runtime.state);
|
|
206
240
|
if (runtime.status.reachable) {
|
|
207
241
|
this.deps.setState(`${config.id}.info.lastUpdate`, new Date(this.deps.now()).toISOString());
|
|
208
242
|
}
|
|
209
243
|
}
|
|
244
|
+
/**
|
|
245
|
+
* Write a state only when its value actually changed since the last write.
|
|
246
|
+
*
|
|
247
|
+
* @param runtime the account's runtime (holds the last-written cache)
|
|
248
|
+
* @param id the state id
|
|
249
|
+
* @param value the value
|
|
250
|
+
*/
|
|
251
|
+
setIfChanged(runtime, id, value) {
|
|
252
|
+
if (runtime.written.get(id) === value) {
|
|
253
|
+
return;
|
|
254
|
+
}
|
|
255
|
+
runtime.written.set(id, value);
|
|
256
|
+
this.deps.setState(id, value);
|
|
257
|
+
}
|
|
210
258
|
/** Recompute and write the totals + info.connection. */
|
|
211
259
|
async writeTotals() {
|
|
212
260
|
const totals = (0, import_totals.computeTotals)(this.runtimes.map((runtime) => runtime.status));
|
|
@@ -241,6 +289,22 @@ class PollEngine {
|
|
|
241
289
|
type: "state",
|
|
242
290
|
common: { name: "Reachable", type: "boolean", role: "indicator.reachable", read: true, write: false }
|
|
243
291
|
},
|
|
292
|
+
{
|
|
293
|
+
id: `${config.id}.info.serviceOnline`,
|
|
294
|
+
type: "state",
|
|
295
|
+
common: { name: "AI service online", type: "boolean", role: "indicator.connected", read: true, write: false }
|
|
296
|
+
},
|
|
297
|
+
{
|
|
298
|
+
id: `${config.id}.info.state`,
|
|
299
|
+
type: "state",
|
|
300
|
+
common: {
|
|
301
|
+
name: "State (ok, unauthorized, rate-limited, service-down, no-connection)",
|
|
302
|
+
type: "string",
|
|
303
|
+
role: "text",
|
|
304
|
+
read: true,
|
|
305
|
+
write: false
|
|
306
|
+
}
|
|
307
|
+
},
|
|
244
308
|
{
|
|
245
309
|
id: `${config.id}.info.lastUpdate`,
|
|
246
310
|
type: "state",
|
|
@@ -263,6 +327,8 @@ class PollEngine {
|
|
|
263
327
|
}
|
|
264
328
|
this.deps.setState(`${config.id}.info.provider`, config.provider);
|
|
265
329
|
this.deps.setState(`${config.id}.info.reachable`, false);
|
|
330
|
+
this.setIfChanged(runtime, `${config.id}.info.serviceOnline`, runtime.serviceOnline);
|
|
331
|
+
this.setIfChanged(runtime, `${config.id}.info.state`, runtime.state);
|
|
266
332
|
}
|
|
267
333
|
/** The totals skeleton (channel + states). */
|
|
268
334
|
async createTotalsSkeleton() {
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"version": 3,
|
|
3
3
|
"sources": ["../../src/lib/poll-engine.ts"],
|
|
4
|
-
"sourcesContent": ["import type { AccountConfig } from \"./pure-helpers\";\nimport { FetchError, type UsageProvider, type UsageSnapshot } from \"./provider\";\nimport { mapSnapshot, maxLimitPercent, type ObjectDef } from \"./snapshot-tree\";\nimport { computeTotals, type AccountStatus } from \"./totals\";\n\n/** Consecutive network failures after which an account is judged unreachable. */\nconst MAX_NETWORK_FAILURES = 3;\n/** First backoff after a rate-limit answer (ms); doubles per repeat. */\nconst BACKOFF_START_MS = 10 * 60 * 1000;\n/** Backoff ceiling (ms). */\nconst BACKOFF_MAX_MS = 60 * 60 * 1000;\n/** Stagger between the accounts' first polls (ms) so they never fire in one burst. */\nconst STAGGER_MS = 3000;\n\n/** The adapter callbacks the engine drives \u2014 narrow, so tests need no adapter mock. */\nexport interface EngineDeps {\n /** Create or update an object. */\n upsertObject(def: ObjectDef): Promise<void>;\n /** Write a state value with ack. */\n setState(id: string, value: boolean | number | string): void;\n /** Schedule a repeating callback; returns a cancel handle. */\n schedule(cb: () => void, ms: number): unknown;\n /** Schedule a one-shot callback; returns a cancel handle. */\n scheduleOnce(cb: () => void, ms: number): unknown;\n /** Cancel a handle from schedule/scheduleOnce. */\n cancel(handle: unknown): void;\n /** Current time (ms since epoch) \u2014 injected for tests. */\n now(): number;\n /** Adapter log. */\n log: { debug(m: string): void; info(m: string): void; warn(m: string): void; error(m: string): void };\n /** Raise a user-facing notification (threshold crossing, broken credentials). */\n notify?(accountName: string, message: string): void;\n}\n\n/** One account's runtime state inside the engine. */\ninterface AccountRuntime {\n config: AccountConfig;\n provider: UsageProvider;\n status: AccountStatus;\n /** Consecutive network failures. */\n failCount: number;\n /** Skip polls until this time (rate-limit backoff). */\n skipUntil: number;\n /** Current backoff length (ms). */\n backoffMs: number;\n /** Whether the auth-broken notification has been raised (reset on success). */\n authNotified: boolean;\n /** Object ids already created for this account (create-once cache). */\n createdObjects: Set<string>;\n}\n\n/**\n * Drives the polling of all configured accounts: staggered starts, one independent\n * cycle per account, typed failure handling (auth = immediate + one notification,\n * rate-limit = backoff keeping last values, network = tolerant), warn-threshold\n * transitions and the adapter-wide totals. Pure orchestration \u2014 all IO is injected.\n */\nexport class PollEngine {\n private readonly runtimes: AccountRuntime[] = [];\n private readonly handles: unknown[] = [];\n private stopped = false;\n\n /**\n * @param accounts the validated account configs\n * @param providers each account id's provider (accounts without one are skipped)\n * @param intervalSec the poll interval in seconds\n * @param deps the injected adapter callbacks\n */\n public constructor(\n accounts: readonly AccountConfig[],\n providers: ReadonlyMap<string, UsageProvider>,\n private readonly intervalSec: number,\n private readonly deps: EngineDeps,\n ) {\n for (const config of accounts) {\n const provider = providers.get(config.id);\n if (!provider) {\n deps.log.warn(`${config.name}: provider \"${config.provider}\" is not available \u2014 account skipped`);\n continue;\n }\n this.runtimes.push({\n config,\n provider,\n status: { reachable: false, warning: false },\n failCount: 0,\n skipUntil: 0,\n backoffMs: BACKOFF_START_MS,\n authNotified: false,\n createdObjects: new Set(),\n });\n }\n }\n\n /** Create the static per-account and totals objects, then arm the poll cycles. */\n public async start(): Promise<void> {\n for (const runtime of this.runtimes) {\n await this.createAccountSkeleton(runtime);\n }\n await this.createTotalsSkeleton();\n await this.writeTotals();\n this.runtimes.forEach((runtime, index) => {\n this.handles.push(\n this.deps.scheduleOnce(() => void this.pollAccount(runtime), index * STAGGER_MS),\n this.deps.schedule(() => void this.pollAccount(runtime), this.intervalSec * 1000),\n );\n });\n }\n\n /** Cancel every timer. Synchronous \u2014 safe from onUnload. */\n public stop(): void {\n this.stopped = true;\n for (const handle of this.handles) {\n this.deps.cancel(handle);\n }\n this.handles.length = 0;\n }\n\n /** The account ids the engine drives (for the stale-object cleanup). */\n public get accountIds(): string[] {\n return this.runtimes.map(runtime => runtime.config.id);\n }\n\n /**\n * Poll one account immediately, by id. Used after a successful sign-in: waiting\n * up to a full interval there reads as \"the sign-in did not work\".\n *\n * @param accountId the account's object id\n */\n public async pollNow(accountId: string): Promise<void> {\n const runtime = this.runtimes.find(entry => entry.config.id === accountId);\n if (runtime) {\n // A fresh sign-in clears a previous auth failure and any backoff.\n runtime.authNotified = false;\n runtime.skipUntil = 0;\n await this.pollAccount(runtime);\n }\n }\n\n /**\n * Poll one account now (also used by the staggered first run).\n *\n * @param runtime the account's runtime\n */\n private async pollAccount(runtime: AccountRuntime): Promise<void> {\n if (this.stopped) {\n return;\n }\n const { config } = runtime;\n if (this.deps.now() < runtime.skipUntil) {\n this.deps.log.debug(`${config.name}: in rate-limit backoff \u2014 poll skipped`);\n return;\n }\n try {\n const snapshot = await runtime.provider.fetch();\n runtime.failCount = 0;\n runtime.backoffMs = BACKOFF_START_MS;\n runtime.authNotified = false;\n runtime.status.snapshot = snapshot;\n runtime.status.reachable = true;\n await this.applySnapshot(runtime, snapshot);\n } catch (e) {\n this.handleFailure(runtime, e);\n }\n this.writeAccountInfo(runtime);\n await this.writeTotals();\n }\n\n /**\n * Write a successful snapshot: upsert new objects (create-once cache), write the\n * values, and run the warn-threshold transition.\n *\n * @param runtime the account's runtime\n * @param snapshot the fetched snapshot\n */\n private async applySnapshot(runtime: AccountRuntime, snapshot: UsageSnapshot): Promise<void> {\n const { config } = runtime;\n const { objects, writes } = mapSnapshot(config.id, config.name, config.provider, snapshot);\n for (const object of objects) {\n if (!runtime.createdObjects.has(object.id)) {\n await this.deps.upsertObject(object);\n runtime.createdObjects.add(object.id);\n }\n }\n for (const write of writes) {\n this.deps.setState(write.id, write.value);\n }\n const percent = maxLimitPercent(snapshot) ?? 0;\n const wasWarning = runtime.status.warning;\n runtime.status.warning = percent >= config.warnThreshold;\n this.deps.setState(`${config.id}.warning`, runtime.status.warning);\n this.deps.setState(`${config.id}.limitReached`, percent >= 100);\n if (runtime.status.warning && !wasWarning) {\n const message = `${config.name}: usage at ${Math.round(percent)} % (threshold ${config.warnThreshold} %)`;\n this.deps.log.warn(message);\n this.deps.notify?.(config.name, message);\n }\n }\n\n /**\n * Classify a fetch failure: auth = unreachable + ONE notification until it recovers;\n * rate-limit = backoff, last values stay; network = tolerated MAX_NETWORK_FAILURES times.\n *\n * @param runtime the account's runtime\n * @param error the thrown error\n */\n private handleFailure(runtime: AccountRuntime, error: unknown): void {\n const { config } = runtime;\n const message = error instanceof Error ? error.message : String(error);\n if (error instanceof FetchError && error.kind === \"auth\") {\n runtime.status.reachable = false;\n if (!runtime.authNotified) {\n runtime.authNotified = true;\n const text = `${config.name}: credentials rejected \u2014 ${message}`;\n this.deps.log.warn(text);\n this.deps.notify?.(config.name, text);\n }\n return;\n }\n if (error instanceof FetchError && error.kind === \"rate-limit\") {\n runtime.skipUntil = this.deps.now() + runtime.backoffMs;\n this.deps.log.warn(\n `${config.name}: rate-limited \u2014 backing off for ${Math.round(runtime.backoffMs / 60000)} min, keeping last values`,\n );\n runtime.backoffMs = Math.min(BACKOFF_MAX_MS, runtime.backoffMs * 2);\n return;\n }\n runtime.failCount++;\n this.deps.log.debug(`${config.name}: fetch failed (${message}), attempt ${runtime.failCount}`);\n if (runtime.failCount >= MAX_NETWORK_FAILURES) {\n runtime.status.reachable = false;\n }\n }\n\n /**\n * Write one account's info states (reachable, last update).\n *\n * @param runtime the account's runtime\n */\n private writeAccountInfo(runtime: AccountRuntime): void {\n const { config } = runtime;\n this.deps.setState(`${config.id}.info.reachable`, runtime.status.reachable);\n if (runtime.status.reachable) {\n this.deps.setState(`${config.id}.info.lastUpdate`, new Date(this.deps.now()).toISOString());\n }\n }\n\n /** Recompute and write the totals + info.connection. */\n private async writeTotals(): Promise<void> {\n const totals = computeTotals(this.runtimes.map(runtime => runtime.status));\n this.deps.setState(\"total.costs.today\", totals.costsToday);\n this.deps.setState(\"total.costs.month\", totals.costsMonth);\n this.deps.setState(\"total.costs.projectedMonth\", totals.costsProjectedMonth);\n this.deps.setState(\"total.maxLimitPercent\", totals.maxLimitPercent);\n this.deps.setState(\"total.warningsActive\", totals.warningsActive);\n this.deps.setState(\"total.limitReached\", totals.limitReached);\n this.deps.setState(\"total.accountsReachable\", totals.accountsReachable);\n this.deps.setState(\"total.accounts\", totals.accounts);\n this.deps.setState(\"info.connection\", totals.accountsReachable > 0);\n return Promise.resolve();\n }\n\n /**\n * The static per-account objects that exist regardless of what the source delivers.\n *\n * @param runtime the account's runtime\n */\n private async createAccountSkeleton(runtime: AccountRuntime): Promise<void> {\n const { config } = runtime;\n const defs: ObjectDef[] = [\n { id: config.id, type: \"device\", common: { name: `${config.name} (${config.provider})` } },\n { id: `${config.id}.info`, type: \"channel\", common: { name: \"Info\" } },\n {\n id: `${config.id}.info.provider`,\n type: \"state\",\n common: { name: \"Provider\", type: \"string\", role: \"text\", read: true, write: false },\n },\n {\n id: `${config.id}.info.reachable`,\n type: \"state\",\n common: { name: \"Reachable\", type: \"boolean\", role: \"indicator.reachable\", read: true, write: false },\n },\n {\n id: `${config.id}.info.lastUpdate`,\n type: \"state\",\n common: { name: \"Last successful update\", type: \"string\", role: \"date\", read: true, write: false },\n },\n {\n id: `${config.id}.warning`,\n type: \"state\",\n common: { name: \"Above warn threshold\", type: \"boolean\", role: \"indicator\", read: true, write: false },\n },\n {\n id: `${config.id}.limitReached`,\n type: \"state\",\n common: { name: \"A limit window is full\", type: \"boolean\", role: \"indicator\", read: true, write: false },\n },\n ];\n for (const def of defs) {\n await this.deps.upsertObject(def);\n runtime.createdObjects.add(def.id);\n }\n this.deps.setState(`${config.id}.info.provider`, config.provider);\n this.deps.setState(`${config.id}.info.reachable`, false);\n }\n\n /** The totals skeleton (channel + states). */\n private async createTotalsSkeleton(): Promise<void> {\n const defs: ObjectDef[] = [\n { id: \"total.costs\", type: \"channel\", common: { name: \"Costs (USD accounts)\" } },\n {\n id: \"total.costs.today\",\n type: \"state\",\n common: {\n name: \"Costs today (all accounts)\",\n type: \"number\",\n role: \"value\",\n read: true,\n write: false,\n unit: \"USD\",\n },\n },\n {\n id: \"total.costs.month\",\n type: \"state\",\n common: {\n name: \"Costs this month (all accounts)\",\n type: \"number\",\n role: \"value\",\n read: true,\n write: false,\n unit: \"USD\",\n },\n },\n {\n id: \"total.costs.projectedMonth\",\n type: \"state\",\n common: {\n name: \"Costs projected month-end (computed)\",\n type: \"number\",\n role: \"value\",\n read: true,\n write: false,\n unit: \"USD\",\n },\n },\n {\n id: \"total.maxLimitPercent\",\n type: \"state\",\n common: {\n name: \"Highest limit utilisation of any account\",\n type: \"number\",\n role: \"value\",\n read: true,\n write: false,\n unit: \"%\",\n },\n },\n {\n id: \"total.warningsActive\",\n type: \"state\",\n common: {\n name: \"Accounts above their warn threshold\",\n type: \"number\",\n role: \"value\",\n read: true,\n write: false,\n },\n },\n {\n id: \"total.limitReached\",\n type: \"state\",\n common: { name: \"Any limit window full\", type: \"boolean\", role: \"indicator\", read: true, write: false },\n },\n {\n id: \"total.accountsReachable\",\n type: \"state\",\n common: { name: \"Reachable accounts\", type: \"number\", role: \"value\", read: true, write: false },\n },\n {\n id: \"total.accounts\",\n type: \"state\",\n common: { name: \"Configured accounts\", type: \"number\", role: \"value\", read: true, write: false },\n },\n ];\n for (const def of defs) {\n await this.deps.upsertObject(def);\n }\n }\n}\n"],
|
|
5
|
-
"mappings": ";;;;;;;;;;;;;;;;;;AAAA;AAAA;AAAA;AAAA;AAAA;AACA,sBAAmE;AACnE,
|
|
4
|
+
"sourcesContent": ["import type { AccountConfig } from \"./pure-helpers\";\nimport { FetchError, type UsageProvider, type UsageSnapshot } from \"./provider\";\nimport { limitingWindow, mapSnapshot, type ObjectDef } from \"./snapshot-tree\";\nimport { computeTotals, type AccountStatus } from \"./totals\";\n\n/**\n * What the adapter currently knows about one account, in one word.\n *\n * `ok` and `rate-limited` mean the AI service is up and talking to us,\n * `unauthorized` means it is up but rejects our sign-in, `service-down` means the\n * service itself answered with a fault, `no-connection` means we never reached it.\n */\nexport type AccountState = \"ok\" | \"unauthorized\" | \"rate-limited\" | \"service-down\" | \"no-connection\";\n\n/** Consecutive network failures after which an account is judged unreachable. */\nconst MAX_NETWORK_FAILURES = 3;\n/** First backoff after a rate-limit answer (ms); doubles per repeat. */\nconst BACKOFF_START_MS = 10 * 60 * 1000;\n/** Backoff ceiling (ms). */\nconst BACKOFF_MAX_MS = 60 * 60 * 1000;\n/** Stagger between the accounts' first polls (ms) so they never fire in one burst. */\nconst STAGGER_MS = 3000;\n\n/** The adapter callbacks the engine drives \u2014 narrow, so tests need no adapter mock. */\nexport interface EngineDeps {\n /** Create or update an object. */\n upsertObject(def: ObjectDef): Promise<void>;\n /** Write a state value with ack. */\n setState(id: string, value: boolean | number | string): void;\n /** Schedule a repeating callback; returns a cancel handle. */\n schedule(cb: () => void, ms: number): unknown;\n /** Schedule a one-shot callback; returns a cancel handle. */\n scheduleOnce(cb: () => void, ms: number): unknown;\n /** Cancel a handle from schedule/scheduleOnce. */\n cancel(handle: unknown): void;\n /** Current time (ms since epoch) \u2014 injected for tests. */\n now(): number;\n /** Adapter log. */\n log: { debug(m: string): void; info(m: string): void; warn(m: string): void; error(m: string): void };\n /** Raise a user-facing notification (threshold crossing, broken credentials). */\n notify?(accountName: string, message: string): void;\n}\n\n/** One account's runtime state inside the engine. */\ninterface AccountRuntime {\n config: AccountConfig;\n provider: UsageProvider;\n status: AccountStatus;\n /** Consecutive network failures. */\n failCount: number;\n /** Skip polls until this time (rate-limit backoff). */\n skipUntil: number;\n /** Current backoff length (ms). */\n backoffMs: number;\n /** Whether the auth-broken notification has been raised (reset on success). */\n authNotified: boolean;\n /** Whether the AI service itself answered on the last attempt. */\n serviceOnline: boolean;\n /** The account's one-word state. */\n state: AccountState;\n /** Object ids already created for this account (create-once cache). */\n createdObjects: Set<string>;\n /** Last written value per state id \u2014 keeps unchanged values out of the history. */\n written: Map<string, boolean | number | string>;\n}\n\n/**\n * Drives the polling of all configured accounts: staggered starts, one independent\n * cycle per account, typed failure handling (auth = immediate + one notification,\n * rate-limit = backoff keeping last values, network = tolerant), warn-threshold\n * transitions and the adapter-wide totals. Pure orchestration \u2014 all IO is injected.\n */\nexport class PollEngine {\n private readonly runtimes: AccountRuntime[] = [];\n private readonly handles: unknown[] = [];\n private stopped = false;\n\n /**\n * @param accounts the validated account configs\n * @param providers each account id's provider (accounts without one are skipped)\n * @param intervalSec the poll interval in seconds\n * @param deps the injected adapter callbacks\n */\n public constructor(\n accounts: readonly AccountConfig[],\n providers: ReadonlyMap<string, UsageProvider>,\n private readonly intervalSec: number,\n private readonly deps: EngineDeps,\n ) {\n for (const config of accounts) {\n const provider = providers.get(config.id);\n if (!provider) {\n deps.log.warn(`${config.name}: provider \"${config.provider}\" is not available \u2014 account skipped`);\n continue;\n }\n this.runtimes.push({\n config,\n provider,\n status: { reachable: false, warning: false },\n failCount: 0,\n skipUntil: 0,\n backoffMs: BACKOFF_START_MS,\n authNotified: false,\n serviceOnline: false,\n state: \"no-connection\",\n createdObjects: new Set(),\n written: new Map(),\n });\n }\n }\n\n /** Create the static per-account and totals objects, then arm the poll cycles. */\n public async start(): Promise<void> {\n for (const runtime of this.runtimes) {\n await this.createAccountSkeleton(runtime);\n }\n await this.createTotalsSkeleton();\n await this.writeTotals();\n this.runtimes.forEach((runtime, index) => {\n this.handles.push(\n this.deps.scheduleOnce(() => void this.pollAccount(runtime), index * STAGGER_MS),\n this.deps.schedule(() => void this.pollAccount(runtime), this.intervalSec * 1000),\n );\n });\n }\n\n /** Cancel every timer. Synchronous \u2014 safe from onUnload. */\n public stop(): void {\n this.stopped = true;\n for (const handle of this.handles) {\n this.deps.cancel(handle);\n }\n this.handles.length = 0;\n }\n\n /** The account ids the engine drives (for the stale-object cleanup). */\n public get accountIds(): string[] {\n return this.runtimes.map(runtime => runtime.config.id);\n }\n\n /**\n * Poll one account immediately, by id. Used after a successful sign-in: waiting\n * up to a full interval there reads as \"the sign-in did not work\".\n *\n * @param accountId the account's object id\n */\n public async pollNow(accountId: string): Promise<void> {\n const runtime = this.runtimes.find(entry => entry.config.id === accountId);\n if (runtime) {\n // A fresh sign-in clears a previous auth failure and any backoff.\n runtime.authNotified = false;\n runtime.skipUntil = 0;\n await this.pollAccount(runtime);\n }\n }\n\n /**\n * Poll one account now (also used by the staggered first run).\n *\n * @param runtime the account's runtime\n */\n private async pollAccount(runtime: AccountRuntime): Promise<void> {\n if (this.stopped) {\n return;\n }\n const { config } = runtime;\n if (this.deps.now() < runtime.skipUntil) {\n this.deps.log.debug(`${config.name}: in rate-limit backoff \u2014 poll skipped`);\n return;\n }\n try {\n const snapshot = await runtime.provider.fetch();\n runtime.failCount = 0;\n runtime.backoffMs = BACKOFF_START_MS;\n runtime.authNotified = false;\n runtime.status.snapshot = snapshot;\n runtime.status.reachable = true;\n runtime.serviceOnline = true;\n runtime.state = \"ok\";\n await this.applySnapshot(runtime, snapshot);\n } catch (e) {\n this.handleFailure(runtime, e);\n }\n this.writeAccountInfo(runtime);\n await this.writeTotals();\n }\n\n /**\n * Write a successful snapshot: upsert new objects (create-once cache), write the\n * values, and run the warn-threshold transition.\n *\n * @param runtime the account's runtime\n * @param snapshot the fetched snapshot\n */\n private async applySnapshot(runtime: AccountRuntime, snapshot: UsageSnapshot): Promise<void> {\n const { config } = runtime;\n const { objects, writes } = mapSnapshot(config.id, config.name, config.provider, snapshot);\n for (const object of objects) {\n if (!runtime.createdObjects.has(object.id)) {\n await this.deps.upsertObject(object);\n runtime.createdObjects.add(object.id);\n }\n }\n for (const write of writes) {\n this.deps.setState(write.id, write.value);\n }\n // Only PLAN-WIDE windows speak for the account \u2014 a per-model bucket at 100 %\n // must not read as \"this AI is full\" (krobi 2026-08-26).\n const driver = limitingWindow(snapshot);\n const percent = driver?.percent ?? 0;\n const wasWarning = runtime.status.warning;\n runtime.status.warning = percent >= config.warnThreshold;\n this.deps.setState(`${config.id}.warning`, runtime.status.warning);\n this.deps.setState(`${config.id}.limitReached`, percent >= 100);\n if (runtime.status.warning && !wasWarning) {\n // Always name the window: \"usage at 100 %\" without it was misleading whenever\n // several windows existed.\n const window = driver ? `${driver.label} ` : \"\";\n const message = `${config.name}: ${window}at ${Math.round(percent)} % (threshold ${config.warnThreshold} %)`;\n this.deps.log.warn(message);\n this.deps.notify?.(config.name, message);\n }\n }\n\n /**\n * Classify a fetch failure.\n *\n * The split matters for the online indicator: with `auth` and `rate-limit` the AI\n * service ANSWERED \u2014 it is online, it just said no \u2014 so only our own access is\n * broken. `service` means the service answered with a fault of its own and is\n * reported as down at once (it told us, that is not a flake). A `network` failure\n * is tolerated MAX_NETWORK_FAILURES times before we call the connection gone, so\n * a single hiccup does not make the indicator flap.\n *\n * @param runtime the account's runtime\n * @param error the thrown error\n */\n private handleFailure(runtime: AccountRuntime, error: unknown): void {\n const { config } = runtime;\n const message = error instanceof Error ? error.message : String(error);\n if (error instanceof FetchError && error.kind === \"auth\") {\n runtime.status.reachable = false;\n runtime.serviceOnline = true;\n runtime.state = \"unauthorized\";\n if (!runtime.authNotified) {\n runtime.authNotified = true;\n const text = `${config.name}: credentials rejected \u2014 ${message}`;\n this.deps.log.warn(text);\n this.deps.notify?.(config.name, text);\n }\n return;\n }\n if (error instanceof FetchError && error.kind === \"rate-limit\") {\n runtime.serviceOnline = true;\n runtime.state = \"rate-limited\";\n runtime.skipUntil = this.deps.now() + runtime.backoffMs;\n this.deps.log.warn(\n `${config.name}: rate-limited \u2014 backing off for ${Math.round(runtime.backoffMs / 60000)} min, keeping last values`,\n );\n runtime.backoffMs = Math.min(BACKOFF_MAX_MS, runtime.backoffMs * 2);\n return;\n }\n if (error instanceof FetchError && error.kind === \"service\") {\n runtime.status.reachable = false;\n runtime.failCount = 0;\n if (runtime.serviceOnline) {\n this.deps.log.warn(`${config.name}: the service reports a fault (${message}) \u2014 values kept`);\n }\n runtime.serviceOnline = false;\n runtime.state = \"service-down\";\n return;\n }\n runtime.failCount++;\n this.deps.log.debug(`${config.name}: fetch failed (${message}), attempt ${runtime.failCount}`);\n if (runtime.failCount >= MAX_NETWORK_FAILURES) {\n runtime.status.reachable = false;\n if (runtime.serviceOnline) {\n this.deps.log.warn(`${config.name}: not reachable after ${runtime.failCount} attempts (${message})`);\n }\n runtime.serviceOnline = false;\n runtime.state = \"no-connection\";\n }\n }\n\n /**\n * Write one account's info states (data received, service online, state, last update).\n *\n * @param runtime the account's runtime\n */\n private writeAccountInfo(runtime: AccountRuntime): void {\n const { config } = runtime;\n this.deps.setState(`${config.id}.info.reachable`, runtime.status.reachable);\n // Written only on change: an indicator rewritten every cycle floods the history\n // and makes a real transition impossible to spot.\n this.setIfChanged(runtime, `${config.id}.info.serviceOnline`, runtime.serviceOnline);\n this.setIfChanged(runtime, `${config.id}.info.state`, runtime.state);\n if (runtime.status.reachable) {\n this.deps.setState(`${config.id}.info.lastUpdate`, new Date(this.deps.now()).toISOString());\n }\n }\n\n /**\n * Write a state only when its value actually changed since the last write.\n *\n * @param runtime the account's runtime (holds the last-written cache)\n * @param id the state id\n * @param value the value\n */\n private setIfChanged(runtime: AccountRuntime, id: string, value: boolean | number | string): void {\n if (runtime.written.get(id) === value) {\n return;\n }\n runtime.written.set(id, value);\n this.deps.setState(id, value);\n }\n\n /** Recompute and write the totals + info.connection. */\n private async writeTotals(): Promise<void> {\n const totals = computeTotals(this.runtimes.map(runtime => runtime.status));\n this.deps.setState(\"total.costs.today\", totals.costsToday);\n this.deps.setState(\"total.costs.month\", totals.costsMonth);\n this.deps.setState(\"total.costs.projectedMonth\", totals.costsProjectedMonth);\n this.deps.setState(\"total.maxLimitPercent\", totals.maxLimitPercent);\n this.deps.setState(\"total.warningsActive\", totals.warningsActive);\n this.deps.setState(\"total.limitReached\", totals.limitReached);\n this.deps.setState(\"total.accountsReachable\", totals.accountsReachable);\n this.deps.setState(\"total.accounts\", totals.accounts);\n this.deps.setState(\"info.connection\", totals.accountsReachable > 0);\n return Promise.resolve();\n }\n\n /**\n * The static per-account objects that exist regardless of what the source delivers.\n *\n * @param runtime the account's runtime\n */\n private async createAccountSkeleton(runtime: AccountRuntime): Promise<void> {\n const { config } = runtime;\n const defs: ObjectDef[] = [\n { id: config.id, type: \"device\", common: { name: `${config.name} (${config.provider})` } },\n { id: `${config.id}.info`, type: \"channel\", common: { name: \"Info\" } },\n {\n id: `${config.id}.info.provider`,\n type: \"state\",\n common: { name: \"Provider\", type: \"string\", role: \"text\", read: true, write: false },\n },\n {\n id: `${config.id}.info.reachable`,\n type: \"state\",\n common: { name: \"Reachable\", type: \"boolean\", role: \"indicator.reachable\", read: true, write: false },\n },\n {\n id: `${config.id}.info.serviceOnline`,\n type: \"state\",\n common: { name: \"AI service online\", type: \"boolean\", role: \"indicator.connected\", read: true, write: false },\n },\n {\n id: `${config.id}.info.state`,\n type: \"state\",\n common: {\n name: \"State (ok, unauthorized, rate-limited, service-down, no-connection)\",\n type: \"string\",\n role: \"text\",\n read: true,\n write: false,\n },\n },\n {\n id: `${config.id}.info.lastUpdate`,\n type: \"state\",\n common: { name: \"Last successful update\", type: \"string\", role: \"date\", read: true, write: false },\n },\n {\n id: `${config.id}.warning`,\n type: \"state\",\n common: { name: \"Above warn threshold\", type: \"boolean\", role: \"indicator\", read: true, write: false },\n },\n {\n id: `${config.id}.limitReached`,\n type: \"state\",\n common: { name: \"A limit window is full\", type: \"boolean\", role: \"indicator\", read: true, write: false },\n },\n ];\n for (const def of defs) {\n await this.deps.upsertObject(def);\n runtime.createdObjects.add(def.id);\n }\n this.deps.setState(`${config.id}.info.provider`, config.provider);\n this.deps.setState(`${config.id}.info.reachable`, false);\n this.setIfChanged(runtime, `${config.id}.info.serviceOnline`, runtime.serviceOnline);\n this.setIfChanged(runtime, `${config.id}.info.state`, runtime.state);\n }\n\n /** The totals skeleton (channel + states). */\n private async createTotalsSkeleton(): Promise<void> {\n const defs: ObjectDef[] = [\n { id: \"total.costs\", type: \"channel\", common: { name: \"Costs (USD accounts)\" } },\n {\n id: \"total.costs.today\",\n type: \"state\",\n common: {\n name: \"Costs today (all accounts)\",\n type: \"number\",\n role: \"value\",\n read: true,\n write: false,\n unit: \"USD\",\n },\n },\n {\n id: \"total.costs.month\",\n type: \"state\",\n common: {\n name: \"Costs this month (all accounts)\",\n type: \"number\",\n role: \"value\",\n read: true,\n write: false,\n unit: \"USD\",\n },\n },\n {\n id: \"total.costs.projectedMonth\",\n type: \"state\",\n common: {\n name: \"Costs projected month-end (computed)\",\n type: \"number\",\n role: \"value\",\n read: true,\n write: false,\n unit: \"USD\",\n },\n },\n {\n id: \"total.maxLimitPercent\",\n type: \"state\",\n common: {\n name: \"Highest limit utilisation of any account\",\n type: \"number\",\n role: \"value\",\n read: true,\n write: false,\n unit: \"%\",\n },\n },\n {\n id: \"total.warningsActive\",\n type: \"state\",\n common: {\n name: \"Accounts above their warn threshold\",\n type: \"number\",\n role: \"value\",\n read: true,\n write: false,\n },\n },\n {\n id: \"total.limitReached\",\n type: \"state\",\n common: { name: \"Any limit window full\", type: \"boolean\", role: \"indicator\", read: true, write: false },\n },\n {\n id: \"total.accountsReachable\",\n type: \"state\",\n common: { name: \"Reachable accounts\", type: \"number\", role: \"value\", read: true, write: false },\n },\n {\n id: \"total.accounts\",\n type: \"state\",\n common: { name: \"Configured accounts\", type: \"number\", role: \"value\", read: true, write: false },\n },\n ];\n for (const def of defs) {\n await this.deps.upsertObject(def);\n }\n }\n}\n"],
|
|
5
|
+
"mappings": "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",
|
|
6
6
|
"names": []
|
|
7
7
|
}
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"version": 3,
|
|
3
3
|
"sources": ["../../src/lib/provider.ts"],
|
|
4
|
-
"sourcesContent": ["/** The provider kinds the adapter speaks. */\nexport type ProviderKind =\n \"claude-sub\" | \"chatgpt-sub\" | \"gemini-sub\" | \"openrouter\" | \"deepseek\" | \"openai\" | \"anthropic-api\";\n\n/**\n * How a subscription account is signed in. Each provider dictates its own flow \u2014\n * the admin panel renders the matching instructions, the adapter drives the rest.\n *\n * - `paste-code`: the user opens a link, signs in and pastes the code shown there (Claude).\n * - `device-code`: the adapter shows a short code the user types on the provider's page,\n * then polls until the user confirmed (ChatGPT/Codex).\n * - `paste-url`: the user opens a link, signs in and lands on a browser error page whose\n * ADDRESS carries the code \u2014 the whole address is pasted back (Gemini, the only redirect\n * Google accepts for the usable client; measured 2026-08-26).\n */\nexport type SignInFlow = \"paste-code\" | \"device-code\" | \"paste-url\";\n\n/** One limit window (session, week, per-model) \u2014 the same shape for every provider. */\nexport interface LimitWindow {\n /** Window name; becomes the object id segment (session, week, a model name, month). */\n name: string;\n /** Human-readable label for the object name. */\n label: string;\n /** Utilisation in percent (0-100+). */\n percent: number;\n /** When the window resets (ISO timestamp), if the source reports it. */\n resetAt?: string;\n}\n\n/** Granted budget (prepaid money or request credits). */\nexport interface CreditInfo {\n /** Used amount. */\n used?: number;\n /** Granted ceiling; undefined = unlimited/unknown. */\n limit?: number;\n /** Remaining amount. */\n remaining?: number;\n /** Utilisation in percent, when both used and limit are known. */\n percent?: number;\n /** Granted (gifted) part of the balance, where the source distinguishes it (DeepSeek). */\n granted?: number;\n /** Topped-up (paid) part of the balance. */\n toppedUp?: number;\n /** Currency code (\"USD\", \"CNY\") \u2014 or a unit word for piece-counters. */\n currency: string;\n /** True when the credits are pieces (requests), not money \u2014 excluded from cost totals. */\n pieces?: boolean;\n}\n\n/** Real money spent. */\nexport interface CostInfo {\n /** Spent today. */\n today?: number;\n /** Spent this month (or billing period). */\n month?: number;\n /** Lifetime counter, where the source only reports that. */\n total?: number;\n /** Projected month-end spend (computed by the provider module, marked in the object name). */\n projectedMonth?: number;\n /** Currency code. */\n currency: string;\n}\n\n/** Token counters (API accounts). */\nexport interface TokenInfo {\n /** Input tokens today. */\n inputToday?: number;\n /** Output tokens today. */\n outputToday?: number;\n /** Per-model breakdown. */\n perModel?: { model: string; tokens?: number; cost?: number }[];\n}\n\n/**\n * A transport-neutral usage snapshot \u2014 one fetch result. Only what the source\n * actually delivered is present; the tree builder creates nothing for absent parts.\n */\nexport interface UsageSnapshot {\n /** Limit windows (subscription accounts). */\n limits?: LimitWindow[];\n /** Granted budget. */\n credits?: CreditInfo;\n /** Real money spent. */\n costs?: CostInfo;\n /** Token counters. */\n tokens?: TokenInfo;\n /** Provider-specific extra flags (e.g. DeepSeek `available`). */\n available?: boolean;\n}\n\n
|
|
5
|
-
"mappings": ";;;;;;;;;;;;;;;;;;AAAA;AAAA;AAAA;AAAA;AAAA;
|
|
4
|
+
"sourcesContent": ["/** The provider kinds the adapter speaks. */\nexport type ProviderKind =\n \"claude-sub\" | \"chatgpt-sub\" | \"gemini-sub\" | \"openrouter\" | \"deepseek\" | \"openai\" | \"anthropic-api\";\n\n/**\n * How a subscription account is signed in. Each provider dictates its own flow \u2014\n * the admin panel renders the matching instructions, the adapter drives the rest.\n *\n * - `paste-code`: the user opens a link, signs in and pastes the code shown there (Claude).\n * - `device-code`: the adapter shows a short code the user types on the provider's page,\n * then polls until the user confirmed (ChatGPT/Codex).\n * - `paste-url`: the user opens a link, signs in and lands on a browser error page whose\n * ADDRESS carries the code \u2014 the whole address is pasted back (Gemini, the only redirect\n * Google accepts for the usable client; measured 2026-08-26).\n */\nexport type SignInFlow = \"paste-code\" | \"device-code\" | \"paste-url\";\n\n/** One limit window (session, week, per-model) \u2014 the same shape for every provider. */\nexport interface LimitWindow {\n /** Window name; becomes the object id segment (session, week, a model name, month). */\n name: string;\n /** Human-readable label for the object name. */\n label: string;\n /** Utilisation in percent (0-100+). */\n percent: number;\n /** When the window resets (ISO timestamp), if the source reports it. */\n resetAt?: string;\n /**\n * True when this window covers only a PART of the plan \u2014 a single model or\n * surface that sits next to a plan-wide window of the same period.\n *\n * Such a bucket is reported as its own datapoint but never drives the account's\n * warning: a model the user does not use can sit at 100 % forever, and a counter\n * that never falls is worse than no counter (krobi 2026-08-26: \"das betrifft nur\n * Fable, nicht allgemein\"). Providers whose ONLY buckets are per-model (Google)\n * leave this unset \u2014 there the model buckets are the plan.\n */\n scoped?: boolean;\n}\n\n/** Granted budget (prepaid money or request credits). */\nexport interface CreditInfo {\n /** Used amount. */\n used?: number;\n /** Granted ceiling; undefined = unlimited/unknown. */\n limit?: number;\n /** Remaining amount. */\n remaining?: number;\n /** Utilisation in percent, when both used and limit are known. */\n percent?: number;\n /** Granted (gifted) part of the balance, where the source distinguishes it (DeepSeek). */\n granted?: number;\n /** Topped-up (paid) part of the balance. */\n toppedUp?: number;\n /** Currency code (\"USD\", \"CNY\") \u2014 or a unit word for piece-counters. */\n currency: string;\n /** True when the credits are pieces (requests), not money \u2014 excluded from cost totals. */\n pieces?: boolean;\n}\n\n/** Real money spent. */\nexport interface CostInfo {\n /** Spent today. */\n today?: number;\n /** Spent this month (or billing period). */\n month?: number;\n /** Lifetime counter, where the source only reports that. */\n total?: number;\n /** Projected month-end spend (computed by the provider module, marked in the object name). */\n projectedMonth?: number;\n /** Currency code. */\n currency: string;\n}\n\n/** Token counters (API accounts). */\nexport interface TokenInfo {\n /** Input tokens today. */\n inputToday?: number;\n /** Output tokens today. */\n outputToday?: number;\n /** Per-model breakdown. */\n perModel?: { model: string; tokens?: number; cost?: number }[];\n}\n\n/**\n * A transport-neutral usage snapshot \u2014 one fetch result. Only what the source\n * actually delivered is present; the tree builder creates nothing for absent parts.\n */\nexport interface UsageSnapshot {\n /** Limit windows (subscription accounts). */\n limits?: LimitWindow[];\n /** Granted budget. */\n credits?: CreditInfo;\n /** Real money spent. */\n costs?: CostInfo;\n /** Token counters. */\n tokens?: TokenInfo;\n /** Provider-specific extra flags (e.g. DeepSeek `available`). */\n available?: boolean;\n}\n\n/**\n * Why a fetch failed \u2014 drives reachability, backoff and notifications.\n *\n * `auth` and `rate-limit` mean the AI service ANSWERED (it is online, it just said\n * no), `service` means it answered with a server fault of its own, and `network`\n * means we never reached it. Keeping the last two apart is what lets the adapter\n * say whether the AI service is down or the ioBroker host has no connection.\n */\nexport type FetchErrorKind = \"auth\" | \"rate-limit\" | \"service\" | \"network\";\n\n/** A typed fetch failure. */\nexport class FetchError extends Error {\n /**\n * @param kind the failure class\n * @param message the human-readable reason\n */\n public constructor(\n public readonly kind: FetchErrorKind,\n message: string,\n ) {\n super(message);\n this.name = \"FetchError\";\n }\n}\n\n/** One account's usage source. Implementations are pure fetch+parse \u2014 no ioBroker inside. */\nexport interface UsageProvider {\n /** Which provider this is. */\n readonly kind: ProviderKind;\n /** Fetch the current snapshot; throws {@link FetchError} on failure. */\n fetch(): Promise<UsageSnapshot>;\n}\n\n/** Persisted OAuth tokens of one subscription account. */\nexport interface TokenSet {\n /** The bearer token used for the usage call. */\n accessToken: string;\n /** The token used to obtain a fresh access token. */\n refreshToken: string;\n /** Absolute expiry of {@link accessToken} in ms since epoch. */\n expiresAt: number;\n /** Provider-specific extra the usage call needs (e.g. ChatGPT account id). */\n accountRef?: string;\n}\n\n/** Where a subscription's tokens live. Keyed by PROVIDER, never by account name. */\nexport interface TokenStore {\n /** Read the stored tokens, or null when never signed in. */\n load(): Promise<TokenSet | null>;\n /** Persist tokens (encrypted by the adapter). */\n save(tokens: TokenSet): Promise<void>;\n /** Forget the tokens (sign out / unusable refresh token). */\n clear(): Promise<void>;\n}\n"],
|
|
5
|
+
"mappings": ";;;;;;;;;;;;;;;;;;AAAA;AAAA;AAAA;AAAA;AAAA;AAgHO,MAAM,mBAAmB,MAAM;AAAA;AAAA;AAAA;AAAA;AAAA,EAK7B,YACW,MAChB,SACA;AACA,UAAM,OAAO;AAHG;AAIhB,SAAK,OAAO;AAAA,EACd;AAAA,EALkB;AAMpB;",
|
|
6
6
|
"names": []
|
|
7
7
|
}
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"version": 3,
|
|
3
3
|
"sources": ["../../../src/lib/providers/chatgpt-sub.ts"],
|
|
4
|
-
"sourcesContent": ["import { getJson, type JsonFetch } from \"../http\";\nimport {\n FetchError,\n type LimitWindow,\n type TokenSet,\n type TokenStore,\n type UsageProvider,\n type UsageSnapshot,\n} from \"../provider\";\nimport { CHATGPT_OAUTH, refreshChatgptTokens, type JsonPost } from \"./chatgpt-auth\";\n\n/** Where the subscription usage lives (the endpoint OpenAI's own Codex client uses). */\nexport const CHATGPT_USAGE_URL = \"https://chatgpt.com/backend-api/wham/usage\";\n\n/**\n * Read one rate-limit window.\n *\n * Both windows may be null when they do not apply to the account, and `reset_at`\n * is in Unix SECONDS \u2014 a value that already looks like milliseconds is passed\n * through, so a future format change cannot produce a year-58000 timestamp.\n *\n * @param raw the window object\n * @param name the object id segment\n * @param label the human-readable label\n * @returns the window, or undefined when unusable\n */\nfunction readWindow(raw: unknown, name: string, label: string): LimitWindow | undefined {\n if (typeof raw !== \"object\" || raw === null) {\n return undefined;\n }\n const entry = raw as Record<string, unknown>;\n const percent = Number(entry.used_percent);\n if (!Number.isFinite(percent)) {\n return undefined;\n }\n const window: LimitWindow = { name, label, percent };\n const resetAt = Number(entry.reset_at);\n if (Number.isFinite(resetAt) && resetAt > 0) {\n const ms = resetAt > 1e12 ? resetAt : resetAt * 1000;\n window.resetAt = new Date(ms).toISOString();\n }\n return window;\n}\n\n/**\n * Parse a `/wham/usage` answer into a snapshot.\n *\n * Fields follow OpenAI's own generated models: `plan_type`, `rate_limit` with a\n * 5-hour `primary_window` and a weekly `secondary_window`, plus `credits` whose\n * balance arrives as a string in the schema but as a number in practice.\n *\n * @param body the parsed answer\n * @returns the snapshot\n */\nexport function parseChatgptUsage(body: unknown): UsageSnapshot {\n if (typeof body !== \"object\" || body === null) {\n throw new FetchError(\"network\", \"unexpected usage response\");\n }\n const raw = body as Record<string, unknown>;\n const limits: LimitWindow[] = [];\n const rateLimit = (raw.rate_limit ?? {}) as Record<string, unknown>;\n const session = readWindow(rateLimit.primary_window, \"session\", \"Session (5 h)\");\n const week = readWindow(rateLimit.secondary_window, \"week\", \"Week\");\n if (session) {\n limits.push(session);\n }\n if (week) {\n limits.push(week);\n }\n for (const extra of Array.isArray(raw.additional_rate_limits) ? raw.additional_rate_limits : []) {\n if (typeof extra !== \"object\" || extra === null) {\n continue;\n }\n const entry = extra as Record<string, unknown>;\n const label = typeof entry.limit_name === \"string\" ? entry.limit_name : \"\";\n // The name becomes an object-id segment, so it must survive sanitizing and must\n // not collide with a window already collected \u2014 a name of \"Session\" would\n // otherwise overwrite the 5-hour window with an unrelated counter.\n const name = label\n .toLowerCase()\n .replace(/[^a-z0-9]+/g, \"-\")\n .replace(/^-+|-+$/g, \"\");\n if (!name || limits.some(window => window.name === name)) {\n continue;\n }\n const window = readWindow(entry.rate_limit, name, label);\n if (window) {\n limits.push(window);\n }\n }\n\n const snapshot: UsageSnapshot = {};\n if (limits.length) {\n snapshot.limits = limits;\n }\n\n const credits = (raw.credits ?? {}) as Record<string, unknown>;\n const balance = Number(credits.balance);\n if (Number.isFinite(balance) && credits.unlimited !== true) {\n snapshot.credits = { remaining: balance, currency: \"USD\" };\n }\n return snapshot;\n}\n\n/**\n * The ChatGPT/Codex subscription provider.\n *\n * @param store where the tokens live (keyed by provider, shared by no one else)\n * @param fetchJson the JSON-GET seam\n * @param postJson the JSON-POST seam (token refresh)\n * @param now clock (ms)\n * @returns the provider\n */\nexport function chatgptSubProvider(\n store: TokenStore,\n fetchJson: JsonFetch = getJson,\n postJson: JsonPost,\n now: () => number = Date.now,\n): UsageProvider {\n let cached: TokenSet | null = null;\n return {\n kind: \"chatgpt-sub\",\n fetch: async (): Promise<UsageSnapshot> => {\n cached ??= await store.load();\n if (!cached) {\n throw new FetchError(\"auth\", \"not signed in \u2014 start the ChatGPT sign-in in the instance settings\");\n }\n if (now() >= cached.expiresAt - 60_000) {\n cached = await refreshChatgptTokens(cached, postJson, now());\n await store.save(cached);\n }\n const headers: Record<string, string> = {\n Authorization: `Bearer ${cached.accessToken}`,\n \"User-Agent\": \"ioBroker.ai-usage\",\n };\n // Only send the account id when we have one \u2014 an empty header is rejected.\n if (cached.accountRef) {\n headers[\"ChatGPT-Account-Id\"] = cached.accountRef;\n }\n return parseChatgptUsage(await fetchJson(CHATGPT_USAGE_URL, headers));\n },\n };\n}\n\nexport { CHATGPT_OAUTH };\n"],
|
|
5
|
-
"mappings": ";;;;;;;;;;;;;;;;;;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA,kBAAwC;AACxC,sBAOO;AACP,0BAAmE;AAG5D,MAAM,oBAAoB;AAcjC,SAAS,WAAW,KAAc,MAAc,OAAwC;AACtF,MAAI,OAAO,QAAQ,YAAY,QAAQ,MAAM;AAC3C,WAAO;AAAA,EACT;AACA,QAAM,QAAQ;AACd,QAAM,UAAU,OAAO,MAAM,YAAY;AACzC,MAAI,CAAC,OAAO,SAAS,OAAO,GAAG;AAC7B,WAAO;AAAA,EACT;AACA,QAAM,SAAsB,EAAE,MAAM,OAAO,QAAQ;AACnD,QAAM,UAAU,OAAO,MAAM,QAAQ;AACrC,MAAI,OAAO,SAAS,OAAO,KAAK,UAAU,GAAG;AAC3C,UAAM,KAAK,UAAU,OAAO,UAAU,UAAU;AAChD,WAAO,UAAU,IAAI,KAAK,EAAE,EAAE,YAAY;AAAA,EAC5C;AACA,SAAO;AACT;AAYO,SAAS,kBAAkB,MAA8B;AAtDhE;AAuDE,MAAI,OAAO,SAAS,YAAY,SAAS,MAAM;AAC7C,UAAM,IAAI,2BAAW,WAAW,2BAA2B;AAAA,EAC7D;AACA,QAAM,MAAM;AACZ,QAAM,SAAwB,CAAC;AAC/B,QAAM,aAAa,SAAI,eAAJ,YAAkB,CAAC;AACtC,QAAM,UAAU,WAAW,UAAU,gBAAgB,WAAW,eAAe;AAC/E,QAAM,OAAO,WAAW,UAAU,kBAAkB,QAAQ,MAAM;AAClE,MAAI,SAAS;AACX,WAAO,KAAK,OAAO;AAAA,EACrB;AACA,MAAI,MAAM;AACR,WAAO,KAAK,IAAI;AAAA,EAClB;AACA,aAAW,SAAS,MAAM,QAAQ,IAAI,sBAAsB,IAAI,IAAI,yBAAyB,CAAC,GAAG;AAC/F,QAAI,OAAO,UAAU,YAAY,UAAU,MAAM;AAC/C;AAAA,IACF;AACA,UAAM,QAAQ;AACd,UAAM,QAAQ,OAAO,MAAM,eAAe,WAAW,MAAM,aAAa;AAIxE,UAAM,OAAO,MACV,YAAY,EACZ,QAAQ,eAAe,GAAG,EAC1B,QAAQ,YAAY,EAAE;AACzB,QAAI,CAAC,QAAQ,OAAO,KAAK,CAAAA,YAAUA,QAAO,SAAS,IAAI,GAAG;AACxD;AAAA,IACF;AACA,UAAM,SAAS,WAAW,MAAM,YAAY,MAAM,KAAK;AACvD,QAAI,QAAQ;
|
|
4
|
+
"sourcesContent": ["import { getJson, type JsonFetch } from \"../http\";\nimport {\n FetchError,\n type LimitWindow,\n type TokenSet,\n type TokenStore,\n type UsageProvider,\n type UsageSnapshot,\n} from \"../provider\";\nimport { CHATGPT_OAUTH, refreshChatgptTokens, type JsonPost } from \"./chatgpt-auth\";\n\n/** Where the subscription usage lives (the endpoint OpenAI's own Codex client uses). */\nexport const CHATGPT_USAGE_URL = \"https://chatgpt.com/backend-api/wham/usage\";\n\n/**\n * Read one rate-limit window.\n *\n * Both windows may be null when they do not apply to the account, and `reset_at`\n * is in Unix SECONDS \u2014 a value that already looks like milliseconds is passed\n * through, so a future format change cannot produce a year-58000 timestamp.\n *\n * @param raw the window object\n * @param name the object id segment\n * @param label the human-readable label\n * @returns the window, or undefined when unusable\n */\nfunction readWindow(raw: unknown, name: string, label: string): LimitWindow | undefined {\n if (typeof raw !== \"object\" || raw === null) {\n return undefined;\n }\n const entry = raw as Record<string, unknown>;\n const percent = Number(entry.used_percent);\n if (!Number.isFinite(percent)) {\n return undefined;\n }\n const window: LimitWindow = { name, label, percent };\n const resetAt = Number(entry.reset_at);\n if (Number.isFinite(resetAt) && resetAt > 0) {\n const ms = resetAt > 1e12 ? resetAt : resetAt * 1000;\n window.resetAt = new Date(ms).toISOString();\n }\n return window;\n}\n\n/**\n * Parse a `/wham/usage` answer into a snapshot.\n *\n * Fields follow OpenAI's own generated models: `plan_type`, `rate_limit` with a\n * 5-hour `primary_window` and a weekly `secondary_window`, plus `credits` whose\n * balance arrives as a string in the schema but as a number in practice.\n *\n * @param body the parsed answer\n * @returns the snapshot\n */\nexport function parseChatgptUsage(body: unknown): UsageSnapshot {\n if (typeof body !== \"object\" || body === null) {\n throw new FetchError(\"network\", \"unexpected usage response\");\n }\n const raw = body as Record<string, unknown>;\n const limits: LimitWindow[] = [];\n const rateLimit = (raw.rate_limit ?? {}) as Record<string, unknown>;\n const session = readWindow(rateLimit.primary_window, \"session\", \"Session (5 h)\");\n const week = readWindow(rateLimit.secondary_window, \"week\", \"Week\");\n if (session) {\n limits.push(session);\n }\n if (week) {\n limits.push(week);\n }\n for (const extra of Array.isArray(raw.additional_rate_limits) ? raw.additional_rate_limits : []) {\n if (typeof extra !== \"object\" || extra === null) {\n continue;\n }\n const entry = extra as Record<string, unknown>;\n const label = typeof entry.limit_name === \"string\" ? entry.limit_name : \"\";\n // The name becomes an object-id segment, so it must survive sanitizing and must\n // not collide with a window already collected \u2014 a name of \"Session\" would\n // otherwise overwrite the 5-hour window with an unrelated counter.\n const name = label\n .toLowerCase()\n .replace(/[^a-z0-9]+/g, \"-\")\n .replace(/^-+|-+$/g, \"\");\n if (!name || limits.some(window => window.name === name)) {\n continue;\n }\n const window = readWindow(entry.rate_limit, name, label);\n if (window) {\n // These sit next to the plan-wide session/week windows and cover one model\n // each \u2014 reported, but never the reason for a warning (LimitWindow.scoped).\n window.scoped = true;\n limits.push(window);\n }\n }\n\n const snapshot: UsageSnapshot = {};\n if (limits.length) {\n snapshot.limits = limits;\n }\n\n const credits = (raw.credits ?? {}) as Record<string, unknown>;\n const balance = Number(credits.balance);\n if (Number.isFinite(balance) && credits.unlimited !== true) {\n snapshot.credits = { remaining: balance, currency: \"USD\" };\n }\n return snapshot;\n}\n\n/**\n * The ChatGPT/Codex subscription provider.\n *\n * @param store where the tokens live (keyed by provider, shared by no one else)\n * @param fetchJson the JSON-GET seam\n * @param postJson the JSON-POST seam (token refresh)\n * @param now clock (ms)\n * @returns the provider\n */\nexport function chatgptSubProvider(\n store: TokenStore,\n fetchJson: JsonFetch = getJson,\n postJson: JsonPost,\n now: () => number = Date.now,\n): UsageProvider {\n let cached: TokenSet | null = null;\n return {\n kind: \"chatgpt-sub\",\n fetch: async (): Promise<UsageSnapshot> => {\n cached ??= await store.load();\n if (!cached) {\n throw new FetchError(\"auth\", \"not signed in \u2014 start the ChatGPT sign-in in the instance settings\");\n }\n if (now() >= cached.expiresAt - 60_000) {\n cached = await refreshChatgptTokens(cached, postJson, now());\n await store.save(cached);\n }\n const headers: Record<string, string> = {\n Authorization: `Bearer ${cached.accessToken}`,\n \"User-Agent\": \"ioBroker.ai-usage\",\n };\n // Only send the account id when we have one \u2014 an empty header is rejected.\n if (cached.accountRef) {\n headers[\"ChatGPT-Account-Id\"] = cached.accountRef;\n }\n return parseChatgptUsage(await fetchJson(CHATGPT_USAGE_URL, headers));\n },\n };\n}\n\nexport { CHATGPT_OAUTH };\n"],
|
|
5
|
+
"mappings": "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",
|
|
6
6
|
"names": ["window"]
|
|
7
7
|
}
|
|
@@ -34,7 +34,7 @@ function parseClaudeUsage(body) {
|
|
|
34
34
|
const raw = body;
|
|
35
35
|
const limits = [];
|
|
36
36
|
const seen = /* @__PURE__ */ new Set();
|
|
37
|
-
const push = (name, label, percent, resetsAt) => {
|
|
37
|
+
const push = (name, label, percent, resetsAt, scoped = false) => {
|
|
38
38
|
const id = (0, import_pure_helpers.sanitizeId)(name);
|
|
39
39
|
const value = Number(percent);
|
|
40
40
|
if (!id || seen.has(id) || !Number.isFinite(value)) {
|
|
@@ -45,6 +45,9 @@ function parseClaudeUsage(body) {
|
|
|
45
45
|
if (typeof resetsAt === "string" && resetsAt) {
|
|
46
46
|
window.resetAt = resetsAt;
|
|
47
47
|
}
|
|
48
|
+
if (scoped) {
|
|
49
|
+
window.scoped = true;
|
|
50
|
+
}
|
|
48
51
|
limits.push(window);
|
|
49
52
|
};
|
|
50
53
|
if (Array.isArray(raw.limits)) {
|
|
@@ -72,20 +75,21 @@ function parseClaudeUsage(body) {
|
|
|
72
75
|
nameParts.push(surface);
|
|
73
76
|
labelParts.push(`(${surface})`);
|
|
74
77
|
}
|
|
75
|
-
|
|
78
|
+
const scoped = kind !== "session" && kind !== "weekly_all";
|
|
79
|
+
push(nameParts.join("-"), labelParts.join(" "), limit.percent, limit.resets_at, scoped);
|
|
76
80
|
}
|
|
77
81
|
}
|
|
78
82
|
if (limits.length === 0) {
|
|
79
|
-
const flat = (key, name, label) => {
|
|
83
|
+
const flat = (key, name, label, scoped = false) => {
|
|
80
84
|
const block = raw[key];
|
|
81
85
|
if (typeof block === "object" && block !== null) {
|
|
82
86
|
const data = block;
|
|
83
|
-
push(name, label, data.utilization, data.resets_at);
|
|
87
|
+
push(name, label, data.utilization, data.resets_at, scoped);
|
|
84
88
|
}
|
|
85
89
|
};
|
|
86
90
|
flat("five_hour", "session", "Session (5 h)");
|
|
87
91
|
flat("seven_day", "week", "Week (all models)");
|
|
88
|
-
flat("seven_day_sonnet", "week-sonnet", "Week Sonnet");
|
|
92
|
+
flat("seven_day_sonnet", "week-sonnet", "Week Sonnet", true);
|
|
89
93
|
}
|
|
90
94
|
const snapshot = {};
|
|
91
95
|
if (limits.length > 0) {
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"version": 3,
|
|
3
3
|
"sources": ["../../../src/lib/providers/claude-sub.ts"],
|
|
4
|
-
"sourcesContent": ["import { getJson, type JsonFetch } from \"../http\";\nimport {\n FetchError,\n type LimitWindow,\n type TokenSet,\n type TokenStore,\n type UsageProvider,\n type UsageSnapshot,\n} from \"../provider\";\nimport { sanitizeId } from \"../pure-helpers\";\nimport { CLAUDE_OAUTH, refreshTokens, type JsonPost } from \"./claude-auth\";\n\n/**\n * Parse a Claude subscription `GET /api/oauth/usage` response into a snapshot.\n *\n * Source-verified against the HA reference integration (trickv/hass-claude-usage):\n * the LIVE per-scope meters are the top-level `limits[]` array (kind session /\n * weekly_all / weekly_scoped + scope.model/surface, percent, resets_at); the flat\n * `five_hour`/`seven_day*` keys are placeholders on current accounts and serve only\n * as a fallback. Extra usage arrives as `extra_usage` (credits \u00D7 decimal_places) or\n * the newer `spend` schema (money objects with amount_minor/exponent).\n *\n * @param body the usage response\n * @returns the snapshot\n */\nexport function parseClaudeUsage(body: unknown): UsageSnapshot {\n if (typeof body !== \"object\" || body === null) {\n throw new FetchError(\"network\", \"unexpected usage response\");\n }\n const raw = body as Record<string, unknown>;\n const limits: LimitWindow[] = [];\n const seen = new Set<string>();\n const push = (name: string, label: string, percent: unknown, resetsAt: unknown): void => {\n const id = sanitizeId(name);\n const value = Number(percent);\n if (!id || seen.has(id) || !Number.isFinite(value)) {\n return;\n }\n seen.add(id);\n const window: LimitWindow = { name: id, label, percent: value };\n if (typeof resetsAt === \"string\" && resetsAt) {\n window.resetAt = resetsAt;\n }\n limits.push(window);\n };\n\n // The live meters: limits[] \u2014 buckets keyed by kind + model + surface.\n if (Array.isArray(raw.limits)) {\n for (const entry of raw.limits) {\n if (typeof entry !== \"object\" || entry === null) {\n continue;\n }\n const limit = entry as Record<string, unknown>;\n const kind = typeof limit.kind === \"string\" ? limit.kind : \"\";\n if (!kind) {\n continue;\n }\n const scope = (limit.scope ?? {}) as Record<string, unknown>;\n const model = ((scope.model ?? {}) as Record<string, unknown>).display_name;\n const surface = scope.surface;\n const nameParts = [kind === \"session\" ? \"session\" : kind === \"weekly_all\" ? \"week\" : kind];\n const labelParts = [\n kind === \"session\" ? \"Session (5 h)\" : kind === \"weekly_all\" ? \"Week (all models)\" : kind.replace(/_/g, \" \"),\n ];\n if (typeof model === \"string\" && model) {\n nameParts.push(model);\n labelParts.push(model);\n }\n if (typeof surface === \"string\" && surface) {\n nameParts.push(surface);\n labelParts.push(`(${surface})`);\n }\n push(nameParts.join(\"-\"), labelParts.join(\" \"), limit.percent, limit.resets_at);\n }\n }\n\n // Fallback for older payloads without the limits[] array.\n if (limits.length === 0) {\n const flat = (key: string, name: string, label: string): void => {\n const block = raw[key];\n if (typeof block === \"object\" && block !== null) {\n const data = block as Record<string, unknown>;\n push(name, label, data.utilization, data.resets_at);\n }\n };\n flat(\"five_hour\", \"session\", \"Session (5 h)\");\n flat(\"seven_day\", \"week\", \"Week (all models)\");\n flat(\"seven_day_sonnet\", \"week-sonnet\", \"Week Sonnet\");\n }\n\n const snapshot: UsageSnapshot = {};\n if (limits.length > 0) {\n snapshot.limits = limits;\n }\n applyExtraUsage(raw, snapshot);\n return snapshot;\n}\n\n/**\n * Map the extra-usage block (either schema) onto credits + monthly costs.\n *\n * @param raw the usage response\n * @param snapshot the snapshot to extend\n */\nfunction applyExtraUsage(raw: Record<string, unknown>, snapshot: UsageSnapshot): void {\n const extra = raw.extra_usage as Record<string, unknown> | undefined | null;\n const spend = raw.spend as Record<string, unknown> | undefined | null;\n if (extra && extra.is_enabled === true) {\n const divisor = 10 ** (Number.isFinite(Number(extra.decimal_places)) ? Number(extra.decimal_places) : 2);\n const used = Number(extra.used_credits);\n const limit = Number(extra.monthly_limit);\n const percent = Number(extra.utilization);\n snapshot.credits = {\n used: Number.isFinite(used) ? used / divisor : undefined,\n limit: Number.isFinite(limit) ? limit / divisor : undefined,\n percent: Number.isFinite(percent) ? percent : undefined,\n currency: \"USD\",\n };\n if (snapshot.credits.used !== undefined) {\n snapshot.costs = { month: snapshot.credits.used, currency: \"USD\" };\n }\n return;\n }\n if (spend && spend.enabled === true) {\n const money = (value: unknown): number | undefined => {\n const obj = value as Record<string, unknown> | null | undefined;\n const amount = Number(obj?.amount_minor);\n const exponent = Number(obj?.exponent);\n return Number.isFinite(amount) ? amount / 10 ** (Number.isFinite(exponent) ? exponent : 2) : undefined;\n };\n const used = money(spend.used);\n const percent = Number(spend.percent);\n snapshot.credits = {\n used,\n limit: money(spend.limit),\n percent: Number.isFinite(percent) ? percent : undefined,\n currency: \"USD\",\n };\n if (used !== undefined) {\n snapshot.costs = { month: used, currency: \"USD\" };\n }\n }\n}\n\n/**\n * The Claude subscription provider: keeps the access token fresh (refresh 60 s\n * before expiry, persisted via the store) and reads the usage meters.\n *\n * @param store the token storage\n * @param fetchJson the JSON-GET seam\n * @param postJson the JSON-POST seam (token refresh)\n * @param now clock (ms) \u2014 injected for tests\n * @returns the provider\n */\nexport function claudeSubProvider(\n store: TokenStore,\n fetchJson: JsonFetch = getJson,\n postJson: JsonPost,\n now: () => number = Date.now,\n): UsageProvider {\n let cached: TokenSet | null = null;\n return {\n kind: \"claude-sub\",\n fetch: async (): Promise<UsageSnapshot> => {\n cached ??= await store.load();\n if (!cached) {\n throw new FetchError(\"auth\", \"not signed in \u2014 run the Claude sign-in in the instance settings\");\n }\n if (now() >= cached.expiresAt - 60_000) {\n cached = await refreshTokens(cached, postJson, now());\n await store.save(cached);\n }\n const body = await fetchJson(CLAUDE_OAUTH.usageUrl, {\n Authorization: `Bearer ${cached.accessToken}`,\n \"anthropic-beta\": CLAUDE_OAUTH.betaHeader,\n // Identify ourselves \u2014 an unset/odd user agent lands in a harder-throttled\n // bucket of this endpoint (community-measured; sources in the concept doc).\n \"User-Agent\": \"ioBroker.ai-usage\",\n });\n return parseClaudeUsage(body);\n },\n };\n}\n"],
|
|
5
|
-
"mappings": ";;;;;;;;;;;;;;;;;;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA,kBAAwC;AACxC,sBAOO;AACP,0BAA2B;AAC3B,yBAA2D;AAepD,SAAS,iBAAiB,MAA8B;AAzB/D;AA0BE,MAAI,OAAO,SAAS,YAAY,SAAS,MAAM;AAC7C,UAAM,IAAI,2BAAW,WAAW,2BAA2B;AAAA,EAC7D;AACA,QAAM,MAAM;AACZ,QAAM,SAAwB,CAAC;AAC/B,QAAM,OAAO,oBAAI,IAAY;AAC7B,QAAM,OAAO,CAAC,MAAc,OAAe,SAAkB,
|
|
4
|
+
"sourcesContent": ["import { getJson, type JsonFetch } from \"../http\";\nimport {\n FetchError,\n type LimitWindow,\n type TokenSet,\n type TokenStore,\n type UsageProvider,\n type UsageSnapshot,\n} from \"../provider\";\nimport { sanitizeId } from \"../pure-helpers\";\nimport { CLAUDE_OAUTH, refreshTokens, type JsonPost } from \"./claude-auth\";\n\n/**\n * Parse a Claude subscription `GET /api/oauth/usage` response into a snapshot.\n *\n * Source-verified against the HA reference integration (trickv/hass-claude-usage):\n * the LIVE per-scope meters are the top-level `limits[]` array (kind session /\n * weekly_all / weekly_scoped + scope.model/surface, percent, resets_at); the flat\n * `five_hour`/`seven_day*` keys are placeholders on current accounts and serve only\n * as a fallback. Extra usage arrives as `extra_usage` (credits \u00D7 decimal_places) or\n * the newer `spend` schema (money objects with amount_minor/exponent).\n *\n * @param body the usage response\n * @returns the snapshot\n */\nexport function parseClaudeUsage(body: unknown): UsageSnapshot {\n if (typeof body !== \"object\" || body === null) {\n throw new FetchError(\"network\", \"unexpected usage response\");\n }\n const raw = body as Record<string, unknown>;\n const limits: LimitWindow[] = [];\n const seen = new Set<string>();\n const push = (name: string, label: string, percent: unknown, resetsAt: unknown, scoped = false): void => {\n const id = sanitizeId(name);\n const value = Number(percent);\n if (!id || seen.has(id) || !Number.isFinite(value)) {\n return;\n }\n seen.add(id);\n const window: LimitWindow = { name: id, label, percent: value };\n if (typeof resetsAt === \"string\" && resetsAt) {\n window.resetAt = resetsAt;\n }\n if (scoped) {\n window.scoped = true;\n }\n limits.push(window);\n };\n\n // The live meters: limits[] \u2014 buckets keyed by kind + model + surface.\n if (Array.isArray(raw.limits)) {\n for (const entry of raw.limits) {\n if (typeof entry !== \"object\" || entry === null) {\n continue;\n }\n const limit = entry as Record<string, unknown>;\n const kind = typeof limit.kind === \"string\" ? limit.kind : \"\";\n if (!kind) {\n continue;\n }\n const scope = (limit.scope ?? {}) as Record<string, unknown>;\n const model = ((scope.model ?? {}) as Record<string, unknown>).display_name;\n const surface = scope.surface;\n const nameParts = [kind === \"session\" ? \"session\" : kind === \"weekly_all\" ? \"week\" : kind];\n const labelParts = [\n kind === \"session\" ? \"Session (5 h)\" : kind === \"weekly_all\" ? \"Week (all models)\" : kind.replace(/_/g, \" \"),\n ];\n if (typeof model === \"string\" && model) {\n nameParts.push(model);\n labelParts.push(model);\n }\n if (typeof surface === \"string\" && surface) {\n nameParts.push(surface);\n labelParts.push(`(${surface})`);\n }\n // `session` and `weekly_all` are the plan-wide meters; every other kind\n // (weekly_scoped and friends) covers ONE model and must not drive the\n // account's warning \u2014 see LimitWindow.scoped.\n const scoped = kind !== \"session\" && kind !== \"weekly_all\";\n push(nameParts.join(\"-\"), labelParts.join(\" \"), limit.percent, limit.resets_at, scoped);\n }\n }\n\n // Fallback for older payloads without the limits[] array.\n if (limits.length === 0) {\n const flat = (key: string, name: string, label: string, scoped = false): void => {\n const block = raw[key];\n if (typeof block === \"object\" && block !== null) {\n const data = block as Record<string, unknown>;\n push(name, label, data.utilization, data.resets_at, scoped);\n }\n };\n flat(\"five_hour\", \"session\", \"Session (5 h)\");\n flat(\"seven_day\", \"week\", \"Week (all models)\");\n flat(\"seven_day_sonnet\", \"week-sonnet\", \"Week Sonnet\", true);\n }\n\n const snapshot: UsageSnapshot = {};\n if (limits.length > 0) {\n snapshot.limits = limits;\n }\n applyExtraUsage(raw, snapshot);\n return snapshot;\n}\n\n/**\n * Map the extra-usage block (either schema) onto credits + monthly costs.\n *\n * @param raw the usage response\n * @param snapshot the snapshot to extend\n */\nfunction applyExtraUsage(raw: Record<string, unknown>, snapshot: UsageSnapshot): void {\n const extra = raw.extra_usage as Record<string, unknown> | undefined | null;\n const spend = raw.spend as Record<string, unknown> | undefined | null;\n if (extra && extra.is_enabled === true) {\n const divisor = 10 ** (Number.isFinite(Number(extra.decimal_places)) ? Number(extra.decimal_places) : 2);\n const used = Number(extra.used_credits);\n const limit = Number(extra.monthly_limit);\n const percent = Number(extra.utilization);\n snapshot.credits = {\n used: Number.isFinite(used) ? used / divisor : undefined,\n limit: Number.isFinite(limit) ? limit / divisor : undefined,\n percent: Number.isFinite(percent) ? percent : undefined,\n currency: \"USD\",\n };\n if (snapshot.credits.used !== undefined) {\n snapshot.costs = { month: snapshot.credits.used, currency: \"USD\" };\n }\n return;\n }\n if (spend && spend.enabled === true) {\n const money = (value: unknown): number | undefined => {\n const obj = value as Record<string, unknown> | null | undefined;\n const amount = Number(obj?.amount_minor);\n const exponent = Number(obj?.exponent);\n return Number.isFinite(amount) ? amount / 10 ** (Number.isFinite(exponent) ? exponent : 2) : undefined;\n };\n const used = money(spend.used);\n const percent = Number(spend.percent);\n snapshot.credits = {\n used,\n limit: money(spend.limit),\n percent: Number.isFinite(percent) ? percent : undefined,\n currency: \"USD\",\n };\n if (used !== undefined) {\n snapshot.costs = { month: used, currency: \"USD\" };\n }\n }\n}\n\n/**\n * The Claude subscription provider: keeps the access token fresh (refresh 60 s\n * before expiry, persisted via the store) and reads the usage meters.\n *\n * @param store the token storage\n * @param fetchJson the JSON-GET seam\n * @param postJson the JSON-POST seam (token refresh)\n * @param now clock (ms) \u2014 injected for tests\n * @returns the provider\n */\nexport function claudeSubProvider(\n store: TokenStore,\n fetchJson: JsonFetch = getJson,\n postJson: JsonPost,\n now: () => number = Date.now,\n): UsageProvider {\n let cached: TokenSet | null = null;\n return {\n kind: \"claude-sub\",\n fetch: async (): Promise<UsageSnapshot> => {\n cached ??= await store.load();\n if (!cached) {\n throw new FetchError(\"auth\", \"not signed in \u2014 run the Claude sign-in in the instance settings\");\n }\n if (now() >= cached.expiresAt - 60_000) {\n cached = await refreshTokens(cached, postJson, now());\n await store.save(cached);\n }\n const body = await fetchJson(CLAUDE_OAUTH.usageUrl, {\n Authorization: `Bearer ${cached.accessToken}`,\n \"anthropic-beta\": CLAUDE_OAUTH.betaHeader,\n // Identify ourselves \u2014 an unset/odd user agent lands in a harder-throttled\n // bucket of this endpoint (community-measured; sources in the concept doc).\n \"User-Agent\": \"ioBroker.ai-usage\",\n });\n return parseClaudeUsage(body);\n },\n };\n}\n"],
|
|
5
|
+
"mappings": "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",
|
|
6
6
|
"names": []
|
|
7
7
|
}
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"version": 3,
|
|
3
3
|
"sources": ["../../../src/lib/providers/gemini-sub.ts"],
|
|
4
|
-
"sourcesContent": ["import {\n FetchError,\n type LimitWindow,\n type TokenSet,\n type TokenStore,\n type UsageProvider,\n type UsageSnapshot,\n} from \"../provider\";\nimport type { FormPost } from \"./chatgpt-auth\";\nimport { refreshGeminiTokens } from \"./gemini-auth\";\nimport { sanitizeId } from \"../pure-helpers\";\n\n/**\n * Google's internal Code-Assist endpoints. The `daily-` host is tried first:\n * two independent measurements report 429 on the plain host and 200 on `daily-`,\n * so the plain one stays as a fallback rather than as the default.\n */\nexport const GEMINI_HOSTS = [\n \"https://daily-cloudcode-pa.googleapis.com/v1internal\",\n \"https://cloudcode-pa.googleapis.com/v1internal\",\n] as const;\n\n/**\n * The identity that decides WHICH quota buckets Google returns. Sending the\n * Gemini-CLI identity to a paid account yields the retired free-tier set, where\n * every bucket sits at 100 % forever \u2014 a counter that never moves is worse than\n * an error, so both calls must carry this one identity.\n */\nexport const GEMINI_IDENTITY = {\n ideType: \"ANTIGRAVITY\",\n userAgent: \"antigravity/1.11.3\",\n} as const;\n\n/** A JSON POST seam that also reports which host answered. */\nexport type GeminiPost = (\n url: string,\n body: Record<string, unknown>,\n headers?: Record<string, string>,\n) => Promise<unknown>;\n\n/**\n * Call a Code-Assist method, falling back to the second host on failure.\n *\n * @param method the method name (loadCodeAssist, retrieveUserQuota)\n * @param body the request body\n * @param accessToken the bearer token\n * @param post the POST seam\n * @returns the parsed answer\n */\nasync function callCodeAssist(\n method: string,\n body: Record<string, unknown>,\n accessToken: string,\n post: GeminiPost,\n): Promise<unknown> {\n const headers = {\n Authorization: `Bearer ${accessToken}`,\n \"User-Agent\": GEMINI_IDENTITY.userAgent,\n };\n let lastError: unknown;\n for (const host of GEMINI_HOSTS) {\n try {\n return await post(`${host}:${method}`, body, headers);\n } catch (e) {\n // An auth failure is the same on every host \u2014 only retry transport trouble.\n if (e instanceof FetchError && e.kind === \"auth\") {\n throw e;\n }\n lastError = e;\n }\n }\n throw lastError instanceof Error ? lastError : new FetchError(\"network\", \"no Code-Assist host answered\");\n}\n\n/**\n * Read the project id and the tier out of a `loadCodeAssist` answer.\n *\n * @param body the parsed answer\n * @returns project id and tier label\n */\nexport function parseCodeAssist(body: unknown): { project: string; tier: string } {\n const raw = (typeof body === \"object\" && body !== null ? body : {}) as Record<string, unknown>;\n const project = typeof raw.cloudaicompanionProject === \"string\" ? raw.cloudaicompanionProject : \"\";\n const paid = (raw.paidTier ?? {}) as Record<string, unknown>;\n const current = (raw.currentTier ?? {}) as Record<string, unknown>;\n const tier =\n (typeof paid.name === \"string\" && paid.name) ||\n (typeof current.name === \"string\" && current.name) ||\n (typeof paid.id === \"string\" && paid.id) ||\n (typeof current.id === \"string\" && current.id) ||\n \"\";\n return { project, tier };\n}\n\n/**\n * Turn a `retrieveUserQuota` answer into limit windows.\n *\n * Google reports what is LEFT (`remainingFraction` 0\u20261); the tree shows utilisation,\n * so it is inverted here. A bucket without a usable fraction is skipped rather than\n * guessed \u2014 an invented 0 % would read as \"nothing used yet\".\n *\n * @param body the parsed answer\n * @returns the snapshot\n */\nexport function parseGeminiQuota(body: unknown): UsageSnapshot {\n if (typeof body !== \"object\" || body === null) {\n throw new FetchError(\"network\", \"unexpected quota response\");\n }\n const raw = body as Record<string, unknown>;\n const buckets = Array.isArray(raw.buckets) ? raw.buckets : [];\n const limits: LimitWindow[] = [];\n const seen = new Set<string>();\n for (const entry of buckets) {\n if (typeof entry !== \"object\" || entry === null) {\n continue;\n }\n const bucket = entry as Record<string, unknown>;\n const fraction = Number(bucket.remainingFraction);\n if (!Number.isFinite(fraction)) {\n continue;\n }\n const model = typeof bucket.modelId === \"string\" ? bucket.modelId : \"\";\n const kind = typeof bucket.tokenType === \"string\" ? bucket.tokenType.toLowerCase() : \"\";\n const name = sanitizeId(model || kind || \"quota\");\n if (!name || seen.has(name)) {\n continue;\n }\n seen.add(name);\n const window: LimitWindow = {\n name,\n label: model || kind || \"Quota\",\n percent: Math.round((1 - Math.min(Math.max(fraction, 0), 1)) * 1000) / 10,\n };\n if (typeof bucket.resetTime === \"string\" && bucket.resetTime) {\n window.resetAt = bucket.resetTime;\n }\n limits.push(window);\n }\n return limits.length ? { limits } : {};\n}\n\n/**\n * The Google/Gemini subscription provider.\n *\n * @param store where the tokens live\n * @param post the JSON POST seam\n * @param postForm the form POST seam (token refresh)\n * @param now clock (ms)\n * @returns the provider\n */\nexport function geminiSubProvider(\n store: TokenStore,\n post: GeminiPost,\n postForm: FormPost,\n now: () => number = Date.now,\n): UsageProvider {\n let cached: TokenSet | null = null;\n return {\n kind: \"gemini-sub\",\n fetch: async (): Promise<UsageSnapshot> => {\n cached ??= await store.load();\n if (!cached) {\n throw new FetchError(\"auth\", \"not signed in \u2014 start the Google sign-in in the instance settings\");\n }\n if (now() >= cached.expiresAt - 60_000) {\n cached = await refreshGeminiTokens(cached, postForm, now());\n await store.save(cached);\n }\n // The project id is stable per account \u2014 look it up once, then reuse it.\n if (!cached.accountRef) {\n const info = parseCodeAssist(\n await callCodeAssist(\n \"loadCodeAssist\",\n { metadata: { ideType: GEMINI_IDENTITY.ideType } },\n cached.accessToken,\n post,\n ),\n );\n if (!info.project) {\n throw new FetchError(\n \"auth\",\n \"Google returned no project for this account \u2014 a Google AI subscription (Pro/Ultra) is required\",\n );\n }\n cached = { ...cached, accountRef: info.project };\n await store.save(cached);\n }\n return parseGeminiQuota(\n await callCodeAssist(\"retrieveUserQuota\", { project: cached.accountRef }, cached.accessToken, post),\n );\n },\n };\n}\n"],
|
|
5
|
-
"mappings": ";;;;;;;;;;;;;;;;;;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA,sBAOO;AAEP,yBAAoC;AACpC,0BAA2B;AAOpB,MAAM,eAAe;AAAA,EAC1B;AAAA,EACA;AACF;AAQO,MAAM,kBAAkB;AAAA,EAC7B,SAAS;AAAA,EACT,WAAW;AACb;AAkBA,eAAe,eACb,QACA,MACA,aACA,MACkB;AAClB,QAAM,UAAU;AAAA,IACd,eAAe,UAAU,WAAW;AAAA,IACpC,cAAc,gBAAgB;AAAA,EAChC;AACA,MAAI;AACJ,aAAW,QAAQ,cAAc;AAC/B,QAAI;AACF,aAAO,MAAM,KAAK,GAAG,IAAI,IAAI,MAAM,IAAI,MAAM,OAAO;AAAA,IACtD,SAAS,GAAG;AAEV,UAAI,aAAa,8BAAc,EAAE,SAAS,QAAQ;AAChD,cAAM;AAAA,MACR;AACA,kBAAY;AAAA,IACd;AAAA,EACF;AACA,QAAM,qBAAqB,QAAQ,YAAY,IAAI,2BAAW,WAAW,8BAA8B;AACzG;AAQO,SAAS,gBAAgB,MAAkD;AAhFlF;AAiFE,QAAM,MAAO,OAAO,SAAS,YAAY,SAAS,OAAO,OAAO,CAAC;AACjE,QAAM,UAAU,OAAO,IAAI,4BAA4B,WAAW,IAAI,0BAA0B;AAChG,QAAM,QAAQ,SAAI,aAAJ,YAAgB,CAAC;AAC/B,QAAM,WAAW,SAAI,gBAAJ,YAAmB,CAAC;AACrC,QAAM,OACH,OAAO,KAAK,SAAS,YAAY,KAAK,QACtC,OAAO,QAAQ,SAAS,YAAY,QAAQ,QAC5C,OAAO,KAAK,OAAO,YAAY,KAAK,MACpC,OAAO,QAAQ,OAAO,YAAY,QAAQ,MAC3C;AACF,SAAO,EAAE,SAAS,KAAK;AACzB;AAYO,SAAS,iBAAiB,MAA8B;AAC7D,MAAI,OAAO,SAAS,YAAY,SAAS,MAAM;AAC7C,UAAM,IAAI,2BAAW,WAAW,2BAA2B;AAAA,EAC7D;AACA,QAAM,MAAM;AACZ,QAAM,UAAU,MAAM,QAAQ,IAAI,OAAO,IAAI,IAAI,UAAU,CAAC;AAC5D,QAAM,SAAwB,CAAC;AAC/B,QAAM,OAAO,oBAAI,IAAY;AAC7B,aAAW,SAAS,SAAS;AAC3B,QAAI,OAAO,UAAU,YAAY,UAAU,MAAM;AAC/C;AAAA,IACF;AACA,UAAM,SAAS;AACf,UAAM,WAAW,OAAO,OAAO,iBAAiB;AAChD,QAAI,CAAC,OAAO,SAAS,QAAQ,GAAG;AAC9B;AAAA,IACF;AACA,UAAM,QAAQ,OAAO,OAAO,YAAY,WAAW,OAAO,UAAU;AACpE,UAAM,OAAO,OAAO,OAAO,cAAc,WAAW,OAAO,UAAU,YAAY,IAAI;AACrF,UAAM,WAAO,gCAAW,SAAS,QAAQ,OAAO;AAChD,QAAI,CAAC,QAAQ,KAAK,IAAI,IAAI,GAAG;AAC3B;AAAA,IACF;AACA,SAAK,IAAI,IAAI;
|
|
4
|
+
"sourcesContent": ["import {\n FetchError,\n type LimitWindow,\n type TokenSet,\n type TokenStore,\n type UsageProvider,\n type UsageSnapshot,\n} from \"../provider\";\nimport type { FormPost } from \"./chatgpt-auth\";\nimport { refreshGeminiTokens } from \"./gemini-auth\";\nimport { sanitizeId } from \"../pure-helpers\";\n\n/**\n * Google's internal Code-Assist endpoints. The `daily-` host is tried first:\n * two independent measurements report 429 on the plain host and 200 on `daily-`,\n * so the plain one stays as a fallback rather than as the default.\n */\nexport const GEMINI_HOSTS = [\n \"https://daily-cloudcode-pa.googleapis.com/v1internal\",\n \"https://cloudcode-pa.googleapis.com/v1internal\",\n] as const;\n\n/**\n * The identity that decides WHICH quota buckets Google returns. Sending the\n * Gemini-CLI identity to a paid account yields the retired free-tier set, where\n * every bucket sits at 100 % forever \u2014 a counter that never moves is worse than\n * an error, so both calls must carry this one identity.\n */\nexport const GEMINI_IDENTITY = {\n ideType: \"ANTIGRAVITY\",\n userAgent: \"antigravity/1.11.3\",\n} as const;\n\n/** A JSON POST seam that also reports which host answered. */\nexport type GeminiPost = (\n url: string,\n body: Record<string, unknown>,\n headers?: Record<string, string>,\n) => Promise<unknown>;\n\n/**\n * Call a Code-Assist method, falling back to the second host on failure.\n *\n * @param method the method name (loadCodeAssist, retrieveUserQuota)\n * @param body the request body\n * @param accessToken the bearer token\n * @param post the POST seam\n * @returns the parsed answer\n */\nasync function callCodeAssist(\n method: string,\n body: Record<string, unknown>,\n accessToken: string,\n post: GeminiPost,\n): Promise<unknown> {\n const headers = {\n Authorization: `Bearer ${accessToken}`,\n \"User-Agent\": GEMINI_IDENTITY.userAgent,\n };\n let lastError: unknown;\n for (const host of GEMINI_HOSTS) {\n try {\n return await post(`${host}:${method}`, body, headers);\n } catch (e) {\n // An auth failure is the same on every host \u2014 only retry transport trouble.\n if (e instanceof FetchError && e.kind === \"auth\") {\n throw e;\n }\n lastError = e;\n }\n }\n throw lastError instanceof Error ? lastError : new FetchError(\"network\", \"no Code-Assist host answered\");\n}\n\n/**\n * Read the project id and the tier out of a `loadCodeAssist` answer.\n *\n * @param body the parsed answer\n * @returns project id and tier label\n */\nexport function parseCodeAssist(body: unknown): { project: string; tier: string } {\n const raw = (typeof body === \"object\" && body !== null ? body : {}) as Record<string, unknown>;\n const project = typeof raw.cloudaicompanionProject === \"string\" ? raw.cloudaicompanionProject : \"\";\n const paid = (raw.paidTier ?? {}) as Record<string, unknown>;\n const current = (raw.currentTier ?? {}) as Record<string, unknown>;\n const tier =\n (typeof paid.name === \"string\" && paid.name) ||\n (typeof current.name === \"string\" && current.name) ||\n (typeof paid.id === \"string\" && paid.id) ||\n (typeof current.id === \"string\" && current.id) ||\n \"\";\n return { project, tier };\n}\n\n/**\n * Turn a `retrieveUserQuota` answer into limit windows.\n *\n * Google reports what is LEFT (`remainingFraction` 0\u20261); the tree shows utilisation,\n * so it is inverted here. A bucket without a usable fraction is skipped rather than\n * guessed \u2014 an invented 0 % would read as \"nothing used yet\".\n *\n * @param body the parsed answer\n * @returns the snapshot\n */\nexport function parseGeminiQuota(body: unknown): UsageSnapshot {\n if (typeof body !== \"object\" || body === null) {\n throw new FetchError(\"network\", \"unexpected quota response\");\n }\n const raw = body as Record<string, unknown>;\n const buckets = Array.isArray(raw.buckets) ? raw.buckets : [];\n const limits: LimitWindow[] = [];\n const seen = new Set<string>();\n for (const entry of buckets) {\n if (typeof entry !== \"object\" || entry === null) {\n continue;\n }\n const bucket = entry as Record<string, unknown>;\n const fraction = Number(bucket.remainingFraction);\n if (!Number.isFinite(fraction)) {\n continue;\n }\n const model = typeof bucket.modelId === \"string\" ? bucket.modelId : \"\";\n const kind = typeof bucket.tokenType === \"string\" ? bucket.tokenType.toLowerCase() : \"\";\n const name = sanitizeId(model || kind || \"quota\");\n if (!name || seen.has(name)) {\n continue;\n }\n seen.add(name);\n // NOT marked as `scoped`: Google reports no plan-wide bucket at all, so these\n // per-model buckets ARE the user's quota. Marking them would leave the account\n // without any window that can raise a warning.\n const window: LimitWindow = {\n name,\n label: model || kind || \"Quota\",\n percent: Math.round((1 - Math.min(Math.max(fraction, 0), 1)) * 1000) / 10,\n };\n if (typeof bucket.resetTime === \"string\" && bucket.resetTime) {\n window.resetAt = bucket.resetTime;\n }\n limits.push(window);\n }\n return limits.length ? { limits } : {};\n}\n\n/**\n * The Google/Gemini subscription provider.\n *\n * @param store where the tokens live\n * @param post the JSON POST seam\n * @param postForm the form POST seam (token refresh)\n * @param now clock (ms)\n * @returns the provider\n */\nexport function geminiSubProvider(\n store: TokenStore,\n post: GeminiPost,\n postForm: FormPost,\n now: () => number = Date.now,\n): UsageProvider {\n let cached: TokenSet | null = null;\n return {\n kind: \"gemini-sub\",\n fetch: async (): Promise<UsageSnapshot> => {\n cached ??= await store.load();\n if (!cached) {\n throw new FetchError(\"auth\", \"not signed in \u2014 start the Google sign-in in the instance settings\");\n }\n if (now() >= cached.expiresAt - 60_000) {\n cached = await refreshGeminiTokens(cached, postForm, now());\n await store.save(cached);\n }\n // The project id is stable per account \u2014 look it up once, then reuse it.\n if (!cached.accountRef) {\n const info = parseCodeAssist(\n await callCodeAssist(\n \"loadCodeAssist\",\n { metadata: { ideType: GEMINI_IDENTITY.ideType } },\n cached.accessToken,\n post,\n ),\n );\n if (!info.project) {\n throw new FetchError(\n \"auth\",\n \"Google returned no project for this account \u2014 a Google AI subscription (Pro/Ultra) is required\",\n );\n }\n cached = { ...cached, accountRef: info.project };\n await store.save(cached);\n }\n return parseGeminiQuota(\n await callCodeAssist(\"retrieveUserQuota\", { project: cached.accountRef }, cached.accessToken, post),\n );\n },\n };\n}\n"],
|
|
5
|
+
"mappings": "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",
|
|
6
6
|
"names": []
|
|
7
7
|
}
|
|
@@ -18,6 +18,7 @@ var __copyProps = (to, from, except, desc) => {
|
|
|
18
18
|
var __toCommonJS = (mod) => __copyProps(__defProp({}, "__esModule", { value: true }), mod);
|
|
19
19
|
var snapshot_tree_exports = {};
|
|
20
20
|
__export(snapshot_tree_exports, {
|
|
21
|
+
limitingWindow: () => limitingWindow,
|
|
21
22
|
mapSnapshot: () => mapSnapshot,
|
|
22
23
|
maxLimitPercent: () => maxLimitPercent
|
|
23
24
|
});
|
|
@@ -156,19 +157,30 @@ function mapSnapshot(accountId, accountName, provider, snapshot) {
|
|
|
156
157
|
}
|
|
157
158
|
return { objects, writes };
|
|
158
159
|
}
|
|
159
|
-
function
|
|
160
|
-
var _a, _b
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
160
|
+
function limitingWindow(snapshot) {
|
|
161
|
+
var _a, _b;
|
|
162
|
+
let best;
|
|
163
|
+
for (const limit of (_a = snapshot.limits) != null ? _a : []) {
|
|
164
|
+
if (limit.scoped) {
|
|
165
|
+
continue;
|
|
166
|
+
}
|
|
167
|
+
if (!best || limit.percent > best.percent) {
|
|
168
|
+
best = { percent: limit.percent, label: limit.label };
|
|
169
|
+
}
|
|
167
170
|
}
|
|
168
|
-
|
|
171
|
+
const credits = (_b = snapshot.credits) == null ? void 0 : _b.percent;
|
|
172
|
+
if (credits !== void 0 && (!best || credits > best.percent)) {
|
|
173
|
+
best = { percent: credits, label: "Credits" };
|
|
174
|
+
}
|
|
175
|
+
return best;
|
|
176
|
+
}
|
|
177
|
+
function maxLimitPercent(snapshot) {
|
|
178
|
+
var _a;
|
|
179
|
+
return (_a = limitingWindow(snapshot)) == null ? void 0 : _a.percent;
|
|
169
180
|
}
|
|
170
181
|
// Annotate the CommonJS export names for ESM import in node:
|
|
171
182
|
0 && (module.exports = {
|
|
183
|
+
limitingWindow,
|
|
172
184
|
mapSnapshot,
|
|
173
185
|
maxLimitPercent
|
|
174
186
|
});
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"version": 3,
|
|
3
3
|
"sources": ["../../src/lib/snapshot-tree.ts"],
|
|
4
|
-
"sourcesContent": ["import type { UsageSnapshot } from \"./provider\";\nimport { sanitizeId } from \"./pure-helpers\";\n\n/** An object to create: id (relative to the instance), type and common. */\nexport interface ObjectDef {\n /** Object id relative to the instance root. */\n id: string;\n /** ioBroker object type. */\n type: \"device\" | \"channel\" | \"state\";\n /** The object's common. */\n common: {\n name: string | Record<string, string>;\n type?: \"boolean\" | \"number\" | \"string\";\n role?: string;\n read?: boolean;\n write?: boolean;\n unit?: string;\n min?: number;\n max?: number;\n };\n}\n\n/** A state value to write (ack). */\nexport interface StateWrite {\n /** State id relative to the instance root. */\n id: string;\n /** The value. */\n value: boolean | number | string;\n}\n\n/**\n * A read-only state definition plus its value \u2014 the tree builder's working unit.\n *\n * @param id the state id\n * @param name the object name\n * @param type the value type\n * @param role the state role\n * @param value the current value\n * @param unit optional unit\n * @returns the def/value pair\n */\nfunction state(\n id: string,\n name: string,\n type: \"boolean\" | \"number\" | \"string\",\n role: string,\n value: boolean | number | string,\n unit?: string,\n): { def: ObjectDef; write: StateWrite } {\n const common: ObjectDef[\"common\"] = { name, type, role, read: true, write: false };\n if (unit !== undefined) {\n common.unit = unit;\n }\n return { def: { id, type: \"state\", common }, write: { id, value } };\n}\n\n/** The result of mapping one snapshot: objects to upsert (parents first) and values to write. */\nexport interface TreeResult {\n /** Objects in creation order (device, channels, states). */\n objects: ObjectDef[];\n /** State values to write with ack. */\n writes: StateWrite[];\n}\n\n/**\n * Map one account snapshot onto the harmonised object tree: `limits.*`, `credits.*`,\n * `costs.*`, `tokens.*` (+ `models.*`) \u2014 only what the snapshot carries is created,\n * so the tree mirrors exactly what the provider's source delivers.\n *\n * @param accountId the id-safe account id (device node)\n * @param accountName the display name for the device object\n * @param provider the provider kind (device object name suffix)\n * @param snapshot the fetched snapshot\n * @returns objects (parents first) and state writes\n */\nexport function mapSnapshot(\n accountId: string,\n accountName: string,\n provider: string,\n snapshot: UsageSnapshot,\n): TreeResult {\n const objects: ObjectDef[] = [];\n const writes: StateWrite[] = [];\n const add = (pair: { def: ObjectDef; write: StateWrite }): void => {\n objects.push(pair.def);\n writes.push(pair.write);\n };\n const channel = (id: string, name: string): void => {\n objects.push({ id, type: \"channel\", common: { name } });\n };\n\n objects.push({ id: accountId, type: \"device\", common: { name: `${accountName} (${provider})` } });\n\n if (snapshot.limits && snapshot.limits.length > 0) {\n channel(`${accountId}.limits`, \"Limit windows\");\n for (const limit of snapshot.limits) {\n const windowId = sanitizeId(limit.name);\n if (!windowId) {\n continue;\n }\n channel(`${accountId}.limits.${windowId}`, limit.label);\n add(\n state(`${accountId}.limits.${windowId}.percent`, `${limit.label} used`, \"number\", \"value\", limit.percent, \"%\"),\n );\n if (limit.resetAt !== undefined) {\n add(\n state(`${accountId}.limits.${windowId}.resetAt`, `${limit.label} resets at`, \"string\", \"date\", limit.resetAt),\n );\n }\n }\n }\n\n const credits = snapshot.credits;\n if (credits) {\n channel(`${accountId}.credits`, \"Credits\");\n const unit = credits.pieces ? \"\" : credits.currency;\n if (credits.used !== undefined) {\n add(state(`${accountId}.credits.used`, \"Credits used\", \"number\", \"value\", credits.used, unit));\n }\n if (credits.limit !== undefined) {\n add(state(`${accountId}.credits.limit`, \"Credits limit\", \"number\", \"value\", credits.limit, unit));\n }\n if (credits.remaining !== undefined) {\n add(state(`${accountId}.credits.remaining`, \"Credits remaining\", \"number\", \"value\", credits.remaining, unit));\n }\n if (credits.percent !== undefined) {\n add(state(`${accountId}.credits.percent`, \"Credits used (percent)\", \"number\", \"value\", credits.percent, \"%\"));\n }\n if (credits.granted !== undefined) {\n add(state(`${accountId}.credits.granted`, \"Granted balance\", \"number\", \"value\", credits.granted, unit));\n }\n if (credits.toppedUp !== undefined) {\n add(state(`${accountId}.credits.toppedUp`, \"Topped-up balance\", \"number\", \"value\", credits.toppedUp, unit));\n }\n }\n\n const costs = snapshot.costs;\n if (costs) {\n channel(`${accountId}.costs`, \"Costs\");\n if (costs.today !== undefined) {\n add(state(`${accountId}.costs.today`, \"Costs today\", \"number\", \"value\", costs.today, costs.currency));\n }\n if (costs.month !== undefined) {\n add(state(`${accountId}.costs.month`, \"Costs this month\", \"number\", \"value\", costs.month, costs.currency));\n }\n if (costs.total !== undefined) {\n add(state(`${accountId}.costs.total`, \"Costs total (lifetime)\", \"number\", \"value\", costs.total, costs.currency));\n }\n if (costs.projectedMonth !== undefined) {\n add(\n state(\n `${accountId}.costs.projectedMonth`,\n \"Costs projected month-end (computed)\",\n \"number\",\n \"value\",\n costs.projectedMonth,\n costs.currency,\n ),\n );\n }\n }\n\n const tokens = snapshot.tokens;\n if (tokens) {\n channel(`${accountId}.tokens`, \"Tokens\");\n if (tokens.inputToday !== undefined) {\n add(state(`${accountId}.tokens.inputToday`, \"Input tokens today\", \"number\", \"value\", tokens.inputToday));\n }\n if (tokens.outputToday !== undefined) {\n add(state(`${accountId}.tokens.outputToday`, \"Output tokens today\", \"number\", \"value\", tokens.outputToday));\n }\n if (tokens.perModel && tokens.perModel.length > 0) {\n channel(`${accountId}.models`, \"Per-model usage\");\n for (const model of tokens.perModel) {\n const modelId = sanitizeId(model.model);\n if (!modelId) {\n continue;\n }\n channel(`${accountId}.models.${modelId}`, model.model);\n if (model.tokens !== undefined) {\n add(\n state(\n `${accountId}.models.${modelId}.tokensToday`,\n `${model.model} tokens today`,\n \"number\",\n \"value\",\n model.tokens,\n ),\n );\n }\n if (model.cost !== undefined) {\n add(\n state(\n `${accountId}.models.${modelId}.costToday`,\n `${model.model} costs today`,\n \"number\",\n \"value\",\n model.cost,\n snapshot.costs?.currency ?? \"USD\",\n ),\n );\n }\n }\n }\n }\n\n if (snapshot.available !== undefined) {\n add(state(`${accountId}.available`, \"Balance sufficient for calls\", \"boolean\", \"indicator\", snapshot.available));\n }\n\n return { objects, writes };\n}\n\n/**\n * The
|
|
5
|
-
"mappings": "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
|
|
4
|
+
"sourcesContent": ["import type { UsageSnapshot } from \"./provider\";\nimport { sanitizeId } from \"./pure-helpers\";\n\n/** An object to create: id (relative to the instance), type and common. */\nexport interface ObjectDef {\n /** Object id relative to the instance root. */\n id: string;\n /** ioBroker object type. */\n type: \"device\" | \"channel\" | \"state\";\n /** The object's common. */\n common: {\n name: string | Record<string, string>;\n type?: \"boolean\" | \"number\" | \"string\";\n role?: string;\n read?: boolean;\n write?: boolean;\n unit?: string;\n min?: number;\n max?: number;\n };\n}\n\n/** A state value to write (ack). */\nexport interface StateWrite {\n /** State id relative to the instance root. */\n id: string;\n /** The value. */\n value: boolean | number | string;\n}\n\n/**\n * A read-only state definition plus its value \u2014 the tree builder's working unit.\n *\n * @param id the state id\n * @param name the object name\n * @param type the value type\n * @param role the state role\n * @param value the current value\n * @param unit optional unit\n * @returns the def/value pair\n */\nfunction state(\n id: string,\n name: string,\n type: \"boolean\" | \"number\" | \"string\",\n role: string,\n value: boolean | number | string,\n unit?: string,\n): { def: ObjectDef; write: StateWrite } {\n const common: ObjectDef[\"common\"] = { name, type, role, read: true, write: false };\n if (unit !== undefined) {\n common.unit = unit;\n }\n return { def: { id, type: \"state\", common }, write: { id, value } };\n}\n\n/** The result of mapping one snapshot: objects to upsert (parents first) and values to write. */\nexport interface TreeResult {\n /** Objects in creation order (device, channels, states). */\n objects: ObjectDef[];\n /** State values to write with ack. */\n writes: StateWrite[];\n}\n\n/**\n * Map one account snapshot onto the harmonised object tree: `limits.*`, `credits.*`,\n * `costs.*`, `tokens.*` (+ `models.*`) \u2014 only what the snapshot carries is created,\n * so the tree mirrors exactly what the provider's source delivers.\n *\n * @param accountId the id-safe account id (device node)\n * @param accountName the display name for the device object\n * @param provider the provider kind (device object name suffix)\n * @param snapshot the fetched snapshot\n * @returns objects (parents first) and state writes\n */\nexport function mapSnapshot(\n accountId: string,\n accountName: string,\n provider: string,\n snapshot: UsageSnapshot,\n): TreeResult {\n const objects: ObjectDef[] = [];\n const writes: StateWrite[] = [];\n const add = (pair: { def: ObjectDef; write: StateWrite }): void => {\n objects.push(pair.def);\n writes.push(pair.write);\n };\n const channel = (id: string, name: string): void => {\n objects.push({ id, type: \"channel\", common: { name } });\n };\n\n objects.push({ id: accountId, type: \"device\", common: { name: `${accountName} (${provider})` } });\n\n if (snapshot.limits && snapshot.limits.length > 0) {\n channel(`${accountId}.limits`, \"Limit windows\");\n for (const limit of snapshot.limits) {\n const windowId = sanitizeId(limit.name);\n if (!windowId) {\n continue;\n }\n channel(`${accountId}.limits.${windowId}`, limit.label);\n add(\n state(`${accountId}.limits.${windowId}.percent`, `${limit.label} used`, \"number\", \"value\", limit.percent, \"%\"),\n );\n if (limit.resetAt !== undefined) {\n add(\n state(`${accountId}.limits.${windowId}.resetAt`, `${limit.label} resets at`, \"string\", \"date\", limit.resetAt),\n );\n }\n }\n }\n\n const credits = snapshot.credits;\n if (credits) {\n channel(`${accountId}.credits`, \"Credits\");\n const unit = credits.pieces ? \"\" : credits.currency;\n if (credits.used !== undefined) {\n add(state(`${accountId}.credits.used`, \"Credits used\", \"number\", \"value\", credits.used, unit));\n }\n if (credits.limit !== undefined) {\n add(state(`${accountId}.credits.limit`, \"Credits limit\", \"number\", \"value\", credits.limit, unit));\n }\n if (credits.remaining !== undefined) {\n add(state(`${accountId}.credits.remaining`, \"Credits remaining\", \"number\", \"value\", credits.remaining, unit));\n }\n if (credits.percent !== undefined) {\n add(state(`${accountId}.credits.percent`, \"Credits used (percent)\", \"number\", \"value\", credits.percent, \"%\"));\n }\n if (credits.granted !== undefined) {\n add(state(`${accountId}.credits.granted`, \"Granted balance\", \"number\", \"value\", credits.granted, unit));\n }\n if (credits.toppedUp !== undefined) {\n add(state(`${accountId}.credits.toppedUp`, \"Topped-up balance\", \"number\", \"value\", credits.toppedUp, unit));\n }\n }\n\n const costs = snapshot.costs;\n if (costs) {\n channel(`${accountId}.costs`, \"Costs\");\n if (costs.today !== undefined) {\n add(state(`${accountId}.costs.today`, \"Costs today\", \"number\", \"value\", costs.today, costs.currency));\n }\n if (costs.month !== undefined) {\n add(state(`${accountId}.costs.month`, \"Costs this month\", \"number\", \"value\", costs.month, costs.currency));\n }\n if (costs.total !== undefined) {\n add(state(`${accountId}.costs.total`, \"Costs total (lifetime)\", \"number\", \"value\", costs.total, costs.currency));\n }\n if (costs.projectedMonth !== undefined) {\n add(\n state(\n `${accountId}.costs.projectedMonth`,\n \"Costs projected month-end (computed)\",\n \"number\",\n \"value\",\n costs.projectedMonth,\n costs.currency,\n ),\n );\n }\n }\n\n const tokens = snapshot.tokens;\n if (tokens) {\n channel(`${accountId}.tokens`, \"Tokens\");\n if (tokens.inputToday !== undefined) {\n add(state(`${accountId}.tokens.inputToday`, \"Input tokens today\", \"number\", \"value\", tokens.inputToday));\n }\n if (tokens.outputToday !== undefined) {\n add(state(`${accountId}.tokens.outputToday`, \"Output tokens today\", \"number\", \"value\", tokens.outputToday));\n }\n if (tokens.perModel && tokens.perModel.length > 0) {\n channel(`${accountId}.models`, \"Per-model usage\");\n for (const model of tokens.perModel) {\n const modelId = sanitizeId(model.model);\n if (!modelId) {\n continue;\n }\n channel(`${accountId}.models.${modelId}`, model.model);\n if (model.tokens !== undefined) {\n add(\n state(\n `${accountId}.models.${modelId}.tokensToday`,\n `${model.model} tokens today`,\n \"number\",\n \"value\",\n model.tokens,\n ),\n );\n }\n if (model.cost !== undefined) {\n add(\n state(\n `${accountId}.models.${modelId}.costToday`,\n `${model.model} costs today`,\n \"number\",\n \"value\",\n model.cost,\n snapshot.costs?.currency ?? \"USD\",\n ),\n );\n }\n }\n }\n }\n\n if (snapshot.available !== undefined) {\n add(state(`${accountId}.available`, \"Balance sufficient for calls\", \"boolean\", \"indicator\", snapshot.available));\n }\n\n return { objects, writes };\n}\n\n/**\n * The window that decides how full an account is: the highest PLAN-WIDE limit, or\n * the granted budget when the account has no windows at all.\n *\n * Windows marked `scoped` are left out on purpose \u2014 they cover a single model and\n * may sit at 100 % permanently for a model the user never touches, which would\n * pin the account's warning on forever. Their datapoints still exist; they just\n * do not speak for the account.\n *\n * @param snapshot the snapshot\n * @returns percent plus the label that produced it, or undefined when nothing applies\n */\nexport function limitingWindow(snapshot: UsageSnapshot): { percent: number; label: string } | undefined {\n let best: { percent: number; label: string } | undefined;\n for (const limit of snapshot.limits ?? []) {\n if (limit.scoped) {\n continue;\n }\n if (!best || limit.percent > best.percent) {\n best = { percent: limit.percent, label: limit.label };\n }\n }\n const credits = snapshot.credits?.percent;\n if (credits !== undefined && (!best || credits > best.percent)) {\n best = { percent: credits, label: \"Credits\" };\n }\n return best;\n}\n\n/**\n * The highest plan-wide limit percent in a snapshot, or undefined when it has none.\n *\n * @param snapshot the snapshot\n * @returns the maximum percent\n */\nexport function maxLimitPercent(snapshot: UsageSnapshot): number | undefined {\n return limitingWindow(snapshot)?.percent;\n}\n"],
|
|
5
|
+
"mappings": "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",
|
|
6
6
|
"names": []
|
|
7
7
|
}
|
package/io-package.json
CHANGED
|
@@ -1,8 +1,21 @@
|
|
|
1
1
|
{
|
|
2
2
|
"common": {
|
|
3
3
|
"name": "ai-usage",
|
|
4
|
-
"version": "0.
|
|
4
|
+
"version": "0.4.0",
|
|
5
5
|
"news": {
|
|
6
|
+
"0.4.0": {
|
|
7
|
+
"en": "Limits of a single model no longer report the whole account as full, every account shows whether the AI service itself is online, plus Sentry error reporting and a new icon",
|
|
8
|
+
"de": "Limits eines einzelnen Modells melden nicht mehr das ganze Konto als voll, jedes Konto zeigt ob der KI-Dienst selbst online ist, dazu Fehlerberichte über Sentry und ein neues Symbol",
|
|
9
|
+
"ru": "Лимиты отдельной модели больше не отмечают весь аккаунт как исчерпанный, каждый аккаунт показывает, онлайн ли сам сервис ИИ, плюс отчёты об ошибках Sentry и новый значок",
|
|
10
|
+
"pt": "Limites de um único modelo já não marcam a conta inteira como cheia, cada conta mostra se o próprio serviço de IA está online, mais relatórios de erros Sentry e um novo ícone",
|
|
11
|
+
"nl": "Limieten van één model melden niet langer het hele account als vol, elk account toont of de AI-dienst zelf online is, plus foutrapportage via Sentry en een nieuw pictogram",
|
|
12
|
+
"fr": "Les limites d'un seul modèle ne marquent plus tout le compte comme plein, chaque compte indique si le service IA est en ligne, plus les rapports d'erreurs Sentry et une nouvelle icône",
|
|
13
|
+
"it": "I limiti di un singolo modello non segnalano più l'intero account come pieno, ogni account mostra se il servizio IA è online, più i rapporti di errore Sentry e una nuova icona",
|
|
14
|
+
"es": "Los límites de un solo modelo ya no marcan toda la cuenta como llena, cada cuenta muestra si el servicio de IA está en línea, además de informes de errores Sentry y un icono nuevo",
|
|
15
|
+
"pl": "Limity pojedynczego modelu nie oznaczają już całego konta jako pełnego, każde konto pokazuje, czy usługa AI jest online, do tego raporty błędów Sentry i nowa ikona",
|
|
16
|
+
"uk": "Ліміти окремої моделі більше не позначають весь акаунт як заповнений, кожен акаунт показує, чи сервіс ШІ онлайн, а також звіти про помилки Sentry і нова піктограма",
|
|
17
|
+
"zh-cn": "单个模型的限额不再把整个账户标记为已满,每个账户都会显示 AI 服务本身是否在线,另有 Sentry 错误报告和新图标"
|
|
18
|
+
},
|
|
6
19
|
"0.3.0": {
|
|
7
20
|
"en": "Three subscriptions instead of one: Claude, ChatGPT and Google/Gemini each with their own guided sign-in; ChatGPT and Gemini could not be tested on a live account yet",
|
|
8
21
|
"de": "Drei Abos statt einem: Claude, ChatGPT und Google/Gemini mit je eigener geführter Anmeldung; ChatGPT und Gemini konnten noch an keinem echten Konto getestet werden",
|
|
@@ -43,6 +56,11 @@
|
|
|
43
56
|
"zh-cn": "首个版本:将 Claude、OpenAI、Anthropic、OpenRouter 和 DeepSeek 账户的使用限额、余额与费用作为数据点,并在达到所选阈值时发出警告"
|
|
44
57
|
}
|
|
45
58
|
},
|
|
59
|
+
"plugins": {
|
|
60
|
+
"sentry": {
|
|
61
|
+
"dsn": "https://66f27ceff38c1ce1e6cdcc3095ba06d3@o4511524771266560.ingest.de.sentry.io/4511524773560400"
|
|
62
|
+
}
|
|
63
|
+
},
|
|
46
64
|
"titleLang": {
|
|
47
65
|
"en": "AI Usage",
|
|
48
66
|
"de": "AI Usage",
|