@linxin666/dsh-usage 0.4.2 → 0.4.4
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/README.i18n.yaml +2 -2
- package/README.md +1 -1
- package/README.zh.md +1 -1
- package/lib/client.js +104 -2
- package/lib/client.js.map +1 -1
- package/lib/index.js +252 -45
- package/lib/types/client/body-mutations.d.ts +4 -4
- package/lib/types/client/settings-entry-form.d.ts +49 -0
- package/lib/types/host/usage-service.d.ts +4 -0
- package/lib/types/index.d.ts +61 -10
- package/lib/types/mount-once.d.ts +38 -7
- package/package.json +15 -15
- package/src/client/body-mutations.ts +4 -4
- package/src/client/index.ts +17 -7
- package/src/client/settings-entry-form.ts +142 -0
- package/src/host/usage-service.ts +129 -8
- package/src/index.ts +138 -43
- package/src/mount-once.ts +107 -13
- package/lib/types/host/run-guarded.d.ts +0 -39
- package/src/host/run-guarded.ts +0 -76
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
|
|
15
|
-
*
|
|
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.
|
|
20
|
-
*
|
|
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
|
-
|
|
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
|
|
31
|
-
*
|
|
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
|
-
|
|
86
|
+
const mount = (...args) => {
|
|
38
87
|
const mounted = mountedSet();
|
|
39
|
-
|
|
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
|
-
|
|
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.
|
|
121
|
+
//#region ../../node_modules/.pnpm/@deepseek-ai+dsh-brand@0.2.0-rc.1_@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.
|
|
144
|
+
//#region ../../node_modules/.pnpm/@deepseek-ai+dsh-credentials@0.2.0-rc.1_@deepseek-ai+cordis@4.0.4_@deepseek-ai+dsh-inva_1a647ad178a224e363f109a5f72e2237/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
|
|
@@ -1025,16 +1098,71 @@ async function writeJsonAtomic(path, value) {
|
|
|
1025
1098
|
throw error;
|
|
1026
1099
|
}
|
|
1027
1100
|
}
|
|
1101
|
+
function unwrapVolatiles(value) {
|
|
1102
|
+
if (value === null || value === void 0) return value;
|
|
1103
|
+
if (typeof value === "object") {
|
|
1104
|
+
if (typeof value.get === "function") return unwrapVolatiles(value.get());
|
|
1105
|
+
if (Array.isArray(value)) return value.map(unwrapVolatiles);
|
|
1106
|
+
const result = {};
|
|
1107
|
+
for (const [k, v] of Object.entries(value)) result[k] = unwrapVolatiles(v);
|
|
1108
|
+
return result;
|
|
1109
|
+
}
|
|
1110
|
+
return value;
|
|
1111
|
+
}
|
|
1112
|
+
function isPlainObject(value) {
|
|
1113
|
+
return typeof value === "object" && value !== null && !Array.isArray(value);
|
|
1114
|
+
}
|
|
1115
|
+
function deepMerge(base, patch) {
|
|
1116
|
+
if (!isPlainObject(base) || !isPlainObject(patch)) return patch !== void 0 ? patch : base;
|
|
1117
|
+
const result = { ...base };
|
|
1118
|
+
for (const [key, value] of Object.entries(patch)) {
|
|
1119
|
+
if (value === void 0) continue;
|
|
1120
|
+
result[key] = isPlainObject(value) && isPlainObject(result[key]) ? deepMerge(result[key], value) : value;
|
|
1121
|
+
}
|
|
1122
|
+
return result;
|
|
1123
|
+
}
|
|
1028
1124
|
/**
|
|
1029
1125
|
* Read a foreign settings namespace's resolved value (the llm adapter
|
|
1030
|
-
* profiles, the agent default model).
|
|
1031
|
-
*
|
|
1126
|
+
* profiles, the agent default model). Resolves across pre-0.1.7 settings.get(),
|
|
1127
|
+
* 0.1.7 SettingsForms.describe(), live loader entries, and configEditor.
|
|
1128
|
+
* Unregistered namespaces read as undefined; nothing here throws into the poll loop.
|
|
1032
1129
|
*/
|
|
1033
1130
|
function readNamespace(ctx, ns) {
|
|
1034
1131
|
try {
|
|
1035
1132
|
const settings = service(ctx, "settings");
|
|
1036
|
-
if (settings
|
|
1037
|
-
|
|
1133
|
+
if (settings !== void 0) {
|
|
1134
|
+
if (typeof settings.get === "function") {
|
|
1135
|
+
const value = settings.get(ns);
|
|
1136
|
+
if (value !== void 0) return unwrapVolatiles(value);
|
|
1137
|
+
}
|
|
1138
|
+
if (typeof settings.describe === "function") {
|
|
1139
|
+
const descriptors = settings.describe({ redactSecrets: false });
|
|
1140
|
+
if (Array.isArray(descriptors)) {
|
|
1141
|
+
const match = descriptors.find((d) => d.ns === ns || ns === "llm-pi-ai" && (d.ns === "llm" || isPlainObject(d.value?.providers)) || ns === "llm-deepseek" && (d.ns === "llm-deepseek" || d.ns === "llm-deepseek-api-key"));
|
|
1142
|
+
if (match !== void 0 && match.value !== void 0) return unwrapVolatiles(match.value);
|
|
1143
|
+
}
|
|
1144
|
+
}
|
|
1145
|
+
}
|
|
1146
|
+
const loader = ctx.root?.loader ?? ctx.loader;
|
|
1147
|
+
if (loader !== void 0 && typeof loader.entries === "function") for (const entry of loader.entries()) {
|
|
1148
|
+
const id = entry.options?.id;
|
|
1149
|
+
const name = entry.options?.name;
|
|
1150
|
+
if (id === ns || name === `@deepseek-ai/dsh-${ns}` || name?.endsWith(`/${ns}`) || ns === "llm-pi-ai" && (id === "llm" || name === "@deepseek-ai/dsh-llm-pi-ai") || ns === "llm-deepseek" && (id === "llm-deepseek-api-key" || name === "@deepseek-ai/dsh-llm-deepseek-api-key")) {
|
|
1151
|
+
if (entry.fiber?.config !== void 0) return unwrapVolatiles(entry.fiber.config);
|
|
1152
|
+
}
|
|
1153
|
+
}
|
|
1154
|
+
const editor = service(ctx, "configEditor");
|
|
1155
|
+
if (editor !== void 0 && typeof editor.configuration === "function") {
|
|
1156
|
+
const match = editor.configuration().find((r) => {
|
|
1157
|
+
const id = r.entry.options.id;
|
|
1158
|
+
const name = r.entry.options.name;
|
|
1159
|
+
return id === ns || name === `@deepseek-ai/dsh-${ns}` || name?.endsWith(`/${ns}`) || ns === "llm-pi-ai" && (id === "llm" || name === "@deepseek-ai/dsh-llm-pi-ai") || ns === "llm-deepseek" && (id === "llm-deepseek-api-key" || name === "@deepseek-ai/dsh-llm-deepseek-api-key");
|
|
1160
|
+
});
|
|
1161
|
+
if (match !== void 0) {
|
|
1162
|
+
if (match.entry.fiber?.config !== void 0) return unwrapVolatiles(match.entry.fiber.config);
|
|
1163
|
+
return unwrapVolatiles(deepMerge(match.inherited, match.override));
|
|
1164
|
+
}
|
|
1165
|
+
}
|
|
1038
1166
|
} catch {
|
|
1039
1167
|
return;
|
|
1040
1168
|
}
|
|
@@ -1115,8 +1243,8 @@ var UsageService = class {
|
|
|
1115
1243
|
displayName: route.displayName || adapter?.displayName || route.id,
|
|
1116
1244
|
credential: snapshot?.credential ?? "none",
|
|
1117
1245
|
supported: adapter !== void 0 && (adapter.balance !== void 0 || adapter.plan !== void 0),
|
|
1118
|
-
...adapter?.balance !== void 0 && balanceAppliesToRoute(adapter, this.
|
|
1119
|
-
...adapter?.balance !== void 0 && !balanceAppliesToRoute(adapter, this.
|
|
1246
|
+
...adapter?.balance !== void 0 && balanceAppliesToRoute(adapter, this.routeBaseUrl(route.id)) ? { balanceSupported: true } : {},
|
|
1247
|
+
...adapter?.balance !== void 0 && !balanceAppliesToRoute(adapter, this.routeBaseUrl(route.id)) ? { balanceSupported: false } : {},
|
|
1120
1248
|
...adapter?.plan !== void 0 ? { planSupported: true } : {},
|
|
1121
1249
|
...snapshot?.balance !== void 0 ? { balance: snapshot.balance } : {},
|
|
1122
1250
|
...snapshot?.plan !== void 0 ? { plan: snapshot.plan } : {},
|
|
@@ -1445,7 +1573,7 @@ var UsageService = class {
|
|
|
1445
1573
|
async probeRoute(route, adapter) {
|
|
1446
1574
|
const credential = await this.resolveCredential(route.id);
|
|
1447
1575
|
const previous = this.snapshots.get(route.id);
|
|
1448
|
-
const balanceApplies = balanceAppliesToRoute(adapter, this.
|
|
1576
|
+
const balanceApplies = balanceAppliesToRoute(adapter, this.routeBaseUrl(route.id));
|
|
1449
1577
|
if (credential.key === void 0) {
|
|
1450
1578
|
this.snapshots.set(route.id, {
|
|
1451
1579
|
provider: route.id,
|
|
@@ -1560,6 +1688,14 @@ var UsageService = class {
|
|
|
1560
1688
|
deepseekApiKeyEnv() {
|
|
1561
1689
|
return readNamespace(this.ctx, "llm-deepseek")?.apiKeyEnv ?? "DEEPSEEK_API_KEY";
|
|
1562
1690
|
}
|
|
1691
|
+
/** The DeepSeek official adapter's configured baseURL. */
|
|
1692
|
+
deepseekBaseUrl() {
|
|
1693
|
+
return readNamespace(this.ctx, "llm-deepseek")?.baseURL;
|
|
1694
|
+
}
|
|
1695
|
+
/** The effective base URL for a provider route (pi-ai or deepseek). */
|
|
1696
|
+
routeBaseUrl(provider) {
|
|
1697
|
+
return this.piAiProfile(provider)?.baseURL ?? (isDeepSeekProviderRoute(provider) ? this.deepseekBaseUrl() : void 0);
|
|
1698
|
+
}
|
|
1563
1699
|
};
|
|
1564
1700
|
//#endregion
|
|
1565
1701
|
//#region src/host/loopback.ts
|
|
@@ -1711,16 +1847,48 @@ const inject = ["webServer"];
|
|
|
1711
1847
|
* family settings bridge (dsh-web-settings) resolves onto this row's entry id.
|
|
1712
1848
|
*/
|
|
1713
1849
|
const USAGE_SETTINGS_NAMESPACE = "dsh-usage";
|
|
1850
|
+
/**
|
|
1851
|
+
* Plugin config schema. Under the 0.1.7 settings model this schema IS the
|
|
1852
|
+
* entry's settings page: the Host derives one form per profile entry from it
|
|
1853
|
+
* and serves it only when at least one field is `volatile()`. The marker is
|
|
1854
|
+
* also what admits a write and what keeps the edit on the live path — the
|
|
1855
|
+
* Loader commits the new value into the field's reference and announces
|
|
1856
|
+
* `loader/volatile-update` on this fiber instead of remounting the row, so the
|
|
1857
|
+
* probe cycle, its ledger and its route registrations survive a settings save.
|
|
1858
|
+
*
|
|
1859
|
+
* The schema is left to inference rather than annotated with
|
|
1860
|
+
* `z<UsageConfigFields>`: a volatile field's parsed output is a reference while
|
|
1861
|
+
* its accepted input stays the plain value, so the two sides no longer share
|
|
1862
|
+
* one shape and the annotation would reject the schema the Host must be given.
|
|
1863
|
+
*/
|
|
1714
1864
|
const Config = z.object({
|
|
1715
|
-
enabled: z.boolean().default(true),
|
|
1716
|
-
pollIntervalSec: z.number().min(30).max(3600).default(60),
|
|
1717
|
-
retainDays: z.number().min(7).max(730).default(180)
|
|
1865
|
+
enabled: z.boolean().default(true).volatile(),
|
|
1866
|
+
pollIntervalSec: z.number().min(30).max(3600).default(60).volatile(),
|
|
1867
|
+
retainDays: z.number().min(7).max(730).default(180).volatile()
|
|
1718
1868
|
});
|
|
1869
|
+
/** Read one config field, following the live reference the schema produces. */
|
|
1870
|
+
function readConfigField(field) {
|
|
1871
|
+
if (field === void 0) return void 0;
|
|
1872
|
+
if (typeof field === "object" && field !== null && typeof field.get === "function") return field.get();
|
|
1873
|
+
return field;
|
|
1874
|
+
}
|
|
1875
|
+
/** Read one numeric field, falling back when the value is absent or not a usable number. */
|
|
1876
|
+
function readNumber(field, fallback) {
|
|
1877
|
+
const value = readConfigField(field);
|
|
1878
|
+
return typeof value === "number" && Number.isFinite(value) ? value : fallback;
|
|
1879
|
+
}
|
|
1880
|
+
/**
|
|
1881
|
+
* Resolve the effective settings of one activation. Volatile references are
|
|
1882
|
+
* read here, so every re-arm observes the latest committed values rather than
|
|
1883
|
+
* whatever the row was activated with.
|
|
1884
|
+
* @param config - the config the Host passed to the activation.
|
|
1885
|
+
* @returns the resolved settings.
|
|
1886
|
+
*/
|
|
1719
1887
|
function resolveConfig(config) {
|
|
1720
1888
|
return {
|
|
1721
|
-
enabled: config?.enabled ?? true,
|
|
1722
|
-
pollIntervalSec:
|
|
1723
|
-
retainDays:
|
|
1889
|
+
enabled: readConfigField(config?.enabled) ?? true,
|
|
1890
|
+
pollIntervalSec: readNumber(config?.pollIntervalSec, 60),
|
|
1891
|
+
retainDays: readNumber(config?.retainDays, 180)
|
|
1724
1892
|
};
|
|
1725
1893
|
}
|
|
1726
1894
|
/**
|
|
@@ -1734,39 +1902,78 @@ let pendingStop;
|
|
|
1734
1902
|
/**
|
|
1735
1903
|
* Host body. The plugin's own Config schema above is the settings document the
|
|
1736
1904
|
* 0.1.7 Host serves for this row; the effective config arrives here as the
|
|
1737
|
-
* `config` argument
|
|
1738
|
-
*
|
|
1905
|
+
* `config` argument, and a settings save re-enters this body through the
|
|
1906
|
+
* volatile path rather than a row reload (see {@link Config}).
|
|
1739
1907
|
* @param ctx - host root context.
|
|
1740
1908
|
* @param config - the row's resolved configuration.
|
|
1741
1909
|
*/
|
|
1742
1910
|
const apply = mountOnce("@linxin666/dsh-usage", (ctx, config) => {
|
|
1743
|
-
const value = resolveConfig(config);
|
|
1744
|
-
if (!value.enabled) return;
|
|
1745
1911
|
let live = true;
|
|
1746
1912
|
let service;
|
|
1747
1913
|
let disposeRoutes;
|
|
1748
|
-
|
|
1749
|
-
|
|
1750
|
-
|
|
1751
|
-
|
|
1752
|
-
next.start();
|
|
1914
|
+
/** True while this activation has a start queued behind a predecessor's flush. */
|
|
1915
|
+
let queued = false;
|
|
1916
|
+
/** Register the service's two routes; the returned disposer unregisters them. */
|
|
1917
|
+
const mountRoutes = (next) => {
|
|
1753
1918
|
const disposers = [makeUsageOverviewRoute(ctx, next), makeUsageRefreshRoute(ctx, next)].map((route) => ctx.webServer.register(route));
|
|
1754
|
-
|
|
1919
|
+
return () => {
|
|
1755
1920
|
for (const dispose of disposers) try {
|
|
1756
1921
|
dispose();
|
|
1757
1922
|
} catch {}
|
|
1758
1923
|
};
|
|
1759
1924
|
};
|
|
1925
|
+
/** Start a fresh activation, unless this fiber is down or one already runs. */
|
|
1926
|
+
const launch = () => {
|
|
1927
|
+
queued = false;
|
|
1928
|
+
if (!live || service !== void 0) return;
|
|
1929
|
+
const value = resolveConfig(config);
|
|
1930
|
+
if (!value.enabled) return;
|
|
1931
|
+
const next = new UsageService(ctx, value);
|
|
1932
|
+
service = next;
|
|
1933
|
+
next.start();
|
|
1934
|
+
disposeRoutes = mountRoutes(next);
|
|
1935
|
+
};
|
|
1936
|
+
/** Tear the running activation down, chaining its final flush for any successor. */
|
|
1937
|
+
const stop = () => {
|
|
1938
|
+
disposeRoutes?.();
|
|
1939
|
+
disposeRoutes = void 0;
|
|
1940
|
+
const stopping = service?.stop();
|
|
1941
|
+
service = void 0;
|
|
1942
|
+
if (stopping !== void 0) pendingStop = (pendingStop ?? Promise.resolve()).then(() => stopping);
|
|
1943
|
+
};
|
|
1944
|
+
/**
|
|
1945
|
+
* Apply the activation's current settings: a disabled row mounts nothing (no
|
|
1946
|
+
* probe cycle, no ledger, no routes), an enabled one starts on first use and
|
|
1947
|
+
* otherwise re-applies its options in place.
|
|
1948
|
+
*/
|
|
1949
|
+
const rearm = () => {
|
|
1950
|
+
const value = resolveConfig(config);
|
|
1951
|
+
if (!value.enabled) {
|
|
1952
|
+
queued = false;
|
|
1953
|
+
stop();
|
|
1954
|
+
return;
|
|
1955
|
+
}
|
|
1956
|
+
if (service !== void 0) {
|
|
1957
|
+
service.applyOptions(value);
|
|
1958
|
+
return;
|
|
1959
|
+
}
|
|
1960
|
+
if (queued) return;
|
|
1961
|
+
if (pendingStop === void 0) {
|
|
1962
|
+
launch();
|
|
1963
|
+
return;
|
|
1964
|
+
}
|
|
1965
|
+
queued = true;
|
|
1966
|
+
pendingStop = pendingStop.then(launch, launch).then(() => void 0);
|
|
1967
|
+
};
|
|
1968
|
+
ctx.on("loader/volatile-update", () => {
|
|
1969
|
+
rearm();
|
|
1970
|
+
});
|
|
1760
1971
|
ctx.effect(() => {
|
|
1761
|
-
|
|
1762
|
-
else pendingStop = pendingStop.then(begin, begin).then(() => void 0);
|
|
1972
|
+
rearm();
|
|
1763
1973
|
return () => {
|
|
1764
1974
|
live = false;
|
|
1765
|
-
|
|
1766
|
-
|
|
1767
|
-
const stopping = service?.stop();
|
|
1768
|
-
service = void 0;
|
|
1769
|
-
if (stopping !== void 0) pendingStop = (pendingStop ?? Promise.resolve()).then(() => stopping);
|
|
1975
|
+
queued = false;
|
|
1976
|
+
stop();
|
|
1770
1977
|
};
|
|
1771
1978
|
}, "dsh-usage: runtime");
|
|
1772
1979
|
});
|
|
@@ -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
|
|
7
|
-
*
|
|
8
|
-
*
|
|
9
|
-
*
|
|
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>;
|
|
@@ -153,4 +153,8 @@ export declare class UsageService {
|
|
|
153
153
|
private piAiProfile;
|
|
154
154
|
/** The DeepSeek official adapter's credential reference name. */
|
|
155
155
|
private deepseekApiKeyEnv;
|
|
156
|
+
/** The DeepSeek official adapter's configured baseURL. */
|
|
157
|
+
private deepseekBaseUrl;
|
|
158
|
+
/** The effective base URL for a provider route (pi-ai or deepseek). */
|
|
159
|
+
private routeBaseUrl;
|
|
156
160
|
}
|
package/lib/types/index.d.ts
CHANGED
|
@@ -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
|
-
|
|
15
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
30
|
-
*
|
|
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?:
|
|
84
|
+
export declare const apply: (ctx: Context, config?: UsageConfigFields) => void;
|
|
85
|
+
export {};
|
|
@@ -4,19 +4,50 @@
|
|
|
4
4
|
* the loader accepts a standalone install of the same package side by side;
|
|
5
5
|
* without this guard the second instance would still re-register the same
|
|
6
6
|
* webserver routes, tools, settings namespaces, and system-prompt sections
|
|
7
|
-
* and fail the boot. mountOnce makes
|
|
8
|
-
*
|
|
9
|
-
* package name in the client module host).
|
|
7
|
+
* and fail the boot. mountOnce makes a mount of an already-mounted package a
|
|
8
|
+
* no-op for as long as the first instance lives (the browser half is already
|
|
9
|
+
* deduped by package name in the client module host).
|
|
10
|
+
*
|
|
11
|
+
* A no-op is only safe while the holder is ALIVE. The holder can be disposed
|
|
12
|
+
* long after the refused mount ran its course: the Host reloads a profile by
|
|
13
|
+
* creating the new loader entries before the old ones are torn down (a
|
|
14
|
+
* plugin-manager enable/disable/install write, a settings-driven row reload,
|
|
15
|
+
* HMR), so the new aggregate shell entry mounts its family plugin while the
|
|
16
|
+
* previous entry still owns the name. Dropping that refused mount lost the
|
|
17
|
+
* plugin for good - the previous entry then disposed its own mount, releasing
|
|
18
|
+
* the name with nobody left to take it, and the family row stayed listed as
|
|
19
|
+
* active while its host routes 404ed (the task-board panel showed
|
|
20
|
+
* "board.hostError.notMounted", no degraded record appeared, and only a Host
|
|
21
|
+
* restart recovered it).
|
|
22
|
+
*
|
|
23
|
+
* The refused mount is therefore QUEUED, not dropped, and replayed the moment
|
|
24
|
+
* the holder releases the name - if the waiting fiber is still alive then. The
|
|
25
|
+
* single-instance guarantee is unchanged: exactly one mount is live per
|
|
26
|
+
* package name, and the replay re-enters the guard so a later mount still
|
|
27
|
+
* dedupes against it.
|
|
10
28
|
*
|
|
11
29
|
* The registry rides a global symbol so two module instances of the same
|
|
12
|
-
* package (npm copy vs repository link) still share one verdict.
|
|
13
|
-
*
|
|
30
|
+
* package (npm copy vs repository link) still share one verdict. That symbol
|
|
31
|
+
* is a CROSS-REPOSITORY contract, not this file's private state: the four
|
|
32
|
+
* satellite packages (dsh-skins / dsh-pet / dsh-presets /
|
|
33
|
+
* dsh-community-plugins) are separate repositories carrying their own copy of
|
|
34
|
+
* this guard, rebuilt on their own schedule, so the value under `MOUNTED`
|
|
35
|
+
* must keep the shape every published copy reads (a `Set` of package names
|
|
36
|
+
* with `has`/`add`/`delete`). The wait queues this guard added therefore live
|
|
37
|
+
* under their own additive key, and the registry reads back a `Set` even when
|
|
38
|
+
* some other build left a different value there. Changing `MOUNTED`'s shape
|
|
39
|
+
* in place broke that contract once: a satellite's legacy copy created a
|
|
40
|
+
* `Set`, the family's new copy read it as a `Map`, and every family row
|
|
41
|
+
* mounted after it failed with "claims.get is not a function".
|
|
42
|
+
*
|
|
43
|
+
* cordis `ctx.effect` runs its callback immediately and treats the callback's
|
|
14
44
|
* return value as the fiber disposer, so the unmarker is returned, not run.
|
|
15
45
|
*/
|
|
16
46
|
/**
|
|
17
47
|
* Wrap a cordis plugin apply so the package runs at most once per process.
|
|
18
|
-
* The first mount registers normally and
|
|
19
|
-
*
|
|
48
|
+
* The first mount registers normally and releases the name when its fiber
|
|
49
|
+
* disposes; a mount refused while that name is held waits for the release and
|
|
50
|
+
* then runs, unless its own fiber disposes first.
|
|
20
51
|
* @param packageName - npm package identity shared by every install source.
|
|
21
52
|
* @param fn - the original plugin apply.
|
|
22
53
|
* @returns an apply of the same shape.
|