@astrosheep/pi-quota 0.5.0 → 0.5.1

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
@@ -41,12 +41,12 @@ Use the **exact Pi provider ID**. Only selected, explicitly bound providers are
41
41
  | Adapter | Endpoint | Result |
42
42
  |---|---|---|
43
43
  | `sub2api` | `{baseUrl}/usage` | Wallet, key quota, rate windows or subscription day/week/month windows; API-key Today/Lifetime actual spend |
44
- | `new-api` | Account PAT `/api/user/self`, falling back to key-native billing/token endpoints | Account or key balance; **not** a renewable window |
44
+ | `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 |
45
45
  | `deepseek` | Official `/user/balance` | Balance only; no invented spend |
46
46
 
47
47
  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.
48
48
 
49
- 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. Values in settings and server error bodies are never logged.
49
+ 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.
50
50
 
51
51
  Authenticated quota URLs must use HTTPS, except HTTP loopback for local deployments. URL userinfo, query strings, fragments and HTTP redirects are rejected; credentials never follow redirects. Fixed official endpoints reject custom origins. No browser cookie scraping or credential discovery.
52
52
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@astrosheep/pi-quota",
3
- "version": "0.5.0",
3
+ "version": "0.5.1",
4
4
  "publishConfig": { "access": "public" },
5
5
  "type": "module",
6
6
  "description": "Composable quota bar with Codex, Kimi, OpenCode Go, new-api, DeepSeek and Sub2API adapters",
@@ -2,7 +2,9 @@ import { bearer } from './http.ts';
2
2
  import { numeric, object } from './parse.ts';
3
3
  import { QuotaError } from './types.ts';
4
4
  import { safeBaseUrl } from './url.ts';
5
- import type { AccountBalance, QuotaAdapter } from './types.ts';
5
+ import type { QuotaAdapter, QuotaSnapshot } from './types.ts';
6
+
7
+ type Amounts = Pick<QuotaSnapshot, 'balance' | 'allowance' | 'spend'>;
6
8
 
7
9
  export interface NewApiOptions {
8
10
  quotaPerUnit?: number;
@@ -33,29 +35,40 @@ export function newApiRootUrl(baseUrl: string | undefined): string {
33
35
  // GET /api/usage/token (new-api >= v0.9.0-alpha.8, PR QuantumNous/new-api#1161):
34
36
  // accepts the sk- model key itself (TokenAuth), returns per-token quota in
35
37
  // the same unit as /api/user/self quota. Response shell: {code:true,data:{...}}.
36
- export function parseNewApiTokenUsage(payload: unknown, options: NewApiOptions = {}): AccountBalance | null {
38
+ export function parseNewApiTokenUsage(payload: unknown, options: NewApiOptions = {}): Amounts | null {
37
39
  const { quotaPerUnit, currency } = validateNewApiOptions(options);
38
40
  const response = object(payload);
39
41
  if (response.code !== true) return null;
40
42
  const data = object(response.data);
41
43
  if (data.object !== 'token_usage') return null;
42
44
  const used = numeric(data.total_used);
43
- // Missing fields must not become a made-up zero balance.
44
45
  if (used === null || !Number.isSafeInteger(used) || used < 0) throw new QuotaError('schema');
45
46
  if (data.unlimited_quota === true) {
46
- return { currency, remaining: 0, unlimited: true };
47
+ return { balance: { currency, remaining: 0, unlimited: true },
48
+ spend: { currency, lifetime: used / quotaPerUnit } };
47
49
  }
48
50
  const remaining = numeric(data.total_available);
49
51
  if (remaining === null || !Number.isSafeInteger(remaining)) throw new QuotaError('schema');
50
- const balance = { currency, remaining: remaining / quotaPerUnit };
51
- if (!Number.isFinite(balance.remaining)) throw new QuotaError('schema');
52
- return balance;
52
+ const moneyLeft = remaining / quotaPerUnit;
53
+ if (!Number.isFinite(moneyLeft)) throw new QuotaError('schema');
54
+
55
+ // New-api reports granted = key used + key remaining. Only draw a finite
56
+ // allocation bar when the server actually supplies that total.
57
+ if (data.total_granted !== undefined) {
58
+ const granted = numeric(data.total_granted);
59
+ if (granted === null || !Number.isSafeInteger(granted) || granted < 0
60
+ || !Number.isSafeInteger(used + remaining) || granted !== used + remaining) throw new QuotaError('schema');
61
+ if (granted > 0) return { allowance: { currency, limit: granted / quotaPerUnit,
62
+ used: used / quotaPerUnit, remaining: moneyLeft } };
63
+ }
64
+ return { balance: { currency, remaining: moneyLeft },
65
+ spend: { currency, lifetime: used / quotaPerUnit } };
53
66
  }
54
67
 
55
68
  // GET /api/user/self (UserAuth): the console's own account quota, readable with
56
69
  // a dashboard access token (PAT), which sk- keys are not. Old forks also demand
57
70
  // a matching New-Api-User header. Response shell: {success:true,data:{...}}.
58
- export function parseNewApiUserSelf(payload: unknown, options: NewApiOptions = {}): AccountBalance {
71
+ export function parseNewApiUserSelf(payload: unknown, options: NewApiOptions = {}): Amounts {
59
72
  const { quotaPerUnit, currency } = validateNewApiOptions(options);
60
73
  const response = object(payload);
61
74
  if (response.success !== true) throw new QuotaError('schema');
@@ -65,13 +78,14 @@ export function parseNewApiUserSelf(payload: unknown, options: NewApiOptions = {
65
78
  if (quota === null || usedQuota === null
66
79
  || !Number.isSafeInteger(quota) || !Number.isSafeInteger(usedQuota)
67
80
  || quota < 0 || usedQuota < 0) throw new QuotaError('schema');
68
- return { currency, remaining: quota / quotaPerUnit };
81
+ return { balance: { currency, remaining: quota / quotaPerUnit },
82
+ spend: { currency, lifetime: usedQuota / quotaPerUnit } };
69
83
  }
70
84
 
71
85
  // Legacy one-api billing pair, still the only option on older deployments.
72
86
  // total_usage is in cents (divide by 100, per one-api issue #1785); most
73
87
  // deployments report a fake 1e8 hard limit for unlimited quotas.
74
- export function parseNewApiBilling(subscription: unknown, usage: unknown, options: NewApiOptions = {}): AccountBalance {
88
+ export function parseNewApiBilling(subscription: unknown, usage: unknown, options: NewApiOptions = {}): Amounts {
75
89
  const { currency } = validateNewApiOptions(options);
76
90
  const sub = object(subscription);
77
91
  const limit = numeric(sub.hard_limit_usd);
@@ -80,8 +94,12 @@ export function parseNewApiBilling(subscription: unknown, usage: unknown, option
80
94
  if (limit === null || !Number.isFinite(limit) || limit < 0
81
95
  || totalUsage === null || !Number.isFinite(totalUsage) || totalUsage < 0) throw new QuotaError('schema');
82
96
  const used = totalUsage / 100;
83
- if (limit >= 1e7) return { currency, remaining: 0, unlimited: true };
84
- return { currency, remaining: limit - used };
97
+ // This endpoint reports account remaining + lifetime usage as its hard limit.
98
+ // Top-ups change that total, so it is a wallet balance, not a fixed allowance.
99
+ if (limit >= 1e7) return { balance: { currency, remaining: 0, unlimited: true },
100
+ spend: { currency, lifetime: used } };
101
+ return { balance: { currency, remaining: limit - used },
102
+ spend: { currency, lifetime: used } };
85
103
  }
86
104
 
87
105
  export function createNewApiAdapter(provider: string, options: NewApiOptions = {}): QuotaAdapter {
@@ -111,7 +129,7 @@ export function createNewApiAdapter(provider: string, options: NewApiOptions = {
111
129
  if (dashboardUserId !== undefined) headers['New-Api-User'] = String(dashboardUserId);
112
130
  try {
113
131
  const payload = await context.getJson(`${root}/api/user/self`, headers, context.signal);
114
- return { windows: [], balance: parseNewApiUserSelf(payload, settings), fetchedAt: context.now() };
132
+ return { windows: [], ...parseNewApiUserSelf(payload, settings), fetchedAt: context.now() };
115
133
  } catch (error) {
116
134
  if (!(error instanceof QuotaError)) throw error;
117
135
  }
@@ -120,14 +138,14 @@ export function createNewApiAdapter(provider: string, options: NewApiOptions = {
120
138
  // 1. Account billing (one-api legacy): a finite hard limit is the real
121
139
  // account balance. 1e8 means unlimited — then the key quota may still
122
140
  // be finite and more precise.
123
- let billingBalance: AccountBalance | null = null;
141
+ let billingBalance: Amounts | null = null;
124
142
  try {
125
143
  const pair = await Promise.all([
126
144
  context.getJson(`${root}/v1/dashboard/billing/subscription`, headers, context.signal),
127
145
  context.getJson(`${root}/v1/dashboard/billing/usage?start_date=2020-01-01&end_date=2100-01-01`, headers, context.signal),
128
146
  ]);
129
147
  const billing = parseNewApiBilling(pair[0], pair[1], settings);
130
- if (billing.unlimited !== true) return { windows: [], balance: billing, fetchedAt: context.now() };
148
+ if (billing.balance?.unlimited !== true) return { windows: [], ...billing, fetchedAt: context.now() };
131
149
  billingBalance = billing;
132
150
  } catch (error) {
133
151
  // 404: deployment has no billing pair. 401: try the key-native endpoint
@@ -139,15 +157,15 @@ export function createNewApiAdapter(provider: string, options: NewApiOptions = {
139
157
  try {
140
158
  const payload = await context.getJson(`${root}/api/usage/token/`, headers, context.signal);
141
159
  const parsed = parseNewApiTokenUsage(payload, settings);
142
- if (parsed) return { windows: [], balance: parsed, fetchedAt: context.now() };
160
+ if (parsed) return { windows: [], ...parsed, fetchedAt: context.now() };
143
161
  } catch (error) {
144
162
  if (billingBalance && error instanceof QuotaError && error.code === 'http') {
145
- return { windows: [], balance: billingBalance, fetchedAt: context.now() };
163
+ return { windows: [], ...billingBalance, fetchedAt: context.now() };
146
164
  }
147
165
  if (error instanceof QuotaError && error.code === 'auth') throw new QuotaError('account-access');
148
166
  throw error;
149
167
  }
150
- if (billingBalance) return { windows: [], balance: billingBalance, fetchedAt: context.now() };
168
+ if (billingBalance) return { windows: [], ...billingBalance, fetchedAt: context.now() };
151
169
  throw new QuotaError('schema');
152
170
  },
153
171
  };