@goodandready/dsh-opencode-provider 0.5.23 → 0.5.25

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/CHANGELOG.md CHANGED
@@ -1,5 +1,17 @@
1
1
  # Changelog
2
2
 
3
+ ## 0.5.25 — release 2: stale data
4
+
5
+ - **#87 — stale usage**: a failed usage refresh now returns the last successful report with `stale: true` and `lastSuccessAt` instead of discarding it. The quota sidebar/panel and Settings label the stale snapshot; an explicit refresh no longer loses the previous reading.
6
+ - **#88 — stale analytics**: a failed analytics rescan keeps the previous report and heatmap visible with a stale notice and a retry button instead of blanking the panel; successful rescans clear the stale flag.
7
+ - 127 tests.
8
+
9
+ ## 0.5.24 — release 1: account safety
10
+
11
+ - **#89 — managed credential cleanup**: keys saved through Settings or imported from `auth.json` are recorded in `~/.dsh/storages/dsh-opencode/managed-credentials.json` (refs only, never secrets). Removing an account asks for confirmation and deletes only plugin-managed credentials after the settings save succeeds; external environment/`auth.json` references are left intact. Cleanup failures are reported separately from the account removal.
12
+ - **#83 — autoSwitch**: new `autoSwitch` setting (default `true`, preserving prior behavior). When off, the selected/first usable account serves alone; quota/credential failures do not rotate and quota fallback is disabled. Exposed in Settings and TUI.
13
+ - 126 tests.
14
+
3
15
  ## 0.5.23
4
16
 
5
17
  - **DSH 0.2.2 support**: compatibility now accepts every `0.2.2` prerelease and final release with `>=0.2.1-rc.2 <0.2.3-0`; `0.2.2-rc.1` is recorded as verified. Dev/typecheck dependencies moved to `@deepseek-ai/*@0.2.2-rc.1`, Cordis 4.1.0 and Schemastery 3.19.0; peer ranges admit both the 0.2.1 and 0.2.2 lines.
package/README.md CHANGED
@@ -132,12 +132,12 @@ npm pack
132
132
  Install the **packed tarball** into a profile:
133
133
 
134
134
  ```bash
135
- dsh plugin --profile web add /absolute/path/to/goodandready-dsh-opencode-provider-0.5.23.tgz
135
+ dsh plugin --profile web add /absolute/path/to/goodandready-dsh-opencode-provider-0.5.25.tgz
136
136
  # DSH Desktop:
137
- dsh plugin --profile desktop add /absolute/path/to/goodandready-dsh-opencode-provider-0.5.23.tgz
137
+ dsh plugin --profile desktop add /absolute/path/to/goodandready-dsh-opencode-provider-0.5.25.tgz
138
138
 
139
139
  # Or install the published package:
140
- dsh plugin --profile web add @goodandready/dsh-opencode-provider@0.5.23
140
+ dsh plugin --profile web add @goodandready/dsh-opencode-provider@0.5.25
141
141
  ```
142
142
 
143
143
  Restart dsh (or DSH Desktop), then pick **OpenCode (plugin)** or **OpenCode Go (plugin)** in the model picker.
package/README.ru.md CHANGED
@@ -131,12 +131,12 @@ npm pack
131
131
  Установка из **упакованного tarball**:
132
132
 
133
133
  ```bash
134
- dsh plugin --profile web add /absolute/path/to/goodandready-dsh-opencode-provider-0.5.23.tgz
134
+ dsh plugin --profile web add /absolute/path/to/goodandready-dsh-opencode-provider-0.5.25.tgz
135
135
  # DSH Desktop:
136
- dsh plugin --profile desktop add /absolute/path/to/goodandready-dsh-opencode-provider-0.5.23.tgz
136
+ dsh plugin --profile desktop add /absolute/path/to/goodandready-dsh-opencode-provider-0.5.25.tgz
137
137
 
138
138
  # Либо установка опубликованного пакета из npm:
139
- dsh plugin --profile web add @goodandready/dsh-opencode-provider@0.5.23
139
+ dsh plugin --profile web add @goodandready/dsh-opencode-provider@0.5.25
140
140
  ```
141
141
 
142
142
  После установки перезапустите dsh (или DSH Desktop) и выберите **OpenCode (plugin)** или **OpenCode Go (plugin)** в списке моделей.
package/README.zh.md CHANGED
@@ -126,12 +126,12 @@ npm pack
126
126
  通过**打包文件 (.tgz)**安装:
127
127
 
128
128
  ```bash
129
- dsh plugin --profile web add /absolute/path/to/goodandready-dsh-opencode-provider-0.5.23.tgz
129
+ dsh plugin --profile web add /absolute/path/to/goodandready-dsh-opencode-provider-0.5.25.tgz
130
130
  # DSH 桌面端:
131
- dsh plugin --profile desktop add /absolute/path/to/goodandready-dsh-opencode-provider-0.5.23.tgz
131
+ dsh plugin --profile desktop add /absolute/path/to/goodandready-dsh-opencode-provider-0.5.25.tgz
132
132
 
133
133
  # 或从 npm 安装已发布版本:
134
- dsh plugin --profile web add @goodandready/dsh-opencode-provider@0.5.23
134
+ dsh plugin --profile web add @goodandready/dsh-opencode-provider@0.5.25
135
135
  ```
136
136
 
137
137
  安装完成后重启 dsh 或 DSH 桌面端,在模型选择器中选择 **OpenCode (plugin)** 或 **OpenCode Go (plugin)** 即可。
package/lib/client.js CHANGED
@@ -65,6 +65,9 @@ window.__ModuleLoader__.load({
65
65
  dragAccount: "Drag to reorder accounts",
66
66
  accountLimit: "At most 20 accounts are allowed.",
67
67
  removeAccount: "Remove",
68
+ removeAccountConfirm: "Remove this account and delete its plugin-managed credential {ref}? This cannot be undone.",
69
+ managedKey: "Managed key",
70
+ accountCleanupFailed: "Account removed, but credential cleanup failed:",
68
71
  pinAccount: "Pin",
69
72
  activeAccount: "Pinned account",
70
73
  activeAccountNow: "Active account",
@@ -93,6 +96,7 @@ window.__ModuleLoader__.load({
93
96
  runwaySafe: "safe",
94
97
  noUsage: "Quota is unavailable.",
95
98
  usageError: "Quota error",
99
+ usageStale: "Showing the last successful quota reading; the latest refresh failed.",
96
100
  refresh: "Refresh",
97
101
  refreshed: "Refreshed",
98
102
  loading: "Loading…",
@@ -141,6 +145,7 @@ window.__ModuleLoader__.load({
141
145
  heatmapAllAccounts: "All accounts",
142
146
  analyticsHint: "Aggregated from local session metadata by last activity. A session that switched models is counted under its last model; failures are not exposed by the projection. Only plugin routes are counted; legacy opencode sessions belong to the built-in catalog.",
143
147
  analyticsPartial: "This report is partial: the projection scan hit its file limit or skipped unreadable files.",
148
+ analyticsStale: "Showing the previous successful scan; the latest refresh failed.",
144
149
  analyticsEmpty: "No plugin sessions in this window. Legacy opencode sessions belong to the built-in catalog.",
145
150
  showAll: "Show all",
146
151
  modelSearch: "Search models…",
@@ -197,6 +202,8 @@ window.__ModuleLoader__.load({
197
202
  routingFailed: "Failed",
198
203
  quotaFallbackModel: "Quota fallback model",
199
204
  quotaFallbackModelHint: "If Go rejects with a quota error, retry once on this free model through Console/Zen.",
205
+ autoSwitch: "Automatic account failover",
206
+ autoSwitchHint: "Try the next account before any output when the current account hits quota or a credential failure. Off keeps the selected account only.",
200
207
  stickySession: "Sticky account per conversation",
201
208
  stickySessionHint: "Prefer the last successful account for the same conversation; explicit model rules and pinned accounts win.",
202
209
  off: "Off",
@@ -278,6 +285,9 @@ window.__ModuleLoader__.load({
278
285
  dragAccount: "拖动以调整账户顺序",
279
286
  accountLimit: "最多允许 20 个账户。",
280
287
  removeAccount: "移除",
288
+ removeAccountConfirm: "移除此账户并删除插件管理的凭据 {ref}?此操作无法撤销。",
289
+ managedKey: "插件管理的密钥",
290
+ accountCleanupFailed: "账户已移除,但凭据清理失败:",
281
291
  pinAccount: "固定",
282
292
  activeAccount: "固定账户",
283
293
  activeAccountNow: "当前账户",
@@ -306,6 +316,7 @@ window.__ModuleLoader__.load({
306
316
  runwaySafe: "安全",
307
317
  noUsage: "配额不可用。",
308
318
  usageError: "配额错误",
319
+ usageStale: "显示上次成功读取的配额;最新刷新失败。",
309
320
  refresh: "刷新",
310
321
  refreshed: "已刷新",
311
322
  loading: "加载中…",
@@ -354,6 +365,7 @@ window.__ModuleLoader__.load({
354
365
  heatmapAllAccounts: "全部账户",
355
366
  analyticsHint: "按最后活动时间聚合本地会话元数据。切换过模型的会话计入最后使用的模型;本地投影不暴露失败。仅统计插件路由;旧 opencode 会话属于内置目录。",
356
367
  analyticsPartial: "此报告不完整:投影扫描达到文件上限或跳过了不可读文件。",
368
+ analyticsStale: "显示上次成功扫描的结果;最新刷新失败。",
357
369
  analyticsEmpty: "此窗口内没有插件会话。旧 opencode 会话属于内置目录。",
358
370
  showAll: "显示全部",
359
371
  modelSearch: "搜索模型…",
@@ -410,6 +422,8 @@ window.__ModuleLoader__.load({
410
422
  routingFailed: "失败",
411
423
  quotaFallbackModel: "配额回退模型",
412
424
  quotaFallbackModelHint: "Go 返回配额错误时,通过 Console/Zen 用此免费模型重试一次。",
425
+ autoSwitch: "自动切换账户",
426
+ autoSwitchHint: "当前账户额度耗尽或凭据失败时,在输出前尝试下一个账户。关闭后只使用选定账户。",
413
427
  stickySession: "每个会话固定账户",
414
428
  stickySessionHint: "同一会话优先使用上次成功的账户;显式模型规则和固定账户优先。",
415
429
  off: "关闭",
@@ -1006,6 +1020,10 @@ window.__ModuleLoader__.load({
1006
1020
  return /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("div", {
1007
1021
  className: "oc-bars",
1008
1022
  children: [
1023
+ usage.stale === true ? /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("p", {
1024
+ className: "oc-hint",
1025
+ children: [t("usageStale"), usage.lastSuccessAt === void 0 ? "" : ` (${new Date(usage.lastSuccessAt).toLocaleTimeString()})`]
1026
+ }) : null,
1009
1027
  /* @__PURE__ */ (0, react_jsx_runtime.jsx)(Row, {
1010
1028
  label: t("fiveHour"),
1011
1029
  window: windows.fiveHour
@@ -1334,6 +1352,7 @@ window.__ModuleLoader__.load({
1334
1352
  const [stateError, setStateError] = (0, react.useState)(null);
1335
1353
  const [analytics, setAnalytics] = (0, react.useState)(null);
1336
1354
  const [analyticsError, setAnalyticsError] = (0, react.useState)(null);
1355
+ const [analyticsStale, setAnalyticsStale] = (0, react.useState)(false);
1337
1356
  const [days, setDays] = (0, react.useState)(7);
1338
1357
  const [busy, setBusy] = (0, react.useState)(false);
1339
1358
  const analyticsSeq = (0, react.useRef)(0);
@@ -1349,14 +1368,16 @@ window.__ModuleLoader__.load({
1349
1368
  const seq = analyticsSeq.current + 1;
1350
1369
  analyticsSeq.current = seq;
1351
1370
  setAnalyticsError(null);
1371
+ setAnalyticsStale(analytics !== null);
1352
1372
  fetchAnalytics(windowDays).then((next) => {
1353
1373
  if (seq !== analyticsSeq.current) return;
1354
1374
  setAnalytics(next);
1355
1375
  setAnalyticsError(null);
1376
+ setAnalyticsStale(false);
1356
1377
  }).catch((caught) => {
1357
1378
  if (seq !== analyticsSeq.current) return;
1358
- setAnalytics(null);
1359
1379
  setAnalyticsError(caught instanceof Error ? caught.message : "analytics failed");
1380
+ setAnalyticsStale(analytics !== null);
1360
1381
  });
1361
1382
  };
1362
1383
  (0, react.useEffect)(() => {
@@ -1573,59 +1594,69 @@ window.__ModuleLoader__.load({
1573
1594
  children: t("intro")
1574
1595
  })]
1575
1596
  }),
1576
- /* @__PURE__ */ (0, react_jsx_runtime.jsx)("section", {
1597
+ /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("section", {
1577
1598
  className: "oc-card",
1578
- children: analyticsError !== null ? /* @__PURE__ */ (0, react_jsx_runtime.jsxs)(react_jsx_runtime.Fragment, { children: [
1599
+ children: [
1579
1600
  /* @__PURE__ */ (0, react_jsx_runtime.jsx)("h3", {
1580
1601
  className: "oc-card-title",
1581
1602
  children: t("usageAnalytics")
1582
1603
  }),
1583
- /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("p", {
1604
+ analyticsError !== null ? /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("p", {
1584
1605
  className: "oc-hint",
1585
1606
  children: [
1586
1607
  t("failed"),
1587
1608
  ": ",
1588
1609
  analyticsError
1589
1610
  ]
1590
- }),
1591
- /* @__PURE__ */ (0, react_jsx_runtime.jsx)("button", {
1611
+ }) : null,
1612
+ analyticsStale && analytics !== null ? /* @__PURE__ */ (0, react_jsx_runtime.jsx)("p", {
1613
+ className: "oc-hint",
1614
+ children: t("analyticsStale")
1615
+ }) : null,
1616
+ analyticsError !== null ? /* @__PURE__ */ (0, react_jsx_runtime.jsx)("button", {
1592
1617
  type: "button",
1593
1618
  className: "oc-button",
1594
1619
  onClick: () => loadAnalytics(days),
1595
1620
  children: t("refresh")
1621
+ }) : null,
1622
+ analytics === null ? /* @__PURE__ */ (0, react_jsx_runtime.jsx)("p", {
1623
+ className: "oc-hint",
1624
+ children: t("loading")
1625
+ }) : /* @__PURE__ */ (0, react_jsx_runtime.jsx)(UsageAnalyticsView, {
1626
+ analytics,
1627
+ t,
1628
+ days,
1629
+ onDays: setDays
1596
1630
  })
1597
- ] }) : analytics === null ? /* @__PURE__ */ (0, react_jsx_runtime.jsxs)(react_jsx_runtime.Fragment, { children: [/* @__PURE__ */ (0, react_jsx_runtime.jsx)("h3", {
1598
- className: "oc-card-title",
1599
- children: t("usageAnalytics")
1600
- }), /* @__PURE__ */ (0, react_jsx_runtime.jsx)("p", {
1601
- className: "oc-hint",
1602
- children: t("loading")
1603
- })] }) : /* @__PURE__ */ (0, react_jsx_runtime.jsx)(UsageAnalyticsView, {
1604
- analytics,
1605
- t,
1606
- days,
1607
- onDays: setDays
1608
- })
1631
+ ]
1609
1632
  }),
1610
1633
  /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("section", {
1611
1634
  className: "oc-card",
1612
- children: [/* @__PURE__ */ (0, react_jsx_runtime.jsx)("h3", {
1613
- className: "oc-card-title",
1614
- children: t("requestHeatmap")
1615
- }), analyticsError !== null ? /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("p", {
1616
- className: "oc-hint",
1617
- children: [
1618
- t("failed"),
1619
- ": ",
1620
- analyticsError
1621
- ]
1622
- }) : analytics === null ? /* @__PURE__ */ (0, react_jsx_runtime.jsx)("p", {
1623
- className: "oc-hint",
1624
- children: t("loading")
1625
- }) : /* @__PURE__ */ (0, react_jsx_runtime.jsx)(RequestHeatmapView, {
1626
- heatmap: analytics.heatmap,
1627
- t
1628
- })]
1635
+ children: [
1636
+ /* @__PURE__ */ (0, react_jsx_runtime.jsx)("h3", {
1637
+ className: "oc-card-title",
1638
+ children: t("requestHeatmap")
1639
+ }),
1640
+ analyticsError !== null ? /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("p", {
1641
+ className: "oc-hint",
1642
+ children: [
1643
+ t("failed"),
1644
+ ": ",
1645
+ analyticsError
1646
+ ]
1647
+ }) : null,
1648
+ analyticsStale && analytics !== null ? /* @__PURE__ */ (0, react_jsx_runtime.jsx)("p", {
1649
+ className: "oc-hint",
1650
+ children: t("analyticsStale")
1651
+ }) : null,
1652
+ analytics === null ? /* @__PURE__ */ (0, react_jsx_runtime.jsx)("p", {
1653
+ className: "oc-hint",
1654
+ children: t("loading")
1655
+ }) : /* @__PURE__ */ (0, react_jsx_runtime.jsx)(RequestHeatmapView, {
1656
+ heatmap: analytics.heatmap,
1657
+ t
1658
+ })
1659
+ ]
1629
1660
  })
1630
1661
  ]
1631
1662
  });
@@ -1653,6 +1684,7 @@ window.__ModuleLoader__.load({
1653
1684
  accounts: state.config.accounts.map((account) => ({ ...account })),
1654
1685
  modelAccountRules: state.config.modelAccountRules.map((rule) => ({ ...rule })),
1655
1686
  quotaFallbackModel: state.config.quotaFallbackModel,
1687
+ autoSwitch: state.config.autoSwitch !== false,
1656
1688
  stickySession: state.config.stickySession,
1657
1689
  visibleModels: state.config.visibleModels.length === 0 ? allIds : [...state.config.visibleModels],
1658
1690
  filterModelsByPlan: state.config.filterModelsByPlan,
@@ -1704,6 +1736,7 @@ window.__ModuleLoader__.load({
1704
1736
  account: rule.account.trim()
1705
1737
  })).filter((rule) => rule.pattern !== "" && rule.account !== ""),
1706
1738
  quotaFallbackModel: draft.quotaFallbackModel.trim(),
1739
+ autoSwitch: draft.autoSwitch,
1707
1740
  stickySession: draft.stickySession,
1708
1741
  visibleModels: draft.showAll ? [] : draft.visibleModels,
1709
1742
  filterModelsByPlan: draft.filterModelsByPlan,
@@ -1935,6 +1968,7 @@ window.__ModuleLoader__.load({
1935
1968
  const [importing, setImporting] = (0, react.useState)(false);
1936
1969
  const [checking, setChecking] = (0, react.useState)(null);
1937
1970
  const [dragFrom, setDragFrom] = (0, react.useState)(null);
1971
+ const [pendingCleanup, setPendingCleanup] = (0, react.useState)([]);
1938
1972
  const load = (0, react.useCallback)(async () => {
1939
1973
  setLoading(true);
1940
1974
  try {
@@ -1964,12 +1998,25 @@ window.__ModuleLoader__.load({
1964
1998
  setSaving(true);
1965
1999
  setError(null);
1966
2000
  try {
1967
- await saveConfig(patchOf(draft), draft.revision);
2001
+ const patch = patchOf(draft);
2002
+ await saveConfig(patch, draft.revision);
2003
+ const stillUsed = new Set(patch.accounts.map((account) => account.apiKeyEnv?.trim() ?? "").filter((ref) => ref !== ""));
2004
+ let cleanupError = null;
2005
+ for (const ref of pendingCleanup) {
2006
+ if (stillUsed.has(ref)) continue;
2007
+ try {
2008
+ await clearKey(ref);
2009
+ } catch (caught) {
2010
+ cleanupError = caught instanceof Error ? caught.message : String(caught);
2011
+ }
2012
+ }
2013
+ setPendingCleanup([]);
1968
2014
  window.dispatchEvent(new CustomEvent(CONFIG_CHANGED_EVENT));
1969
2015
  setNotice(t("saved"));
1970
2016
  setState(null);
1971
2017
  setDraft(null);
1972
2018
  await load();
2019
+ if (cleanupError !== null) setError(`${t("accountCleanupFailed")} ${cleanupError}`);
1973
2020
  } catch (caught) {
1974
2021
  setError(caught instanceof Error ? caught.message : t("saveFailed"));
1975
2022
  } finally {
@@ -2308,6 +2355,10 @@ window.__ModuleLoader__.load({
2308
2355
  ]
2309
2356
  });
2310
2357
  })(),
2358
+ defaultAccount?.usage?.stale === true ? /* @__PURE__ */ (0, react_jsx_runtime.jsx)("p", {
2359
+ className: "oc-hint",
2360
+ children: t("usageStale")
2361
+ }) : null,
2311
2362
  /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("div", {
2312
2363
  className: "oc-row",
2313
2364
  children: [
@@ -2409,8 +2460,8 @@ window.__ModuleLoader__.load({
2409
2460
  /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("h3", {
2410
2461
  className: "oc-card-title",
2411
2462
  children: [t("serviceStatus"), /* @__PURE__ */ (0, react_jsx_runtime.jsx)("span", {
2412
- className: `oc-badge ${state.service.usageCheckedAt === void 0 ? "oc-badge-warn" : state.service.usageError === void 0 ? "oc-badge-ok" : "oc-badge-bad"}`,
2413
- children: state.service.usageCheckedAt === void 0 ? t("notChecked") : state.service.usageError === void 0 ? t("serviceOk") : t("serviceDegraded")
2463
+ className: `oc-badge ${state.service.usageCheckedAt === void 0 ? "oc-badge-warn" : state.service.usageError === void 0 && state.service.usageStale !== true ? "oc-badge-ok" : "oc-badge-bad"}`,
2464
+ children: state.service.usageCheckedAt === void 0 ? t("notChecked") : state.service.usageError === void 0 && state.service.usageStale !== true ? t("serviceOk") : t("serviceDegraded")
2414
2465
  })]
2415
2466
  }),
2416
2467
  /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("div", {
@@ -2421,7 +2472,7 @@ window.__ModuleLoader__.load({
2421
2472
  children: t("usageEndpoint")
2422
2473
  }),
2423
2474
  /* @__PURE__ */ (0, react_jsx_runtime.jsx)("span", {
2424
- className: `oc-badge ${state.service.usageCheckedAt === void 0 ? "oc-badge-warn" : state.service.usageError === void 0 ? "oc-badge-ok" : "oc-badge-bad"}`,
2475
+ className: `oc-badge ${state.service.usageCheckedAt === void 0 ? "oc-badge-warn" : state.service.usageError === void 0 && state.service.usageStale !== true ? "oc-badge-ok" : "oc-badge-bad"}`,
2425
2476
  children: state.service.usageCheckedAt === void 0 ? t("notChecked") : t("checkedAt").replace("{time}", new Date(state.service.usageCheckedAt).toLocaleTimeString())
2426
2477
  }),
2427
2478
  state.service.usageLatencyMs === void 0 ? null : /* @__PURE__ */ (0, react_jsx_runtime.jsx)("span", {
@@ -2434,6 +2485,10 @@ window.__ModuleLoader__.load({
2434
2485
  className: "oc-hint",
2435
2486
  children: state.service.usageError
2436
2487
  }),
2488
+ state.service.usageStale === true ? /* @__PURE__ */ (0, react_jsx_runtime.jsx)("p", {
2489
+ className: "oc-hint",
2490
+ children: t("usageStale")
2491
+ }) : null,
2437
2492
  /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("div", {
2438
2493
  className: "oc-row",
2439
2494
  children: [
@@ -2592,7 +2647,11 @@ window.__ModuleLoader__.load({
2592
2647
  known === void 0 || known.mark === "" ? null : /* @__PURE__ */ (0, react_jsx_runtime.jsx)("span", {
2593
2648
  className: "oc-badge oc-badge-bad",
2594
2649
  children: accountMark(known, t)
2595
- })
2650
+ }),
2651
+ known?.managed === true ? /* @__PURE__ */ (0, react_jsx_runtime.jsx)("span", {
2652
+ className: "oc-badge",
2653
+ children: t("managedKey")
2654
+ }) : null
2596
2655
  ]
2597
2656
  }),
2598
2657
  known?.usage?.report === void 0 ? null : /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("div", {
@@ -2611,6 +2670,10 @@ window.__ModuleLoader__.load({
2611
2670
  known.usage.error
2612
2671
  ]
2613
2672
  }),
2673
+ known?.usage?.stale === true ? /* @__PURE__ */ (0, react_jsx_runtime.jsx)("p", {
2674
+ className: "oc-hint",
2675
+ children: t("usageStale")
2676
+ }) : null,
2614
2677
  /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("div", {
2615
2678
  className: "oc-row",
2616
2679
  children: [
@@ -2661,6 +2724,10 @@ window.__ModuleLoader__.load({
2661
2724
  type: "button",
2662
2725
  className: "oc-button oc-button-danger",
2663
2726
  onClick: () => {
2727
+ const ref = account.apiKeyEnv.trim();
2728
+ const managed = known?.managed === true && ref !== "";
2729
+ if (managed && !window.confirm(t("removeAccountConfirm").replace("{ref}", ref))) return;
2730
+ if (managed) setPendingCleanup((current) => current.includes(ref) ? current : [...current, ref]);
2664
2731
  setKeyDrafts({});
2665
2732
  update({ accounts: draft.accounts.filter((_, i) => i !== index) });
2666
2733
  },
@@ -2796,6 +2863,18 @@ window.__ModuleLoader__.load({
2796
2863
  className: "oc-hint",
2797
2864
  children: t("routingHint")
2798
2865
  }),
2866
+ /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("label", {
2867
+ className: "oc-row",
2868
+ children: [/* @__PURE__ */ (0, react_jsx_runtime.jsx)("input", {
2869
+ type: "checkbox",
2870
+ checked: draft.autoSwitch,
2871
+ onChange: (event) => update({ autoSwitch: event.target.checked })
2872
+ }), /* @__PURE__ */ (0, react_jsx_runtime.jsx)("span", { children: t("autoSwitch") })]
2873
+ }),
2874
+ /* @__PURE__ */ (0, react_jsx_runtime.jsx)("p", {
2875
+ className: "oc-hint",
2876
+ children: t("autoSwitchHint")
2877
+ }),
2799
2878
  /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("label", {
2800
2879
  className: "oc-row",
2801
2880
  children: [/* @__PURE__ */ (0, react_jsx_runtime.jsx)("input", {
package/lib/index.d.ts CHANGED
@@ -60,6 +60,8 @@ interface Config {
60
60
  modelAccountRules?: ModelAccountRule[];
61
61
  /** Free model used once when the Go quota rejects a request; empty disables it. */
62
62
  quotaFallbackModel?: string;
63
+ /** Try the next account after a pre-stream quota/credential failure. */
64
+ autoSwitch?: boolean;
63
65
  /** Prefer the last successful account for a conversation. */
64
66
  stickySession?: boolean;
65
67
  /** Pinned account id; empty means automatic. */
@@ -92,6 +94,7 @@ interface ResolvedConfig {
92
94
  accounts: readonly AccountConfig[];
93
95
  modelAccountRules: readonly ModelAccountRule[];
94
96
  quotaFallbackModel: string;
97
+ autoSwitch: boolean;
95
98
  stickySession: boolean;
96
99
  activeAccount: string;
97
100
  showSidebarQuota: boolean;
@@ -260,6 +263,9 @@ interface UsageResult {
260
263
  readonly keyStatus?: UsageKeyStatus;
261
264
  readonly latencyMs?: number;
262
265
  readonly checkedAt: number;
266
+ /** True when `report` is the last successful reading after a failed refresh. */
267
+ readonly stale?: boolean;
268
+ readonly lastSuccessAt?: number;
263
269
  }
264
270
  interface UsageWindowPayload {
265
271
  status?: string;
@@ -373,6 +379,8 @@ interface RouteDeps {
373
379
  readonly analytics?: (days: number) => Promise<AnalyticsReport>;
374
380
  readonly heatmap?: (days: number) => Promise<RequestHeatmap>;
375
381
  readonly refreshTui?: () => void;
382
+ /** Test seam; defaults to the local managed-credentials registry. */
383
+ readonly managedPath?: string;
376
384
  }
377
385
  /** Register the Host HTTP surface consumed by the browser settings page. */
378
386
  declare function applyRoutes(ctx: Context, web: WebServer, deps: RouteDeps): void;
package/lib/index.js CHANGED
@@ -81,7 +81,7 @@ const DEFAULT_GO_API_BASE = "https://opencode.ai/inference/go";
81
81
  const DEFAULT_GO_ZEN_BASE = "https://opencode.ai/zen/go/v1";
82
82
  const DEFAULT_USAGE_URL = "https://opencode.ai/zen/go/v1/usage";
83
83
  const DEFAULT_AUTH_ENTRY = "opencode-go";
84
- const DEFAULT_USER_AGENT = "dsh-opencode-provider/0.5.23";
84
+ const DEFAULT_USER_AGENT = "dsh-opencode-provider/0.5.25";
85
85
  const DEFAULT_CONTEXT_WINDOW = 262144;
86
86
  const DEFAULT_MAX_TOKENS = 32768;
87
87
  const DEFAULT_GENERATE_MAX_TOKENS = 32768;
@@ -123,6 +123,7 @@ const Config = z.object({
123
123
  account: z.string()
124
124
  })).default([])),
125
125
  quotaFallbackModel: markVolatile(z.string().default("")),
126
+ autoSwitch: markVolatile(z.boolean().default(true)),
126
127
  stickySession: markVolatile(z.boolean().default(true)),
127
128
  activeAccount: markVolatile(z.string().default("")),
128
129
  showSidebarQuota: markVolatile(z.boolean().default(false)),
@@ -218,6 +219,7 @@ function resolveConfig(config) {
218
219
  accounts: Array.isArray(config.accounts) ? config.accounts : [],
219
220
  modelAccountRules: modelAccountRuleList(config.modelAccountRules),
220
221
  quotaFallbackModel: typeof config.quotaFallbackModel === "string" ? config.quotaFallbackModel.trim() : "",
222
+ autoSwitch: config.autoSwitch !== false,
221
223
  stickySession: config.stickySession !== false,
222
224
  activeAccount: typeof config.activeAccount === "string" ? config.activeAccount : "",
223
225
  showSidebarQuota: config.showSidebarQuota === true,
@@ -3327,8 +3329,9 @@ var OpenCodeAdapter = class extends LlmAdapter {
3327
3329
  }
3328
3330
  const eligible = fallback ? keys : keys.filter((entry) => !this.coolingDown(entry.key) && !this.modelCoolingDown(entry.key, options.model));
3329
3331
  const ordered = eligible.length > 0 ? eligible : keys;
3332
+ const candidates = config.autoSwitch ? ordered : ordered.slice(0, 1);
3330
3333
  const forced = !fallback && eligible.length === 0;
3331
- const initial = ordered[0];
3334
+ const initial = candidates[0];
3332
3335
  const url = endpointFor(options.provider, model.transport, options.model, config);
3333
3336
  let images;
3334
3337
  if (hasImages(options)) {
@@ -3352,7 +3355,7 @@ var OpenCodeAdapter = class extends LlmAdapter {
3352
3355
  let lastError;
3353
3356
  let lastAccount = initial?.label ?? "default";
3354
3357
  let loggedAny = false;
3355
- for (const [index, entry] of ordered.entries()) {
3358
+ for (const [index, entry] of candidates.entries()) {
3356
3359
  if (!fallback && !forced && (this.coolingDown(entry.key) || this.modelCoolingDown(entry.key, options.model))) continue;
3357
3360
  lastAccount = entry.label ?? "default";
3358
3361
  const attemptReason = index === 0 ? reason : "rotation";
@@ -3380,7 +3383,7 @@ var OpenCodeAdapter = class extends LlmAdapter {
3380
3383
  const error = classifyResponseError(response.status, text, response.headers);
3381
3384
  lastError = error;
3382
3385
  logAttempt(false, error.code);
3383
- const canRotate = index < ordered.length - 1;
3386
+ const canRotate = index < candidates.length - 1;
3384
3387
  if (error.code === "INVALID_CREDENTIAL" || error.code === "RATE_LIMIT" || error.code === "QUOTA") {
3385
3388
  if (!fallback) this.markRejection(entry.key, options.model, response.status, response.headers, error.code);
3386
3389
  responseFailure = true;
@@ -3425,8 +3428,8 @@ var OpenCodeAdapter = class extends LlmAdapter {
3425
3428
  lastError = classified;
3426
3429
  const transient = this.isTransient(classified);
3427
3430
  if (transient && !responseFailure && !fallback) this.markRejection(entry.key, options.model, 0, void 0, classified.code);
3428
- if (!(index < ordered.length - 1 && transient)) {
3429
- if (!fallback && config.quotaFallbackModel !== "" && isGoProvider(options.provider) && classified.code === "QUOTA") break;
3431
+ if (!(index < candidates.length - 1 && transient)) {
3432
+ if (!fallback && config.autoSwitch && config.quotaFallbackModel !== "" && isGoProvider(options.provider) && classified.code === "QUOTA") break;
3430
3433
  throw classified;
3431
3434
  }
3432
3435
  }
@@ -3448,7 +3451,7 @@ var OpenCodeAdapter = class extends LlmAdapter {
3448
3451
  ok: false,
3449
3452
  error: finalError.code
3450
3453
  });
3451
- if (!fallback && config.quotaFallbackModel !== "" && isGoProvider(options.provider) && (lastError === void 0 || lastError.code === "QUOTA")) try {
3454
+ if (!fallback && config.autoSwitch && config.quotaFallbackModel !== "" && isGoProvider(options.provider) && (lastError === void 0 || lastError.code === "QUOTA")) try {
3452
3455
  yield* this.run({
3453
3456
  ...options,
3454
3457
  provider: CONSOLE_PROVIDER,
@@ -4661,6 +4664,44 @@ async function readModelAnalytics(days = 30) {
4661
4664
  });
4662
4665
  return report;
4663
4666
  }
4667
+ // src/managed-credentials.ts
4668
+ const REF_LIMIT = 128;
4669
+ function managedCredentialsPath() {
4670
+ const home = process.env.DSH_HOME?.trim() || join(homedir(), ".dsh");
4671
+ return join(home, "storages", "dsh-opencode", "managed-credentials.json");
4672
+ }
4673
+ function validRef(value) {
4674
+ return typeof value === "string" && value !== "" && value.length <= REF_LIMIT;
4675
+ }
4676
+ async function readRefs(path) {
4677
+ try {
4678
+ const parsed = JSON.parse(await readFile(path, "utf8"));
4679
+ if (!Array.isArray(parsed.refs)) return /* @__PURE__ */ new Set();
4680
+ return new Set(parsed.refs.filter(validRef));
4681
+ } catch {
4682
+ return /* @__PURE__ */ new Set();
4683
+ }
4684
+ }
4685
+ async function writeRefs(path, refs) {
4686
+ await mkdir(dirname(path), { recursive: true });
4687
+ await writeFile(path, JSON.stringify({ refs: [...refs].sort() }), "utf8");
4688
+ }
4689
+ async function readManagedCredentialRefs(path = managedCredentialsPath()) {
4690
+ return readRefs(path);
4691
+ }
4692
+ async function mutate(path, ref, add) {
4693
+ if (!validRef(ref)) return;
4694
+ const refs = await readRefs(path);
4695
+ if (add) refs.add(ref);
4696
+ else refs.delete(ref);
4697
+ await writeRefs(path, refs);
4698
+ }
4699
+ async function addManagedCredentialRef(ref, path = managedCredentialsPath()) {
4700
+ await mutate(path, ref, true);
4701
+ }
4702
+ async function removeManagedCredentialRef(ref, path = managedCredentialsPath()) {
4703
+ await mutate(path, ref, false);
4704
+ }
4664
4705
  // src/routes-public.ts
4665
4706
  function publicConfig(config) {
4666
4707
  return {
@@ -4690,6 +4731,7 @@ function publicConfig(config) {
4690
4731
  account: rule.account
4691
4732
  })),
4692
4733
  quotaFallbackModel: config.quotaFallbackModel,
4734
+ autoSwitch: config.autoSwitch,
4693
4735
  stickySession: config.stickySession,
4694
4736
  activeAccount: config.activeAccount,
4695
4737
  showSidebarQuota: config.showSidebarQuota,
@@ -4878,6 +4920,7 @@ function configOps(patch) {
4878
4920
  if ("accounts" in patch) set("accounts", cleanAccounts(patch.accounts));
4879
4921
  if ("modelAccountRules" in patch) set("modelAccountRules", cleanModelAccountRules(patch.modelAccountRules));
4880
4922
  if ("quotaFallbackModel" in patch) set("quotaFallbackModel", patch.quotaFallbackModel);
4923
+ if ("autoSwitch" in patch) set("autoSwitch", patch.autoSwitch === true);
4881
4924
  if ("stickySession" in patch) set("stickySession", patch.stickySession === true);
4882
4925
  if ("activeAccount" in patch) set("activeAccount", patch.activeAccount);
4883
4926
  if ("showSidebarQuota" in patch) set("showSidebarQuota", patch.showSidebarQuota === true);
@@ -4911,6 +4954,7 @@ async function buildState(ctx, deps) {
4911
4954
  }));
4912
4955
  const usageByLabel = /* @__PURE__ */ new Map();
4913
4956
  for (const entry of resolved) usageByLabel.set(entry.label ?? "default", usageByKey.get(entry.key) ?? null);
4957
+ const managedRefs = await readManagedCredentialRefs(deps.managedPath);
4914
4958
  const slots = [{
4915
4959
  label: "default",
4916
4960
  ref: config.apiKeyEnv,
@@ -4951,7 +4995,8 @@ async function buildState(ctx, deps) {
4951
4995
  coolingUntil: health?.coolingUntil ?? 0,
4952
4996
  mark: health?.reason ?? "",
4953
4997
  keyStatus,
4954
- usage
4998
+ usage,
4999
+ managed: slot.ref !== "" && managedRefs.has(slot.ref)
4955
5000
  });
4956
5001
  }
4957
5002
  let models = [];
@@ -4988,7 +5033,8 @@ async function buildState(ctx, deps) {
4988
5033
  service: {
4989
5034
  ...usage?.checkedAt === void 0 ? {} : { usageCheckedAt: usage.checkedAt },
4990
5035
  ...usage?.latencyMs === void 0 ? {} : { usageLatencyMs: usage.latencyMs },
4991
- ...usage?.error === void 0 ? {} : { usageError: usage.error }
5036
+ ...usage?.error === void 0 ? {} : { usageError: usage.error },
5037
+ ...usage?.stale === true ? { usageStale: true } : {}
4992
5038
  },
4993
5039
  routing: deps.adapter.routingTrace(),
4994
5040
  auth: {
@@ -5125,6 +5171,7 @@ function applyRoutes(ctx, web, deps) {
5125
5171
  return;
5126
5172
  }
5127
5173
  await credentials.set(credentialRef(refName), key);
5174
+ await addManagedCredentialRef(refName, deps.managedPath);
5128
5175
  json(res, 200, {
5129
5176
  ok: true,
5130
5177
  ref: refName
@@ -5144,6 +5191,7 @@ function applyRoutes(ctx, web, deps) {
5144
5191
  const body = await readJson(req);
5145
5192
  const refName = typeof body.ref === "string" && body.ref.trim() !== "" ? body.ref.trim() : deps.config().apiKeyEnv;
5146
5193
  await credentials.unset(credentialRef(refName));
5194
+ await removeManagedCredentialRef(refName, deps.managedPath);
5147
5195
  json(res, 200, {
5148
5196
  ok: true,
5149
5197
  ref: refName
@@ -5169,6 +5217,7 @@ function applyRoutes(ctx, web, deps) {
5169
5217
  return;
5170
5218
  }
5171
5219
  await credentials.set(credentialRef(ref), key);
5220
+ await addManagedCredentialRef(ref, deps.managedPath);
5172
5221
  json(res, 200, {
5173
5222
  ok: true,
5174
5223
  entry,
@@ -5404,6 +5453,18 @@ function buildOpenCodeTuiSection(deps) {
5404
5453
  format: () => deps.quotaFallbackModel(),
5405
5454
  parse: setOrClear
5406
5455
  },
5456
+ {
5457
+ path: ["autoSwitch"],
5458
+ group: "advanced",
5459
+ kind: "boolean",
5460
+ label: "Automatic account failover",
5461
+ hint: "Try the next account before output on quota or credential failure.",
5462
+ format: (value) => String(value === void 0 ? deps.autoSwitch() : value !== false),
5463
+ parse: (text) => ({
5464
+ kind: "set",
5465
+ value: text.trim() === "true"
5466
+ })
5467
+ },
5407
5468
  {
5408
5469
  path: ["stickySession"],
5409
5470
  group: "advanced",
@@ -5558,6 +5619,8 @@ function fingerprint(key) {
5558
5619
  /** Cached reader for the (undocumented) Go usage endpoint. Fail-open by design. */
5559
5620
  function createUsageReader(config, resolveKey, fetchImpl = fetch) {
5560
5621
  const cache = /* @__PURE__ */ new Map();
5622
+ /** Last successful reading per key, kept across explicit cache clears. */
5623
+ const lastSuccess = /* @__PURE__ */ new Map();
5561
5624
  const read = async (provider, explicitKey) => {
5562
5625
  if (provider !== "opencode-go-direct" && provider !== "opencode-go") return void 0;
5563
5626
  const current = config();
@@ -5575,7 +5638,7 @@ function createUsageReader(config, resolveKey, fetchImpl = fetch) {
5575
5638
  const cacheKey = `${provider}:${fingerprint(key)}`;
5576
5639
  const cached = cache.get(cacheKey);
5577
5640
  if (cached !== void 0) {
5578
- const ttl = cached.report === void 0 ? FAILURE_TTL_MS : SUCCESS_TTL_MS;
5641
+ const ttl = cached.report === void 0 || cached.stale === true ? FAILURE_TTL_MS : SUCCESS_TTL_MS;
5579
5642
  if (Date.now() - cached.checkedAt < ttl) return cached;
5580
5643
  }
5581
5644
  const result = async () => {
@@ -5633,8 +5696,25 @@ function createUsageReader(config, resolveKey, fetchImpl = fetch) {
5633
5696
  }
5634
5697
  };
5635
5698
  const fresh = await result();
5636
- cache.set(cacheKey, fresh);
5637
- return fresh;
5699
+ const previous = cached?.report !== void 0 ? cached : lastSuccess.get(cacheKey);
5700
+ if (fresh.report === void 0 && previous?.report !== void 0) {
5701
+ const stale = {
5702
+ ...fresh,
5703
+ report: previous.report,
5704
+ stale: true,
5705
+ lastSuccessAt: previous.lastSuccessAt ?? previous.checkedAt
5706
+ };
5707
+ cache.set(cacheKey, stale);
5708
+ return stale;
5709
+ }
5710
+ const next = fresh.report === void 0 ? fresh : {
5711
+ ...fresh,
5712
+ stale: false,
5713
+ lastSuccessAt: fresh.checkedAt
5714
+ };
5715
+ if (next.report !== void 0) lastSuccess.set(cacheKey, next);
5716
+ cache.set(cacheKey, next);
5717
+ return next;
5638
5718
  };
5639
5719
  read.clear = () => {
5640
5720
  cache.clear();
@@ -5711,6 +5791,7 @@ function apply(ctx, config) {
5711
5791
  activeAccount: () => current().activeAccount,
5712
5792
  filterModelsByPlan: () => current().filterModelsByPlan,
5713
5793
  quotaFallbackModel: () => current().quotaFallbackModel,
5794
+ autoSwitch: () => current().autoSwitch,
5714
5795
  stickySession: () => current().stickySession,
5715
5796
  showSidebarQuota: () => current().showSidebarQuota
5716
5797
  });
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@goodandready/dsh-opencode-provider",
3
- "version": "0.5.23",
3
+ "version": "0.5.25",
4
4
  "description": "OpenCode / OpenCode Go LLM provider plugin for DeepSeek Harness (dsh).",
5
5
  "license": "MIT",
6
6
  "type": "module",