@goodandready/dsh-opencode-provider 0.5.18 → 0.5.20

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,20 @@
1
1
  # Changelog
2
2
 
3
+ ## 0.5.20
4
+
5
+ - **CI/reproducibility**: regenerated `package-lock.json` with the normal npm resolver; clean `npm ci --ignore-scripts` now succeeds and `npm run check` passes.
6
+ - **Streaming correctness**: SSE parsing now normalizes CRLF/CR across chunk boundaries; all four transports reject streams that end without their protocol terminal event. Pre-aborted caller signals are rejected before provider dispatch.
7
+ - **Concurrent settings**: settings saves carry the draft revision and use the settings compare-and-swap; revision conflicts return 409 instead of overwriting another page's changes.
8
+ - **UI safety**: removing an account clears unsaved per-row key drafts; clearing a credential asks for confirmation; update checks distinguish registry failure from “up to date”; service status never reports healthy before a successful check.
9
+ - **Images**: request budget counts repeated image occurrences and base64 overhead; offloaded images no longer require the attachment service.
10
+ - **Docs/tests**: added project `AGENTS.md`; removed the unsafe local engine-matrix script pending the approved DSH test contour (`#55`).
11
+
12
+ ## 0.5.19
13
+
14
+ - **Per-model soft cooldown**: quota/free-tier/rate-limit rejections now cool only that key+model (`quota` 5m, `rate-limit` 60s); other models on the same account remain usable. Invalid credentials still cool the account. If every key is cooling, the request degrades to the normal candidate list instead of failing before dispatch.
15
+ - **Service status**: `/state` exposes the usage check time, latency and error, plus catalog source (`live`/`snapshot`), count and fetch time. Settings shows a compact Service status card.
16
+ - 105 tests; engine matrix/install matrix remain green.
17
+
3
18
  ## 0.5.18
4
19
 
5
20
  - **Compatibility range**: `engines.dsh`, DSH peer ranges and `dsh.compatibility.dsh` now use `>=0.2.1-rc.2 <0.2.2-0`; `dshReleases` keeps the tested rc.2/rc.3/rc.4 records. Future `0.2.1` RCs are admitted automatically; the next minor remains excluded.
package/README.md CHANGED
@@ -130,12 +130,12 @@ npm pack
130
130
  Install the **packed tarball** into a profile:
131
131
 
132
132
  ```bash
133
- dsh plugin --profile web add /absolute/path/to/goodandready-dsh-opencode-provider-0.5.18.tgz
133
+ dsh plugin --profile web add /absolute/path/to/goodandready-dsh-opencode-provider-0.5.20.tgz
134
134
  # DSH Desktop:
135
- dsh plugin --profile desktop add /absolute/path/to/goodandready-dsh-opencode-provider-0.5.18.tgz
135
+ dsh plugin --profile desktop add /absolute/path/to/goodandready-dsh-opencode-provider-0.5.20.tgz
136
136
 
137
137
  # Or install the published package:
138
- dsh plugin --profile web add @goodandready/dsh-opencode-provider@0.5.18
138
+ dsh plugin --profile web add @goodandready/dsh-opencode-provider@0.5.20
139
139
  ```
140
140
 
141
141
  Restart dsh (or DSH Desktop), then pick **OpenCode (plugin)** or **OpenCode Go (plugin)** in the model picker.
package/README.ru.md CHANGED
@@ -129,12 +129,12 @@ npm pack
129
129
  Установка из **упакованного tarball**:
130
130
 
131
131
  ```bash
132
- dsh plugin --profile web add /absolute/path/to/goodandready-dsh-opencode-provider-0.5.18.tgz
132
+ dsh plugin --profile web add /absolute/path/to/goodandready-dsh-opencode-provider-0.5.20.tgz
133
133
  # DSH Desktop:
134
- dsh plugin --profile desktop add /absolute/path/to/goodandready-dsh-opencode-provider-0.5.18.tgz
134
+ dsh plugin --profile desktop add /absolute/path/to/goodandready-dsh-opencode-provider-0.5.20.tgz
135
135
 
136
136
  # Либо установка опубликованного пакета из npm:
137
- dsh plugin --profile web add @goodandready/dsh-opencode-provider@0.5.18
137
+ dsh plugin --profile web add @goodandready/dsh-opencode-provider@0.5.20
138
138
  ```
139
139
 
140
140
  После установки перезапустите dsh (или DSH Desktop) и выберите **OpenCode (plugin)** или **OpenCode Go (plugin)** в списке моделей.
package/README.zh.md CHANGED
@@ -124,12 +124,12 @@ npm pack
124
124
  通过**打包文件 (.tgz)**安装:
125
125
 
126
126
  ```bash
127
- dsh plugin --profile web add /absolute/path/to/goodandready-dsh-opencode-provider-0.5.18.tgz
127
+ dsh plugin --profile web add /absolute/path/to/goodandready-dsh-opencode-provider-0.5.20.tgz
128
128
  # DSH 桌面端:
129
- dsh plugin --profile desktop add /absolute/path/to/goodandready-dsh-opencode-provider-0.5.18.tgz
129
+ dsh plugin --profile desktop add /absolute/path/to/goodandready-dsh-opencode-provider-0.5.20.tgz
130
130
 
131
131
  # 或从 npm 安装已发布版本:
132
- dsh plugin --profile web add @goodandready/dsh-opencode-provider@0.5.18
132
+ dsh plugin --profile web add @goodandready/dsh-opencode-provider@0.5.20
133
133
  ```
134
134
 
135
135
  安装完成后重启 dsh 或 DSH 桌面端,在模型选择器中选择 **OpenCode (plugin)** 或 **OpenCode Go (plugin)** 即可。
package/lib/client.js CHANGED
@@ -6,7 +6,7 @@ window.__ModuleLoader__.load({
6
6
  Object.defineProperty(exports, Symbol.toStringTag, { value: "Module" });
7
7
  let react = require("react");
8
8
  let react_jsx_runtime = require("react/jsx-runtime");
9
- //#region src/client/locales.ts
9
+ // src/client/locales.ts
10
10
  const NS = "settings.opencode";
11
11
  const dictionaries = {
12
12
  en: {
@@ -25,11 +25,23 @@ window.__ModuleLoader__.load({
25
25
  keyStatusUnchecked: "Key unchecked",
26
26
  checkKey: "Check key",
27
27
  keyStatusHint: "A key check reads the usage endpoint; it does not send a model request.",
28
+ serviceStatus: "Service status",
29
+ serviceOk: "Reachable",
30
+ serviceDegraded: "Degraded",
31
+ usageEndpoint: "Usage endpoint",
32
+ catalogStatus: "Model catalog",
33
+ catalogLive: "live",
34
+ catalogSnapshot: "snapshot",
35
+ catalogAge: "updated {age}",
36
+ checkedAt: "checked {time}",
37
+ latencyValue: "{ms} ms",
38
+ notChecked: "not checked",
28
39
  keySource: "Source",
29
40
  keyWritable: "Writable",
30
41
  pasteKey: "Paste a key",
31
42
  saveKey: "Save key",
32
43
  clearKey: "Clear key",
44
+ clearKeyConfirm: "Delete the stored credential for {ref}? This cannot be undone.",
33
45
  importAuth: "Import from OpenCode",
34
46
  imported: "Imported the OpenCode key",
35
47
  testConnection: "Test connection",
@@ -218,11 +230,23 @@ window.__ModuleLoader__.load({
218
230
  keyStatusUnchecked: "未检查",
219
231
  checkKey: "检查密钥",
220
232
  keyStatusHint: "检查仅读取用量端点,不会发送模型请求。",
233
+ serviceStatus: "服务状态",
234
+ serviceOk: "可访问",
235
+ serviceDegraded: "异常",
236
+ usageEndpoint: "用量端点",
237
+ catalogStatus: "模型目录",
238
+ catalogLive: "实时",
239
+ catalogSnapshot: "快照",
240
+ catalogAge: "更新于 {age}",
241
+ checkedAt: "检查于 {time}",
242
+ latencyValue: "{ms} ms",
243
+ notChecked: "未检查",
221
244
  keySource: "来源",
222
245
  keyWritable: "可写",
223
246
  pasteKey: "粘贴密钥",
224
247
  saveKey: "保存密钥",
225
248
  clearKey: "清除密钥",
249
+ clearKeyConfirm: "删除 {ref} 的已保存凭据?此操作无法撤销。",
226
250
  importAuth: "从 OpenCode 导入",
227
251
  imported: "已导入 OpenCode 密钥",
228
252
  testConnection: "测试连接",
@@ -397,8 +421,7 @@ window.__ModuleLoader__.load({
397
421
  }
398
422
  };
399
423
  const GO_PROVIDER = "opencode-go-direct";
400
- //#endregion
401
- //#region src/client/api.ts
424
+ // src/client/api.ts
402
425
  async function call(path, init) {
403
426
  const response = await fetch(path, {
404
427
  credentials: "same-origin",
@@ -434,10 +457,13 @@ window.__ModuleLoader__.load({
434
457
  body: JSON.stringify({ label })
435
458
  });
436
459
  }
437
- function saveConfig(patch) {
460
+ function saveConfig(patch, revision) {
438
461
  return call("/dsh-opencode/config", {
439
462
  method: "POST",
440
- body: JSON.stringify({ patch })
463
+ body: JSON.stringify(revision === void 0 ? { patch } : {
464
+ patch,
465
+ revision
466
+ })
441
467
  });
442
468
  }
443
469
  function fetchUpdate() {
@@ -486,11 +512,9 @@ window.__ModuleLoader__.load({
486
512
  })
487
513
  });
488
514
  }
489
- //#endregion
490
- //#region src/client/events.ts
515
+ // src/client/events.ts
491
516
  const CONFIG_CHANGED_EVENT = "dsh-opencode-config-changed";
492
- //#endregion
493
- //#region src/client/heatmap.tsx
517
+ // src/client/heatmap.tsx
494
518
  const DAYS = [
495
519
  "Mon",
496
520
  "Tue",
@@ -699,8 +723,7 @@ window.__ModuleLoader__.load({
699
723
  ]
700
724
  });
701
725
  }
702
- //#endregion
703
- //#region src/client/usage-analytics.tsx
726
+ // src/client/usage-analytics.tsx
704
727
  function compact(value) {
705
728
  if (!Number.isFinite(value) || value <= 0) return "0";
706
729
  if (value >= 1e6) return `${(value / 1e6).toFixed(1)}M`;
@@ -909,8 +932,7 @@ window.__ModuleLoader__.load({
909
932
  tab === "sessions" ? sessionRows(analytics, t) : null
910
933
  ] });
911
934
  }
912
- //#endregion
913
- //#region src/client/usage-bars.tsx
935
+ // src/client/usage-bars.tsx
914
936
  function percentClass(percent) {
915
937
  if (percent >= 90) return "oc-bar-fill oc-bar-fill-bad";
916
938
  if (percent >= 75) return "oc-bar-fill oc-bar-fill-warn";
@@ -979,8 +1001,7 @@ window.__ModuleLoader__.load({
979
1001
  ]
980
1002
  });
981
1003
  }
982
- //#endregion
983
- //#region src/quota-runway.ts
1004
+ // src/quota-runway.ts
984
1005
  const HOUR = 36e5;
985
1006
  const DAY = 24 * HOUR;
986
1007
  const DURATIONS = {
@@ -1046,8 +1067,7 @@ window.__ModuleLoader__.load({
1046
1067
  };
1047
1068
  return known === 0 ? { kind: "unknown" } : { kind: "safe" };
1048
1069
  }
1049
- //#endregion
1050
- //#region src/client/quota-card.tsx
1070
+ // src/client/quota-card.tsx
1051
1071
  const PANEL_EVENT = "dsh-opencode-panel-state";
1052
1072
  function broadcastPanel(open) {
1053
1073
  if (typeof window === "undefined") return;
@@ -1589,11 +1609,11 @@ window.__ModuleLoader__.load({
1589
1609
  ]
1590
1610
  });
1591
1611
  }
1592
- //#endregion
1593
- //#region src/client/settings-draft.ts
1612
+ // src/client/settings-draft.ts
1594
1613
  function draftOf(state) {
1595
1614
  const allIds = state.models.map((model) => model.id);
1596
1615
  return {
1616
+ revision: state.revision ?? 0,
1597
1617
  apiKeyEnv: state.config.apiKeyEnv,
1598
1618
  apiBase: state.config.apiBase,
1599
1619
  goApiBase: state.config.goApiBase,
@@ -1679,8 +1699,7 @@ window.__ModuleLoader__.load({
1679
1699
  function describeLimitsFull(limits) {
1680
1700
  return `${planLine("Go", limits.go)} · ${planLine("Go Plus", limits.goPlus)}`;
1681
1701
  }
1682
- //#endregion
1683
- //#region src/client/settings-widgets.tsx
1702
+ // src/client/settings-widgets.tsx
1684
1703
  function Field(props) {
1685
1704
  return /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("label", {
1686
1705
  className: "oc-field",
@@ -1743,8 +1762,7 @@ window.__ModuleLoader__.load({
1743
1762
  if (account.coolingUntil <= 0) return label;
1744
1763
  return `${label} · ${untilText(account.coolingUntil)}`;
1745
1764
  }
1746
- //#endregion
1747
- //#region src/client/update-card.tsx
1765
+ // src/client/update-card.tsx
1748
1766
  function PluginUpdateCard(props) {
1749
1767
  const { t } = props;
1750
1768
  const [status, setStatus] = (0, react.useState)(null);
@@ -1781,6 +1799,9 @@ window.__ModuleLoader__.load({
1781
1799
  children: [t("pluginUpdate"), status === null ? null : status.updateAvailable ? /* @__PURE__ */ (0, react_jsx_runtime.jsx)("span", {
1782
1800
  className: "oc-badge oc-badge-warn",
1783
1801
  children: t("updateAvailable")
1802
+ }) : status.latestCheckFailed || status.latestVersion === void 0 ? /* @__PURE__ */ (0, react_jsx_runtime.jsx)("span", {
1803
+ className: "oc-badge oc-badge-warn",
1804
+ children: t("updateCheckFailed")
1784
1805
  }) : /* @__PURE__ */ (0, react_jsx_runtime.jsx)("span", {
1785
1806
  className: "oc-badge oc-badge-ok",
1786
1807
  children: t("upToDate")
@@ -1827,8 +1848,7 @@ window.__ModuleLoader__.load({
1827
1848
  ]
1828
1849
  });
1829
1850
  }
1830
- //#endregion
1831
- //#region src/client/auth-ref.ts
1851
+ // src/client/auth-ref.ts
1832
1852
  /**
1833
1853
  * Collision-free credential ref for an OpenCode auth entry. Hex encoding is
1834
1854
  * injective (unlike replacing punctuation with underscores) and uses only
@@ -1839,8 +1859,7 @@ window.__ModuleLoader__.load({
1839
1859
  for (const byte of new TextEncoder().encode(entry)) hex += byte.toString(16).padStart(2, "0");
1840
1860
  return `OPENCODE_AUTH_${hex}`;
1841
1861
  }
1842
- //#endregion
1843
- //#region src/client/settings-page.tsx
1862
+ // src/client/settings-page.tsx
1844
1863
  function keyStatusLabel(status, t) {
1845
1864
  if (status === "ok") return t("keyStatusOk");
1846
1865
  if (status === "expired") return t("keyStatusExpired");
@@ -1854,6 +1873,13 @@ window.__ModuleLoader__.load({
1854
1873
  if (status === "expired" || status === "invalid") return "oc-badge-bad";
1855
1874
  return "oc-badge-warn";
1856
1875
  }
1876
+ function ageText(at) {
1877
+ if (at === void 0) return "";
1878
+ const minutes = Math.max(0, Math.round((Date.now() - at) / 6e4));
1879
+ if (minutes < 1) return "now";
1880
+ if (minutes < 60) return `${minutes}m`;
1881
+ return `${Math.round(minutes / 60)}h`;
1882
+ }
1857
1883
  function OpenCodeSettingsPage(props) {
1858
1884
  const { t } = props;
1859
1885
  const [state, setState] = (0, react.useState)(null);
@@ -1899,7 +1925,7 @@ window.__ModuleLoader__.load({
1899
1925
  setSaving(true);
1900
1926
  setError(null);
1901
1927
  try {
1902
- await saveConfig(patchOf(draft));
1928
+ await saveConfig(patchOf(draft), draft.revision);
1903
1929
  window.dispatchEvent(new CustomEvent(CONFIG_CHANGED_EVENT));
1904
1930
  setNotice(t("saved"));
1905
1931
  setState(null);
@@ -2236,7 +2262,9 @@ window.__ModuleLoader__.load({
2236
2262
  type: "button",
2237
2263
  className: "oc-button oc-button-danger",
2238
2264
  onClick: () => {
2239
- clearKey(draft.apiKeyEnv.trim() === "" ? state.config.apiKeyEnv : draft.apiKeyEnv.trim()).then(() => {
2265
+ const ref = draft.apiKeyEnv.trim() === "" ? state.config.apiKeyEnv : draft.apiKeyEnv.trim();
2266
+ if (!window.confirm(t("clearKeyConfirm").replace("{ref}", ref))) return;
2267
+ clearKey(ref).then(() => {
2240
2268
  setNotice(t("saved"));
2241
2269
  return load();
2242
2270
  }).catch((caught) => setError(caught instanceof Error ? caught.message : t("failed")));
@@ -2300,6 +2328,62 @@ window.__ModuleLoader__.load({
2300
2328
  })
2301
2329
  ]
2302
2330
  }),
2331
+ /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("section", {
2332
+ className: "oc-card",
2333
+ children: [
2334
+ /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("h3", {
2335
+ className: "oc-card-title",
2336
+ children: [t("serviceStatus"), /* @__PURE__ */ (0, react_jsx_runtime.jsx)("span", {
2337
+ className: `oc-badge ${state.service.usageCheckedAt === void 0 ? "oc-badge-warn" : state.service.usageError === void 0 ? "oc-badge-ok" : "oc-badge-bad"}`,
2338
+ children: state.service.usageCheckedAt === void 0 ? t("notChecked") : state.service.usageError === void 0 ? t("serviceOk") : t("serviceDegraded")
2339
+ })]
2340
+ }),
2341
+ /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("div", {
2342
+ className: "oc-row",
2343
+ children: [
2344
+ /* @__PURE__ */ (0, react_jsx_runtime.jsx)("span", {
2345
+ className: "oc-hint",
2346
+ children: t("usageEndpoint")
2347
+ }),
2348
+ /* @__PURE__ */ (0, react_jsx_runtime.jsx)("span", {
2349
+ className: `oc-badge ${state.service.usageCheckedAt === void 0 ? "oc-badge-warn" : state.service.usageError === void 0 ? "oc-badge-ok" : "oc-badge-bad"}`,
2350
+ children: state.service.usageCheckedAt === void 0 ? t("notChecked") : t("checkedAt").replace("{time}", new Date(state.service.usageCheckedAt).toLocaleTimeString())
2351
+ }),
2352
+ state.service.usageLatencyMs === void 0 ? null : /* @__PURE__ */ (0, react_jsx_runtime.jsx)("span", {
2353
+ className: "oc-badge",
2354
+ children: t("latencyValue").replace("{ms}", String(state.service.usageLatencyMs))
2355
+ })
2356
+ ]
2357
+ }),
2358
+ state.service.usageError === void 0 ? null : /* @__PURE__ */ (0, react_jsx_runtime.jsx)("p", {
2359
+ className: "oc-hint",
2360
+ children: state.service.usageError
2361
+ }),
2362
+ /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("div", {
2363
+ className: "oc-row",
2364
+ children: [
2365
+ /* @__PURE__ */ (0, react_jsx_runtime.jsx)("span", {
2366
+ className: "oc-hint",
2367
+ children: t("catalogStatus")
2368
+ }),
2369
+ /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("span", {
2370
+ className: `oc-badge ${state.catalog.source === "live" ? "oc-badge-ok" : "oc-badge-warn"}`,
2371
+ children: [
2372
+ state.catalog.source === "live" ? t("catalogLive") : t("catalogSnapshot"),
2373
+ " · ",
2374
+ state.catalog.count,
2375
+ " ",
2376
+ t("modelCount")
2377
+ ]
2378
+ }),
2379
+ state.catalog.fetchedAt === void 0 ? null : /* @__PURE__ */ (0, react_jsx_runtime.jsx)("span", {
2380
+ className: "oc-badge",
2381
+ children: t("catalogAge").replace("{age}", ageText(state.catalog.fetchedAt))
2382
+ })
2383
+ ]
2384
+ })
2385
+ ]
2386
+ }),
2303
2387
  /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("section", {
2304
2388
  className: "oc-card",
2305
2389
  children: [
@@ -2479,7 +2563,10 @@ window.__ModuleLoader__.load({
2479
2563
  /* @__PURE__ */ (0, react_jsx_runtime.jsx)("button", {
2480
2564
  type: "button",
2481
2565
  className: "oc-button oc-button-danger",
2482
- onClick: () => update({ accounts: draft.accounts.filter((_, i) => i !== index) }),
2566
+ onClick: () => {
2567
+ setKeyDrafts({});
2568
+ update({ accounts: draft.accounts.filter((_, i) => i !== index) });
2569
+ },
2483
2570
  children: t("removeAccount")
2484
2571
  })
2485
2572
  ]
@@ -2921,8 +3008,7 @@ window.__ModuleLoader__.load({
2921
3008
  ]
2922
3009
  });
2923
3010
  }
2924
- //#endregion
2925
- //#region src/client/styles.ts
3011
+ // src/client/styles.ts
2926
3012
  const CSS_ID = "dsh-opencode-provider";
2927
3013
  const CSS = `
2928
3014
  :where(.oc-page, .oc-panel, .oc-card, .oc-foot, .oc-railButton) {
@@ -3042,8 +3128,7 @@ window.__ModuleLoader__.load({
3042
3128
  .oc-table th, .oc-table td { text-align: left; padding: 5px 8px; border-bottom: 1px solid var(--oc-border-soft); vertical-align: top; }
3043
3129
  .oc-table th { font-weight: 600; opacity: 0.8; }
3044
3130
  `;
3045
- //#endregion
3046
- //#region src/client/index.tsx
3131
+ // src/client/index.tsx
3047
3132
  function injectCss() {
3048
3133
  if (typeof document === "undefined") return () => {};
3049
3134
  if (document.querySelector(`style[data-dsh-plugin="dsh-opencode-provider"]`) !== null) return () => {};
@@ -3089,7 +3174,6 @@ window.__ModuleLoader__.load({
3089
3174
  }, QuotaCard));
3090
3175
  });
3091
3176
  }
3092
- //#endregion
3093
3177
  exports.apply = apply;
3094
3178
  exports.inject = inject;
3095
3179
  return module.exports;
package/lib/index.d.ts CHANGED
@@ -3,7 +3,6 @@ import z from "@deepseek-ai/schemastery";
3
3
  import { Context } from "@deepseek-ai/cordis";
4
4
  import { AttachmentStore } from "@deepseek-ai/dsh-attachment";
5
5
  import { WebServer } from "@deepseek-ai/dsh-host-webserver";
6
- //#region src/config.d.ts
7
6
  type TransportId = 'openai-chat' | 'openai-responses' | 'anthropic' | 'google';
8
7
  interface AccountConfig {
9
8
  label?: string;
@@ -92,8 +91,6 @@ interface ResolvedConfig {
92
91
  showSidebarQuota: boolean;
93
92
  userAgent: string;
94
93
  }
95
- //#endregion
96
- //#region src/credentials.d.ts
97
94
  interface ResolvedKey {
98
95
  readonly key: string;
99
96
  readonly source: string;
@@ -115,8 +112,6 @@ declare function openCodeAuthPath(): string;
115
112
  declare function createKeyPool(ctx: Context, config: () => ResolvedConfig): KeyPool;
116
113
  /** Backwards-compatible single-key facade over the pool. */
117
114
  declare function createKeyResolver(ctx: Context, config: () => ResolvedConfig): KeyResolver;
118
- //#endregion
119
- //#region src/request-log.d.ts
120
115
  interface RequestOutcome {
121
116
  ok: boolean;
122
117
  latencyMs: number;
@@ -153,8 +148,6 @@ interface RequestHeatmap {
153
148
  models: ModelRequestHeatmap[];
154
149
  accounts: AccountRequestHeatmap[];
155
150
  }
156
- //#endregion
157
- //#region src/adapter.d.ts
158
151
  type RoutingReason = 'rule' | 'sticky' | 'pinned' | 'default' | 'rotation' | 'quota-fallback';
159
152
  interface RoutingTraceEntry {
160
153
  readonly at: number;
@@ -190,6 +183,8 @@ declare class OpenCodeAdapter extends LlmAdapter {
190
183
  private stickyWrites;
191
184
  /** key fingerprint → cooldown deadline and the rejection that caused it. */
192
185
  private readonly cooldowns;
186
+ /** key + model → short soft rejection; other models stay usable. */
187
+ private readonly modelCooldowns;
193
188
  private readonly sticky;
194
189
  private readonly routing;
195
190
  private warnedTools;
@@ -215,6 +210,11 @@ declare class OpenCodeAdapter extends LlmAdapter {
215
210
  private send;
216
211
  private keyId;
217
212
  private coolingDown;
213
+ /** Current model-scoped soft cooldown for one key, if any. */
214
+ modelHealth(key: string, model: string): {
215
+ coolingUntil: number;
216
+ reason: string;
217
+ } | undefined;
218
218
  /** Current cooldown for one resolved key, if any. */
219
219
  accountHealth(key: string): {
220
220
  coolingUntil: number;
@@ -225,11 +225,12 @@ declare class OpenCodeAdapter extends LlmAdapter {
225
225
  private loadCooldowns;
226
226
  private pruneCooldowns;
227
227
  private persistCooldowns;
228
+ private modelCoolingDown;
229
+ private markRejection;
230
+ private markModelCooldown;
228
231
  private markCooldown;
229
232
  private isTransient;
230
233
  }
231
- //#endregion
232
- //#region src/usage.d.ts
233
234
  interface UsageWindow {
234
235
  readonly percentUsed: number;
235
236
  readonly resetsAt?: string;
@@ -247,6 +248,7 @@ interface UsageResult {
247
248
  readonly report?: UsageReport;
248
249
  readonly error?: string;
249
250
  readonly keyStatus?: UsageKeyStatus;
251
+ readonly latencyMs?: number;
250
252
  readonly checkedAt: number;
251
253
  }
252
254
  interface UsageWindowPayload {
@@ -269,8 +271,6 @@ interface UsageReader {
269
271
  }
270
272
  /** Cached reader for the (undocumented) Go usage endpoint. Fail-open by design. */
271
273
  declare function createUsageReader(config: () => ResolvedConfig, resolveKey: KeyResolver, fetchImpl?: typeof fetch): UsageReader;
272
- //#endregion
273
- //#region src/commands.d.ts
274
274
  interface CommandDeps {
275
275
  readonly config: () => ResolvedConfig;
276
276
  readonly pool: KeyPool;
@@ -278,8 +278,6 @@ interface CommandDeps {
278
278
  readonly adapter: OpenCodeAdapter;
279
279
  }
280
280
  declare function applyCommands(ctx: Context, deps: CommandDeps): void;
281
- //#endregion
282
- //#region src/analytics.d.ts
283
281
  interface ModelUsageRow {
284
282
  provider: string;
285
283
  model: string;
@@ -355,8 +353,6 @@ interface AnalyticsReport {
355
353
  days: DayUsageRow[];
356
354
  sessions: SessionUsageRow[];
357
355
  }
358
- //#endregion
359
- //#region src/routes.d.ts
360
356
  interface RouteDeps {
361
357
  readonly config: () => ResolvedConfig;
362
358
  readonly adapter: OpenCodeAdapter;
@@ -368,8 +364,6 @@ interface RouteDeps {
368
364
  }
369
365
  /** Register the Host HTTP surface consumed by the browser settings page. */
370
366
  declare function applyRoutes(ctx: Context, web: WebServer, deps: RouteDeps): void;
371
- //#endregion
372
- //#region src/session-id.d.ts
373
367
  /**
374
368
  * A stable, opaque session id for `x-opencode-session`. The header must stay
375
369
  * identical across every turn and retry of one conversation; deriving it from
@@ -378,16 +372,12 @@ declare function applyRoutes(ctx: Context, web: WebServer, deps: RouteDeps): voi
378
372
  * routing key, so the `dsh_` prefix is safe.
379
373
  */
380
374
  declare function openCodeSessionId(options: GenerateOptions): string;
381
- //#endregion
382
- //#region src/errors.d.ts
383
375
  /**
384
376
  * Translate an OpenCode HTTP failure into the harness failure taxonomy.
385
377
  * OpenCode reports several distinct conditions as 400/401, so the body type
386
378
  * and message decide the code — never the status alone.
387
379
  */
388
380
  declare function classifyResponseError(status: number, text: string, headers: Headers): LlmError;
389
- //#endregion
390
- //#region src/models.generated.d.ts
391
381
  type OpenCodeTransport = 'openai-chat' | 'openai-responses' | 'anthropic' | 'google';
392
382
  interface OpenCodeModelMeta {
393
383
  readonly id: string;
@@ -404,8 +394,6 @@ interface OpenCodeModelMeta {
404
394
  readonly cacheRead: number;
405
395
  };
406
396
  }
407
- //#endregion
408
- //#region src/catalog.d.ts
409
397
  interface EffectiveModel extends OpenCodeModelMeta {
410
398
  readonly transport: OpenCodeTransport;
411
399
  readonly contextWindow: number;
@@ -413,8 +401,6 @@ interface EffectiveModel extends OpenCodeModelMeta {
413
401
  declare function effectiveModel(id: string, config: ResolvedConfig): EffectiveModel;
414
402
  declare function describeModel(meta: EffectiveModel, provider?: string): string;
415
403
  declare function clearCatalogCache(): void;
416
- //#endregion
417
- //#region src/index.d.ts
418
404
  declare const name = "llm-opencode";
419
405
  declare const inject: string[];
420
406
  /**
@@ -427,6 +413,5 @@ declare const inject: string[];
427
413
  * next request without a restart.
428
414
  */
429
415
  declare function apply(ctx: Context, config: Config): void;
430
- //#endregion
431
416
  export { type CommandDeps, Config, type Config as OpenCodeConfig, OpenCodeAdapter, type OpenCodeAdapterDeps, type ResolvedConfig, type ResolvedKey, type RouteDeps, type TransportId, type UsageReport, type UsageResult, type UsageWindow, apply, applyCommands, applyRoutes, classifyResponseError, clearCatalogCache, createKeyPool, createKeyResolver, createUsageReader, describeModel, effectiveModel, inject, name, normalizeUsage, openCodeAuthPath, openCodeSessionId };
432
417
  //# sourceMappingURL=index.d.ts.map
package/lib/index.js CHANGED
@@ -10,7 +10,7 @@ import { launchEnvironmentOf } from "@deepseek-ai/dsh-launch-environment";
10
10
  import { spawn } from "node:child_process";
11
11
  import { fileURLToPath } from "node:url";
12
12
  import { isIP } from "node:net";
13
- //#region src/config.ts
13
+ // src/config.ts
14
14
  const NS = "llm-opencode";
15
15
  const DEFAULT_API_KEY_ENV = "OPENCODE_API_KEY";
16
16
  const DEFAULT_API_BASE = "https://opencode.ai/inference";
@@ -18,7 +18,7 @@ const DEFAULT_GO_API_BASE = "https://opencode.ai/inference/go";
18
18
  const DEFAULT_GO_ZEN_BASE = "https://opencode.ai/zen/go/v1";
19
19
  const DEFAULT_USAGE_URL = "https://opencode.ai/zen/go/v1/usage";
20
20
  const DEFAULT_AUTH_ENTRY = "opencode-go";
21
- const DEFAULT_USER_AGENT = "dsh-opencode-provider/0.5.18";
21
+ const DEFAULT_USER_AGENT = "dsh-opencode-provider/0.5.20";
22
22
  const DEFAULT_CONTEXT_WINDOW = 262144;
23
23
  const DEFAULT_MAX_TOKENS = 32768;
24
24
  const DEFAULT_GENERATE_MAX_TOKENS = 32768;
@@ -151,8 +151,7 @@ function resolveConfig(config) {
151
151
  userAgent: typeof config.userAgent === "string" && config.userAgent !== "" ? config.userAgent : DEFAULT_USER_AGENT
152
152
  };
153
153
  }
154
- //#endregion
155
- //#region src/models.generated.ts
154
+ // src/models.generated.ts
156
155
  const GENERATED_MODELS = {
157
156
  "beam-free": {
158
157
  "id": "beam-free",
@@ -791,8 +790,7 @@ const GENERATED_MODELS = {
791
790
  }
792
791
  }
793
792
  };
794
- //#endregion
795
- //#region src/provider-ids.ts
793
+ // src/provider-ids.ts
796
794
  /**
797
795
  * Provider ids owned by this plugin. The built-in llm-pi-ai catalog already
798
796
  * declares `opencode` and `opencode-go`; the core registry rejects duplicate
@@ -801,8 +799,7 @@ const GENERATED_MODELS = {
801
799
  const CONSOLE_PROVIDER = "opencode-direct";
802
800
  const GO_PROVIDER = "opencode-go-direct";
803
801
  const PLUGIN_PROVIDER_SET = /* @__PURE__ */ new Set([CONSOLE_PROVIDER, GO_PROVIDER]);
804
- //#endregion
805
- //#region src/routing.ts
802
+ // src/routing.ts
806
803
  function isGoProvider(provider) {
807
804
  return provider === "opencode-go-direct" || provider === "opencode-go";
808
805
  }
@@ -826,8 +823,7 @@ function endpointFor(provider, transport, model, config) {
826
823
  case "google": return `${root}/google/v1beta/models/${encodeURIComponent(model)}:streamGenerateContent?alt=sse`;
827
824
  }
828
825
  }
829
- //#endregion
830
- //#region src/subscription-limits.ts
826
+ // src/subscription-limits.ts
831
827
  const UNLIMITED = {
832
828
  monthlyUsd: "unlimited",
833
829
  fiveHour: "unlimited",
@@ -974,8 +970,7 @@ function formatRequests(value) {
974
970
  function describeLimitsShort(limits) {
975
971
  return `Go ${formatRequests(limits.go.fiveHour)}/5h · ${formatRequests(limits.go.weekly)}/wk`;
976
972
  }
977
- //#endregion
978
- //#region src/catalog.ts
973
+ // src/catalog.ts
979
974
  const CATALOG_TTL_MS = 6e5;
980
975
  const CATALOG_TIMEOUT_MS = 15e3;
981
976
  const catalogCache = /* @__PURE__ */ new Map();
@@ -1090,11 +1085,21 @@ function describeModel(meta, provider) {
1090
1085
  }
1091
1086
  return parts.join(" · ");
1092
1087
  }
1088
+ function catalogStatus(provider, config, count) {
1089
+ const cached = catalogCache.get(`${provider}|${modelsUrlFor(provider, config)}`);
1090
+ return cached === void 0 ? {
1091
+ source: "snapshot",
1092
+ count
1093
+ } : {
1094
+ source: "live",
1095
+ count,
1096
+ fetchedAt: cached.at
1097
+ };
1098
+ }
1093
1099
  function clearCatalogCache() {
1094
1100
  catalogCache.clear();
1095
1101
  }
1096
- //#endregion
1097
- //#region src/account-rules.ts
1102
+ // src/account-rules.ts
1098
1103
  function glob(pattern) {
1099
1104
  const escaped = pattern.replace(/[.+^${}()|[\]\\]/g, "\\$&").replace(/\*/g, ".*").replace(/\?/g, ".");
1100
1105
  return new RegExp(`^${escaped}$`, "i");
@@ -1117,8 +1122,7 @@ function orderKeysForModel(keys, model, rules) {
1117
1122
  if (preferred === void 0) return keys;
1118
1123
  return [preferred, ...keys.filter((entry) => entry !== preferred)];
1119
1124
  }
1120
- //#endregion
1121
- //#region src/errors.ts
1125
+ // src/errors.ts
1122
1126
  function parseErrorBody(text) {
1123
1127
  try {
1124
1128
  const body = JSON.parse(text);
@@ -1179,8 +1183,7 @@ function classifyStreamError(value, status = 200) {
1179
1183
  if (value.error === void 0) return void 0;
1180
1184
  return classifyResponseError(status, JSON.stringify(value), new Headers());
1181
1185
  }
1182
- //#endregion
1183
- //#region src/images.ts
1186
+ // src/images.ts
1184
1187
  const MAX_LONG_EDGE = 1568;
1185
1188
  const MAX_BYTES = 1048576;
1186
1189
  /** Total base64 image budget for one request; leaves room under the gateway cap. */
@@ -1191,7 +1194,7 @@ const MAX_REQUEST_IMAGES = 50;
1191
1194
  * dropped first; the transports render them as an "image omitted" placeholder
1192
1195
  * instead of failing the whole request.
1193
1196
  */
1194
- function selectRequestImages(refs) {
1197
+ function selectRequestImages(refs, occurrences) {
1195
1198
  const unique = [];
1196
1199
  const seen = /* @__PURE__ */ new Set();
1197
1200
  for (const ref of refs) {
@@ -1199,11 +1202,18 @@ function selectRequestImages(refs) {
1199
1202
  seen.add(ref.attachmentId);
1200
1203
  unique.push(ref);
1201
1204
  }
1205
+ const countOf = (ref) => Math.max(1, occurrences?.get(ref.attachmentId) ?? 1);
1206
+ const encoded = (bytes) => Math.ceil(Math.max(0, bytes) * 4 / 3);
1202
1207
  let start = 0;
1203
- let bytes = unique.reduce((sum, ref) => sum + Math.max(0, ref.bytes), 0);
1204
- while (start < unique.length && (unique.length - start > MAX_REQUEST_IMAGES || bytes > MAX_REQUEST_IMAGE_BYTES)) {
1208
+ let totalImages = unique.reduce((sum, ref) => sum + countOf(ref), 0);
1209
+ let bytes = unique.reduce((sum, ref) => sum + encoded(ref.bytes) * countOf(ref), 0);
1210
+ while (start < unique.length && (totalImages > MAX_REQUEST_IMAGES || bytes > MAX_REQUEST_IMAGE_BYTES)) {
1205
1211
  const dropped = unique[start];
1206
- if (dropped !== void 0) bytes -= Math.max(0, dropped.bytes);
1212
+ if (dropped !== void 0) {
1213
+ const count = countOf(dropped);
1214
+ totalImages -= count;
1215
+ bytes -= encoded(dropped.bytes) * count;
1216
+ }
1207
1217
  start += 1;
1208
1218
  }
1209
1219
  const kept = unique.slice(start);
@@ -1226,21 +1236,23 @@ function targetOf(ref) {
1226
1236
  };
1227
1237
  }
1228
1238
  function hasImages(options) {
1229
- return options.messages.some((message) => message.content.some((block) => block.type === "image"));
1239
+ return options.messages.some((message) => message.content.some((block) => block.type === "image" && block.offloaded !== true));
1230
1240
  }
1231
1241
  /** Decode the distinct request images that fit the request-wide budget. */
1232
1242
  async function resolveImages(options, store, signal) {
1233
1243
  const refs = [];
1244
+ const counts = /* @__PURE__ */ new Map();
1234
1245
  const seen = /* @__PURE__ */ new Set();
1235
1246
  for (const message of options.messages) for (const block of message.content) {
1236
1247
  if (block.type !== "image" || block.offloaded === true) continue;
1237
1248
  const ref = block.attachment;
1249
+ counts.set(ref.attachmentId, (counts.get(ref.attachmentId) ?? 0) + 1);
1238
1250
  if (seen.has(ref.attachmentId)) continue;
1239
1251
  seen.add(ref.attachmentId);
1240
1252
  refs.push(ref);
1241
1253
  }
1242
1254
  const images = /* @__PURE__ */ new Map();
1243
- for (const ref of selectRequestImages(refs).kept) {
1255
+ for (const ref of selectRequestImages(refs, counts).kept) {
1244
1256
  const request = await store.readImageRequest(ref, targetOf(ref), signal);
1245
1257
  const base64 = Buffer.from(request.data).toString("base64");
1246
1258
  images.set(ref.attachmentId, {
@@ -1256,8 +1268,7 @@ async function resolveImages(options, store, signal) {
1256
1268
  function offloadPlaceholder(ref) {
1257
1269
  return `[image omitted: ${ref.name ?? ref.attachmentId}]`;
1258
1270
  }
1259
- //#endregion
1260
- //#region src/files.ts
1271
+ // src/files.ts
1261
1272
  /**
1262
1273
  * Model-facing handle for a file when the adapter receives a raw FileBlock.
1263
1274
  * The harness normally projects files to text with an execution-world path
@@ -1267,8 +1278,7 @@ function filePlaceholder(ref) {
1267
1278
  const digest = String(ref.attachmentId).slice(7, 15);
1268
1279
  return `[File ${JSON.stringify(ref.name)} (${ref.bytes} bytes, sha256:${digest}) was uploaded, but the current execution environment cannot access a readable path. Report that limitation if its contents are needed; do not claim to have read it.]`;
1269
1280
  }
1270
- //#endregion
1271
- //#region src/tool-schema.ts
1281
+ // src/tool-schema.ts
1272
1282
  /**
1273
1283
  * Normalize a tool's parameters schema to the object root the gateway
1274
1284
  * validates. Ported behavior from the Command Code provider: pass an object
@@ -1374,8 +1384,7 @@ function normalizeToolParameters(parameters, depth = 0) {
1374
1384
  additionalProperties: true
1375
1385
  };
1376
1386
  }
1377
- //#endregion
1378
- //#region src/messages.ts
1387
+ // src/messages.ts
1379
1388
  function textOnly$3(content) {
1380
1389
  const parts = [];
1381
1390
  for (const block of content) if (block.type === "text") parts.push(block.text);
@@ -1523,8 +1532,7 @@ function buildChatBody(options, model, config, images) {
1523
1532
  ...options.stop === void 0 || options.stop.length === 0 ? {} : { stop: options.stop }
1524
1533
  };
1525
1534
  }
1526
- //#endregion
1527
- //#region src/request-log.ts
1535
+ // src/request-log.ts
1528
1536
  const DAY_MS$1 = 864e5;
1529
1537
  const MAX_LOG_BYTES = 2097152;
1530
1538
  const KEEP_LINES = 5e3;
@@ -1707,8 +1715,7 @@ async function readRequestHeatmap(days, now = Date.now()) {
1707
1715
  }).sort((a, b) => b.total - a.total || a.account.localeCompare(b.account))
1708
1716
  };
1709
1717
  }
1710
- //#endregion
1711
- //#region src/session-id.ts
1718
+ // src/session-id.ts
1712
1719
  function sha256(value) {
1713
1720
  return createHash("sha256").update(value).digest("hex");
1714
1721
  }
@@ -1762,8 +1769,7 @@ function limitTools(tools, max = 128) {
1762
1769
  const rest = out.filter((tool) => !CORE_TOOLS.has(tool.name));
1763
1770
  return [...core, ...rest].slice(0, max);
1764
1771
  }
1765
- //#endregion
1766
- //#region src/transports/sse.ts
1772
+ // src/transports/sse.ts
1767
1773
  async function readWithIdleTimeout(reader, idleTimeoutMs, signal) {
1768
1774
  if (signal?.aborted === true) throw signal.reason;
1769
1775
  return await new Promise((resolve, reject) => {
@@ -1787,6 +1793,12 @@ async function readWithIdleTimeout(reader, idleTimeoutMs, signal) {
1787
1793
  reader.read().then((value) => finish(() => resolve(value)), (error) => finish(() => reject(error)));
1788
1794
  });
1789
1795
  }
1796
+ function appendDecoded(buffer, decoded) {
1797
+ let text = buffer + decoded;
1798
+ const trailingCR = text.endsWith("\r");
1799
+ if (trailingCR) text = text.slice(0, -1);
1800
+ return text.replace(/\r\n?/g, "\n") + (trailingCR ? "\r" : "");
1801
+ }
1790
1802
  /** Yield the `data:` payload of every SSE event, honoring cancellation and idle timeouts. */
1791
1803
  async function* sseData(body, idleTimeoutMs, signal) {
1792
1804
  const reader = body.getReader();
@@ -1796,7 +1808,7 @@ async function* sseData(body, idleTimeoutMs, signal) {
1796
1808
  for (;;) {
1797
1809
  const result = await readWithIdleTimeout(reader, idleTimeoutMs, signal);
1798
1810
  if (result.done) break;
1799
- buffer += decoder.decode(result.value, { stream: true }).replace(/\r\n/g, "\n");
1811
+ buffer = appendDecoded(buffer, decoder.decode(result.value, { stream: true }));
1800
1812
  for (;;) {
1801
1813
  const boundary = buffer.indexOf("\n\n");
1802
1814
  if (boundary < 0) break;
@@ -1824,7 +1836,7 @@ async function* sseEvents(body, idleTimeoutMs, signal) {
1824
1836
  for (;;) {
1825
1837
  const result = await readWithIdleTimeout(reader, idleTimeoutMs, signal);
1826
1838
  if (result.done) break;
1827
- buffer += decoder.decode(result.value, { stream: true }).replace(/\r\n/g, "\n");
1839
+ buffer = appendDecoded(buffer, decoder.decode(result.value, { stream: true }));
1828
1840
  for (;;) {
1829
1841
  const boundary = buffer.indexOf("\n\n");
1830
1842
  if (boundary < 0) break;
@@ -1854,8 +1866,7 @@ async function* sseEvents(body, idleTimeoutMs, signal) {
1854
1866
  reader.releaseLock();
1855
1867
  }
1856
1868
  }
1857
- //#endregion
1858
- //#region src/transports/anthropic.ts
1869
+ // src/transports/anthropic.ts
1859
1870
  const EFFORT_LEVELS$1 = /* @__PURE__ */ new Set([
1860
1871
  "low",
1861
1872
  "medium",
@@ -2040,6 +2051,7 @@ async function* parseAnthropicStream(response, idleTimeoutMs, signal) {
2040
2051
  let nextIndex = 0;
2041
2052
  let sawContent = false;
2042
2053
  let sawToolUse = false;
2054
+ let sawMessageStop = false;
2043
2055
  let stopReason;
2044
2056
  let inputUsage;
2045
2057
  let outputTokens;
@@ -2208,8 +2220,12 @@ async function* parseAnthropicStream(response, idleTimeoutMs, signal) {
2208
2220
  if (typeof event.usage?.output_tokens === "number") outputTokens = event.usage.output_tokens;
2209
2221
  continue;
2210
2222
  }
2211
- if (event.type === "message_stop") break;
2223
+ if (event.type === "message_stop") {
2224
+ sawMessageStop = true;
2225
+ break;
2226
+ }
2212
2227
  }
2228
+ if (sawContent && !sawMessageStop) throw new LlmError("Anthropic stream ended without message_stop", "TRANSPORT");
2213
2229
  const toolBlocks = [...blocks.values()].filter((block) => block.kind === "tool-call");
2214
2230
  if (toolBlocks.length > 0) {
2215
2231
  for (const block of toolBlocks) yield {
@@ -2239,8 +2255,7 @@ async function* parseAnthropicStream(response, idleTimeoutMs, signal) {
2239
2255
  } } : {}
2240
2256
  };
2241
2257
  }
2242
- //#endregion
2243
- //#region src/transports/google.ts
2258
+ // src/transports/google.ts
2244
2259
  function textOnly$1(content) {
2245
2260
  const parts = [];
2246
2261
  for (const block of content) if (block.type === "text") parts.push(block.text);
@@ -2483,6 +2498,7 @@ async function* parseGoogleStream(response, idleTimeoutMs, signal) {
2483
2498
  }
2484
2499
  }
2485
2500
  }
2501
+ if (sawContent && finishReason === void 0) throw new LlmError("Google stream ended without a finish reason", "TRANSPORT");
2486
2502
  if (textIndex !== void 0) yield {
2487
2503
  type: "block-end",
2488
2504
  index: textIndex,
@@ -2514,8 +2530,7 @@ async function* parseGoogleStream(response, idleTimeoutMs, signal) {
2514
2530
  } } : {}
2515
2531
  };
2516
2532
  }
2517
- //#endregion
2518
- //#region src/transports/openai-chat.ts
2533
+ // src/transports/openai-chat.ts
2519
2534
  function mapUsage$1(usage) {
2520
2535
  const prompt = usage.prompt_tokens ?? 0;
2521
2536
  const cached = usage.prompt_tokens_details?.cached_tokens ?? 0;
@@ -2554,8 +2569,12 @@ async function* parseChatStream(response, idleTimeoutMs, signal) {
2554
2569
  let finishReason;
2555
2570
  let usage;
2556
2571
  let sawContent = false;
2572
+ let sawTerminal = false;
2557
2573
  for await (const data of sseData(response.body, idleTimeoutMs, signal)) {
2558
- if (data === "[DONE]") break;
2574
+ if (data === "[DONE]") {
2575
+ sawTerminal = true;
2576
+ break;
2577
+ }
2559
2578
  let chunk;
2560
2579
  try {
2561
2580
  chunk = JSON.parse(data);
@@ -2638,9 +2657,13 @@ async function* parseChatStream(response, idleTimeoutMs, signal) {
2638
2657
  argumentsDelta
2639
2658
  };
2640
2659
  }
2641
- if (choice.finish_reason !== void 0 && choice.finish_reason !== null) finishReason = choice.finish_reason;
2660
+ if (choice.finish_reason !== void 0 && choice.finish_reason !== null) {
2661
+ finishReason = choice.finish_reason;
2662
+ sawTerminal = true;
2663
+ }
2642
2664
  }
2643
2665
  }
2666
+ if (sawContent && !sawTerminal) throw new LlmError("OpenCode stream ended without a terminal finish event", "TRANSPORT");
2644
2667
  if (textIndex !== void 0) yield {
2645
2668
  type: "block-end",
2646
2669
  index: textIndex,
@@ -2771,8 +2794,7 @@ async function* parseCompletion(chunk) {
2771
2794
  reason: finishReasonOf$1(choice?.finish_reason, (message?.tool_calls?.length ?? 0) > 0)
2772
2795
  };
2773
2796
  }
2774
- //#endregion
2775
- //#region src/transports/responses.ts
2797
+ // src/transports/responses.ts
2776
2798
  const EFFORT_LEVELS = /* @__PURE__ */ new Set([
2777
2799
  "minimal",
2778
2800
  "low",
@@ -2927,6 +2949,7 @@ async function* parseResponsesStream(response, idleTimeoutMs, signal) {
2927
2949
  const tools = /* @__PURE__ */ new Map();
2928
2950
  let sawContent = false;
2929
2951
  let hasTools = false;
2952
+ let completed = false;
2930
2953
  let usage;
2931
2954
  let incomplete;
2932
2955
  for await (const { event: eventName, data } of sseEvents(response.body, idleTimeoutMs, signal)) {
@@ -3040,11 +3063,13 @@ async function* parseResponsesStream(response, idleTimeoutMs, signal) {
3040
3063
  continue;
3041
3064
  }
3042
3065
  if (type === "response.completed") {
3066
+ completed = true;
3043
3067
  usage = mapUsage(event.response?.usage);
3044
3068
  incomplete = event.response?.incomplete_details?.reason;
3045
3069
  continue;
3046
3070
  }
3047
3071
  }
3072
+ if (sawContent && !completed) throw new LlmError("OpenAI Responses stream ended without response.completed", "TRANSPORT");
3048
3073
  if (textIndex !== void 0) yield {
3049
3074
  type: "block-end",
3050
3075
  index: textIndex,
@@ -3081,8 +3106,7 @@ async function* parseResponsesStream(response, idleTimeoutMs, signal) {
3081
3106
  reason: finishReasonOf(hasTools, incomplete)
3082
3107
  };
3083
3108
  }
3084
- //#endregion
3085
- //#region src/adapter.ts
3109
+ // src/adapter.ts
3086
3110
  function defaultCooldownPath() {
3087
3111
  const home = process.env.DSH_HOME?.trim() || join(homedir(), ".dsh");
3088
3112
  return join(home, "storages", "dsh-opencode", "cooldowns.json");
@@ -3100,6 +3124,8 @@ var OpenCodeAdapter = class extends LlmAdapter {
3100
3124
  stickyWrites = Promise.resolve();
3101
3125
  /** key fingerprint → cooldown deadline and the rejection that caused it. */
3102
3126
  cooldowns = /* @__PURE__ */ new Map();
3127
+ /** key + model → short soft rejection; other models stay usable. */
3128
+ modelCooldowns = /* @__PURE__ */ new Map();
3103
3129
  sticky = /* @__PURE__ */ new Map();
3104
3130
  routing = [];
3105
3131
  warnedTools = false;
@@ -3202,7 +3228,10 @@ var OpenCodeAdapter = class extends LlmAdapter {
3202
3228
  }
3203
3229
  }
3204
3230
  }
3205
- const initial = keys.find((entry) => fallback || !this.coolingDown(entry.key)) ?? keys[0];
3231
+ const eligible = fallback ? keys : keys.filter((entry) => !this.coolingDown(entry.key) && !this.modelCoolingDown(entry.key, options.model));
3232
+ const ordered = eligible.length > 0 ? eligible : keys;
3233
+ const forced = !fallback && eligible.length === 0;
3234
+ const initial = ordered[0];
3206
3235
  const url = endpointFor(options.provider, model.transport, options.model, config);
3207
3236
  let images;
3208
3237
  if (hasImages(options)) {
@@ -3226,8 +3255,8 @@ var OpenCodeAdapter = class extends LlmAdapter {
3226
3255
  let lastError;
3227
3256
  let lastAccount = initial?.label ?? "default";
3228
3257
  let loggedAny = false;
3229
- for (const [index, entry] of keys.entries()) {
3230
- if (!fallback && this.coolingDown(entry.key)) continue;
3258
+ for (const [index, entry] of ordered.entries()) {
3259
+ if (!fallback && !forced && (this.coolingDown(entry.key) || this.modelCoolingDown(entry.key, options.model))) continue;
3231
3260
  lastAccount = entry.label ?? "default";
3232
3261
  const attemptReason = index === 0 ? reason : "rotation";
3233
3262
  const attemptStartedAt = Date.now();
@@ -3254,9 +3283,9 @@ var OpenCodeAdapter = class extends LlmAdapter {
3254
3283
  const error = classifyResponseError(response.status, text, response.headers);
3255
3284
  lastError = error;
3256
3285
  logAttempt(false, error.code);
3257
- const canRotate = index < keys.length - 1;
3286
+ const canRotate = index < ordered.length - 1;
3258
3287
  if (error.code === "INVALID_CREDENTIAL" || error.code === "RATE_LIMIT" || error.code === "QUOTA") {
3259
- if (!fallback) this.markCooldown(entry.key, response.status, response.headers, error.code);
3288
+ if (!fallback) this.markRejection(entry.key, options.model, response.status, response.headers, error.code);
3260
3289
  responseFailure = true;
3261
3290
  if (canRotate) continue;
3262
3291
  }
@@ -3297,8 +3326,8 @@ var OpenCodeAdapter = class extends LlmAdapter {
3297
3326
  }
3298
3327
  lastError = classified;
3299
3328
  const transient = this.isTransient(classified);
3300
- if (transient && !responseFailure && !fallback) this.markCooldown(entry.key, 0);
3301
- if (!(index < keys.length - 1 && transient)) {
3329
+ if (transient && !responseFailure && !fallback) this.markRejection(entry.key, options.model, 0, void 0, classified.code);
3330
+ if (!(index < ordered.length - 1 && transient)) {
3302
3331
  if (!fallback && config.quotaFallbackModel !== "" && isGoProvider(options.provider) && classified.code === "QUOTA") break;
3303
3332
  throw classified;
3304
3333
  }
@@ -3416,6 +3445,8 @@ var OpenCodeAdapter = class extends LlmAdapter {
3416
3445
  "user-agent": config.userAgent,
3417
3446
  ...transport === "anthropic" ? { "anthropic-version": "2023-06-01" } : {}
3418
3447
  };
3448
+ const callerAborted = () => signal?.aborted === true;
3449
+ if (callerAborted()) throw signal?.reason instanceof Error ? signal.reason : new LlmError("OpenCode request was aborted before dispatch", "TRANSPORT");
3419
3450
  const controller = new AbortController();
3420
3451
  let timedOut = false;
3421
3452
  const onAbort = () => controller.abort(signal?.reason);
@@ -3433,7 +3464,7 @@ var OpenCodeAdapter = class extends LlmAdapter {
3433
3464
  signal: controller.signal
3434
3465
  });
3435
3466
  } catch (error) {
3436
- if (signal?.aborted === true) throw signal.reason ?? error;
3467
+ if (callerAborted()) throw signal?.reason ?? error;
3437
3468
  if (timedOut) throw new LlmError(`OpenCode did not return response headers within ${config.requestTimeoutMs}ms`, "TIMEOUT", { cause: error });
3438
3469
  throw error;
3439
3470
  } finally {
@@ -3453,6 +3484,20 @@ var OpenCodeAdapter = class extends LlmAdapter {
3453
3484
  }
3454
3485
  return true;
3455
3486
  }
3487
+ /** Current model-scoped soft cooldown for one key, if any. */
3488
+ modelHealth(key, model) {
3489
+ const id = `${this.keyId(key)}:${model}`;
3490
+ const entry = this.modelCooldowns.get(id);
3491
+ if (entry === void 0) return void 0;
3492
+ if (entry.until <= Date.now()) {
3493
+ this.modelCooldowns.delete(id);
3494
+ return;
3495
+ }
3496
+ return {
3497
+ coolingUntil: entry.until,
3498
+ reason: entry.reason
3499
+ };
3500
+ }
3456
3501
  /** Current cooldown for one resolved key, if any. */
3457
3502
  accountHealth(key) {
3458
3503
  const id = this.keyId(key);
@@ -3505,6 +3550,30 @@ var OpenCodeAdapter = class extends LlmAdapter {
3505
3550
  await writeFile(this.cooldownPath, payload, "utf8");
3506
3551
  }).catch(() => {});
3507
3552
  }
3553
+ modelCoolingDown(key, model) {
3554
+ const id = `${this.keyId(key)}:${model}`;
3555
+ const entry = this.modelCooldowns.get(id);
3556
+ if (entry === void 0) return false;
3557
+ if (entry.until <= Date.now()) {
3558
+ this.modelCooldowns.delete(id);
3559
+ return false;
3560
+ }
3561
+ return true;
3562
+ }
3563
+ markRejection(key, model, status, headers, code) {
3564
+ if (code === "QUOTA" || code === "RATE_LIMIT") {
3565
+ this.markModelCooldown(key, model, code);
3566
+ return;
3567
+ }
3568
+ this.markCooldown(key, status, headers, code);
3569
+ }
3570
+ markModelCooldown(key, model, code) {
3571
+ const delayMs = code === "QUOTA" ? 3e5 : 6e4;
3572
+ this.modelCooldowns.set(`${this.keyId(key)}:${model}`, {
3573
+ until: Date.now() + delayMs,
3574
+ reason: code === "QUOTA" ? "quota" : "rate-limit"
3575
+ });
3576
+ }
3508
3577
  markCooldown(key, status, headers, code) {
3509
3578
  let delayMs = 3e4;
3510
3579
  let reason = "transport";
@@ -3532,8 +3601,7 @@ var OpenCodeAdapter = class extends LlmAdapter {
3532
3601
  return error.code === "RATE_LIMIT" || error.code === "QUOTA" || error.code === "SERVER" || error.code === "TIMEOUT" || error.code === "TRANSPORT" || error.code === "EMPTY_RESPONSE";
3533
3602
  }
3534
3603
  };
3535
- //#endregion
3536
- //#region src/commands.ts
3604
+ // src/commands.ts
3537
3605
  function bar(percent, width = 10) {
3538
3606
  const clamped = Math.max(0, Math.min(100, percent));
3539
3607
  const filled = Math.round(clamped / 100 * width);
@@ -3719,8 +3787,7 @@ function applyCommands(ctx, deps) {
3719
3787
  }
3720
3788
  });
3721
3789
  }
3722
- //#endregion
3723
- //#region src/credentials.ts
3790
+ // src/credentials.ts
3724
3791
  /** OpenCode stores its own sign-in at this XDG data path. */
3725
3792
  function openCodeAuthPath() {
3726
3793
  const base = process.env.XDG_DATA_HOME?.trim() || join(homedir(), ".local", "share");
@@ -3854,8 +3921,7 @@ function missingKey(config) {
3854
3921
  function createKeyResolver(ctx, config) {
3855
3922
  return createKeyPool(ctx, config).resolve;
3856
3923
  }
3857
- //#endregion
3858
- //#region src/local-host.ts
3924
+ // src/local-host.ts
3859
3925
  const LOOPBACK = /* @__PURE__ */ new Set([
3860
3926
  "localhost",
3861
3927
  "localhost.",
@@ -3896,8 +3962,7 @@ function sameOrigin(authority, origin) {
3896
3962
  return false;
3897
3963
  }
3898
3964
  }
3899
- //#endregion
3900
- //#region src/plugin-updater.ts
3965
+ // src/plugin-updater.ts
3901
3966
  const UPDATE_HEADER = "x-dsh-plugin-update";
3902
3967
  const UPDATE_TIMEOUT_MS = 6e5;
3903
3968
  const VERSION_CACHE_MS = 3e5;
@@ -4193,8 +4258,7 @@ function registerPluginUpdater(ctx, options) {
4193
4258
  }
4194
4259
  });
4195
4260
  }
4196
- //#endregion
4197
- //#region src/analytics.ts
4261
+ // src/analytics.ts
4198
4262
  const DAY_MS = 864e5;
4199
4263
  const CACHE_TTL_MS = 6e4;
4200
4264
  const MAX_FILES = 5e3;
@@ -4465,8 +4529,7 @@ async function readModelAnalytics(days = 30) {
4465
4529
  });
4466
4530
  return report;
4467
4531
  }
4468
- //#endregion
4469
- //#region src/routes-public.ts
4532
+ // src/routes-public.ts
4470
4533
  function publicConfig(config) {
4471
4534
  return {
4472
4535
  apiKeyEnv: config.apiKeyEnv,
@@ -4499,8 +4562,7 @@ function publicConfig(config) {
4499
4562
  userAgent: config.userAgent
4500
4563
  };
4501
4564
  }
4502
- //#endregion
4503
- //#region src/routes-values.ts
4565
+ // src/routes-values.ts
4504
4566
  const TRANSPORTS = /* @__PURE__ */ new Set([
4505
4567
  "openai-chat",
4506
4568
  "openai-responses",
@@ -4560,8 +4622,7 @@ function cleanTransports(value) {
4560
4622
  for (const [key, entry] of Object.entries(value)) if (typeof entry === "string" && TRANSPORTS.has(entry)) out[key] = entry;
4561
4623
  return out;
4562
4624
  }
4563
- //#endregion
4564
- //#region src/routes.ts
4625
+ // src/routes.ts
4565
4626
  const MAX_BODY_BYTES = 262144;
4566
4627
  function json(res, status, value) {
4567
4628
  res.statusCode = status;
@@ -4779,7 +4840,13 @@ async function buildState(ctx, deps) {
4779
4840
  key: keyInfo,
4780
4841
  accounts,
4781
4842
  models,
4843
+ catalog: catalogStatus(GO_PROVIDER, config, models.length),
4782
4844
  usage: usage ?? null,
4845
+ service: {
4846
+ ...usage?.checkedAt === void 0 ? {} : { usageCheckedAt: usage.checkedAt },
4847
+ ...usage?.latencyMs === void 0 ? {} : { usageLatencyMs: usage.latencyMs },
4848
+ ...usage?.error === void 0 ? {} : { usageError: usage.error }
4849
+ },
4783
4850
  routing: deps.adapter.routingTrace(),
4784
4851
  auth: {
4785
4852
  entry: config.openCodeAuthEntry,
@@ -4889,12 +4956,14 @@ function applyRoutes(ctx, web, deps) {
4889
4956
  json(res, 400, { error: "no known fields in patch" });
4890
4957
  return;
4891
4958
  }
4892
- await settings.mutate(NS, ops, settingsRevision(ctx));
4959
+ const revision = typeof body.revision === "number" && Number.isInteger(body.revision) ? body.revision : settingsRevision(ctx);
4960
+ await settings.mutate(NS, ops, revision);
4893
4961
  deps.usage.clear();
4894
4962
  deps.refreshTui?.();
4895
4963
  json(res, 200, { ok: true });
4896
4964
  } catch (error) {
4897
- json(res, 500, { error: error instanceof Error ? error.message : "config write failed" });
4965
+ const message = error instanceof Error ? error.message : "config write failed";
4966
+ json(res, /revision|conflict|stale/i.test(message) ? 409 : 500, { error: message });
4898
4967
  }
4899
4968
  });
4900
4969
  route("/dsh-opencode/key", async (req, res) => {
@@ -5062,8 +5131,7 @@ function applyRoutes(ctx, web, deps) {
5062
5131
  for (const dispose of disposers.splice(0)) dispose();
5063
5132
  }, "dsh-opencode-provider: HTTP routes");
5064
5133
  }
5065
- //#endregion
5066
- //#region src/tui-settings.ts
5134
+ // src/tui-settings.ts
5067
5135
  const TUI_ACCOUNT_AUTO = "auto";
5068
5136
  function setOrClear(text) {
5069
5137
  const trimmed = text.trim();
@@ -5307,8 +5375,7 @@ function applyOpenCodeTuiSettings(ctx, deps) {
5307
5375
  }, "dsh-opencode-provider: tui settings section");
5308
5376
  return refresh;
5309
5377
  }
5310
- //#endregion
5311
- //#region src/usage.ts
5378
+ // src/usage.ts
5312
5379
  const SUCCESS_TTL_MS = 3e5;
5313
5380
  const FAILURE_TTL_MS = 45e3;
5314
5381
  const USAGE_TIMEOUT_MS = 2e4;
@@ -5360,7 +5427,9 @@ function createUsageReader(config, resolveKey, fetchImpl = fetch) {
5360
5427
  if (Date.now() - cached.checkedAt < ttl) return cached;
5361
5428
  }
5362
5429
  const result = async () => {
5430
+ const startedAt = Date.now();
5363
5431
  const signal = AbortSignal.timeout(USAGE_TIMEOUT_MS);
5432
+ const latency = () => Math.max(0, Date.now() - startedAt);
5364
5433
  try {
5365
5434
  let response;
5366
5435
  let lastNetworkError;
@@ -5386,23 +5455,27 @@ function createUsageReader(config, resolveKey, fetchImpl = fetch) {
5386
5455
  return {
5387
5456
  error: "usage endpoint rejected the key (inference may still work)",
5388
5457
  keyStatus: /expired/i.test(body) ? "expired" : "invalid",
5458
+ latencyMs: latency(),
5389
5459
  checkedAt: Date.now()
5390
5460
  };
5391
5461
  }
5392
5462
  if (!response.ok) return {
5393
5463
  error: `usage endpoint returned HTTP ${response.status}`,
5394
5464
  keyStatus: "unavailable",
5465
+ latencyMs: latency(),
5395
5466
  checkedAt: Date.now()
5396
5467
  };
5397
5468
  return {
5398
5469
  report: normalizeUsage(await response.json()),
5399
5470
  keyStatus: "ok",
5471
+ latencyMs: latency(),
5400
5472
  checkedAt: Date.now()
5401
5473
  };
5402
5474
  } catch (error) {
5403
5475
  return {
5404
5476
  error: error instanceof Error ? error.message : "usage request failed",
5405
5477
  keyStatus: "unavailable",
5478
+ latencyMs: latency(),
5406
5479
  checkedAt: Date.now()
5407
5480
  };
5408
5481
  }
@@ -5416,8 +5489,7 @@ function createUsageReader(config, resolveKey, fetchImpl = fetch) {
5416
5489
  };
5417
5490
  return read;
5418
5491
  }
5419
- //#endregion
5420
- //#region src/index.ts
5492
+ // src/index.ts
5421
5493
  const name = "llm-opencode";
5422
5494
  const inject = ["llm"];
5423
5495
  /**
@@ -5501,7 +5573,6 @@ function apply(ctx, config) {
5501
5573
  }), "dsh-opencode-provider: plugin updater");
5502
5574
  });
5503
5575
  }
5504
- //#endregion
5505
5576
  export { Config, OpenCodeAdapter, apply, applyCommands, applyRoutes, classifyResponseError, clearCatalogCache, createKeyPool, createKeyResolver, createUsageReader, describeModel, effectiveModel, inject, name, normalizeUsage, openCodeAuthPath, openCodeSessionId };
5506
5577
 
5507
5578
  //# sourceMappingURL=index.js.map
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@goodandready/dsh-opencode-provider",
3
- "version": "0.5.18",
3
+ "version": "0.5.20",
4
4
  "description": "OpenCode / OpenCode Go LLM provider plugin for DeepSeek Harness (dsh).",
5
5
  "license": "MIT",
6
6
  "type": "module",
@@ -69,10 +69,8 @@
69
69
  "build": "tsdown",
70
70
  "prepare": "tsdown 1>&2",
71
71
  "typecheck": "tsc --noEmit",
72
- "test": "node --import tsx --test tests/core.test.ts tests/routes.test.ts tests/limits.test.ts tests/analytics.test.ts tests/adapter.test.ts tests/usage.test.ts tests/request-log.test.ts tests/account-rules.test.ts tests/commands.test.ts tests/tool-schema.test.ts tests/tui-settings.test.ts tests/plan-filter.test.ts tests/image-budget.test.ts tests/tools.test.ts tests/plugin-updater.test.ts tests/client-render.test.tsx",
72
+ "test": "node --import tsx --test tests/core.test.ts tests/routes.test.ts tests/limits.test.ts tests/analytics.test.ts tests/adapter.test.ts tests/usage.test.ts tests/request-log.test.ts tests/account-rules.test.ts tests/commands.test.ts tests/tool-schema.test.ts tests/tui-settings.test.ts tests/plan-filter.test.ts tests/image-budget.test.ts tests/tools.test.ts tests/plugin-updater.test.ts tests/client-render.test.tsx tests/sse.test.ts",
73
73
  "check": "npm run typecheck && npm test && npm run build",
74
- "test:engine": "node scripts/test-engines.mjs",
75
- "test:install": "node scripts/test-engines.mjs --install-only",
76
74
  "release": "npm run check && npm pack",
77
75
  "sync:models": "node scripts/sync-models.mjs"
78
76
  },