dsh-connect-workbuddy 3.0.2 → 3.3.0
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 +190 -0
- package/README.en.md +49 -20
- package/README.md +49 -22
- package/THIRD_PARTY_NOTICES.md +4 -2
- package/docs/WINDOWS.md +1 -0
- package/docs/assets/account-pool-demo.html +207 -262
- package/lib/bin.js +23 -3
- package/lib/client.js +377 -755
- package/lib/{host-heartbeat-Dwmfxuyw.js → host-heartbeat-t93cLCoW.js} +238 -4
- package/lib/index.d.ts +140 -32
- package/lib/index.js +1409 -265
- package/package.json +1 -1
package/lib/index.js
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
|
-
import { A as
|
|
1
|
+
import { $ as readAtRestKey, A as CREDENTIAL_REJECTED_CODE, B as prepareChatBody, C as defaultDesktopAuthPath, D as parseWorkBuddyAuth, E as legacyWorkbuddyOwnAuthPath, F as declaredTools, G as deriveAtRestKeyId, H as WORKBUDDY_APP_EXECUTABLE_ENV, I as isCredentialRejectedError, J as isEncryptedFieldWrapper, K as fetchAtRestKeyPayload, L as parseCreditMultiplier, M as WorkBuddyCredentialRejectedError, N as WorkBuddyUpstreamClient, O as workbuddyAccountId, P as classifyUpstreamError, Q as openEncryptedField, R as parseReasoning, S as defaultDesktopAuthDirs, T as isEncryptedCredentialError, U as clearAtRestKeyCache, V as regionOf, W as deriveAtRestKey, X as macosBundleExecutable, Y as isWorkbuddyBundle, Z as macosNestedAppBundles, _ as WORKBUDDY_AUTH_FILE_ENV, a as readHostHeartbeat, b as authFileName, c as WORKBUDDY_CONNECT_VERSION, d as FALLBACK_WORKBUDDY_MODELS_GLOBAL, et as workbuddyAppExecutableCandidates, f as WorkBuddyCatalog, g as WORKBUDDY_AUTH_FILENAME, h as ENCRYPTED_CREDENTIAL_CODE, i as processStartTimeMs, j as WORKBUDDY_FALLBACK_SYSTEM_PROMPT, k as workbuddyOwnAuthPath, l as piAiRuntimeInfo, m as fallbackModelsFor, n as clearHostHeartbeat, o as workbuddyHostHeartbeatPath, p as deriveCatalog, q as findWorkbuddyAppExecutable, r as isHeartbeatProcessAlive, s as writeHostHeartbeat, t as WORKBUDDY_HOST_HEARTBEAT_FILENAME, u as FALLBACK_WORKBUDDY_MODELS, v as WorkBuddyCredentialStore, w as hasEncryptedCredentialFields, x as defaultDesktopAuthCandidates, y as WorkBuddyEncryptedCredentialError, z as parseUpstreamModel } from "./host-heartbeat-t93cLCoW.js";
|
|
2
2
|
import z from "@deepseek-ai/schemastery";
|
|
3
3
|
import { readFile } from "node:fs/promises";
|
|
4
|
-
import { randomBytes, timingSafeEqual } from "node:crypto";
|
|
4
|
+
import { randomBytes, randomUUID, timingSafeEqual } from "node:crypto";
|
|
5
5
|
import { join } from "node:path";
|
|
6
6
|
import { withFileLock, writeFileAtomic } from "@deepseek-ai/dsh-atomic-write";
|
|
7
7
|
import { resolveDshHome } from "@deepseek-ai/dsh-home-paths";
|
|
@@ -213,8 +213,8 @@ function toPiModel(info, baseUrl, providerId) {
|
|
|
213
213
|
};
|
|
214
214
|
}
|
|
215
215
|
/**
|
|
216
|
-
* Rewrite a pi-ai 0.87-shaped
|
|
217
|
-
* understands.
|
|
216
|
+
* Rewrite a pi-ai 0.87-shaped transcript into the 0.85 shape this plugin's
|
|
217
|
+
* pi-ai understands — prompt AND tools.
|
|
218
218
|
*
|
|
219
219
|
* WHY THIS EXISTS (issue #24). pi-ai 0.87's `normalizeContext` moves the system
|
|
220
220
|
* prompt INTO `messages` as `{ role: 'system', content: '<string>' }`. pi-ai
|
|
@@ -230,25 +230,54 @@ function toPiModel(info, baseUrl, providerId) {
|
|
|
230
230
|
* built — so every model fails instantly and no upstream traffic is sent. A
|
|
231
231
|
* host running 0.87 hands us the normalized transcript while our own provider
|
|
232
232
|
* is 0.85, and the plugin cannot patch the library. What it CAN do is not hand
|
|
233
|
-
* a 0.87 transcript to a 0.85 API object
|
|
234
|
-
* `Context.systemPrompt`, which is where 0.85 expects it.
|
|
233
|
+
* a 0.87 transcript to a 0.85 API object.
|
|
235
234
|
*
|
|
236
|
-
*
|
|
237
|
-
*
|
|
238
|
-
*
|
|
239
|
-
*
|
|
235
|
+
* The 0.87 transcript carries MORE than the prompt on that leading system
|
|
236
|
+
* message: `toolsAdded` (and `toolsRemoved`) are the ONLY place the tool
|
|
237
|
+
* declarations live — `normalizeContext` deleted the top-level `tools` field,
|
|
238
|
+
* and 0.85's api reads tools ONLY from `context.tools`. So folding the text
|
|
239
|
+
* without promoting the tool state would trade issue #24's loud crash for
|
|
240
|
+
* issue #26's silent one: models that can never emit a `toolCall` because the
|
|
241
|
+
* request never declared any tools. The fold here promotes both, replaying
|
|
242
|
+
* every system message's `toolsRemoved`/`toolsAdded` in order — exactly the
|
|
243
|
+
* merge pi-ai 0.87's own `getCurrentTools` performs — and leaves an existing
|
|
244
|
+
* top-level `tools` array untouched.
|
|
245
|
+
*
|
|
246
|
+
* Deliberately SHAPE-based within the legacy branch, not version-based: it
|
|
247
|
+
* asks "does this context carry a system message inside `messages`?" rather
|
|
248
|
+
* than "which pi-ai version is loaded?". A version check would be wrong the
|
|
249
|
+
* moment either side moves. Which BRANCH runs at all IS generation-gated —
|
|
250
|
+
* see {@link withLegacyContext}: a modern (0.87+) api consumes this very
|
|
251
|
+
* transcript natively, and folding for it would destroy its only carrier of
|
|
252
|
+
* prompt and tools (issues #25/#26).
|
|
240
253
|
*
|
|
241
254
|
* Returns the SAME object when there is nothing to adapt — in particular when
|
|
242
255
|
* `systemPrompt` is already set (the native 0.85 shape), so the ordinary path
|
|
243
256
|
* is byte-for-byte untouched.
|
|
257
|
+
*
|
|
258
|
+
* Two guards beyond the 0.87 fold, both about a request reaching the upstream
|
|
259
|
+
* with NO system message — which the international gateway refuses outright
|
|
260
|
+
* (business code 11128) while the domestic one tolerates:
|
|
261
|
+
*
|
|
262
|
+
* - an EMPTY `systemPrompt` is treated as absent rather than "already set".
|
|
263
|
+
* pi-ai emits the prompt under `if (context.systemPrompt)`, so an empty one
|
|
264
|
+
* produces no system message at all and pi-ai demotes any `system` entry still
|
|
265
|
+
* in `messages` to `user`. Falling through here lets a real system message
|
|
266
|
+
* that IS present in `messages` be folded up and preserved.
|
|
267
|
+
* - when nothing supplies a prompt, {@link WORKBUDDY_FALLBACK_SYSTEM_PROMPT} is
|
|
268
|
+
* used, because a placeholder beats a guaranteed 400. A system message whose
|
|
269
|
+
* text is empty but which carries `toolsAdded` still folds its TOOLS up; the
|
|
270
|
+
* placeholder for the missing prompt then comes from `ensureSystemHead` at
|
|
271
|
+
* the wire (the last of the three layers).
|
|
244
272
|
*/
|
|
245
273
|
function adaptLegacyPiAiContext(context) {
|
|
246
274
|
if (context === null || typeof context !== "object") return context;
|
|
247
|
-
if (context.systemPrompt
|
|
275
|
+
if (context.systemPrompt) return context;
|
|
248
276
|
const messages = Array.isArray(context.messages) ? context.messages : void 0;
|
|
249
277
|
if (messages === void 0) return context;
|
|
250
278
|
const systemTexts = [];
|
|
251
279
|
const rest = [];
|
|
280
|
+
const toolState = /* @__PURE__ */ new Map();
|
|
252
281
|
let sawSystemMessage = false;
|
|
253
282
|
for (const message of messages) {
|
|
254
283
|
if (message?.role !== "system") {
|
|
@@ -258,14 +287,37 @@ function adaptLegacyPiAiContext(context) {
|
|
|
258
287
|
sawSystemMessage = true;
|
|
259
288
|
const text = systemTextOf(message);
|
|
260
289
|
if (text !== "") systemTexts.push(text);
|
|
290
|
+
replayToolState(message, toolState);
|
|
261
291
|
}
|
|
262
|
-
|
|
292
|
+
const head = rest[0];
|
|
293
|
+
const needsFallback = !sawSystemMessage && head !== void 0 && head.role !== "system";
|
|
294
|
+
if (!sawSystemMessage && !needsFallback) return context;
|
|
295
|
+
const systemPrompt = systemTexts.length > 0 ? systemTexts.join("\n\n") : sawSystemMessage ? void 0 : WORKBUDDY_FALLBACK_SYSTEM_PROMPT;
|
|
296
|
+
const tools = Array.isArray(context.tools) ? void 0 : [...toolState.values()];
|
|
263
297
|
return {
|
|
264
298
|
...context,
|
|
265
|
-
...
|
|
299
|
+
...systemPrompt === void 0 ? {} : { systemPrompt },
|
|
300
|
+
...tools !== void 0 && tools.length > 0 ? { tools } : {},
|
|
266
301
|
messages: rest
|
|
267
302
|
};
|
|
268
303
|
}
|
|
304
|
+
/**
|
|
305
|
+
* Replay one 0.87 system message's tool deltas into `state`, the same merge
|
|
306
|
+
* pi-ai 0.87's `getCurrentTools` performs: removals first, then additions,
|
|
307
|
+
* keyed by name so a mid-conversation redeclaration replaces the original.
|
|
308
|
+
*/
|
|
309
|
+
function replayToolState(message, state) {
|
|
310
|
+
const removed = message.toolsRemoved;
|
|
311
|
+
if (Array.isArray(removed)) for (const tool of removed) {
|
|
312
|
+
const name = tool?.name;
|
|
313
|
+
if (typeof name === "string") state.delete(name);
|
|
314
|
+
}
|
|
315
|
+
const added = message.toolsAdded;
|
|
316
|
+
if (Array.isArray(added)) for (const tool of added) {
|
|
317
|
+
const name = tool?.name;
|
|
318
|
+
if (typeof name === "string") state.set(name, tool);
|
|
319
|
+
}
|
|
320
|
+
}
|
|
269
321
|
/** The text of one system message, whether it is a string or text blocks. */
|
|
270
322
|
function systemTextOf(message) {
|
|
271
323
|
const content = message.content;
|
|
@@ -281,11 +333,25 @@ function systemTextOf(message) {
|
|
|
281
333
|
}
|
|
282
334
|
/**
|
|
283
335
|
* Wrap one pi-ai API module so both stream entry points receive a context this
|
|
284
|
-
* build can actually consume (see {@link adaptLegacyPiAiContext})
|
|
336
|
+
* build can actually consume (see {@link adaptLegacyPiAiContext}) — but only
|
|
337
|
+
* when the module RESOLVED for this plugin is the legacy generation.
|
|
338
|
+
*
|
|
339
|
+
* The gate is the plugin's own module, resolved via {@link piAiRuntimeInfo}
|
|
340
|
+
* (manifest version of the copy this plugin resolves — no pi-ai import): a
|
|
341
|
+
* modern (0.87+) api consumes the normalized
|
|
342
|
+
* transcript natively — its request builder reads the prompt from the leading
|
|
343
|
+
* system message and the tools from that message's `toolsAdded`, and never
|
|
344
|
+
* reads the top-level `systemPrompt`/`tools` fields at all. Folding for it
|
|
345
|
+
* would delete the only carrier of both (issues #25/#26: the international
|
|
346
|
+
* gateway answers the prompt-less body with 400 / 11128, the domestic one
|
|
347
|
+
* accepts it and the model fabricates tool calls it can never actually make).
|
|
348
|
+
* The unwrapped modern api is returned AS-IS — byte-for-byte pass-through.
|
|
285
349
|
*
|
|
286
|
-
* The deferred-fetch entry points take no context and are passed through as-is
|
|
350
|
+
* The deferred-fetch entry points take no context and are passed through as-is
|
|
351
|
+
* in both branches.
|
|
287
352
|
*/
|
|
288
|
-
function withLegacyContext(api) {
|
|
353
|
+
function withLegacyContext(api, generation) {
|
|
354
|
+
if (generation === "modern") return api;
|
|
289
355
|
return {
|
|
290
356
|
...api,
|
|
291
357
|
stream: (model, context, options) => api.stream(model, adaptLegacyPiAiContext(context), options),
|
|
@@ -301,6 +367,7 @@ function createWorkBuddyAdapter(options) {
|
|
|
301
367
|
const { shim, store, catalog, resolveAttachments } = options;
|
|
302
368
|
const providerId = options.provider ?? "workbuddy";
|
|
303
369
|
const providerName = options.displayName ?? "WorkBuddy";
|
|
370
|
+
const generation = options.piAiGeneration ?? piAiRuntimeInfo().generation;
|
|
304
371
|
const buildModels = () => {
|
|
305
372
|
const baseUrl = `${shim.baseUrl()}/v1`;
|
|
306
373
|
return catalog.current().map((info) => toPiModel(info, baseUrl, providerId));
|
|
@@ -320,7 +387,7 @@ function createWorkBuddyAdapter(options) {
|
|
|
320
387
|
}
|
|
321
388
|
} },
|
|
322
389
|
models: buildModels(),
|
|
323
|
-
api: withLegacyContext(openAICompletionsApi())
|
|
390
|
+
api: withLegacyContext(openAICompletionsApi(), generation)
|
|
324
391
|
}),
|
|
325
392
|
getModels: () => buildModels()
|
|
326
393
|
};
|
|
@@ -347,6 +414,692 @@ function createWorkBuddyAdapter(options) {
|
|
|
347
414
|
}
|
|
348
415
|
};
|
|
349
416
|
}
|
|
417
|
+
/** One or two full-width bars — the count is a spelling variable. */
|
|
418
|
+
const BARS = `||?`;
|
|
419
|
+
new RegExp(`${BARS}DSML${BARS}`, "g");
|
|
420
|
+
/**
|
|
421
|
+
* Spellings a tag may carry before its name, longest first so the doubled form
|
|
422
|
+
* is consumed before the single one can match its prefix.
|
|
423
|
+
*
|
|
424
|
+
* The ASCII spellings are accepted because the model mixes them: the marker is
|
|
425
|
+
* normalised to ASCII by {@link normalizedPrefixLength}, which is also the only
|
|
426
|
+
* way to catch a block that spells the two sides differently.
|
|
427
|
+
*/
|
|
428
|
+
const MARKUP_PREFIXES = [
|
|
429
|
+
`||DSML||`,
|
|
430
|
+
"||DSML||",
|
|
431
|
+
"|DSML|"
|
|
432
|
+
];
|
|
433
|
+
/** What the full-width marker looks like after {@link normalizedPrefixLength}. */
|
|
434
|
+
const ASCII_PREFIXES = ["||DSML||", "|DSML|"];
|
|
435
|
+
/**
|
|
436
|
+
* Tag names that can OPEN a block worth recovering.
|
|
437
|
+
*
|
|
438
|
+
* Deliberately tiny. `parameter` and the model's ad-hoc tag names are NOT here:
|
|
439
|
+
* they only ever appear inside a block, so treating one as a block start would
|
|
440
|
+
* hold a response hostage waiting for a close that naming never promised.
|
|
441
|
+
*/
|
|
442
|
+
const BLOCK_TAGS = ["tool_calls", "invoke"];
|
|
443
|
+
/**
|
|
444
|
+
* Tag names whose content belongs to the markup rather than to the answer.
|
|
445
|
+
*
|
|
446
|
+
* Used by {@link stripMarkup} to decide whether removing a marker-bearing tag
|
|
447
|
+
* should also remove everything up to its matching close. An INVENTED name is
|
|
448
|
+
* not here on purpose: `<|DSML| validate>` brackets nothing, so removing it
|
|
449
|
+
* means removing the tag and nothing else.
|
|
450
|
+
*/
|
|
451
|
+
const KEYWORD_TAGS = [
|
|
452
|
+
"tool_calls",
|
|
453
|
+
"tool-calls",
|
|
454
|
+
"toolcalls",
|
|
455
|
+
"invoke",
|
|
456
|
+
"parameter",
|
|
457
|
+
"calls",
|
|
458
|
+
"tool"
|
|
459
|
+
];
|
|
460
|
+
/** Tag names normalised to their canonical spelling. */
|
|
461
|
+
const CANONICAL_TAG_NAMES = /* @__PURE__ */ new Map([
|
|
462
|
+
["tool_calls", "tool_calls"],
|
|
463
|
+
["tool-calls", "tool_calls"],
|
|
464
|
+
["toolcalls", "tool_calls"],
|
|
465
|
+
["invoke", "invoke"],
|
|
466
|
+
["parameter", "parameter"]
|
|
467
|
+
]);
|
|
468
|
+
/**
|
|
469
|
+
* Canonical names accepted ONLY in a marker form.
|
|
470
|
+
*
|
|
471
|
+
* `<toolcalls>` without the marker is somebody's HTML, not the model's markup;
|
|
472
|
+
* the marker is what makes the misspelling meaningful.
|
|
473
|
+
*/
|
|
474
|
+
const MARKER_ONLY_TAG_NAMES = /* @__PURE__ */ new Set(["tool-calls", "toolcalls"]);
|
|
475
|
+
/** Fence markers at the start of a line. */
|
|
476
|
+
const FENCE_MARKERS = ["```", "~~~"];
|
|
477
|
+
/**
|
|
478
|
+
* Find every span that must not be scanned for markup.
|
|
479
|
+
*
|
|
480
|
+
* One pass, not a predicate call per character: the reference asks
|
|
481
|
+
* `is_inside_markdown_fence(text, i)` inside a per-character loop, which is
|
|
482
|
+
* quadratic in the size of the buffer. The buffer holds a whole block, and a
|
|
483
|
+
* block can be large (a file's worth of arguments), so the port computes the
|
|
484
|
+
* spans once and walks them.
|
|
485
|
+
*
|
|
486
|
+
* Semantics match the reference: fences only count at the start of a line,
|
|
487
|
+
* code spans are runs of one or two backticks, and an unterminated span runs to
|
|
488
|
+
* the end of the text (the closer may still be in flight).
|
|
489
|
+
*/
|
|
490
|
+
function ignoredSpans(text) {
|
|
491
|
+
const spans = [];
|
|
492
|
+
let i = 0;
|
|
493
|
+
let fence;
|
|
494
|
+
let fenceStart = 0;
|
|
495
|
+
while (i < text.length) {
|
|
496
|
+
const atLineStart = i === 0 || text.charAt(i - 1) === "\n";
|
|
497
|
+
if (fence !== void 0) {
|
|
498
|
+
if (atLineStart && text.startsWith(fence, i)) {
|
|
499
|
+
const lineEnd = text.indexOf("\n", i);
|
|
500
|
+
const end = lineEnd === -1 ? text.length : lineEnd + 1;
|
|
501
|
+
spans.push({
|
|
502
|
+
start: fenceStart,
|
|
503
|
+
end,
|
|
504
|
+
open: false
|
|
505
|
+
});
|
|
506
|
+
fence = void 0;
|
|
507
|
+
i = end;
|
|
508
|
+
continue;
|
|
509
|
+
}
|
|
510
|
+
i += 1;
|
|
511
|
+
continue;
|
|
512
|
+
}
|
|
513
|
+
if (atLineStart) {
|
|
514
|
+
const marker = FENCE_MARKERS.find((candidate) => text.startsWith(candidate, i));
|
|
515
|
+
if (marker !== void 0) {
|
|
516
|
+
fence = marker;
|
|
517
|
+
fenceStart = i;
|
|
518
|
+
i += marker.length;
|
|
519
|
+
continue;
|
|
520
|
+
}
|
|
521
|
+
}
|
|
522
|
+
if (text.charAt(i) === "`") {
|
|
523
|
+
const end = codeSpanEnd(text, i);
|
|
524
|
+
if (end === -1) {
|
|
525
|
+
spans.push({
|
|
526
|
+
start: i,
|
|
527
|
+
end: text.length,
|
|
528
|
+
open: true
|
|
529
|
+
});
|
|
530
|
+
break;
|
|
531
|
+
}
|
|
532
|
+
spans.push({
|
|
533
|
+
start: i,
|
|
534
|
+
end,
|
|
535
|
+
open: false
|
|
536
|
+
});
|
|
537
|
+
i = end;
|
|
538
|
+
continue;
|
|
539
|
+
}
|
|
540
|
+
const xml = xmlIgnoredEnd(text, i);
|
|
541
|
+
if (xml !== void 0) {
|
|
542
|
+
spans.push(xml);
|
|
543
|
+
if (xml.end >= text.length && xml.open) break;
|
|
544
|
+
i = xml.end;
|
|
545
|
+
continue;
|
|
546
|
+
}
|
|
547
|
+
i += 1;
|
|
548
|
+
}
|
|
549
|
+
if (fence !== void 0) spans.push({
|
|
550
|
+
start: fenceStart,
|
|
551
|
+
end: text.length,
|
|
552
|
+
open: true
|
|
553
|
+
});
|
|
554
|
+
return spans;
|
|
555
|
+
}
|
|
556
|
+
/**
|
|
557
|
+
* End of the inline code span starting at `start`, or -1 when there is none.
|
|
558
|
+
*
|
|
559
|
+
* Three or more backticks are a fence, not a code span — {@link ignoredSpans}
|
|
560
|
+
* handles those, and conflating the two is how a fence's opening ticks get
|
|
561
|
+
* mistaken for an unclosed inline span.
|
|
562
|
+
*/
|
|
563
|
+
function codeSpanEnd(text, start) {
|
|
564
|
+
let ticks = 0;
|
|
565
|
+
let i = start;
|
|
566
|
+
while (i < text.length && text.charAt(i) === "`") {
|
|
567
|
+
ticks += 1;
|
|
568
|
+
i += 1;
|
|
569
|
+
}
|
|
570
|
+
if (ticks === 0 || ticks >= 3) return -1;
|
|
571
|
+
let end = i;
|
|
572
|
+
while (end < text.length) {
|
|
573
|
+
if (text.charAt(end) !== "`") {
|
|
574
|
+
end += 1;
|
|
575
|
+
continue;
|
|
576
|
+
}
|
|
577
|
+
let closingTicks = 0;
|
|
578
|
+
let j = end;
|
|
579
|
+
while (j < text.length && text.charAt(j) === "`") {
|
|
580
|
+
closingTicks += 1;
|
|
581
|
+
j += 1;
|
|
582
|
+
}
|
|
583
|
+
if (closingTicks === ticks) return j;
|
|
584
|
+
end = j;
|
|
585
|
+
}
|
|
586
|
+
return -1;
|
|
587
|
+
}
|
|
588
|
+
/** CDATA / comment / processing instruction at `index`, if one starts there. */
|
|
589
|
+
function xmlIgnoredEnd(text, index) {
|
|
590
|
+
for (const [open, close] of [
|
|
591
|
+
["<![CDATA[", "]]>"],
|
|
592
|
+
["<!--", "-->"],
|
|
593
|
+
["<?", "?>"]
|
|
594
|
+
]) {
|
|
595
|
+
if (!text.startsWith(open, index)) continue;
|
|
596
|
+
const found = text.indexOf(close, index + open.length);
|
|
597
|
+
if (found === -1) return {
|
|
598
|
+
start: index,
|
|
599
|
+
end: text.length,
|
|
600
|
+
open: true
|
|
601
|
+
};
|
|
602
|
+
return {
|
|
603
|
+
start: index,
|
|
604
|
+
end: found + close.length,
|
|
605
|
+
open: false
|
|
606
|
+
};
|
|
607
|
+
}
|
|
608
|
+
}
|
|
609
|
+
/**
|
|
610
|
+
* Index just past the ignored span containing `index`, or `index` unchanged.
|
|
611
|
+
*
|
|
612
|
+
* `cursor` is a hint into the sorted span list; callers that walk positions in
|
|
613
|
+
* order get O(1) amortised lookup, which is why the parameter exists.
|
|
614
|
+
*/
|
|
615
|
+
function skipIgnored(spans, index, cursor) {
|
|
616
|
+
while (cursor.at < spans.length) {
|
|
617
|
+
const span = spans[cursor.at];
|
|
618
|
+
if (span === void 0 || index < span.start) return index;
|
|
619
|
+
if (index < span.end) return span.end;
|
|
620
|
+
cursor.at += 1;
|
|
621
|
+
}
|
|
622
|
+
return index;
|
|
623
|
+
}
|
|
624
|
+
/**
|
|
625
|
+
* Length of the marker prefix at `index`, or 0 when none is there.
|
|
626
|
+
*
|
|
627
|
+
* Two passes on purpose. The literal spellings are tried first; then the text
|
|
628
|
+
* is normalised character by character, which is the only way to recognise a
|
|
629
|
+
* block that mixes the two bar styles — and mixing is normal: one captured
|
|
630
|
+
* emission spelled the opening tags with single bars and the closer with double.
|
|
631
|
+
*/
|
|
632
|
+
function prefixLengthAt(text, index) {
|
|
633
|
+
for (const prefix of MARKUP_PREFIXES) if (text.startsWith(prefix, index)) return prefix.length;
|
|
634
|
+
const normalized = normalizeFullwidth(text.slice(index, index + 8));
|
|
635
|
+
for (const prefix of ASCII_PREFIXES) if (normalized.startsWith(prefix)) return prefix.length;
|
|
636
|
+
return 0;
|
|
637
|
+
}
|
|
638
|
+
/** Is a single character usable inside a tag name? Mirrors Python's `isalnum()`. */
|
|
639
|
+
function isNameCharacter(text, index) {
|
|
640
|
+
return /[\p{L}\p{N}_-]/u.test(text.charAt(index));
|
|
641
|
+
}
|
|
642
|
+
/** Read a tag name at `index` (after any marker prefix). */
|
|
643
|
+
function readName(text, index) {
|
|
644
|
+
const prefixLength = prefixLengthAt(text, index);
|
|
645
|
+
const marked = prefixLength > 0;
|
|
646
|
+
let position = index + prefixLength;
|
|
647
|
+
while (position < text.length && /\s/.test(text.charAt(position))) position += 1;
|
|
648
|
+
const start = position;
|
|
649
|
+
while (position < text.length && isNameCharacter(text, position)) position += 1;
|
|
650
|
+
if (position === start) return {
|
|
651
|
+
name: "",
|
|
652
|
+
end: index,
|
|
653
|
+
marked
|
|
654
|
+
};
|
|
655
|
+
return {
|
|
656
|
+
name: text.slice(start, position).toLowerCase(),
|
|
657
|
+
end: position,
|
|
658
|
+
marked
|
|
659
|
+
};
|
|
660
|
+
}
|
|
661
|
+
/**
|
|
662
|
+
* Scan a tag at `index`, or nothing when there is not one.
|
|
663
|
+
*
|
|
664
|
+
* Any name is accepted (the model invents parameter tags freely); only the ones
|
|
665
|
+
* in {@link CANONICAL_TAG_NAMES} are normalised, and the misspellings are
|
|
666
|
+
* normalised only in a marker form — `<toolcalls>` alone is somebody's HTML.
|
|
667
|
+
*/
|
|
668
|
+
function scanTag(text, index) {
|
|
669
|
+
if (text.charAt(index) !== "<") return void 0;
|
|
670
|
+
let position = index + 1;
|
|
671
|
+
let closing = false;
|
|
672
|
+
if (text.charAt(position) === "/") {
|
|
673
|
+
closing = true;
|
|
674
|
+
position += 1;
|
|
675
|
+
}
|
|
676
|
+
while (position < text.length && /\s/.test(text.charAt(position))) position += 1;
|
|
677
|
+
const read = readName(text, position);
|
|
678
|
+
if (read.name === "") return void 0;
|
|
679
|
+
const canonical = CANONICAL_TAG_NAMES.get(read.name);
|
|
680
|
+
const name = canonical !== void 0 && !(MARKER_ONLY_TAG_NAMES.has(read.name) && !read.marked) ? canonical : read.name;
|
|
681
|
+
const attributesStart = read.end;
|
|
682
|
+
let selfClosing = false;
|
|
683
|
+
let cursor = read.end;
|
|
684
|
+
while (cursor < text.length) {
|
|
685
|
+
const character = text.charAt(cursor);
|
|
686
|
+
if (character === ">") return {
|
|
687
|
+
start: index,
|
|
688
|
+
end: cursor,
|
|
689
|
+
name,
|
|
690
|
+
closing,
|
|
691
|
+
selfClosing,
|
|
692
|
+
marked: read.marked,
|
|
693
|
+
attributes: text.slice(attributesStart, cursor).trim()
|
|
694
|
+
};
|
|
695
|
+
if (character === "/" && text.charAt(cursor + 1) === ">") {
|
|
696
|
+
selfClosing = true;
|
|
697
|
+
return {
|
|
698
|
+
start: index,
|
|
699
|
+
end: cursor + 1,
|
|
700
|
+
name,
|
|
701
|
+
closing,
|
|
702
|
+
selfClosing,
|
|
703
|
+
marked: read.marked,
|
|
704
|
+
attributes: text.slice(attributesStart, cursor).trim()
|
|
705
|
+
};
|
|
706
|
+
}
|
|
707
|
+
cursor += 1;
|
|
708
|
+
}
|
|
709
|
+
}
|
|
710
|
+
/** The next markup tag outside every ignored span, at or after `from`. */
|
|
711
|
+
function findTag(text, spans, from) {
|
|
712
|
+
const cursor = { at: 0 };
|
|
713
|
+
let index = Math.max(from, 0);
|
|
714
|
+
while (index < text.length) {
|
|
715
|
+
const skipped = skipIgnored(spans, index, cursor);
|
|
716
|
+
if (skipped !== index) {
|
|
717
|
+
index = skipped;
|
|
718
|
+
continue;
|
|
719
|
+
}
|
|
720
|
+
if (text.charAt(index) === "<") {
|
|
721
|
+
const tag = scanTag(text, index);
|
|
722
|
+
if (tag !== void 0) return tag;
|
|
723
|
+
}
|
|
724
|
+
index += 1;
|
|
725
|
+
}
|
|
726
|
+
}
|
|
727
|
+
/**
|
|
728
|
+
* The close tag matching `open`, or nothing when it has not arrived yet.
|
|
729
|
+
*
|
|
730
|
+
* Depth counting so a nested block of the same name cannot terminate its parent
|
|
731
|
+
* early. This is gate 1's teeth: no close, no call.
|
|
732
|
+
*/
|
|
733
|
+
function findMatchingClose(text, spans, open) {
|
|
734
|
+
let depth = 1;
|
|
735
|
+
let index = open.end + 1;
|
|
736
|
+
while (index < text.length) {
|
|
737
|
+
const tag = findTag(text, spans, index);
|
|
738
|
+
if (tag === void 0) return void 0;
|
|
739
|
+
if (tag.name === open.name) {
|
|
740
|
+
if (tag.closing) {
|
|
741
|
+
depth -= 1;
|
|
742
|
+
if (depth === 0) return tag;
|
|
743
|
+
} else if (!tag.selfClosing) depth += 1;
|
|
744
|
+
}
|
|
745
|
+
index = tag.end + 1;
|
|
746
|
+
}
|
|
747
|
+
}
|
|
748
|
+
/**
|
|
749
|
+
* The first block-opening tag in `text`, with its close when one has arrived.
|
|
750
|
+
*
|
|
751
|
+
* The close is OPTIONAL on purpose, and the two callers need it differently:
|
|
752
|
+
* the buffer holds from `open` while it is missing (gate 1 — the block may
|
|
753
|
+
* still complete), whereas parsing already-complete text stops rather than
|
|
754
|
+
* correcting a broken block and then trusting what follows it.
|
|
755
|
+
*/
|
|
756
|
+
function findFirstOpener(text, spans) {
|
|
757
|
+
let index = 0;
|
|
758
|
+
while (index < text.length) {
|
|
759
|
+
const tag = findTag(text, spans, index);
|
|
760
|
+
if (tag === void 0) return void 0;
|
|
761
|
+
if (!tag.closing && BLOCK_TAGS.includes(tag.name)) {
|
|
762
|
+
const close = findMatchingClose(text, spans, tag);
|
|
763
|
+
return close === void 0 ? { open: tag } : {
|
|
764
|
+
open: tag,
|
|
765
|
+
close
|
|
766
|
+
};
|
|
767
|
+
}
|
|
768
|
+
index = tag.end + 1;
|
|
769
|
+
}
|
|
770
|
+
}
|
|
771
|
+
/** Decode the five predefined XML entities plus numeric references. */
|
|
772
|
+
function decodeEntities(text) {
|
|
773
|
+
if (!text.includes("&")) return text;
|
|
774
|
+
const named = /* @__PURE__ */ new Map([
|
|
775
|
+
["amp", "&"],
|
|
776
|
+
["lt", "<"],
|
|
777
|
+
["gt", ">"],
|
|
778
|
+
["quot", "\""],
|
|
779
|
+
["apos", "'"]
|
|
780
|
+
]);
|
|
781
|
+
return text.replace(/&(#[0-9]+|#x[0-9a-f]+|[a-z]+);/gi, (whole, body) => {
|
|
782
|
+
if (body.startsWith("#")) {
|
|
783
|
+
const hex = body.charAt(1).toLowerCase() === "x";
|
|
784
|
+
const digits = hex ? body.slice(2) : body.slice(1);
|
|
785
|
+
const code = Number.parseInt(digits, hex ? 16 : 10);
|
|
786
|
+
if (!Number.isFinite(code) || code <= 0 || code > 1114111) return whole;
|
|
787
|
+
return String.fromCodePoint(code);
|
|
788
|
+
}
|
|
789
|
+
return named.get(body.toLowerCase()) ?? whole;
|
|
790
|
+
});
|
|
791
|
+
}
|
|
792
|
+
/** `name="value"` / `name='value'` pairs, entities decoded. */
|
|
793
|
+
function parseAttributes(text) {
|
|
794
|
+
const attributes = {};
|
|
795
|
+
const pattern = /([a-z0-9_:-]+)\s*=\s*(?:"([^"]*)"|'([^']*)')/gi;
|
|
796
|
+
let match = pattern.exec(text);
|
|
797
|
+
while (match !== null) {
|
|
798
|
+
const key = match[1];
|
|
799
|
+
const value = match[2] ?? match[3];
|
|
800
|
+
if (key !== void 0 && value !== void 0) attributes[key] = decodeEntities(value);
|
|
801
|
+
match = pattern.exec(text);
|
|
802
|
+
}
|
|
803
|
+
return attributes;
|
|
804
|
+
}
|
|
805
|
+
/**
|
|
806
|
+
* Parse the parameters inside one block body.
|
|
807
|
+
*
|
|
808
|
+
* Two shapes, in the order the model prefers them:
|
|
809
|
+
* - `<|DSML|parameter name="cmd">value</…parameter>` (the explicit form);
|
|
810
|
+
* - `<cmd>value</cmd>` (the tag name IS the parameter name), used only when
|
|
811
|
+
* the explicit form has not already claimed that name — otherwise a nested
|
|
812
|
+
* tag echoing it would overwrite the real value.
|
|
813
|
+
*
|
|
814
|
+
* A `CDATA` body is taken verbatim; everything else is entity-decoded.
|
|
815
|
+
*
|
|
816
|
+
* Every scanner here computes its ignored spans from the string it is about to
|
|
817
|
+
* scan. Span offsets are only meaningful against the text they were measured
|
|
818
|
+
* on, and a body is a substring of its parent — reusing the parent's offsets
|
|
819
|
+
* would misplace every fence and silently turn "quoted markup" into "called
|
|
820
|
+
* markup", which is gate 4's worst failure. Each of these strings is small, so
|
|
821
|
+
* recomputing is cheaper than passing a coordinate system around.
|
|
822
|
+
*/
|
|
823
|
+
function parseParameters(body) {
|
|
824
|
+
const parameters = {};
|
|
825
|
+
const spans = ignoredSpans(body);
|
|
826
|
+
let index = 0;
|
|
827
|
+
while (index < body.length) {
|
|
828
|
+
const tag = findTag(body, spans, index);
|
|
829
|
+
if (tag === void 0) break;
|
|
830
|
+
if (tag.closing || tag.selfClosing) {
|
|
831
|
+
index = tag.end + 1;
|
|
832
|
+
continue;
|
|
833
|
+
}
|
|
834
|
+
const close = findMatchingClose(body, spans, tag);
|
|
835
|
+
if (close === void 0) {
|
|
836
|
+
index = tag.end + 1;
|
|
837
|
+
continue;
|
|
838
|
+
}
|
|
839
|
+
const value = body.slice(tag.end + 1, close.start).trim();
|
|
840
|
+
const attributes = parseAttributes(tag.attributes);
|
|
841
|
+
const explicitName = tag.name === "parameter" ? attributes["name"] : void 0;
|
|
842
|
+
const name = explicitName ?? tag.name;
|
|
843
|
+
if (name !== void 0 && name !== "" && (explicitName !== void 0 || parameters[name] === void 0)) parameters[name] = value.startsWith("<![CDATA[") && value.endsWith("]]>") ? value.slice(9, -3) : nestedOrText(value);
|
|
844
|
+
index = close.end + 1;
|
|
845
|
+
}
|
|
846
|
+
return parameters;
|
|
847
|
+
}
|
|
848
|
+
/** A value that itself contains a complete tag pair is a nested object. */
|
|
849
|
+
function nestedOrText(value) {
|
|
850
|
+
if (!value.includes("<") || !value.includes(">")) return decodeEntities(value);
|
|
851
|
+
const nested = parseParameters(value);
|
|
852
|
+
return Object.keys(nested).length > 0 ? nested : decodeEntities(value);
|
|
853
|
+
}
|
|
854
|
+
/**
|
|
855
|
+
* One block's worth of text → the calls it declares, or nothing.
|
|
856
|
+
*
|
|
857
|
+
* This is where gates 2 and 4 live. Returning nothing is the COMMON case for
|
|
858
|
+
* mistaken input and is not an error: callers must show the block verbatim.
|
|
859
|
+
*/
|
|
860
|
+
function parseBlock(block, gate) {
|
|
861
|
+
const calls = [];
|
|
862
|
+
const spans = ignoredSpans(block);
|
|
863
|
+
let index = 0;
|
|
864
|
+
while (index < block.length) {
|
|
865
|
+
const tag = findTag(block, spans, index);
|
|
866
|
+
if (tag === void 0) break;
|
|
867
|
+
if (!tag.closing && tag.name === "invoke") {
|
|
868
|
+
const close = findMatchingClose(block, spans, tag);
|
|
869
|
+
if (close === void 0) break;
|
|
870
|
+
const call = parseInvoke(tag, close, block, gate);
|
|
871
|
+
if (call !== void 0) calls.push(call);
|
|
872
|
+
index = close.end + 1;
|
|
873
|
+
continue;
|
|
874
|
+
}
|
|
875
|
+
index = tag.end + 1;
|
|
876
|
+
}
|
|
877
|
+
return calls;
|
|
878
|
+
}
|
|
879
|
+
/** One `<invoke>` element → a call, or nothing when a gate refuses it. */
|
|
880
|
+
function parseInvoke(open, close, block, gate) {
|
|
881
|
+
const name = parseAttributes(open.attributes)["name"];
|
|
882
|
+
if (name === void 0 || name === "") return void 0;
|
|
883
|
+
if (!gate.declaredNames.has(name)) return void 0;
|
|
884
|
+
const pinned = gate.pinnedToolName;
|
|
885
|
+
if (pinned !== void 0 && pinned !== "auto" && pinned !== "required" && pinned !== "none" && pinned !== name) return;
|
|
886
|
+
const parameters = parseParameters(block.slice(open.end + 1, close.start));
|
|
887
|
+
const required = gate.requiredParameters?.get(name);
|
|
888
|
+
if (required !== void 0 && required.some((parameter) => parameters[parameter] === void 0)) return void 0;
|
|
889
|
+
return {
|
|
890
|
+
id: `call_${randomUUID().replace(/-/g, "").slice(0, 24)}`,
|
|
891
|
+
name,
|
|
892
|
+
arguments: JSON.stringify(parameters)
|
|
893
|
+
};
|
|
894
|
+
}
|
|
895
|
+
/**
|
|
896
|
+
* `text` with every markup block removed — the check behind {@link hasProse}.
|
|
897
|
+
*
|
|
898
|
+
* ANY marker-bearing tag counts as markup, whatever it is called. Keying on the
|
|
899
|
+
* known names alone was the first attempt and it got the one shape this project
|
|
900
|
+
* has actually captured wrong: `<|DSML| validate>` carries an invented name
|
|
901
|
+
* (`validate` is the TOOL name, written where `invoke name="…"` belongs), so a
|
|
902
|
+
* keyword-only rule left it standing, the response was classified as prose, and
|
|
903
|
+
* the retry-when-the-turn-produced-nothing rule could never fire for the very
|
|
904
|
+
* leak it was written for — a check that never fires, which is the trap this
|
|
905
|
+
* project has already paid for twice.
|
|
906
|
+
*
|
|
907
|
+
* Removal reaches the matching close only for names that bracket content
|
|
908
|
+
* ({@link KEYWORD_TAGS}); an invented name removes just its own tag, because
|
|
909
|
+
* nothing says it opens anything.
|
|
910
|
+
*
|
|
911
|
+
* Ignored spans are KEPT: a fenced or back-ticked quotation of the marker is
|
|
912
|
+
* prose, and the rule is explicitly about a turn that produced nothing usable.
|
|
913
|
+
*/
|
|
914
|
+
function stripMarkup(text) {
|
|
915
|
+
const spans = ignoredSpans(text);
|
|
916
|
+
const cursor = { at: 0 };
|
|
917
|
+
let result = "";
|
|
918
|
+
let index = 0;
|
|
919
|
+
while (index < text.length) {
|
|
920
|
+
const skipped = skipIgnored(spans, index, cursor);
|
|
921
|
+
if (skipped !== index) {
|
|
922
|
+
result += text.slice(index, skipped);
|
|
923
|
+
index = skipped;
|
|
924
|
+
continue;
|
|
925
|
+
}
|
|
926
|
+
const tag = text.charAt(index) === "<" ? scanTag(text, index) : void 0;
|
|
927
|
+
if (tag === void 0 || !tag.marked) {
|
|
928
|
+
result += text.charAt(index);
|
|
929
|
+
index += 1;
|
|
930
|
+
continue;
|
|
931
|
+
}
|
|
932
|
+
const close = !tag.closing && !tag.selfClosing && KEYWORD_TAGS.includes(tag.name) ? findMatchingClose(text, spans, tag) : void 0;
|
|
933
|
+
index = close === void 0 ? tag.end + 1 : close.end + 1;
|
|
934
|
+
}
|
|
935
|
+
return result;
|
|
936
|
+
}
|
|
937
|
+
/**
|
|
938
|
+
* Does `text` carry content outside markup blocks?
|
|
939
|
+
*
|
|
940
|
+
* This is what makes "the turn produced nothing usable" a checkable fact rather
|
|
941
|
+
* than a guess: a response whose only content is markup residue returns false,
|
|
942
|
+
* and the caller may then consider a single retry. Text inside a fence or code
|
|
943
|
+
* span counts as content — a documented quotation of the marker is prose.
|
|
944
|
+
*/
|
|
945
|
+
function hasProse(text) {
|
|
946
|
+
return stripMarkup(text).trim() !== "";
|
|
947
|
+
}
|
|
948
|
+
/**
|
|
949
|
+
* Which of `text`'s tail must wait for more bytes.
|
|
950
|
+
*
|
|
951
|
+
* Two reasons to hold, and only these two:
|
|
952
|
+
* - the tail starts a tag that is not closed yet, and could still turn out to
|
|
953
|
+
* be a block opener (`…<|DSML|inv`), so committing it as text would make
|
|
954
|
+
* the block unparseable;
|
|
955
|
+
* - the tail opens a code span whose closer is still in flight, because
|
|
956
|
+
* whether the following markup is *quoted* depends on that span.
|
|
957
|
+
*
|
|
958
|
+
* Anything else is emitted immediately. In particular a COMPLETE tag that is
|
|
959
|
+
* not a block opener — `<|DSML| validate>`, the shape this project actually
|
|
960
|
+
* captured — is not held: it is prose as far as this layer is concerned, and
|
|
961
|
+
* holding it would delay every ordinary answer that mentions the markup.
|
|
962
|
+
*/
|
|
963
|
+
function holdFrom(text, spans) {
|
|
964
|
+
let hold = text.length;
|
|
965
|
+
const lastLt = text.lastIndexOf("<");
|
|
966
|
+
if (lastLt !== -1 && looksLikeTagStart(text.slice(lastLt))) hold = Math.min(hold, lastLt);
|
|
967
|
+
for (const span of spans) if (span.open) hold = Math.min(hold, span.start);
|
|
968
|
+
return hold;
|
|
969
|
+
}
|
|
970
|
+
/**
|
|
971
|
+
* Could this fragment be the beginning of a block-opening tag?
|
|
972
|
+
*
|
|
973
|
+
* Conservative by construction: it requires an unterminated `<`, and after
|
|
974
|
+
* stripping an optional `/`, whitespace and marker, the remainder must be a
|
|
975
|
+
* prefix of (or extend) a known opener. That is what keeps `a < b`, `<50%` and
|
|
976
|
+
* `i < n` in ordinary prose from stalling a stream — each of them either
|
|
977
|
+
* contains a `>` or fails the opener test.
|
|
978
|
+
*
|
|
979
|
+
* A marker that is STILL ARRIVING also holds (`<|`, `<|DSML`, `||D`). Without
|
|
980
|
+
* that case the leading `<|` is emitted as text the moment it arrives, and the
|
|
981
|
+
* block can never be recovered afterwards because its opening tag has already
|
|
982
|
+
* been split in two — a byte-by-byte feed is how this was found, and the
|
|
983
|
+
* reference implementation has the same hole.
|
|
984
|
+
*/
|
|
985
|
+
function looksLikeTagStart(tail) {
|
|
986
|
+
if (!tail.startsWith("<")) return false;
|
|
987
|
+
if (tail.includes(">")) return false;
|
|
988
|
+
let body = tail.slice(1);
|
|
989
|
+
if (body.startsWith("/")) body = body.slice(1);
|
|
990
|
+
if (body === "" || isPartialMarker(body)) return true;
|
|
991
|
+
const prefixLength = prefixLengthAt(body, 0);
|
|
992
|
+
body = body.slice(prefixLength).trim().toLowerCase();
|
|
993
|
+
if (body === "" || isPartialMarker(body)) return true;
|
|
994
|
+
return [
|
|
995
|
+
"tool",
|
|
996
|
+
"invoke",
|
|
997
|
+
"tool_calls",
|
|
998
|
+
"tool-calls",
|
|
999
|
+
"toolcalls",
|
|
1000
|
+
"tool_call",
|
|
1001
|
+
"tool-call"
|
|
1002
|
+
].some((opener) => opener.startsWith(body) || body.startsWith(opener));
|
|
1003
|
+
}
|
|
1004
|
+
/**
|
|
1005
|
+
* Is this the start of a marker whose remainder has not arrived yet?
|
|
1006
|
+
*
|
|
1007
|
+
* Compared both literally and full-width-normalised, because the model mixes
|
|
1008
|
+
* the two bar styles and a mixture splits at an unpredictable character.
|
|
1009
|
+
*/
|
|
1010
|
+
function isPartialMarker(body) {
|
|
1011
|
+
const trimmed = body.trimStart();
|
|
1012
|
+
if (trimmed === "") return true;
|
|
1013
|
+
const forms = [...MARKUP_PREFIXES, ...ASCII_PREFIXES];
|
|
1014
|
+
return [trimmed, normalizeFullwidth(trimmed.slice(0, 8))].some((candidate) => candidate !== "" && forms.some((form) => form.startsWith(candidate) && candidate.length < form.length));
|
|
1015
|
+
}
|
|
1016
|
+
/** Full-width ASCII (U+FF01..U+FF5E) mapped to its ASCII counterpart. */
|
|
1017
|
+
function normalizeFullwidth(text) {
|
|
1018
|
+
let normalized = "";
|
|
1019
|
+
for (const character of text) {
|
|
1020
|
+
const code = character.codePointAt(0) ?? 0;
|
|
1021
|
+
normalized += code >= 65281 && code <= 65374 ? String.fromCodePoint(code - 65248) : character;
|
|
1022
|
+
}
|
|
1023
|
+
return normalized;
|
|
1024
|
+
}
|
|
1025
|
+
/**
|
|
1026
|
+
* Buffers `delta.content` and commits a call only when the block that declares
|
|
1027
|
+
* it is complete.
|
|
1028
|
+
*
|
|
1029
|
+
* Ported from the reference's `ToolCallStreamBuffer` with its hard-won
|
|
1030
|
+
* behaviour intact:
|
|
1031
|
+
*
|
|
1032
|
+
* - a block with no close tag yet holds the ENTIRE block (not a partial
|
|
1033
|
+
* guess), and only the tail that could still become a tag is held back
|
|
1034
|
+
* from otherwise-finished text;
|
|
1035
|
+
* - a block that fails the gates is emitted VERBATIM (gate 4), not dropped;
|
|
1036
|
+
* - {@link flush} hands back whatever is still held, so a truncated block
|
|
1037
|
+
* loses none of its text.
|
|
1038
|
+
*
|
|
1039
|
+
* The reference's own comment explains why the tail rule is not "hold
|
|
1040
|
+
* everything from the first `<`": an architecture document that shows
|
|
1041
|
+
* `<tool_calls><invoke>` as an EXAMPLE would otherwise starve the rest of the
|
|
1042
|
+
* response until the stream ended, and then lose it.
|
|
1043
|
+
*/
|
|
1044
|
+
var DsmlStreamBuffer = class {
|
|
1045
|
+
pending = "";
|
|
1046
|
+
gate;
|
|
1047
|
+
constructor(gate) {
|
|
1048
|
+
this.gate = gate;
|
|
1049
|
+
}
|
|
1050
|
+
/** Feed one `delta.content` chunk; returns text and any call it completed. */
|
|
1051
|
+
add(chunk) {
|
|
1052
|
+
this.pending += chunk;
|
|
1053
|
+
const parts = [];
|
|
1054
|
+
const calls = [];
|
|
1055
|
+
if (this.gate.declaredNames.size === 0) {
|
|
1056
|
+
const text = this.pending;
|
|
1057
|
+
this.pending = "";
|
|
1058
|
+
return {
|
|
1059
|
+
text,
|
|
1060
|
+
prose: hasProse(text)
|
|
1061
|
+
};
|
|
1062
|
+
}
|
|
1063
|
+
for (;;) {
|
|
1064
|
+
const found = findFirstOpener(this.pending, ignoredSpans(this.pending));
|
|
1065
|
+
if (found === void 0) break;
|
|
1066
|
+
const head = this.pending.slice(0, found.open.start);
|
|
1067
|
+
if (head !== "") parts.push(head);
|
|
1068
|
+
if (found.close === void 0) {
|
|
1069
|
+
this.pending = this.pending.slice(found.open.start);
|
|
1070
|
+
return finish(parts, calls);
|
|
1071
|
+
}
|
|
1072
|
+
const body = this.pending.slice(found.open.start, found.close.end + 1);
|
|
1073
|
+
const recovered = parseBlock(body, this.gate);
|
|
1074
|
+
if (recovered.length > 0) calls.push(...recovered);
|
|
1075
|
+
else parts.push(body);
|
|
1076
|
+
this.pending = this.pending.slice(found.close.end + 1);
|
|
1077
|
+
}
|
|
1078
|
+
const hold = holdFrom(this.pending, ignoredSpans(this.pending));
|
|
1079
|
+
if (hold > 0) parts.push(this.pending.slice(0, hold));
|
|
1080
|
+
this.pending = this.pending.slice(hold);
|
|
1081
|
+
return finish(parts, calls);
|
|
1082
|
+
}
|
|
1083
|
+
/** End of stream: hand back everything still held, verbatim. */
|
|
1084
|
+
flush() {
|
|
1085
|
+
const text = this.pending;
|
|
1086
|
+
this.pending = "";
|
|
1087
|
+
return {
|
|
1088
|
+
text,
|
|
1089
|
+
prose: hasProse(text)
|
|
1090
|
+
};
|
|
1091
|
+
}
|
|
1092
|
+
};
|
|
1093
|
+
/** Assemble one `add()` outcome without ever emitting a `calls: undefined` key. */
|
|
1094
|
+
function finish(parts, calls) {
|
|
1095
|
+
const text = parts.join("");
|
|
1096
|
+
const outcome = {
|
|
1097
|
+
text,
|
|
1098
|
+
prose: hasProse(text)
|
|
1099
|
+
};
|
|
1100
|
+
if (calls.length > 0) outcome.calls = [...calls];
|
|
1101
|
+
return outcome;
|
|
1102
|
+
}
|
|
350
1103
|
//#endregion
|
|
351
1104
|
//#region src/shim.ts
|
|
352
1105
|
/**
|
|
@@ -452,12 +1205,352 @@ function readBody(req) {
|
|
|
452
1205
|
});
|
|
453
1206
|
}
|
|
454
1207
|
/**
|
|
1208
|
+
* Rewrites the upstream SSE stream, converting markup the model wrote into the
|
|
1209
|
+
* text back into a real `delta.tool_calls` — and, when a whole turn produced
|
|
1210
|
+
* nothing but markup, holding the bytes long enough to retry ONCE.
|
|
1211
|
+
*
|
|
1212
|
+
* ============================================================================
|
|
1213
|
+
* Why this class exists rather than a `.pipe()`
|
|
1214
|
+
* ============================================================================
|
|
1215
|
+
*
|
|
1216
|
+
* Recovering a call means inspecting `delta.content` before the client sees it,
|
|
1217
|
+
* which the old `body.pipe(res)` could not do. The parsing rules live in
|
|
1218
|
+
* `src/dsml-recovery.ts`; everything here is about the STREAM's obligations:
|
|
1219
|
+
*
|
|
1220
|
+
* 1. FRAME ORDER IS NEVER CHANGED. Frames are forwarded in arrival order.
|
|
1221
|
+
* The only frame this class invents is the trailing one that carries a
|
|
1222
|
+
* block still unfinished at end-of-stream.
|
|
1223
|
+
*
|
|
1224
|
+
* 2. HOLDS ARE TEMPORARY AND PAY FOR THEMSELVES. Nothing is held unless the
|
|
1225
|
+
* answer so far consists only of markup: the moment real prose or a real
|
|
1226
|
+
* call appears, everything held is flushed in order and the stream returns
|
|
1227
|
+
* to plain pass-through. An ordinary answer therefore pays no latency at
|
|
1228
|
+
* all, and no byte is ever discarded while a retry is still possible.
|
|
1229
|
+
*
|
|
1230
|
+
* 3. A RETRY MUST NOT SPLICE TWO ANSWERS TOGETHER. That constraint is why the
|
|
1231
|
+
* hold exists: because no content frame has been written yet, the caller
|
|
1232
|
+
* can discard this attempt and re-send the same body. The existing
|
|
1233
|
+
* failover loop makes the same promise for failures BEFORE the stream
|
|
1234
|
+
* starts (see its comment in `chatCompletions`); this extends it to the
|
|
1235
|
+
* one case that can only be recognised after the fact.
|
|
1236
|
+
*
|
|
1237
|
+
* 4. NOTHING IS INVENTED WHEN THE UPSTREAM ALREADY SPEAKS STRUCTURED CALLS.
|
|
1238
|
+
* Once a native `delta.tool_calls` appears, recovery switches off for the
|
|
1239
|
+
* rest of the response — two live call channels for one answer is worse
|
|
1240
|
+
* than either one alone.
|
|
1241
|
+
*/
|
|
1242
|
+
var RecoveryStream = class {
|
|
1243
|
+
res;
|
|
1244
|
+
logger;
|
|
1245
|
+
carry = "";
|
|
1246
|
+
decoder = new TextDecoder("utf-8");
|
|
1247
|
+
/** Frames received after the hold began, in arrival order. */
|
|
1248
|
+
held = [];
|
|
1249
|
+
/**
|
|
1250
|
+
* The hold window, as two separate facts.
|
|
1251
|
+
*
|
|
1252
|
+
* `windowOpened` becomes true at the FIRST content-bearing frame and stays
|
|
1253
|
+
* true: before it, frames carry no answer (a role delta, a keep-alive) and go
|
|
1254
|
+
* straight out; from it on, they are held until the answer is known to be
|
|
1255
|
+
* real. `windowClosed` means "an answer was delivered", after which every
|
|
1256
|
+
* frame passes through again — for the rest of the response, because a turn
|
|
1257
|
+
* that has already produced prose can no longer be replaced by a retry.
|
|
1258
|
+
*/
|
|
1259
|
+
windowOpened = false;
|
|
1260
|
+
windowClosed = false;
|
|
1261
|
+
wroteContent = false;
|
|
1262
|
+
sawResidue = false;
|
|
1263
|
+
sawDone = false;
|
|
1264
|
+
/**
|
|
1265
|
+
* Set once the upstream speaks structured calls.
|
|
1266
|
+
*
|
|
1267
|
+
* Sticky for the whole response on purpose: recovering markup into a second
|
|
1268
|
+
* call channel after the upstream already produced a real one would hand the
|
|
1269
|
+
* client two answers to the same question.
|
|
1270
|
+
*/
|
|
1271
|
+
nativeCallsSeen = false;
|
|
1272
|
+
buffer;
|
|
1273
|
+
constructor(res, gate, logger) {
|
|
1274
|
+
this.res = res;
|
|
1275
|
+
this.logger = logger;
|
|
1276
|
+
this.buffer = gate === void 0 ? void 0 : new DsmlStreamBuffer(gate);
|
|
1277
|
+
}
|
|
1278
|
+
/** True when a call or real prose has reached the client. */
|
|
1279
|
+
get delivered() {
|
|
1280
|
+
return this.wroteContent;
|
|
1281
|
+
}
|
|
1282
|
+
/** True when the held bytes contain markup the gates refused. */
|
|
1283
|
+
get residueSeen() {
|
|
1284
|
+
return this.sawResidue;
|
|
1285
|
+
}
|
|
1286
|
+
/** Record that text the gates refused was seen (markup residue, not prose). */
|
|
1287
|
+
noteResidue(seen) {
|
|
1288
|
+
this.sawResidue ||= seen;
|
|
1289
|
+
}
|
|
1290
|
+
/** Drain one upstream body into the client. */
|
|
1291
|
+
async consume(body) {
|
|
1292
|
+
if (body === null) return;
|
|
1293
|
+
const source = Readable.fromWeb(body);
|
|
1294
|
+
try {
|
|
1295
|
+
for await (const chunk of source) this.push(this.decoder.decode(chunk, { stream: true }));
|
|
1296
|
+
} catch (error) {
|
|
1297
|
+
this.logger?.warn("dsh-connect-workbuddy: upstream stream failed mid-flight", error);
|
|
1298
|
+
}
|
|
1299
|
+
const tail = this.carry;
|
|
1300
|
+
this.carry = "";
|
|
1301
|
+
if (tail !== "") this.handleFrame(tail);
|
|
1302
|
+
}
|
|
1303
|
+
/**
|
|
1304
|
+
* Close the response: emit the block still unfinished at end-of-stream, then
|
|
1305
|
+
* everything held, then the terminator.
|
|
1306
|
+
*/
|
|
1307
|
+
finish() {
|
|
1308
|
+
const flushed = this.buffer?.flush();
|
|
1309
|
+
if (flushed !== void 0 && flushed.text !== "") {
|
|
1310
|
+
this.noteResidue(!flushed.prose);
|
|
1311
|
+
this.emitContent(flushed.text);
|
|
1312
|
+
}
|
|
1313
|
+
this.closeWindow();
|
|
1314
|
+
if (!this.sawDone && this.res.writable) this.res.write("data: [DONE]\n\n");
|
|
1315
|
+
if (this.res.writable) this.res.end();
|
|
1316
|
+
}
|
|
1317
|
+
/** Drop held bytes without writing them (used when a retry replaced them). */
|
|
1318
|
+
discard() {
|
|
1319
|
+
this.held = [];
|
|
1320
|
+
this.windowClosed = true;
|
|
1321
|
+
}
|
|
1322
|
+
/** Split incoming bytes into whole SSE frames, handling chunk boundaries. */
|
|
1323
|
+
push(text) {
|
|
1324
|
+
this.carry += text;
|
|
1325
|
+
let start = 0;
|
|
1326
|
+
for (;;) {
|
|
1327
|
+
const newline = this.carry.indexOf("\n", start);
|
|
1328
|
+
if (newline === -1) break;
|
|
1329
|
+
if (this.carry.slice(start, newline).trim() === "") {
|
|
1330
|
+
const frame = this.carry.slice(0, newline + 1);
|
|
1331
|
+
this.carry = this.carry.slice(newline + 1);
|
|
1332
|
+
start = 0;
|
|
1333
|
+
this.handleFrame(frame);
|
|
1334
|
+
continue;
|
|
1335
|
+
}
|
|
1336
|
+
start = newline + 1;
|
|
1337
|
+
}
|
|
1338
|
+
}
|
|
1339
|
+
/** Handle one complete frame, rewriting it only when recovery produced something. */
|
|
1340
|
+
handleFrame(frame) {
|
|
1341
|
+
const payload = dataPayload(frame);
|
|
1342
|
+
if (payload === void 0) {
|
|
1343
|
+
this.send(frame, false);
|
|
1344
|
+
return;
|
|
1345
|
+
}
|
|
1346
|
+
if (payload === "[DONE]") {
|
|
1347
|
+
this.sawDone = true;
|
|
1348
|
+
this.send(frame, false);
|
|
1349
|
+
return;
|
|
1350
|
+
}
|
|
1351
|
+
let chunk;
|
|
1352
|
+
try {
|
|
1353
|
+
chunk = JSON.parse(payload);
|
|
1354
|
+
} catch {
|
|
1355
|
+
this.send(frame, false);
|
|
1356
|
+
return;
|
|
1357
|
+
}
|
|
1358
|
+
const choices = chunk["choices"];
|
|
1359
|
+
const choice = Array.isArray(choices) ? choices[0] : void 0;
|
|
1360
|
+
if (typeof choice !== "object" || choice === null || Array.isArray(choice)) {
|
|
1361
|
+
this.send(frame, false);
|
|
1362
|
+
return;
|
|
1363
|
+
}
|
|
1364
|
+
const choiceRecord = choice;
|
|
1365
|
+
const deltaValue = choiceRecord["delta"];
|
|
1366
|
+
const delta = typeof deltaValue === "object" && deltaValue !== null && !Array.isArray(deltaValue) ? deltaValue : void 0;
|
|
1367
|
+
if (delta === void 0) {
|
|
1368
|
+
this.send(frame, false);
|
|
1369
|
+
return;
|
|
1370
|
+
}
|
|
1371
|
+
const nativeCalls = delta["tool_calls"];
|
|
1372
|
+
if (Array.isArray(nativeCalls) && nativeCalls.length > 0) {
|
|
1373
|
+
this.nativeCallsSeen = true;
|
|
1374
|
+
this.windowOpened = true;
|
|
1375
|
+
this.send(frame, false);
|
|
1376
|
+
this.wroteContent = true;
|
|
1377
|
+
this.closeWindow();
|
|
1378
|
+
return;
|
|
1379
|
+
}
|
|
1380
|
+
const content = delta["content"];
|
|
1381
|
+
if (typeof content !== "string" || content === "") {
|
|
1382
|
+
const reason = finishReasonOf(choiceRecord);
|
|
1383
|
+
if (reason !== "") this.drainBuffer();
|
|
1384
|
+
this.send(frame, reason !== "");
|
|
1385
|
+
return;
|
|
1386
|
+
}
|
|
1387
|
+
if (this.buffer === void 0 || this.nativeCallsSeen) {
|
|
1388
|
+
this.windowOpened = true;
|
|
1389
|
+
this.closeWindow();
|
|
1390
|
+
this.res.write(frame);
|
|
1391
|
+
this.wroteContent = true;
|
|
1392
|
+
return;
|
|
1393
|
+
}
|
|
1394
|
+
this.windowOpened = true;
|
|
1395
|
+
const outcome = this.buffer.add(content);
|
|
1396
|
+
if (outcome.calls !== void 0 && outcome.calls.length > 0) {
|
|
1397
|
+
this.send(callsFrame(chunk, choiceRecord, outcome.calls), false);
|
|
1398
|
+
this.wroteContent = true;
|
|
1399
|
+
this.closeWindow();
|
|
1400
|
+
if (outcome.text !== "") this.res.write(contentFrame(chunk, choiceRecord, outcome.text));
|
|
1401
|
+
return;
|
|
1402
|
+
}
|
|
1403
|
+
this.noteResidue(!outcome.prose && outcome.text !== "");
|
|
1404
|
+
if (outcome.text === "") {
|
|
1405
|
+
this.send(contentFrame(chunk, choiceRecord, ""), false);
|
|
1406
|
+
return;
|
|
1407
|
+
}
|
|
1408
|
+
if (outcome.prose) {
|
|
1409
|
+
this.send(contentFrame(chunk, choiceRecord, outcome.text), false);
|
|
1410
|
+
this.wroteContent = true;
|
|
1411
|
+
this.closeWindow();
|
|
1412
|
+
return;
|
|
1413
|
+
}
|
|
1414
|
+
this.send(contentFrame(chunk, choiceRecord, outcome.text), false);
|
|
1415
|
+
}
|
|
1416
|
+
/**
|
|
1417
|
+
* Hand back whatever the buffer is still holding, right now.
|
|
1418
|
+
*
|
|
1419
|
+
* Called before a terminator frame is forwarded. `flush()` ends the buffer's
|
|
1420
|
+
* attempt — if content somehow keeps arriving afterwards it starts a fresh
|
|
1421
|
+
* one, which is the correct reading of a stream that declared itself finished.
|
|
1422
|
+
*/
|
|
1423
|
+
drainBuffer() {
|
|
1424
|
+
const flushed = this.buffer?.flush();
|
|
1425
|
+
if (flushed === void 0 || flushed.text === "") return;
|
|
1426
|
+
this.noteResidue(!flushed.prose);
|
|
1427
|
+
this.emitContent(flushed.text);
|
|
1428
|
+
}
|
|
1429
|
+
/**
|
|
1430
|
+
* Emit text the buffer was still holding when the stream ended.
|
|
1431
|
+
*
|
|
1432
|
+
* It goes through `send` + `closeWindow` rather than straight to the socket on
|
|
1433
|
+
* purpose: a `finish_reason` frame may already be queued, and this text is
|
|
1434
|
+
* content that logically precedes it. Writing directly would put the answer
|
|
1435
|
+
* after its own terminator — a client that stops accumulating at
|
|
1436
|
+
* `finish_reason` would silently lose the tail.
|
|
1437
|
+
*/
|
|
1438
|
+
emitContent(text) {
|
|
1439
|
+
const chunk = { choices: [{
|
|
1440
|
+
index: 0,
|
|
1441
|
+
delta: {},
|
|
1442
|
+
finish_reason: ""
|
|
1443
|
+
}] };
|
|
1444
|
+
const choiceRecord = {
|
|
1445
|
+
index: 0,
|
|
1446
|
+
delta: {},
|
|
1447
|
+
finish_reason: ""
|
|
1448
|
+
};
|
|
1449
|
+
this.windowOpened = true;
|
|
1450
|
+
const frame = contentFrame(chunk, choiceRecord, text);
|
|
1451
|
+
if (text.trim() !== "") {
|
|
1452
|
+
this.send(frame, false);
|
|
1453
|
+
this.wroteContent = true;
|
|
1454
|
+
this.closeWindow();
|
|
1455
|
+
return;
|
|
1456
|
+
}
|
|
1457
|
+
this.send(frame, false);
|
|
1458
|
+
}
|
|
1459
|
+
/** Queue a frame while the window is open, or write it once it has closed. */
|
|
1460
|
+
send(frame, final) {
|
|
1461
|
+
if (this.windowClosed || !this.windowOpened) {
|
|
1462
|
+
this.res.write(frame);
|
|
1463
|
+
return;
|
|
1464
|
+
}
|
|
1465
|
+
const entry = {
|
|
1466
|
+
frame,
|
|
1467
|
+
final
|
|
1468
|
+
};
|
|
1469
|
+
if (!final) {
|
|
1470
|
+
const at = this.held.findIndex((item) => item.final);
|
|
1471
|
+
if (at !== -1) {
|
|
1472
|
+
this.held.splice(at, 0, entry);
|
|
1473
|
+
return;
|
|
1474
|
+
}
|
|
1475
|
+
}
|
|
1476
|
+
this.held.push(entry);
|
|
1477
|
+
}
|
|
1478
|
+
/**
|
|
1479
|
+
* An answer has been delivered: flush everything held, in order, and never
|
|
1480
|
+
* hold again for this response.
|
|
1481
|
+
*/
|
|
1482
|
+
closeWindow() {
|
|
1483
|
+
this.windowClosed = true;
|
|
1484
|
+
const pending = this.held;
|
|
1485
|
+
this.held = [];
|
|
1486
|
+
for (const entry of pending) this.res.write(entry.frame);
|
|
1487
|
+
}
|
|
1488
|
+
};
|
|
1489
|
+
/** The SSE `data:` payload of one frame, or nothing when it carries none. */
|
|
1490
|
+
function dataPayload(frame) {
|
|
1491
|
+
for (const line of frame.split("\n")) {
|
|
1492
|
+
const trimmed = line.trimEnd();
|
|
1493
|
+
if (!trimmed.startsWith("data:")) continue;
|
|
1494
|
+
return trimmed.slice(5).trim();
|
|
1495
|
+
}
|
|
1496
|
+
}
|
|
1497
|
+
/** A frame's `finish_reason`, or '' when it carries none. */
|
|
1498
|
+
function finishReasonOf(choice) {
|
|
1499
|
+
const reason = choice["finish_reason"];
|
|
1500
|
+
return typeof reason === "string" ? reason : "";
|
|
1501
|
+
}
|
|
1502
|
+
/**
|
|
1503
|
+
* One frame carrying recovered calls.
|
|
1504
|
+
*
|
|
1505
|
+
* The shape is OpenAI's: `index` per call, `type: "function"`, arguments as a
|
|
1506
|
+
* JSON STRING. `finish_reason` becomes `tool_calls` — without it a client that
|
|
1507
|
+
* waits for the reason before executing an assembled call would never run it.
|
|
1508
|
+
*/
|
|
1509
|
+
function callsFrame(chunk, choice, calls) {
|
|
1510
|
+
const toolCalls = calls.map((call, index) => ({
|
|
1511
|
+
index,
|
|
1512
|
+
id: call.id,
|
|
1513
|
+
type: "function",
|
|
1514
|
+
function: {
|
|
1515
|
+
name: call.name,
|
|
1516
|
+
arguments: call.arguments
|
|
1517
|
+
}
|
|
1518
|
+
}));
|
|
1519
|
+
const next = {
|
|
1520
|
+
...chunk,
|
|
1521
|
+
choices: [{
|
|
1522
|
+
...choice,
|
|
1523
|
+
delta: { tool_calls: toolCalls },
|
|
1524
|
+
finish_reason: "tool_calls"
|
|
1525
|
+
}]
|
|
1526
|
+
};
|
|
1527
|
+
return `data: ${JSON.stringify(next)}\n\n`;
|
|
1528
|
+
}
|
|
1529
|
+
/** One frame carrying replacement text for a frame whose content was consumed. */
|
|
1530
|
+
function contentFrame(chunk, choice, content) {
|
|
1531
|
+
const delta = {
|
|
1532
|
+
...choice["delta"],
|
|
1533
|
+
content
|
|
1534
|
+
};
|
|
1535
|
+
const next = {
|
|
1536
|
+
...chunk,
|
|
1537
|
+
choices: [{
|
|
1538
|
+
...choice,
|
|
1539
|
+
delta
|
|
1540
|
+
}]
|
|
1541
|
+
};
|
|
1542
|
+
return `data: ${JSON.stringify(next)}\n\n`;
|
|
1543
|
+
}
|
|
1544
|
+
/**
|
|
455
1545
|
* Start the loopback endpoint. Requests must carry the shim's shared secret;
|
|
456
1546
|
* the loopback bind alone is not a trust boundary.
|
|
457
1547
|
*/
|
|
458
1548
|
function createWorkBuddyShim(options) {
|
|
459
1549
|
const { store, client, catalog } = options;
|
|
460
1550
|
const logger = options.logger;
|
|
1551
|
+
const failoverAccount = options.failoverAccount;
|
|
1552
|
+
const prepareAccount = options.prepareAccount;
|
|
1553
|
+
const onAccountFailure = options.onAccountFailure;
|
|
461
1554
|
const SHARED_SECRET = randomBytes(32).toString("base64url");
|
|
462
1555
|
/** Constant-time bearer check; absent or mismatched bearers are rejected. */
|
|
463
1556
|
function bearerOk(req) {
|
|
@@ -526,11 +1619,27 @@ function createWorkBuddyShim(options) {
|
|
|
526
1619
|
else res.end();
|
|
527
1620
|
}
|
|
528
1621
|
}
|
|
1622
|
+
/**
|
|
1623
|
+
* Failure classes worth retrying against another account.
|
|
1624
|
+
*
|
|
1625
|
+
* `client` is deliberately NOT one of them: the upstream rejected the
|
|
1626
|
+
* REQUEST (malformed body, unsupported field), so every account answers the
|
|
1627
|
+
* same 400 and walking the rest of the pool only multiplies the wait before
|
|
1628
|
+
* the user sees an error they must act on anyway. Everything else describes a
|
|
1629
|
+
* PER-ACCOUNT condition — a rate limit, exhausted credits, a dead session, a
|
|
1630
|
+
* gateway that failed this one call — which another account may well survive.
|
|
1631
|
+
*/
|
|
1632
|
+
function isFailoverWorthy(kind) {
|
|
1633
|
+
return kind !== "client";
|
|
1634
|
+
}
|
|
529
1635
|
async function chatCompletions(req, res) {
|
|
530
1636
|
if (!isJsonContentType(req)) {
|
|
531
1637
|
writeOpenAIError(res, 415, "unsupported_media_type", "Content-Type must be application/json");
|
|
532
1638
|
return;
|
|
533
1639
|
}
|
|
1640
|
+
await prepareAccount?.().catch((error) => {
|
|
1641
|
+
logger?.warn("dsh-connect-workbuddy: pool account selection failed", error);
|
|
1642
|
+
});
|
|
534
1643
|
let credential;
|
|
535
1644
|
try {
|
|
536
1645
|
credential = await store.resolve();
|
|
@@ -541,10 +1650,64 @@ function createWorkBuddyShim(options) {
|
|
|
541
1650
|
const raw = (await readBody(req)).toString("utf8");
|
|
542
1651
|
const prepared = prepareChatBody(raw);
|
|
543
1652
|
const controller = new AbortController();
|
|
544
|
-
|
|
545
|
-
|
|
1653
|
+
res.on("close", () => controller.abort());
|
|
1654
|
+
let result = await client.chatStream(credential, prepared, controller.signal);
|
|
1655
|
+
const triedAccountIds = [];
|
|
1656
|
+
/**
|
|
1657
|
+
* Tell the plugin about a failed attempt so it can store the measurement.
|
|
1658
|
+
*
|
|
1659
|
+
* Every failed attempt is reported exactly once, INCLUDING the last one — an
|
|
1660
|
+
* account that failed with nobody left to try it is precisely the one the
|
|
1661
|
+
* next request must avoid, so reporting only the attempts that had a
|
|
1662
|
+
* successor would miss the case that matters most.
|
|
1663
|
+
*
|
|
1664
|
+
* Reported after the loop rather than inside it, so the account that broke
|
|
1665
|
+
* out of the loop is not also reported by the post-loop call. Not awaited: the
|
|
1666
|
+
* user is waiting on the retry, and the write only affects later requests.
|
|
1667
|
+
*/
|
|
1668
|
+
const failures = [];
|
|
1669
|
+
const recordAttempt = (credential, failure) => {
|
|
1670
|
+
if (failure.ok) return;
|
|
1671
|
+
failures.push({
|
|
1672
|
+
accountId: workbuddyAccountId(credential),
|
|
1673
|
+
failure
|
|
1674
|
+
});
|
|
1675
|
+
};
|
|
1676
|
+
const reportFailures = () => {
|
|
1677
|
+
if (onAccountFailure === void 0) {
|
|
1678
|
+
failures.length = 0;
|
|
1679
|
+
return;
|
|
1680
|
+
}
|
|
1681
|
+
for (const entry of failures.splice(0, failures.length)) {
|
|
1682
|
+
if (entry.failure.ok) continue;
|
|
1683
|
+
try {
|
|
1684
|
+
onAccountFailure(entry.accountId, {
|
|
1685
|
+
status: entry.failure.status,
|
|
1686
|
+
kind: entry.failure.kind,
|
|
1687
|
+
message: entry.failure.message
|
|
1688
|
+
});
|
|
1689
|
+
} catch (error) {
|
|
1690
|
+
logger?.warn("dsh-connect-workbuddy: recording an account failure failed", error);
|
|
1691
|
+
}
|
|
1692
|
+
}
|
|
1693
|
+
};
|
|
1694
|
+
while (!result.ok && isFailoverWorthy(result.kind) && failoverAccount !== void 0) {
|
|
1695
|
+
if (controller.signal.aborted) break;
|
|
1696
|
+
recordAttempt(credential, result);
|
|
1697
|
+
triedAccountIds.push(workbuddyAccountId(credential));
|
|
1698
|
+
const next = await failoverAccount(triedAccountIds).catch(() => void 0);
|
|
1699
|
+
if (next === void 0) break;
|
|
1700
|
+
if (triedAccountIds.includes(workbuddyAccountId(next))) break;
|
|
1701
|
+
logger?.warn(`dsh-connect-workbuddy: retrying chat on another pool account after ${result.kind} (http ${result.status})`);
|
|
1702
|
+
credential = next;
|
|
1703
|
+
result = await client.chatStream(credential, prepared, controller.signal);
|
|
1704
|
+
}
|
|
1705
|
+
if (failures.length === 0 || failures[failures.length - 1]?.accountId !== workbuddyAccountId(credential)) recordAttempt(credential, result);
|
|
1706
|
+
reportFailures();
|
|
546
1707
|
if (!result.ok) {
|
|
547
|
-
|
|
1708
|
+
const attempts = triedAccountIds.length;
|
|
1709
|
+
const note = attempts > 1 ? ` (after trying ${attempts} accounts)` : "";
|
|
1710
|
+
writeOpenAIError(res, KIND_STATUS[result.kind], result.kind, `workbuddy upstream ${result.kind} (http ${result.status})${note}: ${result.message.slice(0, 400)}`);
|
|
548
1711
|
return;
|
|
549
1712
|
}
|
|
550
1713
|
res.writeHead(200, {
|
|
@@ -553,16 +1716,41 @@ function createWorkBuddyShim(options) {
|
|
|
553
1716
|
"Connection": "keep-alive",
|
|
554
1717
|
"X-Accel-Buffering": "no"
|
|
555
1718
|
});
|
|
556
|
-
|
|
557
|
-
const
|
|
558
|
-
|
|
559
|
-
|
|
560
|
-
|
|
561
|
-
|
|
562
|
-
|
|
563
|
-
|
|
564
|
-
|
|
565
|
-
|
|
1719
|
+
const declared = declaredTools(prepared);
|
|
1720
|
+
const gate = declared === void 0 ? void 0 : {
|
|
1721
|
+
declaredNames: declared.names,
|
|
1722
|
+
requiredParameters: declared.requiredParameters,
|
|
1723
|
+
...declared.pinnedToolName === void 0 ? {} : { pinnedToolName: declared.pinnedToolName }
|
|
1724
|
+
};
|
|
1725
|
+
const writer = new RecoveryStream(res, gate, logger);
|
|
1726
|
+
await writer.consume(result.response.body);
|
|
1727
|
+
if (gate !== void 0 && !writer.delivered && writer.residueSeen && !controller.signal.aborted) {
|
|
1728
|
+
logger?.warn("dsh-connect-workbuddy: the turn produced DSML markup only; retrying it once");
|
|
1729
|
+
const retryTried = [...triedAccountIds, workbuddyAccountId(credential)];
|
|
1730
|
+
let retryCredential = credential;
|
|
1731
|
+
for (;;) {
|
|
1732
|
+
const attempt = await client.chatStream(retryCredential, prepared, controller.signal);
|
|
1733
|
+
if (attempt.ok) {
|
|
1734
|
+
writer.discard();
|
|
1735
|
+
const retried = new RecoveryStream(res, gate, logger);
|
|
1736
|
+
await retried.consume(attempt.response.body);
|
|
1737
|
+
retried.finish();
|
|
1738
|
+
reportFailures();
|
|
1739
|
+
return;
|
|
1740
|
+
}
|
|
1741
|
+
recordAttempt(retryCredential, attempt);
|
|
1742
|
+
if (!isFailoverWorthy(attempt.kind) || failoverAccount === void 0) break;
|
|
1743
|
+
if (controller.signal.aborted) break;
|
|
1744
|
+
const next = await failoverAccount(retryTried).catch(() => void 0);
|
|
1745
|
+
if (next === void 0) break;
|
|
1746
|
+
if (retryTried.includes(workbuddyAccountId(next))) break;
|
|
1747
|
+
retryTried.push(workbuddyAccountId(next));
|
|
1748
|
+
retryCredential = next;
|
|
1749
|
+
}
|
|
1750
|
+
logger?.warn("dsh-connect-workbuddy: markup-only retry did not start; showing the original text");
|
|
1751
|
+
}
|
|
1752
|
+
writer.finish();
|
|
1753
|
+
reportFailures();
|
|
566
1754
|
}
|
|
567
1755
|
return {
|
|
568
1756
|
ready,
|
|
@@ -1146,15 +2334,6 @@ function effectiveMembersOf(orderedIds, listedIds) {
|
|
|
1146
2334
|
return orderedIds.filter((id) => listedIds.has(id));
|
|
1147
2335
|
}
|
|
1148
2336
|
/**
|
|
1149
|
-
* The account to bill, or undefined when the pool has no usable member.
|
|
1150
|
-
*
|
|
1151
|
-
* A thin selector over {@link rankPool} so the winner is decided in exactly one
|
|
1152
|
-
* place: every caller that needs "who serves now" gets the same answer.
|
|
1153
|
-
*/
|
|
1154
|
-
function pickAccount(members, nowMs) {
|
|
1155
|
-
return rankPool(members, nowMs).find((row) => row.excludedBy === void 0)?.account;
|
|
1156
|
-
}
|
|
1157
|
-
/**
|
|
1158
2337
|
* The credit figure used for ordering.
|
|
1159
2338
|
*
|
|
1160
2339
|
* Absent credits rank as zero. Deliberately NOT "unknown ranks first": an
|
|
@@ -1188,94 +2367,6 @@ function pickFreeModel(catalog) {
|
|
|
1188
2367
|
return best;
|
|
1189
2368
|
}
|
|
1190
2369
|
/**
|
|
1191
|
-
* A latest-wins guard for one region's asynchronous work.
|
|
1192
|
-
*
|
|
1193
|
-
* Exists because `applyRotation` awaits (it fetches credits per member), so two
|
|
1194
|
-
* overlapping calls can interleave: a slow call that read "rotation is ON" can
|
|
1195
|
-
* resume AFTER a newer call cleared the override for "rotation is OFF" and write
|
|
1196
|
-
* the stale account back — leaving the card showing the pool switched off while
|
|
1197
|
-
* a rotated account is still billed.
|
|
1198
|
-
*
|
|
1199
|
-
* `begin()` claims a new generation and returns a probe; the caller must check
|
|
1200
|
-
* it after EVERY await and bail when it reports stale. Extracted as a pure
|
|
1201
|
-
* factory rather than inlined so the rule is unit-testable: an inlined token is
|
|
1202
|
-
* only observable by reproducing a real interleaving, which is slow and easy to
|
|
1203
|
-
* get subtly wrong (a harness that never actually overlaps passes either way).
|
|
1204
|
-
*/
|
|
1205
|
-
function createLatestWins() {
|
|
1206
|
-
const generations = {};
|
|
1207
|
-
return { begin(key) {
|
|
1208
|
-
const generation = (generations[key] ?? 0) + 1;
|
|
1209
|
-
generations[key] = generation;
|
|
1210
|
-
return () => generations[key] !== generation;
|
|
1211
|
-
} };
|
|
1212
|
-
}
|
|
1213
|
-
/**
|
|
1214
|
-
* How often the pool scheduler wakes to check whether a region is due.
|
|
1215
|
-
*
|
|
1216
|
-
* A fixed, coarse heartbeat rather than one timer per region: the heartbeat
|
|
1217
|
-
* itself does no network work (it only reads config and compares timestamps),
|
|
1218
|
-
* so it is free, and it makes re-arming on a config change unnecessary — the
|
|
1219
|
-
* next tick simply reads the new interval.
|
|
1220
|
-
*/
|
|
1221
|
-
const POOL_TICK_MS = 6e4;
|
|
1222
|
-
/**
|
|
1223
|
-
* Whether an automatic test is due for one region.
|
|
1224
|
-
*
|
|
1225
|
-
* `lastRunMs === undefined` means "never armed", and returns FALSE: the clock
|
|
1226
|
-
* starts when the scheduler first sees the region, so enabling the pool does
|
|
1227
|
-
* not immediately spend credits at startup or after a restart. Waiting one
|
|
1228
|
-
* interval is the least surprising reading of "test every N minutes" — the
|
|
1229
|
-
* alternative (fire immediately) would bill the user on every launch.
|
|
1230
|
-
*
|
|
1231
|
-
* Pure and total so the scheduling rule can be pinned without real timers.
|
|
1232
|
-
*/
|
|
1233
|
-
function poolDueAt(input) {
|
|
1234
|
-
if (input.lastRunMs === void 0) return false;
|
|
1235
|
-
const minutes = Math.max(5, Math.round(input.intervalMinutes));
|
|
1236
|
-
return input.nowMs - input.lastRunMs >= minutes * 6e4;
|
|
1237
|
-
}
|
|
1238
|
-
/**
|
|
1239
|
-
* Decide which regions a scheduler tick should run.
|
|
1240
|
-
*
|
|
1241
|
-
* The FIRST sighting of an enabled region ARMS its clock instead of running it.
|
|
1242
|
-
* That split is the whole reason this is a function rather than an `if`:
|
|
1243
|
-
* {@link poolDueAt} answers "has enough time passed", which is FALSE for a
|
|
1244
|
-
* region that has never run — so a scheduler that only armed the clock when it
|
|
1245
|
-
* decided to run would never arm it at all, and the scheduled test would
|
|
1246
|
-
* silently never fire. Returning the clock state explicitly makes that
|
|
1247
|
-
* impossible to get wrong, and testable without real timers.
|
|
1248
|
-
*
|
|
1249
|
-
* A region that is switched off keeps NO clock: enabling it later must wait a
|
|
1250
|
-
* full interval rather than inheriting a stale timestamp and firing at once.
|
|
1251
|
-
*/
|
|
1252
|
-
function duePoolRegions(input) {
|
|
1253
|
-
const due = [];
|
|
1254
|
-
const next = {};
|
|
1255
|
-
for (const region of input.regions) {
|
|
1256
|
-
if (!input.enabledOf(region)) continue;
|
|
1257
|
-
const last = input.state[region];
|
|
1258
|
-
if (last === void 0) {
|
|
1259
|
-
next[region] = input.nowMs;
|
|
1260
|
-
continue;
|
|
1261
|
-
}
|
|
1262
|
-
if (!poolDueAt({
|
|
1263
|
-
lastRunMs: last,
|
|
1264
|
-
intervalMinutes: input.intervalMinutesOf(region),
|
|
1265
|
-
nowMs: input.nowMs
|
|
1266
|
-
})) {
|
|
1267
|
-
next[region] = last;
|
|
1268
|
-
continue;
|
|
1269
|
-
}
|
|
1270
|
-
next[region] = input.nowMs;
|
|
1271
|
-
due.push(region);
|
|
1272
|
-
}
|
|
1273
|
-
return {
|
|
1274
|
-
due,
|
|
1275
|
-
next
|
|
1276
|
-
};
|
|
1277
|
-
}
|
|
1278
|
-
/**
|
|
1279
2370
|
* Resolve the model the pool should test against.
|
|
1280
2371
|
*
|
|
1281
2372
|
* A user-specified id always wins (it is a preference, not a computed default),
|
|
@@ -1868,7 +2959,6 @@ async function workBuddyWebPool(deps, region) {
|
|
|
1868
2959
|
const byId = new Map(members.map((member) => [member.account.id, member]));
|
|
1869
2960
|
const rankedIds = new Set(ranked.map((row) => row.account.id));
|
|
1870
2961
|
const others = await pool.otherAccounts?.(region) ?? [];
|
|
1871
|
-
const rotatedTo = await pool.rotatedToAccountId?.(region).catch(() => void 0);
|
|
1872
2962
|
const checkedIn = await pool.checkedInToday?.(region).catch(() => ({})) ?? {};
|
|
1873
2963
|
const accounts = [...ranked.map((row) => ({
|
|
1874
2964
|
account: row.account,
|
|
@@ -1900,14 +2990,12 @@ async function workBuddyWebPool(deps, region) {
|
|
|
1900
2990
|
});
|
|
1901
2991
|
return {
|
|
1902
2992
|
enabled: preferences.enabled,
|
|
1903
|
-
|
|
1904
|
-
autoTestIntervalMinutes: preferences.autoTestIntervalMinutes,
|
|
2993
|
+
checkinSupported: pool.checkinSupported?.(region) === true,
|
|
1905
2994
|
...target.modelId === void 0 ? {} : { targetModelId: target.modelId },
|
|
1906
2995
|
...target.staleModelId === void 0 ? {} : { staleTargetModelId: target.staleModelId },
|
|
1907
2996
|
targetModelSource: target.source,
|
|
1908
2997
|
memberAccountIds: preferences.memberAccountIds,
|
|
1909
2998
|
effectiveMemberAccountIds: effectiveMembersOf(accounts.map((account) => account.accountId), new Set(preferences.memberAccountIds)),
|
|
1910
|
-
...rotatedTo === void 0 ? {} : { rotatedToAccountId: rotatedTo },
|
|
1911
2999
|
accounts,
|
|
1912
3000
|
catalog: deps.displayModels(region).map((model) => ({
|
|
1913
3001
|
id: model.id,
|
|
@@ -2053,10 +3141,6 @@ function registerWorkBuddyStatusRoute(ctx, deps) {
|
|
|
2053
3141
|
error: "account pool unavailable"
|
|
2054
3142
|
});
|
|
2055
3143
|
try {
|
|
2056
|
-
if (!pool.preferences(region).enabled) return json(res, 409, {
|
|
2057
|
-
reason: "pool-disabled",
|
|
2058
|
-
error: "account pool is disabled for this region"
|
|
2059
|
-
});
|
|
2060
3144
|
const savedMembers = pool.preferences(region).memberAccountIds;
|
|
2061
3145
|
if ((await pool.effectiveMemberAccountIds?.(region).catch(() => void 0) ?? savedMembers).length === 0) {
|
|
2062
3146
|
const noneChecked = savedMembers.length === 0;
|
|
@@ -2066,6 +3150,10 @@ function registerWorkBuddyStatusRoute(ctx, deps) {
|
|
|
2066
3150
|
});
|
|
2067
3151
|
}
|
|
2068
3152
|
if (action === "checkin") {
|
|
3153
|
+
if (pool.checkinSupported?.(region) !== true) return json(res, 409, {
|
|
3154
|
+
reason: "checkin-unsupported",
|
|
3155
|
+
error: "this region does not offer a daily check-in"
|
|
3156
|
+
});
|
|
2069
3157
|
if (pool.checkin === void 0) return json(res, 503, {
|
|
2070
3158
|
reason: "pool-unavailable",
|
|
2071
3159
|
error: "pool check-in unavailable"
|
|
@@ -2264,9 +3352,7 @@ const modelConfig = z.object({
|
|
|
2264
3352
|
* one "Save" press overwrite results the timer had just written.
|
|
2265
3353
|
*/
|
|
2266
3354
|
const poolConfig = z.object({
|
|
2267
|
-
enabled: z.boolean().default(false).description("Whether
|
|
2268
|
-
rotateByCredits: z.boolean().default(false).description("Whether the plugin may switch the billed account by credits (opt-in; disables manual selection)"),
|
|
2269
|
-
autoTestIntervalMinutes: z.number().step(1).min(5).max(1440).default(30).description("Minutes between automatic pool tests (5..1440)"),
|
|
3355
|
+
enabled: z.boolean().default(false).description("Whether this region's account pool is active (opt-in). While on, a failed chat request is retried against the other usable pool members before the error is reported."),
|
|
2270
3356
|
targetModelId: z.string().default("").description("Model id to test; empty means pick a zero-multiplier model from this region's catalog"),
|
|
2271
3357
|
memberAccountIds: z.array(z.string()).default([]).description("Account ids checked into this pool (opt-in; empty means the pool covers nothing)")
|
|
2272
3358
|
});
|
|
@@ -2454,14 +3540,6 @@ function apply(ctx, config) {
|
|
|
2454
3540
|
* immediately instead of waiting out the TTL.
|
|
2455
3541
|
*/
|
|
2456
3542
|
const checkinStateCache = {};
|
|
2457
|
-
/**
|
|
2458
|
-
* Latest-wins guard for `applyRotation`, keyed by region.
|
|
2459
|
-
*
|
|
2460
|
-
* Declared here (near the top of `apply`) rather than beside `applyRotation`
|
|
2461
|
-
* itself: that function is CALLED from the startup path above its definition,
|
|
2462
|
-
* so a `const` declared down there would be in its temporal dead zone.
|
|
2463
|
-
*/
|
|
2464
|
-
const rotationGuard = createLatestWins();
|
|
2465
3543
|
const settingsNs = settingsNamespaceOf(ctx);
|
|
2466
3544
|
const stacks = {};
|
|
2467
3545
|
for (const region of REGION_KEYS) {
|
|
@@ -2479,7 +3557,27 @@ function apply(ctx, config) {
|
|
|
2479
3557
|
store,
|
|
2480
3558
|
client,
|
|
2481
3559
|
catalog,
|
|
2482
|
-
logger: ctx.logger
|
|
3560
|
+
logger: ctx.logger,
|
|
3561
|
+
/**
|
|
3562
|
+
* Pool routing, bound to THIS region.
|
|
3563
|
+
*
|
|
3564
|
+
* The region is captured in the closure rather than passed per call: each
|
|
3565
|
+
* region owns its own store, pool and shim, and routing that consulted the
|
|
3566
|
+
* other region's members would bill an account from a different account
|
|
3567
|
+
* pool than the one in play.
|
|
3568
|
+
*
|
|
3569
|
+
* `prepareAccount` runs before every request (so the ranking decides who
|
|
3570
|
+
* serves), `failoverAccount` runs after a failure (so the ranking decides
|
|
3571
|
+
* who to try next). Both are resolved lazily — they read pool plumbing
|
|
3572
|
+
* defined below — but never called before it exists: the shim only takes
|
|
3573
|
+
* traffic once the provider is registered, which happens after this whole
|
|
3574
|
+
* setup completes.
|
|
3575
|
+
*/
|
|
3576
|
+
prepareAccount: () => applyPoolSelection(region),
|
|
3577
|
+
failoverAccount: (triedAccountIds) => failoverAccountFor(region, triedAccountIds),
|
|
3578
|
+
onAccountFailure: (accountId, failure) => {
|
|
3579
|
+
recordAccountFailure(region, accountId, failure);
|
|
3580
|
+
}
|
|
2483
3581
|
})
|
|
2484
3582
|
};
|
|
2485
3583
|
}
|
|
@@ -2641,11 +3739,11 @@ function apply(ctx, config) {
|
|
|
2641
3739
|
* stacks of accounts, so one region's pool can never rank or bill the
|
|
2642
3740
|
* other's accounts.
|
|
2643
3741
|
*
|
|
2644
|
-
*
|
|
2645
|
-
*
|
|
2646
|
-
*
|
|
2647
|
-
*
|
|
2648
|
-
*
|
|
3742
|
+
* The pool decides who serves a FAILED request (see `failoverAccountFor`),
|
|
3743
|
+
* and it does so without writing `config.accounts[region]`: a retry borrows
|
|
3744
|
+
* another member's credential for that one request, leaving the record of
|
|
3745
|
+
* what the user picked — and the account the next request starts from —
|
|
3746
|
+
* untouched.
|
|
2649
3747
|
*/
|
|
2650
3748
|
pool: {
|
|
2651
3749
|
preferences: (region) => poolPreferencesOf(current(), region),
|
|
@@ -2706,17 +3804,18 @@ function apply(ctx, config) {
|
|
|
2706
3804
|
}));
|
|
2707
3805
|
return Object.fromEntries(entries.filter((entry) => entry !== void 0));
|
|
2708
3806
|
},
|
|
2709
|
-
/**
|
|
2710
|
-
* The account rotation last moved to, read from the store's runtime
|
|
2711
|
-
* override. Absent means rotation is not currently redirecting billing.
|
|
2712
|
-
*/
|
|
2713
|
-
async rotatedToAccountId(region) {
|
|
2714
|
-
return stacks[region].store.rotatedAccount();
|
|
2715
|
-
},
|
|
2716
3807
|
async currentAccountId(region) {
|
|
3808
|
+
await applyPoolSelection(region).catch(() => void 0);
|
|
2717
3809
|
const credential = await stacks[region].store.current().catch(() => void 0);
|
|
2718
|
-
|
|
3810
|
+
if (credential === void 0) return void 0;
|
|
3811
|
+
if (await servingAccountIsExcluded(region).catch(() => false)) return void 0;
|
|
3812
|
+
return workbuddyAccountId(credential);
|
|
2719
3813
|
},
|
|
3814
|
+
/**
|
|
3815
|
+
* Only the CN app rewards a daily check-in; the international region has
|
|
3816
|
+
* no equivalent here, so its card must not offer the action.
|
|
3817
|
+
*/
|
|
3818
|
+
checkinSupported: (region) => region === "cn",
|
|
2720
3819
|
checkin: async (region) => {
|
|
2721
3820
|
const rows = await checkinAllAccounts(await poolTargets(region), poolRunnerDeps(region));
|
|
2722
3821
|
for (const row of rows) {
|
|
@@ -2729,7 +3828,6 @@ function apply(ctx, config) {
|
|
|
2729
3828
|
test: async (region, modelId) => {
|
|
2730
3829
|
const rows = await testAllAccounts(await poolTargets(region), modelId, poolRunnerDeps(region));
|
|
2731
3830
|
await writePoolProbes(region, probeUpdatesOf(rows));
|
|
2732
|
-
await applyRotation(region);
|
|
2733
3831
|
return rows;
|
|
2734
3832
|
},
|
|
2735
3833
|
catalog: (region) => displayModels(current(), region)
|
|
@@ -2753,33 +3851,159 @@ function apply(ctx, config) {
|
|
|
2753
3851
|
}
|
|
2754
3852
|
};
|
|
2755
3853
|
/**
|
|
2756
|
-
*
|
|
3854
|
+
* The next account to try for a request whose current account just failed,
|
|
3855
|
+
* or `undefined` when this region's pool has nobody left.
|
|
3856
|
+
*
|
|
3857
|
+
* Reads the pool's EXISTING measurements instead of probing: a request that
|
|
3858
|
+
* just failed must not spend further requests deciding who serves next (and a
|
|
3859
|
+
* probe of the target model would itself be billed). Candidates are this
|
|
3860
|
+
* region's checked-in members in rank order, minus everyone this request has
|
|
3861
|
+
* already tried, minus anyone a measurement says cannot serve right now — a
|
|
3862
|
+
* limited account whose stated cooldown has not elapsed, or one whose
|
|
3863
|
+
* credential the upstream rejected.
|
|
2757
3864
|
*
|
|
2758
|
-
* The
|
|
2759
|
-
*
|
|
2760
|
-
* own selection immediately. Clearing on every non-rotating path is what
|
|
2761
|
-
* makes "turn rotation off" complete rather than leaving a stale override
|
|
2762
|
-
* billing under a switch that reads as off.
|
|
3865
|
+
* The pool switch gates this: off means the user's account serves every
|
|
3866
|
+
* request and a failure is reported as-is, exactly as before the pool existed.
|
|
2763
3867
|
*
|
|
2764
|
-
*
|
|
3868
|
+
* Reads the candidate through `credentialFor()`, never `resolve()`: the saved
|
|
3869
|
+
* selection and the store's runtime state stay untouched, so this is
|
|
3870
|
+
* per-request borrowing rather than a silent change of who pays.
|
|
2765
3871
|
*/
|
|
2766
|
-
const
|
|
2767
|
-
|
|
2768
|
-
const
|
|
2769
|
-
const
|
|
2770
|
-
|
|
2771
|
-
|
|
2772
|
-
|
|
3872
|
+
const failoverAccountFor = async (region, triedAccountIds) => {
|
|
3873
|
+
if (!poolPreferencesOf(current(), region).enabled) return void 0;
|
|
3874
|
+
const tried = new Set(triedAccountIds);
|
|
3875
|
+
for (const row of rankPool(await poolMembersOf(region), Date.now())) {
|
|
3876
|
+
if (row.excludedBy !== void 0) continue;
|
|
3877
|
+
if (tried.has(row.account.id)) continue;
|
|
3878
|
+
const credential = await stacks[region].store.credentialFor(row.account.id);
|
|
3879
|
+
if (credential !== void 0) return credential;
|
|
2773
3880
|
}
|
|
2774
|
-
|
|
2775
|
-
|
|
2776
|
-
|
|
2777
|
-
|
|
2778
|
-
|
|
3881
|
+
};
|
|
3882
|
+
/**
|
|
3883
|
+
* The last CREDITS reading per region, so a request can rank by balance
|
|
3884
|
+
* without fetching one.
|
|
3885
|
+
*
|
|
3886
|
+
* Credits are the ranking's second key, and they cost an upstream call per
|
|
3887
|
+
* member. A chat request must never pay that: it happens on the hot path, and
|
|
3888
|
+
* `fetchCredits` per member would turn one page of conversation into N extra
|
|
3889
|
+
* requests. So the request path uses whatever the pool last read (the card's
|
|
3890
|
+
* 60-second poll, a batch test, or a save) and simply skips the key when the
|
|
3891
|
+
* snapshot is too old to speak for today's balances.
|
|
3892
|
+
*/
|
|
3893
|
+
const creditsSnapshot = {};
|
|
3894
|
+
/** How long a credits reading may steer routing. */
|
|
3895
|
+
const CREDITS_SNAPSHOT_MS = 6e5;
|
|
3896
|
+
/** When each region's snapshot was taken, for the freshness check above. */
|
|
3897
|
+
const creditsSnapshotAt = {};
|
|
3898
|
+
/**
|
|
3899
|
+
* One region's pool members from LOCAL sources only: the credential store and
|
|
3900
|
+
* the probe store, plus the credits snapshot when it is fresh.
|
|
3901
|
+
*
|
|
3902
|
+
* The request path calls this on every chat completion, so it must not touch
|
|
3903
|
+
* the network. `poolMembersOf` is the honest-but-expensive version (it fetches
|
|
3904
|
+
* credits per member) and stays on the card's route, which is where those
|
|
3905
|
+
* readings come from in the first place.
|
|
3906
|
+
*/
|
|
3907
|
+
async function localPoolMembers(region) {
|
|
3908
|
+
const accounts = await poolMemberAccounts(region);
|
|
3909
|
+
const probes = await readPoolProbes(region);
|
|
3910
|
+
const credits = Date.now() - (creditsSnapshotAt[region] ?? 0) <= CREDITS_SNAPSHOT_MS ? creditsSnapshot[region] : void 0;
|
|
3911
|
+
return accounts.map((account) => {
|
|
3912
|
+
const reading = credits?.get(account.id);
|
|
3913
|
+
return {
|
|
3914
|
+
account: {
|
|
3915
|
+
id: account.id,
|
|
3916
|
+
accountName: account.accountName
|
|
3917
|
+
},
|
|
3918
|
+
...reading === void 0 ? {} : { credits: reading },
|
|
3919
|
+
...probes[account.id] === void 0 ? {} : { probe: probes[account.id] },
|
|
3920
|
+
tokenExpiresAtMs: account.tokenExpiresAtMs
|
|
3921
|
+
};
|
|
3922
|
+
});
|
|
3923
|
+
}
|
|
3924
|
+
/**
|
|
3925
|
+
* Record a failure a LIVE request hit, so the NEXT request starts from a
|
|
3926
|
+
* usable account.
|
|
3927
|
+
*
|
|
3928
|
+
* Without this the ranking only knew what the manual batch test had measured:
|
|
3929
|
+
* an account that had just answered 429 carried no measurement, so
|
|
3930
|
+
* `exclusionOf` returned "candidate" and the ranking kept picking it — every
|
|
3931
|
+
* request paid one failed round trip before failing over. Keeping traffic off
|
|
3932
|
+
* an account the upstream just refused is the pool's whole point
|
|
3933
|
+
* (`account-pool.ts` says exactly that about `rate-limited`), and it only
|
|
3934
|
+
* works if live failures count as measurements.
|
|
3935
|
+
*
|
|
3936
|
+
* Deliberately NOT recorded for `client` (HTTP 400): the upstream rejected the
|
|
3937
|
+
* REQUEST, which says nothing about the account, and recording it would
|
|
3938
|
+
* sideline a good account over a bad body.
|
|
3939
|
+
*
|
|
3940
|
+
* The reset time comes from the upstream's own words — the 429 body carries
|
|
3941
|
+
* 「将在 … 重置」 — which `cooldownOf` parses. When it states no time, the store's
|
|
3942
|
+
* own short window applies rather than a guess at a long one.
|
|
3943
|
+
*/
|
|
3944
|
+
const recordAccountFailure = async (region, accountId, failure) => {
|
|
3945
|
+
if (!poolPreferencesOf(current(), region).enabled) return;
|
|
3946
|
+
if (failure.kind === "client") return;
|
|
3947
|
+
const members = poolPreferencesOf(current(), region).memberAccountIds;
|
|
3948
|
+
if (members.length > 0 && !members.includes(accountId)) return;
|
|
3949
|
+
const outcome = outcomeOfFailure(failure.status, failure.message);
|
|
3950
|
+
const { retryAtMs } = cooldownOf({
|
|
3951
|
+
outcome,
|
|
3952
|
+
retryAfter: null,
|
|
3953
|
+
nowMs: Date.now(),
|
|
3954
|
+
body: failure.message
|
|
3955
|
+
});
|
|
3956
|
+
await writePoolProbes(region, { [accountId]: {
|
|
3957
|
+
outcome,
|
|
3958
|
+
atMs: Date.now(),
|
|
3959
|
+
...retryAtMs === void 0 ? {} : { retryAtMs }
|
|
3960
|
+
} });
|
|
3961
|
+
};
|
|
3962
|
+
/**
|
|
3963
|
+
* Point this region's store at whoever the pool's ranking says should serve.
|
|
3964
|
+
*
|
|
3965
|
+
* Called once per chat request. With the pool ON the ranking decides the
|
|
3966
|
+
* serving account — the whole point of the switch, and the reason the card can
|
|
3967
|
+
* show a "current account" that is not simply the saved selection. With the
|
|
3968
|
+
* pool OFF the override is CLEARED, which restores the user's own choice
|
|
3969
|
+
* immediately: leaving a stale override in place would keep billing under a
|
|
3970
|
+
* switch that reads as off.
|
|
3971
|
+
*
|
|
3972
|
+
* The override is runtime-only and never written to settings, so the user's
|
|
3973
|
+
* recorded choice survives untouched either way — turning the pool off gives
|
|
3974
|
+
* it back verbatim.
|
|
3975
|
+
*
|
|
3976
|
+
* Best-effort: a failure here leaves the previous state in effect, and the
|
|
3977
|
+
* request still goes out (to the user's account when the pool is off, which is
|
|
3978
|
+
* the safe default).
|
|
3979
|
+
*/
|
|
3980
|
+
const applyPoolSelection = async (region) => {
|
|
3981
|
+
const store = stacks[region].store;
|
|
3982
|
+
if (!poolPreferencesOf(current(), region).enabled) {
|
|
2779
3983
|
store.setRotatedAccount(void 0);
|
|
2780
3984
|
return;
|
|
2781
3985
|
}
|
|
2782
|
-
|
|
3986
|
+
const winner = rankPool(await localPoolMembers(region), Date.now()).find((row) => row.excludedBy === void 0);
|
|
3987
|
+
store.setRotatedAccount(winner?.account.id);
|
|
3988
|
+
};
|
|
3989
|
+
/**
|
|
3990
|
+
* Whether the account the store would bill is one the pool has measured as
|
|
3991
|
+
* unusable.
|
|
3992
|
+
*
|
|
3993
|
+
* The two questions differ exactly when the pool runs out of usable members: a
|
|
3994
|
+
* REQUEST still has to go somewhere (the saved selection), but the CARD must
|
|
3995
|
+
* not label that account "in use" while the same table says it is rate-limited.
|
|
3996
|
+
* One screen contradicting itself is how a user learns to trust neither half —
|
|
3997
|
+
* and "the pool moved to an available account" is unverifiable if the display
|
|
3998
|
+
* keeps naming an account the table calls excluded.
|
|
3999
|
+
*/
|
|
4000
|
+
const servingAccountIsExcluded = async (region) => {
|
|
4001
|
+
if (!poolPreferencesOf(current(), region).enabled) return false;
|
|
4002
|
+
const credential = await stacks[region].store.current().catch(() => void 0);
|
|
4003
|
+
if (credential === void 0) return false;
|
|
4004
|
+
const servingId = workbuddyAccountId(credential);
|
|
4005
|
+
const row = rankPool(await localPoolMembers(region), Date.now()).find((candidate) => candidate.account.id === servingId);
|
|
4006
|
+
return row !== void 0 && row.excludedBy !== void 0;
|
|
2783
4007
|
};
|
|
2784
4008
|
/**
|
|
2785
4009
|
* One region's POOL members, each with its credits and last measurement.
|
|
@@ -2797,7 +4021,7 @@ function apply(ctx, config) {
|
|
|
2797
4021
|
const store = stacks[region].store;
|
|
2798
4022
|
const accounts = await poolMemberAccounts(region);
|
|
2799
4023
|
const probes = await readPoolProbes(region);
|
|
2800
|
-
|
|
4024
|
+
const rows = await Promise.all(accounts.map(async (account) => {
|
|
2801
4025
|
const credential = await store.credentialFor(account.id).catch(() => void 0);
|
|
2802
4026
|
const credits = credential === void 0 ? void 0 : await client.fetchCredits(credential).catch(() => void 0);
|
|
2803
4027
|
return {
|
|
@@ -2814,14 +4038,15 @@ function apply(ctx, config) {
|
|
|
2814
4038
|
tokenExpiresAtMs: account.tokenExpiresAtMs
|
|
2815
4039
|
};
|
|
2816
4040
|
}));
|
|
4041
|
+
creditsSnapshot[region] = new Map(rows.filter((row) => row.credits !== void 0).map((row) => [row.account.id, row.credits]));
|
|
4042
|
+
creditsSnapshotAt[region] = Date.now();
|
|
4043
|
+
return rows;
|
|
2817
4044
|
}
|
|
2818
4045
|
/** One region's pool preferences, with the schema's defaults applied. */
|
|
2819
4046
|
const poolPreferencesOf = (config, region) => {
|
|
2820
4047
|
const pool = regionStateOf(config, region).pool;
|
|
2821
4048
|
return {
|
|
2822
4049
|
enabled: pool?.enabled === true,
|
|
2823
|
-
rotateByCredits: pool?.rotateByCredits === true,
|
|
2824
|
-
autoTestIntervalMinutes: pool?.autoTestIntervalMinutes ?? 30,
|
|
2825
4050
|
targetModelId: pool?.targetModelId ?? "",
|
|
2826
4051
|
memberAccountIds: pool?.memberAccountIds ?? []
|
|
2827
4052
|
};
|
|
@@ -2884,10 +4109,8 @@ function apply(ctx, config) {
|
|
|
2884
4109
|
});
|
|
2885
4110
|
ctx.on("loader/volatile-update", () => {
|
|
2886
4111
|
applySelection(current());
|
|
2887
|
-
for (const region of REGION_KEYS) applyRotation(region).catch(() => {});
|
|
2888
4112
|
});
|
|
2889
4113
|
applySelection(config);
|
|
2890
|
-
for (const region of REGION_KEYS) applyRotation(region).catch(() => {});
|
|
2891
4114
|
(async () => {
|
|
2892
4115
|
try {
|
|
2893
4116
|
const region = await legacyAttributionRegion(current(), async (candidate) => stacks[candidate].store.accounts());
|
|
@@ -2897,85 +4120,6 @@ function apply(ctx, config) {
|
|
|
2897
4120
|
} catch {}
|
|
2898
4121
|
})();
|
|
2899
4122
|
let stopped = false;
|
|
2900
|
-
/**
|
|
2901
|
-
* When each region's automatic test last ran, in epoch ms.
|
|
2902
|
-
*
|
|
2903
|
-
* In-memory only, and deliberately NOT persisted: it exists to space out
|
|
2904
|
-
* runs within one session. Persisting it would make a restart skip a test the
|
|
2905
|
-
* user was owed, and losing it only costs one interval.
|
|
2906
|
-
*/
|
|
2907
|
-
const poolLastRunMs = {};
|
|
2908
|
-
/**
|
|
2909
|
-
* The pool scheduler: ONE heartbeat for both regions.
|
|
2910
|
-
*
|
|
2911
|
-
* A single timer rather than one per region, because the tick does no network
|
|
2912
|
-
* work of its own — it reads the config, compares timestamps, and only then
|
|
2913
|
-
* runs a batch. That also means a settings change needs no re-arming: the next
|
|
2914
|
-
* tick reads whatever interval is now saved.
|
|
2915
|
-
*
|
|
2916
|
-
* This is what makes the "自动测试间隔" setting real. Before it existed the
|
|
2917
|
-
* interval was stored and never consumed, so the card promised a behaviour the
|
|
2918
|
-
* plugin did not have.
|
|
2919
|
-
*/
|
|
2920
|
-
ctx.effect(() => {
|
|
2921
|
-
const timer = setInterval(() => {
|
|
2922
|
-
(async () => {
|
|
2923
|
-
if (stopped) return;
|
|
2924
|
-
const schedule = duePoolRegions({
|
|
2925
|
-
state: poolLastRunMs,
|
|
2926
|
-
regions: REGION_KEYS,
|
|
2927
|
-
enabledOf: (region) => poolPreferencesOf(current(), region).enabled && regionEnabled(current(), region),
|
|
2928
|
-
intervalMinutesOf: (region) => poolPreferencesOf(current(), region).autoTestIntervalMinutes,
|
|
2929
|
-
nowMs: Date.now()
|
|
2930
|
-
});
|
|
2931
|
-
for (const region of REGION_KEYS) {
|
|
2932
|
-
const next = schedule.next[region];
|
|
2933
|
-
if (next === void 0) delete poolLastRunMs[region];
|
|
2934
|
-
else poolLastRunMs[region] = next;
|
|
2935
|
-
}
|
|
2936
|
-
for (const region of schedule.due) await runScheduledPoolTest(region).catch(() => {});
|
|
2937
|
-
})();
|
|
2938
|
-
}, POOL_TICK_MS);
|
|
2939
|
-
return () => {
|
|
2940
|
-
clearInterval(timer);
|
|
2941
|
-
};
|
|
2942
|
-
}, "dsh-connect-workbuddy: account pool scheduler");
|
|
2943
|
-
/**
|
|
2944
|
-
* One scheduled pass for a region: test the pool's members, then re-rank.
|
|
2945
|
-
*
|
|
2946
|
-
* Never bills a paid model on a timer: the target is the user's saved model
|
|
2947
|
-
* when it resolves, and the region's FREE model when the saved one has left
|
|
2948
|
-
* the catalog. An unresolvable target with no free model available stops the
|
|
2949
|
-
* pass — the free-target rule is what stops a timer from spending credits.
|
|
2950
|
-
*
|
|
2951
|
-
* The free FALLBACK for a stale saved model is D3's fix, not a convenience.
|
|
2952
|
-
* The scheduler's job is to keep MEASUREMENTS fresh, and a measurement is
|
|
2953
|
-
* what keeps rotation off a dead account. It used to return early whenever
|
|
2954
|
-
* `resolveTargetModel` produced no id — which is exactly what a saved model
|
|
2955
|
-
* that left the catalog produces — so a stale *display preference* froze the
|
|
2956
|
-
* measurement loop for the whole region. The two exclusions then behaved
|
|
2957
|
-
* asymmetrically: a `rate-limited` member clears by the clock alone
|
|
2958
|
-
* (`exclusionOf` consults `retryAtMs`), while an `unavailable` member can
|
|
2959
|
-
* only be cleared by a NEW probe — and no probe was ever written. The member
|
|
2960
|
-
* hung in the pool's "excluded" state indefinitely, until the user noticed
|
|
2961
|
-
* the stale model and fixed it by hand.
|
|
2962
|
-
*
|
|
2963
|
-
* The manual batch route keeps refusing a stale target (`reason:
|
|
2964
|
-
* 'target-model-stale'`): there the user asked to test THAT model, so
|
|
2965
|
-
* substituting another one would answer a question they did not ask. Here
|
|
2966
|
-
* nobody asked for anything; the pass exists to refresh the pool's own
|
|
2967
|
-
* picture, so it must not be starved by a configuration error it can work
|
|
2968
|
-
* around without spending anything.
|
|
2969
|
-
*/
|
|
2970
|
-
async function runScheduledPoolTest(region) {
|
|
2971
|
-
const targets = await poolTargets(region);
|
|
2972
|
-
if (targets.length === 0) return;
|
|
2973
|
-
const catalog = displayModels(current(), region);
|
|
2974
|
-
const modelId = resolveTargetModel(catalog, poolPreferencesOf(current(), region).targetModelId).modelId ?? pickFreeModel(catalog)?.id;
|
|
2975
|
-
if (modelId === void 0) return;
|
|
2976
|
-
await writePoolProbes(region, probeUpdatesOf(await testAllAccounts(targets, modelId, poolRunnerDeps(region))));
|
|
2977
|
-
await applyRotation(region);
|
|
2978
|
-
}
|
|
2979
4123
|
ctx.effect(() => () => {
|
|
2980
4124
|
stopped = true;
|
|
2981
4125
|
for (const region of REGION_KEYS) stacks[region].shim.close();
|