@linxin666/dsh-usage 0.4.1 → 0.4.3

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/index.js CHANGED
@@ -1,4 +1,4 @@
1
- import z from "schemastery";
1
+ import z from "@deepseek-ai/schemastery";
2
2
  import { mkdir, open, readFile, rename, unlink } from "node:fs/promises";
3
3
  import { dirname, isAbsolute, join } from "node:path";
4
4
  import "@deepseek-ai/cordis";
@@ -11,41 +11,114 @@ import { isAbsolute as isAbsolute$1, join as join$1 } from "node:path/posix";
11
11
  * the loader accepts a standalone install of the same package side by side;
12
12
  * without this guard the second instance would still re-register the same
13
13
  * webserver routes, tools, settings namespaces, and system-prompt sections
14
- * and fail the boot. mountOnce makes the second host apply a no-op for the
15
- * lifetime of the first instance (the browser half is already deduped by
16
- * package name in the client module host).
14
+ * and fail the boot. mountOnce makes a mount of an already-mounted package a
15
+ * no-op for as long as the first instance lives (the browser half is already
16
+ * deduped by package name in the client module host).
17
+ *
18
+ * A no-op is only safe while the holder is ALIVE. The holder can be disposed
19
+ * long after the refused mount ran its course: the Host reloads a profile by
20
+ * creating the new loader entries before the old ones are torn down (a
21
+ * plugin-manager enable/disable/install write, a settings-driven row reload,
22
+ * HMR), so the new aggregate shell entry mounts its family plugin while the
23
+ * previous entry still owns the name. Dropping that refused mount lost the
24
+ * plugin for good - the previous entry then disposed its own mount, releasing
25
+ * the name with nobody left to take it, and the family row stayed listed as
26
+ * active while its host routes 404ed (the task-board panel showed
27
+ * "board.hostError.notMounted", no degraded record appeared, and only a Host
28
+ * restart recovered it).
29
+ *
30
+ * The refused mount is therefore QUEUED, not dropped, and replayed the moment
31
+ * the holder releases the name - if the waiting fiber is still alive then. The
32
+ * single-instance guarantee is unchanged: exactly one mount is live per
33
+ * package name, and the replay re-enters the guard so a later mount still
34
+ * dedupes against it.
17
35
  *
18
36
  * The registry rides a global symbol so two module instances of the same
19
- * package (npm copy vs repository link) still share one verdict. cordis
20
- * `ctx.effect` runs its callback immediately and treats the callback's
37
+ * package (npm copy vs repository link) still share one verdict. That symbol
38
+ * is a CROSS-REPOSITORY contract, not this file's private state: the four
39
+ * satellite packages (dsh-skins / dsh-pet / dsh-presets /
40
+ * dsh-community-plugins) are separate repositories carrying their own copy of
41
+ * this guard, rebuilt on their own schedule, so the value under `MOUNTED`
42
+ * must keep the shape every published copy reads (a `Set` of package names
43
+ * with `has`/`add`/`delete`). The wait queues this guard added therefore live
44
+ * under their own additive key, and the registry reads back a `Set` even when
45
+ * some other build left a different value there. Changing `MOUNTED`'s shape
46
+ * in place broke that contract once: a satellite's legacy copy created a
47
+ * `Set`, the family's new copy read it as a `Map`, and every family row
48
+ * mounted after it failed with "claims.get is not a function".
49
+ *
50
+ * cordis `ctx.effect` runs its callback immediately and treats the callback's
21
51
  * return value as the fiber disposer, so the unmarker is returned, not run.
22
52
  */
53
+ /** Published cross-repository contract: package names currently mounted. */
23
54
  const MOUNTED = Symbol.for("dsh-web.mounted-plugins");
55
+ /** Additive key this guard owns: refused mounts waiting for the name. */
56
+ const WAITERS = Symbol.for("dsh-web.mounted-plugins.waiters");
57
+ /**
58
+ * The shared name registry, always a `Set` whatever another build stored here:
59
+ * a foreign value (an interim shape, a hand-written global) must not take every
60
+ * family plugin down with it.
61
+ * @returns the process-wide set of mounted package names.
62
+ */
24
63
  function mountedSet() {
25
64
  const registry = globalThis;
26
- return registry[MOUNTED] ??= /* @__PURE__ */ new Set();
65
+ const existing = registry[MOUNTED];
66
+ if (existing instanceof Set) return existing;
67
+ const created = /* @__PURE__ */ new Set();
68
+ registry[MOUNTED] = created;
69
+ return created;
70
+ }
71
+ /** Queue per package name for mounts refused while a holder was alive. */
72
+ function mountWaiters() {
73
+ const registry = globalThis;
74
+ return registry[WAITERS] ??= /* @__PURE__ */ new Map();
27
75
  }
28
76
  /**
29
77
  * Wrap a cordis plugin apply so the package runs at most once per process.
30
- * The first mount registers normally and unmarks when its fiber disposes;
31
- * any later mount of the same package name is a no-op.
78
+ * The first mount registers normally and releases the name when its fiber
79
+ * disposes; a mount refused while that name is held waits for the release and
80
+ * then runs, unless its own fiber disposes first.
32
81
  * @param packageName - npm package identity shared by every install source.
33
82
  * @param fn - the original plugin apply.
34
83
  * @returns an apply of the same shape.
35
84
  */
36
85
  function mountOnce(packageName, fn) {
37
- return ((...args) => {
86
+ const mount = (...args) => {
38
87
  const mounted = mountedSet();
39
- if (mounted.has(packageName)) return;
88
+ const ctx = args[0];
89
+ if (mounted.has(packageName)) {
90
+ const waiters = mountWaiters();
91
+ const queue = waiters.get(packageName) ?? [];
92
+ let alive = true;
93
+ const pending = { run: () => {
94
+ if (alive) mount(...args);
95
+ } };
96
+ ctx?.effect?.(() => () => {
97
+ alive = false;
98
+ const index = queue.indexOf(pending);
99
+ if (index >= 0) queue.splice(index, 1);
100
+ });
101
+ queue.push(pending);
102
+ waiters.set(packageName, queue);
103
+ return;
104
+ }
40
105
  mounted.add(packageName);
41
- args[0]?.effect?.(() => () => {
106
+ ctx?.effect?.(() => () => {
42
107
  mounted.delete(packageName);
108
+ const waiters = mountWaiters();
109
+ const queue = waiters.get(packageName);
110
+ if (queue === void 0) return;
111
+ waiters.delete(packageName);
112
+ for (const waiter of queue.splice(0)) queueMicrotask(() => {
113
+ waiter.run();
114
+ });
43
115
  });
44
116
  return fn(...args);
45
- });
117
+ };
118
+ return mount;
46
119
  }
47
120
  //#endregion
48
- //#region ../../node_modules/.pnpm/@deepseek-ai+dsh-brand@0.1.7-rc.1_@deepseek-ai+cordis@4.0.4/node_modules/@deepseek-ai/dsh-brand/lib/index.js
121
+ //#region ../../node_modules/.pnpm/@deepseek-ai+dsh-brand@0.1.7-rc.2_@deepseek-ai+cordis@4.0.4/node_modules/@deepseek-ai/dsh-brand/lib/index.js
49
122
  /**
50
123
  * Duplicate-install-safe nominal primitive helpers.
51
124
  *
@@ -68,7 +141,7 @@ function brandString(value) {
68
141
  return value;
69
142
  }
70
143
  //#endregion
71
- //#region ../../node_modules/.pnpm/@deepseek-ai+dsh-credentials@0.1.7-rc.1_@deepseek-ai+cordis@4.0.4_@deepseek-ai+dsh-inva_9b80248df36b741e5eb76c73b242287a/node_modules/@deepseek-ai/dsh-credentials/lib/index.js
144
+ //#region ../../node_modules/.pnpm/@deepseek-ai+dsh-credentials@0.1.7-rc.2_@deepseek-ai+cordis@4.0.4_@deepseek-ai+dsh-inva_98fa173416f048996704a605a96a2b0e/node_modules/@deepseek-ai/dsh-credentials/lib/index.js
72
145
  /**
73
146
  * Service Definition for the credential-reference capability seam (`ctx.credentials`). Settings and composition files carry
74
147
  * *references* to secrets — environment-variable names — while providers own
@@ -235,16 +308,11 @@ function usedPercent(used, limit) {
235
308
  function bearer(apiKey) {
236
309
  return { authorization: `Bearer ${apiKey}` };
237
310
  }
238
- /**
239
- * The official pay-as-you-go balance. `deepseek` is the configurable-catalog
240
- * route key; `deepseek-official` is the live provider route the llm-deepseek
241
- * adapter registers (sessions and agent-default-model carry it), so both ids
242
- * must resolve here or the current provider would never be probed.
243
- */
244
311
  const DEEPSEEK = {
245
312
  ids: ["deepseek", "deepseek-official"],
246
313
  displayName: "DeepSeek",
247
314
  balance: {
315
+ origin: "https://api.deepseek.com",
248
316
  build: ({ apiKey }) => ({
249
317
  url: "https://api.deepseek.com/user/balance",
250
318
  headers: bearer(apiKey)
@@ -624,6 +692,40 @@ const PROVIDER_ADAPTERS = [
624
692
  function adapterFor(provider) {
625
693
  return PROVIDER_ADAPTERS.find((adapter) => adapter.ids.includes(provider));
626
694
  }
695
+ /** Origin of a configured base URL, or undefined when it cannot be parsed. */
696
+ function originOf(baseURL) {
697
+ if (baseURL === void 0 || baseURL.trim() === "") return void 0;
698
+ try {
699
+ return new URL(baseURL).origin;
700
+ } catch {
701
+ return;
702
+ }
703
+ }
704
+ /**
705
+ * Whether an adapter's balance endpoint reports THIS route's account.
706
+ *
707
+ * Some adapter families share one route key across providers that merely
708
+ * speak the same API: `deepseek` is both the official DeepSeek catalog entry
709
+ * and a common id for an OpenAI-compatible reseller (Alibaba Bailian, ...)
710
+ * pointed at another host by the profile's `baseURL`. The balance endpoint is
711
+ * per ORIGIN, not per route key, so probing the official endpoint for a
712
+ * reseller profile reports the official account's money under the reseller's
713
+ * name (issue #1688). An adapter published with a fixed `origin` therefore
714
+ * only applies to a route whose configured base URL is that origin, or to a
715
+ * route that pins no base URL at all (the catalog alias of the official
716
+ * route, which resolves the family's own credential).
717
+ *
718
+ * @param adapter - the matched adapter.
719
+ * @param baseURL - the route's configured base URL (pi-ai profile), if any.
720
+ * @returns true when the adapter may probe its balance endpoint for the route.
721
+ */
722
+ function balanceAppliesToRoute(adapter, baseURL) {
723
+ const expected = adapter.balance?.origin;
724
+ if (expected === void 0) return true;
725
+ const configured = originOf(baseURL);
726
+ if (configured === void 0) return true;
727
+ return configured === expected;
728
+ }
627
729
  /**
628
730
  * Whether a provider route belongs to the official DeepSeek family: the only
629
731
  * family with a spend price book and a settings-section-owned env credential
@@ -1086,7 +1188,8 @@ var UsageService = class {
1086
1188
  displayName: route.displayName || adapter?.displayName || route.id,
1087
1189
  credential: snapshot?.credential ?? "none",
1088
1190
  supported: adapter !== void 0 && (adapter.balance !== void 0 || adapter.plan !== void 0),
1089
- ...adapter?.balance !== void 0 ? { balanceSupported: true } : {},
1191
+ ...adapter?.balance !== void 0 && balanceAppliesToRoute(adapter, this.piAiProfile(route.id)?.baseURL) ? { balanceSupported: true } : {},
1192
+ ...adapter?.balance !== void 0 && !balanceAppliesToRoute(adapter, this.piAiProfile(route.id)?.baseURL) ? { balanceSupported: false } : {},
1090
1193
  ...adapter?.plan !== void 0 ? { planSupported: true } : {},
1091
1194
  ...snapshot?.balance !== void 0 ? { balance: snapshot.balance } : {},
1092
1195
  ...snapshot?.plan !== void 0 ? { plan: snapshot.plan } : {},
@@ -1415,6 +1518,7 @@ var UsageService = class {
1415
1518
  async probeRoute(route, adapter) {
1416
1519
  const credential = await this.resolveCredential(route.id);
1417
1520
  const previous = this.snapshots.get(route.id);
1521
+ const balanceApplies = balanceAppliesToRoute(adapter, this.piAiProfile(route.id)?.baseURL);
1418
1522
  if (credential.key === void 0) {
1419
1523
  this.snapshots.set(route.id, {
1420
1524
  provider: route.id,
@@ -1430,11 +1534,13 @@ var UsageService = class {
1430
1534
  displayName: route.displayName || adapter.displayName,
1431
1535
  credential: credential.kind,
1432
1536
  supported: true,
1537
+ ...adapter.balance !== void 0 && !balanceApplies ? { balanceSupported: false } : {},
1433
1538
  updatedAt: Date.now()
1434
1539
  };
1435
1540
  const runProbe = async (kind) => {
1436
1541
  const half = adapter[kind];
1437
1542
  if (half === void 0) return void 0;
1543
+ if (kind === "balance" && !balanceApplies) return void 0;
1438
1544
  try {
1439
1545
  const spec = half.build({
1440
1546
  apiKey: credential.key,
@@ -1678,16 +1784,48 @@ const inject = ["webServer"];
1678
1784
  * family settings bridge (dsh-web-settings) resolves onto this row's entry id.
1679
1785
  */
1680
1786
  const USAGE_SETTINGS_NAMESPACE = "dsh-usage";
1787
+ /**
1788
+ * Plugin config schema. Under the 0.1.7 settings model this schema IS the
1789
+ * entry's settings page: the Host derives one form per profile entry from it
1790
+ * and serves it only when at least one field is `volatile()`. The marker is
1791
+ * also what admits a write and what keeps the edit on the live path — the
1792
+ * Loader commits the new value into the field's reference and announces
1793
+ * `loader/volatile-update` on this fiber instead of remounting the row, so the
1794
+ * probe cycle, its ledger and its route registrations survive a settings save.
1795
+ *
1796
+ * The schema is left to inference rather than annotated with
1797
+ * `z<UsageConfigFields>`: a volatile field's parsed output is a reference while
1798
+ * its accepted input stays the plain value, so the two sides no longer share
1799
+ * one shape and the annotation would reject the schema the Host must be given.
1800
+ */
1681
1801
  const Config = z.object({
1682
- enabled: z.boolean().default(true),
1683
- pollIntervalSec: z.number().min(30).max(3600).default(60),
1684
- retainDays: z.number().min(7).max(730).default(180)
1802
+ enabled: z.boolean().default(true).volatile(),
1803
+ pollIntervalSec: z.number().min(30).max(3600).default(60).volatile(),
1804
+ retainDays: z.number().min(7).max(730).default(180).volatile()
1685
1805
  });
1806
+ /** Read one config field, following the live reference the schema produces. */
1807
+ function readConfigField(field) {
1808
+ if (field === void 0) return void 0;
1809
+ if (typeof field === "object" && field !== null && typeof field.get === "function") return field.get();
1810
+ return field;
1811
+ }
1812
+ /** Read one numeric field, falling back when the value is absent or not a usable number. */
1813
+ function readNumber(field, fallback) {
1814
+ const value = readConfigField(field);
1815
+ return typeof value === "number" && Number.isFinite(value) ? value : fallback;
1816
+ }
1817
+ /**
1818
+ * Resolve the effective settings of one activation. Volatile references are
1819
+ * read here, so every re-arm observes the latest committed values rather than
1820
+ * whatever the row was activated with.
1821
+ * @param config - the config the Host passed to the activation.
1822
+ * @returns the resolved settings.
1823
+ */
1686
1824
  function resolveConfig(config) {
1687
1825
  return {
1688
- enabled: config?.enabled ?? true,
1689
- pollIntervalSec: typeof config?.pollIntervalSec === "number" ? config.pollIntervalSec : 60,
1690
- retainDays: typeof config?.retainDays === "number" ? config.retainDays : 180
1826
+ enabled: readConfigField(config?.enabled) ?? true,
1827
+ pollIntervalSec: readNumber(config?.pollIntervalSec, 60),
1828
+ retainDays: readNumber(config?.retainDays, 180)
1691
1829
  };
1692
1830
  }
1693
1831
  /**
@@ -1701,39 +1839,78 @@ let pendingStop;
1701
1839
  /**
1702
1840
  * Host body. The plugin's own Config schema above is the settings document the
1703
1841
  * 0.1.7 Host serves for this row; the effective config arrives here as the
1704
- * `config` argument the Loader resolved and the Host reloads the row when the
1705
- * user saves a change, so nothing in this body observes or writes settings.
1842
+ * `config` argument, and a settings save re-enters this body through the
1843
+ * volatile path rather than a row reload (see {@link Config}).
1706
1844
  * @param ctx - host root context.
1707
1845
  * @param config - the row's resolved configuration.
1708
1846
  */
1709
1847
  const apply = mountOnce("@linxin666/dsh-usage", (ctx, config) => {
1710
- const value = resolveConfig(config);
1711
- if (!value.enabled) return;
1712
1848
  let live = true;
1713
1849
  let service;
1714
1850
  let disposeRoutes;
1715
- const begin = () => {
1716
- if (!live) return;
1717
- const next = new UsageService(ctx, value);
1718
- service = next;
1719
- next.start();
1851
+ /** True while this activation has a start queued behind a predecessor's flush. */
1852
+ let queued = false;
1853
+ /** Register the service's two routes; the returned disposer unregisters them. */
1854
+ const mountRoutes = (next) => {
1720
1855
  const disposers = [makeUsageOverviewRoute(ctx, next), makeUsageRefreshRoute(ctx, next)].map((route) => ctx.webServer.register(route));
1721
- disposeRoutes = () => {
1856
+ return () => {
1722
1857
  for (const dispose of disposers) try {
1723
1858
  dispose();
1724
1859
  } catch {}
1725
1860
  };
1726
1861
  };
1862
+ /** Start a fresh activation, unless this fiber is down or one already runs. */
1863
+ const launch = () => {
1864
+ queued = false;
1865
+ if (!live || service !== void 0) return;
1866
+ const value = resolveConfig(config);
1867
+ if (!value.enabled) return;
1868
+ const next = new UsageService(ctx, value);
1869
+ service = next;
1870
+ next.start();
1871
+ disposeRoutes = mountRoutes(next);
1872
+ };
1873
+ /** Tear the running activation down, chaining its final flush for any successor. */
1874
+ const stop = () => {
1875
+ disposeRoutes?.();
1876
+ disposeRoutes = void 0;
1877
+ const stopping = service?.stop();
1878
+ service = void 0;
1879
+ if (stopping !== void 0) pendingStop = (pendingStop ?? Promise.resolve()).then(() => stopping);
1880
+ };
1881
+ /**
1882
+ * Apply the activation's current settings: a disabled row mounts nothing (no
1883
+ * probe cycle, no ledger, no routes), an enabled one starts on first use and
1884
+ * otherwise re-applies its options in place.
1885
+ */
1886
+ const rearm = () => {
1887
+ const value = resolveConfig(config);
1888
+ if (!value.enabled) {
1889
+ queued = false;
1890
+ stop();
1891
+ return;
1892
+ }
1893
+ if (service !== void 0) {
1894
+ service.applyOptions(value);
1895
+ return;
1896
+ }
1897
+ if (queued) return;
1898
+ if (pendingStop === void 0) {
1899
+ launch();
1900
+ return;
1901
+ }
1902
+ queued = true;
1903
+ pendingStop = pendingStop.then(launch, launch).then(() => void 0);
1904
+ };
1905
+ ctx.on("loader/volatile-update", () => {
1906
+ rearm();
1907
+ });
1727
1908
  ctx.effect(() => {
1728
- if (pendingStop === void 0) begin();
1729
- else pendingStop = pendingStop.then(begin, begin).then(() => void 0);
1909
+ rearm();
1730
1910
  return () => {
1731
1911
  live = false;
1732
- disposeRoutes?.();
1733
- disposeRoutes = void 0;
1734
- const stopping = service?.stop();
1735
- service = void 0;
1736
- if (stopping !== void 0) pendingStop = (pendingStop ?? Promise.resolve()).then(() => stopping);
1912
+ queued = false;
1913
+ stop();
1737
1914
  };
1738
1915
  }, "dsh-usage: runtime");
1739
1916
  });
@@ -3,10 +3,10 @@
3
3
  * plugin bundle.
4
4
  *
5
5
  * Family plugins that inject at the DOM level must notice when the shell
6
- * re-renders around their injected node: the sidebar entry rows
7
- * (sidebar-entry-core), the center-column panel containers
8
- * (panel-mount-core), and the aggregate shell's column shims. Each consumer
9
- * used to install its OWN `MutationObserver` on `document.body` with
6
+ * re-renders around their injected node: the aggregate shell's column shims
7
+ * and any plugin surface still extending the shell from outside the official
8
+ * slot seats. Each consumer used to install its OWN `MutationObserver` on
9
+ * `document.body` with
10
10
  * `{ childList: true, subtree: true }`, so a page carrying N family plugins
11
11
  * paid N native observers and N callback invocations for EVERY mutation batch
12
12
  * the app produced — chat token streaming alone produces many per second, and
@@ -0,0 +1,49 @@
1
+ /**
2
+ * Family settings form bound to the profile entry id a Host actually serves.
3
+ *
4
+ * Under the 0.1.7 settings model a form is addressed by PROFILE ENTRY ID, while
5
+ * a family card knows only its own settings namespace. The family binder
6
+ * (`ctx.get('webUiSettings')`, published by dsh-web-settings) resolves one onto
7
+ * the other and is the preferred transport; this module is the fallback for a
8
+ * page that serves no binder — the group plugin is not installed, or its client
9
+ * half has not applied yet (the aggregate mounts its children in order, and a
10
+ * child still waiting on its own injects publishes nothing).
11
+ *
12
+ * The shared describe mirror is the only local evidence of which row id this
13
+ * profile serves, but it answers asynchronously: at plugin activation it usually
14
+ * holds nothing yet. A one-shot guess therefore cannot be right for every
15
+ * deployment, and the form is bound once per session:
16
+ *
17
+ * - binding the bare namespace made every save answer `No configurable plugin
18
+ * entry "<namespace>"` on an aggregate install (the namespace is not an entry
19
+ * id there), which the card reported as "the deployment did not accept these
20
+ * values";
21
+ * - binding the aggregate row id would break a standalone install, whose row id
22
+ * is the namespace or the package's own `ui-*` row.
23
+ *
24
+ * So the fallback starts on the most likely row and REBINDS the moment the
25
+ * mirror names one of this package's rows. Until the mirror answers, the form
26
+ * reports the binding it holds (usually `loading`), which the card renders as an
27
+ * unavailable form rather than a doomed one.
28
+ */
29
+ import type { ConfigForm, ConfigForms } from '@deepseek-ai/dsh-client-ui-settings/client';
30
+ /** Options of {@link createServedEntryForm}. */
31
+ export interface ServedEntryFormOptions<T> {
32
+ /** The shared configuration forms service (`ctx.configForms`). */
33
+ forms: ConfigForms;
34
+ /**
35
+ * Profile entry ids this package's rows carry, most likely first: the
36
+ * aggregate's generated row, the package's own standalone row, then the bare
37
+ * namespace. The first is the fallback while the mirror has no answer; the
38
+ * last is the answer when the mirror serves none of them.
39
+ */
40
+ entryIds: readonly string[];
41
+ }
42
+ /**
43
+ * Create a settings form bound to the profile entry id this Host serves, and
44
+ * rebound whenever the shared describe mirror names a different one of this
45
+ * package's rows.
46
+ * @param options - the shared forms service and this package's candidate row ids.
47
+ * @returns a form delegating to the currently resolved entry's own form.
48
+ */
49
+ export declare function createServedEntryForm<T>(options: ServedEntryFormOptions<T>): ConfigForm<T>;
@@ -19,6 +19,14 @@ export interface AdapterProbeContext {
19
19
  apiKey: string;
20
20
  /** Provider account id riding a dedicated header, when the credential carries one (Codex). */
21
21
  accountId?: string;
22
+ /**
23
+ * The route's configured base URL (the pi-ai profile's baseURL), when it
24
+ * declares one. An adapter whose endpoint belongs to one provider origin
25
+ * uses it to refuse an account that merely shares the route key, so a
26
+ * differently-hosted model of the same family is never reported with the
27
+ * other account's number.
28
+ */
29
+ baseURL?: string;
22
30
  }
23
31
  /** A parsed balance fact. */
24
32
  export interface BalanceParse {
@@ -37,6 +45,14 @@ export interface ProviderAdapter {
37
45
  /** Fallback display name when the LLM runtime has none. */
38
46
  displayName: string;
39
47
  balance?: {
48
+ /**
49
+ * Origin (scheme + host) whose account this balance endpoint reports.
50
+ * Omitted when the endpoint is per-route by construction (a profile
51
+ * supplies the host) or when the provider has only one origin. When set,
52
+ * a route whose configured baseURL resolves elsewhere is not this
53
+ * origin's account and must not be probed with this adapter.
54
+ */
55
+ origin?: string;
40
56
  build(context: AdapterProbeContext): ProbeSpec;
41
57
  parse(status: number, body: unknown): BalanceParse | undefined;
42
58
  };
@@ -45,6 +61,14 @@ export interface ProviderAdapter {
45
61
  parse(status: number, body: unknown): PlanParse | undefined;
46
62
  };
47
63
  }
64
+ /**
65
+ * The official pay-as-you-go balance. `deepseek` is the configurable-catalog
66
+ * route key; `deepseek-official` is the live provider route the llm-deepseek
67
+ * adapter registers (sessions and agent-default-model carry it), so both ids
68
+ * must resolve here or the current provider would never be probed.
69
+ */
70
+ /** The official DeepSeek API origin: the only account its balance endpoint reports. */
71
+ export declare const DEEPSEEK_API_ORIGIN = "https://api.deepseek.com";
48
72
  /**
49
73
  * The adapter registry, in no particular order. Route keys come from the
50
74
  * pi-ai provider catalog plus the routes this deployment observed in user
@@ -53,6 +77,27 @@ export interface ProviderAdapter {
53
77
  export declare const PROVIDER_ADAPTERS: readonly ProviderAdapter[];
54
78
  /** Find the adapter serving a provider route key, if any. */
55
79
  export declare function adapterFor(provider: string): ProviderAdapter | undefined;
80
+ /** Origin of a configured base URL, or undefined when it cannot be parsed. */
81
+ export declare function originOf(baseURL: string | undefined): string | undefined;
82
+ /**
83
+ * Whether an adapter's balance endpoint reports THIS route's account.
84
+ *
85
+ * Some adapter families share one route key across providers that merely
86
+ * speak the same API: `deepseek` is both the official DeepSeek catalog entry
87
+ * and a common id for an OpenAI-compatible reseller (Alibaba Bailian, ...)
88
+ * pointed at another host by the profile's `baseURL`. The balance endpoint is
89
+ * per ORIGIN, not per route key, so probing the official endpoint for a
90
+ * reseller profile reports the official account's money under the reseller's
91
+ * name (issue #1688). An adapter published with a fixed `origin` therefore
92
+ * only applies to a route whose configured base URL is that origin, or to a
93
+ * route that pins no base URL at all (the catalog alias of the official
94
+ * route, which resolves the family's own credential).
95
+ *
96
+ * @param adapter - the matched adapter.
97
+ * @param baseURL - the route's configured base URL (pi-ai profile), if any.
98
+ * @returns true when the adapter may probe its balance endpoint for the route.
99
+ */
100
+ export declare function balanceAppliesToRoute(adapter: ProviderAdapter, baseURL: string | undefined): boolean;
56
101
  /**
57
102
  * Whether a provider route belongs to the official DeepSeek family: the only
58
103
  * family with a spend price book and a settings-section-owned env credential
@@ -1,7 +1,20 @@
1
1
  import type { Context } from '@deepseek-ai/cordis';
2
2
  import type { SettingsNamespace } from '@deepseek-ai/dsh-settings';
3
- import z from 'schemastery';
3
+ import z from '@deepseek-ai/schemastery';
4
4
  import { type UsageServiceOptions } from './host/usage-service.ts';
5
+ declare module '@deepseek-ai/cordis' {
6
+ interface Events {
7
+ /**
8
+ * Volatile config values were committed into the running fiber without a
9
+ * remount; dispatched to the owning fiber only. Spelled here because the
10
+ * Loader package is not a dependency of this plugin, with the Loader's own
11
+ * shape so the two declarations merge when a Host program carries both.
12
+ * @param paths - changed config paths as key arrays; every value is committed before dispatch.
13
+ * @mode emit
14
+ */
15
+ 'loader/volatile-update'(paths: readonly (readonly string[])[]): void;
16
+ }
17
+ }
5
18
  export declare const name = "dsh-usage";
6
19
  export declare const inject: string[];
7
20
  /**
@@ -11,24 +24,62 @@ export declare const inject: string[];
11
24
  * family settings bridge (dsh-web-settings) resolves onto this row's entry id.
12
25
  */
13
26
  export declare const USAGE_SETTINGS_NAMESPACE: SettingsNamespace;
14
- export interface Config {
15
- enabled?: boolean;
27
+ /** The stable reference a `volatile()` config field resolves to; its owner updates it in place. */
28
+ interface ConfigRef<T> {
29
+ /** @returns the value currently committed for the running instance. */
30
+ get(): T | undefined;
31
+ }
32
+ /** One config field as the Host hands it over: a live reference, or a plain value (profile patches, tests). */
33
+ type ConfigField<T> = ConfigRef<T> | T | undefined;
34
+ /** The config the Host hands this activation — the runtime face of {@link Config}. */
35
+ export interface UsageConfigFields {
36
+ enabled?: ConfigField<boolean>;
16
37
  /** Provider probe cycle in seconds; 30-3600. */
17
- pollIntervalSec?: number;
38
+ pollIntervalSec?: ConfigField<number>;
18
39
  /** Ledger retention in local days. */
19
- retainDays?: number;
40
+ retainDays?: ConfigField<number>;
20
41
  }
21
- export declare const Config: z<Config>;
42
+ /**
43
+ * Plugin config schema. Under the 0.1.7 settings model this schema IS the
44
+ * entry's settings page: the Host derives one form per profile entry from it
45
+ * and serves it only when at least one field is `volatile()`. The marker is
46
+ * also what admits a write and what keeps the edit on the live path — the
47
+ * Loader commits the new value into the field's reference and announces
48
+ * `loader/volatile-update` on this fiber instead of remounting the row, so the
49
+ * probe cycle, its ledger and its route registrations survive a settings save.
50
+ *
51
+ * The schema is left to inference rather than annotated with
52
+ * `z<UsageConfigFields>`: a volatile field's parsed output is a reference while
53
+ * its accepted input stays the plain value, so the two sides no longer share
54
+ * one shape and the annotation would reject the schema the Host must be given.
55
+ */
56
+ export declare const Config: z<Schemastery.ObjectS<NoInfer<{
57
+ enabled: z<boolean, boolean, "volatile-defined">;
58
+ pollIntervalSec: z<number, number, "volatile-defined">;
59
+ retainDays: z<number, number, "volatile-defined">;
60
+ }>>, Schemastery.ObjectT<NoInfer<{
61
+ enabled: z<boolean, boolean, "volatile-defined">;
62
+ pollIntervalSec: z<number, number, "volatile-defined">;
63
+ retainDays: z<number, number, "volatile-defined">;
64
+ }>>, "plain">;
22
65
  export interface ResolvedConfig extends UsageServiceOptions {
23
66
  enabled: boolean;
24
67
  }
25
- export declare function resolveConfig(config?: Config): ResolvedConfig;
68
+ /**
69
+ * Resolve the effective settings of one activation. Volatile references are
70
+ * read here, so every re-arm observes the latest committed values rather than
71
+ * whatever the row was activated with.
72
+ * @param config - the config the Host passed to the activation.
73
+ * @returns the resolved settings.
74
+ */
75
+ export declare function resolveConfig(config?: UsageConfigFields): ResolvedConfig;
26
76
  /**
27
77
  * Host body. The plugin's own Config schema above is the settings document the
28
78
  * 0.1.7 Host serves for this row; the effective config arrives here as the
29
- * `config` argument the Loader resolved and the Host reloads the row when the
30
- * user saves a change, so nothing in this body observes or writes settings.
79
+ * `config` argument, and a settings save re-enters this body through the
80
+ * volatile path rather than a row reload (see {@link Config}).
31
81
  * @param ctx - host root context.
32
82
  * @param config - the row's resolved configuration.
33
83
  */
34
- export declare const apply: (ctx: Context, config?: Config) => void;
84
+ export declare const apply: (ctx: Context, config?: UsageConfigFields) => void;
85
+ export {};