@astrosheep/pi-quota 0.5.6 → 0.7.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 +10 -2
- package/examples/settings.fragment.json +5 -0
- package/package.json +2 -2
- package/src/bar/card.ts +119 -9
- package/src/config.ts +19 -4
- package/src/extension.ts +50 -5
- package/src/query/controller.ts +2 -1
- package/src/query/http.ts +54 -0
- package/src/query/opencode-go.ts +17 -10
- package/src/query/types.ts +6 -0
- package/src/query/yescode.ts +176 -0
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
|
|
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
|
package/package.json
CHANGED
|
@@ -1,11 +1,11 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@astrosheep/pi-quota",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.7.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
|
|
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
|
@@ -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
|
|
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
|
-
|
|
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)} ${padStartVisible(formatPercent(remaining), 4)}`,
|
|
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 = padEndVisible(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
|
-
|
|
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 ?
|
|
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/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: '
|
|
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 "
|
|
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:
|
|
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
|
@@ -7,13 +7,16 @@ 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,23 +181,62 @@ export function createQuotaExtension(options: QuotaExtensionOptions = {}) {
|
|
|
175
181
|
if (!ctx.hasUI) return;
|
|
176
182
|
bind(ctx);
|
|
177
183
|
const generation = ++commandGeneration;
|
|
178
|
-
|
|
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
|
+
const ordered = states
|
|
218
|
+
.map((state, index) => ({ state, index }))
|
|
219
|
+
.sort((a, b) => {
|
|
220
|
+
const rank = (state: QuotaState) => state.kind === 'error' ? 3
|
|
221
|
+
: state.kind === 'ready' && state.snapshot.windows.length > 0 ? 1
|
|
222
|
+
: 2;
|
|
223
|
+
return rank(a.state) - rank(b.state) || a.index - b.index;
|
|
224
|
+
})
|
|
225
|
+
.map(item => item.state);
|
|
183
226
|
// Never append via a stale Pi runtime, or label a replacement model's data
|
|
184
227
|
// as the result of the original command. A newer command owns its result.
|
|
185
228
|
if (!current || generation !== commandGeneration) return;
|
|
186
229
|
const visibleStates = args.trim() === '--all'
|
|
187
|
-
?
|
|
230
|
+
? ordered : ordered.filter(state => state.kind === 'ready');
|
|
188
231
|
const card = captureAllUsage(visibleStates);
|
|
232
|
+
hasUsageSnapshot = true;
|
|
189
233
|
pi.appendEntry<UsageCard>(USAGE_ENTRY, card);
|
|
190
234
|
// Custom entry renderers are TUI-only; RPC clients still get readable output.
|
|
191
235
|
if (ctx.mode !== 'tui') {
|
|
192
236
|
ctx.ui.notify(usageLines(card, undefined).join('\n'), 'info');
|
|
193
237
|
}
|
|
194
238
|
} finally {
|
|
239
|
+
if (spinner) { clearInterval(spinner); spinner = undefined; }
|
|
195
240
|
// An older request must not clear a newer command's loading indicator.
|
|
196
241
|
if (current && generation === commandGeneration && ctx.mode === 'tui') {
|
|
197
242
|
ctx.ui.setWidget(USAGE_WIDGET, undefined);
|
package/src/query/controller.ts
CHANGED
|
@@ -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
|
}
|
package/src/query/opencode-go.ts
CHANGED
|
@@ -30,13 +30,20 @@ export function parseOpenCodeGo(payload: unknown): QuotaWindow[] {
|
|
|
30
30
|
return windows;
|
|
31
31
|
}
|
|
32
32
|
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
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');
|
package/src/query/types.ts
CHANGED
|
@@ -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
|
+
}
|