dsh-xdpool 2.4.2 → 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";
@@ -3539,6 +3539,9 @@ var WorkBuddyAccountPool = class {
3539
3539
  * the LRU order: re-binding deletes then re-inserts.
3540
3540
  */
3541
3541
  affinity = /* @__PURE__ */ new Map();
3542
+ /** Where the bindings above are persisted; undefined keeps them in memory. */
3543
+ affinityPath;
3544
+ affinitySaveTimer;
3542
3545
  lastScanAtMs = 0;
3543
3546
  /**
3544
3547
  * The account id the user pinned, or undefined for none.
@@ -3648,6 +3651,8 @@ var WorkBuddyAccountPool = class {
3648
3651
  this.distribution = options.distribution ?? "priority";
3649
3652
  this.persistent = options.persistent ?? false;
3650
3653
  this.storePath = accountStorePath();
3654
+ this.affinityPath = options.affinityPath;
3655
+ if (this.affinityPath !== void 0) this.loadAffinity();
3651
3656
  }
3652
3657
  /**
3653
3658
  * Re-apply configuration that only affects discovery and cooldown policy,
@@ -3923,6 +3928,49 @@ var WorkBuddyAccountPool = class {
3923
3928
  if (oldest === void 0) break;
3924
3929
  this.affinity.delete(oldest);
3925
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?.();
3926
3974
  }
3927
3975
  /**
3928
3976
  * `sticky`: the account this conversation already used, when it can still
@@ -10628,9 +10676,20 @@ function normalizeRelay(raw) {
10628
10676
  if (typeof value.port === "number" && Number.isInteger(value.port)) relay.port = value.port;
10629
10677
  const key = text$5(value.key);
10630
10678
  if (key !== "") relay.key = key;
10679
+ if (Array.isArray(value.models)) relay.models = normalizeRelayModels(value.models);
10631
10680
  return Object.keys(relay).length === 0 ? void 0 : relay;
10632
10681
  }
10633
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
+ /**
10634
10693
  * Write the store atomically: a sibling `.tmp` first, then a rename.
10635
10694
  *
10636
10695
  * Same discipline as the ignore list. A process killed mid-write must leave
@@ -10763,6 +10822,7 @@ async function updateRelaySetting(patch) {
10763
10822
  if (key === "") delete after.key;
10764
10823
  else after.key = key;
10765
10824
  }
10825
+ if (patch.models !== void 0) after.models = normalizeRelayModels(patch.models);
10766
10826
  const next = { ...current };
10767
10827
  if (Object.keys(after).length === 0) delete next.relay;
10768
10828
  else next.relay = after;
@@ -18037,6 +18097,259 @@ function paidAlternativeFor(model, models) {
18037
18097
  return paidSiblingOf(model, models);
18038
18098
  }
18039
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
18040
18353
  //#region src/shim.ts
18041
18354
  /**
18042
18355
  * Loopback OpenAI-compatible endpoint with multi-account failover.
@@ -18053,7 +18366,74 @@ function paidAlternativeFor(model, models) {
18053
18366
  *
18054
18367
  * @module dsh-xdpool/shim
18055
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
+ }
18056
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";
18057
18437
  const LOOPBACK_HOSTS = /* @__PURE__ */ new Set([
18058
18438
  "127.0.0.1",
18059
18439
  "localhost",
@@ -18209,6 +18589,8 @@ function createWorkBuddyShim(options) {
18209
18589
  const logger = options.logger;
18210
18590
  const aggregate = options.aggregate;
18211
18591
  const extra = options.extra;
18592
+ const channels = options.channels;
18593
+ const lanModels = options.lanModels;
18212
18594
  const maxAttempts = options.maxAttempts ?? 8;
18213
18595
  const SHARED_SECRET = randomBytes(32).toString("base64url");
18214
18596
  function bearerOk(req) {
@@ -18344,6 +18726,153 @@ function createWorkBuddyShim(options) {
18344
18726
  res.end();
18345
18727
  }
18346
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
+ /**
18347
18876
  * Serve one aggregate turn: forward to the provider, stream back verbatim.
18348
18877
  *
18349
18878
  * No account rotation and no compaction retry. The first is honest — the key
@@ -18428,6 +18957,74 @@ function createWorkBuddyShim(options) {
18428
18957
  pool.noteCredits(account.id, credits.total, credits.nearestExpiryMs);
18429
18958
  } catch {}
18430
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
+ }
18431
19028
  async function handle(req, res) {
18432
19029
  try {
18433
19030
  if (!hostIsLoopback(req.headers.host)) {
@@ -18451,30 +19048,16 @@ function createWorkBuddyShim(options) {
18451
19048
  return;
18452
19049
  }
18453
19050
  if (req.method === "GET" && (url === "/v1/models" || url === "/v1/models/")) {
18454
- const rows = catalog.current().map((model) => ({
18455
- id: model.id,
19051
+ const rows = (await roster()).map((entry) => ({
19052
+ id: entry.id,
18456
19053
  object: "model",
18457
19054
  created: 0,
18458
- owned_by: "workbuddy"
19055
+ owned_by: entry.source
18459
19056
  }));
18460
- if (aggregate !== void 0) for (const row of aggregate.catalog.current()) {
18461
- const ownedBy = isComboRow(row) ? COMBO_CHANNEL : row.providerId;
18462
- rows.push({
18463
- id: row.id,
18464
- object: "model",
18465
- created: 0,
18466
- owned_by: ownedBy
18467
- });
18468
- }
18469
- if (extra !== void 0) for (const row of extra.free.current()) rows.push({
18470
- id: row.modelId,
18471
- object: "model",
18472
- created: 0,
18473
- owned_by: FREE_CHANNEL
18474
- });
19057
+ const allowed = fromLan(req) ? lanAllowlist() : void 0;
18475
19058
  writeJson$1(res, 200, {
18476
19059
  object: "list",
18477
- data: rows
19060
+ data: allowed === void 0 ? rows : rows.filter((row) => allowed.has(row.id))
18478
19061
  });
18479
19062
  return;
18480
19063
  }
@@ -18633,6 +19216,13 @@ function createWorkBuddyShim(options) {
18633
19216
  } catch {
18634
19217
  modelId = void 0;
18635
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
+ }
18636
19226
  const comboRow = aggregate === void 0 || modelId === void 0 ? void 0 : aggregate.catalog.findCombo(modelId);
18637
19227
  if (comboRow !== void 0) {
18638
19228
  await serveComboCompletion(res, raw, comboRow, controller.signal);
@@ -18657,8 +19247,28 @@ function createWorkBuddyShim(options) {
18657
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`);
18658
19248
  return;
18659
19249
  }
18660
- const prepared = client.prepareChatBodyFor(raw, region ?? "cn");
18661
- 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);
18662
19272
  const tried = [];
18663
19273
  let last;
18664
19274
  const turnId = randomBytes(8).toString("hex");
@@ -18762,6 +19372,7 @@ function createWorkBuddyShim(options) {
18762
19372
  return {
18763
19373
  ready,
18764
19374
  baseUrl,
19375
+ roster,
18765
19376
  token: () => SHARED_SECRET,
18766
19377
  close: () => new Promise((resolve, reject) => {
18767
19378
  server.close(() => resolve());
@@ -19056,12 +19667,6 @@ async function recoverFromContextOverrun(options) {
19056
19667
  * @module dsh-xdpool/relay
19057
19668
  */
19058
19669
  /**
19059
- * Marked on every forwarded request so a relay pointed at its own address
19060
- * refuses instead of looping. A client has no reason to send it, and refusing
19061
- * is cheaper than debugging a request storm you caused by accident.
19062
- */
19063
- const RELAY_HOP_HEADER = "x-xdpool-relay-hop";
19064
- /**
19065
19670
  * The only paths this relay forwards.
19066
19671
  *
19067
19672
  * Deliberately exactly the routes the shim serves. Anything else is refused
@@ -19322,6 +19927,37 @@ function createPoolRelay(options) {
19322
19927
  function newRelayKey() {
19323
19928
  return randomBytes(32).toString("base64url");
19324
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
+ }
19325
19961
  //#endregion
19326
19962
  //#region src/status.ts
19327
19963
  /** Build the status document. Never throws. */
@@ -20064,7 +20700,8 @@ function createCore(logger) {
20064
20700
  const pool = new WorkBuddyAccountPool({
20065
20701
  ...logger === void 0 ? {} : { logger },
20066
20702
  client,
20067
- persistent: true
20703
+ persistent: true,
20704
+ affinityPath: join(pluginDataDir(), "sticky-affinity.json")
20068
20705
  });
20069
20706
  return {
20070
20707
  pool,
@@ -20301,6 +20938,35 @@ function apply(ctx, config = {}) {
20301
20938
  aggregateCatalog.refresh();
20302
20939
  const freeCatalog = new FreeCatalog();
20303
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;
20304
20970
  const shims = {
20305
20971
  cn: createWorkBuddyShim({
20306
20972
  pool: core.pool,
@@ -20313,7 +20979,9 @@ function apply(ctx, config = {}) {
20313
20979
  catalog: aggregateCatalog,
20314
20980
  readStore: () => readAggregateSettingsSync()
20315
20981
  },
20316
- extra: { free: freeLane }
20982
+ extra: { free: freeLane },
20983
+ channels: channelLane,
20984
+ lanModels: lanModelsOf
20317
20985
  }),
20318
20986
  global: createWorkBuddyShim({
20319
20987
  pool: core.pool,
@@ -20360,6 +21028,19 @@ function apply(ctx, config = {}) {
20360
21028
  relay.ready.catch((error) => {
20361
21029
  ctx.logger.warn(`xdpool relay: startup failed — ${String(error)}`);
20362
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
+ });
20363
21044
  ctx.inject(["webServer"], (webCtx) => registerPoolStatusRoute(webCtx, {
20364
21045
  pool: core.pool,
20365
21046
  catalogs: core.catalogs,
@@ -20560,7 +21241,15 @@ function apply(ctx, config = {}) {
20560
21241
  },
20561
21242
  billionDocument: readBillionStatus,
20562
21243
  /**
20563
- * 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.
20564
21253
  *
20565
21254
  * The write path mints a key when the card asks for a rotation and the user
20566
21255
  * has never had one: an enabled relay with no key would have to either refuse
@@ -20568,7 +21257,6 @@ function apply(ctx, config = {}) {
20568
21257
  * one keeps "enabled" honest, and the card shows it so it can be pasted into
20569
21258
  * the other device.
20570
21259
  */
20571
- relayDocument: async () => relay.status(),
20572
21260
  saveRelay: async (patch) => {
20573
21261
  const before = readAggregateSettingsSync().relay ?? {};
20574
21262
  const key = patch.key === "" ? newRelayKey() : patch.key;
@@ -20576,10 +21264,11 @@ function apply(ctx, config = {}) {
20576
21264
  ...patch.enabled === void 0 ? {} : { enabled: patch.enabled },
20577
21265
  ...patch.host === void 0 ? {} : { host: patch.host },
20578
21266
  ...patch.port === void 0 ? {} : { port: patch.port },
20579
- key: key ?? before.key ?? newRelayKey()
21267
+ key: key ?? before.key ?? newRelayKey(),
21268
+ ...patch.models === void 0 ? {} : { models: patch.models }
20580
21269
  });
20581
21270
  await relay.sync();
20582
- return relay.status();
21271
+ return relayDocument();
20583
21272
  }
20584
21273
  }));
20585
21274
  api = {