@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.
package/README.md ADDED
@@ -0,0 +1,77 @@
1
+ # @astrosheep/pi-quota
2
+
3
+ Compact quota status for Pi: Codex, Kimi and OpenCode Go work out of the box; new-api, DeepSeek and Sub2API use explicit provider bindings.
4
+
5
+ ```bash
6
+ pi install npm:@astrosheep/pi-quota
7
+ ```
8
+
9
+ Reload Pi after installing or changing settings. Use `/usage` for a persistent, context-free snapshot in the chat history. Automatic footer polling never adds chat items.
10
+
11
+ ## What it shows
12
+
13
+ | Kind | Footer | `/usage` |
14
+ |---|---|---|
15
+ | Renewable window | `5h [▆] 72% ↺ 2h15m` | Horizontal bar, remaining percentage, reset, exact amounts if available |
16
+ | Wallet | `Bal $12.34` | Balance; optional Today/Lifetime spend |
17
+ | Fixed quota | `Quota [▇] $85.00` | Remaining bar plus used and limit |
18
+
19
+ 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
+
21
+ The footer shows at most two windows and `+N` for additional ones. `/usage` includes all windows; it does not open a modal or send messages to the model. Snapshots remain frozen at capture time. Errors replace stale values.
22
+
23
+ ## Configure providers
24
+
25
+ Add bindings in global `~/.pi/agent/settings.json` under `quotaUsage` (not project settings):
26
+
27
+ ```json
28
+ {
29
+ "quotaUsage": {
30
+ "providers": {
31
+ "my-sub2api": { "adapter": "sub2api" },
32
+ "my-new-api": { "adapter": "new-api", "quotaPerUnit": 500000, "currency": "USD" },
33
+ "deepseek": { "adapter": "deepseek" }
34
+ }
35
+ }
36
+ }
37
+ ```
38
+
39
+ Use the **exact Pi provider ID**. Only selected, explicitly bound providers are queried; built-in `openai-codex`, `kimi-coding` and `opencode-go` cannot be overridden. Unknown keys or adapter options are rejected. The API key and model `baseUrl` come from Pi's active provider auth; do not put model API keys in `quotaUsage`.
40
+
41
+ | Adapter | Endpoint | Result |
42
+ |---|---|---|
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 |
45
+ | `deepseek` | Official `/user/balance` | Balance only; no invented spend |
46
+
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
+
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.
50
+
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
+
53
+ ## Architecture
54
+
55
+ ```text
56
+ Pi model/auth → adapter → QuotaSnapshot → controller → footer / /usage / state event
57
+ ```
58
+
59
+ - `src/query/`: protocol-specific parsing, credential resolution, safe URLs, bounded GET client, state validation and controller. No Pi/TUI imports.
60
+ - `src/config.ts`: strict global provider bindings.
61
+ - `src/bar/`: pure compact rendering and persistent chat card.
62
+ - `src/extension.ts` and `src/index.ts`: Pi lifecycle and auth bridge.
63
+
64
+ `QuotaSnapshot` separates `balance` (wallet), `allowance` (finite key quota), `windows` (renewable limits), and optional `spend` (Today/Lifetime actual charge). This prevents unrelated amounts from sharing an ambiguous `Used` label. Custom adapters can be passed to `createQuotaExtension({ adapters: [...] })`; see `examples/custom-provider.ts`. Provider aliases are never inferred.
65
+
66
+ The extension publishes structured state on `quota-bar:state:v1`. 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. Switching provider discards old values immediately.
67
+
68
+ ## Development
69
+
70
+ Requires Node ≥22.18 and a compatible Pi installation.
71
+
72
+ ```bash
73
+ npm install --ignore-scripts
74
+ npm test
75
+ npm run typecheck
76
+ npm run demo
77
+ ```
@@ -0,0 +1,30 @@
1
+ // Template only. Replace the provider ID, fixed URL and schema for your service.
2
+ // Load this entry INSTEAD OF src/index.ts, not alongside it.
3
+ import { createQuotaExtension } from '../src/extension.ts';
4
+ import { numeric, object, percent, timestamp } from '../src/query/parse.ts';
5
+ import { QuotaError } from '../src/query/types.ts';
6
+ import type { QuotaAdapter } from '../src/query/types.ts';
7
+
8
+ const customAdapter: QuotaAdapter = {
9
+ provider: 'my-gateway',
10
+ label: 'My Gateway',
11
+ async query(context) {
12
+ // Resolve ONLY this provider's credentials. Never reuse Codex/Kimi credentials.
13
+ const auth = await context.getAuth(context.provider);
14
+ context.signal.throwIfAborted();
15
+ if (!auth?.apiKey) throw new QuotaError('auth');
16
+ // This deliberately nonfunctional domain must be replaced explicitly.
17
+ const data = object(await context.getJson('https://quota.example.invalid/v1/usage', {
18
+ Authorization: `Bearer ${auth.apiKey}`,
19
+ }, context.signal));
20
+ const remaining = numeric(data.remaining_percent);
21
+ if (remaining === null) throw new QuotaError('schema');
22
+ return {
23
+ fetchedAt: context.now(),
24
+ windows: [{ id: 'daily', label: '1d', durationSeconds: 86400,
25
+ remainingPercent: percent(remaining), resetAt: timestamp(data.reset_at) }],
26
+ };
27
+ },
28
+ };
29
+
30
+ export default createQuotaExtension({ adapters: [customAdapter] });
@@ -0,0 +1,30 @@
1
+ // Offline mock: no credentials, no HTTP, no Pi session.
2
+ import { renderBar } from '../src/bar/bar.ts';
3
+ import type { Paint } from '../src/bar/bar.ts';
4
+ import { quotaElement, renderFooter, renderUsage } from '../src/bar/quota.ts';
5
+ import type { QuotaState } from '../src/query/types.ts';
6
+
7
+ const now = Date.now();
8
+ const colors = { text: 39, dim: 90, success: 32, warning: 33, error: 31 };
9
+ const paint: Paint = process.env.NO_COLOR !== undefined ? (_tone, text) => text
10
+ : (tone, text) => `\x1b[${colors[tone]}m${text}\x1b[0m`;
11
+ function mock(short: number, weekly: number): QuotaState {
12
+ return { kind: 'ready', provider: 'openai-codex', label: 'Codex', snapshot: {
13
+ fetchedAt: now, windows: [
14
+ { id: '5h', label: '5h', durationSeconds: 18000, remainingPercent: short, resetAt: now + 8100000 },
15
+ { id: '1w', label: '1w', durationSeconds: 604800, remainingPercent: weekly, resetAt: now + 442800000 },
16
+ ],
17
+ } };
18
+ }
19
+ console.log('MOCK DATA — no live queries\n');
20
+ for (const [short, weekly] of [[72, 85], [25, 60], [8, 50], [0, 50]]) {
21
+ console.log(renderFooter(mock(short, weekly), paint, now));
22
+ }
23
+ console.log('\n/usage\n');
24
+ console.log(renderUsage(mock(72, 85), paint, now).join('\n'));
25
+ console.log('\nComposed bar\n');
26
+ console.log(renderBar([
27
+ { id: 'context', spans: [{ text: 'ctx [▃] 32%', tone: 'dim' }] },
28
+ quotaElement(mock(72, 85), now),
29
+ { id: 'git', spans: [{ text: 'main', tone: 'dim' }] },
30
+ ], paint));
@@ -0,0 +1,15 @@
1
+ {
2
+ "providers": {
3
+ "my-new-api": {
4
+ "adapter": "new-api",
5
+ "quotaPerUnit": 500000,
6
+ "currency": "USD"
7
+ },
8
+ "deepseek": {
9
+ "adapter": "deepseek"
10
+ },
11
+ "my-sub2api": {
12
+ "adapter": "sub2api"
13
+ }
14
+ }
15
+ }
package/package.json ADDED
@@ -0,0 +1,36 @@
1
+ {
2
+ "name": "@astrosheep/pi-quota",
3
+ "version": "0.5.0",
4
+ "publishConfig": { "access": "public" },
5
+ "type": "module",
6
+ "description": "Composable quota bar with Codex, Kimi, OpenCode Go, new-api, DeepSeek and Sub2API adapters",
7
+ "keywords": [
8
+ "pi-package"
9
+ ],
10
+ "files": [
11
+ "src",
12
+ "examples",
13
+ "README.md"
14
+ ],
15
+ "pi": {
16
+ "extensions": [
17
+ "./src/index.ts"
18
+ ]
19
+ },
20
+ "scripts": {
21
+ "test": "node --test test/*.test.ts",
22
+ "typecheck": "tsc --noEmit",
23
+ "demo": "node examples/preview.ts"
24
+ },
25
+ "engines": {
26
+ "node": ">=22.18"
27
+ },
28
+ "peerDependencies": {
29
+ "@earendil-works/pi-coding-agent": "*",
30
+ "@earendil-works/pi-tui": "*"
31
+ },
32
+ "devDependencies": {
33
+ "@types/node": "^22.0.0",
34
+ "typescript": "^6.0.3"
35
+ }
36
+ }
@@ -0,0 +1,57 @@
1
+ import { horizontalBar, remainingTone, verticalBar } from './bar.ts';
2
+ import type { AccountBalance, QuotaAllowance, SpendSummary } from '../query/types.ts';
3
+ import type { Paint, Span } from './bar.ts';
4
+
5
+ export function money(amount: number, currency: string): string {
6
+ const sign = amount < 0 ? '-' : '';
7
+ const prefix = currency === 'USD' ? '$' : `${currency} `;
8
+ const absolute = Math.abs(amount);
9
+ if (absolute > 0 && absolute < 0.01) return `${sign}<${prefix}0.01`;
10
+ return `${sign}${prefix}${absolute.toFixed(2)}`;
11
+ }
12
+
13
+ export function remainingMoney(balance: AccountBalance): string {
14
+ if (balance.unlimited === true) return balance.currency === 'USD' ? '$∞' : `${balance.currency} ∞`;
15
+ return money(balance.remaining, balance.currency);
16
+ }
17
+
18
+ export function balanceSpans(balance: AccountBalance): Span[] {
19
+ return [
20
+ { text: 'Bal ', tone: 'dim' },
21
+ { text: remainingMoney(balance), tone: !balance.unlimited && balance.remaining <= 0 ? 'error' : 'text' },
22
+ ];
23
+ }
24
+
25
+ export function balanceLines(balance: AccountBalance, paint: Paint): string[] {
26
+ const remaining = remainingMoney(balance);
27
+ return [paint('dim', 'Balance ') + paint(!balance.unlimited && balance.remaining <= 0 ? 'error' : 'text', remaining)];
28
+ }
29
+
30
+ export function allowancePercent(allowance: QuotaAllowance): number {
31
+ return Math.max(0, Math.min(100, allowance.remaining / allowance.limit * 100));
32
+ }
33
+
34
+ export function allowanceSpans(allowance: QuotaAllowance): Span[] {
35
+ const remaining = allowancePercent(allowance);
36
+ return [
37
+ { text: 'Quota ', tone: 'dim' },
38
+ { text: `${verticalBar(remaining)} ${money(allowance.remaining, allowance.currency)}`, tone: remainingTone(remaining) },
39
+ ];
40
+ }
41
+
42
+ export function allowanceLines(allowance: QuotaAllowance, paint: Paint, barWidth: number): string[] {
43
+ const remaining = allowancePercent(allowance);
44
+ return [
45
+ paint(remainingTone(remaining), horizontalBar(remaining, barWidth))
46
+ + paint('dim', ` ${money(allowance.remaining, allowance.currency)} left`),
47
+ paint('dim', `Used ${money(allowance.used, allowance.currency)}`),
48
+ paint('dim', `Limit ${money(allowance.limit, allowance.currency)}`),
49
+ ];
50
+ }
51
+
52
+ export function spendLines(spend: SpendSummary, paint: Paint): string[] {
53
+ const lines: string[] = [];
54
+ if (spend.today !== undefined) lines.push(paint('dim', `Today ${money(spend.today, spend.currency)}`));
55
+ if (spend.lifetime !== undefined) lines.push(paint('dim', `Lifetime ${money(spend.lifetime, spend.currency)}`));
56
+ return lines;
57
+ }
package/src/bar/bar.ts ADDED
@@ -0,0 +1,50 @@
1
+ // A bar composes independent, already-computed elements. It never queries providers.
2
+ export type Tone = 'text' | 'dim' | 'success' | 'warning' | 'error';
3
+ export interface Span { text: string; tone?: Tone }
4
+ export interface BarElement { id: string; spans: readonly Span[] }
5
+ export type Paint = (tone: Tone, text: string) => string;
6
+ export const plain: Paint = (_tone, text) => text;
7
+
8
+ export function renderBar(elements: readonly BarElement[], paint: Paint = plain): string {
9
+ const parts = elements.filter(element => element.spans.length > 0).map(element =>
10
+ element.spans.map(span => paint(span.tone ?? 'text', span.text)).join(''));
11
+ // Explicit trailing space is part of the public rendering contract.
12
+ return parts.length ? parts.join(paint('dim', ' │ ')) + ' ' : '';
13
+ }
14
+
15
+ export function remainingTone(value: number | null): Tone {
16
+ return value === null ? 'dim' : value <= 10 ? 'error' : value <= 30 ? 'warning' : 'success';
17
+ }
18
+
19
+ export function verticalBar(value: number | null): string {
20
+ if (value === null || !Number.isFinite(value)) return '[?]';
21
+ if (value <= 0) return '[·]';
22
+ if (value >= 100) return '[█]';
23
+ const height = Math.max(1, Math.min(7, Math.round(value * 8 / 100)));
24
+ return `[${'▁▂▃▄▅▆▇'[height - 1]}]`;
25
+ }
26
+
27
+ export function horizontalBar(value: number | null, width = 20): string {
28
+ width = Math.max(1, Math.min(80, Math.floor(width) || 20));
29
+ if (value === null || !Number.isFinite(value)) return `[${'░'.repeat(width)}]`;
30
+ const filled = value >= 100 ? width : value <= 0 ? 0
31
+ : Math.min(width - 1, Math.round(value / 100 * width));
32
+ return `[${'█'.repeat(filled)}${'░'.repeat(width - filled)}]`;
33
+ }
34
+
35
+ export function formatPercent(value: number | null): string {
36
+ if (value === null) return '?';
37
+ const rounded = value > 0 && value < 100
38
+ ? Math.max(0.1, Math.min(99.9, Math.round(value * 10) / 10)) : value;
39
+ return `${rounded}%`;
40
+ }
41
+
42
+ export function duration(ms: number): string {
43
+ if (ms <= 0) return 'due';
44
+ if (ms < 60000) return '<1m';
45
+ const minutes = Math.floor(ms / 60000);
46
+ const days = Math.floor(minutes / 1440);
47
+ const hours = Math.floor((minutes % 1440) / 60);
48
+ if (days) return `${days}d${hours ? `${hours}h` : ''}`;
49
+ return `${hours ? `${hours}h` : ''}${minutes % 60 ? `${minutes % 60}m` : ''}`;
50
+ }
@@ -0,0 +1,35 @@
1
+ import { Text } from '@earendil-works/pi-tui';
2
+ import type { Component } from '@earendil-works/pi-tui';
3
+ import type { Paint } from './bar.ts';
4
+ import { renderUsage } from './quota.ts';
5
+ import type { QuotaState } from '../query/types.ts';
6
+
7
+ export const USAGE_ENTRY = 'quota-bar:usage:v1';
8
+
9
+ export interface UsageCard {
10
+ version: 1;
11
+ capturedAt: number;
12
+ state: Exclude<QuotaState, { kind: 'loading' }>;
13
+ }
14
+
15
+ export function captureUsage(state: QuotaState, now = Date.now()): UsageCard | undefined {
16
+ if (state.kind === 'loading') return undefined;
17
+ // Separate the persistent entry from the live controller's state/objects.
18
+ return { version: 1, capturedAt: now, state: structuredClone(state) };
19
+ }
20
+
21
+ export function usageCardComponent(card: UsageCard | undefined, paint?: Paint): Component {
22
+ return {
23
+ render(width: number): string[] {
24
+ if (width <= 0) return [];
25
+ if (!card || card.version !== 1 || !Number.isFinite(card.capturedAt)) {
26
+ return new Text('Usage snapshot unavailable.', 0, 0).render(width);
27
+ }
28
+ // Rebuild on every render for theme/width changes, but freeze time at capture.
29
+ const lines = renderUsage(card.state, paint, card.capturedAt, width < 55 ? 10 : 20);
30
+ // Wrapping, not clipping: long quota names remain readable in narrow terminals.
31
+ return new Text(lines.join('\n'), 0, 0).render(width);
32
+ },
33
+ invalidate() {},
34
+ };
35
+ }
@@ -0,0 +1,94 @@
1
+ import { visibleWidth } from '@earendil-works/pi-tui';
2
+ import { duration, formatPercent, horizontalBar, plain, remainingTone, renderBar, verticalBar } from './bar.ts';
3
+ import type { BarElement, Paint, Span } from './bar.ts';
4
+ import { allowanceLines, allowanceSpans, balanceLines, balanceSpans, money, spendLines } from './balance.ts';
5
+ import type { QuotaErrorCode, QuotaState, QuotaWindow } from '../query/types.ts';
6
+
7
+ const errorText: Record<QuotaErrorCode, string> = {
8
+ auth: 'Sign in with /login',
9
+ 'unsupported-auth': 'Unsupported credentials or endpoint',
10
+ 'account-access': 'Account quota access denied',
11
+ network: 'Network error', timeout: 'Request timed out',
12
+ 'rate-limit': 'Rate limited', http: 'Service unavailable', schema: 'Unrecognized quota response',
13
+ };
14
+
15
+ function windowSpans(window: QuotaWindow, now: number): Span[] {
16
+ const expired = window.resetAt !== null && window.resetAt <= now;
17
+ // Reaching the reset deadline is not proof the provider granted a fresh quota.
18
+ const remaining = expired ? null : window.remainingPercent;
19
+ const glyph = verticalBar(remaining);
20
+ return [
21
+ { text: `${window.scope ? `${window.scope}/` : ''}${window.label} `, tone: 'dim' },
22
+ { text: `${glyph} ${formatPercent(remaining)}`, tone: remainingTone(remaining) },
23
+ { text: ` ↺ ${window.resetAt === null ? '?' : duration(window.resetAt - now)}`, tone: 'dim' },
24
+ ];
25
+ }
26
+
27
+ export function quotaElement(state: QuotaState, now = Date.now()): BarElement {
28
+ const element: BarElement = { id: 'quota', spans: [] };
29
+ if (state.kind === 'hidden') return element;
30
+ if (state.kind === 'loading') return { ...element, spans: [{ text: 'Quota …', tone: 'dim' }] };
31
+ if (state.kind === 'error') return { ...element, spans: [{ text: `Quota ! ${errorText[state.code]}`, tone: 'warning' }] };
32
+ const windows = state.snapshot.windows;
33
+ const shared = windows.filter(window => !window.scope);
34
+ const shown = (shared.length ? shared : windows).slice(0, 2);
35
+ const spans: Span[] = state.snapshot.balance ? balanceSpans(state.snapshot.balance)
36
+ : state.snapshot.allowance ? allowanceSpans(state.snapshot.allowance) : [];
37
+ for (const window of shown) {
38
+ if (spans.length) spans.push({ text: ' · ', tone: 'dim' });
39
+ spans.push(...windowSpans(window, now));
40
+ }
41
+ if (windows.length > shown.length) spans.push({ text: ` +${windows.length - shown.length}`, tone: 'dim' });
42
+ return { ...element, spans };
43
+ }
44
+
45
+ export function renderFooter(state: QuotaState, paint: Paint = plain, now = Date.now()): string {
46
+ return renderBar([quotaElement(state, now)], paint);
47
+ }
48
+
49
+ export function renderUsage(state: QuotaState, paint: Paint = plain, now = Date.now(), barWidth = 20): string[] {
50
+ if (state.kind === 'hidden') return ['No quota adapter for the current provider.'];
51
+ const balanceOnly = state.kind === 'ready' && state.snapshot.balance
52
+ && !state.snapshot.allowance && state.snapshot.windows.length === 0;
53
+ const spendOnly = state.kind === 'ready' && state.snapshot.spend
54
+ && !state.snapshot.balance && !state.snapshot.allowance && state.snapshot.windows.length === 0;
55
+ const title = `${state.label} · ${balanceOnly ? (state.snapshot.spend ? 'Account' : 'Balance')
56
+ : spendOnly ? 'Usage' : 'Remaining quota'}`;
57
+ if (state.kind === 'loading') return [title, '', 'Loading…'];
58
+ if (state.kind === 'error') return [title, '', errorText[state.code]];
59
+ const rows = state.snapshot.windows.map(window => {
60
+ const remaining = window.resetAt !== null && window.resetAt <= now ? null : window.remainingPercent;
61
+ return {
62
+ label: `${window.scope ? `${window.scope}/` : ''}${window.label}`,
63
+ remaining,
64
+ value: formatPercent(remaining),
65
+ reset: window.resetAt === null ? '?' : duration(window.resetAt - now),
66
+ amounts: window.amounts,
67
+ };
68
+ });
69
+ const labelWidth = Math.max(0, ...rows.map(row => visibleWidth(row.label)));
70
+ const valueWidth = Math.max(4, ...rows.map(row => visibleWidth(row.value)));
71
+ const lines = [title, '', ...rows.map(row => {
72
+ const label = row.label + ' '.repeat(labelWidth - visibleWidth(row.label));
73
+ const value = ' '.repeat(valueWidth - visibleWidth(row.value)) + row.value;
74
+ const tone = remainingTone(row.remaining);
75
+ return paint('dim', `${label} `)
76
+ + paint(tone, `${horizontalBar(row.remaining, barWidth)} ${value}`)
77
+ + paint('dim', ` ↺ ${row.reset}${row.amounts
78
+ ? ` · ${money(row.amounts.remaining, row.amounts.currency)}/${money(row.amounts.limit, row.amounts.currency)}`
79
+ : ''}`);
80
+ })];
81
+ if (state.snapshot.allowance) {
82
+ if (rows.length) lines.push('');
83
+ lines.push(...allowanceLines(state.snapshot.allowance, paint, barWidth));
84
+ }
85
+ if (state.snapshot.balance) {
86
+ if (rows.length || state.snapshot.allowance) lines.push('');
87
+ lines.push(...balanceLines(state.snapshot.balance, paint));
88
+ }
89
+ if (state.snapshot.spend) {
90
+ if (rows.length || state.snapshot.allowance || state.snapshot.balance) lines.push('');
91
+ lines.push(...spendLines(state.snapshot.spend, paint));
92
+ }
93
+ return lines;
94
+ }
package/src/config.ts ADDED
@@ -0,0 +1,79 @@
1
+ import { createDeepSeekAdapter } from './query/deepseek.ts';
2
+ import { createNewApiAdapter, validateNewApiOptions } from './query/new-api.ts';
3
+ import { createSub2ApiAdapter } from './query/sub2api.ts';
4
+ import type { NewApiOptions } from './query/new-api.ts';
5
+ import type { QuotaAdapter } from './query/types.ts';
6
+
7
+ export type ProviderQuotaConfig =
8
+ | ({ adapter: 'new-api'; quotaPerUnit: number; currency: string } & Pick<NewApiOptions, 'dashboardAccessToken' | 'dashboardUserId'>)
9
+ | { adapter: 'deepseek' | 'sub2api' };
10
+ export interface QuotaConfig { providers: Record<string, ProviderQuotaConfig> }
11
+
12
+ const DASHBOARD_KEYS = ['dashboardAccessToken', 'dashboardUserId'] as const;
13
+
14
+ function parseDashboardOptions(item: Record<string, unknown>): Pick<NewApiOptions, 'dashboardAccessToken' | 'dashboardUserId'> {
15
+ const result: Pick<NewApiOptions, 'dashboardAccessToken' | 'dashboardUserId'> = {};
16
+ if (item.dashboardAccessToken !== undefined) {
17
+ if (typeof item.dashboardAccessToken !== 'string' || item.dashboardAccessToken.trim() === '') throw new QuotaConfigError();
18
+ result.dashboardAccessToken = item.dashboardAccessToken;
19
+ }
20
+ if (item.dashboardUserId !== undefined) {
21
+ if (typeof item.dashboardUserId !== 'number'
22
+ || !Number.isSafeInteger(item.dashboardUserId) || item.dashboardUserId <= 0) throw new QuotaConfigError();
23
+ result.dashboardUserId = item.dashboardUserId;
24
+ }
25
+ return result;
26
+ }
27
+
28
+ export class QuotaConfigError extends Error {
29
+ constructor() {
30
+ // Settings may accidentally contain secrets. Never echo content/values.
31
+ super('Invalid settings.json quotaUsage. Use providers with adapter "new-api", "deepseek" or "sub2api".');
32
+ }
33
+ }
34
+
35
+ function record(value: unknown): value is Record<string, unknown> {
36
+ return value !== null && typeof value === 'object' && !Array.isArray(value);
37
+ }
38
+
39
+ export function parseQuotaConfig(value: unknown): QuotaConfig {
40
+ if (!record(value) || Object.keys(value).some(key => key !== 'providers')
41
+ || !record(value.providers) || Object.keys(value.providers).length > 64) throw new QuotaConfigError();
42
+ const providers: Record<string, ProviderQuotaConfig> = Object.create(null);
43
+ for (const [provider, item] of Object.entries(value.providers)) {
44
+ if (!/^[A-Za-z0-9][A-Za-z0-9._:/-]{0,127}$/.test(provider)
45
+ || ['openai-codex', 'kimi-coding', 'opencode-go'].includes(provider)
46
+ || !record(item)) throw new QuotaConfigError();
47
+ if (item.adapter === 'deepseek') {
48
+ if (Object.keys(item).some(key => key !== 'adapter')) throw new QuotaConfigError();
49
+ providers[provider] = { adapter: 'deepseek' };
50
+ continue;
51
+ }
52
+ if (item.adapter === 'sub2api') {
53
+ if (Object.keys(item).length !== 1) throw new QuotaConfigError();
54
+ providers[provider] = { adapter: 'sub2api' };
55
+ continue;
56
+ }
57
+ if (item.adapter !== 'new-api'
58
+ || Object.keys(item).some(key => !['adapter', 'quotaPerUnit', 'currency', ...DASHBOARD_KEYS].includes(key))
59
+ || (item.quotaPerUnit !== undefined && typeof item.quotaPerUnit !== 'number')
60
+ || (item.currency !== undefined && typeof item.currency !== 'string')) throw new QuotaConfigError();
61
+ try {
62
+ const settings = validateNewApiOptions({ quotaPerUnit: item.quotaPerUnit, currency: item.currency });
63
+ providers[provider] = { adapter: 'new-api', ...settings, ...parseDashboardOptions(item) };
64
+ } catch { throw new QuotaConfigError(); }
65
+ }
66
+ return { providers };
67
+ }
68
+
69
+ export function loadQuotaAdaptersFromSettings(settings: unknown): QuotaAdapter[] {
70
+ if (!record(settings) || settings.quotaUsage === undefined) return [];
71
+ const config = parseQuotaConfig(settings.quotaUsage);
72
+ return Object.entries(config.providers).map(([provider, options]) => {
73
+ switch (options.adapter) {
74
+ case 'deepseek': return createDeepSeekAdapter(provider);
75
+ case 'sub2api': return createSub2ApiAdapter(provider);
76
+ case 'new-api': return createNewApiAdapter(provider, options);
77
+ }
78
+ });
79
+ }
@@ -0,0 +1,152 @@
1
+ import type { ExtensionAPI, ExtensionContext } from '@earendil-works/pi-coding-agent';
2
+ import { renderFooter, renderUsage } from './bar/quota.ts';
3
+ import { captureUsage, USAGE_ENTRY, usageCardComponent } from './bar/card.ts';
4
+ import type { UsageCard } from './bar/card.ts';
5
+ import { codexAdapter } from './query/codex.ts';
6
+ import { kimiAdapter } from './query/kimi.ts';
7
+ import { openCodeGoAdapter } from './query/opencode-go.ts';
8
+ import { QuotaController } from './query/controller.ts';
9
+ import { createJsonClient } from './query/http.ts';
10
+ import { AdapterRegistry } from './query/registry.ts';
11
+ import type { ProviderAuth, QuotaAdapter } from './query/types.ts';
12
+
13
+ export const STATUS_EVENT = 'quota-bar:state:v1';
14
+ const STATUS_KEY = 'quota-bar';
15
+ const USAGE_WIDGET = 'quota-bar:usage';
16
+
17
+ export interface QuotaExtensionOptions {
18
+ adapters?: readonly QuotaAdapter[]; // Extra adapters; duplicate IDs are rejected.
19
+ footer?: boolean; // false: query + /usage + structured events only
20
+ intervalMs?: number;
21
+ timeoutMs?: number;
22
+ }
23
+
24
+ export function createQuotaExtension(options: QuotaExtensionOptions = {}) {
25
+ return (pi: ExtensionAPI): void => {
26
+ const registry = new AdapterRegistry([codexAdapter, kimiAdapter, openCodeGoAdapter, ...(options.adapters ?? [])]);
27
+ let current: ExtensionContext | undefined;
28
+ let selectedProvider: string | undefined;
29
+ let timer: ReturnType<typeof setInterval> | undefined;
30
+ let lastStatus: string | undefined;
31
+ // Invalidates pending commands on model/session replacement or another /usage.
32
+ let commandGeneration = 0;
33
+
34
+ pi.registerEntryRenderer<UsageCard>(USAGE_ENTRY, (entry, _options, theme) => {
35
+ const paint = process.env.NO_COLOR !== undefined ? undefined
36
+ : (tone: Parameters<typeof theme.fg>[0], text: string) => theme.fg(tone, text);
37
+ return usageCardComponent(entry.data, paint);
38
+ });
39
+
40
+ const paintFooter = () => {
41
+ if (!current?.hasUI || options.footer === false) return;
42
+ try {
43
+ const ctx = current;
44
+ const paint = process.env.NO_COLOR !== undefined ? undefined
45
+ : (tone: Parameters<typeof ctx.ui.theme.fg>[0], text: string) => ctx.ui.theme.fg(tone, text);
46
+ const text = renderFooter(controller.state, paint) || undefined;
47
+ if (text !== lastStatus) {
48
+ ctx.ui.setStatus(STATUS_KEY, text);
49
+ lastStatus = text;
50
+ }
51
+ } catch {
52
+ // Stale session contexts must not keep a background polling loop alive.
53
+ current = undefined;
54
+ commandGeneration++;
55
+ if (timer) clearInterval(timer);
56
+ timer = undefined;
57
+ controller.stop();
58
+ }
59
+ };
60
+
61
+ const controller = new QuotaController({
62
+ registry, getJson: createJsonClient(), intervalMs: options.intervalMs, timeoutMs: options.timeoutMs,
63
+ onState(state) {
64
+ paintFooter();
65
+ pi.events.emit(STATUS_EVENT, state); // Structured data, no credentials or ANSI.
66
+ },
67
+ });
68
+
69
+ const refresh = (force = false): Promise<void> => {
70
+ const ctx = current;
71
+ if (!ctx?.hasUI) return Promise.resolve();
72
+ return controller.refresh(async provider => {
73
+ const resolved = await ctx.modelRegistry.getProviderAuth(provider);
74
+ if (!resolved) return undefined;
75
+ const headers: Record<string, string | undefined> = {};
76
+ for (const [name, value] of Object.entries(resolved.auth.headers ?? {})) {
77
+ if (typeof value === 'string') headers[name] = value;
78
+ }
79
+ const auth: ProviderAuth = { ...resolved.auth, headers };
80
+ // A model-level custom endpoint must not be mistaken for an official account.
81
+ if (ctx.model?.provider === provider && ctx.model.baseUrl) auth.baseUrl = ctx.model.baseUrl;
82
+ return auth;
83
+ }, force);
84
+ };
85
+
86
+ const bind = (ctx: ExtensionContext, select = false, provider = ctx.model?.provider) => {
87
+ current = ctx;
88
+ if (!ctx.hasUI) return;
89
+ if (select || provider !== selectedProvider) {
90
+ commandGeneration++;
91
+ if (ctx.mode === 'tui') ctx.ui.setWidget(USAGE_WIDGET, undefined);
92
+ selectedProvider = provider;
93
+ controller.select(provider);
94
+ }
95
+ void refresh();
96
+ };
97
+
98
+ pi.on('session_start', (_event, ctx) => {
99
+ lastStatus = undefined;
100
+ bind(ctx, true);
101
+ if (timer) clearInterval(timer);
102
+ if (ctx.hasUI) {
103
+ timer = setInterval(() => {
104
+ paintFooter(); // Local countdown/theme refresh, no extra HTTP request.
105
+ void refresh(); // Controller throttles queries and applies error backoff.
106
+ }, 10000);
107
+ timer.unref?.();
108
+ }
109
+ });
110
+ pi.on('model_select', (event, ctx) => bind(ctx, true, event.model.provider));
111
+ pi.on('turn_end', (_event, ctx) => bind(ctx));
112
+ pi.on('session_shutdown', (_event, ctx) => {
113
+ if (timer) clearInterval(timer);
114
+ timer = undefined;
115
+ current = undefined;
116
+ selectedProvider = undefined;
117
+ lastStatus = undefined;
118
+ commandGeneration++;
119
+ controller.stop();
120
+ if (ctx.hasUI && ctx.mode === 'tui') ctx.ui.setWidget(USAGE_WIDGET, undefined);
121
+ if (ctx.hasUI && options.footer !== false) ctx.ui.setStatus(STATUS_KEY, undefined);
122
+ });
123
+
124
+ pi.registerCommand('usage', {
125
+ description: 'Query current provider quota and add a snapshot to the chat history',
126
+ handler: async (_args, ctx) => {
127
+ if (!ctx.hasUI) return;
128
+ bind(ctx);
129
+ const generation = ++commandGeneration;
130
+ if (ctx.mode === 'tui') ctx.ui.setWidget(USAGE_WIDGET, ['Loading…']);
131
+ try {
132
+ await refresh(true);
133
+ // Never append via a stale Pi runtime, or label a replacement model's data
134
+ // as the result of the original command. A newer command owns its result.
135
+ if (!current || generation !== commandGeneration) return;
136
+ const card = captureUsage(controller.state);
137
+ if (!card) return;
138
+ pi.appendEntry<UsageCard>(USAGE_ENTRY, card);
139
+ // Custom entry renderers are TUI-only; RPC clients still get readable output.
140
+ if (ctx.mode !== 'tui') {
141
+ ctx.ui.notify(renderUsage(card.state, undefined, card.capturedAt).join('\n'), 'info');
142
+ }
143
+ } finally {
144
+ // An older request must not clear a newer command's loading indicator.
145
+ if (current && generation === commandGeneration && ctx.mode === 'tui') {
146
+ ctx.ui.setWidget(USAGE_WIDGET, undefined);
147
+ }
148
+ }
149
+ },
150
+ });
151
+ };
152
+ }