@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.
- package/dist/query/alias.d.ts +3 -0
- package/dist/query/alias.js +17 -0
- package/dist/query/codex.d.ts +4 -0
- package/dist/query/codex.js +68 -0
- package/dist/query/controller.d.ts +30 -0
- package/dist/query/controller.js +110 -0
- package/dist/query/deepseek.d.ts +4 -0
- package/dist/query/deepseek.js +32 -0
- package/dist/query/exec.d.ts +10 -0
- package/dist/query/exec.js +75 -0
- package/dist/query/http.d.ts +6 -0
- package/dist/query/http.js +160 -0
- package/dist/query/index.d.ts +18 -0
- package/dist/query/index.js +16 -0
- package/dist/query/kimi.d.ts +3 -0
- package/dist/query/kimi.js +52 -0
- package/dist/query/new-api.d.ts +18 -0
- package/dist/query/new-api.js +180 -0
- package/dist/query/opencode-go.d.ts +4 -0
- package/dist/query/opencode-go.js +46 -0
- package/dist/query/parse.d.ts +6 -0
- package/dist/query/parse.js +35 -0
- package/dist/query/registry.d.ts +9 -0
- package/dist/query/registry.js +78 -0
- package/dist/query/settings.d.ts +50 -0
- package/dist/query/settings.js +172 -0
- package/dist/query/sub2api.d.ts +4 -0
- package/dist/query/sub2api.js +132 -0
- package/dist/query/types.d.ts +77 -0
- package/dist/query/types.js +9 -0
- package/dist/query/url.d.ts +1 -0
- package/dist/query/url.js +19 -0
- package/dist/query/yescode.d.ts +15 -0
- package/dist/query/yescode.js +155 -0
- package/package.json +14 -4
- package/src/query/sub2api.ts +3 -1
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
import { QuotaError } from "./types.js";
|
|
2
|
+
// An alias provider shares another provider's account: the query runs under
|
|
3
|
+
// the target's identity, so auth and endpoints resolve exactly as a direct
|
|
4
|
+
// query of the target would. The alias never queries on its own behalf.
|
|
5
|
+
export function createAliasAdapter(provider, target) {
|
|
6
|
+
return {
|
|
7
|
+
provider, label: target.label,
|
|
8
|
+
query: context => target.query({ ...context, provider: target.provider }),
|
|
9
|
+
};
|
|
10
|
+
}
|
|
11
|
+
// A dangling alias stays visible as an error instead of silently vanishing.
|
|
12
|
+
export function createDanglingAliasAdapter(provider) {
|
|
13
|
+
return {
|
|
14
|
+
provider, label: provider,
|
|
15
|
+
query: () => Promise.reject(new QuotaError('unsupported-auth')),
|
|
16
|
+
};
|
|
17
|
+
}
|
|
@@ -0,0 +1,68 @@
|
|
|
1
|
+
import { bearer, header, officialAuth } from "./http.js";
|
|
2
|
+
import { numeric, object, percent, safeLabel, timestamp, windowLabel } from "./parse.js";
|
|
3
|
+
import { QuotaError } from "./types.js";
|
|
4
|
+
// Decode only to obtain routing metadata from the same resolved token.
|
|
5
|
+
// This does not verify a JWT; the official server verifies authentication.
|
|
6
|
+
export function codexAccountId(token) {
|
|
7
|
+
try {
|
|
8
|
+
const payload = object(JSON.parse(Buffer.from(token.split('.')[1] ?? '', 'base64url').toString('utf8')));
|
|
9
|
+
const id = object(payload['https://api.openai.com/auth']).chatgpt_account_id;
|
|
10
|
+
return typeof id === 'string' && id.length > 0 ? id : undefined;
|
|
11
|
+
}
|
|
12
|
+
catch {
|
|
13
|
+
return undefined;
|
|
14
|
+
}
|
|
15
|
+
}
|
|
16
|
+
export function parseCodex(payload, now) {
|
|
17
|
+
const data = object(payload);
|
|
18
|
+
const windows = [];
|
|
19
|
+
const addDomain = (raw, domain, scope) => {
|
|
20
|
+
const rate = object(raw);
|
|
21
|
+
for (const [key, fallback] of [['primary_window', 18000], ['secondary_window', 604800]]) {
|
|
22
|
+
if (rate[key] == null)
|
|
23
|
+
continue;
|
|
24
|
+
const window = object(rate[key]);
|
|
25
|
+
const duration = numeric(window.limit_window_seconds) ?? fallback;
|
|
26
|
+
if (duration <= 0)
|
|
27
|
+
throw new QuotaError('schema');
|
|
28
|
+
const remaining = numeric(window.remaining_percent) ?? numeric(window.percent_left);
|
|
29
|
+
const used = numeric(window.used_percent);
|
|
30
|
+
const resetAfter = numeric(window.reset_after_seconds);
|
|
31
|
+
const resetAt = timestamp(window.reset_at ?? window.reset_time_ms)
|
|
32
|
+
?? (resetAfter !== null && resetAfter >= 0 ? now + resetAfter * 1000 : null);
|
|
33
|
+
windows.push({
|
|
34
|
+
id: `${domain}/${key}`, label: windowLabel(duration), durationSeconds: duration,
|
|
35
|
+
remainingPercent: remaining !== null ? percent(remaining) : used !== null ? percent(100 - used) : null,
|
|
36
|
+
resetAt, ...(scope ? { scope } : {}),
|
|
37
|
+
});
|
|
38
|
+
}
|
|
39
|
+
};
|
|
40
|
+
addDomain(data.rate_limit, 'shared');
|
|
41
|
+
if (Array.isArray(data.additional_rate_limits)) {
|
|
42
|
+
for (const [index, entry] of data.additional_rate_limits.entries()) {
|
|
43
|
+
const item = object(entry);
|
|
44
|
+
const name = item.limit_name ?? item.metered_feature;
|
|
45
|
+
const scope = typeof name === 'string' ? safeLabel(name) : `Model ${index + 1}`;
|
|
46
|
+
addDomain(item.rate_limit, `additional-${index}`, scope);
|
|
47
|
+
}
|
|
48
|
+
}
|
|
49
|
+
if (windows.length === 0)
|
|
50
|
+
throw new QuotaError('schema');
|
|
51
|
+
return windows;
|
|
52
|
+
}
|
|
53
|
+
export const codexAdapter = {
|
|
54
|
+
provider: 'openai-codex', label: 'Codex',
|
|
55
|
+
async query(context) {
|
|
56
|
+
const auth = await officialAuth(context, 'https://chatgpt.com');
|
|
57
|
+
const token = bearer(auth);
|
|
58
|
+
const account = header(auth, 'chatgpt-account-id') ?? codexAccountId(token);
|
|
59
|
+
if (!account)
|
|
60
|
+
throw new QuotaError('unsupported-auth');
|
|
61
|
+
const data = await context.getJson('https://chatgpt.com/backend-api/wham/usage', {
|
|
62
|
+
Authorization: `Bearer ${token}`, 'ChatGPT-Account-Id': account,
|
|
63
|
+
Origin: 'https://chatgpt.com', Referer: 'https://chatgpt.com/',
|
|
64
|
+
}, context.signal);
|
|
65
|
+
const now = context.now();
|
|
66
|
+
return { windows: parseCodex(data, now), fetchedAt: now };
|
|
67
|
+
},
|
|
68
|
+
};
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
import { AdapterRegistry } from './registry.js';
|
|
2
|
+
import type { QueryContext, QuotaState } from './types.js';
|
|
3
|
+
type AuthResolver = QueryContext['getAuth'];
|
|
4
|
+
interface ControllerOptions {
|
|
5
|
+
registry: AdapterRegistry;
|
|
6
|
+
getJson: QueryContext['getJson'];
|
|
7
|
+
postJson: QueryContext['postJson'];
|
|
8
|
+
onState(state: QuotaState): void;
|
|
9
|
+
now?: () => number;
|
|
10
|
+
intervalMs?: number;
|
|
11
|
+
timeoutMs?: number;
|
|
12
|
+
}
|
|
13
|
+
export declare class QuotaController {
|
|
14
|
+
state: QuotaState;
|
|
15
|
+
private provider?;
|
|
16
|
+
private generation;
|
|
17
|
+
private request?;
|
|
18
|
+
private nextAttempt;
|
|
19
|
+
private failures;
|
|
20
|
+
private readonly options;
|
|
21
|
+
private readonly now;
|
|
22
|
+
private readonly interval;
|
|
23
|
+
private readonly timeout;
|
|
24
|
+
constructor(options: ControllerOptions);
|
|
25
|
+
private publish;
|
|
26
|
+
select(provider: string | undefined): void;
|
|
27
|
+
stop(): void;
|
|
28
|
+
refresh(getAuth: AuthResolver, force?: boolean): Promise<void>;
|
|
29
|
+
}
|
|
30
|
+
export {};
|
|
@@ -0,0 +1,110 @@
|
|
|
1
|
+
import { AdapterRegistry, validateSnapshot } from "./registry.js";
|
|
2
|
+
import { QuotaError } from "./types.js";
|
|
3
|
+
// Waiting is bounded even if a custom adapter or auth resolver ignores abort.
|
|
4
|
+
async function abortable(work, signal) {
|
|
5
|
+
signal.throwIfAborted();
|
|
6
|
+
let rejectAbort = () => { };
|
|
7
|
+
const aborted = new Promise((_resolve, reject) => {
|
|
8
|
+
rejectAbort = () => reject(signal.reason);
|
|
9
|
+
signal.addEventListener('abort', rejectAbort, { once: true });
|
|
10
|
+
});
|
|
11
|
+
try {
|
|
12
|
+
return await Promise.race([work, aborted]);
|
|
13
|
+
}
|
|
14
|
+
finally {
|
|
15
|
+
signal.removeEventListener('abort', rejectAbort);
|
|
16
|
+
}
|
|
17
|
+
}
|
|
18
|
+
export class QuotaController {
|
|
19
|
+
state = { kind: 'hidden' };
|
|
20
|
+
provider;
|
|
21
|
+
generation = 0;
|
|
22
|
+
request;
|
|
23
|
+
nextAttempt = 0;
|
|
24
|
+
failures = 0;
|
|
25
|
+
options;
|
|
26
|
+
now;
|
|
27
|
+
interval;
|
|
28
|
+
timeout;
|
|
29
|
+
constructor(options) {
|
|
30
|
+
this.options = options;
|
|
31
|
+
this.now = options.now ?? Date.now;
|
|
32
|
+
this.interval = options.intervalMs ?? 60000;
|
|
33
|
+
this.timeout = options.timeoutMs ?? 10000;
|
|
34
|
+
if (!Number.isFinite(this.interval) || this.interval <= 0
|
|
35
|
+
|| !Number.isFinite(this.timeout) || this.timeout <= 0)
|
|
36
|
+
throw new Error('Invalid quota timing');
|
|
37
|
+
}
|
|
38
|
+
publish(state) {
|
|
39
|
+
this.state = state;
|
|
40
|
+
this.options.onState(state);
|
|
41
|
+
}
|
|
42
|
+
select(provider) {
|
|
43
|
+
this.generation++;
|
|
44
|
+
this.request?.abort.abort();
|
|
45
|
+
this.request = undefined;
|
|
46
|
+
this.provider = provider;
|
|
47
|
+
this.nextAttempt = 0;
|
|
48
|
+
this.failures = 0;
|
|
49
|
+
const adapter = this.options.registry.get(provider);
|
|
50
|
+
// Clear old values synchronously, before auth resolution or any network I/O.
|
|
51
|
+
this.publish(adapter
|
|
52
|
+
? { kind: 'loading', provider: adapter.provider, label: adapter.label }
|
|
53
|
+
: { kind: 'hidden' });
|
|
54
|
+
}
|
|
55
|
+
stop() { this.select(undefined); }
|
|
56
|
+
refresh(getAuth, force = false) {
|
|
57
|
+
const adapter = this.options.registry.get(this.provider);
|
|
58
|
+
if (!adapter)
|
|
59
|
+
return Promise.resolve();
|
|
60
|
+
if (this.request && !force)
|
|
61
|
+
return this.request.promise;
|
|
62
|
+
if (!force && this.now() < this.nextAttempt)
|
|
63
|
+
return Promise.resolve();
|
|
64
|
+
if (force) {
|
|
65
|
+
this.generation++;
|
|
66
|
+
this.request?.abort.abort();
|
|
67
|
+
this.request = undefined;
|
|
68
|
+
}
|
|
69
|
+
const generation = this.generation;
|
|
70
|
+
const abort = new AbortController();
|
|
71
|
+
const active = () => this.generation === generation && this.request?.abort === abort;
|
|
72
|
+
// Keep the last result visible during refresh; selection already clears it.
|
|
73
|
+
if (this.state.kind !== 'ready' && this.state.kind !== 'loading') {
|
|
74
|
+
this.publish({ kind: 'loading', provider: adapter.provider, label: adapter.label });
|
|
75
|
+
}
|
|
76
|
+
const deadline = setTimeout(() => abort.abort(new QuotaError('timeout')), this.timeout);
|
|
77
|
+
const promise = Promise.resolve().then(async () => {
|
|
78
|
+
try {
|
|
79
|
+
abort.signal.throwIfAborted();
|
|
80
|
+
const result = await abortable(adapter.query({
|
|
81
|
+
provider: adapter.provider, signal: abort.signal, now: this.now,
|
|
82
|
+
getAuth, getJson: this.options.getJson, postJson: this.options.postJson,
|
|
83
|
+
}), abort.signal);
|
|
84
|
+
if (!active())
|
|
85
|
+
return;
|
|
86
|
+
const snapshot = validateSnapshot(result);
|
|
87
|
+
this.failures = 0;
|
|
88
|
+
this.nextAttempt = this.now() + this.interval;
|
|
89
|
+
this.publish({ kind: 'ready', provider: adapter.provider, label: adapter.label, snapshot });
|
|
90
|
+
}
|
|
91
|
+
catch (error) {
|
|
92
|
+
if (!active())
|
|
93
|
+
return;
|
|
94
|
+
const safe = error instanceof QuotaError ? error : new QuotaError('network');
|
|
95
|
+
this.failures++;
|
|
96
|
+
const backoff = Math.min(900000, this.interval * 2 ** Math.min(this.failures - 1, 4));
|
|
97
|
+
this.nextAttempt = this.now() + Math.max(backoff, safe.retryAfterMs ?? 0);
|
|
98
|
+
// Do not retain a prior account's cached quota after auth changes/errors.
|
|
99
|
+
this.publish({ kind: 'error', provider: adapter.provider, label: adapter.label, code: safe.code });
|
|
100
|
+
}
|
|
101
|
+
finally {
|
|
102
|
+
clearTimeout(deadline);
|
|
103
|
+
if (active())
|
|
104
|
+
this.request = undefined;
|
|
105
|
+
}
|
|
106
|
+
});
|
|
107
|
+
this.request = { abort, promise };
|
|
108
|
+
return promise;
|
|
109
|
+
}
|
|
110
|
+
}
|
|
@@ -0,0 +1,4 @@
|
|
|
1
|
+
import type { AccountBalance, QuotaAdapter } from './types.js';
|
|
2
|
+
export declare const DEEPSEEK_ORIGIN = "https://api.deepseek.com";
|
|
3
|
+
export declare function parseDeepSeekBalance(payload: unknown): AccountBalance;
|
|
4
|
+
export declare function createDeepSeekAdapter(provider: string): QuotaAdapter;
|
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
import { bearer, officialAuth } from "./http.js";
|
|
2
|
+
import { numeric, object } from "./parse.js";
|
|
3
|
+
import { QuotaError } from "./types.js";
|
|
4
|
+
export const DEEPSEEK_ORIGIN = 'https://api.deepseek.com';
|
|
5
|
+
// https://api-docs.deepseek.com/api/get-user-balance
|
|
6
|
+
// GET /user/balance (also mounted under /v1/). The sk- key works directly.
|
|
7
|
+
// Amounts arrive as strings in yuan; balance_infos may list several currencies.
|
|
8
|
+
export function parseDeepSeekBalance(payload) {
|
|
9
|
+
const body = object(payload);
|
|
10
|
+
if (!Array.isArray(body.balance_infos) || body.balance_infos.length === 0)
|
|
11
|
+
throw new QuotaError('schema');
|
|
12
|
+
for (const entry of body.balance_infos) {
|
|
13
|
+
const info = object(entry);
|
|
14
|
+
const remaining = numeric(info.total_balance);
|
|
15
|
+
if (typeof info.currency !== 'string' || !/^[A-Z]{3}$/.test(info.currency)
|
|
16
|
+
|| remaining === null || remaining < 0)
|
|
17
|
+
continue;
|
|
18
|
+
// The endpoint exposes no usage total; only return the actual balance.
|
|
19
|
+
return { currency: info.currency, remaining };
|
|
20
|
+
}
|
|
21
|
+
throw new QuotaError('schema');
|
|
22
|
+
}
|
|
23
|
+
export function createDeepSeekAdapter(provider) {
|
|
24
|
+
return {
|
|
25
|
+
provider, label: provider === 'deepseek' ? 'DeepSeek' : provider,
|
|
26
|
+
async query(context) {
|
|
27
|
+
const auth = await officialAuth(context, DEEPSEEK_ORIGIN);
|
|
28
|
+
const payload = await context.getJson(`${DEEPSEEK_ORIGIN}/user/balance`, { Authorization: `Bearer ${bearer(auth)}` }, context.signal);
|
|
29
|
+
return { windows: [], balance: parseDeepSeekBalance(payload), fetchedAt: context.now() };
|
|
30
|
+
},
|
|
31
|
+
};
|
|
32
|
+
}
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
import type { QuotaAdapter } from './types.js';
|
|
2
|
+
export interface ExecOptions {
|
|
3
|
+
command: string[];
|
|
4
|
+
label: string;
|
|
5
|
+
}
|
|
6
|
+
export declare function validateExecOptions(options: {
|
|
7
|
+
command?: unknown;
|
|
8
|
+
label?: unknown;
|
|
9
|
+
}): ExecOptions;
|
|
10
|
+
export declare function createExecAdapter(provider: string, options: ExecOptions): QuotaAdapter;
|
|
@@ -0,0 +1,75 @@
|
|
|
1
|
+
import { spawn } from 'node:child_process';
|
|
2
|
+
import { QuotaError } from "./types.js";
|
|
3
|
+
export function validateExecOptions(options) {
|
|
4
|
+
const { command, label } = options;
|
|
5
|
+
if (!Array.isArray(command) || command.length === 0 || command.length > 16
|
|
6
|
+
|| command.some(part => typeof part !== 'string' || part.trim() === '' || part.length > 512)) {
|
|
7
|
+
throw new Error('Invalid exec options');
|
|
8
|
+
}
|
|
9
|
+
if (typeof label !== 'string' || label.trim() === '' || label.length > 64)
|
|
10
|
+
throw new Error('Invalid exec options');
|
|
11
|
+
return { command: [...command], label };
|
|
12
|
+
}
|
|
13
|
+
// Same bound as the HTTP clients: quota payloads are small.
|
|
14
|
+
const MAX_OUTPUT_BYTES = 256 * 1024;
|
|
15
|
+
function runCommand(command, signal) {
|
|
16
|
+
return new Promise((resolve, reject) => {
|
|
17
|
+
let settled = false;
|
|
18
|
+
const fail = (error) => { if (!settled) {
|
|
19
|
+
settled = true;
|
|
20
|
+
reject(error);
|
|
21
|
+
} };
|
|
22
|
+
const done = (text) => { if (!settled) {
|
|
23
|
+
settled = true;
|
|
24
|
+
resolve(text);
|
|
25
|
+
} };
|
|
26
|
+
let child;
|
|
27
|
+
try {
|
|
28
|
+
// No shell: argv passes verbatim; settings.json is the only trust boundary.
|
|
29
|
+
// The controller bounds the wait via the signal even if the child hangs.
|
|
30
|
+
child = spawn(command[0], command.slice(1), { signal, stdio: ['ignore', 'pipe', 'ignore'] });
|
|
31
|
+
}
|
|
32
|
+
catch {
|
|
33
|
+
fail(new QuotaError('network'));
|
|
34
|
+
return;
|
|
35
|
+
}
|
|
36
|
+
let output = '';
|
|
37
|
+
child.stdout?.setEncoding('utf8');
|
|
38
|
+
child.stdout?.on('data', (chunk) => {
|
|
39
|
+
output += chunk;
|
|
40
|
+
if (output.length > MAX_OUTPUT_BYTES) {
|
|
41
|
+
child.kill();
|
|
42
|
+
fail(new QuotaError('schema'));
|
|
43
|
+
}
|
|
44
|
+
});
|
|
45
|
+
child.on('error', () => fail(signal.aborted ? signal.reason : new QuotaError('network')));
|
|
46
|
+
child.on('close', code => {
|
|
47
|
+
if (signal.aborted)
|
|
48
|
+
fail(signal.reason);
|
|
49
|
+
else if (code !== 0)
|
|
50
|
+
fail(new QuotaError('network'));
|
|
51
|
+
else
|
|
52
|
+
done(output);
|
|
53
|
+
});
|
|
54
|
+
});
|
|
55
|
+
}
|
|
56
|
+
export function createExecAdapter(provider, options) {
|
|
57
|
+
return {
|
|
58
|
+
provider, label: options.label,
|
|
59
|
+
async query(context) {
|
|
60
|
+
const text = await runCommand(options.command, context.signal);
|
|
61
|
+
let payload;
|
|
62
|
+
try {
|
|
63
|
+
payload = JSON.parse(text);
|
|
64
|
+
}
|
|
65
|
+
catch {
|
|
66
|
+
throw new QuotaError('schema');
|
|
67
|
+
}
|
|
68
|
+
if (payload === null || typeof payload !== 'object' || Array.isArray(payload))
|
|
69
|
+
throw new QuotaError('schema');
|
|
70
|
+
// fetchedAt is stamped here, never trusted from the command; the
|
|
71
|
+
// controller's validateSnapshot performs the real schema check.
|
|
72
|
+
return { ...payload, fetchedAt: context.now() };
|
|
73
|
+
},
|
|
74
|
+
};
|
|
75
|
+
}
|
|
@@ -0,0 +1,6 @@
|
|
|
1
|
+
import type { ProviderAuth, QueryContext } from './types.js';
|
|
2
|
+
export declare function createJsonClient(fetcher?: typeof fetch): QueryContext['getJson'];
|
|
3
|
+
export declare function createPostJsonClient(fetcher?: typeof fetch): QueryContext['postJson'];
|
|
4
|
+
export declare function header(auth: ProviderAuth, name: string): string | undefined;
|
|
5
|
+
export declare function officialAuth(context: QueryContext, origin: string): Promise<ProviderAuth>;
|
|
6
|
+
export declare function bearer(auth: ProviderAuth): string;
|
|
@@ -0,0 +1,160 @@
|
|
|
1
|
+
import { QuotaError } from "./types.js";
|
|
2
|
+
export function createJsonClient(fetcher = globalThis.fetch) {
|
|
3
|
+
return async (url, headers, signal) => {
|
|
4
|
+
try {
|
|
5
|
+
const response = await fetcher(url, {
|
|
6
|
+
method: 'GET', headers: { Accept: 'application/json', ...headers },
|
|
7
|
+
signal, redirect: 'error', // Never forward credentials to a redirected endpoint.
|
|
8
|
+
});
|
|
9
|
+
if (!response.ok) {
|
|
10
|
+
const retry = response.headers.get('retry-after');
|
|
11
|
+
const seconds = retry && /^\d+(\.\d+)?$/.test(retry) ? Number(retry) : NaN;
|
|
12
|
+
const delay = Number.isFinite(seconds) ? seconds * 1000
|
|
13
|
+
: retry ? Date.parse(retry) - Date.now() : NaN;
|
|
14
|
+
await response.body?.cancel();
|
|
15
|
+
if (response.status === 401 || response.status === 403)
|
|
16
|
+
throw new QuotaError('auth');
|
|
17
|
+
if (response.status === 429) {
|
|
18
|
+
throw new QuotaError('rate-limit', Number.isFinite(delay) ? Math.max(0, delay) : undefined);
|
|
19
|
+
}
|
|
20
|
+
throw new QuotaError('http');
|
|
21
|
+
}
|
|
22
|
+
// Quota responses are small. Bound memory and reject unexpected HTML/proxy pages.
|
|
23
|
+
const reader = response.body?.getReader();
|
|
24
|
+
if (!reader)
|
|
25
|
+
throw new QuotaError('schema');
|
|
26
|
+
const chunks = [];
|
|
27
|
+
let size = 0;
|
|
28
|
+
try {
|
|
29
|
+
while (true) {
|
|
30
|
+
const part = await reader.read();
|
|
31
|
+
if (part.done)
|
|
32
|
+
break;
|
|
33
|
+
size += part.value.length;
|
|
34
|
+
if (size > 256 * 1024) {
|
|
35
|
+
await reader.cancel();
|
|
36
|
+
throw new QuotaError('schema');
|
|
37
|
+
}
|
|
38
|
+
chunks.push(part.value);
|
|
39
|
+
}
|
|
40
|
+
}
|
|
41
|
+
finally {
|
|
42
|
+
reader.releaseLock();
|
|
43
|
+
}
|
|
44
|
+
try {
|
|
45
|
+
return JSON.parse(Buffer.concat(chunks).toString('utf8'));
|
|
46
|
+
}
|
|
47
|
+
catch {
|
|
48
|
+
throw new QuotaError('schema');
|
|
49
|
+
}
|
|
50
|
+
}
|
|
51
|
+
catch (error) {
|
|
52
|
+
if (error instanceof QuotaError)
|
|
53
|
+
throw error;
|
|
54
|
+
if (signal.aborted)
|
|
55
|
+
throw signal.reason;
|
|
56
|
+
throw new QuotaError('network');
|
|
57
|
+
}
|
|
58
|
+
};
|
|
59
|
+
}
|
|
60
|
+
export function createPostJsonClient(fetcher = globalThis.fetch) {
|
|
61
|
+
return async (url, body, headers, signal) => {
|
|
62
|
+
try {
|
|
63
|
+
const response = await fetcher(url, {
|
|
64
|
+
method: 'POST',
|
|
65
|
+
headers: { Accept: 'application/json', 'Content-Type': 'application/json', ...headers },
|
|
66
|
+
body: JSON.stringify(body),
|
|
67
|
+
signal, redirect: 'error', // Never forward credentials to a redirected endpoint.
|
|
68
|
+
});
|
|
69
|
+
if (!response.ok) {
|
|
70
|
+
const retry = response.headers.get('retry-after');
|
|
71
|
+
const seconds = retry && /^\d+(\.\d+)?$/.test(retry) ? Number(retry) : NaN;
|
|
72
|
+
const delay = Number.isFinite(seconds) ? seconds * 1000
|
|
73
|
+
: retry ? Date.parse(retry) - Date.now() : NaN;
|
|
74
|
+
await response.body?.cancel();
|
|
75
|
+
if (response.status === 401 || response.status === 403)
|
|
76
|
+
throw new QuotaError('auth');
|
|
77
|
+
if (response.status === 429) {
|
|
78
|
+
throw new QuotaError('rate-limit', Number.isFinite(delay) ? Math.max(0, delay) : undefined);
|
|
79
|
+
}
|
|
80
|
+
throw new QuotaError('http');
|
|
81
|
+
}
|
|
82
|
+
const setCookie = typeof response.headers.getSetCookie === 'function'
|
|
83
|
+
? response.headers.getSetCookie() : [];
|
|
84
|
+
const reader = response.body?.getReader();
|
|
85
|
+
if (!reader)
|
|
86
|
+
throw new QuotaError('schema');
|
|
87
|
+
const chunks = [];
|
|
88
|
+
let size = 0;
|
|
89
|
+
try {
|
|
90
|
+
while (true) {
|
|
91
|
+
const part = await reader.read();
|
|
92
|
+
if (part.done)
|
|
93
|
+
break;
|
|
94
|
+
size += part.value.length;
|
|
95
|
+
if (size > 256 * 1024) {
|
|
96
|
+
await reader.cancel();
|
|
97
|
+
throw new QuotaError('schema');
|
|
98
|
+
}
|
|
99
|
+
chunks.push(part.value);
|
|
100
|
+
}
|
|
101
|
+
}
|
|
102
|
+
finally {
|
|
103
|
+
reader.releaseLock();
|
|
104
|
+
}
|
|
105
|
+
try {
|
|
106
|
+
return { body: JSON.parse(Buffer.concat(chunks).toString('utf8')), setCookie };
|
|
107
|
+
}
|
|
108
|
+
catch {
|
|
109
|
+
throw new QuotaError('schema');
|
|
110
|
+
}
|
|
111
|
+
}
|
|
112
|
+
catch (error) {
|
|
113
|
+
if (error instanceof QuotaError)
|
|
114
|
+
throw error;
|
|
115
|
+
if (signal.aborted)
|
|
116
|
+
throw signal.reason;
|
|
117
|
+
throw new QuotaError('network');
|
|
118
|
+
}
|
|
119
|
+
};
|
|
120
|
+
}
|
|
121
|
+
export function header(auth, name) {
|
|
122
|
+
return Object.entries(auth.headers ?? {}).find(([key]) => key.toLowerCase() === name.toLowerCase())?.[1];
|
|
123
|
+
}
|
|
124
|
+
export async function officialAuth(context, origin) {
|
|
125
|
+
let auth;
|
|
126
|
+
try {
|
|
127
|
+
auth = await context.getAuth(context.provider);
|
|
128
|
+
}
|
|
129
|
+
catch {
|
|
130
|
+
throw new QuotaError('auth');
|
|
131
|
+
}
|
|
132
|
+
context.signal.throwIfAborted();
|
|
133
|
+
if (!auth)
|
|
134
|
+
throw new QuotaError('auth');
|
|
135
|
+
if (auth.baseUrl) {
|
|
136
|
+
let actual;
|
|
137
|
+
try {
|
|
138
|
+
actual = new URL(auth.baseUrl);
|
|
139
|
+
}
|
|
140
|
+
catch {
|
|
141
|
+
throw new QuotaError('unsupported-auth');
|
|
142
|
+
}
|
|
143
|
+
if (actual.origin !== origin || actual.username || actual.password)
|
|
144
|
+
throw new QuotaError('unsupported-auth');
|
|
145
|
+
}
|
|
146
|
+
return auth;
|
|
147
|
+
}
|
|
148
|
+
export function bearer(auth) {
|
|
149
|
+
const authorization = header(auth, 'authorization');
|
|
150
|
+
// A runtime Authorization header overrides apiKey, just as it does for requests.
|
|
151
|
+
if (authorization !== undefined) {
|
|
152
|
+
const token = /^Bearer\s+(\S+)$/i.exec(authorization)?.[1];
|
|
153
|
+
if (!token)
|
|
154
|
+
throw new QuotaError('unsupported-auth');
|
|
155
|
+
return token;
|
|
156
|
+
}
|
|
157
|
+
if (!auth.apiKey)
|
|
158
|
+
throw new QuotaError('auth');
|
|
159
|
+
return auth.apiKey;
|
|
160
|
+
}
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
export * from './types.js';
|
|
2
|
+
export { AdapterRegistry, validateSnapshot } from './registry.js';
|
|
3
|
+
export { QuotaController } from './controller.js';
|
|
4
|
+
export { createJsonClient, createPostJsonClient, officialAuth, bearer, header } from './http.js';
|
|
5
|
+
export { createAliasAdapter, createDanglingAliasAdapter } from './alias.js';
|
|
6
|
+
export { createExecAdapter, validateExecOptions } from './exec.js';
|
|
7
|
+
export type { ExecOptions } from './exec.js';
|
|
8
|
+
export { codexAccountId, codexAdapter } from './codex.js';
|
|
9
|
+
export { kimiAdapter } from './kimi.js';
|
|
10
|
+
export { openCodeGoAdapter, createOpenCodeGoAdapter } from './opencode-go.js';
|
|
11
|
+
export { createDeepSeekAdapter, DEEPSEEK_ORIGIN } from './deepseek.js';
|
|
12
|
+
export { createNewApiAdapter, validateNewApiOptions } from './new-api.js';
|
|
13
|
+
export type { NewApiOptions } from './new-api.js';
|
|
14
|
+
export { createSub2ApiAdapter } from './sub2api.js';
|
|
15
|
+
export { createYesCodeAdapter, validateYesCodeOptions, YESCODE_ORIGIN } from './yescode.js';
|
|
16
|
+
export type { YesCodeOptions } from './yescode.js';
|
|
17
|
+
export { composeQuotaRegistry, loadQuotaAdaptersFromSettings, loadQuotaAliasesFromSettings, loadQuotaFooterWindows, parseQuotaConfig, QuotaConfigError, } from './settings.js';
|
|
18
|
+
export type { ComposedQuota, FooterWindows, ProviderQuotaConfig, QuotaConfig } from './settings.js';
|
|
@@ -0,0 +1,16 @@
|
|
|
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.js";
|
|
4
|
+
export { AdapterRegistry, validateSnapshot } from "./registry.js";
|
|
5
|
+
export { QuotaController } from "./controller.js";
|
|
6
|
+
export { createJsonClient, createPostJsonClient, officialAuth, bearer, header } from "./http.js";
|
|
7
|
+
export { createAliasAdapter, createDanglingAliasAdapter } from "./alias.js";
|
|
8
|
+
export { createExecAdapter, validateExecOptions } from "./exec.js";
|
|
9
|
+
export { codexAccountId, codexAdapter } from "./codex.js";
|
|
10
|
+
export { kimiAdapter } from "./kimi.js";
|
|
11
|
+
export { openCodeGoAdapter, createOpenCodeGoAdapter } from "./opencode-go.js";
|
|
12
|
+
export { createDeepSeekAdapter, DEEPSEEK_ORIGIN } from "./deepseek.js";
|
|
13
|
+
export { createNewApiAdapter, validateNewApiOptions } from "./new-api.js";
|
|
14
|
+
export { createSub2ApiAdapter } from "./sub2api.js";
|
|
15
|
+
export { createYesCodeAdapter, validateYesCodeOptions, YESCODE_ORIGIN } from "./yescode.js";
|
|
16
|
+
export { composeQuotaRegistry, loadQuotaAdaptersFromSettings, loadQuotaAliasesFromSettings, loadQuotaFooterWindows, parseQuotaConfig, QuotaConfigError, } from "./settings.js";
|
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
import { bearer, officialAuth } from "./http.js";
|
|
2
|
+
import { numeric, object, percent, timestamp, windowLabel } from "./parse.js";
|
|
3
|
+
import { QuotaError } from "./types.js";
|
|
4
|
+
const units = {
|
|
5
|
+
TIME_UNIT_SECOND: 1, TIME_UNIT_MINUTE: 60, TIME_UNIT_HOUR: 3600, TIME_UNIT_DAY: 86400,
|
|
6
|
+
};
|
|
7
|
+
export function parseKimi(payload) {
|
|
8
|
+
const data = object(payload);
|
|
9
|
+
const windows = [];
|
|
10
|
+
const add = (raw, duration, id) => {
|
|
11
|
+
const detail = object(raw);
|
|
12
|
+
const limit = numeric(detail.limit);
|
|
13
|
+
const used = numeric(detail.used);
|
|
14
|
+
const remaining = numeric(detail.remaining);
|
|
15
|
+
// No default for omitted counts: absence is not proof of zero usage.
|
|
16
|
+
const fraction = limit !== null && limit > 0
|
|
17
|
+
? remaining !== null && remaining >= 0 ? remaining / limit
|
|
18
|
+
: used !== null && used >= 0 ? 1 - used / limit : null
|
|
19
|
+
: null;
|
|
20
|
+
windows.push({
|
|
21
|
+
id, label: windowLabel(duration), durationSeconds: duration,
|
|
22
|
+
remainingPercent: fraction === null ? null : percent(fraction * 100),
|
|
23
|
+
resetAt: timestamp(detail.resetTime ?? detail.reset_at),
|
|
24
|
+
});
|
|
25
|
+
};
|
|
26
|
+
if (data.usage != null && typeof data.usage === 'object')
|
|
27
|
+
add(data.usage, 604800, 'weekly');
|
|
28
|
+
if (Array.isArray(data.limits)) {
|
|
29
|
+
for (const [index, raw] of data.limits.entries()) {
|
|
30
|
+
const entry = object(raw);
|
|
31
|
+
const window = object(entry.window);
|
|
32
|
+
const multiplier = typeof window.timeUnit === 'string' ? units[window.timeUnit] : undefined;
|
|
33
|
+
const duration = numeric(window.duration);
|
|
34
|
+
if (!entry.detail || !multiplier || duration === null || duration <= 0)
|
|
35
|
+
continue;
|
|
36
|
+
add(entry.detail, duration * multiplier, `limit-${index}`);
|
|
37
|
+
}
|
|
38
|
+
}
|
|
39
|
+
if (windows.length === 0)
|
|
40
|
+
throw new QuotaError('schema');
|
|
41
|
+
return windows.sort((a, b) => a.durationSeconds - b.durationSeconds);
|
|
42
|
+
}
|
|
43
|
+
export const kimiAdapter = {
|
|
44
|
+
provider: 'kimi-coding', label: 'Kimi',
|
|
45
|
+
async query(context) {
|
|
46
|
+
const auth = await officialAuth(context, 'https://api.kimi.com');
|
|
47
|
+
const data = await context.getJson('https://api.kimi.com/coding/v1/usages', {
|
|
48
|
+
Authorization: `Bearer ${bearer(auth)}`,
|
|
49
|
+
}, context.signal);
|
|
50
|
+
return { windows: parseKimi(data), fetchedAt: context.now() };
|
|
51
|
+
},
|
|
52
|
+
};
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
import type { QuotaAdapter, QuotaSnapshot } from './types.js';
|
|
2
|
+
type Amounts = Pick<QuotaSnapshot, 'balance' | 'allowance' | 'spend'>;
|
|
3
|
+
export interface NewApiOptions {
|
|
4
|
+
quotaPerUnit?: number;
|
|
5
|
+
currency?: string;
|
|
6
|
+
dashboardAccessToken?: string;
|
|
7
|
+
dashboardUserId?: number;
|
|
8
|
+
}
|
|
9
|
+
export declare function validateNewApiOptions(options: NewApiOptions): {
|
|
10
|
+
quotaPerUnit: number;
|
|
11
|
+
currency: string;
|
|
12
|
+
};
|
|
13
|
+
export declare function newApiRootUrl(baseUrl: string | undefined): string;
|
|
14
|
+
export declare function parseNewApiTokenUsage(payload: unknown, options?: NewApiOptions): Amounts | null;
|
|
15
|
+
export declare function parseNewApiUserSelf(payload: unknown, options?: NewApiOptions): Amounts;
|
|
16
|
+
export declare function parseNewApiBilling(subscription: unknown, usage: unknown, options?: NewApiOptions): Amounts;
|
|
17
|
+
export declare function createNewApiAdapter(provider: string, options?: NewApiOptions): QuotaAdapter;
|
|
18
|
+
export {};
|