@astrosheep/pi-quota 0.5.0 → 0.5.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/README.md +6 -6
- package/package.json +4 -2
- package/src/bar/card.ts +20 -8
- package/src/extension.ts +57 -21
- package/src/query/new-api.ts +36 -18
- package/src/query/registry.ts +4 -0
package/README.md
CHANGED
|
@@ -6,7 +6,7 @@ Compact quota status for Pi: Codex, Kimi and OpenCode Go work out of the box; ne
|
|
|
6
6
|
pi install npm:@astrosheep/pi-quota
|
|
7
7
|
```
|
|
8
8
|
|
|
9
|
-
Reload Pi after installing or changing settings. Use `/usage` for a persistent, context-free snapshot
|
|
9
|
+
Reload Pi after installing or changing settings. Use `/usage` for a persistent, context-free snapshot covering every configured provider with a quota adapter, including the current one. Automatic footer polling never adds chat items.
|
|
10
10
|
|
|
11
11
|
## What it shows
|
|
12
12
|
|
|
@@ -18,7 +18,7 @@ Reload Pi after installing or changing settings. Use `/usage` for a persistent,
|
|
|
18
18
|
|
|
19
19
|
A percentage always means **remaining**, not used. Unknown is `[?] ?`; an expired reset is `↺ due` with the stale percentage hidden until the next successful query. A wallet balance never becomes a percentage. Historical spend is not a quota.
|
|
20
20
|
|
|
21
|
-
The footer shows at most two windows and `+N` for additional ones. `/usage` includes all windows; it does not open a modal or send messages to the model. Snapshots remain frozen at capture time. Errors replace stale values.
|
|
21
|
+
The footer shows at most two windows and `+N` for additional ones, for the current provider only. `/usage` queries all configured supported providers and includes all windows, grouped by provider; it does not open a modal or send messages to the model. Snapshots remain frozen at capture time. Errors replace stale values and are shown per provider.
|
|
22
22
|
|
|
23
23
|
## Configure providers
|
|
24
24
|
|
|
@@ -41,12 +41,12 @@ Use the **exact Pi provider ID**. Only selected, explicitly bound providers are
|
|
|
41
41
|
| Adapter | Endpoint | Result |
|
|
42
42
|
|---|---|---|
|
|
43
43
|
| `sub2api` | `{baseUrl}/usage` | Wallet, key quota, rate windows or subscription day/week/month windows; API-key Today/Lifetime actual spend |
|
|
44
|
-
| `new-api` | Account PAT `/api/user/self`, falling back to key-native billing/token endpoints | Account
|
|
44
|
+
| `new-api` | Account PAT `/api/user/self`, falling back to key-native billing/token endpoints | Account balance + lifetime spend; finite key quota when explicitly granted; **not** a renewable window |
|
|
45
45
|
| `deepseek` | Official `/user/balance` | Balance only; no invented spend |
|
|
46
46
|
|
|
47
47
|
Sub2API accepts no extra settings; it preserves deployment subpaths. Rate-only keys need not expose a top-level currency (rates are USD). Unknown rate-window durations and invalid limits fail rather than inventing a 1d window. Subscription periods without a cap are omitted. Its dashboard `/api/v1/usage` is a separate, JWT-authenticated paginated request log; the adapter uses the API-key `/v1/usage` instead.
|
|
48
48
|
|
|
49
|
-
For new-api, `quotaPerUnit` defaults to `500000`, `currency` to `USD`. Optionally add `dashboardAccessToken` (console system access token) and `dashboardUserId` (positive numeric ID required by some old forks) to that provider. This PAT is the **only** optional secret kept in settings. The account endpoint takes precedence; failures fall back to the key-native billing and token usage paths. Without a PAT, an API key cannot read the dashboard account quota. Values in settings and server error bodies are never logged.
|
|
49
|
+
For new-api, `quotaPerUnit` defaults to `500000`, `currency` to `USD`. Optionally add `dashboardAccessToken` (console system access token) and `dashboardUserId` (positive numeric ID required by some old forks) to that provider. This PAT is the **only** optional secret kept in settings. The account endpoint takes precedence; failures fall back to the key-native billing and token usage paths. Without a PAT, an API key cannot read the dashboard account quota. A finite key reports a quota bar only when `total_granted` is present and consistent with used plus remaining; otherwise it shows a balance and lifetime spend, without inventing a limit. Values in settings and server error bodies are never logged.
|
|
50
50
|
|
|
51
51
|
Authenticated quota URLs must use HTTPS, except HTTP loopback for local deployments. URL userinfo, query strings, fragments and HTTP redirects are rejected; credentials never follow redirects. Fixed official endpoints reject custom origins. No browser cookie scraping or credential discovery.
|
|
52
52
|
|
|
@@ -58,12 +58,12 @@ Pi model/auth → adapter → QuotaSnapshot → controller → footer / /usage /
|
|
|
58
58
|
|
|
59
59
|
- `src/query/`: protocol-specific parsing, credential resolution, safe URLs, bounded GET client, state validation and controller. No Pi/TUI imports.
|
|
60
60
|
- `src/config.ts`: strict global provider bindings.
|
|
61
|
-
- `src/bar/`: pure compact rendering and persistent chat card.
|
|
61
|
+
- `src/bar/`: pure compact rendering and persistent multi-provider chat card.
|
|
62
62
|
- `src/extension.ts` and `src/index.ts`: Pi lifecycle and auth bridge.
|
|
63
63
|
|
|
64
64
|
`QuotaSnapshot` separates `balance` (wallet), `allowance` (finite key quota), `windows` (renewable limits), and optional `spend` (Today/Lifetime actual charge). This prevents unrelated amounts from sharing an ambiguous `Used` label. Custom adapters can be passed to `createQuotaExtension({ adapters: [...] })`; see `examples/custom-provider.ts`. Provider aliases are never inferred.
|
|
65
65
|
|
|
66
|
-
The extension publishes structured state on `quota-bar:state:v1`. Use `createQuotaExtension({ footer: false })` to consume it without this extension's footer. Normal queries are throttled, cancellable, timeout-bounded and backed off after errors. `/usage` forces a fresh query. Switching provider discards old values immediately.
|
|
66
|
+
The extension publishes structured state on `quota-bar:state:v1`. Use `createQuotaExtension({ footer: false })` to consume it without this extension's footer. Normal queries are throttled, cancellable, timeout-bounded and backed off after errors. `/usage` forces a fresh query for every configured supported provider. Switching provider discards old footer values immediately.
|
|
67
67
|
|
|
68
68
|
## Development
|
|
69
69
|
|
package/package.json
CHANGED
|
@@ -1,7 +1,9 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@astrosheep/pi-quota",
|
|
3
|
-
"version": "0.5.
|
|
4
|
-
"publishConfig": {
|
|
3
|
+
"version": "0.5.2",
|
|
4
|
+
"publishConfig": {
|
|
5
|
+
"access": "public"
|
|
6
|
+
},
|
|
5
7
|
"type": "module",
|
|
6
8
|
"description": "Composable quota bar with Codex, Kimi, OpenCode Go, new-api, DeepSeek and Sub2API adapters",
|
|
7
9
|
"keywords": [
|
package/src/bar/card.ts
CHANGED
|
@@ -6,27 +6,39 @@ import type { QuotaState } from '../query/types.ts';
|
|
|
6
6
|
|
|
7
7
|
export const USAGE_ENTRY = 'quota-bar:usage:v1';
|
|
8
8
|
|
|
9
|
-
export
|
|
10
|
-
version: 1;
|
|
11
|
-
capturedAt: number;
|
|
12
|
-
state: Exclude<QuotaState, { kind: 'loading' }>;
|
|
13
|
-
}
|
|
9
|
+
export type UsageCard =
|
|
10
|
+
| { version: 1; capturedAt: number; state: Exclude<QuotaState, { kind: 'loading' }> }
|
|
11
|
+
| { version: 2; capturedAt: number; states: Exclude<QuotaState, { kind: 'loading' }>[] };
|
|
14
12
|
|
|
15
13
|
export function captureUsage(state: QuotaState, now = Date.now()): UsageCard | undefined {
|
|
16
14
|
if (state.kind === 'loading') return undefined;
|
|
17
|
-
//
|
|
15
|
+
// Preserve v1 rendering for cards already saved in sessions.
|
|
18
16
|
return { version: 1, capturedAt: now, state: structuredClone(state) };
|
|
19
17
|
}
|
|
20
18
|
|
|
19
|
+
export function captureAllUsage(states: readonly QuotaState[], now = Date.now()): UsageCard {
|
|
20
|
+
return { version: 2, capturedAt: now, states: structuredClone(states.filter(
|
|
21
|
+
(state): state is Exclude<QuotaState, { kind: 'loading' }> => state.kind !== 'loading',
|
|
22
|
+
)) };
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
export function usageLines(card: UsageCard, paint?: Paint, barWidth = 20): string[] {
|
|
26
|
+
if (card.version === 1) return renderUsage(card.state, paint, card.capturedAt, barWidth);
|
|
27
|
+
if (card.states.length === 0) return ['No supported quota providers available.'];
|
|
28
|
+
return card.states.flatMap((state, index) => [
|
|
29
|
+
...(index ? ['', ''] : []), ...renderUsage(state, paint, card.capturedAt, barWidth),
|
|
30
|
+
]);
|
|
31
|
+
}
|
|
32
|
+
|
|
21
33
|
export function usageCardComponent(card: UsageCard | undefined, paint?: Paint): Component {
|
|
22
34
|
return {
|
|
23
35
|
render(width: number): string[] {
|
|
24
36
|
if (width <= 0) return [];
|
|
25
|
-
if (!card || card.version !== 1 || !Number.isFinite(card.capturedAt)) {
|
|
37
|
+
if (!card || (card.version !== 1 && card.version !== 2) || !Number.isFinite(card.capturedAt)) {
|
|
26
38
|
return new Text('Usage snapshot unavailable.', 0, 0).render(width);
|
|
27
39
|
}
|
|
28
40
|
// Rebuild on every render for theme/width changes, but freeze time at capture.
|
|
29
|
-
const lines =
|
|
41
|
+
const lines = usageLines(card, paint, width < 55 ? 10 : 20);
|
|
30
42
|
// Wrapping, not clipping: long quota names remain readable in narrow terminals.
|
|
31
43
|
return new Text(lines.join('\n'), 0, 0).render(width);
|
|
32
44
|
},
|
package/src/extension.ts
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import type { ExtensionAPI, ExtensionContext } from '@earendil-works/pi-coding-agent';
|
|
2
|
-
import { renderFooter
|
|
3
|
-
import {
|
|
2
|
+
import { renderFooter } from './bar/quota.ts';
|
|
3
|
+
import { captureAllUsage, USAGE_ENTRY, usageCardComponent, usageLines } from './bar/card.ts';
|
|
4
4
|
import type { UsageCard } from './bar/card.ts';
|
|
5
5
|
import { codexAdapter } from './query/codex.ts';
|
|
6
6
|
import { kimiAdapter } from './query/kimi.ts';
|
|
@@ -8,7 +8,7 @@ import { openCodeGoAdapter } from './query/opencode-go.ts';
|
|
|
8
8
|
import { QuotaController } from './query/controller.ts';
|
|
9
9
|
import { createJsonClient } from './query/http.ts';
|
|
10
10
|
import { AdapterRegistry } from './query/registry.ts';
|
|
11
|
-
import type { ProviderAuth, QuotaAdapter } from './query/types.ts';
|
|
11
|
+
import type { ProviderAuth, QuotaAdapter, QuotaState } from './query/types.ts';
|
|
12
12
|
|
|
13
13
|
export const STATUS_EVENT = 'quota-bar:state:v1';
|
|
14
14
|
const STATUS_KEY = 'quota-bar';
|
|
@@ -58,29 +58,64 @@ export function createQuotaExtension(options: QuotaExtensionOptions = {}) {
|
|
|
58
58
|
}
|
|
59
59
|
};
|
|
60
60
|
|
|
61
|
+
const getJson = createJsonClient();
|
|
62
|
+
|
|
61
63
|
const controller = new QuotaController({
|
|
62
|
-
registry, getJson
|
|
64
|
+
registry, getJson, intervalMs: options.intervalMs, timeoutMs: options.timeoutMs,
|
|
63
65
|
onState(state) {
|
|
64
66
|
paintFooter();
|
|
65
67
|
pi.events.emit(STATUS_EVENT, state); // Structured data, no credentials or ANSI.
|
|
66
68
|
},
|
|
67
69
|
});
|
|
68
70
|
|
|
71
|
+
const resolveAuth = (ctx: ExtensionContext) => async (provider: string): Promise<ProviderAuth | undefined> => {
|
|
72
|
+
const resolved = await ctx.modelRegistry.getProviderAuth(provider);
|
|
73
|
+
if (!resolved) return undefined;
|
|
74
|
+
const headers: Record<string, string | undefined> = {};
|
|
75
|
+
for (const [name, value] of Object.entries(resolved.auth.headers ?? {})) {
|
|
76
|
+
if (typeof value === 'string') headers[name] = value;
|
|
77
|
+
}
|
|
78
|
+
const auth: ProviderAuth = { ...resolved.auth, headers };
|
|
79
|
+
// A model-level custom endpoint must not be mistaken for an official account.
|
|
80
|
+
if (ctx.model?.provider === provider && ctx.model.baseUrl) auth.baseUrl = ctx.model.baseUrl;
|
|
81
|
+
return auth;
|
|
82
|
+
};
|
|
83
|
+
|
|
69
84
|
const refresh = (force = false): Promise<void> => {
|
|
70
85
|
const ctx = current;
|
|
71
86
|
if (!ctx?.hasUI) return Promise.resolve();
|
|
72
|
-
return controller.refresh(
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
87
|
+
return controller.refresh(resolveAuth(ctx), force);
|
|
88
|
+
};
|
|
89
|
+
|
|
90
|
+
const supportedProviders = (ctx: ExtensionContext): string[] => {
|
|
91
|
+
const providers: string[] = [];
|
|
92
|
+
const seen = new Set<string>();
|
|
93
|
+
const add = (provider: string | undefined) => {
|
|
94
|
+
if (provider && registry.get(provider) && !seen.has(provider)) {
|
|
95
|
+
seen.add(provider);
|
|
96
|
+
providers.push(provider);
|
|
78
97
|
}
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
98
|
+
};
|
|
99
|
+
add(ctx.model?.provider);
|
|
100
|
+
for (const model of ctx.modelRegistry.getAll()) add(model.provider);
|
|
101
|
+
return providers;
|
|
102
|
+
};
|
|
103
|
+
|
|
104
|
+
const queryProvider = async (provider: string, getAuth: ReturnType<typeof resolveAuth>): Promise<QuotaState> => {
|
|
105
|
+
const adapter = registry.get(provider);
|
|
106
|
+
if (!adapter) return { kind: 'error', provider, label: provider, code: 'unsupported-auth' };
|
|
107
|
+
if (provider === selectedProvider) {
|
|
108
|
+
// The footer already owns this provider's lifecycle and cache; do not fork it.
|
|
109
|
+
await controller.refresh(getAuth, true);
|
|
110
|
+
return controller.state;
|
|
111
|
+
}
|
|
112
|
+
const scratch = new QuotaController({
|
|
113
|
+
registry, getJson, intervalMs: options.intervalMs, timeoutMs: options.timeoutMs,
|
|
114
|
+
onState() {},
|
|
115
|
+
});
|
|
116
|
+
scratch.select(provider);
|
|
117
|
+
await scratch.refresh(getAuth, true);
|
|
118
|
+
return scratch.state;
|
|
84
119
|
};
|
|
85
120
|
|
|
86
121
|
const bind = (ctx: ExtensionContext, select = false, provider = ctx.model?.provider) => {
|
|
@@ -122,23 +157,24 @@ export function createQuotaExtension(options: QuotaExtensionOptions = {}) {
|
|
|
122
157
|
});
|
|
123
158
|
|
|
124
159
|
pi.registerCommand('usage', {
|
|
125
|
-
description: 'Query
|
|
160
|
+
description: 'Query quota for every configured supported provider and add snapshots to the chat history',
|
|
126
161
|
handler: async (_args, ctx) => {
|
|
127
162
|
if (!ctx.hasUI) return;
|
|
128
163
|
bind(ctx);
|
|
129
164
|
const generation = ++commandGeneration;
|
|
130
|
-
if (ctx.mode === 'tui') ctx.ui.setWidget(USAGE_WIDGET, ['
|
|
165
|
+
if (ctx.mode === 'tui') ctx.ui.setWidget(USAGE_WIDGET, ['Quota …']);
|
|
131
166
|
try {
|
|
132
|
-
|
|
167
|
+
const getAuth = resolveAuth(ctx);
|
|
168
|
+
const providers = supportedProviders(ctx);
|
|
169
|
+
const states = await Promise.all(providers.map(provider => queryProvider(provider, getAuth)));
|
|
133
170
|
// Never append via a stale Pi runtime, or label a replacement model's data
|
|
134
171
|
// as the result of the original command. A newer command owns its result.
|
|
135
172
|
if (!current || generation !== commandGeneration) return;
|
|
136
|
-
const card =
|
|
137
|
-
if (!card) return;
|
|
173
|
+
const card = captureAllUsage(states);
|
|
138
174
|
pi.appendEntry<UsageCard>(USAGE_ENTRY, card);
|
|
139
175
|
// Custom entry renderers are TUI-only; RPC clients still get readable output.
|
|
140
176
|
if (ctx.mode !== 'tui') {
|
|
141
|
-
ctx.ui.notify(
|
|
177
|
+
ctx.ui.notify(usageLines(card, undefined).join('\n'), 'info');
|
|
142
178
|
}
|
|
143
179
|
} finally {
|
|
144
180
|
// An older request must not clear a newer command's loading indicator.
|
package/src/query/new-api.ts
CHANGED
|
@@ -2,7 +2,9 @@ import { bearer } from './http.ts';
|
|
|
2
2
|
import { numeric, object } from './parse.ts';
|
|
3
3
|
import { QuotaError } from './types.ts';
|
|
4
4
|
import { safeBaseUrl } from './url.ts';
|
|
5
|
-
import type {
|
|
5
|
+
import type { QuotaAdapter, QuotaSnapshot } from './types.ts';
|
|
6
|
+
|
|
7
|
+
type Amounts = Pick<QuotaSnapshot, 'balance' | 'allowance' | 'spend'>;
|
|
6
8
|
|
|
7
9
|
export interface NewApiOptions {
|
|
8
10
|
quotaPerUnit?: number;
|
|
@@ -33,29 +35,40 @@ export function newApiRootUrl(baseUrl: string | undefined): string {
|
|
|
33
35
|
// GET /api/usage/token (new-api >= v0.9.0-alpha.8, PR QuantumNous/new-api#1161):
|
|
34
36
|
// accepts the sk- model key itself (TokenAuth), returns per-token quota in
|
|
35
37
|
// the same unit as /api/user/self quota. Response shell: {code:true,data:{...}}.
|
|
36
|
-
export function parseNewApiTokenUsage(payload: unknown, options: NewApiOptions = {}):
|
|
38
|
+
export function parseNewApiTokenUsage(payload: unknown, options: NewApiOptions = {}): Amounts | null {
|
|
37
39
|
const { quotaPerUnit, currency } = validateNewApiOptions(options);
|
|
38
40
|
const response = object(payload);
|
|
39
41
|
if (response.code !== true) return null;
|
|
40
42
|
const data = object(response.data);
|
|
41
43
|
if (data.object !== 'token_usage') return null;
|
|
42
44
|
const used = numeric(data.total_used);
|
|
43
|
-
// Missing fields must not become a made-up zero balance.
|
|
44
45
|
if (used === null || !Number.isSafeInteger(used) || used < 0) throw new QuotaError('schema');
|
|
45
46
|
if (data.unlimited_quota === true) {
|
|
46
|
-
return { currency, remaining: 0, unlimited: true }
|
|
47
|
+
return { balance: { currency, remaining: 0, unlimited: true },
|
|
48
|
+
spend: { currency, lifetime: used / quotaPerUnit } };
|
|
47
49
|
}
|
|
48
50
|
const remaining = numeric(data.total_available);
|
|
49
51
|
if (remaining === null || !Number.isSafeInteger(remaining)) throw new QuotaError('schema');
|
|
50
|
-
const
|
|
51
|
-
if (!Number.isFinite(
|
|
52
|
-
|
|
52
|
+
const moneyLeft = remaining / quotaPerUnit;
|
|
53
|
+
if (!Number.isFinite(moneyLeft)) throw new QuotaError('schema');
|
|
54
|
+
|
|
55
|
+
// New-api reports granted = key used + key remaining. Only draw a finite
|
|
56
|
+
// allocation bar when the server actually supplies that total.
|
|
57
|
+
if (data.total_granted !== undefined) {
|
|
58
|
+
const granted = numeric(data.total_granted);
|
|
59
|
+
if (granted === null || !Number.isSafeInteger(granted) || granted < 0
|
|
60
|
+
|| !Number.isSafeInteger(used + remaining) || granted !== used + remaining) throw new QuotaError('schema');
|
|
61
|
+
if (granted > 0) return { allowance: { currency, limit: granted / quotaPerUnit,
|
|
62
|
+
used: used / quotaPerUnit, remaining: moneyLeft } };
|
|
63
|
+
}
|
|
64
|
+
return { balance: { currency, remaining: moneyLeft },
|
|
65
|
+
spend: { currency, lifetime: used / quotaPerUnit } };
|
|
53
66
|
}
|
|
54
67
|
|
|
55
68
|
// GET /api/user/self (UserAuth): the console's own account quota, readable with
|
|
56
69
|
// a dashboard access token (PAT), which sk- keys are not. Old forks also demand
|
|
57
70
|
// a matching New-Api-User header. Response shell: {success:true,data:{...}}.
|
|
58
|
-
export function parseNewApiUserSelf(payload: unknown, options: NewApiOptions = {}):
|
|
71
|
+
export function parseNewApiUserSelf(payload: unknown, options: NewApiOptions = {}): Amounts {
|
|
59
72
|
const { quotaPerUnit, currency } = validateNewApiOptions(options);
|
|
60
73
|
const response = object(payload);
|
|
61
74
|
if (response.success !== true) throw new QuotaError('schema');
|
|
@@ -65,13 +78,14 @@ export function parseNewApiUserSelf(payload: unknown, options: NewApiOptions = {
|
|
|
65
78
|
if (quota === null || usedQuota === null
|
|
66
79
|
|| !Number.isSafeInteger(quota) || !Number.isSafeInteger(usedQuota)
|
|
67
80
|
|| quota < 0 || usedQuota < 0) throw new QuotaError('schema');
|
|
68
|
-
return { currency, remaining: quota / quotaPerUnit }
|
|
81
|
+
return { balance: { currency, remaining: quota / quotaPerUnit },
|
|
82
|
+
spend: { currency, lifetime: usedQuota / quotaPerUnit } };
|
|
69
83
|
}
|
|
70
84
|
|
|
71
85
|
// Legacy one-api billing pair, still the only option on older deployments.
|
|
72
86
|
// total_usage is in cents (divide by 100, per one-api issue #1785); most
|
|
73
87
|
// deployments report a fake 1e8 hard limit for unlimited quotas.
|
|
74
|
-
export function parseNewApiBilling(subscription: unknown, usage: unknown, options: NewApiOptions = {}):
|
|
88
|
+
export function parseNewApiBilling(subscription: unknown, usage: unknown, options: NewApiOptions = {}): Amounts {
|
|
75
89
|
const { currency } = validateNewApiOptions(options);
|
|
76
90
|
const sub = object(subscription);
|
|
77
91
|
const limit = numeric(sub.hard_limit_usd);
|
|
@@ -80,8 +94,12 @@ export function parseNewApiBilling(subscription: unknown, usage: unknown, option
|
|
|
80
94
|
if (limit === null || !Number.isFinite(limit) || limit < 0
|
|
81
95
|
|| totalUsage === null || !Number.isFinite(totalUsage) || totalUsage < 0) throw new QuotaError('schema');
|
|
82
96
|
const used = totalUsage / 100;
|
|
83
|
-
|
|
84
|
-
|
|
97
|
+
// This endpoint reports account remaining + lifetime usage as its hard limit.
|
|
98
|
+
// Top-ups change that total, so it is a wallet balance, not a fixed allowance.
|
|
99
|
+
if (limit >= 1e7) return { balance: { currency, remaining: 0, unlimited: true },
|
|
100
|
+
spend: { currency, lifetime: used } };
|
|
101
|
+
return { balance: { currency, remaining: limit - used },
|
|
102
|
+
spend: { currency, lifetime: used } };
|
|
85
103
|
}
|
|
86
104
|
|
|
87
105
|
export function createNewApiAdapter(provider: string, options: NewApiOptions = {}): QuotaAdapter {
|
|
@@ -111,7 +129,7 @@ export function createNewApiAdapter(provider: string, options: NewApiOptions = {
|
|
|
111
129
|
if (dashboardUserId !== undefined) headers['New-Api-User'] = String(dashboardUserId);
|
|
112
130
|
try {
|
|
113
131
|
const payload = await context.getJson(`${root}/api/user/self`, headers, context.signal);
|
|
114
|
-
return { windows: [],
|
|
132
|
+
return { windows: [], ...parseNewApiUserSelf(payload, settings), fetchedAt: context.now() };
|
|
115
133
|
} catch (error) {
|
|
116
134
|
if (!(error instanceof QuotaError)) throw error;
|
|
117
135
|
}
|
|
@@ -120,14 +138,14 @@ export function createNewApiAdapter(provider: string, options: NewApiOptions = {
|
|
|
120
138
|
// 1. Account billing (one-api legacy): a finite hard limit is the real
|
|
121
139
|
// account balance. 1e8 means unlimited — then the key quota may still
|
|
122
140
|
// be finite and more precise.
|
|
123
|
-
let billingBalance:
|
|
141
|
+
let billingBalance: Amounts | null = null;
|
|
124
142
|
try {
|
|
125
143
|
const pair = await Promise.all([
|
|
126
144
|
context.getJson(`${root}/v1/dashboard/billing/subscription`, headers, context.signal),
|
|
127
145
|
context.getJson(`${root}/v1/dashboard/billing/usage?start_date=2020-01-01&end_date=2100-01-01`, headers, context.signal),
|
|
128
146
|
]);
|
|
129
147
|
const billing = parseNewApiBilling(pair[0], pair[1], settings);
|
|
130
|
-
if (billing.unlimited !== true) return { windows: [],
|
|
148
|
+
if (billing.balance?.unlimited !== true) return { windows: [], ...billing, fetchedAt: context.now() };
|
|
131
149
|
billingBalance = billing;
|
|
132
150
|
} catch (error) {
|
|
133
151
|
// 404: deployment has no billing pair. 401: try the key-native endpoint
|
|
@@ -139,15 +157,15 @@ export function createNewApiAdapter(provider: string, options: NewApiOptions = {
|
|
|
139
157
|
try {
|
|
140
158
|
const payload = await context.getJson(`${root}/api/usage/token/`, headers, context.signal);
|
|
141
159
|
const parsed = parseNewApiTokenUsage(payload, settings);
|
|
142
|
-
if (parsed) return { windows: [],
|
|
160
|
+
if (parsed) return { windows: [], ...parsed, fetchedAt: context.now() };
|
|
143
161
|
} catch (error) {
|
|
144
162
|
if (billingBalance && error instanceof QuotaError && error.code === 'http') {
|
|
145
|
-
return { windows: [],
|
|
163
|
+
return { windows: [], ...billingBalance, fetchedAt: context.now() };
|
|
146
164
|
}
|
|
147
165
|
if (error instanceof QuotaError && error.code === 'auth') throw new QuotaError('account-access');
|
|
148
166
|
throw error;
|
|
149
167
|
}
|
|
150
|
-
if (billingBalance) return { windows: [],
|
|
168
|
+
if (billingBalance) return { windows: [], ...billingBalance, fetchedAt: context.now() };
|
|
151
169
|
throw new QuotaError('schema');
|
|
152
170
|
},
|
|
153
171
|
};
|
package/src/query/registry.ts
CHANGED
|
@@ -19,6 +19,10 @@ export class AdapterRegistry {
|
|
|
19
19
|
get(provider: string | undefined): QuotaAdapter | undefined {
|
|
20
20
|
return provider ? this.adapters.get(provider) : undefined;
|
|
21
21
|
}
|
|
22
|
+
|
|
23
|
+
list(): readonly QuotaAdapter[] {
|
|
24
|
+
return [...this.adapters.values()];
|
|
25
|
+
}
|
|
22
26
|
}
|
|
23
27
|
|
|
24
28
|
// Validate even custom adapter output before it reaches the bar.
|