dsh-usage-state 0.3.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/lib/index.js ADDED
@@ -0,0 +1,1764 @@
1
+ import { readFileSync } from "node:fs";
2
+ import { homedir } from "node:os";
3
+ import { join } from "node:path";
4
+ //#region src/shared/config.ts
5
+ const DEFAULT_CONFIG = {
6
+ models: [],
7
+ order: [],
8
+ providers: {},
9
+ sources: {},
10
+ refresh: {
11
+ intervalMinutes: 5,
12
+ turnEndDelayMs: 2e3,
13
+ minIntervalSeconds: 60
14
+ },
15
+ display: {
16
+ thresholdWarnPercent: 80,
17
+ thresholdCriticalPercent: 95,
18
+ progressBar: true
19
+ }
20
+ };
21
+ const MODES = [
22
+ "api",
23
+ "coding-plan",
24
+ "hidden"
25
+ ];
26
+ const PROVIDER_MODES = [
27
+ "api",
28
+ "coding-plan",
29
+ "hidden",
30
+ "auto"
31
+ ];
32
+ function asRecord$5(value) {
33
+ return value !== null && typeof value === "object" && !Array.isArray(value) ? value : void 0;
34
+ }
35
+ function cleanString(value) {
36
+ return typeof value === "string" ? value.trim() : "";
37
+ }
38
+ function clampInt(value, minimum, maximum, fallback) {
39
+ if (typeof value !== "number" || !Number.isFinite(value)) return fallback;
40
+ return Math.min(maximum, Math.max(minimum, Math.round(value)));
41
+ }
42
+ function normalizeModels(value) {
43
+ if (!Array.isArray(value)) return [];
44
+ const models = [];
45
+ const seen = /* @__PURE__ */ new Set();
46
+ for (const raw of value) {
47
+ const entry = asRecord$5(raw);
48
+ if (entry === void 0) continue;
49
+ const provider = cleanString(entry.provider);
50
+ const model = cleanString(entry.model);
51
+ if (provider === "" || model === "") continue;
52
+ const identity = `${provider}\u0000${model}`;
53
+ if (seen.has(identity)) continue;
54
+ seen.add(identity);
55
+ const rawMode = cleanString(entry.mode);
56
+ const mode = MODES.includes(rawMode) ? rawMode : "hidden";
57
+ const sourceId = cleanString(entry.sourceId);
58
+ models.push({
59
+ provider,
60
+ model,
61
+ sourceId: sourceId === "" ? null : sourceId,
62
+ mode
63
+ });
64
+ }
65
+ return models;
66
+ }
67
+ function normalizeSources(value) {
68
+ const root = asRecord$5(value);
69
+ if (root === void 0) return {};
70
+ const sources = {};
71
+ for (const [id, raw] of Object.entries(root)) {
72
+ const entry = asRecord$5(raw);
73
+ if (entry === void 0) continue;
74
+ const config = {};
75
+ const apiKeyRef = cleanString(entry.apiKeyRef);
76
+ if (apiKeyRef !== "") config.apiKeyRef = apiKeyRef;
77
+ const baseUrl = cleanString(entry.baseUrl);
78
+ if (baseUrl !== "") config.baseUrl = baseUrl;
79
+ if (config.apiKeyRef !== void 0 || config.baseUrl !== void 0) sources[id] = config;
80
+ }
81
+ return sources;
82
+ }
83
+ function normalizeRefresh(value) {
84
+ const root = asRecord$5(value) ?? {};
85
+ const defaults = DEFAULT_CONFIG.refresh;
86
+ return {
87
+ intervalMinutes: clampInt(root.intervalMinutes, 1, 1440, defaults.intervalMinutes),
88
+ turnEndDelayMs: clampInt(root.turnEndDelayMs, 0, 6e4, defaults.turnEndDelayMs),
89
+ minIntervalSeconds: clampInt(root.minIntervalSeconds, 0, 3600, defaults.minIntervalSeconds)
90
+ };
91
+ }
92
+ function normalizeDisplay(value) {
93
+ const root = asRecord$5(value) ?? {};
94
+ const defaults = DEFAULT_CONFIG.display;
95
+ const progressBar = typeof root.progressBar === "boolean" ? root.progressBar : defaults.progressBar;
96
+ const thresholdWarnPercent = clampInt(root.thresholdWarnPercent, 1, 100, defaults.thresholdWarnPercent);
97
+ const thresholdCriticalPercent = clampInt(root.thresholdCriticalPercent, 1, 100, defaults.thresholdCriticalPercent);
98
+ if (thresholdWarnPercent >= thresholdCriticalPercent) return {
99
+ ...defaults,
100
+ progressBar
101
+ };
102
+ return {
103
+ thresholdWarnPercent,
104
+ thresholdCriticalPercent,
105
+ progressBar
106
+ };
107
+ }
108
+ function normalizeProviders(value) {
109
+ const root = asRecord$5(value);
110
+ if (root === void 0) return {};
111
+ const providers = {};
112
+ for (const [id, raw] of Object.entries(root)) {
113
+ const entry = asRecord$5(raw);
114
+ if (entry === void 0) continue;
115
+ const rawMode = cleanString(entry.mode);
116
+ const normalized = { mode: PROVIDER_MODES.includes(rawMode) ? rawMode : "auto" };
117
+ const sourceId = cleanString(entry.sourceId);
118
+ if (sourceId !== "") normalized.sourceId = sourceId;
119
+ const baseUrl = cleanString(entry.baseUrl);
120
+ if (baseUrl !== "") normalized.baseUrl = baseUrl;
121
+ const apiKeyRef = cleanString(entry.apiKeyRef);
122
+ if (apiKeyRef !== "") normalized.apiKeyRef = apiKeyRef;
123
+ providers[id] = normalized;
124
+ }
125
+ return providers;
126
+ }
127
+ /** Keep the stored order sane and append providers it does not mention. */
128
+ function normalizeOrder(value, providers) {
129
+ const order = [];
130
+ if (Array.isArray(value)) for (const raw of value) {
131
+ const id = cleanString(raw);
132
+ if (id === "" || order.includes(id)) continue;
133
+ order.push(id);
134
+ }
135
+ for (const id of Object.keys(providers)) if (!order.includes(id)) order.push(id);
136
+ return order;
137
+ }
138
+ /**
139
+ * Turn whatever the hand-editable settings document contains into a usable
140
+ * config. Deliberately never throws: the settings provider calls the schema
141
+ * synchronously at registration time, and a dirty section must not block the
142
+ * plugin from loading. Malformed pieces fall back to defaults instead.
143
+ *
144
+ * Legacy per-model entries are migrated into provider entries so an existing
145
+ * document keeps the choices its owner already made.
146
+ */
147
+ function normalizeConfig(raw) {
148
+ const root = asRecord$5(raw) ?? {};
149
+ const models = normalizeModels(root.models);
150
+ const providers = normalizeProviders(root.providers);
151
+ for (const entry of models) {
152
+ if (providers[entry.provider] !== void 0) continue;
153
+ providers[entry.provider] = {
154
+ mode: entry.mode,
155
+ ...entry.sourceId === null ? {} : { sourceId: entry.sourceId }
156
+ };
157
+ }
158
+ return {
159
+ models,
160
+ order: normalizeOrder(root.order, providers),
161
+ providers,
162
+ sources: normalizeSources(root.sources),
163
+ refresh: normalizeRefresh(root.refresh),
164
+ display: normalizeDisplay(root.display)
165
+ };
166
+ }
167
+ const PROVIDER_HINTS = [
168
+ {
169
+ sourceId: "zai",
170
+ pattern: /(zai|zhipu|bigmodel|glm)/
171
+ },
172
+ {
173
+ sourceId: "kimi",
174
+ pattern: /(kimi|moonshot)/
175
+ },
176
+ {
177
+ sourceId: "sub2api",
178
+ pattern: /sub-?2-?api/
179
+ },
180
+ {
181
+ sourceId: "opencode",
182
+ pattern: /opencode/
183
+ },
184
+ {
185
+ sourceId: "deepseek",
186
+ pattern: /deepseek/
187
+ }
188
+ ];
189
+ const HOST_HINTS = [
190
+ {
191
+ sourceId: "deepseek",
192
+ pattern: /(^|\.)api\.deepseek\.com$/
193
+ },
194
+ {
195
+ sourceId: "zai",
196
+ pattern: /(^|\.)(api\.z\.ai|open\.bigmodel\.cn|bigmodel\.cn)$/
197
+ },
198
+ {
199
+ sourceId: "kimi",
200
+ pattern: /(^|\.)(api\.kimi\.com|api\.moonshot\.cn|moonshot\.cn)$/
201
+ },
202
+ {
203
+ sourceId: "opencode",
204
+ pattern: /(^|\.)opencode\.ai$/
205
+ }
206
+ ];
207
+ /**
208
+ * Best guess at which data source backs a DSH provider. Provider id first (cheap
209
+ * and usually right), then the endpoint host. Because any unrecognised endpoint is
210
+ * most likely a self-hosted gateway, that is the last resort rather than `undefined`.
211
+ */
212
+ function suggestSourceId(providerId, baseUrl) {
213
+ const normalized = providerId.trim().toLowerCase();
214
+ for (const hint of PROVIDER_HINTS) if (hint.pattern.test(normalized)) return hint.sourceId;
215
+ if (baseUrl === void 0) return void 0;
216
+ let host;
217
+ try {
218
+ host = new URL(baseUrl.trim()).host.toLowerCase();
219
+ } catch {
220
+ return;
221
+ }
222
+ for (const hint of HOST_HINTS) if (hint.pattern.test(host)) return hint.sourceId;
223
+ return "sub2api";
224
+ }
225
+ //#endregion
226
+ //#region src/host/sources/normalize.ts
227
+ /** Shared normalization helpers for provider payloads. */
228
+ /** Numbers and numeric strings to a finite number; anything else is absent. */
229
+ function toFiniteNumber(value) {
230
+ if (typeof value === "number") return Number.isFinite(value) ? value : void 0;
231
+ if (typeof value === "string") {
232
+ const trimmed = value.trim();
233
+ if (trimmed === "") return void 0;
234
+ const parsed = Number(trimmed);
235
+ return Number.isFinite(parsed) ? parsed : void 0;
236
+ }
237
+ }
238
+ /** Clamp to 0..100 and round to one decimal. */
239
+ function clampPercent(value) {
240
+ if (!Number.isFinite(value)) return 0;
241
+ return Math.min(100, Math.max(0, Math.round(value * 10) / 10));
242
+ }
243
+ /**
244
+ * Percentages arrive in two conventions: 0..1 fractions and 0..100 percentages.
245
+ * Values `<= 1` are read as fractions (so a literal "1" means 100%), anything
246
+ * larger is read as a percentage. Invalid or negative input yields `null`.
247
+ */
248
+ function normalizePercent(value) {
249
+ const parsed = toFiniteNumber(value);
250
+ if (parsed === void 0 || parsed < 0) return null;
251
+ return clampPercent(parsed <= 1 ? parsed * 100 : parsed);
252
+ }
253
+ function fromNumericInstant(value) {
254
+ if (!Number.isFinite(value) || value <= 0) return void 0;
255
+ return value > 0xe8d4a51000 ? Math.round(value) : Math.round(value * 1e3);
256
+ }
257
+ /**
258
+ * Reset instants arrive as unix seconds, unix milliseconds, numeric strings or
259
+ * ISO timestamps; all of them become epoch milliseconds. Missing or nonsense
260
+ * values become `undefined` rather than a bogus date.
261
+ */
262
+ function normalizeResetAt(value) {
263
+ if (typeof value === "string") {
264
+ const trimmed = value.trim();
265
+ if (trimmed === "") return void 0;
266
+ if (/^\d+(\.\d+)?$/.test(trimmed)) {
267
+ const numeric = toFiniteNumber(trimmed);
268
+ return numeric === void 0 ? void 0 : fromNumericInstant(numeric);
269
+ }
270
+ const parsed = Date.parse(trimmed);
271
+ return Number.isFinite(parsed) ? parsed : void 0;
272
+ }
273
+ const numeric = toFiniteNumber(value);
274
+ return numeric === void 0 ? void 0 : fromNumericInstant(numeric);
275
+ }
276
+ /** Trim whitespace, trailing slashes and a trailing `/vN` API version segment. */
277
+ function normalizeBaseUrl(value) {
278
+ if (typeof value !== "string") return void 0;
279
+ const trimmed = value.trim().replace(/\/+$/, "");
280
+ if (trimmed === "") return void 0;
281
+ const withoutVersion = trimmed.replace(/\/v\d+$/i, "");
282
+ return withoutVersion === "" ? void 0 : withoutVersion;
283
+ }
284
+ //#endregion
285
+ //#region src/host/sources/types.ts
286
+ var SourceError = class extends Error {
287
+ kind;
288
+ constructor(kind, message) {
289
+ super(message);
290
+ this.name = "SourceError";
291
+ this.kind = kind;
292
+ }
293
+ };
294
+ //#endregion
295
+ //#region src/host/sources/deepseek.ts
296
+ const DEFAULT_BASE_URL$1 = "https://api.deepseek.com";
297
+ const BALANCE_PATH = "/user/balance";
298
+ /**
299
+ * DeepSeek returns one entry per currency and the order is not stable, so a
300
+ * fixed "first entry" read makes the displayed balance flip between the real
301
+ * value and zero. Rule: prefer an entry with a positive balance, prefer CNY
302
+ * among equals (the open platform's primary currency), and fall back to CNY or
303
+ * the first entry so the result never depends on response ordering.
304
+ */
305
+ function pickBalanceInfo(infos) {
306
+ if (!Array.isArray(infos)) return void 0;
307
+ const entries = [];
308
+ for (const raw of infos) {
309
+ if (raw === null || typeof raw !== "object") continue;
310
+ const entry = raw;
311
+ const granted = toFiniteNumber(entry.granted_balance);
312
+ const toppedUp = toFiniteNumber(entry.topped_up_balance);
313
+ entries.push({
314
+ amount: toFiniteNumber(entry.total_balance) ?? 0,
315
+ currency: typeof entry.currency === "string" ? entry.currency : "",
316
+ ...granted === void 0 ? {} : { granted },
317
+ ...toppedUp === void 0 ? {} : { toppedUp }
318
+ });
319
+ }
320
+ if (entries.length === 0) return void 0;
321
+ const cnyOf = (list) => list.find((entry) => entry.currency.toUpperCase() === "CNY");
322
+ const funded = entries.filter((entry) => entry.amount > 0);
323
+ return cnyOf(funded) ?? funded[0] ?? cnyOf(entries) ?? entries[0];
324
+ }
325
+ /** DeepSeek official API: balance only — the platform has no coding plan. */
326
+ const deepseek = {
327
+ id: "deepseek",
328
+ displayName: "DeepSeek",
329
+ modes: ["api"],
330
+ credentialRefs: () => ["DEEPSEEK_API_KEY"],
331
+ defaultBaseUrl: () => DEFAULT_BASE_URL$1,
332
+ request(input) {
333
+ return {
334
+ url: `${normalizeBaseUrl(input.baseUrl) ?? DEFAULT_BASE_URL$1}${BALANCE_PATH}`,
335
+ headers: {
336
+ authorization: `Bearer ${input.apiKey}`,
337
+ accept: "application/json"
338
+ }
339
+ };
340
+ },
341
+ parse(payload, _mode) {
342
+ const root = payload !== null && typeof payload === "object" ? payload : void 0;
343
+ const picked = root === void 0 ? void 0 : pickBalanceInfo(root.balance_infos);
344
+ if (picked === void 0) throw new SourceError("parse", "DeepSeek balance response contains no usable balance_infos");
345
+ return {
346
+ balances: [{
347
+ amount: picked.amount,
348
+ currency: picked.currency,
349
+ ...picked.granted === void 0 ? {} : { granted: picked.granted },
350
+ ...picked.toppedUp === void 0 ? {} : { toppedUp: picked.toppedUp }
351
+ }],
352
+ windows: []
353
+ };
354
+ }
355
+ };
356
+ //#endregion
357
+ //#region src/host/sources/kimi.ts
358
+ const MOONSHOT_BASE_URL = "https://api.moonshot.cn";
359
+ const KIMI_CODE_BASE_URL = "https://api.kimi.com";
360
+ /** The Kimi Code quota endpoint rejects requests without the CLI user agent. */
361
+ const KIMI_CLI_USER_AGENT = "KimiCLI/1.6";
362
+ const WINDOW_RANK$2 = {
363
+ "5h": 0,
364
+ "1d": 1,
365
+ "7d": 2
366
+ };
367
+ function asRecord$4(value) {
368
+ return value !== null && typeof value === "object" && !Array.isArray(value) ? value : void 0;
369
+ }
370
+ function firstFinite(candidates) {
371
+ for (const candidate of candidates) {
372
+ const parsed = toFiniteNumber(candidate);
373
+ if (parsed !== void 0) return parsed;
374
+ }
375
+ }
376
+ /**
377
+ * Moonshot's balance is documented in cents by some sources and in yuan by others,
378
+ * and there is no field that disambiguates the two. This mirrors the heuristic used
379
+ * by the plugin this project replaces: amounts of 100 or more are read as cents.
380
+ * Verified against a real key before trusting the absolute value.
381
+ */
382
+ function moonshotBalanceToYuan(value) {
383
+ const yuan = value >= 100 ? value / 100 : value;
384
+ return Math.round(yuan * 100) / 100;
385
+ }
386
+ /** Used percentage from an explicit used/limit pair, or by inverting remaining. */
387
+ function percentFrom(record) {
388
+ const limit = toFiniteNumber(record.limit);
389
+ if (limit === void 0 || limit <= 0) return null;
390
+ const used = toFiniteNumber(record.used);
391
+ if (used !== void 0) return clampPercent(used / limit * 100);
392
+ const remaining = toFiniteNumber(record.remaining);
393
+ if (remaining !== void 0) return clampPercent((limit - remaining) / limit * 100);
394
+ return null;
395
+ }
396
+ /** `{ duration: 5, timeUnit: 'hour' }` -> `5h`, and weekly variants -> `7d`. */
397
+ function windowIdFrom(window) {
398
+ if (window === void 0) return void 0;
399
+ const unit = typeof window.timeUnit === "string" ? window.timeUnit.trim().toLowerCase() : "";
400
+ const duration = toFiniteNumber(window.duration);
401
+ if (unit === "" || duration === void 0 || duration <= 0) return void 0;
402
+ if (unit.startsWith("hour")) return `${duration}h`;
403
+ if (unit.startsWith("day")) return duration === 7 ? "7d" : `${duration}d`;
404
+ if (unit.startsWith("week")) return duration === 1 ? "7d" : `${duration * 7}d`;
405
+ if (unit.startsWith("minute")) return `${duration}m`;
406
+ if (unit.startsWith("month")) return "30d";
407
+ return `${duration}${unit.slice(0, 4)}`;
408
+ }
409
+ function sortWindows$2(windows) {
410
+ return [...windows].sort((a, b) => (WINDOW_RANK$2[a.id] ?? 99) - (WINDOW_RANK$2[b.id] ?? 99));
411
+ }
412
+ /** `GET /v1/users/me/balance` — pay-as-you-go wallet, CNY. */
413
+ function parseMoonshotBalance(payload) {
414
+ const root = asRecord$4(payload);
415
+ const nested = root === void 0 ? void 0 : asRecord$4(root.data);
416
+ const value = firstFinite([
417
+ root?.available_balance,
418
+ root?.balance,
419
+ root?.cash_balance,
420
+ nested?.available_balance,
421
+ nested?.balance
422
+ ]);
423
+ if (value === void 0 || value < 0) throw new SourceError("parse", "Moonshot balance response contains no usable balance field");
424
+ return {
425
+ balances: [{
426
+ amount: moonshotBalanceToYuan(value),
427
+ currency: "CNY"
428
+ }],
429
+ windows: []
430
+ };
431
+ }
432
+ /**
433
+ * `GET /coding/v1/usages` — Kimi Code subscription. The top-level `usage` is the
434
+ * weekly quota; `limits[]` carries the rolling windows (5h among them). Neither is
435
+ * officially documented, so every field is optional here.
436
+ */
437
+ function parseKimiCodeUsage(payload) {
438
+ const root = asRecord$4(payload);
439
+ if (root === void 0) throw new SourceError("parse", "Kimi Code usage response is not a JSON object");
440
+ const found = /* @__PURE__ */ new Map();
441
+ const usage = asRecord$4(root.usage);
442
+ if (usage !== void 0) {
443
+ const usedPercent = percentFrom(usage);
444
+ if (usedPercent !== null) {
445
+ const resetsAt = normalizeResetAt(usage.resetTime ?? usage.reset_at ?? usage.resetsAt);
446
+ found.set("7d", resetsAt === void 0 ? {
447
+ id: "7d",
448
+ usedPercent
449
+ } : {
450
+ id: "7d",
451
+ usedPercent,
452
+ resetsAt
453
+ });
454
+ }
455
+ }
456
+ const limits = Array.isArray(root.limits) ? root.limits : [];
457
+ for (const raw of limits) {
458
+ const row = asRecord$4(raw);
459
+ const detail = row === void 0 ? void 0 : asRecord$4(row.detail);
460
+ if (detail === void 0) continue;
461
+ const usedPercent = percentFrom(detail);
462
+ if (usedPercent === null) continue;
463
+ const id = windowIdFrom(asRecord$4(row?.window));
464
+ if (id === void 0 || found.has(id)) continue;
465
+ const resetsAt = normalizeResetAt(detail.resetTime ?? detail.reset_at ?? detail.resetsAt);
466
+ found.set(id, resetsAt === void 0 ? {
467
+ id,
468
+ usedPercent
469
+ } : {
470
+ id,
471
+ usedPercent,
472
+ resetsAt
473
+ });
474
+ }
475
+ if (found.size === 0) throw new SourceError("parse", "Kimi Code usage response contains no usable quota window");
476
+ return {
477
+ balances: [],
478
+ windows: sortWindows$2(found.values())
479
+ };
480
+ }
481
+ /**
482
+ * Kimi / Moonshot. One vendor, two very different readings: the pay-as-you-go
483
+ * wallet in API mode (`api.moonshot.cn`) and the Kimi Code subscription windows in
484
+ * coding-plan mode (`api.kimi.com`).
485
+ */
486
+ const kimi = {
487
+ id: "kimi",
488
+ displayName: "Kimi / Moonshot",
489
+ modes: ["api", "coding-plan"],
490
+ credentialRefs(mode) {
491
+ return mode === "coding-plan" ? [
492
+ "KIMI_CODING_API_KEY",
493
+ "KIMI_API_KEY",
494
+ "MOONSHOT_API_KEY"
495
+ ] : ["MOONSHOT_API_KEY", "KIMI_API_KEY"];
496
+ },
497
+ defaultBaseUrl(mode) {
498
+ return mode === "coding-plan" ? KIMI_CODE_BASE_URL : MOONSHOT_BASE_URL;
499
+ },
500
+ request(input) {
501
+ const codingPlan = input.mode === "coding-plan";
502
+ const base = normalizeBaseUrl(input.baseUrl) ?? (codingPlan ? KIMI_CODE_BASE_URL : MOONSHOT_BASE_URL);
503
+ return {
504
+ url: codingPlan ? `${base}/coding/v1/usages` : `${base}/v1/users/me/balance`,
505
+ headers: {
506
+ authorization: `Bearer ${input.apiKey}`,
507
+ accept: "application/json",
508
+ ...codingPlan ? { "user-agent": KIMI_CLI_USER_AGENT } : {}
509
+ }
510
+ };
511
+ },
512
+ parse(payload, mode) {
513
+ return mode === "coding-plan" ? parseKimiCodeUsage(payload) : parseMoonshotBalance(payload);
514
+ }
515
+ };
516
+ //#endregion
517
+ //#region src/host/sources/opencode.ts
518
+ const DEFAULT_BASE_URL = "https://opencode.ai";
519
+ const QUOTA_PATH$1 = "/zen/go/v1/usage";
520
+ /**
521
+ * Cloudflare fronts opencode.ai and answers a non-browser agent with error 1010
522
+ * (a flat 403), so the quota request has to present a real Chrome user agent.
523
+ */
524
+ const BROWSER_USER_AGENT = "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Safari/537.36";
525
+ /**
526
+ * The quota endpoint lives under `/zen/go/v1`. A provider profile declares its
527
+ * `baseURL` as the *API* base (`https://opencode.ai/zen/go/v1`), the settings page can
528
+ * pin that same string, and a user can paste the documented URL in full — so the base
529
+ * can arrive already carrying any suffix of our own path. `normalizeBaseUrl` has
530
+ * already dropped a trailing `/vN`; drop whatever of `/zen/go`, `/vN` and `/usage`
531
+ * remains, or the path would be appended to itself.
532
+ */
533
+ function endpointOrigin(value) {
534
+ return (normalizeBaseUrl(value) ?? DEFAULT_BASE_URL).replace(/\/zen\/go(?:\/v\d+)?(?:\/usage)?$/i, "");
535
+ }
536
+ function asRecord$3(value) {
537
+ return value !== null && typeof value === "object" && !Array.isArray(value) ? value : void 0;
538
+ }
539
+ /**
540
+ * The Go plan names its windows by cadence. `rolling` is the 5-hour window,
541
+ * `weekly` the 7-day one, and `monthly` the 30-day one — the same canonical ids
542
+ * the status line already orders and localises.
543
+ */
544
+ const WINDOW_IDS = [
545
+ ["rolling", "5h"],
546
+ ["weekly", "7d"],
547
+ ["monthly", "30d"]
548
+ ];
549
+ function parseUsage(payload) {
550
+ const root = asRecord$3(payload);
551
+ if (root === void 0) throw new SourceError("parse", "OpenCode usage response is not a JSON object");
552
+ const usage = asRecord$3(root.usage) ?? asRecord$3(asRecord$3(root.data)?.usage);
553
+ if (usage === void 0) throw new SourceError("parse", "OpenCode usage response carries no usage windows");
554
+ const windows = [];
555
+ for (const [key, id] of WINDOW_IDS) {
556
+ const entry = asRecord$3(usage[key]);
557
+ if (entry === void 0) continue;
558
+ const percent = toFiniteNumber(entry.percent);
559
+ if (percent === void 0) continue;
560
+ const usedPercent = clampPercent(percent);
561
+ const resetsAt = normalizeResetAt(entry.resetsAt);
562
+ windows.push(resetsAt === void 0 ? {
563
+ id,
564
+ usedPercent
565
+ } : {
566
+ id,
567
+ usedPercent,
568
+ resetsAt
569
+ });
570
+ }
571
+ if (windows.length === 0) throw new SourceError("parse", "OpenCode usage response contains no usable quota window");
572
+ return {
573
+ balances: [],
574
+ windows
575
+ };
576
+ }
577
+ const opencode = {
578
+ id: "opencode",
579
+ displayName: "OpenCode Zen Go",
580
+ modes: ["coding-plan"],
581
+ credentialRefs: () => ["OPENCODE_GO_API_KEY", "OPENCODE_API_KEY"],
582
+ defaultBaseUrl: () => DEFAULT_BASE_URL,
583
+ request(input) {
584
+ return {
585
+ url: `${endpointOrigin(input.baseUrl)}${QUOTA_PATH$1}`,
586
+ headers: {
587
+ authorization: `Bearer ${input.apiKey}`,
588
+ accept: "application/json",
589
+ "user-agent": BROWSER_USER_AGENT
590
+ }
591
+ };
592
+ },
593
+ parse(payload, _mode) {
594
+ return parseUsage(payload);
595
+ }
596
+ };
597
+ //#endregion
598
+ //#region src/host/sources/sub2api.ts
599
+ const USAGE_PATH = "/v1/usage";
600
+ const DEFAULT_CURRENCY = "USD";
601
+ const WINDOW_RANK$1 = {
602
+ "5h": 0,
603
+ "1d": 1,
604
+ "7d": 2,
605
+ "30d": 3
606
+ };
607
+ /** Subscription window name -> our canonical id. */
608
+ const SUBSCRIPTION_WINDOWS = [
609
+ {
610
+ prefix: "daily",
611
+ id: "1d"
612
+ },
613
+ {
614
+ prefix: "weekly",
615
+ id: "7d"
616
+ },
617
+ {
618
+ prefix: "monthly",
619
+ id: "30d"
620
+ }
621
+ ];
622
+ function asRecord$2(value) {
623
+ return value !== null && typeof value === "object" && !Array.isArray(value) ? value : void 0;
624
+ }
625
+ function currencyOf(value) {
626
+ if (typeof value !== "string") return DEFAULT_CURRENCY;
627
+ const trimmed = value.trim().toUpperCase();
628
+ return trimmed === "" ? DEFAULT_CURRENCY : trimmed;
629
+ }
630
+ /** `window: "5h" | "1d" | "7d"` as sent by Sub2API; unknown names are kept as-is. */
631
+ function rateWindowId(raw) {
632
+ if (typeof raw !== "string") return void 0;
633
+ const key = raw.trim().toLowerCase();
634
+ if (key === "") return void 0;
635
+ if (key === "5h") return "5h";
636
+ if (key === "1d" || key === "24h") return "1d";
637
+ if (key === "7d" || key === "168h") return "7d";
638
+ if (key === "30d" || key === "1mo") return "30d";
639
+ return /^[a-z0-9.]+$/.test(key) ? key : void 0;
640
+ }
641
+ function sortWindows$1(windows) {
642
+ return [...windows].sort((a, b) => (WINDOW_RANK$1[a.id] ?? 99) - (WINDOW_RANK$1[b.id] ?? 99));
643
+ }
644
+ function percentFromPair(used, limit) {
645
+ const cap = toFiniteNumber(limit);
646
+ const spent = toFiniteNumber(used);
647
+ if (cap === void 0 || cap <= 0 || spent === void 0) return null;
648
+ return clampPercent(spent / cap * 100);
649
+ }
650
+ /**
651
+ * Sub2API is a self-hosted gateway that redistributes subscription quota as its own
652
+ * `sk-` keys. `GET /v1/usage` is the only endpoint an API key can read, it is
653
+ * undocumented and its field names have drifted between frontend and backend, so
654
+ * every field here is optional and unknown shapes degrade instead of throwing.
655
+ */
656
+ const sub2api = {
657
+ id: "sub2api",
658
+ displayName: "Sub2API",
659
+ modes: ["api", "coding-plan"],
660
+ requiresBaseUrl: true,
661
+ credentialRefs: () => ["SUB2API_API_KEY"],
662
+ /** Self-hosted: there is no sensible default endpoint. */
663
+ defaultBaseUrl: () => void 0,
664
+ request(input) {
665
+ const base = normalizeBaseUrl(input.baseUrl);
666
+ if (base === void 0) throw new SourceError("config", "Sub2API requires the base URL of your own instance");
667
+ return {
668
+ url: `${base}${USAGE_PATH}`,
669
+ headers: {
670
+ authorization: `Bearer ${input.apiKey}`,
671
+ accept: "application/json"
672
+ }
673
+ };
674
+ },
675
+ parse(payload, _mode) {
676
+ const root = asRecord$2(payload);
677
+ if (root === void 0) throw new SourceError("parse", "Sub2API usage response is not a JSON object");
678
+ const balances = [];
679
+ const windows = /* @__PURE__ */ new Map();
680
+ const quota = asRecord$2(root.quota);
681
+ const quotaRemaining = quota === void 0 ? void 0 : toFiniteNumber(quota.remaining);
682
+ const quotaLimit = quota === void 0 ? void 0 : toFiniteNumber(quota.limit);
683
+ if (quotaRemaining !== void 0 && quotaRemaining >= 0 && quotaLimit !== void 0 && quotaLimit > 0) balances.push({
684
+ amount: quotaRemaining,
685
+ currency: currencyOf(quota?.unit ?? root.unit)
686
+ });
687
+ else {
688
+ const wallet = toFiniteNumber(root.balance);
689
+ if (wallet !== void 0 && wallet >= 0) balances.push({
690
+ amount: wallet,
691
+ currency: currencyOf(root.unit)
692
+ });
693
+ }
694
+ const rateLimits = Array.isArray(root.rate_limits) ? root.rate_limits : [];
695
+ for (const raw of rateLimits) {
696
+ const entry = asRecord$2(raw);
697
+ if (entry === void 0) continue;
698
+ const id = rateWindowId(entry.window);
699
+ if (id === void 0 || windows.has(id)) continue;
700
+ const usedPercent = percentFromPair(entry.used, entry.limit);
701
+ if (usedPercent === null) continue;
702
+ const resetsAt = normalizeResetAt(entry.reset_at ?? entry.resetAt);
703
+ windows.set(id, resetsAt === void 0 ? {
704
+ id,
705
+ usedPercent
706
+ } : {
707
+ id,
708
+ usedPercent,
709
+ resetsAt
710
+ });
711
+ }
712
+ const subscription = asRecord$2(root.subscription);
713
+ if (subscription !== void 0) for (const { prefix, id } of SUBSCRIPTION_WINDOWS) {
714
+ if (windows.has(id)) continue;
715
+ const usedPercent = percentFromPair(subscription[`${prefix}_usage_usd`], subscription[`${prefix}_limit_usd`]);
716
+ if (usedPercent === null) continue;
717
+ windows.set(id, {
718
+ id,
719
+ usedPercent
720
+ });
721
+ }
722
+ const sorted = sortWindows$1(windows.values());
723
+ if (balances.length === 0 && sorted.length === 0) throw new SourceError("parse", "Sub2API usage response carries neither balance nor quota window");
724
+ return {
725
+ balances,
726
+ windows: sorted
727
+ };
728
+ }
729
+ };
730
+ //#endregion
731
+ //#region src/host/sources/zai.ts
732
+ /**
733
+ * GLM coding plans are regional and the key only works on its own region's host:
734
+ * a China key answers `身份验证失败` on the global host and vice versa, with HTTP
735
+ * 200 rather than a redirect. China is the primary because that is where the
736
+ * coding plan is sold; the global host is the mirror.
737
+ */
738
+ const CN_BASE_URL = "https://open.bigmodel.cn";
739
+ const GLOBAL_BASE_URL = "https://api.z.ai";
740
+ const QUOTA_PATH = "/api/monitor/usage/quota/limit";
741
+ /** Display order for the windows we know; anything else keeps its insertion order after these. */
742
+ const WINDOW_RANK = {
743
+ "5h": 0,
744
+ "1d": 1,
745
+ "7d": 2
746
+ };
747
+ /** Provider window names -> our canonical ids. Unknown names are kept verbatim. */
748
+ function canonicalWindowId(raw) {
749
+ const key = raw.trim().toLowerCase().replace(/[\s-]+/g, "_");
750
+ if (/^(five_?hour|fivehour|rolling|5h)$/.test(key)) return "5h";
751
+ if (/^(weekly|week|seven_?day|sevenday|7d)$/.test(key)) return "7d";
752
+ if (/^(daily|day|1d)$/.test(key)) return "1d";
753
+ if (/^month(ly)?$/.test(key)) return "30d";
754
+ return key;
755
+ }
756
+ function asRecord$1(value) {
757
+ return value !== null && typeof value === "object" && !Array.isArray(value) ? value : void 0;
758
+ }
759
+ /** Percentage of one `limits[]` entry: explicit `percentage` first, else currentValue/usage. */
760
+ function percentOfLimit(limit) {
761
+ const explicit = toFiniteNumber(limit.percentage);
762
+ if (explicit !== void 0) return clampPercent(explicit);
763
+ const usage = toFiniteNumber(limit.usage);
764
+ const current = toFiniteNumber(limit.currentValue);
765
+ if (usage !== void 0 && usage > 0 && current !== void 0) return clampPercent(current / usage * 100);
766
+ return null;
767
+ }
768
+ /** The 2026-08 monitor endpoint: `data.limits[]`, mapped by unit (3 = hours, 6 = weeks). */
769
+ function parseLimits(data) {
770
+ const body = asRecord$1(data.data);
771
+ const limits = body === void 0 ? void 0 : body.limits;
772
+ if (!Array.isArray(limits)) return void 0;
773
+ const found = /* @__PURE__ */ new Map();
774
+ const unassigned = [];
775
+ for (const raw of limits) {
776
+ const limit = asRecord$1(raw);
777
+ if (limit === void 0) continue;
778
+ if (limit.type !== "TOKENS_LIMIT" && limit.type !== "CREDIT_LIMIT") continue;
779
+ const usedPercent = percentOfLimit(limit);
780
+ if (usedPercent === null) continue;
781
+ const resetsAt = normalizeResetAt(limit.nextResetTime);
782
+ const window = resetsAt === void 0 ? {
783
+ id: "",
784
+ usedPercent
785
+ } : {
786
+ id: "",
787
+ usedPercent,
788
+ resetsAt
789
+ };
790
+ const unit = toFiniteNumber(limit.unit);
791
+ if (unit === 3) {
792
+ if (!found.has("5h")) found.set("5h", {
793
+ ...window,
794
+ id: "5h"
795
+ });
796
+ } else if (unit === 6) {
797
+ if (!found.has("7d")) found.set("7d", {
798
+ ...window,
799
+ id: "7d"
800
+ });
801
+ } else if (unit === void 0) unassigned.push({
802
+ usedPercent,
803
+ ...resetsAt === void 0 ? {} : { resetsAt },
804
+ resetMs: resetsAt ?? 0
805
+ });
806
+ }
807
+ unassigned.sort((a, b) => a.resetMs - b.resetMs);
808
+ for (const item of unassigned) {
809
+ const id = found.has("5h") ? found.has("7d") ? void 0 : "7d" : "5h";
810
+ if (id === void 0) break;
811
+ found.set(id, {
812
+ id,
813
+ usedPercent: item.usedPercent,
814
+ ...item.resetsAt === void 0 ? {} : { resetsAt: item.resetsAt }
815
+ });
816
+ }
817
+ return found.size > 0 ? sortWindows(found.values()) : void 0;
818
+ }
819
+ /** The legacy billing endpoint: `plans[]`, where the reset span decides 5h vs weekly. */
820
+ function parsePlans(data) {
821
+ if (!Array.isArray(data.plans)) return void 0;
822
+ const found = /* @__PURE__ */ new Map();
823
+ for (const raw of data.plans) {
824
+ const plan = asRecord$1(raw);
825
+ if (plan === void 0) continue;
826
+ const total = toFiniteNumber(plan.total_units);
827
+ const used = toFiniteNumber(plan.used_units);
828
+ const usedPercent = total !== void 0 && total > 0 && used !== void 0 ? clampPercent(used / total * 100) : normalizePercent(plan.utilization ?? plan.percent ?? plan.used_percentage);
829
+ if (usedPercent === null) continue;
830
+ const resetsAt = normalizeResetAt(plan.period_end);
831
+ const spanMs = resetsAt === void 0 ? NaN : resetsAt - Date.now();
832
+ const id = Number.isFinite(spanMs) && spanMs > 864e5 ? "7d" : "5h";
833
+ found.set(id, resetsAt === void 0 ? {
834
+ id,
835
+ usedPercent
836
+ } : {
837
+ id,
838
+ usedPercent,
839
+ resetsAt
840
+ });
841
+ }
842
+ return found.size > 0 ? sortWindows(found.values()) : void 0;
843
+ }
844
+ /** A flat `{ five_hour: { utilization, resets_at }, ... }` object (Anthropic-like). */
845
+ function parseFlatWindows(data) {
846
+ const found = /* @__PURE__ */ new Map();
847
+ for (const [name, raw] of Object.entries(data)) {
848
+ if (name === "plans" || name === "data") continue;
849
+ const entry = asRecord$1(raw);
850
+ if (entry === void 0) continue;
851
+ const usedPercent = normalizePercent(entry.utilization ?? entry.percent ?? entry.used_percentage);
852
+ if (usedPercent === null) continue;
853
+ const id = canonicalWindowId(name);
854
+ if (found.has(id)) continue;
855
+ const resetsAt = normalizeResetAt(entry.resets_at ?? entry.reset_at ?? entry.resetsAt);
856
+ found.set(id, resetsAt === void 0 ? {
857
+ id,
858
+ usedPercent
859
+ } : {
860
+ id,
861
+ usedPercent,
862
+ resetsAt
863
+ });
864
+ }
865
+ return found.size > 0 ? sortWindows(found.values()) : void 0;
866
+ }
867
+ /** Recognise the error envelopes these endpoints return with HTTP 200. */
868
+ function errorEnvelope(root) {
869
+ const nested = asRecord$1(root.error);
870
+ const rawCode = toFiniteNumber(root.code) ?? toFiniteNumber(nested?.code);
871
+ const rawMessage = typeof root.msg === "string" && root.msg || typeof root.message === "string" && root.message || typeof nested?.message === "string" && nested.message || "";
872
+ if (!(root.success === false || nested !== void 0)) return void 0;
873
+ const message = rawMessage.trim() === "" ? `error ${rawCode ?? "unknown"}` : rawMessage.trim();
874
+ return {
875
+ kind: rawCode === 1e3 ? "auth" : "http",
876
+ message
877
+ };
878
+ }
879
+ function sortWindows(windows) {
880
+ return [...windows].sort((a, b) => (WINDOW_RANK[a.id] ?? 99) - (WINDOW_RANK[b.id] ?? 99));
881
+ }
882
+ /**
883
+ * z.ai / Zhipu GLM coding plan. The monitor endpoint is the only documented-free
884
+ * source of the rolling 5h and weekly windows; the legacy and flat shapes are kept
885
+ * as fallbacks because the endpoint is community-reverse-engineered and has already
886
+ * changed shape once.
887
+ */
888
+ function quotaRequest(base, apiKey) {
889
+ return {
890
+ url: `${base}${QUOTA_PATH}`,
891
+ headers: {
892
+ authorization: `Bearer ${apiKey}`,
893
+ accept: "application/json"
894
+ }
895
+ };
896
+ }
897
+ //#endregion
898
+ //#region src/host/sources/index.ts
899
+ /** Every data source shipped in v1, in the order the settings page lists them. */
900
+ const ALL_SOURCES = [
901
+ deepseek,
902
+ {
903
+ id: "zai",
904
+ displayName: "z.ai / GLM",
905
+ modes: ["coding-plan"],
906
+ credentialRefs: () => [
907
+ "ZAI_API_KEY",
908
+ "GLM_API_KEY",
909
+ "ZHIPU_API_KEY"
910
+ ],
911
+ defaultBaseUrl: () => CN_BASE_URL,
912
+ request(input) {
913
+ return quotaRequest(normalizeBaseUrl(input.baseUrl) ?? CN_BASE_URL, input.apiKey);
914
+ },
915
+ /**
916
+ * Try the other region unless the user pinned an endpoint themselves: a declared
917
+ * host is a strong hint, but a wrong region guess is exactly what the mirror is
918
+ * there to survive.
919
+ */
920
+ fallbackRequests(input) {
921
+ if (input.pinnedBaseUrl === true) return [];
922
+ return [quotaRequest((normalizeBaseUrl(input.baseUrl) ?? CN_BASE_URL) === GLOBAL_BASE_URL ? CN_BASE_URL : GLOBAL_BASE_URL, input.apiKey)];
923
+ },
924
+ parse(payload, _mode) {
925
+ const root = asRecord$1(payload);
926
+ if (root === void 0) throw new SourceError("parse", "z.ai quota response is not a JSON object");
927
+ const failure = errorEnvelope(root);
928
+ if (failure !== void 0) throw new SourceError(failure.kind, failure.message);
929
+ const windows = parseLimits(root) ?? parsePlans(root) ?? parseFlatWindows(root);
930
+ if (windows === void 0 || windows.length === 0) throw new SourceError("parse", "z.ai quota response contains no usable 5h/7d window");
931
+ return {
932
+ balances: [],
933
+ windows
934
+ };
935
+ }
936
+ },
937
+ kimi,
938
+ opencode,
939
+ sub2api
940
+ ];
941
+ function findSource(id) {
942
+ return ALL_SOURCES.find((source) => source.id === id);
943
+ }
944
+ //#endregion
945
+ //#region src/host/catalog.ts
946
+ /** Flatten the adapters into plain JSON, because this crosses the RPC boundary. */
947
+ function toSourceCatalog(sources = ALL_SOURCES) {
948
+ return sources.map((source) => {
949
+ const defaultBaseUrl = {};
950
+ const credentialRefs = {};
951
+ for (const mode of source.modes) {
952
+ const base = source.defaultBaseUrl(mode);
953
+ if (base !== void 0) defaultBaseUrl[mode] = base;
954
+ credentialRefs[mode] = [...source.credentialRefs(mode)];
955
+ }
956
+ return {
957
+ id: source.id,
958
+ displayName: source.displayName,
959
+ modes: [...source.modes],
960
+ requiresBaseUrl: source.requiresBaseUrl === true,
961
+ defaultBaseUrl,
962
+ credentialRefs
963
+ };
964
+ });
965
+ }
966
+ //#endregion
967
+ //#region src/host/credential-fallback.ts
968
+ /** Extract one ref from the `refs:` block of a credentials document. */
969
+ function refFromCredentialsDocument(text, ref) {
970
+ const lines = text.split(/\r?\n/);
971
+ let inRefs = false;
972
+ for (const line of lines) {
973
+ if (/^[A-Za-z_][A-Za-z0-9_]*:/.test(line)) {
974
+ inRefs = line.startsWith("refs:");
975
+ continue;
976
+ }
977
+ if (!inRefs) continue;
978
+ const match = /^\s+([A-Za-z_][A-Za-z0-9_]*):\s*(.*)$/.exec(line);
979
+ if (match === null || match[1] !== ref) continue;
980
+ const raw = (match[2] ?? "").trim();
981
+ if (raw === "" || raw === "null" || raw === "~") return void 0;
982
+ const unquoted = /^(['"])(.*)\1$/.exec(raw);
983
+ return unquoted === null ? raw : unquoted[2] ?? "";
984
+ }
985
+ }
986
+ function defaultCredentialsPath() {
987
+ const home = process.env.DSH_HOME;
988
+ const root = home !== void 0 && home.trim() !== "" ? home : join(homedir(), ".dsh");
989
+ return join(root, ".credentials.yaml");
990
+ }
991
+ /**
992
+ * Wrap the platform lookup with the direct fallbacks. The platform answer always
993
+ * wins; the fallbacks only run when it reports "not configured".
994
+ */
995
+ function withCredentialFallback(platform, deps = {}) {
996
+ const environment = deps.environment ?? process.env;
997
+ const path = deps.credentialsPath ?? defaultCredentialsPath();
998
+ const fromEnvironment = (ref) => {
999
+ const value = environment[ref];
1000
+ return typeof value === "string" && value.trim() !== "" ? {
1001
+ value,
1002
+ source: "env (direct)"
1003
+ } : void 0;
1004
+ };
1005
+ const fromFile = (ref) => {
1006
+ try {
1007
+ const value = refFromCredentialsDocument(readFileSync(path, "utf8"), ref);
1008
+ return value === void 0 ? void 0 : {
1009
+ value,
1010
+ source: "file (direct)"
1011
+ };
1012
+ } catch {
1013
+ return;
1014
+ }
1015
+ };
1016
+ return {
1017
+ resolve: async (ref) => {
1018
+ const platformValue = await platform.resolve(ref);
1019
+ if (platformValue !== void 0) return platformValue;
1020
+ return fromEnvironment(ref) ?? fromFile(ref);
1021
+ },
1022
+ describe: async (ref) => {
1023
+ const described = await platform.describe(ref);
1024
+ if (described.configured) return described;
1025
+ const fallback = fromEnvironment(ref) ?? fromFile(ref);
1026
+ if (fallback === void 0) return described;
1027
+ return {
1028
+ configured: true,
1029
+ source: fallback.source,
1030
+ writable: false
1031
+ };
1032
+ }
1033
+ };
1034
+ }
1035
+ //#endregion
1036
+ //#region src/host/credentials.ts
1037
+ function clean(ref) {
1038
+ if (ref === void 0) return void 0;
1039
+ const trimmed = ref.trim();
1040
+ return trimmed === "" ? void 0 : trimmed;
1041
+ }
1042
+ /**
1043
+ * The refs to probe for one source+mode, most specific first: the user's explicit
1044
+ * override, then refs derived from the provider's own configuration, then the
1045
+ * source's built-in candidates.
1046
+ */
1047
+ function orderedCredentialRefs(source, mode, options = {}) {
1048
+ const ordered = [];
1049
+ for (const candidate of [
1050
+ clean(options.overrideRef),
1051
+ ...options.preferredRefs ?? [],
1052
+ ...source.credentialRefs(mode)
1053
+ ]) {
1054
+ const ref = clean(candidate);
1055
+ if (ref === void 0 || ordered.includes(ref)) continue;
1056
+ ordered.push(ref);
1057
+ }
1058
+ return ordered;
1059
+ }
1060
+ /** The first candidate that resolves to a non-blank secret wins. */
1061
+ async function resolveApiKey(source, mode, options, lookup) {
1062
+ for (const ref of orderedCredentialRefs(source, mode, options)) {
1063
+ const resolved = await lookup.resolve(ref);
1064
+ if (resolved === void 0 || resolved.value.trim() === "") continue;
1065
+ return {
1066
+ apiKey: resolved.value,
1067
+ ref,
1068
+ origin: resolved.source
1069
+ };
1070
+ }
1071
+ }
1072
+ /**
1073
+ * Status of every candidate, for the settings page. Never returns a secret: only
1074
+ * whether a ref is configured and which layer provides it.
1075
+ */
1076
+ async function describeCredentials(source, mode, options, lookup) {
1077
+ const candidates = [];
1078
+ let resolvedRef;
1079
+ let resolvedSource;
1080
+ let resolvedWritable;
1081
+ for (const ref of orderedCredentialRefs(source, mode, options)) {
1082
+ const described = await lookup.describe(ref);
1083
+ const candidate = {
1084
+ ref,
1085
+ configured: described.configured,
1086
+ writable: described.writable
1087
+ };
1088
+ if (described.source !== void 0) candidate.source = described.source;
1089
+ candidates.push(candidate);
1090
+ if (described.configured && resolvedRef === void 0) {
1091
+ resolvedRef = ref;
1092
+ resolvedSource = described.source;
1093
+ resolvedWritable = described.writable;
1094
+ }
1095
+ }
1096
+ return {
1097
+ candidates,
1098
+ configured: resolvedRef !== void 0,
1099
+ ref: resolvedRef,
1100
+ source: resolvedSource,
1101
+ writable: resolvedWritable
1102
+ };
1103
+ }
1104
+ //#endregion
1105
+ //#region src/host/provider-refs.ts
1106
+ function asRecord(value) {
1107
+ return value !== null && typeof value === "object" && !Array.isArray(value) ? value : void 0;
1108
+ }
1109
+ /**
1110
+ * The `apiKeyEnv` values the user already declared for the providers that feed a
1111
+ * data source. These are probed before the source's built-in ref names, so a
1112
+ * provider configured with a custom environment variable works without any
1113
+ * extra configuration in this plugin.
1114
+ */
1115
+ function providerCredentialRefs(input) {
1116
+ const suggest = input.suggest ?? suggestSourceId;
1117
+ const refs = [];
1118
+ const push = (value) => {
1119
+ if (typeof value !== "string") return;
1120
+ const ref = value.trim();
1121
+ if (ref === "" || refs.includes(ref)) return;
1122
+ refs.push(ref);
1123
+ };
1124
+ if (input.sourceId === "deepseek") push(asRecord(input.deepseekSettings)?.apiKeyEnv);
1125
+ const providers = asRecord(asRecord(input.piAiSettings)?.providers);
1126
+ if (providers === void 0) return refs;
1127
+ for (const [providerId, raw] of Object.entries(providers)) {
1128
+ const profile = asRecord(raw);
1129
+ if (profile === void 0) continue;
1130
+ if (suggest(providerId, typeof profile.baseURL === "string" ? profile.baseURL : void 0) !== input.sourceId) continue;
1131
+ push(profile.apiKeyEnv);
1132
+ }
1133
+ return refs;
1134
+ }
1135
+ /**
1136
+ * The endpoint each configured DSH provider declares (`llm-pi-ai` profiles carry a
1137
+ * `baseURL`). Used only to sharpen the data-source suggestion for providers whose
1138
+ * id says nothing — e.g. a relay pointed at `open.bigmodel.cn` is a z.ai account.
1139
+ */
1140
+ function providerEndpointHints(piAiSettings) {
1141
+ const providers = asRecord(asRecord(piAiSettings)?.providers);
1142
+ const hints = {};
1143
+ if (providers === void 0) return hints;
1144
+ for (const [providerId, raw] of Object.entries(providers)) {
1145
+ const profile = asRecord(raw);
1146
+ const baseUrl = profile === void 0 ? void 0 : profile.baseURL;
1147
+ if (typeof baseUrl !== "string") continue;
1148
+ const trimmed = baseUrl.trim();
1149
+ if (trimmed !== "") hints[providerId] = trimmed;
1150
+ }
1151
+ return hints;
1152
+ }
1153
+ /**
1154
+ * A rejected key and a broken endpoint need different copy in the UI, so HTTP
1155
+ * statuses are classified rather than collapsed into one "failed" bucket.
1156
+ */
1157
+ function failureKindForStatus(status) {
1158
+ if (status === 401 || status === 403) return "auth";
1159
+ return "http";
1160
+ }
1161
+ function messageOf(error) {
1162
+ if (error instanceof Error) return error.message;
1163
+ return String(error);
1164
+ }
1165
+ /**
1166
+ * The whole network path for one reading: ask the adapter for a request, send it,
1167
+ * decode JSON, hand the payload back to the adapter's pure parser, and translate
1168
+ * every failure into a `SourceError` the UI knows how to phrase.
1169
+ */
1170
+ async function readUsage(input, deps) {
1171
+ const requestInput = {
1172
+ mode: input.mode,
1173
+ apiKey: input.apiKey,
1174
+ ...input.baseUrl === void 0 ? {} : { baseUrl: input.baseUrl },
1175
+ ...input.pinnedBaseUrl === true ? { pinnedBaseUrl: true } : {}
1176
+ };
1177
+ const candidates = [input.source.request(requestInput), ...input.source.fallbackRequests?.(requestInput) ?? []];
1178
+ const failures = [];
1179
+ for (const [index, request] of candidates.entries()) try {
1180
+ const payload = await fetchPayload(request, deps);
1181
+ return input.source.parse(payload, input.mode);
1182
+ } catch (error) {
1183
+ const failure = toSourceError(error);
1184
+ failures.push(failure);
1185
+ if (index === candidates.length - 1) break;
1186
+ }
1187
+ throw failures.find((failure) => failure.kind !== "network") ?? failures[0] ?? new SourceError("network", "no endpoint tried");
1188
+ }
1189
+ function toSourceError(error) {
1190
+ return error instanceof SourceError ? error : new SourceError("network", messageOf(error));
1191
+ }
1192
+ async function fetchPayload(request, deps) {
1193
+ const timeoutMs = deps.timeoutMs ?? 15e3;
1194
+ const signal = timeoutMs > 0 ? AbortSignal.timeout(timeoutMs) : void 0;
1195
+ let response;
1196
+ try {
1197
+ response = await deps.fetch(request.url, signal === void 0 ? { headers: request.headers } : {
1198
+ headers: request.headers,
1199
+ signal
1200
+ });
1201
+ } catch (error) {
1202
+ throw new SourceError("network", messageOf(error));
1203
+ }
1204
+ if (!response.ok) throw new SourceError(failureKindForStatus(response.status), `HTTP ${response.status}`);
1205
+ try {
1206
+ return await response.json();
1207
+ } catch (error) {
1208
+ throw new SourceError("parse", `invalid JSON: ${messageOf(error)}`);
1209
+ }
1210
+ }
1211
+ /** Adapt `readUsage` to the shape `UsageStateStore` expects. */
1212
+ function createTargetReader(deps) {
1213
+ return (target, credentials, source) => readUsage({
1214
+ source,
1215
+ mode: target.mode,
1216
+ apiKey: credentials.apiKey,
1217
+ ...credentials.baseUrl === void 0 ? {} : { baseUrl: credentials.baseUrl },
1218
+ ...credentials.baseUrlPinned === true ? { pinnedBaseUrl: true } : {}
1219
+ }, deps);
1220
+ }
1221
+ //#endregion
1222
+ //#region src/host/refresh.ts
1223
+ function toSnapshotError(error) {
1224
+ if (error instanceof SourceError) return error.message === "" ? { kind: error.kind } : {
1225
+ kind: error.kind,
1226
+ detail: error.message
1227
+ };
1228
+ if (error instanceof Error) return error.message === "" ? { kind: "unknown" } : {
1229
+ kind: "unknown",
1230
+ detail: error.message
1231
+ };
1232
+ return { kind: "unknown" };
1233
+ }
1234
+ /**
1235
+ * Holds the last known reading of every configured source and decides when to
1236
+ * refresh it.
1237
+ *
1238
+ * The rules that matter for a plugin polling somebody else's billing endpoint:
1239
+ * a reading that succeeded is reused until the minimum interval has passed; a
1240
+ * failed attempt is never throttled (a retry may follow immediately); concurrent
1241
+ * callers share one in-flight request; and a failure never replaces a good
1242
+ * reading — it only marks it stale, so the status line can never show invented
1243
+ * numbers.
1244
+ */
1245
+ var UsageStateStore = class {
1246
+ deps;
1247
+ readings = /* @__PURE__ */ new Map();
1248
+ succeededAt = /* @__PURE__ */ new Map();
1249
+ inflight = /* @__PURE__ */ new Map();
1250
+ turnEndCancel;
1251
+ idleCancel;
1252
+ constructor(deps) {
1253
+ this.deps = deps;
1254
+ }
1255
+ /** Everything known so far, keyed by target key. */
1256
+ snapshots() {
1257
+ const result = {};
1258
+ for (const [key, snapshot] of this.readings) result[key] = snapshot;
1259
+ return result;
1260
+ }
1261
+ snapshot(key) {
1262
+ return this.readings.get(key);
1263
+ }
1264
+ /** Refresh every configured target; failures are captured, never thrown. */
1265
+ async refreshAll() {
1266
+ await Promise.all(this.deps.targets().map((target) => this.refresh(target.key)));
1267
+ }
1268
+ /**
1269
+ * Refresh one target. Returns the cached snapshot when the minimum interval has
1270
+ * not elapsed yet, unless `force` is set.
1271
+ */
1272
+ async refresh(key, options = {}) {
1273
+ const target = this.deps.targets().find((candidate) => candidate.key === key);
1274
+ if (target === void 0) return this.fail({
1275
+ key,
1276
+ sourceId: key.split(":")[0] ?? "",
1277
+ mode: "api"
1278
+ }, {
1279
+ kind: "config",
1280
+ detail: `unknown target ${key}`
1281
+ }, this.readings.get(key));
1282
+ const cached = this.readings.get(key);
1283
+ const lastSuccess = this.succeededAt.get(key);
1284
+ if (options.force !== true && cached !== void 0 && cached.error === void 0 && lastSuccess !== void 0 && this.deps.clock.now() - lastSuccess < this.deps.policy().minIntervalSeconds * 1e3) return cached;
1285
+ const running = this.inflight.get(key);
1286
+ if (running !== void 0) return running;
1287
+ const promise = this.run(target).finally(() => {
1288
+ this.inflight.delete(key);
1289
+ });
1290
+ this.inflight.set(key, promise);
1291
+ return promise;
1292
+ }
1293
+ async run(target) {
1294
+ const previous = this.readings.get(target.key);
1295
+ const source = this.deps.findSource(target.sourceId);
1296
+ if (source === void 0) return this.fail(target, {
1297
+ kind: "config",
1298
+ detail: `unknown source ${target.sourceId}`
1299
+ }, previous);
1300
+ let credential;
1301
+ try {
1302
+ credential = await this.deps.credentials.resolve(target);
1303
+ } catch (error) {
1304
+ return this.fail(target, toSnapshotError(error), previous);
1305
+ }
1306
+ if (credential === void 0) return this.fail(target, {
1307
+ kind: "config",
1308
+ detail: "no credential configured"
1309
+ }, previous);
1310
+ try {
1311
+ const reading = await this.deps.read(target, credential, source);
1312
+ const fetchedAt = this.deps.clock.now();
1313
+ const snapshot = {
1314
+ sourceId: target.sourceId,
1315
+ mode: target.mode,
1316
+ balances: reading.balances,
1317
+ windows: reading.windows,
1318
+ fetchedAt
1319
+ };
1320
+ this.readings.set(target.key, snapshot);
1321
+ this.succeededAt.set(target.key, fetchedAt);
1322
+ return snapshot;
1323
+ } catch (error) {
1324
+ return this.fail(target, toSnapshotError(error), previous);
1325
+ }
1326
+ }
1327
+ fail(target, error, previous) {
1328
+ const snapshot = previous === void 0 ? {
1329
+ sourceId: target.sourceId,
1330
+ mode: target.mode,
1331
+ balances: [],
1332
+ windows: [],
1333
+ fetchedAt: this.deps.clock.now(),
1334
+ error
1335
+ } : {
1336
+ ...previous,
1337
+ stale: true,
1338
+ error
1339
+ };
1340
+ this.readings.set(target.key, snapshot);
1341
+ return snapshot;
1342
+ }
1343
+ /**
1344
+ * A turn just finished: refresh shortly, so the provider has time to settle the
1345
+ * request we just paid for. Repeated calls within the window collapse into one.
1346
+ */
1347
+ onTurnEnd() {
1348
+ if (this.turnEndCancel !== void 0) return;
1349
+ const cancel = this.deps.clock.after(this.deps.policy().turnEndDelayMs, () => {
1350
+ this.turnEndCancel = void 0;
1351
+ this.refreshAll();
1352
+ });
1353
+ this.turnEndCancel = cancel;
1354
+ }
1355
+ /** Start the idle fallback timer; returns a stop function. */
1356
+ start() {
1357
+ if (this.idleCancel === void 0) {
1358
+ const intervalMs = this.deps.policy().intervalMinutes * 6e4;
1359
+ this.idleCancel = this.deps.clock.every(intervalMs, () => {
1360
+ this.refreshAll();
1361
+ });
1362
+ }
1363
+ return () => this.stop();
1364
+ }
1365
+ stop() {
1366
+ this.turnEndCancel?.();
1367
+ this.turnEndCancel = void 0;
1368
+ this.idleCancel?.();
1369
+ this.idleCancel = void 0;
1370
+ }
1371
+ };
1372
+ //#endregion
1373
+ //#region src/host/service.ts
1374
+ /** Cordis service key the RPC gateway resolves. */
1375
+ const USAGE_STATE_SERVICE = "usageState";
1376
+ /** RPC namespace; the wire endpoint is `<namespace>/<method>`. */
1377
+ const USAGE_STATE_RPC_NAMESPACE = "usageState";
1378
+ /**
1379
+ * The browser-facing face of the plugin. Tiny on purpose: one poll method and one
1380
+ * credential-status method. Configuration is *not* here — the settings namespace
1381
+ * already carries it to the browser over the platform's own transport.
1382
+ */
1383
+ var UsageStateService = class {
1384
+ deps;
1385
+ constructor(deps) {
1386
+ this.deps = deps;
1387
+ }
1388
+ /**
1389
+ * Refresh what is due and report every reading. Forcing bypasses the minimum
1390
+ * interval, so it is reserved for the explicit "refresh now" affordance.
1391
+ *
1392
+ * Settled rather than all: one unreachable provider must not blank the whole
1393
+ * status line for the others.
1394
+ */
1395
+ async getState(force) {
1396
+ const options = force === true ? { force: true } : {};
1397
+ await Promise.allSettled(this.deps.targets().map((target) => this.deps.store.refresh(target.key, options)));
1398
+ return {
1399
+ sources: this.deps.catalog(),
1400
+ snapshots: this.deps.store.snapshots(),
1401
+ checkedAt: this.deps.now()
1402
+ };
1403
+ }
1404
+ /** Credential status per target key, never a secret value. */
1405
+ async describeCredentials() {
1406
+ const credentials = {};
1407
+ for (const target of this.deps.targets()) credentials[target.key] = await this.deps.describe(target);
1408
+ return { credentials };
1409
+ }
1410
+ };
1411
+ //#endregion
1412
+ //#region src/host/settings.ts
1413
+ /** Settings namespace owned by this plugin; must match `/^[a-z][a-z0-9-]*$/`. */
1414
+ const USAGE_STATE_NS = "usage-state";
1415
+ const usageStateSchema = Object.assign((section) => normalizeConfig(section), { toJSON: () => ({
1416
+ uid: 1,
1417
+ refs: { 1: {
1418
+ type: "object",
1419
+ meta: {},
1420
+ dict: {}
1421
+ } }
1422
+ }) });
1423
+ /**
1424
+ * Register the namespace and keep a live in-process snapshot of it.
1425
+ *
1426
+ * `ctx.inject` is the graceful-degradation boundary: on a host without a settings
1427
+ * provider the callback never runs and the plugin's own defaults stand. `applies`
1428
+ * is metadata in this DSH version — what actually makes a change live is the
1429
+ * `watch` subscription below.
1430
+ */
1431
+ function installUsageStateSettings(ctx, onConfig) {
1432
+ ctx.inject(["settings"], (settingsCtx) => {
1433
+ const scope = settingsCtx.settings.register(USAGE_STATE_NS, usageStateSchema, { applies: "live" });
1434
+ onConfig(normalizeConfig(scope.get()));
1435
+ scope.watch((next) => onConfig(normalizeConfig(next)));
1436
+ });
1437
+ }
1438
+ //#endregion
1439
+ //#region src/shared/providers.ts
1440
+ /**
1441
+ * The scheme+host of a declared endpoint. A DSH provider profile's `baseURL` is an
1442
+ * *API* base (`https://api.z.ai/api/paas/v4`), while every adapter here appends its
1443
+ * own path to a bare origin, so only the origin is usable as our endpoint.
1444
+ */
1445
+ function originOf(url) {
1446
+ if (url === void 0) return void 0;
1447
+ try {
1448
+ const parsed = new URL(url.trim());
1449
+ return parsed.origin === "null" ? void 0 : parsed.origin;
1450
+ } catch {
1451
+ return;
1452
+ }
1453
+ }
1454
+ function withTarget(resolution) {
1455
+ if (resolution.sourceId === null || resolution.mode === null) return resolution;
1456
+ return {
1457
+ ...resolution,
1458
+ key: `${resolution.sourceId}:${resolution.mode}`
1459
+ };
1460
+ }
1461
+ /**
1462
+ * Resolve one provider. `auto` picks the source the provider id (or its endpoint)
1463
+ * suggests and that source's primary mode, so a provider DSH already has a key
1464
+ * for starts working without touching the settings page.
1465
+ */
1466
+ function resolveProvider(input) {
1467
+ const entry = input.config.providers[input.provider];
1468
+ const declared = originOf(input.endpointHint);
1469
+ const baseUrl = entry?.baseUrl ?? declared;
1470
+ const base = {
1471
+ provider: input.provider,
1472
+ ...baseUrl === void 0 ? {} : { baseUrl },
1473
+ ...entry?.baseUrl === void 0 ? {} : { baseUrlPinned: true },
1474
+ ...entry?.apiKeyRef === void 0 ? {} : { apiKeyRef: entry.apiKeyRef }
1475
+ };
1476
+ if (entry?.mode === "hidden") return {
1477
+ ...base,
1478
+ sourceId: null,
1479
+ mode: null,
1480
+ reason: "hidden"
1481
+ };
1482
+ const sourceId = entry?.sourceId ?? suggestSourceId(input.provider, input.endpointHint) ?? null;
1483
+ if (sourceId === null) return {
1484
+ ...base,
1485
+ sourceId: null,
1486
+ mode: null,
1487
+ reason: "unknown-source"
1488
+ };
1489
+ const source = input.catalog.find((candidate) => candidate.id === sourceId);
1490
+ if (source === void 0) return {
1491
+ ...base,
1492
+ sourceId,
1493
+ mode: null,
1494
+ reason: "unknown-source"
1495
+ };
1496
+ if (entry?.mode === "api" || entry?.mode === "coding-plan") {
1497
+ if (!source.modes.includes(entry.mode)) return {
1498
+ ...base,
1499
+ sourceId,
1500
+ mode: null,
1501
+ reason: "unsupported"
1502
+ };
1503
+ return withTarget({
1504
+ ...base,
1505
+ sourceId,
1506
+ mode: entry.mode,
1507
+ reason: "configured"
1508
+ });
1509
+ }
1510
+ if (source.requiresBaseUrl && baseUrl === void 0) return {
1511
+ ...base,
1512
+ sourceId,
1513
+ mode: null,
1514
+ reason: "needs-endpoint"
1515
+ };
1516
+ const mode = source.modes[0];
1517
+ if (mode === void 0) return {
1518
+ ...base,
1519
+ sourceId,
1520
+ mode: null,
1521
+ reason: "unsupported"
1522
+ };
1523
+ return withTarget({
1524
+ ...base,
1525
+ sourceId,
1526
+ mode,
1527
+ reason: "auto"
1528
+ });
1529
+ }
1530
+ //#endregion
1531
+ //#region src/host/targets.ts
1532
+ function targetKey(sourceId, mode) {
1533
+ return `${sourceId}:${mode}`;
1534
+ }
1535
+ /**
1536
+ * The set of readings the plugin should keep fresh, in display order.
1537
+ *
1538
+ * Balance and quota are account-level, so several providers (or legacy per-model
1539
+ * entries) sharing one source and mode collapse into a single target — the status
1540
+ * line never shows the same account twice. Providers that are hidden, map to no
1541
+ * known source, or ask for a mode the source cannot serve are skipped silently.
1542
+ */
1543
+ function resolveTargets(config, options = {}) {
1544
+ const sources = options.sources ?? ALL_SOURCES;
1545
+ const catalog = toSourceCatalog(sources);
1546
+ const targets = [];
1547
+ const seen = /* @__PURE__ */ new Set();
1548
+ const push = (sourceId, mode, endpoint) => {
1549
+ const source = sources.find((candidate) => candidate.id === sourceId);
1550
+ if (source === void 0 || !source.modes.includes(mode)) return;
1551
+ const key = targetKey(sourceId, mode);
1552
+ if (seen.has(key)) return;
1553
+ seen.add(key);
1554
+ targets.push({
1555
+ key,
1556
+ sourceId,
1557
+ mode,
1558
+ ...endpoint?.baseUrl === void 0 ? {} : { baseUrl: endpoint.baseUrl },
1559
+ ...endpoint?.pinned === true ? { baseUrlPinned: true } : {}
1560
+ });
1561
+ };
1562
+ /**
1563
+ * The live runtime's provider ids, when we have them. An empty list means "could
1564
+ * not ask" (the `llm` service is missing on early ticks), not "nothing exists", so
1565
+ * it must not suppress the configured entries.
1566
+ */
1567
+ const live = options.providers !== void 0 && options.providers.length > 0 ? new Set(options.providers) : void 0;
1568
+ /**
1569
+ * Whether a stored entry may still produce requests. The entry itself is kept — a
1570
+ * provider that comes back keeps the mode the user chose for it — but a provider
1571
+ * DSH no longer has must not keep an account being polled behind an invisible row.
1572
+ */
1573
+ const pollable = (provider) => live === void 0 || live.has(provider);
1574
+ for (const entry of config.models) {
1575
+ if (entry.mode === "hidden" || entry.sourceId === null) continue;
1576
+ if (!pollable(entry.provider)) continue;
1577
+ push(entry.sourceId, entry.mode);
1578
+ }
1579
+ const providers = /* @__PURE__ */ new Set();
1580
+ for (const provider of [...config.order, ...Object.keys(config.providers)]) if (pollable(provider)) providers.add(provider);
1581
+ for (const provider of options.providers ?? []) providers.add(provider);
1582
+ for (const provider of providers) {
1583
+ const resolution = resolveProvider({
1584
+ provider,
1585
+ config,
1586
+ catalog,
1587
+ ...options.endpointHints?.[provider] === void 0 ? {} : { endpointHint: options.endpointHints[provider] }
1588
+ });
1589
+ if (resolution.sourceId !== null && resolution.mode !== null) push(resolution.sourceId, resolution.mode, {
1590
+ ...resolution.baseUrl === void 0 ? {} : { baseUrl: resolution.baseUrl },
1591
+ ...resolution.baseUrlPinned === true ? { pinned: true } : {}
1592
+ });
1593
+ }
1594
+ return targets;
1595
+ }
1596
+ //#endregion
1597
+ //#region src/index.ts
1598
+ const name = "usage-state";
1599
+ /** `timer` is what provides `ctx.timeout` / `ctx.interval`. */
1600
+ const inject = ["timer"];
1601
+ function readNamespace(ctx, namespace) {
1602
+ const settings = ctx.get("settings");
1603
+ if (settings?.get === void 0) return void 0;
1604
+ try {
1605
+ return settings.get(namespace);
1606
+ } catch {
1607
+ return;
1608
+ }
1609
+ }
1610
+ /**
1611
+ * Credential access through the host's credential provider. A provider that is
1612
+ * absent or throws must degrade to "not configured" — never to a crash inside a
1613
+ * refresh loop.
1614
+ */
1615
+ function createCredentialLookup(ctx, fallback = {}) {
1616
+ const provider = ctx.get("credentials");
1617
+ const platform = {
1618
+ resolve: async (ref) => {
1619
+ if (provider === void 0) return void 0;
1620
+ try {
1621
+ return await provider.resolve(ref);
1622
+ } catch {
1623
+ return;
1624
+ }
1625
+ },
1626
+ describe: async (ref) => {
1627
+ if (provider === void 0) return {
1628
+ configured: false,
1629
+ writable: false
1630
+ };
1631
+ try {
1632
+ return await provider.describe(ref);
1633
+ } catch {
1634
+ return {
1635
+ configured: false,
1636
+ writable: false
1637
+ };
1638
+ }
1639
+ }
1640
+ };
1641
+ return fallback === false ? platform : withCredentialFallback(platform, fallback);
1642
+ }
1643
+ /**
1644
+ * Publish the service under the name the RPC gateway resolves.
1645
+ *
1646
+ * The binding shape is exact and unforgiving (`dsh-api-gateway`'s `readBinding`):
1647
+ * - `service` must be the service OBJECT itself, not its name;
1648
+ * - `serviceKey` must be the registered name;
1649
+ * - `namespace` must equal the RPC namespace.
1650
+ * A mismatch fails every dispatch with `gateway/binding-invalid` — which looks
1651
+ * like "the plugin silently returns nothing" from the browser. Non-enumerable so
1652
+ * it stays out of any serialization of the service.
1653
+ */
1654
+ function provideUsageState(ctx, service) {
1655
+ Object.defineProperty(service, "typertRemote", {
1656
+ configurable: false,
1657
+ enumerable: false,
1658
+ writable: false,
1659
+ value: {
1660
+ service,
1661
+ serviceKey: USAGE_STATE_SERVICE,
1662
+ namespace: USAGE_STATE_RPC_NAMESPACE
1663
+ }
1664
+ });
1665
+ ctx.provide(USAGE_STATE_SERVICE, service);
1666
+ return service;
1667
+ }
1668
+ function isTurnEnd(event) {
1669
+ return event !== null && typeof event === "object" && event.type === "turn/end";
1670
+ }
1671
+ /** Wire the whole host half; exported so tests can drive it with a fake context. */
1672
+ function createUsageState(ctx, deps = {}) {
1673
+ const now = deps.now ?? (() => Date.now());
1674
+ const fetchImpl = deps.fetch ?? globalThis.fetch;
1675
+ const catalog = toSourceCatalog(ALL_SOURCES);
1676
+ const lookup = createCredentialLookup(ctx, deps.credentialFallback ?? {});
1677
+ let config = DEFAULT_CONFIG;
1678
+ installUsageStateSettings(ctx, (next) => {
1679
+ config = next;
1680
+ });
1681
+ /**
1682
+ * The plugin's own configuration says nothing about which providers exist, so
1683
+ * ask the host's LLM runtime. This is what makes zero configuration work: a
1684
+ * provider nobody configured still resolves to its suggested source.
1685
+ */
1686
+ const providerFacts = () => {
1687
+ const llm = ctx.get("llm");
1688
+ const ids = [];
1689
+ try {
1690
+ for (const provider of llm?.listProviders?.() ?? []) if (typeof provider?.id === "string" && provider.id !== "") ids.push(provider.id);
1691
+ } catch {}
1692
+ return {
1693
+ ids,
1694
+ hints: providerEndpointHints(readNamespace(ctx, "llm-pi-ai"))
1695
+ };
1696
+ };
1697
+ const targets = () => {
1698
+ const { ids, hints } = providerFacts();
1699
+ return resolveTargets(config, {
1700
+ providers: ids,
1701
+ endpointHints: hints
1702
+ });
1703
+ };
1704
+ const optionsFor = (target) => {
1705
+ const overrideRef = config.sources[target.sourceId]?.apiKeyRef;
1706
+ return {
1707
+ ...overrideRef === void 0 ? {} : { overrideRef },
1708
+ preferredRefs: providerCredentialRefs({
1709
+ sourceId: target.sourceId,
1710
+ deepseekSettings: readNamespace(ctx, "llm-deepseek"),
1711
+ piAiSettings: readNamespace(ctx, "llm-pi-ai")
1712
+ })
1713
+ };
1714
+ };
1715
+ const store = new UsageStateStore({
1716
+ clock: {
1717
+ now,
1718
+ after: (ms, fn) => ctx.timeout(fn, ms),
1719
+ every: (ms, fn) => ctx.interval(fn, ms)
1720
+ },
1721
+ policy: () => config.refresh,
1722
+ targets,
1723
+ findSource,
1724
+ credentials: { resolve: async (target) => {
1725
+ const source = findSource(target.sourceId);
1726
+ if (source === void 0) return void 0;
1727
+ const resolved = await resolveApiKey(source, target.mode, optionsFor(target), lookup);
1728
+ if (resolved === void 0) return void 0;
1729
+ const pinnedBaseUrl = config.sources[target.sourceId]?.baseUrl;
1730
+ const baseUrl = pinnedBaseUrl ?? target.baseUrl;
1731
+ if (baseUrl === void 0) return resolved;
1732
+ return {
1733
+ ...resolved,
1734
+ baseUrl,
1735
+ ...pinnedBaseUrl === void 0 ? {} : { baseUrlPinned: true }
1736
+ };
1737
+ } },
1738
+ read: createTargetReader({ fetch: fetchImpl })
1739
+ });
1740
+ const service = new UsageStateService({
1741
+ store,
1742
+ targets,
1743
+ catalog: () => catalog,
1744
+ describe: async (target) => {
1745
+ const source = findSource(target.sourceId);
1746
+ if (source === void 0) return {
1747
+ candidates: [],
1748
+ configured: false
1749
+ };
1750
+ return describeCredentials(source, target.mode, optionsFor(target), lookup);
1751
+ },
1752
+ now
1753
+ });
1754
+ ctx.on("session/event", (...args) => {
1755
+ if (isTurnEnd(args[1])) store.onTurnEnd();
1756
+ });
1757
+ store.start();
1758
+ return provideUsageState(ctx, service);
1759
+ }
1760
+ function apply(ctx) {
1761
+ createUsageState(ctx);
1762
+ }
1763
+ //#endregion
1764
+ export { apply, createCredentialLookup, createUsageState, inject, name, provideUsageState };