@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 +10 -2
- package/examples/settings.fragment.json +5 -0
- package/package.json +2 -2
- package/src/bar/card.ts +57 -12
- package/src/config.ts +19 -4
- package/src/extension.ts +50 -8
- 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.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
|
|
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
|
-
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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 =
|
|
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: '
|
|
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
|
@@ -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
|
-
|
|
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(
|
|
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);
|
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
|
+
}
|