dsh-xdpool 2.4.1 → 2.4.3

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/lib/index.js CHANGED
@@ -2,9 +2,9 @@ import { createRequire } from "node:module";
2
2
  import { basename, dirname, join, resolve } from "node:path";
3
3
  import z from "@deepseek-ai/schemastery";
4
4
  import crypto, { createDecipheriv, createHash, randomBytes, randomUUID, timingSafeEqual } from "node:crypto";
5
+ import { appendFileSync, cpSync, existsSync, mkdirSync, readFileSync, readdirSync, renameSync, statSync, truncateSync, writeFileSync } from "node:fs";
5
6
  import { mkdir, readFile, readdir, rename, rm, writeFile } from "node:fs/promises";
6
7
  import { homedir } from "node:os";
7
- import { appendFileSync, cpSync, existsSync, mkdirSync, readFileSync, readdirSync, statSync, truncateSync } from "node:fs";
8
8
  import { execFile, execFileSync } from "node:child_process";
9
9
  import { fileURLToPath, pathToFileURL } from "node:url";
10
10
  import { createProvider } from "@earendil-works/pi-ai";
@@ -351,8 +351,11 @@ const CHAT_IDENTITY_HEADERS = Object.freeze({
351
351
  * it" above is the same finding from the other side).
352
352
  * - it is case-insensitive (`you are an ai agent powered by deepseek harness`
353
353
  * is blocked too).
354
- * - only `role: "system"` is inspected: the same sentence in a `user` or
355
- * `tool` message passes, and it is blocked in a second system message too.
354
+ * - the scanned roles are `system` and `assistant`: the same sentence in a
355
+ * `user` or `tool` message passes, and it is blocked in a second system
356
+ * message too. 2.4.1 recorded this as system-only; re-measured
357
+ * 2026-10-21, an `assistant` message carrying the sentence 400s as well,
358
+ * so `prepareChatBody` rewrites both roles.
356
359
  * - `deepseek harness` alone, or `powered by deepseek` alone, passes — the
357
360
  * whole phrase is what matches.
358
361
  * - replacing `powered by` with `running on` passes, which is the rewrite
@@ -364,14 +367,38 @@ const CHAT_IDENTITY_HEADERS = Object.freeze({
364
367
  * lose its anchor tomorrow. Rewriting the phrase is what the report's repro
365
368
  * verified, so that is what this does.
366
369
  *
370
+ * A SECOND FINGERPRINT (measured against the live domestic gateway 2026-10-21)
371
+ *
372
+ * Once the sentence above was rewritten, requests still 400/11128'd, and the
373
+ * blocker turned out to be another region of the same DSH system prompt: the
374
+ * runtime-context sentence pair
375
+ *
376
+ * You are interacting with the user through the DeepSeek Harness Web GUI
377
+ * whose URL the runtime context names. When the user refers to "this page",
378
+ * "this GUI", or "this app" without naming another target, they mean this GUI.
379
+ *
380
+ * The pair is load-bearing: either sentence alone 200s, and so does the pair
381
+ * with any small edit inside it — so this is a second CONTENT fingerprint, not
382
+ * a prompt-length rule and not another header rule. Renaming the product's
383
+ * "Web GUI" to "web interface" is the smallest edit that keeps the meaning for
384
+ * the model and clears the check; it was verified at 200 against both a main
385
+ * agent system prompt and a subagent system prompt, on the same account that
386
+ * reproduced the 400.
387
+ *
388
+ * The same window in an `assistant` message also answers 400, and 200 after
389
+ * this rewrite; `user` and `tool` messages are not scanned. That is why the
390
+ * rewrite is applied to assistant history too — one past reply carrying a
391
+ * fingerprint would otherwise fail every later request in the conversation.
392
+ *
367
393
  * The list is an ARRAY because these fingerprints are appended to over time; a
368
394
  * future rejection should be one more row here, not a new code path.
369
395
  */
370
- const BANNED_SYSTEM_PHRASES = Object.freeze([[/powered by DeepSeek Harness/gi, "running on DeepSeek Harness"]]);
396
+ const BANNED_SYSTEM_PHRASES = Object.freeze([[/powered by DeepSeek Harness/gi, "running on DeepSeek Harness"], [/DeepSeek Harness Web GUI/gi, "DeepSeek Harness web interface"]]);
371
397
  /**
372
- * Rewrite fingerprinted system content, leaving everything else byte-identical.
398
+ * Rewrite fingerprinted system/assistant content, leaving everything else
399
+ * byte-identical.
373
400
  *
374
- * System content arrives as either a plain string or an array of content blocks
401
+ * Message content arrives as either a plain string or an array of content blocks
375
402
  * (an OpenAI-compatible body allows both), so both shapes are handled — fixing
376
403
  * only the string form would miss every request that carries an image or a
377
404
  * cache-control block.
@@ -1122,9 +1149,9 @@ var WorkBuddyUpstreamClient = class {
1122
1149
  * Normalize an OpenAI chat-completions body for the WorkBuddy upstream:
1123
1150
  * force `stream: true` (the upstream rejects non-streaming), convert the
1124
1151
  * DSH `developer` role into `system` (upstream rejects `developer` with
1125
- * business code 11128), rewrite system content the gateway fingerprints as a
1126
- * non-official client (also 11128), and flatten `tool_choice` into its string
1127
- * form.
1152
+ * business code 11128), rewrite fingerprinted system/assistant content the
1153
+ * gateway reads as a non-official client (also 11128), and flatten
1154
+ * `tool_choice` into its string form.
1128
1155
  *
1129
1156
  * This is the REGION-BLIND half. Everything here is measured good on both
1130
1157
  * gateways, so it runs before the region is known — the pool lane picks its
@@ -1149,7 +1176,7 @@ var WorkBuddyUpstreamClient = class {
1149
1176
  if (typeof value !== "object" || value === null || Array.isArray(value)) continue;
1150
1177
  const message = value;
1151
1178
  if (message["role"] === "developer") message["role"] = "system";
1152
- if (message["role"] === "system") message["content"] = sanitizeSystemContent(message["content"]);
1179
+ if (message["role"] === "system" || message["role"] === "assistant") message["content"] = sanitizeSystemContent(message["content"]);
1153
1180
  }
1154
1181
  const choice = obj["tool_choice"];
1155
1182
  if (typeof choice === "string") {
@@ -3512,6 +3539,9 @@ var WorkBuddyAccountPool = class {
3512
3539
  * the LRU order: re-binding deletes then re-inserts.
3513
3540
  */
3514
3541
  affinity = /* @__PURE__ */ new Map();
3542
+ /** Where the bindings above are persisted; undefined keeps them in memory. */
3543
+ affinityPath;
3544
+ affinitySaveTimer;
3515
3545
  lastScanAtMs = 0;
3516
3546
  /**
3517
3547
  * The account id the user pinned, or undefined for none.
@@ -3621,6 +3651,8 @@ var WorkBuddyAccountPool = class {
3621
3651
  this.distribution = options.distribution ?? "priority";
3622
3652
  this.persistent = options.persistent ?? false;
3623
3653
  this.storePath = accountStorePath();
3654
+ this.affinityPath = options.affinityPath;
3655
+ if (this.affinityPath !== void 0) this.loadAffinity();
3624
3656
  }
3625
3657
  /**
3626
3658
  * Re-apply configuration that only affects discovery and cooldown policy,
@@ -3896,6 +3928,49 @@ var WorkBuddyAccountPool = class {
3896
3928
  if (oldest === void 0) break;
3897
3929
  this.affinity.delete(oldest);
3898
3930
  }
3931
+ this.scheduleAffinitySave();
3932
+ }
3933
+ /**
3934
+ * Restore bindings written by a previous process.
3935
+ *
3936
+ * Tolerant like the other state files: a malformed document or one bad entry
3937
+ * must not take the pool down, and an account id that no longer exists is
3938
+ * simply never matched (the next acquire re-picks and rebinds).
3939
+ */
3940
+ loadAffinity() {
3941
+ if (this.affinityPath === void 0) return;
3942
+ try {
3943
+ const bindings = JSON.parse(readFileSync(this.affinityPath, "utf8")).bindings;
3944
+ if (typeof bindings !== "object" || bindings === null || Array.isArray(bindings)) return;
3945
+ for (const [key, value] of Object.entries(bindings)) {
3946
+ if (key === "" || typeof value !== "string" || value === "") continue;
3947
+ this.affinity.set(key, value);
3948
+ }
3949
+ while (this.affinity.size > STICKY_AFFINITY_LIMIT) {
3950
+ const oldest = this.affinity.keys().next().value;
3951
+ if (oldest === void 0) break;
3952
+ this.affinity.delete(oldest);
3953
+ }
3954
+ } catch {}
3955
+ }
3956
+ /** Write the bindings back, coalescing a burst into one atomic rename. */
3957
+ scheduleAffinitySave() {
3958
+ if (this.affinityPath === void 0 || this.affinitySaveTimer !== void 0) return;
3959
+ this.affinitySaveTimer = setTimeout(() => {
3960
+ this.affinitySaveTimer = void 0;
3961
+ const path = this.affinityPath;
3962
+ if (path === void 0) return;
3963
+ try {
3964
+ mkdirSync(dirname(path), { recursive: true });
3965
+ writeFileSync(`${path}.tmp`, `${JSON.stringify({
3966
+ version: 1,
3967
+ bindings: Object.fromEntries(this.affinity)
3968
+ }, null, 2)}
3969
+ `, "utf8");
3970
+ renameSync(`${path}.tmp`, path);
3971
+ } catch {}
3972
+ }, 500);
3973
+ this.affinitySaveTimer.unref?.();
3899
3974
  }
3900
3975
  /**
3901
3976
  * `sticky`: the account this conversation already used, when it can still
@@ -10601,9 +10676,20 @@ function normalizeRelay(raw) {
10601
10676
  if (typeof value.port === "number" && Number.isInteger(value.port)) relay.port = value.port;
10602
10677
  const key = text$5(value.key);
10603
10678
  if (key !== "") relay.key = key;
10679
+ if (Array.isArray(value.models)) relay.models = normalizeRelayModels(value.models);
10604
10680
  return Object.keys(relay).length === 0 ? void 0 : relay;
10605
10681
  }
10606
10682
  /**
10683
+ * A shared-model list, cleaned: trimmed, de-duplicated, non-strings dropped.
10684
+ *
10685
+ * One helper for both doors into the field — reading the file and merging a
10686
+ * patch — so a list saved from the card is stored exactly as a list loaded from
10687
+ * disk would be, and the gateway cannot see two spellings of one selection.
10688
+ */
10689
+ function normalizeRelayModels(raw) {
10690
+ return [...new Set(raw.map((entry) => text$5(entry)).filter((entry) => entry !== ""))];
10691
+ }
10692
+ /**
10607
10693
  * Write the store atomically: a sibling `.tmp` first, then a rename.
10608
10694
  *
10609
10695
  * Same discipline as the ignore list. A process killed mid-write must leave
@@ -10736,6 +10822,7 @@ async function updateRelaySetting(patch) {
10736
10822
  if (key === "") delete after.key;
10737
10823
  else after.key = key;
10738
10824
  }
10825
+ if (patch.models !== void 0) after.models = normalizeRelayModels(patch.models);
10739
10826
  const next = { ...current };
10740
10827
  if (Object.keys(after).length === 0) delete next.relay;
10741
10828
  else next.relay = after;
@@ -17624,7 +17711,10 @@ async function probeModelConnectivity(llm, provider, model, options = {}) {
17624
17711
  system: "You are being called by a connection test. Answer in one short word.",
17625
17712
  messages: [{
17626
17713
  role: "user",
17627
- content: PROBE_PROMPT
17714
+ content: [{
17715
+ type: "text",
17716
+ text: PROBE_PROMPT
17717
+ }]
17628
17718
  }],
17629
17719
  maxTokens: 24,
17630
17720
  signal: timeout.signal
@@ -18007,6 +18097,259 @@ function paidAlternativeFor(model, models) {
18007
18097
  return paidSiblingOf(model, models);
18008
18098
  }
18009
18099
  //#endregion
18100
+ //#region src/channel-lane.ts
18101
+ /**
18102
+ * A request this lane will not serve as written, and that is the caller's to fix.
18103
+ *
18104
+ * Kept as its own type so the route can answer 400 for it: a refused image and a
18105
+ * dead upstream must not read the same to a client, and neither must read like a
18106
+ * success that quietly dropped the picture.
18107
+ */
18108
+ var ChannelRequestError = class extends Error {};
18109
+ /** The image types the host's attachment store will hold. */
18110
+ const IMAGE_MEDIA_TYPES = /* @__PURE__ */ new Set([
18111
+ "image/png",
18112
+ "image/jpeg",
18113
+ "image/webp",
18114
+ "image/gif"
18115
+ ]);
18116
+ /**
18117
+ * Read one OpenAI `image_url` value into the store's input form.
18118
+ *
18119
+ * Every rejection names itself, because the alternative — dropping the part and
18120
+ * continuing — is what let a model answer about an image it never received.
18121
+ */
18122
+ function interpretImagePart(url) {
18123
+ const target = url.trim();
18124
+ if (/^https?:\/\//iu.test(target)) return {
18125
+ ok: false,
18126
+ reason: "remote image URLs are not fetched by this gateway; send a base64 data URL"
18127
+ };
18128
+ if (!target.startsWith("data:")) return {
18129
+ ok: false,
18130
+ reason: "only base64 data URLs can be forwarded as images"
18131
+ };
18132
+ const comma = target.indexOf(",");
18133
+ if (comma < 0) return {
18134
+ ok: false,
18135
+ reason: "image data URL has no payload"
18136
+ };
18137
+ const meta = target.slice(5, comma);
18138
+ if (!meta.toLowerCase().includes(";base64")) return {
18139
+ ok: false,
18140
+ reason: "image data URL must be base64-encoded"
18141
+ };
18142
+ const declared = meta.split(";")[0]?.toLowerCase() ?? "";
18143
+ const mediaType = declared === "image/jpg" ? "image/jpeg" : declared;
18144
+ if (!mediaType.startsWith("image/")) return {
18145
+ ok: false,
18146
+ reason: `image part carries a ${mediaType || "unnamed"} payload, not an image`
18147
+ };
18148
+ if (!IMAGE_MEDIA_TYPES.has(mediaType)) return {
18149
+ ok: false,
18150
+ reason: `unsupported image type ${mediaType}; the host stores png, jpeg, webp or gif`
18151
+ };
18152
+ const bytes = Buffer.from(target.slice(comma + 1), "base64");
18153
+ if (bytes.length === 0) return {
18154
+ ok: false,
18155
+ reason: "image payload is empty"
18156
+ };
18157
+ return {
18158
+ ok: true,
18159
+ data: new Uint8Array(bytes),
18160
+ mediaType
18161
+ };
18162
+ }
18163
+ /** How long a listing is reused before it is asked again. */
18164
+ const LISTING_TTL_MS = 6e4;
18165
+ /**
18166
+ * Build the channel lane.
18167
+ *
18168
+ * `providers` is supplied rather than derived here: the caller knows which
18169
+ * channels are mounted and which are pool-owned, and importing `channels.ts`
18170
+ * would pull the pack mount into this module.
18171
+ */
18172
+ function createChannelLane(options) {
18173
+ const { llm, providers, toId, attachments, logger } = options;
18174
+ const cache = /* @__PURE__ */ new Map();
18175
+ const inflight = /* @__PURE__ */ new Map();
18176
+ const fetchRows = (channel) => {
18177
+ const hit = cache.get(channel);
18178
+ if (hit !== void 0 && Date.now() - hit.at < LISTING_TTL_MS) return Promise.resolve(hit.rows);
18179
+ const running = inflight.get(channel);
18180
+ if (running !== void 0) return running;
18181
+ const tracked = llm.listModels(channel).then((models) => models.map((model) => ({
18182
+ id: toId(channel, model.id),
18183
+ channel,
18184
+ modelId: model.id,
18185
+ name: model.name ?? model.id
18186
+ }))).catch((error) => {
18187
+ logger?.warn?.(`dsh-xdpool: channel ${channel} listed no models: ${String(error)}`);
18188
+ return [];
18189
+ }).then((rows) => {
18190
+ cache.set(channel, {
18191
+ at: Date.now(),
18192
+ rows
18193
+ });
18194
+ inflight.delete(channel);
18195
+ return rows;
18196
+ });
18197
+ inflight.set(channel, tracked);
18198
+ return tracked;
18199
+ };
18200
+ return {
18201
+ rows: async () => (await Promise.all(providers().map(fetchRows))).flat(),
18202
+ resolve: (id) => {
18203
+ const separator = id.indexOf(":");
18204
+ if (separator <= 0 || separator === id.length - 1) return void 0;
18205
+ const channel = id.slice(0, separator);
18206
+ if (!providers().includes(channel)) return void 0;
18207
+ return {
18208
+ channel,
18209
+ modelId: id.slice(separator + 1)
18210
+ };
18211
+ },
18212
+ turn: (input) => runTurn({
18213
+ llm,
18214
+ attachments: attachments?.(),
18215
+ ...input
18216
+ })
18217
+ };
18218
+ }
18219
+ /**
18220
+ * Forward one OpenAI message as the host's typed content blocks.
18221
+ *
18222
+ * Text and image parts carry over. Anything else is dropped rather than
18223
+ * guessed at: inventing a block shape the provider was never told about fails
18224
+ * upstream with an error that names neither the message nor the part.
18225
+ *
18226
+ * An image that cannot be carried is a refusal, not a drop — see the module
18227
+ * note on why `{ type: 'image', url }` was the wrong answer.
18228
+ */
18229
+ async function toHostMessage(message, attachments) {
18230
+ const role = typeof message.role === "string" ? message.role : "user";
18231
+ const content = message.content;
18232
+ if (typeof content === "string") return {
18233
+ role,
18234
+ content: content === "" ? [] : [{
18235
+ type: "text",
18236
+ text: content
18237
+ }]
18238
+ };
18239
+ if (!Array.isArray(content)) return {
18240
+ role,
18241
+ content: []
18242
+ };
18243
+ const blocks = [];
18244
+ for (const part of content) {
18245
+ if (typeof part !== "object" || part === null) continue;
18246
+ const block = part;
18247
+ if (block.type === "text" && typeof block.text === "string") {
18248
+ blocks.push({
18249
+ type: "text",
18250
+ text: block.text
18251
+ });
18252
+ continue;
18253
+ }
18254
+ if (block.type === "image_url" && typeof block.image_url === "object" && block.image_url !== null) {
18255
+ const url = block.image_url.url;
18256
+ if (typeof url !== "string" || url.trim() === "") continue;
18257
+ if (attachments === void 0) throw new ChannelRequestError("this gateway build has no attachment store, so images cannot be forwarded");
18258
+ const reading = interpretImagePart(url);
18259
+ if (!reading.ok) throw new ChannelRequestError(reading.reason);
18260
+ const ref = await attachments.saveImage({
18261
+ data: reading.data,
18262
+ mediaType: reading.mediaType
18263
+ });
18264
+ blocks.push({
18265
+ type: "image",
18266
+ attachment: ref
18267
+ });
18268
+ }
18269
+ }
18270
+ return {
18271
+ role,
18272
+ content: blocks
18273
+ };
18274
+ }
18275
+ /** The host's finish reason, in the OpenAI spelling. */
18276
+ function mapFinishReason(reason) {
18277
+ const kind = typeof reason === "object" && reason !== null ? reason.kind : reason;
18278
+ if (kind === "toolUse" || kind === "tool_use") return "tool_calls";
18279
+ if (kind === "length") return "length";
18280
+ if (kind === "aborted") return "abort";
18281
+ return "stop";
18282
+ }
18283
+ /** Fold host chunks into OpenAI delta objects. */
18284
+ function toChannelEvent(chunk) {
18285
+ switch (chunk.type) {
18286
+ case "text-delta": return {
18287
+ kind: "delta",
18288
+ delta: { content: String(chunk.text ?? "") }
18289
+ };
18290
+ case "reasoning-delta": return {
18291
+ kind: "delta",
18292
+ delta: { reasoning_content: String(chunk.text ?? "") }
18293
+ };
18294
+ case "tool-call-delta": {
18295
+ const id = String(chunk.id ?? "");
18296
+ const name = typeof chunk.name === "string" ? chunk.name : void 0;
18297
+ const fn = { arguments: String(chunk.argumentsDelta ?? "") };
18298
+ if (name !== void 0 && name !== "") fn.name = name;
18299
+ const call = {
18300
+ index: 0,
18301
+ function: fn
18302
+ };
18303
+ if (id !== "") call.id = id;
18304
+ return {
18305
+ kind: "delta",
18306
+ delta: { tool_calls: [call] }
18307
+ };
18308
+ }
18309
+ case "usage": {
18310
+ const usage = chunk.usage;
18311
+ if (usage === void 0) return void 0;
18312
+ const prompt = Number(usage.input ?? usage.inputTokens ?? 0);
18313
+ const completion = Number(usage.output ?? usage.outputTokens ?? 0);
18314
+ return {
18315
+ kind: "usage",
18316
+ usage: {
18317
+ prompt_tokens: prompt,
18318
+ completion_tokens: completion,
18319
+ total_tokens: Number(usage.total ?? usage.totalTokens ?? prompt + completion)
18320
+ }
18321
+ };
18322
+ }
18323
+ case "finish": return {
18324
+ kind: "done",
18325
+ finishReason: mapFinishReason(chunk.reason)
18326
+ };
18327
+ default: return;
18328
+ }
18329
+ }
18330
+ /** Drive one turn through the host seam. */
18331
+ async function* runTurn(input) {
18332
+ const { llm, channel, modelId, body, signal, attachments } = input;
18333
+ const incoming = Array.isArray(body.messages) ? body.messages : [];
18334
+ const converted = [];
18335
+ for (const message of incoming) converted.push(await toHostMessage(message, attachments));
18336
+ const system = converted.filter((message) => message.role === "system");
18337
+ const options = {
18338
+ provider: channel,
18339
+ model: modelId,
18340
+ messages: converted.filter((message) => message.role !== "system"),
18341
+ signal
18342
+ };
18343
+ if (system.length > 0) options.system = system[0].content;
18344
+ if (typeof body.max_tokens === "number") options.maxTokens = body.max_tokens;
18345
+ if (typeof body.temperature === "number") options.temperature = body.temperature;
18346
+ if (Array.isArray(body.tools) && body.tools.length > 0) options.tools = body.tools;
18347
+ for await (const chunk of llm.stream(options)) {
18348
+ const event = toChannelEvent(chunk);
18349
+ if (event !== void 0) yield event;
18350
+ }
18351
+ }
18352
+ //#endregion
18010
18353
  //#region src/shim.ts
18011
18354
  /**
18012
18355
  * Loopback OpenAI-compatible endpoint with multi-account failover.
@@ -18023,7 +18366,74 @@ function paidAlternativeFor(model, models) {
18023
18366
  *
18024
18367
  * @module dsh-xdpool/shim
18025
18368
  */
18369
+ /**
18370
+ * The namespace the gateway lists POOL rows under.
18371
+ *
18372
+ * The listing already speaks three prefixes — `combo:<slug>` for a chain,
18373
+ * `free:<model>` for the keyless lane, and a composite `providerId/modelId` for
18374
+ * every aggregate row — and only the pool's own models were listed bare. A
18375
+ * client reading `/v1/models` therefore could tell three lanes apart and could
18376
+ * not tell the fourth from anything else, which is the opposite of what a
18377
+ * listing is for.
18378
+ *
18379
+ * A prefix rather than a slash, because the slash IS the aggregate
18380
+ * discriminator: `parseProviderModelId` splits on the first `/`, so a prefixed
18381
+ * id spelled with a slash would be read as an aggregate row and sent to the
18382
+ * wrong lane.
18383
+ *
18384
+ * `buddy:` because that is what this lane is. `REGISTERED_CHANNELS` in
18385
+ * `channels.ts` gives `buddy` to WorkBuddy 国内版 (`copilot.tencent.com`) and
18386
+ * `workbuddy` to 国际版, and the relay is wired to `shims.cn` — the domestic
18387
+ * pool. Naming the lane after the vendor rather than after the mechanism keeps it
18388
+ * consistent with every channel row the gateway lists, and it leaves `workbuddy:`
18389
+ * free for the international pool whenever `shims.global` is exposed too.
18390
+ *
18391
+ * Bare pool ids keep being accepted. Clients configured before this existed are
18392
+ * not broken by it; they are just no longer the only way to say it.
18393
+ */
18394
+ const POOL_MODEL_PREFIX = "buddy:";
18395
+ /**
18396
+ * The lane this shim's pool rows belong to, spelled the way the channel registry
18397
+ * spells it. Exported so the card and the channel listing can agree on which
18398
+ * channel ids the pool already covers instead of each keeping its own list.
18399
+ */
18400
+ const POOL_LANE_CHANNEL_ID = "buddy";
18401
+ /**
18402
+ * Prefixes reserved for lane routing. A channel must not be listed under one of
18403
+ * these, because its rows would then be indistinguishable from the lane that
18404
+ * owns the prefix.
18405
+ */
18406
+ const RESERVED_MODEL_PREFIXES = [
18407
+ POOL_MODEL_PREFIX,
18408
+ FREE_MODEL_PREFIX,
18409
+ `${COMBO_CHANNEL}:`
18410
+ ];
18411
+ /**
18412
+ * Whether one channel id is already spoken for by a lane.
18413
+ *
18414
+ * The pool-owned ids are the interesting case rather than an accident: `buddy`
18415
+ * and `workbuddy` ARE account pools this plugin serves itself, so listing them
18416
+ * again as channels would offer the same models twice under two routes.
18417
+ */
18418
+ function channelListableAsRows(channelId, poolOwned = /* @__PURE__ */ new Set([POOL_LANE_CHANNEL_ID])) {
18419
+ if (poolOwned.has(channelId)) return false;
18420
+ return !RESERVED_MODEL_PREFIXES.includes(`${channelId}:`);
18421
+ }
18026
18422
  const REQUEST_BODY_LIMIT = 67108864;
18423
+ /**
18424
+ * Marked by the LAN relay on every request it forwards.
18425
+ *
18426
+ * Defined here rather than in `relay.ts` because the shim has to READ it, and
18427
+ * `relay.ts` already depends on this module — importing back would be a cycle.
18428
+ *
18429
+ * It is also the only safe way to scope the gateway allowlist. This one process
18430
+ * serves two very different callers: the host's own adapter (so every model
18431
+ * DSH's picker offers, and every turn the user has in chat) and, through the
18432
+ * relay, whatever device on the LAN holds the key. Gating the shim outright
18433
+ * would gate the user's own chat with it; gating on this header restricts the
18434
+ * LAN surface and leaves the host untouched.
18435
+ */
18436
+ const RELAY_HOP_HEADER = "x-xdpool-relay-hop";
18027
18437
  const LOOPBACK_HOSTS = /* @__PURE__ */ new Set([
18028
18438
  "127.0.0.1",
18029
18439
  "localhost",
@@ -18179,6 +18589,8 @@ function createWorkBuddyShim(options) {
18179
18589
  const logger = options.logger;
18180
18590
  const aggregate = options.aggregate;
18181
18591
  const extra = options.extra;
18592
+ const channels = options.channels;
18593
+ const lanModels = options.lanModels;
18182
18594
  const maxAttempts = options.maxAttempts ?? 8;
18183
18595
  const SHARED_SECRET = randomBytes(32).toString("base64url");
18184
18596
  function bearerOk(req) {
@@ -18314,6 +18726,153 @@ function createWorkBuddyShim(options) {
18314
18726
  res.end();
18315
18727
  }
18316
18728
  /**
18729
+ * Serve one login-channel turn.
18730
+ *
18731
+ * The lane yields already-translated events; this function owns the wire. It
18732
+ * writes each delta as it arrives rather than collecting first, because
18733
+ * time-to-first-token is the entire reason a client points at a gateway, and
18734
+ * buffering would make a channel feel slower than using it through the picker.
18735
+ *
18736
+ * `stream: false` is honoured here — one `chat.completion`, no SSE. The pool
18737
+ * lane forces streaming (`prepareChatBody`), so the two lanes differ on
18738
+ * purpose; making the channel lane copy that deviation would mean a client's
18739
+ * parse strategy depended on which lane a model happened to sit in.
18740
+ */
18741
+ async function serveChannelCompletion(res, raw, modelId, target, signal) {
18742
+ if (channels === void 0) return;
18743
+ let body;
18744
+ try {
18745
+ body = JSON.parse(raw);
18746
+ } catch {
18747
+ body = {};
18748
+ }
18749
+ const nonStreaming = body.stream === false;
18750
+ const created = Math.floor(Date.now() / 1e3);
18751
+ const completionId = `chatcmpl-xdpool-${randomBytes(6).toString("base64url")}`;
18752
+ let text = "";
18753
+ let reasoning = "";
18754
+ let toolCalls = [];
18755
+ let usage;
18756
+ let finish = "stop";
18757
+ let started = false;
18758
+ /** Open the SSE body on the first delta, so an early failure is a status. */
18759
+ const begin = () => {
18760
+ if (started || nonStreaming) return;
18761
+ started = true;
18762
+ res.writeHead(200, {
18763
+ "Content-Type": "text/event-stream",
18764
+ "Cache-Control": "no-cache",
18765
+ "Connection": "keep-alive",
18766
+ "X-Accel-Buffering": "no"
18767
+ });
18768
+ };
18769
+ const writeFrame = (payload) => {
18770
+ begin();
18771
+ if (started) res.write(`data: ${JSON.stringify(payload)}\n\n`);
18772
+ };
18773
+ const chunk = (delta, reason) => ({
18774
+ id: completionId,
18775
+ object: "chat.completion.chunk",
18776
+ created,
18777
+ model: target.modelId,
18778
+ choices: [{
18779
+ index: 0,
18780
+ delta,
18781
+ finish_reason: reason
18782
+ }],
18783
+ usage: null
18784
+ });
18785
+ try {
18786
+ for await (const event of channels.turn({
18787
+ channel: target.channel,
18788
+ modelId: target.modelId,
18789
+ body,
18790
+ signal
18791
+ })) {
18792
+ if (event.kind === "usage") {
18793
+ usage = event.usage;
18794
+ continue;
18795
+ }
18796
+ if (event.kind === "done") {
18797
+ finish = event.finishReason;
18798
+ continue;
18799
+ }
18800
+ const delta = event.delta;
18801
+ if (typeof delta.content === "string") text += delta.content;
18802
+ if (typeof delta.reasoning_content === "string") reasoning += delta.reasoning_content;
18803
+ if (Array.isArray(delta.tool_calls)) for (const call of delta.tool_calls) {
18804
+ const index = Number(call.index ?? toolCalls.length);
18805
+ toolCalls[index] = mergeToolCallFragment(toolCalls[index] ?? {}, call);
18806
+ }
18807
+ if (!nonStreaming) writeFrame(chunk(delta, ""));
18808
+ }
18809
+ } catch (error) {
18810
+ if (started) {
18811
+ res.write("data: [DONE]\n\n");
18812
+ res.end();
18813
+ return;
18814
+ }
18815
+ const message = error instanceof Error ? error.message : String(error);
18816
+ if (error instanceof ChannelRequestError) {
18817
+ writeOpenAIError(res, 400, "invalid_request", message);
18818
+ return;
18819
+ }
18820
+ writeOpenAIError(res, 502, "channel_upstream_error", `channel ${target.channel}: ${message}`);
18821
+ return;
18822
+ }
18823
+ if (nonStreaming) {
18824
+ writeJson$1(res, 200, {
18825
+ id: completionId,
18826
+ object: "chat.completion",
18827
+ created,
18828
+ model: target.modelId,
18829
+ choices: [{
18830
+ index: 0,
18831
+ message: {
18832
+ role: "assistant",
18833
+ content: text === "" && toolCalls.length > 0 ? null : text,
18834
+ ...reasoning === "" ? {} : { reasoning_content: reasoning },
18835
+ ...toolCalls.length === 0 ? {} : { tool_calls: toolCalls }
18836
+ },
18837
+ finish_reason: finish
18838
+ }],
18839
+ ...usage === void 0 ? {} : { usage }
18840
+ });
18841
+ return;
18842
+ }
18843
+ begin();
18844
+ res.write(`data: ${JSON.stringify(chunk({}, finish))}\n\n`);
18845
+ if (usage !== void 0) res.write(`data: ${JSON.stringify({
18846
+ ...chunk({}, finish),
18847
+ usage
18848
+ })}\n\n`);
18849
+ res.write("data: [DONE]\n\n");
18850
+ res.end();
18851
+ }
18852
+ /**
18853
+ * Fold one tool-call fragment into its slot.
18854
+ *
18855
+ * The host streams a call as an opening fragment carrying `name` then argument
18856
+ * deltas that do not, so the name must survive the merge while the argument
18857
+ * strings concatenate in arrival order.
18858
+ */
18859
+ function mergeToolCallFragment(existing, fragment) {
18860
+ const before = existing.function ?? {};
18861
+ const next = fragment.function ?? {};
18862
+ const merged = {
18863
+ ...existing,
18864
+ ...fragment,
18865
+ function: {
18866
+ ...before,
18867
+ ...next,
18868
+ arguments: `${String(before.arguments ?? "")}${String(next.arguments ?? "")}`
18869
+ }
18870
+ };
18871
+ if (typeof fragment.id === "string" && fragment.id !== "") merged.id = fragment.id;
18872
+ else if (typeof existing.id === "string") merged.id = existing.id;
18873
+ return merged;
18874
+ }
18875
+ /**
18317
18876
  * Serve one aggregate turn: forward to the provider, stream back verbatim.
18318
18877
  *
18319
18878
  * No account rotation and no compaction retry. The first is honest — the key
@@ -18398,6 +18957,74 @@ function createWorkBuddyShim(options) {
18398
18957
  pool.noteCredits(account.id, credits.total, credits.nearestExpiryMs);
18399
18958
  } catch {}
18400
18959
  }
18960
+ /**
18961
+ * Whether this request came in through the LAN relay.
18962
+ *
18963
+ * The relay stamps the header on what it forwards and strips any client-supplied
18964
+ * copy, so this is a fact about the path the request took rather than something
18965
+ * a caller can claim.
18966
+ */
18967
+ const fromLan = (req) => req.headers[RELAY_HOP_HEADER] !== void 0;
18968
+ /**
18969
+ * The allowlist LAN clients are held to, or undefined when there is none.
18970
+ *
18971
+ * `undefined` covers both "the build has no allowlist wired" and "the user has
18972
+ * never chosen one", and both mean serve everything — a gateway that answered
18973
+ * 403 to every model before the user had seen the picker would look like the
18974
+ * feature is broken rather than unset. A throwing reader is treated the same
18975
+ * way: refusing a whole gateway over an unreadable file is worse than serving
18976
+ * it, because the user cannot fix what they cannot reach.
18977
+ */
18978
+ const lanAllowlist = () => {
18979
+ if (lanModels === void 0) return void 0;
18980
+ try {
18981
+ const list = lanModels();
18982
+ return list === void 0 ? void 0 : new Set(list);
18983
+ } catch {
18984
+ return;
18985
+ }
18986
+ };
18987
+ /**
18988
+ * Everything this shim could serve, with the name a human would recognise.
18989
+ *
18990
+ * One function for two consumers on purpose. `/v1/models` needs the ids, and
18991
+ * the card's Gateway picker needs the ids AND the display name — a channel
18992
+ * model is `qfmodel` on the wire but reads `Qwen3.8-Flash · 免费` everywhere the
18993
+ * user has seen it so far. If the picker assembled its own list, the two would
18994
+ * drift, and the failure would be invisible: the card offers a model the
18995
+ * gateway then refuses, or hides one it serves.
18996
+ *
18997
+ * `source` is the value `/v1/models` reports as `owned_by`, and also what the
18998
+ * picker groups rows under.
18999
+ */
19000
+ async function roster() {
19001
+ const entries = [];
19002
+ for (const model of catalog.visible()) entries.push({
19003
+ id: `${POOL_MODEL_PREFIX}${model.id}`,
19004
+ name: model.name ?? model.id,
19005
+ source: "workbuddy"
19006
+ });
19007
+ if (aggregate !== void 0) for (const row of aggregate.catalog.current()) entries.push({
19008
+ id: row.id,
19009
+ name: row.name ?? row.id,
19010
+ source: isComboRow(row) ? COMBO_CHANNEL : row.providerId
19011
+ });
19012
+ if (extra !== void 0) for (const row of extra.free.current()) entries.push({
19013
+ id: row.modelId,
19014
+ name: row.name ?? row.modelId,
19015
+ source: FREE_CHANNEL
19016
+ });
19017
+ if (channels !== void 0) try {
19018
+ for (const row of await channels.rows()) entries.push({
19019
+ id: row.id,
19020
+ name: row.name,
19021
+ source: row.channel
19022
+ });
19023
+ } catch (error) {
19024
+ logger?.warn?.(`dsh-xdpool: channel listing failed: ${String(error)}`);
19025
+ }
19026
+ return entries;
19027
+ }
18401
19028
  async function handle(req, res) {
18402
19029
  try {
18403
19030
  if (!hostIsLoopback(req.headers.host)) {
@@ -18421,30 +19048,16 @@ function createWorkBuddyShim(options) {
18421
19048
  return;
18422
19049
  }
18423
19050
  if (req.method === "GET" && (url === "/v1/models" || url === "/v1/models/")) {
18424
- const rows = catalog.current().map((model) => ({
18425
- id: model.id,
19051
+ const rows = (await roster()).map((entry) => ({
19052
+ id: entry.id,
18426
19053
  object: "model",
18427
19054
  created: 0,
18428
- owned_by: "workbuddy"
19055
+ owned_by: entry.source
18429
19056
  }));
18430
- if (aggregate !== void 0) for (const row of aggregate.catalog.current()) {
18431
- const ownedBy = isComboRow(row) ? COMBO_CHANNEL : row.providerId;
18432
- rows.push({
18433
- id: row.id,
18434
- object: "model",
18435
- created: 0,
18436
- owned_by: ownedBy
18437
- });
18438
- }
18439
- if (extra !== void 0) for (const row of extra.free.current()) rows.push({
18440
- id: row.modelId,
18441
- object: "model",
18442
- created: 0,
18443
- owned_by: FREE_CHANNEL
18444
- });
19057
+ const allowed = fromLan(req) ? lanAllowlist() : void 0;
18445
19058
  writeJson$1(res, 200, {
18446
19059
  object: "list",
18447
- data: rows
19060
+ data: allowed === void 0 ? rows : rows.filter((row) => allowed.has(row.id))
18448
19061
  });
18449
19062
  return;
18450
19063
  }
@@ -18603,6 +19216,13 @@ function createWorkBuddyShim(options) {
18603
19216
  } catch {
18604
19217
  modelId = void 0;
18605
19218
  }
19219
+ if (fromLan(req)) {
19220
+ const allowed = lanAllowlist();
19221
+ if (allowed !== void 0 && (modelId === void 0 || !allowed.has(modelId))) {
19222
+ writeOpenAIError(res, 403, "model_not_shared", modelId === void 0 ? "no model was named, and this gateway only serves the models shared on its XD Pool card" : `${modelId} is not shared on this gateway; enable it in XD Pool → Gateway`);
19223
+ return;
19224
+ }
19225
+ }
18606
19226
  const comboRow = aggregate === void 0 || modelId === void 0 ? void 0 : aggregate.catalog.findCombo(modelId);
18607
19227
  if (comboRow !== void 0) {
18608
19228
  await serveComboCompletion(res, raw, comboRow, controller.signal);
@@ -18627,8 +19247,28 @@ function createWorkBuddyShim(options) {
18627
19247
  writeOpenAIError(res, 503, `${lane}_unavailable`, lane === "free" ? `${modelId} is not available right now; the free lane's model listing is refreshed on the XD Pool card` : `${modelId} is not available right now; sign in to the co-paid lane on the XD Pool card`);
18628
19248
  return;
18629
19249
  }
18630
- const prepared = client.prepareChatBodyFor(raw, region ?? "cn");
18631
- const conversationKey = conversationKeyOf(raw);
19250
+ if (channels !== void 0 && modelId !== void 0) {
19251
+ const target = channels.resolve(modelId);
19252
+ if (target !== void 0) {
19253
+ await serveChannelCompletion(res, raw, modelId, target, controller.signal);
19254
+ return;
19255
+ }
19256
+ }
19257
+ let poolRaw = raw;
19258
+ if (modelId !== void 0 && modelId.startsWith("buddy:")) {
19259
+ const bare = modelId.slice(6);
19260
+ if (bare === "" || bare.startsWith("free:") || isComboId(bare) || bare.includes("/")) {
19261
+ writeOpenAIError(res, 404, "model_not_found", `${modelId} is not a pool model`);
19262
+ return;
19263
+ }
19264
+ modelId = bare;
19265
+ poolRaw = JSON.stringify({
19266
+ ...JSON.parse(raw),
19267
+ model: bare
19268
+ });
19269
+ }
19270
+ const prepared = client.prepareChatBodyFor(poolRaw, region ?? "cn");
19271
+ const conversationKey = conversationKeyOf(poolRaw);
18632
19272
  const tried = [];
18633
19273
  let last;
18634
19274
  const turnId = randomBytes(8).toString("hex");
@@ -18732,6 +19372,7 @@ function createWorkBuddyShim(options) {
18732
19372
  return {
18733
19373
  ready,
18734
19374
  baseUrl,
19375
+ roster,
18735
19376
  token: () => SHARED_SECRET,
18736
19377
  close: () => new Promise((resolve, reject) => {
18737
19378
  server.close(() => resolve());
@@ -19026,12 +19667,6 @@ async function recoverFromContextOverrun(options) {
19026
19667
  * @module dsh-xdpool/relay
19027
19668
  */
19028
19669
  /**
19029
- * Marked on every forwarded request so a relay pointed at its own address
19030
- * refuses instead of looping. A client has no reason to send it, and refusing
19031
- * is cheaper than debugging a request storm you caused by accident.
19032
- */
19033
- const RELAY_HOP_HEADER = "x-xdpool-relay-hop";
19034
- /**
19035
19670
  * The only paths this relay forwards.
19036
19671
  *
19037
19672
  * Deliberately exactly the routes the shim serves. Anything else is refused
@@ -19292,6 +19927,37 @@ function createPoolRelay(options) {
19292
19927
  function newRelayKey() {
19293
19928
  return randomBytes(32).toString("base64url");
19294
19929
  }
19930
+ /**
19931
+ * Compose the relay document the Gateway card renders.
19932
+ *
19933
+ * Extracted, and awaited HERE, because the bug this shape exists to prevent is
19934
+ * invisible at the call site: `status()` is async, and
19935
+ * `{ ...relay.status(), candidates }` spreads a PENDING PROMISE, which yields no
19936
+ * own properties at all. The document then carries the new fields and silently
19937
+ * drops `enabled`, `listening`, `host`, `port`, `key` and `error` — a card with no
19938
+ * switch, no key and no endpoint, served over HTTP 200 with nothing to indicate it
19939
+ * broke. This happened when the picker was added; the assertions below are the
19940
+ * reason it cannot happen quietly again.
19941
+ *
19942
+ * Every input is a function so a test can hand it a rejecting roster and still
19943
+ * require the rest of the document to arrive.
19944
+ */
19945
+ async function relayDocumentOf(input) {
19946
+ const status = await input.status();
19947
+ let candidates;
19948
+ try {
19949
+ candidates = await input.roster();
19950
+ } catch (error) {
19951
+ input.warn?.(error);
19952
+ candidates = [];
19953
+ }
19954
+ const shared = input.shared();
19955
+ return {
19956
+ ...status,
19957
+ candidates,
19958
+ ...shared === void 0 ? {} : { shared }
19959
+ };
19960
+ }
19295
19961
  //#endregion
19296
19962
  //#region src/status.ts
19297
19963
  /** Build the status document. Never throws. */
@@ -20034,7 +20700,8 @@ function createCore(logger) {
20034
20700
  const pool = new WorkBuddyAccountPool({
20035
20701
  ...logger === void 0 ? {} : { logger },
20036
20702
  client,
20037
- persistent: true
20703
+ persistent: true,
20704
+ affinityPath: join(pluginDataDir(), "sticky-affinity.json")
20038
20705
  });
20039
20706
  return {
20040
20707
  pool,
@@ -20271,6 +20938,35 @@ function apply(ctx, config = {}) {
20271
20938
  aggregateCatalog.refresh();
20272
20939
  const freeCatalog = new FreeCatalog();
20273
20940
  const freeLane = createFreeLaneSource(freeCatalog, ctx.logger, usageRecorder);
20941
+ /**
20942
+ * The login channels, for the LAN gateway.
20943
+ *
20944
+ * Reached through `ctx.llm` rather than a socket: the pack registers each
20945
+ * channel as a host adapter, so the host is the only thing that knows the
20946
+ * credential, the endpoint and the wire dialect.
20947
+ *
20948
+ * The two pool-owned ids are left out on purpose. `buddy` and `workbuddy` ARE
20949
+ * account pools this plugin already serves, so listing them again as channels
20950
+ * would offer the same models twice under two routes — and `buddy:` is already
20951
+ * the prefix the pool lane's own rows carry.
20952
+ */
20953
+ const channelLane = createChannelLane({
20954
+ llm: ctx.llm,
20955
+ providers: () => REGISTERED_CHANNELS.map((channel) => channel.id).filter((id) => channelListableAsRows(id, POOL_OWNED_CHANNELS)),
20956
+ toId: (channel, modelId) => `${channel}:${modelId}`,
20957
+ attachments: () => ctx.get("attachments"),
20958
+ logger: ctx.logger
20959
+ });
20960
+ /**
20961
+ * The gateway allowlist, re-read per request.
20962
+ *
20963
+ * `undefined` and `[]` are deliberately NOT collapsed: the first means the
20964
+ * user has never opened the picker, and the second means they unticked
20965
+ * everything. Reading the stored field directly rather than through
20966
+ * `relaySettingsOf` is what preserves that, since the relay's own settings
20967
+ * object has no reason to carry a list it never consults.
20968
+ */
20969
+ const lanModelsOf = () => readAggregateSettingsSync().relay?.models;
20274
20970
  const shims = {
20275
20971
  cn: createWorkBuddyShim({
20276
20972
  pool: core.pool,
@@ -20283,7 +20979,9 @@ function apply(ctx, config = {}) {
20283
20979
  catalog: aggregateCatalog,
20284
20980
  readStore: () => readAggregateSettingsSync()
20285
20981
  },
20286
- extra: { free: freeLane }
20982
+ extra: { free: freeLane },
20983
+ channels: channelLane,
20984
+ lanModels: lanModelsOf
20287
20985
  }),
20288
20986
  global: createWorkBuddyShim({
20289
20987
  pool: core.pool,
@@ -20330,6 +21028,19 @@ function apply(ctx, config = {}) {
20330
21028
  relay.ready.catch((error) => {
20331
21029
  ctx.logger.warn(`xdpool relay: startup failed — ${String(error)}`);
20332
21030
  });
21031
+ /**
21032
+ * The relay document the Gateway card reads.
21033
+ *
21034
+ * One loader for BOTH routes rather than a per-route assembly: the card takes
21035
+ * the write response as the new state, so a save that answered with only the
21036
+ * listener status dropped the picker's roster off the card until the next poll.
21037
+ */
21038
+ const relayDocument = () => relayDocumentOf({
21039
+ status: () => relay.status(),
21040
+ shared: () => readAggregateSettingsSync().relay?.models,
21041
+ roster: () => shim.roster(),
21042
+ warn: (error) => ctx.logger.warn("dsh-xdpool: gateway roster failed", error)
21043
+ });
20333
21044
  ctx.inject(["webServer"], (webCtx) => registerPoolStatusRoute(webCtx, {
20334
21045
  pool: core.pool,
20335
21046
  catalogs: core.catalogs,
@@ -20530,7 +21241,15 @@ function apply(ctx, config = {}) {
20530
21241
  },
20531
21242
  billionDocument: readBillionStatus,
20532
21243
  /**
20533
- * Relay state and patches.
21244
+ * Relay state plus the gateway's model picker.
21245
+ *
21246
+ * The candidates come from the shim's own roster rather than a parallel
21247
+ * assembly here, so the card cannot offer a model the gateway would then
21248
+ * refuse — the exact drift the picker exists to prevent.
21249
+ */
21250
+ relayDocument,
21251
+ /**
21252
+ * Apply one patch from the card and answer with the fresh document.
20534
21253
  *
20535
21254
  * The write path mints a key when the card asks for a rotation and the user
20536
21255
  * has never had one: an enabled relay with no key would have to either refuse
@@ -20538,7 +21257,6 @@ function apply(ctx, config = {}) {
20538
21257
  * one keeps "enabled" honest, and the card shows it so it can be pasted into
20539
21258
  * the other device.
20540
21259
  */
20541
- relayDocument: async () => relay.status(),
20542
21260
  saveRelay: async (patch) => {
20543
21261
  const before = readAggregateSettingsSync().relay ?? {};
20544
21262
  const key = patch.key === "" ? newRelayKey() : patch.key;
@@ -20546,10 +21264,11 @@ function apply(ctx, config = {}) {
20546
21264
  ...patch.enabled === void 0 ? {} : { enabled: patch.enabled },
20547
21265
  ...patch.host === void 0 ? {} : { host: patch.host },
20548
21266
  ...patch.port === void 0 ? {} : { port: patch.port },
20549
- key: key ?? before.key ?? newRelayKey()
21267
+ key: key ?? before.key ?? newRelayKey(),
21268
+ ...patch.models === void 0 ? {} : { models: patch.models }
20550
21269
  });
20551
21270
  await relay.sync();
20552
- return relay.status();
21271
+ return relayDocument();
20553
21272
  }
20554
21273
  }));
20555
21274
  api = {