@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 +78 -12
- package/dist/query/codex.d.ts +2 -2
- package/dist/query/codex.js +1 -1
- package/dist/query/deepseek.d.ts +2 -2
- package/dist/query/deepseek.js +7 -8
- package/dist/query/kimi.d.ts +2 -2
- package/dist/query/kimi.js +1 -1
- package/dist/query/new-api.d.ts +1 -1
- package/dist/query/new-api.js +24 -20
- package/dist/query/opencode-go.d.ts +2 -2
- package/dist/query/opencode-go.js +1 -1
- package/dist/query/registry.d.ts +1 -1
- package/dist/query/registry.js +70 -43
- package/dist/query/sub2api.js +34 -33
- package/dist/query/types.d.ts +22 -19
- package/dist/query/yescode.js +9 -10
- package/examples/custom-provider.ts +3 -1
- package/examples/preview.ts +2 -2
- package/package.json +7 -3
- package/src/bar/balance.ts +10 -48
- package/src/bar/card.ts +36 -47
- package/src/bar/quota.ts +54 -49
- package/src/extension.ts +3 -3
- package/src/query/codex.ts +4 -4
- package/src/query/deepseek.ts +8 -9
- package/src/query/kimi.ts +5 -5
- package/src/query/new-api.ts +24 -19
- package/src/query/opencode-go.ts +4 -4
- package/src/query/registry.ts +72 -47
- package/src/query/sub2api.ts +35 -34
- package/src/query/types.ts +25 -22
- package/src/query/yescode.ts +14 -15
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
|
-
|
|
|
16
|
-
| Wallet | `Bal $12.34` |
|
|
17
|
-
|
|
|
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
|
|
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;
|
|
49
|
-
| `deepseek` | Official `/user/balance` |
|
|
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` |
|
|
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
|
|
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.
|
|
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
|
|
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`
|
|
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:
|
|
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
|
```
|
package/dist/query/codex.d.ts
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import type { QuotaAdapter,
|
|
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):
|
|
3
|
+
export declare function parseCodex(payload: unknown, now: number): UsageLimit[];
|
|
4
4
|
export declare const codexAdapter: QuotaAdapter;
|
package/dist/query/codex.js
CHANGED
|
@@ -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 {
|
|
66
|
+
return { balances: [], limits: parseCodex(data, now), spending: [], fetchedAt: now };
|
|
67
67
|
},
|
|
68
68
|
};
|
package/dist/query/deepseek.d.ts
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import type {
|
|
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):
|
|
3
|
+
export declare function parseDeepSeekBalance(payload: unknown): Balance[];
|
|
4
4
|
export declare function createDeepSeekAdapter(provider: string): QuotaAdapter;
|
package/dist/query/deepseek.js
CHANGED
|
@@ -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
|
-
|
|
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
|
|
17
|
-
|
|
18
|
-
//
|
|
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 {
|
|
28
|
+
return { balances: parseDeepSeekBalance(payload), limits: [], spending: [], fetchedAt: context.now() };
|
|
30
29
|
},
|
|
31
30
|
};
|
|
32
31
|
}
|
package/dist/query/kimi.d.ts
CHANGED
|
@@ -1,3 +1,3 @@
|
|
|
1
|
-
import type { QuotaAdapter,
|
|
2
|
-
export declare function parseKimi(payload: unknown):
|
|
1
|
+
import type { QuotaAdapter, UsageLimit } from './types.js';
|
|
2
|
+
export declare function parseKimi(payload: unknown): UsageLimit[];
|
|
3
3
|
export declare const kimiAdapter: QuotaAdapter;
|
package/dist/query/kimi.js
CHANGED
|
@@ -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 {
|
|
50
|
+
return { balances: [], limits: parseKimi(data), spending: [], fetchedAt: context.now() };
|
|
51
51
|
},
|
|
52
52
|
};
|
package/dist/query/new-api.d.ts
CHANGED
package/dist/query/new-api.js
CHANGED
|
@@ -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 {
|
|
37
|
-
|
|
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
|
-
|
|
53
|
-
return { allowance: { currency, limit: granted / quotaPerUnit,
|
|
54
|
-
used: used / quotaPerUnit, remaining: moneyLeft } };
|
|
55
|
+
limit = granted / quotaPerUnit;
|
|
55
56
|
}
|
|
56
|
-
|
|
57
|
-
|
|
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 {
|
|
75
|
-
|
|
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 {
|
|
94
|
-
|
|
95
|
-
return {
|
|
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 {
|
|
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.
|
|
150
|
-
return {
|
|
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 {
|
|
169
|
+
return { ...parsed, fetchedAt: context.now() };
|
|
166
170
|
}
|
|
167
171
|
catch (error) {
|
|
168
172
|
if (billingBalance && error instanceof QuotaError && error.code === 'http') {
|
|
169
|
-
return {
|
|
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 {
|
|
180
|
+
return { ...billingBalance, fetchedAt: context.now() };
|
|
177
181
|
throw new QuotaError('schema');
|
|
178
182
|
},
|
|
179
183
|
};
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import type { QuotaAdapter,
|
|
2
|
-
export declare function parseOpenCodeGo(payload: unknown):
|
|
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 {
|
|
42
|
+
return { balances: [], limits: parseOpenCodeGo(data), spending: [], fetchedAt: context.now() };
|
|
43
43
|
},
|
|
44
44
|
};
|
|
45
45
|
}
|
package/dist/query/registry.d.ts
CHANGED
|
@@ -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:
|
|
9
|
+
export declare function validateSnapshot(snapshot: unknown): QuotaSnapshot;
|
package/dist/query/registry.js
CHANGED
|
@@ -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
|
|
22
|
+
// Validate even custom/exec adapter output before it reaches a consumer.
|
|
23
23
|
export function validateSnapshot(snapshot) {
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
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
|
-
|
|
30
|
-
|
|
31
|
-
|
|
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
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|| (
|
|
45
|
-
|| (
|
|
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
|
-
|
|
50
|
-
|
|
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
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
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
|
-
|
|
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
|
|
72
|
-
|
|
73
|
-
|
|
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 {
|
|
103
|
+
return { unit: label(data.unit), limit: data.limit,
|
|
104
|
+
used: data.used, remaining: data.remaining };
|
|
78
105
|
}
|
package/dist/query/sub2api.js
CHANGED
|
@@ -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
|
-
|
|
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 {
|
|
29
|
+
return { unit, limit, used, remaining };
|
|
28
30
|
}
|
|
29
|
-
function spend(data,
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
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
|
|
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,
|
|
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
|
|
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 = {
|
|
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:
|
|
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
|
|
87
|
-
|
|
88
|
-
let finiteQuota;
|
|
84
|
+
const limits = [];
|
|
85
|
+
const balances = [];
|
|
89
86
|
if (body.mode === 'quota_limited') {
|
|
90
|
-
|
|
91
|
-
|
|
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 =>
|
|
96
|
+
rates.forEach(entry => limits.push(rateWindow(object(entry))));
|
|
97
97
|
}
|
|
98
98
|
}
|
|
99
99
|
else if (body.subscription !== undefined) {
|
|
100
|
-
|
|
100
|
+
limits.push(...subscriptionWindows(object(body.subscription)));
|
|
101
101
|
}
|
|
102
|
-
else {
|
|
103
|
-
const remaining =
|
|
104
|
-
|
|
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
|
|
107
|
-
if (
|
|
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 {
|
|
110
|
-
...(usage ? { spend: usage } : {}) };
|
|
111
|
+
return { limits, balances, spending };
|
|
111
112
|
}
|
|
112
113
|
export function createSub2ApiAdapter(provider) {
|
|
113
114
|
return {
|