@astrosheep/pi-quota 0.12.2 → 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.
@@ -1,10 +1,10 @@
1
1
  import { bearer } from './http.ts';
2
- import { numeric, object } from './parse.ts';
2
+ import { numeric, object, percent } from './parse.ts';
3
3
  import { QuotaError } from './types.ts';
4
4
  import { safeBaseUrl } from './url.ts';
5
5
  import type { QuotaAdapter, QuotaSnapshot } from './types.ts';
6
6
 
7
- type Amounts = Pick<QuotaSnapshot, 'balance' | 'allowance' | 'spend'>;
7
+ type Amounts = Omit<QuotaSnapshot, 'fetchedAt'>;
8
8
 
9
9
  export interface NewApiOptions {
10
10
  quotaPerUnit?: number;
@@ -43,9 +43,11 @@ export function parseNewApiTokenUsage(payload: unknown, options: NewApiOptions =
43
43
  if (data.object !== 'token_usage') return null;
44
44
  const used = numeric(data.total_used);
45
45
  if (used === null || !Number.isSafeInteger(used) || used < 0) throw new QuotaError('schema');
46
+ const spending = [{ id: 'key-lifetime', label: 'API key spend', currency,
47
+ amount: used / quotaPerUnit, period: 'lifetime' as const }];
46
48
  if (data.unlimited_quota === true) {
47
- return { balance: { currency, remaining: 0, unlimited: true },
48
- spend: { currency, lifetime: used / quotaPerUnit } };
49
+ return { balances: [], limits: [{ id: 'key-quota', label: 'Key quota',
50
+ remainingPercent: null, resetAt: null, unlimited: true }], spending };
49
51
  }
50
52
  const remaining = numeric(data.total_available);
51
53
  if (remaining === null || !Number.isSafeInteger(remaining)) throw new QuotaError('schema');
@@ -54,15 +56,17 @@ export function parseNewApiTokenUsage(payload: unknown, options: NewApiOptions =
54
56
 
55
57
  // New-api reports granted = key used + key remaining. Only draw a finite
56
58
  // allocation bar when the server actually supplies that total.
59
+ let limit: number | null = null;
57
60
  if (data.total_granted !== undefined) {
58
61
  const granted = numeric(data.total_granted);
59
62
  if (granted === null || !Number.isSafeInteger(granted) || granted < 0
60
63
  || !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 } };
64
+ limit = granted / quotaPerUnit;
63
65
  }
64
- return { balance: { currency, remaining: moneyLeft },
65
- spend: { currency, lifetime: used / quotaPerUnit } };
66
+ // Key quota is not owned account money. Missing total_granted stays unknown.
67
+ return { balances: [], limits: [{ id: 'key-quota', label: 'Key quota', resetAt: null,
68
+ remainingPercent: limit !== null && limit > 0 ? percent(moneyLeft / limit * 100) : null,
69
+ amounts: { unit: currency, limit, used: used / quotaPerUnit, remaining: moneyLeft } }], spending };
66
70
  }
67
71
 
68
72
  // GET /api/user/self (UserAuth): the console's own account quota, readable with
@@ -78,8 +82,9 @@ export function parseNewApiUserSelf(payload: unknown, options: NewApiOptions = {
78
82
  if (quota === null || usedQuota === null
79
83
  || !Number.isSafeInteger(quota) || !Number.isSafeInteger(usedQuota)
80
84
  || quota < 0 || usedQuota < 0) throw new QuotaError('schema');
81
- return { balance: { currency, remaining: quota / quotaPerUnit },
82
- spend: { currency, lifetime: usedQuota / quotaPerUnit } };
85
+ return { balances: [{ id: 'account', label: 'Balance', currency, remaining: quota / quotaPerUnit }],
86
+ limits: [], spending: [{ id: 'account-lifetime', label: 'Account spend', currency,
87
+ amount: usedQuota / quotaPerUnit, period: 'lifetime' }] };
83
88
  }
84
89
 
85
90
  // Legacy one-api billing pair, still the only option on older deployments.
@@ -96,10 +101,10 @@ export function parseNewApiBilling(subscription: unknown, usage: unknown, option
96
101
  const used = totalUsage / 100;
97
102
  // This endpoint reports account remaining + lifetime usage as its hard limit.
98
103
  // 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 } };
104
+ const spending = [{ id: 'billing-lifetime', label: 'Reported spend', currency, amount: used, period: 'lifetime' as const }];
105
+ if (limit >= 1e7) return { balances: [], limits: [{ id: 'billing-quota', label: 'Quota',
106
+ remainingPercent: null, resetAt: null, unlimited: true }], spending };
107
+ return { balances: [{ id: 'account', label: 'Balance', currency, remaining: limit - used }], limits: [], spending };
103
108
  }
104
109
 
105
110
  export function createNewApiAdapter(provider: string, options: NewApiOptions = {}): QuotaAdapter {
@@ -129,7 +134,7 @@ export function createNewApiAdapter(provider: string, options: NewApiOptions = {
129
134
  if (dashboardUserId !== undefined) headers['New-Api-User'] = String(dashboardUserId);
130
135
  try {
131
136
  const payload = await context.getJson(`${root}/api/user/self`, headers, context.signal);
132
- return { windows: [], ...parseNewApiUserSelf(payload, settings), fetchedAt: context.now() };
137
+ return { ...parseNewApiUserSelf(payload, settings), fetchedAt: context.now() };
133
138
  } catch (error) {
134
139
  if (!(error instanceof QuotaError)) throw error;
135
140
  }
@@ -145,7 +150,7 @@ export function createNewApiAdapter(provider: string, options: NewApiOptions = {
145
150
  context.getJson(`${root}/v1/dashboard/billing/usage?start_date=2020-01-01&end_date=2100-01-01`, headers, context.signal),
146
151
  ]);
147
152
  const billing = parseNewApiBilling(pair[0], pair[1], settings);
148
- if (billing.balance?.unlimited !== true) return { windows: [], ...billing, fetchedAt: context.now() };
153
+ if (billing.balances.length > 0) return { ...billing, fetchedAt: context.now() };
149
154
  billingBalance = billing;
150
155
  } catch (error) {
151
156
  // 404: deployment has no billing pair. 401: try the key-native endpoint
@@ -157,15 +162,15 @@ export function createNewApiAdapter(provider: string, options: NewApiOptions = {
157
162
  try {
158
163
  const payload = await context.getJson(`${root}/api/usage/token/`, headers, context.signal);
159
164
  const parsed = parseNewApiTokenUsage(payload, settings);
160
- if (parsed) return { windows: [], ...parsed, fetchedAt: context.now() };
165
+ if (parsed) return { ...parsed, fetchedAt: context.now() };
161
166
  } catch (error) {
162
167
  if (billingBalance && error instanceof QuotaError && error.code === 'http') {
163
- return { windows: [], ...billingBalance, fetchedAt: context.now() };
168
+ return { ...billingBalance, fetchedAt: context.now() };
164
169
  }
165
170
  if (error instanceof QuotaError && error.code === 'auth') throw new QuotaError('account-access');
166
171
  throw error;
167
172
  }
168
- if (billingBalance) return { windows: [], ...billingBalance, fetchedAt: context.now() };
173
+ if (billingBalance) return { ...billingBalance, fetchedAt: context.now() };
169
174
  throw new QuotaError('schema');
170
175
  },
171
176
  };
@@ -1,7 +1,7 @@
1
1
  import { bearer, officialAuth } from './http.ts';
2
2
  import { numeric, object, percent, timestamp, windowLabel } from './parse.ts';
3
3
  import { QuotaError } from './types.ts';
4
- import type { QuotaAdapter, QuotaWindow } from './types.ts';
4
+ import type { QuotaAdapter, UsageLimit } from './types.ts';
5
5
 
6
6
  // OpenCode Go (opencode.ai Zen Go plan) is a single official deployment, so the
7
7
  // window keys are fixed and undecorated: percent is already "used", and there is
@@ -12,9 +12,9 @@ const WINDOWS = [
12
12
  { key: 'monthly', duration: 2592000 }, // 30 days
13
13
  ] as const;
14
14
 
15
- export function parseOpenCodeGo(payload: unknown): QuotaWindow[] {
15
+ export function parseOpenCodeGo(payload: unknown): UsageLimit[] {
16
16
  const usage = object(object(payload).usage);
17
- const windows: QuotaWindow[] = [];
17
+ const windows: UsageLimit[] = [];
18
18
  for (const { key, duration } of WINDOWS) {
19
19
  if (usage[key] === undefined) continue;
20
20
  const detail = object(usage[key]);
@@ -41,7 +41,7 @@ export function createOpenCodeGoAdapter(provider: string): QuotaAdapter {
41
41
  const data = await context.getJson('https://opencode.ai/zen/go/v1/usage', {
42
42
  Authorization: `Bearer ${bearer(auth)}`,
43
43
  }, context.signal);
44
- return { windows: parseOpenCodeGo(data), fetchedAt: context.now() };
44
+ return { balances: [], limits: parseOpenCodeGo(data), spending: [], fetchedAt: context.now() };
45
45
  },
46
46
  };
47
47
  }
@@ -1,6 +1,6 @@
1
1
  import { safeLabel } from './parse.ts';
2
2
  import { QuotaError } from './types.ts';
3
- import type { AccountBalance, QuotaAdapter, QuotaAllowance, QuotaSnapshot, SpendSummary } from './types.ts';
3
+ import type { LimitAmounts, QuotaAdapter, QuotaSnapshot } from './types.ts';
4
4
 
5
5
  export class AdapterRegistry {
6
6
  private readonly adapters = new Map<string, QuotaAdapter>();
@@ -25,58 +25,83 @@ export class AdapterRegistry {
25
25
  }
26
26
  }
27
27
 
28
- // Validate even custom adapter output before it reaches the bar.
29
- export function validateSnapshot(snapshot: QuotaSnapshot): QuotaSnapshot {
30
- if (!snapshot || !Number.isFinite(snapshot.fetchedAt) || !Array.isArray(snapshot.windows)
31
- || (snapshot.windows.length === 0 && snapshot.balance === undefined
32
- && snapshot.allowance === undefined)
33
- || snapshot.windows.length > 64) throw new QuotaError('schema');
34
- let balance: AccountBalance | undefined;
35
- if (snapshot.balance !== undefined) {
36
- const data = snapshot.balance;
37
- if (!data || typeof data.currency !== 'string' || !/^[A-Z]{3}$/.test(data.currency)
38
- || !Number.isFinite(data.remaining)
39
- || (data.unlimited !== undefined && typeof data.unlimited !== 'boolean')) throw new QuotaError('schema');
40
- balance = { currency: data.currency, remaining: data.remaining,
41
- ...(data.unlimited === true ? { unlimited: true } : {}) };
42
- }
43
- const allowance = snapshot.allowance === undefined ? undefined : validateAllowance(snapshot.allowance);
44
- let spend: SpendSummary | undefined;
45
- if (snapshot.spend !== undefined) {
46
- const data = snapshot.spend;
47
- if (!data || typeof data.currency !== 'string' || !/^[A-Z]{3}$/.test(data.currency)
48
- || (data.today === undefined && data.lifetime === undefined)
49
- || (data.today !== undefined && (!Number.isFinite(data.today) || data.today < 0))
50
- || (data.lifetime !== undefined && (!Number.isFinite(data.lifetime) || data.lifetime < 0))) {
28
+ // Validate even custom/exec adapter output before it reaches a consumer.
29
+ export function validateSnapshot(snapshot: unknown): QuotaSnapshot {
30
+ const data = record(snapshot);
31
+ if (!Number.isFinite(data.fetchedAt)
32
+ || ['windows', 'balance', 'allowance', 'spend'].some(key => key in data)) throw new QuotaError('schema');
33
+ const balances = entries(data.balances).map(entry => {
34
+ if (!isCurrency(entry.currency) || !Number.isFinite(entry.remaining)
35
+ || entry.unlimited !== undefined) throw new QuotaError('schema');
36
+ return { ...identity(entry), currency: entry.currency, remaining: entry.remaining as number };
37
+ });
38
+ const limits = entries(data.limits).map(entry => {
39
+ if ((entry.scope !== undefined && typeof entry.scope !== 'string')
40
+ || (entry.durationSeconds !== undefined && (!Number.isFinite(entry.durationSeconds) || (entry.durationSeconds as number) <= 0))
41
+ || (entry.remainingPercent !== null && (!Number.isFinite(entry.remainingPercent)
42
+ || (entry.remainingPercent as number) < 0 || (entry.remainingPercent as number) > 100))
43
+ || (entry.resetAt !== null && (!Number.isFinite(entry.resetAt) || (entry.resetAt as number) <= 0))
44
+ || (entry.unlimited !== undefined && entry.unlimited !== true)
45
+ || (entry.unlimited === true && (entry.amounts !== undefined || entry.remainingPercent !== null))) {
51
46
  throw new QuotaError('schema');
52
47
  }
53
- spend = { currency: data.currency, ...(data.today !== undefined ? { today: data.today } : {}),
54
- ...(data.lifetime !== undefined ? { lifetime: data.lifetime } : {}) };
48
+ return { ...identity(entry), remainingPercent: entry.remainingPercent as number | null,
49
+ resetAt: entry.resetAt as number | null,
50
+ ...(entry.scope !== undefined ? { scope: label(entry.scope) } : {}),
51
+ ...(entry.durationSeconds !== undefined ? { durationSeconds: entry.durationSeconds as number } : {}),
52
+ ...(entry.amounts !== undefined ? { amounts: validateAmounts(entry.amounts) } : {}),
53
+ ...(entry.unlimited === true ? { unlimited: true as const } : {}) };
54
+ });
55
+ const spending = entries(data.spending).map(entry => {
56
+ if (!isCurrency(entry.currency) || !Number.isFinite(entry.amount) || (entry.amount as number) < 0
57
+ || (entry.period !== 'today' && entry.period !== 'lifetime')) throw new QuotaError('schema');
58
+ return { ...identity(entry), currency: entry.currency, amount: entry.amount as number, period: entry.period as 'today' | 'lifetime' };
59
+ });
60
+ if (balances.length + limits.length + spending.length === 0) throw new QuotaError('schema');
61
+ return { balances, limits, spending, fetchedAt: data.fetchedAt as number };
62
+ }
63
+
64
+ function record(value: unknown): Record<string, unknown> {
65
+ if (!value || typeof value !== 'object' || Array.isArray(value)) throw new QuotaError('schema');
66
+ return value as Record<string, unknown>;
67
+ }
68
+
69
+ function label(value: unknown): string {
70
+ if (typeof value !== 'string') throw new QuotaError('schema');
71
+ const clean = safeLabel(value).trim();
72
+ if (!clean) throw new QuotaError('schema');
73
+ return clean;
74
+ }
75
+
76
+ function identity(data: Record<string, unknown>): { id: string; label: string } {
77
+ // IDs are opaque and preserved, never derived from duration, scope or label.
78
+ if (typeof data.id !== 'string' || !data.id || data.id !== label(data.id) || data.id.length > 64) {
79
+ throw new QuotaError('schema');
55
80
  }
81
+ return { id: data.id, label: label(data.label) };
82
+ }
83
+
84
+ function entries(value: unknown): Record<string, unknown>[] {
85
+ if (!Array.isArray(value) || value.length > 64) throw new QuotaError('schema');
56
86
  const ids = new Set<string>();
57
- const windows = snapshot.windows.map(window => {
58
- if (!window || typeof window.id !== 'string' || ids.has(window.id)
59
- || typeof window.label !== 'string'
60
- || (window.scope !== undefined && typeof window.scope !== 'string')
61
- || !Number.isFinite(window.durationSeconds) || window.durationSeconds <= 0
62
- || (window.remainingPercent !== null && (!Number.isFinite(window.remainingPercent)
63
- || window.remainingPercent < 0 || window.remainingPercent > 100))
64
- || (window.resetAt !== null && (!Number.isFinite(window.resetAt) || window.resetAt <= 0))) {
65
- throw new QuotaError('schema');
66
- }
67
- ids.add(window.id);
68
- return { ...window, label: safeLabel(window.label),
69
- ...(window.amounts !== undefined ? { amounts: validateAllowance(window.amounts) } : {}),
70
- ...(window.scope !== undefined ? { scope: safeLabel(window.scope) } : {}) };
87
+ return value.map(item => {
88
+ const data = record(item);
89
+ const { id } = identity(data);
90
+ if (ids.has(id)) throw new QuotaError('schema');
91
+ ids.add(id);
92
+ return data;
71
93
  });
72
- return { fetchedAt: snapshot.fetchedAt, windows, ...(balance ? { balance } : {}),
73
- ...(allowance ? { allowance } : {}), ...(spend ? { spend } : {}) };
74
94
  }
75
95
 
76
- function validateAllowance(data: QuotaAllowance): QuotaAllowance {
77
- if (!data || typeof data.currency !== 'string' || !/^[A-Z]{3}$/.test(data.currency)
78
- || !Number.isFinite(data.limit) || data.limit <= 0
79
- || !Number.isFinite(data.used) || data.used < 0
96
+ function isCurrency(value: unknown): value is string {
97
+ return typeof value === 'string' && /^[A-Z]{3}$/.test(value);
98
+ }
99
+
100
+ function validateAmounts(value: unknown): LimitAmounts {
101
+ const data = record(value);
102
+ if ((data.limit !== null && (!Number.isFinite(data.limit) || (data.limit as number) < 0))
103
+ || !Number.isFinite(data.used) || (data.used as number) < 0
80
104
  || !Number.isFinite(data.remaining)) throw new QuotaError('schema');
81
- return { currency: data.currency, limit: data.limit, used: data.used, remaining: data.remaining };
105
+ return { unit: label(data.unit), limit: data.limit as number | null,
106
+ used: data.used as number, remaining: data.remaining as number };
82
107
  }
@@ -2,7 +2,7 @@ import { bearer } from './http.ts';
2
2
  import { numeric, object, percent, timestamp } from './parse.ts';
3
3
  import { QuotaError } from './types.ts';
4
4
  import { safeBaseUrl } from './url.ts';
5
- import type { AccountBalance, QuotaAdapter, QuotaAllowance, QuotaSnapshot, QuotaWindow, ProviderAuth, SpendSummary } from './types.ts';
5
+ import type { Balance, LimitAmounts, QuotaAdapter, QuotaSnapshot, UsageLimit, ProviderAuth, Spending } from './types.ts';
6
6
 
7
7
  export function sub2ApiUsageUrl(baseUrl: string | undefined): string {
8
8
  const url = safeBaseUrl(baseUrl);
@@ -23,23 +23,21 @@ function amount(value: unknown): number {
23
23
  return result;
24
24
  }
25
25
 
26
- function allowance(data: Record<string, unknown>, unit: string): QuotaAllowance {
26
+ function allowance(data: Record<string, unknown>, unit: string): LimitAmounts {
27
27
  const limit = amount(data.limit);
28
28
  if (limit === 0) throw new QuotaError('schema');
29
29
  const used = amount(data.used);
30
30
  const remaining = amount(data.remaining);
31
- return { currency: unit, limit, used, remaining };
31
+ return { unit, limit, used, remaining };
32
32
  }
33
33
 
34
- function spend(data: Record<string, unknown>, unit: string): SpendSummary | undefined {
35
- const today = object(data.today);
36
- const total = object(data.total);
37
- const todayCost = numeric(today.actual_cost);
38
- const lifetimeCost = numeric(total.actual_cost);
39
- if (todayCost === null && lifetimeCost === null) return undefined;
40
- if ((todayCost !== null && todayCost < 0) || (lifetimeCost !== null && lifetimeCost < 0)) throw new QuotaError('schema');
41
- return { currency: unit, ...(todayCost !== null ? { today: todayCost } : {}),
42
- ...(lifetimeCost !== null ? { lifetime: lifetimeCost } : {}) };
34
+ function spend(data: Record<string, unknown>, currency: string): Spending[] {
35
+ return [['today', data.today], ['lifetime', data.total]].flatMap(([period, raw]) => {
36
+ const cost = object(raw).actual_cost;
37
+ if (cost === undefined) return [];
38
+ return [{ id: `key-${period}`, label: 'API key spend', currency, amount: amount(cost),
39
+ period: period as 'today' | 'lifetime' }];
40
+ });
43
41
  }
44
42
 
45
43
  function percentFromAmounts(remaining: number, limit: number): number {
@@ -48,16 +46,16 @@ function percentFromAmounts(remaining: number, limit: number): number {
48
46
 
49
47
  const RATE_WINDOWS: Record<string, number> = { '5h': 18000, '1d': 86400, '7d': 604800 };
50
48
 
51
- function rateWindow(data: Record<string, unknown>, unit: string): QuotaWindow {
49
+ function rateWindow(data: Record<string, unknown>): UsageLimit {
52
50
  const label = data.window;
53
51
  if (typeof label !== 'string' || !Object.hasOwn(RATE_WINDOWS, label)) throw new QuotaError('schema');
54
- const limits = allowance(data, unit);
52
+ const limits = allowance(data, 'USD');
55
53
  return { id: `rate-${label}`, label, durationSeconds: RATE_WINDOWS[label],
56
- remainingPercent: percentFromAmounts(limits.remaining, limits.limit),
54
+ remainingPercent: percentFromAmounts(limits.remaining, limits.limit!),
57
55
  resetAt: timestamp(data.reset_at), amounts: limits };
58
56
  }
59
57
 
60
- function subscriptionWindows(data: Record<string, unknown>, unit: string): QuotaWindow[] {
58
+ function subscriptionWindows(data: Record<string, unknown>): UsageLimit[] {
61
59
  const specs = [
62
60
  ['daily', '1d', 'daily_usage_usd', 'daily_limit_usd', 86400],
63
61
  ['weekly', '1w', 'weekly_usage_usd', 'weekly_limit_usd', 604800],
@@ -70,10 +68,9 @@ function subscriptionWindows(data: Record<string, unknown>, unit: string): Quota
70
68
  if (limit === 0) return []; // No cap for this period; no percentage can be computed.
71
69
  const used = numeric(data[usedKey]);
72
70
  if (used === null || used < 0) throw new QuotaError('schema');
73
- const amounts: QuotaAllowance = { currency: unit, limit, used, remaining: Math.max(0, limit - used) };
74
- const start = id === 'weekly' ? timestamp(data.weekly_window_start) : null;
71
+ const amounts: LimitAmounts = { unit: 'USD', limit, used, remaining: Math.max(0, limit - used) };
75
72
  return [{ id, label, durationSeconds, remainingPercent: percentFromAmounts(amounts.remaining, limit),
76
- resetAt: start === null ? null : start + durationSeconds * 1000, amounts }];
73
+ resetAt: null, amounts }];
77
74
  });
78
75
  }
79
76
 
@@ -86,27 +83,29 @@ export function parseSub2ApiUsage(payload: unknown): Omit<QuotaSnapshot, 'fetche
86
83
  // Sub2API rate limits are always USD; a fixed quota carries its own unit.
87
84
  const unit = currency(body.unit ?? (body.mode === 'quota_limited'
88
85
  ? object(body.quota).unit ?? 'USD' : undefined));
89
- const windows: QuotaWindow[] = [];
90
- let balance: AccountBalance | undefined;
91
- let finiteQuota: QuotaAllowance | undefined;
86
+ const limits: UsageLimit[] = [];
87
+ const balances: Balance[] = [];
92
88
  if (body.mode === 'quota_limited') {
93
- const quota = body.quota === undefined ? undefined : allowance(object(body.quota), unit);
94
- finiteQuota = quota;
89
+ if (body.quota !== undefined) {
90
+ const amounts = allowance(object(body.quota), unit);
91
+ limits.push({ id: 'key-quota', label: 'Key quota', resetAt: null,
92
+ remainingPercent: percentFromAmounts(amounts.remaining, amounts.limit!), amounts });
93
+ }
95
94
  const rates = body.rate_limits;
96
95
  if (rates !== undefined) {
97
96
  if (!Array.isArray(rates)) throw new QuotaError('schema');
98
- rates.forEach(entry => windows.push(rateWindow(object(entry), unit)));
97
+ rates.forEach(entry => limits.push(rateWindow(object(entry))));
99
98
  }
100
99
  } else if (body.subscription !== undefined) {
101
- windows.push(...subscriptionWindows(object(body.subscription), unit));
102
- } else {
103
- const remaining = amount(body.remaining ?? body.balance);
104
- balance = { currency: unit, remaining };
100
+ limits.push(...subscriptionWindows(object(body.subscription)));
101
+ } else if (body.remaining !== undefined || body.balance !== undefined) {
102
+ const remaining = numeric(body.remaining ?? body.balance);
103
+ if (remaining === null) throw new QuotaError('schema');
104
+ balances.push({ id: 'wallet', label: 'Balance', currency: unit, remaining });
105
105
  }
106
- const usage = spend(object(body.usage), unit);
107
- if (windows.length === 0 && !balance && !finiteQuota) throw new QuotaError('schema');
108
- return { windows, ...(balance ? { balance } : {}), ...(finiteQuota ? { allowance: finiteQuota } : {}),
109
- ...(usage ? { spend: usage } : {}) };
106
+ const spending = spend(object(body.usage), unit);
107
+ if (limits.length + balances.length + spending.length === 0) throw new QuotaError('schema');
108
+ return { limits, balances, spending };
110
109
  }
111
110
 
112
111
  export function createSub2ApiAdapter(provider: string): QuotaAdapter {
@@ -1,38 +1,41 @@
1
1
  // Query-layer contracts: no Pi, terminal, or rendering dependencies.
2
- export interface QuotaWindow {
2
+ export interface Balance {
3
3
  id: string;
4
4
  label: string;
5
- remainingPercent: number | null; // null is unknown, never silently 0 or 100
6
- resetAt: number | null; // epoch milliseconds
7
- durationSeconds: number;
8
- scope?: string; // e.g. a model-specific Codex quota domain
9
- amounts?: QuotaAllowance; // exact periodic amounts when exposed by the API
10
- }
11
-
12
- export interface AccountBalance {
13
5
  currency: string; // ISO-style currency code, e.g. USD
14
- remaining: number; // may be negative (debt); not a periodic allowance
15
- unlimited?: boolean; // provider granted no finite cap; ignore remaining
6
+ remaining: number; // finite; may be negative (debt); owned money, not a quota
16
7
  }
17
8
 
18
- export interface QuotaAllowance {
19
- currency: string;
20
- limit: number;
9
+ export interface LimitAmounts {
10
+ unit: string; // currency code or a count unit, e.g. USD, CNY, tokens, requests
11
+ limit: number | null; // null when the API supplies amounts but no total cap
21
12
  used: number;
22
- remaining: number;
13
+ remaining: number; // may be negative when overdrawn
23
14
  }
24
15
 
25
- export interface SpendSummary {
16
+ export interface UsageLimit {
17
+ id: string;
18
+ label: string;
19
+ scope?: string; // e.g. a model-specific Codex quota domain
20
+ remainingPercent: number | null; // remaining, not used; null is unknown
21
+ resetAt: number | null; // epoch milliseconds; null is unknown/not supplied
22
+ durationSeconds?: number; // absence says nothing about whether it renews
23
+ amounts?: LimitAmounts; // independent of rounded/provider-specific percentages
24
+ unlimited?: true; // no finite cap; no amounts or fabricated percentage
25
+ }
26
+
27
+ export interface Spending {
28
+ id: string;
29
+ label: string;
26
30
  currency: string;
27
- today?: number;
28
- lifetime?: number;
31
+ amount: number;
32
+ period: 'today' | 'lifetime'; // today uses the provider's day, not an inferred timezone
29
33
  }
30
34
 
31
35
  export interface QuotaSnapshot {
32
- windows: readonly QuotaWindow[];
33
- balance?: AccountBalance;
34
- allowance?: QuotaAllowance;
35
- spend?: SpendSummary;
36
+ balances: Balance[];
37
+ limits: UsageLimit[];
38
+ spending: Spending[];
36
39
  fetchedAt: number;
37
40
  }
38
41
 
@@ -1,7 +1,7 @@
1
1
  import { officialAuth } from './http.ts';
2
2
  import { numeric, object, percent, timestamp, windowLabel } from './parse.ts';
3
3
  import { QuotaError } from './types.ts';
4
- import type { PostJsonResult, QuotaAdapter, QuotaSnapshot, QuotaWindow } from './types.ts';
4
+ import type { PostJsonResult, QuotaAdapter, QuotaSnapshot, UsageLimit } from './types.ts';
5
5
 
6
6
  export const YESCODE_ORIGIN = 'https://co.yes.vg';
7
7
 
@@ -70,7 +70,7 @@ function nonNegative(value: unknown): number | undefined {
70
70
  }
71
71
 
72
72
  function limitWindow(id: string, label: string, durationSeconds: number,
73
- limitRaw: unknown, usedRaw: unknown, resetAt: number | null): QuotaWindow[] {
73
+ limitRaw: unknown, usedRaw: unknown, resetAt: number | null): UsageLimit[] {
74
74
  const used = nonNegative(usedRaw); // validate present-but-corrupt spends even without a cap
75
75
  const limit = nonNegative(limitRaw);
76
76
  if (limit === undefined || limit === 0) return []; // no configured cap: nothing to draw
@@ -79,17 +79,17 @@ function limitWindow(id: string, label: string, durationSeconds: number,
79
79
  }
80
80
  const remaining = Math.max(0, limit - used);
81
81
  return [{ id, label, durationSeconds, remainingPercent: percent(remaining / limit * 100), resetAt,
82
- amounts: { currency: 'USD', limit, used, remaining } }];
82
+ amounts: { unit: 'USD', limit, used, remaining } }];
83
83
  }
84
84
 
85
85
  // GET /api/v1/user/balance (optional; some accounts may lack it) merged with
86
86
  // GET /api/v1/auth/profile (required). The live response carries a generic
87
87
  // spending_limits array (scope/spent/limit/remaining/resets_at/window_seconds)
88
88
  // plus top-level weekly_* and monthly_* fields; profile is the last fallback.
89
- function spendingLimitWindows(balanceData: Record<string, unknown>): QuotaWindow[] | null {
89
+ function spendingLimitWindows(balanceData: Record<string, unknown>): UsageLimit[] | null {
90
90
  if (balanceData.spending_limits === undefined) return null;
91
91
  if (!Array.isArray(balanceData.spending_limits)) throw new QuotaError('schema');
92
- const windows = balanceData.spending_limits.flatMap((entry): QuotaWindow[] => {
92
+ const windows = balanceData.spending_limits.flatMap((entry): UsageLimit[] => {
93
93
  const item = object(entry);
94
94
  const limit = nonNegative(item.limit);
95
95
  const spent = nonNegative(item.spent);
@@ -108,10 +108,10 @@ function spendingLimitWindows(balanceData: Record<string, unknown>): QuotaWindow
108
108
  const left = remaining ?? Math.max(0, limit - spent);
109
109
  return [{ id: `spending:${item.key}`, label: windowLabel(windowSeconds), durationSeconds: windowSeconds,
110
110
  remainingPercent: percent(left / limit * 100), resetAt,
111
- amounts: { currency: 'USD', limit, used: spent, remaining: left } }];
111
+ amounts: { unit: 'USD', limit, used: spent, remaining: left } }];
112
112
  });
113
113
  // Footer/detail convention is ascending duration (5h → 1w → 30d).
114
- return windows.sort((a, b) => a.durationSeconds - b.durationSeconds);
114
+ return windows.sort((a, b) => a.durationSeconds! - b.durationSeconds!);
115
115
  }
116
116
 
117
117
  export function parseYesCodeAccount(balancePayload: unknown, profilePayload: unknown): Omit<QuotaSnapshot, 'fetchedAt'> {
@@ -122,21 +122,20 @@ export function parseYesCodeAccount(balancePayload: unknown, profilePayload: unk
122
122
  const paygo = optionalAmount(balanceData.pay_as_you_go_balance ?? profile.pay_as_you_go_balance);
123
123
  if (subscription === undefined && paygo === undefined) throw new QuotaError('schema');
124
124
 
125
- const weekStart = timestamp(profile.last_week_reset);
126
- const monthStart = timestamp(profile.last_month_reset);
127
- const windows = spendingLimitWindows(balanceData) ?? [
125
+ const limits = spendingLimitWindows(balanceData) ?? [
128
126
  ...limitWindow('daily', '1d', 86400, balanceData.daily_limit, balanceData.daily_spent,
129
127
  timestamp(balanceData.daily_resets_at ?? balanceData.daily_reset_at)),
130
128
  ...limitWindow('weekly', '1w', 604800, balanceData.weekly_limit ?? plan.weekly_limit,
131
129
  balanceData.weekly_spent_balance ?? balanceData.weekly_spent ?? profile.current_week_spend,
132
- timestamp(balanceData.weekly_resets_at ?? balanceData.weekly_reset_at)
133
- ?? (weekStart === null ? null : weekStart + 604800000)),
130
+ timestamp(balanceData.weekly_resets_at ?? balanceData.weekly_reset_at)),
134
131
  ...limitWindow('monthly', '30d', 2592000, balanceData.monthly_spend_limit ?? plan.monthly_spend_limit,
135
132
  balanceData.monthly_spent_balance ?? balanceData.monthly_spent ?? profile.current_month_spend,
136
- timestamp(balanceData.monthly_resets_at ?? balanceData.monthly_reset_at)
137
- ?? (monthStart === null ? null : monthStart + 2592000000)),
133
+ timestamp(balanceData.monthly_resets_at ?? balanceData.monthly_reset_at)),
138
134
  ];
139
- return { windows, balance: { currency: 'USD', remaining: (subscription ?? 0) + (paygo ?? 0) } };
135
+ return { limits, balances: [
136
+ ...(subscription !== undefined ? [{ id: 'subscription', label: 'Subscription', currency: 'USD', remaining: subscription }] : []),
137
+ ...(paygo !== undefined ? [{ id: 'paygo', label: 'Pay-as-you-go', currency: 'USD', remaining: paygo }] : []),
138
+ ], spending: [] };
140
139
  }
141
140
 
142
141
  export function createYesCodeAdapter(provider: string, options: YesCodeOptions): QuotaAdapter {