@astrosheep/pi-quota 0.12.0 → 0.12.2

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.
@@ -0,0 +1,180 @@
1
+ import { bearer } from "./http.js";
2
+ import { numeric, object } from "./parse.js";
3
+ import { QuotaError } from "./types.js";
4
+ import { safeBaseUrl } from "./url.js";
5
+ export function validateNewApiOptions(options) {
6
+ const quotaPerUnit = options.quotaPerUnit ?? 500000;
7
+ const currency = options.currency ?? 'USD';
8
+ if (!Number.isFinite(quotaPerUnit) || quotaPerUnit <= 0)
9
+ throw new Error('quotaPerUnit must be a positive number');
10
+ if (typeof currency !== 'string' || !/^[A-Z]{3}$/.test(currency))
11
+ throw new Error('currency must be a three-letter uppercase code');
12
+ return { quotaPerUnit, currency };
13
+ }
14
+ // Billing endpoints live at the deployment root, never under /v1.
15
+ // https://host/v1 -> https://host, https://host/gateway/v1 -> https://host/gateway
16
+ export function newApiRootUrl(baseUrl) {
17
+ const url = safeBaseUrl(baseUrl);
18
+ const path = url.pathname.replace(/\/+$/, '').replace(/\/v\d+(?:beta\d*)?$/i, '');
19
+ return url.origin + path;
20
+ }
21
+ // GET /api/usage/token (new-api >= v0.9.0-alpha.8, PR QuantumNous/new-api#1161):
22
+ // accepts the sk- model key itself (TokenAuth), returns per-token quota in
23
+ // the same unit as /api/user/self quota. Response shell: {code:true,data:{...}}.
24
+ export function parseNewApiTokenUsage(payload, options = {}) {
25
+ const { quotaPerUnit, currency } = validateNewApiOptions(options);
26
+ const response = object(payload);
27
+ if (response.code !== true)
28
+ return null;
29
+ const data = object(response.data);
30
+ if (data.object !== 'token_usage')
31
+ return null;
32
+ const used = numeric(data.total_used);
33
+ if (used === null || !Number.isSafeInteger(used) || used < 0)
34
+ throw new QuotaError('schema');
35
+ if (data.unlimited_quota === true) {
36
+ return { balance: { currency, remaining: 0, unlimited: true },
37
+ spend: { currency, lifetime: used / quotaPerUnit } };
38
+ }
39
+ const remaining = numeric(data.total_available);
40
+ if (remaining === null || !Number.isSafeInteger(remaining))
41
+ throw new QuotaError('schema');
42
+ const moneyLeft = remaining / quotaPerUnit;
43
+ if (!Number.isFinite(moneyLeft))
44
+ throw new QuotaError('schema');
45
+ // New-api reports granted = key used + key remaining. Only draw a finite
46
+ // allocation bar when the server actually supplies that total.
47
+ if (data.total_granted !== undefined) {
48
+ const granted = numeric(data.total_granted);
49
+ if (granted === null || !Number.isSafeInteger(granted) || granted < 0
50
+ || !Number.isSafeInteger(used + remaining) || granted !== used + remaining)
51
+ throw new QuotaError('schema');
52
+ if (granted > 0)
53
+ return { allowance: { currency, limit: granted / quotaPerUnit,
54
+ used: used / quotaPerUnit, remaining: moneyLeft } };
55
+ }
56
+ return { balance: { currency, remaining: moneyLeft },
57
+ spend: { currency, lifetime: used / quotaPerUnit } };
58
+ }
59
+ // GET /api/user/self (UserAuth): the console's own account quota, readable with
60
+ // a dashboard access token (PAT), which sk- keys are not. Old forks also demand
61
+ // a matching New-Api-User header. Response shell: {success:true,data:{...}}.
62
+ export function parseNewApiUserSelf(payload, options = {}) {
63
+ const { quotaPerUnit, currency } = validateNewApiOptions(options);
64
+ const response = object(payload);
65
+ if (response.success !== true)
66
+ throw new QuotaError('schema');
67
+ const data = object(response.data);
68
+ const quota = numeric(data.quota);
69
+ const usedQuota = numeric(data.used_quota);
70
+ if (quota === null || usedQuota === null
71
+ || !Number.isSafeInteger(quota) || !Number.isSafeInteger(usedQuota)
72
+ || quota < 0 || usedQuota < 0)
73
+ throw new QuotaError('schema');
74
+ return { balance: { currency, remaining: quota / quotaPerUnit },
75
+ spend: { currency, lifetime: usedQuota / quotaPerUnit } };
76
+ }
77
+ // Legacy one-api billing pair, still the only option on older deployments.
78
+ // total_usage is in cents (divide by 100, per one-api issue #1785); most
79
+ // deployments report a fake 1e8 hard limit for unlimited quotas.
80
+ export function parseNewApiBilling(subscription, usage, options = {}) {
81
+ const { currency } = validateNewApiOptions(options);
82
+ const sub = object(subscription);
83
+ const limit = numeric(sub.hard_limit_usd);
84
+ const list = object(usage);
85
+ const totalUsage = numeric(list.total_usage);
86
+ if (limit === null || !Number.isFinite(limit) || limit < 0
87
+ || totalUsage === null || !Number.isFinite(totalUsage) || totalUsage < 0)
88
+ throw new QuotaError('schema');
89
+ const used = totalUsage / 100;
90
+ // This endpoint reports account remaining + lifetime usage as its hard limit.
91
+ // Top-ups change that total, so it is a wallet balance, not a fixed allowance.
92
+ if (limit >= 1e7)
93
+ return { balance: { currency, remaining: 0, unlimited: true },
94
+ spend: { currency, lifetime: used } };
95
+ return { balance: { currency, remaining: limit - used },
96
+ spend: { currency, lifetime: used } };
97
+ }
98
+ export function createNewApiAdapter(provider, options = {}) {
99
+ const settings = validateNewApiOptions(options);
100
+ const { dashboardAccessToken, dashboardUserId } = options;
101
+ if (dashboardAccessToken !== undefined
102
+ && (typeof dashboardAccessToken !== 'string' || dashboardAccessToken.trim() === '')) {
103
+ throw new Error('dashboardAccessToken must be a non-empty string');
104
+ }
105
+ if (dashboardUserId !== undefined
106
+ && (!Number.isSafeInteger(dashboardUserId) || dashboardUserId <= 0)) {
107
+ throw new Error('dashboardUserId must be a positive integer');
108
+ }
109
+ return {
110
+ provider, label: provider,
111
+ async query(context) {
112
+ let auth;
113
+ try {
114
+ auth = await context.getAuth(context.provider);
115
+ }
116
+ catch {
117
+ throw new QuotaError('auth');
118
+ }
119
+ context.signal.throwIfAborted();
120
+ if (!auth)
121
+ throw new QuotaError('auth');
122
+ const root = newApiRootUrl(auth.baseUrl);
123
+ // 0. Console account balance via PAT: the only view of real remaining
124
+ // account quota. Any failure falls through to the key-native paths.
125
+ if (dashboardAccessToken !== undefined) {
126
+ const headers = { Authorization: `Bearer ${dashboardAccessToken}` };
127
+ if (dashboardUserId !== undefined)
128
+ headers['New-Api-User'] = String(dashboardUserId);
129
+ try {
130
+ const payload = await context.getJson(`${root}/api/user/self`, headers, context.signal);
131
+ return { windows: [], ...parseNewApiUserSelf(payload, settings), fetchedAt: context.now() };
132
+ }
133
+ catch (error) {
134
+ if (!(error instanceof QuotaError))
135
+ throw error;
136
+ }
137
+ }
138
+ const headers = { Authorization: `Bearer ${bearer(auth)}` };
139
+ // 1. Account billing (one-api legacy): a finite hard limit is the real
140
+ // account balance. 1e8 means unlimited — then the key quota may still
141
+ // be finite and more precise.
142
+ let billingBalance = null;
143
+ try {
144
+ const pair = await Promise.all([
145
+ context.getJson(`${root}/v1/dashboard/billing/subscription`, headers, context.signal),
146
+ context.getJson(`${root}/v1/dashboard/billing/usage?start_date=2020-01-01&end_date=2100-01-01`, headers, context.signal),
147
+ ]);
148
+ const billing = parseNewApiBilling(pair[0], pair[1], settings);
149
+ if (billing.balance?.unlimited !== true)
150
+ return { windows: [], ...billing, fetchedAt: context.now() };
151
+ billingBalance = billing;
152
+ }
153
+ catch (error) {
154
+ // 404: deployment has no billing pair. 401: try the key-native endpoint
155
+ // below — some deployments accept sk- only there.
156
+ if (error instanceof QuotaError && (error.code === 'http' || error.code === 'auth')) { /* fall through */ }
157
+ else
158
+ throw error;
159
+ }
160
+ // 2. Per-token usage (new-api ≥ v0.9.0-alpha.8): native sk- support.
161
+ try {
162
+ const payload = await context.getJson(`${root}/api/usage/token/`, headers, context.signal);
163
+ const parsed = parseNewApiTokenUsage(payload, settings);
164
+ if (parsed)
165
+ return { windows: [], ...parsed, fetchedAt: context.now() };
166
+ }
167
+ catch (error) {
168
+ if (billingBalance && error instanceof QuotaError && error.code === 'http') {
169
+ return { windows: [], ...billingBalance, fetchedAt: context.now() };
170
+ }
171
+ if (error instanceof QuotaError && error.code === 'auth')
172
+ throw new QuotaError('account-access');
173
+ throw error;
174
+ }
175
+ if (billingBalance)
176
+ return { windows: [], ...billingBalance, fetchedAt: context.now() };
177
+ throw new QuotaError('schema');
178
+ },
179
+ };
180
+ }
@@ -0,0 +1,4 @@
1
+ import type { QuotaAdapter, QuotaWindow } from './types.js';
2
+ export declare function parseOpenCodeGo(payload: unknown): QuotaWindow[];
3
+ export declare function createOpenCodeGoAdapter(provider: string): QuotaAdapter;
4
+ export declare const openCodeGoAdapter: QuotaAdapter;
@@ -0,0 +1,46 @@
1
+ import { bearer, officialAuth } from "./http.js";
2
+ import { numeric, object, percent, timestamp, windowLabel } from "./parse.js";
3
+ import { QuotaError } from "./types.js";
4
+ // OpenCode Go (opencode.ai Zen Go plan) is a single official deployment, so the
5
+ // window keys are fixed and undecorated: percent is already "used", and there is
6
+ // no dollar balance — only the console shows money.
7
+ const WINDOWS = [
8
+ { key: 'rolling', duration: 18000 }, // 5 hours
9
+ { key: 'weekly', duration: 604800 }, // 7 days
10
+ { key: 'monthly', duration: 2592000 }, // 30 days
11
+ ];
12
+ export function parseOpenCodeGo(payload) {
13
+ const usage = object(object(payload).usage);
14
+ const windows = [];
15
+ for (const { key, duration } of WINDOWS) {
16
+ if (usage[key] === undefined)
17
+ continue;
18
+ const detail = object(usage[key]);
19
+ const used = numeric(detail.percent);
20
+ windows.push({
21
+ id: key, label: windowLabel(duration), durationSeconds: duration,
22
+ // Absent or malformed percent stays unknown; never fabricate a window.
23
+ remainingPercent: used === null ? null : percent(100 - used),
24
+ resetAt: timestamp(detail.resetsAt),
25
+ });
26
+ }
27
+ if (windows.length === 0)
28
+ throw new QuotaError('schema');
29
+ return windows;
30
+ }
31
+ // A second OpenCode Go account is just another Pi provider ID (configured in
32
+ // models.json with its own apiKey); auth and endpoint stay official regardless.
33
+ export function createOpenCodeGoAdapter(provider) {
34
+ return {
35
+ provider,
36
+ label: provider === 'opencode-go' ? 'OpenCode Go' : `OpenCode Go (${provider})`,
37
+ async query(context) {
38
+ const auth = await officialAuth(context, 'https://opencode.ai');
39
+ const data = await context.getJson('https://opencode.ai/zen/go/v1/usage', {
40
+ Authorization: `Bearer ${bearer(auth)}`,
41
+ }, context.signal);
42
+ return { windows: parseOpenCodeGo(data), fetchedAt: context.now() };
43
+ },
44
+ };
45
+ }
46
+ export const openCodeGoAdapter = createOpenCodeGoAdapter('opencode-go');
@@ -0,0 +1,6 @@
1
+ export declare function object(value: unknown): Record<string, unknown>;
2
+ export declare function numeric(value: unknown): number | null;
3
+ export declare function percent(value: number): number;
4
+ export declare function timestamp(value: unknown): number | null;
5
+ export declare function windowLabel(seconds: number): string;
6
+ export declare function safeLabel(value: string): string;
@@ -0,0 +1,35 @@
1
+ export function object(value) {
2
+ return value !== null && typeof value === 'object' && !Array.isArray(value)
3
+ ? value : {};
4
+ }
5
+ export function numeric(value) {
6
+ if (typeof value !== 'number' && !(typeof value === 'string' && value.trim() !== ''))
7
+ return null;
8
+ const result = Number(value);
9
+ return Number.isFinite(result) ? result : null;
10
+ }
11
+ export function percent(value) {
12
+ return Math.max(0, Math.min(100, value));
13
+ }
14
+ export function timestamp(value) {
15
+ const number = numeric(value);
16
+ const parsed = number !== null
17
+ ? (number < 1e12 ? number * 1000 : number)
18
+ : typeof value === 'string' ? Date.parse(value) : NaN;
19
+ return Number.isFinite(parsed) && parsed > 0 && parsed <= 8.64e15 ? parsed : null;
20
+ }
21
+ export function windowLabel(seconds) {
22
+ if (seconds === 604800)
23
+ return '1w';
24
+ if (seconds % 86400 === 0)
25
+ return `${seconds / 86400}d`;
26
+ if (seconds % 3600 === 0)
27
+ return `${seconds / 3600}h`;
28
+ if (seconds % 60 === 0)
29
+ return `${seconds / 60}m`;
30
+ return `${seconds}s`;
31
+ }
32
+ // API-controlled labels may not inject terminal controls, ANSI, or newlines.
33
+ export function safeLabel(value) {
34
+ return value.replace(/[\p{Cc}\p{Cf}\p{Zl}\p{Zp}]/gu, '').slice(0, 64);
35
+ }
@@ -0,0 +1,9 @@
1
+ import type { QuotaAdapter, QuotaSnapshot } from './types.js';
2
+ export declare class AdapterRegistry {
3
+ private readonly adapters;
4
+ constructor(adapters?: readonly QuotaAdapter[]);
5
+ register(adapter: QuotaAdapter): void;
6
+ get(provider: string | undefined): QuotaAdapter | undefined;
7
+ list(): readonly QuotaAdapter[];
8
+ }
9
+ export declare function validateSnapshot(snapshot: QuotaSnapshot): QuotaSnapshot;
@@ -0,0 +1,78 @@
1
+ import { safeLabel } from "./parse.js";
2
+ import { QuotaError } from "./types.js";
3
+ export class AdapterRegistry {
4
+ adapters = new Map();
5
+ constructor(adapters = []) {
6
+ for (const adapter of adapters)
7
+ this.register(adapter);
8
+ }
9
+ register(adapter) {
10
+ if (!adapter.provider || !adapter.label || this.adapters.has(adapter.provider)) {
11
+ throw new Error('Quota adapter must have a unique provider ID and a label');
12
+ }
13
+ this.adapters.set(adapter.provider, { ...adapter, label: safeLabel(adapter.label) });
14
+ }
15
+ get(provider) {
16
+ return provider ? this.adapters.get(provider) : undefined;
17
+ }
18
+ list() {
19
+ return [...this.adapters.values()];
20
+ }
21
+ }
22
+ // Validate even custom adapter output before it reaches the bar.
23
+ export function validateSnapshot(snapshot) {
24
+ if (!snapshot || !Number.isFinite(snapshot.fetchedAt) || !Array.isArray(snapshot.windows)
25
+ || (snapshot.windows.length === 0 && snapshot.balance === undefined
26
+ && snapshot.allowance === undefined)
27
+ || snapshot.windows.length > 64)
28
+ throw new QuotaError('schema');
29
+ let balance;
30
+ if (snapshot.balance !== undefined) {
31
+ const data = snapshot.balance;
32
+ if (!data || typeof data.currency !== 'string' || !/^[A-Z]{3}$/.test(data.currency)
33
+ || !Number.isFinite(data.remaining)
34
+ || (data.unlimited !== undefined && typeof data.unlimited !== 'boolean'))
35
+ throw new QuotaError('schema');
36
+ balance = { currency: data.currency, remaining: data.remaining,
37
+ ...(data.unlimited === true ? { unlimited: true } : {}) };
38
+ }
39
+ const allowance = snapshot.allowance === undefined ? undefined : validateAllowance(snapshot.allowance);
40
+ let spend;
41
+ if (snapshot.spend !== undefined) {
42
+ const data = snapshot.spend;
43
+ if (!data || typeof data.currency !== 'string' || !/^[A-Z]{3}$/.test(data.currency)
44
+ || (data.today === undefined && data.lifetime === undefined)
45
+ || (data.today !== undefined && (!Number.isFinite(data.today) || data.today < 0))
46
+ || (data.lifetime !== undefined && (!Number.isFinite(data.lifetime) || data.lifetime < 0))) {
47
+ throw new QuotaError('schema');
48
+ }
49
+ spend = { currency: data.currency, ...(data.today !== undefined ? { today: data.today } : {}),
50
+ ...(data.lifetime !== undefined ? { lifetime: data.lifetime } : {}) };
51
+ }
52
+ const ids = new Set();
53
+ const windows = snapshot.windows.map(window => {
54
+ if (!window || typeof window.id !== 'string' || ids.has(window.id)
55
+ || typeof window.label !== 'string'
56
+ || (window.scope !== undefined && typeof window.scope !== 'string')
57
+ || !Number.isFinite(window.durationSeconds) || window.durationSeconds <= 0
58
+ || (window.remainingPercent !== null && (!Number.isFinite(window.remainingPercent)
59
+ || window.remainingPercent < 0 || window.remainingPercent > 100))
60
+ || (window.resetAt !== null && (!Number.isFinite(window.resetAt) || window.resetAt <= 0))) {
61
+ throw new QuotaError('schema');
62
+ }
63
+ ids.add(window.id);
64
+ return { ...window, label: safeLabel(window.label),
65
+ ...(window.amounts !== undefined ? { amounts: validateAllowance(window.amounts) } : {}),
66
+ ...(window.scope !== undefined ? { scope: safeLabel(window.scope) } : {}) };
67
+ });
68
+ return { fetchedAt: snapshot.fetchedAt, windows, ...(balance ? { balance } : {}),
69
+ ...(allowance ? { allowance } : {}), ...(spend ? { spend } : {}) };
70
+ }
71
+ function validateAllowance(data) {
72
+ if (!data || typeof data.currency !== 'string' || !/^[A-Z]{3}$/.test(data.currency)
73
+ || !Number.isFinite(data.limit) || data.limit <= 0
74
+ || !Number.isFinite(data.used) || data.used < 0
75
+ || !Number.isFinite(data.remaining))
76
+ throw new QuotaError('schema');
77
+ return { currency: data.currency, limit: data.limit, used: data.used, remaining: data.remaining };
78
+ }
@@ -0,0 +1,50 @@
1
+ import { AdapterRegistry } from './registry.js';
2
+ import type { NewApiOptions } from './new-api.js';
3
+ import type { YesCodeOptions } from './yescode.js';
4
+ import type { QuotaAdapter } from './types.js';
5
+ export type FooterWindows = number | 'all';
6
+ export type ProviderQuotaConfig = ({
7
+ adapter: 'new-api';
8
+ quotaPerUnit: number;
9
+ currency: string;
10
+ } & Pick<NewApiOptions, 'dashboardAccessToken' | 'dashboardUserId'>) | ({
11
+ adapter: 'yescode';
12
+ } & YesCodeOptions) | {
13
+ adapter: 'exec';
14
+ command: string[];
15
+ label: string;
16
+ } | {
17
+ adapter: 'alias';
18
+ of: string;
19
+ } | {
20
+ adapter: 'deepseek' | 'sub2api' | 'opencode-go';
21
+ };
22
+ export interface QuotaConfig {
23
+ providers: Record<string, ProviderQuotaConfig>;
24
+ footerWindows?: FooterWindows;
25
+ }
26
+ export declare class QuotaConfigError extends Error {
27
+ constructor();
28
+ }
29
+ export declare function parseQuotaConfig(value: unknown): QuotaConfig;
30
+ export declare function loadQuotaFooterWindows(settings: unknown): FooterWindows | undefined;
31
+ export declare function loadQuotaAdaptersFromSettings(settings: unknown): QuotaAdapter[];
32
+ export declare function loadQuotaAliasesFromSettings(settings: unknown): Record<string, string>;
33
+ export interface ComposedQuota {
34
+ registry: AdapterRegistry;
35
+ aliases: Record<string, string>;
36
+ footerWindows: FooterWindows | undefined;
37
+ }
38
+ /**
39
+ * The full quota surface in one place: built-in adapters, settings-bound adapters
40
+ * and aliases wired exactly as the Pi extension does. Settings are deliberate user
41
+ * bindings, so they take precedence over defaults; AdapterRegistry still detects an
42
+ * ambiguous duplicate among explicit adapters. Alias providers share their target's
43
+ * account: one hop only, and a dangling alias stays visible as an error instead of
44
+ * vanishing silently.
45
+ */
46
+ export declare function composeQuotaRegistry(input?: {
47
+ settings?: unknown;
48
+ adapters?: readonly QuotaAdapter[];
49
+ aliases?: Record<string, string>;
50
+ }): ComposedQuota;
@@ -0,0 +1,172 @@
1
+ import { createAliasAdapter, createDanglingAliasAdapter } from "./alias.js";
2
+ import { codexAdapter } from "./codex.js";
3
+ import { createDeepSeekAdapter } from "./deepseek.js";
4
+ import { createExecAdapter, validateExecOptions } from "./exec.js";
5
+ import { kimiAdapter } from "./kimi.js";
6
+ import { createNewApiAdapter, validateNewApiOptions } from "./new-api.js";
7
+ import { createOpenCodeGoAdapter, openCodeGoAdapter } from "./opencode-go.js";
8
+ import { AdapterRegistry } from "./registry.js";
9
+ import { createSub2ApiAdapter } from "./sub2api.js";
10
+ import { createYesCodeAdapter, validateYesCodeOptions } from "./yescode.js";
11
+ const DASHBOARD_KEYS = ['dashboardAccessToken', 'dashboardUserId'];
12
+ function parseDashboardOptions(item) {
13
+ const result = {};
14
+ if (item.dashboardAccessToken !== undefined) {
15
+ if (typeof item.dashboardAccessToken !== 'string' || item.dashboardAccessToken.trim() === '')
16
+ throw new QuotaConfigError();
17
+ result.dashboardAccessToken = item.dashboardAccessToken;
18
+ }
19
+ if (item.dashboardUserId !== undefined) {
20
+ if (typeof item.dashboardUserId !== 'number'
21
+ || !Number.isSafeInteger(item.dashboardUserId) || item.dashboardUserId <= 0)
22
+ throw new QuotaConfigError();
23
+ result.dashboardUserId = item.dashboardUserId;
24
+ }
25
+ return result;
26
+ }
27
+ function parseFooterWindows(value) {
28
+ if (value === 'all')
29
+ return value;
30
+ if (typeof value === 'number' && Number.isSafeInteger(value) && value >= 1)
31
+ return value;
32
+ throw new QuotaConfigError();
33
+ }
34
+ export class QuotaConfigError extends Error {
35
+ constructor() {
36
+ // Settings may accidentally contain secrets. Never echo content/values.
37
+ 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".');
38
+ }
39
+ }
40
+ function record(value) {
41
+ return value !== null && typeof value === 'object' && !Array.isArray(value);
42
+ }
43
+ export function parseQuotaConfig(value) {
44
+ if (!record(value) || Object.keys(value).some(key => key !== 'providers' && key !== 'footerWindows')
45
+ || !record(value.providers) || Object.keys(value.providers).length > 64)
46
+ throw new QuotaConfigError();
47
+ const providers = Object.create(null);
48
+ for (const [provider, item] of Object.entries(value.providers)) {
49
+ if (!/^[A-Za-z0-9][A-Za-z0-9._:/-]{0,127}$/.test(provider)
50
+ || ['openai-codex', 'kimi-coding', 'opencode-go'].includes(provider)
51
+ || !record(item))
52
+ throw new QuotaConfigError();
53
+ if (item.adapter === 'deepseek') {
54
+ if (Object.keys(item).some(key => key !== 'adapter'))
55
+ throw new QuotaConfigError();
56
+ providers[provider] = { adapter: 'deepseek' };
57
+ continue;
58
+ }
59
+ if (item.adapter === 'sub2api' || item.adapter === 'opencode-go') {
60
+ if (Object.keys(item).length !== 1)
61
+ throw new QuotaConfigError();
62
+ providers[provider] = { adapter: item.adapter };
63
+ continue;
64
+ }
65
+ if (item.adapter === 'yescode') {
66
+ if (Object.keys(item).some(key => !['adapter', 'username', 'password'].includes(key))
67
+ || typeof item.username !== 'string' || typeof item.password !== 'string')
68
+ throw new QuotaConfigError();
69
+ try {
70
+ providers[provider] = { adapter: 'yescode',
71
+ ...validateYesCodeOptions({ username: item.username, password: item.password }) };
72
+ }
73
+ catch {
74
+ throw new QuotaConfigError();
75
+ }
76
+ continue;
77
+ }
78
+ if (item.adapter === 'exec') {
79
+ if (Object.keys(item).some(key => !['adapter', 'command', 'label'].includes(key)))
80
+ throw new QuotaConfigError();
81
+ try {
82
+ providers[provider] = { adapter: 'exec', ...validateExecOptions(item) };
83
+ }
84
+ catch {
85
+ throw new QuotaConfigError();
86
+ }
87
+ continue;
88
+ }
89
+ if (item.adapter === 'alias') {
90
+ if (Object.keys(item).some(key => key !== 'adapter' && key !== 'of')
91
+ || typeof item.of !== 'string' || !/^[A-Za-z0-9][A-Za-z0-9._:/-]{0,127}$/.test(item.of)
92
+ || item.of === provider)
93
+ throw new QuotaConfigError();
94
+ providers[provider] = { adapter: 'alias', of: item.of };
95
+ continue;
96
+ }
97
+ if (item.adapter !== 'new-api'
98
+ || Object.keys(item).some(key => !['adapter', 'quotaPerUnit', 'currency', ...DASHBOARD_KEYS].includes(key))
99
+ || (item.quotaPerUnit !== undefined && typeof item.quotaPerUnit !== 'number')
100
+ || (item.currency !== undefined && typeof item.currency !== 'string'))
101
+ throw new QuotaConfigError();
102
+ try {
103
+ const settings = validateNewApiOptions({ quotaPerUnit: item.quotaPerUnit, currency: item.currency });
104
+ providers[provider] = { adapter: 'new-api', ...settings, ...parseDashboardOptions(item) };
105
+ }
106
+ catch {
107
+ throw new QuotaConfigError();
108
+ }
109
+ }
110
+ const config = { providers };
111
+ if (value.footerWindows !== undefined)
112
+ config.footerWindows = parseFooterWindows(value.footerWindows);
113
+ return config;
114
+ }
115
+ export function loadQuotaFooterWindows(settings) {
116
+ if (!record(settings) || settings.quotaUsage === undefined)
117
+ return undefined;
118
+ return parseQuotaConfig(settings.quotaUsage).footerWindows;
119
+ }
120
+ export function loadQuotaAdaptersFromSettings(settings) {
121
+ if (!record(settings) || settings.quotaUsage === undefined)
122
+ return [];
123
+ const config = parseQuotaConfig(settings.quotaUsage);
124
+ return Object.entries(config.providers).flatMap(([provider, options]) => {
125
+ switch (options.adapter) {
126
+ case 'alias': return []; // Aliases declare no adapter; extension.ts wires them.
127
+ case 'deepseek': return createDeepSeekAdapter(provider);
128
+ case 'sub2api': return createSub2ApiAdapter(provider);
129
+ case 'opencode-go': return createOpenCodeGoAdapter(provider);
130
+ case 'yescode': return createYesCodeAdapter(provider, options);
131
+ case 'exec': return createExecAdapter(provider, options);
132
+ case 'new-api': return createNewApiAdapter(provider, options);
133
+ }
134
+ });
135
+ }
136
+ export function loadQuotaAliasesFromSettings(settings) {
137
+ if (!record(settings) || settings.quotaUsage === undefined)
138
+ return {};
139
+ const config = parseQuotaConfig(settings.quotaUsage);
140
+ const aliases = Object.create(null);
141
+ for (const [provider, options] of Object.entries(config.providers)) {
142
+ if (options.adapter === 'alias')
143
+ aliases[provider] = options.of;
144
+ }
145
+ return aliases;
146
+ }
147
+ /**
148
+ * The full quota surface in one place: built-in adapters, settings-bound adapters
149
+ * and aliases wired exactly as the Pi extension does. Settings are deliberate user
150
+ * bindings, so they take precedence over defaults; AdapterRegistry still detects an
151
+ * ambiguous duplicate among explicit adapters. Alias providers share their target's
152
+ * account: one hop only, and a dangling alias stays visible as an error instead of
153
+ * vanishing silently.
154
+ */
155
+ export function composeQuotaRegistry(input = {}) {
156
+ const settingsAdapters = input.settings === undefined ? [] : loadQuotaAdaptersFromSettings(input.settings);
157
+ const explicitAdapters = [...settingsAdapters, ...(input.adapters ?? [])];
158
+ const aliases = { ...loadQuotaAliasesFromSettings(input.settings ?? {}), ...(input.aliases ?? {}) };
159
+ const explicitProviderIds = new Set([...explicitAdapters.map(adapter => adapter.provider), ...Object.keys(aliases)]);
160
+ const registry = new AdapterRegistry([
161
+ codexAdapter, kimiAdapter, openCodeGoAdapter, createDeepSeekAdapter('deepseek'),
162
+ ].filter(adapter => !explicitProviderIds.has(adapter.provider)).concat(explicitAdapters));
163
+ for (const [alias, targetId] of Object.entries(aliases)) {
164
+ const target = aliases[targetId] === undefined ? registry.get(targetId) : undefined;
165
+ registry.register(target ? createAliasAdapter(alias, target) : createDanglingAliasAdapter(alias));
166
+ }
167
+ return {
168
+ registry,
169
+ aliases,
170
+ footerWindows: input.settings === undefined ? undefined : loadQuotaFooterWindows(input.settings),
171
+ };
172
+ }
@@ -0,0 +1,4 @@
1
+ import type { QuotaAdapter, QuotaSnapshot } from './types.js';
2
+ export declare function sub2ApiUsageUrl(baseUrl: string | undefined): string;
3
+ export declare function parseSub2ApiUsage(payload: unknown): Omit<QuotaSnapshot, 'fetchedAt'>;
4
+ export declare function createSub2ApiAdapter(provider: string): QuotaAdapter;