@theokit/sdk 2.15.0 → 2.15.2

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 CHANGED
@@ -1,5 +1,17 @@
1
1
  # Changelog
2
2
 
3
+ ## 2.15.2
4
+
5
+ ### Patch Changes
6
+
7
+ - 6336f81: Suppress the leaked-dialect tool-call from the visible stream (R7). When `extractToolCallsFromContent` is enabled and a model leaks a `<function=NAME>` tool call as assistant text, the OpenAI-compat streaming now HOLDS that text back at the stream boundary (a small suspicion-buffer FSM that reuses the request-scoped allowlist from R5) instead of emitting it as `text_delta` events — so the raw dialect no longer flashes by in the live stream or lands in the final assistant text. `finish()` still recovers the call (unchanged). Fail-open: a never-closing marker or un-suppressable input is flushed as visible text (never held forever). Flag-off streaming is byte-for-byte unchanged. Grounded in openclaw's stream-normalizer FSM.
8
+
9
+ ## 2.15.1
10
+
11
+ ### Patch Changes
12
+
13
+ - bec2077: Make the leaked-dialect recovery **request-scoped (R5)**. The opt-in `extractToolCallsFromContent` recovery previously promoted ANY `<function=NAME>` block leaked into assistant text on an enabled route, so a code assistant printing a literal `<function=example>` in a fenced code block could be wrongly turned into a tool call. Recovery now gates on an exact, case-sensitive allowlist derived automatically from the current request's declared tools (`request.tools`): the per-route flag stays the coarse enable, and the allowlist is the precise false-positive guard. A request with no tools recovers nothing; a gated-out block keeps its text visible (it is not silently deleted). No public API change — the allowlist is derived from the tools you already pass. Mirrors openclaw's `@openclaw/tool-call-repair` allowlist.
14
+
3
15
  ## 2.15.0
4
16
 
5
17
  ### Minor Changes
@@ -10332,11 +10332,16 @@ var init_sanitize_tool_input = __esm({
10332
10332
  });
10333
10333
 
10334
10334
  // src/internal/llm/hermes-tool-extract.ts
10335
- function extractHermesToolCalls(content, makeId) {
10335
+ function extractHermesToolCalls(content, makeId, allowedToolNames) {
10336
+ const isPromoted = (name) => name.length > 0 && (allowedToolNames === void 0 || allowedToolNames.has(name));
10336
10337
  const toolCalls = [];
10338
+ const droppedNames = [];
10337
10339
  for (const block of content.matchAll(HERMES_BLOCK)) {
10338
10340
  const name = (block[1] ?? "").trim();
10339
- if (name.length === 0) continue;
10341
+ if (!isPromoted(name)) {
10342
+ if (name.length > 0 && allowedToolNames !== void 0) droppedNames.push(name);
10343
+ continue;
10344
+ }
10340
10345
  toolCalls.push({
10341
10346
  type: "tool_use",
10342
10347
  id: makeId(),
@@ -10344,8 +10349,11 @@ function extractHermesToolCalls(content, makeId) {
10344
10349
  input: parseHermesParams(block[2] ?? "")
10345
10350
  });
10346
10351
  }
10347
- const residualText = toolCalls.length === 0 ? content : content.replace(HERMES_BLOCK, "").trim();
10348
- return { toolCalls, residualText };
10352
+ const residualText = toolCalls.length === 0 ? content : content.replace(
10353
+ HERMES_BLOCK,
10354
+ (full, rawName) => isPromoted((rawName ?? "").trim()) ? "" : full
10355
+ ).trim();
10356
+ return { toolCalls, residualText, droppedNames };
10349
10357
  }
10350
10358
  function parseHermesParams(inner) {
10351
10359
  const input = {};
@@ -10357,12 +10365,83 @@ function parseHermesParams(inner) {
10357
10365
  }
10358
10366
  return sanitizeToolInput(input, { trim: true }).value;
10359
10367
  }
10360
- var HERMES_BLOCK, HERMES_PARAM;
10368
+ function streamToolCallBufferState(held, allowedToolNames, cap = DEFAULT_STREAM_BUFFER_CAP) {
10369
+ if (allowedToolNames.size === 0) return "impossible";
10370
+ const t = held.trimStart();
10371
+ if (t.length < STREAM_MARKER.length) {
10372
+ return STREAM_MARKER.startsWith(t) ? "possible" : "impossible";
10373
+ }
10374
+ if (!t.startsWith(STREAM_MARKER)) return "impossible";
10375
+ const parsed = parseStreamMarkerName(t);
10376
+ if (parsed === "building") return "possible";
10377
+ if (parsed === "invalid") return "impossible";
10378
+ const nameOk = parsed.complete ? allowedToolNames.has(parsed.name) : someToolNameStartsWith(allowedToolNames, parsed.name);
10379
+ if (!nameOk) return "impossible";
10380
+ return held.length > cap ? "impossible" : "possible";
10381
+ }
10382
+ function parseStreamMarkerName(t) {
10383
+ let cursor = STREAM_MARKER.length;
10384
+ while (cursor < t.length && isStreamWs(t[cursor])) cursor += 1;
10385
+ const nameStart = cursor;
10386
+ while (cursor < t.length && t[cursor] !== ">" && !isStreamWs(t[cursor])) cursor += 1;
10387
+ const name = t.slice(nameStart, cursor);
10388
+ if (name.length === 0) return cursor >= t.length ? "building" : "invalid";
10389
+ return { name, complete: cursor < t.length && t[cursor] === ">" };
10390
+ }
10391
+ function someToolNameStartsWith(allowedToolNames, prefix) {
10392
+ for (const name of allowedToolNames) {
10393
+ if (name.startsWith(prefix)) return true;
10394
+ }
10395
+ return false;
10396
+ }
10397
+ function firstPossibleMarkerStart(held, allowedToolNames) {
10398
+ for (let i = held.indexOf("<"); i !== -1; i = held.indexOf("<", i + 1)) {
10399
+ if (streamToolCallBufferState(held.slice(i), allowedToolNames) === "possible") return i;
10400
+ }
10401
+ return -1;
10402
+ }
10403
+ var HERMES_BLOCK, HERMES_PARAM, STREAM_MARKER, DEFAULT_STREAM_BUFFER_CAP, isStreamWs, StreamSuppressionBuffer;
10361
10404
  var init_hermes_tool_extract = __esm({
10362
10405
  "src/internal/llm/hermes-tool-extract.ts"() {
10363
10406
  init_sanitize_tool_input();
10364
10407
  HERMES_BLOCK = /<function=\s*([^>\s]+)\s*>([\s\S]*?)<\/tool_call>/g;
10365
10408
  HERMES_PARAM = /<parameter=\s*([^>\s]+)\s*>([\s\S]*?)<\/parameter>/g;
10409
+ STREAM_MARKER = "<function=";
10410
+ DEFAULT_STREAM_BUFFER_CAP = 8192;
10411
+ isStreamWs = (c) => c === " " || c === " " || c === "\n" || c === "\r";
10412
+ StreamSuppressionBuffer = class {
10413
+ constructor(allowedToolNames) {
10414
+ this.allowedToolNames = allowedToolNames;
10415
+ }
10416
+ allowedToolNames;
10417
+ #held = "";
10418
+ /** Feed a content delta; returns the text to emit as a `text_delta` now, or `undefined` to hold. */
10419
+ push(content) {
10420
+ this.#held += content;
10421
+ if (streamToolCallBufferState(this.#held, this.allowedToolNames) === "possible")
10422
+ return void 0;
10423
+ const holdStart = firstPossibleMarkerStart(this.#held, this.allowedToolNames);
10424
+ if (holdStart > 0) {
10425
+ const flush2 = this.#held.slice(0, holdStart);
10426
+ this.#held = this.#held.slice(holdStart);
10427
+ return flush2;
10428
+ }
10429
+ const flush = this.#held;
10430
+ this.#held = "";
10431
+ return flush;
10432
+ }
10433
+ /** Drain the held buffer at stream end. `hasNativeCalls` mirrors `finish()`'s size-guard: when
10434
+ * native `tool_calls` exist, `finish()` won't strip the leaked block, so stream the held text WHOLE
10435
+ * (keeping `accumulatedText == finish.text`); otherwise strip the recoverable blocks. Idempotent. */
10436
+ drain(hasNativeCalls) {
10437
+ if (this.#held.length === 0) return void 0;
10438
+ const held = this.#held;
10439
+ this.#held = "";
10440
+ if (hasNativeCalls) return held;
10441
+ const residual = extractHermesToolCalls(held, () => "held", this.allowedToolNames).residualText;
10442
+ return residual.length > 0 ? residual : void 0;
10443
+ }
10444
+ };
10366
10445
  }
10367
10446
  });
10368
10447
 
@@ -10550,7 +10629,10 @@ var init_openai2 = __esm({
10550
10629
  }
10551
10630
  const accumulator = new OpenAIStreamAccumulator(
10552
10631
  this.options.extractToolCallsFromContent ?? false,
10553
- providerId
10632
+ providerId,
10633
+ // R5: request-scoped allowlist — leaked recovery only promotes a block whose name is a tool the
10634
+ // model was actually given. Empty set (no tools) recovers nothing.
10635
+ new Set(request.tools?.map((tool) => tool.name) ?? [])
10554
10636
  );
10555
10637
  for await (const record of parseSseStream(response.body, signal)) {
10556
10638
  if (record.data === "[DONE]") break;
@@ -10573,6 +10655,8 @@ var init_openai2 = __esm({
10573
10655
  const events = accumulator.consume(chunk);
10574
10656
  for (const event of events) yield event;
10575
10657
  }
10658
+ const drainEvent = accumulator.finalizeHeldText();
10659
+ if (drainEvent !== void 0) yield drainEvent;
10576
10660
  return accumulator.finish();
10577
10661
  }
10578
10662
  };
@@ -10580,13 +10664,19 @@ var init_openai2 = __esm({
10580
10664
  /**
10581
10665
  * @param extractFromContent opt-in leaked-dialect safe-parse (theokit#58). Default false.
10582
10666
  * @param providerName provider id, used only to label the recovery log line.
10667
+ * @param allowedToolNames R5 request-scoped allowlist — built from `request.tools` at `stream()`;
10668
+ * leaked recovery in `finish()` only promotes a block whose name is in this set. `undefined`
10669
+ * (direct construction) recovers all (back-compat); an empty set recovers nothing.
10583
10670
  */
10584
- constructor(extractFromContent = false, providerName = "openai") {
10671
+ constructor(extractFromContent = false, providerName = "openai", allowedToolNames) {
10585
10672
  this.extractFromContent = extractFromContent;
10586
10673
  this.providerName = providerName;
10674
+ this.allowedToolNames = allowedToolNames;
10675
+ this.suppress = extractFromContent && allowedToolNames !== void 0 && allowedToolNames.size > 0 ? new StreamSuppressionBuffer(allowedToolNames) : void 0;
10587
10676
  }
10588
10677
  extractFromContent;
10589
10678
  providerName;
10679
+ allowedToolNames;
10590
10680
  text = "";
10591
10681
  stopReason = "end_turn";
10592
10682
  inputTokens;
@@ -10595,18 +10685,30 @@ var init_openai2 = __esm({
10595
10685
  cacheWriteTokens;
10596
10686
  reasoningTokens;
10597
10687
  toolCalls = /* @__PURE__ */ new Map();
10688
+ /** R7: present only when recovery is enabled AND the request declares tools — holds suspected
10689
+ * leaked-dialect content back from the `text_delta` stream. `undefined` ⇒ stream immediately. */
10690
+ suppress;
10598
10691
  consume(chunk) {
10599
10692
  const events = [];
10600
10693
  this.applyUsage(chunk.usage);
10601
10694
  for (const choice of chunk.choices ?? []) {
10602
- const reasoningEvent = this.applyReasoningDelta(
10603
- choice.delta?.reasoning ?? choice.delta?.reasoning_content
10604
- );
10605
- if (reasoningEvent !== void 0) events.push(reasoningEvent);
10606
- const textEvent = this.applyContentDelta(choice.delta?.content);
10607
- if (textEvent !== void 0) events.push(textEvent);
10608
- this.mergeToolCallDeltas(choice.delta?.tool_calls);
10609
- this.applyFinishReason(choice.finish_reason);
10695
+ events.push(...this.applyChoice(choice));
10696
+ }
10697
+ return events;
10698
+ }
10699
+ applyChoice(choice) {
10700
+ const events = [];
10701
+ const reasoningEvent = this.applyReasoningDelta(
10702
+ choice.delta?.reasoning ?? choice.delta?.reasoning_content
10703
+ );
10704
+ if (reasoningEvent !== void 0) events.push(reasoningEvent);
10705
+ const textEvent = this.applyContentDelta(choice.delta?.content);
10706
+ if (textEvent !== void 0) events.push(textEvent);
10707
+ this.mergeToolCallDeltas(choice.delta?.tool_calls);
10708
+ this.applyFinishReason(choice.finish_reason);
10709
+ if (choice.finish_reason !== void 0 && choice.finish_reason !== null) {
10710
+ const flushEvent = this.finalizeHeldText();
10711
+ if (flushEvent !== void 0) events.push(flushEvent);
10610
10712
  }
10611
10713
  return events;
10612
10714
  }
@@ -10631,7 +10733,17 @@ var init_openai2 = __esm({
10631
10733
  applyContentDelta(content) {
10632
10734
  if (typeof content !== "string" || content.length === 0) return void 0;
10633
10735
  this.text += content;
10634
- return { type: "text_delta", text: content };
10736
+ if (this.suppress === void 0) return { type: "text_delta", text: content };
10737
+ const emit = this.suppress.push(content);
10738
+ return emit !== void 0 ? { type: "text_delta", text: emit } : void 0;
10739
+ }
10740
+ /** R7 held-buffer finalizer, called at the `finish_reason` chunk (in `applyChoice`) AND after the
10741
+ * SSE loop in `stream()` — so a stream that omits a `finish_reason` terminal never silently drops
10742
+ * held text. `toolCalls.size > 0` (native calls present) makes `finish()` skip recovery, so the
10743
+ * buffer streams the held text whole. Idempotent once drained. */
10744
+ finalizeHeldText() {
10745
+ const emit = this.suppress?.drain(this.toolCalls.size > 0);
10746
+ return emit !== void 0 ? { type: "text_delta", text: emit } : void 0;
10635
10747
  }
10636
10748
  mergeToolCallDeltas(deltas) {
10637
10749
  for (const call of deltas ?? []) {
@@ -10657,7 +10769,8 @@ var init_openai2 = __esm({
10657
10769
  if (this.extractFromContent && toolCalls.length === 0) {
10658
10770
  const recovered = extractHermesToolCalls(
10659
10771
  this.text,
10660
- () => `hermes-${globalThis.crypto.randomUUID()}`
10772
+ () => `hermes-${globalThis.crypto.randomUUID()}`,
10773
+ this.allowedToolNames
10661
10774
  );
10662
10775
  if (recovered.toolCalls.length > 0) {
10663
10776
  toolCalls.push(...recovered.toolCalls);
@@ -10665,6 +10778,12 @@ var init_openai2 = __esm({
10665
10778
  stopReason = "tool_use";
10666
10779
  process.stderr.write(
10667
10780
  `[theokit-sdk] recovered ${recovered.toolCalls.length} leaked tool call(s) from assistant content (provider="${this.providerName}", names=${recovered.toolCalls.map((c) => c.name).join(",")})
10781
+ `
10782
+ );
10783
+ }
10784
+ if (recovered.droppedNames.length > 0) {
10785
+ process.stderr.write(
10786
+ `[theokit-sdk] dropped ${recovered.droppedNames.length} leaked block(s) whose name is not a tool in the request (provider="${this.providerName}", names=${recovered.droppedNames.join(",")})
10668
10787
  `
10669
10788
  );
10670
10789
  }