@goodandready/dsh-opencode-provider 0.5.19 → 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,14 @@
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
+
3
12
  ## 0.5.19
4
13
 
5
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.
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.20.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.20.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.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.19.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.19.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.19
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.19.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.19.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.19
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: {
@@ -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",
@@ -245,6 +246,7 @@ window.__ModuleLoader__.load({
245
246
  pasteKey: "粘贴密钥",
246
247
  saveKey: "保存密钥",
247
248
  clearKey: "清除密钥",
249
+ clearKeyConfirm: "删除 {ref} 的已保存凭据?此操作无法撤销。",
248
250
  importAuth: "从 OpenCode 导入",
249
251
  imported: "已导入 OpenCode 密钥",
250
252
  testConnection: "测试连接",
@@ -419,8 +421,7 @@ window.__ModuleLoader__.load({
419
421
  }
420
422
  };
421
423
  const GO_PROVIDER = "opencode-go-direct";
422
- //#endregion
423
- //#region src/client/api.ts
424
+ // src/client/api.ts
424
425
  async function call(path, init) {
425
426
  const response = await fetch(path, {
426
427
  credentials: "same-origin",
@@ -456,10 +457,13 @@ window.__ModuleLoader__.load({
456
457
  body: JSON.stringify({ label })
457
458
  });
458
459
  }
459
- function saveConfig(patch) {
460
+ function saveConfig(patch, revision) {
460
461
  return call("/dsh-opencode/config", {
461
462
  method: "POST",
462
- body: JSON.stringify({ patch })
463
+ body: JSON.stringify(revision === void 0 ? { patch } : {
464
+ patch,
465
+ revision
466
+ })
463
467
  });
464
468
  }
465
469
  function fetchUpdate() {
@@ -508,11 +512,9 @@ window.__ModuleLoader__.load({
508
512
  })
509
513
  });
510
514
  }
511
- //#endregion
512
- //#region src/client/events.ts
515
+ // src/client/events.ts
513
516
  const CONFIG_CHANGED_EVENT = "dsh-opencode-config-changed";
514
- //#endregion
515
- //#region src/client/heatmap.tsx
517
+ // src/client/heatmap.tsx
516
518
  const DAYS = [
517
519
  "Mon",
518
520
  "Tue",
@@ -721,8 +723,7 @@ window.__ModuleLoader__.load({
721
723
  ]
722
724
  });
723
725
  }
724
- //#endregion
725
- //#region src/client/usage-analytics.tsx
726
+ // src/client/usage-analytics.tsx
726
727
  function compact(value) {
727
728
  if (!Number.isFinite(value) || value <= 0) return "0";
728
729
  if (value >= 1e6) return `${(value / 1e6).toFixed(1)}M`;
@@ -931,8 +932,7 @@ window.__ModuleLoader__.load({
931
932
  tab === "sessions" ? sessionRows(analytics, t) : null
932
933
  ] });
933
934
  }
934
- //#endregion
935
- //#region src/client/usage-bars.tsx
935
+ // src/client/usage-bars.tsx
936
936
  function percentClass(percent) {
937
937
  if (percent >= 90) return "oc-bar-fill oc-bar-fill-bad";
938
938
  if (percent >= 75) return "oc-bar-fill oc-bar-fill-warn";
@@ -1001,8 +1001,7 @@ window.__ModuleLoader__.load({
1001
1001
  ]
1002
1002
  });
1003
1003
  }
1004
- //#endregion
1005
- //#region src/quota-runway.ts
1004
+ // src/quota-runway.ts
1006
1005
  const HOUR = 36e5;
1007
1006
  const DAY = 24 * HOUR;
1008
1007
  const DURATIONS = {
@@ -1068,8 +1067,7 @@ window.__ModuleLoader__.load({
1068
1067
  };
1069
1068
  return known === 0 ? { kind: "unknown" } : { kind: "safe" };
1070
1069
  }
1071
- //#endregion
1072
- //#region src/client/quota-card.tsx
1070
+ // src/client/quota-card.tsx
1073
1071
  const PANEL_EVENT = "dsh-opencode-panel-state";
1074
1072
  function broadcastPanel(open) {
1075
1073
  if (typeof window === "undefined") return;
@@ -1611,11 +1609,11 @@ window.__ModuleLoader__.load({
1611
1609
  ]
1612
1610
  });
1613
1611
  }
1614
- //#endregion
1615
- //#region src/client/settings-draft.ts
1612
+ // src/client/settings-draft.ts
1616
1613
  function draftOf(state) {
1617
1614
  const allIds = state.models.map((model) => model.id);
1618
1615
  return {
1616
+ revision: state.revision ?? 0,
1619
1617
  apiKeyEnv: state.config.apiKeyEnv,
1620
1618
  apiBase: state.config.apiBase,
1621
1619
  goApiBase: state.config.goApiBase,
@@ -1701,8 +1699,7 @@ window.__ModuleLoader__.load({
1701
1699
  function describeLimitsFull(limits) {
1702
1700
  return `${planLine("Go", limits.go)} · ${planLine("Go Plus", limits.goPlus)}`;
1703
1701
  }
1704
- //#endregion
1705
- //#region src/client/settings-widgets.tsx
1702
+ // src/client/settings-widgets.tsx
1706
1703
  function Field(props) {
1707
1704
  return /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("label", {
1708
1705
  className: "oc-field",
@@ -1765,8 +1762,7 @@ window.__ModuleLoader__.load({
1765
1762
  if (account.coolingUntil <= 0) return label;
1766
1763
  return `${label} · ${untilText(account.coolingUntil)}`;
1767
1764
  }
1768
- //#endregion
1769
- //#region src/client/update-card.tsx
1765
+ // src/client/update-card.tsx
1770
1766
  function PluginUpdateCard(props) {
1771
1767
  const { t } = props;
1772
1768
  const [status, setStatus] = (0, react.useState)(null);
@@ -1803,6 +1799,9 @@ window.__ModuleLoader__.load({
1803
1799
  children: [t("pluginUpdate"), status === null ? null : status.updateAvailable ? /* @__PURE__ */ (0, react_jsx_runtime.jsx)("span", {
1804
1800
  className: "oc-badge oc-badge-warn",
1805
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")
1806
1805
  }) : /* @__PURE__ */ (0, react_jsx_runtime.jsx)("span", {
1807
1806
  className: "oc-badge oc-badge-ok",
1808
1807
  children: t("upToDate")
@@ -1849,8 +1848,7 @@ window.__ModuleLoader__.load({
1849
1848
  ]
1850
1849
  });
1851
1850
  }
1852
- //#endregion
1853
- //#region src/client/auth-ref.ts
1851
+ // src/client/auth-ref.ts
1854
1852
  /**
1855
1853
  * Collision-free credential ref for an OpenCode auth entry. Hex encoding is
1856
1854
  * injective (unlike replacing punctuation with underscores) and uses only
@@ -1861,8 +1859,7 @@ window.__ModuleLoader__.load({
1861
1859
  for (const byte of new TextEncoder().encode(entry)) hex += byte.toString(16).padStart(2, "0");
1862
1860
  return `OPENCODE_AUTH_${hex}`;
1863
1861
  }
1864
- //#endregion
1865
- //#region src/client/settings-page.tsx
1862
+ // src/client/settings-page.tsx
1866
1863
  function keyStatusLabel(status, t) {
1867
1864
  if (status === "ok") return t("keyStatusOk");
1868
1865
  if (status === "expired") return t("keyStatusExpired");
@@ -1928,7 +1925,7 @@ window.__ModuleLoader__.load({
1928
1925
  setSaving(true);
1929
1926
  setError(null);
1930
1927
  try {
1931
- await saveConfig(patchOf(draft));
1928
+ await saveConfig(patchOf(draft), draft.revision);
1932
1929
  window.dispatchEvent(new CustomEvent(CONFIG_CHANGED_EVENT));
1933
1930
  setNotice(t("saved"));
1934
1931
  setState(null);
@@ -2265,7 +2262,9 @@ window.__ModuleLoader__.load({
2265
2262
  type: "button",
2266
2263
  className: "oc-button oc-button-danger",
2267
2264
  onClick: () => {
2268
- 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(() => {
2269
2268
  setNotice(t("saved"));
2270
2269
  return load();
2271
2270
  }).catch((caught) => setError(caught instanceof Error ? caught.message : t("failed")));
@@ -2335,8 +2334,8 @@ window.__ModuleLoader__.load({
2335
2334
  /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("h3", {
2336
2335
  className: "oc-card-title",
2337
2336
  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")
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")
2340
2339
  })]
2341
2340
  }),
2342
2341
  /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("div", {
@@ -2347,7 +2346,7 @@ window.__ModuleLoader__.load({
2347
2346
  children: t("usageEndpoint")
2348
2347
  }),
2349
2348
  /* @__PURE__ */ (0, react_jsx_runtime.jsx)("span", {
2350
- className: `oc-badge ${state.service.usageError === void 0 ? "oc-badge-ok" : "oc-badge-bad"}`,
2349
+ className: `oc-badge ${state.service.usageCheckedAt === void 0 ? "oc-badge-warn" : state.service.usageError === void 0 ? "oc-badge-ok" : "oc-badge-bad"}`,
2351
2350
  children: state.service.usageCheckedAt === void 0 ? t("notChecked") : t("checkedAt").replace("{time}", new Date(state.service.usageCheckedAt).toLocaleTimeString())
2352
2351
  }),
2353
2352
  state.service.usageLatencyMs === void 0 ? null : /* @__PURE__ */ (0, react_jsx_runtime.jsx)("span", {
@@ -2564,7 +2563,10 @@ window.__ModuleLoader__.load({
2564
2563
  /* @__PURE__ */ (0, react_jsx_runtime.jsx)("button", {
2565
2564
  type: "button",
2566
2565
  className: "oc-button oc-button-danger",
2567
- onClick: () => update({ accounts: draft.accounts.filter((_, i) => i !== index) }),
2566
+ onClick: () => {
2567
+ setKeyDrafts({});
2568
+ update({ accounts: draft.accounts.filter((_, i) => i !== index) });
2569
+ },
2568
2570
  children: t("removeAccount")
2569
2571
  })
2570
2572
  ]
@@ -3006,8 +3008,7 @@ window.__ModuleLoader__.load({
3006
3008
  ]
3007
3009
  });
3008
3010
  }
3009
- //#endregion
3010
- //#region src/client/styles.ts
3011
+ // src/client/styles.ts
3011
3012
  const CSS_ID = "dsh-opencode-provider";
3012
3013
  const CSS = `
3013
3014
  :where(.oc-page, .oc-panel, .oc-card, .oc-foot, .oc-railButton) {
@@ -3127,8 +3128,7 @@ window.__ModuleLoader__.load({
3127
3128
  .oc-table th, .oc-table td { text-align: left; padding: 5px 8px; border-bottom: 1px solid var(--oc-border-soft); vertical-align: top; }
3128
3129
  .oc-table th { font-weight: 600; opacity: 0.8; }
3129
3130
  `;
3130
- //#endregion
3131
- //#region src/client/index.tsx
3131
+ // src/client/index.tsx
3132
3132
  function injectCss() {
3133
3133
  if (typeof document === "undefined") return () => {};
3134
3134
  if (document.querySelector(`style[data-dsh-plugin="dsh-opencode-provider"]`) !== null) return () => {};
@@ -3174,7 +3174,6 @@ window.__ModuleLoader__.load({
3174
3174
  }, QuotaCard));
3175
3175
  });
3176
3176
  }
3177
- //#endregion
3178
3177
  exports.apply = apply;
3179
3178
  exports.inject = inject;
3180
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;
@@ -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;
@@ -366,8 +353,6 @@ interface AnalyticsReport {
366
353
  days: DayUsageRow[];
367
354
  sessions: SessionUsageRow[];
368
355
  }
369
- //#endregion
370
- //#region src/routes.d.ts
371
356
  interface RouteDeps {
372
357
  readonly config: () => ResolvedConfig;
373
358
  readonly adapter: OpenCodeAdapter;
@@ -379,8 +364,6 @@ interface RouteDeps {
379
364
  }
380
365
  /** Register the Host HTTP surface consumed by the browser settings page. */
381
366
  declare function applyRoutes(ctx: Context, web: WebServer, deps: RouteDeps): void;
382
- //#endregion
383
- //#region src/session-id.d.ts
384
367
  /**
385
368
  * A stable, opaque session id for `x-opencode-session`. The header must stay
386
369
  * identical across every turn and retry of one conversation; deriving it from
@@ -389,16 +372,12 @@ declare function applyRoutes(ctx: Context, web: WebServer, deps: RouteDeps): voi
389
372
  * routing key, so the `dsh_` prefix is safe.
390
373
  */
391
374
  declare function openCodeSessionId(options: GenerateOptions): string;
392
- //#endregion
393
- //#region src/errors.d.ts
394
375
  /**
395
376
  * Translate an OpenCode HTTP failure into the harness failure taxonomy.
396
377
  * OpenCode reports several distinct conditions as 400/401, so the body type
397
378
  * and message decide the code — never the status alone.
398
379
  */
399
380
  declare function classifyResponseError(status: number, text: string, headers: Headers): LlmError;
400
- //#endregion
401
- //#region src/models.generated.d.ts
402
381
  type OpenCodeTransport = 'openai-chat' | 'openai-responses' | 'anthropic' | 'google';
403
382
  interface OpenCodeModelMeta {
404
383
  readonly id: string;
@@ -415,8 +394,6 @@ interface OpenCodeModelMeta {
415
394
  readonly cacheRead: number;
416
395
  };
417
396
  }
418
- //#endregion
419
- //#region src/catalog.d.ts
420
397
  interface EffectiveModel extends OpenCodeModelMeta {
421
398
  readonly transport: OpenCodeTransport;
422
399
  readonly contextWindow: number;
@@ -424,8 +401,6 @@ interface EffectiveModel extends OpenCodeModelMeta {
424
401
  declare function effectiveModel(id: string, config: ResolvedConfig): EffectiveModel;
425
402
  declare function describeModel(meta: EffectiveModel, provider?: string): string;
426
403
  declare function clearCatalogCache(): void;
427
- //#endregion
428
- //#region src/index.d.ts
429
404
  declare const name = "llm-opencode";
430
405
  declare const inject: string[];
431
406
  /**
@@ -438,6 +413,5 @@ declare const inject: string[];
438
413
  * next request without a restart.
439
414
  */
440
415
  declare function apply(ctx: Context, config: Config): void;
441
- //#endregion
442
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 };
443
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.19";
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();
@@ -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;
@@ -4519,8 +4529,7 @@ async function readModelAnalytics(days = 30) {
4519
4529
  });
4520
4530
  return report;
4521
4531
  }
4522
- //#endregion
4523
- //#region src/routes-public.ts
4532
+ // src/routes-public.ts
4524
4533
  function publicConfig(config) {
4525
4534
  return {
4526
4535
  apiKeyEnv: config.apiKeyEnv,
@@ -4553,8 +4562,7 @@ function publicConfig(config) {
4553
4562
  userAgent: config.userAgent
4554
4563
  };
4555
4564
  }
4556
- //#endregion
4557
- //#region src/routes-values.ts
4565
+ // src/routes-values.ts
4558
4566
  const TRANSPORTS = /* @__PURE__ */ new Set([
4559
4567
  "openai-chat",
4560
4568
  "openai-responses",
@@ -4614,8 +4622,7 @@ function cleanTransports(value) {
4614
4622
  for (const [key, entry] of Object.entries(value)) if (typeof entry === "string" && TRANSPORTS.has(entry)) out[key] = entry;
4615
4623
  return out;
4616
4624
  }
4617
- //#endregion
4618
- //#region src/routes.ts
4625
+ // src/routes.ts
4619
4626
  const MAX_BODY_BYTES = 262144;
4620
4627
  function json(res, status, value) {
4621
4628
  res.statusCode = status;
@@ -4949,12 +4956,14 @@ function applyRoutes(ctx, web, deps) {
4949
4956
  json(res, 400, { error: "no known fields in patch" });
4950
4957
  return;
4951
4958
  }
4952
- 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);
4953
4961
  deps.usage.clear();
4954
4962
  deps.refreshTui?.();
4955
4963
  json(res, 200, { ok: true });
4956
4964
  } catch (error) {
4957
- 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 });
4958
4967
  }
4959
4968
  });
4960
4969
  route("/dsh-opencode/key", async (req, res) => {
@@ -5122,8 +5131,7 @@ function applyRoutes(ctx, web, deps) {
5122
5131
  for (const dispose of disposers.splice(0)) dispose();
5123
5132
  }, "dsh-opencode-provider: HTTP routes");
5124
5133
  }
5125
- //#endregion
5126
- //#region src/tui-settings.ts
5134
+ // src/tui-settings.ts
5127
5135
  const TUI_ACCOUNT_AUTO = "auto";
5128
5136
  function setOrClear(text) {
5129
5137
  const trimmed = text.trim();
@@ -5367,8 +5375,7 @@ function applyOpenCodeTuiSettings(ctx, deps) {
5367
5375
  }, "dsh-opencode-provider: tui settings section");
5368
5376
  return refresh;
5369
5377
  }
5370
- //#endregion
5371
- //#region src/usage.ts
5378
+ // src/usage.ts
5372
5379
  const SUCCESS_TTL_MS = 3e5;
5373
5380
  const FAILURE_TTL_MS = 45e3;
5374
5381
  const USAGE_TIMEOUT_MS = 2e4;
@@ -5482,8 +5489,7 @@ function createUsageReader(config, resolveKey, fetchImpl = fetch) {
5482
5489
  };
5483
5490
  return read;
5484
5491
  }
5485
- //#endregion
5486
- //#region src/index.ts
5492
+ // src/index.ts
5487
5493
  const name = "llm-opencode";
5488
5494
  const inject = ["llm"];
5489
5495
  /**
@@ -5567,7 +5573,6 @@ function apply(ctx, config) {
5567
5573
  }), "dsh-opencode-provider: plugin updater");
5568
5574
  });
5569
5575
  }
5570
- //#endregion
5571
5576
  export { Config, OpenCodeAdapter, apply, applyCommands, applyRoutes, classifyResponseError, clearCatalogCache, createKeyPool, createKeyResolver, createUsageReader, describeModel, effectiveModel, inject, name, normalizeUsage, openCodeAuthPath, openCodeSessionId };
5572
5577
 
5573
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.19",
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
  },