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/CHANGELOG.md +82 -0
- package/lib/bin.js +81 -7
- package/lib/client.js +332 -1
- package/lib/index.d.ts +189 -23
- package/lib/index.js +753 -39
- package/package.json +1 -1
- package/vendor/channel-pack/pack.js +159 -34
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.
|
|
3269
|
-
* 3. the
|
|
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
|
|
3279
|
-
|
|
3280
|
-
|
|
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 =
|
|
18455
|
-
id:
|
|
19076
|
+
const rows = (await roster()).map((entry) => ({
|
|
19077
|
+
id: entry.id,
|
|
18456
19078
|
object: "model",
|
|
18457
19079
|
created: 0,
|
|
18458
|
-
owned_by:
|
|
19080
|
+
owned_by: entry.source
|
|
18459
19081
|
}));
|
|
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
|
-
});
|
|
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
|
-
|
|
18661
|
-
|
|
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
|
|
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
|
|
21296
|
+
return relayDocument();
|
|
20583
21297
|
}
|
|
20584
21298
|
}));
|
|
20585
21299
|
api = {
|