@astrosheep/pi-quota 0.12.1 → 0.13.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -12,13 +12,15 @@ Reload Pi after installing or changing settings. Use `/usage` for a persistent,
12
12
 
13
13
  | Kind | Footer | `/usage` |
14
14
  |---|---|---|
15
- | Renewable window | `5h [▆] 72% ↺ 2h15m` | Horizontal bar, remaining percentage, reset, exact amounts if available |
16
- | Wallet | `Bal $12.34` | Balance; optional Today/Lifetime spend |
17
- | Fixed quota | `Quota [▇] $85.00` | Remaining bar plus used and limit |
15
+ | Usage limit with a known period | `5h [▆] 72% ↺ 2h15m` | Horizontal bar, remaining percentage, reset, exact amounts if available |
16
+ | Wallet | `Bal $12.34` | Each finite balance, separately from limits and spend |
17
+ | Amount-based limit without a supplied period | `Key quota [▇] $85.00` | Remaining percentage plus exact remaining/used/cap amounts |
18
+ | Unlimited limit | `Key quota ∞` | Unlimited, without fake money or a percentage |
19
+ | Spending | Not shown | Today expenses; Lifetime is retained in data, not displayed |
18
20
 
19
21
  A percentage always means **remaining**, not used. Unknown is `[?] ?`; an expired reset is `↺ due` with the stale percentage hidden until the next successful query. A wallet balance never becomes a percentage. Historical spend is not a quota.
20
22
 
21
- The footer shows at most two windows — set `quotaUsage.footerWindows` to a positive integer or `"all"` to show more — and `+N` for additional ones, for the current provider only. `/usage` queries all configured supported providers and shows successful results with all windows, grouped by provider; it does not open a modal or send messages to the model. Snapshots remain frozen at capture time. Query failures replace stale values in the footer and appear in `/usage --all`.
23
+ The footer shows every balance, then limits in adapter order, capped at two by default — set `quotaUsage.footerWindows` to a positive integer or `"all"` to show more — followed by `+N` for limits it did not show, for the current provider only. Shared limits are preferred over scoped ones, so a scoped limit can be counted in `+N`. `/usage` queries all configured supported providers and shows every balance, limit and Today spending entry, grouped by provider; it does not open a modal or send messages to the model. Snapshots remain frozen at capture time. Query failures replace stale values in the footer and appear in `/usage --all`.
22
24
 
23
25
  ## Configure providers
24
26
 
@@ -45,10 +47,10 @@ Use the **exact Pi provider ID**. Built-in providers and explicitly bound provid
45
47
  | Adapter | Endpoint | Result |
46
48
  |---|---|---|
47
49
  | `sub2api` | `{baseUrl}/usage` | Wallet, key quota, rate windows or subscription day/week/month windows; API-key Today/Lifetime actual spend |
48
- | `new-api` | Account PAT `/api/user/self`, falling back to key-native billing/token endpoints | Account balance + lifetime spend; finite key quota when explicitly granted; **not** a renewable window |
49
- | `deepseek` | Official `/user/balance` | Balance only; no invented spend |
50
+ | `new-api` | Account PAT `/api/user/self`, falling back to key-native billing/token endpoints | Account balance + lifetime spend; key limits (finite or unlimited), with no invented renewal |
51
+ | `deepseek` | Official `/user/balance` | All reported currency balances; no invented spend |
50
52
  | `opencode-go` | Official `https://opencode.ai/zen/go/v1/usage` | 5h/1w/30d windows, same as the built-in `opencode-go` adapter |
51
- | `yescode` | `co.yes.vg` login → session → `/api/v1/auth/profile` + `/api/v1/user/balance` | Wallet (subscription + pay-as-you-go) plus daily/weekly subscription windows |
53
+ | `yescode` | `co.yes.vg` login → session → `/api/v1/auth/profile` + `/api/v1/user/balance` | Separate subscription/pay-as-you-go balances plus scoped subscription limits |
52
54
  | `exec` | Runs `command` as argv (no shell), parses stdout as a `QuotaSnapshot` JSON | Whatever the command reports; `fetchedAt` is stamped by the adapter, never by the command |
53
55
  | `alias` | No endpoint — delegates to the `of` binding (built-in or explicit) | Shares the target account's quota; one folded row in `/usage` |
54
56
 
@@ -56,11 +58,11 @@ Bind `opencode-go` to a second OpenCode Go account: register it as its own Pi pr
56
58
 
57
59
  Sub2API accepts no extra settings; it preserves deployment subpaths. Rate-only keys need not expose a top-level currency (rates are USD). Unknown rate-window durations and invalid limits fail rather than inventing a 1d window. Subscription periods without a cap are omitted. Its dashboard `/api/v1/usage` is a separate, JWT-authenticated paginated request log; the adapter uses the API-key `/v1/usage` instead.
58
60
 
59
- For new-api, `quotaPerUnit` defaults to `500000`, `currency` to `USD`. Optionally add `dashboardAccessToken` (console system access token) and `dashboardUserId` (positive numeric ID required by some old forks) to that provider. This PAT is the **only** optional secret kept in settings. The account endpoint takes precedence; failures fall back to the key-native billing and token usage paths. Without a PAT, an API key cannot read the dashboard account quota. A finite key reports a quota bar only when `total_granted` is present and consistent with used plus remaining; otherwise it shows a balance and lifetime spend, without inventing a limit. Values in settings and server error bodies are never logged.
61
+ For new-api, `quotaPerUnit` defaults to `500000`, `currency` to `USD`. Optionally add `dashboardAccessToken` (console system access token) and `dashboardUserId` (positive numeric ID required by some old forks) to that provider. This PAT is the **only** optional secret kept in settings. The account endpoint takes precedence; failures fall back to the key-native billing and token usage paths. Without a PAT, an API key cannot read the dashboard account quota. A finite key reports a known percentage only when `total_granted` is present and consistent with used plus remaining; otherwise its limit retains exact used/remaining with `amounts.limit: null` and an unknown percentage. It never becomes a wallet. `unlimited_quota` and legacy billing's unlimited sentinel become unlimited **limits**, not infinite balances. Values in settings and server error bodies are never logged.
60
62
 
61
- For YesCode, `username` is the account email and `password` the account password; both stay in global `settings.json`, which is the trade-off of this adapter. YesCode rejects API keys on account endpoints ("requires a user session or JWT"), so the adapter logs in via `/api/v1/auth/login`, caches the issued cookie/JWT in memory for ~23 hours, and re-logs in once on an auth failure. The model `cr_` key is never sent to account endpoints; it only pins the provider to the official `co.yes.vg` origin. Balances are summed from subscription and pay-as-you-go; daily/weekly windows appear only when the account actually has those caps.
63
+ For YesCode, `username` is the account email and `password` the account password; both stay in global `settings.json`, which is the trade-off of this adapter. YesCode rejects API keys on account endpoints ("requires a user session or JWT"), so the adapter logs in via `/api/v1/auth/login`, caches the issued cookie/JWT in memory for ~23 hours, and re-logs in once on an auth failure. The model `cr_` key is never sent to account endpoints; it only pins the provider to the official `co.yes.vg` origin. Subscription and pay-as-you-go balances remain separate; daily/weekly limits appear only when the account actually has those caps. Only an explicitly reported next reset is preserved; `last_*_reset` does not establish a future schedule.
62
64
 
63
- Exec runs the configured argv without a shell and parses stdout as a `QuotaSnapshot` JSON object: `windows`, optional `balance`, `allowance` and `spend` — the same schema every adapter produces, enforced by the controller. `fetchedAt` is stamped by the adapter; a value from the command is ignored. The controller bounds every query (10s default) and kills the child on timeout; non-zero exits, unreadable JSON and output over 256KB become errors, and command output is never logged. The child inherits Pi's environment, so keep secrets in env vars, not in `quotaUsage`.
65
+ Exec runs the configured argv without a shell and parses stdout as a JSON object with required `balances`, `limits` and `spending` arrays — the same schema every adapter produces, enforced by the controller. This is a **breaking query/exec contract migration**: legacy `windows`, `balance`, `allowance` and `spend` fields are rejected, not converted. `fetchedAt` is stamped by the adapter; a value from the command is ignored. The controller bounds every query (10s default) and kills the child on timeout; non-zero exits, unreadable JSON and output over 256KB become errors, and command output is never logged. The child inherits Pi's environment, so keep secrets in env vars, not in `quotaUsage`.
64
66
 
65
67
  Alias binds a second provider ID to the same account: `"of"` names the real binding, which may be a built-in or an explicit adapter. The alias never queries on its own — a session on the alias shows the target's quota in the footer, queried under the target's identity and credentials, and `/usage` folds the alias into the target's single row with the session pin (and ★) following the fold. Aliases are one hop: `of` must not itself be an alias, and an alias whose target is not configured fails visibly instead of vanishing.
66
68
 
@@ -77,9 +79,72 @@ Pi model/auth → adapter → QuotaSnapshot → controller → footer / /usage /
77
79
  - `src/bar/`: pure compact rendering and persistent multi-provider chat card.
78
80
  - `src/extension.ts` and `src/index.ts`: Pi lifecycle and auth bridge.
79
81
 
80
- `QuotaSnapshot` separates `balance` (wallet), `allowance` (finite key quota), `windows` (renewable limits), and optional `spend` (Today/Lifetime actual charge). This prevents unrelated amounts from sharing an ambiguous `Used` label. Custom adapters can be passed to `createQuotaExtension({ adapters: [...] })`; see `examples/custom-provider.ts`. Provider aliases are never inferred.
82
+ `QuotaSnapshot` preserves three independent concepts: `balances` (owned money), `limits` (periodic and non-periodic allowances), and `spending` (reported expenses, not necessarily wallet debit or limit consumption). Pi providers only select/bind adapters; the model adds no inferred owner/account graph. Custom adapters can be passed to `createQuotaExtension({ adapters: [...] })`; see `examples/custom-provider.ts`. Provider aliases are never inferred.
81
83
 
82
- The extension publishes structured state on `quota-bar:state:v1`. Use `createQuotaExtension({ footer: false })` to consume it without this extension's footer. Normal queries are throttled, cancellable, timeout-bounded and backed off after errors. `/usage` forces a fresh query for every configured supported provider, then omits failed results from its default view. Switching provider discards old footer values immediately.
84
+ The extension publishes structured state on `quota-bar:state:v2` and saves usage cards under `quota-bar:usage:v2`; old contract events/cards are not migrated. Use `createQuotaExtension({ footer: false })` to consume it without this extension's footer. Normal queries are throttled, cancellable, timeout-bounded and backed off after errors. `/usage` forces a fresh query for every configured supported provider, then omits failed results from its default view. Switching provider discards old footer values immediately.
85
+
86
+ ## Snapshot schema
87
+
88
+ All types below are named exports of `@astrosheep/pi-quota/query`:
89
+
90
+ ```ts
91
+ interface Balance {
92
+ id: string;
93
+ label: string;
94
+ currency: string;
95
+ remaining: number;
96
+ }
97
+ interface LimitAmounts {
98
+ unit: string;
99
+ limit: number | null;
100
+ used: number;
101
+ remaining: number;
102
+ }
103
+ interface UsageLimit {
104
+ id: string;
105
+ label: string;
106
+ scope?: string;
107
+ remainingPercent: number | null;
108
+ resetAt: number | null;
109
+ durationSeconds?: number;
110
+ amounts?: LimitAmounts;
111
+ unlimited?: true;
112
+ }
113
+ interface Spending {
114
+ id: string;
115
+ label: string;
116
+ currency: string;
117
+ amount: number;
118
+ period: 'today' | 'lifetime';
119
+ }
120
+ interface QuotaSnapshot {
121
+ balances: Balance[];
122
+ limits: UsageLimit[];
123
+ spending: Spending[];
124
+ fetchedAt: number;
125
+ }
126
+ ```
127
+
128
+ - All three arrays are required, with at most 64 entries each. Spend-only snapshots are valid; all-empty snapshots are not.
129
+ - IDs are nonblank, control-free strings of at most 64 characters, unique **within each category**. They are opaque and preserved exactly, never derived from duration: two `5h` limits with different IDs remain distinct. Labels/scopes/units are sanitized for terminal safety, trimmed and bounded to 64 characters; blank values are rejected.
130
+ - Currency is a three-letter uppercase code. Balances are finite, including zero and negative debt. They cannot be unlimited.
131
+ - Percentages are finite values in `[0, 100]`, always **remaining**; `null` means unknown. Exact amounts and provider percentages need not agree after rounding or provider-specific accounting. `limit` is nonnegative or `null` (cap not reported); `used` is nonnegative; `remaining` is finite and may be negative. Amount units can be currencies or counts such as `tokens`/`requests`; counts are not formatted as money.
132
+ - Unlimited limits use `unlimited: true`, `remainingPercent: null`, and no `amounts`. Unknown cap (`amounts.limit: null`) does **not** mean unlimited.
133
+ - `fetchedAt` and `resetAt` are epoch milliseconds; `resetAt` is positive or `null`. A supplied duration must be positive. Missing duration/reset is unknown, not proof of no renewal. No fixed/rolling classification or future reset schedule is inferred.
134
+ - Spending amounts are finite and nonnegative. `today` means the **provider-defined day**, with no inferred timezone. Both periods are retained in snapshots, but the footer omits spending and `/usage` displays only Today.
135
+
136
+ For example, an exec command can emit this (no `fetchedAt` required):
137
+
138
+ ```json
139
+ {
140
+ "balances": [{ "id": "wallet", "label": "Balance", "currency": "USD", "remaining": 12.34 }],
141
+ "limits": [{
142
+ "id": "requests", "label": "Requests", "remainingPercent": 80, "resetAt": null,
143
+ "amounts": { "unit": "requests", "limit": 100, "used": 20, "remaining": 80 }
144
+ }],
145
+ "spending": [{ "id": "today", "label": "Reported spend", "currency": "USD", "amount": 2.5, "period": "today" }]
146
+ }
147
+ ```
83
148
 
84
149
  ## Standalone query layer
85
150
 
@@ -111,5 +176,6 @@ Requires Node ≥22.18 and a compatible Pi installation.
111
176
  npm install --ignore-scripts
112
177
  npm test
113
178
  npm run typecheck
179
+ npm run build
114
180
  npm run demo
115
181
  ```
@@ -1,4 +1,4 @@
1
- import type { QuotaAdapter, QuotaWindow } from './types.js';
1
+ import type { QuotaAdapter, UsageLimit } from './types.js';
2
2
  export declare function codexAccountId(token: string): string | undefined;
3
- export declare function parseCodex(payload: unknown, now: number): QuotaWindow[];
3
+ export declare function parseCodex(payload: unknown, now: number): UsageLimit[];
4
4
  export declare const codexAdapter: QuotaAdapter;
@@ -63,6 +63,6 @@ export const codexAdapter = {
63
63
  Origin: 'https://chatgpt.com', Referer: 'https://chatgpt.com/',
64
64
  }, context.signal);
65
65
  const now = context.now();
66
- return { windows: parseCodex(data, now), fetchedAt: now };
66
+ return { balances: [], limits: parseCodex(data, now), spending: [], fetchedAt: now };
67
67
  },
68
68
  };
@@ -1,4 +1,4 @@
1
- import type { AccountBalance, QuotaAdapter } from './types.js';
1
+ import type { Balance, QuotaAdapter } from './types.js';
2
2
  export declare const DEEPSEEK_ORIGIN = "https://api.deepseek.com";
3
- export declare function parseDeepSeekBalance(payload: unknown): AccountBalance;
3
+ export declare function parseDeepSeekBalance(payload: unknown): Balance[];
4
4
  export declare function createDeepSeekAdapter(provider: string): QuotaAdapter;
@@ -9,16 +9,15 @@ export function parseDeepSeekBalance(payload) {
9
9
  const body = object(payload);
10
10
  if (!Array.isArray(body.balance_infos) || body.balance_infos.length === 0)
11
11
  throw new QuotaError('schema');
12
- for (const entry of body.balance_infos) {
12
+ return body.balance_infos.map(entry => {
13
13
  const info = object(entry);
14
14
  const remaining = numeric(info.total_balance);
15
15
  if (typeof info.currency !== 'string' || !/^[A-Z]{3}$/.test(info.currency)
16
- || remaining === null || remaining < 0)
17
- continue;
18
- // The endpoint exposes no usage total; only return the actual balance.
19
- return { currency: info.currency, remaining };
20
- }
21
- throw new QuotaError('schema');
16
+ || remaining === null)
17
+ throw new QuotaError('schema');
18
+ // Preserve each currency; the endpoint exposes no usage total.
19
+ return { id: info.currency, label: 'Balance', currency: info.currency, remaining };
20
+ });
22
21
  }
23
22
  export function createDeepSeekAdapter(provider) {
24
23
  return {
@@ -26,7 +25,7 @@ export function createDeepSeekAdapter(provider) {
26
25
  async query(context) {
27
26
  const auth = await officialAuth(context, DEEPSEEK_ORIGIN);
28
27
  const payload = await context.getJson(`${DEEPSEEK_ORIGIN}/user/balance`, { Authorization: `Bearer ${bearer(auth)}` }, context.signal);
29
- return { windows: [], balance: parseDeepSeekBalance(payload), fetchedAt: context.now() };
28
+ return { balances: parseDeepSeekBalance(payload), limits: [], spending: [], fetchedAt: context.now() };
30
29
  },
31
30
  };
32
31
  }
@@ -1,3 +1,3 @@
1
- import type { QuotaAdapter, QuotaWindow } from './types.js';
2
- export declare function parseKimi(payload: unknown): QuotaWindow[];
1
+ import type { QuotaAdapter, UsageLimit } from './types.js';
2
+ export declare function parseKimi(payload: unknown): UsageLimit[];
3
3
  export declare const kimiAdapter: QuotaAdapter;
@@ -47,6 +47,6 @@ export const kimiAdapter = {
47
47
  const data = await context.getJson('https://api.kimi.com/coding/v1/usages', {
48
48
  Authorization: `Bearer ${bearer(auth)}`,
49
49
  }, context.signal);
50
- return { windows: parseKimi(data), fetchedAt: context.now() };
50
+ return { balances: [], limits: parseKimi(data), spending: [], fetchedAt: context.now() };
51
51
  },
52
52
  };
@@ -1,5 +1,5 @@
1
1
  import type { QuotaAdapter, QuotaSnapshot } from './types.js';
2
- type Amounts = Pick<QuotaSnapshot, 'balance' | 'allowance' | 'spend'>;
2
+ type Amounts = Omit<QuotaSnapshot, 'fetchedAt'>;
3
3
  export interface NewApiOptions {
4
4
  quotaPerUnit?: number;
5
5
  currency?: string;
@@ -1,5 +1,5 @@
1
1
  import { bearer } from "./http.js";
2
- import { numeric, object } from "./parse.js";
2
+ import { numeric, object, percent } from "./parse.js";
3
3
  import { QuotaError } from "./types.js";
4
4
  import { safeBaseUrl } from "./url.js";
5
5
  export function validateNewApiOptions(options) {
@@ -32,9 +32,11 @@ export function parseNewApiTokenUsage(payload, options = {}) {
32
32
  const used = numeric(data.total_used);
33
33
  if (used === null || !Number.isSafeInteger(used) || used < 0)
34
34
  throw new QuotaError('schema');
35
+ const spending = [{ id: 'key-lifetime', label: 'API key spend', currency,
36
+ amount: used / quotaPerUnit, period: 'lifetime' }];
35
37
  if (data.unlimited_quota === true) {
36
- return { balance: { currency, remaining: 0, unlimited: true },
37
- spend: { currency, lifetime: used / quotaPerUnit } };
38
+ return { balances: [], limits: [{ id: 'key-quota', label: 'Key quota',
39
+ remainingPercent: null, resetAt: null, unlimited: true }], spending };
38
40
  }
39
41
  const remaining = numeric(data.total_available);
40
42
  if (remaining === null || !Number.isSafeInteger(remaining))
@@ -44,17 +46,18 @@ export function parseNewApiTokenUsage(payload, options = {}) {
44
46
  throw new QuotaError('schema');
45
47
  // New-api reports granted = key used + key remaining. Only draw a finite
46
48
  // allocation bar when the server actually supplies that total.
49
+ let limit = null;
47
50
  if (data.total_granted !== undefined) {
48
51
  const granted = numeric(data.total_granted);
49
52
  if (granted === null || !Number.isSafeInteger(granted) || granted < 0
50
53
  || !Number.isSafeInteger(used + remaining) || granted !== used + remaining)
51
54
  throw new QuotaError('schema');
52
- if (granted > 0)
53
- return { allowance: { currency, limit: granted / quotaPerUnit,
54
- used: used / quotaPerUnit, remaining: moneyLeft } };
55
+ limit = granted / quotaPerUnit;
55
56
  }
56
- return { balance: { currency, remaining: moneyLeft },
57
- spend: { currency, lifetime: used / quotaPerUnit } };
57
+ // Key quota is not owned account money. Missing total_granted stays unknown.
58
+ return { balances: [], limits: [{ id: 'key-quota', label: 'Key quota', resetAt: null,
59
+ remainingPercent: limit !== null && limit > 0 ? percent(moneyLeft / limit * 100) : null,
60
+ amounts: { unit: currency, limit, used: used / quotaPerUnit, remaining: moneyLeft } }], spending };
58
61
  }
59
62
  // GET /api/user/self (UserAuth): the console's own account quota, readable with
60
63
  // a dashboard access token (PAT), which sk- keys are not. Old forks also demand
@@ -71,8 +74,9 @@ export function parseNewApiUserSelf(payload, options = {}) {
71
74
  || !Number.isSafeInteger(quota) || !Number.isSafeInteger(usedQuota)
72
75
  || quota < 0 || usedQuota < 0)
73
76
  throw new QuotaError('schema');
74
- return { balance: { currency, remaining: quota / quotaPerUnit },
75
- spend: { currency, lifetime: usedQuota / quotaPerUnit } };
77
+ return { balances: [{ id: 'account', label: 'Balance', currency, remaining: quota / quotaPerUnit }],
78
+ limits: [], spending: [{ id: 'account-lifetime', label: 'Account spend', currency,
79
+ amount: usedQuota / quotaPerUnit, period: 'lifetime' }] };
76
80
  }
77
81
  // Legacy one-api billing pair, still the only option on older deployments.
78
82
  // total_usage is in cents (divide by 100, per one-api issue #1785); most
@@ -89,11 +93,11 @@ export function parseNewApiBilling(subscription, usage, options = {}) {
89
93
  const used = totalUsage / 100;
90
94
  // This endpoint reports account remaining + lifetime usage as its hard limit.
91
95
  // Top-ups change that total, so it is a wallet balance, not a fixed allowance.
96
+ const spending = [{ id: 'billing-lifetime', label: 'Reported spend', currency, amount: used, period: 'lifetime' }];
92
97
  if (limit >= 1e7)
93
- return { balance: { currency, remaining: 0, unlimited: true },
94
- spend: { currency, lifetime: used } };
95
- return { balance: { currency, remaining: limit - used },
96
- spend: { currency, lifetime: used } };
98
+ return { balances: [], limits: [{ id: 'billing-quota', label: 'Quota',
99
+ remainingPercent: null, resetAt: null, unlimited: true }], spending };
100
+ return { balances: [{ id: 'account', label: 'Balance', currency, remaining: limit - used }], limits: [], spending };
97
101
  }
98
102
  export function createNewApiAdapter(provider, options = {}) {
99
103
  const settings = validateNewApiOptions(options);
@@ -128,7 +132,7 @@ export function createNewApiAdapter(provider, options = {}) {
128
132
  headers['New-Api-User'] = String(dashboardUserId);
129
133
  try {
130
134
  const payload = await context.getJson(`${root}/api/user/self`, headers, context.signal);
131
- return { windows: [], ...parseNewApiUserSelf(payload, settings), fetchedAt: context.now() };
135
+ return { ...parseNewApiUserSelf(payload, settings), fetchedAt: context.now() };
132
136
  }
133
137
  catch (error) {
134
138
  if (!(error instanceof QuotaError))
@@ -146,8 +150,8 @@ export function createNewApiAdapter(provider, options = {}) {
146
150
  context.getJson(`${root}/v1/dashboard/billing/usage?start_date=2020-01-01&end_date=2100-01-01`, headers, context.signal),
147
151
  ]);
148
152
  const billing = parseNewApiBilling(pair[0], pair[1], settings);
149
- if (billing.balance?.unlimited !== true)
150
- return { windows: [], ...billing, fetchedAt: context.now() };
153
+ if (billing.balances.length > 0)
154
+ return { ...billing, fetchedAt: context.now() };
151
155
  billingBalance = billing;
152
156
  }
153
157
  catch (error) {
@@ -162,18 +166,18 @@ export function createNewApiAdapter(provider, options = {}) {
162
166
  const payload = await context.getJson(`${root}/api/usage/token/`, headers, context.signal);
163
167
  const parsed = parseNewApiTokenUsage(payload, settings);
164
168
  if (parsed)
165
- return { windows: [], ...parsed, fetchedAt: context.now() };
169
+ return { ...parsed, fetchedAt: context.now() };
166
170
  }
167
171
  catch (error) {
168
172
  if (billingBalance && error instanceof QuotaError && error.code === 'http') {
169
- return { windows: [], ...billingBalance, fetchedAt: context.now() };
173
+ return { ...billingBalance, fetchedAt: context.now() };
170
174
  }
171
175
  if (error instanceof QuotaError && error.code === 'auth')
172
176
  throw new QuotaError('account-access');
173
177
  throw error;
174
178
  }
175
179
  if (billingBalance)
176
- return { windows: [], ...billingBalance, fetchedAt: context.now() };
180
+ return { ...billingBalance, fetchedAt: context.now() };
177
181
  throw new QuotaError('schema');
178
182
  },
179
183
  };
@@ -1,4 +1,4 @@
1
- import type { QuotaAdapter, QuotaWindow } from './types.js';
2
- export declare function parseOpenCodeGo(payload: unknown): QuotaWindow[];
1
+ import type { QuotaAdapter, UsageLimit } from './types.js';
2
+ export declare function parseOpenCodeGo(payload: unknown): UsageLimit[];
3
3
  export declare function createOpenCodeGoAdapter(provider: string): QuotaAdapter;
4
4
  export declare const openCodeGoAdapter: QuotaAdapter;
@@ -39,7 +39,7 @@ export function createOpenCodeGoAdapter(provider) {
39
39
  const data = await context.getJson('https://opencode.ai/zen/go/v1/usage', {
40
40
  Authorization: `Bearer ${bearer(auth)}`,
41
41
  }, context.signal);
42
- return { windows: parseOpenCodeGo(data), fetchedAt: context.now() };
42
+ return { balances: [], limits: parseOpenCodeGo(data), spending: [], fetchedAt: context.now() };
43
43
  },
44
44
  };
45
45
  }
@@ -6,4 +6,4 @@ export declare class AdapterRegistry {
6
6
  get(provider: string | undefined): QuotaAdapter | undefined;
7
7
  list(): readonly QuotaAdapter[];
8
8
  }
9
- export declare function validateSnapshot(snapshot: QuotaSnapshot): QuotaSnapshot;
9
+ export declare function validateSnapshot(snapshot: unknown): QuotaSnapshot;
@@ -19,60 +19,87 @@ export class AdapterRegistry {
19
19
  return [...this.adapters.values()];
20
20
  }
21
21
  }
22
- // Validate even custom adapter output before it reaches the bar.
22
+ // Validate even custom/exec adapter output before it reaches a consumer.
23
23
  export function validateSnapshot(snapshot) {
24
- if (!snapshot || !Number.isFinite(snapshot.fetchedAt) || !Array.isArray(snapshot.windows)
25
- || (snapshot.windows.length === 0 && snapshot.balance === undefined
26
- && snapshot.allowance === undefined)
27
- || snapshot.windows.length > 64)
24
+ const data = record(snapshot);
25
+ if (!Number.isFinite(data.fetchedAt)
26
+ || ['windows', 'balance', 'allowance', 'spend'].some(key => key in data))
28
27
  throw new QuotaError('schema');
29
- let balance;
30
- if (snapshot.balance !== undefined) {
31
- const data = snapshot.balance;
32
- if (!data || typeof data.currency !== 'string' || !/^[A-Z]{3}$/.test(data.currency)
33
- || !Number.isFinite(data.remaining)
34
- || (data.unlimited !== undefined && typeof data.unlimited !== 'boolean'))
28
+ const balances = entries(data.balances).map(entry => {
29
+ if (!isCurrency(entry.currency) || !Number.isFinite(entry.remaining)
30
+ || entry.unlimited !== undefined)
35
31
  throw new QuotaError('schema');
36
- balance = { currency: data.currency, remaining: data.remaining,
37
- ...(data.unlimited === true ? { unlimited: true } : {}) };
38
- }
39
- const allowance = snapshot.allowance === undefined ? undefined : validateAllowance(snapshot.allowance);
40
- let spend;
41
- if (snapshot.spend !== undefined) {
42
- const data = snapshot.spend;
43
- if (!data || typeof data.currency !== 'string' || !/^[A-Z]{3}$/.test(data.currency)
44
- || (data.today === undefined && data.lifetime === undefined)
45
- || (data.today !== undefined && (!Number.isFinite(data.today) || data.today < 0))
46
- || (data.lifetime !== undefined && (!Number.isFinite(data.lifetime) || data.lifetime < 0))) {
32
+ return { ...identity(entry), currency: entry.currency, remaining: entry.remaining };
33
+ });
34
+ const limits = entries(data.limits).map(entry => {
35
+ if ((entry.scope !== undefined && typeof entry.scope !== 'string')
36
+ || (entry.durationSeconds !== undefined && (!Number.isFinite(entry.durationSeconds) || entry.durationSeconds <= 0))
37
+ || (entry.remainingPercent !== null && (!Number.isFinite(entry.remainingPercent)
38
+ || entry.remainingPercent < 0 || entry.remainingPercent > 100))
39
+ || (entry.resetAt !== null && (!Number.isFinite(entry.resetAt) || entry.resetAt <= 0))
40
+ || (entry.unlimited !== undefined && entry.unlimited !== true)
41
+ || (entry.unlimited === true && (entry.amounts !== undefined || entry.remainingPercent !== null))) {
47
42
  throw new QuotaError('schema');
48
43
  }
49
- spend = { currency: data.currency, ...(data.today !== undefined ? { today: data.today } : {}),
50
- ...(data.lifetime !== undefined ? { lifetime: data.lifetime } : {}) };
44
+ return { ...identity(entry), remainingPercent: entry.remainingPercent,
45
+ resetAt: entry.resetAt,
46
+ ...(entry.scope !== undefined ? { scope: label(entry.scope) } : {}),
47
+ ...(entry.durationSeconds !== undefined ? { durationSeconds: entry.durationSeconds } : {}),
48
+ ...(entry.amounts !== undefined ? { amounts: validateAmounts(entry.amounts) } : {}),
49
+ ...(entry.unlimited === true ? { unlimited: true } : {}) };
50
+ });
51
+ const spending = entries(data.spending).map(entry => {
52
+ if (!isCurrency(entry.currency) || !Number.isFinite(entry.amount) || entry.amount < 0
53
+ || (entry.period !== 'today' && entry.period !== 'lifetime'))
54
+ throw new QuotaError('schema');
55
+ return { ...identity(entry), currency: entry.currency, amount: entry.amount, period: entry.period };
56
+ });
57
+ if (balances.length + limits.length + spending.length === 0)
58
+ throw new QuotaError('schema');
59
+ return { balances, limits, spending, fetchedAt: data.fetchedAt };
60
+ }
61
+ function record(value) {
62
+ if (!value || typeof value !== 'object' || Array.isArray(value))
63
+ throw new QuotaError('schema');
64
+ return value;
65
+ }
66
+ function label(value) {
67
+ if (typeof value !== 'string')
68
+ throw new QuotaError('schema');
69
+ const clean = safeLabel(value).trim();
70
+ if (!clean)
71
+ throw new QuotaError('schema');
72
+ return clean;
73
+ }
74
+ function identity(data) {
75
+ // IDs are opaque and preserved, never derived from duration, scope or label.
76
+ if (typeof data.id !== 'string' || !data.id || data.id !== label(data.id) || data.id.length > 64) {
77
+ throw new QuotaError('schema');
51
78
  }
79
+ return { id: data.id, label: label(data.label) };
80
+ }
81
+ function entries(value) {
82
+ if (!Array.isArray(value) || value.length > 64)
83
+ throw new QuotaError('schema');
52
84
  const ids = new Set();
53
- const windows = snapshot.windows.map(window => {
54
- if (!window || typeof window.id !== 'string' || ids.has(window.id)
55
- || typeof window.label !== 'string'
56
- || (window.scope !== undefined && typeof window.scope !== 'string')
57
- || !Number.isFinite(window.durationSeconds) || window.durationSeconds <= 0
58
- || (window.remainingPercent !== null && (!Number.isFinite(window.remainingPercent)
59
- || window.remainingPercent < 0 || window.remainingPercent > 100))
60
- || (window.resetAt !== null && (!Number.isFinite(window.resetAt) || window.resetAt <= 0))) {
85
+ return value.map(item => {
86
+ const data = record(item);
87
+ const { id } = identity(data);
88
+ if (ids.has(id))
61
89
  throw new QuotaError('schema');
62
- }
63
- ids.add(window.id);
64
- return { ...window, label: safeLabel(window.label),
65
- ...(window.amounts !== undefined ? { amounts: validateAllowance(window.amounts) } : {}),
66
- ...(window.scope !== undefined ? { scope: safeLabel(window.scope) } : {}) };
90
+ ids.add(id);
91
+ return data;
67
92
  });
68
- return { fetchedAt: snapshot.fetchedAt, windows, ...(balance ? { balance } : {}),
69
- ...(allowance ? { allowance } : {}), ...(spend ? { spend } : {}) };
70
93
  }
71
- function validateAllowance(data) {
72
- if (!data || typeof data.currency !== 'string' || !/^[A-Z]{3}$/.test(data.currency)
73
- || !Number.isFinite(data.limit) || data.limit <= 0
94
+ function isCurrency(value) {
95
+ return typeof value === 'string' && /^[A-Z]{3}$/.test(value);
96
+ }
97
+ function validateAmounts(value) {
98
+ const data = record(value);
99
+ if ((data.limit !== null && (!Number.isFinite(data.limit) || data.limit < 0))
74
100
  || !Number.isFinite(data.used) || data.used < 0
75
101
  || !Number.isFinite(data.remaining))
76
102
  throw new QuotaError('schema');
77
- return { currency: data.currency, limit: data.limit, used: data.used, remaining: data.remaining };
103
+ return { unit: label(data.unit), limit: data.limit,
104
+ used: data.used, remaining: data.remaining };
78
105
  }
@@ -4,7 +4,9 @@ import { QuotaError } from "./types.js";
4
4
  import { safeBaseUrl } from "./url.js";
5
5
  export function sub2ApiUsageUrl(baseUrl) {
6
6
  const url = safeBaseUrl(baseUrl);
7
- url.pathname = `${url.pathname.replace(/\/+$/, '')}/usage`;
7
+ // Anthropic clients use the host root; OpenAI clients include /v1.
8
+ const prefix = url.pathname.replace(/\/+$/, '') || '/v1';
9
+ url.pathname = `${prefix}/usage`;
8
10
  return url.toString();
9
11
  }
10
12
  function currency(value) {
@@ -24,34 +26,31 @@ function allowance(data, unit) {
24
26
  throw new QuotaError('schema');
25
27
  const used = amount(data.used);
26
28
  const remaining = amount(data.remaining);
27
- return { currency: unit, limit, used, remaining };
29
+ return { unit, limit, used, remaining };
28
30
  }
29
- function spend(data, unit) {
30
- const today = object(data.today);
31
- const total = object(data.total);
32
- const todayCost = numeric(today.actual_cost);
33
- const lifetimeCost = numeric(total.actual_cost);
34
- if (todayCost === null && lifetimeCost === null)
35
- return undefined;
36
- if ((todayCost !== null && todayCost < 0) || (lifetimeCost !== null && lifetimeCost < 0))
37
- throw new QuotaError('schema');
38
- return { currency: unit, ...(todayCost !== null ? { today: todayCost } : {}),
39
- ...(lifetimeCost !== null ? { lifetime: lifetimeCost } : {}) };
31
+ function spend(data, currency) {
32
+ return [['today', data.today], ['lifetime', data.total]].flatMap(([period, raw]) => {
33
+ const cost = object(raw).actual_cost;
34
+ if (cost === undefined)
35
+ return [];
36
+ return [{ id: `key-${period}`, label: 'API key spend', currency, amount: amount(cost),
37
+ period: period }];
38
+ });
40
39
  }
41
40
  function percentFromAmounts(remaining, limit) {
42
41
  return percent(remaining / limit * 100);
43
42
  }
44
43
  const RATE_WINDOWS = { '5h': 18000, '1d': 86400, '7d': 604800 };
45
- function rateWindow(data, unit) {
44
+ function rateWindow(data) {
46
45
  const label = data.window;
47
46
  if (typeof label !== 'string' || !Object.hasOwn(RATE_WINDOWS, label))
48
47
  throw new QuotaError('schema');
49
- const limits = allowance(data, unit);
48
+ const limits = allowance(data, 'USD');
50
49
  return { id: `rate-${label}`, label, durationSeconds: RATE_WINDOWS[label],
51
50
  remainingPercent: percentFromAmounts(limits.remaining, limits.limit),
52
51
  resetAt: timestamp(data.reset_at), amounts: limits };
53
52
  }
54
- function subscriptionWindows(data, unit) {
53
+ function subscriptionWindows(data) {
55
54
  const specs = [
56
55
  ['daily', '1d', 'daily_usage_usd', 'daily_limit_usd', 86400],
57
56
  ['weekly', '1w', 'weekly_usage_usd', 'weekly_limit_usd', 604800],
@@ -68,10 +67,9 @@ function subscriptionWindows(data, unit) {
68
67
  const used = numeric(data[usedKey]);
69
68
  if (used === null || used < 0)
70
69
  throw new QuotaError('schema');
71
- const amounts = { currency: unit, limit, used, remaining: Math.max(0, limit - used) };
72
- const start = id === 'weekly' ? timestamp(data.weekly_window_start) : null;
70
+ const amounts = { unit: 'USD', limit, used, remaining: Math.max(0, limit - used) };
73
71
  return [{ id, label, durationSeconds, remainingPercent: percentFromAmounts(amounts.remaining, limit),
74
- resetAt: start === null ? null : start + durationSeconds * 1000, amounts }];
72
+ resetAt: null, amounts }];
75
73
  });
76
74
  }
77
75
  export function parseSub2ApiUsage(payload) {
@@ -83,31 +81,34 @@ export function parseSub2ApiUsage(payload) {
83
81
  // Sub2API rate limits are always USD; a fixed quota carries its own unit.
84
82
  const unit = currency(body.unit ?? (body.mode === 'quota_limited'
85
83
  ? object(body.quota).unit ?? 'USD' : undefined));
86
- const windows = [];
87
- let balance;
88
- let finiteQuota;
84
+ const limits = [];
85
+ const balances = [];
89
86
  if (body.mode === 'quota_limited') {
90
- const quota = body.quota === undefined ? undefined : allowance(object(body.quota), unit);
91
- finiteQuota = quota;
87
+ if (body.quota !== undefined) {
88
+ const amounts = allowance(object(body.quota), unit);
89
+ limits.push({ id: 'key-quota', label: 'Key quota', resetAt: null,
90
+ remainingPercent: percentFromAmounts(amounts.remaining, amounts.limit), amounts });
91
+ }
92
92
  const rates = body.rate_limits;
93
93
  if (rates !== undefined) {
94
94
  if (!Array.isArray(rates))
95
95
  throw new QuotaError('schema');
96
- rates.forEach(entry => windows.push(rateWindow(object(entry), unit)));
96
+ rates.forEach(entry => limits.push(rateWindow(object(entry))));
97
97
  }
98
98
  }
99
99
  else if (body.subscription !== undefined) {
100
- windows.push(...subscriptionWindows(object(body.subscription), unit));
100
+ limits.push(...subscriptionWindows(object(body.subscription)));
101
101
  }
102
- else {
103
- const remaining = amount(body.remaining ?? body.balance);
104
- balance = { currency: unit, remaining };
102
+ else if (body.remaining !== undefined || body.balance !== undefined) {
103
+ const remaining = numeric(body.remaining ?? body.balance);
104
+ if (remaining === null)
105
+ throw new QuotaError('schema');
106
+ balances.push({ id: 'wallet', label: 'Balance', currency: unit, remaining });
105
107
  }
106
- const usage = spend(object(body.usage), unit);
107
- if (windows.length === 0 && !balance && !finiteQuota)
108
+ const spending = spend(object(body.usage), unit);
109
+ if (limits.length + balances.length + spending.length === 0)
108
110
  throw new QuotaError('schema');
109
- return { windows, ...(balance ? { balance } : {}), ...(finiteQuota ? { allowance: finiteQuota } : {}),
110
- ...(usage ? { spend: usage } : {}) };
111
+ return { limits, balances, spending };
111
112
  }
112
113
  export function createSub2ApiAdapter(provider) {
113
114
  return {