@astrosheep/pi-quota 0.5.1 → 0.5.3

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -6,7 +6,7 @@ Compact quota status for Pi: Codex, Kimi and OpenCode Go work out of the box; ne
6
6
  pi install npm:@astrosheep/pi-quota
7
7
  ```
8
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.
9
+ Reload Pi after installing or changing settings. Use `/usage` for a persistent, context-free snapshot covering every configured provider with a quota adapter, including the current one. Automatic footer polling never adds chat items.
10
10
 
11
11
  ## What it shows
12
12
 
@@ -18,7 +18,7 @@ Reload Pi after installing or changing settings. Use `/usage` for a persistent,
18
18
 
19
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
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.
21
+ The footer shows at most two windows and `+N` for additional ones, for the current provider only. `/usage` queries all configured supported providers and includes all windows, grouped by provider; it does not open a modal or send messages to the model. Snapshots remain frozen at capture time. Errors replace stale values and are shown per provider.
22
22
 
23
23
  ## Configure providers
24
24
 
@@ -58,12 +58,12 @@ Pi model/auth → adapter → QuotaSnapshot → controller → footer / /usage /
58
58
 
59
59
  - `src/query/`: protocol-specific parsing, credential resolution, safe URLs, bounded GET client, state validation and controller. No Pi/TUI imports.
60
60
  - `src/config.ts`: strict global provider bindings.
61
- - `src/bar/`: pure compact rendering and persistent chat card.
61
+ - `src/bar/`: pure compact rendering and persistent multi-provider chat card.
62
62
  - `src/extension.ts` and `src/index.ts`: Pi lifecycle and auth bridge.
63
63
 
64
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
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.
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 for every configured supported provider. Switching provider discards old footer values immediately.
67
67
 
68
68
  ## Development
69
69
 
package/package.json CHANGED
@@ -1,7 +1,9 @@
1
1
  {
2
2
  "name": "@astrosheep/pi-quota",
3
- "version": "0.5.1",
4
- "publishConfig": { "access": "public" },
3
+ "version": "0.5.3",
4
+ "publishConfig": {
5
+ "access": "public"
6
+ },
5
7
  "type": "module",
6
8
  "description": "Composable quota bar with Codex, Kimi, OpenCode Go, new-api, DeepSeek and Sub2API adapters",
7
9
  "keywords": [
@@ -4,21 +4,29 @@ import type { Paint, Span } from './bar.ts';
4
4
 
5
5
  export function money(amount: number, currency: string): string {
6
6
  const sign = amount < 0 ? '-' : '';
7
- const prefix = currency === 'USD' ? '$' : `${currency} `;
7
+ const symbol = currency === 'CNY' ? '¥' : currency === 'USD' ? '$' : `${currency} `;
8
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)}`;
9
+ if (absolute > 0 && absolute < 0.01) return `${sign}<${symbol}0.01`;
10
+ return `${sign}${symbol}${absolute.toFixed(2)}`;
11
11
  }
12
12
 
13
13
  export function remainingMoney(balance: AccountBalance): string {
14
- if (balance.unlimited === true) return balance.currency === 'USD' ? '$∞' : `${balance.currency} ∞`;
14
+ if (balance.unlimited === true) return balance.currency === 'CNY' ? '¥∞' : balance.currency === 'USD' ? '$∞' : `${balance.currency} ∞`;
15
15
  return money(balance.remaining, balance.currency);
16
16
  }
17
17
 
18
+ export function compactMoney(amount: number, currency: string): string {
19
+ return money(amount, currency);
20
+ }
21
+
22
+ export function compactRemainingMoney(balance: AccountBalance): string {
23
+ return remainingMoney(balance);
24
+ }
25
+
18
26
  export function balanceSpans(balance: AccountBalance): Span[] {
19
27
  return [
20
28
  { text: 'Bal ', tone: 'dim' },
21
- { text: remainingMoney(balance), tone: !balance.unlimited && balance.remaining <= 0 ? 'error' : 'text' },
29
+ { text: compactRemainingMoney(balance), tone: !balance.unlimited && balance.remaining <= 0 ? 'error' : 'text' },
22
30
  ];
23
31
  }
24
32
 
@@ -35,7 +43,7 @@ export function allowanceSpans(allowance: QuotaAllowance): Span[] {
35
43
  const remaining = allowancePercent(allowance);
36
44
  return [
37
45
  { text: 'Quota ', tone: 'dim' },
38
- { text: `${verticalBar(remaining)} ${money(allowance.remaining, allowance.currency)}`, tone: remainingTone(remaining) },
46
+ { text: `${verticalBar(remaining)} ${compactMoney(allowance.remaining, allowance.currency)}`, tone: remainingTone(remaining) },
39
47
  ];
40
48
  }
41
49
 
@@ -52,6 +60,5 @@ export function allowanceLines(allowance: QuotaAllowance, paint: Paint, barWidth
52
60
  export function spendLines(spend: SpendSummary, paint: Paint): string[] {
53
61
  const lines: string[] = [];
54
62
  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
63
  return lines;
57
64
  }
package/src/bar/card.ts CHANGED
@@ -6,27 +6,39 @@ import type { QuotaState } from '../query/types.ts';
6
6
 
7
7
  export const USAGE_ENTRY = 'quota-bar:usage:v1';
8
8
 
9
- export interface UsageCard {
10
- version: 1;
11
- capturedAt: number;
12
- state: Exclude<QuotaState, { kind: 'loading' }>;
13
- }
9
+ export type UsageCard =
10
+ | { version: 1; capturedAt: number; state: Exclude<QuotaState, { kind: 'loading' }> }
11
+ | { version: 2; capturedAt: number; states: Exclude<QuotaState, { kind: 'loading' }>[] };
14
12
 
15
13
  export function captureUsage(state: QuotaState, now = Date.now()): UsageCard | undefined {
16
14
  if (state.kind === 'loading') return undefined;
17
- // Separate the persistent entry from the live controller's state/objects.
15
+ // Preserve v1 rendering for cards already saved in sessions.
18
16
  return { version: 1, capturedAt: now, state: structuredClone(state) };
19
17
  }
20
18
 
19
+ export function captureAllUsage(states: readonly QuotaState[], now = Date.now()): UsageCard {
20
+ return { version: 2, capturedAt: now, states: structuredClone(states.filter(
21
+ (state): state is Exclude<QuotaState, { kind: 'loading' }> => state.kind !== 'loading',
22
+ )) };
23
+ }
24
+
25
+ export function usageLines(card: UsageCard, paint?: Paint, barWidth = 20): string[] {
26
+ if (card.version === 1) return renderUsage(card.state, paint, card.capturedAt, barWidth);
27
+ if (card.states.length === 0) return ['No supported quota providers available.'];
28
+ return card.states.flatMap((state, index) => [
29
+ ...(index ? ['', ''] : []), ...renderUsage(state, paint, card.capturedAt, barWidth),
30
+ ]);
31
+ }
32
+
21
33
  export function usageCardComponent(card: UsageCard | undefined, paint?: Paint): Component {
22
34
  return {
23
35
  render(width: number): string[] {
24
36
  if (width <= 0) return [];
25
- if (!card || card.version !== 1 || !Number.isFinite(card.capturedAt)) {
37
+ if (!card || (card.version !== 1 && card.version !== 2) || !Number.isFinite(card.capturedAt)) {
26
38
  return new Text('Usage snapshot unavailable.', 0, 0).render(width);
27
39
  }
28
40
  // 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);
41
+ const lines = usageLines(card, paint, width < 55 ? 10 : 18);
30
42
  // Wrapping, not clipping: long quota names remain readable in narrow terminals.
31
43
  return new Text(lines.join('\n'), 0, 0).render(width);
32
44
  },
package/src/bar/quota.ts CHANGED
@@ -46,16 +46,14 @@ export function renderFooter(state: QuotaState, paint: Paint = plain, now = Date
46
46
  return renderBar([quotaElement(state, now)], paint);
47
47
  }
48
48
 
49
- export function renderUsage(state: QuotaState, paint: Paint = plain, now = Date.now(), barWidth = 20): string[] {
49
+ const displayLabel: Record<string, string> = { deepseek: 'DeepSeek' };
50
+
51
+ export function renderUsage(state: QuotaState, paint: Paint = plain, now = Date.now(), barWidth = 16): string[] {
50
52
  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]];
53
+ const title = displayLabel[state.provider] ?? state.label;
54
+ const indent = (line: string) => line ? ` ${line}` : line;
55
+ if (state.kind === 'loading') return [title, '', indent('Loading…')];
56
+ if (state.kind === 'error') return [title, '', indent(errorText[state.code])];
59
57
  const rows = state.snapshot.windows.map(window => {
60
58
  const remaining = window.resetAt !== null && window.resetAt <= now ? null : window.remainingPercent;
61
59
  return {
@@ -69,26 +67,25 @@ export function renderUsage(state: QuotaState, paint: Paint = plain, now = Date.
69
67
  const labelWidth = Math.max(0, ...rows.map(row => visibleWidth(row.label)));
70
68
  const valueWidth = Math.max(4, ...rows.map(row => visibleWidth(row.value)));
71
69
  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
70
  const tone = remainingTone(row.remaining);
75
- return paint('dim', `${label} `)
71
+ const value = ' '.repeat(valueWidth - visibleWidth(row.value)) + row.value;
72
+ return indent(paint('dim', `${row.label}${' '.repeat(labelWidth - visibleWidth(row.label))} `)
76
73
  + paint(tone, `${horizontalBar(row.remaining, barWidth)} ${value}`)
77
74
  + paint('dim', ` ↺ ${row.reset}${row.amounts
78
75
  ? ` · ${money(row.amounts.remaining, row.amounts.currency)}/${money(row.amounts.limit, row.amounts.currency)}`
79
- : ''}`);
76
+ : ''}`));
80
77
  })];
81
78
  if (state.snapshot.allowance) {
82
79
  if (rows.length) lines.push('');
83
- lines.push(...allowanceLines(state.snapshot.allowance, paint, barWidth));
80
+ lines.push(...allowanceLines(state.snapshot.allowance, paint, barWidth).map(indent));
84
81
  }
85
82
  if (state.snapshot.balance) {
86
83
  if (rows.length || state.snapshot.allowance) lines.push('');
87
- lines.push(...balanceLines(state.snapshot.balance, paint));
84
+ lines.push(...balanceLines(state.snapshot.balance, paint).map(indent));
88
85
  }
89
- if (state.snapshot.spend) {
86
+ if (state.snapshot.spend?.today !== undefined) {
90
87
  if (rows.length || state.snapshot.allowance || state.snapshot.balance) lines.push('');
91
- lines.push(...spendLines(state.snapshot.spend, paint));
88
+ lines.push(...spendLines(state.snapshot.spend, paint).map(indent));
92
89
  }
93
90
  return lines;
94
91
  }
package/src/config.ts CHANGED
@@ -77,3 +77,6 @@ export function loadQuotaAdaptersFromSettings(settings: unknown): QuotaAdapter[]
77
77
  }
78
78
  });
79
79
  }
80
+
81
+ // Explicit adapter bindings from quotaUsage already have adapters registered; listing every
82
+ // registered adapter is the boundary between quota providers and ordinary model providers.
package/src/extension.ts CHANGED
@@ -1,14 +1,15 @@
1
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';
2
+ import { renderFooter } from './bar/quota.ts';
3
+ import { captureAllUsage, USAGE_ENTRY, usageCardComponent, usageLines } from './bar/card.ts';
4
4
  import type { UsageCard } from './bar/card.ts';
5
5
  import { codexAdapter } from './query/codex.ts';
6
+ import { createDeepSeekAdapter } from './query/deepseek.ts';
6
7
  import { kimiAdapter } from './query/kimi.ts';
7
8
  import { openCodeGoAdapter } from './query/opencode-go.ts';
8
9
  import { QuotaController } from './query/controller.ts';
9
10
  import { createJsonClient } from './query/http.ts';
10
11
  import { AdapterRegistry } from './query/registry.ts';
11
- import type { ProviderAuth, QuotaAdapter } from './query/types.ts';
12
+ import type { ProviderAuth, QuotaAdapter, QuotaState } from './query/types.ts';
12
13
 
13
14
  export const STATUS_EVENT = 'quota-bar:state:v1';
14
15
  const STATUS_KEY = 'quota-bar';
@@ -16,6 +17,7 @@ const USAGE_WIDGET = 'quota-bar:usage';
16
17
 
17
18
  export interface QuotaExtensionOptions {
18
19
  adapters?: readonly QuotaAdapter[]; // Extra adapters; duplicate IDs are rejected.
20
+ providers?: readonly string[]; // Extra configured quota providers to include in /usage
19
21
  footer?: boolean; // false: query + /usage + structured events only
20
22
  intervalMs?: number;
21
23
  timeoutMs?: number;
@@ -23,7 +25,8 @@ export interface QuotaExtensionOptions {
23
25
 
24
26
  export function createQuotaExtension(options: QuotaExtensionOptions = {}) {
25
27
  return (pi: ExtensionAPI): void => {
26
- const registry = new AdapterRegistry([codexAdapter, kimiAdapter, openCodeGoAdapter, ...(options.adapters ?? [])]);
28
+ const registry = new AdapterRegistry([codexAdapter, kimiAdapter, openCodeGoAdapter,
29
+ createDeepSeekAdapter('deepseek'), ...(options.adapters ?? [])]);
27
30
  let current: ExtensionContext | undefined;
28
31
  let selectedProvider: string | undefined;
29
32
  let timer: ReturnType<typeof setInterval> | undefined;
@@ -58,29 +61,65 @@ export function createQuotaExtension(options: QuotaExtensionOptions = {}) {
58
61
  }
59
62
  };
60
63
 
64
+ const getJson = createJsonClient();
65
+
61
66
  const controller = new QuotaController({
62
- registry, getJson: createJsonClient(), intervalMs: options.intervalMs, timeoutMs: options.timeoutMs,
67
+ registry, getJson, intervalMs: options.intervalMs, timeoutMs: options.timeoutMs,
63
68
  onState(state) {
64
69
  paintFooter();
65
70
  pi.events.emit(STATUS_EVENT, state); // Structured data, no credentials or ANSI.
66
71
  },
67
72
  });
68
73
 
74
+ const resolveAuth = (ctx: ExtensionContext) => async (provider: string): Promise<ProviderAuth | undefined> => {
75
+ const resolved = await ctx.modelRegistry.getProviderAuth(provider);
76
+ if (!resolved) return undefined;
77
+ const headers: Record<string, string | undefined> = {};
78
+ for (const [name, value] of Object.entries(resolved.auth.headers ?? {})) {
79
+ if (typeof value === 'string') headers[name] = value;
80
+ }
81
+ const auth: ProviderAuth = { ...resolved.auth, headers };
82
+ // A model-level custom endpoint must not be mistaken for an official account.
83
+ if (ctx.model?.provider === provider && ctx.model.baseUrl) auth.baseUrl = ctx.model.baseUrl;
84
+ return auth;
85
+ };
86
+
69
87
  const refresh = (force = false): Promise<void> => {
70
88
  const ctx = current;
71
89
  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;
90
+ return controller.refresh(resolveAuth(ctx), force);
91
+ };
92
+
93
+ const supportedProviders = (ctx: ExtensionContext): string[] => {
94
+ const providers: string[] = [];
95
+ const seen = new Set<string>();
96
+ const add = (provider: string | undefined) => {
97
+ if (provider && registry.get(provider) && !seen.has(provider)) {
98
+ seen.add(provider);
99
+ providers.push(provider);
78
100
  }
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);
101
+ };
102
+ add(ctx.model?.provider);
103
+ for (const adapter of registry.list()) add(adapter.provider);
104
+ for (const provider of options.providers ?? []) add(provider);
105
+ return providers;
106
+ };
107
+
108
+ const queryProvider = async (provider: string, getAuth: ReturnType<typeof resolveAuth>): Promise<QuotaState> => {
109
+ const adapter = registry.get(provider);
110
+ if (!adapter) return { kind: 'error', provider, label: provider, code: 'unsupported-auth' };
111
+ if (provider === selectedProvider) {
112
+ // The footer already owns this provider's lifecycle and cache; do not fork it.
113
+ await controller.refresh(getAuth, true);
114
+ return controller.state;
115
+ }
116
+ const scratch = new QuotaController({
117
+ registry, getJson, intervalMs: options.intervalMs, timeoutMs: options.timeoutMs,
118
+ onState() {},
119
+ });
120
+ scratch.select(provider);
121
+ await scratch.refresh(getAuth, true);
122
+ return scratch.state;
84
123
  };
85
124
 
86
125
  const bind = (ctx: ExtensionContext, select = false, provider = ctx.model?.provider) => {
@@ -122,23 +161,24 @@ export function createQuotaExtension(options: QuotaExtensionOptions = {}) {
122
161
  });
123
162
 
124
163
  pi.registerCommand('usage', {
125
- description: 'Query current provider quota and add a snapshot to the chat history',
164
+ description: 'Query quota for every configured supported provider and add snapshots to the chat history',
126
165
  handler: async (_args, ctx) => {
127
166
  if (!ctx.hasUI) return;
128
167
  bind(ctx);
129
168
  const generation = ++commandGeneration;
130
- if (ctx.mode === 'tui') ctx.ui.setWidget(USAGE_WIDGET, ['Loading…']);
169
+ if (ctx.mode === 'tui') ctx.ui.setWidget(USAGE_WIDGET, ['Quota …']);
131
170
  try {
132
- await refresh(true);
171
+ const getAuth = resolveAuth(ctx);
172
+ const providers = supportedProviders(ctx);
173
+ const states = await Promise.all(providers.map(provider => queryProvider(provider, getAuth)));
133
174
  // Never append via a stale Pi runtime, or label a replacement model's data
134
175
  // as the result of the original command. A newer command owns its result.
135
176
  if (!current || generation !== commandGeneration) return;
136
- const card = captureUsage(controller.state);
137
- if (!card) return;
177
+ const card = captureAllUsage(states);
138
178
  pi.appendEntry<UsageCard>(USAGE_ENTRY, card);
139
179
  // Custom entry renderers are TUI-only; RPC clients still get readable output.
140
180
  if (ctx.mode !== 'tui') {
141
- ctx.ui.notify(renderUsage(card.state, undefined, card.capturedAt).join('\n'), 'info');
181
+ ctx.ui.notify(usageLines(card, undefined).join('\n'), 'info');
142
182
  }
143
183
  } finally {
144
184
  // An older request must not clear a newer command's loading indicator.
@@ -19,6 +19,10 @@ export class AdapterRegistry {
19
19
  get(provider: string | undefined): QuotaAdapter | undefined {
20
20
  return provider ? this.adapters.get(provider) : undefined;
21
21
  }
22
+
23
+ list(): readonly QuotaAdapter[] {
24
+ return [...this.adapters.values()];
25
+ }
22
26
  }
23
27
 
24
28
  // Validate even custom adapter output before it reaches the bar.