@ebony-vinyl/dsh-our-free-model 2.8.3 → 2.8.4

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/src/model-test.js CHANGED
@@ -1,8 +1,24 @@
1
- /** Text-test validation adapted from upstream PR #491; timing, cancellation and persistence complete issue #462. */
1
+ /**
2
+ * Model measurement: the text test this lane runs itself, and the figures the
3
+ * site publishes for the same models.
4
+ *
5
+ * The two halves answer the same question — "how is this model actually
6
+ * serving?" — from opposite directions. `measureModelTest` establishes it by
7
+ * sending the smallest request that can produce a verdict; the second half
8
+ * reads the site's own hourly statistics so a card can show the strip the site
9
+ * shows, rather than an invented one.
10
+ *
11
+ * They share a module because the release set has a hard ceiling: updaters from
12
+ * 1.3.1 and 2.5.9 reject any manifest listing more than 80 files, so a new
13
+ * module of this size cannot simply be added. Folding it in beside the
14
+ * measurement it complements keeps old installs upgradable.
15
+ *
16
+ * Text-test validation adapted from upstream PR #491; timing, cancellation and
17
+ * persistence complete issue #462.
18
+ */
2
19
  import { CODE, UpstreamError } from './http.js'
3
20
  import { decodeWindow } from './store.js'
4
21
  import { windowTokens } from './stream.js'
5
-
6
22
  export const MODEL_TEST_TIMEOUT_MS = 300_000
7
23
 
8
24
  /** Only a nonempty, normally finished tool-free answer establishes a successful text test. */
@@ -92,3 +108,265 @@ export function modelAvailability(id, snapshot, listed = false) {
92
108
  ttftMs: measured?.ttftMs, latencyMs: measured?.latencyMs,
93
109
  }
94
110
  }
111
+
112
+ // ── the site's own per-model figures ─────────────────────────────────────────
113
+ //
114
+ // The model square at the site root draws one performance strip per card: a
115
+ // success-rate bar of 24 hourly ticks, the average latency, the output speed,
116
+ // and a 详情 button that opens the trend charts. These functions read the same
117
+ // numbers from the same endpoint so the plugin's cards show the same strip
118
+ // rather than an invented one.
119
+ //
120
+ // The endpoint (`GET /api/perf-metrics/summary?hours=24`) is public — no
121
+ // credential — and answers every model in one document, each with its own
122
+ // `recent_success_series`. It sends no CORS headers, so a page served from the
123
+ // harness cannot call it directly; index.js hops through it.
124
+ //
125
+ // Two rules are enforced, both learned from the live data:
126
+ //
127
+ // 1. **A window that spans a channel change is not a verdict.** Two relayed
128
+ // models were moved onto a different upstream mid-window; their 24h rate
129
+ // still averages in the hours of fast failures from the lane they left, so it
130
+ // reads ~3% while the last hour reads ~93%. Such a model is reported as
131
+ // `settling` and its card draws no strip — a wrong number is worse than none.
132
+ //
133
+ // 2. **No endpoint, no strip.** When the site does not answer, the map is empty
134
+ // and every card renders exactly as it did before this feature existed.
135
+
136
+ /** How long one metrics read may take before the page gets an empty map. */
137
+ const METRICS_TIMEOUT_MS = 6000
138
+
139
+ /** The window the strip describes, in hours. The site's own strip says 24. */
140
+ export const WINDOW_HOURS = 24
141
+
142
+ /**
143
+ * A model's window rate and its recent hour may differ by this much before the
144
+ * figure is withheld. The relay move left a 90-point gap; ordinary drift on a
145
+ * busy day stays well inside 20 (the live minimax card swings 57→96 across the
146
+ * day and is still shown).
147
+ */
148
+ const SETTLING_GAP = 30
149
+
150
+ /**
151
+ * The site root, derived from the lane's own base (`…/eac/v1` → `…`).
152
+ *
153
+ * The metrics endpoint lives beside the panel, not under the signed lane, so
154
+ * the `/eac/v1` suffix is dropped rather than rewritten. A base that does not
155
+ * look like a URL yields null and the feature stays off.
156
+ */
157
+ export function siteRootFor(base) {
158
+ const text = String(base ?? '').trim()
159
+ if (text === '') return null
160
+ let url
161
+ try { url = new URL(text) } catch { return null }
162
+ if (url.protocol !== 'http:' && url.protocol !== 'https:') return null
163
+ // Drop the lane's own mount (`/eac/v1`, `/v1`) so the result is the site root
164
+ // the panel and its API are served from. A bare `/eac` is dropped too, for a
165
+ // base that names only the mount and no API version.
166
+ const path = url.pathname
167
+ .replace(/\/(?:eac\/)?v1\/?$/i, '')
168
+ .replace(/\/eac\/?$/i, '')
169
+ .replace(/\/+$/, '')
170
+ return `${url.origin}${path}`
171
+ }
172
+
173
+ /**
174
+ * A finite non-negative number, or null.
175
+ *
176
+ * `null` and `''` must stay null rather than becoming 0: the endpoint uses null
177
+ * for "no sample in this hour", and `Number(null)` is 0 — which would paint a
178
+ * red 0% tick, or drop a latency line to the floor, for an hour that simply had
179
+ * no traffic.
180
+ */
181
+ function metricOf(value) {
182
+ if (value === null || value === undefined || value === '') return null
183
+ const n = Number(value)
184
+ return Number.isFinite(n) && n >= 0 ? n : null
185
+ }
186
+
187
+ /**
188
+ * One tick of the success bar.
189
+ *
190
+ * The site colours each hourly tick by its own rate — emerald at 99 and above,
191
+ * amber from 90, red below — so a glance reads the shape of the day, not just
192
+ * its average. `null` marks an hour with no sample: the site draws those grey.
193
+ */
194
+ function tickOf(row) {
195
+ return metricOf(row?.success_rate)
196
+ }
197
+
198
+ /**
199
+ * Reduce one model's entry to what the card draws.
200
+ *
201
+ * Returns null when the entry carries nothing worth showing, and
202
+ * `{ settling: true }` when its window rate disagrees with how it is serving
203
+ * now by more than {@link SETTLING_GAP} points.
204
+ *
205
+ * The comparison deliberately skips the final bucket. The series ends with the
206
+ * hour still in progress (the endpoint's `window_end` is "now"), so that sample
207
+ * covers however many minutes have elapsed — three, or fifty-nine. Comparing a
208
+ * partial hour against a 24-hour average compares incomparable things and made
209
+ * the strip appear and disappear as the hour filled: on live data one model
210
+ * read a 34-point gap three minutes into the hour and an 18-point gap minutes
211
+ * later, flipping between hidden and shown. The newest *complete* hour is a
212
+ * settled sample, so the verdict stops flapping without changing which models
213
+ * are withheld.
214
+ *
215
+ * The reference is a recent hour rather than a median of the window on purpose:
216
+ * this endpoint reports hours with no traffic as 0 rather than null, so a median
217
+ * over the whole series is dominated by quiet hours and stops noticing the very
218
+ * upstream switch this check exists to catch.
219
+ */
220
+ export function perfFrom(entry) {
221
+ if (entry === null || typeof entry !== 'object') return null
222
+ const rate = metricOf(entry.success_rate)
223
+ if (rate === null) return null
224
+ const latency = metricOf(entry.avg_latency_ms)
225
+ const speed = metricOf(entry.avg_tps)
226
+
227
+ const series = Array.isArray(entry.recent_success_series) ? entry.recent_success_series : []
228
+ const ticks = series.map(tickOf)
229
+ // Everything but the in-progress bucket. With a single bucket there is no
230
+ // complete hour to compare against, so the window rate stands unexamined.
231
+ const complete = ticks.slice(0, -1).filter(value => value !== null)
232
+ const reference = complete.length > 0 ? complete[complete.length - 1] : null
233
+
234
+ // A window rate that disagrees violently with the recent complete hour
235
+ // describes a window that spanned a change of upstream, not the model as it
236
+ // serves now.
237
+ if (reference !== null && Math.abs(reference - rate) > SETTLING_GAP) {
238
+ return { settling: true, rate, newest: reference }
239
+ }
240
+
241
+ return {
242
+ rate,
243
+ latencyMs: latency,
244
+ speed,
245
+ // Every hourly sample, oldest first: the bar the card draws.
246
+ ticks,
247
+ windowHours: series.length > 0 ? series.length : WINDOW_HOURS,
248
+ }
249
+ }
250
+
251
+ /**
252
+ * One point of a trend chart, from the per-model series.
253
+ *
254
+ * The site draws two charts from this one series: latency over time and
255
+ * availability over time. Both read the same hourly rows, so they are reduced
256
+ * together rather than in two passes.
257
+ */
258
+ function pointOf(row) {
259
+ if (row === null || typeof row !== 'object') return null
260
+ const ts = metricOf(row.ts)
261
+ if (ts === null) return null
262
+ return {
263
+ ts,
264
+ latencyMs: metricOf(row.avg_latency_ms),
265
+ ttftMs: metricOf(row.avg_ttft_ms),
266
+ rate: metricOf(row.success_rate),
267
+ speed: metricOf(row.avg_tps),
268
+ }
269
+ }
270
+
271
+ /**
272
+ * The trend series for one model, oldest first.
273
+ *
274
+ * A point with no latency sample keeps its slot so the two charts stay aligned
275
+ * in time; the renderer skips it when drawing the line.
276
+ */
277
+ export function seriesFrom(entry) {
278
+ const rows = Array.isArray(entry?.series) ? entry.series : []
279
+ return rows.map(pointOf).filter(point => point !== null)
280
+ }
281
+
282
+ /**
283
+ * Read one model's full trend series (`GET /api/perf-metrics?model=…`).
284
+ *
285
+ * The summary endpoint answers every model's headline figures in one request;
286
+ * the per-model endpoint is the only one that carries the hourly series. The
287
+ * card asks for this only when its 详情 is opened, so a roster of twelve cards
288
+ * costs one request until the reader actually looks.
289
+ */
290
+ export async function readSeries({ root, modelId, fetch, signal, timeoutMs = METRICS_TIMEOUT_MS }) {
291
+ if (root === null || root === undefined || typeof modelId !== 'string' || modelId === '') return null
292
+ // Already cancelled: do not spend a round trip on a request nobody will read.
293
+ if (signal?.aborted === true) return null
294
+ const url = `${root}/api/perf-metrics?model=${encodeURIComponent(modelId)}&hours=${WINDOW_HOURS}`
295
+ const controller = new AbortController()
296
+ const timer = setTimeout(() => controller.abort(), timeoutMs)
297
+ timer.unref?.()
298
+ const onAbort = () => controller.abort()
299
+ signal?.addEventListener('abort', onAbort, { once: true })
300
+ try {
301
+ const response = await fetch(url, { signal: controller.signal, headers: { accept: 'application/json' } })
302
+ if (!response.ok) return null
303
+ const text = await response.text()
304
+ let payload
305
+ try { payload = JSON.parse(text) } catch { return null }
306
+ const series = seriesFrom(payload?.data)
307
+ if (series.length === 0) return null
308
+ return {
309
+ series,
310
+ windowStart: metricOf(payload?.data?.window_start),
311
+ windowEnd: metricOf(payload?.data?.window_end),
312
+ }
313
+ } catch {
314
+ return null
315
+ } finally {
316
+ clearTimeout(timer)
317
+ signal?.removeEventListener?.('abort', onAbort)
318
+ }
319
+ }
320
+
321
+ /**
322
+ * Read every model's figures from the site in one request.
323
+ *
324
+ * The endpoint answers all models together, so this is one round trip rather
325
+ * than one per model. The map is keyed by the id the site spells, which is the
326
+ * same id the catalog uses. Models the site does not answer for are absent
327
+ * rather than present-and-null: absence is what the card renders as "no strip".
328
+ *
329
+ * The site's root is deliberately not echoed back: it is the co-paid gateway's
330
+ * host, the seal keeps it server-side, and the page never reads it. Only the
331
+ * figures travel.
332
+ *
333
+ * Never throws — a failure is the same answer as "the site has no figures",
334
+ * which the card already handles by drawing nothing.
335
+ */
336
+ export async function readPerf({ root, fetch, signal, timeoutMs = METRICS_TIMEOUT_MS }) {
337
+ if (root === null || root === undefined) return { models: {} }
338
+ // Already cancelled: do not spend a round trip on a request nobody will read.
339
+ if (signal?.aborted === true) return { models: {} }
340
+ const url = `${root}/api/perf-metrics/summary?hours=${WINDOW_HOURS}`
341
+ const controller = new AbortController()
342
+ const timer = setTimeout(() => controller.abort(), timeoutMs)
343
+ timer.unref?.()
344
+ const onAbort = () => controller.abort()
345
+ signal?.addEventListener('abort', onAbort, { once: true })
346
+ try {
347
+ const response = await fetch(url, { signal: controller.signal, headers: { accept: 'application/json' } })
348
+ if (!response.ok) return { models: {} }
349
+ const text = await response.text()
350
+ let payload
351
+ try { payload = JSON.parse(text) } catch { return { models: {} } }
352
+ const rows = payload?.data?.models
353
+ if (!Array.isArray(rows)) return { models: {} }
354
+ const models = {}
355
+ for (const row of rows) {
356
+ const id = typeof row?.model_name === 'string' ? row.model_name.trim() : ''
357
+ if (id === '') continue
358
+ const perf = perfFrom(row)
359
+ if (perf !== null) models[id] = perf
360
+ }
361
+ return {
362
+ models,
363
+ windowStart: metricOf(payload?.data?.window_start),
364
+ windowEnd: metricOf(payload?.data?.window_end),
365
+ }
366
+ } catch {
367
+ return { models: {} }
368
+ } finally {
369
+ clearTimeout(timer)
370
+ signal?.removeEventListener?.('abort', onAbort)
371
+ }
372
+ }
package/src/o2a.js CHANGED
@@ -631,6 +631,26 @@ function renameAndCompleteTools(payload, shape, { keep } = {}) {
631
631
  dropDanglingToolChoice(payload, shape, known);
632
632
  return { renames, injected, known };
633
633
  }
634
+ function chatMessageHasContent(message) {
635
+ const keys = ["content", "reasoning_content", "reasoning"];
636
+ for (const key of keys) {
637
+ const value = message[key];
638
+ if (typeof value === "string") {
639
+ if (value !== "") return true;
640
+ continue;
641
+ }
642
+ if (Array.isArray(value)) {
643
+ if (value.length > 0) return true;
644
+ continue;
645
+ }
646
+ if (value !== null && typeof value === "object") {
647
+ if (Object.keys(value).length > 0) return true;
648
+ continue;
649
+ }
650
+ if (value !== void 0 && value !== null) return true;
651
+ }
652
+ return false;
653
+ }
634
654
  function sanitizeToolHistory(payload, shape, known) {
635
655
  const fateOf = (name) => {
636
656
  if (typeof name !== "string" || name === "") return { verdict: "drop" };
@@ -644,13 +664,14 @@ function sanitizeToolHistory(payload, shape, known) {
644
664
  if (messages2 === null) return;
645
665
  const droppedCalls2 = /* @__PURE__ */ new Set();
646
666
  const droppedFunctions = /* @__PURE__ */ new Set();
667
+ const emptied = /* @__PURE__ */ new Set();
647
668
  let changed2 = false;
648
669
  for (let i = 0; i < messages2.length; i += 1) {
649
670
  const message = messages2[i];
650
671
  if (!message || typeof message !== "object") continue;
651
672
  if (Array.isArray(message.tool_calls)) {
652
673
  const kept = [];
653
- let touched = false;
674
+ let touched = message.tool_calls.length === 0;
654
675
  for (const call of message.tool_calls) {
655
676
  const fate = fateOf(call?.function?.name);
656
677
  if (fate.verdict === "keep") {
@@ -667,33 +688,49 @@ function sanitizeToolHistory(payload, shape, known) {
667
688
  touched = true;
668
689
  }
669
690
  if (touched) {
670
- messages2[i] = { ...message, tool_calls: kept };
691
+ if (kept.length === 0) {
692
+ const { tool_calls: _emptied, ...rest } = message;
693
+ messages2[i] = rest;
694
+ if (message.tool_calls.length > 0 && message.function_call === void 0) emptied.add(rest);
695
+ } else {
696
+ messages2[i] = { ...message, tool_calls: kept };
697
+ }
671
698
  changed2 = true;
672
699
  }
673
700
  }
674
- if (message.function_call && typeof message.function_call === "object") {
675
- const fate = fateOf(message.function_call.name);
701
+ const current = messages2[i];
702
+ if (current.function_call && typeof current.function_call === "object") {
703
+ const fate = fateOf(current.function_call.name);
676
704
  if (fate.verdict === "rename") {
677
- messages2[i] = { ...message, function_call: { ...message.function_call, name: fate.official } };
705
+ messages2[i] = { ...current, function_call: { ...current.function_call, name: fate.official } };
678
706
  changed2 = true;
679
707
  } else if (fate.verdict === "drop") {
680
- if (typeof message.function_call.name === "string") droppedFunctions.add(message.function_call.name);
681
- const { function_call: _dropped, ...rest } = messages2[i];
708
+ if (typeof current.function_call.name === "string") droppedFunctions.add(current.function_call.name);
709
+ const { function_call: _dropped, ...rest } = current;
682
710
  messages2[i] = rest;
683
711
  changed2 = true;
684
712
  }
685
713
  }
686
- if (message.role === "tool" && typeof message.tool_call_id === "string" && droppedCalls2.has(message.tool_call_id)) {
714
+ const row = messages2[i];
715
+ if (row.role === "tool" && typeof row.tool_call_id === "string" && droppedCalls2.has(row.tool_call_id)) {
687
716
  messages2.splice(i, 1);
688
717
  i -= 1;
689
718
  changed2 = true;
690
719
  }
691
- if (message.role === "function" && typeof message.name === "string" && droppedFunctions.has(message.name)) {
720
+ if (row.role === "function" && typeof row.name === "string" && droppedFunctions.has(row.name)) {
692
721
  messages2.splice(i, 1);
693
722
  i -= 1;
694
723
  changed2 = true;
695
724
  }
696
725
  }
726
+ if (emptied.size > 0) {
727
+ for (let i = messages2.length - 1; i >= 0; i -= 1) {
728
+ if (emptied.has(messages2[i]) && !chatMessageHasContent(messages2[i])) {
729
+ messages2.splice(i, 1);
730
+ changed2 = true;
731
+ }
732
+ }
733
+ }
697
734
  if (changed2 && messages2.length === 0) payload.messages = [];
698
735
  return;
699
736
  }
package/src/plugins.js CHANGED
@@ -660,8 +660,8 @@ export function builtinDescriptors() {
660
660
  {
661
661
  manifest: {
662
662
  id: 'core-model-workspace',
663
- name: '模型工作台',
664
- description: '内置的本地模型工作台页面与模型目录同步。',
663
+ name: '模型工作台(magpie)',
664
+ description: '内置的本地模型工作台页面与模型目录同步(引擎为 magpie)。',
665
665
  icon: 'panels',
666
666
  provides: ['ui'],
667
667
  },
package/src/store.js CHANGED
@@ -256,6 +256,14 @@ export const SETTINGS_INITIAL = {
256
256
  feedUrl: '',
257
257
  /** Minutes between announcement-feed polls; floored at 5. */
258
258
  feedPollMinutes: 30,
259
+ /** Where the in-plugin feedback form submits. Empty keeps the built-in
260
+ * project server; an override lets a self-hosting operator point at their
261
+ * own collector. Feedback is never sent anywhere else. */
262
+ feedbackUrl: '',
263
+ /** GitHub login this install completed for the feedback form: `{ token,
264
+ * login, at }`, or null. The token only authorizes feedback submission —
265
+ * it is not an API credential and is never sent to a model endpoint. */
266
+ feedbackAuth: null,
259
267
  /** Raise OS-level notifications for new announcements and updates (the
260
268
  * browser asks for permission on the user's click). */
261
269
  notifyOs: false,
@@ -276,6 +284,10 @@ export const SETTINGS_INITIAL = {
276
284
  /** When the running code was hot-reloaded into place, and how many times. */
277
285
  reloadedAt: 0,
278
286
  reloadCount: 0,
287
+ /** The `reloadedAt` the browser has already acknowledged. Stored here, not
288
+ * in localStorage, so a new origin (another port or the desktop shell's
289
+ * random port) does not replay the same notice. */
290
+ reloadNoticeSeen: 0,
279
291
  /** Version installed by the in-app upgrader, for the settings page. */
280
292
  installedVersion: '',
281
293
  }
package/src/updater.js CHANGED
@@ -856,12 +856,26 @@ export function verifyInstalled(pkgDir, manifest) {
856
856
  * @param {string} pkgDir - the installed package directory
857
857
  * @returns {boolean}
858
858
  */
859
- export function isPackageManagerOwned(pkgDir) {
860
- const profileDir = path.dirname(path.dirname(path.resolve(pkgDir)))
859
+ export function isPackageManagerOwned(pkgDir, packageName = PACKAGE_NAME) {
860
+ const profileDir = profileDirOf(pkgDir)
861
861
  const lockfile = readTextFile(path.join(profileDir, 'pnpm-lock.yaml'))
862
- if (lockfile !== undefined && lockfileDeclaresPackage(lockfile, PACKAGE_NAME)) return true
862
+ if (lockfile !== undefined && lockfileDeclaresPackage(lockfile, packageName)) return true
863
863
  const modules = readTextFile(path.join(profileDir, 'node_modules', '.modules.yaml'))
864
- return modules !== undefined && modulesHoistsPackage(modules, PACKAGE_NAME)
864
+ return modules !== undefined && modulesHoistsPackage(modules, packageName)
865
+ }
866
+
867
+ /**
868
+ * The profile root a package directory belongs to.
869
+ *
870
+ * A bare install is <profile>/node_modules/<name>; a scoped one is
871
+ * <profile>/node_modules/@scope/<name>. Walking to node_modules instead of
872
+ * counting two parents keeps both layouts pointed at the profile, where pnpm
873
+ * writes its lockfile and .modules.yaml (issue #773).
874
+ */
875
+ export function profileDirOf(pkgDir) {
876
+ let dir = path.resolve(pkgDir)
877
+ while (dir !== path.dirname(dir) && path.basename(dir) !== 'node_modules') dir = path.dirname(dir)
878
+ return path.dirname(dir)
865
879
  }
866
880
 
867
881
  function readTextFile(file) {
@@ -879,9 +893,10 @@ function readTextFile(file) {
879
893
  * full install — a real tree inside the profile declared as a relative
880
894
  * `link:`. A `file:`, tarball-URL or registry specifier does record a source
881
895
  * pnpm can re-extract, and stays "owned". */
882
- function lockfileDeclaresPackage(text, name) {
896
+ export function lockfileDeclaresPackage(text, name) {
883
897
  const lines = text.split(/\r?\n/)
884
- const entry = new RegExp(`^(\\s*)${name.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')}\\s*:\\s*$`)
898
+ const escaped = name.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')
899
+ const entry = new RegExp(`^(\\s*)['"]?${escaped}['"]?\\s*:\\s*$`)
885
900
  for (let index = 0; index < lines.length; index += 1) {
886
901
  const match = entry.exec(lines[index])
887
902
  if (match === null) continue
@@ -58738,7 +58738,10 @@ function registerChannelPackEndpoints(ctx, pool, lobsterai, qoder, qoderCn, trae
58738
58738
  const values = await collectCreditBalances(accounts, GEMINI, {
58739
58739
  resolve: (ref) => ctx.credentials.resolve(ref),
58740
58740
  fetchBalanceDetailed: async (credential) => {
58741
- const { balance, error, tier } = await fetchGeminiCreditBalance(credential, GEMINI, { force: req.force === true });
58741
+ const { balance, error, tier } = await fetchGeminiCreditBalance(credential, GEMINI, {
58742
+ force: req.force === true,
58743
+ ...config.fetcher === void 0 ? {} : { fetcher: config.fetcher }
58744
+ });
58742
58745
  const extra = tier === void 0 ? void 0 : { accountTier: { label: tier.label, title: `${tier.name}\uFF08${tier.id}\uFF09` } };
58743
58746
  return {
58744
58747
  balance,
@@ -65535,7 +65538,9 @@ function apply(ctx, config = {}) {
65535
65538
  });
65536
65539
  });
65537
65540
  registerChannelPackRpc(ctx, pool, lobsterai, qoder, qoderCn, trae, cline, loomy, raccoon, minimax, zcode, gemini, modelAdapters, {
65538
- ...config
65541
+ ...config,
65542
+ // ⚠️ 配额等附属查询必须复用同一出口;省略会让 gemini-credits 回落到裸 fetch。
65543
+ fetcher: network.fetch
65539
65544
  });
65540
65545
  mountOpenAiGateway(ctx, pool);
65541
65546
  installLoopResume(ctx);