@astrosheep/pi-quota 0.9.0 → 0.11.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 +9 -1
- package/package.json +1 -1
- package/src/config.ts +31 -2
- package/src/extension.ts +21 -5
- package/src/index.ts +4 -2
- package/src/query/alias.ts +20 -0
- package/src/query/exec.ts +59 -0
package/README.md
CHANGED
|
@@ -32,7 +32,9 @@ Add bindings in global `~/.pi/agent/settings.json` under `quotaUsage` (not proje
|
|
|
32
32
|
"my-new-api": { "adapter": "new-api", "quotaPerUnit": 500000, "currency": "USD" },
|
|
33
33
|
"deepseek": { "adapter": "deepseek" },
|
|
34
34
|
"opencode-go-2": { "adapter": "opencode-go" },
|
|
35
|
-
"yes-vg": { "adapter": "yescode", "username": "you@example.com", "password": "..." }
|
|
35
|
+
"yes-vg": { "adapter": "yescode", "username": "you@example.com", "password": "..." },
|
|
36
|
+
"my-script": { "adapter": "exec", "command": ["my-quota-cli", "--json"], "label": "My Script" },
|
|
37
|
+
"my-alias": { "adapter": "alias", "of": "my-sub2api" }
|
|
36
38
|
}
|
|
37
39
|
}
|
|
38
40
|
}
|
|
@@ -47,6 +49,8 @@ Use the **exact Pi provider ID**. Built-in providers and explicitly bound provid
|
|
|
47
49
|
| `deepseek` | Official `/user/balance` | Balance only; no invented spend |
|
|
48
50
|
| `opencode-go` | Official `https://opencode.ai/zen/go/v1/usage` | 5h/1w/30d windows, same as the built-in `opencode-go` adapter |
|
|
49
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 |
|
|
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` |
|
|
50
54
|
|
|
51
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.
|
|
52
56
|
|
|
@@ -56,6 +60,10 @@ For new-api, `quotaPerUnit` defaults to `500000`, `currency` to `USD`. Optionall
|
|
|
56
60
|
|
|
57
61
|
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
62
|
|
|
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`.
|
|
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
|
+
|
|
59
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.
|
|
60
68
|
|
|
61
69
|
## Architecture
|
package/package.json
CHANGED
package/src/config.ts
CHANGED
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import { createDeepSeekAdapter } from './query/deepseek.ts';
|
|
2
|
+
import { createExecAdapter, validateExecOptions } from './query/exec.ts';
|
|
2
3
|
import { createNewApiAdapter, validateNewApiOptions } from './query/new-api.ts';
|
|
3
4
|
import { createOpenCodeGoAdapter } from './query/opencode-go.ts';
|
|
4
5
|
import { createSub2ApiAdapter } from './query/sub2api.ts';
|
|
@@ -11,6 +12,8 @@ import type { FooterWindows } from './bar/quota.ts';
|
|
|
11
12
|
export type ProviderQuotaConfig =
|
|
12
13
|
| ({ adapter: 'new-api'; quotaPerUnit: number; currency: string } & Pick<NewApiOptions, 'dashboardAccessToken' | 'dashboardUserId'>)
|
|
13
14
|
| ({ adapter: 'yescode' } & YesCodeOptions)
|
|
15
|
+
| { adapter: 'exec'; command: string[]; label: string }
|
|
16
|
+
| { adapter: 'alias'; of: string }
|
|
14
17
|
| { adapter: 'deepseek' | 'sub2api' | 'opencode-go' };
|
|
15
18
|
export interface QuotaConfig { providers: Record<string, ProviderQuotaConfig>; footerWindows?: FooterWindows }
|
|
16
19
|
|
|
@@ -39,7 +42,7 @@ function parseFooterWindows(value: unknown): FooterWindows {
|
|
|
39
42
|
export class QuotaConfigError extends Error {
|
|
40
43
|
constructor() {
|
|
41
44
|
// Settings may accidentally contain secrets. Never echo content/values.
|
|
42
|
-
super('Invalid settings.json quotaUsage. Use providers with adapter "new-api", "deepseek", "sub2api", "opencode-go" or "
|
|
45
|
+
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".');
|
|
43
46
|
}
|
|
44
47
|
}
|
|
45
48
|
|
|
@@ -74,6 +77,20 @@ export function parseQuotaConfig(value: unknown): QuotaConfig {
|
|
|
74
77
|
} catch { throw new QuotaConfigError(); }
|
|
75
78
|
continue;
|
|
76
79
|
}
|
|
80
|
+
if (item.adapter === 'exec') {
|
|
81
|
+
if (Object.keys(item).some(key => !['adapter', 'command', 'label'].includes(key))) throw new QuotaConfigError();
|
|
82
|
+
try {
|
|
83
|
+
providers[provider] = { adapter: 'exec', ...validateExecOptions(item) };
|
|
84
|
+
} catch { throw new QuotaConfigError(); }
|
|
85
|
+
continue;
|
|
86
|
+
}
|
|
87
|
+
if (item.adapter === 'alias') {
|
|
88
|
+
if (Object.keys(item).some(key => key !== 'adapter' && key !== 'of')
|
|
89
|
+
|| typeof item.of !== 'string' || !/^[A-Za-z0-9][A-Za-z0-9._:/-]{0,127}$/.test(item.of)
|
|
90
|
+
|| item.of === provider) throw new QuotaConfigError();
|
|
91
|
+
providers[provider] = { adapter: 'alias', of: item.of };
|
|
92
|
+
continue;
|
|
93
|
+
}
|
|
77
94
|
if (item.adapter !== 'new-api'
|
|
78
95
|
|| Object.keys(item).some(key => !['adapter', 'quotaPerUnit', 'currency', ...DASHBOARD_KEYS].includes(key))
|
|
79
96
|
|| (item.quotaPerUnit !== undefined && typeof item.quotaPerUnit !== 'number')
|
|
@@ -96,16 +113,28 @@ export function loadQuotaFooterWindows(settings: unknown): FooterWindows | undef
|
|
|
96
113
|
export function loadQuotaAdaptersFromSettings(settings: unknown): QuotaAdapter[] {
|
|
97
114
|
if (!record(settings) || settings.quotaUsage === undefined) return [];
|
|
98
115
|
const config = parseQuotaConfig(settings.quotaUsage);
|
|
99
|
-
return Object.entries(config.providers).
|
|
116
|
+
return Object.entries(config.providers).flatMap(([provider, options]) => {
|
|
100
117
|
switch (options.adapter) {
|
|
118
|
+
case 'alias': return []; // Aliases declare no adapter; extension.ts wires them.
|
|
101
119
|
case 'deepseek': return createDeepSeekAdapter(provider);
|
|
102
120
|
case 'sub2api': return createSub2ApiAdapter(provider);
|
|
103
121
|
case 'opencode-go': return createOpenCodeGoAdapter(provider);
|
|
104
122
|
case 'yescode': return createYesCodeAdapter(provider, options);
|
|
123
|
+
case 'exec': return createExecAdapter(provider, options);
|
|
105
124
|
case 'new-api': return createNewApiAdapter(provider, options);
|
|
106
125
|
}
|
|
107
126
|
});
|
|
108
127
|
}
|
|
109
128
|
|
|
129
|
+
export function loadQuotaAliasesFromSettings(settings: unknown): Record<string, string> {
|
|
130
|
+
if (!record(settings) || settings.quotaUsage === undefined) return {};
|
|
131
|
+
const config = parseQuotaConfig(settings.quotaUsage);
|
|
132
|
+
const aliases: Record<string, string> = Object.create(null);
|
|
133
|
+
for (const [provider, options] of Object.entries(config.providers)) {
|
|
134
|
+
if (options.adapter === 'alias') aliases[provider] = options.of;
|
|
135
|
+
}
|
|
136
|
+
return aliases;
|
|
137
|
+
}
|
|
138
|
+
|
|
110
139
|
// Explicit adapter bindings from quotaUsage already have adapters registered; listing every
|
|
111
140
|
// registered adapter is the boundary between quota providers and ordinary model providers.
|
package/src/extension.ts
CHANGED
|
@@ -10,6 +10,7 @@ import { openCodeGoAdapter } from './query/opencode-go.ts';
|
|
|
10
10
|
import { QuotaController } from './query/controller.ts';
|
|
11
11
|
import { createJsonClient, createPostJsonClient } from './query/http.ts';
|
|
12
12
|
import { AdapterRegistry } from './query/registry.ts';
|
|
13
|
+
import { createAliasAdapter, createDanglingAliasAdapter } from './query/alias.ts';
|
|
13
14
|
import type { ProviderAuth, QuotaAdapter, QuotaState } from './query/types.ts';
|
|
14
15
|
|
|
15
16
|
export const STATUS_EVENT = 'quota-bar:state:v1';
|
|
@@ -23,6 +24,7 @@ export interface QuotaExtensionOptions {
|
|
|
23
24
|
// Explicit adapters replace matching built-ins; duplicate explicit IDs are rejected.
|
|
24
25
|
adapters?: readonly QuotaAdapter[];
|
|
25
26
|
providers?: readonly string[]; // Extra configured quota providers to include in /usage
|
|
27
|
+
aliases?: Record<string, string>; // alias provider → target provider; shares the target's account quota
|
|
26
28
|
footer?: boolean; // false: query + /usage + structured events only
|
|
27
29
|
footerWindows?: FooterWindows; // footer window cap; default 2, 'all' shows every window
|
|
28
30
|
intervalMs?: number;
|
|
@@ -32,12 +34,20 @@ export interface QuotaExtensionOptions {
|
|
|
32
34
|
export function createQuotaExtension(options: QuotaExtensionOptions = {}) {
|
|
33
35
|
return (pi: ExtensionAPI): void => {
|
|
34
36
|
const explicitAdapters = options.adapters ?? [];
|
|
35
|
-
const
|
|
37
|
+
const aliases = options.aliases ?? {};
|
|
38
|
+
const explicitProviderIds = new Set([...explicitAdapters.map(adapter => adapter.provider), ...Object.keys(aliases)]);
|
|
36
39
|
// Settings are deliberate user bindings, so they take precedence over defaults.
|
|
37
40
|
// AdapterRegistry still detects an ambiguous duplicate among explicit adapters.
|
|
38
41
|
const registry = new AdapterRegistry([
|
|
39
42
|
codexAdapter, kimiAdapter, openCodeGoAdapter, createDeepSeekAdapter('deepseek'),
|
|
40
43
|
].filter(adapter => !explicitProviderIds.has(adapter.provider)).concat(explicitAdapters));
|
|
44
|
+
// Alias providers share their target's account: register a delegating adapter
|
|
45
|
+
// under the alias ID. One hop only — an alias of an alias never resolves, and
|
|
46
|
+
// a dangling alias stays visible as an error instead of vanishing silently.
|
|
47
|
+
for (const [alias, targetId] of Object.entries(aliases)) {
|
|
48
|
+
const target = aliases[targetId] === undefined ? registry.get(targetId) : undefined;
|
|
49
|
+
registry.register(target ? createAliasAdapter(alias, target) : createDanglingAliasAdapter(alias));
|
|
50
|
+
}
|
|
41
51
|
let current: ExtensionContext | undefined;
|
|
42
52
|
let selectedProvider: string | undefined;
|
|
43
53
|
let timer: ReturnType<typeof setInterval> | undefined;
|
|
@@ -119,7 +129,8 @@ export function createQuotaExtension(options: QuotaExtensionOptions = {}) {
|
|
|
119
129
|
add(ctx.model?.provider);
|
|
120
130
|
for (const adapter of registry.list()) add(adapter.provider);
|
|
121
131
|
for (const provider of options.providers ?? []) add(provider);
|
|
122
|
-
|
|
132
|
+
// An alias absorbed by its queried target gets no row of its own.
|
|
133
|
+
return providers.filter(provider => aliases[provider] === undefined || !providers.includes(aliases[provider]));
|
|
123
134
|
};
|
|
124
135
|
|
|
125
136
|
const queryProvider = async (provider: string, getAuth: ReturnType<typeof resolveAuth>): Promise<QuotaState> => {
|
|
@@ -218,11 +229,16 @@ export function createQuotaExtension(options: QuotaExtensionOptions = {}) {
|
|
|
218
229
|
const states = await Promise.all(providers.map(provider => queryProvider(provider, getAuth)));
|
|
219
230
|
// The session's own provider always leads, whatever its health looks like.
|
|
220
231
|
const currentProvider = ctx.model?.provider;
|
|
232
|
+
// An alias session pins and stars the target's folded row instead.
|
|
233
|
+
const canonicalProvider = currentProvider === undefined ? undefined : (aliases[currentProvider] ?? currentProvider);
|
|
221
234
|
const ordered = states
|
|
222
235
|
.map((state, index) => ({ state, index }))
|
|
223
236
|
.sort((a, b) => {
|
|
224
|
-
const pinned = (item: { state: QuotaState }) =>
|
|
225
|
-
currentProvider
|
|
237
|
+
const pinned = (item: { state: QuotaState }) => {
|
|
238
|
+
if (currentProvider === undefined || !('provider' in item.state)) return 1;
|
|
239
|
+
const provider = item.state.provider;
|
|
240
|
+
return provider === currentProvider || provider === canonicalProvider ? 0 : 1;
|
|
241
|
+
};
|
|
226
242
|
const rank = (state: QuotaState) => state.kind === 'error' ? 3
|
|
227
243
|
: state.kind === 'ready' && state.snapshot.windows.length > 0 ? 1
|
|
228
244
|
: 2;
|
|
@@ -234,7 +250,7 @@ export function createQuotaExtension(options: QuotaExtensionOptions = {}) {
|
|
|
234
250
|
if (!current || generation !== commandGeneration) return;
|
|
235
251
|
const visibleStates = args.trim() === '--all'
|
|
236
252
|
? ordered : ordered.filter(state => state.kind === 'ready');
|
|
237
|
-
const card = captureAllUsage(visibleStates, Date.now(),
|
|
253
|
+
const card = captureAllUsage(visibleStates, Date.now(), canonicalProvider);
|
|
238
254
|
hasUsageSnapshot = true;
|
|
239
255
|
pi.appendEntry<UsageCard>(USAGE_ENTRY, card);
|
|
240
256
|
// 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,59 @@
|
|
|
1
|
+
import { spawn } from 'node:child_process';
|
|
2
|
+
import { QuotaError } from './types.ts';
|
|
3
|
+
import type { QuotaAdapter, QuotaSnapshot } from './types.ts';
|
|
4
|
+
|
|
5
|
+
export interface ExecOptions { command: string[]; label: string }
|
|
6
|
+
|
|
7
|
+
export function validateExecOptions(options: { command?: unknown; label?: unknown }): ExecOptions {
|
|
8
|
+
const { command, label } = options;
|
|
9
|
+
if (!Array.isArray(command) || command.length === 0 || command.length > 16
|
|
10
|
+
|| command.some(part => typeof part !== 'string' || part.trim() === '' || part.length > 512)) {
|
|
11
|
+
throw new Error('Invalid exec options');
|
|
12
|
+
}
|
|
13
|
+
if (typeof label !== 'string' || label.trim() === '' || label.length > 64) throw new Error('Invalid exec options');
|
|
14
|
+
return { command: [...command] as string[], label };
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
// Same bound as the HTTP clients: quota payloads are small.
|
|
18
|
+
const MAX_OUTPUT_BYTES = 256 * 1024;
|
|
19
|
+
|
|
20
|
+
function runCommand(command: readonly string[], signal: AbortSignal): Promise<string> {
|
|
21
|
+
return new Promise((resolve, reject) => {
|
|
22
|
+
let settled = false;
|
|
23
|
+
const fail = (error: unknown) => { if (!settled) { settled = true; reject(error); } };
|
|
24
|
+
const done = (text: string) => { if (!settled) { settled = true; resolve(text); } };
|
|
25
|
+
let child;
|
|
26
|
+
try {
|
|
27
|
+
// No shell: argv passes verbatim; settings.json is the only trust boundary.
|
|
28
|
+
// The controller bounds the wait via the signal even if the child hangs.
|
|
29
|
+
child = spawn(command[0], command.slice(1), { signal, stdio: ['ignore', 'pipe', 'ignore'] });
|
|
30
|
+
} catch { fail(new QuotaError('network')); return; }
|
|
31
|
+
let output = '';
|
|
32
|
+
child.stdout?.setEncoding('utf8');
|
|
33
|
+
child.stdout?.on('data', (chunk: string) => {
|
|
34
|
+
output += chunk;
|
|
35
|
+
if (output.length > MAX_OUTPUT_BYTES) { child.kill(); fail(new QuotaError('schema')); }
|
|
36
|
+
});
|
|
37
|
+
child.on('error', () => fail(signal.aborted ? signal.reason : new QuotaError('network')));
|
|
38
|
+
child.on('close', code => {
|
|
39
|
+
if (signal.aborted) fail(signal.reason);
|
|
40
|
+
else if (code !== 0) fail(new QuotaError('network'));
|
|
41
|
+
else done(output);
|
|
42
|
+
});
|
|
43
|
+
});
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
export function createExecAdapter(provider: string, options: ExecOptions): QuotaAdapter {
|
|
47
|
+
return {
|
|
48
|
+
provider, label: options.label,
|
|
49
|
+
async query(context) {
|
|
50
|
+
const text = await runCommand(options.command, context.signal);
|
|
51
|
+
let payload: unknown;
|
|
52
|
+
try { payload = JSON.parse(text); } catch { throw new QuotaError('schema'); }
|
|
53
|
+
if (payload === null || typeof payload !== 'object' || Array.isArray(payload)) throw new QuotaError('schema');
|
|
54
|
+
// fetchedAt is stamped here, never trusted from the command; the
|
|
55
|
+
// controller's validateSnapshot performs the real schema check.
|
|
56
|
+
return { ...(payload as Record<string, unknown>), fetchedAt: context.now() } as QuotaSnapshot;
|
|
57
|
+
},
|
|
58
|
+
};
|
|
59
|
+
}
|