iobroker.ai-usage 0.9.2 → 0.10.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.
Files changed (36) hide show
  1. package/README.md +18 -18
  2. package/admin/custom/assets/Components-SGigWPbz.js +1 -0
  3. package/admin/custom/assets/ConfigPanel-7wtMdF7R.js +1 -0
  4. package/admin/custom/assets/{index-DKkCKYc8.js → index-BOIC_Bq7.js} +1 -1
  5. package/admin/custom/assets/{mf-entry-bootstrap-0-d02c5f06.js → mf-entry-bootstrap-0-d8335d6f.js} +1 -1
  6. package/admin/custom/assets/virtualExposes--n4JvPAZ.js +1 -0
  7. package/admin/custom/assets/{virtual_mf-REMOTE_ENTRY_ID___mfe_internal__AiUsageComponentSet__customComponents_js-BjDe1IXR.js → virtual_mf-REMOTE_ENTRY_ID___mfe_internal__AiUsageComponentSet__customComponents_js-haafVBNm.js} +1 -1
  8. package/admin/custom/assets/virtual_mf-exposes-ssr___mfe_internal__AiUsageComponentSet__customComponents_js-CGHI3HBU.js +2 -0
  9. package/admin/custom/assets/{virtual_mf-exposes___mfe_internal__AiUsageComponentSet__customComponents_js-Wtw6jqAg.js → virtual_mf-exposes___mfe_internal__AiUsageComponentSet__customComponents_js-DAX-5uLT.js} +2 -2
  10. package/admin/custom/customComponents.js +1 -1
  11. package/admin/custom/mf-manifest.json +1 -1
  12. package/build/lib/poll-engine.js +6 -6
  13. package/build/lib/poll-engine.js.map +1 -1
  14. package/build/lib/provider.js.map +2 -2
  15. package/build/lib/providers/chatgpt-sub.js +56 -2
  16. package/build/lib/providers/chatgpt-sub.js.map +2 -2
  17. package/build/lib/providers/claude-auth.js +21 -2
  18. package/build/lib/providers/claude-auth.js.map +2 -2
  19. package/build/lib/providers/claude-sub.js +5 -4
  20. package/build/lib/providers/claude-sub.js.map +2 -2
  21. package/build/lib/providers/deepseek.js +1 -1
  22. package/build/lib/providers/deepseek.js.map +2 -2
  23. package/build/lib/providers/gemini-sub.js +1 -1
  24. package/build/lib/providers/gemini-sub.js.map +2 -2
  25. package/build/lib/providers/openrouter.js +1 -1
  26. package/build/lib/providers/openrouter.js.map +2 -2
  27. package/build/lib/snapshot-tree.js +50 -9
  28. package/build/lib/snapshot-tree.js.map +2 -2
  29. package/build/main.js +20 -6
  30. package/build/main.js.map +2 -2
  31. package/io-package.json +27 -27
  32. package/package.json +2 -2
  33. package/admin/custom/assets/Components-C4wFRka3.js +0 -1
  34. package/admin/custom/assets/ConfigPanel-CGCj6Crq.js +0 -1
  35. package/admin/custom/assets/virtualExposes-CdmVMFlA.js +0 -1
  36. package/admin/custom/assets/virtual_mf-exposes-ssr___mfe_internal__AiUsageComponentSet__customComponents_js-DA9gAGbr.js +0 -2
@@ -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 * 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;",
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 * Purchasable limit-reset vouchers currently available (ChatGPT/Codex \"rate limit\n * reset credits\"). A count of pieces, never money.\n */\n resetCredits?: number;\n /**\n * When the next available reset voucher expires (ISO timestamp); empty string\n * while none is held \u2014 the datapoint stays, only its value empties.\n */\n resetCreditsNextExpiry?: string;\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;AA0HO,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
  }
@@ -19,8 +19,10 @@ var __toCommonJS = (mod) => __copyProps(__defProp({}, "__esModule", { value: tru
19
19
  var chatgpt_sub_exports = {};
20
20
  __export(chatgpt_sub_exports, {
21
21
  CHATGPT_OAUTH: () => import_chatgpt_auth.CHATGPT_OAUTH,
22
+ CHATGPT_RESET_CREDITS_URL: () => CHATGPT_RESET_CREDITS_URL,
22
23
  CHATGPT_USAGE_URL: () => CHATGPT_USAGE_URL,
23
24
  chatgptSubProvider: () => chatgptSubProvider,
25
+ parseChatgptResetCredits: () => parseChatgptResetCredits,
24
26
  parseChatgptUsage: () => parseChatgptUsage
25
27
  });
26
28
  module.exports = __toCommonJS(chatgpt_sub_exports);
@@ -28,6 +30,7 @@ var import_http = require("../http");
28
30
  var import_provider = require("../provider");
29
31
  var import_chatgpt_auth = require("./chatgpt-auth");
30
32
  const CHATGPT_USAGE_URL = "https://chatgpt.com/backend-api/wham/usage";
33
+ const CHATGPT_RESET_CREDITS_URL = "https://chatgpt.com/backend-api/wham/rate-limit-reset-credits";
31
34
  function readWindow(raw, name, label) {
32
35
  if (typeof raw !== "object" || raw === null) {
33
36
  return void 0;
@@ -48,7 +51,7 @@ function readWindow(raw, name, label) {
48
51
  function parseChatgptUsage(body) {
49
52
  var _a, _b;
50
53
  if (typeof body !== "object" || body === null) {
51
- throw new import_provider.FetchError("network", "unexpected usage response");
54
+ throw new import_provider.FetchError("service", "unexpected usage response");
52
55
  }
53
56
  const raw = body;
54
57
  const limits = [];
@@ -88,10 +91,42 @@ function parseChatgptUsage(body) {
88
91
  }
89
92
  return snapshot;
90
93
  }
94
+ function parseChatgptResetCredits(body, nowMs) {
95
+ const raw = typeof body === "object" && body !== null ? body : {};
96
+ const list = Array.isArray(raw.credits) ? raw.credits : null;
97
+ if (!list) {
98
+ const serverCount = Number(raw.available_count);
99
+ return { count: Number.isFinite(serverCount) && serverCount >= 0 ? serverCount : 0, nextExpiry: "" };
100
+ }
101
+ let count = 0;
102
+ let nextExpiry = "";
103
+ for (const entry of list) {
104
+ if (typeof entry !== "object" || entry === null) {
105
+ continue;
106
+ }
107
+ const voucher = entry;
108
+ if (voucher.status !== "available") {
109
+ continue;
110
+ }
111
+ const expiresAt = typeof voucher.expires_at === "string" ? voucher.expires_at : "";
112
+ if (expiresAt) {
113
+ const expiryMs = Date.parse(expiresAt);
114
+ if (Number.isFinite(expiryMs) && expiryMs <= nowMs) {
115
+ continue;
116
+ }
117
+ if (!nextExpiry || expiresAt < nextExpiry) {
118
+ nextExpiry = expiresAt;
119
+ }
120
+ }
121
+ count++;
122
+ }
123
+ return { count, nextExpiry };
124
+ }
91
125
  function chatgptSubProvider(store, postJson, fetchJson = import_http.getJson, now = Date.now) {
92
126
  return {
93
127
  kind: "chatgpt-sub",
94
128
  fetch: async () => {
129
+ var _a;
95
130
  let tokens = await store.load();
96
131
  if (!tokens) {
97
132
  throw new import_provider.FetchError("auth", "not signed in \u2014 start the ChatGPT sign-in in the instance settings");
@@ -107,15 +142,34 @@ function chatgptSubProvider(store, postJson, fetchJson = import_http.getJson, no
107
142
  if (tokens.accountRef) {
108
143
  headers["ChatGPT-Account-Id"] = tokens.accountRef;
109
144
  }
110
- return parseChatgptUsage(await fetchJson(CHATGPT_USAGE_URL, headers));
145
+ const snapshot = parseChatgptUsage(await fetchJson(CHATGPT_USAGE_URL, headers));
146
+ try {
147
+ const vouchers = parseChatgptResetCredits(
148
+ await fetchJson(CHATGPT_RESET_CREDITS_URL, {
149
+ ...headers,
150
+ // What OpenAI's own desktop client sends on this route (CodexBar-verified).
151
+ "OpenAI-Beta": "codex-1",
152
+ originator: "Codex Desktop"
153
+ }),
154
+ now()
155
+ );
156
+ const credits = (_a = snapshot.credits) != null ? _a : { currency: "USD" };
157
+ credits.resetCredits = vouchers.count;
158
+ credits.resetCreditsNextExpiry = vouchers.nextExpiry;
159
+ snapshot.credits = credits;
160
+ } catch {
161
+ }
162
+ return snapshot;
111
163
  }
112
164
  };
113
165
  }
114
166
  // Annotate the CommonJS export names for ESM import in node:
115
167
  0 && (module.exports = {
116
168
  CHATGPT_OAUTH,
169
+ CHATGPT_RESET_CREDITS_URL,
117
170
  CHATGPT_USAGE_URL,
118
171
  chatgptSubProvider,
172
+ parseChatgptResetCredits,
119
173
  parseChatgptUsage
120
174
  });
121
175
  //# sourceMappingURL=chatgpt-sub.js.map
@@ -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 // 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 * The tokens come from the store on every round and are never held here \u2014 see\n * {@link claudeSubProvider} for why that matters when the user signs out.\n *\n * @param store where the tokens live (keyed by provider, shared by no one else)\n * @param postJson the JSON-POST seam (token refresh)\n * @param fetchJson the JSON-GET seam\n * @param now clock (ms)\n * @returns the provider\n */\nexport function chatgptSubProvider(\n store: TokenStore,\n postJson: JsonPost,\n fetchJson: JsonFetch = getJson,\n now: () => number = Date.now,\n): UsageProvider {\n return {\n kind: \"chatgpt-sub\",\n fetch: async (): Promise<UsageSnapshot> => {\n let tokens: TokenSet | null = await store.load();\n if (!tokens) {\n throw new FetchError(\"auth\", \"not signed in \u2014 start the ChatGPT sign-in in the instance settings\");\n }\n if (now() >= tokens.expiresAt - 60_000) {\n tokens = await refreshChatgptTokens(tokens, postJson, now());\n await store.save(tokens);\n }\n const headers: Record<string, string> = {\n Authorization: `Bearer ${tokens.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 (tokens.accountRef) {\n headers[\"ChatGPT-Account-Id\"] = tokens.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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",
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 * The reset-voucher inventory (\"rate limit reset credits\" \u2014 purchasable vouchers\n * that clear a full limit window). Shape source-verified against CodexBar\n * (steipete/CodexBar, fetcher + test fixtures): `{ credits: [{ id, reset_type,\n * status, granted_at, expires_at }], available_count }`; the two extra headers\n * are what OpenAI's own desktop client sends on this route.\n */\nexport const CHATGPT_RESET_CREDITS_URL = \"https://chatgpt.com/backend-api/wham/rate-limit-reset-credits\";\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 // \"service\": the service answered with something we cannot read \u2014 that is a\n // fault of its own, not a missing connection.\n throw new FetchError(\"service\", \"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 * Read the reset-voucher inventory: how many vouchers are usable right now and\n * when the next one expires.\n *\n * Counted here rather than trusting the server's `available_count`: the answer can\n * carry vouchers whose status still says \"available\" although their expiry has\n * passed (CodexBar skips those for the same reason). A voucher without an expiry\n * counts as usable. Falls back to the server count when no list is present.\n *\n * @param body the parsed answer\n * @param nowMs current time (ms)\n * @returns voucher count plus the next expiry (empty string while none is held)\n */\nexport function parseChatgptResetCredits(body: unknown, nowMs: number): { count: number; nextExpiry: string } {\n const raw = (typeof body === \"object\" && body !== null ? body : {}) as Record<string, unknown>;\n const list = Array.isArray(raw.credits) ? raw.credits : null;\n if (!list) {\n const serverCount = Number(raw.available_count);\n return { count: Number.isFinite(serverCount) && serverCount >= 0 ? serverCount : 0, nextExpiry: \"\" };\n }\n let count = 0;\n let nextExpiry = \"\";\n for (const entry of list) {\n if (typeof entry !== \"object\" || entry === null) {\n continue;\n }\n const voucher = entry as Record<string, unknown>;\n if (voucher.status !== \"available\") {\n continue;\n }\n const expiresAt = typeof voucher.expires_at === \"string\" ? voucher.expires_at : \"\";\n if (expiresAt) {\n const expiryMs = Date.parse(expiresAt);\n if (Number.isFinite(expiryMs) && expiryMs <= nowMs) {\n continue; // stale: still flagged available, but already expired\n }\n if (!nextExpiry || expiresAt < nextExpiry) {\n nextExpiry = expiresAt;\n }\n }\n count++;\n }\n return { count, nextExpiry };\n}\n\n/**\n * The ChatGPT/Codex subscription provider.\n *\n * The tokens come from the store on every round and are never held here \u2014 see\n * {@link claudeSubProvider} for why that matters when the user signs out.\n *\n * @param store where the tokens live (keyed by provider, shared by no one else)\n * @param postJson the JSON-POST seam (token refresh)\n * @param fetchJson the JSON-GET seam\n * @param now clock (ms)\n * @returns the provider\n */\nexport function chatgptSubProvider(\n store: TokenStore,\n postJson: JsonPost,\n fetchJson: JsonFetch = getJson,\n now: () => number = Date.now,\n): UsageProvider {\n return {\n kind: \"chatgpt-sub\",\n fetch: async (): Promise<UsageSnapshot> => {\n let tokens: TokenSet | null = await store.load();\n if (!tokens) {\n throw new FetchError(\"auth\", \"not signed in \u2014 start the ChatGPT sign-in in the instance settings\");\n }\n if (now() >= tokens.expiresAt - 60_000) {\n tokens = await refreshChatgptTokens(tokens, postJson, now());\n await store.save(tokens);\n }\n const headers: Record<string, string> = {\n Authorization: `Bearer ${tokens.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 (tokens.accountRef) {\n headers[\"ChatGPT-Account-Id\"] = tokens.accountRef;\n }\n const snapshot = parseChatgptUsage(await fetchJson(CHATGPT_USAGE_URL, headers));\n // Reset-voucher inventory \u2014 best-effort second call: its failure must not\n // discard the usage snapshot that already succeeded. The datapoints keep\n // their last value in that case (the orphan sweep no longer touches\n // credit values), so a transient miss never makes them come and go.\n try {\n const vouchers = parseChatgptResetCredits(\n await fetchJson(CHATGPT_RESET_CREDITS_URL, {\n ...headers,\n // What OpenAI's own desktop client sends on this route (CodexBar-verified).\n \"OpenAI-Beta\": \"codex-1\",\n originator: \"Codex Desktop\",\n }),\n now(),\n );\n const credits = snapshot.credits ?? { currency: \"USD\" };\n credits.resetCredits = vouchers.count;\n credits.resetCreditsNextExpiry = vouchers.nextExpiry;\n snapshot.credits = credits;\n } catch {\n // inventory unavailable this round \u2014 the usage snapshot stands on its own\n }\n return snapshot;\n },\n };\n}\n\nexport { CHATGPT_OAUTH };\n"],
5
+ "mappings": ";;;;;;;;;;;;;;;;;;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA,kBAAwC;AACxC,sBAOO;AACP,0BAAmE;AAG5D,MAAM,oBAAoB;AAS1B,MAAM,4BAA4B;AAczC,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;AA/DhE;AAgEE,MAAI,OAAO,SAAS,YAAY,SAAS,MAAM;AAG7C,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;AAGV,aAAO,SAAS;AAChB,aAAO,KAAK,MAAM;AAAA,IACpB;AAAA,EACF;AAEA,QAAM,WAA0B,CAAC;AACjC,MAAI,OAAO,QAAQ;AACjB,aAAS,SAAS;AAAA,EACpB;AAEA,QAAM,WAAW,SAAI,YAAJ,YAAe,CAAC;AACjC,QAAM,UAAU,OAAO,QAAQ,OAAO;AACtC,MAAI,OAAO,SAAS,OAAO,KAAK,QAAQ,cAAc,MAAM;AAC1D,aAAS,UAAU,EAAE,WAAW,SAAS,UAAU,MAAM;AAAA,EAC3D;AACA,SAAO;AACT;AAeO,SAAS,yBAAyB,MAAe,OAAsD;AAC5G,QAAM,MAAO,OAAO,SAAS,YAAY,SAAS,OAAO,OAAO,CAAC;AACjE,QAAM,OAAO,MAAM,QAAQ,IAAI,OAAO,IAAI,IAAI,UAAU;AACxD,MAAI,CAAC,MAAM;AACT,UAAM,cAAc,OAAO,IAAI,eAAe;AAC9C,WAAO,EAAE,OAAO,OAAO,SAAS,WAAW,KAAK,eAAe,IAAI,cAAc,GAAG,YAAY,GAAG;AAAA,EACrG;AACA,MAAI,QAAQ;AACZ,MAAI,aAAa;AACjB,aAAW,SAAS,MAAM;AACxB,QAAI,OAAO,UAAU,YAAY,UAAU,MAAM;AAC/C;AAAA,IACF;AACA,UAAM,UAAU;AAChB,QAAI,QAAQ,WAAW,aAAa;AAClC;AAAA,IACF;AACA,UAAM,YAAY,OAAO,QAAQ,eAAe,WAAW,QAAQ,aAAa;AAChF,QAAI,WAAW;AACb,YAAM,WAAW,KAAK,MAAM,SAAS;AACrC,UAAI,OAAO,SAAS,QAAQ,KAAK,YAAY,OAAO;AAClD;AAAA,MACF;AACA,UAAI,CAAC,cAAc,YAAY,YAAY;AACzC,qBAAa;AAAA,MACf;AAAA,IACF;AACA;AAAA,EACF;AACA,SAAO,EAAE,OAAO,WAAW;AAC7B;AAcO,SAAS,mBACd,OACA,UACA,YAAuB,qBACvB,MAAoB,KAAK,KACV;AACf,SAAO;AAAA,IACL,MAAM;AAAA,IACN,OAAO,YAAoC;AAvL/C;AAwLM,UAAI,SAA0B,MAAM,MAAM,KAAK;AAC/C,UAAI,CAAC,QAAQ;AACX,cAAM,IAAI,2BAAW,QAAQ,yEAAoE;AAAA,MACnG;AACA,UAAI,IAAI,KAAK,OAAO,YAAY,KAAQ;AACtC,iBAAS,UAAM,0CAAqB,QAAQ,UAAU,IAAI,CAAC;AAC3D,cAAM,MAAM,KAAK,MAAM;AAAA,MACzB;AACA,YAAM,UAAkC;AAAA,QACtC,eAAe,UAAU,OAAO,WAAW;AAAA,QAC3C,cAAc;AAAA,MAChB;AAEA,UAAI,OAAO,YAAY;AACrB,gBAAQ,oBAAoB,IAAI,OAAO;AAAA,MACzC;AACA,YAAM,WAAW,kBAAkB,MAAM,UAAU,mBAAmB,OAAO,CAAC;AAK9E,UAAI;AACF,cAAM,WAAW;AAAA,UACf,MAAM,UAAU,2BAA2B;AAAA,YACzC,GAAG;AAAA;AAAA,YAEH,eAAe;AAAA,YACf,YAAY;AAAA,UACd,CAAC;AAAA,UACD,IAAI;AAAA,QACN;AACA,cAAM,WAAU,cAAS,YAAT,YAAoB,EAAE,UAAU,MAAM;AACtD,gBAAQ,eAAe,SAAS;AAChC,gBAAQ,yBAAyB,SAAS;AAC1C,iBAAS,UAAU;AAAA,MACrB,QAAQ;AAAA,MAER;AACA,aAAO;AAAA,IACT;AAAA,EACF;AACF;",
6
6
  "names": ["window"]
7
7
  }
@@ -32,10 +32,29 @@ const CLAUDE_OAUTH = {
32
32
  authorizeUrl: "https://claude.ai/oauth/authorize",
33
33
  tokenUrl: "https://console.anthropic.com/v1/oauth/token",
34
34
  redirectUri: "https://console.anthropic.com/oauth/code/callback",
35
- scopes: "org:create_api_key user:profile user:inference",
35
+ /**
36
+ * Least privilege: the usage endpoint works with the profile scope alone —
37
+ * proven by the HA reference integration, which narrowed to exactly this on
38
+ * 2026-08-28 (trickv/hass-claude-usage PR #14, released as v10). The broader
39
+ * set we started with (org:create_api_key + user:inference) let the stored
40
+ * token CREATE API keys and CALL models — powers a read-only monitor must not
41
+ * hold. Existing sign-ins keep working; the narrow scope applies from the
42
+ * next sign-in on.
43
+ */
44
+ scopes: "user:profile",
36
45
  usageUrl: "https://api.anthropic.com/api/oauth/usage",
37
46
  profileUrl: "https://api.anthropic.com/api/oauth/profile",
38
- betaHeader: "oauth-2025-04-20"
47
+ betaHeader: "oauth-2025-04-20",
48
+ /**
49
+ * The user agent decides WHICH throttle bucket the usage endpoint applies —
50
+ * community-measured three times over (Claude-Code-Usage-Monitor #202,
51
+ * claude-code #31021/#31637): a claude-code identity is safe at 3-minute
52
+ * polls, any other identity lands in an aggressively limited bucket with
53
+ * persistent 429s. Version pinned to the npm release current at build time;
54
+ * the bucket keys on the product name, not the exact number. Same approach
55
+ * as govee-smart, which identifies as the Govee app for the same reason.
56
+ */
57
+ userAgent: "claude-code/2.1.252"
39
58
  };
40
59
  function generatePkce() {
41
60
  const verifier = (0, import_node_crypto.randomBytes)(32).toString("base64url");
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "version": 3,
3
3
  "sources": ["../../../src/lib/providers/claude-auth.ts"],
4
- "sourcesContent": ["import { createHash, randomBytes } from \"node:crypto\";\nimport { FetchError, type TokenSet } from \"../provider\";\n\n/**\n * Claude subscription OAuth \u2014 the flow Claude Code and the HA reference integration\n * (trickv/hass-claude-usage, source-verified 2026-08-25) use: PKCE authorize on\n * claude.ai, the user pastes the resulting code, tokens come from the console\n * token endpoint and are refreshed with the refresh token.\n */\nexport const CLAUDE_OAUTH = {\n clientId: \"9d1c250a-e61b-44d9-88ed-5944d1962f5e\",\n authorizeUrl: \"https://claude.ai/oauth/authorize\",\n tokenUrl: \"https://console.anthropic.com/v1/oauth/token\",\n redirectUri: \"https://console.anthropic.com/oauth/code/callback\",\n scopes: \"org:create_api_key user:profile user:inference\",\n usageUrl: \"https://api.anthropic.com/api/oauth/usage\",\n profileUrl: \"https://api.anthropic.com/api/oauth/profile\",\n betaHeader: \"oauth-2025-04-20\",\n} as const;\n\n/** A PKCE pair for one sign-in attempt. */\nexport interface PkcePair {\n /** The secret verifier (sent in the code exchange). */\n verifier: string;\n /** The S256 challenge (sent in the authorize URL). */\n challenge: string;\n /** The CSRF state. */\n state: string;\n}\n\n/** The JSON-POST seam (tests inject a fake). */\nexport type JsonPost = (url: string, body: Record<string, unknown>) => Promise<unknown>;\n\n/**\n * Generate a PKCE verifier/challenge pair plus CSRF state.\n *\n * @returns the pair\n */\nexport function generatePkce(): PkcePair {\n const verifier = randomBytes(32).toString(\"base64url\");\n const challenge = createHash(\"sha256\").update(verifier).digest(\"base64url\");\n return { verifier, challenge, state: randomBytes(24).toString(\"base64url\") };\n}\n\n/**\n * Build the authorize URL the user opens to sign in.\n *\n * @param pkce the sign-in attempt's PKCE pair\n * @returns the URL\n */\nexport function buildAuthorizeUrl(pkce: PkcePair): string {\n const params = new URLSearchParams({\n code: \"true\",\n client_id: CLAUDE_OAUTH.clientId,\n response_type: \"code\",\n redirect_uri: CLAUDE_OAUTH.redirectUri,\n scope: CLAUDE_OAUTH.scopes,\n code_challenge: pkce.challenge,\n code_challenge_method: \"S256\",\n state: pkce.state,\n });\n return `${CLAUDE_OAUTH.authorizeUrl}?${params.toString()}`;\n}\n\n/**\n * Read a token response body into a {@link TokenSet}.\n *\n * @param body the token endpoint response\n * @param now current time (ms)\n * @param previousRefresh the refresh token to keep when the response carries none\n * @returns the token set\n */\nfunction tokenSetFrom(body: unknown, now: number, previousRefresh = \"\"): TokenSet {\n const data = body as Record<string, unknown> | null;\n const accessToken = typeof data?.access_token === \"string\" ? data.access_token : \"\";\n if (!accessToken) {\n throw new FetchError(\"auth\", \"token response carries no access token\");\n }\n const refreshToken =\n typeof data?.refresh_token === \"string\" && data.refresh_token ? data.refresh_token : previousRefresh;\n const expiresIn = Number(data?.expires_in);\n return {\n accessToken,\n refreshToken,\n expiresAt: now + (Number.isFinite(expiresIn) && expiresIn > 0 ? expiresIn : 3600) * 1000,\n };\n}\n\n/**\n * Exchange a pasted authorization code (possibly in the `code#state` form) for tokens.\n *\n * @param pastedCode what the user pasted\n * @param pkce the sign-in attempt's PKCE pair\n * @param postJson the JSON-POST seam\n * @param now current time (ms)\n * @returns the token set\n */\nexport async function exchangeCode(\n pastedCode: string,\n pkce: PkcePair,\n postJson: JsonPost,\n now: number,\n): Promise<TokenSet> {\n const [code, state = \"\"] = pastedCode.trim().split(\"#\");\n if (!code) {\n throw new FetchError(\"auth\", \"empty authorization code\");\n }\n if (state && state !== pkce.state) {\n throw new FetchError(\"auth\", \"state mismatch \u2014 start the sign-in again\");\n }\n const body = await postJson(CLAUDE_OAUTH.tokenUrl, {\n grant_type: \"authorization_code\",\n code,\n state,\n client_id: CLAUDE_OAUTH.clientId,\n redirect_uri: CLAUDE_OAUTH.redirectUri,\n code_verifier: pkce.verifier,\n });\n return tokenSetFrom(body, now);\n}\n\n/**\n * Obtain a fresh access token with the refresh token.\n *\n * @param tokens the current token set\n * @param postJson the JSON-POST seam\n * @param now current time (ms)\n * @returns the new token set (keeps the old refresh token when none is returned)\n */\nexport async function refreshTokens(tokens: TokenSet, postJson: JsonPost, now: number): Promise<TokenSet> {\n if (!tokens.refreshToken) {\n throw new FetchError(\"auth\", \"no refresh token \u2014 sign in again\");\n }\n const body = await postJson(CLAUDE_OAUTH.tokenUrl, {\n grant_type: \"refresh_token\",\n refresh_token: tokens.refreshToken,\n client_id: CLAUDE_OAUTH.clientId,\n });\n return tokenSetFrom(body, now, tokens.refreshToken);\n}\n"],
5
- "mappings": ";;;;;;;;;;;;;;;;;;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA,yBAAwC;AACxC,sBAA0C;AAQnC,MAAM,eAAe;AAAA,EAC1B,UAAU;AAAA,EACV,cAAc;AAAA,EACd,UAAU;AAAA,EACV,aAAa;AAAA,EACb,QAAQ;AAAA,EACR,UAAU;AAAA,EACV,YAAY;AAAA,EACZ,YAAY;AACd;AAoBO,SAAS,eAAyB;AACvC,QAAM,eAAW,gCAAY,EAAE,EAAE,SAAS,WAAW;AACrD,QAAM,gBAAY,+BAAW,QAAQ,EAAE,OAAO,QAAQ,EAAE,OAAO,WAAW;AAC1E,SAAO,EAAE,UAAU,WAAW,WAAO,gCAAY,EAAE,EAAE,SAAS,WAAW,EAAE;AAC7E;AAQO,SAAS,kBAAkB,MAAwB;AACxD,QAAM,SAAS,IAAI,gBAAgB;AAAA,IACjC,MAAM;AAAA,IACN,WAAW,aAAa;AAAA,IACxB,eAAe;AAAA,IACf,cAAc,aAAa;AAAA,IAC3B,OAAO,aAAa;AAAA,IACpB,gBAAgB,KAAK;AAAA,IACrB,uBAAuB;AAAA,IACvB,OAAO,KAAK;AAAA,EACd,CAAC;AACD,SAAO,GAAG,aAAa,YAAY,IAAI,OAAO,SAAS,CAAC;AAC1D;AAUA,SAAS,aAAa,MAAe,KAAa,kBAAkB,IAAc;AAChF,QAAM,OAAO;AACb,QAAM,cAAc,QAAO,6BAAM,kBAAiB,WAAW,KAAK,eAAe;AACjF,MAAI,CAAC,aAAa;AAChB,UAAM,IAAI,2BAAW,QAAQ,wCAAwC;AAAA,EACvE;AACA,QAAM,eACJ,QAAO,6BAAM,mBAAkB,YAAY,KAAK,gBAAgB,KAAK,gBAAgB;AACvF,QAAM,YAAY,OAAO,6BAAM,UAAU;AACzC,SAAO;AAAA,IACL;AAAA,IACA;AAAA,IACA,WAAW,OAAO,OAAO,SAAS,SAAS,KAAK,YAAY,IAAI,YAAY,QAAQ;AAAA,EACtF;AACF;AAWA,eAAsB,aACpB,YACA,MACA,UACA,KACmB;AACnB,QAAM,CAAC,MAAM,QAAQ,EAAE,IAAI,WAAW,KAAK,EAAE,MAAM,GAAG;AACtD,MAAI,CAAC,MAAM;AACT,UAAM,IAAI,2BAAW,QAAQ,0BAA0B;AAAA,EACzD;AACA,MAAI,SAAS,UAAU,KAAK,OAAO;AACjC,UAAM,IAAI,2BAAW,QAAQ,+CAA0C;AAAA,EACzE;AACA,QAAM,OAAO,MAAM,SAAS,aAAa,UAAU;AAAA,IACjD,YAAY;AAAA,IACZ;AAAA,IACA;AAAA,IACA,WAAW,aAAa;AAAA,IACxB,cAAc,aAAa;AAAA,IAC3B,eAAe,KAAK;AAAA,EACtB,CAAC;AACD,SAAO,aAAa,MAAM,GAAG;AAC/B;AAUA,eAAsB,cAAc,QAAkB,UAAoB,KAAgC;AACxG,MAAI,CAAC,OAAO,cAAc;AACxB,UAAM,IAAI,2BAAW,QAAQ,uCAAkC;AAAA,EACjE;AACA,QAAM,OAAO,MAAM,SAAS,aAAa,UAAU;AAAA,IACjD,YAAY;AAAA,IACZ,eAAe,OAAO;AAAA,IACtB,WAAW,aAAa;AAAA,EAC1B,CAAC;AACD,SAAO,aAAa,MAAM,KAAK,OAAO,YAAY;AACpD;",
4
+ "sourcesContent": ["import { createHash, randomBytes } from \"node:crypto\";\nimport { FetchError, type TokenSet } from \"../provider\";\n\n/**\n * Claude subscription OAuth \u2014 the flow Claude Code and the HA reference integration\n * (trickv/hass-claude-usage, source-verified 2026-08-25) use: PKCE authorize on\n * claude.ai, the user pastes the resulting code, tokens come from the console\n * token endpoint and are refreshed with the refresh token.\n */\nexport const CLAUDE_OAUTH = {\n clientId: \"9d1c250a-e61b-44d9-88ed-5944d1962f5e\",\n authorizeUrl: \"https://claude.ai/oauth/authorize\",\n tokenUrl: \"https://console.anthropic.com/v1/oauth/token\",\n redirectUri: \"https://console.anthropic.com/oauth/code/callback\",\n /**\n * Least privilege: the usage endpoint works with the profile scope alone \u2014\n * proven by the HA reference integration, which narrowed to exactly this on\n * 2026-08-28 (trickv/hass-claude-usage PR #14, released as v10). The broader\n * set we started with (org:create_api_key + user:inference) let the stored\n * token CREATE API keys and CALL models \u2014 powers a read-only monitor must not\n * hold. Existing sign-ins keep working; the narrow scope applies from the\n * next sign-in on.\n */\n scopes: \"user:profile\",\n usageUrl: \"https://api.anthropic.com/api/oauth/usage\",\n profileUrl: \"https://api.anthropic.com/api/oauth/profile\",\n betaHeader: \"oauth-2025-04-20\",\n /**\n * The user agent decides WHICH throttle bucket the usage endpoint applies \u2014\n * community-measured three times over (Claude-Code-Usage-Monitor #202,\n * claude-code #31021/#31637): a claude-code identity is safe at 3-minute\n * polls, any other identity lands in an aggressively limited bucket with\n * persistent 429s. Version pinned to the npm release current at build time;\n * the bucket keys on the product name, not the exact number. Same approach\n * as govee-smart, which identifies as the Govee app for the same reason.\n */\n userAgent: \"claude-code/2.1.252\",\n} as const;\n\n/** A PKCE pair for one sign-in attempt. */\nexport interface PkcePair {\n /** The secret verifier (sent in the code exchange). */\n verifier: string;\n /** The S256 challenge (sent in the authorize URL). */\n challenge: string;\n /** The CSRF state. */\n state: string;\n}\n\n/** The JSON-POST seam (tests inject a fake). */\nexport type JsonPost = (url: string, body: Record<string, unknown>) => Promise<unknown>;\n\n/**\n * Generate a PKCE verifier/challenge pair plus CSRF state.\n *\n * @returns the pair\n */\nexport function generatePkce(): PkcePair {\n const verifier = randomBytes(32).toString(\"base64url\");\n const challenge = createHash(\"sha256\").update(verifier).digest(\"base64url\");\n return { verifier, challenge, state: randomBytes(24).toString(\"base64url\") };\n}\n\n/**\n * Build the authorize URL the user opens to sign in.\n *\n * @param pkce the sign-in attempt's PKCE pair\n * @returns the URL\n */\nexport function buildAuthorizeUrl(pkce: PkcePair): string {\n const params = new URLSearchParams({\n code: \"true\",\n client_id: CLAUDE_OAUTH.clientId,\n response_type: \"code\",\n redirect_uri: CLAUDE_OAUTH.redirectUri,\n scope: CLAUDE_OAUTH.scopes,\n code_challenge: pkce.challenge,\n code_challenge_method: \"S256\",\n state: pkce.state,\n });\n return `${CLAUDE_OAUTH.authorizeUrl}?${params.toString()}`;\n}\n\n/**\n * Read a token response body into a {@link TokenSet}.\n *\n * @param body the token endpoint response\n * @param now current time (ms)\n * @param previousRefresh the refresh token to keep when the response carries none\n * @returns the token set\n */\nfunction tokenSetFrom(body: unknown, now: number, previousRefresh = \"\"): TokenSet {\n const data = body as Record<string, unknown> | null;\n const accessToken = typeof data?.access_token === \"string\" ? data.access_token : \"\";\n if (!accessToken) {\n throw new FetchError(\"auth\", \"token response carries no access token\");\n }\n const refreshToken =\n typeof data?.refresh_token === \"string\" && data.refresh_token ? data.refresh_token : previousRefresh;\n const expiresIn = Number(data?.expires_in);\n return {\n accessToken,\n refreshToken,\n expiresAt: now + (Number.isFinite(expiresIn) && expiresIn > 0 ? expiresIn : 3600) * 1000,\n };\n}\n\n/**\n * Exchange a pasted authorization code (possibly in the `code#state` form) for tokens.\n *\n * @param pastedCode what the user pasted\n * @param pkce the sign-in attempt's PKCE pair\n * @param postJson the JSON-POST seam\n * @param now current time (ms)\n * @returns the token set\n */\nexport async function exchangeCode(\n pastedCode: string,\n pkce: PkcePair,\n postJson: JsonPost,\n now: number,\n): Promise<TokenSet> {\n const [code, state = \"\"] = pastedCode.trim().split(\"#\");\n if (!code) {\n throw new FetchError(\"auth\", \"empty authorization code\");\n }\n if (state && state !== pkce.state) {\n throw new FetchError(\"auth\", \"state mismatch \u2014 start the sign-in again\");\n }\n const body = await postJson(CLAUDE_OAUTH.tokenUrl, {\n grant_type: \"authorization_code\",\n code,\n state,\n client_id: CLAUDE_OAUTH.clientId,\n redirect_uri: CLAUDE_OAUTH.redirectUri,\n code_verifier: pkce.verifier,\n });\n return tokenSetFrom(body, now);\n}\n\n/**\n * Obtain a fresh access token with the refresh token.\n *\n * @param tokens the current token set\n * @param postJson the JSON-POST seam\n * @param now current time (ms)\n * @returns the new token set (keeps the old refresh token when none is returned)\n */\nexport async function refreshTokens(tokens: TokenSet, postJson: JsonPost, now: number): Promise<TokenSet> {\n if (!tokens.refreshToken) {\n throw new FetchError(\"auth\", \"no refresh token \u2014 sign in again\");\n }\n const body = await postJson(CLAUDE_OAUTH.tokenUrl, {\n grant_type: \"refresh_token\",\n refresh_token: tokens.refreshToken,\n client_id: CLAUDE_OAUTH.clientId,\n });\n return tokenSetFrom(body, now, tokens.refreshToken);\n}\n"],
5
+ "mappings": ";;;;;;;;;;;;;;;;;;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA,yBAAwC;AACxC,sBAA0C;AAQnC,MAAM,eAAe;AAAA,EAC1B,UAAU;AAAA,EACV,cAAc;AAAA,EACd,UAAU;AAAA,EACV,aAAa;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA,EAUb,QAAQ;AAAA,EACR,UAAU;AAAA,EACV,YAAY;AAAA,EACZ,YAAY;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA,EAUZ,WAAW;AACb;AAoBO,SAAS,eAAyB;AACvC,QAAM,eAAW,gCAAY,EAAE,EAAE,SAAS,WAAW;AACrD,QAAM,gBAAY,+BAAW,QAAQ,EAAE,OAAO,QAAQ,EAAE,OAAO,WAAW;AAC1E,SAAO,EAAE,UAAU,WAAW,WAAO,gCAAY,EAAE,EAAE,SAAS,WAAW,EAAE;AAC7E;AAQO,SAAS,kBAAkB,MAAwB;AACxD,QAAM,SAAS,IAAI,gBAAgB;AAAA,IACjC,MAAM;AAAA,IACN,WAAW,aAAa;AAAA,IACxB,eAAe;AAAA,IACf,cAAc,aAAa;AAAA,IAC3B,OAAO,aAAa;AAAA,IACpB,gBAAgB,KAAK;AAAA,IACrB,uBAAuB;AAAA,IACvB,OAAO,KAAK;AAAA,EACd,CAAC;AACD,SAAO,GAAG,aAAa,YAAY,IAAI,OAAO,SAAS,CAAC;AAC1D;AAUA,SAAS,aAAa,MAAe,KAAa,kBAAkB,IAAc;AAChF,QAAM,OAAO;AACb,QAAM,cAAc,QAAO,6BAAM,kBAAiB,WAAW,KAAK,eAAe;AACjF,MAAI,CAAC,aAAa;AAChB,UAAM,IAAI,2BAAW,QAAQ,wCAAwC;AAAA,EACvE;AACA,QAAM,eACJ,QAAO,6BAAM,mBAAkB,YAAY,KAAK,gBAAgB,KAAK,gBAAgB;AACvF,QAAM,YAAY,OAAO,6BAAM,UAAU;AACzC,SAAO;AAAA,IACL;AAAA,IACA;AAAA,IACA,WAAW,OAAO,OAAO,SAAS,SAAS,KAAK,YAAY,IAAI,YAAY,QAAQ;AAAA,EACtF;AACF;AAWA,eAAsB,aACpB,YACA,MACA,UACA,KACmB;AACnB,QAAM,CAAC,MAAM,QAAQ,EAAE,IAAI,WAAW,KAAK,EAAE,MAAM,GAAG;AACtD,MAAI,CAAC,MAAM;AACT,UAAM,IAAI,2BAAW,QAAQ,0BAA0B;AAAA,EACzD;AACA,MAAI,SAAS,UAAU,KAAK,OAAO;AACjC,UAAM,IAAI,2BAAW,QAAQ,+CAA0C;AAAA,EACzE;AACA,QAAM,OAAO,MAAM,SAAS,aAAa,UAAU;AAAA,IACjD,YAAY;AAAA,IACZ;AAAA,IACA;AAAA,IACA,WAAW,aAAa;AAAA,IACxB,cAAc,aAAa;AAAA,IAC3B,eAAe,KAAK;AAAA,EACtB,CAAC;AACD,SAAO,aAAa,MAAM,GAAG;AAC/B;AAUA,eAAsB,cAAc,QAAkB,UAAoB,KAAgC;AACxG,MAAI,CAAC,OAAO,cAAc;AACxB,UAAM,IAAI,2BAAW,QAAQ,uCAAkC;AAAA,EACjE;AACA,QAAM,OAAO,MAAM,SAAS,aAAa,UAAU;AAAA,IACjD,YAAY;AAAA,IACZ,eAAe,OAAO;AAAA,IACtB,WAAW,aAAa;AAAA,EAC1B,CAAC;AACD,SAAO,aAAa,MAAM,KAAK,OAAO,YAAY;AACpD;",
6
6
  "names": []
7
7
  }
@@ -29,7 +29,7 @@ var import_claude_auth = require("./claude-auth");
29
29
  function parseClaudeUsage(body) {
30
30
  var _a, _b;
31
31
  if (typeof body !== "object" || body === null) {
32
- throw new import_provider.FetchError("network", "unexpected usage response");
32
+ throw new import_provider.FetchError("service", "unexpected usage response");
33
33
  }
34
34
  const raw = body;
35
35
  const limits = [];
@@ -152,9 +152,10 @@ function claudeSubProvider(store, postJson, fetchJson = import_http.getJson, now
152
152
  const body = await fetchJson(import_claude_auth.CLAUDE_OAUTH.usageUrl, {
153
153
  Authorization: `Bearer ${tokens.accessToken}`,
154
154
  "anthropic-beta": import_claude_auth.CLAUDE_OAUTH.betaHeader,
155
- // Identify ourselves — an unset/odd user agent lands in a harder-throttled
156
- // bucket of this endpoint (community-measured; sources in the concept doc).
157
- "User-Agent": "ioBroker.ai-usage"
155
+ // The claude-code identity, NOT our own name: the endpoint's throttle
156
+ // bucket keys on this header, and our previous "ioBroker.ai-usage"
157
+ // identity sat in the aggressive bucket — see CLAUDE_OAUTH.userAgent.
158
+ "User-Agent": import_claude_auth.CLAUDE_OAUTH.userAgent
158
159
  });
159
160
  return parseClaudeUsage(body);
160
161
  }
@@ -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, 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 * The tokens are read from the store on every round and never kept here. Holding\n * them would survive a sign-out \u2014 the file would be gone, the adapter would keep\n * polling with what it still had, and the next refresh would write the file back.\n * The store owns the tokens and their cache; this module only uses them.\n *\n * @param store the token storage\n * @param postJson the JSON-POST seam (token refresh)\n * @param fetchJson the JSON-GET seam\n * @param now clock (ms) \u2014 injected for tests\n * @returns the provider\n */\nexport function claudeSubProvider(\n store: TokenStore,\n postJson: JsonPost,\n fetchJson: JsonFetch = getJson,\n now: () => number = Date.now,\n): UsageProvider {\n return {\n kind: \"claude-sub\",\n fetch: async (): Promise<UsageSnapshot> => {\n let tokens: TokenSet | null = await store.load();\n if (!tokens) {\n throw new FetchError(\"auth\", \"not signed in \u2014 run the Claude sign-in in the instance settings\");\n }\n if (now() >= tokens.expiresAt - 60_000) {\n tokens = await refreshTokens(tokens, postJson, now());\n await store.save(tokens);\n }\n const body = await fetchJson(CLAUDE_OAUTH.usageUrl, {\n Authorization: `Bearer ${tokens.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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",
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 // \"service\", not \"network\": a body we could parse but cannot understand\n // means the service ANSWERED and is broken \u2014 reporting it as \"no\n // connection\" (with three tolerated attempts) hid a real fault.\n throw new FetchError(\"service\", \"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 * The tokens are read from the store on every round and never kept here. Holding\n * them would survive a sign-out \u2014 the file would be gone, the adapter would keep\n * polling with what it still had, and the next refresh would write the file back.\n * The store owns the tokens and their cache; this module only uses them.\n *\n * @param store the token storage\n * @param postJson the JSON-POST seam (token refresh)\n * @param fetchJson the JSON-GET seam\n * @param now clock (ms) \u2014 injected for tests\n * @returns the provider\n */\nexport function claudeSubProvider(\n store: TokenStore,\n postJson: JsonPost,\n fetchJson: JsonFetch = getJson,\n now: () => number = Date.now,\n): UsageProvider {\n return {\n kind: \"claude-sub\",\n fetch: async (): Promise<UsageSnapshot> => {\n let tokens: TokenSet | null = await store.load();\n if (!tokens) {\n throw new FetchError(\"auth\", \"not signed in \u2014 run the Claude sign-in in the instance settings\");\n }\n if (now() >= tokens.expiresAt - 60_000) {\n tokens = await refreshTokens(tokens, postJson, now());\n await store.save(tokens);\n }\n const body = await fetchJson(CLAUDE_OAUTH.usageUrl, {\n Authorization: `Bearer ${tokens.accessToken}`,\n \"anthropic-beta\": CLAUDE_OAUTH.betaHeader,\n // The claude-code identity, NOT our own name: the endpoint's throttle\n // bucket keys on this header, and our previous \"ioBroker.ai-usage\"\n // identity sat in the aggressive bucket \u2014 see CLAUDE_OAUTH.userAgent.\n \"User-Agent\": CLAUDE_OAUTH.userAgent,\n });\n return parseClaudeUsage(body);\n },\n };\n}\n"],
5
+ "mappings": "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",
6
6
  "names": []
7
7
  }
@@ -28,7 +28,7 @@ var import_pure_helpers = require("../pure-helpers");
28
28
  function parseDeepSeekBalance(body) {
29
29
  const obj = body;
30
30
  if (typeof obj !== "object" || obj === null || !Array.isArray(obj.balance_infos)) {
31
- throw new import_provider.FetchError("network", "unexpected response shape (no balance_infos)");
31
+ throw new import_provider.FetchError("service", "unexpected response shape (no balance_infos)");
32
32
  }
33
33
  const first = obj.balance_infos.find((entry) => typeof entry === "object" && entry !== null);
34
34
  const snapshot = {};
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "version": 3,
3
3
  "sources": ["../../../src/lib/providers/deepseek.ts"],
4
- "sourcesContent": ["import { getJson, type JsonFetch } from \"../http\";\nimport { FetchError, type UsageProvider, type UsageSnapshot } from \"../provider\";\nimport { finiteNumber } from \"../pure-helpers\";\n\n/**\n * Parse a DeepSeek `GET /user/balance` response into a snapshot. Capture-verified\n * shape: `{ is_available, balance_infos: [{ currency, total_balance,\n * granted_balance, topped_up_balance }] }` \u2014 the amounts arrive as strings.\n * With several currency entries the first is used (the rest is ignored).\n *\n * @param body the response body\n * @returns the snapshot\n */\nexport function parseDeepSeekBalance(body: unknown): UsageSnapshot {\n const obj = body as { is_available?: unknown; balance_infos?: unknown } | null;\n if (typeof obj !== \"object\" || obj === null || !Array.isArray(obj.balance_infos)) {\n throw new FetchError(\"network\", \"unexpected response shape (no balance_infos)\");\n }\n const first = obj.balance_infos.find(entry => typeof entry === \"object\" && entry !== null) as\n Record<string, unknown> | undefined;\n const snapshot: UsageSnapshot = {};\n if (typeof obj.is_available === \"boolean\") {\n snapshot.available = obj.is_available;\n }\n if (first) {\n snapshot.credits = {\n remaining: finiteNumber(first.total_balance),\n granted: finiteNumber(first.granted_balance),\n toppedUp: finiteNumber(first.topped_up_balance),\n currency: typeof first.currency === \"string\" ? first.currency : \"USD\",\n };\n }\n return snapshot;\n}\n\n/**\n * The DeepSeek provider: reads the account balance.\n *\n * @param apiKey the DeepSeek API key\n * @param fetchJson the JSON-GET seam (tests inject a fake)\n * @returns the provider\n */\nexport function deepSeekProvider(apiKey: string, fetchJson: JsonFetch = getJson): UsageProvider {\n return {\n kind: \"deepseek\",\n fetch: async (): Promise<UsageSnapshot> =>\n parseDeepSeekBalance(\n await fetchJson(\"https://api.deepseek.com/user/balance\", {\n Authorization: `Bearer ${apiKey}`,\n Accept: \"application/json\",\n }),\n ),\n };\n}\n"],
5
- "mappings": ";;;;;;;;;;;;;;;;;;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA,kBAAwC;AACxC,sBAAmE;AACnE,0BAA6B;AAWtB,SAAS,qBAAqB,MAA8B;AACjE,QAAM,MAAM;AACZ,MAAI,OAAO,QAAQ,YAAY,QAAQ,QAAQ,CAAC,MAAM,QAAQ,IAAI,aAAa,GAAG;AAChF,UAAM,IAAI,2BAAW,WAAW,8CAA8C;AAAA,EAChF;AACA,QAAM,QAAQ,IAAI,cAAc,KAAK,WAAS,OAAO,UAAU,YAAY,UAAU,IAAI;AAEzF,QAAM,WAA0B,CAAC;AACjC,MAAI,OAAO,IAAI,iBAAiB,WAAW;AACzC,aAAS,YAAY,IAAI;AAAA,EAC3B;AACA,MAAI,OAAO;AACT,aAAS,UAAU;AAAA,MACjB,eAAW,kCAAa,MAAM,aAAa;AAAA,MAC3C,aAAS,kCAAa,MAAM,eAAe;AAAA,MAC3C,cAAU,kCAAa,MAAM,iBAAiB;AAAA,MAC9C,UAAU,OAAO,MAAM,aAAa,WAAW,MAAM,WAAW;AAAA,IAClE;AAAA,EACF;AACA,SAAO;AACT;AASO,SAAS,iBAAiB,QAAgB,YAAuB,qBAAwB;AAC9F,SAAO;AAAA,IACL,MAAM;AAAA,IACN,OAAO,YACL;AAAA,MACE,MAAM,UAAU,yCAAyC;AAAA,QACvD,eAAe,UAAU,MAAM;AAAA,QAC/B,QAAQ;AAAA,MACV,CAAC;AAAA,IACH;AAAA,EACJ;AACF;",
4
+ "sourcesContent": ["import { getJson, type JsonFetch } from \"../http\";\nimport { FetchError, type UsageProvider, type UsageSnapshot } from \"../provider\";\nimport { finiteNumber } from \"../pure-helpers\";\n\n/**\n * Parse a DeepSeek `GET /user/balance` response into a snapshot. Capture-verified\n * shape: `{ is_available, balance_infos: [{ currency, total_balance,\n * granted_balance, topped_up_balance }] }` \u2014 the amounts arrive as strings.\n * With several currency entries the first is used (the rest is ignored).\n *\n * @param body the response body\n * @returns the snapshot\n */\nexport function parseDeepSeekBalance(body: unknown): UsageSnapshot {\n const obj = body as { is_available?: unknown; balance_infos?: unknown } | null;\n if (typeof obj !== \"object\" || obj === null || !Array.isArray(obj.balance_infos)) {\n // \"service\": DeepSeek answered, we just cannot read it \u2014 not \"no connection\".\n throw new FetchError(\"service\", \"unexpected response shape (no balance_infos)\");\n }\n const first = obj.balance_infos.find(entry => typeof entry === \"object\" && entry !== null) as\n Record<string, unknown> | undefined;\n const snapshot: UsageSnapshot = {};\n if (typeof obj.is_available === \"boolean\") {\n snapshot.available = obj.is_available;\n }\n if (first) {\n snapshot.credits = {\n remaining: finiteNumber(first.total_balance),\n granted: finiteNumber(first.granted_balance),\n toppedUp: finiteNumber(first.topped_up_balance),\n currency: typeof first.currency === \"string\" ? first.currency : \"USD\",\n };\n }\n return snapshot;\n}\n\n/**\n * The DeepSeek provider: reads the account balance.\n *\n * @param apiKey the DeepSeek API key\n * @param fetchJson the JSON-GET seam (tests inject a fake)\n * @returns the provider\n */\nexport function deepSeekProvider(apiKey: string, fetchJson: JsonFetch = getJson): UsageProvider {\n return {\n kind: \"deepseek\",\n fetch: async (): Promise<UsageSnapshot> =>\n parseDeepSeekBalance(\n await fetchJson(\"https://api.deepseek.com/user/balance\", {\n Authorization: `Bearer ${apiKey}`,\n Accept: \"application/json\",\n }),\n ),\n };\n}\n"],
5
+ "mappings": ";;;;;;;;;;;;;;;;;;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA,kBAAwC;AACxC,sBAAmE;AACnE,0BAA6B;AAWtB,SAAS,qBAAqB,MAA8B;AACjE,QAAM,MAAM;AACZ,MAAI,OAAO,QAAQ,YAAY,QAAQ,QAAQ,CAAC,MAAM,QAAQ,IAAI,aAAa,GAAG;AAEhF,UAAM,IAAI,2BAAW,WAAW,8CAA8C;AAAA,EAChF;AACA,QAAM,QAAQ,IAAI,cAAc,KAAK,WAAS,OAAO,UAAU,YAAY,UAAU,IAAI;AAEzF,QAAM,WAA0B,CAAC;AACjC,MAAI,OAAO,IAAI,iBAAiB,WAAW;AACzC,aAAS,YAAY,IAAI;AAAA,EAC3B;AACA,MAAI,OAAO;AACT,aAAS,UAAU;AAAA,MACjB,eAAW,kCAAa,MAAM,aAAa;AAAA,MAC3C,aAAS,kCAAa,MAAM,eAAe;AAAA,MAC3C,cAAU,kCAAa,MAAM,iBAAiB;AAAA,MAC9C,UAAU,OAAO,MAAM,aAAa,WAAW,MAAM,WAAW;AAAA,IAClE;AAAA,EACF;AACA,SAAO;AACT;AASO,SAAS,iBAAiB,QAAgB,YAAuB,qBAAwB;AAC9F,SAAO;AAAA,IACL,MAAM;AAAA,IACN,OAAO,YACL;AAAA,MACE,MAAM,UAAU,yCAAyC;AAAA,QACvD,eAAe,UAAU,MAAM;AAAA,QAC/B,QAAQ;AAAA,MACV,CAAC;AAAA,IACH;AAAA,EACJ;AACF;",
6
6
  "names": []
7
7
  }
@@ -65,7 +65,7 @@ function parseCodeAssist(body) {
65
65
  }
66
66
  function parseGeminiQuota(body) {
67
67
  if (typeof body !== "object" || body === null) {
68
- throw new import_provider.FetchError("network", "unexpected quota response");
68
+ throw new import_provider.FetchError("service", "unexpected quota response");
69
69
  }
70
70
  const raw = body;
71
71
  const buckets = Array.isArray(raw.buckets) ? raw.buckets : [];
@@ -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 // Marked `scoped` like every other per-model bucket: Google reports NO plan-wide\n // window, so `limitingWindow` falls back to the fullest of these and names the\n // model in the warning. Without the mark, every single model could raise the\n // account's warning \u2014 the \"Fable at 100 %\" case that made the alarm meaningless.\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 scoped: true,\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 return {\n kind: \"gemini-sub\",\n fetch: async (): Promise<UsageSnapshot> => {\n let tokens: TokenSet | null = await store.load();\n if (!tokens) {\n throw new FetchError(\"auth\", \"not signed in \u2014 start the Google sign-in in the instance settings\");\n }\n if (now() >= tokens.expiresAt - 60_000) {\n tokens = await refreshGeminiTokens(tokens, postForm, now());\n await store.save(tokens);\n }\n // The project id is stable per account \u2014 look it up once, then reuse it. It\n // is stored with the tokens, so the store's cache keeps it for us.\n if (!tokens.accountRef) {\n const info = parseCodeAssist(\n await callCodeAssist(\n \"loadCodeAssist\",\n { metadata: { ideType: GEMINI_IDENTITY.ideType } },\n tokens.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 tokens = { ...tokens, accountRef: info.project };\n await store.save(tokens);\n }\n return parseGeminiQuota(\n await callCodeAssist(\"retrieveUserQuota\", { project: tokens.accountRef }, tokens.accessToken, post),\n );\n },\n };\n}\n"],
5
- "mappings": "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",
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 // \"service\": the endpoint answered with an unreadable body \u2014 its own fault,\n // not a connection problem (which only a thrown fetch means).\n throw new FetchError(\"service\", \"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 // Marked `scoped` like every other per-model bucket: Google reports NO plan-wide\n // window, so `limitingWindow` falls back to the fullest of these and names the\n // model in the warning. Without the mark, every single model could raise the\n // account's warning \u2014 the \"Fable at 100 %\" case that made the alarm meaningless.\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 scoped: true,\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 return {\n kind: \"gemini-sub\",\n fetch: async (): Promise<UsageSnapshot> => {\n let tokens: TokenSet | null = await store.load();\n if (!tokens) {\n throw new FetchError(\"auth\", \"not signed in \u2014 start the Google sign-in in the instance settings\");\n }\n if (now() >= tokens.expiresAt - 60_000) {\n tokens = await refreshGeminiTokens(tokens, postForm, now());\n await store.save(tokens);\n }\n // The project id is stable per account \u2014 look it up once, then reuse it. It\n // is stored with the tokens, so the store's cache keeps it for us.\n if (!tokens.accountRef) {\n const info = parseCodeAssist(\n await callCodeAssist(\n \"loadCodeAssist\",\n { metadata: { ideType: GEMINI_IDENTITY.ideType } },\n tokens.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 tokens = { ...tokens, accountRef: info.project };\n await store.save(tokens);\n }\n return parseGeminiQuota(\n await callCodeAssist(\"retrieveUserQuota\", { project: tokens.accountRef }, tokens.accessToken, post),\n );\n },\n };\n}\n"],
5
+ "mappings": "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",
6
6
  "names": []
7
7
  }
@@ -29,7 +29,7 @@ function parseOpenRouterKeyInfo(body) {
29
29
  var _a, _b, _c;
30
30
  const data = body == null ? void 0 : body.data;
31
31
  if (typeof data !== "object" || data === null) {
32
- throw new import_provider.FetchError("network", "unexpected response shape (no data object)");
32
+ throw new import_provider.FetchError("service", "unexpected response shape (no data object)");
33
33
  }
34
34
  const info = data;
35
35
  const used = (0, import_pure_helpers.finiteNumber)((_a = info.usage) != null ? _a : info.credits_used);
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "version": 3,
3
3
  "sources": ["../../../src/lib/providers/openrouter.ts"],
4
- "sourcesContent": ["import { getJson, type JsonFetch } from \"../http\";\nimport { FetchError, type UsageProvider, type UsageSnapshot } from \"../provider\";\nimport { finiteNumber, round2 } from \"../pure-helpers\";\n\n/**\n * Parse an OpenRouter `GET /api/v1/auth/key` response into a snapshot. The key info\n * carries lifetime usage in dollars and an optional credit ceiling. Field names are\n * read defensively (`usage`/`limit` per the API reference, `credits_used`/\n * `credit_limit` as seen in community captures).\n *\n * @param body the response body\n * @returns the snapshot\n */\nexport function parseOpenRouterKeyInfo(body: unknown): UsageSnapshot {\n const data = (body as { data?: unknown } | null)?.data;\n if (typeof data !== \"object\" || data === null) {\n throw new FetchError(\"network\", \"unexpected response shape (no data object)\");\n }\n const info = data as Record<string, unknown>;\n const used = finiteNumber(info.usage ?? info.credits_used);\n const limit = finiteNumber(info.limit ?? info.credit_limit);\n const remaining =\n finiteNumber(info.limit_remaining) ??\n (used !== undefined && limit !== undefined ? round2(limit - used) : undefined);\n const snapshot: UsageSnapshot = {\n credits: {\n used,\n limit,\n remaining,\n percent: used !== undefined && limit !== undefined && limit > 0 ? round2((used / limit) * 100) : undefined,\n currency: \"USD\",\n },\n };\n if (used !== undefined) {\n snapshot.costs = { total: used, currency: \"USD\" };\n }\n return snapshot;\n}\n\n/**\n * The OpenRouter provider: reads the key info (credits used/limit/remaining).\n *\n * @param apiKey the OpenRouter API key\n * @param fetchJson the JSON-GET seam (tests inject a fake)\n * @returns the provider\n */\nexport function openRouterProvider(apiKey: string, fetchJson: JsonFetch = getJson): UsageProvider {\n return {\n kind: \"openrouter\",\n fetch: async (): Promise<UsageSnapshot> =>\n parseOpenRouterKeyInfo(\n await fetchJson(\"https://openrouter.ai/api/v1/auth/key\", { Authorization: `Bearer ${apiKey}` }),\n ),\n };\n}\n"],
5
- "mappings": ";;;;;;;;;;;;;;;;;;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA,kBAAwC;AACxC,sBAAmE;AACnE,0BAAqC;AAW9B,SAAS,uBAAuB,MAA8B;AAbrE;AAcE,QAAM,OAAQ,6BAAoC;AAClD,MAAI,OAAO,SAAS,YAAY,SAAS,MAAM;AAC7C,UAAM,IAAI,2BAAW,WAAW,4CAA4C;AAAA,EAC9E;AACA,QAAM,OAAO;AACb,QAAM,WAAO,mCAAa,UAAK,UAAL,YAAc,KAAK,YAAY;AACzD,QAAM,YAAQ,mCAAa,UAAK,UAAL,YAAc,KAAK,YAAY;AAC1D,QAAM,aACJ,2CAAa,KAAK,eAAe,MAAjC,YACC,SAAS,UAAa,UAAU,aAAY,4BAAO,QAAQ,IAAI,IAAI;AACtE,QAAM,WAA0B;AAAA,IAC9B,SAAS;AAAA,MACP;AAAA,MACA;AAAA,MACA;AAAA,MACA,SAAS,SAAS,UAAa,UAAU,UAAa,QAAQ,QAAI,4BAAQ,OAAO,QAAS,GAAG,IAAI;AAAA,MACjG,UAAU;AAAA,IACZ;AAAA,EACF;AACA,MAAI,SAAS,QAAW;AACtB,aAAS,QAAQ,EAAE,OAAO,MAAM,UAAU,MAAM;AAAA,EAClD;AACA,SAAO;AACT;AASO,SAAS,mBAAmB,QAAgB,YAAuB,qBAAwB;AAChG,SAAO;AAAA,IACL,MAAM;AAAA,IACN,OAAO,YACL;AAAA,MACE,MAAM,UAAU,yCAAyC,EAAE,eAAe,UAAU,MAAM,GAAG,CAAC;AAAA,IAChG;AAAA,EACJ;AACF;",
4
+ "sourcesContent": ["import { getJson, type JsonFetch } from \"../http\";\nimport { FetchError, type UsageProvider, type UsageSnapshot } from \"../provider\";\nimport { finiteNumber, round2 } from \"../pure-helpers\";\n\n/**\n * Parse an OpenRouter `GET /api/v1/auth/key` response into a snapshot. The key info\n * carries lifetime usage in dollars and an optional credit ceiling. Field names are\n * read defensively (`usage`/`limit` per the API reference, `credits_used`/\n * `credit_limit` as seen in community captures).\n *\n * @param body the response body\n * @returns the snapshot\n */\nexport function parseOpenRouterKeyInfo(body: unknown): UsageSnapshot {\n const data = (body as { data?: unknown } | null)?.data;\n if (typeof data !== \"object\" || data === null) {\n // \"service\": OpenRouter answered, we just cannot read it \u2014 not \"no connection\".\n throw new FetchError(\"service\", \"unexpected response shape (no data object)\");\n }\n const info = data as Record<string, unknown>;\n const used = finiteNumber(info.usage ?? info.credits_used);\n const limit = finiteNumber(info.limit ?? info.credit_limit);\n const remaining =\n finiteNumber(info.limit_remaining) ??\n (used !== undefined && limit !== undefined ? round2(limit - used) : undefined);\n const snapshot: UsageSnapshot = {\n credits: {\n used,\n limit,\n remaining,\n percent: used !== undefined && limit !== undefined && limit > 0 ? round2((used / limit) * 100) : undefined,\n currency: \"USD\",\n },\n };\n if (used !== undefined) {\n snapshot.costs = { total: used, currency: \"USD\" };\n }\n return snapshot;\n}\n\n/**\n * The OpenRouter provider: reads the key info (credits used/limit/remaining).\n *\n * @param apiKey the OpenRouter API key\n * @param fetchJson the JSON-GET seam (tests inject a fake)\n * @returns the provider\n */\nexport function openRouterProvider(apiKey: string, fetchJson: JsonFetch = getJson): UsageProvider {\n return {\n kind: \"openrouter\",\n fetch: async (): Promise<UsageSnapshot> =>\n parseOpenRouterKeyInfo(\n await fetchJson(\"https://openrouter.ai/api/v1/auth/key\", { Authorization: `Bearer ${apiKey}` }),\n ),\n };\n}\n"],
5
+ "mappings": ";;;;;;;;;;;;;;;;;;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA,kBAAwC;AACxC,sBAAmE;AACnE,0BAAqC;AAW9B,SAAS,uBAAuB,MAA8B;AAbrE;AAcE,QAAM,OAAQ,6BAAoC;AAClD,MAAI,OAAO,SAAS,YAAY,SAAS,MAAM;AAE7C,UAAM,IAAI,2BAAW,WAAW,4CAA4C;AAAA,EAC9E;AACA,QAAM,OAAO;AACb,QAAM,WAAO,mCAAa,UAAK,UAAL,YAAc,KAAK,YAAY;AACzD,QAAM,YAAQ,mCAAa,UAAK,UAAL,YAAc,KAAK,YAAY;AAC1D,QAAM,aACJ,2CAAa,KAAK,eAAe,MAAjC,YACC,SAAS,UAAa,UAAU,aAAY,4BAAO,QAAQ,IAAI,IAAI;AACtE,QAAM,WAA0B;AAAA,IAC9B,SAAS;AAAA,MACP;AAAA,MACA;AAAA,MACA;AAAA,MACA,SAAS,SAAS,UAAa,UAAU,UAAa,QAAQ,QAAI,4BAAQ,OAAO,QAAS,GAAG,IAAI;AAAA,MACjG,UAAU;AAAA,IACZ;AAAA,EACF;AACA,MAAI,SAAS,QAAW;AACtB,aAAS,QAAQ,EAAE,OAAO,MAAM,UAAU,MAAM;AAAA,EAClD;AACA,SAAO;AACT;AASO,SAAS,mBAAmB,QAAgB,YAAuB,qBAAwB;AAChG,SAAO;AAAA,IACL,MAAM;AAAA,IACN,OAAO,YACL;AAAA,MACE,MAAM,UAAU,yCAAyC,EAAE,eAAe,UAAU,MAAM,GAAG,CAAC;AAAA,IAChG;AAAA,EACJ;AACF;",
6
6
  "names": []
7
7
  }
@@ -33,7 +33,7 @@ function state(id, name, type, role, value, unit) {
33
33
  return { def: { id, type: "state", common }, write: { id, value } };
34
34
  }
35
35
  function mapSnapshot(accountId, snapshot) {
36
- var _a, _b;
36
+ var _a, _b, _c, _d;
37
37
  const objects = [];
38
38
  const writes = [];
39
39
  const add = (pair) => {
@@ -54,11 +54,15 @@ function mapSnapshot(accountId, snapshot) {
54
54
  add(
55
55
  state(`${accountId}.limits.${windowId}.percent`, `${limit.label} used`, "number", "value", limit.percent, "%")
56
56
  );
57
- if (limit.resetAt !== void 0) {
58
- add(
59
- state(`${accountId}.limits.${windowId}.resetAt`, `${limit.label} resets at`, "string", "date", limit.resetAt)
60
- );
61
- }
57
+ add(
58
+ state(
59
+ `${accountId}.limits.${windowId}.resetAt`,
60
+ `${limit.label} resets at`,
61
+ "string",
62
+ "date",
63
+ (_a = limit.resetAt) != null ? _a : ""
64
+ )
65
+ );
62
66
  }
63
67
  }
64
68
  const credits = snapshot.credits;
@@ -83,6 +87,26 @@ function mapSnapshot(accountId, snapshot) {
83
87
  if (credits.toppedUp !== void 0) {
84
88
  add(state(`${accountId}.credits.toppedUp`, "Topped-up balance", "number", "value", credits.toppedUp, unit));
85
89
  }
90
+ if (credits.resetCredits !== void 0) {
91
+ add(
92
+ state(
93
+ `${accountId}.credits.resetCredits`,
94
+ "Available limit-reset credits",
95
+ "number",
96
+ "value",
97
+ credits.resetCredits
98
+ )
99
+ );
100
+ add(
101
+ state(
102
+ `${accountId}.credits.resetCreditsNextExpiry`,
103
+ "Next reset credit expires at",
104
+ "string",
105
+ "date",
106
+ (_b = credits.resetCreditsNextExpiry) != null ? _b : ""
107
+ )
108
+ );
109
+ }
86
110
  }
87
111
  const costs = snapshot.costs;
88
112
  if (costs) {
@@ -145,7 +169,7 @@ function mapSnapshot(accountId, snapshot) {
145
169
  "number",
146
170
  "value",
147
171
  model.cost,
148
- (_b = (_a = snapshot.costs) == null ? void 0 : _a.currency) != null ? _b : "USD"
172
+ (_d = (_c = snapshot.costs) == null ? void 0 : _c.currency) != null ? _d : "USD"
149
173
  )
150
174
  );
151
175
  }
@@ -175,13 +199,30 @@ function limitingWindow(snapshot) {
175
199
  }
176
200
  function orphanObjectIds(known, current, keep) {
177
201
  const surviving = /* @__PURE__ */ new Set([...current, ...keep]);
178
- const goneStates = known.filter((id) => !surviving.has(id));
202
+ const livingSubtrees = /* @__PURE__ */ new Set();
203
+ for (const id of current) {
204
+ const parts = id.split(".");
205
+ if (parts.length >= 4 && (parts[1] === "limits" || parts[1] === "models")) {
206
+ livingSubtrees.add(parts.slice(0, 3).join("."));
207
+ }
208
+ }
209
+ const goneStates = known.filter((id) => {
210
+ if (surviving.has(id)) {
211
+ return false;
212
+ }
213
+ const parts = id.split(".");
214
+ if (parts.length >= 4 && (parts[1] === "limits" || parts[1] === "models")) {
215
+ return !livingSubtrees.has(parts.slice(0, 3).join("."));
216
+ }
217
+ return false;
218
+ });
179
219
  const emptyChannels = /* @__PURE__ */ new Set();
220
+ const remaining = /* @__PURE__ */ new Set([...surviving, ...known.filter((id) => !goneStates.includes(id))]);
180
221
  for (const id of goneStates) {
181
222
  const parts = id.split(".");
182
223
  for (let depth = parts.length - 1; depth >= 2; depth--) {
183
224
  const parent = parts.slice(0, depth).join(".");
184
- if (![...surviving].some((alive) => alive.startsWith(`${parent}.`))) {
225
+ if (![...remaining].some((alive) => alive.startsWith(`${parent}.`))) {
185
226
  emptyChannels.add(parent);
186
227
  }
187
228
  }