dsh-xdpool 2.4.2 → 2.4.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/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";
@@ -3260,24 +3260,49 @@ function isLiveAuthFile(path) {
3260
3260
  return basename(path) === WORKBUDDY_LIVE_FILENAME;
3261
3261
  }
3262
3262
  /**
3263
+ * True for a credential this plugin minted from the channel-pack sign-in flow.
3264
+ *
3265
+ * `packCredentialToWorkBuddy` stamps `channel-pack:<provider>:<ref>` here, so the
3266
+ * prefix is the only thing distinguishing a scanned credential from a file the
3267
+ * desktop app wrote. Nothing about it is a path on disk — there is no file to
3268
+ * stat and no mtime to read.
3269
+ */
3270
+ function isChannelPackSource(path) {
3271
+ return path.startsWith("channel-pack:");
3272
+ }
3273
+ /**
3263
3274
  * Which of two credentials for the same account the pool should keep.
3264
3275
  *
3265
3276
  * Ordering, highest first:
3266
3277
  *
3267
3278
  * 1. the live file the desktop app is signed in with;
3268
- * 2. the credential the upstream issued most recently (`lastRefreshAtMs`);
3269
- * 3. the longer stored expiry, as a fallback for documents that carry no issue
3279
+ * 2. a credential minted by our own channel-pack sign-in;
3280
+ * 3. the credential the upstream issued most recently (`lastRefreshAtMs`);
3281
+ * 4. the longer stored expiry, as a fallback for documents that carry no issue
3270
3282
  * time (the plugin's own refreshed copy, older builds).
3271
3283
  *
3272
3284
  * The stored expiry alone is NOT a freshness signal: the upstream does not
3273
3285
  * rewrite it when it revokes a token, so a long-dead backup can claim to expire
3274
3286
  * later than the token that actually works. Selecting on it made every upstream
3275
3287
  * call return 401 while a perfectly good credential sat in the same directory.
3288
+ *
3289
+ * Rank 2 exists because a scanned credential loses both of the lower tests by
3290
+ * construction: it is not the live filename, and the sign-in flow records no
3291
+ * `lastRefreshAtMs`. Ranking it below a leftover snapshot meant a user who had
3292
+ * just scanned a fresh sign-in still saw "sign-in was rejected", because the
3293
+ * abandoned file kept winning the comparison and the dead mark was never
3294
+ * cleared. The live file stays on top: that is the session the running desktop
3295
+ * app is genuinely using right now, and it is newer than anything we mint.
3276
3296
  */
3277
3297
  function compareFreshness(a, b) {
3278
- const aLive = isLiveAuthFile(a.sourcePath) ? 1 : 0;
3279
- const bLive = isLiveAuthFile(b.sourcePath) ? 1 : 0;
3280
- if (aLive !== bLive) return bLive - aLive;
3298
+ const rankOf = (credential) => {
3299
+ if (isLiveAuthFile(credential.sourcePath)) return 2;
3300
+ if (isChannelPackSource(credential.sourcePath)) return 1;
3301
+ return 0;
3302
+ };
3303
+ const aRank = rankOf(a);
3304
+ const bRank = rankOf(b);
3305
+ if (aRank !== bRank) return bRank - aRank;
3281
3306
  const aIssued = a.lastRefreshAtMs ?? 0;
3282
3307
  const bIssued = b.lastRefreshAtMs ?? 0;
3283
3308
  if (aIssued !== bIssued) return bIssued - aIssued;
@@ -3539,6 +3564,9 @@ var WorkBuddyAccountPool = class {
3539
3564
  * the LRU order: re-binding deletes then re-inserts.
3540
3565
  */
3541
3566
  affinity = /* @__PURE__ */ new Map();
3567
+ /** Where the bindings above are persisted; undefined keeps them in memory. */
3568
+ affinityPath;
3569
+ affinitySaveTimer;
3542
3570
  lastScanAtMs = 0;
3543
3571
  /**
3544
3572
  * The account id the user pinned, or undefined for none.
@@ -3648,6 +3676,8 @@ var WorkBuddyAccountPool = class {
3648
3676
  this.distribution = options.distribution ?? "priority";
3649
3677
  this.persistent = options.persistent ?? false;
3650
3678
  this.storePath = accountStorePath();
3679
+ this.affinityPath = options.affinityPath;
3680
+ if (this.affinityPath !== void 0) this.loadAffinity();
3651
3681
  }
3652
3682
  /**
3653
3683
  * Re-apply configuration that only affects discovery and cooldown policy,
@@ -3923,6 +3953,49 @@ var WorkBuddyAccountPool = class {
3923
3953
  if (oldest === void 0) break;
3924
3954
  this.affinity.delete(oldest);
3925
3955
  }
3956
+ this.scheduleAffinitySave();
3957
+ }
3958
+ /**
3959
+ * Restore bindings written by a previous process.
3960
+ *
3961
+ * Tolerant like the other state files: a malformed document or one bad entry
3962
+ * must not take the pool down, and an account id that no longer exists is
3963
+ * simply never matched (the next acquire re-picks and rebinds).
3964
+ */
3965
+ loadAffinity() {
3966
+ if (this.affinityPath === void 0) return;
3967
+ try {
3968
+ const bindings = JSON.parse(readFileSync(this.affinityPath, "utf8")).bindings;
3969
+ if (typeof bindings !== "object" || bindings === null || Array.isArray(bindings)) return;
3970
+ for (const [key, value] of Object.entries(bindings)) {
3971
+ if (key === "" || typeof value !== "string" || value === "") continue;
3972
+ this.affinity.set(key, value);
3973
+ }
3974
+ while (this.affinity.size > STICKY_AFFINITY_LIMIT) {
3975
+ const oldest = this.affinity.keys().next().value;
3976
+ if (oldest === void 0) break;
3977
+ this.affinity.delete(oldest);
3978
+ }
3979
+ } catch {}
3980
+ }
3981
+ /** Write the bindings back, coalescing a burst into one atomic rename. */
3982
+ scheduleAffinitySave() {
3983
+ if (this.affinityPath === void 0 || this.affinitySaveTimer !== void 0) return;
3984
+ this.affinitySaveTimer = setTimeout(() => {
3985
+ this.affinitySaveTimer = void 0;
3986
+ const path = this.affinityPath;
3987
+ if (path === void 0) return;
3988
+ try {
3989
+ mkdirSync(dirname(path), { recursive: true });
3990
+ writeFileSync(`${path}.tmp`, `${JSON.stringify({
3991
+ version: 1,
3992
+ bindings: Object.fromEntries(this.affinity)
3993
+ }, null, 2)}
3994
+ `, "utf8");
3995
+ renameSync(`${path}.tmp`, path);
3996
+ } catch {}
3997
+ }, 500);
3998
+ this.affinitySaveTimer.unref?.();
3926
3999
  }
3927
4000
  /**
3928
4001
  * `sticky`: the account this conversation already used, when it can still
@@ -10628,9 +10701,20 @@ function normalizeRelay(raw) {
10628
10701
  if (typeof value.port === "number" && Number.isInteger(value.port)) relay.port = value.port;
10629
10702
  const key = text$5(value.key);
10630
10703
  if (key !== "") relay.key = key;
10704
+ if (Array.isArray(value.models)) relay.models = normalizeRelayModels(value.models);
10631
10705
  return Object.keys(relay).length === 0 ? void 0 : relay;
10632
10706
  }
10633
10707
  /**
10708
+ * A shared-model list, cleaned: trimmed, de-duplicated, non-strings dropped.
10709
+ *
10710
+ * One helper for both doors into the field — reading the file and merging a
10711
+ * patch — so a list saved from the card is stored exactly as a list loaded from
10712
+ * disk would be, and the gateway cannot see two spellings of one selection.
10713
+ */
10714
+ function normalizeRelayModels(raw) {
10715
+ return [...new Set(raw.map((entry) => text$5(entry)).filter((entry) => entry !== ""))];
10716
+ }
10717
+ /**
10634
10718
  * Write the store atomically: a sibling `.tmp` first, then a rename.
10635
10719
  *
10636
10720
  * Same discipline as the ignore list. A process killed mid-write must leave
@@ -10763,6 +10847,7 @@ async function updateRelaySetting(patch) {
10763
10847
  if (key === "") delete after.key;
10764
10848
  else after.key = key;
10765
10849
  }
10850
+ if (patch.models !== void 0) after.models = normalizeRelayModels(patch.models);
10766
10851
  const next = { ...current };
10767
10852
  if (Object.keys(after).length === 0) delete next.relay;
10768
10853
  else next.relay = after;
@@ -18037,6 +18122,259 @@ function paidAlternativeFor(model, models) {
18037
18122
  return paidSiblingOf(model, models);
18038
18123
  }
18039
18124
  //#endregion
18125
+ //#region src/channel-lane.ts
18126
+ /**
18127
+ * A request this lane will not serve as written, and that is the caller's to fix.
18128
+ *
18129
+ * Kept as its own type so the route can answer 400 for it: a refused image and a
18130
+ * dead upstream must not read the same to a client, and neither must read like a
18131
+ * success that quietly dropped the picture.
18132
+ */
18133
+ var ChannelRequestError = class extends Error {};
18134
+ /** The image types the host's attachment store will hold. */
18135
+ const IMAGE_MEDIA_TYPES = /* @__PURE__ */ new Set([
18136
+ "image/png",
18137
+ "image/jpeg",
18138
+ "image/webp",
18139
+ "image/gif"
18140
+ ]);
18141
+ /**
18142
+ * Read one OpenAI `image_url` value into the store's input form.
18143
+ *
18144
+ * Every rejection names itself, because the alternative — dropping the part and
18145
+ * continuing — is what let a model answer about an image it never received.
18146
+ */
18147
+ function interpretImagePart(url) {
18148
+ const target = url.trim();
18149
+ if (/^https?:\/\//iu.test(target)) return {
18150
+ ok: false,
18151
+ reason: "remote image URLs are not fetched by this gateway; send a base64 data URL"
18152
+ };
18153
+ if (!target.startsWith("data:")) return {
18154
+ ok: false,
18155
+ reason: "only base64 data URLs can be forwarded as images"
18156
+ };
18157
+ const comma = target.indexOf(",");
18158
+ if (comma < 0) return {
18159
+ ok: false,
18160
+ reason: "image data URL has no payload"
18161
+ };
18162
+ const meta = target.slice(5, comma);
18163
+ if (!meta.toLowerCase().includes(";base64")) return {
18164
+ ok: false,
18165
+ reason: "image data URL must be base64-encoded"
18166
+ };
18167
+ const declared = meta.split(";")[0]?.toLowerCase() ?? "";
18168
+ const mediaType = declared === "image/jpg" ? "image/jpeg" : declared;
18169
+ if (!mediaType.startsWith("image/")) return {
18170
+ ok: false,
18171
+ reason: `image part carries a ${mediaType || "unnamed"} payload, not an image`
18172
+ };
18173
+ if (!IMAGE_MEDIA_TYPES.has(mediaType)) return {
18174
+ ok: false,
18175
+ reason: `unsupported image type ${mediaType}; the host stores png, jpeg, webp or gif`
18176
+ };
18177
+ const bytes = Buffer.from(target.slice(comma + 1), "base64");
18178
+ if (bytes.length === 0) return {
18179
+ ok: false,
18180
+ reason: "image payload is empty"
18181
+ };
18182
+ return {
18183
+ ok: true,
18184
+ data: new Uint8Array(bytes),
18185
+ mediaType
18186
+ };
18187
+ }
18188
+ /** How long a listing is reused before it is asked again. */
18189
+ const LISTING_TTL_MS = 6e4;
18190
+ /**
18191
+ * Build the channel lane.
18192
+ *
18193
+ * `providers` is supplied rather than derived here: the caller knows which
18194
+ * channels are mounted and which are pool-owned, and importing `channels.ts`
18195
+ * would pull the pack mount into this module.
18196
+ */
18197
+ function createChannelLane(options) {
18198
+ const { llm, providers, toId, attachments, logger } = options;
18199
+ const cache = /* @__PURE__ */ new Map();
18200
+ const inflight = /* @__PURE__ */ new Map();
18201
+ const fetchRows = (channel) => {
18202
+ const hit = cache.get(channel);
18203
+ if (hit !== void 0 && Date.now() - hit.at < LISTING_TTL_MS) return Promise.resolve(hit.rows);
18204
+ const running = inflight.get(channel);
18205
+ if (running !== void 0) return running;
18206
+ const tracked = llm.listModels(channel).then((models) => models.map((model) => ({
18207
+ id: toId(channel, model.id),
18208
+ channel,
18209
+ modelId: model.id,
18210
+ name: model.name ?? model.id
18211
+ }))).catch((error) => {
18212
+ logger?.warn?.(`dsh-xdpool: channel ${channel} listed no models: ${String(error)}`);
18213
+ return [];
18214
+ }).then((rows) => {
18215
+ cache.set(channel, {
18216
+ at: Date.now(),
18217
+ rows
18218
+ });
18219
+ inflight.delete(channel);
18220
+ return rows;
18221
+ });
18222
+ inflight.set(channel, tracked);
18223
+ return tracked;
18224
+ };
18225
+ return {
18226
+ rows: async () => (await Promise.all(providers().map(fetchRows))).flat(),
18227
+ resolve: (id) => {
18228
+ const separator = id.indexOf(":");
18229
+ if (separator <= 0 || separator === id.length - 1) return void 0;
18230
+ const channel = id.slice(0, separator);
18231
+ if (!providers().includes(channel)) return void 0;
18232
+ return {
18233
+ channel,
18234
+ modelId: id.slice(separator + 1)
18235
+ };
18236
+ },
18237
+ turn: (input) => runTurn({
18238
+ llm,
18239
+ attachments: attachments?.(),
18240
+ ...input
18241
+ })
18242
+ };
18243
+ }
18244
+ /**
18245
+ * Forward one OpenAI message as the host's typed content blocks.
18246
+ *
18247
+ * Text and image parts carry over. Anything else is dropped rather than
18248
+ * guessed at: inventing a block shape the provider was never told about fails
18249
+ * upstream with an error that names neither the message nor the part.
18250
+ *
18251
+ * An image that cannot be carried is a refusal, not a drop — see the module
18252
+ * note on why `{ type: 'image', url }` was the wrong answer.
18253
+ */
18254
+ async function toHostMessage(message, attachments) {
18255
+ const role = typeof message.role === "string" ? message.role : "user";
18256
+ const content = message.content;
18257
+ if (typeof content === "string") return {
18258
+ role,
18259
+ content: content === "" ? [] : [{
18260
+ type: "text",
18261
+ text: content
18262
+ }]
18263
+ };
18264
+ if (!Array.isArray(content)) return {
18265
+ role,
18266
+ content: []
18267
+ };
18268
+ const blocks = [];
18269
+ for (const part of content) {
18270
+ if (typeof part !== "object" || part === null) continue;
18271
+ const block = part;
18272
+ if (block.type === "text" && typeof block.text === "string") {
18273
+ blocks.push({
18274
+ type: "text",
18275
+ text: block.text
18276
+ });
18277
+ continue;
18278
+ }
18279
+ if (block.type === "image_url" && typeof block.image_url === "object" && block.image_url !== null) {
18280
+ const url = block.image_url.url;
18281
+ if (typeof url !== "string" || url.trim() === "") continue;
18282
+ if (attachments === void 0) throw new ChannelRequestError("this gateway build has no attachment store, so images cannot be forwarded");
18283
+ const reading = interpretImagePart(url);
18284
+ if (!reading.ok) throw new ChannelRequestError(reading.reason);
18285
+ const ref = await attachments.saveImage({
18286
+ data: reading.data,
18287
+ mediaType: reading.mediaType
18288
+ });
18289
+ blocks.push({
18290
+ type: "image",
18291
+ attachment: ref
18292
+ });
18293
+ }
18294
+ }
18295
+ return {
18296
+ role,
18297
+ content: blocks
18298
+ };
18299
+ }
18300
+ /** The host's finish reason, in the OpenAI spelling. */
18301
+ function mapFinishReason(reason) {
18302
+ const kind = typeof reason === "object" && reason !== null ? reason.kind : reason;
18303
+ if (kind === "toolUse" || kind === "tool_use") return "tool_calls";
18304
+ if (kind === "length") return "length";
18305
+ if (kind === "aborted") return "abort";
18306
+ return "stop";
18307
+ }
18308
+ /** Fold host chunks into OpenAI delta objects. */
18309
+ function toChannelEvent(chunk) {
18310
+ switch (chunk.type) {
18311
+ case "text-delta": return {
18312
+ kind: "delta",
18313
+ delta: { content: String(chunk.text ?? "") }
18314
+ };
18315
+ case "reasoning-delta": return {
18316
+ kind: "delta",
18317
+ delta: { reasoning_content: String(chunk.text ?? "") }
18318
+ };
18319
+ case "tool-call-delta": {
18320
+ const id = String(chunk.id ?? "");
18321
+ const name = typeof chunk.name === "string" ? chunk.name : void 0;
18322
+ const fn = { arguments: String(chunk.argumentsDelta ?? "") };
18323
+ if (name !== void 0 && name !== "") fn.name = name;
18324
+ const call = {
18325
+ index: 0,
18326
+ function: fn
18327
+ };
18328
+ if (id !== "") call.id = id;
18329
+ return {
18330
+ kind: "delta",
18331
+ delta: { tool_calls: [call] }
18332
+ };
18333
+ }
18334
+ case "usage": {
18335
+ const usage = chunk.usage;
18336
+ if (usage === void 0) return void 0;
18337
+ const prompt = Number(usage.input ?? usage.inputTokens ?? 0);
18338
+ const completion = Number(usage.output ?? usage.outputTokens ?? 0);
18339
+ return {
18340
+ kind: "usage",
18341
+ usage: {
18342
+ prompt_tokens: prompt,
18343
+ completion_tokens: completion,
18344
+ total_tokens: Number(usage.total ?? usage.totalTokens ?? prompt + completion)
18345
+ }
18346
+ };
18347
+ }
18348
+ case "finish": return {
18349
+ kind: "done",
18350
+ finishReason: mapFinishReason(chunk.reason)
18351
+ };
18352
+ default: return;
18353
+ }
18354
+ }
18355
+ /** Drive one turn through the host seam. */
18356
+ async function* runTurn(input) {
18357
+ const { llm, channel, modelId, body, signal, attachments } = input;
18358
+ const incoming = Array.isArray(body.messages) ? body.messages : [];
18359
+ const converted = [];
18360
+ for (const message of incoming) converted.push(await toHostMessage(message, attachments));
18361
+ const system = converted.filter((message) => message.role === "system");
18362
+ const options = {
18363
+ provider: channel,
18364
+ model: modelId,
18365
+ messages: converted.filter((message) => message.role !== "system"),
18366
+ signal
18367
+ };
18368
+ if (system.length > 0) options.system = system[0].content;
18369
+ if (typeof body.max_tokens === "number") options.maxTokens = body.max_tokens;
18370
+ if (typeof body.temperature === "number") options.temperature = body.temperature;
18371
+ if (Array.isArray(body.tools) && body.tools.length > 0) options.tools = body.tools;
18372
+ for await (const chunk of llm.stream(options)) {
18373
+ const event = toChannelEvent(chunk);
18374
+ if (event !== void 0) yield event;
18375
+ }
18376
+ }
18377
+ //#endregion
18040
18378
  //#region src/shim.ts
18041
18379
  /**
18042
18380
  * Loopback OpenAI-compatible endpoint with multi-account failover.
@@ -18053,7 +18391,74 @@ function paidAlternativeFor(model, models) {
18053
18391
  *
18054
18392
  * @module dsh-xdpool/shim
18055
18393
  */
18394
+ /**
18395
+ * The namespace the gateway lists POOL rows under.
18396
+ *
18397
+ * The listing already speaks three prefixes — `combo:<slug>` for a chain,
18398
+ * `free:<model>` for the keyless lane, and a composite `providerId/modelId` for
18399
+ * every aggregate row — and only the pool's own models were listed bare. A
18400
+ * client reading `/v1/models` therefore could tell three lanes apart and could
18401
+ * not tell the fourth from anything else, which is the opposite of what a
18402
+ * listing is for.
18403
+ *
18404
+ * A prefix rather than a slash, because the slash IS the aggregate
18405
+ * discriminator: `parseProviderModelId` splits on the first `/`, so a prefixed
18406
+ * id spelled with a slash would be read as an aggregate row and sent to the
18407
+ * wrong lane.
18408
+ *
18409
+ * `buddy:` because that is what this lane is. `REGISTERED_CHANNELS` in
18410
+ * `channels.ts` gives `buddy` to WorkBuddy 国内版 (`copilot.tencent.com`) and
18411
+ * `workbuddy` to 国际版, and the relay is wired to `shims.cn` — the domestic
18412
+ * pool. Naming the lane after the vendor rather than after the mechanism keeps it
18413
+ * consistent with every channel row the gateway lists, and it leaves `workbuddy:`
18414
+ * free for the international pool whenever `shims.global` is exposed too.
18415
+ *
18416
+ * Bare pool ids keep being accepted. Clients configured before this existed are
18417
+ * not broken by it; they are just no longer the only way to say it.
18418
+ */
18419
+ const POOL_MODEL_PREFIX = "buddy:";
18420
+ /**
18421
+ * The lane this shim's pool rows belong to, spelled the way the channel registry
18422
+ * spells it. Exported so the card and the channel listing can agree on which
18423
+ * channel ids the pool already covers instead of each keeping its own list.
18424
+ */
18425
+ const POOL_LANE_CHANNEL_ID = "buddy";
18426
+ /**
18427
+ * Prefixes reserved for lane routing. A channel must not be listed under one of
18428
+ * these, because its rows would then be indistinguishable from the lane that
18429
+ * owns the prefix.
18430
+ */
18431
+ const RESERVED_MODEL_PREFIXES = [
18432
+ POOL_MODEL_PREFIX,
18433
+ FREE_MODEL_PREFIX,
18434
+ `${COMBO_CHANNEL}:`
18435
+ ];
18436
+ /**
18437
+ * Whether one channel id is already spoken for by a lane.
18438
+ *
18439
+ * The pool-owned ids are the interesting case rather than an accident: `buddy`
18440
+ * and `workbuddy` ARE account pools this plugin serves itself, so listing them
18441
+ * again as channels would offer the same models twice under two routes.
18442
+ */
18443
+ function channelListableAsRows(channelId, poolOwned = /* @__PURE__ */ new Set([POOL_LANE_CHANNEL_ID])) {
18444
+ if (poolOwned.has(channelId)) return false;
18445
+ return !RESERVED_MODEL_PREFIXES.includes(`${channelId}:`);
18446
+ }
18056
18447
  const REQUEST_BODY_LIMIT = 67108864;
18448
+ /**
18449
+ * Marked by the LAN relay on every request it forwards.
18450
+ *
18451
+ * Defined here rather than in `relay.ts` because the shim has to READ it, and
18452
+ * `relay.ts` already depends on this module — importing back would be a cycle.
18453
+ *
18454
+ * It is also the only safe way to scope the gateway allowlist. This one process
18455
+ * serves two very different callers: the host's own adapter (so every model
18456
+ * DSH's picker offers, and every turn the user has in chat) and, through the
18457
+ * relay, whatever device on the LAN holds the key. Gating the shim outright
18458
+ * would gate the user's own chat with it; gating on this header restricts the
18459
+ * LAN surface and leaves the host untouched.
18460
+ */
18461
+ const RELAY_HOP_HEADER = "x-xdpool-relay-hop";
18057
18462
  const LOOPBACK_HOSTS = /* @__PURE__ */ new Set([
18058
18463
  "127.0.0.1",
18059
18464
  "localhost",
@@ -18209,6 +18614,8 @@ function createWorkBuddyShim(options) {
18209
18614
  const logger = options.logger;
18210
18615
  const aggregate = options.aggregate;
18211
18616
  const extra = options.extra;
18617
+ const channels = options.channels;
18618
+ const lanModels = options.lanModels;
18212
18619
  const maxAttempts = options.maxAttempts ?? 8;
18213
18620
  const SHARED_SECRET = randomBytes(32).toString("base64url");
18214
18621
  function bearerOk(req) {
@@ -18344,6 +18751,153 @@ function createWorkBuddyShim(options) {
18344
18751
  res.end();
18345
18752
  }
18346
18753
  /**
18754
+ * Serve one login-channel turn.
18755
+ *
18756
+ * The lane yields already-translated events; this function owns the wire. It
18757
+ * writes each delta as it arrives rather than collecting first, because
18758
+ * time-to-first-token is the entire reason a client points at a gateway, and
18759
+ * buffering would make a channel feel slower than using it through the picker.
18760
+ *
18761
+ * `stream: false` is honoured here — one `chat.completion`, no SSE. The pool
18762
+ * lane forces streaming (`prepareChatBody`), so the two lanes differ on
18763
+ * purpose; making the channel lane copy that deviation would mean a client's
18764
+ * parse strategy depended on which lane a model happened to sit in.
18765
+ */
18766
+ async function serveChannelCompletion(res, raw, modelId, target, signal) {
18767
+ if (channels === void 0) return;
18768
+ let body;
18769
+ try {
18770
+ body = JSON.parse(raw);
18771
+ } catch {
18772
+ body = {};
18773
+ }
18774
+ const nonStreaming = body.stream === false;
18775
+ const created = Math.floor(Date.now() / 1e3);
18776
+ const completionId = `chatcmpl-xdpool-${randomBytes(6).toString("base64url")}`;
18777
+ let text = "";
18778
+ let reasoning = "";
18779
+ let toolCalls = [];
18780
+ let usage;
18781
+ let finish = "stop";
18782
+ let started = false;
18783
+ /** Open the SSE body on the first delta, so an early failure is a status. */
18784
+ const begin = () => {
18785
+ if (started || nonStreaming) return;
18786
+ started = true;
18787
+ res.writeHead(200, {
18788
+ "Content-Type": "text/event-stream",
18789
+ "Cache-Control": "no-cache",
18790
+ "Connection": "keep-alive",
18791
+ "X-Accel-Buffering": "no"
18792
+ });
18793
+ };
18794
+ const writeFrame = (payload) => {
18795
+ begin();
18796
+ if (started) res.write(`data: ${JSON.stringify(payload)}\n\n`);
18797
+ };
18798
+ const chunk = (delta, reason) => ({
18799
+ id: completionId,
18800
+ object: "chat.completion.chunk",
18801
+ created,
18802
+ model: target.modelId,
18803
+ choices: [{
18804
+ index: 0,
18805
+ delta,
18806
+ finish_reason: reason
18807
+ }],
18808
+ usage: null
18809
+ });
18810
+ try {
18811
+ for await (const event of channels.turn({
18812
+ channel: target.channel,
18813
+ modelId: target.modelId,
18814
+ body,
18815
+ signal
18816
+ })) {
18817
+ if (event.kind === "usage") {
18818
+ usage = event.usage;
18819
+ continue;
18820
+ }
18821
+ if (event.kind === "done") {
18822
+ finish = event.finishReason;
18823
+ continue;
18824
+ }
18825
+ const delta = event.delta;
18826
+ if (typeof delta.content === "string") text += delta.content;
18827
+ if (typeof delta.reasoning_content === "string") reasoning += delta.reasoning_content;
18828
+ if (Array.isArray(delta.tool_calls)) for (const call of delta.tool_calls) {
18829
+ const index = Number(call.index ?? toolCalls.length);
18830
+ toolCalls[index] = mergeToolCallFragment(toolCalls[index] ?? {}, call);
18831
+ }
18832
+ if (!nonStreaming) writeFrame(chunk(delta, ""));
18833
+ }
18834
+ } catch (error) {
18835
+ if (started) {
18836
+ res.write("data: [DONE]\n\n");
18837
+ res.end();
18838
+ return;
18839
+ }
18840
+ const message = error instanceof Error ? error.message : String(error);
18841
+ if (error instanceof ChannelRequestError) {
18842
+ writeOpenAIError(res, 400, "invalid_request", message);
18843
+ return;
18844
+ }
18845
+ writeOpenAIError(res, 502, "channel_upstream_error", `channel ${target.channel}: ${message}`);
18846
+ return;
18847
+ }
18848
+ if (nonStreaming) {
18849
+ writeJson$1(res, 200, {
18850
+ id: completionId,
18851
+ object: "chat.completion",
18852
+ created,
18853
+ model: target.modelId,
18854
+ choices: [{
18855
+ index: 0,
18856
+ message: {
18857
+ role: "assistant",
18858
+ content: text === "" && toolCalls.length > 0 ? null : text,
18859
+ ...reasoning === "" ? {} : { reasoning_content: reasoning },
18860
+ ...toolCalls.length === 0 ? {} : { tool_calls: toolCalls }
18861
+ },
18862
+ finish_reason: finish
18863
+ }],
18864
+ ...usage === void 0 ? {} : { usage }
18865
+ });
18866
+ return;
18867
+ }
18868
+ begin();
18869
+ res.write(`data: ${JSON.stringify(chunk({}, finish))}\n\n`);
18870
+ if (usage !== void 0) res.write(`data: ${JSON.stringify({
18871
+ ...chunk({}, finish),
18872
+ usage
18873
+ })}\n\n`);
18874
+ res.write("data: [DONE]\n\n");
18875
+ res.end();
18876
+ }
18877
+ /**
18878
+ * Fold one tool-call fragment into its slot.
18879
+ *
18880
+ * The host streams a call as an opening fragment carrying `name` then argument
18881
+ * deltas that do not, so the name must survive the merge while the argument
18882
+ * strings concatenate in arrival order.
18883
+ */
18884
+ function mergeToolCallFragment(existing, fragment) {
18885
+ const before = existing.function ?? {};
18886
+ const next = fragment.function ?? {};
18887
+ const merged = {
18888
+ ...existing,
18889
+ ...fragment,
18890
+ function: {
18891
+ ...before,
18892
+ ...next,
18893
+ arguments: `${String(before.arguments ?? "")}${String(next.arguments ?? "")}`
18894
+ }
18895
+ };
18896
+ if (typeof fragment.id === "string" && fragment.id !== "") merged.id = fragment.id;
18897
+ else if (typeof existing.id === "string") merged.id = existing.id;
18898
+ return merged;
18899
+ }
18900
+ /**
18347
18901
  * Serve one aggregate turn: forward to the provider, stream back verbatim.
18348
18902
  *
18349
18903
  * No account rotation and no compaction retry. The first is honest — the key
@@ -18428,6 +18982,74 @@ function createWorkBuddyShim(options) {
18428
18982
  pool.noteCredits(account.id, credits.total, credits.nearestExpiryMs);
18429
18983
  } catch {}
18430
18984
  }
18985
+ /**
18986
+ * Whether this request came in through the LAN relay.
18987
+ *
18988
+ * The relay stamps the header on what it forwards and strips any client-supplied
18989
+ * copy, so this is a fact about the path the request took rather than something
18990
+ * a caller can claim.
18991
+ */
18992
+ const fromLan = (req) => req.headers[RELAY_HOP_HEADER] !== void 0;
18993
+ /**
18994
+ * The allowlist LAN clients are held to, or undefined when there is none.
18995
+ *
18996
+ * `undefined` covers both "the build has no allowlist wired" and "the user has
18997
+ * never chosen one", and both mean serve everything — a gateway that answered
18998
+ * 403 to every model before the user had seen the picker would look like the
18999
+ * feature is broken rather than unset. A throwing reader is treated the same
19000
+ * way: refusing a whole gateway over an unreadable file is worse than serving
19001
+ * it, because the user cannot fix what they cannot reach.
19002
+ */
19003
+ const lanAllowlist = () => {
19004
+ if (lanModels === void 0) return void 0;
19005
+ try {
19006
+ const list = lanModels();
19007
+ return list === void 0 ? void 0 : new Set(list);
19008
+ } catch {
19009
+ return;
19010
+ }
19011
+ };
19012
+ /**
19013
+ * Everything this shim could serve, with the name a human would recognise.
19014
+ *
19015
+ * One function for two consumers on purpose. `/v1/models` needs the ids, and
19016
+ * the card's Gateway picker needs the ids AND the display name — a channel
19017
+ * model is `qfmodel` on the wire but reads `Qwen3.8-Flash · 免费` everywhere the
19018
+ * user has seen it so far. If the picker assembled its own list, the two would
19019
+ * drift, and the failure would be invisible: the card offers a model the
19020
+ * gateway then refuses, or hides one it serves.
19021
+ *
19022
+ * `source` is the value `/v1/models` reports as `owned_by`, and also what the
19023
+ * picker groups rows under.
19024
+ */
19025
+ async function roster() {
19026
+ const entries = [];
19027
+ for (const model of catalog.visible()) entries.push({
19028
+ id: `${POOL_MODEL_PREFIX}${model.id}`,
19029
+ name: model.name ?? model.id,
19030
+ source: "workbuddy"
19031
+ });
19032
+ if (aggregate !== void 0) for (const row of aggregate.catalog.current()) entries.push({
19033
+ id: row.id,
19034
+ name: row.name ?? row.id,
19035
+ source: isComboRow(row) ? COMBO_CHANNEL : row.providerId
19036
+ });
19037
+ if (extra !== void 0) for (const row of extra.free.current()) entries.push({
19038
+ id: row.modelId,
19039
+ name: row.name ?? row.modelId,
19040
+ source: FREE_CHANNEL
19041
+ });
19042
+ if (channels !== void 0) try {
19043
+ for (const row of await channels.rows()) entries.push({
19044
+ id: row.id,
19045
+ name: row.name,
19046
+ source: row.channel
19047
+ });
19048
+ } catch (error) {
19049
+ logger?.warn?.(`dsh-xdpool: channel listing failed: ${String(error)}`);
19050
+ }
19051
+ return entries;
19052
+ }
18431
19053
  async function handle(req, res) {
18432
19054
  try {
18433
19055
  if (!hostIsLoopback(req.headers.host)) {
@@ -18451,30 +19073,16 @@ function createWorkBuddyShim(options) {
18451
19073
  return;
18452
19074
  }
18453
19075
  if (req.method === "GET" && (url === "/v1/models" || url === "/v1/models/")) {
18454
- const rows = catalog.current().map((model) => ({
18455
- id: model.id,
19076
+ const rows = (await roster()).map((entry) => ({
19077
+ id: entry.id,
18456
19078
  object: "model",
18457
19079
  created: 0,
18458
- owned_by: "workbuddy"
19080
+ owned_by: entry.source
18459
19081
  }));
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
- });
19082
+ const allowed = fromLan(req) ? lanAllowlist() : void 0;
18475
19083
  writeJson$1(res, 200, {
18476
19084
  object: "list",
18477
- data: rows
19085
+ data: allowed === void 0 ? rows : rows.filter((row) => allowed.has(row.id))
18478
19086
  });
18479
19087
  return;
18480
19088
  }
@@ -18633,6 +19241,13 @@ function createWorkBuddyShim(options) {
18633
19241
  } catch {
18634
19242
  modelId = void 0;
18635
19243
  }
19244
+ if (fromLan(req)) {
19245
+ const allowed = lanAllowlist();
19246
+ if (allowed !== void 0 && (modelId === void 0 || !allowed.has(modelId))) {
19247
+ 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`);
19248
+ return;
19249
+ }
19250
+ }
18636
19251
  const comboRow = aggregate === void 0 || modelId === void 0 ? void 0 : aggregate.catalog.findCombo(modelId);
18637
19252
  if (comboRow !== void 0) {
18638
19253
  await serveComboCompletion(res, raw, comboRow, controller.signal);
@@ -18657,8 +19272,28 @@ function createWorkBuddyShim(options) {
18657
19272
  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
19273
  return;
18659
19274
  }
18660
- const prepared = client.prepareChatBodyFor(raw, region ?? "cn");
18661
- const conversationKey = conversationKeyOf(raw);
19275
+ if (channels !== void 0 && modelId !== void 0) {
19276
+ const target = channels.resolve(modelId);
19277
+ if (target !== void 0) {
19278
+ await serveChannelCompletion(res, raw, modelId, target, controller.signal);
19279
+ return;
19280
+ }
19281
+ }
19282
+ let poolRaw = raw;
19283
+ if (modelId !== void 0 && modelId.startsWith("buddy:")) {
19284
+ const bare = modelId.slice(6);
19285
+ if (bare === "" || bare.startsWith("free:") || isComboId(bare) || bare.includes("/")) {
19286
+ writeOpenAIError(res, 404, "model_not_found", `${modelId} is not a pool model`);
19287
+ return;
19288
+ }
19289
+ modelId = bare;
19290
+ poolRaw = JSON.stringify({
19291
+ ...JSON.parse(raw),
19292
+ model: bare
19293
+ });
19294
+ }
19295
+ const prepared = client.prepareChatBodyFor(poolRaw, region ?? "cn");
19296
+ const conversationKey = conversationKeyOf(poolRaw);
18662
19297
  const tried = [];
18663
19298
  let last;
18664
19299
  const turnId = randomBytes(8).toString("hex");
@@ -18762,6 +19397,7 @@ function createWorkBuddyShim(options) {
18762
19397
  return {
18763
19398
  ready,
18764
19399
  baseUrl,
19400
+ roster,
18765
19401
  token: () => SHARED_SECRET,
18766
19402
  close: () => new Promise((resolve, reject) => {
18767
19403
  server.close(() => resolve());
@@ -19056,12 +19692,6 @@ async function recoverFromContextOverrun(options) {
19056
19692
  * @module dsh-xdpool/relay
19057
19693
  */
19058
19694
  /**
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
19695
  * The only paths this relay forwards.
19066
19696
  *
19067
19697
  * Deliberately exactly the routes the shim serves. Anything else is refused
@@ -19322,6 +19952,37 @@ function createPoolRelay(options) {
19322
19952
  function newRelayKey() {
19323
19953
  return randomBytes(32).toString("base64url");
19324
19954
  }
19955
+ /**
19956
+ * Compose the relay document the Gateway card renders.
19957
+ *
19958
+ * Extracted, and awaited HERE, because the bug this shape exists to prevent is
19959
+ * invisible at the call site: `status()` is async, and
19960
+ * `{ ...relay.status(), candidates }` spreads a PENDING PROMISE, which yields no
19961
+ * own properties at all. The document then carries the new fields and silently
19962
+ * drops `enabled`, `listening`, `host`, `port`, `key` and `error` — a card with no
19963
+ * switch, no key and no endpoint, served over HTTP 200 with nothing to indicate it
19964
+ * broke. This happened when the picker was added; the assertions below are the
19965
+ * reason it cannot happen quietly again.
19966
+ *
19967
+ * Every input is a function so a test can hand it a rejecting roster and still
19968
+ * require the rest of the document to arrive.
19969
+ */
19970
+ async function relayDocumentOf(input) {
19971
+ const status = await input.status();
19972
+ let candidates;
19973
+ try {
19974
+ candidates = await input.roster();
19975
+ } catch (error) {
19976
+ input.warn?.(error);
19977
+ candidates = [];
19978
+ }
19979
+ const shared = input.shared();
19980
+ return {
19981
+ ...status,
19982
+ candidates,
19983
+ ...shared === void 0 ? {} : { shared }
19984
+ };
19985
+ }
19325
19986
  //#endregion
19326
19987
  //#region src/status.ts
19327
19988
  /** Build the status document. Never throws. */
@@ -20064,7 +20725,8 @@ function createCore(logger) {
20064
20725
  const pool = new WorkBuddyAccountPool({
20065
20726
  ...logger === void 0 ? {} : { logger },
20066
20727
  client,
20067
- persistent: true
20728
+ persistent: true,
20729
+ affinityPath: join(pluginDataDir(), "sticky-affinity.json")
20068
20730
  });
20069
20731
  return {
20070
20732
  pool,
@@ -20301,6 +20963,35 @@ function apply(ctx, config = {}) {
20301
20963
  aggregateCatalog.refresh();
20302
20964
  const freeCatalog = new FreeCatalog();
20303
20965
  const freeLane = createFreeLaneSource(freeCatalog, ctx.logger, usageRecorder);
20966
+ /**
20967
+ * The login channels, for the LAN gateway.
20968
+ *
20969
+ * Reached through `ctx.llm` rather than a socket: the pack registers each
20970
+ * channel as a host adapter, so the host is the only thing that knows the
20971
+ * credential, the endpoint and the wire dialect.
20972
+ *
20973
+ * The two pool-owned ids are left out on purpose. `buddy` and `workbuddy` ARE
20974
+ * account pools this plugin already serves, so listing them again as channels
20975
+ * would offer the same models twice under two routes — and `buddy:` is already
20976
+ * the prefix the pool lane's own rows carry.
20977
+ */
20978
+ const channelLane = createChannelLane({
20979
+ llm: ctx.llm,
20980
+ providers: () => REGISTERED_CHANNELS.map((channel) => channel.id).filter((id) => channelListableAsRows(id, POOL_OWNED_CHANNELS)),
20981
+ toId: (channel, modelId) => `${channel}:${modelId}`,
20982
+ attachments: () => ctx.get("attachments"),
20983
+ logger: ctx.logger
20984
+ });
20985
+ /**
20986
+ * The gateway allowlist, re-read per request.
20987
+ *
20988
+ * `undefined` and `[]` are deliberately NOT collapsed: the first means the
20989
+ * user has never opened the picker, and the second means they unticked
20990
+ * everything. Reading the stored field directly rather than through
20991
+ * `relaySettingsOf` is what preserves that, since the relay's own settings
20992
+ * object has no reason to carry a list it never consults.
20993
+ */
20994
+ const lanModelsOf = () => readAggregateSettingsSync().relay?.models;
20304
20995
  const shims = {
20305
20996
  cn: createWorkBuddyShim({
20306
20997
  pool: core.pool,
@@ -20313,7 +21004,9 @@ function apply(ctx, config = {}) {
20313
21004
  catalog: aggregateCatalog,
20314
21005
  readStore: () => readAggregateSettingsSync()
20315
21006
  },
20316
- extra: { free: freeLane }
21007
+ extra: { free: freeLane },
21008
+ channels: channelLane,
21009
+ lanModels: lanModelsOf
20317
21010
  }),
20318
21011
  global: createWorkBuddyShim({
20319
21012
  pool: core.pool,
@@ -20360,6 +21053,19 @@ function apply(ctx, config = {}) {
20360
21053
  relay.ready.catch((error) => {
20361
21054
  ctx.logger.warn(`xdpool relay: startup failed — ${String(error)}`);
20362
21055
  });
21056
+ /**
21057
+ * The relay document the Gateway card reads.
21058
+ *
21059
+ * One loader for BOTH routes rather than a per-route assembly: the card takes
21060
+ * the write response as the new state, so a save that answered with only the
21061
+ * listener status dropped the picker's roster off the card until the next poll.
21062
+ */
21063
+ const relayDocument = () => relayDocumentOf({
21064
+ status: () => relay.status(),
21065
+ shared: () => readAggregateSettingsSync().relay?.models,
21066
+ roster: () => shim.roster(),
21067
+ warn: (error) => ctx.logger.warn("dsh-xdpool: gateway roster failed", error)
21068
+ });
20363
21069
  ctx.inject(["webServer"], (webCtx) => registerPoolStatusRoute(webCtx, {
20364
21070
  pool: core.pool,
20365
21071
  catalogs: core.catalogs,
@@ -20560,7 +21266,15 @@ function apply(ctx, config = {}) {
20560
21266
  },
20561
21267
  billionDocument: readBillionStatus,
20562
21268
  /**
20563
- * Relay state and patches.
21269
+ * Relay state plus the gateway's model picker.
21270
+ *
21271
+ * The candidates come from the shim's own roster rather than a parallel
21272
+ * assembly here, so the card cannot offer a model the gateway would then
21273
+ * refuse — the exact drift the picker exists to prevent.
21274
+ */
21275
+ relayDocument,
21276
+ /**
21277
+ * Apply one patch from the card and answer with the fresh document.
20564
21278
  *
20565
21279
  * The write path mints a key when the card asks for a rotation and the user
20566
21280
  * has never had one: an enabled relay with no key would have to either refuse
@@ -20568,7 +21282,6 @@ function apply(ctx, config = {}) {
20568
21282
  * one keeps "enabled" honest, and the card shows it so it can be pasted into
20569
21283
  * the other device.
20570
21284
  */
20571
- relayDocument: async () => relay.status(),
20572
21285
  saveRelay: async (patch) => {
20573
21286
  const before = readAggregateSettingsSync().relay ?? {};
20574
21287
  const key = patch.key === "" ? newRelayKey() : patch.key;
@@ -20576,10 +21289,11 @@ function apply(ctx, config = {}) {
20576
21289
  ...patch.enabled === void 0 ? {} : { enabled: patch.enabled },
20577
21290
  ...patch.host === void 0 ? {} : { host: patch.host },
20578
21291
  ...patch.port === void 0 ? {} : { port: patch.port },
20579
- key: key ?? before.key ?? newRelayKey()
21292
+ key: key ?? before.key ?? newRelayKey(),
21293
+ ...patch.models === void 0 ? {} : { models: patch.models }
20580
21294
  });
20581
21295
  await relay.sync();
20582
- return relay.status();
21296
+ return relayDocument();
20583
21297
  }
20584
21298
  }));
20585
21299
  api = {