dsh-usage-state 0.4.4 → 0.4.6

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/lib/index.js CHANGED
@@ -1,6 +1,7 @@
1
1
  import { readFileSync } from "node:fs";
2
2
  import { homedir } from "node:os";
3
3
  import { join } from "node:path";
4
+ import { createHash } from "node:crypto";
4
5
  import z from "@deepseek-ai/schemastery";
5
6
  //#region src/shared/config.ts
6
7
  const DEFAULT_CONFIG = {
@@ -198,7 +199,7 @@ const HOST_HINTS = [
198
199
  },
199
200
  {
200
201
  sourceId: "kimi",
201
- pattern: /(^|\.)(api\.kimi\.com|api\.moonshot\.cn|moonshot\.cn)$/
202
+ pattern: /(^|\.)(api\.kimi\.(com|ai)|api\.moonshot\.(cn|ai)|moonshot\.cn)$/
202
203
  },
203
204
  {
204
205
  sourceId: "opencode",
@@ -224,6 +225,111 @@ function suggestSourceId(providerId, baseUrl) {
224
225
  return "sub2api";
225
226
  }
226
227
  //#endregion
228
+ //#region src/shared/providers.ts
229
+ /**
230
+ * The scheme+host of a declared endpoint. A DSH provider profile's `baseURL` is an
231
+ * *API* base (`https://api.z.ai/api/paas/v4`), while every adapter here appends its
232
+ * own path to a bare origin, so only the origin is usable as our endpoint.
233
+ */
234
+ function originOf(url) {
235
+ if (url === void 0) return void 0;
236
+ try {
237
+ const parsed = new URL(url.trim());
238
+ return parsed.origin === "null" ? void 0 : parsed.origin;
239
+ } catch {
240
+ return;
241
+ }
242
+ }
243
+ /** Kimi Code and Moonshot use distinct keys and billing endpoints. */
244
+ function kimiMode(provider, endpoint) {
245
+ const origin = originOf(endpoint);
246
+ if (origin !== void 0) {
247
+ const host = new URL(origin).hostname;
248
+ if (host === "api.kimi.com" || host === "api.kimi.ai") return "coding-plan";
249
+ if (host === "api.moonshot.cn" || host === "api.moonshot.ai") return "api";
250
+ }
251
+ return /(?:^|[^a-z])kimi[-_ ]?(?:for[-_ ]?coding|coding|code)(?:$|[^a-z])/i.test(provider) ? "coding-plan" : "api";
252
+ }
253
+ function withTarget(resolution) {
254
+ if (resolution.sourceId === null || resolution.mode === null) return resolution;
255
+ return {
256
+ ...resolution,
257
+ key: `${resolution.sourceId}:${resolution.mode}`
258
+ };
259
+ }
260
+ /**
261
+ * Resolve one provider. `auto` picks the source the provider id (or its endpoint)
262
+ * suggests and that source's primary mode, so a provider DSH already has a key
263
+ * for starts working without touching the settings page.
264
+ */
265
+ function resolveProvider(input) {
266
+ const entry = input.config.providers[input.provider];
267
+ const sourceId = entry?.sourceId ?? suggestSourceId(input.provider, input.endpointHint) ?? null;
268
+ const defaults = sourceId === null ? void 0 : input.config.sources[sourceId];
269
+ const declared = originOf(input.endpointHint);
270
+ const explicitBaseUrl = entry?.baseUrl ?? defaults?.baseUrl;
271
+ const baseUrl = explicitBaseUrl ?? declared;
272
+ const apiKeyRef = entry?.apiKeyRef ?? defaults?.apiKeyRef;
273
+ const base = {
274
+ provider: input.provider,
275
+ ...baseUrl === void 0 ? {} : { baseUrl },
276
+ ...explicitBaseUrl === void 0 ? {} : { baseUrlPinned: true },
277
+ ...apiKeyRef === void 0 ? {} : { apiKeyRef }
278
+ };
279
+ if (entry?.mode === "hidden") return {
280
+ ...base,
281
+ sourceId: null,
282
+ mode: null,
283
+ reason: "hidden"
284
+ };
285
+ if (sourceId === null) return {
286
+ ...base,
287
+ sourceId: null,
288
+ mode: null,
289
+ reason: "unknown-source"
290
+ };
291
+ const source = input.catalog.find((candidate) => candidate.id === sourceId);
292
+ if (source === void 0) return {
293
+ ...base,
294
+ sourceId,
295
+ mode: null,
296
+ reason: "unknown-source"
297
+ };
298
+ if (entry?.mode === "api" || entry?.mode === "coding-plan") {
299
+ if (!source.modes.includes(entry.mode)) return {
300
+ ...base,
301
+ sourceId,
302
+ mode: null,
303
+ reason: "unsupported"
304
+ };
305
+ return withTarget({
306
+ ...base,
307
+ sourceId,
308
+ mode: entry.mode,
309
+ reason: "configured"
310
+ });
311
+ }
312
+ if (source.requiresBaseUrl && baseUrl === void 0) return {
313
+ ...base,
314
+ sourceId,
315
+ mode: null,
316
+ reason: "needs-endpoint"
317
+ };
318
+ const mode = sourceId === "kimi" ? kimiMode(input.provider, baseUrl) : source.modes[0];
319
+ if (mode === void 0 || !source.modes.includes(mode)) return {
320
+ ...base,
321
+ sourceId,
322
+ mode: null,
323
+ reason: "unsupported"
324
+ };
325
+ return withTarget({
326
+ ...base,
327
+ sourceId,
328
+ mode,
329
+ reason: "auto"
330
+ });
331
+ }
332
+ //#endregion
227
333
  //#region src/host/sources/normalize.ts
228
334
  /** Shared normalization helpers for provider payloads. */
229
335
  /** Numbers and numeric strings to a finite number; anything else is absent. */
@@ -309,10 +415,12 @@ function pickBalanceInfo(infos) {
309
415
  for (const raw of infos) {
310
416
  if (raw === null || typeof raw !== "object") continue;
311
417
  const entry = raw;
418
+ const amount = toFiniteNumber(entry.total_balance);
419
+ if (amount === void 0) continue;
312
420
  const granted = toFiniteNumber(entry.granted_balance);
313
421
  const toppedUp = toFiniteNumber(entry.topped_up_balance);
314
422
  entries.push({
315
- amount: toFiniteNumber(entry.total_balance) ?? 0,
423
+ amount,
316
424
  currency: typeof entry.currency === "string" ? entry.currency : "",
317
425
  ...granted === void 0 ? {} : { granted },
318
426
  ...toppedUp === void 0 ? {} : { toppedUp }
@@ -363,7 +471,9 @@ const KIMI_CLI_USER_AGENT = "KimiCLI/1.6";
363
471
  const WINDOW_RANK$2 = {
364
472
  "5h": 0,
365
473
  "1d": 1,
366
- "7d": 2
474
+ "7d": 2,
475
+ month: 3,
476
+ "month-code": 4
367
477
  };
368
478
  function asRecord$4(value) {
369
479
  return value !== null && typeof value === "object" && !Array.isArray(value) ? value : void 0;
@@ -375,35 +485,36 @@ function firstFinite(candidates) {
375
485
  }
376
486
  }
377
487
  /**
378
- * Moonshot's balance is documented in cents by some sources and in yuan by others,
379
- * and there is no field that disambiguates the two. This mirrors the heuristic used
380
- * by the plugin this project replaces: amounts of 100 or more are read as cents.
381
- * Verified against a real key before trusting the absolute value.
488
+ * Moonshot's official /v1/users/me/balance fields are already in yuan.
489
+ * https://platform.kimi.com/docs/api/balance
490
+ * Amount size never changes the unit; retain the existing two-decimal rounding.
382
491
  */
383
492
  function moonshotBalanceToYuan(value) {
384
- const yuan = value >= 100 ? value / 100 : value;
385
- return Math.round(yuan * 100) / 100;
493
+ return Math.round(value * 100) / 100;
386
494
  }
387
495
  /** Used percentage from an explicit used/limit pair, or by inverting remaining. */
388
496
  function percentFrom(record) {
389
497
  const limit = toFiniteNumber(record.limit);
390
498
  if (limit === void 0 || limit <= 0) return null;
391
499
  const used = toFiniteNumber(record.used);
392
- if (used !== void 0) return clampPercent(used / limit * 100);
500
+ if (used !== void 0 && used >= 0) return clampPercent(Math.min(1, used / limit) * 100);
393
501
  const remaining = toFiniteNumber(record.remaining);
394
- if (remaining !== void 0) return clampPercent((limit - remaining) / limit * 100);
502
+ if (remaining !== void 0 && remaining >= 0 && remaining <= limit) return clampPercent((limit - remaining) / limit * 100);
395
503
  return null;
396
504
  }
397
505
  /** `{ duration: 5, timeUnit: 'hour' }` -> `5h`, and weekly variants -> `7d`. */
398
506
  function windowIdFrom(window) {
399
507
  if (window === void 0) return void 0;
400
- const unit = typeof window.timeUnit === "string" ? window.timeUnit.trim().toLowerCase() : "";
508
+ const unit = typeof window.timeUnit === "string" ? window.timeUnit.trim().toLowerCase().replace(/^time_unit_/, "") : "";
401
509
  const duration = toFiniteNumber(window.duration);
402
510
  if (unit === "" || duration === void 0 || duration <= 0) return void 0;
403
- if (unit.startsWith("hour")) return `${duration}h`;
511
+ if (unit.startsWith("hour")) return duration % 24 === 0 ? `${duration / 24}d` : `${duration}h`;
404
512
  if (unit.startsWith("day")) return duration === 7 ? "7d" : `${duration}d`;
405
513
  if (unit.startsWith("week")) return duration === 1 ? "7d" : `${duration * 7}d`;
406
- if (unit.startsWith("minute")) return `${duration}m`;
514
+ if (unit.startsWith("minute")) return windowIdFrom({
515
+ duration: duration / 60,
516
+ timeUnit: "hour"
517
+ });
407
518
  if (unit.startsWith("month")) return "30d";
408
519
  return `${duration}${unit.slice(0, 4)}`;
409
520
  }
@@ -413,6 +524,7 @@ function sortWindows$2(windows) {
413
524
  /** `GET /v1/users/me/balance` — pay-as-you-go wallet, CNY. */
414
525
  function parseMoonshotBalance(payload) {
415
526
  const root = asRecord$4(payload);
527
+ rejectErrorEnvelope(root);
416
528
  const nested = root === void 0 ? void 0 : asRecord$4(root.data);
417
529
  const value = firstFinite([
418
530
  root?.available_balance,
@@ -430,19 +542,44 @@ function parseMoonshotBalance(payload) {
430
542
  windows: []
431
543
  };
432
544
  }
545
+ function rejectErrorEnvelope(root) {
546
+ if (root?.error != null || root?.status === false || root?.code !== void 0 && root.code !== 0 && root.code !== "0") throw new SourceError("parse", "Kimi response contains an API error");
547
+ }
433
548
  /**
434
549
  * `GET /coding/v1/usages` — Kimi Code subscription. The top-level `usage` is the
435
- * weekly quota; `limits[]` carries the rolling windows (5h among them). Neither is
436
- * officially documented, so every field is optional here.
550
+ * weekly quota in legacy responses; `limits[]` carries rolling windows. Current
551
+ * official client reads ratio pools in `usages` (see docs/adapters.md).
437
552
  */
438
553
  function parseKimiCodeUsage(payload) {
439
554
  const root = asRecord$4(payload);
440
555
  if (root === void 0) throw new SourceError("parse", "Kimi Code usage response is not a JSON object");
556
+ rejectErrorEnvelope(root);
441
557
  const found = /* @__PURE__ */ new Map();
558
+ const usages = asRecord$4(root.usages);
559
+ for (const [field, id] of [
560
+ ["limit_5h", "5h"],
561
+ ["limit_7d", "7d"],
562
+ ["limit_month_total", "month"],
563
+ ["limit_month_code", "month-code"]
564
+ ]) {
565
+ const pool = asRecord$4(usages?.[field]);
566
+ const ratio = toFiniteNumber(pool?.used_ratio);
567
+ if (ratio === void 0 || ratio < 0) continue;
568
+ const usedPercent = clampPercent(Math.min(1, ratio) * 100);
569
+ const resetsAt = normalizeResetAt(pool?.reset_time);
570
+ found.set(id, resetsAt === void 0 ? {
571
+ id,
572
+ usedPercent
573
+ } : {
574
+ id,
575
+ usedPercent,
576
+ resetsAt
577
+ });
578
+ }
442
579
  const usage = asRecord$4(root.usage);
443
580
  if (usage !== void 0) {
444
581
  const usedPercent = percentFrom(usage);
445
- if (usedPercent !== null) {
582
+ if (usedPercent !== null && !found.has("7d")) {
446
583
  const resetsAt = normalizeResetAt(usage.resetTime ?? usage.reset_at ?? usage.resetsAt);
447
584
  found.set("7d", resetsAt === void 0 ? {
448
585
  id: "7d",
@@ -500,7 +637,8 @@ const kimi = {
500
637
  },
501
638
  request(input) {
502
639
  const codingPlan = input.mode === "coding-plan";
503
- const base = normalizeBaseUrl(input.baseUrl) ?? (codingPlan ? KIMI_CODE_BASE_URL : MOONSHOT_BASE_URL);
640
+ const normalized = normalizeBaseUrl(input.baseUrl) ?? (codingPlan ? KIMI_CODE_BASE_URL : MOONSHOT_BASE_URL);
641
+ const base = codingPlan ? normalized.replace(/\/coding$/i, "") : normalized;
504
642
  return {
505
643
  url: codingPlan ? `${base}/coding/v1/usages` : `${base}/v1/users/me/balance`,
506
644
  headers: {
@@ -1128,15 +1266,23 @@ function providerCredentialRefs(input) {
1128
1266
  for (const [providerId, raw] of Object.entries(providers)) {
1129
1267
  const profile = asRecord(raw);
1130
1268
  if (profile === void 0) continue;
1131
- if (suggest(providerId, typeof profile.baseURL === "string" ? profile.baseURL : void 0) !== input.sourceId) continue;
1269
+ const baseUrl = typeof profile.baseURL === "string" ? profile.baseURL : void 0;
1270
+ if (suggest(providerId, baseUrl) !== input.sourceId) continue;
1271
+ if (input.sourceId === "kimi" && input.mode !== void 0) {
1272
+ const entry = input.config?.providers[providerId];
1273
+ const choice = entry?.mode;
1274
+ if (choice === "hidden") continue;
1275
+ const endpoint = entry?.baseUrl ?? input.config?.sources.kimi?.baseUrl ?? baseUrl;
1276
+ if ((choice === "api" || choice === "coding-plan" ? choice : kimiMode(providerId, endpoint)) !== input.mode) continue;
1277
+ }
1132
1278
  push(profile.apiKeyEnv);
1133
1279
  }
1134
1280
  return refs;
1135
1281
  }
1136
1282
  /**
1137
1283
  * The endpoint each configured DSH provider declares (`llm-pi-ai` profiles carry a
1138
- * `baseURL`). Used only to sharpen the data-source suggestion for providers whose
1139
- * id says nothing — e.g. a relay pointed at `open.bigmodel.cn` is a z.ai account.
1284
+ * `baseURL`). Its origin also supplies the account request endpoint, and a
1285
+ * sanitized origin is shared with the browser for consistent resolution.
1140
1286
  */
1141
1287
  function providerEndpointHints(piAiSettings) {
1142
1288
  const providers = asRecord(asRecord(piAiSettings)?.providers);
@@ -1206,7 +1352,8 @@ async function fetchPayload(request, deps) {
1206
1352
  try {
1207
1353
  return await response.json();
1208
1354
  } catch (error) {
1209
- throw new SourceError("parse", `invalid JSON: ${messageOf(error)}`);
1355
+ if (error instanceof SyntaxError) throw new SourceError("parse", `invalid JSON: ${messageOf(error)}`);
1356
+ throw new SourceError("network", messageOf(error));
1210
1357
  }
1211
1358
  }
1212
1359
  /** Adapt `readUsage` to the shape `UsageStateStore` expects. */
@@ -1221,16 +1368,25 @@ function createTargetReader(deps) {
1221
1368
  }
1222
1369
  //#endregion
1223
1370
  //#region src/host/refresh.ts
1224
- function toSnapshotError(error) {
1225
- if (error instanceof SourceError) return error.message === "" ? { kind: error.kind } : {
1226
- kind: error.kind,
1227
- detail: error.message
1371
+ function toSnapshotError(error, secret) {
1372
+ const kind = error instanceof SourceError ? error.kind : "unknown";
1373
+ if (!(error instanceof Error)) return { kind };
1374
+ const message = secret === void 0 || secret === "" ? error.message : error.message.replaceAll(secret, "[redacted]");
1375
+ return message === "" ? { kind } : {
1376
+ kind,
1377
+ detail: message
1228
1378
  };
1229
- if (error instanceof Error) return error.message === "" ? { kind: "unknown" } : {
1230
- kind: "unknown",
1231
- detail: error.message
1232
- };
1233
- return { kind: "unknown" };
1379
+ }
1380
+ function targetSignature(target) {
1381
+ return JSON.stringify(target === void 0 ? null : [
1382
+ target.key,
1383
+ target.sourceId,
1384
+ target.mode,
1385
+ target.baseUrl,
1386
+ target.baseUrlPinned === true,
1387
+ target.apiKeyRef,
1388
+ target.preferredRefs
1389
+ ]);
1234
1390
  }
1235
1391
  /**
1236
1392
  * Holds the last known reading of every configured source and decides when to
@@ -1247,23 +1403,56 @@ var UsageStateStore = class {
1247
1403
  deps;
1248
1404
  readings = /* @__PURE__ */ new Map();
1249
1405
  succeededAt = /* @__PURE__ */ new Map();
1406
+ identities = /* @__PURE__ */ new Map();
1407
+ generations = /* @__PURE__ */ new Map();
1408
+ targetSignatures = /* @__PURE__ */ new Map();
1250
1409
  inflight = /* @__PURE__ */ new Map();
1251
1410
  turnEndCancel;
1252
1411
  idleCancel;
1412
+ idleIntervalMs;
1413
+ stopped = false;
1253
1414
  constructor(deps) {
1254
1415
  this.deps = deps;
1255
1416
  }
1417
+ invalidate(key) {
1418
+ this.generations.set(key, (this.generations.get(key) ?? 0) + 1);
1419
+ this.identities.delete(key);
1420
+ this.readings.delete(key);
1421
+ this.succeededAt.delete(key);
1422
+ this.inflight.delete(key);
1423
+ }
1424
+ currentTargets() {
1425
+ const targets = this.deps.targets().map((target) => ({ ...target }));
1426
+ const active = new Set(targets.map((target) => target.key));
1427
+ for (const key of this.targetSignatures.keys()) {
1428
+ if (active.has(key)) continue;
1429
+ this.invalidate(key);
1430
+ this.targetSignatures.delete(key);
1431
+ }
1432
+ for (const target of targets) {
1433
+ const signature = targetSignature(target);
1434
+ const previous = this.targetSignatures.get(target.key);
1435
+ if (previous !== void 0 && previous !== signature) this.invalidate(target.key);
1436
+ this.targetSignatures.set(target.key, signature);
1437
+ }
1438
+ return targets;
1439
+ }
1256
1440
  /** Everything known so far, keyed by target key. */
1257
1441
  snapshots() {
1442
+ if (this.stopped) return {};
1443
+ this.currentTargets();
1258
1444
  const result = {};
1259
1445
  for (const [key, snapshot] of this.readings) result[key] = snapshot;
1260
1446
  return result;
1261
1447
  }
1262
1448
  snapshot(key) {
1449
+ if (this.stopped) return void 0;
1450
+ this.currentTargets();
1263
1451
  return this.readings.get(key);
1264
1452
  }
1265
1453
  /** Refresh every configured target; failures are captured, never thrown. */
1266
1454
  async refreshAll() {
1455
+ if (this.stopped) return;
1267
1456
  await Promise.all(this.deps.targets().map((target) => this.refresh(target.key)));
1268
1457
  }
1269
1458
  /**
@@ -1271,45 +1460,83 @@ var UsageStateStore = class {
1271
1460
  * not elapsed yet, unless `force` is set.
1272
1461
  */
1273
1462
  async refresh(key, options = {}) {
1274
- const target = this.deps.targets().find((candidate) => candidate.key === key);
1275
- if (target === void 0) return this.fail({
1276
- key,
1463
+ if (this.stopped) return {
1277
1464
  sourceId: key.split(":")[0] ?? "",
1278
- mode: "api"
1279
- }, {
1280
- kind: "config",
1281
- detail: `unknown target ${key}`
1282
- }, this.readings.get(key));
1283
- const cached = this.readings.get(key);
1284
- const lastSuccess = this.succeededAt.get(key);
1285
- 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;
1286
- const running = this.inflight.get(key);
1287
- if (running !== void 0) return running;
1288
- const promise = this.run(target).finally(() => {
1289
- this.inflight.delete(key);
1290
- });
1291
- this.inflight.set(key, promise);
1292
- return promise;
1293
- }
1294
- async run(target) {
1295
- const previous = this.readings.get(target.key);
1465
+ mode: key.endsWith(":coding-plan") ? "coding-plan" : "api",
1466
+ balances: [],
1467
+ windows: [],
1468
+ fetchedAt: this.deps.clock.now(),
1469
+ error: {
1470
+ kind: "config",
1471
+ detail: "refresh stopped"
1472
+ }
1473
+ };
1474
+ const target = this.currentTargets().find((candidate) => candidate.key === key);
1475
+ if (target === void 0) return {
1476
+ sourceId: key.split(":")[0] ?? "",
1477
+ mode: key.endsWith(":coding-plan") ? "coding-plan" : "api",
1478
+ balances: [],
1479
+ windows: [],
1480
+ fetchedAt: this.deps.clock.now(),
1481
+ error: {
1482
+ kind: "config",
1483
+ detail: `unknown target ${key}`
1484
+ }
1485
+ };
1486
+ const signature = targetSignature(target);
1487
+ const generationAtLookup = this.generations.get(key) ?? 0;
1296
1488
  const source = this.deps.findSource(target.sourceId);
1297
1489
  if (source === void 0) return this.fail(target, {
1298
1490
  kind: "config",
1299
1491
  detail: `unknown source ${target.sourceId}`
1300
- }, previous);
1492
+ }, this.readings.get(key));
1301
1493
  let credential;
1302
1494
  try {
1303
1495
  credential = await this.deps.credentials.resolve(target);
1304
1496
  } catch (error) {
1305
- return this.fail(target, toSnapshotError(error), previous);
1497
+ if (this.stopped) return this.refresh(key, options);
1498
+ if (signature !== targetSignature(this.currentTargets().find((candidate) => candidate.key === key)) || (this.generations.get(key) ?? 0) !== generationAtLookup) return this.refresh(key, options);
1499
+ return this.fail(target, toSnapshotError(error), this.readings.get(key));
1500
+ }
1501
+ if (this.stopped) return this.refresh(key, options);
1502
+ if (signature !== targetSignature(this.currentTargets().find((candidate) => candidate.key === key))) return this.refresh(key, options);
1503
+ const identity = createHash("sha256").update(JSON.stringify([
1504
+ target.sourceId,
1505
+ target.mode,
1506
+ credential?.baseUrl ?? target.baseUrl ?? source.defaultBaseUrl(target.mode),
1507
+ credential?.baseUrlPinned === true || target.baseUrlPinned === true,
1508
+ credential?.ref ?? target.apiKeyRef,
1509
+ credential?.apiKey
1510
+ ])).digest("hex");
1511
+ if ((this.generations.get(key) ?? 0) !== generationAtLookup && this.identities.get(key) !== identity) return this.refresh(key, options);
1512
+ if (this.identities.get(key) !== identity) {
1513
+ this.identities.set(key, identity);
1514
+ this.generations.set(key, (this.generations.get(key) ?? 0) + 1);
1515
+ this.readings.delete(key);
1516
+ this.succeededAt.delete(key);
1517
+ this.inflight.delete(key);
1306
1518
  }
1307
1519
  if (credential === void 0) return this.fail(target, {
1308
1520
  kind: "config",
1309
1521
  detail: "no credential configured"
1310
- }, previous);
1522
+ }, this.readings.get(key));
1523
+ const cached = this.readings.get(key);
1524
+ const lastSuccess = this.succeededAt.get(key);
1525
+ if (options.force !== true && cached !== void 0 && (options.onlyIfMissing === true || cached.error === void 0 && lastSuccess !== void 0 && this.deps.clock.now() - lastSuccess < this.deps.policy().minIntervalSeconds * 1e3)) return cached;
1526
+ const running = this.inflight.get(key);
1527
+ if (running !== void 0) return running;
1528
+ const generation = this.generations.get(key) ?? 0;
1529
+ const promise = this.run(target, credential, source, generation).finally(() => {
1530
+ if (this.inflight.get(key) === promise) this.inflight.delete(key);
1531
+ });
1532
+ this.inflight.set(key, promise);
1533
+ return promise;
1534
+ }
1535
+ async run(target, credential, source, generation) {
1536
+ const previous = this.readings.get(target.key);
1311
1537
  try {
1312
1538
  const reading = await this.deps.read(target, credential, source);
1539
+ if (this.generations.get(target.key) !== generation) return this.refresh(target.key);
1313
1540
  const fetchedAt = this.deps.clock.now();
1314
1541
  const snapshot = {
1315
1542
  sourceId: target.sourceId,
@@ -1322,7 +1549,8 @@ var UsageStateStore = class {
1322
1549
  this.succeededAt.set(target.key, fetchedAt);
1323
1550
  return snapshot;
1324
1551
  } catch (error) {
1325
- return this.fail(target, toSnapshotError(error), previous);
1552
+ if (this.generations.get(target.key) !== generation) return this.refresh(target.key);
1553
+ return this.fail(target, toSnapshotError(error, credential.apiKey), previous);
1326
1554
  }
1327
1555
  }
1328
1556
  fail(target, error, previous) {
@@ -1353,10 +1581,22 @@ var UsageStateStore = class {
1353
1581
  });
1354
1582
  this.turnEndCancel = cancel;
1355
1583
  }
1584
+ /** Apply a live policy edit without leaving the previous idle timer running. */
1585
+ reconfigure() {
1586
+ if (this.stopped) return;
1587
+ this.currentTargets();
1588
+ if (this.idleCancel === void 0) return;
1589
+ if (this.idleIntervalMs === this.deps.policy().intervalMinutes * 6e4) return;
1590
+ this.idleCancel();
1591
+ this.idleCancel = void 0;
1592
+ this.start();
1593
+ }
1356
1594
  /** Start the idle fallback timer; returns a stop function. */
1357
1595
  start() {
1596
+ this.stopped = false;
1358
1597
  if (this.idleCancel === void 0) {
1359
1598
  const intervalMs = this.deps.policy().intervalMinutes * 6e4;
1599
+ this.idleIntervalMs = intervalMs;
1360
1600
  this.idleCancel = this.deps.clock.every(intervalMs, () => {
1361
1601
  this.refreshAll();
1362
1602
  });
@@ -1364,6 +1604,8 @@ var UsageStateStore = class {
1364
1604
  return () => this.stop();
1365
1605
  }
1366
1606
  stop() {
1607
+ this.stopped = true;
1608
+ for (const key of this.targetSignatures.keys()) this.invalidate(key);
1367
1609
  this.turnEndCancel?.();
1368
1610
  this.turnEndCancel = void 0;
1369
1611
  this.idleCancel?.();
@@ -1387,18 +1629,20 @@ var UsageStateService = class {
1387
1629
  this.deps = deps;
1388
1630
  }
1389
1631
  /**
1390
- * Refresh what is due and report every reading. Forcing bypasses the minimum
1391
- * interval, so it is reserved for the explicit "refresh now" affordance.
1632
+ * Initialize a missing account identity, otherwise report cached readings.
1633
+ * The host owns periodic/turn refreshes; polling this method cannot shorten
1634
+ * that cadence. Forcing is reserved for an explicit refresh.
1392
1635
  *
1393
1636
  * Settled rather than all: one unreachable provider must not blank the whole
1394
1637
  * status line for the others.
1395
1638
  */
1396
1639
  async getState(force) {
1397
- const options = force === true ? { force: true } : {};
1640
+ const options = force === true ? { force: true } : { onlyIfMissing: true };
1398
1641
  await Promise.allSettled(this.deps.targets().map((target) => this.deps.store.refresh(target.key, options)));
1399
1642
  return {
1400
1643
  sources: this.deps.catalog(),
1401
1644
  snapshots: this.deps.store.snapshots(),
1645
+ ...this.deps.endpointHints === void 0 ? {} : { endpointHints: this.deps.endpointHints() },
1402
1646
  checkedAt: this.deps.now()
1403
1647
  };
1404
1648
  }
@@ -1450,98 +1694,6 @@ function installUsageStateSettings(ctx, reference, onConfig) {
1450
1694
  });
1451
1695
  }
1452
1696
  //#endregion
1453
- //#region src/shared/providers.ts
1454
- /**
1455
- * The scheme+host of a declared endpoint. A DSH provider profile's `baseURL` is an
1456
- * *API* base (`https://api.z.ai/api/paas/v4`), while every adapter here appends its
1457
- * own path to a bare origin, so only the origin is usable as our endpoint.
1458
- */
1459
- function originOf(url) {
1460
- if (url === void 0) return void 0;
1461
- try {
1462
- const parsed = new URL(url.trim());
1463
- return parsed.origin === "null" ? void 0 : parsed.origin;
1464
- } catch {
1465
- return;
1466
- }
1467
- }
1468
- function withTarget(resolution) {
1469
- if (resolution.sourceId === null || resolution.mode === null) return resolution;
1470
- return {
1471
- ...resolution,
1472
- key: `${resolution.sourceId}:${resolution.mode}`
1473
- };
1474
- }
1475
- /**
1476
- * Resolve one provider. `auto` picks the source the provider id (or its endpoint)
1477
- * suggests and that source's primary mode, so a provider DSH already has a key
1478
- * for starts working without touching the settings page.
1479
- */
1480
- function resolveProvider(input) {
1481
- const entry = input.config.providers[input.provider];
1482
- const declared = originOf(input.endpointHint);
1483
- const baseUrl = entry?.baseUrl ?? declared;
1484
- const base = {
1485
- provider: input.provider,
1486
- ...baseUrl === void 0 ? {} : { baseUrl },
1487
- ...entry?.baseUrl === void 0 ? {} : { baseUrlPinned: true },
1488
- ...entry?.apiKeyRef === void 0 ? {} : { apiKeyRef: entry.apiKeyRef }
1489
- };
1490
- if (entry?.mode === "hidden") return {
1491
- ...base,
1492
- sourceId: null,
1493
- mode: null,
1494
- reason: "hidden"
1495
- };
1496
- const sourceId = entry?.sourceId ?? suggestSourceId(input.provider, input.endpointHint) ?? null;
1497
- if (sourceId === null) return {
1498
- ...base,
1499
- sourceId: null,
1500
- mode: null,
1501
- reason: "unknown-source"
1502
- };
1503
- const source = input.catalog.find((candidate) => candidate.id === sourceId);
1504
- if (source === void 0) return {
1505
- ...base,
1506
- sourceId,
1507
- mode: null,
1508
- reason: "unknown-source"
1509
- };
1510
- if (entry?.mode === "api" || entry?.mode === "coding-plan") {
1511
- if (!source.modes.includes(entry.mode)) return {
1512
- ...base,
1513
- sourceId,
1514
- mode: null,
1515
- reason: "unsupported"
1516
- };
1517
- return withTarget({
1518
- ...base,
1519
- sourceId,
1520
- mode: entry.mode,
1521
- reason: "configured"
1522
- });
1523
- }
1524
- if (source.requiresBaseUrl && baseUrl === void 0) return {
1525
- ...base,
1526
- sourceId,
1527
- mode: null,
1528
- reason: "needs-endpoint"
1529
- };
1530
- const mode = source.modes[0];
1531
- if (mode === void 0) return {
1532
- ...base,
1533
- sourceId,
1534
- mode: null,
1535
- reason: "unsupported"
1536
- };
1537
- return withTarget({
1538
- ...base,
1539
- sourceId,
1540
- mode,
1541
- reason: "auto"
1542
- });
1543
- }
1544
- //#endregion
1545
1697
  //#region src/host/targets.ts
1546
1698
  function targetKey(sourceId, mode) {
1547
1699
  return `${sourceId}:${mode}`;
@@ -1554,7 +1706,8 @@ function targetKey(sourceId, mode) {
1554
1706
  * line never shows the same account twice. Providers that are hidden, map to no
1555
1707
  * known source, or ask for a mode the source cannot serve are skipped silently.
1556
1708
  */
1557
- function resolveTargets(config, options = {}) {
1709
+ function resolveTargets(rawConfig, options = {}) {
1710
+ const config = normalizeConfig(rawConfig);
1558
1711
  const sources = options.sources ?? ALL_SOURCES;
1559
1712
  const catalog = toSourceCatalog(sources);
1560
1713
  const targets = [];
@@ -1570,26 +1723,21 @@ function resolveTargets(config, options = {}) {
1570
1723
  sourceId,
1571
1724
  mode,
1572
1725
  ...endpoint?.baseUrl === void 0 ? {} : { baseUrl: endpoint.baseUrl },
1573
- ...endpoint?.pinned === true ? { baseUrlPinned: true } : {}
1726
+ ...endpoint?.pinned === true ? { baseUrlPinned: true } : {},
1727
+ ...endpoint?.apiKeyRef === void 0 ? {} : { apiKeyRef: endpoint.apiKeyRef }
1574
1728
  });
1575
1729
  };
1576
1730
  /**
1577
- * The live runtime's provider ids, when we have them. An empty list means "could
1578
- * not ask" (the `llm` service is missing on early ticks), not "nothing exists", so
1579
- * it must not suppress the configured entries.
1731
+ * The live runtime's provider ids, when known. `undefined` means the host
1732
+ * could not answer; an empty list is authoritative and stops stale targets.
1580
1733
  */
1581
- const live = options.providers !== void 0 && options.providers.length > 0 ? new Set(options.providers) : void 0;
1734
+ const live = options.providers === void 0 ? void 0 : new Set(options.providers);
1582
1735
  /**
1583
1736
  * Whether a stored entry may still produce requests. The entry itself is kept — a
1584
1737
  * provider that comes back keeps the mode the user chose for it — but a provider
1585
1738
  * DSH no longer has must not keep an account being polled behind an invisible row.
1586
1739
  */
1587
1740
  const pollable = (provider) => live === void 0 || live.has(provider);
1588
- for (const entry of config.models) {
1589
- if (entry.mode === "hidden" || entry.sourceId === null) continue;
1590
- if (!pollable(entry.provider)) continue;
1591
- push(entry.sourceId, entry.mode);
1592
- }
1593
1741
  const providers = /* @__PURE__ */ new Set();
1594
1742
  for (const provider of [...config.order, ...Object.keys(config.providers)]) if (pollable(provider)) providers.add(provider);
1595
1743
  for (const provider of options.providers ?? []) providers.add(provider);
@@ -1602,7 +1750,8 @@ function resolveTargets(config, options = {}) {
1602
1750
  });
1603
1751
  if (resolution.sourceId !== null && resolution.mode !== null) push(resolution.sourceId, resolution.mode, {
1604
1752
  ...resolution.baseUrl === void 0 ? {} : { baseUrl: resolution.baseUrl },
1605
- ...resolution.baseUrlPinned === true ? { pinned: true } : {}
1753
+ ...resolution.baseUrlPinned === true ? { pinned: true } : {},
1754
+ ...resolution.apiKeyRef === void 0 ? {} : { apiKeyRef: resolution.apiKeyRef }
1606
1755
  });
1607
1756
  }
1608
1757
  return targets;
@@ -1716,8 +1865,10 @@ function createUsageState(ctx, deps = {}) {
1716
1865
  const catalog = toSourceCatalog(ALL_SOURCES);
1717
1866
  const lookup = createCredentialLookup(ctx, deps.credentialFallback ?? {});
1718
1867
  let config = DEFAULT_CONFIG;
1868
+ let reconfigure = () => {};
1719
1869
  installUsageStateSettings(ctx, deps.config, (next) => {
1720
1870
  config = next;
1871
+ reconfigure();
1721
1872
  });
1722
1873
  /**
1723
1874
  * The plugin's own configuration says nothing about which providers exist, so
@@ -1725,11 +1876,16 @@ function createUsageState(ctx, deps = {}) {
1725
1876
  * provider nobody configured still resolves to its suggested source.
1726
1877
  */
1727
1878
  const providerFacts = () => {
1728
- const llm = ctx.get("llm");
1729
- const ids = [];
1879
+ let ids;
1730
1880
  try {
1731
- for (const provider of llm?.listProviders?.() ?? []) if (typeof provider?.id === "string" && provider.id !== "") ids.push(provider.id);
1732
- } catch {}
1881
+ const llm = ctx.get("llm");
1882
+ if (typeof llm?.listProviders === "function") {
1883
+ ids = [];
1884
+ for (const provider of llm.listProviders()) if (typeof provider?.id === "string" && provider.id !== "") ids.push(provider.id);
1885
+ }
1886
+ } catch {
1887
+ ids = void 0;
1888
+ }
1733
1889
  return {
1734
1890
  ids,
1735
1891
  hints: providerEndpointHints(readNamespace(ctx, "llm-pi-ai"))
@@ -1740,17 +1896,22 @@ function createUsageState(ctx, deps = {}) {
1740
1896
  return resolveTargets(config, {
1741
1897
  providers: ids,
1742
1898
  endpointHints: hints
1743
- });
1744
- };
1745
- const optionsFor = (target) => {
1746
- const overrideRef = config.sources[target.sourceId]?.apiKeyRef;
1747
- return {
1748
- ...overrideRef === void 0 ? {} : { overrideRef },
1899
+ }).map((target) => ({
1900
+ ...target,
1749
1901
  preferredRefs: providerCredentialRefs({
1750
1902
  sourceId: target.sourceId,
1903
+ mode: target.mode,
1904
+ config,
1751
1905
  deepseekSettings: readNamespace(ctx, "llm-deepseek"),
1752
1906
  piAiSettings: readNamespace(ctx, "llm-pi-ai")
1753
1907
  })
1908
+ }));
1909
+ };
1910
+ const optionsFor = (target) => {
1911
+ const overrideRef = target.apiKeyRef;
1912
+ return {
1913
+ ...overrideRef === void 0 ? {} : { overrideRef },
1914
+ preferredRefs: target.preferredRefs ?? []
1754
1915
  };
1755
1916
  };
1756
1917
  const store = new UsageStateStore({
@@ -1767,21 +1928,24 @@ function createUsageState(ctx, deps = {}) {
1767
1928
  if (source === void 0) return void 0;
1768
1929
  const resolved = await resolveApiKey(source, target.mode, optionsFor(target), lookup);
1769
1930
  if (resolved === void 0) return void 0;
1770
- const pinnedBaseUrl = config.sources[target.sourceId]?.baseUrl;
1771
- const baseUrl = pinnedBaseUrl ?? target.baseUrl;
1772
- if (baseUrl === void 0) return resolved;
1931
+ if (target.baseUrl === void 0) return resolved;
1773
1932
  return {
1774
1933
  ...resolved,
1775
- baseUrl,
1776
- ...pinnedBaseUrl === void 0 ? {} : { baseUrlPinned: true }
1934
+ baseUrl: target.baseUrl,
1935
+ ...target.baseUrlPinned === true ? { baseUrlPinned: true } : {}
1777
1936
  };
1778
1937
  } },
1779
1938
  read: createTargetReader({ fetch: fetchImpl })
1780
1939
  });
1940
+ reconfigure = () => store.reconfigure();
1781
1941
  const service = new UsageStateService({
1782
1942
  store,
1783
1943
  targets,
1784
1944
  catalog: () => catalog,
1945
+ endpointHints: () => Object.fromEntries(Object.entries(providerFacts().hints).flatMap(([provider, endpoint]) => {
1946
+ const origin = originOf(endpoint);
1947
+ return origin === void 0 ? [] : [[provider, origin]];
1948
+ })),
1785
1949
  describe: async (target) => {
1786
1950
  const source = findSource(target.sourceId);
1787
1951
  if (source === void 0) return {
@@ -1795,7 +1959,7 @@ function createUsageState(ctx, deps = {}) {
1795
1959
  ctx.on("session/event", (...args) => {
1796
1960
  if (isTurnEnd(args[1])) store.onTurnEnd();
1797
1961
  });
1798
- store.start();
1962
+ ctx.effect(() => store.start(), "dsh-usage-state: refresh scheduling");
1799
1963
  return provideUsageState(ctx, service);
1800
1964
  }
1801
1965
  /**