dsh-plugin-subscription-oauth 0.6.1 → 0.8.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/lib/auth/allowance-scheduler.d.ts +58 -0
- package/lib/auth/allowance-scheduler.js +72 -0
- package/lib/auth/ccswitch.d.ts +26 -1
- package/lib/auth/ccswitch.js +60 -3
- package/lib/auth/rpc.d.ts +2 -0
- package/lib/auth/rpc.js +13 -7
- package/lib/client/UsageChip.d.ts +58 -2
- package/lib/client/UsageChip.js +104 -22
- package/lib/client.js +83 -54
- package/lib/client.js.map +1 -1
- package/lib/index.d.ts +12 -6
- package/lib/index.js +29 -8
- package/lib/providers/antigravity.d.ts +6 -0
- package/lib/providers/antigravity.js +65 -14
- package/lib/providers/ark-provider.js +8 -0
- package/lib/providers/ark.d.ts +3 -0
- package/lib/providers/ark.js +4 -1
- package/lib/providers/glm-provider.js +5 -0
- package/lib/providers/glm.d.ts +2 -0
- package/lib/providers/glm.js +4 -1
- package/lib/providers/grok.d.ts +3 -0
- package/lib/providers/grok.js +9 -1
- package/lib/providers/kimi-provider.js +5 -0
- package/lib/providers/kimi.d.ts +9 -0
- package/lib/providers/kimi.js +8 -1
- package/lib/providers/responses-stream.d.ts +44 -0
- package/lib/providers/responses-stream.js +125 -30
- package/package.json +1 -1
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Cross-request memory of when each provider account's allowance resets.
|
|
3
|
+
*
|
|
4
|
+
* This is what lets one model id draw on the whole account pool without a wasted
|
|
5
|
+
* request: when the default account's allowance runs out, the moment it will refill is
|
|
6
|
+
* remembered, and until then the scheduler sends work straight to another account
|
|
7
|
+
* instead of trying the spent one, failing, and only then moving on. When the reset
|
|
8
|
+
* time passes the spent account is first choice again.
|
|
9
|
+
*
|
|
10
|
+
* The holder is shared by the route's reader (which learns reset times from usage
|
|
11
|
+
* reads) and its streamer (which consults them when picking an account), so the
|
|
12
|
+
* scheduler's view is exactly the last read's.
|
|
13
|
+
*
|
|
14
|
+
* @module dsh-plugin-subscription-oauth/auth/allowance-scheduler
|
|
15
|
+
*/
|
|
16
|
+
/** One account's remembered allowance state. */
|
|
17
|
+
export interface AllowanceState {
|
|
18
|
+
/** Epoch milliseconds at which the account's allowance resets, when known. */
|
|
19
|
+
resetsAt?: number;
|
|
20
|
+
/** Epoch milliseconds at which this state was last learned. */
|
|
21
|
+
learnedAt: number;
|
|
22
|
+
}
|
|
23
|
+
/** In-memory, per-provider memory of account reset times. */
|
|
24
|
+
export declare class AllowanceScheduler {
|
|
25
|
+
private readonly byProvider;
|
|
26
|
+
/** One provider's account states, creating the map on first use. */
|
|
27
|
+
private provider;
|
|
28
|
+
/**
|
|
29
|
+
* Record that one account's allowance resets at a given moment.
|
|
30
|
+
*
|
|
31
|
+
* @param provider - the provider route id.
|
|
32
|
+
* @param account - the account key.
|
|
33
|
+
* @param resetsAt - epoch milliseconds of the allowance reset, when known.
|
|
34
|
+
*/
|
|
35
|
+
noteReset(provider: string, account: string, resetsAt?: number): void;
|
|
36
|
+
/**
|
|
37
|
+
* Whether one account's allowance is known to be spent now.
|
|
38
|
+
*
|
|
39
|
+
* True when the account was recorded with a reset time that is still in the future:
|
|
40
|
+
* its allowance is spent and will not refill until then, so a request should go to a
|
|
41
|
+
* different account rather than fail against this one.
|
|
42
|
+
*
|
|
43
|
+
* @param provider - the provider route id.
|
|
44
|
+
* @param account - the account key.
|
|
45
|
+
* @returns whether the account's allowance is spent until its remembered reset.
|
|
46
|
+
*/
|
|
47
|
+
isSpentUntilReset(provider: string, account: string): boolean;
|
|
48
|
+
/**
|
|
49
|
+
* The moment one account's allowance is known to reset, when it was recorded.
|
|
50
|
+
*
|
|
51
|
+
* @param provider - the provider route id.
|
|
52
|
+
* @param account - the account key.
|
|
53
|
+
* @returns the reset instant, or undefined when unknown or already passed.
|
|
54
|
+
*/
|
|
55
|
+
resetAt(provider: string, account: string): number | undefined;
|
|
56
|
+
}
|
|
57
|
+
/** The one shared scheduler every route reads and writes. */
|
|
58
|
+
export declare const allowanceScheduler: AllowanceScheduler;
|
|
@@ -0,0 +1,72 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Cross-request memory of when each provider account's allowance resets.
|
|
3
|
+
*
|
|
4
|
+
* This is what lets one model id draw on the whole account pool without a wasted
|
|
5
|
+
* request: when the default account's allowance runs out, the moment it will refill is
|
|
6
|
+
* remembered, and until then the scheduler sends work straight to another account
|
|
7
|
+
* instead of trying the spent one, failing, and only then moving on. When the reset
|
|
8
|
+
* time passes the spent account is first choice again.
|
|
9
|
+
*
|
|
10
|
+
* The holder is shared by the route's reader (which learns reset times from usage
|
|
11
|
+
* reads) and its streamer (which consults them when picking an account), so the
|
|
12
|
+
* scheduler's view is exactly the last read's.
|
|
13
|
+
*
|
|
14
|
+
* @module dsh-plugin-subscription-oauth/auth/allowance-scheduler
|
|
15
|
+
*/
|
|
16
|
+
/** In-memory, per-provider memory of account reset times. */
|
|
17
|
+
export class AllowanceScheduler {
|
|
18
|
+
byProvider = new Map();
|
|
19
|
+
/** One provider's account states, creating the map on first use. */
|
|
20
|
+
provider(provider) {
|
|
21
|
+
let map = this.byProvider.get(provider);
|
|
22
|
+
if (map === undefined) {
|
|
23
|
+
map = new Map();
|
|
24
|
+
this.byProvider.set(provider, map);
|
|
25
|
+
}
|
|
26
|
+
return map;
|
|
27
|
+
}
|
|
28
|
+
/**
|
|
29
|
+
* Record that one account's allowance resets at a given moment.
|
|
30
|
+
*
|
|
31
|
+
* @param provider - the provider route id.
|
|
32
|
+
* @param account - the account key.
|
|
33
|
+
* @param resetsAt - epoch milliseconds of the allowance reset, when known.
|
|
34
|
+
*/
|
|
35
|
+
noteReset(provider, account, resetsAt) {
|
|
36
|
+
this.provider(provider).set(account, { ...resetsAt === undefined ? {} : { resetsAt }, learnedAt: Date.now() });
|
|
37
|
+
}
|
|
38
|
+
/**
|
|
39
|
+
* Whether one account's allowance is known to be spent now.
|
|
40
|
+
*
|
|
41
|
+
* True when the account was recorded with a reset time that is still in the future:
|
|
42
|
+
* its allowance is spent and will not refill until then, so a request should go to a
|
|
43
|
+
* different account rather than fail against this one.
|
|
44
|
+
*
|
|
45
|
+
* @param provider - the provider route id.
|
|
46
|
+
* @param account - the account key.
|
|
47
|
+
* @returns whether the account's allowance is spent until its remembered reset.
|
|
48
|
+
*/
|
|
49
|
+
isSpentUntilReset(provider, account) {
|
|
50
|
+
const state = this.byProvider.get(provider)?.get(account);
|
|
51
|
+
if (state === undefined)
|
|
52
|
+
return false;
|
|
53
|
+
if (state.resetsAt === undefined)
|
|
54
|
+
return false;
|
|
55
|
+
return Date.now() < state.resetsAt;
|
|
56
|
+
}
|
|
57
|
+
/**
|
|
58
|
+
* The moment one account's allowance is known to reset, when it was recorded.
|
|
59
|
+
*
|
|
60
|
+
* @param provider - the provider route id.
|
|
61
|
+
* @param account - the account key.
|
|
62
|
+
* @returns the reset instant, or undefined when unknown or already passed.
|
|
63
|
+
*/
|
|
64
|
+
resetAt(provider, account) {
|
|
65
|
+
const state = this.byProvider.get(provider)?.get(account);
|
|
66
|
+
if (state === undefined || state.resetsAt === undefined)
|
|
67
|
+
return undefined;
|
|
68
|
+
return Date.now() < state.resetsAt ? state.resetsAt : undefined;
|
|
69
|
+
}
|
|
70
|
+
}
|
|
71
|
+
/** The one shared scheduler every route reads and writes. */
|
|
72
|
+
export const allowanceScheduler = new AllowanceScheduler();
|
package/lib/auth/ccswitch.d.ts
CHANGED
|
@@ -24,13 +24,38 @@ export interface CcSwitchBalance {
|
|
|
24
24
|
/** Whether the account reports itself as usable. */
|
|
25
25
|
available: boolean;
|
|
26
26
|
}
|
|
27
|
+
/**
|
|
28
|
+
* Query the balance endpoint some OpenAI-compatible providers expose at `{base}/v1/usage`.
|
|
29
|
+
*
|
|
30
|
+
* huanling is one: it answers `GET {base}/v1/usage` with `{ remaining, unit, … }`, which is
|
|
31
|
+
* the figure cc-switch shows for it. This is the provider's own shape, not cc-switch's
|
|
32
|
+
* `balance_infos`, so it is read separately. The base has any trailing `/v1` stripped before
|
|
33
|
+
* the path is added, so both `https://api.huanling.icu` and `https://api.huanling.icu/v1`
|
|
34
|
+
* resolve to the same `{base}/v1/usage`.
|
|
35
|
+
*
|
|
36
|
+
* @param input - the provider's API key and base URL.
|
|
37
|
+
* @returns the balance, or undefined when the provider did not answer with one.
|
|
38
|
+
*/
|
|
39
|
+
export declare function queryUsageBalance(input: {
|
|
40
|
+
apiKey: string;
|
|
41
|
+
baseUrl: string;
|
|
42
|
+
fetchFn?: FetchFn;
|
|
43
|
+
}): Promise<CcSwitchBalance | undefined>;
|
|
27
44
|
/**
|
|
28
45
|
* Query one provider's balance the way cc-switch does.
|
|
29
46
|
*
|
|
30
|
-
*
|
|
47
|
+
* cc-switch reads a DeepSeek-compatible provider's balance with `GET <base>/user/balance`
|
|
48
|
+
* and a bearer API key. For the official DeepSeek that base is `api.deepseek.com`, so the
|
|
49
|
+
* path is effectively hardcoded there; for a provider the user added through a custom
|
|
50
|
+
* endpoint (like huanling), the same path is read against that provider's own base URL,
|
|
51
|
+
* which is the only place its balance can live.
|
|
52
|
+
*
|
|
53
|
+
* @param input - the provider's API key and base URL.
|
|
31
54
|
* @returns the balance, or undefined when the provider did not answer with one.
|
|
32
55
|
*/
|
|
33
56
|
export declare function queryCcSwitchBalance(input: {
|
|
34
57
|
apiKey: string;
|
|
58
|
+
/** The provider's own base URL; `https://api.deepseek.com` for the official one. */
|
|
59
|
+
baseUrl: string;
|
|
35
60
|
fetchFn?: FetchFn;
|
|
36
61
|
}): Promise<CcSwitchBalance | undefined>;
|
package/lib/auth/ccswitch.js
CHANGED
|
@@ -23,18 +23,75 @@ function num(value) {
|
|
|
23
23
|
return value.trim();
|
|
24
24
|
return undefined;
|
|
25
25
|
}
|
|
26
|
+
/**
|
|
27
|
+
* Query the balance endpoint some OpenAI-compatible providers expose at `{base}/v1/usage`.
|
|
28
|
+
*
|
|
29
|
+
* huanling is one: it answers `GET {base}/v1/usage` with `{ remaining, unit, … }`, which is
|
|
30
|
+
* the figure cc-switch shows for it. This is the provider's own shape, not cc-switch's
|
|
31
|
+
* `balance_infos`, so it is read separately. The base has any trailing `/v1` stripped before
|
|
32
|
+
* the path is added, so both `https://api.huanling.icu` and `https://api.huanling.icu/v1`
|
|
33
|
+
* resolve to the same `{base}/v1/usage`.
|
|
34
|
+
*
|
|
35
|
+
* @param input - the provider's API key and base URL.
|
|
36
|
+
* @returns the balance, or undefined when the provider did not answer with one.
|
|
37
|
+
*/
|
|
38
|
+
export async function queryUsageBalance(input) {
|
|
39
|
+
const fetchFn = input.fetchFn ?? proxiedFetch;
|
|
40
|
+
const base = input.baseUrl.replace(/\/+$/, '').replace(/\/v1$/i, '');
|
|
41
|
+
const url = `${base}/v1/usage`;
|
|
42
|
+
let response;
|
|
43
|
+
try {
|
|
44
|
+
response = await fetchFn(url, {
|
|
45
|
+
headers: { authorization: `Bearer ${input.apiKey}`, accept: 'application/json' },
|
|
46
|
+
signal: AbortSignal.timeout(15_000),
|
|
47
|
+
});
|
|
48
|
+
}
|
|
49
|
+
catch {
|
|
50
|
+
return undefined;
|
|
51
|
+
}
|
|
52
|
+
if (!response.ok)
|
|
53
|
+
return undefined;
|
|
54
|
+
let payload;
|
|
55
|
+
try {
|
|
56
|
+
payload = await response.json();
|
|
57
|
+
}
|
|
58
|
+
catch {
|
|
59
|
+
return undefined;
|
|
60
|
+
}
|
|
61
|
+
if (typeof payload !== 'object' || payload === null)
|
|
62
|
+
return undefined;
|
|
63
|
+
const record = payload;
|
|
64
|
+
const total = num(record.remaining);
|
|
65
|
+
if (total === undefined)
|
|
66
|
+
return undefined;
|
|
67
|
+
const currency = typeof record.unit === 'string' && record.unit.trim() !== '' ? record.unit.trim() : 'USD';
|
|
68
|
+
return {
|
|
69
|
+
currency,
|
|
70
|
+
total,
|
|
71
|
+
available: record.isValid !== false && record.is_active !== false,
|
|
72
|
+
};
|
|
73
|
+
}
|
|
26
74
|
/**
|
|
27
75
|
* Query one provider's balance the way cc-switch does.
|
|
28
76
|
*
|
|
29
|
-
*
|
|
77
|
+
* cc-switch reads a DeepSeek-compatible provider's balance with `GET <base>/user/balance`
|
|
78
|
+
* and a bearer API key. For the official DeepSeek that base is `api.deepseek.com`, so the
|
|
79
|
+
* path is effectively hardcoded there; for a provider the user added through a custom
|
|
80
|
+
* endpoint (like huanling), the same path is read against that provider's own base URL,
|
|
81
|
+
* which is the only place its balance can live.
|
|
82
|
+
*
|
|
83
|
+
* @param input - the provider's API key and base URL.
|
|
30
84
|
* @returns the balance, or undefined when the provider did not answer with one.
|
|
31
85
|
*/
|
|
32
86
|
export async function queryCcSwitchBalance(input) {
|
|
33
87
|
const fetchFn = input.fetchFn ?? proxiedFetch;
|
|
88
|
+
// Resolve `<base>/user/balance` without doubling or dropping the path: a base that
|
|
89
|
+
// already ends in `/v1` keeps it, since that is the path the OpenAI-compatible wire
|
|
90
|
+
// uses and the balance endpoint lives beside it.
|
|
91
|
+
const url = `${input.baseUrl.replace(/\/+$/, '')}/user/balance`;
|
|
34
92
|
let response;
|
|
35
93
|
try {
|
|
36
|
-
|
|
37
|
-
response = await fetchFn('https://api.deepseek.com/user/balance', {
|
|
94
|
+
response = await fetchFn(url, {
|
|
38
95
|
headers: { authorization: `Bearer ${input.apiKey}`, accept: 'application/json' },
|
|
39
96
|
signal: AbortSignal.timeout(15_000),
|
|
40
97
|
});
|
package/lib/auth/rpc.d.ts
CHANGED
|
@@ -16,6 +16,8 @@ export type CredentialResolver = (ref: string) => Promise<string | undefined>;
|
|
|
16
16
|
export interface BalanceContext {
|
|
17
17
|
/** The credential reference each provider id's balance query uses. */
|
|
18
18
|
readonly credentialRefs: Readonly<Record<string, string>>;
|
|
19
|
+
/** The base URL each provider id's balance query reads, when it is not DeepSeek's own. */
|
|
20
|
+
readonly baseUrls: Readonly<Record<string, string>>;
|
|
19
21
|
/** Resolve one credential reference to its key. */
|
|
20
22
|
readonly resolveCredential?: CredentialResolver;
|
|
21
23
|
/**
|
package/lib/auth/rpc.js
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import { BAD_REQUEST, ProviderError } from '../errors.js';
|
|
2
|
-
import { queryCcSwitchBalance } from './ccswitch.js';
|
|
2
|
+
import { queryCcSwitchBalance, queryUsageBalance } from './ccswitch.js';
|
|
3
3
|
import { readDeepSeekBalance } from './deepseek-balance.js';
|
|
4
4
|
import { RPC_ENDPOINTS, RPC_PREFIX } from '../protocol.js';
|
|
5
5
|
/** Wrap a value as a successful envelope result. */
|
|
@@ -165,11 +165,12 @@ async function runEndpoint(registry, endpoint, payload, balance) {
|
|
|
165
165
|
return await readDeepSeekBalance(balance?.resolveDeepSeekAccount?.(), client);
|
|
166
166
|
}
|
|
167
167
|
case 'ccswitch-balance': {
|
|
168
|
-
// A non-subscription provider's balance, by cc-switch's query
|
|
169
|
-
// the provider; the configured map turns that into
|
|
170
|
-
// provider's profile uses
|
|
171
|
-
//
|
|
172
|
-
//
|
|
168
|
+
// A non-subscription provider's balance, by cc-switch's query against the provider's
|
|
169
|
+
// own base URL. The caller names the provider; the configured map turns that into
|
|
170
|
+
// the credential reference the provider's profile uses and the base URL that profile
|
|
171
|
+
// connects to, and the host's credential service resolves the reference. No entry,
|
|
172
|
+
// no key, or a key the provider does not answer for means no balance — never a
|
|
173
|
+
// fabricated zero.
|
|
173
174
|
const provider = requiredProvider(payload, endpoint);
|
|
174
175
|
const credentialRef = balance?.credentialRefs[provider];
|
|
175
176
|
if (credentialRef === undefined || balance?.resolveCredential === undefined)
|
|
@@ -177,7 +178,12 @@ async function runEndpoint(registry, endpoint, payload, balance) {
|
|
|
177
178
|
const apiKey = await balance.resolveCredential(credentialRef);
|
|
178
179
|
if (apiKey === undefined)
|
|
179
180
|
return undefined;
|
|
180
|
-
|
|
181
|
+
const baseUrl = balance?.baseUrls[provider] ?? 'https://api.deepseek.com';
|
|
182
|
+
// A provider that answers the OpenAI-compatible `/v1/usage` (the shape cc-switch's
|
|
183
|
+
// custom template reads, `{ remaining, unit }`) is asked that first; one that
|
|
184
|
+
// answers the DeepSeek `balance_infos` shape is asked that as a fallback.
|
|
185
|
+
return await queryUsageBalance({ apiKey, baseUrl })
|
|
186
|
+
?? await queryCcSwitchBalance({ apiKey, baseUrl });
|
|
181
187
|
}
|
|
182
188
|
case 'reload':
|
|
183
189
|
return registry.reload();
|
|
@@ -117,10 +117,12 @@ export declare function balanceOf(value: {
|
|
|
117
117
|
* Format one wallet for the chip's compact line.
|
|
118
118
|
*
|
|
119
119
|
* A balance is money, not a percentage, so it renders as `¥6.89` rather than a meter;
|
|
120
|
-
* the currency prefix keeps two currencies from being mistaken for one sum.
|
|
120
|
+
* the currency prefix keeps two currencies from being mistaken for one sum. The amount
|
|
121
|
+
* is rounded to two decimal places (a balance reads to the cent, and a figure like
|
|
122
|
+
* `1.1` for `1.10` would mislead) and always carries both places.
|
|
121
123
|
*
|
|
122
124
|
* @param wallet - the wallet to show.
|
|
123
|
-
* @returns the short form, e.g. `¥6.89` or
|
|
125
|
+
* @returns the short form, e.g. `¥6.89` or `+$2.00` for a bonus wallet.
|
|
124
126
|
*/
|
|
125
127
|
export declare function formatBalance(wallet: ChipBalance): string;
|
|
126
128
|
/**
|
|
@@ -157,6 +159,60 @@ export declare function pickWindow(windows: readonly UsageWindow[]): UsageWindow
|
|
|
157
159
|
* @param usage - the accounts the route answered for.
|
|
158
160
|
* @returns the reading, or undefined when the route stated no figure.
|
|
159
161
|
*/
|
|
162
|
+
/** What one account's allowance reduces to for the chip. */
|
|
163
|
+
export interface AccountReading {
|
|
164
|
+
/** Which account these figures belong to. */
|
|
165
|
+
account: string;
|
|
166
|
+
/** Plan name, when the provider stated one. */
|
|
167
|
+
plan?: string;
|
|
168
|
+
/** Every metered window, in the order the route stated them. */
|
|
169
|
+
windows: readonly UsageWindow[];
|
|
170
|
+
}
|
|
171
|
+
/**
|
|
172
|
+
* Reduce a route's per-account usage to one reading per account.
|
|
173
|
+
*
|
|
174
|
+
* A provider can hold several accounts (two Antigravity sign-ins, say), and the chip
|
|
175
|
+
* shows each one's allowance on its own line, the way the settings cards do — a merged
|
|
176
|
+
* figure would hide that one account is exhausted while the other is free.
|
|
177
|
+
*
|
|
178
|
+
* @param usage - every account the route answered for.
|
|
179
|
+
* @returns one reading per account that states a figure, in the route's order.
|
|
180
|
+
*/
|
|
181
|
+
/**
|
|
182
|
+
* Reduce a route's per-account usage to one reading per account.
|
|
183
|
+
*
|
|
184
|
+
* A provider can hold several accounts (two Antigravity sign-ins, say). Every account's
|
|
185
|
+
* windows are kept as the route stated them — a percentage-metered quota and a credit
|
|
186
|
+
* balance (`credits`) alike — so the per-account panel shows the real figures, and the
|
|
187
|
+
* compact line's merge decides which to integrate.
|
|
188
|
+
*
|
|
189
|
+
* @param usage - every account the route answered for.
|
|
190
|
+
* @returns one reading per account that states any window, in the route's order.
|
|
191
|
+
*/
|
|
192
|
+
export declare function accountReadingsOf(usage: readonly ProviderUsage[]): readonly AccountReading[];
|
|
193
|
+
/**
|
|
194
|
+
* Merge every account's percentage-metered windows into one figure per label.
|
|
195
|
+
*
|
|
196
|
+
* The chip's compact line integrates the whole pool: two accounts at 50 percent read as
|
|
197
|
+
* one window at 50 percent (the same allowance spent twice as wide), not two lines. A
|
|
198
|
+
* credit balance (`credits`) is money, not a share of a shared allowance, so it is not
|
|
199
|
+
* merged — it stays on the per-account panel, where it reads correctly. Only windows
|
|
200
|
+
* that state a `usedPercent` (a share of an allowance) merge.
|
|
201
|
+
*
|
|
202
|
+
* @param readings - one per account, from {@link accountReadingsOf}.
|
|
203
|
+
* @returns one merged window per percentage-metered label, most-spent first.
|
|
204
|
+
*/
|
|
205
|
+
export declare function mergeAccountWindows(readings: readonly AccountReading[]): readonly UsageWindow[];
|
|
206
|
+
/**
|
|
207
|
+
* Reduce one route's usage to the single line the compact chip renders.
|
|
208
|
+
*
|
|
209
|
+
* Kept for the balance-free providers that still show one merged line.
|
|
210
|
+
*
|
|
211
|
+
* @param provider - the route id.
|
|
212
|
+
* @param label - the route's display name.
|
|
213
|
+
* @param usage - the accounts the route answered for.
|
|
214
|
+
* @returns the first account's reading, or undefined when none states a figure.
|
|
215
|
+
*/
|
|
160
216
|
export declare function readingOf(provider: string, label: string, usage: readonly ProviderUsage[]): ChipReading | undefined;
|
|
161
217
|
/**
|
|
162
218
|
* Format a window's reset the way the settings cards do.
|
package/lib/client/UsageChip.js
CHANGED
|
@@ -82,14 +82,16 @@ export function balanceOf(value) {
|
|
|
82
82
|
* Format one wallet for the chip's compact line.
|
|
83
83
|
*
|
|
84
84
|
* A balance is money, not a percentage, so it renders as `¥6.89` rather than a meter;
|
|
85
|
-
* the currency prefix keeps two currencies from being mistaken for one sum.
|
|
85
|
+
* the currency prefix keeps two currencies from being mistaken for one sum. The amount
|
|
86
|
+
* is rounded to two decimal places (a balance reads to the cent, and a figure like
|
|
87
|
+
* `1.1` for `1.10` would mislead) and always carries both places.
|
|
86
88
|
*
|
|
87
89
|
* @param wallet - the wallet to show.
|
|
88
|
-
* @returns the short form, e.g. `¥6.89` or
|
|
90
|
+
* @returns the short form, e.g. `¥6.89` or `+$2.00` for a bonus wallet.
|
|
89
91
|
*/
|
|
90
92
|
export function formatBalance(wallet) {
|
|
91
93
|
const amount = Number.parseFloat(wallet.balance);
|
|
92
|
-
const trimmed = Number.isFinite(amount) ?
|
|
94
|
+
const trimmed = Number.isFinite(amount) ? amount.toFixed(2) : wallet.balance;
|
|
93
95
|
const symbol = wallet.currency === 'CNY' ? '¥' : wallet.currency === 'USD' ? '$' : `${wallet.currency} `;
|
|
94
96
|
return `${wallet.bonus ? '+' : ''}${symbol}${trimmed}`;
|
|
95
97
|
}
|
|
@@ -142,12 +144,90 @@ export function pickWindow(windows) {
|
|
|
142
144
|
return best;
|
|
143
145
|
}
|
|
144
146
|
/**
|
|
145
|
-
* Reduce
|
|
147
|
+
* Reduce a route's per-account usage to one reading per account.
|
|
146
148
|
*
|
|
147
|
-
*
|
|
149
|
+
* A provider can hold several accounts (two Antigravity sign-ins, say), and the chip
|
|
150
|
+
* shows each one's allowance on its own line, the way the settings cards do — a merged
|
|
151
|
+
* figure would hide that one account is exhausted while the other is free.
|
|
152
|
+
*
|
|
153
|
+
* @param usage - every account the route answered for.
|
|
154
|
+
* @returns one reading per account that states a figure, in the route's order.
|
|
155
|
+
*/
|
|
156
|
+
/**
|
|
157
|
+
* Reduce a route's per-account usage to one reading per account.
|
|
158
|
+
*
|
|
159
|
+
* A provider can hold several accounts (two Antigravity sign-ins, say). Every account's
|
|
160
|
+
* windows are kept as the route stated them — a percentage-metered quota and a credit
|
|
161
|
+
* balance (`credits`) alike — so the per-account panel shows the real figures, and the
|
|
162
|
+
* compact line's merge decides which to integrate.
|
|
163
|
+
*
|
|
164
|
+
* @param usage - every account the route answered for.
|
|
165
|
+
* @returns one reading per account that states any window, in the route's order.
|
|
166
|
+
*/
|
|
167
|
+
export function accountReadingsOf(usage) {
|
|
168
|
+
const out = [];
|
|
169
|
+
for (const entry of usage) {
|
|
170
|
+
if (entry.windows.length === 0)
|
|
171
|
+
continue;
|
|
172
|
+
out.push({ account: entry.account, windows: entry.windows, ...entry.plan === undefined ? {} : { plan: entry.plan } });
|
|
173
|
+
}
|
|
174
|
+
return out;
|
|
175
|
+
}
|
|
176
|
+
/**
|
|
177
|
+
* Merge every account's percentage-metered windows into one figure per label.
|
|
178
|
+
*
|
|
179
|
+
* The chip's compact line integrates the whole pool: two accounts at 50 percent read as
|
|
180
|
+
* one window at 50 percent (the same allowance spent twice as wide), not two lines. A
|
|
181
|
+
* credit balance (`credits`) is money, not a share of a shared allowance, so it is not
|
|
182
|
+
* merged — it stays on the per-account panel, where it reads correctly. Only windows
|
|
183
|
+
* that state a `usedPercent` (a share of an allowance) merge.
|
|
184
|
+
*
|
|
185
|
+
* @param readings - one per account, from {@link accountReadingsOf}.
|
|
186
|
+
* @returns one merged window per percentage-metered label, most-spent first.
|
|
187
|
+
*/
|
|
188
|
+
export function mergeAccountWindows(readings) {
|
|
189
|
+
const byLabel = new Map();
|
|
190
|
+
for (const reading of readings) {
|
|
191
|
+
for (const window of reading.windows) {
|
|
192
|
+
// Only a percentage-metered window merges. A credit balance (a bare used/limit
|
|
193
|
+
// pair) is money, not a share of a shared allowance, and averaging it across
|
|
194
|
+
// accounts would misread money as a quota.
|
|
195
|
+
if (window.usedPercent === undefined)
|
|
196
|
+
continue;
|
|
197
|
+
const list = byLabel.get(window.label) ?? [];
|
|
198
|
+
list.push(window);
|
|
199
|
+
byLabel.set(window.label, list);
|
|
200
|
+
}
|
|
201
|
+
}
|
|
202
|
+
const merged = [];
|
|
203
|
+
for (const [label, list] of byLabel) {
|
|
204
|
+
// Percentages average across accounts: the pool's spent share is the mean of each
|
|
205
|
+
// account's spent share (accounts share an allowance, they do not add to it).
|
|
206
|
+
const withPercent = list.filter((window) => window.usedPercent !== undefined);
|
|
207
|
+
if (withPercent.length === 0)
|
|
208
|
+
continue;
|
|
209
|
+
const usedPercent = withPercent.reduce((sum, window) => sum + (window.usedPercent ?? 0), 0) / withPercent.length;
|
|
210
|
+
// The pool's reset is the soonest account reset: the window refills when the first
|
|
211
|
+
// account's does.
|
|
212
|
+
const resets = list.map((window) => window.resetsAt).filter((at) => typeof at === 'number');
|
|
213
|
+
merged.push({
|
|
214
|
+
label,
|
|
215
|
+
usedPercent: Math.max(0, Math.min(100, usedPercent)),
|
|
216
|
+
...resets.length === 0 ? {} : { resetsAt: Math.min(...resets) },
|
|
217
|
+
});
|
|
218
|
+
}
|
|
219
|
+
// Most-spent first, so the line leads with the window that will stop work soonest.
|
|
220
|
+
return merged.sort((left, right) => (right.usedPercent ?? 0) - (left.usedPercent ?? 0));
|
|
221
|
+
}
|
|
222
|
+
/**
|
|
223
|
+
* Reduce one route's usage to the single line the compact chip renders.
|
|
224
|
+
*
|
|
225
|
+
* Kept for the balance-free providers that still show one merged line.
|
|
226
|
+
*
|
|
227
|
+
* @param provider - the route id.
|
|
148
228
|
* @param label - the route's display name.
|
|
149
229
|
* @param usage - the accounts the route answered for.
|
|
150
|
-
* @returns the reading, or undefined when
|
|
230
|
+
* @returns the first account's reading, or undefined when none states a figure.
|
|
151
231
|
*/
|
|
152
232
|
export function readingOf(provider, label, usage) {
|
|
153
233
|
for (const entry of usage) {
|
|
@@ -197,6 +277,7 @@ export function formatReset(at, now) {
|
|
|
197
277
|
*/
|
|
198
278
|
export function UsageChip({ call, t, isOwnProvider, accountBalance, ccSwitchBalance, useProjection }) {
|
|
199
279
|
const [reading, setReading] = useState(undefined);
|
|
280
|
+
const [accountReadings, setAccountReadings] = useState(undefined);
|
|
200
281
|
const [balance, setBalance] = useState(undefined);
|
|
201
282
|
const [ccBalance, setCcBalance] = useState(undefined);
|
|
202
283
|
const [now, setNow] = useState(() => Date.now());
|
|
@@ -239,8 +320,7 @@ export function UsageChip({ call, t, isOwnProvider, accountBalance, ccSwitchBala
|
|
|
239
320
|
const load = useCallback(async (target) => {
|
|
240
321
|
try {
|
|
241
322
|
const result = await call('usage', { provider: target });
|
|
242
|
-
|
|
243
|
-
return readingOf(target, label, result.usage);
|
|
323
|
+
return accountReadingsOf(result.usage);
|
|
244
324
|
}
|
|
245
325
|
catch {
|
|
246
326
|
// A failed read is not worth a chip of its own: the settings page is where a user
|
|
@@ -276,7 +356,7 @@ export function UsageChip({ call, t, isOwnProvider, accountBalance, ccSwitchBala
|
|
|
276
356
|
// Not one of ours (or none selected): keep showing the last own reading.
|
|
277
357
|
: lastOwn.current;
|
|
278
358
|
if (target === undefined) {
|
|
279
|
-
|
|
359
|
+
setAccountReadings(undefined);
|
|
280
360
|
return;
|
|
281
361
|
}
|
|
282
362
|
let live = true;
|
|
@@ -286,7 +366,7 @@ export function UsageChip({ call, t, isOwnProvider, accountBalance, ccSwitchBala
|
|
|
286
366
|
if (!live || next === undefined)
|
|
287
367
|
return;
|
|
288
368
|
lastOwn.current = target;
|
|
289
|
-
|
|
369
|
+
setAccountReadings(next);
|
|
290
370
|
});
|
|
291
371
|
};
|
|
292
372
|
refresh();
|
|
@@ -327,20 +407,22 @@ export function UsageChip({ call, t, isOwnProvider, accountBalance, ccSwitchBala
|
|
|
327
407
|
setOpen((was) => !was);
|
|
328
408
|
}, children: [_jsx("span", { className: "dsh-subscription-usage-chip-label", children: short }), balance.map((wallet) => (_jsx("span", { className: "dsh-subscription-usage-chip-cell", children: _jsx("span", { className: "dsh-subscription-usage-chip-value", children: formatBalance(wallet) }) }, `${wallet.currency}${wallet.bonus ? ':bonus' : ''}`)))] }), open && (_jsxs("span", { className: "dsh-subscription-usage-chip-panel", role: "status", children: [_jsx("span", { className: "dsh-subscription-usage-chip-full", children: t('usageChipFull', { provider: 'DeepSeek Account' }) }), balance.map((wallet) => (_jsx("span", { className: "dsh-subscription-usage-chip-meter", children: _jsxs("span", { className: "dsh-subscription-usage-chip-meterHead", children: [_jsx("span", { children: `${wallet.bonus ? 'Bonus · ' : ''}${wallet.currency}` }), _jsx("span", { className: "dsh-subscription-usage-chip-detail", children: formatBalance(wallet) })] }) }, `${wallet.currency}${wallet.bonus ? ':bonus' : ''}`)))] }))] }));
|
|
329
409
|
}
|
|
330
|
-
if (
|
|
331
|
-
return null;
|
|
332
|
-
const percent = reading.window.usedPercent;
|
|
333
|
-
if (percent === undefined)
|
|
410
|
+
if (accountReadings === undefined || accountReadings.length === 0)
|
|
334
411
|
return null;
|
|
335
|
-
const
|
|
336
|
-
|
|
337
|
-
|
|
338
|
-
|
|
412
|
+
const short = shortLabel(selected ?? '', selected ?? '');
|
|
413
|
+
// The compact line integrates the whole pool; the panel shows each account's own
|
|
414
|
+
// figures, so a merged glance hides nothing a per-account look cannot recover.
|
|
415
|
+
const merged = mergeAccountWindows(accountReadings);
|
|
416
|
+
return (_jsxs("span", { className: "dsh-subscription-usage-chip", children: [_jsx("style", { children: usageChipCss }), _jsxs("button", { type: "button", className: "dsh-subscription-usage-chip-trigger", "aria-expanded": open, title: t('usageChipTitle', { provider: selected ?? '' }), onClick: (event) => {
|
|
339
417
|
// The document listener would otherwise close what this click just opened.
|
|
340
418
|
event.stopPropagation();
|
|
341
419
|
setOpen((was) => !was);
|
|
342
|
-
}, children: [_jsx("span", { className: "dsh-subscription-usage-chip-label", children: short }),
|
|
343
|
-
|
|
344
|
-
|
|
345
|
-
|
|
420
|
+
}, children: [_jsx("span", { className: "dsh-subscription-usage-chip-label", children: short }), merged.map((window) => (_jsxs("span", { className: "dsh-subscription-usage-chip-cell", children: [_jsx("span", { className: "dsh-subscription-usage-chip-window", children: shortWindow(window.label) }), _jsx("span", { className: "dsh-subscription-usage-chip-value", children: `${String(Math.round(window.usedPercent ?? 0))}%` })] }, window.label)))] }), open && (_jsxs("span", { className: "dsh-subscription-usage-chip-panel", role: "status", children: [_jsx("span", { className: "dsh-subscription-usage-chip-full", children: t('usageChipFull', { provider: selected ?? '' }) }), accountReadings.map((account) => (_jsxs("span", { className: "dsh-subscription-usage-chip-account", children: [_jsx("span", { className: "dsh-subscription-usage-chip-meterHead", children: _jsx("span", { children: account.plan ?? account.account }) }), account.windows.map((window) => {
|
|
421
|
+
const windowReset = formatReset(window.resetsAt, now);
|
|
422
|
+
// A credit balance reads as money (`used`/`limit`), a quota as a percent.
|
|
423
|
+
const amount = window.usedPercent === undefined && window.used !== undefined && window.limit !== undefined
|
|
424
|
+
? `${String(window.used)}/${String(window.limit)}`
|
|
425
|
+
: `${String(Math.round(window.usedPercent ?? 0))}%`;
|
|
426
|
+
return (_jsxs("span", { className: "dsh-subscription-usage-chip-meter", children: [_jsxs("span", { className: "dsh-subscription-usage-chip-meterHead", children: [_jsx("span", { children: window.label }), _jsx("span", { className: "dsh-subscription-usage-chip-detail", children: `${amount}${windowReset === undefined ? '' : ` · ${windowReset}`}` })] }), window.usedPercent !== undefined && (_jsx("span", { className: "dsh-subscription-usage-chip-track", "aria-hidden": "true", children: _jsx("span", { className: "dsh-subscription-usage-chip-fill", style: { width: `${String(Math.max(0, Math.min(100, window.usedPercent ?? 0)))}%` } }) }))] }, window.label));
|
|
427
|
+
})] }, account.account)))] }))] }));
|
|
346
428
|
}
|