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/CHANGELOG.md +54 -0
- package/lib/bin.js +51 -2
- package/lib/client.js +149 -1
- package/lib/index.d.ts +189 -23
- package/lib/index.js +723 -34
- package/package.json +1 -1
- package/vendor/channel-pack/pack.js +97 -9
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 =
|
|
18455
|
-
id:
|
|
19051
|
+
const rows = (await roster()).map((entry) => ({
|
|
19052
|
+
id: entry.id,
|
|
18456
19053
|
object: "model",
|
|
18457
19054
|
created: 0,
|
|
18458
|
-
owned_by:
|
|
19055
|
+
owned_by: entry.source
|
|
18459
19056
|
}));
|
|
18460
|
-
|
|
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
|
-
|
|
18661
|
-
|
|
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
|
|
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
|
|
21271
|
+
return relayDocument();
|
|
20583
21272
|
}
|
|
20584
21273
|
}));
|
|
20585
21274
|
api = {
|