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/lib/index.js CHANGED
@@ -1,7 +1,7 @@
1
- import { A as WorkBuddyCredentialRejectedError, B as clearAtRestKeyCache, C as hasEncryptedCredentialFields, D as workbuddyAccountId, E as parseWorkBuddyAuth, F as parseReasoning, G as isEncryptedFieldWrapper, H as deriveAtRestKeyId, I as parseUpstreamModel, J as macosNestedAppBundles, K as isWorkbuddyBundle, L as prepareChatBody, M as classifyUpstreamError, N as isCredentialRejectedError, O as workbuddyOwnAuthPath, P as parseCreditMultiplier, R as regionOf, S as defaultDesktopAuthPath, T as legacyWorkbuddyOwnAuthPath, U as fetchAtRestKeyPayload, V as deriveAtRestKey, W as findWorkbuddyAppExecutable, X as readAtRestKey, Y as openEncryptedField, Z as workbuddyAppExecutableCandidates, _ as WorkBuddyCredentialStore, a as readHostHeartbeat, b as defaultDesktopAuthCandidates, c as WORKBUDDY_CONNECT_VERSION, d as WorkBuddyCatalog, f as deriveCatalog, g as WORKBUDDY_AUTH_FILE_ENV, h as WORKBUDDY_AUTH_FILENAME, i as processStartTimeMs, j as WorkBuddyUpstreamClient, k as CREDENTIAL_REJECTED_CODE, l as FALLBACK_WORKBUDDY_MODELS, m as ENCRYPTED_CREDENTIAL_CODE, n as clearHostHeartbeat, o as workbuddyHostHeartbeatPath, p as fallbackModelsFor, q as macosBundleExecutable, r as isHeartbeatProcessAlive, s as writeHostHeartbeat, t as WORKBUDDY_HOST_HEARTBEAT_FILENAME, u as FALLBACK_WORKBUDDY_MODELS_GLOBAL, v as WorkBuddyEncryptedCredentialError, w as isEncryptedCredentialError, x as defaultDesktopAuthDirs, y as authFileName, z as WORKBUDDY_APP_EXECUTABLE_ENV } from "./host-heartbeat-Dwmfxuyw.js";
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 context into the 0.85 shape this plugin's pi-ai
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: fold the system text back into
234
- * `Context.systemPrompt`, which is where 0.85 expects it.
233
+ * a 0.87 transcript to a 0.85 API object.
235
234
  *
236
- * Deliberately SHAPE-based, not version-based: it asks "does this context carry
237
- * a system message inside `messages`?" rather than "which pi-ai version is
238
- * loaded?". A version check would be wrong the moment either side moves, and
239
- * this plugin has to survive both host generations.
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 !== void 0) return context;
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
- if (!sawSystemMessage) return context;
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
- ...systemTexts.length === 0 ? {} : { systemPrompt: systemTexts.join("\n\n") },
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
- req.on("close", () => controller.abort());
545
- const result = await client.chatStream(credential, prepared, controller.signal);
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
- writeOpenAIError(res, KIND_STATUS[result.kind], result.kind, `workbuddy upstream ${result.kind} (http ${result.status}): ${result.message.slice(0, 400)}`);
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
- let sawDone = false;
557
- const body = Readable.fromWeb(result.response.body);
558
- body.on("data", (chunk) => {
559
- if (chunk.includes("[DONE]")) sawDone = true;
560
- });
561
- body.on("error", (error) => {
562
- logger?.warn("dsh-connect-workbuddy: upstream stream failed mid-flight", error);
563
- if (!sawDone && res.writable) res.end("data: [DONE]\n\n");
564
- });
565
- body.pipe(res);
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
- rotateByCredits: preferences.rotateByCredits,
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 the account pool is active for this region (opt-in)"),
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
- * `rotateByCredits` is consumed HERE, by the credential store's selection
2645
- * hook, rather than by writing `config.accounts[region]`. That separation
2646
- * is the whole reason "turn rotation off" cleanly restores the user's own
2647
- * choice: rotation decides who serves at runtime and never overwrites the
2648
- * record of what the user picked.
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
- return credential === void 0 ? void 0 : workbuddyAccountId(credential);
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
- * Apply the pool's rotation to one region's store.
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 store's runtime override is set only when rotation is switched ON and a
2759
- * usable account exists; otherwise it is CLEARED, which restores the user's
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
- * Never writes settings: the user's saved choice is untouched throughout.
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 applyRotation = async (region) => {
2767
- const store = stacks[region].store;
2768
- const stale = rotationGuard.begin(region);
2769
- const preferences = poolPreferencesOf(current(), region);
2770
- if (!preferences.enabled || !preferences.rotateByCredits) {
2771
- store.setRotatedAccount(void 0);
2772
- return;
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
- const members = await poolMembersOf(region);
2775
- if (stale()) return;
2776
- const picked = pickAccount(members, Date.now());
2777
- const latest = poolPreferencesOf(current(), region);
2778
- if (!latest.enabled || !latest.rotateByCredits) {
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
- store.setRotatedAccount(picked?.id);
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
- return await Promise.all(accounts.map(async (account) => {
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();