dsh-plugin-subscription-oauth 0.4.0 → 0.5.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
+ }
package/lib/auth/rpc.d.ts CHANGED
@@ -1,3 +1,10 @@
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>;
1
8
  /**
2
9
  * The host-side transport behind the Settings page.
3
10
  *
@@ -21,7 +28,7 @@ import type { SubscriptionRegistry } from './registry.js';
21
28
  * @param payload - the request's decoded payload.
22
29
  * @returns the envelope result.
23
30
  */
24
- export declare function dispatchRpc(registry: SubscriptionRegistry, endpoint: string, payload: unknown): Promise<RpcResult>;
31
+ export declare function dispatchRpc(registry: SubscriptionRegistry, endpoint: string, payload: unknown, resolveCredential?: CredentialResolver): Promise<RpcResult>;
25
32
  /**
26
33
  * Register every endpoint route on the `connection` service, when one exists.
27
34
  *
package/lib/auth/rpc.js CHANGED
@@ -1,15 +1,5 @@
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';
13
3
  import { RPC_ENDPOINTS, RPC_PREFIX } from '../protocol.js';
14
4
  /** Wrap a value as a successful envelope result. */
15
5
  function success(value) {
@@ -85,9 +75,9 @@ function requiredProvider(payload, endpoint) {
85
75
  * @param payload - the request's decoded payload.
86
76
  * @returns the envelope result.
87
77
  */
88
- export async function dispatchRpc(registry, endpoint, payload) {
78
+ export async function dispatchRpc(registry, endpoint, payload, resolveCredential) {
89
79
  try {
90
- return success(await runEndpoint(registry, endpoint, payload));
80
+ return success(await runEndpoint(registry, endpoint, payload, resolveCredential));
91
81
  }
92
82
  catch (error) {
93
83
  return failure(error);
@@ -97,7 +87,7 @@ export async function dispatchRpc(registry, endpoint, payload) {
97
87
  * The endpoint table.
98
88
  * @throws {ProviderError} for a request the registry refuses.
99
89
  */
100
- async function runEndpoint(registry, endpoint, payload) {
90
+ async function runEndpoint(registry, endpoint, payload, resolveCredential) {
101
91
  switch (endpoint) {
102
92
  case 'status':
103
93
  return registry.status();
@@ -145,6 +135,21 @@ async function runEndpoint(registry, endpoint, payload) {
145
135
  }
146
136
  case 'usage':
147
137
  return registry.usage(requiredProvider(payload, endpoint), booleanField(payload, 'force'));
138
+ case 'ccswitch-balance': {
139
+ // A manually added provider's balance, by cc-switch's query. The caller names the
140
+ // credential reference the provider's profile uses (its apiKeyEnv), and the host's
141
+ // credential service resolves it. No key, or a key the provider does not answer
142
+ // for, means no balance — never a fabricated zero.
143
+ if (resolveCredential === undefined)
144
+ return undefined;
145
+ const credentialRef = stringField(payload, 'credentialRef');
146
+ if (credentialRef === undefined)
147
+ return undefined;
148
+ const apiKey = await resolveCredential(credentialRef);
149
+ if (apiKey === undefined)
150
+ return undefined;
151
+ return await queryCcSwitchBalance({ apiKey });
152
+ }
148
153
  case 'reload':
149
154
  return registry.reload();
150
155
  default:
@@ -152,7 +157,7 @@ async function runEndpoint(registry, endpoint, payload) {
152
157
  }
153
158
  }
154
159
  /** Build the Fetch route for one endpoint. */
155
- function rpcRoute(endpoint, registry) {
160
+ function rpcRoute(endpoint, registry, resolveCredential) {
156
161
  const method = `${RPC_PREFIX}${endpoint}`;
157
162
  return {
158
163
  path: `/api/${method}`,
@@ -176,7 +181,7 @@ function rpcRoute(endpoint, registry) {
176
181
  if (envelopeRequest.type !== 'client-request' || envelopeRequest.method !== method) {
177
182
  return rejection(rpcId, 'gateway/bad-request', `${method} received an envelope for another method`, 400);
178
183
  }
179
- return envelope(rpcId, await dispatchRpc(registry, endpoint, envelopeRequest.payload));
184
+ return envelope(rpcId, await dispatchRpc(registry, endpoint, envelopeRequest.payload, resolveCredential));
180
185
  },
181
186
  };
182
187
  }
@@ -191,10 +196,17 @@ export function registerSubscriptionRpc(ctx, registry) {
191
196
  const connection = connectionCtx.get('connection');
192
197
  if (connection === undefined)
193
198
  return;
199
+ // The cc-switch balance endpoint resolves a credential reference through the host's
200
+ // credential service. It is optional: a composition with no credential service still
201
+ // serves every other endpoint, and the balance endpoint simply answers nothing.
202
+ const credentials = connectionCtx.get('credentials');
203
+ const resolveCredential = credentials?.resolve === undefined
204
+ ? undefined
205
+ : async (ref) => (await credentials.resolve?.(ref))?.value;
194
206
  const register = (route) => connection.fetch.register(route);
195
207
  for (const endpoint of RPC_ENDPOINTS) {
196
208
  const method = `${RPC_PREFIX}${endpoint}`;
197
- connectionCtx.effect(() => register(rpcRoute(endpoint, registry)), `dsh-plugin-subscription-oauth: /api/${method} route`);
209
+ connectionCtx.effect(() => register(rpcRoute(endpoint, registry, resolveCredential)), `dsh-plugin-subscription-oauth: /api/${method} route`);
198
210
  }
199
211
  });
200
212
  }
@@ -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,34 @@ 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 manually added provider's balance, by cc-switch's query.
40
+ *
41
+ * The caller names the credential reference the provider's profile uses (its
42
+ * `apiKeyEnv`); the host's credential service resolves it, and the node half asks
43
+ * cc-switch's balance endpoint. It resolves to `undefined` when there is no key or
44
+ * the provider did not answer.
45
+ */
46
+ ccSwitchBalance: CcSwitchBalanceCall;
47
+ /**
48
+ * The credential reference a manually added provider's balance query uses, by provider
49
+ * id.
50
+ *
51
+ * A manually added provider is not one this plugin serves and has no account route, so
52
+ * the only way to read its balance is cc-switch's query, which needs the provider's API
53
+ * key. That key is named by the provider's profile (`apiKeyEnv`), and the host's
54
+ * credential service resolves the name; this map is the one place a provider id is tied
55
+ * to that name. A provider with no entry gets no balance.
56
+ */
57
+ ccSwitchCredentialRef: (provider: string) => string | undefined;
30
58
  }
31
59
  /** One model choice, as the host records it. */
32
60
  export interface ModelSelection {
@@ -65,6 +93,46 @@ export interface ChipReading {
65
93
  /** Plan name, when the provider stated one. */
66
94
  plan?: string;
67
95
  }
96
+ /** One currency's balance, as the host's account controller reports it. */
97
+ export interface ChipBalance {
98
+ /** The wallet's currency. */
99
+ currency: string;
100
+ /** Remaining balance, as a decimal string. */
101
+ balance: string;
102
+ /** Whether this is a bonus / promotional wallet rather than topped-up credit. */
103
+ bonus: boolean;
104
+ }
105
+ /**
106
+ * Reduce the host's account-balance payload to the wallets the chip shows.
107
+ *
108
+ * Bonus wallets are shown after the topped-up ones, so a reader sees their own credit
109
+ * first and any promotional credit as a secondary figure.
110
+ *
111
+ * @param value - the host's ready balance, or undefined when there is none.
112
+ * @returns the wallets, topped-up first then bonus, or undefined when nothing is usable.
113
+ */
114
+ export declare function balanceOf(value: {
115
+ value?: readonly {
116
+ currency: string;
117
+ balance: string;
118
+ }[];
119
+ bonusWallets?: readonly {
120
+ currency: string;
121
+ balance: string;
122
+ }[];
123
+ } | {
124
+ status: string;
125
+ } | null | undefined): readonly ChipBalance[] | undefined;
126
+ /**
127
+ * Format one wallet for the chip's compact line.
128
+ *
129
+ * A balance is money, not a percentage, so it renders as `¥6.89` rather than a meter;
130
+ * the currency prefix keeps two currencies from being mistaken for one sum.
131
+ *
132
+ * @param wallet - the wallet to show.
133
+ * @returns the short form, e.g. `¥6.89` or `+¥2` for a bonus wallet.
134
+ */
135
+ export declare function formatBalance(wallet: ChipBalance): string;
68
136
  /**
69
137
  * The short form of a route's name.
70
138
  *
@@ -117,4 +185,4 @@ export declare function formatReset(at: number | undefined, now: number): string
117
185
  * @param props - injected RPC caller, copy, and the session's provider reader.
118
186
  * @returns the chip, or null when there is nothing true to say.
119
187
  */
120
- export declare function UsageChip({ call, t, isOwnProvider, useProjection }: UsageChipInjected & SessionStandardProps): ReactNode;
188
+ export declare function UsageChip({ call, t, isOwnProvider, accountBalance, ccSwitchBalance, ccSwitchCredentialRef, useProjection }: UsageChipInjected & SessionStandardProps): ReactNode;
@@ -55,6 +55,41 @@ const SHORT_WINDOWS = {
55
55
  export function providerOf(projection) {
56
56
  return (projection?.next ?? projection?.lastUsed)?.provider;
57
57
  }
58
+ /**
59
+ * Reduce the host's account-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.
63
+ *
64
+ * @param value - the host's ready balance, or undefined when there is none.
65
+ * @returns the wallets, topped-up first then bonus, or undefined when nothing is usable.
66
+ */
67
+ export function balanceOf(value) {
68
+ // A failed read or no account (`{status:'failed'}` or `null`) reduces to nothing, as
69
+ // does a ready payload that names no wallet.
70
+ if (value === null || value === undefined || !('value' in value) || value.value === undefined)
71
+ return undefined;
72
+ const wallets = [
73
+ ...value.value.map((wallet) => ({ currency: wallet.currency, balance: wallet.balance, bonus: false })),
74
+ ...(value.bonusWallets ?? []).map((wallet) => ({ currency: wallet.currency, balance: wallet.balance, bonus: true })),
75
+ ];
76
+ return wallets.length === 0 ? undefined : wallets;
77
+ }
78
+ /**
79
+ * Format one wallet for the chip's compact line.
80
+ *
81
+ * A balance is money, not a percentage, so it renders as `¥6.89` rather than a meter;
82
+ * the currency prefix keeps two currencies from being mistaken for one sum.
83
+ *
84
+ * @param wallet - the wallet to show.
85
+ * @returns the short form, e.g. `¥6.89` or `+¥2` for a bonus wallet.
86
+ */
87
+ export function formatBalance(wallet) {
88
+ const amount = Number.parseFloat(wallet.balance);
89
+ const trimmed = Number.isFinite(amount) ? String(Math.round(amount * 100) / 100) : wallet.balance;
90
+ const symbol = wallet.currency === 'CNY' ? '¥' : wallet.currency === 'USD' ? '$' : `${wallet.currency} `;
91
+ return `${wallet.bonus ? '+' : ''}${symbol}${trimmed}`;
92
+ }
58
93
  /**
59
94
  * The short form of a route's name.
60
95
  *
@@ -157,8 +192,10 @@ export function formatReset(at, now) {
157
192
  * @param props - injected RPC caller, copy, and the session's provider reader.
158
193
  * @returns the chip, or null when there is nothing true to say.
159
194
  */
160
- export function UsageChip({ call, t, isOwnProvider, useProjection }) {
195
+ export function UsageChip({ call, t, isOwnProvider, accountBalance, ccSwitchBalance, ccSwitchCredentialRef, useProjection }) {
161
196
  const [reading, setReading] = useState(undefined);
197
+ const [balance, setBalance] = useState(undefined);
198
+ const [ccBalance, setCcBalance] = useState(undefined);
162
199
  const [now, setNow] = useState(() => Date.now());
163
200
  const [open, setOpen] = useState(false);
164
201
  // The last route that answered, so a switch to a foreign provider leaves the last own
@@ -167,6 +204,39 @@ export function UsageChip({ call, t, isOwnProvider, useProjection }) {
167
204
  // A projection read, so the chip follows the picker: changing the model re-renders it
168
205
  // with the new provider instead of waiting for the next unrelated render.
169
206
  const selected = useProjection('modelSelection', (value) => providerOf(value));
207
+ // The DeepSeek account is not one of this plugin's routes, but the host owns its grant
208
+ // and the balance endpoint, so the chip shows its balance rather than staying silent.
209
+ // An own route takes the balance read over nothing.
210
+ const isDeepSeek = selected === 'deepseek-account';
211
+ // A manually added provider reads its balance by cc-switch's query, which needs the
212
+ // provider's API key. The provider's profile names that key by reference, and this map
213
+ // is the one place a provider id is tied to the reference; a provider with no entry,
214
+ // or one whose key does not resolve, gets no balance.
215
+ const ccRef = selected === undefined || isOwnProvider(selected) || isDeepSeek
216
+ ? undefined
217
+ : ccSwitchCredentialRef(selected);
218
+ // Read a manually added provider's balance while it is in force, on the same cadence as
219
+ // the other reads. It resolves to undefined when there is no key or the provider did
220
+ // not answer, which leaves the chip silent.
221
+ useEffect(() => {
222
+ if (ccRef === undefined) {
223
+ setCcBalance(undefined);
224
+ return;
225
+ }
226
+ let live = true;
227
+ const refresh = () => {
228
+ void ccSwitchBalance(ccRef)
229
+ .then((value) => { if (live)
230
+ setCcBalance(value === undefined ? undefined : { currency: value.currency, total: value.total }); })
231
+ .catch(() => { });
232
+ };
233
+ refresh();
234
+ const timer = setInterval(refresh, REFRESH_INTERVAL_MS);
235
+ return () => {
236
+ live = false;
237
+ clearInterval(timer);
238
+ };
239
+ }, [ccRef, ccSwitchBalance]);
170
240
  const load = useCallback(async (target) => {
171
241
  try {
172
242
  const result = await call('usage', { provider: target });
@@ -179,6 +249,28 @@ export function UsageChip({ call, t, isOwnProvider, useProjection }) {
179
249
  return undefined;
180
250
  }
181
251
  }, [call]);
252
+ // Read the DeepSeek account's balance while that provider is in force, on the same
253
+ // cadence as a usage read. It resolves to undefined when the account is signed out or
254
+ // the read fails, which leaves the chip silent.
255
+ useEffect(() => {
256
+ if (!isDeepSeek) {
257
+ setBalance(undefined);
258
+ return;
259
+ }
260
+ let live = true;
261
+ const refresh = () => {
262
+ void accountBalance()
263
+ .then((value) => { if (live)
264
+ setBalance(balanceOf(value)); })
265
+ .catch(() => { });
266
+ };
267
+ refresh();
268
+ const timer = setInterval(refresh, REFRESH_INTERVAL_MS);
269
+ return () => {
270
+ live = false;
271
+ clearInterval(timer);
272
+ };
273
+ }, [isDeepSeek, accountBalance]);
182
274
  useEffect(() => {
183
275
  const target = selected !== undefined && isOwnProvider(selected)
184
276
  ? selected
@@ -213,6 +305,29 @@ export function UsageChip({ call, t, isOwnProvider, useProjection }) {
213
305
  document.addEventListener('click', close);
214
306
  return () => { document.removeEventListener('click', close); };
215
307
  }, [open]);
308
+ // A manually added provider reads as money too, through cc-switch's query. It shows
309
+ // the one currency's total the provider answered with, nothing when there is no usable
310
+ // balance.
311
+ if (ccRef !== undefined) {
312
+ if (ccBalance === undefined)
313
+ return null;
314
+ const short = shortLabel(selected ?? '', selected ?? '');
315
+ 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) => {
316
+ event.stopPropagation();
317
+ setOpen((was) => !was);
318
+ }, 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 })] }) })] }))] }));
319
+ }
320
+ // The DeepSeek account reads as money, not as a percentage, so it takes its own line:
321
+ // the route abbreviated, then each wallet. An unknown selection leaves the chip silent.
322
+ if (isDeepSeek) {
323
+ if (balance === undefined)
324
+ return null;
325
+ const short = shortLabel('deepseek-account', 'DeepSeek Account');
326
+ 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) => {
327
+ event.stopPropagation();
328
+ setOpen((was) => !was);
329
+ }, 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' : ''}`)))] }))] }));
330
+ }
216
331
  if (reading === undefined)
217
332
  return null;
218
333
  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. */
@@ -10,7 +10,7 @@
10
10
  */
11
11
  import { SubscriptionsSection } from './SubscriptionsSection.js';
12
12
  import { UsageChip } from './UsageChip.js';
13
- import { createSubscriptionsCall } from './subscriptions-rpc.js';
13
+ import { createSubscriptionsCall, createAccountBalanceCall, createCcSwitchBalanceCall } from './subscriptions-rpc.js';
14
14
  import { en, zh } from './locales.js';
15
15
  export { createSubscriptionsCall, SubscriptionsRpcError } from './subscriptions-rpc.js';
16
16
  /**
@@ -31,6 +31,19 @@ const SECTION_ORDER = 92;
31
31
  * chip follows them rather than displacing them.
32
32
  */
33
33
  const CHIP_ORDER = 10;
34
+ /**
35
+ * The credential reference a manually added provider's balance query uses, by provider
36
+ * id.
37
+ *
38
+ * A manually added provider is not one this plugin serves, so the only way to read its
39
+ * balance is cc-switch's query, which needs the provider's API key. The provider's
40
+ * profile names that key by reference (`apiKeyEnv`), and the host's credential service
41
+ * resolves the name; this map is the one place a provider id is tied to that name. A
42
+ * provider with no entry gets no balance.
43
+ */
44
+ const CC_SWITCH_CREDENTIAL_REFS = {
45
+ huanling: 'HUANLING_API_KEY',
46
+ };
34
47
  /**
35
48
  * Required services: `slots` carries the registration seat, `connection` the
36
49
  * RPC caller, and `locale` the copy dictionaries.
@@ -71,6 +84,9 @@ export function apply(ctx) {
71
84
  call,
72
85
  t: t,
73
86
  isOwnProvider: (provider) => OWN_PROVIDER_IDS.has(provider),
87
+ accountBalance: createAccountBalanceCall(connection.rpc),
88
+ ccSwitchBalance: createCcSwitchBalanceCall(connection.rpc),
89
+ ccSwitchCredentialRef: (provider) => CC_SWITCH_CREDENTIAL_REFS[provider],
74
90
  }),
75
91
  }, UsageChip));
76
92
  }
@@ -8,7 +8,7 @@
8
8
  * @module dsh-plugin-subscription-oauth/client/subscriptions-rpc
9
9
  */
10
10
  import type { ConnectionHandle } from '@deepseek-ai/dsh-api-remotes/client';
11
- import type { RpcEndpoint, RpcMap } from '../protocol.js';
11
+ import type { CcSwitchBalanceResult, RpcEndpoint, RpcMap } from '../protocol.js';
12
12
  /** A failed call: either the transport rejected or the endpoint answered `ok: false`. */
13
13
  export declare class SubscriptionsRpcError extends Error {
14
14
  readonly code: string;
@@ -16,9 +16,61 @@ export declare class SubscriptionsRpcError extends Error {
16
16
  }
17
17
  /** Call one endpoint, unwrapping the transport result. */
18
18
  export type SubscriptionsCall = <K extends RpcEndpoint>(endpoint: K, payload: RpcMap[K]['payload']) => Promise<RpcMap[K]['value']>;
19
+ /** A caller for one manually added provider's cc-switch balance. */
20
+ export interface CcSwitchBalanceCall {
21
+ (credentialRef: string): Promise<CcSwitchBalanceResult>;
22
+ }
19
23
  /**
20
24
  * Bind a caller to one connection's RPC transport.
21
25
  * @param rpc - the browser connection's RPC caller.
22
26
  * @returns a typed caller for this plugin's endpoints.
23
27
  */
24
28
  export declare function createSubscriptionsCall(rpc: ConnectionHandle['rpc']): SubscriptionsCall;
29
+ /**
30
+ * The DeepSeek account's wallet balances, as the host's account controller reports them.
31
+ *
32
+ * The controller answers `null` when the account is signed out, `{ status: 'failed' }`
33
+ * when the read did not complete, and `{ status: 'ready', value: <normal wallets>,
34
+ * bonusWallets: <bonus wallets> }` on success.
35
+ */
36
+ export interface HostAccountBalance {
37
+ /** Normal (topped-up) wallets, one per currency. */
38
+ value: readonly {
39
+ currency: string;
40
+ balance: string;
41
+ }[];
42
+ /** Bonus / promotional wallets, one per currency. */
43
+ bonusWallets?: readonly {
44
+ currency: string;
45
+ balance: string;
46
+ }[];
47
+ }
48
+ /** A narrow caller for the host's own account controller. */
49
+ export interface AccountBalanceCall {
50
+ (): Promise<HostAccountBalance | {
51
+ status: 'failed';
52
+ } | null>;
53
+ }
54
+ /**
55
+ * Read the DeepSeek account's balance through the host's own account controller.
56
+ *
57
+ * The host owns the DeepSeek OAuth grant and the platform endpoint, so this asks it
58
+ * rather than re-deriving either. It returns `null` when the account is signed out or
59
+ * the read fails, so a chip can simply not render in that case.
60
+ *
61
+ * @param rpc - the browser connection's RPC caller.
62
+ * @returns the balance, or null when there is nothing to show.
63
+ */
64
+ export declare function createAccountBalanceCall(rpc: ConnectionHandle['rpc']): AccountBalanceCall;
65
+ /**
66
+ * Read a manually added provider's balance through the plugin's cc-switch endpoint.
67
+ *
68
+ * The caller names the credential reference the provider's profile uses (its
69
+ * `apiKeyEnv`), and the host's credential service resolves it to the API key. The result
70
+ * is `undefined` when there is no key or the provider did not answer — never a wrong
71
+ * figure.
72
+ *
73
+ * @param rpc - the browser connection's RPC caller.
74
+ * @returns a narrow caller taking the credential reference.
75
+ */
76
+ export declare function createCcSwitchBalanceCall(rpc: ConnectionHandle['rpc']): CcSwitchBalanceCall;
@@ -10,6 +10,9 @@
10
10
  import { RPC_PREFIX } from '../protocol.js';
11
11
  /** The shared channel the node half's routes are mounted on. */
12
12
  const CHANNEL = '/api';
13
+ /** The host's own DeepSeek account controller, from which the chip reads a balance. */
14
+ const ACCOUNT_CHANNEL = '/api';
15
+ const ACCOUNT_NAMESPACE = 'account';
13
16
  /** A failed call: either the transport rejected or the endpoint answered `ok: false`. */
14
17
  export class SubscriptionsRpcError extends Error {
15
18
  code;
@@ -42,3 +45,56 @@ export function createSubscriptionsCall(rpc) {
42
45
  return result.value;
43
46
  };
44
47
  }
48
+ /**
49
+ * Read the DeepSeek account's balance through the host's own account controller.
50
+ *
51
+ * The host owns the DeepSeek OAuth grant and the platform endpoint, so this asks it
52
+ * rather than re-deriving either. It returns `null` when the account is signed out or
53
+ * the read fails, so a chip can simply not render in that case.
54
+ *
55
+ * @param rpc - the browser connection's RPC caller.
56
+ * @returns the balance, or null when there is nothing to show.
57
+ */
58
+ export function createAccountBalanceCall(rpc) {
59
+ return async function call() {
60
+ let result;
61
+ try {
62
+ result = await rpc.call(ACCOUNT_CHANNEL, 'account/getBalance', {
63
+ version: '1',
64
+ locale: Intl.DateTimeFormat().resolvedOptions().locale ?? 'en-US',
65
+ timezoneOffsetSeconds: -new Date().getTimezoneOffset(),
66
+ });
67
+ }
68
+ catch (error) {
69
+ throw new SubscriptionsRpcError(messageOf(error), 'RPC_TRANSPORT');
70
+ }
71
+ if (!result.ok)
72
+ throw new SubscriptionsRpcError(result.error.message, result.error.code);
73
+ return result.value;
74
+ };
75
+ }
76
+ /**
77
+ * Read a manually added provider's balance through the plugin's cc-switch endpoint.
78
+ *
79
+ * The caller names the credential reference the provider's profile uses (its
80
+ * `apiKeyEnv`), and the host's credential service resolves it to the API key. The result
81
+ * is `undefined` when there is no key or the provider did not answer — never a wrong
82
+ * figure.
83
+ *
84
+ * @param rpc - the browser connection's RPC caller.
85
+ * @returns a narrow caller taking the credential reference.
86
+ */
87
+ export function createCcSwitchBalanceCall(rpc) {
88
+ return async function call(credentialRef) {
89
+ let result;
90
+ try {
91
+ result = await rpc.call(CHANNEL, `${RPC_PREFIX}ccswitch-balance`, { credentialRef });
92
+ }
93
+ catch (error) {
94
+ throw new SubscriptionsRpcError(messageOf(error), 'RPC_TRANSPORT');
95
+ }
96
+ if (!result.ok)
97
+ throw new SubscriptionsRpcError(result.error.message, result.error.code);
98
+ return result.value;
99
+ };
100
+ }