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.
@@ -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;
@@ -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;
@@ -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. */