@theokit/sdk 2.11.2 → 2.12.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.12.0
4
+
5
+ ### Minor Changes
6
+
7
+ - 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.
8
+
9
+ ## 2.11.3
10
+
11
+ ### Patch Changes
12
+
13
+ - bb3a7d8: Fail-loud on in-stream provider errors. OpenRouter (and some OpenAI-compatible proxies) report auth / quota / rate-limit failures as an HTTP 200 SSE body carrying `data: {"error":{"message":"...","code":401}}` rather than a non-2xx status. The stream accumulator only reads `choices`, so such an error-only chunk produced zero events and the turn finished empty — the failure was silently swallowed (a dead API key looked like an empty model response). The OpenAI client now detects an in-stream `error` chunk and throws the same typed error a non-2xx HTTP status would (`AuthenticationError` / `RateLimitError` / `ConfigurationError` / …), so callers surface it instead of a blank turn. Fixes usetheodev/theocode#31.
14
+
3
15
  ## 2.11.2
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) {
@@ -10270,7 +10305,10 @@ var init_openai2 = __esm({
10270
10305
  endpoint: "/v1/chat/completions"
10271
10306
  });
10272
10307
  }
10273
- const accumulator = new OpenAIStreamAccumulator();
10308
+ const accumulator = new OpenAIStreamAccumulator(
10309
+ this.options.extractToolCallsFromContent ?? false,
10310
+ providerId
10311
+ );
10274
10312
  for await (const record of parseSseStream(response.body, signal)) {
10275
10313
  if (record.data === "[DONE]") break;
10276
10314
  let chunk;
@@ -10279,6 +10317,16 @@ var init_openai2 = __esm({
10279
10317
  } catch {
10280
10318
  continue;
10281
10319
  }
10320
+ if (chunk.error !== void 0 && chunk.error !== null) {
10321
+ const status = typeof chunk.error.code === "number" ? chunk.error.code : 502;
10322
+ throw mapOpenAICompatibleError({
10323
+ providerId,
10324
+ status,
10325
+ body: chunk,
10326
+ headers: response.headers,
10327
+ endpoint: "/v1/chat/completions"
10328
+ });
10329
+ }
10282
10330
  const events = accumulator.consume(chunk);
10283
10331
  for (const event of events) yield event;
10284
10332
  }
@@ -10286,6 +10334,16 @@ var init_openai2 = __esm({
10286
10334
  }
10287
10335
  };
10288
10336
  OpenAIStreamAccumulator = class {
10337
+ /**
10338
+ * @param extractFromContent opt-in leaked-dialect safe-parse (theokit#58). Default false.
10339
+ * @param providerName provider id, used only to label the recovery log line.
10340
+ */
10341
+ constructor(extractFromContent = false, providerName = "openai") {
10342
+ this.extractFromContent = extractFromContent;
10343
+ this.providerName = providerName;
10344
+ }
10345
+ extractFromContent;
10346
+ providerName;
10289
10347
  text = "";
10290
10348
  stopReason = "end_turn";
10291
10349
  inputTokens;
@@ -10351,9 +10409,26 @@ var init_openai2 = __esm({
10351
10409
  const input = parseToolArguments(call.args);
10352
10410
  toolCalls.push({ type: "tool_use", id: call.id, name: call.name, input });
10353
10411
  }
10412
+ let text = this.text;
10413
+ let stopReason = this.stopReason;
10414
+ if (this.extractFromContent && toolCalls.length === 0) {
10415
+ const recovered = extractHermesToolCalls(
10416
+ this.text,
10417
+ () => `hermes-${globalThis.crypto.randomUUID()}`
10418
+ );
10419
+ if (recovered.toolCalls.length > 0) {
10420
+ toolCalls.push(...recovered.toolCalls);
10421
+ text = recovered.residualText;
10422
+ stopReason = "tool_use";
10423
+ process.stderr.write(
10424
+ `[theokit-sdk] recovered ${recovered.toolCalls.length} leaked tool call(s) from assistant content (provider="${this.providerName}", names=${recovered.toolCalls.map((c) => c.name).join(",")})
10425
+ `
10426
+ );
10427
+ }
10428
+ }
10354
10429
  return makeLlmFinish({
10355
- stopReason: this.stopReason,
10356
- text: this.text,
10430
+ stopReason,
10431
+ text,
10357
10432
  toolCalls,
10358
10433
  inputTokens: this.inputTokens,
10359
10434
  outputTokens: this.outputTokens,
@@ -10866,6 +10941,9 @@ function selectTransport(profile, apiKey) {
10866
10941
  const opts = { apiKey };
10867
10942
  opts.baseUrl = profile.baseUrl;
10868
10943
  opts.providerName = profile.name;
10944
+ if (profile.extractToolCallsFromContent === true) {
10945
+ opts.extractToolCallsFromContent = true;
10946
+ }
10869
10947
  if (profile.name === "openai" && process.env.OPENAI_ORGANIZATION !== void 0) {
10870
10948
  opts.organization = process.env.OPENAI_ORGANIZATION;
10871
10949
  }