@astrosheep/pi-quota 0.5.5 → 0.5.7

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
@@ -36,7 +36,7 @@ Add bindings in global `~/.pi/agent/settings.json` under `quotaUsage` (not proje
36
36
  }
37
37
  ```
38
38
 
39
- Use the **exact Pi provider ID**. Built-in providers and explicitly bound providers are queried; explicit bindings replace a built-in adapter with the same ID. 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`.
39
+ Use the **exact Pi provider ID**. Built-in providers and explicitly bound providers are queried; explicit bindings replace a built-in adapter with the same ID. Unknown keys or adapter options are rejected. The API key comes from Pi's provider auth, and the endpoint comes from the provider's registered model; do not put model API keys in `quotaUsage`.
40
40
 
41
41
  | Adapter | Endpoint | Result |
42
42
  |---|---|---|
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@astrosheep/pi-quota",
3
- "version": "0.5.5",
3
+ "version": "0.5.7",
4
4
  "publishConfig": {
5
5
  "access": "public"
6
6
  },
package/src/bar/card.ts CHANGED
@@ -1,8 +1,10 @@
1
- import { Text } from '@earendil-works/pi-tui';
1
+ import { Text, visibleWidth } from '@earendil-works/pi-tui';
2
2
  import type { Component } from '@earendil-works/pi-tui';
3
- import type { Paint } from './bar.ts';
3
+ import { duration, formatPercent, horizontalBar, remainingTone } from './bar.ts';
4
+ import type { Paint, Tone } from './bar.ts';
5
+ import { money, remainingMoney } from './balance.ts';
4
6
  import { renderUsage } from './quota.ts';
5
- import type { QuotaState } from '../query/types.ts';
7
+ import type { QuotaErrorCode, QuotaState } from '../query/types.ts';
6
8
 
7
9
  export const USAGE_ENTRY = 'quota-bar:usage:v1';
8
10
 
@@ -22,12 +24,120 @@ export function captureAllUsage(states: readonly QuotaState[], now = Date.now())
22
24
  )) };
23
25
  }
24
26
 
25
- export function usageLines(card: UsageCard, paint?: Paint, barWidth = 20): string[] {
27
+ const errorText: Record<QuotaErrorCode, string> = {
28
+ auth: 'Sign in with /login',
29
+ 'unsupported-auth': 'Unsupported credentials or endpoint',
30
+ 'account-access': 'Account quota access denied',
31
+ network: 'Network error', timeout: 'Request timed out',
32
+ 'rate-limit': 'Rate limited', http: 'Service unavailable', schema: 'Unrecognized quota response',
33
+ };
34
+
35
+ interface UsageRow {
36
+ group: number;
37
+ kind: 'quota' | 'balance' | 'error';
38
+ provider: string;
39
+ metric: string;
40
+ value: string;
41
+ secondary: string;
42
+ valueTone: Tone;
43
+ metricTone: Tone;
44
+ secondaryTone: Tone;
45
+ }
46
+
47
+ function padEndVisible(text: string, width: number): string {
48
+ return text + ' '.repeat(Math.max(0, width - visibleWidth(text)));
49
+ }
50
+
51
+ function padStartVisible(text: string, width: number): string {
52
+ return ' '.repeat(Math.max(0, width - visibleWidth(text))) + text;
53
+ }
54
+
55
+ function gridRows(states: readonly Exclude<QuotaState, { kind: 'loading' }>[], now: number, barWidth: number): UsageRow[] {
56
+ const rows: UsageRow[] = [];
57
+ states.forEach((state, group) => {
58
+ if (state.kind === 'ready') {
59
+ const windows = state.snapshot.windows;
60
+ if (windows.length > 0) {
61
+ windows.forEach((window, index) => {
62
+ const expired = window.resetAt !== null && window.resetAt <= now;
63
+ const remaining = expired ? null : window.remainingPercent;
64
+ rows.push({
65
+ group, kind: 'quota',
66
+ provider: index === 0 ? state.label : '',
67
+ metric: `${window.scope ? `${window.scope}/` : ''}${window.label}`,
68
+ value: `${horizontalBar(remaining, barWidth)} ${formatPercent(remaining)}`,
69
+ secondary: `↺ ${window.resetAt === null ? '?' : duration(window.resetAt - now)}`,
70
+ valueTone: remainingTone(remaining), metricTone: 'dim', secondaryTone: 'dim',
71
+ });
72
+ });
73
+ return;
74
+ }
75
+ if (state.snapshot.balance) {
76
+ const balance = state.snapshot.balance;
77
+ rows.push({
78
+ group, kind: 'balance', provider: state.label, metric: '',
79
+ value: remainingMoney(balance),
80
+ secondary: state.snapshot.spend?.today !== undefined
81
+ ? `today ${money(state.snapshot.spend.today, state.snapshot.spend.currency)}` : '',
82
+ valueTone: !balance.unlimited && balance.remaining <= 0 ? 'error' : 'text',
83
+ metricTone: 'dim', secondaryTone: 'dim',
84
+ });
85
+ return;
86
+ }
87
+ if (state.snapshot.allowance) {
88
+ const allowance = state.snapshot.allowance;
89
+ const remaining = Math.max(0, Math.min(100, allowance.remaining / allowance.limit * 100));
90
+ rows.push({
91
+ group, kind: 'balance', provider: state.label, metric: '',
92
+ value: `${horizontalBar(remaining, barWidth)} ${formatPercent(remaining)}`,
93
+ secondary: `${money(allowance.used, allowance.currency)} used`,
94
+ valueTone: remainingTone(remaining), metricTone: 'dim', secondaryTone: 'dim',
95
+ });
96
+ return;
97
+ }
98
+ }
99
+ if (state.kind === 'error') {
100
+ rows.push({ group, kind: 'error', provider: state.label, metric: '', value: errorText[state.code], secondary: '',
101
+ valueTone: 'warning', metricTone: 'dim', secondaryTone: 'dim' });
102
+ }
103
+ });
104
+ return rows;
105
+ }
106
+
107
+ export function aggregateUsageLines(
108
+ states: readonly Exclude<QuotaState, { kind: 'loading' }>[],
109
+ paint: Paint = (_tone, text) => text,
110
+ now = Date.now(),
111
+ barWidth = 12,
112
+ ): string[] {
113
+ const rows = gridRows(states, now, barWidth);
114
+ if (rows.length === 0) return ['No quota data available. Run /usage --all for details.'];
115
+ const quotaRows = rows.filter(row => row.kind === 'quota');
116
+ const otherRows = rows.filter(row => row.kind !== 'quota');
117
+ const providerWidth = Math.max(...quotaRows.map(row => visibleWidth(row.provider)), ...otherRows.map(row => visibleWidth(row.provider)));
118
+ const metricWidth = Math.max(0, ...quotaRows.map(row => visibleWidth(row.metric)));
119
+ const quotaValueWidth = Math.max(0, ...quotaRows.map(row => visibleWidth(row.value)));
120
+ const otherValueWidth = Math.max(0, ...otherRows.map(row => visibleWidth(row.value)));
121
+ const result: string[] = [];
122
+ let previousGroup: number | undefined;
123
+ for (const row of rows) {
124
+ if (previousGroup !== undefined && row.group !== previousGroup && (row.kind === 'quota' || rows.some(other => other.group === previousGroup && other.kind === 'quota'))) result.push('');
125
+ previousGroup = row.group;
126
+ const provider = padEndVisible(row.provider, providerWidth);
127
+ const metric = row.kind === 'quota' ? padEndVisible(row.metric, metricWidth) : '';
128
+ const value = padStartVisible(row.value, row.kind === 'quota' ? quotaValueWidth : otherValueWidth);
129
+ const line = paint('text', provider)
130
+ + (metric ? paint(row.metricTone, ` ${metric}`) : '')
131
+ + paint(row.valueTone, ` ${value}`)
132
+ + (row.secondary ? paint(row.secondaryTone, ` ${row.secondary}`) : '');
133
+ result.push(line.replace(/\s+$/u, ''));
134
+ }
135
+ return result;
136
+ }
137
+
138
+ export function usageLines(card: UsageCard, paint?: Paint, barWidth = 12): string[] {
26
139
  if (card.version === 1) return renderUsage(card.state, paint, card.capturedAt, barWidth);
27
- if (card.states.length === 0) return ['No quota data available. Run /usage --all for details.'];
28
- return card.states.flatMap((state, index) => [
29
- ...(index ? ['', ''] : []), ...renderUsage(state, paint, card.capturedAt, barWidth),
30
- ]);
140
+ return aggregateUsageLines(card.states, paint, card.capturedAt, barWidth);
31
141
  }
32
142
 
33
143
  export function usageCardComponent(card: UsageCard | undefined, paint?: Paint): Component {
@@ -38,7 +148,7 @@ export function usageCardComponent(card: UsageCard | undefined, paint?: Paint):
38
148
  return new Text('Usage snapshot unavailable.', 0, 0).render(width);
39
149
  }
40
150
  // Rebuild on every render for theme/width changes, but freeze time at capture.
41
- const lines = usageLines(card, paint, width < 55 ? 10 : 18);
151
+ const lines = usageLines(card, paint, width < 55 ? 6 : 12);
42
152
  // Wrapping, not clipping: long quota names remain readable in narrow terminals.
43
153
  return new Text(lines.join('\n'), 0, 0).render(width);
44
154
  },
package/src/extension.ts CHANGED
@@ -85,8 +85,11 @@ export function createQuotaExtension(options: QuotaExtensionOptions = {}) {
85
85
  if (typeof value === 'string') headers[name] = value;
86
86
  }
87
87
  const auth: ProviderAuth = { ...resolved.auth, headers };
88
- // A model-level custom endpoint must not be mistaken for an official account.
89
- if (ctx.model?.provider === provider && ctx.model.baseUrl) auth.baseUrl = ctx.model.baseUrl;
88
+ // Provider auth often has no endpoint. /usage also queries providers other
89
+ // than the selected model, so resolve their configured model URL as well.
90
+ const model = ctx.model?.provider === provider ? ctx.model
91
+ : ctx.modelRegistry.getAll().find(model => model.provider === provider);
92
+ if (model?.baseUrl) auth.baseUrl = model.baseUrl;
90
93
  return auth;
91
94
  };
92
95
 
@@ -177,11 +180,20 @@ export function createQuotaExtension(options: QuotaExtensionOptions = {}) {
177
180
  const getAuth = resolveAuth(ctx);
178
181
  const providers = supportedProviders(ctx);
179
182
  const states = await Promise.all(providers.map(provider => queryProvider(provider, getAuth)));
183
+ const ordered = states
184
+ .map((state, index) => ({ state, index }))
185
+ .sort((a, b) => {
186
+ const rank = (state: QuotaState) => state.kind === 'error' ? 3
187
+ : state.kind === 'ready' && state.snapshot.windows.length > 0 ? 1
188
+ : 2;
189
+ return rank(a.state) - rank(b.state) || a.index - b.index;
190
+ })
191
+ .map(item => item.state);
180
192
  // Never append via a stale Pi runtime, or label a replacement model's data
181
193
  // as the result of the original command. A newer command owns its result.
182
194
  if (!current || generation !== commandGeneration) return;
183
195
  const visibleStates = args.trim() === '--all'
184
- ? states : states.filter(state => state.kind === 'ready');
196
+ ? ordered : ordered.filter(state => state.kind === 'ready');
185
197
  const card = captureAllUsage(visibleStates);
186
198
  pi.appendEntry<UsageCard>(USAGE_ENTRY, card);
187
199
  // Custom entry renderers are TUI-only; RPC clients still get readable output.