@astrosheep/pi-quota 0.12.0 → 0.12.2

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,132 @@
1
+ import { bearer } from "./http.js";
2
+ import { numeric, object, percent, timestamp } from "./parse.js";
3
+ import { QuotaError } from "./types.js";
4
+ import { safeBaseUrl } from "./url.js";
5
+ export function sub2ApiUsageUrl(baseUrl) {
6
+ const url = safeBaseUrl(baseUrl);
7
+ // Anthropic clients use the host root; OpenAI clients include /v1.
8
+ const prefix = url.pathname.replace(/\/+$/, '') || '/v1';
9
+ url.pathname = `${prefix}/usage`;
10
+ return url.toString();
11
+ }
12
+ function currency(value) {
13
+ if (typeof value !== 'string' || !/^[A-Z]{3}$/.test(value))
14
+ throw new QuotaError('schema');
15
+ return value;
16
+ }
17
+ function amount(value) {
18
+ const result = numeric(value);
19
+ if (result === null || result < 0)
20
+ throw new QuotaError('schema');
21
+ return result;
22
+ }
23
+ function allowance(data, unit) {
24
+ const limit = amount(data.limit);
25
+ if (limit === 0)
26
+ 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
+ function spend(data, unit) {
32
+ const today = object(data.today);
33
+ const total = object(data.total);
34
+ const todayCost = numeric(today.actual_cost);
35
+ const lifetimeCost = numeric(total.actual_cost);
36
+ if (todayCost === null && lifetimeCost === null)
37
+ return undefined;
38
+ if ((todayCost !== null && todayCost < 0) || (lifetimeCost !== null && lifetimeCost < 0))
39
+ throw new QuotaError('schema');
40
+ return { currency: unit, ...(todayCost !== null ? { today: todayCost } : {}),
41
+ ...(lifetimeCost !== null ? { lifetime: lifetimeCost } : {}) };
42
+ }
43
+ function percentFromAmounts(remaining, limit) {
44
+ return percent(remaining / limit * 100);
45
+ }
46
+ const RATE_WINDOWS = { '5h': 18000, '1d': 86400, '7d': 604800 };
47
+ function rateWindow(data, unit) {
48
+ const label = data.window;
49
+ if (typeof label !== 'string' || !Object.hasOwn(RATE_WINDOWS, label))
50
+ throw new QuotaError('schema');
51
+ const limits = allowance(data, unit);
52
+ return { id: `rate-${label}`, label, durationSeconds: RATE_WINDOWS[label],
53
+ remainingPercent: percentFromAmounts(limits.remaining, limits.limit),
54
+ resetAt: timestamp(data.reset_at), amounts: limits };
55
+ }
56
+ function subscriptionWindows(data, unit) {
57
+ const specs = [
58
+ ['daily', '1d', 'daily_usage_usd', 'daily_limit_usd', 86400],
59
+ ['weekly', '1w', 'weekly_usage_usd', 'weekly_limit_usd', 604800],
60
+ ['monthly', '30d', 'monthly_usage_usd', 'monthly_limit_usd', 2592000],
61
+ ];
62
+ return specs.flatMap(([id, label, usedKey, limitKey, durationSeconds]) => {
63
+ if (data[limitKey] == null)
64
+ return []; // Upstream sends null for an unconfigured cap.
65
+ const limit = numeric(data[limitKey]);
66
+ if (limit === null || limit < 0)
67
+ throw new QuotaError('schema');
68
+ if (limit === 0)
69
+ return []; // No cap for this period; no percentage can be computed.
70
+ const used = numeric(data[usedKey]);
71
+ if (used === null || used < 0)
72
+ throw new QuotaError('schema');
73
+ const amounts = { currency: unit, limit, used, remaining: Math.max(0, limit - used) };
74
+ const start = id === 'weekly' ? timestamp(data.weekly_window_start) : null;
75
+ return [{ id, label, durationSeconds, remainingPercent: percentFromAmounts(amounts.remaining, limit),
76
+ resetAt: start === null ? null : start + durationSeconds * 1000, amounts }];
77
+ });
78
+ }
79
+ export function parseSub2ApiUsage(payload) {
80
+ const body = object(payload);
81
+ if (body.isValid !== true || (body.mode !== 'quota_limited' && body.mode !== 'unrestricted')) {
82
+ throw new QuotaError('schema');
83
+ }
84
+ // Upstream omits `unit` when a key has rate limits but no total quota.
85
+ // Sub2API rate limits are always USD; a fixed quota carries its own unit.
86
+ const unit = currency(body.unit ?? (body.mode === 'quota_limited'
87
+ ? object(body.quota).unit ?? 'USD' : undefined));
88
+ const windows = [];
89
+ let balance;
90
+ let finiteQuota;
91
+ if (body.mode === 'quota_limited') {
92
+ const quota = body.quota === undefined ? undefined : allowance(object(body.quota), unit);
93
+ finiteQuota = quota;
94
+ const rates = body.rate_limits;
95
+ if (rates !== undefined) {
96
+ if (!Array.isArray(rates))
97
+ throw new QuotaError('schema');
98
+ rates.forEach(entry => windows.push(rateWindow(object(entry), unit)));
99
+ }
100
+ }
101
+ else if (body.subscription !== undefined) {
102
+ windows.push(...subscriptionWindows(object(body.subscription), unit));
103
+ }
104
+ else {
105
+ const remaining = amount(body.remaining ?? body.balance);
106
+ balance = { currency: unit, remaining };
107
+ }
108
+ const usage = spend(object(body.usage), unit);
109
+ if (windows.length === 0 && !balance && !finiteQuota)
110
+ throw new QuotaError('schema');
111
+ return { windows, ...(balance ? { balance } : {}), ...(finiteQuota ? { allowance: finiteQuota } : {}),
112
+ ...(usage ? { spend: usage } : {}) };
113
+ }
114
+ export function createSub2ApiAdapter(provider) {
115
+ return {
116
+ provider, label: provider,
117
+ async query(context) {
118
+ let auth;
119
+ try {
120
+ auth = await context.getAuth(context.provider);
121
+ }
122
+ catch {
123
+ throw new QuotaError('auth');
124
+ }
125
+ context.signal.throwIfAborted();
126
+ if (!auth)
127
+ throw new QuotaError('auth');
128
+ const payload = await context.getJson(sub2ApiUsageUrl(auth.baseUrl), { Authorization: `Bearer ${bearer(auth)}` }, context.signal);
129
+ return { ...parseSub2ApiUsage(payload), fetchedAt: context.now() };
130
+ },
131
+ };
132
+ }
@@ -0,0 +1,77 @@
1
+ export interface QuotaWindow {
2
+ id: string;
3
+ label: string;
4
+ remainingPercent: number | null;
5
+ resetAt: number | null;
6
+ durationSeconds: number;
7
+ scope?: string;
8
+ amounts?: QuotaAllowance;
9
+ }
10
+ export interface AccountBalance {
11
+ currency: string;
12
+ remaining: number;
13
+ unlimited?: boolean;
14
+ }
15
+ export interface QuotaAllowance {
16
+ currency: string;
17
+ limit: number;
18
+ used: number;
19
+ remaining: number;
20
+ }
21
+ export interface SpendSummary {
22
+ currency: string;
23
+ today?: number;
24
+ lifetime?: number;
25
+ }
26
+ export interface QuotaSnapshot {
27
+ windows: readonly QuotaWindow[];
28
+ balance?: AccountBalance;
29
+ allowance?: QuotaAllowance;
30
+ spend?: SpendSummary;
31
+ fetchedAt: number;
32
+ }
33
+ export interface ProviderAuth {
34
+ apiKey?: string;
35
+ headers?: Record<string, string | undefined>;
36
+ baseUrl?: string;
37
+ }
38
+ export interface PostJsonResult {
39
+ body: unknown;
40
+ setCookie: string[];
41
+ }
42
+ export interface QueryContext {
43
+ provider: string;
44
+ signal: AbortSignal;
45
+ now(): number;
46
+ getAuth(provider: string): Promise<ProviderAuth | undefined>;
47
+ getJson(url: string, headers: Record<string, string>, signal: AbortSignal): Promise<unknown>;
48
+ postJson(url: string, body: unknown, headers: Record<string, string>, signal: AbortSignal): Promise<PostJsonResult>;
49
+ }
50
+ export interface QuotaAdapter {
51
+ provider: string;
52
+ label: string;
53
+ query(context: QueryContext): Promise<QuotaSnapshot>;
54
+ }
55
+ export type QuotaErrorCode = 'auth' | 'unsupported-auth' | 'account-access' | 'network' | 'timeout' | 'rate-limit' | 'http' | 'schema';
56
+ export declare class QuotaError extends Error {
57
+ readonly code: QuotaErrorCode;
58
+ readonly retryAfterMs?: number;
59
+ constructor(code: QuotaErrorCode, retryAfterMs?: number);
60
+ }
61
+ export type QuotaState = {
62
+ kind: 'hidden';
63
+ } | {
64
+ kind: 'loading';
65
+ provider: string;
66
+ label: string;
67
+ } | {
68
+ kind: 'ready';
69
+ provider: string;
70
+ label: string;
71
+ snapshot: QuotaSnapshot;
72
+ } | {
73
+ kind: 'error';
74
+ provider: string;
75
+ label: string;
76
+ code: QuotaErrorCode;
77
+ };
@@ -0,0 +1,9 @@
1
+ export class QuotaError extends Error {
2
+ code;
3
+ retryAfterMs;
4
+ constructor(code, retryAfterMs) {
5
+ super(code); // Never include response bodies, request headers, or raw exceptions.
6
+ this.code = code;
7
+ this.retryAfterMs = retryAfterMs;
8
+ }
9
+ }
@@ -0,0 +1 @@
1
+ export declare function safeBaseUrl(baseUrl: string | undefined): URL;
@@ -0,0 +1,19 @@
1
+ import { QuotaError } from "./types.js";
2
+ // Validate a model endpoint before deriving any authenticated quota URL.
3
+ // Never accept redirects (see http.ts) or move credentials to another origin.
4
+ export function safeBaseUrl(baseUrl) {
5
+ if (!baseUrl)
6
+ throw new QuotaError('unsupported-auth');
7
+ let url;
8
+ try {
9
+ url = new URL(baseUrl);
10
+ }
11
+ catch {
12
+ throw new QuotaError('unsupported-auth');
13
+ }
14
+ const loopback = ['localhost', '127.0.0.1', '[::1]'].includes(url.hostname);
15
+ if ((url.protocol !== 'https:' && !(url.protocol === 'http:' && loopback))
16
+ || url.username || url.password || url.search || url.hash)
17
+ throw new QuotaError('unsupported-auth');
18
+ return url;
19
+ }
@@ -0,0 +1,15 @@
1
+ import type { PostJsonResult, QuotaAdapter, QuotaSnapshot } from './types.js';
2
+ export declare const YESCODE_ORIGIN = "https://co.yes.vg";
3
+ export interface YesCodeOptions {
4
+ username: string;
5
+ password: string;
6
+ }
7
+ interface YesCodeSession {
8
+ headers: Record<string, string>;
9
+ expiresAt: number;
10
+ }
11
+ export declare function validateYesCodeOptions(options: YesCodeOptions): YesCodeOptions;
12
+ export declare function parseYesCodeLogin(result: PostJsonResult, now: number): YesCodeSession;
13
+ export declare function parseYesCodeAccount(balancePayload: unknown, profilePayload: unknown): Omit<QuotaSnapshot, 'fetchedAt'>;
14
+ export declare function createYesCodeAdapter(provider: string, options: YesCodeOptions): QuotaAdapter;
15
+ export {};
@@ -0,0 +1,155 @@
1
+ import { officialAuth } from "./http.js";
2
+ import { numeric, object, percent, timestamp, windowLabel } from "./parse.js";
3
+ import { QuotaError } from "./types.js";
4
+ export const YESCODE_ORIGIN = 'https://co.yes.vg';
5
+ const SESSION_TTL_MS = 23 * 3600 * 1000; // observed cookie Max-Age is 24h
6
+ export function validateYesCodeOptions(options) {
7
+ const { username, password } = options;
8
+ if (typeof username !== 'string' || username.trim() === '') {
9
+ throw new Error('username must be a non-empty string');
10
+ }
11
+ if (typeof password !== 'string' || password === '')
12
+ throw new Error('password must be a non-empty string');
13
+ return { username, password };
14
+ }
15
+ function cookieValue(setCookie, name) {
16
+ for (const entry of setCookie) {
17
+ if (!entry.startsWith(`${name}=`))
18
+ continue;
19
+ const value = entry.slice(name.length + 1).split(';', 1)[0];
20
+ if (value !== '' && !/[\s;]/.test(value))
21
+ return value;
22
+ }
23
+ return undefined;
24
+ }
25
+ // POST /api/v1/auth/login returns {token} and sets yescode_auth/yescode_csrf.
26
+ // The cookie pair is what the web app itself uses; the bare JWT is the
27
+ // documented UserToken fallback (Authorization: Bearer).
28
+ export function parseYesCodeLogin(result, now) {
29
+ const auth = cookieValue(result.setCookie, 'yescode_auth');
30
+ if (auth) {
31
+ const csrf = cookieValue(result.setCookie, 'yescode_csrf');
32
+ return { headers: { Cookie: csrf ? `yescode_auth=${auth}; yescode_csrf=${csrf}` : `yescode_auth=${auth}` },
33
+ expiresAt: now + SESSION_TTL_MS };
34
+ }
35
+ const token = object(result.body).token;
36
+ if (typeof token !== 'string' || token.trim() === '' || /[\s;]/.test(token))
37
+ throw new QuotaError('schema');
38
+ return { headers: { Authorization: `Bearer ${token}` }, expiresAt: now + SESSION_TTL_MS };
39
+ }
40
+ // Balances may legitimately be negative (debt); limits/spends may not.
41
+ function optionalAmount(value) {
42
+ if (value === undefined || value === null)
43
+ return undefined;
44
+ const result = numeric(value);
45
+ if (result === null)
46
+ throw new QuotaError('schema');
47
+ return result;
48
+ }
49
+ function nonNegative(value) {
50
+ const result = optionalAmount(value);
51
+ if (result !== undefined && result < 0)
52
+ throw new QuotaError('schema');
53
+ return result;
54
+ }
55
+ function limitWindow(id, label, durationSeconds, limitRaw, usedRaw, resetAt) {
56
+ const used = nonNegative(usedRaw); // validate present-but-corrupt spends even without a cap
57
+ const limit = nonNegative(limitRaw);
58
+ if (limit === undefined || limit === 0)
59
+ return []; // no configured cap: nothing to draw
60
+ if (used === undefined) {
61
+ return [{ id, label, durationSeconds, remainingPercent: null, resetAt }];
62
+ }
63
+ const remaining = Math.max(0, limit - used);
64
+ return [{ id, label, durationSeconds, remainingPercent: percent(remaining / limit * 100), resetAt,
65
+ amounts: { currency: 'USD', limit, used, remaining } }];
66
+ }
67
+ // GET /api/v1/user/balance (optional; some accounts may lack it) merged with
68
+ // GET /api/v1/auth/profile (required). The live response carries a generic
69
+ // spending_limits array (scope/spent/limit/remaining/resets_at/window_seconds)
70
+ // plus top-level weekly_* and monthly_* fields; profile is the last fallback.
71
+ function spendingLimitWindows(balanceData) {
72
+ if (balanceData.spending_limits === undefined)
73
+ return null;
74
+ if (!Array.isArray(balanceData.spending_limits))
75
+ throw new QuotaError('schema');
76
+ const windows = balanceData.spending_limits.flatMap((entry) => {
77
+ const item = object(entry);
78
+ const limit = nonNegative(item.limit);
79
+ const spent = nonNegative(item.spent);
80
+ const remaining = nonNegative(item.remaining);
81
+ const windowSeconds = numeric(item.window_seconds);
82
+ const resetAt = timestamp(item.resets_at);
83
+ if (typeof item.key !== 'string' || item.key === ''
84
+ || windowSeconds === null || !Number.isSafeInteger(windowSeconds) || windowSeconds <= 0) {
85
+ throw new QuotaError('schema');
86
+ }
87
+ if (limit === undefined || limit === 0)
88
+ return []; // uncapped: nothing to draw
89
+ if (spent === undefined) {
90
+ return [{ id: `spending:${item.key}`, label: windowLabel(windowSeconds), durationSeconds: windowSeconds,
91
+ remainingPercent: null, resetAt }];
92
+ }
93
+ const left = remaining ?? Math.max(0, limit - spent);
94
+ return [{ id: `spending:${item.key}`, label: windowLabel(windowSeconds), durationSeconds: windowSeconds,
95
+ remainingPercent: percent(left / limit * 100), resetAt,
96
+ amounts: { currency: 'USD', limit, used: spent, remaining: left } }];
97
+ });
98
+ // Footer/detail convention is ascending duration (5h → 1w → 30d).
99
+ return windows.sort((a, b) => a.durationSeconds - b.durationSeconds);
100
+ }
101
+ export function parseYesCodeAccount(balancePayload, profilePayload) {
102
+ const balanceData = object(balancePayload);
103
+ const profile = object(profilePayload);
104
+ const plan = object(profile.subscription_plan);
105
+ const subscription = optionalAmount(balanceData.subscription_balance ?? profile.subscription_balance);
106
+ const paygo = optionalAmount(balanceData.pay_as_you_go_balance ?? profile.pay_as_you_go_balance);
107
+ if (subscription === undefined && paygo === undefined)
108
+ throw new QuotaError('schema');
109
+ const weekStart = timestamp(profile.last_week_reset);
110
+ const monthStart = timestamp(profile.last_month_reset);
111
+ const windows = spendingLimitWindows(balanceData) ?? [
112
+ ...limitWindow('daily', '1d', 86400, balanceData.daily_limit, balanceData.daily_spent, timestamp(balanceData.daily_resets_at ?? balanceData.daily_reset_at)),
113
+ ...limitWindow('weekly', '1w', 604800, balanceData.weekly_limit ?? plan.weekly_limit, balanceData.weekly_spent_balance ?? balanceData.weekly_spent ?? profile.current_week_spend, timestamp(balanceData.weekly_resets_at ?? balanceData.weekly_reset_at)
114
+ ?? (weekStart === null ? null : weekStart + 604800000)),
115
+ ...limitWindow('monthly', '30d', 2592000, balanceData.monthly_spend_limit ?? plan.monthly_spend_limit, balanceData.monthly_spent_balance ?? balanceData.monthly_spent ?? profile.current_month_spend, timestamp(balanceData.monthly_resets_at ?? balanceData.monthly_reset_at)
116
+ ?? (monthStart === null ? null : monthStart + 2592000000)),
117
+ ];
118
+ return { windows, balance: { currency: 'USD', remaining: (subscription ?? 0) + (paygo ?? 0) } };
119
+ }
120
+ export function createYesCodeAdapter(provider, options) {
121
+ const credentials = validateYesCodeOptions(options);
122
+ let session;
123
+ return {
124
+ provider, label: provider,
125
+ async query(context) {
126
+ await officialAuth(context, YESCODE_ORIGIN);
127
+ const login = async () => parseYesCodeLogin(await context.postJson(`${YESCODE_ORIGIN}/api/v1/auth/login`, { username: credentials.username, password: credentials.password }, { Origin: YESCODE_ORIGIN, Referer: `${YESCODE_ORIGIN}/login` }, context.signal), context.now());
128
+ const fetchAccount = async (current) => {
129
+ const [profile, balance] = await Promise.all([
130
+ context.getJson(`${YESCODE_ORIGIN}/api/v1/auth/profile`, current.headers, context.signal),
131
+ // Optional endpoint: a plain 404 must not fail the whole query.
132
+ context.getJson(`${YESCODE_ORIGIN}/api/v1/user/balance`, current.headers, context.signal)
133
+ .catch((error) => {
134
+ if (error instanceof QuotaError && error.code === 'http')
135
+ return undefined;
136
+ throw error;
137
+ }),
138
+ ]);
139
+ return parseYesCodeAccount(balance, profile);
140
+ };
141
+ if (!session || context.now() >= session.expiresAt)
142
+ session = await login();
143
+ try {
144
+ return { ...await fetchAccount(session), fetchedAt: context.now() };
145
+ }
146
+ catch (error) {
147
+ // Session died early (24h cookie or revocation): re-login once, then give up.
148
+ if (!(error instanceof QuotaError) || error.code !== 'auth')
149
+ throw error;
150
+ session = await login();
151
+ return { ...await fetchAccount(session), fetchedAt: context.now() };
152
+ }
153
+ },
154
+ };
155
+ }
package/package.json CHANGED
@@ -1,13 +1,16 @@
1
1
  {
2
2
  "name": "@astrosheep/pi-quota",
3
- "version": "0.12.0",
3
+ "version": "0.12.2",
4
4
  "publishConfig": {
5
5
  "access": "public"
6
6
  },
7
7
  "type": "module",
8
8
  "exports": {
9
9
  ".": "./src/index.ts",
10
- "./query": "./src/query/index.ts",
10
+ "./query": {
11
+ "types": "./dist/query/index.d.ts",
12
+ "default": "./dist/query/index.js"
13
+ },
11
14
  "./package.json": "./package.json"
12
15
  },
13
16
  "description": "Composable quota bar with Codex, Kimi, OpenCode Go, new-api, DeepSeek, Sub2API and YesCode adapters",
@@ -16,6 +19,7 @@
16
19
  ],
17
20
  "files": [
18
21
  "src",
22
+ "dist",
19
23
  "examples",
20
24
  "README.md"
21
25
  ],
@@ -26,6 +30,8 @@
26
30
  },
27
31
  "scripts": {
28
32
  "test": "node --test test/*.test.ts",
33
+ "build": "tsc -p tsconfig.build.json && node scripts/fix-dist-types.mjs",
34
+ "prepublishOnly": "npm run build",
29
35
  "typecheck": "tsc --noEmit",
30
36
  "demo": "node examples/preview.ts"
31
37
  },
@@ -37,8 +43,12 @@
37
43
  "@earendil-works/pi-tui": "*"
38
44
  },
39
45
  "peerDependenciesMeta": {
40
- "@earendil-works/pi-coding-agent": { "optional": true },
41
- "@earendil-works/pi-tui": { "optional": true }
46
+ "@earendil-works/pi-coding-agent": {
47
+ "optional": true
48
+ },
49
+ "@earendil-works/pi-tui": {
50
+ "optional": true
51
+ }
42
52
  },
43
53
  "devDependencies": {
44
54
  "@types/node": "^22.0.0",
@@ -6,7 +6,9 @@ import type { AccountBalance, QuotaAdapter, QuotaAllowance, QuotaSnapshot, Quota
6
6
 
7
7
  export function sub2ApiUsageUrl(baseUrl: string | undefined): string {
8
8
  const url = safeBaseUrl(baseUrl);
9
- url.pathname = `${url.pathname.replace(/\/+$/, '')}/usage`;
9
+ // Anthropic clients use the host root; OpenAI clients include /v1.
10
+ const prefix = url.pathname.replace(/\/+$/, '') || '/v1';
11
+ url.pathname = `${prefix}/usage`;
10
12
  return url.toString();
11
13
  }
12
14