dsh-plugin-subscription-oauth 0.4.0 → 0.6.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/ccswitch.d.ts +36 -0
- package/lib/auth/ccswitch.js +75 -0
- package/lib/auth/deepseek-balance.d.ts +58 -0
- package/lib/auth/deepseek-balance.js +63 -0
- package/lib/auth/rpc.d.ts +32 -2
- package/lib/auth/rpc.js +52 -18
- package/lib/client/UsageChip.d.ts +60 -2
- package/lib/client/UsageChip.js +115 -1
- package/lib/client/index.d.ts +1 -1
- package/lib/client/index.js +3 -1
- package/lib/client/subscriptions-rpc.d.ts +54 -1
- package/lib/client/subscriptions-rpc.js +58 -0
- package/lib/client.js +237 -2
- package/lib/client.js.map +1 -1
- package/lib/index.d.ts +12 -0
- package/lib/index.js +33 -1
- package/lib/protocol.d.ts +80 -1
- package/lib/protocol.js +2 -0
- package/lib/providers/kimi.js +2 -1
- package/lib/translate/moonshot-schema.d.ts +31 -0
- package/lib/translate/moonshot-schema.js +81 -0
- package/lib/translate/responses.d.ts +3 -1
- package/lib/translate/responses.js +4 -2
- package/package.json +1 -1
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The cc-switch balance query, reimplemented for the plugin's node half.
|
|
3
|
+
*
|
|
4
|
+
* cc-switch (github.com/farion1231/cc-switch) reads a DeepSeek-compatible provider's
|
|
5
|
+
* balance with `GET https://api.deepseek.com/user/balance`, an `Authorization: Bearer
|
|
6
|
+
* <api key>` header, and a 15 s timeout. This is that request, with the URL hardcoded
|
|
7
|
+
* exactly as cc-switch does — the provider's own base URL is never substituted in.
|
|
8
|
+
*
|
|
9
|
+
* The endpoint answers `{ is_available, balance_infos: [{ currency, total_balance, … }] }`,
|
|
10
|
+
* where a balance is a number or a numeric string. The module reduces that to the one
|
|
11
|
+
* figure a status chip needs: the first currency's total. Anything else — a missing key,
|
|
12
|
+
* a non-2xx answer, malformed JSON — resolves to nothing, so the caller shows no balance
|
|
13
|
+
* rather than a wrong one.
|
|
14
|
+
*
|
|
15
|
+
* @module dsh-plugin-subscription-oauth/auth/ccswitch
|
|
16
|
+
*/
|
|
17
|
+
import type { FetchFn } from '../http.js';
|
|
18
|
+
/** One currency's balance, normalised from the endpoint's answer. */
|
|
19
|
+
export interface CcSwitchBalance {
|
|
20
|
+
/** The currency the balance is denominated in. */
|
|
21
|
+
currency: string;
|
|
22
|
+
/** The remaining balance, as a decimal string. */
|
|
23
|
+
total: string;
|
|
24
|
+
/** Whether the account reports itself as usable. */
|
|
25
|
+
available: boolean;
|
|
26
|
+
}
|
|
27
|
+
/**
|
|
28
|
+
* Query one provider's balance the way cc-switch does.
|
|
29
|
+
*
|
|
30
|
+
* @param input - the provider's API key, and optionally a fetch to substitute in tests.
|
|
31
|
+
* @returns the balance, or undefined when the provider did not answer with one.
|
|
32
|
+
*/
|
|
33
|
+
export declare function queryCcSwitchBalance(input: {
|
|
34
|
+
apiKey: string;
|
|
35
|
+
fetchFn?: FetchFn;
|
|
36
|
+
}): Promise<CcSwitchBalance | undefined>;
|
|
@@ -0,0 +1,75 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The cc-switch balance query, reimplemented for the plugin's node half.
|
|
3
|
+
*
|
|
4
|
+
* cc-switch (github.com/farion1231/cc-switch) reads a DeepSeek-compatible provider's
|
|
5
|
+
* balance with `GET https://api.deepseek.com/user/balance`, an `Authorization: Bearer
|
|
6
|
+
* <api key>` header, and a 15 s timeout. This is that request, with the URL hardcoded
|
|
7
|
+
* exactly as cc-switch does — the provider's own base URL is never substituted in.
|
|
8
|
+
*
|
|
9
|
+
* The endpoint answers `{ is_available, balance_infos: [{ currency, total_balance, … }] }`,
|
|
10
|
+
* where a balance is a number or a numeric string. The module reduces that to the one
|
|
11
|
+
* figure a status chip needs: the first currency's total. Anything else — a missing key,
|
|
12
|
+
* a non-2xx answer, malformed JSON — resolves to nothing, so the caller shows no balance
|
|
13
|
+
* rather than a wrong one.
|
|
14
|
+
*
|
|
15
|
+
* @module dsh-plugin-subscription-oauth/auth/ccswitch
|
|
16
|
+
*/
|
|
17
|
+
import { proxiedFetch } from '../http.js';
|
|
18
|
+
/** Read a balance field that may be a number or a numeric string. */
|
|
19
|
+
function num(value) {
|
|
20
|
+
if (typeof value === 'number' && Number.isFinite(value))
|
|
21
|
+
return String(value);
|
|
22
|
+
if (typeof value === 'string' && value.trim() !== '' && Number.isFinite(Number(value)))
|
|
23
|
+
return value.trim();
|
|
24
|
+
return undefined;
|
|
25
|
+
}
|
|
26
|
+
/**
|
|
27
|
+
* Query one provider's balance the way cc-switch does.
|
|
28
|
+
*
|
|
29
|
+
* @param input - the provider's API key, and optionally a fetch to substitute in tests.
|
|
30
|
+
* @returns the balance, or undefined when the provider did not answer with one.
|
|
31
|
+
*/
|
|
32
|
+
export async function queryCcSwitchBalance(input) {
|
|
33
|
+
const fetchFn = input.fetchFn ?? proxiedFetch;
|
|
34
|
+
let response;
|
|
35
|
+
try {
|
|
36
|
+
// cc-switch's exact request shape: hardcoded URL, bearer auth, 15 s timeout.
|
|
37
|
+
response = await fetchFn('https://api.deepseek.com/user/balance', {
|
|
38
|
+
headers: { authorization: `Bearer ${input.apiKey}`, accept: 'application/json' },
|
|
39
|
+
signal: AbortSignal.timeout(15_000),
|
|
40
|
+
});
|
|
41
|
+
}
|
|
42
|
+
catch {
|
|
43
|
+
return undefined;
|
|
44
|
+
}
|
|
45
|
+
if (!response.ok)
|
|
46
|
+
return undefined;
|
|
47
|
+
let payload;
|
|
48
|
+
try {
|
|
49
|
+
payload = await response.json();
|
|
50
|
+
}
|
|
51
|
+
catch {
|
|
52
|
+
return undefined;
|
|
53
|
+
}
|
|
54
|
+
if (typeof payload !== 'object' || payload === null)
|
|
55
|
+
return undefined;
|
|
56
|
+
const record = payload;
|
|
57
|
+
const infos = record.balance_infos;
|
|
58
|
+
if (!Array.isArray(infos))
|
|
59
|
+
return undefined;
|
|
60
|
+
for (const raw of infos) {
|
|
61
|
+
if (typeof raw !== 'object' || raw === null)
|
|
62
|
+
continue;
|
|
63
|
+
const info = raw;
|
|
64
|
+
const total = num(info.total_balance);
|
|
65
|
+
if (total === undefined)
|
|
66
|
+
continue;
|
|
67
|
+
const currency = typeof info.currency === 'string' && info.currency.trim() !== '' ? info.currency.trim() : 'CNY';
|
|
68
|
+
return {
|
|
69
|
+
currency,
|
|
70
|
+
total,
|
|
71
|
+
available: record.is_available !== false,
|
|
72
|
+
};
|
|
73
|
+
}
|
|
74
|
+
return undefined;
|
|
75
|
+
}
|
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The DeepSeek account's balance, read on the node half.
|
|
3
|
+
*
|
|
4
|
+
* The host owns the DeepSeek OAuth grant and its platform endpoint, and exposes a
|
|
5
|
+
* `deepseekAccount` service with a `getBalance(client)`. The browser cannot call that
|
|
6
|
+
* service directly — it lives behind the host's Remote controller, not a `/api` fetch
|
|
7
|
+
* route — so this plugin's node half reads it and serves it over the plugin's own RPC,
|
|
8
|
+
* the same channel the rest of the chip's data already travels on.
|
|
9
|
+
*
|
|
10
|
+
* @module dsh-plugin-subscription-oauth/auth/deepseek-balance
|
|
11
|
+
*/
|
|
12
|
+
import type { FetchFn } from '../http.js';
|
|
13
|
+
/** One currency's wallet, as the platform reports it. */
|
|
14
|
+
export interface DeepSeekWallet {
|
|
15
|
+
/** ISO currency the balance is denominated in. */
|
|
16
|
+
currency: 'CNY' | 'USD' | string;
|
|
17
|
+
/** Remaining balance, as a decimal string. */
|
|
18
|
+
balance: string;
|
|
19
|
+
}
|
|
20
|
+
/** The DeepSeek account's wallets, normal and bonus. */
|
|
21
|
+
export interface DeepSeekBalance {
|
|
22
|
+
/** Normal (topped-up) wallets, one per currency. */
|
|
23
|
+
wallets: readonly DeepSeekWallet[];
|
|
24
|
+
/** Bonus / promotional wallets, one per currency. */
|
|
25
|
+
bonusWallets: readonly DeepSeekWallet[];
|
|
26
|
+
}
|
|
27
|
+
/**
|
|
28
|
+
* The slice of the host's `deepseekAccount` service this module reads.
|
|
29
|
+
*
|
|
30
|
+
* Declared locally rather than imported: the host package's published typings differ
|
|
31
|
+
* across DSH lines, and this plugin only needs this one method.
|
|
32
|
+
*/
|
|
33
|
+
export interface DeepSeekAccountService {
|
|
34
|
+
getBalance: (client: unknown) => Promise<unknown>;
|
|
35
|
+
}
|
|
36
|
+
/**
|
|
37
|
+
* Read the DeepSeek account's balance through the host's service.
|
|
38
|
+
*
|
|
39
|
+
* @param account - the host's `deepseekAccount` service, when the composition provides
|
|
40
|
+
* one.
|
|
41
|
+
* @param client - the client metadata the service expects.
|
|
42
|
+
* @returns the balance, or undefined when the account is signed out or the read failed.
|
|
43
|
+
*/
|
|
44
|
+
export declare function readDeepSeekBalance(account: DeepSeekAccountService | undefined, client: unknown): Promise<DeepSeekBalance | undefined>;
|
|
45
|
+
/**
|
|
46
|
+
* Reduce the host service's answer to the wallets the chip shows.
|
|
47
|
+
*
|
|
48
|
+
* The service resolves `null` when the account is signed out, `{ status: 'failed' }`
|
|
49
|
+
* when the read did not complete, and `{ status: 'ready', value: <normal>, bonusWallets }`
|
|
50
|
+
* on success. Anything else — an unexpected shape from a newer host — is nothing rather
|
|
51
|
+
* than a wrong figure.
|
|
52
|
+
*
|
|
53
|
+
* @param value - the service's raw answer.
|
|
54
|
+
* @returns the wallets, or undefined when there is nothing usable.
|
|
55
|
+
*/
|
|
56
|
+
export declare function normalizeDeepSeekBalance(value: unknown): DeepSeekBalance | undefined;
|
|
57
|
+
/** A fetch substitute is accepted for symmetry with the other balance readers. */
|
|
58
|
+
export type { FetchFn };
|
|
@@ -0,0 +1,63 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The DeepSeek account's balance, read on the node half.
|
|
3
|
+
*
|
|
4
|
+
* The host owns the DeepSeek OAuth grant and its platform endpoint, and exposes a
|
|
5
|
+
* `deepseekAccount` service with a `getBalance(client)`. The browser cannot call that
|
|
6
|
+
* service directly — it lives behind the host's Remote controller, not a `/api` fetch
|
|
7
|
+
* route — so this plugin's node half reads it and serves it over the plugin's own RPC,
|
|
8
|
+
* the same channel the rest of the chip's data already travels on.
|
|
9
|
+
*
|
|
10
|
+
* @module dsh-plugin-subscription-oauth/auth/deepseek-balance
|
|
11
|
+
*/
|
|
12
|
+
import { proxiedFetch } from '../http.js';
|
|
13
|
+
/**
|
|
14
|
+
* Read the DeepSeek account's balance through the host's service.
|
|
15
|
+
*
|
|
16
|
+
* @param account - the host's `deepseekAccount` service, when the composition provides
|
|
17
|
+
* one.
|
|
18
|
+
* @param client - the client metadata the service expects.
|
|
19
|
+
* @returns the balance, or undefined when the account is signed out or the read failed.
|
|
20
|
+
*/
|
|
21
|
+
export async function readDeepSeekBalance(account, client) {
|
|
22
|
+
if (account === undefined)
|
|
23
|
+
return undefined;
|
|
24
|
+
let result;
|
|
25
|
+
try {
|
|
26
|
+
result = await account.getBalance(client);
|
|
27
|
+
}
|
|
28
|
+
catch {
|
|
29
|
+
return undefined;
|
|
30
|
+
}
|
|
31
|
+
return normalizeDeepSeekBalance(result);
|
|
32
|
+
}
|
|
33
|
+
/**
|
|
34
|
+
* Reduce the host service's answer to the wallets the chip shows.
|
|
35
|
+
*
|
|
36
|
+
* The service resolves `null` when the account is signed out, `{ status: 'failed' }`
|
|
37
|
+
* when the read did not complete, and `{ status: 'ready', value: <normal>, bonusWallets }`
|
|
38
|
+
* on success. Anything else — an unexpected shape from a newer host — is nothing rather
|
|
39
|
+
* than a wrong figure.
|
|
40
|
+
*
|
|
41
|
+
* @param value - the service's raw answer.
|
|
42
|
+
* @returns the wallets, or undefined when there is nothing usable.
|
|
43
|
+
*/
|
|
44
|
+
export function normalizeDeepSeekBalance(value) {
|
|
45
|
+
if (typeof value !== 'object' || value === null)
|
|
46
|
+
return undefined;
|
|
47
|
+
const record = value;
|
|
48
|
+
if (record.status !== 'ready')
|
|
49
|
+
return undefined;
|
|
50
|
+
if (!Array.isArray(record.value))
|
|
51
|
+
return undefined;
|
|
52
|
+
const wallets = record.value.filter(isWallet);
|
|
53
|
+
const bonusRaw = record.bonusWallets;
|
|
54
|
+
const bonusWallets = Array.isArray(bonusRaw) ? bonusRaw.filter(isWallet) : [];
|
|
55
|
+
return { wallets, bonusWallets };
|
|
56
|
+
}
|
|
57
|
+
/** Narrow one entry to a wallet shape. */
|
|
58
|
+
function isWallet(value) {
|
|
59
|
+
if (typeof value !== 'object' || value === null)
|
|
60
|
+
return false;
|
|
61
|
+
const record = value;
|
|
62
|
+
return typeof record.currency === 'string' && typeof record.balance === 'string';
|
|
63
|
+
}
|
package/lib/auth/rpc.d.ts
CHANGED
|
@@ -1,3 +1,32 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* How one named credential resolves to a value.
|
|
3
|
+
*
|
|
4
|
+
* The host's credential service owns this; the RPC layer only calls it, so the balance
|
|
5
|
+
* query does not re-derive how a key is stored.
|
|
6
|
+
*/
|
|
7
|
+
export type CredentialResolver = (ref: string) => Promise<string | undefined>;
|
|
8
|
+
/**
|
|
9
|
+
* The balance a non-subscription provider reports, and how to read it.
|
|
10
|
+
*
|
|
11
|
+
* A provider that is not a subscription this plugin serves has no usage windows, so the
|
|
12
|
+
* chip shows its balance instead. The balance is read by cc-switch's query, which needs
|
|
13
|
+
* the provider's API key; the provider's profile names that key by reference
|
|
14
|
+
* (`apiKeyEnv`), and this map is the one place a provider id is tied to that reference.
|
|
15
|
+
*/
|
|
16
|
+
export interface BalanceContext {
|
|
17
|
+
/** The credential reference each provider id's balance query uses. */
|
|
18
|
+
readonly credentialRefs: Readonly<Record<string, string>>;
|
|
19
|
+
/** Resolve one credential reference to its key. */
|
|
20
|
+
readonly resolveCredential?: CredentialResolver;
|
|
21
|
+
/**
|
|
22
|
+
* Resolve the host's DeepSeek account service, read lazily per request.
|
|
23
|
+
*
|
|
24
|
+
* The service may register after this plugin applies, so it is resolved when a
|
|
25
|
+
* balance is actually read, not once at startup. Returns undefined when the
|
|
26
|
+
* composition has no such service.
|
|
27
|
+
*/
|
|
28
|
+
readonly resolveDeepSeekAccount?: () => DeepSeekAccountService | undefined;
|
|
29
|
+
}
|
|
1
30
|
/**
|
|
2
31
|
* The host-side transport behind the Settings page.
|
|
3
32
|
*
|
|
@@ -10,6 +39,7 @@
|
|
|
10
39
|
* @module dsh-plugin-subscription-oauth/auth/rpc
|
|
11
40
|
*/
|
|
12
41
|
import type { Context } from '@deepseek-ai/cordis';
|
|
42
|
+
import type { DeepSeekAccountService } from './deepseek-balance.js';
|
|
13
43
|
import type { RpcResult } from '../protocol.js';
|
|
14
44
|
import type { SubscriptionRegistry } from './registry.js';
|
|
15
45
|
/**
|
|
@@ -21,11 +51,11 @@ import type { SubscriptionRegistry } from './registry.js';
|
|
|
21
51
|
* @param payload - the request's decoded payload.
|
|
22
52
|
* @returns the envelope result.
|
|
23
53
|
*/
|
|
24
|
-
export declare function dispatchRpc(registry: SubscriptionRegistry, endpoint: string, payload: unknown): Promise<RpcResult>;
|
|
54
|
+
export declare function dispatchRpc(registry: SubscriptionRegistry, endpoint: string, payload: unknown, balance?: BalanceContext): Promise<RpcResult>;
|
|
25
55
|
/**
|
|
26
56
|
* Register every endpoint route on the `connection` service, when one exists.
|
|
27
57
|
*
|
|
28
58
|
* @param ctx - the plugin's context.
|
|
29
59
|
* @param registry - the provider routes every route dispatches through.
|
|
30
60
|
*/
|
|
31
|
-
export declare function registerSubscriptionRpc(ctx: Context, registry: SubscriptionRegistry): void;
|
|
61
|
+
export declare function registerSubscriptionRpc(ctx: Context, registry: SubscriptionRegistry, balance?: BalanceContext): void;
|
package/lib/auth/rpc.js
CHANGED
|
@@ -1,15 +1,6 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* The host-side transport behind the Settings page.
|
|
3
|
-
*
|
|
4
|
-
* Each endpoint is served as its own `POST /api/subscription-oauth.<endpoint>`
|
|
5
|
-
* Fetch route on the `connection` service. `connection` is deliberately not in
|
|
6
|
-
* the plugin's `inject` list: a headless composition has no connection service,
|
|
7
|
-
* and the provider routes must still work there. Nothing else is registered
|
|
8
|
-
* when it is absent, so the browser half simply finds no routes.
|
|
9
|
-
*
|
|
10
|
-
* @module dsh-plugin-subscription-oauth/auth/rpc
|
|
11
|
-
*/
|
|
12
1
|
import { BAD_REQUEST, ProviderError } from '../errors.js';
|
|
2
|
+
import { queryCcSwitchBalance } from './ccswitch.js';
|
|
3
|
+
import { readDeepSeekBalance } from './deepseek-balance.js';
|
|
13
4
|
import { RPC_ENDPOINTS, RPC_PREFIX } from '../protocol.js';
|
|
14
5
|
/** Wrap a value as a successful envelope result. */
|
|
15
6
|
function success(value) {
|
|
@@ -42,6 +33,25 @@ function stringField(payload, field) {
|
|
|
42
33
|
const trimmed = value.trim();
|
|
43
34
|
return trimmed.length === 0 ? undefined : trimmed;
|
|
44
35
|
}
|
|
36
|
+
/**
|
|
37
|
+
* Read the client metadata a balance endpoint forwards to the host service.
|
|
38
|
+
*
|
|
39
|
+
* The host's account service expects a version / locale / timezone object; a missing or
|
|
40
|
+
* partial one is tolerated by building a default rather than failing the read.
|
|
41
|
+
*
|
|
42
|
+
* @param payload - the request payload.
|
|
43
|
+
* @returns the client metadata object.
|
|
44
|
+
*/
|
|
45
|
+
function payloadClient(payload) {
|
|
46
|
+
const record = typeof payload === 'object' && payload !== null ? payload : {};
|
|
47
|
+
return {
|
|
48
|
+
version: typeof record.version === 'string' ? record.version : '1',
|
|
49
|
+
locale: typeof record.locale === 'string' ? record.locale : 'en-US',
|
|
50
|
+
timezoneOffsetSeconds: typeof record.timezoneOffsetSeconds === 'number'
|
|
51
|
+
? record.timezoneOffsetSeconds
|
|
52
|
+
: 0,
|
|
53
|
+
};
|
|
54
|
+
}
|
|
45
55
|
/** Read a boolean field from a payload, defaulting to false when absent. */
|
|
46
56
|
function booleanField(payload, field) {
|
|
47
57
|
if (typeof payload !== 'object' || payload === null)
|
|
@@ -85,9 +95,9 @@ function requiredProvider(payload, endpoint) {
|
|
|
85
95
|
* @param payload - the request's decoded payload.
|
|
86
96
|
* @returns the envelope result.
|
|
87
97
|
*/
|
|
88
|
-
export async function dispatchRpc(registry, endpoint, payload) {
|
|
98
|
+
export async function dispatchRpc(registry, endpoint, payload, balance) {
|
|
89
99
|
try {
|
|
90
|
-
return success(await runEndpoint(registry, endpoint, payload));
|
|
100
|
+
return success(await runEndpoint(registry, endpoint, payload, balance));
|
|
91
101
|
}
|
|
92
102
|
catch (error) {
|
|
93
103
|
return failure(error);
|
|
@@ -97,7 +107,7 @@ export async function dispatchRpc(registry, endpoint, payload) {
|
|
|
97
107
|
* The endpoint table.
|
|
98
108
|
* @throws {ProviderError} for a request the registry refuses.
|
|
99
109
|
*/
|
|
100
|
-
async function runEndpoint(registry, endpoint, payload) {
|
|
110
|
+
async function runEndpoint(registry, endpoint, payload, balance) {
|
|
101
111
|
switch (endpoint) {
|
|
102
112
|
case 'status':
|
|
103
113
|
return registry.status();
|
|
@@ -145,6 +155,30 @@ async function runEndpoint(registry, endpoint, payload) {
|
|
|
145
155
|
}
|
|
146
156
|
case 'usage':
|
|
147
157
|
return registry.usage(requiredProvider(payload, endpoint), booleanField(payload, 'force'));
|
|
158
|
+
case 'deepseek-balance': {
|
|
159
|
+
// The DeepSeek account's balance, read on the node half through the host's own
|
|
160
|
+
// account service. The browser cannot call that service directly (it sits behind
|
|
161
|
+
// the host's Remote controller, not a /api fetch route), so the node half reads it
|
|
162
|
+
// and serves it here, on the same channel as the rest of the chip's data. The
|
|
163
|
+
// service is resolved lazily, per request, because it may register after apply.
|
|
164
|
+
const client = payloadClient(payload);
|
|
165
|
+
return await readDeepSeekBalance(balance?.resolveDeepSeekAccount?.(), client);
|
|
166
|
+
}
|
|
167
|
+
case 'ccswitch-balance': {
|
|
168
|
+
// A non-subscription provider's balance, by cc-switch's query. The caller names
|
|
169
|
+
// the provider; the configured map turns that into the credential reference the
|
|
170
|
+
// provider's profile uses, and the host's credential service resolves it. No
|
|
171
|
+
// entry, no key, or a key the provider does not answer for means no balance —
|
|
172
|
+
// never a fabricated zero.
|
|
173
|
+
const provider = requiredProvider(payload, endpoint);
|
|
174
|
+
const credentialRef = balance?.credentialRefs[provider];
|
|
175
|
+
if (credentialRef === undefined || balance?.resolveCredential === undefined)
|
|
176
|
+
return undefined;
|
|
177
|
+
const apiKey = await balance.resolveCredential(credentialRef);
|
|
178
|
+
if (apiKey === undefined)
|
|
179
|
+
return undefined;
|
|
180
|
+
return await queryCcSwitchBalance({ apiKey });
|
|
181
|
+
}
|
|
148
182
|
case 'reload':
|
|
149
183
|
return registry.reload();
|
|
150
184
|
default:
|
|
@@ -152,7 +186,7 @@ async function runEndpoint(registry, endpoint, payload) {
|
|
|
152
186
|
}
|
|
153
187
|
}
|
|
154
188
|
/** Build the Fetch route for one endpoint. */
|
|
155
|
-
function rpcRoute(endpoint, registry) {
|
|
189
|
+
function rpcRoute(endpoint, registry, balance) {
|
|
156
190
|
const method = `${RPC_PREFIX}${endpoint}`;
|
|
157
191
|
return {
|
|
158
192
|
path: `/api/${method}`,
|
|
@@ -176,7 +210,7 @@ function rpcRoute(endpoint, registry) {
|
|
|
176
210
|
if (envelopeRequest.type !== 'client-request' || envelopeRequest.method !== method) {
|
|
177
211
|
return rejection(rpcId, 'gateway/bad-request', `${method} received an envelope for another method`, 400);
|
|
178
212
|
}
|
|
179
|
-
return envelope(rpcId, await dispatchRpc(registry, endpoint, envelopeRequest.payload));
|
|
213
|
+
return envelope(rpcId, await dispatchRpc(registry, endpoint, envelopeRequest.payload, balance));
|
|
180
214
|
},
|
|
181
215
|
};
|
|
182
216
|
}
|
|
@@ -186,7 +220,7 @@ function rpcRoute(endpoint, registry) {
|
|
|
186
220
|
* @param ctx - the plugin's context.
|
|
187
221
|
* @param registry - the provider routes every route dispatches through.
|
|
188
222
|
*/
|
|
189
|
-
export function registerSubscriptionRpc(ctx, registry) {
|
|
223
|
+
export function registerSubscriptionRpc(ctx, registry, balance) {
|
|
190
224
|
ctx.inject(['connection'], (connectionCtx) => {
|
|
191
225
|
const connection = connectionCtx.get('connection');
|
|
192
226
|
if (connection === undefined)
|
|
@@ -194,7 +228,7 @@ export function registerSubscriptionRpc(ctx, registry) {
|
|
|
194
228
|
const register = (route) => connection.fetch.register(route);
|
|
195
229
|
for (const endpoint of RPC_ENDPOINTS) {
|
|
196
230
|
const method = `${RPC_PREFIX}${endpoint}`;
|
|
197
|
-
connectionCtx.effect(() => register(rpcRoute(endpoint, registry)), `dsh-plugin-subscription-oauth: /api/${method} route`);
|
|
231
|
+
connectionCtx.effect(() => register(rpcRoute(endpoint, registry, balance)), `dsh-plugin-subscription-oauth: /api/${method} route`);
|
|
198
232
|
}
|
|
199
233
|
});
|
|
200
234
|
}
|
|
@@ -17,7 +17,7 @@
|
|
|
17
17
|
*/
|
|
18
18
|
import type { ReactNode } from 'react';
|
|
19
19
|
import type { SessionStandardProps } from '@deepseek-ai/dsh-client-ui-slots';
|
|
20
|
-
import type { SubscriptionsCall } from './subscriptions-rpc.js';
|
|
20
|
+
import type { SubscriptionsCall, AccountBalanceCall, CcSwitchBalanceCall } from './subscriptions-rpc.js';
|
|
21
21
|
import type { ProviderUsage, UsageWindow } from '../protocol.js';
|
|
22
22
|
/** Injected props the chip is registered with. */
|
|
23
23
|
export interface UsageChipInjected {
|
|
@@ -27,6 +27,24 @@ export interface UsageChipInjected {
|
|
|
27
27
|
t: (key: 'usageChipTitle' | 'usageChipFull', values?: Record<string, string | number>) => string;
|
|
28
28
|
/** Whether the provider is one this plugin serves. */
|
|
29
29
|
isOwnProvider: (provider: string) => boolean;
|
|
30
|
+
/**
|
|
31
|
+
* The DeepSeek account's balance, when the session uses it.
|
|
32
|
+
*
|
|
33
|
+
* The host owns that account's grant and the platform endpoint, so the chip asks the
|
|
34
|
+
* host rather than re-deriving either. It resolves to `undefined` when the account is
|
|
35
|
+
* signed out or the read fails, in which case the chip stays silent.
|
|
36
|
+
*/
|
|
37
|
+
accountBalance: AccountBalanceCall;
|
|
38
|
+
/**
|
|
39
|
+
* A non-subscription provider's balance, by cc-switch's query.
|
|
40
|
+
*
|
|
41
|
+
* A provider that is not a subscription this plugin serves has no usage windows to
|
|
42
|
+
* meter, so the chip shows its balance instead. The caller names the provider; the
|
|
43
|
+
* server maps it to the credential reference the provider's profile names (`apiKeyEnv`),
|
|
44
|
+
* resolves that to the API key, and asks cc-switch's balance endpoint. It resolves to
|
|
45
|
+
* `undefined` when the provider has no balance source or did not answer.
|
|
46
|
+
*/
|
|
47
|
+
ccSwitchBalance: CcSwitchBalanceCall;
|
|
30
48
|
}
|
|
31
49
|
/** One model choice, as the host records it. */
|
|
32
50
|
export interface ModelSelection {
|
|
@@ -65,6 +83,46 @@ export interface ChipReading {
|
|
|
65
83
|
/** Plan name, when the provider stated one. */
|
|
66
84
|
plan?: string;
|
|
67
85
|
}
|
|
86
|
+
/** One currency's balance, as the host's account controller reports it. */
|
|
87
|
+
export interface ChipBalance {
|
|
88
|
+
/** The wallet's currency. */
|
|
89
|
+
currency: string;
|
|
90
|
+
/** Remaining balance, as a decimal string. */
|
|
91
|
+
balance: string;
|
|
92
|
+
/** Whether this is a bonus / promotional wallet rather than topped-up credit. */
|
|
93
|
+
bonus: boolean;
|
|
94
|
+
}
|
|
95
|
+
/**
|
|
96
|
+
* Reduce the node half's balance payload to the wallets the chip shows.
|
|
97
|
+
*
|
|
98
|
+
* Bonus wallets are shown after the topped-up ones, so a reader sees their own credit
|
|
99
|
+
* first and any promotional credit as a secondary figure. A bonus wallet that holds
|
|
100
|
+
* nothing is dropped rather than shown as `+¥0`: the platform reports one for every
|
|
101
|
+
* currency whether or not there is a bonus, and an empty one is noise, not information.
|
|
102
|
+
*
|
|
103
|
+
* @param value - the node half's balance, or undefined when there is none.
|
|
104
|
+
* @returns the wallets, topped-up first then bonus, or undefined when nothing is usable.
|
|
105
|
+
*/
|
|
106
|
+
export declare function balanceOf(value: {
|
|
107
|
+
wallets?: readonly {
|
|
108
|
+
currency: string;
|
|
109
|
+
balance: string;
|
|
110
|
+
}[];
|
|
111
|
+
bonusWallets?: readonly {
|
|
112
|
+
currency: string;
|
|
113
|
+
balance: string;
|
|
114
|
+
}[];
|
|
115
|
+
} | null | undefined): readonly ChipBalance[] | undefined;
|
|
116
|
+
/**
|
|
117
|
+
* Format one wallet for the chip's compact line.
|
|
118
|
+
*
|
|
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.
|
|
121
|
+
*
|
|
122
|
+
* @param wallet - the wallet to show.
|
|
123
|
+
* @returns the short form, e.g. `¥6.89` or `+¥2` for a bonus wallet.
|
|
124
|
+
*/
|
|
125
|
+
export declare function formatBalance(wallet: ChipBalance): string;
|
|
68
126
|
/**
|
|
69
127
|
* The short form of a route's name.
|
|
70
128
|
*
|
|
@@ -117,4 +175,4 @@ export declare function formatReset(at: number | undefined, now: number): string
|
|
|
117
175
|
* @param props - injected RPC caller, copy, and the session's provider reader.
|
|
118
176
|
* @returns the chip, or null when there is nothing true to say.
|
|
119
177
|
*/
|
|
120
|
-
export declare function UsageChip({ call, t, isOwnProvider, useProjection }: UsageChipInjected & SessionStandardProps): ReactNode;
|
|
178
|
+
export declare function UsageChip({ call, t, isOwnProvider, accountBalance, ccSwitchBalance, useProjection }: UsageChipInjected & SessionStandardProps): ReactNode;
|
package/lib/client/UsageChip.js
CHANGED
|
@@ -55,6 +55,44 @@ const SHORT_WINDOWS = {
|
|
|
55
55
|
export function providerOf(projection) {
|
|
56
56
|
return (projection?.next ?? projection?.lastUsed)?.provider;
|
|
57
57
|
}
|
|
58
|
+
/**
|
|
59
|
+
* Reduce the node half's balance payload to the wallets the chip shows.
|
|
60
|
+
*
|
|
61
|
+
* Bonus wallets are shown after the topped-up ones, so a reader sees their own credit
|
|
62
|
+
* first and any promotional credit as a secondary figure. A bonus wallet that holds
|
|
63
|
+
* nothing is dropped rather than shown as `+¥0`: the platform reports one for every
|
|
64
|
+
* currency whether or not there is a bonus, and an empty one is noise, not information.
|
|
65
|
+
*
|
|
66
|
+
* @param value - the node half's balance, or undefined when there is none.
|
|
67
|
+
* @returns the wallets, topped-up first then bonus, or undefined when nothing is usable.
|
|
68
|
+
*/
|
|
69
|
+
export function balanceOf(value) {
|
|
70
|
+
if (value === null || value === undefined)
|
|
71
|
+
return undefined;
|
|
72
|
+
const wallets = [
|
|
73
|
+
...(value.wallets ?? []).map((wallet) => ({ currency: wallet.currency, balance: wallet.balance, bonus: false })),
|
|
74
|
+
// Only a bonus wallet that actually holds something is worth a line.
|
|
75
|
+
...(value.bonusWallets ?? [])
|
|
76
|
+
.filter((wallet) => Number.parseFloat(wallet.balance) > 0)
|
|
77
|
+
.map((wallet) => ({ currency: wallet.currency, balance: wallet.balance, bonus: true })),
|
|
78
|
+
];
|
|
79
|
+
return wallets.length === 0 ? undefined : wallets;
|
|
80
|
+
}
|
|
81
|
+
/**
|
|
82
|
+
* Format one wallet for the chip's compact line.
|
|
83
|
+
*
|
|
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.
|
|
86
|
+
*
|
|
87
|
+
* @param wallet - the wallet to show.
|
|
88
|
+
* @returns the short form, e.g. `¥6.89` or `+¥2` for a bonus wallet.
|
|
89
|
+
*/
|
|
90
|
+
export function formatBalance(wallet) {
|
|
91
|
+
const amount = Number.parseFloat(wallet.balance);
|
|
92
|
+
const trimmed = Number.isFinite(amount) ? String(Math.round(amount * 100) / 100) : wallet.balance;
|
|
93
|
+
const symbol = wallet.currency === 'CNY' ? '¥' : wallet.currency === 'USD' ? '$' : `${wallet.currency} `;
|
|
94
|
+
return `${wallet.bonus ? '+' : ''}${symbol}${trimmed}`;
|
|
95
|
+
}
|
|
58
96
|
/**
|
|
59
97
|
* The short form of a route's name.
|
|
60
98
|
*
|
|
@@ -157,8 +195,10 @@ export function formatReset(at, now) {
|
|
|
157
195
|
* @param props - injected RPC caller, copy, and the session's provider reader.
|
|
158
196
|
* @returns the chip, or null when there is nothing true to say.
|
|
159
197
|
*/
|
|
160
|
-
export function UsageChip({ call, t, isOwnProvider, useProjection }) {
|
|
198
|
+
export function UsageChip({ call, t, isOwnProvider, accountBalance, ccSwitchBalance, useProjection }) {
|
|
161
199
|
const [reading, setReading] = useState(undefined);
|
|
200
|
+
const [balance, setBalance] = useState(undefined);
|
|
201
|
+
const [ccBalance, setCcBalance] = useState(undefined);
|
|
162
202
|
const [now, setNow] = useState(() => Date.now());
|
|
163
203
|
const [open, setOpen] = useState(false);
|
|
164
204
|
// The last route that answered, so a switch to a foreign provider leaves the last own
|
|
@@ -167,6 +207,35 @@ export function UsageChip({ call, t, isOwnProvider, useProjection }) {
|
|
|
167
207
|
// A projection read, so the chip follows the picker: changing the model re-renders it
|
|
168
208
|
// with the new provider instead of waiting for the next unrelated render.
|
|
169
209
|
const selected = useProjection('modelSelection', (value) => providerOf(value));
|
|
210
|
+
// A non-subscription provider has no usage windows to meter, so the chip shows its
|
|
211
|
+
// balance instead, read by cc-switch's query. The host's account (deepseek-account)
|
|
212
|
+
// has its own balance source and takes that over cc-switch; a subscription this plugin
|
|
213
|
+
// serves keeps its usage meter.
|
|
214
|
+
const isDeepSeek = selected === 'deepseek-account';
|
|
215
|
+
const isBalanceProvider = selected !== undefined && !isOwnProvider(selected) && !isDeepSeek;
|
|
216
|
+
// Read a non-subscription provider's balance while it is in force, on the same cadence
|
|
217
|
+
// as the other reads. It resolves to undefined when the provider has no balance source
|
|
218
|
+
// or did not answer, which leaves the chip silent.
|
|
219
|
+
useEffect(() => {
|
|
220
|
+
if (!isBalanceProvider || selected === undefined) {
|
|
221
|
+
setCcBalance(undefined);
|
|
222
|
+
return;
|
|
223
|
+
}
|
|
224
|
+
let live = true;
|
|
225
|
+
const target = selected;
|
|
226
|
+
const refresh = () => {
|
|
227
|
+
void ccSwitchBalance(target)
|
|
228
|
+
.then((value) => { if (live)
|
|
229
|
+
setCcBalance(value === undefined ? undefined : { currency: value.currency, total: value.total }); })
|
|
230
|
+
.catch(() => { });
|
|
231
|
+
};
|
|
232
|
+
refresh();
|
|
233
|
+
const timer = setInterval(refresh, REFRESH_INTERVAL_MS);
|
|
234
|
+
return () => {
|
|
235
|
+
live = false;
|
|
236
|
+
clearInterval(timer);
|
|
237
|
+
};
|
|
238
|
+
}, [isBalanceProvider, selected, ccSwitchBalance]);
|
|
170
239
|
const load = useCallback(async (target) => {
|
|
171
240
|
try {
|
|
172
241
|
const result = await call('usage', { provider: target });
|
|
@@ -179,6 +248,28 @@ export function UsageChip({ call, t, isOwnProvider, useProjection }) {
|
|
|
179
248
|
return undefined;
|
|
180
249
|
}
|
|
181
250
|
}, [call]);
|
|
251
|
+
// Read the DeepSeek account's balance while that provider is in force, on the same
|
|
252
|
+
// cadence as a usage read. It resolves to undefined when the account is signed out or
|
|
253
|
+
// the read fails, which leaves the chip silent.
|
|
254
|
+
useEffect(() => {
|
|
255
|
+
if (!isDeepSeek) {
|
|
256
|
+
setBalance(undefined);
|
|
257
|
+
return;
|
|
258
|
+
}
|
|
259
|
+
let live = true;
|
|
260
|
+
const refresh = () => {
|
|
261
|
+
void accountBalance()
|
|
262
|
+
.then((value) => { if (live)
|
|
263
|
+
setBalance(balanceOf(value)); })
|
|
264
|
+
.catch(() => { });
|
|
265
|
+
};
|
|
266
|
+
refresh();
|
|
267
|
+
const timer = setInterval(refresh, REFRESH_INTERVAL_MS);
|
|
268
|
+
return () => {
|
|
269
|
+
live = false;
|
|
270
|
+
clearInterval(timer);
|
|
271
|
+
};
|
|
272
|
+
}, [isDeepSeek, accountBalance]);
|
|
182
273
|
useEffect(() => {
|
|
183
274
|
const target = selected !== undefined && isOwnProvider(selected)
|
|
184
275
|
? selected
|
|
@@ -213,6 +304,29 @@ export function UsageChip({ call, t, isOwnProvider, useProjection }) {
|
|
|
213
304
|
document.addEventListener('click', close);
|
|
214
305
|
return () => { document.removeEventListener('click', close); };
|
|
215
306
|
}, [open]);
|
|
307
|
+
// A non-subscription provider reads as money, through cc-switch's query. It shows the
|
|
308
|
+
// one currency's total the provider answered with, nothing when there is no usable
|
|
309
|
+
// balance.
|
|
310
|
+
if (isBalanceProvider) {
|
|
311
|
+
if (ccBalance === undefined)
|
|
312
|
+
return null;
|
|
313
|
+
const short = shortLabel(selected ?? '', selected ?? '');
|
|
314
|
+
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) => {
|
|
315
|
+
event.stopPropagation();
|
|
316
|
+
setOpen((was) => !was);
|
|
317
|
+
}, children: [_jsx("span", { className: "dsh-subscription-usage-chip-label", children: short }), _jsx("span", { className: "dsh-subscription-usage-chip-cell", children: _jsx("span", { className: "dsh-subscription-usage-chip-value", children: `${ccBalance.currency === 'CNY' ? '¥' : ccBalance.currency === 'USD' ? '$' : `${ccBalance.currency} `}${ccBalance.total}` }) })] }), 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 ?? '' }) }), _jsx("span", { className: "dsh-subscription-usage-chip-meter", children: _jsxs("span", { className: "dsh-subscription-usage-chip-meterHead", children: [_jsx("span", { children: ccBalance.currency }), _jsx("span", { className: "dsh-subscription-usage-chip-detail", children: ccBalance.total })] }) })] }))] }));
|
|
318
|
+
}
|
|
319
|
+
// The DeepSeek account reads as money, not as a percentage, so it takes its own line:
|
|
320
|
+
// the route abbreviated, then each wallet. An unknown selection leaves the chip silent.
|
|
321
|
+
if (isDeepSeek) {
|
|
322
|
+
if (balance === undefined)
|
|
323
|
+
return null;
|
|
324
|
+
const short = shortLabel('deepseek-account', 'DeepSeek Account');
|
|
325
|
+
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: 'DeepSeek Account' }), onClick: (event) => {
|
|
326
|
+
event.stopPropagation();
|
|
327
|
+
setOpen((was) => !was);
|
|
328
|
+
}, 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
|
+
}
|
|
216
330
|
if (reading === undefined)
|
|
217
331
|
return null;
|
|
218
332
|
const percent = reading.window.usedPercent;
|
package/lib/client/index.d.ts
CHANGED
|
@@ -14,7 +14,7 @@ export type { SubscriptionsSectionInjected, SubscriptionsSectionProps } from './
|
|
|
14
14
|
export type { UsageChipInjected } from './UsageChip.js';
|
|
15
15
|
export type { SubscriptionsKey } from './locales.js';
|
|
16
16
|
export { createSubscriptionsCall, SubscriptionsRpcError } from './subscriptions-rpc.js';
|
|
17
|
-
export type { SubscriptionsCall } from './subscriptions-rpc.js';
|
|
17
|
+
export type { SubscriptionsCall, AccountBalanceCall } from './subscriptions-rpc.js';
|
|
18
18
|
declare module '@deepseek-ai/dsh-client-ui-slots' {
|
|
19
19
|
interface LocaleNamespaceMap {
|
|
20
20
|
/** The Subscriptions settings page copy. */
|