@goodandready/dsh-opencode-provider 0.5.19 → 0.5.21

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,19 @@
1
1
  # Changelog
2
2
 
3
+ ## 0.5.21
4
+
5
+ - **Analytics completeness**: projection scans above 5000 files now select the newest files by metadata instead of an arbitrary readdir page; file-limit and unreadable-projection cases set `AnalyticsReport.partial`, surfaced in the dashboard.
6
+ - 113 tests.
7
+
8
+ ## 0.5.20
9
+
10
+ - **CI/reproducibility**: regenerated `package-lock.json` with the normal npm resolver; clean `npm ci --ignore-scripts` now succeeds and `npm run check` passes.
11
+ - **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.
12
+ - **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.
13
+ - **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.
14
+ - **Images**: request budget counts repeated image occurrences and base64 overhead; offloaded images no longer require the attachment service.
15
+ - **Docs/tests**: added project `AGENTS.md`; removed the unsafe local engine-matrix script pending the approved DSH test contour (`#55`).
16
+
3
17
  ## 0.5.19
4
18
 
5
19
  - **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.
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.19.tgz
133
+ dsh plugin --profile web add /absolute/path/to/goodandready-dsh-opencode-provider-0.5.21.tgz
134
134
  # DSH Desktop:
135
- dsh plugin --profile desktop add /absolute/path/to/goodandready-dsh-opencode-provider-0.5.19.tgz
135
+ dsh plugin --profile desktop add /absolute/path/to/goodandready-dsh-opencode-provider-0.5.21.tgz
136
136
 
137
137
  # Or install the published package:
138
- dsh plugin --profile web add @goodandready/dsh-opencode-provider@0.5.19
138
+ dsh plugin --profile web add @goodandready/dsh-opencode-provider@0.5.21
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.19.tgz
132
+ dsh plugin --profile web add /absolute/path/to/goodandready-dsh-opencode-provider-0.5.21.tgz
133
133
  # DSH Desktop:
134
- dsh plugin --profile desktop add /absolute/path/to/goodandready-dsh-opencode-provider-0.5.19.tgz
134
+ dsh plugin --profile desktop add /absolute/path/to/goodandready-dsh-opencode-provider-0.5.21.tgz
135
135
 
136
136
  # Либо установка опубликованного пакета из npm:
137
- dsh plugin --profile web add @goodandready/dsh-opencode-provider@0.5.19
137
+ dsh plugin --profile web add @goodandready/dsh-opencode-provider@0.5.21
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.19.tgz
127
+ dsh plugin --profile web add /absolute/path/to/goodandready-dsh-opencode-provider-0.5.21.tgz
128
128
  # DSH 桌面端:
129
- dsh plugin --profile desktop add /absolute/path/to/goodandready-dsh-opencode-provider-0.5.19.tgz
129
+ dsh plugin --profile desktop add /absolute/path/to/goodandready-dsh-opencode-provider-0.5.21.tgz
130
130
 
131
131
  # 或从 npm 安装已发布版本:
132
- dsh plugin --profile web add @goodandready/dsh-opencode-provider@0.5.19
132
+ dsh plugin --profile web add @goodandready/dsh-opencode-provider@0.5.21
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: {
@@ -41,6 +41,7 @@ window.__ModuleLoader__.load({
41
41
  pasteKey: "Paste a key",
42
42
  saveKey: "Save key",
43
43
  clearKey: "Clear key",
44
+ clearKeyConfirm: "Delete the stored credential for {ref}? This cannot be undone.",
44
45
  importAuth: "Import from OpenCode",
45
46
  imported: "Imported the OpenCode key",
46
47
  testConnection: "Test connection",
@@ -137,6 +138,7 @@ window.__ModuleLoader__.load({
137
138
  heatmapByAccount: "Account",
138
139
  heatmapAllAccounts: "All accounts",
139
140
  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.",
141
+ analyticsPartial: "This report is partial: the projection scan hit its file limit or skipped unreadable files.",
140
142
  analyticsEmpty: "No plugin sessions in this window. Legacy opencode sessions belong to the built-in catalog.",
141
143
  showAll: "Show all",
142
144
  modelSearch: "Search models…",
@@ -245,6 +247,7 @@ window.__ModuleLoader__.load({
245
247
  pasteKey: "粘贴密钥",
246
248
  saveKey: "保存密钥",
247
249
  clearKey: "清除密钥",
250
+ clearKeyConfirm: "删除 {ref} 的已保存凭据?此操作无法撤销。",
248
251
  importAuth: "从 OpenCode 导入",
249
252
  imported: "已导入 OpenCode 密钥",
250
253
  testConnection: "测试连接",
@@ -341,6 +344,7 @@ window.__ModuleLoader__.load({
341
344
  heatmapByAccount: "账户",
342
345
  heatmapAllAccounts: "全部账户",
343
346
  analyticsHint: "按最后活动时间聚合本地会话元数据。切换过模型的会话计入最后使用的模型;本地投影不暴露失败。仅统计插件路由;旧 opencode 会话属于内置目录。",
347
+ analyticsPartial: "此报告不完整:投影扫描达到文件上限或跳过了不可读文件。",
344
348
  analyticsEmpty: "此窗口内没有插件会话。旧 opencode 会话属于内置目录。",
345
349
  showAll: "显示全部",
346
350
  modelSearch: "搜索模型…",
@@ -419,8 +423,7 @@ window.__ModuleLoader__.load({
419
423
  }
420
424
  };
421
425
  const GO_PROVIDER = "opencode-go-direct";
422
- //#endregion
423
- //#region src/client/api.ts
426
+ // src/client/api.ts
424
427
  async function call(path, init) {
425
428
  const response = await fetch(path, {
426
429
  credentials: "same-origin",
@@ -456,10 +459,13 @@ window.__ModuleLoader__.load({
456
459
  body: JSON.stringify({ label })
457
460
  });
458
461
  }
459
- function saveConfig(patch) {
462
+ function saveConfig(patch, revision) {
460
463
  return call("/dsh-opencode/config", {
461
464
  method: "POST",
462
- body: JSON.stringify({ patch })
465
+ body: JSON.stringify(revision === void 0 ? { patch } : {
466
+ patch,
467
+ revision
468
+ })
463
469
  });
464
470
  }
465
471
  function fetchUpdate() {
@@ -508,11 +514,9 @@ window.__ModuleLoader__.load({
508
514
  })
509
515
  });
510
516
  }
511
- //#endregion
512
- //#region src/client/events.ts
517
+ // src/client/events.ts
513
518
  const CONFIG_CHANGED_EVENT = "dsh-opencode-config-changed";
514
- //#endregion
515
- //#region src/client/heatmap.tsx
519
+ // src/client/heatmap.tsx
516
520
  const DAYS = [
517
521
  "Mon",
518
522
  "Tue",
@@ -721,8 +725,7 @@ window.__ModuleLoader__.load({
721
725
  ]
722
726
  });
723
727
  }
724
- //#endregion
725
- //#region src/client/usage-analytics.tsx
728
+ // src/client/usage-analytics.tsx
726
729
  function compact(value) {
727
730
  if (!Number.isFinite(value) || value <= 0) return "0";
728
731
  if (value >= 1e6) return `${(value / 1e6).toFixed(1)}M`;
@@ -922,6 +925,10 @@ window.__ModuleLoader__.load({
922
925
  })
923
926
  ]
924
927
  }),
928
+ analytics.partial === true ? /* @__PURE__ */ (0, react_jsx_runtime.jsx)("p", {
929
+ className: "oc-hint",
930
+ children: t("analyticsPartial")
931
+ }) : null,
925
932
  /* @__PURE__ */ (0, react_jsx_runtime.jsx)("p", {
926
933
  className: "oc-hint",
927
934
  children: t("analyticsHint")
@@ -931,8 +938,7 @@ window.__ModuleLoader__.load({
931
938
  tab === "sessions" ? sessionRows(analytics, t) : null
932
939
  ] });
933
940
  }
934
- //#endregion
935
- //#region src/client/usage-bars.tsx
941
+ // src/client/usage-bars.tsx
936
942
  function percentClass(percent) {
937
943
  if (percent >= 90) return "oc-bar-fill oc-bar-fill-bad";
938
944
  if (percent >= 75) return "oc-bar-fill oc-bar-fill-warn";
@@ -1001,8 +1007,7 @@ window.__ModuleLoader__.load({
1001
1007
  ]
1002
1008
  });
1003
1009
  }
1004
- //#endregion
1005
- //#region src/quota-runway.ts
1010
+ // src/quota-runway.ts
1006
1011
  const HOUR = 36e5;
1007
1012
  const DAY = 24 * HOUR;
1008
1013
  const DURATIONS = {
@@ -1068,8 +1073,7 @@ window.__ModuleLoader__.load({
1068
1073
  };
1069
1074
  return known === 0 ? { kind: "unknown" } : { kind: "safe" };
1070
1075
  }
1071
- //#endregion
1072
- //#region src/client/quota-card.tsx
1076
+ // src/client/quota-card.tsx
1073
1077
  const PANEL_EVENT = "dsh-opencode-panel-state";
1074
1078
  function broadcastPanel(open) {
1075
1079
  if (typeof window === "undefined") return;
@@ -1611,11 +1615,11 @@ window.__ModuleLoader__.load({
1611
1615
  ]
1612
1616
  });
1613
1617
  }
1614
- //#endregion
1615
- //#region src/client/settings-draft.ts
1618
+ // src/client/settings-draft.ts
1616
1619
  function draftOf(state) {
1617
1620
  const allIds = state.models.map((model) => model.id);
1618
1621
  return {
1622
+ revision: state.revision ?? 0,
1619
1623
  apiKeyEnv: state.config.apiKeyEnv,
1620
1624
  apiBase: state.config.apiBase,
1621
1625
  goApiBase: state.config.goApiBase,
@@ -1701,8 +1705,7 @@ window.__ModuleLoader__.load({
1701
1705
  function describeLimitsFull(limits) {
1702
1706
  return `${planLine("Go", limits.go)} · ${planLine("Go Plus", limits.goPlus)}`;
1703
1707
  }
1704
- //#endregion
1705
- //#region src/client/settings-widgets.tsx
1708
+ // src/client/settings-widgets.tsx
1706
1709
  function Field(props) {
1707
1710
  return /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("label", {
1708
1711
  className: "oc-field",
@@ -1765,8 +1768,7 @@ window.__ModuleLoader__.load({
1765
1768
  if (account.coolingUntil <= 0) return label;
1766
1769
  return `${label} · ${untilText(account.coolingUntil)}`;
1767
1770
  }
1768
- //#endregion
1769
- //#region src/client/update-card.tsx
1771
+ // src/client/update-card.tsx
1770
1772
  function PluginUpdateCard(props) {
1771
1773
  const { t } = props;
1772
1774
  const [status, setStatus] = (0, react.useState)(null);
@@ -1803,6 +1805,9 @@ window.__ModuleLoader__.load({
1803
1805
  children: [t("pluginUpdate"), status === null ? null : status.updateAvailable ? /* @__PURE__ */ (0, react_jsx_runtime.jsx)("span", {
1804
1806
  className: "oc-badge oc-badge-warn",
1805
1807
  children: t("updateAvailable")
1808
+ }) : status.latestCheckFailed || status.latestVersion === void 0 ? /* @__PURE__ */ (0, react_jsx_runtime.jsx)("span", {
1809
+ className: "oc-badge oc-badge-warn",
1810
+ children: t("updateCheckFailed")
1806
1811
  }) : /* @__PURE__ */ (0, react_jsx_runtime.jsx)("span", {
1807
1812
  className: "oc-badge oc-badge-ok",
1808
1813
  children: t("upToDate")
@@ -1849,8 +1854,7 @@ window.__ModuleLoader__.load({
1849
1854
  ]
1850
1855
  });
1851
1856
  }
1852
- //#endregion
1853
- //#region src/client/auth-ref.ts
1857
+ // src/client/auth-ref.ts
1854
1858
  /**
1855
1859
  * Collision-free credential ref for an OpenCode auth entry. Hex encoding is
1856
1860
  * injective (unlike replacing punctuation with underscores) and uses only
@@ -1861,8 +1865,7 @@ window.__ModuleLoader__.load({
1861
1865
  for (const byte of new TextEncoder().encode(entry)) hex += byte.toString(16).padStart(2, "0");
1862
1866
  return `OPENCODE_AUTH_${hex}`;
1863
1867
  }
1864
- //#endregion
1865
- //#region src/client/settings-page.tsx
1868
+ // src/client/settings-page.tsx
1866
1869
  function keyStatusLabel(status, t) {
1867
1870
  if (status === "ok") return t("keyStatusOk");
1868
1871
  if (status === "expired") return t("keyStatusExpired");
@@ -1928,7 +1931,7 @@ window.__ModuleLoader__.load({
1928
1931
  setSaving(true);
1929
1932
  setError(null);
1930
1933
  try {
1931
- await saveConfig(patchOf(draft));
1934
+ await saveConfig(patchOf(draft), draft.revision);
1932
1935
  window.dispatchEvent(new CustomEvent(CONFIG_CHANGED_EVENT));
1933
1936
  setNotice(t("saved"));
1934
1937
  setState(null);
@@ -2265,7 +2268,9 @@ window.__ModuleLoader__.load({
2265
2268
  type: "button",
2266
2269
  className: "oc-button oc-button-danger",
2267
2270
  onClick: () => {
2268
- clearKey(draft.apiKeyEnv.trim() === "" ? state.config.apiKeyEnv : draft.apiKeyEnv.trim()).then(() => {
2271
+ const ref = draft.apiKeyEnv.trim() === "" ? state.config.apiKeyEnv : draft.apiKeyEnv.trim();
2272
+ if (!window.confirm(t("clearKeyConfirm").replace("{ref}", ref))) return;
2273
+ clearKey(ref).then(() => {
2269
2274
  setNotice(t("saved"));
2270
2275
  return load();
2271
2276
  }).catch((caught) => setError(caught instanceof Error ? caught.message : t("failed")));
@@ -2335,8 +2340,8 @@ window.__ModuleLoader__.load({
2335
2340
  /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("h3", {
2336
2341
  className: "oc-card-title",
2337
2342
  children: [t("serviceStatus"), /* @__PURE__ */ (0, react_jsx_runtime.jsx)("span", {
2338
- className: `oc-badge ${state.service.usageError === void 0 ? "oc-badge-ok" : "oc-badge-warn"}`,
2339
- children: state.service.usageError === void 0 ? t("serviceOk") : t("serviceDegraded")
2343
+ className: `oc-badge ${state.service.usageCheckedAt === void 0 ? "oc-badge-warn" : state.service.usageError === void 0 ? "oc-badge-ok" : "oc-badge-bad"}`,
2344
+ children: state.service.usageCheckedAt === void 0 ? t("notChecked") : state.service.usageError === void 0 ? t("serviceOk") : t("serviceDegraded")
2340
2345
  })]
2341
2346
  }),
2342
2347
  /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("div", {
@@ -2347,7 +2352,7 @@ window.__ModuleLoader__.load({
2347
2352
  children: t("usageEndpoint")
2348
2353
  }),
2349
2354
  /* @__PURE__ */ (0, react_jsx_runtime.jsx)("span", {
2350
- className: `oc-badge ${state.service.usageError === void 0 ? "oc-badge-ok" : "oc-badge-bad"}`,
2355
+ className: `oc-badge ${state.service.usageCheckedAt === void 0 ? "oc-badge-warn" : state.service.usageError === void 0 ? "oc-badge-ok" : "oc-badge-bad"}`,
2351
2356
  children: state.service.usageCheckedAt === void 0 ? t("notChecked") : t("checkedAt").replace("{time}", new Date(state.service.usageCheckedAt).toLocaleTimeString())
2352
2357
  }),
2353
2358
  state.service.usageLatencyMs === void 0 ? null : /* @__PURE__ */ (0, react_jsx_runtime.jsx)("span", {
@@ -2564,7 +2569,10 @@ window.__ModuleLoader__.load({
2564
2569
  /* @__PURE__ */ (0, react_jsx_runtime.jsx)("button", {
2565
2570
  type: "button",
2566
2571
  className: "oc-button oc-button-danger",
2567
- onClick: () => update({ accounts: draft.accounts.filter((_, i) => i !== index) }),
2572
+ onClick: () => {
2573
+ setKeyDrafts({});
2574
+ update({ accounts: draft.accounts.filter((_, i) => i !== index) });
2575
+ },
2568
2576
  children: t("removeAccount")
2569
2577
  })
2570
2578
  ]
@@ -3006,8 +3014,7 @@ window.__ModuleLoader__.load({
3006
3014
  ]
3007
3015
  });
3008
3016
  }
3009
- //#endregion
3010
- //#region src/client/styles.ts
3017
+ // src/client/styles.ts
3011
3018
  const CSS_ID = "dsh-opencode-provider";
3012
3019
  const CSS = `
3013
3020
  :where(.oc-page, .oc-panel, .oc-card, .oc-foot, .oc-railButton) {
@@ -3127,8 +3134,7 @@ window.__ModuleLoader__.load({
3127
3134
  .oc-table th, .oc-table td { text-align: left; padding: 5px 8px; border-bottom: 1px solid var(--oc-border-soft); vertical-align: top; }
3128
3135
  .oc-table th { font-weight: 600; opacity: 0.8; }
3129
3136
  `;
3130
- //#endregion
3131
- //#region src/client/index.tsx
3137
+ // src/client/index.tsx
3132
3138
  function injectCss() {
3133
3139
  if (typeof document === "undefined") return () => {};
3134
3140
  if (document.querySelector(`style[data-dsh-plugin="dsh-opencode-provider"]`) !== null) return () => {};
@@ -3174,7 +3180,6 @@ window.__ModuleLoader__.load({
3174
3180
  }, QuotaCard));
3175
3181
  });
3176
3182
  }
3177
- //#endregion
3178
3183
  exports.apply = apply;
3179
3184
  exports.inject = inject;
3180
3185
  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;
@@ -238,8 +231,6 @@ declare class OpenCodeAdapter extends LlmAdapter {
238
231
  private markCooldown;
239
232
  private isTransient;
240
233
  }
241
- //#endregion
242
- //#region src/usage.d.ts
243
234
  interface UsageWindow {
244
235
  readonly percentUsed: number;
245
236
  readonly resetsAt?: string;
@@ -280,8 +271,6 @@ interface UsageReader {
280
271
  }
281
272
  /** Cached reader for the (undocumented) Go usage endpoint. Fail-open by design. */
282
273
  declare function createUsageReader(config: () => ResolvedConfig, resolveKey: KeyResolver, fetchImpl?: typeof fetch): UsageReader;
283
- //#endregion
284
- //#region src/commands.d.ts
285
274
  interface CommandDeps {
286
275
  readonly config: () => ResolvedConfig;
287
276
  readonly pool: KeyPool;
@@ -289,8 +278,6 @@ interface CommandDeps {
289
278
  readonly adapter: OpenCodeAdapter;
290
279
  }
291
280
  declare function applyCommands(ctx: Context, deps: CommandDeps): void;
292
- //#endregion
293
- //#region src/analytics.d.ts
294
281
  interface ModelUsageRow {
295
282
  provider: string;
296
283
  model: string;
@@ -365,9 +352,9 @@ interface AnalyticsReport {
365
352
  summary: AnalyticsSummary;
366
353
  days: DayUsageRow[];
367
354
  sessions: SessionUsageRow[];
355
+ /** True when the projection scan hit a cap or skipped unreadable files. */
356
+ partial?: boolean;
368
357
  }
369
- //#endregion
370
- //#region src/routes.d.ts
371
358
  interface RouteDeps {
372
359
  readonly config: () => ResolvedConfig;
373
360
  readonly adapter: OpenCodeAdapter;
@@ -379,8 +366,6 @@ interface RouteDeps {
379
366
  }
380
367
  /** Register the Host HTTP surface consumed by the browser settings page. */
381
368
  declare function applyRoutes(ctx: Context, web: WebServer, deps: RouteDeps): void;
382
- //#endregion
383
- //#region src/session-id.d.ts
384
369
  /**
385
370
  * A stable, opaque session id for `x-opencode-session`. The header must stay
386
371
  * identical across every turn and retry of one conversation; deriving it from
@@ -389,16 +374,12 @@ declare function applyRoutes(ctx: Context, web: WebServer, deps: RouteDeps): voi
389
374
  * routing key, so the `dsh_` prefix is safe.
390
375
  */
391
376
  declare function openCodeSessionId(options: GenerateOptions): string;
392
- //#endregion
393
- //#region src/errors.d.ts
394
377
  /**
395
378
  * Translate an OpenCode HTTP failure into the harness failure taxonomy.
396
379
  * OpenCode reports several distinct conditions as 400/401, so the body type
397
380
  * and message decide the code — never the status alone.
398
381
  */
399
382
  declare function classifyResponseError(status: number, text: string, headers: Headers): LlmError;
400
- //#endregion
401
- //#region src/models.generated.d.ts
402
383
  type OpenCodeTransport = 'openai-chat' | 'openai-responses' | 'anthropic' | 'google';
403
384
  interface OpenCodeModelMeta {
404
385
  readonly id: string;
@@ -415,8 +396,6 @@ interface OpenCodeModelMeta {
415
396
  readonly cacheRead: number;
416
397
  };
417
398
  }
418
- //#endregion
419
- //#region src/catalog.d.ts
420
399
  interface EffectiveModel extends OpenCodeModelMeta {
421
400
  readonly transport: OpenCodeTransport;
422
401
  readonly contextWindow: number;
@@ -424,8 +403,6 @@ interface EffectiveModel extends OpenCodeModelMeta {
424
403
  declare function effectiveModel(id: string, config: ResolvedConfig): EffectiveModel;
425
404
  declare function describeModel(meta: EffectiveModel, provider?: string): string;
426
405
  declare function clearCatalogCache(): void;
427
- //#endregion
428
- //#region src/index.d.ts
429
406
  declare const name = "llm-opencode";
430
407
  declare const inject: string[];
431
408
  /**
@@ -438,6 +415,5 @@ declare const inject: string[];
438
415
  * next request without a restart.
439
416
  */
440
417
  declare function apply(ctx: Context, config: Config): void;
441
- //#endregion
442
418
  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 };
443
419
  //# 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.19";
21
+ const DEFAULT_USER_AGENT = "dsh-opencode-provider/0.5.21";
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();
@@ -1104,8 +1099,7 @@ function catalogStatus(provider, config, count) {
1104
1099
  function clearCatalogCache() {
1105
1100
  catalogCache.clear();
1106
1101
  }
1107
- //#endregion
1108
- //#region src/account-rules.ts
1102
+ // src/account-rules.ts
1109
1103
  function glob(pattern) {
1110
1104
  const escaped = pattern.replace(/[.+^${}()|[\]\\]/g, "\\$&").replace(/\*/g, ".*").replace(/\?/g, ".");
1111
1105
  return new RegExp(`^${escaped}$`, "i");
@@ -1128,8 +1122,7 @@ function orderKeysForModel(keys, model, rules) {
1128
1122
  if (preferred === void 0) return keys;
1129
1123
  return [preferred, ...keys.filter((entry) => entry !== preferred)];
1130
1124
  }
1131
- //#endregion
1132
- //#region src/errors.ts
1125
+ // src/errors.ts
1133
1126
  function parseErrorBody(text) {
1134
1127
  try {
1135
1128
  const body = JSON.parse(text);
@@ -1190,8 +1183,7 @@ function classifyStreamError(value, status = 200) {
1190
1183
  if (value.error === void 0) return void 0;
1191
1184
  return classifyResponseError(status, JSON.stringify(value), new Headers());
1192
1185
  }
1193
- //#endregion
1194
- //#region src/images.ts
1186
+ // src/images.ts
1195
1187
  const MAX_LONG_EDGE = 1568;
1196
1188
  const MAX_BYTES = 1048576;
1197
1189
  /** Total base64 image budget for one request; leaves room under the gateway cap. */
@@ -1202,7 +1194,7 @@ const MAX_REQUEST_IMAGES = 50;
1202
1194
  * dropped first; the transports render them as an "image omitted" placeholder
1203
1195
  * instead of failing the whole request.
1204
1196
  */
1205
- function selectRequestImages(refs) {
1197
+ function selectRequestImages(refs, occurrences) {
1206
1198
  const unique = [];
1207
1199
  const seen = /* @__PURE__ */ new Set();
1208
1200
  for (const ref of refs) {
@@ -1210,11 +1202,18 @@ function selectRequestImages(refs) {
1210
1202
  seen.add(ref.attachmentId);
1211
1203
  unique.push(ref);
1212
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);
1213
1207
  let start = 0;
1214
- let bytes = unique.reduce((sum, ref) => sum + Math.max(0, ref.bytes), 0);
1215
- 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)) {
1216
1211
  const dropped = unique[start];
1217
- 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
+ }
1218
1217
  start += 1;
1219
1218
  }
1220
1219
  const kept = unique.slice(start);
@@ -1237,21 +1236,23 @@ function targetOf(ref) {
1237
1236
  };
1238
1237
  }
1239
1238
  function hasImages(options) {
1240
- 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));
1241
1240
  }
1242
1241
  /** Decode the distinct request images that fit the request-wide budget. */
1243
1242
  async function resolveImages(options, store, signal) {
1244
1243
  const refs = [];
1244
+ const counts = /* @__PURE__ */ new Map();
1245
1245
  const seen = /* @__PURE__ */ new Set();
1246
1246
  for (const message of options.messages) for (const block of message.content) {
1247
1247
  if (block.type !== "image" || block.offloaded === true) continue;
1248
1248
  const ref = block.attachment;
1249
+ counts.set(ref.attachmentId, (counts.get(ref.attachmentId) ?? 0) + 1);
1249
1250
  if (seen.has(ref.attachmentId)) continue;
1250
1251
  seen.add(ref.attachmentId);
1251
1252
  refs.push(ref);
1252
1253
  }
1253
1254
  const images = /* @__PURE__ */ new Map();
1254
- for (const ref of selectRequestImages(refs).kept) {
1255
+ for (const ref of selectRequestImages(refs, counts).kept) {
1255
1256
  const request = await store.readImageRequest(ref, targetOf(ref), signal);
1256
1257
  const base64 = Buffer.from(request.data).toString("base64");
1257
1258
  images.set(ref.attachmentId, {
@@ -1267,8 +1268,7 @@ async function resolveImages(options, store, signal) {
1267
1268
  function offloadPlaceholder(ref) {
1268
1269
  return `[image omitted: ${ref.name ?? ref.attachmentId}]`;
1269
1270
  }
1270
- //#endregion
1271
- //#region src/files.ts
1271
+ // src/files.ts
1272
1272
  /**
1273
1273
  * Model-facing handle for a file when the adapter receives a raw FileBlock.
1274
1274
  * The harness normally projects files to text with an execution-world path
@@ -1278,8 +1278,7 @@ function filePlaceholder(ref) {
1278
1278
  const digest = String(ref.attachmentId).slice(7, 15);
1279
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.]`;
1280
1280
  }
1281
- //#endregion
1282
- //#region src/tool-schema.ts
1281
+ // src/tool-schema.ts
1283
1282
  /**
1284
1283
  * Normalize a tool's parameters schema to the object root the gateway
1285
1284
  * validates. Ported behavior from the Command Code provider: pass an object
@@ -1385,8 +1384,7 @@ function normalizeToolParameters(parameters, depth = 0) {
1385
1384
  additionalProperties: true
1386
1385
  };
1387
1386
  }
1388
- //#endregion
1389
- //#region src/messages.ts
1387
+ // src/messages.ts
1390
1388
  function textOnly$3(content) {
1391
1389
  const parts = [];
1392
1390
  for (const block of content) if (block.type === "text") parts.push(block.text);
@@ -1534,8 +1532,7 @@ function buildChatBody(options, model, config, images) {
1534
1532
  ...options.stop === void 0 || options.stop.length === 0 ? {} : { stop: options.stop }
1535
1533
  };
1536
1534
  }
1537
- //#endregion
1538
- //#region src/request-log.ts
1535
+ // src/request-log.ts
1539
1536
  const DAY_MS$1 = 864e5;
1540
1537
  const MAX_LOG_BYTES = 2097152;
1541
1538
  const KEEP_LINES = 5e3;
@@ -1718,8 +1715,7 @@ async function readRequestHeatmap(days, now = Date.now()) {
1718
1715
  }).sort((a, b) => b.total - a.total || a.account.localeCompare(b.account))
1719
1716
  };
1720
1717
  }
1721
- //#endregion
1722
- //#region src/session-id.ts
1718
+ // src/session-id.ts
1723
1719
  function sha256(value) {
1724
1720
  return createHash("sha256").update(value).digest("hex");
1725
1721
  }
@@ -1773,8 +1769,7 @@ function limitTools(tools, max = 128) {
1773
1769
  const rest = out.filter((tool) => !CORE_TOOLS.has(tool.name));
1774
1770
  return [...core, ...rest].slice(0, max);
1775
1771
  }
1776
- //#endregion
1777
- //#region src/transports/sse.ts
1772
+ // src/transports/sse.ts
1778
1773
  async function readWithIdleTimeout(reader, idleTimeoutMs, signal) {
1779
1774
  if (signal?.aborted === true) throw signal.reason;
1780
1775
  return await new Promise((resolve, reject) => {
@@ -1798,6 +1793,12 @@ async function readWithIdleTimeout(reader, idleTimeoutMs, signal) {
1798
1793
  reader.read().then((value) => finish(() => resolve(value)), (error) => finish(() => reject(error)));
1799
1794
  });
1800
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
+ }
1801
1802
  /** Yield the `data:` payload of every SSE event, honoring cancellation and idle timeouts. */
1802
1803
  async function* sseData(body, idleTimeoutMs, signal) {
1803
1804
  const reader = body.getReader();
@@ -1807,7 +1808,7 @@ async function* sseData(body, idleTimeoutMs, signal) {
1807
1808
  for (;;) {
1808
1809
  const result = await readWithIdleTimeout(reader, idleTimeoutMs, signal);
1809
1810
  if (result.done) break;
1810
- buffer += decoder.decode(result.value, { stream: true }).replace(/\r\n/g, "\n");
1811
+ buffer = appendDecoded(buffer, decoder.decode(result.value, { stream: true }));
1811
1812
  for (;;) {
1812
1813
  const boundary = buffer.indexOf("\n\n");
1813
1814
  if (boundary < 0) break;
@@ -1835,7 +1836,7 @@ async function* sseEvents(body, idleTimeoutMs, signal) {
1835
1836
  for (;;) {
1836
1837
  const result = await readWithIdleTimeout(reader, idleTimeoutMs, signal);
1837
1838
  if (result.done) break;
1838
- buffer += decoder.decode(result.value, { stream: true }).replace(/\r\n/g, "\n");
1839
+ buffer = appendDecoded(buffer, decoder.decode(result.value, { stream: true }));
1839
1840
  for (;;) {
1840
1841
  const boundary = buffer.indexOf("\n\n");
1841
1842
  if (boundary < 0) break;
@@ -1865,8 +1866,7 @@ async function* sseEvents(body, idleTimeoutMs, signal) {
1865
1866
  reader.releaseLock();
1866
1867
  }
1867
1868
  }
1868
- //#endregion
1869
- //#region src/transports/anthropic.ts
1869
+ // src/transports/anthropic.ts
1870
1870
  const EFFORT_LEVELS$1 = /* @__PURE__ */ new Set([
1871
1871
  "low",
1872
1872
  "medium",
@@ -2051,6 +2051,7 @@ async function* parseAnthropicStream(response, idleTimeoutMs, signal) {
2051
2051
  let nextIndex = 0;
2052
2052
  let sawContent = false;
2053
2053
  let sawToolUse = false;
2054
+ let sawMessageStop = false;
2054
2055
  let stopReason;
2055
2056
  let inputUsage;
2056
2057
  let outputTokens;
@@ -2219,8 +2220,12 @@ async function* parseAnthropicStream(response, idleTimeoutMs, signal) {
2219
2220
  if (typeof event.usage?.output_tokens === "number") outputTokens = event.usage.output_tokens;
2220
2221
  continue;
2221
2222
  }
2222
- if (event.type === "message_stop") break;
2223
+ if (event.type === "message_stop") {
2224
+ sawMessageStop = true;
2225
+ break;
2226
+ }
2223
2227
  }
2228
+ if (sawContent && !sawMessageStop) throw new LlmError("Anthropic stream ended without message_stop", "TRANSPORT");
2224
2229
  const toolBlocks = [...blocks.values()].filter((block) => block.kind === "tool-call");
2225
2230
  if (toolBlocks.length > 0) {
2226
2231
  for (const block of toolBlocks) yield {
@@ -2250,8 +2255,7 @@ async function* parseAnthropicStream(response, idleTimeoutMs, signal) {
2250
2255
  } } : {}
2251
2256
  };
2252
2257
  }
2253
- //#endregion
2254
- //#region src/transports/google.ts
2258
+ // src/transports/google.ts
2255
2259
  function textOnly$1(content) {
2256
2260
  const parts = [];
2257
2261
  for (const block of content) if (block.type === "text") parts.push(block.text);
@@ -2494,6 +2498,7 @@ async function* parseGoogleStream(response, idleTimeoutMs, signal) {
2494
2498
  }
2495
2499
  }
2496
2500
  }
2501
+ if (sawContent && finishReason === void 0) throw new LlmError("Google stream ended without a finish reason", "TRANSPORT");
2497
2502
  if (textIndex !== void 0) yield {
2498
2503
  type: "block-end",
2499
2504
  index: textIndex,
@@ -2525,8 +2530,7 @@ async function* parseGoogleStream(response, idleTimeoutMs, signal) {
2525
2530
  } } : {}
2526
2531
  };
2527
2532
  }
2528
- //#endregion
2529
- //#region src/transports/openai-chat.ts
2533
+ // src/transports/openai-chat.ts
2530
2534
  function mapUsage$1(usage) {
2531
2535
  const prompt = usage.prompt_tokens ?? 0;
2532
2536
  const cached = usage.prompt_tokens_details?.cached_tokens ?? 0;
@@ -2565,8 +2569,12 @@ async function* parseChatStream(response, idleTimeoutMs, signal) {
2565
2569
  let finishReason;
2566
2570
  let usage;
2567
2571
  let sawContent = false;
2572
+ let sawTerminal = false;
2568
2573
  for await (const data of sseData(response.body, idleTimeoutMs, signal)) {
2569
- if (data === "[DONE]") break;
2574
+ if (data === "[DONE]") {
2575
+ sawTerminal = true;
2576
+ break;
2577
+ }
2570
2578
  let chunk;
2571
2579
  try {
2572
2580
  chunk = JSON.parse(data);
@@ -2649,9 +2657,13 @@ async function* parseChatStream(response, idleTimeoutMs, signal) {
2649
2657
  argumentsDelta
2650
2658
  };
2651
2659
  }
2652
- 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
+ }
2653
2664
  }
2654
2665
  }
2666
+ if (sawContent && !sawTerminal) throw new LlmError("OpenCode stream ended without a terminal finish event", "TRANSPORT");
2655
2667
  if (textIndex !== void 0) yield {
2656
2668
  type: "block-end",
2657
2669
  index: textIndex,
@@ -2782,8 +2794,7 @@ async function* parseCompletion(chunk) {
2782
2794
  reason: finishReasonOf$1(choice?.finish_reason, (message?.tool_calls?.length ?? 0) > 0)
2783
2795
  };
2784
2796
  }
2785
- //#endregion
2786
- //#region src/transports/responses.ts
2797
+ // src/transports/responses.ts
2787
2798
  const EFFORT_LEVELS = /* @__PURE__ */ new Set([
2788
2799
  "minimal",
2789
2800
  "low",
@@ -2938,6 +2949,7 @@ async function* parseResponsesStream(response, idleTimeoutMs, signal) {
2938
2949
  const tools = /* @__PURE__ */ new Map();
2939
2950
  let sawContent = false;
2940
2951
  let hasTools = false;
2952
+ let completed = false;
2941
2953
  let usage;
2942
2954
  let incomplete;
2943
2955
  for await (const { event: eventName, data } of sseEvents(response.body, idleTimeoutMs, signal)) {
@@ -3051,11 +3063,13 @@ async function* parseResponsesStream(response, idleTimeoutMs, signal) {
3051
3063
  continue;
3052
3064
  }
3053
3065
  if (type === "response.completed") {
3066
+ completed = true;
3054
3067
  usage = mapUsage(event.response?.usage);
3055
3068
  incomplete = event.response?.incomplete_details?.reason;
3056
3069
  continue;
3057
3070
  }
3058
3071
  }
3072
+ if (sawContent && !completed) throw new LlmError("OpenAI Responses stream ended without response.completed", "TRANSPORT");
3059
3073
  if (textIndex !== void 0) yield {
3060
3074
  type: "block-end",
3061
3075
  index: textIndex,
@@ -3092,8 +3106,7 @@ async function* parseResponsesStream(response, idleTimeoutMs, signal) {
3092
3106
  reason: finishReasonOf(hasTools, incomplete)
3093
3107
  };
3094
3108
  }
3095
- //#endregion
3096
- //#region src/adapter.ts
3109
+ // src/adapter.ts
3097
3110
  function defaultCooldownPath() {
3098
3111
  const home = process.env.DSH_HOME?.trim() || join(homedir(), ".dsh");
3099
3112
  return join(home, "storages", "dsh-opencode", "cooldowns.json");
@@ -3432,6 +3445,8 @@ var OpenCodeAdapter = class extends LlmAdapter {
3432
3445
  "user-agent": config.userAgent,
3433
3446
  ...transport === "anthropic" ? { "anthropic-version": "2023-06-01" } : {}
3434
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");
3435
3450
  const controller = new AbortController();
3436
3451
  let timedOut = false;
3437
3452
  const onAbort = () => controller.abort(signal?.reason);
@@ -3449,7 +3464,7 @@ var OpenCodeAdapter = class extends LlmAdapter {
3449
3464
  signal: controller.signal
3450
3465
  });
3451
3466
  } catch (error) {
3452
- if (signal?.aborted === true) throw signal.reason ?? error;
3467
+ if (callerAborted()) throw signal?.reason ?? error;
3453
3468
  if (timedOut) throw new LlmError(`OpenCode did not return response headers within ${config.requestTimeoutMs}ms`, "TIMEOUT", { cause: error });
3454
3469
  throw error;
3455
3470
  } finally {
@@ -3586,8 +3601,7 @@ var OpenCodeAdapter = class extends LlmAdapter {
3586
3601
  return error.code === "RATE_LIMIT" || error.code === "QUOTA" || error.code === "SERVER" || error.code === "TIMEOUT" || error.code === "TRANSPORT" || error.code === "EMPTY_RESPONSE";
3587
3602
  }
3588
3603
  };
3589
- //#endregion
3590
- //#region src/commands.ts
3604
+ // src/commands.ts
3591
3605
  function bar(percent, width = 10) {
3592
3606
  const clamped = Math.max(0, Math.min(100, percent));
3593
3607
  const filled = Math.round(clamped / 100 * width);
@@ -3773,8 +3787,7 @@ function applyCommands(ctx, deps) {
3773
3787
  }
3774
3788
  });
3775
3789
  }
3776
- //#endregion
3777
- //#region src/credentials.ts
3790
+ // src/credentials.ts
3778
3791
  /** OpenCode stores its own sign-in at this XDG data path. */
3779
3792
  function openCodeAuthPath() {
3780
3793
  const base = process.env.XDG_DATA_HOME?.trim() || join(homedir(), ".local", "share");
@@ -3908,8 +3921,7 @@ function missingKey(config) {
3908
3921
  function createKeyResolver(ctx, config) {
3909
3922
  return createKeyPool(ctx, config).resolve;
3910
3923
  }
3911
- //#endregion
3912
- //#region src/local-host.ts
3924
+ // src/local-host.ts
3913
3925
  const LOOPBACK = /* @__PURE__ */ new Set([
3914
3926
  "localhost",
3915
3927
  "localhost.",
@@ -3950,8 +3962,7 @@ function sameOrigin(authority, origin) {
3950
3962
  return false;
3951
3963
  }
3952
3964
  }
3953
- //#endregion
3954
- //#region src/plugin-updater.ts
3965
+ // src/plugin-updater.ts
3955
3966
  const UPDATE_HEADER = "x-dsh-plugin-update";
3956
3967
  const UPDATE_TIMEOUT_MS = 6e5;
3957
3968
  const VERSION_CACHE_MS = 3e5;
@@ -4247,8 +4258,7 @@ function registerPluginUpdater(ctx, options) {
4247
4258
  }
4248
4259
  });
4249
4260
  }
4250
- //#endregion
4251
- //#region src/analytics.ts
4261
+ // src/analytics.ts
4252
4262
  const DAY_MS = 864e5;
4253
4263
  const CACHE_TTL_MS = 6e4;
4254
4264
  const MAX_FILES = 5e3;
@@ -4492,8 +4502,25 @@ async function readModelAnalytics(days = 30) {
4492
4502
  if (cached !== void 0 && Date.now() - cached.at < CACHE_TTL_MS) return cached.report;
4493
4503
  const dir = projectionDirectory();
4494
4504
  const sessions = [];
4505
+ let partial = false;
4495
4506
  try {
4496
- const files = (await readdir(dir, { withFileTypes: true })).filter((entry) => entry.isFile() && entry.name.endsWith(".json")).slice(0, MAX_FILES);
4507
+ let files = (await readdir(dir, { withFileTypes: true })).filter((entry) => entry.isFile() && entry.name.endsWith(".json"));
4508
+ if (files.length > MAX_FILES) {
4509
+ files = (await Promise.all(files.map(async (entry) => {
4510
+ try {
4511
+ return {
4512
+ entry,
4513
+ at: (await stat(join(dir, entry.name))).mtimeMs
4514
+ };
4515
+ } catch {
4516
+ return {
4517
+ entry,
4518
+ at: 0
4519
+ };
4520
+ }
4521
+ }))).sort((left, right) => right.at - left.at).slice(0, MAX_FILES).map((item) => item.entry);
4522
+ partial = true;
4523
+ }
4497
4524
  let cursor = 0;
4498
4525
  const workers = Array.from({ length: Math.min(16, files.length) }, async () => {
4499
4526
  while (true) {
@@ -4507,20 +4534,24 @@ async function readModelAnalytics(days = 30) {
4507
4534
  meta.id = entry.name.replace(/\.json$/i, "");
4508
4535
  sessions.push(meta);
4509
4536
  }
4510
- } catch {}
4537
+ } catch {
4538
+ partial = true;
4539
+ }
4511
4540
  }
4512
4541
  });
4513
4542
  await Promise.all(workers);
4514
- } catch {}
4543
+ } catch {
4544
+ partial = true;
4545
+ }
4515
4546
  const report = aggregateSessions(sessions, clamped);
4547
+ report.partial = partial;
4516
4548
  cache.set(clamped, {
4517
4549
  at: Date.now(),
4518
4550
  report
4519
4551
  });
4520
4552
  return report;
4521
4553
  }
4522
- //#endregion
4523
- //#region src/routes-public.ts
4554
+ // src/routes-public.ts
4524
4555
  function publicConfig(config) {
4525
4556
  return {
4526
4557
  apiKeyEnv: config.apiKeyEnv,
@@ -4553,8 +4584,7 @@ function publicConfig(config) {
4553
4584
  userAgent: config.userAgent
4554
4585
  };
4555
4586
  }
4556
- //#endregion
4557
- //#region src/routes-values.ts
4587
+ // src/routes-values.ts
4558
4588
  const TRANSPORTS = /* @__PURE__ */ new Set([
4559
4589
  "openai-chat",
4560
4590
  "openai-responses",
@@ -4614,8 +4644,7 @@ function cleanTransports(value) {
4614
4644
  for (const [key, entry] of Object.entries(value)) if (typeof entry === "string" && TRANSPORTS.has(entry)) out[key] = entry;
4615
4645
  return out;
4616
4646
  }
4617
- //#endregion
4618
- //#region src/routes.ts
4647
+ // src/routes.ts
4619
4648
  const MAX_BODY_BYTES = 262144;
4620
4649
  function json(res, status, value) {
4621
4650
  res.statusCode = status;
@@ -4949,12 +4978,14 @@ function applyRoutes(ctx, web, deps) {
4949
4978
  json(res, 400, { error: "no known fields in patch" });
4950
4979
  return;
4951
4980
  }
4952
- await settings.mutate(NS, ops, settingsRevision(ctx));
4981
+ const revision = typeof body.revision === "number" && Number.isInteger(body.revision) ? body.revision : settingsRevision(ctx);
4982
+ await settings.mutate(NS, ops, revision);
4953
4983
  deps.usage.clear();
4954
4984
  deps.refreshTui?.();
4955
4985
  json(res, 200, { ok: true });
4956
4986
  } catch (error) {
4957
- json(res, 500, { error: error instanceof Error ? error.message : "config write failed" });
4987
+ const message = error instanceof Error ? error.message : "config write failed";
4988
+ json(res, /revision|conflict|stale/i.test(message) ? 409 : 500, { error: message });
4958
4989
  }
4959
4990
  });
4960
4991
  route("/dsh-opencode/key", async (req, res) => {
@@ -5122,8 +5153,7 @@ function applyRoutes(ctx, web, deps) {
5122
5153
  for (const dispose of disposers.splice(0)) dispose();
5123
5154
  }, "dsh-opencode-provider: HTTP routes");
5124
5155
  }
5125
- //#endregion
5126
- //#region src/tui-settings.ts
5156
+ // src/tui-settings.ts
5127
5157
  const TUI_ACCOUNT_AUTO = "auto";
5128
5158
  function setOrClear(text) {
5129
5159
  const trimmed = text.trim();
@@ -5367,8 +5397,7 @@ function applyOpenCodeTuiSettings(ctx, deps) {
5367
5397
  }, "dsh-opencode-provider: tui settings section");
5368
5398
  return refresh;
5369
5399
  }
5370
- //#endregion
5371
- //#region src/usage.ts
5400
+ // src/usage.ts
5372
5401
  const SUCCESS_TTL_MS = 3e5;
5373
5402
  const FAILURE_TTL_MS = 45e3;
5374
5403
  const USAGE_TIMEOUT_MS = 2e4;
@@ -5482,8 +5511,7 @@ function createUsageReader(config, resolveKey, fetchImpl = fetch) {
5482
5511
  };
5483
5512
  return read;
5484
5513
  }
5485
- //#endregion
5486
- //#region src/index.ts
5514
+ // src/index.ts
5487
5515
  const name = "llm-opencode";
5488
5516
  const inject = ["llm"];
5489
5517
  /**
@@ -5567,7 +5595,6 @@ function apply(ctx, config) {
5567
5595
  }), "dsh-opencode-provider: plugin updater");
5568
5596
  });
5569
5597
  }
5570
- //#endregion
5571
5598
  export { Config, OpenCodeAdapter, apply, applyCommands, applyRoutes, classifyResponseError, clearCatalogCache, createKeyPool, createKeyResolver, createUsageReader, describeModel, effectiveModel, inject, name, normalizeUsage, openCodeAuthPath, openCodeSessionId };
5572
5599
 
5573
5600
  //# 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.19",
3
+ "version": "0.5.21",
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
  },