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.
@@ -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
  /**
@@ -71,6 +71,8 @@ export function apply(ctx) {
71
71
  call,
72
72
  t: t,
73
73
  isOwnProvider: (provider) => OWN_PROVIDER_IDS.has(provider),
74
+ accountBalance: createAccountBalanceCall(connection.rpc),
75
+ ccSwitchBalance: createCcSwitchBalanceCall(connection.rpc),
74
76
  }),
75
77
  }, UsageChip));
76
78
  }
@@ -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,62 @@ 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 non-subscription provider's cc-switch balance. */
20
+ export interface CcSwitchBalanceCall {
21
+ (provider: 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 balance, as the plugin's node half reports it.
31
+ *
32
+ * The node half reads the host's `deepseekAccount` service (which owns the OAuth grant
33
+ * and the platform endpoint) and serves the wallets here. It answers `undefined` when
34
+ * the account is signed out or the read failed, so a chip simply does not render.
35
+ */
36
+ export interface AccountBalance {
37
+ /** Normal (topped-up) wallets, one per currency. */
38
+ wallets: 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 DeepSeek account's balance. */
49
+ export interface AccountBalanceCall {
50
+ (): Promise<AccountBalance | undefined>;
51
+ }
52
+ /**
53
+ * Read the DeepSeek account's balance through the plugin's own RPC.
54
+ *
55
+ * The browser cannot call the host's account service directly — it sits behind the
56
+ * host's Remote controller, not a `/api` fetch route — so the node half reads it and
57
+ * serves it on the plugin's channel, the same one the rest of the chip's data travels.
58
+ *
59
+ * @param rpc - the browser connection's RPC caller.
60
+ * @returns the balance, or undefined when there is nothing to show.
61
+ */
62
+ export declare function createAccountBalanceCall(rpc: ConnectionHandle['rpc']): AccountBalanceCall;
63
+ /**
64
+ * Read a manually added provider's balance through the plugin's cc-switch endpoint.
65
+ *
66
+ * The caller names the credential reference the provider's profile uses (its
67
+ * Read a non-subscription provider's balance through the plugin's cc-switch endpoint.
68
+ *
69
+ * The caller names the provider; the server maps it to the credential reference the
70
+ * provider's profile names (`apiKeyEnv`) and resolves that to the API key. The result
71
+ * is `undefined` when there is no key or the provider did not answer — never a wrong
72
+ * figure.
73
+ *
74
+ * @param rpc - the browser connection's RPC caller.
75
+ * @returns a narrow caller taking the provider id.
76
+ */
77
+ export declare function createCcSwitchBalanceCall(rpc: ConnectionHandle['rpc']): CcSwitchBalanceCall;
@@ -42,3 +42,61 @@ export function createSubscriptionsCall(rpc) {
42
42
  return result.value;
43
43
  };
44
44
  }
45
+ /**
46
+ * Read the DeepSeek account's balance through the plugin's own RPC.
47
+ *
48
+ * The browser cannot call the host's account service directly — it sits behind the
49
+ * host's Remote controller, not a `/api` fetch route — so the node half reads it and
50
+ * serves it on the plugin's channel, the same one the rest of the chip's data travels.
51
+ *
52
+ * @param rpc - the browser connection's RPC caller.
53
+ * @returns the balance, or undefined when there is nothing to show.
54
+ */
55
+ export function createAccountBalanceCall(rpc) {
56
+ return async function call() {
57
+ let result;
58
+ try {
59
+ result = await rpc.call(CHANNEL, `${RPC_PREFIX}deepseek-balance`, {
60
+ version: '1',
61
+ locale: Intl.DateTimeFormat().resolvedOptions().locale ?? 'en-US',
62
+ // East-positive whole seconds, as the host's account service expects (the same
63
+ // value DSH's own account card sends).
64
+ timezoneOffsetSeconds: -new Date().getTimezoneOffset() * 60,
65
+ });
66
+ }
67
+ catch (error) {
68
+ throw new SubscriptionsRpcError(messageOf(error), 'RPC_TRANSPORT');
69
+ }
70
+ if (!result.ok)
71
+ throw new SubscriptionsRpcError(result.error.message, result.error.code);
72
+ return result.value;
73
+ };
74
+ }
75
+ /**
76
+ * Read a manually added provider's balance through the plugin's cc-switch endpoint.
77
+ *
78
+ * The caller names the credential reference the provider's profile uses (its
79
+ * Read a non-subscription provider's balance through the plugin's cc-switch endpoint.
80
+ *
81
+ * The caller names the provider; the server maps it to the credential reference the
82
+ * provider's profile names (`apiKeyEnv`) and resolves that to the API key. The result
83
+ * is `undefined` when there is no key or the provider did not answer — never a wrong
84
+ * figure.
85
+ *
86
+ * @param rpc - the browser connection's RPC caller.
87
+ * @returns a narrow caller taking the provider id.
88
+ */
89
+ export function createCcSwitchBalanceCall(rpc) {
90
+ return async function call(provider) {
91
+ let result;
92
+ try {
93
+ result = await rpc.call(CHANNEL, `${RPC_PREFIX}ccswitch-balance`, { provider });
94
+ }
95
+ catch (error) {
96
+ throw new SubscriptionsRpcError(messageOf(error), 'RPC_TRANSPORT');
97
+ }
98
+ if (!result.ok)
99
+ throw new SubscriptionsRpcError(result.error.message, result.error.code);
100
+ return result.value;
101
+ };
102
+ }
package/lib/client.js CHANGED
@@ -661,6 +661,58 @@ function createSubscriptionsCall(rpc) {
661
661
  return result.value;
662
662
  };
663
663
  }
664
+ /**
665
+ * Read the DeepSeek account's balance through the plugin's own RPC.
666
+ *
667
+ * The browser cannot call the host's account service directly — it sits behind the
668
+ * host's Remote controller, not a `/api` fetch route — so the node half reads it and
669
+ * serves it on the plugin's channel, the same one the rest of the chip's data travels.
670
+ *
671
+ * @param rpc - the browser connection's RPC caller.
672
+ * @returns the balance, or undefined when there is nothing to show.
673
+ */
674
+ function createAccountBalanceCall(rpc) {
675
+ return async function call() {
676
+ let result;
677
+ try {
678
+ result = await rpc.call(CHANNEL, `${RPC_PREFIX}deepseek-balance`, {
679
+ version: "1",
680
+ locale: Intl.DateTimeFormat().resolvedOptions().locale ?? "en-US",
681
+ timezoneOffsetSeconds: -(/* @__PURE__ */ new Date()).getTimezoneOffset() * 60
682
+ });
683
+ } catch (error) {
684
+ throw new SubscriptionsRpcError(messageOf$1(error), "RPC_TRANSPORT");
685
+ }
686
+ if (!result.ok) throw new SubscriptionsRpcError(result.error.message, result.error.code);
687
+ return result.value;
688
+ };
689
+ }
690
+ /**
691
+ * Read a manually added provider's balance through the plugin's cc-switch endpoint.
692
+ *
693
+ * The caller names the credential reference the provider's profile uses (its
694
+ * Read a non-subscription provider's balance through the plugin's cc-switch endpoint.
695
+ *
696
+ * The caller names the provider; the server maps it to the credential reference the
697
+ * provider's profile names (`apiKeyEnv`) and resolves that to the API key. The result
698
+ * is `undefined` when there is no key or the provider did not answer — never a wrong
699
+ * figure.
700
+ *
701
+ * @param rpc - the browser connection's RPC caller.
702
+ * @returns a narrow caller taking the provider id.
703
+ */
704
+ function createCcSwitchBalanceCall(rpc) {
705
+ return async function call(provider) {
706
+ let result;
707
+ try {
708
+ result = await rpc.call(CHANNEL, `${RPC_PREFIX}ccswitch-balance`, { provider });
709
+ } catch (error) {
710
+ throw new SubscriptionsRpcError(messageOf$1(error), "RPC_TRANSPORT");
711
+ }
712
+ if (!result.ok) throw new SubscriptionsRpcError(result.error.message, result.error.code);
713
+ return result.value;
714
+ };
715
+ }
664
716
 
665
717
  //#endregion
666
718
  //#region src/client/SubscriptionsSection.tsx
@@ -2221,6 +2273,45 @@ function providerOf(projection) {
2221
2273
  return (projection?.next ?? projection?.lastUsed)?.provider;
2222
2274
  }
2223
2275
  /**
2276
+ * Reduce the node half's balance payload to the wallets the chip shows.
2277
+ *
2278
+ * Bonus wallets are shown after the topped-up ones, so a reader sees their own credit
2279
+ * first and any promotional credit as a secondary figure. A bonus wallet that holds
2280
+ * nothing is dropped rather than shown as `+¥0`: the platform reports one for every
2281
+ * currency whether or not there is a bonus, and an empty one is noise, not information.
2282
+ *
2283
+ * @param value - the node half's balance, or undefined when there is none.
2284
+ * @returns the wallets, topped-up first then bonus, or undefined when nothing is usable.
2285
+ */
2286
+ function balanceOf(value) {
2287
+ if (value === null || value === void 0) return void 0;
2288
+ const wallets = [...(value.wallets ?? []).map((wallet) => ({
2289
+ currency: wallet.currency,
2290
+ balance: wallet.balance,
2291
+ bonus: false
2292
+ })), ...(value.bonusWallets ?? []).filter((wallet) => Number.parseFloat(wallet.balance) > 0).map((wallet) => ({
2293
+ currency: wallet.currency,
2294
+ balance: wallet.balance,
2295
+ bonus: true
2296
+ }))];
2297
+ return wallets.length === 0 ? void 0 : wallets;
2298
+ }
2299
+ /**
2300
+ * Format one wallet for the chip's compact line.
2301
+ *
2302
+ * A balance is money, not a percentage, so it renders as `¥6.89` rather than a meter;
2303
+ * the currency prefix keeps two currencies from being mistaken for one sum.
2304
+ *
2305
+ * @param wallet - the wallet to show.
2306
+ * @returns the short form, e.g. `¥6.89` or `+¥2` for a bonus wallet.
2307
+ */
2308
+ function formatBalance(wallet) {
2309
+ const amount = Number.parseFloat(wallet.balance);
2310
+ const trimmed = Number.isFinite(amount) ? String(Math.round(amount * 100) / 100) : wallet.balance;
2311
+ const symbol = wallet.currency === "CNY" ? "¥" : wallet.currency === "USD" ? "$" : `${wallet.currency} `;
2312
+ return `${wallet.bonus ? "+" : ""}${symbol}${trimmed}`;
2313
+ }
2314
+ /**
2224
2315
  * The short form of a route's name.
2225
2316
  *
2226
2317
  * @param provider - the route id.
@@ -2309,12 +2400,42 @@ function formatReset(at, now) {
2309
2400
  * @param props - injected RPC caller, copy, and the session's provider reader.
2310
2401
  * @returns the chip, or null when there is nothing true to say.
2311
2402
  */
2312
- function UsageChip({ call, t, isOwnProvider, useProjection }) {
2403
+ function UsageChip({ call, t, isOwnProvider, accountBalance, ccSwitchBalance, useProjection }) {
2313
2404
  const [reading, setReading] = (0, react.useState)(void 0);
2405
+ const [balance, setBalance] = (0, react.useState)(void 0);
2406
+ const [ccBalance, setCcBalance] = (0, react.useState)(void 0);
2314
2407
  const [now, setNow] = (0, react.useState)(() => Date.now());
2315
2408
  const [open, setOpen] = (0, react.useState)(false);
2316
2409
  const lastOwn = (0, react.useRef)(void 0);
2317
2410
  const selected = useProjection("modelSelection", (value) => providerOf(value));
2411
+ const isDeepSeek = selected === "deepseek-account";
2412
+ const isBalanceProvider = selected !== void 0 && !isOwnProvider(selected) && !isDeepSeek;
2413
+ (0, react.useEffect)(() => {
2414
+ if (!isBalanceProvider || selected === void 0) {
2415
+ setCcBalance(void 0);
2416
+ return;
2417
+ }
2418
+ let live = true;
2419
+ const target = selected;
2420
+ const refresh = () => {
2421
+ ccSwitchBalance(target).then((value) => {
2422
+ if (live) setCcBalance(value === void 0 ? void 0 : {
2423
+ currency: value.currency,
2424
+ total: value.total
2425
+ });
2426
+ }).catch(() => {});
2427
+ };
2428
+ refresh();
2429
+ const timer = setInterval(refresh, REFRESH_INTERVAL_MS);
2430
+ return () => {
2431
+ live = false;
2432
+ clearInterval(timer);
2433
+ };
2434
+ }, [
2435
+ isBalanceProvider,
2436
+ selected,
2437
+ ccSwitchBalance
2438
+ ]);
2318
2439
  const load = (0, react.useCallback)(async (target) => {
2319
2440
  try {
2320
2441
  const result = await call("usage", { provider: target });
@@ -2323,6 +2444,24 @@ function UsageChip({ call, t, isOwnProvider, useProjection }) {
2323
2444
  return;
2324
2445
  }
2325
2446
  }, [call]);
2447
+ (0, react.useEffect)(() => {
2448
+ if (!isDeepSeek) {
2449
+ setBalance(void 0);
2450
+ return;
2451
+ }
2452
+ let live = true;
2453
+ const refresh = () => {
2454
+ accountBalance().then((value) => {
2455
+ if (live) setBalance(balanceOf(value));
2456
+ }).catch(() => {});
2457
+ };
2458
+ refresh();
2459
+ const timer = setInterval(refresh, REFRESH_INTERVAL_MS);
2460
+ return () => {
2461
+ live = false;
2462
+ clearInterval(timer);
2463
+ };
2464
+ }, [isDeepSeek, accountBalance]);
2326
2465
  (0, react.useEffect)(() => {
2327
2466
  const target = selected !== void 0 && isOwnProvider(selected) ? selected : lastOwn.current;
2328
2467
  if (target === void 0) {
@@ -2359,6 +2498,100 @@ function UsageChip({ call, t, isOwnProvider, useProjection }) {
2359
2498
  document.removeEventListener("click", close);
2360
2499
  };
2361
2500
  }, [open]);
2501
+ if (isBalanceProvider) {
2502
+ if (ccBalance === void 0) return null;
2503
+ const short$1 = shortLabel(selected ?? "", selected ?? "");
2504
+ return /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("span", {
2505
+ className: "dsh-subscription-usage-chip",
2506
+ children: [
2507
+ /* @__PURE__ */ (0, react_jsx_runtime.jsx)("style", { children: usageChipCss }),
2508
+ /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("button", {
2509
+ type: "button",
2510
+ className: "dsh-subscription-usage-chip-trigger",
2511
+ "aria-expanded": open,
2512
+ title: t("usageChipTitle", { provider: selected ?? "" }),
2513
+ onClick: (event) => {
2514
+ event.stopPropagation();
2515
+ setOpen((was) => !was);
2516
+ },
2517
+ children: [/* @__PURE__ */ (0, react_jsx_runtime.jsx)("span", {
2518
+ className: "dsh-subscription-usage-chip-label",
2519
+ children: short$1
2520
+ }), /* @__PURE__ */ (0, react_jsx_runtime.jsx)("span", {
2521
+ className: "dsh-subscription-usage-chip-cell",
2522
+ children: /* @__PURE__ */ (0, react_jsx_runtime.jsx)("span", {
2523
+ className: "dsh-subscription-usage-chip-value",
2524
+ children: `${ccBalance.currency === "CNY" ? "¥" : ccBalance.currency === "USD" ? "$" : `${ccBalance.currency} `}${ccBalance.total}`
2525
+ })
2526
+ })]
2527
+ }),
2528
+ open && /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("span", {
2529
+ className: "dsh-subscription-usage-chip-panel",
2530
+ role: "status",
2531
+ children: [/* @__PURE__ */ (0, react_jsx_runtime.jsx)("span", {
2532
+ className: "dsh-subscription-usage-chip-full",
2533
+ children: t("usageChipFull", { provider: selected ?? "" })
2534
+ }), /* @__PURE__ */ (0, react_jsx_runtime.jsx)("span", {
2535
+ className: "dsh-subscription-usage-chip-meter",
2536
+ children: /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("span", {
2537
+ className: "dsh-subscription-usage-chip-meterHead",
2538
+ children: [/* @__PURE__ */ (0, react_jsx_runtime.jsx)("span", { children: ccBalance.currency }), /* @__PURE__ */ (0, react_jsx_runtime.jsx)("span", {
2539
+ className: "dsh-subscription-usage-chip-detail",
2540
+ children: ccBalance.total
2541
+ })]
2542
+ })
2543
+ })]
2544
+ })
2545
+ ]
2546
+ });
2547
+ }
2548
+ if (isDeepSeek) {
2549
+ if (balance === void 0) return null;
2550
+ const short$1 = shortLabel("deepseek-account", "DeepSeek Account");
2551
+ return /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("span", {
2552
+ className: "dsh-subscription-usage-chip",
2553
+ children: [
2554
+ /* @__PURE__ */ (0, react_jsx_runtime.jsx)("style", { children: usageChipCss }),
2555
+ /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("button", {
2556
+ type: "button",
2557
+ className: "dsh-subscription-usage-chip-trigger",
2558
+ "aria-expanded": open,
2559
+ title: t("usageChipTitle", { provider: "DeepSeek Account" }),
2560
+ onClick: (event) => {
2561
+ event.stopPropagation();
2562
+ setOpen((was) => !was);
2563
+ },
2564
+ children: [/* @__PURE__ */ (0, react_jsx_runtime.jsx)("span", {
2565
+ className: "dsh-subscription-usage-chip-label",
2566
+ children: short$1
2567
+ }), balance.map((wallet) => /* @__PURE__ */ (0, react_jsx_runtime.jsx)("span", {
2568
+ className: "dsh-subscription-usage-chip-cell",
2569
+ children: /* @__PURE__ */ (0, react_jsx_runtime.jsx)("span", {
2570
+ className: "dsh-subscription-usage-chip-value",
2571
+ children: formatBalance(wallet)
2572
+ })
2573
+ }, `${wallet.currency}${wallet.bonus ? ":bonus" : ""}`))]
2574
+ }),
2575
+ open && /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("span", {
2576
+ className: "dsh-subscription-usage-chip-panel",
2577
+ role: "status",
2578
+ children: [/* @__PURE__ */ (0, react_jsx_runtime.jsx)("span", {
2579
+ className: "dsh-subscription-usage-chip-full",
2580
+ children: t("usageChipFull", { provider: "DeepSeek Account" })
2581
+ }), balance.map((wallet) => /* @__PURE__ */ (0, react_jsx_runtime.jsx)("span", {
2582
+ className: "dsh-subscription-usage-chip-meter",
2583
+ children: /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("span", {
2584
+ className: "dsh-subscription-usage-chip-meterHead",
2585
+ children: [/* @__PURE__ */ (0, react_jsx_runtime.jsx)("span", { children: `${wallet.bonus ? "Bonus · " : ""}${wallet.currency}` }), /* @__PURE__ */ (0, react_jsx_runtime.jsx)("span", {
2586
+ className: "dsh-subscription-usage-chip-detail",
2587
+ children: formatBalance(wallet)
2588
+ })]
2589
+ })
2590
+ }, `${wallet.currency}${wallet.bonus ? ":bonus" : ""}`))]
2591
+ })
2592
+ ]
2593
+ });
2594
+ }
2362
2595
  if (reading === void 0) return null;
2363
2596
  if (reading.window.usedPercent === void 0) return null;
2364
2597
  formatReset(reading.window.resetsAt, now);
@@ -2496,7 +2729,9 @@ function apply(ctx) {
2496
2729
  inject: () => ({
2497
2730
  call,
2498
2731
  t,
2499
- isOwnProvider: (provider) => OWN_PROVIDER_IDS.has(provider)
2732
+ isOwnProvider: (provider) => OWN_PROVIDER_IDS.has(provider),
2733
+ accountBalance: createAccountBalanceCall(connection.rpc),
2734
+ ccSwitchBalance: createCcSwitchBalanceCall(connection.rpc)
2500
2735
  })
2501
2736
  }, UsageChip));
2502
2737
  }