@dan-ai-studio/dshopencodego 0.1.60 → 0.1.62

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/README.en.md CHANGED
@@ -97,6 +97,10 @@ Configuration lives in the profile's `cordis.patch.yml`:
97
97
  # or https://opencode.ai/inference/v1 (Console main endpoint, pay-as-you-go/free models)
98
98
  refreshMinutes: 60 # live catalog TTL, 1 ~ 10080 (7 days)
99
99
  streamIdleTimeoutMs: 300000 # stream idle timeout: longest silence between events (ms)
100
+ keepAliveTimeoutMs: 600000 # connection kept idle between requests (ms, 1 ~ 1800000)
101
+ # the timer restarts on every answer, so a connection in
102
+ # frequent use is never dropped; a long pause in a
103
+ # conversation costs one handshake (about 200ms)
100
104
  maxRequestImageBytes: 6291456 # whole-request image payload cap (after base64, bytes)
101
105
  requestImagePixelBudget: 1440000 # total-pixel budget for one image
102
106
  requestImageMaxBytes: 400000 # encoded-byte target for one image (before base64)
@@ -121,12 +125,19 @@ Configuration lives in the profile's `cordis.patch.yml`:
121
125
  jitterRatio: 0.1 # default 0.1, range 0 ~ 1
122
126
  ```
123
127
 
124
- **Validated on save**: those constraints are checked when the profile is **written**, not at the first request — `baseURL` must be http(s) with no query or fragment; `refreshMinutes` ranges from 1 to 10080; every `modelLimits` field may be `null`, meaning "select the catalog value explicitly" (`modelLimits.<id>: null` sends both fields back); `modelProtocols` accepts only those three values, and a wrong one makes that model appear as *configuration missing* (switch off, not selectable) rather than falling back to a guess; `retryPolicy` is a union (`normal` reads `maxRetries`/`retryableCodes`/`backoff`, `always` reads only `backoff`) and unknown keys are rejected.
128
+ **Validated on save**: those constraints are checked when the profile is **written**, not at the first request — `baseURL` must be http(s) with no query or fragment; `refreshMinutes` ranges from 1 to 10080; `keepAliveTimeoutMs` ranges from 1 to 1800000; every `modelLimits` field may be `null`, meaning "select the catalog value explicitly" (`modelLimits.<id>: null` sends both fields back); `modelProtocols` accepts only those three values, and a wrong one makes that model appear as *configuration missing* (switch off, not selectable) rather than falling back to a guess; `retryPolicy` is a union (`normal` reads `maxRetries`/`retryableCodes`/`backoff`, `always` reads only `backoff`) and unknown keys are rejected.
125
129
 
126
130
  The API key comes from the Harness credential store (reference name `OPENCODE_GO_API_KEY`), or from `export OPENCODE_GO_API_KEY=...`.
127
131
 
128
132
  **Stream timeout**: `streamIdleTimeoutMs` bounds the **longest silence between two stream events**, not the total request duration — a long-thinking model that keeps producing events is never cut off. Default 5 minutes; the ceiling is `2147483647` (about 24.8 days). Exceeding it raises `TIMEOUT`, which is retryable.
129
133
 
134
+ **Connection kept**: `keepAliveTimeoutMs` decides **how long a connection to the gateway is held between two requests**. The timer **restarts on every answer**, so a connection in frequent use is never dropped; it bounds only the silence between two requests. Default 10 minutes.
135
+
136
+ - The reason is practical: once a connection is dropped, the next request pays a TCP and a TLS handshake again — **about 200 milliseconds** from here to the gateway — and that cost lands on the **first token of the next turn**. A pause in a conversation (reading the answer, thinking, typing) usually runs 5 to 30 seconds, which is longer than the HTTP client's own 4-second default, so **almost every turn paid for a handshake it did not need**.
137
+ - This only affects deployments that **pin an address** (`pinnedAddresses`): with no pin the request goes through the system's own fetch, and this plugin adds nothing to it.
138
+ - The settings page shows and accepts the value in **seconds** (a number of milliseconds is not readable at a glance) and stores it in milliseconds. Range: 1 to 1800000 milliseconds.
139
+ - Once a connection has been idle that long, the pool **reclaims it** on its own — no separate mechanism is needed. The actual release comes a couple of seconds later than the setting, which is the client's own small margin beyond the deadline the server states. When the plugin is unloaded, the pool is **closed with it**: the pool belongs to the plugin and should not be left behind on the machine.
140
+
130
141
  **Images**: three fields, one layer each. An image outside a bound is refused explicitly rather than silently downscaled or dropped:
131
142
 
132
143
  | Field | What it bounds | Default |
package/README.md CHANGED
@@ -97,6 +97,9 @@ DSH 桌面端是 Electron 壳 + 完整的 dsh Web 应用,本插件对它同样
97
97
  # 也可指定为 https://opencode.ai/inference/v1(Console 主端点,含按量/免费模型)
98
98
  refreshMinutes: 60 # 目录缓存时长,1 ~ 10080(7 天)
99
99
  streamIdleTimeoutMs: 300000 # 流空闲超时:两次流事件之间的最长静默(毫秒)
100
+ keepAliveTimeoutMs: 600000 # 连接空闲保留:两次请求之间连接留多久(毫秒,1 ~ 1800000)
101
+ # 每次收到回答后重新计时,所以常用时连接不会被断开;
102
+ # 对话中每次停顿较久都要重新握手一次(约 200ms)
100
103
  maxRequestImageBytes: 6291456 # 整个请求的图片载荷上限(base64 之后,字节)
101
104
  requestImagePixelBudget: 1440000 # 单张图的总像素预算
102
105
  requestImageMaxBytes: 400000 # 单张图编码前的字节目标(base64 之前)
@@ -121,12 +124,19 @@ DSH 桌面端是 Electron 壳 + 完整的 dsh Web 应用,本插件对它同样
121
124
  jitterRatio: 0.1 # 默认 0.1,取值 0 ~ 1
122
125
  ```
123
126
 
124
- **保存即校验**:上面这些约束在**写入 profile 时**就检查,不会拖到第一次请求——`baseURL` 必须是 http(s) 且不带 query/fragment;`refreshMinutes` 取值 1 ~ 10080;`modelLimits` 的每个字段都可以写 `null`,语义是"显式选回目录值"(`modelLimits.<模型>: null` 表示两个字段一起回退);`modelProtocols` 只接受那三个合法值,写错会让该模型以"配置缺失"出现(开关关闭、不可选),而不是回退到猜测;`retryPolicy` 是联合类型(`normal` 读 `maxRetries`/`retryableCodes`/`backoff`,`always` 只读 `backoff`),未知字段会被拒绝。
127
+ **保存即校验**:上面这些约束在**写入 profile 时**就检查,不会拖到第一次请求——`baseURL` 必须是 http(s) 且不带 query/fragment;`refreshMinutes` 取值 1 ~ 10080;`keepAliveTimeoutMs` 取值 1 ~ 1800000;`modelLimits` 的每个字段都可以写 `null`,语义是"显式选回目录值"(`modelLimits.<模型>: null` 表示两个字段一起回退);`modelProtocols` 只接受那三个合法值,写错会让该模型以"配置缺失"出现(开关关闭、不可选),而不是回退到猜测;`retryPolicy` 是联合类型(`normal` 读 `maxRetries`/`retryableCodes`/`backoff`,`always` 只读 `backoff`),未知字段会被拒绝。
125
128
 
126
129
  API Key 通过 Harness 凭证库提供(引用名 `OPENCODE_GO_API_KEY`),也可以直接 `export OPENCODE_GO_API_KEY=...`。
127
130
 
128
131
  **流超时**:`streamIdleTimeoutMs` 约束的是**两次流事件之间的最长静默**,不是请求总时长——长思考模型只要持续产出事件就不会被误判。默认 5 分钟,上限为 `2147483647`(约 24.8 天)。超时抛出 `TIMEOUT`(可重试类)。
129
132
 
133
+ **连接保留**:`keepAliveTimeoutMs` 决定**与网关的连接在两次请求之间保留多久**。计时器**每次收到回答后重新开始**,所以常用时连接不会被断开,它约束的只是两次请求之间的静默。默认 10 分钟。
134
+
135
+ - 背后的原因很实际:连接一旦被断开,下一次请求要重新做一次 TCP 与 TLS 握手,**到网关这一侧约 200 毫秒**,而这笔开销正好落在**下一轮的第一个 token 上**。对话里的停顿(读答案、思考、再打字)通常在 5 ~ 30 秒,正好跨过 HTTP 客户端默认的 4 秒,于是**几乎每一轮都在白付一次握手**。
136
+ - 这条只影响**固定了地址**(`pinnedAddresses`)的部署:没有固定地址时走的是系统自己的 fetch,本插件不加任何东西。
137
+ - 设置页里以**秒**显示与填写(毫秒的数字人读不出量级),存储时换算成毫秒。取值 1 ~ 1800000 毫秒。
138
+ - 空闲满这个时间之后,连接由连接池**自己回收**,不需要额外的清理机制(实际释放时间比设置值多约 2 秒,那是客户端在服务端给的期限之外自留的一点缓冲)。插件被卸载时,连接池也会**一并关掉**——它是插件的,不该留在机器上。
139
+
130
140
  **图片**:三个字段各管一层,超限的图片会被明确拒绝,而不是静默降质或丢弃:
131
141
 
132
142
  | 字段 | 约束对象 | 默认 |
package/lib/client.js CHANGED
@@ -386,6 +386,8 @@ var en = {
386
386
  catalogRefreshing: "Showing the last known catalog; a refresh is running in the background.",
387
387
  refreshLabel: "Catalog refresh (minutes)",
388
388
  refreshHint: "How long requests and the model picker reuse one listing.",
389
+ keepAliveLabel: "Connection kept idle (seconds)",
390
+ keepAliveHint: "How long a connection to the gateway is held between requests. Longer pauses in a conversation cost one handshake each; the timer restarts on every answer.",
389
391
  writeRejected: "The settings write was rejected (the namespace was reloaded) \u2014 try again",
390
392
  writeFailed: "The settings write failed",
391
393
  filterPlaceholder: "Filter by name or id",
@@ -496,6 +498,8 @@ var zh = {
496
498
  catalogRefreshing: "\u663E\u793A\u4E0A\u6B21\u5DF2\u77E5\u7684\u76EE\u5F55\uFF0C\u540E\u53F0\u6B63\u5728\u5237\u65B0\u3002",
497
499
  refreshLabel: "\u76EE\u5F55\u5237\u65B0\uFF08\u5206\u949F\uFF09",
498
500
  refreshHint: "\u8BF7\u6C42\u4E0E\u6A21\u578B\u9009\u62E9\u5668\u590D\u7528\u540C\u4E00\u4EFD\u5217\u8868\u7684\u65F6\u957F\u3002",
501
+ keepAliveLabel: "\u8FDE\u63A5\u7A7A\u95F2\u4FDD\u7559\uFF08\u79D2\uFF09",
502
+ keepAliveHint: "\u4E0E\u7F51\u5173\u7684\u8FDE\u63A5\u5728\u4E24\u6B21\u8BF7\u6C42\u4E4B\u95F4\u4FDD\u7559\u591A\u4E45\u3002\u5BF9\u8BDD\u4E2D\u6BCF\u6B21\u505C\u987F\u8F83\u4E45\u90FD\u8981\u91CD\u65B0\u63E1\u624B\u4E00\u6B21\uFF1B\u6BCF\u6B21\u6536\u5230\u56DE\u7B54\u540E\u8BA1\u65F6\u90FD\u4F1A\u91CD\u65B0\u5F00\u59CB\u3002",
499
503
  writeRejected: "\u8BBE\u7F6E\u5199\u5165\u88AB\u62D2\u7EDD\uFF08\u5DF2\u91CD\u65B0\u8F7D\u5165\uFF09\uFF0C\u8BF7\u91CD\u8BD5",
500
504
  writeFailed: "\u8BBE\u7F6E\u5199\u5165\u5931\u8D25",
501
505
  filterPlaceholder: "\u6309\u540D\u79F0\u6216 ID \u7B5B\u9009",
@@ -578,6 +582,12 @@ var REPOSITORY = "dan-ai-studio/dshopencodego";
578
582
  var REGISTRY_LATEST_URL = `https://registry.npmjs.org/${encodeURIComponent(PACKAGE_NAME)}/latest`;
579
583
  var CHECK_CACHE_MS = 6 * 60 * 60 * 1e3;
580
584
 
585
+ // src/network/keep-alive.ts
586
+ var DEFAULT_KEEP_ALIVE_TIMEOUT_SECONDS = 600;
587
+ var MAX_KEEP_ALIVE_TIMEOUT_SECONDS = 1800;
588
+ var DEFAULT_KEEP_ALIVE_TIMEOUT_MS = DEFAULT_KEEP_ALIVE_TIMEOUT_SECONDS * 1e3;
589
+ var MAX_KEEP_ALIVE_TIMEOUT_MS = MAX_KEEP_ALIVE_TIMEOUT_SECONDS * 1e3;
590
+
581
591
  // src/go-limits.ts
582
592
  var SEED_TRANSCRIBED = "2026-09-25";
583
593
  function monthlyRequestsRank(quota) {
@@ -738,10 +748,12 @@ function settingsOf(value) {
738
748
  const entries = Object.entries(visibility).filter(([, enabled]) => typeof enabled === "boolean");
739
749
  const pins = record["pinnedAddresses"];
740
750
  const pinEntries = pins !== null && typeof pins === "object" ? Object.entries(pins).filter(([, address]) => typeof address === "string") : [];
751
+ const keepAlive = record["keepAliveTimeoutMs"];
741
752
  return {
742
753
  modelVisibility: Object.fromEntries(entries),
743
754
  refreshMinutes: minutes,
744
- pinnedAddresses: Object.fromEntries(pinEntries)
755
+ pinnedAddresses: Object.fromEntries(pinEntries),
756
+ keepAliveTimeoutMs: typeof keepAlive === "number" ? keepAlive : DEFAULT_KEEP_ALIVE_TIMEOUT_MS
745
757
  };
746
758
  }
747
759
  var SectionController = class _SectionController {
@@ -1122,6 +1134,25 @@ var SectionController = class _SectionController {
1122
1134
  () => this.set({ settings: { ...current, refreshMinutes: minutes } })
1123
1135
  );
1124
1136
  }
1137
+ /**
1138
+ * Set how long an idle gateway connection is kept, clamped to the schema's
1139
+ * own bounds.
1140
+ *
1141
+ * The value is in seconds here and stored in milliseconds, because a
1142
+ * person reading a number of seconds can tell whether it is right and a
1143
+ * person reading a number of milliseconds cannot.
1144
+ */
1145
+ async setKeepAliveTimeoutSeconds(seconds) {
1146
+ const scope = this.services.scope;
1147
+ const current = this.state.settings;
1148
+ if (scope === void 0 || current === void 0 || !this.state.writable) return;
1149
+ if (!Number.isSafeInteger(seconds) || seconds < 1 || seconds > MAX_KEEP_ALIVE_TIMEOUT_SECONDS) return;
1150
+ const milliseconds = seconds * 1e3;
1151
+ await this.write(
1152
+ () => scope.set("keepAliveTimeoutMs", milliseconds),
1153
+ () => this.set({ settings: { ...current, keepAliveTimeoutMs: milliseconds } })
1154
+ );
1155
+ }
1125
1156
  async write(operation, onAccepted) {
1126
1157
  this.set({ saving: true });
1127
1158
  try {
@@ -1500,6 +1531,25 @@ function Section({ controller, t, getLocale }) {
1500
1531
  ),
1501
1532
  /* @__PURE__ */ (0, import_jsx_runtime.jsx)("span", { className: section_default.hint, children: t("refreshHint") })
1502
1533
  ] }),
1534
+ /* @__PURE__ */ (0, import_jsx_runtime.jsxs)("div", { className: section_default.row, children: [
1535
+ /* @__PURE__ */ (0, import_jsx_runtime.jsx)("label", { className: section_default.hint, htmlFor: "dshopencodego-keepalive", children: t("keepAliveLabel") }),
1536
+ /* @__PURE__ */ (0, import_jsx_runtime.jsx)(
1537
+ "input",
1538
+ {
1539
+ id: "dshopencodego-keepalive",
1540
+ className: section_default.number,
1541
+ type: "number",
1542
+ min: 1,
1543
+ max: MAX_KEEP_ALIVE_TIMEOUT_SECONDS,
1544
+ disabled: !editable || state.saving,
1545
+ value: state.settings === void 0 ? "" : Math.round(state.settings.keepAliveTimeoutMs / 1e3),
1546
+ onChange: (event) => {
1547
+ void controller.setKeepAliveTimeoutSeconds(Number(event.target.value));
1548
+ }
1549
+ }
1550
+ ),
1551
+ /* @__PURE__ */ (0, import_jsx_runtime.jsx)("span", { className: section_default.hint, children: t("keepAliveHint") })
1552
+ ] }),
1503
1553
  state.writeFailure !== void 0 && /* @__PURE__ */ (0, import_jsx_runtime.jsx)("p", { className: section_default.warn, children: state.writeFailure.kind === "rejected" ? t("writeRejected") : state.writeFailure.message.length === 0 ? t("writeFailed") : `${t("writeFailed")}: ${state.writeFailure.message}` }),
1504
1554
  reading !== void 0 && /* @__PURE__ */ (0, import_jsx_runtime.jsxs)("div", { className: section_default.row, children: [
1505
1555
  /* @__PURE__ */ (0, import_jsx_runtime.jsx)(
package/lib/index.js CHANGED
@@ -53107,6 +53107,7 @@ var require_client_h2 = __commonJS({
53107
53107
  var kGoAwayReplayAttempts = /* @__PURE__ */ Symbol("goaway replay attempts");
53108
53108
  var kRefusedStreamRetry = /* @__PURE__ */ Symbol("refused stream retry");
53109
53109
  var MAX_GOAWAY_REPLAY_ATTEMPTS = 1;
53110
+ var MIN_PROBE_TIMEOUT = 1e3;
53110
53111
  var extractBody2;
53111
53112
  var http2;
53112
53113
  try {
@@ -53267,6 +53268,9 @@ var require_client_h2 = __commonJS({
53267
53268
  // Armed while the peer advertises MAX_CONCURRENT_STREAMS = 0 and we have
53268
53269
  // work that cannot start. See setNoStreamsTimeout.
53269
53270
  noStreamsTimeout: null,
53271
+ // Armed while a PING checks whether the peer is still there after a
53272
+ // stream timed out. See probeHttp2Session.
53273
+ probeTimeout: null,
53270
53274
  // Sockets start out ref'd. Session ref/unref proxies to the socket, so a
53271
53275
  // single cached flag lets us skip redundant uv ref/unref calls, provided
53272
53276
  // every ref/unref of the session or its socket goes through
@@ -53336,6 +53340,9 @@ var require_client_h2 = __commonJS({
53336
53340
  if (session[kRemoteSettings] === false && client[kRunning] > 0) {
53337
53341
  return true;
53338
53342
  }
53343
+ if (session[kHTTP2SessionState].probeTimeout != null) {
53344
+ return true;
53345
+ }
53339
53346
  if (client[kRunning] >= client[kMaxConcurrentStreams]) {
53340
53347
  return true;
53341
53348
  }
@@ -53420,6 +53427,41 @@ var require_client_h2 = __commonJS({
53420
53427
  session[kError] = err;
53421
53428
  resetHttp2Session(session, err);
53422
53429
  }
53430
+ function probeHttp2Session(session, timeout) {
53431
+ const state2 = session[kHTTP2SessionState];
53432
+ if (state2.probeTimeout != null || session.closed || session.destroyed) {
53433
+ return;
53434
+ }
53435
+ try {
53436
+ session.ping(onHttp2ProbeAck.bind(session));
53437
+ } catch {
53438
+ return;
53439
+ }
53440
+ state2.probeTimeout = setTimeout(onHttp2ProbeTimeout, Math.max(timeout, MIN_PROBE_TIMEOUT), session).unref();
53441
+ }
53442
+ function clearHttp2ProbeTimeout(session) {
53443
+ const state2 = session[kHTTP2SessionState];
53444
+ if (state2?.probeTimeout != null) {
53445
+ clearTimeout(state2.probeTimeout);
53446
+ state2.probeTimeout = null;
53447
+ }
53448
+ }
53449
+ function onHttp2ProbeAck() {
53450
+ if (this[kHTTP2SessionState].probeTimeout == null) {
53451
+ return;
53452
+ }
53453
+ clearHttp2ProbeTimeout(this);
53454
+ this[kClient][kResume]();
53455
+ }
53456
+ function onHttp2ProbeTimeout(session) {
53457
+ session[kHTTP2SessionState].probeTimeout = null;
53458
+ if (session.closed || session.destroyed) {
53459
+ return;
53460
+ }
53461
+ const err = new SocketError("HTTP/2: connection unresponsive", util2.getSocketInfo(session[kSocket]));
53462
+ session[kError] = err;
53463
+ resetHttp2Session(session, err);
53464
+ }
53423
53465
  function clearHttp2IdleTimeout(session) {
53424
53466
  const state2 = session[kHTTP2SessionState];
53425
53467
  if (state2?.idleTimeout != null) {
@@ -53554,6 +53596,7 @@ var require_client_h2 = __commonJS({
53554
53596
  }
53555
53597
  clearHttp2IdleTimeout(this);
53556
53598
  clearNoStreamsTimeout(this);
53599
+ clearHttp2ProbeTimeout(this);
53557
53600
  if (!this.closed && !this.destroyed) {
53558
53601
  this.close();
53559
53602
  }
@@ -53571,6 +53614,7 @@ var require_client_h2 = __commonJS({
53571
53614
  }
53572
53615
  clearHttp2IdleTimeout(this);
53573
53616
  clearNoStreamsTimeout(this);
53617
+ clearHttp2ProbeTimeout(this);
53574
53618
  if (state2.ping.interval != null) {
53575
53619
  clearInterval(state2.ping.interval);
53576
53620
  state2.ping.interval = null;
@@ -54157,7 +54201,9 @@ var require_client_h2 = __commonJS({
54157
54201
  return;
54158
54202
  }
54159
54203
  stream5.off("timeout", onTimeout);
54160
- const err = state2.responseReceived ? new BodyTimeoutError(`HTTP/2: "stream timeout after ${state2.bodyTimeout}"`) : new HeadersTimeoutError(`HTTP/2: "headers timeout after ${state2.headersTimeout}"`);
54204
+ const { responseReceived, session, bodyTimeout, headersTimeout } = state2;
54205
+ const err = responseReceived ? new BodyTimeoutError(`HTTP/2: "stream timeout after ${bodyTimeout}"`) : new HeadersTimeoutError(`HTTP/2: "headers timeout after ${headersTimeout}"`);
54206
+ probeHttp2Session(session, responseReceived ? bodyTimeout : headersTimeout);
54161
54207
  state2.abort(err);
54162
54208
  }
54163
54209
  function onTrailers(trailers) {
@@ -55031,6 +55077,7 @@ var require_fixed_queue = __commonJS({
55031
55077
  var require_pool_base = __commonJS({
55032
55078
  "node_modules/undici/lib/dispatcher/pool-base.js"(exports, module) {
55033
55079
  "use strict";
55080
+ var { AsyncResource } = __require("node:async_hooks");
55034
55081
  var { PoolStats } = require_stats();
55035
55082
  var DispatcherBase = require_dispatcher_base();
55036
55083
  var FixedQueue = require_fixed_queue();
@@ -55052,6 +55099,16 @@ var require_pool_base = __commonJS({
55052
55099
  var kRemoveClient = /* @__PURE__ */ Symbol("remove client");
55053
55100
  var kRetireClient = /* @__PURE__ */ Symbol("retire client");
55054
55101
  var kRetiring = /* @__PURE__ */ Symbol("retiring clients");
55102
+ var QueuedRequest = class extends AsyncResource {
55103
+ constructor(opts, handler) {
55104
+ super("UNDICI_POOL_QUEUED_REQUEST");
55105
+ this.opts = opts;
55106
+ this.handler = handler;
55107
+ }
55108
+ dispatch(dispatcher) {
55109
+ return this.runInAsyncScope(dispatcher.dispatch, dispatcher, this.opts, this.handler);
55110
+ }
55111
+ };
55055
55112
  function closeClients(pool) {
55056
55113
  const closeAll = [];
55057
55114
  for (let i = 0; i < pool[kClients].length; i++) {
@@ -55086,7 +55143,7 @@ var require_pool_base = __commonJS({
55086
55143
  break;
55087
55144
  }
55088
55145
  this[kQueued]--;
55089
- needDrain = !client.dispatch(item.opts, item.handler);
55146
+ needDrain = !item.dispatch(client);
55090
55147
  }
55091
55148
  client[kNeedDrain] = needDrain;
55092
55149
  if (needDrain) {
@@ -55118,7 +55175,7 @@ var require_pool_base = __commonJS({
55118
55175
  }
55119
55176
  const item = queue.shift();
55120
55177
  this[kQueued]--;
55121
- if (!dispatcher.dispatch(item.opts, item.handler)) {
55178
+ if (!item.dispatch(dispatcher)) {
55122
55179
  dispatcher[kNeedDrain] = true;
55123
55180
  this[kOnClientBusy](dispatcher);
55124
55181
  hasDispatcher = this[kHasDispatcher]();
@@ -55215,7 +55272,7 @@ var require_pool_base = __commonJS({
55215
55272
  const dispatcher = this[kGetDispatcher]();
55216
55273
  if (!dispatcher) {
55217
55274
  this[kNeedDrain] = true;
55218
- this[kQueue].push({ opts, handler });
55275
+ this[kQueue].push(new QueuedRequest(opts, handler));
55219
55276
  this[kQueued]++;
55220
55277
  } else if (!dispatcher.dispatch(opts, handler)) {
55221
55278
  dispatcher[kNeedDrain] = true;
@@ -75658,6 +75715,12 @@ defineMethod("transform", [
75658
75715
  "preserve"
75659
75716
  ], ({ inner }, isInner) => inner.toString(isInner));
75660
75717
 
75718
+ // src/network/keep-alive.ts
75719
+ var DEFAULT_KEEP_ALIVE_TIMEOUT_SECONDS = 600;
75720
+ var MAX_KEEP_ALIVE_TIMEOUT_SECONDS = 1800;
75721
+ var DEFAULT_KEEP_ALIVE_TIMEOUT_MS = DEFAULT_KEEP_ALIVE_TIMEOUT_SECONDS * 1e3;
75722
+ var MAX_KEEP_ALIVE_TIMEOUT_MS = MAX_KEEP_ALIVE_TIMEOUT_SECONDS * 1e3;
75723
+
75661
75724
  // src/config.ts
75662
75725
  var DEFAULT_API_KEY_ENV = "OPENCODE_GO_API_KEY";
75663
75726
  var DEFAULT_REFRESH_MINUTES = 60;
@@ -75672,6 +75735,12 @@ var fields = {
75672
75735
  baseURL: Schema.string().default(DEFAULT_BASE_URL),
75673
75736
  refreshMinutes: Schema.number().step(1).min(1).max(7 * 24 * 60).default(DEFAULT_REFRESH_MINUTES),
75674
75737
  streamIdleTimeoutMs: Schema.number().min(Number.MIN_VALUE).max(MAX_TIMER_DELAY_MS).default(DEFAULT_STREAM_IDLE_TIMEOUT_MS),
75738
+ // The ceiling is the plugin's own, and it is well under the point where a
75739
+ // Node timer overflows: a value above 2^31-1 does not become a longer
75740
+ // timer, it becomes ONE MILLISECOND, which would drop the connection at
75741
+ // once and say so only in a warning on stderr. The bound here is what
75742
+ // keeps that unreachable.
75743
+ keepAliveTimeoutMs: Schema.number().step(1).min(1).max(MAX_KEEP_ALIVE_TIMEOUT_MS).default(DEFAULT_KEEP_ALIVE_TIMEOUT_MS),
75675
75744
  maxRequestImageBytes: Schema.number().step(1).min(1).default(DEFAULT_MAX_REQUEST_IMAGE_BYTES),
75676
75745
  requestImagePixelBudget: Schema.number().step(1).min(1).default(DEFAULT_REQUEST_IMAGE_PIXEL_BUDGET),
75677
75746
  requestImageMaxBytes: Schema.number().step(1).min(1).default(DEFAULT_REQUEST_IMAGE_MAX_BYTES),
@@ -77269,14 +77338,23 @@ function pinnedLookup(address) {
77269
77338
  };
77270
77339
  }
77271
77340
  var current;
77272
- function dispatcherFor(host, address) {
77273
- const key = `${host}@${address}`;
77341
+ function dispatcherFor(host, address, keepAliveTimeoutMs) {
77342
+ const key = `${host}@${address}@${String(keepAliveTimeoutMs)}`;
77274
77343
  if (current?.key === key) return current.agent;
77275
77344
  void current?.agent.close();
77276
- const agent = new import_undici.Agent({ connect: { lookup: pinnedLookup(address) } });
77345
+ const agent = new import_undici.Agent({
77346
+ connect: { lookup: pinnedLookup(address) },
77347
+ keepAliveTimeout: keepAliveTimeoutMs,
77348
+ keepAliveMaxTimeout: keepAliveTimeoutMs
77349
+ });
77277
77350
  current = { key, agent };
77278
77351
  return agent;
77279
77352
  }
77353
+ async function closeDispatcher() {
77354
+ const agent = current?.agent;
77355
+ current = void 0;
77356
+ if (agent !== void 0) await agent.close();
77357
+ }
77280
77358
  var SPENT_ADDRESS_CODES = /* @__PURE__ */ new Set([
77281
77359
  // The socket never came up.
77282
77360
  "ECONNREFUSED",
@@ -77342,7 +77420,7 @@ async function edgeRefused(response) {
77342
77420
  return false;
77343
77421
  }
77344
77422
  }
77345
- function gatewayFetch(pin) {
77423
+ function gatewayFetch(pin, keepAliveTimeoutMs) {
77346
77424
  const address = pin.address;
77347
77425
  if (address === void 0) return globalThis.fetch;
77348
77426
  return async (input, init) => {
@@ -77350,7 +77428,7 @@ function gatewayFetch(pin) {
77350
77428
  if (host !== pin.host) return globalThis.fetch(input, init);
77351
77429
  if (isPinSpent(pin)) return globalThis.fetch(input, init);
77352
77430
  try {
77353
- const dispatcher = dispatcherFor(pin.host, address);
77431
+ const dispatcher = dispatcherFor(pin.host, address, keepAliveTimeoutMs);
77354
77432
  const response = await dispatchingFetch(input, {
77355
77433
  ...init,
77356
77434
  dispatcher
@@ -77483,7 +77561,7 @@ function createUpdateChecker(deps) {
77483
77561
  }
77484
77562
 
77485
77563
  // src/update/version.ts
77486
- var PLUGIN_VERSION = true ? "0.1.60" : "0.0.0-dev";
77564
+ var PLUGIN_VERSION = true ? "0.1.62" : "0.0.0-dev";
77487
77565
 
77488
77566
  // src/update/service.ts
77489
77567
  import { TypertRemoteService as TypertRemoteService3 } from "@deepseek-ai/dsh-typert-protocol";
@@ -77654,7 +77732,8 @@ function apply(ctx, raw) {
77654
77732
  const meter = new UsageMeter();
77655
77733
  const cacheDir = resolveCacheDir(ctx);
77656
77734
  const gatewayRequest = () => gatewayFetch(
77657
- resolveGatewayPin(current2().pinnedAddresses, current2().baseURL)
77735
+ resolveGatewayPin(current2().pinnedAddresses, current2().baseURL),
77736
+ current2().keepAliveTimeoutMs
77658
77737
  );
77659
77738
  const adapter = new OpencodeGoAdapter({
77660
77739
  config: current2,
@@ -77766,6 +77845,7 @@ function apply(ctx, raw) {
77766
77845
  ctx.effect(() => () => {
77767
77846
  registration?.();
77768
77847
  undiscover();
77848
+ return closeDispatcher();
77769
77849
  });
77770
77850
  ctx.on("internal/config", function(_raw, next) {
77771
77851
  const value = next();
@@ -13,6 +13,8 @@
13
13
  */
14
14
  import type { RetryPolicyConfig } from '@deepseek-ai/dsh-llm';
15
15
  import z from '@deepseek-ai/schemastery';
16
+ import { DEFAULT_KEEP_ALIVE_TIMEOUT_MS, DEFAULT_KEEP_ALIVE_TIMEOUT_SECONDS, MAX_KEEP_ALIVE_TIMEOUT_MS, MAX_KEEP_ALIVE_TIMEOUT_SECONDS, TIMER_CEILING_MS } from './network/keep-alive.ts';
17
+ export { DEFAULT_KEEP_ALIVE_TIMEOUT_MS, DEFAULT_KEEP_ALIVE_TIMEOUT_SECONDS, MAX_KEEP_ALIVE_TIMEOUT_MS, MAX_KEEP_ALIVE_TIMEOUT_SECONDS, TIMER_CEILING_MS, };
16
18
  /** Credential reference resolving the OpenCode Go API key. */
17
19
  export declare const DEFAULT_API_KEY_ENV = "OPENCODE_GO_API_KEY";
18
20
  /** Requests and pickers share this cache lifetime; explicit discovery revalidates immediately. */
@@ -50,6 +52,14 @@ export interface OpencodeGoConfig {
50
52
  refreshMinutes: number;
51
53
  /** Largest idle gap between stream events before the request fails. */
52
54
  streamIdleTimeoutMs: number;
55
+ /**
56
+ * Idle time a gateway connection is kept between requests, in milliseconds.
57
+ *
58
+ * The timer is reset by every response, so a connection that is used
59
+ * often is never dropped; this bounds only the silence between two
60
+ * requests.
61
+ */
62
+ keepAliveTimeoutMs: number;
53
63
  /** Request-level bound on base64-encoded image payload, in bytes. */
54
64
  maxRequestImageBytes: number;
55
65
  /** Total-pixel budget for one request image. */
@@ -106,4 +116,3 @@ export declare function readConfig(config: LiveConfig): OpencodeGoConfig;
106
116
  * @returns the normalized base URL without trailing slashes.
107
117
  */
108
118
  export declare function assertBaseURL(raw: string): string;
109
- export {};
@@ -0,0 +1,52 @@
1
+ /**
2
+ * How long a gateway connection is kept between requests.
3
+ *
4
+ * These two numbers are declared apart from `config.ts` on purpose. The
5
+ * configuration module imports the Harness' own packages — a schema library
6
+ * and the retry policy type — and the settings page must not drag those into
7
+ * the browser bundle: the page is shipped to a page, and a package that
8
+ * reaches for `node:module` cannot be bundled for one.
9
+ *
10
+ * @module @dan-ai-studio/dshopencodego/network/keep-alive
11
+ */
12
+ /**
13
+ * Default idle time a gateway connection is kept between requests, in seconds.
14
+ *
15
+ * Undici's own default is four seconds, and a conversation pauses for longer
16
+ * than that all the time: a person reads the answer, thinks, and types the
17
+ * next question. A connection dropped during such a pause has to be built
18
+ * again — a TCP and a TLS handshake, roughly a fifth of a second from here
19
+ * to the gateway — and that cost lands on the first token of the next turn.
20
+ *
21
+ * The timer is reset by every response, so this bounds only the silence
22
+ * between two requests: a connection that is used often is never dropped.
23
+ *
24
+ * The unit is seconds because that is what the settings page shows and
25
+ * accepts; a number of milliseconds is not readable at a glance. The
26
+ * millisecond value below is derived from it, never written separately, so
27
+ * the two can never disagree.
28
+ */
29
+ export declare const DEFAULT_KEEP_ALIVE_TIMEOUT_SECONDS = 600;
30
+ /**
31
+ * Largest idle time a connection may be kept, in seconds.
32
+ *
33
+ * A connection held for hours belongs to a machine that never talks to the
34
+ * gateway, and a socket that outlives the server's own idea of the address
35
+ * it is talking to is a socket that fails on first use rather than on the
36
+ * one that mattered. Half an hour is longer than any conversation and short
37
+ * enough that the address is re-checked often.
38
+ */
39
+ export declare const MAX_KEEP_ALIVE_TIMEOUT_SECONDS = 1800;
40
+ /**
41
+ * The ceiling Node's timers can hold, in milliseconds.
42
+ *
43
+ * A value above this does not become a longer timer: it overflows and
44
+ * becomes **one millisecond**, so the connection would be dropped at once —
45
+ * and silently, because the warning goes to stderr and nothing else. This
46
+ * is the reason the maximum below is far smaller than the ceiling.
47
+ */
48
+ export declare const TIMER_CEILING_MS = 2147483647;
49
+ /** Default idle time a gateway connection is kept between requests, in milliseconds. */
50
+ export declare const DEFAULT_KEEP_ALIVE_TIMEOUT_MS: number;
51
+ /** Largest idle time a connection may be kept, in milliseconds. */
52
+ export declare const MAX_KEEP_ALIVE_TIMEOUT_MS: number;
@@ -100,6 +100,7 @@ export declare function resetSpentAddresses(): void;
100
100
  * by the address the system would have chosen — so the request is sent
101
101
  * again, that way, and the answer is the one the caller gets.
102
102
  * @param pin - the gateway's name and its pinned address.
103
+ * @param keepAliveTimeoutMs - how long an idle connection is kept.
103
104
  * @returns the fetch to use.
104
105
  */
105
- export declare function gatewayFetch(pin: GatewayPin): typeof fetch;
106
+ export declare function gatewayFetch(pin: GatewayPin, keepAliveTimeoutMs: number): typeof fetch;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@dan-ai-studio/dshopencodego",
3
- "version": "0.1.60",
3
+ "version": "0.1.62",
4
4
  "description": "OpenCode Go provider for DeepSeek Harness: live gateway catalog, per-conversation session header, usage",
5
5
  "type": "module",
6
6
  "license": "MIT",
@@ -145,6 +145,7 @@
145
145
  "react": "^18.3.1",
146
146
  "react-dom": "^18.3.1",
147
147
  "typescript": "^7.0.2",
148
+ "undici": "~8.12.3",
148
149
  "vitest": "^5.0.5"
149
150
  },
150
151
  "keywords": [