@theokit/sdk 2.11.3 → 2.13.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/CHANGELOG.md CHANGED
@@ -1,5 +1,17 @@
1
1
  # Changelog
2
2
 
3
+ ## 2.13.0
4
+
5
+ ### Minor Changes
6
+
7
+ - 958a81f: Add per-route opt-in for leaked-dialect safe-parse (`ProviderRoute.extractToolCallsFromContent`, default off). The 2.12.0 release exposed the recovery flag only on a static `ProviderProfile`; enabling it required redeclaring a provider profile. This adds the same flag at the routing layer, so a consumer can opt a single chat route into recovery without cloning the built-in provider: `providers: { routes: [{ capability: "chat", provider: "openrouter", extractToolCallsFromContent: true }] }`. The router clones the resolved profile with the flag for that run (built-in profiles still ship the flag off), so the OpenAI-compatible transport recovers the Hermes `<function=…></tool_call>` dialect leaked as text by models like qwen3-coder. Derived from `routes[0]` (mirrors how the primary provider is derived) and applied to the resolved chat chain; fail-open and default-off, so a non-leaking route is unaffected. This is the enablement path consumed by `@theokit/agents`' `recoverLeakedToolCalls` knob.
8
+
9
+ ## 2.12.0
10
+
11
+ ### Minor Changes
12
+
13
+ - 95e9cba: Add opt-in leaked-dialect safe-parse for OpenAI-compatible providers (`ProviderProfile.extractToolCallsFromContent`, default off). Some models — notably qwen3-coder via OpenRouter — intermittently emit their Hermes tool-call dialect (`<function=NAME><parameter=KEY>VALUE</parameter></function></tool_call>`) as assistant TEXT instead of native `tool_calls`. When that happens the provider sends ZERO native `tool_calls`, so the agent loop sees a plain `end_turn` and the intended call is silently lost (theokit#58 follow-up). With the flag enabled, a `chat_completions` finish that has no native `tool_calls` has its assistant content scanned for the leaked dialect; any recovered calls are surfaced as real `tool_calls` and the stop reason flips to `tool_use` so the loop dispatches them. Fail-open like `stripThinkBlocks` — a partial/unclosed block is left as text and never fabricates a call. Default off, dedup-guarded (native `tool_calls` always win, no double-count), and scoped per-provider so a code assistant printing a literal `<function=` in a fenced block on a non-leaking route is unaffected.
14
+
3
15
  ## 2.11.3
4
16
 
5
17
  ### Patch Changes
@@ -10097,6 +10097,40 @@ var init_ollama_native = __esm({
10097
10097
  }
10098
10098
  });
10099
10099
 
10100
+ // src/internal/llm/hermes-tool-extract.ts
10101
+ function extractHermesToolCalls(content, makeId) {
10102
+ const toolCalls = [];
10103
+ for (const block of content.matchAll(HERMES_BLOCK)) {
10104
+ const name = (block[1] ?? "").trim();
10105
+ if (name.length === 0) continue;
10106
+ toolCalls.push({
10107
+ type: "tool_use",
10108
+ id: makeId(),
10109
+ name,
10110
+ input: parseHermesParams(block[2] ?? "")
10111
+ });
10112
+ }
10113
+ const residualText = toolCalls.length === 0 ? content : content.replace(HERMES_BLOCK, "").trim();
10114
+ return { toolCalls, residualText };
10115
+ }
10116
+ function parseHermesParams(inner) {
10117
+ const input = {};
10118
+ for (const param of inner.matchAll(HERMES_PARAM)) {
10119
+ const key = param[1];
10120
+ const value = param[2];
10121
+ if (key === void 0 || value === void 0) continue;
10122
+ input[key.trim()] = value;
10123
+ }
10124
+ return input;
10125
+ }
10126
+ var HERMES_BLOCK, HERMES_PARAM;
10127
+ var init_hermes_tool_extract = __esm({
10128
+ "src/internal/llm/hermes-tool-extract.ts"() {
10129
+ HERMES_BLOCK = /<function=\s*([^>\s]+)\s*>([\s\S]*?)<\/tool_call>/g;
10130
+ HERMES_PARAM = /<parameter=\s*([^>\s]+)\s*>([\s\S]*?)<\/parameter>/g;
10131
+ }
10132
+ });
10133
+
10100
10134
  // src/internal/llm/openai.ts
10101
10135
  function mapOpenAIFinish(reason) {
10102
10136
  switch (reason) {
@@ -10209,6 +10243,7 @@ var init_openai2 = __esm({
10209
10243
  init_ollama2();
10210
10244
  init_openai_compatible();
10211
10245
  init_finish();
10246
+ init_hermes_tool_extract();
10212
10247
  init_sse();
10213
10248
  OpenAIClient = class {
10214
10249
  constructor(options) {
@@ -10220,6 +10255,14 @@ var init_openai2 = __esm({
10220
10255
  name = "openai";
10221
10256
  baseUrl;
10222
10257
  fetchImpl;
10258
+ /**
10259
+ * Whether this client recovers leaked Hermes tool-call dialect from assistant
10260
+ * text (theokit#58 follow-up). Observability for the route→client wiring of
10261
+ * `extractToolCallsFromContent`. @internal
10262
+ */
10263
+ get recoversLeakedToolCalls() {
10264
+ return this.options.extractToolCallsFromContent ?? false;
10265
+ }
10223
10266
  // biome-ignore lint/complexity/noExcessiveCognitiveComplexity: HTTP+SSE handshake + accumulator is intentionally one block
10224
10267
  async *stream(request, signal) {
10225
10268
  const headers = {
@@ -10270,7 +10313,10 @@ var init_openai2 = __esm({
10270
10313
  endpoint: "/v1/chat/completions"
10271
10314
  });
10272
10315
  }
10273
- const accumulator = new OpenAIStreamAccumulator();
10316
+ const accumulator = new OpenAIStreamAccumulator(
10317
+ this.options.extractToolCallsFromContent ?? false,
10318
+ providerId
10319
+ );
10274
10320
  for await (const record of parseSseStream(response.body, signal)) {
10275
10321
  if (record.data === "[DONE]") break;
10276
10322
  let chunk;
@@ -10296,6 +10342,16 @@ var init_openai2 = __esm({
10296
10342
  }
10297
10343
  };
10298
10344
  OpenAIStreamAccumulator = class {
10345
+ /**
10346
+ * @param extractFromContent opt-in leaked-dialect safe-parse (theokit#58). Default false.
10347
+ * @param providerName provider id, used only to label the recovery log line.
10348
+ */
10349
+ constructor(extractFromContent = false, providerName = "openai") {
10350
+ this.extractFromContent = extractFromContent;
10351
+ this.providerName = providerName;
10352
+ }
10353
+ extractFromContent;
10354
+ providerName;
10299
10355
  text = "";
10300
10356
  stopReason = "end_turn";
10301
10357
  inputTokens;
@@ -10361,9 +10417,26 @@ var init_openai2 = __esm({
10361
10417
  const input = parseToolArguments(call.args);
10362
10418
  toolCalls.push({ type: "tool_use", id: call.id, name: call.name, input });
10363
10419
  }
10420
+ let text = this.text;
10421
+ let stopReason = this.stopReason;
10422
+ if (this.extractFromContent && toolCalls.length === 0) {
10423
+ const recovered = extractHermesToolCalls(
10424
+ this.text,
10425
+ () => `hermes-${globalThis.crypto.randomUUID()}`
10426
+ );
10427
+ if (recovered.toolCalls.length > 0) {
10428
+ toolCalls.push(...recovered.toolCalls);
10429
+ text = recovered.residualText;
10430
+ stopReason = "tool_use";
10431
+ process.stderr.write(
10432
+ `[theokit-sdk] recovered ${recovered.toolCalls.length} leaked tool call(s) from assistant content (provider="${this.providerName}", names=${recovered.toolCalls.map((c) => c.name).join(",")})
10433
+ `
10434
+ );
10435
+ }
10436
+ }
10364
10437
  return makeLlmFinish({
10365
- stopReason: this.stopReason,
10366
- text: this.text,
10438
+ stopReason,
10439
+ text,
10367
10440
  toolCalls,
10368
10441
  inputTokens: this.inputTokens,
10369
10442
  outputTokens: this.outputTokens,
@@ -10775,8 +10848,9 @@ function buildChain(options) {
10775
10848
  return clients;
10776
10849
  }
10777
10850
  function buildClient(name, routerOptions) {
10778
- const profile = getProviderProfile(name);
10779
- if (profile === void 0) return void 0;
10851
+ const baseProfile = getProviderProfile(name);
10852
+ if (baseProfile === void 0) return void 0;
10853
+ const profile = routerOptions.extractToolCallsFromContent === true && baseProfile.extractToolCallsFromContent !== true ? { ...baseProfile, extractToolCallsFromContent: true } : baseProfile;
10780
10854
  const ambient = currentCredentialPool(name);
10781
10855
  if (ambient !== void 0) {
10782
10856
  return new PoolAwareLlmClient(ambient, (apiKey) => selectTransport(profile, apiKey));
@@ -10876,6 +10950,9 @@ function selectTransport(profile, apiKey) {
10876
10950
  const opts = { apiKey };
10877
10951
  opts.baseUrl = profile.baseUrl;
10878
10952
  opts.providerName = profile.name;
10953
+ if (profile.extractToolCallsFromContent === true) {
10954
+ opts.extractToolCallsFromContent = true;
10955
+ }
10879
10956
  if (profile.name === "openai" && process.env.OPENAI_ORGANIZATION !== void 0) {
10880
10957
  opts.organization = process.env.OPENAI_ORGANIZATION;
10881
10958
  }
@@ -11202,11 +11279,13 @@ function buildLoopInputs(options, runId, userText) {
11202
11279
  const fallback = options.agentOptions.providers?.fallback;
11203
11280
  const apiKeys = options.agentOptions.providers?.apiKeys;
11204
11281
  const credentialPoolStrategy = options.agentOptions.providers?.credentialPoolStrategy;
11282
+ const extractToolCallsFromContent = options.agentOptions.providers?.routes?.[0]?.extractToolCallsFromContent;
11205
11283
  const chain = resolveProviderChain({
11206
11284
  primary,
11207
11285
  ...fallback !== void 0 ? { fallback } : {},
11208
11286
  ...apiKeys !== void 0 ? { apiKeys } : {},
11209
- ...credentialPoolStrategy !== void 0 ? { credentialPoolStrategy } : {}
11287
+ ...credentialPoolStrategy !== void 0 ? { credentialPoolStrategy } : {},
11288
+ ...extractToolCallsFromContent === true ? { extractToolCallsFromContent: true } : {}
11210
11289
  });
11211
11290
  const llm = chain.length === 1 ? chain[0] : new FallbackLlmClient(chain);
11212
11291
  return {