@astrosheep/pi-quota 0.10.0 → 0.12.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
@@ -33,7 +33,8 @@ Add bindings in global `~/.pi/agent/settings.json` under `quotaUsage` (not proje
33
33
  "deepseek": { "adapter": "deepseek" },
34
34
  "opencode-go-2": { "adapter": "opencode-go" },
35
35
  "yes-vg": { "adapter": "yescode", "username": "you@example.com", "password": "..." },
36
- "my-script": { "adapter": "exec", "command": ["my-quota-cli", "--json"], "label": "My Script" }
36
+ "my-script": { "adapter": "exec", "command": ["my-quota-cli", "--json"], "label": "My Script" },
37
+ "my-alias": { "adapter": "alias", "of": "my-sub2api" }
37
38
  }
38
39
  }
39
40
  }
@@ -49,6 +50,7 @@ Use the **exact Pi provider ID**. Built-in providers and explicitly bound provid
49
50
  | `opencode-go` | Official `https://opencode.ai/zen/go/v1/usage` | 5h/1w/30d windows, same as the built-in `opencode-go` adapter |
50
51
  | `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 |
51
52
  | `exec` | Runs `command` as argv (no shell), parses stdout as a `QuotaSnapshot` JSON | Whatever the command reports; `fetchedAt` is stamped by the adapter, never by the command |
53
+ | `alias` | No endpoint — delegates to the `of` binding (built-in or explicit) | Shares the target account's quota; one folded row in `/usage` |
52
54
 
53
55
  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.
54
56
 
@@ -60,6 +62,8 @@ For YesCode, `username` is the account email and `password` the account password
60
62
 
61
63
  Exec runs the configured argv without a shell and parses stdout as a `QuotaSnapshot` JSON object: `windows`, optional `balance`, `allowance` and `spend` — the same schema every adapter produces, enforced by the controller. `fetchedAt` is stamped by the adapter; a value from the command is ignored. The controller bounds every query (10s default) and kills the child on timeout; non-zero exits, unreadable JSON and output over 256KB become errors, and command output is never logged. The child inherits Pi's environment, so keep secrets in env vars, not in `quotaUsage`.
62
64
 
65
+ Alias binds a second provider ID to the same account: `"of"` names the real binding, which may be a built-in or an explicit adapter. The alias never queries on its own — a session on the alias shows the target's quota in the footer, queried under the target's identity and credentials, and `/usage` folds the alias into the target's single row with the session pin (and ★) following the fold. Aliases are one hop: `of` must not itself be an alias, and an alias whose target is not configured fails visibly instead of vanishing.
66
+
63
67
  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.
64
68
 
65
69
  ## Architecture
@@ -77,6 +81,28 @@ Pi model/auth → adapter → QuotaSnapshot → controller → footer / /usage /
77
81
 
78
82
  The extension publishes structured state on `quota-bar:state:v1`. Use `createQuotaExtension({ footer: false })` to consume it without this extension's footer. Normal queries are throttled, cancellable, timeout-bounded and backed off after errors. `/usage` forces a fresh query for every configured supported provider, then omits failed results from its default view. Switching provider discards old footer values immediately.
79
83
 
84
+ ## Standalone query layer
85
+
86
+ Non-Pi consumers (bridges, plugins, scripts) can import the query layer without pulling in the Pi extension or its peer dependencies:
87
+
88
+ ```ts
89
+ import {
90
+ AdapterRegistry, codexAdapter, createJsonClient, createPostJsonClient,
91
+ } from '@astrosheep/pi-quota/query';
92
+
93
+ const registry = new AdapterRegistry([codexAdapter]);
94
+ const snapshot = await registry.get('openai-codex')!.query({
95
+ provider: 'openai-codex',
96
+ signal: AbortSignal.timeout(10_000),
97
+ now: () => Date.now(),
98
+ getAuth: myAuthResolver, // you supply credentials
99
+ getJson: createJsonClient(),
100
+ postJson: createPostJsonClient(),
101
+ });
102
+ ```
103
+
104
+ The subpath exports every adapter, the registry/controller, option validators, HTTP/exec plumbing, and all query types. Peer dependencies are marked optional, so installing for the query layer alone stays light.
105
+
80
106
  ## Development
81
107
 
82
108
  Requires Node ≥22.18 and a compatible Pi installation.
package/package.json CHANGED
@@ -1,10 +1,15 @@
1
1
  {
2
2
  "name": "@astrosheep/pi-quota",
3
- "version": "0.10.0",
3
+ "version": "0.12.0",
4
4
  "publishConfig": {
5
5
  "access": "public"
6
6
  },
7
7
  "type": "module",
8
+ "exports": {
9
+ ".": "./src/index.ts",
10
+ "./query": "./src/query/index.ts",
11
+ "./package.json": "./package.json"
12
+ },
8
13
  "description": "Composable quota bar with Codex, Kimi, OpenCode Go, new-api, DeepSeek, Sub2API and YesCode adapters",
9
14
  "keywords": [
10
15
  "pi-package"
@@ -31,6 +36,10 @@
31
36
  "@earendil-works/pi-coding-agent": "*",
32
37
  "@earendil-works/pi-tui": "*"
33
38
  },
39
+ "peerDependenciesMeta": {
40
+ "@earendil-works/pi-coding-agent": { "optional": true },
41
+ "@earendil-works/pi-tui": { "optional": true }
42
+ },
34
43
  "devDependencies": {
35
44
  "@types/node": "^22.0.0",
36
45
  "typescript": "^6.0.3"
package/src/bar/quota.ts CHANGED
@@ -3,6 +3,7 @@ import { duration, formatPercent, horizontalBar, plain, remainingTone, renderBar
3
3
  import type { BarElement, Paint, Span } from './bar.ts';
4
4
  import { allowanceLines, allowanceSpans, balanceLines, balanceSpans, money, spendLines } from './balance.ts';
5
5
  import type { QuotaErrorCode, QuotaState, QuotaWindow } from '../query/types.ts';
6
+ import type { FooterWindows } from '../query/settings.ts';
6
7
 
7
8
  const errorText: Record<QuotaErrorCode, string> = {
8
9
  auth: 'Sign in with /login',
@@ -25,7 +26,7 @@ function windowSpans(window: QuotaWindow, now: number): Span[] {
25
26
  }
26
27
 
27
28
  // Footer window cap: a positive count, or 'all' for every shared window.
28
- export type FooterWindows = number | 'all';
29
+ export type { FooterWindows };
29
30
 
30
31
  export function quotaElement(state: QuotaState, now = Date.now(), maxWindows: FooterWindows = 2): BarElement {
31
32
  const element: BarElement = { id: 'quota', spans: [] };
package/src/config.ts CHANGED
@@ -1,121 +1,11 @@
1
- import { createDeepSeekAdapter } from './query/deepseek.ts';
2
- import { createExecAdapter, validateExecOptions } from './query/exec.ts';
3
- import { createNewApiAdapter, validateNewApiOptions } from './query/new-api.ts';
4
- import { createOpenCodeGoAdapter } from './query/opencode-go.ts';
5
- import { createSub2ApiAdapter } from './query/sub2api.ts';
6
- import { createYesCodeAdapter, validateYesCodeOptions } from './query/yescode.ts';
7
- import type { NewApiOptions } from './query/new-api.ts';
8
- import type { YesCodeOptions } from './query/yescode.ts';
9
- import type { QuotaAdapter } from './query/types.ts';
10
- import type { FooterWindows } from './bar/quota.ts';
11
-
12
- export type ProviderQuotaConfig =
13
- | ({ adapter: 'new-api'; quotaPerUnit: number; currency: string } & Pick<NewApiOptions, 'dashboardAccessToken' | 'dashboardUserId'>)
14
- | ({ adapter: 'yescode' } & YesCodeOptions)
15
- | { adapter: 'exec'; command: string[]; label: string }
16
- | { adapter: 'deepseek' | 'sub2api' | 'opencode-go' };
17
- export interface QuotaConfig { providers: Record<string, ProviderQuotaConfig>; footerWindows?: FooterWindows }
18
-
19
- const DASHBOARD_KEYS = ['dashboardAccessToken', 'dashboardUserId'] as const;
20
-
21
- function parseDashboardOptions(item: Record<string, unknown>): Pick<NewApiOptions, 'dashboardAccessToken' | 'dashboardUserId'> {
22
- const result: Pick<NewApiOptions, 'dashboardAccessToken' | 'dashboardUserId'> = {};
23
- if (item.dashboardAccessToken !== undefined) {
24
- if (typeof item.dashboardAccessToken !== 'string' || item.dashboardAccessToken.trim() === '') throw new QuotaConfigError();
25
- result.dashboardAccessToken = item.dashboardAccessToken;
26
- }
27
- if (item.dashboardUserId !== undefined) {
28
- if (typeof item.dashboardUserId !== 'number'
29
- || !Number.isSafeInteger(item.dashboardUserId) || item.dashboardUserId <= 0) throw new QuotaConfigError();
30
- result.dashboardUserId = item.dashboardUserId;
31
- }
32
- return result;
33
- }
34
-
35
- function parseFooterWindows(value: unknown): FooterWindows {
36
- if (value === 'all') return value;
37
- if (typeof value === 'number' && Number.isSafeInteger(value) && value >= 1) return value;
38
- throw new QuotaConfigError();
39
- }
40
-
41
- export class QuotaConfigError extends Error {
42
- constructor() {
43
- // Settings may accidentally contain secrets. Never echo content/values.
44
- super('Invalid settings.json quotaUsage. Use providers with adapter "new-api", "deepseek", "sub2api", "opencode-go", "yescode" or "exec", and optionally footerWindows as a positive integer or "all".');
45
- }
46
- }
47
-
48
- function record(value: unknown): value is Record<string, unknown> {
49
- return value !== null && typeof value === 'object' && !Array.isArray(value);
50
- }
51
-
52
- export function parseQuotaConfig(value: unknown): QuotaConfig {
53
- if (!record(value) || Object.keys(value).some(key => key !== 'providers' && key !== 'footerWindows')
54
- || !record(value.providers) || Object.keys(value.providers).length > 64) throw new QuotaConfigError();
55
- const providers: Record<string, ProviderQuotaConfig> = Object.create(null);
56
- for (const [provider, item] of Object.entries(value.providers)) {
57
- if (!/^[A-Za-z0-9][A-Za-z0-9._:/-]{0,127}$/.test(provider)
58
- || ['openai-codex', 'kimi-coding', 'opencode-go'].includes(provider)
59
- || !record(item)) throw new QuotaConfigError();
60
- if (item.adapter === 'deepseek') {
61
- if (Object.keys(item).some(key => key !== 'adapter')) throw new QuotaConfigError();
62
- providers[provider] = { adapter: 'deepseek' };
63
- continue;
64
- }
65
- if (item.adapter === 'sub2api' || item.adapter === 'opencode-go') {
66
- if (Object.keys(item).length !== 1) throw new QuotaConfigError();
67
- providers[provider] = { adapter: item.adapter };
68
- continue;
69
- }
70
- if (item.adapter === 'yescode') {
71
- if (Object.keys(item).some(key => !['adapter', 'username', 'password'].includes(key))
72
- || typeof item.username !== 'string' || typeof item.password !== 'string') throw new QuotaConfigError();
73
- try {
74
- providers[provider] = { adapter: 'yescode',
75
- ...validateYesCodeOptions({ username: item.username, password: item.password }) };
76
- } catch { throw new QuotaConfigError(); }
77
- continue;
78
- }
79
- if (item.adapter === 'exec') {
80
- if (Object.keys(item).some(key => !['adapter', 'command', 'label'].includes(key))) throw new QuotaConfigError();
81
- try {
82
- providers[provider] = { adapter: 'exec', ...validateExecOptions(item) };
83
- } catch { throw new QuotaConfigError(); }
84
- continue;
85
- }
86
- if (item.adapter !== 'new-api'
87
- || Object.keys(item).some(key => !['adapter', 'quotaPerUnit', 'currency', ...DASHBOARD_KEYS].includes(key))
88
- || (item.quotaPerUnit !== undefined && typeof item.quotaPerUnit !== 'number')
89
- || (item.currency !== undefined && typeof item.currency !== 'string')) throw new QuotaConfigError();
90
- try {
91
- const settings = validateNewApiOptions({ quotaPerUnit: item.quotaPerUnit, currency: item.currency });
92
- providers[provider] = { adapter: 'new-api', ...settings, ...parseDashboardOptions(item) };
93
- } catch { throw new QuotaConfigError(); }
94
- }
95
- const config: QuotaConfig = { providers };
96
- if (value.footerWindows !== undefined) config.footerWindows = parseFooterWindows(value.footerWindows);
97
- return config;
98
- }
99
-
100
- export function loadQuotaFooterWindows(settings: unknown): FooterWindows | undefined {
101
- if (!record(settings) || settings.quotaUsage === undefined) return undefined;
102
- return parseQuotaConfig(settings.quotaUsage).footerWindows;
103
- }
104
-
105
- export function loadQuotaAdaptersFromSettings(settings: unknown): QuotaAdapter[] {
106
- if (!record(settings) || settings.quotaUsage === undefined) return [];
107
- const config = parseQuotaConfig(settings.quotaUsage);
108
- return Object.entries(config.providers).map(([provider, options]) => {
109
- switch (options.adapter) {
110
- case 'deepseek': return createDeepSeekAdapter(provider);
111
- case 'sub2api': return createSub2ApiAdapter(provider);
112
- case 'opencode-go': return createOpenCodeGoAdapter(provider);
113
- case 'yescode': return createYesCodeAdapter(provider, options);
114
- case 'exec': return createExecAdapter(provider, options);
115
- case 'new-api': return createNewApiAdapter(provider, options);
116
- }
117
- });
118
- }
119
-
120
- // Explicit adapter bindings from quotaUsage already have adapters registered; listing every
121
- // registered adapter is the boundary between quota providers and ordinary model providers.
1
+ // Back-compatible surface: the implementation lives in the Pi-free query layer
2
+ // (src/query/settings.ts) so non-Pi consumers can compose the same registry.
3
+ export {
4
+ composeQuotaRegistry,
5
+ loadQuotaAdaptersFromSettings,
6
+ loadQuotaAliasesFromSettings,
7
+ loadQuotaFooterWindows,
8
+ parseQuotaConfig,
9
+ QuotaConfigError,
10
+ } from './query/settings.ts';
11
+ export type { ComposedQuota, FooterWindows, ProviderQuotaConfig, QuotaConfig } from './query/settings.ts';
package/src/extension.ts CHANGED
@@ -3,13 +3,9 @@ import { renderFooter } from './bar/quota.ts';
3
3
  import type { FooterWindows } from './bar/quota.ts';
4
4
  import { captureAllUsage, USAGE_ENTRY, usageCardComponent, usageLines, compactUsageLines } from './bar/card.ts';
5
5
  import type { UsageCard } from './bar/card.ts';
6
- import { codexAdapter } from './query/codex.ts';
7
- import { createDeepSeekAdapter } from './query/deepseek.ts';
8
- import { kimiAdapter } from './query/kimi.ts';
9
- import { openCodeGoAdapter } from './query/opencode-go.ts';
10
6
  import { QuotaController } from './query/controller.ts';
11
7
  import { createJsonClient, createPostJsonClient } from './query/http.ts';
12
- import { AdapterRegistry } from './query/registry.ts';
8
+ import { composeQuotaRegistry } from './query/settings.ts';
13
9
  import type { ProviderAuth, QuotaAdapter, QuotaState } from './query/types.ts';
14
10
 
15
11
  export const STATUS_EVENT = 'quota-bar:state:v1';
@@ -23,6 +19,7 @@ export interface QuotaExtensionOptions {
23
19
  // Explicit adapters replace matching built-ins; duplicate explicit IDs are rejected.
24
20
  adapters?: readonly QuotaAdapter[];
25
21
  providers?: readonly string[]; // Extra configured quota providers to include in /usage
22
+ aliases?: Record<string, string>; // alias provider → target provider; shares the target's account quota
26
23
  footer?: boolean; // false: query + /usage + structured events only
27
24
  footerWindows?: FooterWindows; // footer window cap; default 2, 'all' shows every window
28
25
  intervalMs?: number;
@@ -31,13 +28,8 @@ export interface QuotaExtensionOptions {
31
28
 
32
29
  export function createQuotaExtension(options: QuotaExtensionOptions = {}) {
33
30
  return (pi: ExtensionAPI): void => {
34
- const explicitAdapters = options.adapters ?? [];
35
- const explicitProviderIds = new Set(explicitAdapters.map(adapter => adapter.provider));
36
31
  // Settings are deliberate user bindings, so they take precedence over defaults.
37
- // AdapterRegistry still detects an ambiguous duplicate among explicit adapters.
38
- const registry = new AdapterRegistry([
39
- codexAdapter, kimiAdapter, openCodeGoAdapter, createDeepSeekAdapter('deepseek'),
40
- ].filter(adapter => !explicitProviderIds.has(adapter.provider)).concat(explicitAdapters));
32
+ const { registry, aliases } = composeQuotaRegistry({ adapters: options.adapters, aliases: options.aliases });
41
33
  let current: ExtensionContext | undefined;
42
34
  let selectedProvider: string | undefined;
43
35
  let timer: ReturnType<typeof setInterval> | undefined;
@@ -119,7 +111,8 @@ export function createQuotaExtension(options: QuotaExtensionOptions = {}) {
119
111
  add(ctx.model?.provider);
120
112
  for (const adapter of registry.list()) add(adapter.provider);
121
113
  for (const provider of options.providers ?? []) add(provider);
122
- return providers;
114
+ // An alias absorbed by its queried target gets no row of its own.
115
+ return providers.filter(provider => aliases[provider] === undefined || !providers.includes(aliases[provider]));
123
116
  };
124
117
 
125
118
  const queryProvider = async (provider: string, getAuth: ReturnType<typeof resolveAuth>): Promise<QuotaState> => {
@@ -218,11 +211,16 @@ export function createQuotaExtension(options: QuotaExtensionOptions = {}) {
218
211
  const states = await Promise.all(providers.map(provider => queryProvider(provider, getAuth)));
219
212
  // The session's own provider always leads, whatever its health looks like.
220
213
  const currentProvider = ctx.model?.provider;
214
+ // An alias session pins and stars the target's folded row instead.
215
+ const canonicalProvider = currentProvider === undefined ? undefined : (aliases[currentProvider] ?? currentProvider);
221
216
  const ordered = states
222
217
  .map((state, index) => ({ state, index }))
223
218
  .sort((a, b) => {
224
- const pinned = (item: { state: QuotaState }) =>
225
- currentProvider !== undefined && 'provider' in item.state && item.state.provider === currentProvider ? 0 : 1;
219
+ const pinned = (item: { state: QuotaState }) => {
220
+ if (currentProvider === undefined || !('provider' in item.state)) return 1;
221
+ const provider = item.state.provider;
222
+ return provider === currentProvider || provider === canonicalProvider ? 0 : 1;
223
+ };
226
224
  const rank = (state: QuotaState) => state.kind === 'error' ? 3
227
225
  : state.kind === 'ready' && state.snapshot.windows.length > 0 ? 1
228
226
  : 2;
@@ -234,7 +232,7 @@ export function createQuotaExtension(options: QuotaExtensionOptions = {}) {
234
232
  if (!current || generation !== commandGeneration) return;
235
233
  const visibleStates = args.trim() === '--all'
236
234
  ? ordered : ordered.filter(state => state.kind === 'ready');
237
- const card = captureAllUsage(visibleStates, Date.now(), currentProvider);
235
+ const card = captureAllUsage(visibleStates, Date.now(), canonicalProvider);
238
236
  hasUsageSnapshot = true;
239
237
  pi.appendEntry<UsageCard>(USAGE_ENTRY, card);
240
238
  // Custom entry renderers are TUI-only; RPC clients still get readable output.
package/src/index.ts CHANGED
@@ -1,12 +1,13 @@
1
1
  import { getAgentDir, SettingsManager } from '@earendil-works/pi-coding-agent';
2
2
  import type { ExtensionAPI } from '@earendil-works/pi-coding-agent';
3
3
  import { createQuotaExtension } from './extension.ts';
4
- import { loadQuotaAdaptersFromSettings, loadQuotaFooterWindows, QuotaConfigError } from './config.ts';
4
+ import { loadQuotaAdaptersFromSettings, loadQuotaAliasesFromSettings, loadQuotaFooterWindows, QuotaConfigError } from './config.ts';
5
5
  import type { FooterWindows } from './bar/quota.ts';
6
6
  import type { QuotaAdapter } from './query/types.ts';
7
7
 
8
8
  export default function (pi: ExtensionAPI): void {
9
9
  let adapters: QuotaAdapter[] = [];
10
+ let aliases: Record<string, string> = {};
10
11
  let footerWindows: FooterWindows | undefined;
11
12
  try {
12
13
  // Quota provider bindings belong to the user's global Pi settings. Project
@@ -16,6 +17,7 @@ export default function (pi: ExtensionAPI): void {
16
17
  projectTrusted: false,
17
18
  }).getGlobalSettings() as unknown;
18
19
  adapters = loadQuotaAdaptersFromSettings(settings);
20
+ aliases = loadQuotaAliasesFromSettings(settings);
19
21
  footerWindows = loadQuotaFooterWindows(settings);
20
22
  } catch {
21
23
  pi.on('session_start', (_event, ctx) => {
@@ -23,5 +25,5 @@ export default function (pi: ExtensionAPI): void {
23
25
  });
24
26
  }
25
27
  // Invalid optional settings never disable the built-in Codex/Kimi/OpenCode Go adapters.
26
- createQuotaExtension({ adapters, footerWindows })(pi);
28
+ createQuotaExtension({ adapters, aliases, footerWindows })(pi);
27
29
  }
@@ -0,0 +1,20 @@
1
+ import { QuotaError } from './types.ts';
2
+ import type { QuotaAdapter } from './types.ts';
3
+
4
+ // An alias provider shares another provider's account: the query runs under
5
+ // the target's identity, so auth and endpoints resolve exactly as a direct
6
+ // query of the target would. The alias never queries on its own behalf.
7
+ export function createAliasAdapter(provider: string, target: QuotaAdapter): QuotaAdapter {
8
+ return {
9
+ provider, label: target.label,
10
+ query: context => target.query({ ...context, provider: target.provider }),
11
+ };
12
+ }
13
+
14
+ // A dangling alias stays visible as an error instead of silently vanishing.
15
+ export function createDanglingAliasAdapter(provider: string): QuotaAdapter {
16
+ return {
17
+ provider, label: provider,
18
+ query: () => Promise.reject(new QuotaError('unsupported-auth')),
19
+ };
20
+ }
@@ -0,0 +1,23 @@
1
+ // Public entry for the standalone query layer — no Pi, terminal, or rendering
2
+ // dependencies. Consumers supply auth and transport through QueryContext.
3
+ export * from './types.ts';
4
+ export { AdapterRegistry, validateSnapshot } from './registry.ts';
5
+ export { QuotaController } from './controller.ts';
6
+ export { createJsonClient, createPostJsonClient, officialAuth, bearer, header } from './http.ts';
7
+ export { createAliasAdapter, createDanglingAliasAdapter } from './alias.ts';
8
+ export { createExecAdapter, validateExecOptions } from './exec.ts';
9
+ export type { ExecOptions } from './exec.ts';
10
+ export { codexAccountId, codexAdapter } from './codex.ts';
11
+ export { kimiAdapter } from './kimi.ts';
12
+ export { openCodeGoAdapter, createOpenCodeGoAdapter } from './opencode-go.ts';
13
+ export { createDeepSeekAdapter, DEEPSEEK_ORIGIN } from './deepseek.ts';
14
+ export { createNewApiAdapter, validateNewApiOptions } from './new-api.ts';
15
+ export type { NewApiOptions } from './new-api.ts';
16
+ export { createSub2ApiAdapter } from './sub2api.ts';
17
+ export { createYesCodeAdapter, validateYesCodeOptions, YESCODE_ORIGIN } from './yescode.ts';
18
+ export type { YesCodeOptions } from './yescode.ts';
19
+ export {
20
+ composeQuotaRegistry, loadQuotaAdaptersFromSettings, loadQuotaAliasesFromSettings,
21
+ loadQuotaFooterWindows, parseQuotaConfig, QuotaConfigError,
22
+ } from './settings.ts';
23
+ export type { ComposedQuota, FooterWindows, ProviderQuotaConfig, QuotaConfig } from './settings.ts';
@@ -0,0 +1,179 @@
1
+ import { createAliasAdapter, createDanglingAliasAdapter } from './alias.ts';
2
+ import { codexAdapter } from './codex.ts';
3
+ import { createDeepSeekAdapter } from './deepseek.ts';
4
+ import { createExecAdapter, validateExecOptions } from './exec.ts';
5
+ import { kimiAdapter } from './kimi.ts';
6
+ import { createNewApiAdapter, validateNewApiOptions } from './new-api.ts';
7
+ import { createOpenCodeGoAdapter, openCodeGoAdapter } from './opencode-go.ts';
8
+ import { AdapterRegistry } from './registry.ts';
9
+ import { createSub2ApiAdapter } from './sub2api.ts';
10
+ import { createYesCodeAdapter, validateYesCodeOptions } from './yescode.ts';
11
+ import type { NewApiOptions } from './new-api.ts';
12
+ import type { YesCodeOptions } from './yescode.ts';
13
+ import type { QuotaAdapter } from './types.ts';
14
+
15
+ export type FooterWindows = number | 'all';
16
+
17
+ export type ProviderQuotaConfig =
18
+ | ({ adapter: 'new-api'; quotaPerUnit: number; currency: string } & Pick<NewApiOptions, 'dashboardAccessToken' | 'dashboardUserId'>)
19
+ | ({ adapter: 'yescode' } & YesCodeOptions)
20
+ | { adapter: 'exec'; command: string[]; label: string }
21
+ | { adapter: 'alias'; of: string }
22
+ | { adapter: 'deepseek' | 'sub2api' | 'opencode-go' };
23
+ export interface QuotaConfig { providers: Record<string, ProviderQuotaConfig>; footerWindows?: FooterWindows }
24
+
25
+ const DASHBOARD_KEYS = ['dashboardAccessToken', 'dashboardUserId'] as const;
26
+
27
+ function parseDashboardOptions(item: Record<string, unknown>): Pick<NewApiOptions, 'dashboardAccessToken' | 'dashboardUserId'> {
28
+ const result: Pick<NewApiOptions, 'dashboardAccessToken' | 'dashboardUserId'> = {};
29
+ if (item.dashboardAccessToken !== undefined) {
30
+ if (typeof item.dashboardAccessToken !== 'string' || item.dashboardAccessToken.trim() === '') throw new QuotaConfigError();
31
+ result.dashboardAccessToken = item.dashboardAccessToken;
32
+ }
33
+ if (item.dashboardUserId !== undefined) {
34
+ if (typeof item.dashboardUserId !== 'number'
35
+ || !Number.isSafeInteger(item.dashboardUserId) || item.dashboardUserId <= 0) throw new QuotaConfigError();
36
+ result.dashboardUserId = item.dashboardUserId;
37
+ }
38
+ return result;
39
+ }
40
+
41
+ function parseFooterWindows(value: unknown): FooterWindows {
42
+ if (value === 'all') return value;
43
+ if (typeof value === 'number' && Number.isSafeInteger(value) && value >= 1) return value;
44
+ throw new QuotaConfigError();
45
+ }
46
+
47
+ export class QuotaConfigError extends Error {
48
+ constructor() {
49
+ // Settings may accidentally contain secrets. Never echo content/values.
50
+ super('Invalid settings.json quotaUsage. Use providers with adapter "new-api", "deepseek", "sub2api", "opencode-go", "yescode", "exec" or "alias", and optionally footerWindows as a positive integer or "all".');
51
+ }
52
+ }
53
+
54
+ function record(value: unknown): value is Record<string, unknown> {
55
+ return value !== null && typeof value === 'object' && !Array.isArray(value);
56
+ }
57
+
58
+ export function parseQuotaConfig(value: unknown): QuotaConfig {
59
+ if (!record(value) || Object.keys(value).some(key => key !== 'providers' && key !== 'footerWindows')
60
+ || !record(value.providers) || Object.keys(value.providers).length > 64) throw new QuotaConfigError();
61
+ const providers: Record<string, ProviderQuotaConfig> = Object.create(null);
62
+ for (const [provider, item] of Object.entries(value.providers)) {
63
+ if (!/^[A-Za-z0-9][A-Za-z0-9._:/-]{0,127}$/.test(provider)
64
+ || ['openai-codex', 'kimi-coding', 'opencode-go'].includes(provider)
65
+ || !record(item)) throw new QuotaConfigError();
66
+ if (item.adapter === 'deepseek') {
67
+ if (Object.keys(item).some(key => key !== 'adapter')) throw new QuotaConfigError();
68
+ providers[provider] = { adapter: 'deepseek' };
69
+ continue;
70
+ }
71
+ if (item.adapter === 'sub2api' || item.adapter === 'opencode-go') {
72
+ if (Object.keys(item).length !== 1) throw new QuotaConfigError();
73
+ providers[provider] = { adapter: item.adapter };
74
+ continue;
75
+ }
76
+ if (item.adapter === 'yescode') {
77
+ if (Object.keys(item).some(key => !['adapter', 'username', 'password'].includes(key))
78
+ || typeof item.username !== 'string' || typeof item.password !== 'string') throw new QuotaConfigError();
79
+ try {
80
+ providers[provider] = { adapter: 'yescode',
81
+ ...validateYesCodeOptions({ username: item.username, password: item.password }) };
82
+ } catch { throw new QuotaConfigError(); }
83
+ continue;
84
+ }
85
+ if (item.adapter === 'exec') {
86
+ if (Object.keys(item).some(key => !['adapter', 'command', 'label'].includes(key))) throw new QuotaConfigError();
87
+ try {
88
+ providers[provider] = { adapter: 'exec', ...validateExecOptions(item) };
89
+ } catch { throw new QuotaConfigError(); }
90
+ continue;
91
+ }
92
+ if (item.adapter === 'alias') {
93
+ if (Object.keys(item).some(key => key !== 'adapter' && key !== 'of')
94
+ || typeof item.of !== 'string' || !/^[A-Za-z0-9][A-Za-z0-9._:/-]{0,127}$/.test(item.of)
95
+ || item.of === provider) throw new QuotaConfigError();
96
+ providers[provider] = { adapter: 'alias', of: item.of };
97
+ continue;
98
+ }
99
+ if (item.adapter !== 'new-api'
100
+ || Object.keys(item).some(key => !['adapter', 'quotaPerUnit', 'currency', ...DASHBOARD_KEYS].includes(key))
101
+ || (item.quotaPerUnit !== undefined && typeof item.quotaPerUnit !== 'number')
102
+ || (item.currency !== undefined && typeof item.currency !== 'string')) throw new QuotaConfigError();
103
+ try {
104
+ const settings = validateNewApiOptions({ quotaPerUnit: item.quotaPerUnit, currency: item.currency });
105
+ providers[provider] = { adapter: 'new-api', ...settings, ...parseDashboardOptions(item) };
106
+ } catch { throw new QuotaConfigError(); }
107
+ }
108
+ const config: QuotaConfig = { providers };
109
+ if (value.footerWindows !== undefined) config.footerWindows = parseFooterWindows(value.footerWindows);
110
+ return config;
111
+ }
112
+
113
+ export function loadQuotaFooterWindows(settings: unknown): FooterWindows | undefined {
114
+ if (!record(settings) || settings.quotaUsage === undefined) return undefined;
115
+ return parseQuotaConfig(settings.quotaUsage).footerWindows;
116
+ }
117
+
118
+ export function loadQuotaAdaptersFromSettings(settings: unknown): QuotaAdapter[] {
119
+ if (!record(settings) || settings.quotaUsage === undefined) return [];
120
+ const config = parseQuotaConfig(settings.quotaUsage);
121
+ return Object.entries(config.providers).flatMap(([provider, options]) => {
122
+ switch (options.adapter) {
123
+ case 'alias': return []; // Aliases declare no adapter; extension.ts wires them.
124
+ case 'deepseek': return createDeepSeekAdapter(provider);
125
+ case 'sub2api': return createSub2ApiAdapter(provider);
126
+ case 'opencode-go': return createOpenCodeGoAdapter(provider);
127
+ case 'yescode': return createYesCodeAdapter(provider, options);
128
+ case 'exec': return createExecAdapter(provider, options);
129
+ case 'new-api': return createNewApiAdapter(provider, options);
130
+ }
131
+ });
132
+ }
133
+
134
+ export function loadQuotaAliasesFromSettings(settings: unknown): Record<string, string> {
135
+ if (!record(settings) || settings.quotaUsage === undefined) return {};
136
+ const config = parseQuotaConfig(settings.quotaUsage);
137
+ const aliases: Record<string, string> = Object.create(null);
138
+ for (const [provider, options] of Object.entries(config.providers)) {
139
+ if (options.adapter === 'alias') aliases[provider] = options.of;
140
+ }
141
+ return aliases;
142
+ }
143
+
144
+ export interface ComposedQuota {
145
+ registry: AdapterRegistry;
146
+ aliases: Record<string, string>;
147
+ footerWindows: FooterWindows | undefined;
148
+ }
149
+
150
+ /**
151
+ * The full quota surface in one place: built-in adapters, settings-bound adapters
152
+ * and aliases wired exactly as the Pi extension does. Settings are deliberate user
153
+ * bindings, so they take precedence over defaults; AdapterRegistry still detects an
154
+ * ambiguous duplicate among explicit adapters. Alias providers share their target's
155
+ * account: one hop only, and a dangling alias stays visible as an error instead of
156
+ * vanishing silently.
157
+ */
158
+ export function composeQuotaRegistry(input: {
159
+ settings?: unknown;
160
+ adapters?: readonly QuotaAdapter[];
161
+ aliases?: Record<string, string>;
162
+ } = {}): ComposedQuota {
163
+ const settingsAdapters = input.settings === undefined ? [] : loadQuotaAdaptersFromSettings(input.settings);
164
+ const explicitAdapters = [...settingsAdapters, ...(input.adapters ?? [])];
165
+ const aliases = { ...loadQuotaAliasesFromSettings(input.settings ?? {}), ...(input.aliases ?? {}) };
166
+ const explicitProviderIds = new Set([...explicitAdapters.map(adapter => adapter.provider), ...Object.keys(aliases)]);
167
+ const registry = new AdapterRegistry([
168
+ codexAdapter, kimiAdapter, openCodeGoAdapter, createDeepSeekAdapter('deepseek'),
169
+ ].filter(adapter => !explicitProviderIds.has(adapter.provider)).concat(explicitAdapters));
170
+ for (const [alias, targetId] of Object.entries(aliases)) {
171
+ const target = aliases[targetId] === undefined ? registry.get(targetId) : undefined;
172
+ registry.register(target ? createAliasAdapter(alias, target) : createDanglingAliasAdapter(alias));
173
+ }
174
+ return {
175
+ registry,
176
+ aliases,
177
+ footerWindows: input.settings === undefined ? undefined : loadQuotaFooterWindows(input.settings),
178
+ };
179
+ }