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/LICENSE +21 -0
- package/README.md +380 -2
- package/README.zh.md +341 -0
- package/cordis.patch.yml +15 -0
- package/docs/widget.png +0 -0
- package/lib/auto-update.js +559 -0
- package/lib/client.js +1753 -0
- package/lib/index.js +1278 -0
- package/lib/opencode-console.js +321 -0
- package/lib/opencode-session.js +135 -0
- package/lib/providers.js +902 -0
- package/lib/util.js +27 -0
- package/package.json +64 -4
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
|
+
};
|