@astrosheep/pi-quota 0.5.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.
@@ -0,0 +1,78 @@
1
+ import { safeLabel } from './parse.ts';
2
+ import { QuotaError } from './types.ts';
3
+ import type { AccountBalance, QuotaAdapter, QuotaAllowance, QuotaSnapshot, SpendSummary } from './types.ts';
4
+
5
+ export class AdapterRegistry {
6
+ private readonly adapters = new Map<string, QuotaAdapter>();
7
+
8
+ constructor(adapters: readonly QuotaAdapter[] = []) {
9
+ for (const adapter of adapters) this.register(adapter);
10
+ }
11
+
12
+ register(adapter: QuotaAdapter): void {
13
+ if (!adapter.provider || !adapter.label || this.adapters.has(adapter.provider)) {
14
+ throw new Error('Quota adapter must have a unique provider ID and a label');
15
+ }
16
+ this.adapters.set(adapter.provider, { ...adapter, label: safeLabel(adapter.label) });
17
+ }
18
+
19
+ get(provider: string | undefined): QuotaAdapter | undefined {
20
+ return provider ? this.adapters.get(provider) : undefined;
21
+ }
22
+ }
23
+
24
+ // Validate even custom adapter output before it reaches the bar.
25
+ export function validateSnapshot(snapshot: QuotaSnapshot): QuotaSnapshot {
26
+ if (!snapshot || !Number.isFinite(snapshot.fetchedAt) || !Array.isArray(snapshot.windows)
27
+ || (snapshot.windows.length === 0 && snapshot.balance === undefined
28
+ && snapshot.allowance === undefined)
29
+ || snapshot.windows.length > 64) throw new QuotaError('schema');
30
+ let balance: AccountBalance | undefined;
31
+ if (snapshot.balance !== undefined) {
32
+ const data = snapshot.balance;
33
+ if (!data || typeof data.currency !== 'string' || !/^[A-Z]{3}$/.test(data.currency)
34
+ || !Number.isFinite(data.remaining)
35
+ || (data.unlimited !== undefined && typeof data.unlimited !== 'boolean')) 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: SpendSummary | undefined;
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))) {
47
+ throw new QuotaError('schema');
48
+ }
49
+ spend = { currency: data.currency, ...(data.today !== undefined ? { today: data.today } : {}),
50
+ ...(data.lifetime !== undefined ? { lifetime: data.lifetime } : {}) };
51
+ }
52
+ const ids = new Set<string>();
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))) {
61
+ 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) } : {}) };
67
+ });
68
+ return { fetchedAt: snapshot.fetchedAt, windows, ...(balance ? { balance } : {}),
69
+ ...(allowance ? { allowance } : {}), ...(spend ? { spend } : {}) };
70
+ }
71
+
72
+ function validateAllowance(data: QuotaAllowance): QuotaAllowance {
73
+ if (!data || typeof data.currency !== 'string' || !/^[A-Z]{3}$/.test(data.currency)
74
+ || !Number.isFinite(data.limit) || data.limit <= 0
75
+ || !Number.isFinite(data.used) || data.used < 0
76
+ || !Number.isFinite(data.remaining)) throw new QuotaError('schema');
77
+ return { currency: data.currency, limit: data.limit, used: data.used, remaining: data.remaining };
78
+ }
@@ -0,0 +1,123 @@
1
+ import { bearer } from './http.ts';
2
+ import { numeric, object, percent, timestamp } from './parse.ts';
3
+ import { QuotaError } from './types.ts';
4
+ import { safeBaseUrl } from './url.ts';
5
+ import type { AccountBalance, QuotaAdapter, QuotaAllowance, QuotaSnapshot, QuotaWindow, ProviderAuth, SpendSummary } from './types.ts';
6
+
7
+ export function sub2ApiUsageUrl(baseUrl: string | undefined): string {
8
+ const url = safeBaseUrl(baseUrl);
9
+ url.pathname = `${url.pathname.replace(/\/+$/, '')}/usage`;
10
+ return url.toString();
11
+ }
12
+
13
+ function currency(value: unknown): string {
14
+ if (typeof value !== 'string' || !/^[A-Z]{3}$/.test(value)) throw new QuotaError('schema');
15
+ return value;
16
+ }
17
+
18
+ function amount(value: unknown): number {
19
+ const result = numeric(value);
20
+ if (result === null || result < 0) throw new QuotaError('schema');
21
+ return result;
22
+ }
23
+
24
+ function allowance(data: Record<string, unknown>, unit: string): QuotaAllowance {
25
+ const limit = amount(data.limit);
26
+ if (limit === 0) throw new QuotaError('schema');
27
+ const used = amount(data.used);
28
+ const remaining = amount(data.remaining);
29
+ return { currency: unit, limit, used, remaining };
30
+ }
31
+
32
+ function spend(data: Record<string, unknown>, unit: string): SpendSummary | undefined {
33
+ const today = object(data.today);
34
+ const total = object(data.total);
35
+ const todayCost = numeric(today.actual_cost);
36
+ const lifetimeCost = numeric(total.actual_cost);
37
+ if (todayCost === null && lifetimeCost === null) return undefined;
38
+ if ((todayCost !== null && todayCost < 0) || (lifetimeCost !== null && lifetimeCost < 0)) throw new QuotaError('schema');
39
+ return { currency: unit, ...(todayCost !== null ? { today: todayCost } : {}),
40
+ ...(lifetimeCost !== null ? { lifetime: lifetimeCost } : {}) };
41
+ }
42
+
43
+ function percentFromAmounts(remaining: number, limit: number): number {
44
+ return percent(remaining / limit * 100);
45
+ }
46
+
47
+ const RATE_WINDOWS: Record<string, number> = { '5h': 18000, '1d': 86400, '7d': 604800 };
48
+
49
+ function rateWindow(data: Record<string, unknown>, unit: string): QuotaWindow {
50
+ const label = data.window;
51
+ if (typeof label !== 'string' || !Object.hasOwn(RATE_WINDOWS, label)) throw new QuotaError('schema');
52
+ const limits = allowance(data, unit);
53
+ return { id: `rate-${label}`, label, durationSeconds: RATE_WINDOWS[label],
54
+ remainingPercent: percentFromAmounts(limits.remaining, limits.limit),
55
+ resetAt: timestamp(data.reset_at), amounts: limits };
56
+ }
57
+
58
+ function subscriptionWindows(data: Record<string, unknown>, unit: string): QuotaWindow[] {
59
+ const specs = [
60
+ ['daily', '1d', 'daily_usage_usd', 'daily_limit_usd', 86400],
61
+ ['weekly', '1w', 'weekly_usage_usd', 'weekly_limit_usd', 604800],
62
+ ['monthly', '30d', 'monthly_usage_usd', 'monthly_limit_usd', 2592000],
63
+ ] as const;
64
+ return specs.flatMap(([id, label, usedKey, limitKey, durationSeconds]) => {
65
+ if (data[limitKey] == null) return []; // Upstream sends null for an unconfigured cap.
66
+ const limit = numeric(data[limitKey]);
67
+ if (limit === null || limit < 0) throw new QuotaError('schema');
68
+ if (limit === 0) return []; // No cap for this period; no percentage can be computed.
69
+ const used = numeric(data[usedKey]);
70
+ if (used === null || used < 0) throw new QuotaError('schema');
71
+ const amounts: QuotaAllowance = { currency: unit, limit, used, remaining: Math.max(0, limit - used) };
72
+ const start = id === 'weekly' ? timestamp(data.weekly_window_start) : null;
73
+ return [{ id, label, durationSeconds, remainingPercent: percentFromAmounts(amounts.remaining, limit),
74
+ resetAt: start === null ? null : start + durationSeconds * 1000, amounts }];
75
+ });
76
+ }
77
+
78
+ export function parseSub2ApiUsage(payload: unknown): Omit<QuotaSnapshot, 'fetchedAt'> {
79
+ const body = object(payload);
80
+ if (body.isValid !== true || (body.mode !== 'quota_limited' && body.mode !== 'unrestricted')) {
81
+ throw new QuotaError('schema');
82
+ }
83
+ // Upstream omits `unit` when a key has rate limits but no total quota.
84
+ // Sub2API rate limits are always USD; a fixed quota carries its own unit.
85
+ const unit = currency(body.unit ?? (body.mode === 'quota_limited'
86
+ ? object(body.quota).unit ?? 'USD' : undefined));
87
+ const windows: QuotaWindow[] = [];
88
+ let balance: AccountBalance | undefined;
89
+ let finiteQuota: QuotaAllowance | undefined;
90
+ if (body.mode === 'quota_limited') {
91
+ const quota = body.quota === undefined ? undefined : allowance(object(body.quota), unit);
92
+ finiteQuota = quota;
93
+ const rates = body.rate_limits;
94
+ if (rates !== undefined) {
95
+ if (!Array.isArray(rates)) throw new QuotaError('schema');
96
+ rates.forEach(entry => windows.push(rateWindow(object(entry), unit)));
97
+ }
98
+ } else if (body.subscription !== undefined) {
99
+ windows.push(...subscriptionWindows(object(body.subscription), unit));
100
+ } else {
101
+ const remaining = amount(body.remaining ?? body.balance);
102
+ balance = { currency: unit, remaining };
103
+ }
104
+ const usage = spend(object(body.usage), unit);
105
+ if (windows.length === 0 && !balance && !finiteQuota) throw new QuotaError('schema');
106
+ return { windows, ...(balance ? { balance } : {}), ...(finiteQuota ? { allowance: finiteQuota } : {}),
107
+ ...(usage ? { spend: usage } : {}) };
108
+ }
109
+
110
+ export function createSub2ApiAdapter(provider: string): QuotaAdapter {
111
+ return {
112
+ provider, label: provider,
113
+ async query(context) {
114
+ let auth: ProviderAuth | undefined;
115
+ try { auth = await context.getAuth(context.provider); } catch { throw new QuotaError('auth'); }
116
+ context.signal.throwIfAborted();
117
+ if (!auth) throw new QuotaError('auth');
118
+ const payload = await context.getJson(sub2ApiUsageUrl(auth.baseUrl),
119
+ { Authorization: `Bearer ${bearer(auth)}` }, context.signal);
120
+ return { ...parseSub2ApiUsage(payload), fetchedAt: context.now() };
121
+ },
122
+ };
123
+ }
@@ -0,0 +1,76 @@
1
+ // Query-layer contracts: no Pi, terminal, or rendering dependencies.
2
+ export interface QuotaWindow {
3
+ id: string;
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
+ 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
16
+ }
17
+
18
+ export interface QuotaAllowance {
19
+ currency: string;
20
+ limit: number;
21
+ used: number;
22
+ remaining: number;
23
+ }
24
+
25
+ export interface SpendSummary {
26
+ currency: string;
27
+ today?: number;
28
+ lifetime?: number;
29
+ }
30
+
31
+ export interface QuotaSnapshot {
32
+ windows: readonly QuotaWindow[];
33
+ balance?: AccountBalance;
34
+ allowance?: QuotaAllowance;
35
+ spend?: SpendSummary;
36
+ fetchedAt: number;
37
+ }
38
+
39
+ export interface ProviderAuth {
40
+ apiKey?: string;
41
+ headers?: Record<string, string | undefined>;
42
+ baseUrl?: string;
43
+ }
44
+
45
+ export interface QueryContext {
46
+ provider: string;
47
+ signal: AbortSignal;
48
+ now(): number;
49
+ getAuth(provider: string): Promise<ProviderAuth | undefined>;
50
+ getJson(url: string, headers: Record<string, string>, signal: AbortSignal): Promise<unknown>;
51
+ }
52
+
53
+ export interface QuotaAdapter {
54
+ provider: string; // exact Pi provider ID; aliases must be explicitly registered
55
+ label: string;
56
+ query(context: QueryContext): Promise<QuotaSnapshot>;
57
+ }
58
+
59
+ export type QuotaErrorCode = 'auth' | 'unsupported-auth' | 'account-access' | 'network' | 'timeout' | 'rate-limit' | 'http' | 'schema';
60
+
61
+ export class QuotaError extends Error {
62
+ readonly code: QuotaErrorCode;
63
+ readonly retryAfterMs?: number;
64
+
65
+ constructor(code: QuotaErrorCode, retryAfterMs?: number) {
66
+ super(code); // Never include response bodies, request headers, or raw exceptions.
67
+ this.code = code;
68
+ this.retryAfterMs = retryAfterMs;
69
+ }
70
+ }
71
+
72
+ export type QuotaState =
73
+ | { kind: 'hidden' }
74
+ | { kind: 'loading'; provider: string; label: string }
75
+ | { kind: 'ready'; provider: string; label: string; snapshot: QuotaSnapshot }
76
+ | { kind: 'error'; provider: string; label: string; code: QuotaErrorCode };
@@ -0,0 +1,13 @@
1
+ import { QuotaError } from './types.ts';
2
+
3
+ // Validate a model endpoint before deriving any authenticated quota URL.
4
+ // Never accept redirects (see http.ts) or move credentials to another origin.
5
+ export function safeBaseUrl(baseUrl: string | undefined): URL {
6
+ if (!baseUrl) throw new QuotaError('unsupported-auth');
7
+ let url: URL;
8
+ try { url = new URL(baseUrl); } catch { throw new QuotaError('unsupported-auth'); }
9
+ const loopback = ['localhost', '127.0.0.1', '[::1]'].includes(url.hostname);
10
+ if ((url.protocol !== 'https:' && !(url.protocol === 'http:' && loopback))
11
+ || url.username || url.password || url.search || url.hash) throw new QuotaError('unsupported-auth');
12
+ return url;
13
+ }