@astrosheep/pi-quota 0.5.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 +77 -0
- package/examples/custom-provider.ts +30 -0
- package/examples/preview.ts +30 -0
- package/examples/settings.fragment.json +15 -0
- package/package.json +36 -0
- package/src/bar/balance.ts +57 -0
- package/src/bar/bar.ts +50 -0
- package/src/bar/card.ts +35 -0
- package/src/bar/quota.ts +94 -0
- package/src/config.ts +79 -0
- package/src/extension.ts +152 -0
- package/src/index.ts +24 -0
- package/src/query/codex.ts +65 -0
- package/src/query/controller.ts +115 -0
- package/src/query/deepseek.ts +35 -0
- package/src/query/http.ts +86 -0
- package/src/query/kimi.ts +53 -0
- package/src/query/new-api.ts +154 -0
- package/src/query/opencode-go.ts +42 -0
- package/src/query/parse.ts +35 -0
- package/src/query/registry.ts +78 -0
- package/src/query/sub2api.ts +123 -0
- package/src/query/types.ts +76 -0
- package/src/query/url.ts +13 -0
package/src/index.ts
ADDED
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
import { getAgentDir, SettingsManager } from '@earendil-works/pi-coding-agent';
|
|
2
|
+
import type { ExtensionAPI } from '@earendil-works/pi-coding-agent';
|
|
3
|
+
import { createQuotaExtension } from './extension.ts';
|
|
4
|
+
import { loadQuotaAdaptersFromSettings, QuotaConfigError } from './config.ts';
|
|
5
|
+
import type { QuotaAdapter } from './query/types.ts';
|
|
6
|
+
|
|
7
|
+
export default function (pi: ExtensionAPI): void {
|
|
8
|
+
let adapters: QuotaAdapter[] = [];
|
|
9
|
+
try {
|
|
10
|
+
// Quota provider bindings belong to the user's global Pi settings. Project
|
|
11
|
+
// settings are intentionally not read here: a project must not redirect a
|
|
12
|
+
// globally authenticated provider's credential to an account endpoint.
|
|
13
|
+
const settings = SettingsManager.create(process.cwd(), getAgentDir(), {
|
|
14
|
+
projectTrusted: false,
|
|
15
|
+
}).getGlobalSettings() as unknown;
|
|
16
|
+
adapters = loadQuotaAdaptersFromSettings(settings);
|
|
17
|
+
} catch {
|
|
18
|
+
pi.on('session_start', (_event, ctx) => {
|
|
19
|
+
if (ctx.hasUI) ctx.ui.notify(new QuotaConfigError().message, 'warning');
|
|
20
|
+
});
|
|
21
|
+
}
|
|
22
|
+
// Invalid optional settings never disable the built-in Codex/Kimi/OpenCode Go adapters.
|
|
23
|
+
createQuotaExtension({ adapters })(pi);
|
|
24
|
+
}
|
|
@@ -0,0 +1,65 @@
|
|
|
1
|
+
import { bearer, header, officialAuth } from './http.ts';
|
|
2
|
+
import { numeric, object, percent, safeLabel, timestamp, windowLabel } from './parse.ts';
|
|
3
|
+
import { QuotaError } from './types.ts';
|
|
4
|
+
import type { QuotaAdapter, QuotaWindow } from './types.ts';
|
|
5
|
+
|
|
6
|
+
// Decode only to obtain routing metadata from the same resolved token.
|
|
7
|
+
// This does not verify a JWT; the official server verifies authentication.
|
|
8
|
+
export function codexAccountId(token: string): string | undefined {
|
|
9
|
+
try {
|
|
10
|
+
const payload = object(JSON.parse(Buffer.from(token.split('.')[1] ?? '', 'base64url').toString('utf8')));
|
|
11
|
+
const id = object(payload['https://api.openai.com/auth']).chatgpt_account_id;
|
|
12
|
+
return typeof id === 'string' && id.length > 0 ? id : undefined;
|
|
13
|
+
} catch { return undefined; }
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
export function parseCodex(payload: unknown, now: number): QuotaWindow[] {
|
|
17
|
+
const data = object(payload);
|
|
18
|
+
const windows: QuotaWindow[] = [];
|
|
19
|
+
const addDomain = (raw: unknown, domain: string, scope?: string) => {
|
|
20
|
+
const rate = object(raw);
|
|
21
|
+
for (const [key, fallback] of [['primary_window', 18000], ['secondary_window', 604800]] as const) {
|
|
22
|
+
if (rate[key] == null) continue;
|
|
23
|
+
const window = object(rate[key]);
|
|
24
|
+
const duration = numeric(window.limit_window_seconds) ?? fallback;
|
|
25
|
+
if (duration <= 0) throw new QuotaError('schema');
|
|
26
|
+
const remaining = numeric(window.remaining_percent) ?? numeric(window.percent_left);
|
|
27
|
+
const used = numeric(window.used_percent);
|
|
28
|
+
const resetAfter = numeric(window.reset_after_seconds);
|
|
29
|
+
const resetAt = timestamp(window.reset_at ?? window.reset_time_ms)
|
|
30
|
+
?? (resetAfter !== null && resetAfter >= 0 ? now + resetAfter * 1000 : null);
|
|
31
|
+
windows.push({
|
|
32
|
+
id: `${domain}/${key}`, label: windowLabel(duration), durationSeconds: duration,
|
|
33
|
+
remainingPercent: remaining !== null ? percent(remaining) : used !== null ? percent(100 - used) : null,
|
|
34
|
+
resetAt, ...(scope ? { scope } : {}),
|
|
35
|
+
});
|
|
36
|
+
}
|
|
37
|
+
};
|
|
38
|
+
addDomain(data.rate_limit, 'shared');
|
|
39
|
+
if (Array.isArray(data.additional_rate_limits)) {
|
|
40
|
+
for (const [index, entry] of data.additional_rate_limits.entries()) {
|
|
41
|
+
const item = object(entry);
|
|
42
|
+
const name = item.limit_name ?? item.metered_feature;
|
|
43
|
+
const scope = typeof name === 'string' ? safeLabel(name) : `Model ${index + 1}`;
|
|
44
|
+
addDomain(item.rate_limit, `additional-${index}`, scope);
|
|
45
|
+
}
|
|
46
|
+
}
|
|
47
|
+
if (windows.length === 0) throw new QuotaError('schema');
|
|
48
|
+
return windows;
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
export const codexAdapter: QuotaAdapter = {
|
|
52
|
+
provider: 'openai-codex', label: 'Codex',
|
|
53
|
+
async query(context) {
|
|
54
|
+
const auth = await officialAuth(context, 'https://chatgpt.com');
|
|
55
|
+
const token = bearer(auth);
|
|
56
|
+
const account = header(auth, 'chatgpt-account-id') ?? codexAccountId(token);
|
|
57
|
+
if (!account) throw new QuotaError('unsupported-auth');
|
|
58
|
+
const data = await context.getJson('https://chatgpt.com/backend-api/wham/usage', {
|
|
59
|
+
Authorization: `Bearer ${token}`, 'ChatGPT-Account-Id': account,
|
|
60
|
+
Origin: 'https://chatgpt.com', Referer: 'https://chatgpt.com/',
|
|
61
|
+
}, context.signal);
|
|
62
|
+
const now = context.now();
|
|
63
|
+
return { windows: parseCodex(data, now), fetchedAt: now };
|
|
64
|
+
},
|
|
65
|
+
};
|
|
@@ -0,0 +1,115 @@
|
|
|
1
|
+
import { AdapterRegistry, validateSnapshot } from './registry.ts';
|
|
2
|
+
import { QuotaError } from './types.ts';
|
|
3
|
+
import type { QueryContext, QuotaState } from './types.ts';
|
|
4
|
+
|
|
5
|
+
type AuthResolver = QueryContext['getAuth'];
|
|
6
|
+
interface ControllerOptions {
|
|
7
|
+
registry: AdapterRegistry;
|
|
8
|
+
getJson: QueryContext['getJson'];
|
|
9
|
+
onState(state: QuotaState): void;
|
|
10
|
+
now?: () => number;
|
|
11
|
+
intervalMs?: number;
|
|
12
|
+
timeoutMs?: number;
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
// Waiting is bounded even if a custom adapter or auth resolver ignores abort.
|
|
16
|
+
async function abortable<T>(work: Promise<T>, signal: AbortSignal): Promise<T> {
|
|
17
|
+
signal.throwIfAborted();
|
|
18
|
+
let rejectAbort: () => void = () => {};
|
|
19
|
+
const aborted = new Promise<never>((_resolve, reject) => {
|
|
20
|
+
rejectAbort = () => reject(signal.reason);
|
|
21
|
+
signal.addEventListener('abort', rejectAbort, { once: true });
|
|
22
|
+
});
|
|
23
|
+
try { return await Promise.race([work, aborted]); }
|
|
24
|
+
finally { signal.removeEventListener('abort', rejectAbort); }
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
export class QuotaController {
|
|
28
|
+
state: QuotaState = { kind: 'hidden' };
|
|
29
|
+
private provider?: string;
|
|
30
|
+
private generation = 0;
|
|
31
|
+
private request?: { abort: AbortController; promise: Promise<void> };
|
|
32
|
+
private nextAttempt = 0;
|
|
33
|
+
private failures = 0;
|
|
34
|
+
private readonly options: ControllerOptions;
|
|
35
|
+
private readonly now: () => number;
|
|
36
|
+
private readonly interval: number;
|
|
37
|
+
private readonly timeout: number;
|
|
38
|
+
|
|
39
|
+
constructor(options: ControllerOptions) {
|
|
40
|
+
this.options = options;
|
|
41
|
+
this.now = options.now ?? Date.now;
|
|
42
|
+
this.interval = options.intervalMs ?? 60000;
|
|
43
|
+
this.timeout = options.timeoutMs ?? 10000;
|
|
44
|
+
if (!Number.isFinite(this.interval) || this.interval <= 0
|
|
45
|
+
|| !Number.isFinite(this.timeout) || this.timeout <= 0) throw new Error('Invalid quota timing');
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
private publish(state: QuotaState): void {
|
|
49
|
+
this.state = state;
|
|
50
|
+
this.options.onState(state);
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
select(provider: string | undefined): void {
|
|
54
|
+
this.generation++;
|
|
55
|
+
this.request?.abort.abort();
|
|
56
|
+
this.request = undefined;
|
|
57
|
+
this.provider = provider;
|
|
58
|
+
this.nextAttempt = 0;
|
|
59
|
+
this.failures = 0;
|
|
60
|
+
const adapter = this.options.registry.get(provider);
|
|
61
|
+
// Clear old values synchronously, before auth resolution or any network I/O.
|
|
62
|
+
this.publish(adapter
|
|
63
|
+
? { kind: 'loading', provider: adapter.provider, label: adapter.label }
|
|
64
|
+
: { kind: 'hidden' });
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
stop(): void { this.select(undefined); }
|
|
68
|
+
|
|
69
|
+
refresh(getAuth: AuthResolver, force = false): Promise<void> {
|
|
70
|
+
const adapter = this.options.registry.get(this.provider);
|
|
71
|
+
if (!adapter) return Promise.resolve();
|
|
72
|
+
if (this.request && !force) return this.request.promise;
|
|
73
|
+
if (!force && this.now() < this.nextAttempt) return Promise.resolve();
|
|
74
|
+
if (force) {
|
|
75
|
+
this.generation++;
|
|
76
|
+
this.request?.abort.abort();
|
|
77
|
+
this.request = undefined;
|
|
78
|
+
}
|
|
79
|
+
const generation = this.generation;
|
|
80
|
+
const abort = new AbortController();
|
|
81
|
+
const active = () => this.generation === generation && this.request?.abort === abort;
|
|
82
|
+
// Keep the last result visible during refresh; selection already clears it.
|
|
83
|
+
if (this.state.kind !== 'ready' && this.state.kind !== 'loading') {
|
|
84
|
+
this.publish({ kind: 'loading', provider: adapter.provider, label: adapter.label });
|
|
85
|
+
}
|
|
86
|
+
const deadline = setTimeout(() => abort.abort(new QuotaError('timeout')), this.timeout);
|
|
87
|
+
const promise = Promise.resolve().then(async () => {
|
|
88
|
+
try {
|
|
89
|
+
abort.signal.throwIfAborted();
|
|
90
|
+
const result = await abortable(adapter.query({
|
|
91
|
+
provider: adapter.provider, signal: abort.signal, now: this.now,
|
|
92
|
+
getAuth, getJson: this.options.getJson,
|
|
93
|
+
}), abort.signal);
|
|
94
|
+
if (!active()) return;
|
|
95
|
+
const snapshot = validateSnapshot(result);
|
|
96
|
+
this.failures = 0;
|
|
97
|
+
this.nextAttempt = this.now() + this.interval;
|
|
98
|
+
this.publish({ kind: 'ready', provider: adapter.provider, label: adapter.label, snapshot });
|
|
99
|
+
} catch (error) {
|
|
100
|
+
if (!active()) return;
|
|
101
|
+
const safe = error instanceof QuotaError ? error : new QuotaError('network');
|
|
102
|
+
this.failures++;
|
|
103
|
+
const backoff = Math.min(900000, this.interval * 2 ** Math.min(this.failures - 1, 4));
|
|
104
|
+
this.nextAttempt = this.now() + Math.max(backoff, safe.retryAfterMs ?? 0);
|
|
105
|
+
// Do not retain a prior account's cached quota after auth changes/errors.
|
|
106
|
+
this.publish({ kind: 'error', provider: adapter.provider, label: adapter.label, code: safe.code });
|
|
107
|
+
} finally {
|
|
108
|
+
clearTimeout(deadline);
|
|
109
|
+
if (active()) this.request = undefined;
|
|
110
|
+
}
|
|
111
|
+
});
|
|
112
|
+
this.request = { abort, promise };
|
|
113
|
+
return promise;
|
|
114
|
+
}
|
|
115
|
+
}
|
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
import { bearer, officialAuth } from './http.ts';
|
|
2
|
+
import { numeric, object } from './parse.ts';
|
|
3
|
+
import { QuotaError } from './types.ts';
|
|
4
|
+
import type { AccountBalance, QuotaAdapter } from './types.ts';
|
|
5
|
+
|
|
6
|
+
export const DEEPSEEK_ORIGIN = 'https://api.deepseek.com';
|
|
7
|
+
|
|
8
|
+
// https://api-docs.deepseek.com/api/get-user-balance
|
|
9
|
+
// GET /user/balance (also mounted under /v1/). The sk- key works directly.
|
|
10
|
+
// Amounts arrive as strings in yuan; balance_infos may list several currencies.
|
|
11
|
+
export function parseDeepSeekBalance(payload: unknown): AccountBalance {
|
|
12
|
+
const body = object(payload);
|
|
13
|
+
if (!Array.isArray(body.balance_infos) || body.balance_infos.length === 0) throw new QuotaError('schema');
|
|
14
|
+
for (const entry of body.balance_infos) {
|
|
15
|
+
const info = object(entry);
|
|
16
|
+
const remaining = numeric(info.total_balance);
|
|
17
|
+
if (typeof info.currency !== 'string' || !/^[A-Z]{3}$/.test(info.currency)
|
|
18
|
+
|| remaining === null || remaining < 0) continue;
|
|
19
|
+
// The endpoint exposes no usage total; only return the actual balance.
|
|
20
|
+
return { currency: info.currency, remaining };
|
|
21
|
+
}
|
|
22
|
+
throw new QuotaError('schema');
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
export function createDeepSeekAdapter(provider: string): QuotaAdapter {
|
|
26
|
+
return {
|
|
27
|
+
provider, label: provider,
|
|
28
|
+
async query(context) {
|
|
29
|
+
const auth = await officialAuth(context, DEEPSEEK_ORIGIN);
|
|
30
|
+
const payload = await context.getJson(`${DEEPSEEK_ORIGIN}/user/balance`,
|
|
31
|
+
{ Authorization: `Bearer ${bearer(auth)}` }, context.signal);
|
|
32
|
+
return { windows: [], balance: parseDeepSeekBalance(payload), fetchedAt: context.now() };
|
|
33
|
+
},
|
|
34
|
+
};
|
|
35
|
+
}
|
|
@@ -0,0 +1,86 @@
|
|
|
1
|
+
import { QuotaError } from './types.ts';
|
|
2
|
+
import type { ProviderAuth, QueryContext } from './types.ts';
|
|
3
|
+
|
|
4
|
+
export function createJsonClient(fetcher: typeof fetch = globalThis.fetch): QueryContext['getJson'] {
|
|
5
|
+
return async (url, headers, signal) => {
|
|
6
|
+
try {
|
|
7
|
+
const response = await fetcher(url, {
|
|
8
|
+
method: 'GET', headers: { Accept: 'application/json', ...headers },
|
|
9
|
+
signal, redirect: 'error', // Never forward credentials to a redirected endpoint.
|
|
10
|
+
});
|
|
11
|
+
if (!response.ok) {
|
|
12
|
+
const retry = response.headers.get('retry-after');
|
|
13
|
+
const seconds = retry && /^\d+(\.\d+)?$/.test(retry) ? Number(retry) : NaN;
|
|
14
|
+
const delay = Number.isFinite(seconds) ? seconds * 1000
|
|
15
|
+
: retry ? Date.parse(retry) - Date.now() : NaN;
|
|
16
|
+
await response.body?.cancel();
|
|
17
|
+
if (response.status === 401 || response.status === 403) throw new QuotaError('auth');
|
|
18
|
+
if (response.status === 429) {
|
|
19
|
+
throw new QuotaError('rate-limit', Number.isFinite(delay) ? Math.max(0, delay) : undefined);
|
|
20
|
+
}
|
|
21
|
+
throw new QuotaError('http');
|
|
22
|
+
}
|
|
23
|
+
// Quota responses are small. Bound memory and reject unexpected HTML/proxy pages.
|
|
24
|
+
const reader = response.body?.getReader();
|
|
25
|
+
if (!reader) throw new QuotaError('schema');
|
|
26
|
+
const chunks: Uint8Array[] = [];
|
|
27
|
+
let size = 0;
|
|
28
|
+
try {
|
|
29
|
+
while (true) {
|
|
30
|
+
const part = await reader.read();
|
|
31
|
+
if (part.done) break;
|
|
32
|
+
size += part.value.length;
|
|
33
|
+
if (size > 256 * 1024) {
|
|
34
|
+
await reader.cancel();
|
|
35
|
+
throw new QuotaError('schema');
|
|
36
|
+
}
|
|
37
|
+
chunks.push(part.value);
|
|
38
|
+
}
|
|
39
|
+
} finally {
|
|
40
|
+
reader.releaseLock();
|
|
41
|
+
}
|
|
42
|
+
try {
|
|
43
|
+
return JSON.parse(Buffer.concat(chunks).toString('utf8')) as unknown;
|
|
44
|
+
} catch {
|
|
45
|
+
throw new QuotaError('schema');
|
|
46
|
+
}
|
|
47
|
+
} catch (error) {
|
|
48
|
+
if (error instanceof QuotaError) throw error;
|
|
49
|
+
if (signal.aborted) throw signal.reason;
|
|
50
|
+
throw new QuotaError('network');
|
|
51
|
+
}
|
|
52
|
+
};
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
export function header(auth: ProviderAuth, name: string): string | undefined {
|
|
56
|
+
return Object.entries(auth.headers ?? {}).find(([key]) => key.toLowerCase() === name.toLowerCase())?.[1];
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
export async function officialAuth(context: QueryContext, origin: string): Promise<ProviderAuth> {
|
|
60
|
+
let auth: ProviderAuth | undefined;
|
|
61
|
+
try {
|
|
62
|
+
auth = await context.getAuth(context.provider);
|
|
63
|
+
} catch {
|
|
64
|
+
throw new QuotaError('auth');
|
|
65
|
+
}
|
|
66
|
+
context.signal.throwIfAborted();
|
|
67
|
+
if (!auth) throw new QuotaError('auth');
|
|
68
|
+
if (auth.baseUrl) {
|
|
69
|
+
let actual: URL;
|
|
70
|
+
try { actual = new URL(auth.baseUrl); } catch { throw new QuotaError('unsupported-auth'); }
|
|
71
|
+
if (actual.origin !== origin || actual.username || actual.password) throw new QuotaError('unsupported-auth');
|
|
72
|
+
}
|
|
73
|
+
return auth;
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
export function bearer(auth: ProviderAuth): string {
|
|
77
|
+
const authorization = header(auth, 'authorization');
|
|
78
|
+
// A runtime Authorization header overrides apiKey, just as it does for requests.
|
|
79
|
+
if (authorization !== undefined) {
|
|
80
|
+
const token = /^Bearer\s+(\S+)$/i.exec(authorization)?.[1];
|
|
81
|
+
if (!token) throw new QuotaError('unsupported-auth');
|
|
82
|
+
return token;
|
|
83
|
+
}
|
|
84
|
+
if (!auth.apiKey) throw new QuotaError('auth');
|
|
85
|
+
return auth.apiKey;
|
|
86
|
+
}
|
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
import { bearer, officialAuth } from './http.ts';
|
|
2
|
+
import { numeric, object, percent, timestamp, windowLabel } from './parse.ts';
|
|
3
|
+
import { QuotaError } from './types.ts';
|
|
4
|
+
import type { QuotaAdapter, QuotaWindow } from './types.ts';
|
|
5
|
+
|
|
6
|
+
const units: Record<string, number> = {
|
|
7
|
+
TIME_UNIT_SECOND: 1, TIME_UNIT_MINUTE: 60, TIME_UNIT_HOUR: 3600, TIME_UNIT_DAY: 86400,
|
|
8
|
+
};
|
|
9
|
+
|
|
10
|
+
export function parseKimi(payload: unknown): QuotaWindow[] {
|
|
11
|
+
const data = object(payload);
|
|
12
|
+
const windows: QuotaWindow[] = [];
|
|
13
|
+
const add = (raw: unknown, duration: number, id: string) => {
|
|
14
|
+
const detail = object(raw);
|
|
15
|
+
const limit = numeric(detail.limit);
|
|
16
|
+
const used = numeric(detail.used);
|
|
17
|
+
const remaining = numeric(detail.remaining);
|
|
18
|
+
// No default for omitted counts: absence is not proof of zero usage.
|
|
19
|
+
const fraction = limit !== null && limit > 0
|
|
20
|
+
? remaining !== null && remaining >= 0 ? remaining / limit
|
|
21
|
+
: used !== null && used >= 0 ? 1 - used / limit : null
|
|
22
|
+
: null;
|
|
23
|
+
windows.push({
|
|
24
|
+
id, label: windowLabel(duration), durationSeconds: duration,
|
|
25
|
+
remainingPercent: fraction === null ? null : percent(fraction * 100),
|
|
26
|
+
resetAt: timestamp(detail.resetTime ?? detail.reset_at),
|
|
27
|
+
});
|
|
28
|
+
};
|
|
29
|
+
if (data.usage != null && typeof data.usage === 'object') add(data.usage, 604800, 'weekly');
|
|
30
|
+
if (Array.isArray(data.limits)) {
|
|
31
|
+
for (const [index, raw] of data.limits.entries()) {
|
|
32
|
+
const entry = object(raw);
|
|
33
|
+
const window = object(entry.window);
|
|
34
|
+
const multiplier = typeof window.timeUnit === 'string' ? units[window.timeUnit] : undefined;
|
|
35
|
+
const duration = numeric(window.duration);
|
|
36
|
+
if (!entry.detail || !multiplier || duration === null || duration <= 0) continue;
|
|
37
|
+
add(entry.detail, duration * multiplier, `limit-${index}`);
|
|
38
|
+
}
|
|
39
|
+
}
|
|
40
|
+
if (windows.length === 0) throw new QuotaError('schema');
|
|
41
|
+
return windows.sort((a, b) => a.durationSeconds - b.durationSeconds);
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
export const kimiAdapter: QuotaAdapter = {
|
|
45
|
+
provider: 'kimi-coding', label: 'Kimi',
|
|
46
|
+
async query(context) {
|
|
47
|
+
const auth = await officialAuth(context, 'https://api.kimi.com');
|
|
48
|
+
const data = await context.getJson('https://api.kimi.com/coding/v1/usages', {
|
|
49
|
+
Authorization: `Bearer ${bearer(auth)}`,
|
|
50
|
+
}, context.signal);
|
|
51
|
+
return { windows: parseKimi(data), fetchedAt: context.now() };
|
|
52
|
+
},
|
|
53
|
+
};
|
|
@@ -0,0 +1,154 @@
|
|
|
1
|
+
import { bearer } from './http.ts';
|
|
2
|
+
import { numeric, object } from './parse.ts';
|
|
3
|
+
import { QuotaError } from './types.ts';
|
|
4
|
+
import { safeBaseUrl } from './url.ts';
|
|
5
|
+
import type { AccountBalance, QuotaAdapter } from './types.ts';
|
|
6
|
+
|
|
7
|
+
export interface NewApiOptions {
|
|
8
|
+
quotaPerUnit?: number;
|
|
9
|
+
currency?: string;
|
|
10
|
+
// Console dashboard PAT (个人设置 → 安全设置 → 系统访问令牌). When set,
|
|
11
|
+
// /api/user/self is queried first for the real account balance.
|
|
12
|
+
dashboardAccessToken?: string;
|
|
13
|
+
// Numeric user ID, sent as New-Api-User. Only old new-api forks require it.
|
|
14
|
+
dashboardUserId?: number;
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
export function validateNewApiOptions(options: NewApiOptions): { quotaPerUnit: number; currency: string } {
|
|
18
|
+
const quotaPerUnit = options.quotaPerUnit ?? 500000;
|
|
19
|
+
const currency = options.currency ?? 'USD';
|
|
20
|
+
if (!Number.isFinite(quotaPerUnit) || quotaPerUnit <= 0) throw new Error('quotaPerUnit must be a positive number');
|
|
21
|
+
if (typeof currency !== 'string' || !/^[A-Z]{3}$/.test(currency)) throw new Error('currency must be a three-letter uppercase code');
|
|
22
|
+
return { quotaPerUnit, currency };
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
// Billing endpoints live at the deployment root, never under /v1.
|
|
26
|
+
// https://host/v1 -> https://host, https://host/gateway/v1 -> https://host/gateway
|
|
27
|
+
export function newApiRootUrl(baseUrl: string | undefined): string {
|
|
28
|
+
const url = safeBaseUrl(baseUrl);
|
|
29
|
+
const path = url.pathname.replace(/\/+$/, '').replace(/\/v\d+(?:beta\d*)?$/i, '');
|
|
30
|
+
return url.origin + path;
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
// GET /api/usage/token (new-api >= v0.9.0-alpha.8, PR QuantumNous/new-api#1161):
|
|
34
|
+
// accepts the sk- model key itself (TokenAuth), returns per-token quota in
|
|
35
|
+
// the same unit as /api/user/self quota. Response shell: {code:true,data:{...}}.
|
|
36
|
+
export function parseNewApiTokenUsage(payload: unknown, options: NewApiOptions = {}): AccountBalance | null {
|
|
37
|
+
const { quotaPerUnit, currency } = validateNewApiOptions(options);
|
|
38
|
+
const response = object(payload);
|
|
39
|
+
if (response.code !== true) return null;
|
|
40
|
+
const data = object(response.data);
|
|
41
|
+
if (data.object !== 'token_usage') return null;
|
|
42
|
+
const used = numeric(data.total_used);
|
|
43
|
+
// Missing fields must not become a made-up zero balance.
|
|
44
|
+
if (used === null || !Number.isSafeInteger(used) || used < 0) throw new QuotaError('schema');
|
|
45
|
+
if (data.unlimited_quota === true) {
|
|
46
|
+
return { currency, remaining: 0, unlimited: true };
|
|
47
|
+
}
|
|
48
|
+
const remaining = numeric(data.total_available);
|
|
49
|
+
if (remaining === null || !Number.isSafeInteger(remaining)) throw new QuotaError('schema');
|
|
50
|
+
const balance = { currency, remaining: remaining / quotaPerUnit };
|
|
51
|
+
if (!Number.isFinite(balance.remaining)) throw new QuotaError('schema');
|
|
52
|
+
return balance;
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
// GET /api/user/self (UserAuth): the console's own account quota, readable with
|
|
56
|
+
// a dashboard access token (PAT), which sk- keys are not. Old forks also demand
|
|
57
|
+
// a matching New-Api-User header. Response shell: {success:true,data:{...}}.
|
|
58
|
+
export function parseNewApiUserSelf(payload: unknown, options: NewApiOptions = {}): AccountBalance {
|
|
59
|
+
const { quotaPerUnit, currency } = validateNewApiOptions(options);
|
|
60
|
+
const response = object(payload);
|
|
61
|
+
if (response.success !== true) throw new QuotaError('schema');
|
|
62
|
+
const data = object(response.data);
|
|
63
|
+
const quota = numeric(data.quota);
|
|
64
|
+
const usedQuota = numeric(data.used_quota);
|
|
65
|
+
if (quota === null || usedQuota === null
|
|
66
|
+
|| !Number.isSafeInteger(quota) || !Number.isSafeInteger(usedQuota)
|
|
67
|
+
|| quota < 0 || usedQuota < 0) throw new QuotaError('schema');
|
|
68
|
+
return { currency, remaining: quota / quotaPerUnit };
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
// Legacy one-api billing pair, still the only option on older deployments.
|
|
72
|
+
// total_usage is in cents (divide by 100, per one-api issue #1785); most
|
|
73
|
+
// deployments report a fake 1e8 hard limit for unlimited quotas.
|
|
74
|
+
export function parseNewApiBilling(subscription: unknown, usage: unknown, options: NewApiOptions = {}): AccountBalance {
|
|
75
|
+
const { currency } = validateNewApiOptions(options);
|
|
76
|
+
const sub = object(subscription);
|
|
77
|
+
const limit = numeric(sub.hard_limit_usd);
|
|
78
|
+
const list = object(usage);
|
|
79
|
+
const totalUsage = numeric(list.total_usage);
|
|
80
|
+
if (limit === null || !Number.isFinite(limit) || limit < 0
|
|
81
|
+
|| totalUsage === null || !Number.isFinite(totalUsage) || totalUsage < 0) throw new QuotaError('schema');
|
|
82
|
+
const used = totalUsage / 100;
|
|
83
|
+
if (limit >= 1e7) return { currency, remaining: 0, unlimited: true };
|
|
84
|
+
return { currency, remaining: limit - used };
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
export function createNewApiAdapter(provider: string, options: NewApiOptions = {}): QuotaAdapter {
|
|
88
|
+
const settings = validateNewApiOptions(options);
|
|
89
|
+
const { dashboardAccessToken, dashboardUserId } = options;
|
|
90
|
+
if (dashboardAccessToken !== undefined
|
|
91
|
+
&& (typeof dashboardAccessToken !== 'string' || dashboardAccessToken.trim() === '')) {
|
|
92
|
+
throw new Error('dashboardAccessToken must be a non-empty string');
|
|
93
|
+
}
|
|
94
|
+
if (dashboardUserId !== undefined
|
|
95
|
+
&& (!Number.isSafeInteger(dashboardUserId) || dashboardUserId <= 0)) {
|
|
96
|
+
throw new Error('dashboardUserId must be a positive integer');
|
|
97
|
+
}
|
|
98
|
+
return {
|
|
99
|
+
provider, label: provider,
|
|
100
|
+
async query(context) {
|
|
101
|
+
let auth;
|
|
102
|
+
try { auth = await context.getAuth(context.provider); }
|
|
103
|
+
catch { throw new QuotaError('auth'); }
|
|
104
|
+
context.signal.throwIfAborted();
|
|
105
|
+
if (!auth) throw new QuotaError('auth');
|
|
106
|
+
const root = newApiRootUrl(auth.baseUrl);
|
|
107
|
+
// 0. Console account balance via PAT: the only view of real remaining
|
|
108
|
+
// account quota. Any failure falls through to the key-native paths.
|
|
109
|
+
if (dashboardAccessToken !== undefined) {
|
|
110
|
+
const headers: Record<string, string> = { Authorization: `Bearer ${dashboardAccessToken}` };
|
|
111
|
+
if (dashboardUserId !== undefined) headers['New-Api-User'] = String(dashboardUserId);
|
|
112
|
+
try {
|
|
113
|
+
const payload = await context.getJson(`${root}/api/user/self`, headers, context.signal);
|
|
114
|
+
return { windows: [], balance: parseNewApiUserSelf(payload, settings), fetchedAt: context.now() };
|
|
115
|
+
} catch (error) {
|
|
116
|
+
if (!(error instanceof QuotaError)) throw error;
|
|
117
|
+
}
|
|
118
|
+
}
|
|
119
|
+
const headers = { Authorization: `Bearer ${bearer(auth)}` };
|
|
120
|
+
// 1. Account billing (one-api legacy): a finite hard limit is the real
|
|
121
|
+
// account balance. 1e8 means unlimited — then the key quota may still
|
|
122
|
+
// be finite and more precise.
|
|
123
|
+
let billingBalance: AccountBalance | null = null;
|
|
124
|
+
try {
|
|
125
|
+
const pair = await Promise.all([
|
|
126
|
+
context.getJson(`${root}/v1/dashboard/billing/subscription`, headers, context.signal),
|
|
127
|
+
context.getJson(`${root}/v1/dashboard/billing/usage?start_date=2020-01-01&end_date=2100-01-01`, headers, context.signal),
|
|
128
|
+
]);
|
|
129
|
+
const billing = parseNewApiBilling(pair[0], pair[1], settings);
|
|
130
|
+
if (billing.unlimited !== true) return { windows: [], balance: billing, fetchedAt: context.now() };
|
|
131
|
+
billingBalance = billing;
|
|
132
|
+
} catch (error) {
|
|
133
|
+
// 404: deployment has no billing pair. 401: try the key-native endpoint
|
|
134
|
+
// below — some deployments accept sk- only there.
|
|
135
|
+
if (error instanceof QuotaError && (error.code === 'http' || error.code === 'auth')) { /* fall through */ }
|
|
136
|
+
else throw error;
|
|
137
|
+
}
|
|
138
|
+
// 2. Per-token usage (new-api ≥ v0.9.0-alpha.8): native sk- support.
|
|
139
|
+
try {
|
|
140
|
+
const payload = await context.getJson(`${root}/api/usage/token/`, headers, context.signal);
|
|
141
|
+
const parsed = parseNewApiTokenUsage(payload, settings);
|
|
142
|
+
if (parsed) return { windows: [], balance: parsed, fetchedAt: context.now() };
|
|
143
|
+
} catch (error) {
|
|
144
|
+
if (billingBalance && error instanceof QuotaError && error.code === 'http') {
|
|
145
|
+
return { windows: [], balance: billingBalance, fetchedAt: context.now() };
|
|
146
|
+
}
|
|
147
|
+
if (error instanceof QuotaError && error.code === 'auth') throw new QuotaError('account-access');
|
|
148
|
+
throw error;
|
|
149
|
+
}
|
|
150
|
+
if (billingBalance) return { windows: [], balance: billingBalance, fetchedAt: context.now() };
|
|
151
|
+
throw new QuotaError('schema');
|
|
152
|
+
},
|
|
153
|
+
};
|
|
154
|
+
}
|
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
import { bearer, officialAuth } from './http.ts';
|
|
2
|
+
import { numeric, object, percent, timestamp, windowLabel } from './parse.ts';
|
|
3
|
+
import { QuotaError } from './types.ts';
|
|
4
|
+
import type { QuotaAdapter, QuotaWindow } from './types.ts';
|
|
5
|
+
|
|
6
|
+
// OpenCode Go (opencode.ai Zen Go plan) is a single official deployment, so the
|
|
7
|
+
// window keys are fixed and undecorated: percent is already "used", and there is
|
|
8
|
+
// no dollar balance — only the console shows money.
|
|
9
|
+
const WINDOWS = [
|
|
10
|
+
{ key: 'rolling', duration: 18000 }, // 5 hours
|
|
11
|
+
{ key: 'weekly', duration: 604800 }, // 7 days
|
|
12
|
+
{ key: 'monthly', duration: 2592000 }, // 30 days
|
|
13
|
+
] as const;
|
|
14
|
+
|
|
15
|
+
export function parseOpenCodeGo(payload: unknown): QuotaWindow[] {
|
|
16
|
+
const usage = object(object(payload).usage);
|
|
17
|
+
const windows: QuotaWindow[] = [];
|
|
18
|
+
for (const { key, duration } of WINDOWS) {
|
|
19
|
+
if (usage[key] === undefined) continue;
|
|
20
|
+
const detail = object(usage[key]);
|
|
21
|
+
const used = numeric(detail.percent);
|
|
22
|
+
windows.push({
|
|
23
|
+
id: key, label: windowLabel(duration), durationSeconds: duration,
|
|
24
|
+
// Absent or malformed percent stays unknown; never fabricate a window.
|
|
25
|
+
remainingPercent: used === null ? null : percent(100 - used),
|
|
26
|
+
resetAt: timestamp(detail.resetsAt),
|
|
27
|
+
});
|
|
28
|
+
}
|
|
29
|
+
if (windows.length === 0) throw new QuotaError('schema');
|
|
30
|
+
return windows;
|
|
31
|
+
}
|
|
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
|
+
};
|
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
export function object(value: unknown): Record<string, unknown> {
|
|
2
|
+
return value !== null && typeof value === 'object' && !Array.isArray(value)
|
|
3
|
+
? value as Record<string, unknown> : {};
|
|
4
|
+
}
|
|
5
|
+
|
|
6
|
+
export function numeric(value: unknown): number | null {
|
|
7
|
+
if (typeof value !== 'number' && !(typeof value === 'string' && value.trim() !== '')) return null;
|
|
8
|
+
const result = Number(value);
|
|
9
|
+
return Number.isFinite(result) ? result : null;
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
export function percent(value: number): number {
|
|
13
|
+
return Math.max(0, Math.min(100, value));
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
export function timestamp(value: unknown): number | null {
|
|
17
|
+
const number = numeric(value);
|
|
18
|
+
const parsed = number !== null
|
|
19
|
+
? (number < 1e12 ? number * 1000 : number)
|
|
20
|
+
: typeof value === 'string' ? Date.parse(value) : NaN;
|
|
21
|
+
return Number.isFinite(parsed) && parsed > 0 && parsed <= 8.64e15 ? parsed : null;
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
export function windowLabel(seconds: number): string {
|
|
25
|
+
if (seconds === 604800) return '1w';
|
|
26
|
+
if (seconds % 86400 === 0) return `${seconds / 86400}d`;
|
|
27
|
+
if (seconds % 3600 === 0) return `${seconds / 3600}h`;
|
|
28
|
+
if (seconds % 60 === 0) return `${seconds / 60}m`;
|
|
29
|
+
return `${seconds}s`;
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
// API-controlled labels may not inject terminal controls, ANSI, or newlines.
|
|
33
|
+
export function safeLabel(value: string): string {
|
|
34
|
+
return value.replace(/[\p{Cc}\p{Cf}\p{Zl}\p{Zp}]/gu, '').slice(0, 64);
|
|
35
|
+
}
|