@astrosheep/pi-quota 0.5.7 → 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 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.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 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
@@ -65,7 +65,7 @@ function gridRows(states: readonly Exclude<QuotaState, { kind: 'loading' }>[], n
65
65
  group, kind: 'quota',
66
66
  provider: index === 0 ? state.label : '',
67
67
  metric: `${window.scope ? `${window.scope}/` : ''}${window.label}`,
68
- value: `${horizontalBar(remaining, barWidth)} ${formatPercent(remaining)}`,
68
+ value: `${horizontalBar(remaining, barWidth)} ${padStartVisible(formatPercent(remaining), 4)}`,
69
69
  secondary: `↺ ${window.resetAt === null ? '?' : duration(window.resetAt - now)}`,
70
70
  valueTone: remainingTone(remaining), metricTone: 'dim', secondaryTone: 'dim',
71
71
  });
@@ -125,7 +125,7 @@ export function aggregateUsageLines(
125
125
  previousGroup = row.group;
126
126
  const provider = padEndVisible(row.provider, providerWidth);
127
127
  const metric = row.kind === 'quota' ? padEndVisible(row.metric, metricWidth) : '';
128
- const value = padStartVisible(row.value, row.kind === 'quota' ? quotaValueWidth : otherValueWidth);
128
+ const value = padEndVisible(row.value, row.kind === 'quota' ? quotaValueWidth : otherValueWidth);
129
129
  const line = paint('text', provider)
130
130
  + (metric ? paint(row.metricTone, ` ${metric}`) : '')
131
131
  + paint(row.valueTone, ` ${value}`)
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
@@ -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,7 +181,35 @@ 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);
@@ -195,12 +229,14 @@ export function createQuotaExtension(options: QuotaExtensionOptions = {}) {
195
229
  const visibleStates = args.trim() === '--all'
196
230
  ? ordered : ordered.filter(state => state.kind === 'ready');
197
231
  const card = captureAllUsage(visibleStates);
232
+ hasUsageSnapshot = true;
198
233
  pi.appendEntry<UsageCard>(USAGE_ENTRY, card);
199
234
  // Custom entry renderers are TUI-only; RPC clients still get readable output.
200
235
  if (ctx.mode !== 'tui') {
201
236
  ctx.ui.notify(usageLines(card, undefined).join('\n'), 'info');
202
237
  }
203
238
  } finally {
239
+ if (spinner) { clearInterval(spinner); spinner = undefined; }
204
240
  // An older request must not clear a newer command's loading indicator.
205
241
  if (current && generation === commandGeneration && ctx.mode === 'tui') {
206
242
  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
+ }