@astrosheep/pi-quota 0.5.7 → 0.8.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 CHANGED
@@ -1,6 +1,6 @@
1
1
  # @astrosheep/pi-quota
2
2
 
3
- Compact quota status for Pi: Codex, Kimi, OpenCode Go and DeepSeek work out of the box; new-api and Sub2API use explicit provider bindings.
3
+ Compact quota status for Pi: Codex, Kimi, OpenCode Go and DeepSeek work out of the box; new-api, Sub2API and YesCode use explicit provider bindings.
4
4
 
5
5
  ```bash
6
6
  pi install npm:@astrosheep/pi-quota
@@ -30,7 +30,9 @@ Add bindings in global `~/.pi/agent/settings.json` under `quotaUsage` (not proje
30
30
  "providers": {
31
31
  "my-sub2api": { "adapter": "sub2api" },
32
32
  "my-new-api": { "adapter": "new-api", "quotaPerUnit": 500000, "currency": "USD" },
33
- "deepseek": { "adapter": "deepseek" }
33
+ "deepseek": { "adapter": "deepseek" },
34
+ "opencode-go-2": { "adapter": "opencode-go" },
35
+ "yes-vg": { "adapter": "yescode", "username": "you@example.com", "password": "..." }
34
36
  }
35
37
  }
36
38
  }
@@ -43,11 +45,17 @@ Use the **exact Pi provider ID**. Built-in providers and explicitly bound provid
43
45
  | `sub2api` | `{baseUrl}/usage` | Wallet, key quota, rate windows or subscription day/week/month windows; API-key Today/Lifetime actual spend |
44
46
  | `new-api` | Account PAT `/api/user/self`, falling back to key-native billing/token endpoints | Account balance + lifetime spend; finite key quota when explicitly granted; **not** a renewable window |
45
47
  | `deepseek` | Official `/user/balance` | Balance only; no invented spend |
48
+ | `opencode-go` | Official `https://opencode.ai/zen/go/v1/usage` | 5h/1w/30d windows, same as the built-in `opencode-go` adapter |
49
+ | `yescode` | `co.yes.vg` login → session → `/api/v1/auth/profile` + `/api/v1/user/balance` | Wallet (subscription + pay-as-you-go) plus daily/weekly subscription windows |
50
+
51
+ Bind `opencode-go` to a second OpenCode Go account: register it as its own Pi provider in `models.json` with its own `apiKey` (e.g. `opencode-go-2`), then bind that ID here. The built-in `opencode-go` binding is implicit and needs no entry; the reserved IDs `openai-codex`, `kimi-coding` and `opencode-go` cannot be rebound.
46
52
 
47
53
  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
54
 
49
55
  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. A finite key reports a quota bar only when `total_granted` is present and consistent with used plus remaining; otherwise it shows a balance and lifetime spend, without inventing a limit. Values in settings and server error bodies are never logged.
50
56
 
57
+ For YesCode, `username` is the account email and `password` the account password; both stay in global `settings.json`, which is the trade-off of this adapter. YesCode rejects API keys on account endpoints ("requires a user session or JWT"), so the adapter logs in via `/api/v1/auth/login`, caches the issued cookie/JWT in memory for ~23 hours, and re-logs in once on an auth failure. The model `cr_` key is never sent to account endpoints; it only pins the provider to the official `co.yes.vg` origin. Balances are summed from subscription and pay-as-you-go; daily/weekly windows appear only when the account actually has those caps.
58
+
51
59
  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
60
 
53
61
  ## Architecture
@@ -10,6 +10,11 @@
10
10
  },
11
11
  "my-sub2api": {
12
12
  "adapter": "sub2api"
13
+ },
14
+ "yes-vg": {
15
+ "adapter": "yescode",
16
+ "username": "you@example.com",
17
+ "password": "your-account-password"
13
18
  }
14
19
  }
15
20
  }
package/package.json CHANGED
@@ -1,11 +1,11 @@
1
1
  {
2
2
  "name": "@astrosheep/pi-quota",
3
- "version": "0.5.7",
3
+ "version": "0.8.0",
4
4
  "publishConfig": {
5
5
  "access": "public"
6
6
  },
7
7
  "type": "module",
8
- "description": "Composable quota bar with Codex, Kimi, OpenCode Go, new-api, DeepSeek and Sub2API adapters",
8
+ "description": "Composable quota bar with Codex, Kimi, OpenCode Go, new-api, DeepSeek, Sub2API and YesCode adapters",
9
9
  "keywords": [
10
10
  "pi-package"
11
11
  ],
package/src/bar/card.ts CHANGED
@@ -10,7 +10,8 @@ export const USAGE_ENTRY = 'quota-bar:usage:v1';
10
10
 
11
11
  export type UsageCard =
12
12
  | { version: 1; capturedAt: number; state: Exclude<QuotaState, { kind: 'loading' }> }
13
- | { version: 2; capturedAt: number; states: Exclude<QuotaState, { kind: 'loading' }>[] };
13
+ // currentProvider is optional so cards saved before the marker existed still render.
14
+ | { version: 2; capturedAt: number; states: Exclude<QuotaState, { kind: 'loading' }>[]; currentProvider?: string };
14
15
 
15
16
  export function captureUsage(state: QuotaState, now = Date.now()): UsageCard | undefined {
16
17
  if (state.kind === 'loading') return undefined;
@@ -18,8 +19,8 @@ export function captureUsage(state: QuotaState, now = Date.now()): UsageCard | u
18
19
  return { version: 1, capturedAt: now, state: structuredClone(state) };
19
20
  }
20
21
 
21
- export function captureAllUsage(states: readonly QuotaState[], now = Date.now()): UsageCard {
22
- return { version: 2, capturedAt: now, states: structuredClone(states.filter(
22
+ export function captureAllUsage(states: readonly QuotaState[], now = Date.now(), currentProvider?: string): UsageCard {
23
+ return { version: 2, capturedAt: now, currentProvider, states: structuredClone(states.filter(
23
24
  (state): state is Exclude<QuotaState, { kind: 'loading' }> => state.kind !== 'loading',
24
25
  )) };
25
26
  }
@@ -52,9 +53,13 @@ function padStartVisible(text: string, width: number): string {
52
53
  return ' '.repeat(Math.max(0, width - visibleWidth(text))) + text;
53
54
  }
54
55
 
55
- function gridRows(states: readonly Exclude<QuotaState, { kind: 'loading' }>[], now: number, barWidth: number): UsageRow[] {
56
+ function gridRows(states: readonly Exclude<QuotaState, { kind: 'loading' }>[], now: number, barWidth: number, currentProvider?: string): UsageRow[] {
56
57
  const rows: UsageRow[] = [];
57
58
  states.forEach((state, group) => {
59
+ // The session's own provider is marked so it stays findable in long lists.
60
+ // (Only ready/error states carry provider and label; hidden rows never render.)
61
+ const mark = (entry: { provider: string; label: string }) =>
62
+ currentProvider !== undefined && entry.provider === currentProvider ? `★ ${entry.label}` : entry.label;
58
63
  if (state.kind === 'ready') {
59
64
  const windows = state.snapshot.windows;
60
65
  if (windows.length > 0) {
@@ -63,9 +68,9 @@ function gridRows(states: readonly Exclude<QuotaState, { kind: 'loading' }>[], n
63
68
  const remaining = expired ? null : window.remainingPercent;
64
69
  rows.push({
65
70
  group, kind: 'quota',
66
- provider: index === 0 ? state.label : '',
71
+ provider: index === 0 ? mark(state) : '',
67
72
  metric: `${window.scope ? `${window.scope}/` : ''}${window.label}`,
68
- value: `${horizontalBar(remaining, barWidth)} ${formatPercent(remaining)}`,
73
+ value: `${horizontalBar(remaining, barWidth)} ${padStartVisible(formatPercent(remaining), 4)}`,
69
74
  secondary: `↺ ${window.resetAt === null ? '?' : duration(window.resetAt - now)}`,
70
75
  valueTone: remainingTone(remaining), metricTone: 'dim', secondaryTone: 'dim',
71
76
  });
@@ -75,7 +80,7 @@ function gridRows(states: readonly Exclude<QuotaState, { kind: 'loading' }>[], n
75
80
  if (state.snapshot.balance) {
76
81
  const balance = state.snapshot.balance;
77
82
  rows.push({
78
- group, kind: 'balance', provider: state.label, metric: '',
83
+ group, kind: 'balance', provider: mark(state), metric: '',
79
84
  value: remainingMoney(balance),
80
85
  secondary: state.snapshot.spend?.today !== undefined
81
86
  ? `today ${money(state.snapshot.spend.today, state.snapshot.spend.currency)}` : '',
@@ -88,7 +93,7 @@ function gridRows(states: readonly Exclude<QuotaState, { kind: 'loading' }>[], n
88
93
  const allowance = state.snapshot.allowance;
89
94
  const remaining = Math.max(0, Math.min(100, allowance.remaining / allowance.limit * 100));
90
95
  rows.push({
91
- group, kind: 'balance', provider: state.label, metric: '',
96
+ group, kind: 'balance', provider: mark(state), metric: '',
92
97
  value: `${horizontalBar(remaining, barWidth)} ${formatPercent(remaining)}`,
93
98
  secondary: `${money(allowance.used, allowance.currency)} used`,
94
99
  valueTone: remainingTone(remaining), metricTone: 'dim', secondaryTone: 'dim',
@@ -97,7 +102,7 @@ function gridRows(states: readonly Exclude<QuotaState, { kind: 'loading' }>[], n
97
102
  }
98
103
  }
99
104
  if (state.kind === 'error') {
100
- rows.push({ group, kind: 'error', provider: state.label, metric: '', value: errorText[state.code], secondary: '',
105
+ rows.push({ group, kind: 'error', provider: mark(state), metric: '', value: errorText[state.code], secondary: '',
101
106
  valueTone: 'warning', metricTone: 'dim', secondaryTone: 'dim' });
102
107
  }
103
108
  });
@@ -109,8 +114,9 @@ export function aggregateUsageLines(
109
114
  paint: Paint = (_tone, text) => text,
110
115
  now = Date.now(),
111
116
  barWidth = 12,
117
+ currentProvider?: string,
112
118
  ): string[] {
113
- const rows = gridRows(states, now, barWidth);
119
+ const rows = gridRows(states, now, barWidth, currentProvider);
114
120
  if (rows.length === 0) return ['No quota data available. Run /usage --all for details.'];
115
121
  const quotaRows = rows.filter(row => row.kind === 'quota');
116
122
  const otherRows = rows.filter(row => row.kind !== 'quota');
@@ -125,7 +131,7 @@ export function aggregateUsageLines(
125
131
  previousGroup = row.group;
126
132
  const provider = padEndVisible(row.provider, providerWidth);
127
133
  const metric = row.kind === 'quota' ? padEndVisible(row.metric, metricWidth) : '';
128
- const value = padStartVisible(row.value, row.kind === 'quota' ? quotaValueWidth : otherValueWidth);
134
+ const value = padEndVisible(row.value, row.kind === 'quota' ? quotaValueWidth : otherValueWidth);
129
135
  const line = paint('text', provider)
130
136
  + (metric ? paint(row.metricTone, ` ${metric}`) : '')
131
137
  + paint(row.valueTone, ` ${value}`)
@@ -137,7 +143,46 @@ export function aggregateUsageLines(
137
143
 
138
144
  export function usageLines(card: UsageCard, paint?: Paint, barWidth = 12): string[] {
139
145
  if (card.version === 1) return renderUsage(card.state, paint, card.capturedAt, barWidth);
140
- return aggregateUsageLines(card.states, paint, card.capturedAt, barWidth);
146
+ return aggregateUsageLines(card.states, paint, card.capturedAt, barWidth, card.currentProvider);
147
+ }
148
+
149
+ // Narrow-client layout (RPC notify bubbles, phones): provider name on its own
150
+ // header line, windows indented below. No wide grid columns, so monospace code
151
+ // blocks never wrap. Paseo's fence blocks have no horizontal scroll: RN Text
152
+ // wraps long lines and a wrapped grid is worse than no grid.
153
+ export function aggregateCompactLines(
154
+ states: readonly Exclude<QuotaState, { kind: 'loading' }>[],
155
+ paint: Paint = (_tone, text) => text,
156
+ now = Date.now(),
157
+ barWidth = 6,
158
+ currentProvider?: string,
159
+ ): string[] {
160
+ const rows = gridRows(states, now, barWidth, currentProvider);
161
+ if (rows.length === 0) return ['No quota data available. Run /usage --all for details.'];
162
+ const metricWidth = Math.max(0, ...rows.filter(row => row.kind === 'quota').map(row => visibleWidth(row.metric)));
163
+ const lines: string[] = [];
164
+ let previousGroup: number | undefined;
165
+ for (const row of rows) {
166
+ if (previousGroup !== undefined && row.group !== previousGroup) lines.push('');
167
+ previousGroup = row.group;
168
+ if (row.kind === 'quota') {
169
+ if (row.provider) lines.push(paint('text', row.provider));
170
+ const metric = padEndVisible(row.metric, metricWidth);
171
+ const line = ` ${paint(row.metricTone, metric)} ${paint(row.valueTone, row.value)}`
172
+ + (row.secondary ? ` ${paint(row.secondaryTone, row.secondary)}` : '');
173
+ lines.push(line.replace(/\s+$/u, ''));
174
+ } else {
175
+ const line = paint('text', row.provider) + ` ${paint(row.valueTone, row.value)}`
176
+ + (row.secondary ? ` ${paint(row.secondaryTone, row.secondary)}` : '');
177
+ lines.push(line.replace(/\s+$/u, ''));
178
+ }
179
+ }
180
+ return lines;
181
+ }
182
+
183
+ export function compactUsageLines(card: UsageCard, paint?: Paint, barWidth = 6): string[] {
184
+ if (card.version === 1) return renderUsage(card.state, paint, card.capturedAt, barWidth);
185
+ return aggregateCompactLines(card.states, paint, card.capturedAt, barWidth, card.currentProvider);
141
186
  }
142
187
 
143
188
  export function usageCardComponent(card: UsageCard | undefined, paint?: Paint): Component {
package/src/config.ts CHANGED
@@ -1,12 +1,16 @@
1
1
  import { createDeepSeekAdapter } from './query/deepseek.ts';
2
2
  import { createNewApiAdapter, validateNewApiOptions } from './query/new-api.ts';
3
+ import { createOpenCodeGoAdapter } from './query/opencode-go.ts';
3
4
  import { createSub2ApiAdapter } from './query/sub2api.ts';
5
+ import { createYesCodeAdapter, validateYesCodeOptions } from './query/yescode.ts';
4
6
  import type { NewApiOptions } from './query/new-api.ts';
7
+ import type { YesCodeOptions } from './query/yescode.ts';
5
8
  import type { QuotaAdapter } from './query/types.ts';
6
9
 
7
10
  export type ProviderQuotaConfig =
8
11
  | ({ adapter: 'new-api'; quotaPerUnit: number; currency: string } & Pick<NewApiOptions, 'dashboardAccessToken' | 'dashboardUserId'>)
9
- | { adapter: 'deepseek' | 'sub2api' };
12
+ | ({ adapter: 'yescode' } & YesCodeOptions)
13
+ | { adapter: 'deepseek' | 'sub2api' | 'opencode-go' };
10
14
  export interface QuotaConfig { providers: Record<string, ProviderQuotaConfig> }
11
15
 
12
16
  const DASHBOARD_KEYS = ['dashboardAccessToken', 'dashboardUserId'] as const;
@@ -28,7 +32,7 @@ function parseDashboardOptions(item: Record<string, unknown>): Pick<NewApiOption
28
32
  export class QuotaConfigError extends Error {
29
33
  constructor() {
30
34
  // Settings may accidentally contain secrets. Never echo content/values.
31
- super('Invalid settings.json quotaUsage. Use providers with adapter "new-api", "deepseek" or "sub2api".');
35
+ super('Invalid settings.json quotaUsage. Use providers with adapter "new-api", "deepseek", "sub2api", "opencode-go" or "yescode".');
32
36
  }
33
37
  }
34
38
 
@@ -49,9 +53,18 @@ export function parseQuotaConfig(value: unknown): QuotaConfig {
49
53
  providers[provider] = { adapter: 'deepseek' };
50
54
  continue;
51
55
  }
52
- if (item.adapter === 'sub2api') {
56
+ if (item.adapter === 'sub2api' || item.adapter === 'opencode-go') {
53
57
  if (Object.keys(item).length !== 1) throw new QuotaConfigError();
54
- providers[provider] = { adapter: 'sub2api' };
58
+ providers[provider] = { adapter: item.adapter };
59
+ continue;
60
+ }
61
+ if (item.adapter === 'yescode') {
62
+ if (Object.keys(item).some(key => !['adapter', 'username', 'password'].includes(key))
63
+ || typeof item.username !== 'string' || typeof item.password !== 'string') throw new QuotaConfigError();
64
+ try {
65
+ providers[provider] = { adapter: 'yescode',
66
+ ...validateYesCodeOptions({ username: item.username, password: item.password }) };
67
+ } catch { throw new QuotaConfigError(); }
55
68
  continue;
56
69
  }
57
70
  if (item.adapter !== 'new-api'
@@ -73,6 +86,8 @@ export function loadQuotaAdaptersFromSettings(settings: unknown): QuotaAdapter[]
73
86
  switch (options.adapter) {
74
87
  case 'deepseek': return createDeepSeekAdapter(provider);
75
88
  case 'sub2api': return createSub2ApiAdapter(provider);
89
+ case 'opencode-go': return createOpenCodeGoAdapter(provider);
90
+ case 'yescode': return createYesCodeAdapter(provider, options);
76
91
  case 'new-api': return createNewApiAdapter(provider, options);
77
92
  }
78
93
  });
package/src/extension.ts CHANGED
@@ -1,19 +1,22 @@
1
1
  import type { ExtensionAPI, ExtensionContext } from '@earendil-works/pi-coding-agent';
2
2
  import { renderFooter } from './bar/quota.ts';
3
- import { captureAllUsage, USAGE_ENTRY, usageCardComponent, usageLines } from './bar/card.ts';
3
+ import { captureAllUsage, USAGE_ENTRY, usageCardComponent, usageLines, compactUsageLines } from './bar/card.ts';
4
4
  import type { UsageCard } from './bar/card.ts';
5
5
  import { codexAdapter } from './query/codex.ts';
6
6
  import { createDeepSeekAdapter } from './query/deepseek.ts';
7
7
  import { kimiAdapter } from './query/kimi.ts';
8
8
  import { openCodeGoAdapter } from './query/opencode-go.ts';
9
9
  import { QuotaController } from './query/controller.ts';
10
- import { createJsonClient } from './query/http.ts';
10
+ import { createJsonClient, createPostJsonClient } from './query/http.ts';
11
11
  import { AdapterRegistry } from './query/registry.ts';
12
12
  import type { ProviderAuth, QuotaAdapter, QuotaState } from './query/types.ts';
13
13
 
14
14
  export const STATUS_EVENT = 'quota-bar:state:v1';
15
15
  const STATUS_KEY = 'quota-bar';
16
16
  const USAGE_WIDGET = 'quota-bar:usage';
17
+ const REFRESHING_TEXT = 'Refreshing…';
18
+ const SPINNER_FRAMES = ['⠋', '⠙', '⠹', '⠸', '⠼', '⠴', '⠦', '⠧', '⠇', '⠏'];
19
+ export const SPINNER_INTERVAL_MS = 80;
17
20
 
18
21
  export interface QuotaExtensionOptions {
19
22
  // Explicit adapters replace matching built-ins; duplicate explicit IDs are rejected.
@@ -39,6 +42,8 @@ export function createQuotaExtension(options: QuotaExtensionOptions = {}) {
39
42
  let lastStatus: string | undefined;
40
43
  // Invalidates pending commands on model/session replacement or another /usage.
41
44
  let commandGeneration = 0;
45
+ // Tracks whether /usage has already produced a snapshot in this runtime.
46
+ let hasUsageSnapshot = false;
42
47
 
43
48
  pi.registerEntryRenderer<UsageCard>(USAGE_ENTRY, (entry, _options, theme) => {
44
49
  const paint = process.env.NO_COLOR !== undefined ? undefined
@@ -68,9 +73,10 @@ export function createQuotaExtension(options: QuotaExtensionOptions = {}) {
68
73
  };
69
74
 
70
75
  const getJson = createJsonClient();
76
+ const postJson = createPostJsonClient();
71
77
 
72
78
  const controller = new QuotaController({
73
- registry, getJson, intervalMs: options.intervalMs, timeoutMs: options.timeoutMs,
79
+ registry, getJson, postJson, intervalMs: options.intervalMs, timeoutMs: options.timeoutMs,
74
80
  onState(state) {
75
81
  paintFooter();
76
82
  pi.events.emit(STATUS_EVENT, state); // Structured data, no credentials or ANSI.
@@ -123,7 +129,7 @@ export function createQuotaExtension(options: QuotaExtensionOptions = {}) {
123
129
  return controller.state;
124
130
  }
125
131
  const scratch = new QuotaController({
126
- registry, getJson, intervalMs: options.intervalMs, timeoutMs: options.timeoutMs,
132
+ registry, getJson, postJson, intervalMs: options.intervalMs, timeoutMs: options.timeoutMs,
127
133
  onState() {},
128
134
  });
129
135
  scratch.select(provider);
@@ -175,18 +181,50 @@ export function createQuotaExtension(options: QuotaExtensionOptions = {}) {
175
181
  if (!ctx.hasUI) return;
176
182
  bind(ctx);
177
183
  const generation = ++commandGeneration;
178
- if (ctx.mode === 'tui') ctx.ui.setWidget(USAGE_WIDGET, ['Quota …']);
184
+ let spinner: ReturnType<typeof setInterval> | undefined;
185
+ if (ctx.mode === 'tui') {
186
+ // Previous snapshots are already rendered in the transcript. Only add the
187
+ // spinner here, otherwise refreshing duplicates the last grid in a widget.
188
+ const label = hasUsageSnapshot ? REFRESHING_TEXT : 'Fetching quota…';
189
+ let frame = 0;
190
+ const paintSpinner = () => {
191
+ if (!current || generation !== commandGeneration) return false;
192
+ const spinnerLine = `${SPINNER_FRAMES[frame]} ${label}`;
193
+ ctx.ui.setWidget(USAGE_WIDGET, (_tui, theme) => {
194
+ const paint = process.env.NO_COLOR !== undefined ? undefined
195
+ : (tone: Parameters<typeof theme.fg>[0], text: string) => theme.fg(tone, text);
196
+ return {
197
+ render(width: number): string[] {
198
+ if (width < spinnerLine.length) return [];
199
+ return [paint ? paint('dim', spinnerLine) : spinnerLine];
200
+ },
201
+ invalidate() {},
202
+ };
203
+ });
204
+ return true;
205
+ };
206
+ paintSpinner();
207
+ spinner = setInterval(() => {
208
+ frame = (frame + 1) % SPINNER_FRAMES.length;
209
+ if (!paintSpinner() && spinner) { clearInterval(spinner); spinner = undefined; }
210
+ }, SPINNER_INTERVAL_MS);
211
+ spinner.unref?.();
212
+ }
179
213
  try {
180
214
  const getAuth = resolveAuth(ctx);
181
215
  const providers = supportedProviders(ctx);
182
216
  const states = await Promise.all(providers.map(provider => queryProvider(provider, getAuth)));
217
+ // The session's own provider always leads, whatever its health looks like.
218
+ const currentProvider = ctx.model?.provider;
183
219
  const ordered = states
184
220
  .map((state, index) => ({ state, index }))
185
221
  .sort((a, b) => {
222
+ const pinned = (item: { state: QuotaState }) =>
223
+ currentProvider !== undefined && 'provider' in item.state && item.state.provider === currentProvider ? 0 : 1;
186
224
  const rank = (state: QuotaState) => state.kind === 'error' ? 3
187
225
  : state.kind === 'ready' && state.snapshot.windows.length > 0 ? 1
188
226
  : 2;
189
- return rank(a.state) - rank(b.state) || a.index - b.index;
227
+ return pinned(a) - pinned(b) || rank(a.state) - rank(b.state) || a.index - b.index;
190
228
  })
191
229
  .map(item => item.state);
192
230
  // Never append via a stale Pi runtime, or label a replacement model's data
@@ -194,13 +232,17 @@ export function createQuotaExtension(options: QuotaExtensionOptions = {}) {
194
232
  if (!current || generation !== commandGeneration) return;
195
233
  const visibleStates = args.trim() === '--all'
196
234
  ? ordered : ordered.filter(state => state.kind === 'ready');
197
- const card = captureAllUsage(visibleStates);
235
+ const card = captureAllUsage(visibleStates, Date.now(), currentProvider);
236
+ hasUsageSnapshot = true;
198
237
  pi.appendEntry<UsageCard>(USAGE_ENTRY, card);
199
238
  // Custom entry renderers are TUI-only; RPC clients still get readable output.
239
+ // Fenced so clients that render markdown use a monospace font; compact
240
+ // layout because narrow clients (phones) wrap long grid lines anyway.
200
241
  if (ctx.mode !== 'tui') {
201
- ctx.ui.notify(usageLines(card, undefined).join('\n'), 'info');
242
+ ctx.ui.notify(`\`\`\`\n${compactUsageLines(card, undefined).join('\n')}\n\`\`\``, 'info');
202
243
  }
203
244
  } finally {
245
+ if (spinner) { clearInterval(spinner); spinner = undefined; }
204
246
  // An older request must not clear a newer command's loading indicator.
205
247
  if (current && generation === commandGeneration && ctx.mode === 'tui') {
206
248
  ctx.ui.setWidget(USAGE_WIDGET, undefined);
@@ -6,6 +6,7 @@ type AuthResolver = QueryContext['getAuth'];
6
6
  interface ControllerOptions {
7
7
  registry: AdapterRegistry;
8
8
  getJson: QueryContext['getJson'];
9
+ postJson: QueryContext['postJson'];
9
10
  onState(state: QuotaState): void;
10
11
  now?: () => number;
11
12
  intervalMs?: number;
@@ -89,7 +90,7 @@ export class QuotaController {
89
90
  abort.signal.throwIfAborted();
90
91
  const result = await abortable(adapter.query({
91
92
  provider: adapter.provider, signal: abort.signal, now: this.now,
92
- getAuth, getJson: this.options.getJson,
93
+ getAuth, getJson: this.options.getJson, postJson: this.options.postJson,
93
94
  }), abort.signal);
94
95
  if (!active()) return;
95
96
  const snapshot = validateSnapshot(result);
package/src/query/http.ts CHANGED
@@ -52,6 +52,60 @@ export function createJsonClient(fetcher: typeof fetch = globalThis.fetch): Quer
52
52
  };
53
53
  }
54
54
 
55
+ export function createPostJsonClient(fetcher: typeof fetch = globalThis.fetch): QueryContext['postJson'] {
56
+ return async (url, body, headers, signal) => {
57
+ try {
58
+ const response = await fetcher(url, {
59
+ method: 'POST',
60
+ headers: { Accept: 'application/json', 'Content-Type': 'application/json', ...headers },
61
+ body: JSON.stringify(body),
62
+ signal, redirect: 'error', // Never forward credentials to a redirected endpoint.
63
+ });
64
+ if (!response.ok) {
65
+ const retry = response.headers.get('retry-after');
66
+ const seconds = retry && /^\d+(\.\d+)?$/.test(retry) ? Number(retry) : NaN;
67
+ const delay = Number.isFinite(seconds) ? seconds * 1000
68
+ : retry ? Date.parse(retry) - Date.now() : NaN;
69
+ await response.body?.cancel();
70
+ if (response.status === 401 || response.status === 403) throw new QuotaError('auth');
71
+ if (response.status === 429) {
72
+ throw new QuotaError('rate-limit', Number.isFinite(delay) ? Math.max(0, delay) : undefined);
73
+ }
74
+ throw new QuotaError('http');
75
+ }
76
+ const setCookie = typeof response.headers.getSetCookie === 'function'
77
+ ? response.headers.getSetCookie() : [];
78
+ const reader = response.body?.getReader();
79
+ if (!reader) throw new QuotaError('schema');
80
+ const chunks: Uint8Array[] = [];
81
+ let size = 0;
82
+ try {
83
+ while (true) {
84
+ const part = await reader.read();
85
+ if (part.done) break;
86
+ size += part.value.length;
87
+ if (size > 256 * 1024) {
88
+ await reader.cancel();
89
+ throw new QuotaError('schema');
90
+ }
91
+ chunks.push(part.value);
92
+ }
93
+ } finally {
94
+ reader.releaseLock();
95
+ }
96
+ try {
97
+ return { body: JSON.parse(Buffer.concat(chunks).toString('utf8')) as unknown, setCookie };
98
+ } catch {
99
+ throw new QuotaError('schema');
100
+ }
101
+ } catch (error) {
102
+ if (error instanceof QuotaError) throw error;
103
+ if (signal.aborted) throw signal.reason;
104
+ throw new QuotaError('network');
105
+ }
106
+ };
107
+ }
108
+
55
109
  export function header(auth: ProviderAuth, name: string): string | undefined {
56
110
  return Object.entries(auth.headers ?? {}).find(([key]) => key.toLowerCase() === name.toLowerCase())?.[1];
57
111
  }
@@ -30,13 +30,20 @@ export function parseOpenCodeGo(payload: unknown): QuotaWindow[] {
30
30
  return windows;
31
31
  }
32
32
 
33
- export const openCodeGoAdapter: QuotaAdapter = {
34
- provider: 'opencode-go', label: 'OpenCode Go',
35
- async query(context) {
36
- const auth = await officialAuth(context, 'https://opencode.ai');
37
- const data = await context.getJson('https://opencode.ai/zen/go/v1/usage', {
38
- Authorization: `Bearer ${bearer(auth)}`,
39
- }, context.signal);
40
- return { windows: parseOpenCodeGo(data), fetchedAt: context.now() };
41
- },
42
- };
33
+ // A second OpenCode Go account is just another Pi provider ID (configured in
34
+ // models.json with its own apiKey); auth and endpoint stay official regardless.
35
+ export function createOpenCodeGoAdapter(provider: string): QuotaAdapter {
36
+ return {
37
+ provider,
38
+ label: provider === 'opencode-go' ? 'OpenCode Go' : `OpenCode Go (${provider})`,
39
+ async query(context) {
40
+ const auth = await officialAuth(context, 'https://opencode.ai');
41
+ const data = await context.getJson('https://opencode.ai/zen/go/v1/usage', {
42
+ Authorization: `Bearer ${bearer(auth)}`,
43
+ }, context.signal);
44
+ return { windows: parseOpenCodeGo(data), fetchedAt: context.now() };
45
+ },
46
+ };
47
+ }
48
+
49
+ export const openCodeGoAdapter = createOpenCodeGoAdapter('opencode-go');
@@ -42,12 +42,18 @@ export interface ProviderAuth {
42
42
  baseUrl?: string;
43
43
  }
44
44
 
45
+ export interface PostJsonResult {
46
+ body: unknown;
47
+ setCookie: string[];
48
+ }
49
+
45
50
  export interface QueryContext {
46
51
  provider: string;
47
52
  signal: AbortSignal;
48
53
  now(): number;
49
54
  getAuth(provider: string): Promise<ProviderAuth | undefined>;
50
55
  getJson(url: string, headers: Record<string, string>, signal: AbortSignal): Promise<unknown>;
56
+ postJson(url: string, body: unknown, headers: Record<string, string>, signal: AbortSignal): Promise<PostJsonResult>;
51
57
  }
52
58
 
53
59
  export interface QuotaAdapter {
@@ -0,0 +1,176 @@
1
+ import { officialAuth } from './http.ts';
2
+ import { numeric, object, percent, timestamp, windowLabel } from './parse.ts';
3
+ import { QuotaError } from './types.ts';
4
+ import type { PostJsonResult, QuotaAdapter, QuotaSnapshot, QuotaWindow } from './types.ts';
5
+
6
+ export const YESCODE_ORIGIN = 'https://co.yes.vg';
7
+
8
+ // co.yes.vg rejects API keys on account endpoints ("requires a user session or
9
+ // JWT"). Login is the only way in; the issued cookie/JWT lives ~24h, so cache
10
+ // it in memory and re-login on auth failure. The model cr_ key is never sent
11
+ // to account endpoints — officialAuth only pins the origin and proves the
12
+ // provider exists in Pi auth.
13
+ export interface YesCodeOptions {
14
+ username: string; // Login takes the account username (email also works).
15
+ password: string;
16
+ }
17
+
18
+ interface YesCodeSession {
19
+ headers: Record<string, string>;
20
+ expiresAt: number;
21
+ }
22
+
23
+ const SESSION_TTL_MS = 23 * 3600 * 1000; // observed cookie Max-Age is 24h
24
+
25
+ export function validateYesCodeOptions(options: YesCodeOptions): YesCodeOptions {
26
+ const { username, password } = options;
27
+ if (typeof username !== 'string' || username.trim() === '') {
28
+ throw new Error('username must be a non-empty string');
29
+ }
30
+ if (typeof password !== 'string' || password === '') throw new Error('password must be a non-empty string');
31
+ return { username, password };
32
+ }
33
+
34
+ function cookieValue(setCookie: readonly string[], name: string): string | undefined {
35
+ for (const entry of setCookie) {
36
+ if (!entry.startsWith(`${name}=`)) continue;
37
+ const value = entry.slice(name.length + 1).split(';', 1)[0]!;
38
+ if (value !== '' && !/[\s;]/.test(value)) return value;
39
+ }
40
+ return undefined;
41
+ }
42
+
43
+ // POST /api/v1/auth/login returns {token} and sets yescode_auth/yescode_csrf.
44
+ // The cookie pair is what the web app itself uses; the bare JWT is the
45
+ // documented UserToken fallback (Authorization: Bearer).
46
+ export function parseYesCodeLogin(result: PostJsonResult, now: number): YesCodeSession {
47
+ const auth = cookieValue(result.setCookie, 'yescode_auth');
48
+ if (auth) {
49
+ const csrf = cookieValue(result.setCookie, 'yescode_csrf');
50
+ return { headers: { Cookie: csrf ? `yescode_auth=${auth}; yescode_csrf=${csrf}` : `yescode_auth=${auth}` },
51
+ expiresAt: now + SESSION_TTL_MS };
52
+ }
53
+ const token = object(result.body).token;
54
+ if (typeof token !== 'string' || token.trim() === '' || /[\s;]/.test(token)) throw new QuotaError('schema');
55
+ return { headers: { Authorization: `Bearer ${token}` }, expiresAt: now + SESSION_TTL_MS };
56
+ }
57
+
58
+ // Balances may legitimately be negative (debt); limits/spends may not.
59
+ function optionalAmount(value: unknown): number | undefined {
60
+ if (value === undefined || value === null) return undefined;
61
+ const result = numeric(value);
62
+ if (result === null) throw new QuotaError('schema');
63
+ return result;
64
+ }
65
+
66
+ function nonNegative(value: unknown): number | undefined {
67
+ const result = optionalAmount(value);
68
+ if (result !== undefined && result < 0) throw new QuotaError('schema');
69
+ return result;
70
+ }
71
+
72
+ function limitWindow(id: string, label: string, durationSeconds: number,
73
+ limitRaw: unknown, usedRaw: unknown, resetAt: number | null): QuotaWindow[] {
74
+ const used = nonNegative(usedRaw); // validate present-but-corrupt spends even without a cap
75
+ const limit = nonNegative(limitRaw);
76
+ if (limit === undefined || limit === 0) return []; // no configured cap: nothing to draw
77
+ if (used === undefined) {
78
+ return [{ id, label, durationSeconds, remainingPercent: null, resetAt }];
79
+ }
80
+ const remaining = Math.max(0, limit - used);
81
+ return [{ id, label, durationSeconds, remainingPercent: percent(remaining / limit * 100), resetAt,
82
+ amounts: { currency: 'USD', limit, used, remaining } }];
83
+ }
84
+
85
+ // GET /api/v1/user/balance (optional; some accounts may lack it) merged with
86
+ // GET /api/v1/auth/profile (required). The live response carries a generic
87
+ // spending_limits array (scope/spent/limit/remaining/resets_at/window_seconds)
88
+ // plus top-level weekly_* and monthly_* fields; profile is the last fallback.
89
+ function spendingLimitWindows(balanceData: Record<string, unknown>): QuotaWindow[] | null {
90
+ if (balanceData.spending_limits === undefined) return null;
91
+ if (!Array.isArray(balanceData.spending_limits)) throw new QuotaError('schema');
92
+ const windows = balanceData.spending_limits.flatMap((entry): QuotaWindow[] => {
93
+ const item = object(entry);
94
+ const limit = nonNegative(item.limit);
95
+ const spent = nonNegative(item.spent);
96
+ const remaining = nonNegative(item.remaining);
97
+ const windowSeconds = numeric(item.window_seconds);
98
+ const resetAt = timestamp(item.resets_at);
99
+ if (typeof item.key !== 'string' || item.key === ''
100
+ || windowSeconds === null || !Number.isSafeInteger(windowSeconds) || windowSeconds <= 0) {
101
+ throw new QuotaError('schema');
102
+ }
103
+ if (limit === undefined || limit === 0) return []; // uncapped: nothing to draw
104
+ if (spent === undefined) {
105
+ return [{ id: `spending:${item.key}`, label: windowLabel(windowSeconds), durationSeconds: windowSeconds,
106
+ remainingPercent: null, resetAt }];
107
+ }
108
+ const left = remaining ?? Math.max(0, limit - spent);
109
+ return [{ id: `spending:${item.key}`, label: windowLabel(windowSeconds), durationSeconds: windowSeconds,
110
+ remainingPercent: percent(left / limit * 100), resetAt,
111
+ amounts: { currency: 'USD', limit, used: spent, remaining: left } }];
112
+ });
113
+ // Footer/detail convention is ascending duration (5h → 1w → 30d).
114
+ return windows.sort((a, b) => a.durationSeconds - b.durationSeconds);
115
+ }
116
+
117
+ export function parseYesCodeAccount(balancePayload: unknown, profilePayload: unknown): Omit<QuotaSnapshot, 'fetchedAt'> {
118
+ const balanceData = object(balancePayload);
119
+ const profile = object(profilePayload);
120
+ const plan = object(profile.subscription_plan);
121
+ const subscription = optionalAmount(balanceData.subscription_balance ?? profile.subscription_balance);
122
+ const paygo = optionalAmount(balanceData.pay_as_you_go_balance ?? profile.pay_as_you_go_balance);
123
+ if (subscription === undefined && paygo === undefined) throw new QuotaError('schema');
124
+
125
+ const weekStart = timestamp(profile.last_week_reset);
126
+ const monthStart = timestamp(profile.last_month_reset);
127
+ const windows = spendingLimitWindows(balanceData) ?? [
128
+ ...limitWindow('daily', '1d', 86400, balanceData.daily_limit, balanceData.daily_spent,
129
+ timestamp(balanceData.daily_resets_at ?? balanceData.daily_reset_at)),
130
+ ...limitWindow('weekly', '1w', 604800, balanceData.weekly_limit ?? plan.weekly_limit,
131
+ balanceData.weekly_spent_balance ?? balanceData.weekly_spent ?? profile.current_week_spend,
132
+ timestamp(balanceData.weekly_resets_at ?? balanceData.weekly_reset_at)
133
+ ?? (weekStart === null ? null : weekStart + 604800000)),
134
+ ...limitWindow('monthly', '30d', 2592000, balanceData.monthly_spend_limit ?? plan.monthly_spend_limit,
135
+ balanceData.monthly_spent_balance ?? balanceData.monthly_spent ?? profile.current_month_spend,
136
+ timestamp(balanceData.monthly_resets_at ?? balanceData.monthly_reset_at)
137
+ ?? (monthStart === null ? null : monthStart + 2592000000)),
138
+ ];
139
+ return { windows, balance: { currency: 'USD', remaining: (subscription ?? 0) + (paygo ?? 0) } };
140
+ }
141
+
142
+ export function createYesCodeAdapter(provider: string, options: YesCodeOptions): QuotaAdapter {
143
+ const credentials = validateYesCodeOptions(options);
144
+ let session: YesCodeSession | undefined;
145
+ return {
146
+ provider, label: provider,
147
+ async query(context) {
148
+ await officialAuth(context, YESCODE_ORIGIN);
149
+ const login = async (): Promise<YesCodeSession> => parseYesCodeLogin(await context.postJson(
150
+ `${YESCODE_ORIGIN}/api/v1/auth/login`,
151
+ { username: credentials.username, password: credentials.password },
152
+ { Origin: YESCODE_ORIGIN, Referer: `${YESCODE_ORIGIN}/login` }, context.signal), context.now());
153
+ const fetchAccount = async (current: YesCodeSession): Promise<Omit<QuotaSnapshot, 'fetchedAt'>> => {
154
+ const [profile, balance] = await Promise.all([
155
+ context.getJson(`${YESCODE_ORIGIN}/api/v1/auth/profile`, current.headers, context.signal),
156
+ // Optional endpoint: a plain 404 must not fail the whole query.
157
+ context.getJson(`${YESCODE_ORIGIN}/api/v1/user/balance`, current.headers, context.signal)
158
+ .catch((error: unknown) => {
159
+ if (error instanceof QuotaError && error.code === 'http') return undefined;
160
+ throw error;
161
+ }),
162
+ ]);
163
+ return parseYesCodeAccount(balance, profile);
164
+ };
165
+ if (!session || context.now() >= session.expiresAt) session = await login();
166
+ try {
167
+ return { ...await fetchAccount(session), fetchedAt: context.now() };
168
+ } catch (error) {
169
+ // Session died early (24h cookie or revocation): re-login once, then give up.
170
+ if (!(error instanceof QuotaError) || error.code !== 'auth') throw error;
171
+ session = await login();
172
+ return { ...await fetchAccount(session), fetchedAt: context.now() };
173
+ }
174
+ },
175
+ };
176
+ }