dsh-multi-provider-usage 0.0.0-stage → 0.7.1

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 ADDED
@@ -0,0 +1,1278 @@
1
+ // @ts-check
2
+ /**
3
+ * dsh-multi-provider-usage — host half.
4
+ *
5
+ * One DSH plugin that watches the quotas of three coding-plan providers:
6
+ *
7
+ * - OpenCode GO — official `/v1/usage` endpoint, or the live
8
+ * `dsh-opencode-go` service when it is available;
9
+ * - Command Code — `/alpha/billing/*` and `/alpha/usage/summary`;
10
+ * - GLM Code Plan — Z.ai / BigModel `/api/monitor/usage/quota/limit`.
11
+ *
12
+ * The host exposes same-origin routes so the browser widget never sees an API
13
+ * key and no CORS is involved:
14
+ *
15
+ * - `GET /provider-usage/usage` — all enabled providers, canonical shape.
16
+ * - `GET /provider-usage/settings` — redacted settings + non-network detection.
17
+ * - `POST /provider-usage/settings` — whitelisted settings updates.
18
+ *
19
+ * It also registers the `/provider-usage` chat command, the sidebar widget
20
+ * (through the official `sidebar.footer.action` slot) and a bundle settings
21
+ * page (`plugins.bundle.config`), mirroring dsh-opencode-go-usage.
22
+ *
23
+ * Installation / configuration (official DSH flows):
24
+ * - Install: `dsh plugin --profile web add dsh-multi-provider-usage` — the
25
+ * `dsh.bundle.patch` declaration makes the CLI reconcile this package into
26
+ * the profile's bundle layer stack automatically (no manual patch edits).
27
+ * - Configure: the loader picks the exported `Config` schema up by itself.
28
+ * Every field is `.volatile()`, so Settings → Plugins → Plugin
29
+ * configuration exposes it and edits hot-apply. The API keys themselves
30
+ * live in the credentials domain (credential refs), never in settings.
31
+ *
32
+ * Credential discovery is deliberately forgiving:
33
+ * - OpenCode GO: configured ref, then the dsh-opencode-go active account ref,
34
+ * then OPENCODE_GO_API_KEY / OPENCODE_API_KEY.
35
+ * - Command Code: configured ref, then COMMANDCODE_API_KEY, then
36
+ * `~/.commandcode/auth.json` written by the Command Code CLI.
37
+ * - GLM: configured ref, then ZAI_CODING_CN_API_KEY / ZAI_API_KEY /
38
+ * ZHIPU_API_KEY / GLM_API_KEY / BIGMODEL_API_KEY.
39
+ */
40
+
41
+ import { readFileSync } from "node:fs";
42
+ import { createRequire } from "node:module";
43
+ import { fileURLToPath } from "node:url";
44
+ import z from "@deepseek-ai/schemastery";
45
+ import {
46
+ GLM_BIGMODEL_BASE_URL,
47
+ GLM_ZAI_BASE_URL,
48
+ GLM_KNOWN_REFS,
49
+ PROVIDER_IDS,
50
+ PROVIDERS,
51
+ DEFAULT_COMMAND_CODE_BASE_URL,
52
+ DEFAULT_COMMAND_CODE_API_KEY_ENV,
53
+ DEFAULT_GLM_API_KEY_ENV,
54
+ DEFAULT_OPENCODE_API_KEY_ENV,
55
+ DEFAULT_OPENCODE_BASE_URL,
56
+ ProviderError,
57
+ providerInfo,
58
+ readCommandCode,
59
+ readCommandCodeAuthFile,
60
+ readGlm,
61
+ readOpencodeGo
62
+ } from "./providers.js";
63
+ import { isCredentialRefName } from "./util.js";
64
+ import { patchFetch, recordStream, sessionDiag, sessionHeaderCount, withSession } from "./opencode-session.js";
65
+ import { startAutoUpdate, isNewerVersion } from "./auto-update.js";
66
+ import {
67
+ DEFAULT_OPENCODE_CONSOLE_API_KEY_ENV,
68
+ DEFAULT_OPENCODE_CONSOLE_BASE_URL,
69
+ opencodeConsoleStatus
70
+ } from "./opencode-console.js";
71
+
72
+ export const name = "dsh-multi-provider-usage";
73
+
74
+ /** Required services: web routes, chat commands, credentials. */
75
+ export const inject = ["webServer", "commands", "credentials"];
76
+
77
+ /** Settings namespace owned by this plugin. */
78
+ export const namespace = "dsh-multi-provider-usage";
79
+
80
+ const DEFAULT_CACHE_MS = 30_000;
81
+ /** npm package name this plugin is published under (update checks). */
82
+ const NPM_PACKAGE = "dsh-multi-provider-usage";
83
+ /** How often the update check may hit the npm registry (ms). */
84
+ const UPDATE_CHECK_INTERVAL_MS = 24 * 3600_000;
85
+ /** Same-origin route the sidebar widget polls. */
86
+ const USAGE_ROUTE_PATH = "/provider-usage/usage";
87
+ /** Same-origin route the Plugins-panel settings page uses. */
88
+ const SETTINGS_ROUTE_PATH = "/provider-usage/settings";
89
+ /** Chat command name this plugin owns. */
90
+ const COMMAND_NAME = "provider-usage";
91
+ /** Profile entry ids / package names probed for the dsh-opencode-go config. */
92
+ const OPENCODE_GO_ENTRY_SOURCES = ["opencode-go", "dsh-opencode-go"];
93
+ /** npm package whose live usage service is read in `opencode-go` mode. */
94
+ const OPENCODE_GO_PACKAGE = "dsh-opencode-go";
95
+ /** Cordis service dsh-opencode-go registers for host-side usage reads. */
96
+ const OPENCODE_GO_USAGE_SERVICE = "opencodeGoUsage";
97
+ /** Fallback account reference when dsh-opencode-go omits `apiKeyEnv`. */
98
+ const OPENCODE_GO_DEFAULT_ACCOUNT_REF = "OPENCODE_API_KEY";
99
+ /** Entry ids / package names probed when reading another plugin's live config. */
100
+ const LOCALE_ENTRY_SOURCES = ["locale", "@deepseek-ai/dsh-client-locale"];
101
+ const PI_AI_ENTRY_SOURCES = ["llm-pi-ai", "@deepseek-ai/dsh-llm-pi-ai"];
102
+
103
+ /** Allowed values for the GLM region field. */
104
+ export const GLM_REGIONS = ["auto", "zai", "bigmodel", "custom"];
105
+ /** Allowed OpenCode GO quota sources. */
106
+ export const OPENCODE_GO_SOURCES = ["opencode-go", "builtin"];
107
+
108
+ /** Resolves packages relative to this file (the profile's node_modules). */
109
+ const pluginRequire = createRequire(import.meta.url);
110
+
111
+ /** The version this running copy was installed as (read once at load). */
112
+ const CURRENT_VERSION = readPackageVersion();
113
+
114
+ /** Read `version` from the installed package.json next to this file. */
115
+ function readPackageVersion() {
116
+ try {
117
+ const pkg = JSON.parse(readFileSync(new URL("../package.json", import.meta.url), "utf8"));
118
+ if (typeof pkg?.version === "string") return pkg.version;
119
+ } catch {
120
+ // fall through
121
+ }
122
+ return "0.0.0";
123
+ }
124
+
125
+ /**
126
+ * Mark one field live-editable when the resolved schemastery supports it.
127
+ * `.volatile()` only exists in schemastery >= 3.18.4; an older hoisted copy
128
+ * must not make activation throw, it only loses the settings page/hot-edit.
129
+ * @template T
130
+ * @param {T} field
131
+ * @returns {T}
132
+ */
133
+ function live(field) {
134
+ return typeof field?.volatile === "function" ? field.volatile() : field;
135
+ }
136
+
137
+ /**
138
+ * Settings schema for this plugin's namespace.
139
+ *
140
+ * The loader consumes `Config` directly (DSH 2.x removed
141
+ * `ctx.settings.register`). All fields are volatile so the auto-generated
142
+ * Settings page can edit them live. The API keys themselves are credential
143
+ * refs (`role("credential-ref")`), never values.
144
+ */
145
+ export const Config = z.object({
146
+ opencodeGoEnabled: live(z.boolean().default(true)),
147
+ opencodeGoSource: live(z.union([
148
+ z.const("opencode-go"),
149
+ z.const("builtin")
150
+ ]).default("opencode-go").description(
151
+ "OpenCode GO quota source: \"opencode-go\" follows the dsh-opencode-go active account and falls back to the built-in endpoint; \"builtin\" always uses apiKeyEnv/baseUrl."
152
+ )),
153
+ opencodeGoApiKeyEnv: live(z.string().role("credential-ref").default(DEFAULT_OPENCODE_API_KEY_ENV)),
154
+ opencodeGoBaseUrl: live(z.string().default(DEFAULT_OPENCODE_BASE_URL)),
155
+ opencodeConsoleEnabled: live(z.boolean().default(true)),
156
+ opencodeConsoleApiKeyEnv: live(z.string().role("credential-ref").default(DEFAULT_OPENCODE_CONSOLE_API_KEY_ENV)),
157
+ opencodeConsoleOrgId: live(z.string().default("")),
158
+ opencodeConsoleAuthFile: live(z.string().default("")).description(
159
+ "Optional OpenCode CLI auth.json path; empty auto-discovers auth.json and opencode.db."
160
+ ),
161
+ opencodeConsoleDbFile: live(z.string().default("")).description(
162
+ "Optional OpenCode opencode.db path; empty derives it from auth.json or the default data dir."
163
+ ),
164
+ opencodeConsoleBaseUrl: live(z.string().default(DEFAULT_OPENCODE_CONSOLE_BASE_URL)),
165
+
166
+ commandCodeEnabled: live(z.boolean().default(true)),
167
+ commandCodeApiKeyEnv: live(z.string().role("credential-ref").default(DEFAULT_COMMAND_CODE_API_KEY_ENV)),
168
+ commandCodeBaseUrl: live(z.string().default(DEFAULT_COMMAND_CODE_BASE_URL)),
169
+ commandCodeAuthFile: live(z.string().default("")).description(
170
+ "Optional Command Code CLI auth file; empty means ~/.commandcode/auth.json."
171
+ ),
172
+
173
+ glmEnabled: live(z.boolean().default(true)),
174
+ glmApiKeyEnv: live(z.string().role("credential-ref").default(DEFAULT_GLM_API_KEY_ENV)),
175
+ glmBaseUrl: live(z.string().default("")).description(
176
+ "Optional GLM API base override (for example https://api.z.ai); empty uses the region setting."
177
+ ),
178
+ glmRegion: live(z.union([
179
+ z.const("auto"),
180
+ z.const("zai"),
181
+ z.const("bigmodel"),
182
+ z.const("custom")
183
+ ]).default("auto").description(
184
+ "GLM region: auto probes api.z.ai then open.bigmodel.cn; zai/bigmodel pin one host; custom uses glmBaseUrl."
185
+ )),
186
+
187
+ cacheMs: live(z.number().default(DEFAULT_CACHE_MS)),
188
+ updateCheck: live(z.boolean().default(true)),
189
+ autoUpdate: live(z.boolean().default(true)).description(
190
+ "Check the GitHub release feed and fast-forward a clean local checkout; a DSH restart applies the update. Market-installed copies report an update instead of modifying themselves."
191
+ ),
192
+ autoUpdateCheckHours: live(z.number().default(6)).description(
193
+ "Hours between automatic update checks (1-168)."
194
+ ),
195
+ injectSessionHeader: live(z.boolean().default(true)),
196
+ providerOrder: live(z.array(z.string()).default(["opencode-go", "command-code", "glm"]).description(
197
+ "Provider display order in the sidebar widget; drag the cards in the plugin settings to change it."
198
+ ))
199
+ });
200
+
201
+ /** Unwrap a cosmokit volatile reference. */
202
+ function unwrap(value) {
203
+ return value !== null && typeof value === "object" && typeof value.get === "function" ? value.get() : value;
204
+ }
205
+
206
+ /** A live string field, or its fallback when absent/empty. */
207
+ function stringOr(value, fallback) {
208
+ const text = unwrap(value);
209
+ return typeof text === "string" && text.trim().length > 0 ? text.trim() : fallback;
210
+ }
211
+
212
+ /** A live boolean field (false only when explicitly false). */
213
+ function boolOr(value, fallback) {
214
+ const flag = unwrap(value);
215
+ return typeof flag === "boolean" ? flag : fallback;
216
+ }
217
+
218
+ /** A live finite number field. */
219
+ function numberOr(value, fallback) {
220
+ const number = unwrap(value);
221
+ return typeof number === "number" && Number.isFinite(number) ? number : fallback;
222
+ }
223
+
224
+ /**
225
+ * Read another profile entry's resolved config from the loader. `sources` is
226
+ * probed as (entry id, then package name); the first active match wins.
227
+ * Returns `undefined` when no match is live yet — callers fall back to their
228
+ * own defaults, so load order never blocks activation.
229
+ */
230
+ function liveEntryConfig(ctx, sources) {
231
+ const loader = ctx.loader ?? ctx.root?.loader;
232
+ if (!loader || typeof loader.entries !== "function") return undefined;
233
+ for (const probe of sources) {
234
+ for (const entry of loader.entries()) {
235
+ if (!entry?.fiber || entry.fiber.uid == null || entry.disabled) continue;
236
+ if (entry.options?.id === probe || entry.options?.name === probe) return entry.fiber.config;
237
+ let qualified;
238
+ try {
239
+ qualified = entry.id;
240
+ } catch {
241
+ qualified = undefined;
242
+ }
243
+ if (qualified === probe) return entry.fiber.config;
244
+ }
245
+ }
246
+ return undefined;
247
+ }
248
+
249
+ /** Normalize a user-supplied OpenCode GO source. */
250
+ export function normalizeOpencodeGoSource(value) {
251
+ return value === "builtin" ? "builtin" : "opencode-go";
252
+ }
253
+
254
+ /** Normalize the GLM region choice. */
255
+ export function normalizeGlmRegion(value) {
256
+ return GLM_REGIONS.includes(value) ? value : "auto";
257
+ }
258
+
259
+ /**
260
+ * Normalize the provider display order: keep the first occurrence of every
261
+ * known id, drop unknown or repeated ones, then append anything still missing
262
+ * in canonical order — the result is a full permutation of PROVIDER_IDS.
263
+ */
264
+ export function normalizeProviderOrder(value) {
265
+ const order = [];
266
+ if (Array.isArray(value)) {
267
+ for (const item of value) {
268
+ if (PROVIDER_IDS.includes(item) && !order.includes(item)) order.push(item);
269
+ }
270
+ }
271
+ for (const id of PROVIDER_IDS) {
272
+ if (!order.includes(id)) order.push(id);
273
+ }
274
+ return order;
275
+ }
276
+
277
+ /** Read the installed dsh-opencode-go version, or null when it is not installed. */
278
+ function opencodeGoVersion() {
279
+ try {
280
+ const path = pluginRequire.resolve(`${OPENCODE_GO_PACKAGE}/package.json`);
281
+ const pkg = JSON.parse(readFileSync(path, "utf8"));
282
+ return typeof pkg?.version === "string" ? pkg.version : null;
283
+ } catch {
284
+ return null;
285
+ }
286
+ }
287
+
288
+ /**
289
+ * Find one loader entry by id or package name, including a disabled entry.
290
+ * The difference between "not installed" and "installed but disabled" is what
291
+ * the settings page and the fallback badge report.
292
+ */
293
+ function findLoaderEntry(ctx, sources) {
294
+ try {
295
+ const loader = ctx.loader ?? ctx.root?.loader;
296
+ if (!loader || typeof loader.entries !== "function") return null;
297
+ for (const entry of loader.entries()) {
298
+ if (entry === null || typeof entry !== "object") continue;
299
+ if (sources.includes(entry.options?.id) || sources.includes(entry.options?.name)) return entry;
300
+ let qualified;
301
+ try {
302
+ qualified = entry.id;
303
+ } catch {
304
+ qualified = undefined;
305
+ }
306
+ if (sources.includes(qualified)) return entry;
307
+ }
308
+ } catch {
309
+ // an unavailable loader reads as "not installed"
310
+ }
311
+ return null;
312
+ }
313
+
314
+ /**
315
+ * Read the active-account metadata of the dsh-opencode-go profile entry.
316
+ * dsh-opencode-go selects the active account by `apiKeyEnv` and keeps the
317
+ * accounts list re-ordered so that entry is first; reading the same fields
318
+ * reproduces its choice without touching its internals.
319
+ */
320
+ function opencodeGoEntryInfo(ctx) {
321
+ const entry = findLoaderEntry(ctx, OPENCODE_GO_ENTRY_SOURCES);
322
+ if (entry === null) {
323
+ return { found: false, enabled: null, baseURL: null, ref: null, account: null, accounts: [] };
324
+ }
325
+ const inactive = entry.disabled === true || entry.fiber === null || entry.fiber === undefined || entry.fiber.uid == null;
326
+ /** @type {Record<string, unknown>} */
327
+ const cfg = inactive ? {} : (entry.fiber.config ?? {});
328
+ let enabled = inactive ? false : true;
329
+ if (!inactive) {
330
+ try {
331
+ enabled = unwrap(cfg.enabled) !== false;
332
+ } catch {
333
+ // an unreadable live ref keeps the documented default (enabled)
334
+ }
335
+ }
336
+ let ref = OPENCODE_GO_DEFAULT_ACCOUNT_REF;
337
+ try {
338
+ const value = unwrap(cfg.apiKeyEnv);
339
+ if (typeof value === "string" && value.length > 0) ref = value;
340
+ } catch {
341
+ // keep the default ref
342
+ }
343
+ /** @type {Array<Record<string, unknown>>} */
344
+ let accounts = [];
345
+ try {
346
+ const value = unwrap(cfg.accounts);
347
+ if (Array.isArray(value)) accounts = value.filter((account) => account !== null && typeof account === "object");
348
+ } catch {
349
+ // no account list → a legacy single-key profile
350
+ }
351
+ const hit = accounts.find((account) => account.apiKeyEnv === ref) ?? null;
352
+ let baseURL = null;
353
+ try {
354
+ const value = unwrap(cfg.baseURL);
355
+ if (typeof value === "string" && value.length > 0) baseURL = value;
356
+ } catch {
357
+ // callers fall back to the plugin's own baseUrl
358
+ }
359
+ return {
360
+ found: true,
361
+ enabled,
362
+ baseURL,
363
+ ref,
364
+ account: {
365
+ ref,
366
+ id: typeof hit?.id === "string" ? hit.id : null,
367
+ name: typeof hit?.name === "string" ? hit.name : ""
368
+ },
369
+ accounts: accounts.map((account) => ({
370
+ ref: typeof account.apiKeyEnv === "string" ? account.apiKeyEnv : "",
371
+ name: typeof account.name === "string" ? account.name : ""
372
+ }))
373
+ };
374
+ }
375
+
376
+ /**
377
+ * The dsh-opencode-go host usage service, or null when that plugin is absent
378
+ * or too old to register it. `ctx.get` is intentionally used instead of an
379
+ * `inject` dependency: a missing service must not block this plugin.
380
+ */
381
+ function opencodeGoService(ctx) {
382
+ try {
383
+ if (typeof ctx.get !== "function") return null;
384
+ const service = ctx.get(OPENCODE_GO_USAGE_SERVICE);
385
+ return service !== null && typeof service?.read === "function" ? service : null;
386
+ } catch {
387
+ return null;
388
+ }
389
+ }
390
+
391
+ /** Public (JSON-safe) view of an OpenCode GO probe result. */
392
+ function publicOpencodeGoProbe(probe) {
393
+ return {
394
+ detected: probe.detected === true,
395
+ version: probe.version ?? null,
396
+ entryFound: probe.entryFound === true,
397
+ enabled: probe.enabled ?? null,
398
+ service: probe.service === true,
399
+ compatible: probe.compatible === true,
400
+ reason: probe.reason ?? null,
401
+ message: probe.message ?? null
402
+ };
403
+ }
404
+
405
+ /** Base detection metadata shared by every OpenCode GO probe. */
406
+ function opencodeGoProbe(ctx) {
407
+ const info = opencodeGoEntryInfo(ctx);
408
+ const version = opencodeGoVersion();
409
+ const service = opencodeGoService(ctx);
410
+ return {
411
+ info,
412
+ service,
413
+ probe: {
414
+ detected: info.found || version !== null,
415
+ version,
416
+ entryFound: info.found,
417
+ enabled: info.enabled,
418
+ service: service !== null,
419
+ compatible: false,
420
+ reason: null,
421
+ message: null
422
+ }
423
+ };
424
+ }
425
+
426
+ /**
427
+ * Resolve the first credential ref that has a value, without ever returning
428
+ * the value to the caller's caller. `resolveCredential` is also used by the
429
+ * adapters.
430
+ * @param {import('@deepseek-ai/cordis').Context} ctx
431
+ */
432
+ function makeCredentialResolver(ctx) {
433
+ return async (refs) => {
434
+ for (const ref of refs) {
435
+ if (!isCredentialRefName(ref)) continue;
436
+ try {
437
+ const hit = await ctx.credentials.resolve(ref);
438
+ if (hit?.value !== null && hit?.value !== undefined && String(hit.value).length > 0) {
439
+ return { ref, value: String(hit.value) };
440
+ }
441
+ } catch {
442
+ // an unresolvable ref is just absent; try the next one
443
+ }
444
+ }
445
+ return null;
446
+ };
447
+ }
448
+
449
+ /** Build the normalized, defaulted config read on every request. */
450
+ function makeConfigReader(ctx, config) {
451
+ return () => {
452
+ const own = unwrap(config) ?? {};
453
+ const localeEntry = liveEntryConfig(ctx, LOCALE_ENTRY_SOURCES) ?? {};
454
+ const preference = unwrap(localeEntry.preference);
455
+ return {
456
+ opencodeGoEnabled: boolOr(own.opencodeGoEnabled, true),
457
+ opencodeGoSource: normalizeOpencodeGoSource(unwrap(own.opencodeGoSource)),
458
+ opencodeGoApiKeyEnv: stringOr(own.opencodeGoApiKeyEnv, DEFAULT_OPENCODE_API_KEY_ENV),
459
+ opencodeGoBaseUrl: stringOr(own.opencodeGoBaseUrl, DEFAULT_OPENCODE_BASE_URL),
460
+ opencodeConsoleEnabled: boolOr(own.opencodeConsoleEnabled, true),
461
+ opencodeConsoleApiKeyEnv: stringOr(own.opencodeConsoleApiKeyEnv, DEFAULT_OPENCODE_CONSOLE_API_KEY_ENV),
462
+ opencodeConsoleOrgId: stringOr(own.opencodeConsoleOrgId, ""),
463
+ opencodeConsoleAuthFile: stringOr(own.opencodeConsoleAuthFile, ""),
464
+ opencodeConsoleDbFile: stringOr(own.opencodeConsoleDbFile, ""),
465
+ opencodeConsoleBaseUrl: stringOr(own.opencodeConsoleBaseUrl, DEFAULT_OPENCODE_CONSOLE_BASE_URL),
466
+ commandCodeEnabled: boolOr(own.commandCodeEnabled, true),
467
+ commandCodeApiKeyEnv: stringOr(own.commandCodeApiKeyEnv, DEFAULT_COMMAND_CODE_API_KEY_ENV),
468
+ commandCodeBaseUrl: stringOr(own.commandCodeBaseUrl, DEFAULT_COMMAND_CODE_BASE_URL),
469
+ commandCodeAuthFile: stringOr(own.commandCodeAuthFile, ""),
470
+ glmEnabled: boolOr(own.glmEnabled, true),
471
+ glmApiKeyEnv: stringOr(own.glmApiKeyEnv, DEFAULT_GLM_API_KEY_ENV),
472
+ glmBaseUrl: stringOr(own.glmBaseUrl, ""),
473
+ glmRegion: normalizeGlmRegion(unwrap(own.glmRegion)),
474
+ cacheMs: Math.max(0, numberOr(own.cacheMs, DEFAULT_CACHE_MS)),
475
+ updateCheck: boolOr(own.updateCheck, true),
476
+ autoUpdate: boolOr(own.autoUpdate, true),
477
+ autoUpdateCheckHours: Math.min(168, Math.max(1, numberOr(own.autoUpdateCheckHours, 6))),
478
+ injectSessionHeader: boolOr(own.injectSessionHeader, true),
479
+ providerOrder: normalizeProviderOrder(unwrap(own.providerOrder)),
480
+ // DSH's own locale preference (live entry config); absent falls
481
+ // back to zh (the browser decides when the user never picked).
482
+ locale: typeof preference === "string" ? preference : "zh"
483
+ };
484
+ };
485
+ }
486
+
487
+ /** Whether a provider is enabled in a normalized config. */
488
+ function providerEnabled(id, config) {
489
+ if (id === "opencode-go") return config.opencodeGoEnabled;
490
+ if (id === "command-code") return config.commandCodeEnabled;
491
+ if (id === "glm") return config.glmEnabled;
492
+ return false;
493
+ }
494
+
495
+ /** Credential refs probed for a provider, in priority order. */
496
+ function credentialRefsFor(id, config, entry) {
497
+ if (id === "opencode-go") {
498
+ return [
499
+ config.opencodeGoApiKeyEnv,
500
+ entry?.enabled === true ? entry.ref : null,
501
+ DEFAULT_OPENCODE_API_KEY_ENV,
502
+ "OPENCODE_API_KEY"
503
+ ].filter(isCredentialRefName);
504
+ }
505
+ if (id === "command-code") {
506
+ return [config.commandCodeApiKeyEnv, DEFAULT_COMMAND_CODE_API_KEY_ENV].filter(isCredentialRefName);
507
+ }
508
+ return [config.glmApiKeyEnv, ...GLM_KNOWN_REFS].filter(isCredentialRefName);
509
+ }
510
+
511
+ /** Cache identity for one provider. */
512
+ function cacheKeyFor(id, config, entry) {
513
+ if (id === "opencode-go") {
514
+ return [
515
+ id,
516
+ config.opencodeGoSource,
517
+ config.opencodeGoApiKeyEnv,
518
+ config.opencodeGoBaseUrl,
519
+ entry?.ref ?? "",
520
+ entry?.enabled ?? "",
521
+ config.opencodeConsoleEnabled ? "console" : "no-console",
522
+ config.opencodeConsoleApiKeyEnv,
523
+ config.opencodeConsoleOrgId,
524
+ config.opencodeConsoleAuthFile,
525
+ config.opencodeConsoleDbFile,
526
+ config.opencodeConsoleBaseUrl
527
+ ].join("|");
528
+ }
529
+ if (id === "command-code") {
530
+ return [id, config.commandCodeApiKeyEnv, config.commandCodeBaseUrl, config.commandCodeAuthFile].join("|");
531
+ }
532
+ return [id, config.glmApiKeyEnv, config.glmBaseUrl, config.glmRegion].join("|");
533
+ }
534
+
535
+ /**
536
+ * Error view of one provider. The widget renders this instead of throwing, so
537
+ * one broken provider never hides the other two.
538
+ */
539
+ function errorResult(id, error) {
540
+ const info = providerInfo(id);
541
+ return {
542
+ ...info,
543
+ status: "error",
544
+ error: error instanceof Error ? error.message : String(error),
545
+ code: error instanceof ProviderError ? error.code : "read-failed",
546
+ account: error?.account ?? null,
547
+ plan: error?.plan ?? null,
548
+ source: null,
549
+ requested: null,
550
+ fallback: error?.fallback ?? null,
551
+ balance: error?.balance ?? null,
552
+ windows: [],
553
+ meta: error?.meta ?? null
554
+ };
555
+ }
556
+
557
+ /**
558
+ * Read one provider through its adapter.
559
+ * @param {string} id
560
+ * @param {ReturnType<typeof makeConfigReader>} readConfig
561
+ * @param {any} ctx
562
+ * @param {boolean} force
563
+ * @param {Map<string, { at: number, key: string, promise: Promise<any> }>} cache
564
+ */
565
+ async function readProvider(id, readConfig, ctx, force, cache) {
566
+ const config = readConfig();
567
+ const entry = id === "opencode-go" ? opencodeGoEntryInfo(ctx) : null;
568
+ const key = cacheKeyFor(id, config, entry);
569
+ const existing = cache.get(id);
570
+ const now = Date.now();
571
+ if (!force && existing !== undefined && existing.key === key && now - existing.at < config.cacheMs) {
572
+ return existing.promise;
573
+ }
574
+ const resolver = makeCredentialResolver(ctx);
575
+ const promise = (async () => {
576
+ try {
577
+ if (id === "opencode-go") {
578
+ return await readOpencodeGo({
579
+ config: {
580
+ source: config.opencodeGoSource,
581
+ apiKeyEnv: config.opencodeGoApiKeyEnv,
582
+ baseUrl: config.opencodeGoBaseUrl,
583
+ console: {
584
+ enabled: config.opencodeConsoleEnabled,
585
+ apiKeyEnv: config.opencodeConsoleApiKeyEnv,
586
+ orgId: config.opencodeConsoleOrgId,
587
+ authFile: config.opencodeConsoleAuthFile,
588
+ dbFile: config.opencodeConsoleDbFile,
589
+ baseUrl: config.opencodeConsoleBaseUrl
590
+ }
591
+ },
592
+ resolveCredential: resolver,
593
+ service: opencodeGoService(ctx),
594
+ entry
595
+ });
596
+ }
597
+ if (id === "command-code") {
598
+ return await readCommandCode({
599
+ config: {
600
+ apiKeyEnv: config.commandCodeApiKeyEnv,
601
+ baseUrl: config.commandCodeBaseUrl,
602
+ authFile: config.commandCodeAuthFile
603
+ },
604
+ resolveCredential: resolver
605
+ });
606
+ }
607
+ return await readGlm({
608
+ config: {
609
+ apiKeyEnv: config.glmApiKeyEnv,
610
+ baseUrl: config.glmBaseUrl,
611
+ region: config.glmRegion
612
+ },
613
+ resolveCredential: resolver
614
+ });
615
+ } catch (error) {
616
+ return errorResult(id, error);
617
+ }
618
+ })();
619
+ cache.set(id, { at: now, key, promise });
620
+ return promise;
621
+ }
622
+
623
+ /**
624
+ * Collect every enabled provider, in display order. Failures are isolated.
625
+ * @param {import('@deepseek-ai/cordis').Context} ctx
626
+ * @param {ReturnType<typeof makeConfigReader>} readConfig
627
+ * @param {Map<string, { at: number, key: string, promise: Promise<any> }>} cache
628
+ * @param {boolean} force
629
+ */
630
+ async function collectUsage(ctx, readConfig, cache, force = false) {
631
+ const config = readConfig();
632
+ const enabled = PROVIDER_IDS.filter((id) => providerEnabled(id, config));
633
+ if (enabled.length === 0) {
634
+ return { ok: false, providers: [], enabled: [], error: "no providers enabled" };
635
+ }
636
+ const providers = await Promise.all(enabled.map((id) => readProvider(id, readConfig, ctx, force, cache)));
637
+ const errors = providers.filter((provider) => provider.status === "error");
638
+ return {
639
+ ok: errors.length < providers.length,
640
+ providers,
641
+ enabled,
642
+ error: errors.length === providers.length
643
+ ? providers.map((provider) => `${provider.name}: ${provider.error}`).join("; ")
644
+ : undefined
645
+ };
646
+ }
647
+
648
+ /** Human-friendly chat output, localized (zh | en). */
649
+ function renderUsageText(data, lang) {
650
+ const zh = !(typeof lang === "string" && /^en\b/i.test(lang.trim()));
651
+ const lines = [zh ? "服务商用量:" : "Provider usage:"];
652
+ const labels = zh
653
+ ? { rolling: "5 小时窗口", "five-hour": "5 小时窗口", weekly: "周窗口", monthly: "月窗口", mcp: "MCP 工具 (月)", token: "Token 窗口", other: "其他窗口" }
654
+ : { rolling: "5-hour window", "five-hour": "5-hour window", weekly: "Weekly", monthly: "Monthly", mcp: "MCP tools (monthly)", token: "Token window", other: "Other window" };
655
+ const statusLabel = (status) => {
656
+ if (status === "ok" || status === undefined || status === null) return zh ? "正常" : "ok";
657
+ if (status === "exceeded") return zh ? "已超限" : "exceeded";
658
+ return String(status);
659
+ };
660
+ for (const provider of data.providers) {
661
+ if (provider.status === "error") {
662
+ lines.push(zh
663
+ ? ` ${provider.name}: 不可用 — ${provider.error}`
664
+ : ` ${provider.name}: unavailable — ${provider.error}`);
665
+ continue;
666
+ }
667
+ const account = typeof provider.account?.name === "string" && provider.account.name.length > 0
668
+ ? provider.account.name
669
+ : "";
670
+ const plan = typeof provider.plan?.name === "string" && provider.plan.name.length > 0 ? provider.plan.name : "";
671
+ const suffix = [account, plan].filter(Boolean).join(" / ");
672
+ lines.push(` ${provider.name}${suffix === "" ? "" : ` (${suffix})`}`);
673
+ for (const window of provider.windows ?? []) {
674
+ const label = labels[window.key] ?? window.key;
675
+ const percent = typeof window.percent === "number" && Number.isFinite(window.percent)
676
+ ? Math.round(window.percent * 10) / 10
677
+ : "—";
678
+ const resets = window.resetsAt
679
+ ? (zh
680
+ ? `, 重置于 ${new Date(window.resetsAt).toLocaleString("zh-CN")}`
681
+ : `, resets at ${new Date(window.resetsAt).toLocaleString("en-US")}`)
682
+ : "";
683
+ lines.push(` ${label}: ${percent}% (${statusLabel(window.status)})${resets}`);
684
+ }
685
+ if (provider.balance && typeof provider.balance.amount === "number") {
686
+ const money = (value) => provider.balance.currency === "USD" || !provider.balance.currency
687
+ ? `$${value.toFixed(2)}`
688
+ : `${value.toFixed(2)} ${provider.balance.currency}`;
689
+ let amount = money(provider.balance.amount);
690
+ const parts = provider.balance.details;
691
+ if (parts && typeof parts === "object" && typeof parts.purchased === "number") {
692
+ const free = money(typeof parts.free === "number" ? parts.free : 0);
693
+ amount += zh
694
+ ? ` (购买 ${money(parts.purchased)} + 免费 ${free})`
695
+ : ` (purchased ${money(parts.purchased)} + free ${free})`;
696
+ }
697
+ lines.push(zh ? ` 订阅外充值余额: ${amount}` : ` Top-up balance: ${amount}`);
698
+ } else if (provider.meta?.balanceMissing === true) {
699
+ lines.push(zh ? " 订阅外充值余额: 无充值额度" : " Top-up balance: no top-up credits");
700
+ } else if (typeof provider.meta?.balanceError === "string" && provider.meta.balanceError !== "") {
701
+ if (/no console credential/i.test(provider.meta.balanceError)) {
702
+ lines.push(zh
703
+ ? " 订阅外充值余额: 未配置 — 运行 opencode auth login 或设置 OPENCODE_CONSOLE_TOKEN"
704
+ : " Top-up balance: not configured — run opencode auth login or set OPENCODE_CONSOLE_TOKEN");
705
+ } else {
706
+ lines.push(zh
707
+ ? ` 订阅外充值余额: 不可用 — ${provider.meta.balanceError}`
708
+ : ` Top-up balance: unavailable — ${provider.meta.balanceError}`);
709
+ }
710
+ }
711
+ const partialErrors = Array.isArray(provider.meta?.errors)
712
+ ? provider.meta.errors.filter((message) => typeof message === "string" && message !== "")
713
+ : [];
714
+ if (partialErrors.length > 0) {
715
+ lines.push(zh
716
+ ? ` 部分接口失败: ${partialErrors.join("; ")}`
717
+ : ` Some endpoints failed: ${partialErrors.join("; ")}`);
718
+ }
719
+ if (provider.fallback) {
720
+ const reason = provider.fallback.message || provider.fallback.reason || "";
721
+ lines.push(zh
722
+ ? ` 已回退到内置提供商: ${reason}`
723
+ : ` Fell back to the built-in provider: ${reason}`);
724
+ }
725
+ }
726
+ if (data.providers.length === 0) lines.push(zh ? " 未启用任何服务商。" : " No providers are enabled.");
727
+ const auto = data.autoUpdate;
728
+ if (auto?.status === "updated" && auto.restartRequired) {
729
+ lines.push(zh
730
+ ? ` 插件已自动更新到 v${auto.version ?? auto.latestVersion ?? "?"},重启 DSH 后生效。`
731
+ : ` Plugin auto-updated to v${auto.version ?? auto.latestVersion ?? "?"}; restart DSH to apply.`);
732
+ } else if (auto?.status === "available") {
733
+ const detail = typeof auto.message === "string" && auto.message.length > 0 ? auto.message : "";
734
+ lines.push(zh
735
+ ? ` 插件有新版本 v${auto.latestVersion ?? "?"},但自动更新未成功${detail ? `:${detail}` : ""}。请手动更新。`
736
+ : ` Plugin update v${auto.latestVersion ?? "?"} is available, but the automatic update failed${detail ? `: ${detail}` : ""}. Update it manually.`);
737
+ } else if (auto?.status === "error" && auto.message) {
738
+ lines.push(zh ? ` 自动更新检查失败: ${auto.message}` : ` Auto-update check failed: ${auto.message}`);
739
+ }
740
+ return lines.join("\n");
741
+ }
742
+
743
+ /**
744
+ * Migration shim: delete a route row this plugin owns but an older build
745
+ * leaked past its fiber. `webServer.register()` returns a disposer rather than
746
+ * owning the row, so a registration made outside `ctx.effect` can outlive a
747
+ * reload and make the next apply fail with a duplicate-route error.
748
+ */
749
+ function dropStaleRoute(ctx, path) {
750
+ try {
751
+ for (const table of [ctx.webServer?.exact, ctx.webServer?.prefixes]) {
752
+ if (table instanceof Map) table.delete(path);
753
+ }
754
+ } catch {
755
+ // fall through: the duplicate-route error is still actionable
756
+ }
757
+ }
758
+
759
+ /**
760
+ * @param {import('@deepseek-ai/cordis').Context} ctx
761
+ * @param {Record<string, unknown>} config - the resolved plugin config.
762
+ */
763
+ export function apply(ctx, config = {}) {
764
+ const readConfig = makeConfigReader(ctx, config);
765
+ const packageDir = fileURLToPath(new URL("..", import.meta.url));
766
+ const profileDir = typeof process.env.DSH_PROFILE_DIR === "string" && process.env.DSH_PROFILE_DIR.length > 0
767
+ ? process.env.DSH_PROFILE_DIR
768
+ : typeof ctx.profileContext?.dir === "string" ? ctx.profileContext.dir : "";
769
+ const autoUpdate = startAutoUpdate({
770
+ packageDir,
771
+ profileDir,
772
+ packageName: NPM_PACKAGE,
773
+ currentVersion: CURRENT_VERSION,
774
+ getConfig: () => {
775
+ const current = readConfig();
776
+ return { autoUpdate: current.autoUpdate, autoUpdateCheckHours: current.autoUpdateCheckHours };
777
+ },
778
+ logger: ctx.logger
779
+ });
780
+ ctx.effect(() => () => autoUpdate.dispose(), "dsh-multi-provider-usage: auto update");
781
+
782
+ // Host-side cache: one in-flight promise + a TTL per provider, so several
783
+ // open tabs or the command never hammer the vendor APIs. The key includes
784
+ // the resolved source and the active dsh-opencode-go account, so a settings
785
+ // switch or an account auto-switch never serves the previous account.
786
+ /** @type {Map<string, { at: number, key: string, promise: Promise<any> }>} */
787
+ const providerCache = new Map();
788
+
789
+ // ── runtime x-opencode-session injection ───────────────────────────────
790
+ // The GO gateway rejects chat-completion requests without
791
+ // `x-opencode-session`. Wrap globalThis.fetch once and stand on the
792
+ // official `llm/stream` waterfall to learn the per-call harness session id.
793
+ // The listener is always registered and re-reads the config on every call,
794
+ // so toggling `injectSessionHeader` hot-applies instead of needing a restart.
795
+ patchFetch();
796
+ try {
797
+ ctx.effect(() => ctx.on("llm/stream", (options, next) => {
798
+ const config = readConfig();
799
+ if (!config.injectSessionHeader) return next();
800
+ const info = {
801
+ provider: options.provider ?? null,
802
+ hasSessionId: options.sessionId !== void 0,
803
+ base: null,
804
+ baseFrom: /** @type {"settings" | "config" | null} */ (null)
805
+ };
806
+ if (options.sessionId === void 0) {
807
+ recordStream(info);
808
+ return next();
809
+ }
810
+ const providers = unwrap(liveEntryConfig(ctx, PI_AI_ENTRY_SOURCES)?.providers);
811
+ const configured = providers?.[options.provider]?.baseURL;
812
+ const fallback = config.opencodeGoBaseUrl;
813
+ const base = typeof configured === "string" && configured.length > 0 ? configured : fallback;
814
+ info.base = typeof base === "string" ? base : null;
815
+ info.baseFrom = typeof configured === "string" && configured.length > 0 ? "settings" : "config";
816
+ recordStream(info);
817
+ if (typeof base !== "string" || base.length === 0) return next();
818
+ return withSession(next(), { sessionId: String(options.sessionId), base });
819
+ }), "dsh-multi-provider-usage: session header");
820
+ } catch {
821
+ // A composition without the llm/stream waterfall still gets the widget
822
+ // and the quota routes; only this optional chat-header fix is lost.
823
+ }
824
+
825
+ // ── update check (npm) ─────────────────────────────────────────────────
826
+ // Checks the registry at most once per UPDATE_CHECK_INTERVAL_MS; failures
827
+ // are silent. The plugin only reports — upgrading stays a user action.
828
+ /** @type {{ at: number, latest: string | null } | null} */
829
+ let updateState = null;
830
+ const checkForUpdate = async (config) => {
831
+ if (!config.updateCheck) return null;
832
+ const now = Date.now();
833
+ if (updateState !== null && now - updateState.at < UPDATE_CHECK_INTERVAL_MS) return updateState;
834
+ const snapshot = { at: now, latest: null };
835
+ try {
836
+ const res = await fetch(`https://registry.npmjs.org/${NPM_PACKAGE}/latest`, {
837
+ signal: AbortSignal.timeout(10_000)
838
+ });
839
+ if (res.ok) {
840
+ const json = await res.json();
841
+ if (typeof json?.version === "string" && isNewerVersion(json.version, CURRENT_VERSION)) {
842
+ snapshot.latest = json.version;
843
+ }
844
+ }
845
+ } catch {
846
+ // offline / registry hiccup — keep the previous result, refresh later
847
+ snapshot.at = updateState?.at ?? now;
848
+ snapshot.latest = updateState?.latest ?? null;
849
+ }
850
+ updateState = snapshot;
851
+ return snapshot;
852
+ };
853
+ const updateInfoOf = (config) => {
854
+ const st = updateState;
855
+ if (!config.updateCheck || st === null || st.latest === null) {
856
+ return { current: CURRENT_VERSION, available: false, latest: null };
857
+ }
858
+ return { current: CURRENT_VERSION, available: true, latest: st.latest };
859
+ };
860
+
861
+ // Kick off the first check shortly after boot (non-blocking).
862
+ void checkForUpdate(readConfig()).catch(() => {});
863
+
864
+ /** JSON response helper shared by the usage and settings routes. */
865
+ const sendJson = (res, statusCode, body) => {
866
+ res.statusCode = statusCode;
867
+ res.setHeader("content-type", "application/json; charset=utf-8");
868
+ res.setHeader("cache-control", "no-store");
869
+ res.end(JSON.stringify(body));
870
+ };
871
+
872
+ const handleUsage = async (req, res) => {
873
+ try {
874
+ const config = readConfig();
875
+ const url = new URL(req.url ?? USAGE_ROUTE_PATH, "http://127.0.0.1");
876
+ const force = url.searchParams.get("refresh") === "1" || url.searchParams.get("force") === "1";
877
+ void checkForUpdate(config).catch(() => {});
878
+ const data = await collectUsage(ctx, readConfig, providerCache, force);
879
+ const status = data.ok || data.providers.some((provider) => provider.status === "ok") ? 200 : 502;
880
+ sendJson(res, status, {
881
+ ...data,
882
+ providerOrder: config.providerOrder,
883
+ autoUpdate: autoUpdate.getState(),
884
+ update: updateInfoOf(config),
885
+ sessionHeader: { active: config.injectSessionHeader, count: sessionHeaderCount(), diag: sessionDiag() }
886
+ });
887
+ } catch (error) {
888
+ const config = readConfig();
889
+ sendJson(res, 502, {
890
+ ok: false,
891
+ error: error instanceof Error ? error.message : String(error),
892
+ providers: [],
893
+ enabled: [],
894
+ providerOrder: config.providerOrder,
895
+ fallback: error?.details?.fallback ?? null
896
+ });
897
+ }
898
+ };
899
+
900
+ // ── settings page support ──────────────────────────────────────────────
901
+ // DSH 2.x keys a plugin's settings page by its profile entry id and only
902
+ // exposes volatile fields through `settings.describe()`. The custom page
903
+ // persists through this route, so it works even when the auto-generated
904
+ // "Plugin configuration" form is the only other view.
905
+ const settingsServiceNow = () => {
906
+ try {
907
+ return typeof ctx.get === "function" ? ctx.get("settings") : null;
908
+ } catch {
909
+ return null;
910
+ }
911
+ };
912
+ const settingsNamespaceNow = () => {
913
+ try {
914
+ const id = ctx?.fiber?.entry?.options?.id;
915
+ if (typeof id === "string" && id.length > 0) return id;
916
+ } catch {
917
+ // a fiber with no owning entry falls back to the exported namespace
918
+ }
919
+ return namespace;
920
+ };
921
+ const settingsDescriptorNow = (settings, ns) => {
922
+ try {
923
+ return (settings.describe({ redactSecrets: true }) ?? []).find((entry) => entry.ns === ns) ?? null;
924
+ } catch {
925
+ return null;
926
+ }
927
+ };
928
+
929
+ /**
930
+ * Non-network detection snapshot for the settings page and diagnostics.
931
+ * It resolves credentials only to answer "is this provider configured?";
932
+ * values never leave this function.
933
+ */
934
+ const detectProvider = async (id, config) => {
935
+ const resolver = makeCredentialResolver(ctx);
936
+ if (id === "opencode-go") {
937
+ const { info, service, probe } = opencodeGoProbe(ctx);
938
+ const refs = credentialRefsFor(id, config, info);
939
+ const credential = await resolver(refs);
940
+ const integration = config.opencodeGoSource === "opencode-go"
941
+ ? (info.found && info.enabled !== false && service !== null)
942
+ : false;
943
+ const fallback = config.opencodeGoSource === "opencode-go" && !integration
944
+ ? {
945
+ from: "opencode-go",
946
+ reason: !info.found ? "not-installed" : info.enabled === false ? "entry-disabled" : "service-unavailable",
947
+ message: !info.found
948
+ ? "dsh-opencode-go is not installed, or its profile entry is inactive"
949
+ : info.enabled === false
950
+ ? "dsh-opencode-go is installed but disabled in this profile"
951
+ : "dsh-opencode-go does not expose the opencodeGoUsage service (unsupported version)"
952
+ }
953
+ : null;
954
+ let consoleInfo = { enabled: config.opencodeConsoleEnabled, configured: false, source: null, orgId: null, error: null };
955
+ try {
956
+ consoleInfo = await opencodeConsoleStatus({
957
+ config: {
958
+ enabled: config.opencodeConsoleEnabled,
959
+ apiKeyEnv: config.opencodeConsoleApiKeyEnv,
960
+ orgId: config.opencodeConsoleOrgId,
961
+ authFile: config.opencodeConsoleAuthFile,
962
+ dbFile: config.opencodeConsoleDbFile
963
+ },
964
+ resolveCredential: resolver
965
+ });
966
+ } catch (error) {
967
+ consoleInfo = {
968
+ enabled: config.opencodeConsoleEnabled,
969
+ configured: false,
970
+ source: null,
971
+ orgId: null,
972
+ error: error instanceof Error ? error.message : String(error)
973
+ };
974
+ }
975
+ return {
976
+ id,
977
+ name: providerInfo(id).name,
978
+ enabled: config.opencodeGoEnabled,
979
+ configured: integration || credential !== null,
980
+ credentialRef: credential?.ref ?? refs[0] ?? null,
981
+ source: integration ? "opencode-go" : "builtin",
982
+ requested: config.opencodeGoSource,
983
+ account: info.account,
984
+ accounts: info.accounts,
985
+ version: opencodeGoVersion(),
986
+ entryFound: info.found,
987
+ entryEnabled: info.enabled,
988
+ service: service !== null,
989
+ compatible: integration,
990
+ fallback,
991
+ console: consoleInfo,
992
+ opencodeGo: {
993
+ ...publicOpencodeGoProbe(probe),
994
+ service: service !== null,
995
+ compatible: integration,
996
+ account: info.account,
997
+ accounts: info.accounts
998
+ }
999
+ };
1000
+ }
1001
+ if (id === "command-code") {
1002
+ const credential = await resolver(credentialRefsFor(id, config, null));
1003
+ const authFile = readCommandCodeAuthFile(config.commandCodeAuthFile);
1004
+ return {
1005
+ id,
1006
+ name: providerInfo(id).name,
1007
+ enabled: config.commandCodeEnabled,
1008
+ configured: credential !== null || authFile !== null,
1009
+ credentialRef: credential?.ref ?? config.commandCodeApiKeyEnv,
1010
+ source: credential !== null ? "credential" : authFile !== null ? "auth-file" : null,
1011
+ authFile: authFile?.path ?? null,
1012
+ account: authFile?.userName ? { ref: authFile.path, id: null, name: authFile.userName } : null
1013
+ };
1014
+ }
1015
+ const credential = await resolver(credentialRefsFor(id, config, null));
1016
+ return {
1017
+ id,
1018
+ name: providerInfo(id).name,
1019
+ enabled: config.glmEnabled,
1020
+ configured: credential !== null,
1021
+ credentialRef: credential?.ref ?? config.glmApiKeyEnv,
1022
+ source: credential === null ? null : `credential:${credential.ref}`,
1023
+ region: config.glmRegion,
1024
+ baseUrl: config.glmBaseUrl || (config.glmRegion === "zai" ? GLM_ZAI_BASE_URL : config.glmRegion === "bigmodel" ? GLM_BIGMODEL_BASE_URL : null)
1025
+ };
1026
+ };
1027
+
1028
+ const detectionPayload = async () => {
1029
+ const config = readConfig();
1030
+ const entries = await Promise.all(PROVIDER_IDS.map(async (id) => {
1031
+ try {
1032
+ return [id, await detectProvider(id, config)];
1033
+ } catch (error) {
1034
+ return [id, {
1035
+ id,
1036
+ name: providerInfo(id).name,
1037
+ enabled: providerEnabled(id, config),
1038
+ configured: false,
1039
+ error: error instanceof Error ? error.message : String(error)
1040
+ }];
1041
+ }
1042
+ }));
1043
+ return {
1044
+ providerOrder: config.providerOrder,
1045
+ providers: Object.fromEntries(entries),
1046
+ enabled: PROVIDER_IDS.filter((id) => providerEnabled(id, config)),
1047
+ autoUpdate: autoUpdate.getState(),
1048
+ locale: config.locale
1049
+ };
1050
+ };
1051
+
1052
+ const readRequestBody = async (req) => {
1053
+ let body = "";
1054
+ for await (const chunk of req) {
1055
+ body += chunk;
1056
+ if (body.length > 64_000) throw new Error("request body too large");
1057
+ }
1058
+ return body;
1059
+ };
1060
+
1061
+ /** Reject a cross-origin settings write (the usage route is read-only). */
1062
+ const sameOrigin = (req) => {
1063
+ const origin = req.headers?.origin;
1064
+ if (typeof origin !== "string" || origin.length === 0) return true;
1065
+ const port = ctx.webServer?.port;
1066
+ if (typeof port !== "number") return true;
1067
+ const host = ctx.webServer?.host;
1068
+ const allowed = new Set([
1069
+ `http://127.0.0.1:${port}`,
1070
+ `http://localhost:${port}`,
1071
+ `http://${host}:${port}`
1072
+ ]);
1073
+ return allowed.has(origin);
1074
+ };
1075
+
1076
+ /** Validate and whitelist a settings patch; returns null when nothing valid is present. */
1077
+ const cleanSettingsPatch = (input) => {
1078
+ if (input === null || typeof input !== "object" || Array.isArray(input)) return null;
1079
+ const patch = {};
1080
+ const bools = ["opencodeGoEnabled", "opencodeConsoleEnabled", "commandCodeEnabled", "glmEnabled", "updateCheck", "autoUpdate", "injectSessionHeader"];
1081
+ for (const key of bools) {
1082
+ if (typeof input[key] === "boolean") patch[key] = input[key];
1083
+ }
1084
+ if (input.opencodeGoSource === "opencode-go" || input.opencodeGoSource === "builtin") {
1085
+ patch.opencodeGoSource = input.opencodeGoSource;
1086
+ }
1087
+ if (Array.isArray(input.providerOrder)) patch.providerOrder = normalizeProviderOrder(input.providerOrder);
1088
+ if (GLM_REGIONS.includes(input.glmRegion)) patch.glmRegion = input.glmRegion;
1089
+ const strings = [
1090
+ "opencodeGoApiKeyEnv",
1091
+ "opencodeGoBaseUrl",
1092
+ "opencodeConsoleApiKeyEnv",
1093
+ "opencodeConsoleOrgId",
1094
+ "opencodeConsoleAuthFile",
1095
+ "opencodeConsoleDbFile",
1096
+ "opencodeConsoleBaseUrl",
1097
+ "commandCodeApiKeyEnv",
1098
+ "commandCodeBaseUrl",
1099
+ "commandCodeAuthFile",
1100
+ "glmApiKeyEnv",
1101
+ "glmBaseUrl"
1102
+ ];
1103
+ for (const key of strings) {
1104
+ if (typeof input[key] === "string" && input[key].length <= 500 && !/[\r\n]/.test(input[key])) {
1105
+ patch[key] = input[key].trim();
1106
+ }
1107
+ }
1108
+ if (typeof input.cacheMs === "number" && Number.isFinite(input.cacheMs) && input.cacheMs >= 0 && input.cacheMs <= 3_600_000) {
1109
+ patch.cacheMs = Math.round(input.cacheMs);
1110
+ }
1111
+ if (typeof input.autoUpdateCheckHours === "number" && Number.isFinite(input.autoUpdateCheckHours) && input.autoUpdateCheckHours >= 1 && input.autoUpdateCheckHours <= 168) {
1112
+ patch.autoUpdateCheckHours = Math.round(input.autoUpdateCheckHours);
1113
+ }
1114
+ return Object.keys(patch).length === 0 ? null : patch;
1115
+ };
1116
+
1117
+ const handleSettings = async (req, res) => {
1118
+ const method = req.method ?? "GET";
1119
+ if (method !== "GET" && method !== "POST") {
1120
+ res.statusCode = 405;
1121
+ res.end();
1122
+ return;
1123
+ }
1124
+ if (method === "POST" && !sameOrigin(req)) {
1125
+ sendJson(res, 403, { ok: false, error: "cross-origin request refused" });
1126
+ return;
1127
+ }
1128
+ const settings = settingsServiceNow();
1129
+ if (settings === null) {
1130
+ sendJson(res, 503, { ok: false, error: "the settings service is not available in this composition" });
1131
+ return;
1132
+ }
1133
+ const ns = settingsNamespaceNow();
1134
+ const descriptor = settingsDescriptorNow(settings, ns);
1135
+ if (descriptor === null) {
1136
+ sendJson(res, 503, {
1137
+ ok: false,
1138
+ namespace: ns,
1139
+ error: `settings namespace "${ns}" is not registered`,
1140
+ hint: "DSH derives this page from the plugin's exported Config; it must be active and declare at least one volatile field. Restart DSH after upgrading, and check that the profile entry id matches."
1141
+ });
1142
+ return;
1143
+ }
1144
+ if (method === "GET") {
1145
+ let detection = null;
1146
+ try {
1147
+ detection = await detectionPayload();
1148
+ } catch {
1149
+ detection = null;
1150
+ }
1151
+ sendJson(res, 200, {
1152
+ ok: true,
1153
+ namespace: descriptor.ns,
1154
+ revision: descriptor.revision ?? 0,
1155
+ value: descriptor.value ?? {},
1156
+ base: descriptor.base ?? {},
1157
+ user: descriptor.user ?? {},
1158
+ detection
1159
+ });
1160
+ return;
1161
+ }
1162
+
1163
+ let body;
1164
+ try {
1165
+ body = JSON.parse((await readRequestBody(req)) || "{}");
1166
+ } catch {
1167
+ sendJson(res, 400, { ok: false, error: "invalid JSON body" });
1168
+ return;
1169
+ }
1170
+ const patch = cleanSettingsPatch(body?.patch);
1171
+ if (patch === null) {
1172
+ sendJson(res, 400, { ok: false, error: "missing or invalid patch object" });
1173
+ return;
1174
+ }
1175
+ try {
1176
+ await settings.update(ns, patch, Number.isInteger(body?.expectedRevision) ? body.expectedRevision : undefined);
1177
+ providerCache.clear();
1178
+ // A settings save is the natural point to act on a just-enabled
1179
+ // auto-update toggle instead of waiting for the next hourly tick.
1180
+ void autoUpdate.checkNow(true).catch(() => {});
1181
+ const next = settingsDescriptorNow(settings, ns);
1182
+ let detection = null;
1183
+ try {
1184
+ detection = await detectionPayload();
1185
+ } catch {
1186
+ detection = null;
1187
+ }
1188
+ sendJson(res, 200, { ok: true, revision: next?.revision ?? 0, detection });
1189
+ } catch (error) {
1190
+ if (error?.code === "SETTINGS_CONFLICT" || error?.name === "SettingsConflictError") {
1191
+ sendJson(res, 409, {
1192
+ ok: false,
1193
+ conflict: true,
1194
+ currentRevision: error.actual,
1195
+ error: "设置已被其他会话修改,请重新加载后再保存。"
1196
+ });
1197
+ return;
1198
+ }
1199
+ sendJson(res, 400, { ok: false, error: String(error?.message ?? error).slice(0, 500) });
1200
+ }
1201
+ };
1202
+
1203
+ // Same-origin routes the browser widget and settings page use. Registered
1204
+ // through `ctx.effect` so a live plugin reload disposes the previous
1205
+ // registrations before the next apply.
1206
+ ctx.effect(() => {
1207
+ dropStaleRoute(ctx, USAGE_ROUTE_PATH);
1208
+ dropStaleRoute(ctx, SETTINGS_ROUTE_PATH);
1209
+ const owned = [
1210
+ { kind: "exact", path: USAGE_ROUTE_PATH, handler: handleUsage },
1211
+ { kind: "exact", path: SETTINGS_ROUTE_PATH, handler: handleSettings }
1212
+ ].map((route) => ({
1213
+ route,
1214
+ dispose: ctx.webServer.register(route)
1215
+ }));
1216
+ return () => {
1217
+ for (const { route, dispose } of owned) {
1218
+ // A newer fiber may already own this path. Releasing it only when
1219
+ // ours is still current keeps an overlapping reload from deleting
1220
+ // the replacement's route.
1221
+ try {
1222
+ const table = route.kind === "exact" ? ctx.webServer?.exact : ctx.webServer?.prefixes;
1223
+ if (table instanceof Map && table.get(route.path) !== route) continue;
1224
+ } catch {
1225
+ // no introspectable table: fall through to the normal disposer
1226
+ }
1227
+ if (typeof dispose === "function") dispose();
1228
+ }
1229
+ };
1230
+ }, "dsh-multi-provider-usage: routes");
1231
+
1232
+ // Chat command so the numbers are reachable from a conversation too. Owned
1233
+ // by this fiber; a duplicate left by an older build only warns.
1234
+ try {
1235
+ ctx.effect(
1236
+ () => ctx.commands.register({
1237
+ name: COMMAND_NAME,
1238
+ description: "show OpenCode GO / Command Code / GLM Code Plan quota windows",
1239
+ handler: async () => {
1240
+ try {
1241
+ const config = readConfig();
1242
+ const data = await collectUsage(ctx, readConfig, providerCache, false);
1243
+ data.autoUpdate = autoUpdate.getState();
1244
+ let text = renderUsageText(data, config.locale);
1245
+ const update = updateInfoOf(config);
1246
+ if (update.available && update.latest !== null) {
1247
+ text += config.locale === "en"
1248
+ ? `\n\nUpdate available: v${update.latest} — run \`dsh plugin --profile <profile> add ${NPM_PACKAGE}@latest\` to upgrade.`
1249
+ : `\n\n发现新版本 v${update.latest} — 执行 \`dsh plugin --profile <profile> add ${NPM_PACKAGE}@latest\` 升级。`;
1250
+ }
1251
+ return { kind: data.ok ? "success" : "error", text };
1252
+ } catch (error) {
1253
+ return { kind: "error", text: error instanceof Error ? error.message : String(error) };
1254
+ }
1255
+ }
1256
+ }),
1257
+ "dsh-multi-provider-usage: command"
1258
+ );
1259
+ } catch (error) {
1260
+ const message = error instanceof Error ? error.message : String(error);
1261
+ if (!/already registered/i.test(message)) throw error;
1262
+ try {
1263
+ ctx.logger?.warn?.(`dsh-multi-provider-usage: /${COMMAND_NAME} is still owned by a previous instance; the widget keeps working and a restart restores the command`);
1264
+ } catch {
1265
+ // logging is best-effort
1266
+ }
1267
+ }
1268
+ }
1269
+
1270
+ /** Exposed for tests and diagnostics. */
1271
+ export const PROVIDER_USAGE_INFO = {
1272
+ providers: PROVIDERS,
1273
+ providerIds: PROVIDER_IDS,
1274
+ usageRoute: USAGE_ROUTE_PATH,
1275
+ settingsRoute: SETTINGS_ROUTE_PATH,
1276
+ commandName: COMMAND_NAME,
1277
+ currentVersion: CURRENT_VERSION
1278
+ };