@p-sw/brainbox 0.1.2-alpha.11 → 0.1.2-alpha.12

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.
Files changed (2) hide show
  1. package/dist/index.js +212 -87
  2. package/package.json +1 -1
package/dist/index.js CHANGED
@@ -489,15 +489,100 @@ function stripThinkTags(content) {
489
489
  }
490
490
  function parseModelJson(content) {
491
491
  const cleaned = stripThinkTags(content);
492
- try {
493
- return JSON.parse(cleaned);
494
- } catch (first) {
495
- const fence = cleaned.match(/```(?:json)?\s*([\s\S]*?)```/i);
496
- if (fence?.[1]) {
497
- return JSON.parse(fence[1].trim());
492
+ const candidates = [cleaned];
493
+ const fence = cleaned.match(/```(?:json)?\s*([\s\S]*?)```/i);
494
+ if (fence?.[1])
495
+ candidates.push(fence[1].trim());
496
+ const extracted = extractJsonSlice(cleaned);
497
+ if (extracted)
498
+ candidates.push(extracted);
499
+ let lastErr;
500
+ for (const candidate of candidates) {
501
+ try {
502
+ return decodeJsonValue(candidate);
503
+ } catch (err) {
504
+ lastErr = err;
505
+ }
506
+ }
507
+ throw lastErr instanceof Error ? lastErr : new Error("Failed to parse model JSON");
508
+ }
509
+ function decodeJsonValue(text) {
510
+ let value = JSON.parse(text);
511
+ for (let i = 0;i < 2 && typeof value === "string"; i++) {
512
+ const inner = value.trim();
513
+ if (!(inner.startsWith("{") && inner.endsWith("}") || inner.startsWith("[") && inner.endsWith("]") || inner.startsWith('"') && inner.endsWith('"'))) {
514
+ break;
498
515
  }
499
- throw first;
516
+ value = JSON.parse(inner);
500
517
  }
518
+ return value;
519
+ }
520
+ function extractJsonSlice(text) {
521
+ const start = text.search(/[\{\[]/);
522
+ if (start < 0)
523
+ return;
524
+ const open = text[start];
525
+ const close = open === "{" ? "}" : "]";
526
+ let depth = 0;
527
+ let inString = false;
528
+ let escape = false;
529
+ for (let i = start;i < text.length; i++) {
530
+ const ch = text[i];
531
+ if (inString) {
532
+ if (escape) {
533
+ escape = false;
534
+ } else if (ch === "\\") {
535
+ escape = true;
536
+ } else if (ch === '"') {
537
+ inString = false;
538
+ }
539
+ continue;
540
+ }
541
+ if (ch === '"') {
542
+ inString = true;
543
+ continue;
544
+ }
545
+ if (ch === open)
546
+ depth++;
547
+ else if (ch === close) {
548
+ depth--;
549
+ if (depth === 0)
550
+ return text.slice(start, i + 1);
551
+ }
552
+ }
553
+ return;
554
+ }
555
+ function schemaToolName(name) {
556
+ const cleaned = name.replace(/[^a-zA-Z0-9_]/g, "_").replace(/^(\d)/, "_$1");
557
+ return cleaned.length > 0 ? cleaned : "submit_result";
558
+ }
559
+ function buildStructuredJsonRequest(options) {
560
+ const toolName = schemaToolName(options.jsonSchemaName);
561
+ return {
562
+ toolName,
563
+ tool: {
564
+ name: toolName,
565
+ description: `Submit the structured ${options.jsonSchemaName} result.`,
566
+ parameters: options.jsonSchema ?? {
567
+ type: "object",
568
+ additionalProperties: true
569
+ }
570
+ },
571
+ instruction: `${options.instruction}
572
+
573
+ You MUST call the \`${toolName}\` tool exactly once with the complete answer.
574
+ Do not write the JSON as plain text or inside a markdown code fence.`
575
+ };
576
+ }
577
+ function parseStructuredJsonResult(choice, toolName) {
578
+ const call = choice.message.toolCalls?.find((c) => c.function.name === toolName) ?? choice.message.toolCalls?.[0];
579
+ if (call?.function.arguments) {
580
+ return parseModelJson(call.function.arguments);
581
+ }
582
+ if (choice.message.content) {
583
+ return parseModelJson(choice.message.content);
584
+ }
585
+ throw new Error("Empty response from model");
501
586
  }
502
587
  function readAuthString(auth, key, envName) {
503
588
  const fromAuth = typeof auth?.[key] === "string" ? auth[key] : undefined;
@@ -781,6 +866,7 @@ class OpenAICompatibleExecutor extends LLMExecutor {
781
866
  chatPath;
782
867
  noBearerPrefix;
783
868
  reasoningEffortInQuery;
869
+ supportsResponseFormat;
784
870
  constructor(opts) {
785
871
  super();
786
872
  this.providerName = opts.providerName;
@@ -790,6 +876,7 @@ class OpenAICompatibleExecutor extends LLMExecutor {
790
876
  this.chatPath = opts.chatPath ?? "/chat/completions";
791
877
  this.noBearerPrefix = opts.noBearerPrefix ?? false;
792
878
  this.reasoningEffortInQuery = opts.reasoningEffortInQuery ?? false;
879
+ this.supportsResponseFormat = opts.supportsResponseFormat ?? true;
793
880
  this.models = {
794
881
  conversation: opts.conversationModel,
795
882
  identity: opts.identityModel
@@ -861,6 +948,9 @@ class OpenAICompatibleExecutor extends LLMExecutor {
861
948
  return data;
862
949
  }
863
950
  async call(model, options) {
951
+ if ("jsonSchemaName" in options && !this.supportsResponseFormat) {
952
+ return this.callJsonViaTool(model, options);
953
+ }
864
954
  const jsonMode = "jsonSchemaName" in options;
865
955
  const reasoning = defaultReasoningEffort(options.reasoningEffort, model, this.models.identity);
866
956
  log4.debug(`call: provider=${this.providerName} model=${model} jsonSchema=${jsonMode ? options.jsonSchemaName : "-"} msgLen=${options.message.length}`);
@@ -886,6 +976,19 @@ class OpenAICompatibleExecutor extends LLMExecutor {
886
976
  log4.debug(`call: response ${content.length} chars`);
887
977
  return jsonMode ? parseModelJson(content) : content;
888
978
  }
979
+ async callJsonViaTool(model, options) {
980
+ const { toolName, tool, instruction } = buildStructuredJsonRequest(options);
981
+ log4.debug(`callJsonViaTool: provider=${this.providerName} model=${model} tool=${toolName}`);
982
+ const choice = await this.chatWithTools(model, {
983
+ caller: options.caller ?? options.jsonSchemaName,
984
+ instruction,
985
+ messages: [{ role: "user", content: options.message }],
986
+ tools: [tool],
987
+ reasoningEffort: "none",
988
+ parallelToolCalls: false
989
+ });
990
+ return parseStructuredJsonResult(choice, toolName);
991
+ }
889
992
  async chatWithTools(model, options) {
890
993
  const reasoning = defaultReasoningEffort(options.reasoningEffort, model, this.models.identity);
891
994
  log4.debug(`chatWithTools: provider=${this.providerName} model=${model} msgs=${options.messages.length} tools=${options.tools.length}`);
@@ -1219,7 +1322,8 @@ function minimaxOpts(providerName, baseURL, opts) {
1219
1322
  baseURL,
1220
1323
  apiKey: opts.apiKey,
1221
1324
  conversationModel: opts.conversationModel,
1222
- identityModel: opts.identityModel
1325
+ identityModel: opts.identityModel,
1326
+ supportsResponseFormat: false
1223
1327
  };
1224
1328
  }
1225
1329
 
@@ -1295,7 +1399,8 @@ class LmStudioExecutor extends OpenAICompatibleExecutor {
1295
1399
  baseURL: "http://127.0.0.1:1234/v1",
1296
1400
  apiKey: opts.apiKey || "lm-studio",
1297
1401
  conversationModel: opts.conversationModel,
1298
- identityModel: opts.identityModel
1402
+ identityModel: opts.identityModel,
1403
+ supportsResponseFormat: false
1299
1404
  });
1300
1405
  }
1301
1406
  }
@@ -1308,7 +1413,8 @@ class OllamaExecutor extends OpenAICompatibleExecutor {
1308
1413
  baseURL: "http://127.0.0.1:11434/v1",
1309
1414
  apiKey: opts.apiKey || "ollama",
1310
1415
  conversationModel: opts.conversationModel,
1311
- identityModel: opts.identityModel
1416
+ identityModel: opts.identityModel,
1417
+ supportsResponseFormat: false
1312
1418
  });
1313
1419
  }
1314
1420
  }
@@ -1334,7 +1440,8 @@ class LlamaCppExecutor extends OpenAICompatibleExecutor {
1334
1440
  baseURL: "http://127.0.0.1:8080/v1",
1335
1441
  apiKey: opts.apiKey || "llama.cpp",
1336
1442
  conversationModel: opts.conversationModel,
1337
- identityModel: opts.identityModel
1443
+ identityModel: opts.identityModel,
1444
+ supportsResponseFormat: false
1338
1445
  });
1339
1446
  }
1340
1447
  }
@@ -1573,17 +1680,17 @@ var AnthropicAuthSchema = z4.object({
1573
1680
  apiVersion: z4.string().optional()
1574
1681
  }).loose();
1575
1682
  function toAnthropicMessages(messages) {
1576
- let system;
1577
- const msgs = [];
1683
+ let system2;
1684
+ const msgs2 = [];
1578
1685
  for (const m of messages) {
1579
1686
  if (m.role === "system") {
1580
- system = (system ? system + `
1687
+ system2 = (system2 ? system2 + `
1581
1688
 
1582
1689
  ` : "") + m.content;
1583
1690
  continue;
1584
1691
  }
1585
1692
  if (m.role === "user") {
1586
- msgs.push({ role: "user", content: m.content });
1693
+ msgs2.push({ role: "user", content: m.content });
1587
1694
  continue;
1588
1695
  }
1589
1696
  if (m.role === "assistant") {
@@ -1606,11 +1713,11 @@ function toAnthropicMessages(messages) {
1606
1713
  });
1607
1714
  }
1608
1715
  }
1609
- msgs.push({ role: "assistant", content: blocks });
1716
+ msgs2.push({ role: "assistant", content: blocks });
1610
1717
  continue;
1611
1718
  }
1612
1719
  if (m.role === "tool") {
1613
- msgs.push({
1720
+ msgs2.push({
1614
1721
  role: "user",
1615
1722
  content: [
1616
1723
  {
@@ -1622,7 +1729,7 @@ function toAnthropicMessages(messages) {
1622
1729
  });
1623
1730
  }
1624
1731
  }
1625
- return { system, msgs };
1732
+ return { system: system2, msgs: msgs2 };
1626
1733
  }
1627
1734
  function toAnthropicTool(t) {
1628
1735
  return {
@@ -1669,10 +1776,25 @@ class AnthropicExecutor extends LLMExecutor {
1669
1776
  return JSON.parse(responseRaw);
1670
1777
  }
1671
1778
  async call(model, options) {
1672
- const jsonMode = "jsonSchemaName" in options;
1779
+ if ("jsonSchemaName" in options) {
1780
+ const { toolName, tool, instruction } = buildStructuredJsonRequest({
1781
+ instruction: options.instruction,
1782
+ jsonSchemaName: options.jsonSchemaName,
1783
+ jsonSchema: options.jsonSchema
1784
+ });
1785
+ const choice = await this.chatWithTools(model, {
1786
+ caller: options.caller ?? options.jsonSchemaName,
1787
+ instruction,
1788
+ messages: [{ role: "user", content: options.message }],
1789
+ tools: [tool],
1790
+ reasoningEffort: "none",
1791
+ toolChoice: { type: "tool", name: toolName }
1792
+ });
1793
+ return parseStructuredJsonResult(choice, toolName);
1794
+ }
1673
1795
  const reasoning = defaultReasoningEffort(options.reasoningEffort, model, this.models.identity);
1674
1796
  const log5 = logger.child("llm:anthropic");
1675
- log5.debug(`call: model=${model} jsonSchema=${jsonMode ? options.jsonSchemaName : "-"} msgLen=${options.message.length}`);
1797
+ log5.debug(`call: model=${model} jsonSchema=- msgLen=${options.message.length}`);
1676
1798
  const outputCap = 4096;
1677
1799
  const body = {
1678
1800
  model,
@@ -1696,21 +1818,27 @@ class AnthropicExecutor extends LLMExecutor {
1696
1818
  if (!text) {
1697
1819
  throw new Error("Empty response from model");
1698
1820
  }
1699
- return jsonMode ? parseModelJson(text) : text;
1821
+ return text;
1700
1822
  }
1701
1823
  async chatWithTools(model, options) {
1702
1824
  const reasoning = defaultReasoningEffort(options.reasoningEffort, model, this.models.identity);
1703
1825
  const log5 = logger.child("llm:anthropic");
1704
1826
  log5.debug(`chatWithTools: model=${model} msgs=${options.messages.length} tools=${options.tools.length}`);
1705
- const { system, msgs } = toAnthropicMessages(options.messages);
1827
+ const { system: system2, msgs: msgs2 } = toAnthropicMessages(options.messages);
1706
1828
  const outputCap = 4096;
1707
1829
  const body = {
1708
1830
  model,
1709
1831
  max_tokens: outputCap,
1710
- system: system ?? options.instruction,
1711
- messages: msgs,
1832
+ system: system2 ?? options.instruction,
1833
+ messages: msgs2,
1712
1834
  tools: options.tools.map(toAnthropicTool)
1713
1835
  };
1836
+ if (options.toolChoice) {
1837
+ body["tool_choice"] = {
1838
+ type: "tool",
1839
+ name: options.toolChoice.name
1840
+ };
1841
+ }
1714
1842
  if (reasoning !== "none") {
1715
1843
  const budget = REASONING_BUDGET[reasoning];
1716
1844
  body["max_tokens"] = budget + outputCap;
@@ -1789,58 +1917,6 @@ function signRequest(opts) {
1789
1917
  headers["X-Amz-Security-Token"] = opts.sessionToken;
1790
1918
  return headers;
1791
1919
  }
1792
- function toAnthropicMessages2(messages) {
1793
- let system;
1794
- const msgs = [];
1795
- for (const m of messages) {
1796
- if (m.role === "system") {
1797
- system = (system ? system + `
1798
-
1799
- ` : "") + m.content;
1800
- continue;
1801
- }
1802
- if (m.role === "user") {
1803
- msgs.push({ role: "user", content: [{ type: "text", text: m.content }] });
1804
- continue;
1805
- }
1806
- if (m.role === "assistant") {
1807
- const blocks = [];
1808
- if (m.content)
1809
- blocks.push({ type: "text", text: m.content });
1810
- if (m.toolCalls) {
1811
- for (const c of m.toolCalls) {
1812
- let input = {};
1813
- try {
1814
- input = c.function.arguments ? JSON.parse(c.function.arguments) : {};
1815
- } catch {
1816
- input = {};
1817
- }
1818
- blocks.push({
1819
- type: "tool_use",
1820
- id: c.id,
1821
- name: c.function.name,
1822
- input
1823
- });
1824
- }
1825
- }
1826
- msgs.push({ role: "assistant", content: blocks });
1827
- continue;
1828
- }
1829
- if (m.role === "tool") {
1830
- msgs.push({
1831
- role: "user",
1832
- content: [
1833
- {
1834
- type: "tool_result",
1835
- tool_use_id: m.toolCallId,
1836
- content: [{ type: "text", text: m.content }]
1837
- }
1838
- ]
1839
- });
1840
- }
1841
- }
1842
- return { system, msgs };
1843
- }
1844
1920
  function toAnthropicTool2(t) {
1845
1921
  return {
1846
1922
  name: t.name,
@@ -1909,9 +1985,24 @@ class BedrockExecutor extends LLMExecutor {
1909
1985
  return JSON.parse(responseRaw);
1910
1986
  }
1911
1987
  async call(model, options) {
1912
- const jsonMode = "jsonSchemaName" in options;
1913
1988
  const log5 = logger.child("llm:bedrock");
1914
- log5.debug(`call: model=${model} jsonSchema=${jsonMode ? options.jsonSchemaName : "-"} msgLen=${options.message.length}`);
1989
+ if ("jsonSchemaName" in options) {
1990
+ log5.debug(`call: model=${model} jsonSchema=${options.jsonSchemaName} via tool`);
1991
+ const { toolName, tool, instruction } = buildStructuredJsonRequest({
1992
+ instruction: options.instruction,
1993
+ jsonSchemaName: options.jsonSchemaName,
1994
+ jsonSchema: options.jsonSchema
1995
+ });
1996
+ const choice = await this.chatWithTools(model, {
1997
+ caller: options.caller ?? options.jsonSchemaName,
1998
+ instruction,
1999
+ messages: [{ role: "user", content: options.message }],
2000
+ tools: [tool],
2001
+ toolChoice: { type: "tool", name: toolName }
2002
+ });
2003
+ return parseStructuredJsonResult(choice, toolName);
2004
+ }
2005
+ log5.debug(`call: model=${model} jsonSchema=- msgLen=${options.message.length}`);
1915
2006
  if (!model.startsWith("anthropic.") && !model.startsWith("us.anthropic.")) {
1916
2007
  throw new Error(`bedrock provider currently only supports Anthropic models (got ${model})`);
1917
2008
  }
@@ -1926,7 +2017,7 @@ class BedrockExecutor extends LLMExecutor {
1926
2017
  if (!text) {
1927
2018
  throw new Error("Empty response from model");
1928
2019
  }
1929
- return jsonMode ? parseModelJson(text) : text;
2020
+ return text;
1930
2021
  }
1931
2022
  async chatWithTools(model, options) {
1932
2023
  const log5 = logger.child("llm:bedrock");
@@ -1934,7 +2025,6 @@ class BedrockExecutor extends LLMExecutor {
1934
2025
  if (!model.startsWith("anthropic.") && !model.startsWith("us.anthropic.")) {
1935
2026
  throw new Error(`bedrock provider currently only supports Anthropic models (got ${model})`);
1936
2027
  }
1937
- const { system, msgs } = toAnthropicMessages2(options.messages);
1938
2028
  const body = {
1939
2029
  anthropic_version: "bedrock-2023-05-31",
1940
2030
  max_tokens: 4096,
@@ -1942,6 +2032,12 @@ class BedrockExecutor extends LLMExecutor {
1942
2032
  messages: msgs,
1943
2033
  tools: options.tools.map(toAnthropicTool2)
1944
2034
  };
2035
+ if (options.toolChoice) {
2036
+ body.tool_choice = {
2037
+ type: "tool",
2038
+ name: options.toolChoice.name
2039
+ };
2040
+ }
1945
2041
  const data = await this.invoke(model, body, resolveLlmCaller(options));
1946
2042
  const { text, toolCalls } = extractAnthropicContent(data);
1947
2043
  return { message: { content: text || undefined, toolCalls } };
@@ -2208,9 +2304,23 @@ class GitLabDuoExecutor extends LLMExecutor {
2208
2304
  return JSON.parse(responseRaw);
2209
2305
  }
2210
2306
  async call(model, options) {
2211
- const jsonMode = "jsonSchemaName" in options;
2212
2307
  const log5 = logger.child("llm:gitlab-duo");
2213
- log5.debug(`call: model=${model} jsonSchema=${jsonMode ? options.jsonSchemaName : "-"} msgLen=${options.message.length}`);
2308
+ if ("jsonSchemaName" in options) {
2309
+ log5.debug(`call: model=${model} jsonSchema=${options.jsonSchemaName} via tool`);
2310
+ const { toolName, tool, instruction } = buildStructuredJsonRequest({
2311
+ instruction: options.instruction,
2312
+ jsonSchemaName: options.jsonSchemaName,
2313
+ jsonSchema: options.jsonSchema
2314
+ });
2315
+ const choice = await this.chatWithTools(model, {
2316
+ caller: options.caller ?? options.jsonSchemaName,
2317
+ instruction,
2318
+ messages: [{ role: "user", content: options.message }],
2319
+ tools: [tool]
2320
+ });
2321
+ return parseStructuredJsonResult(choice, toolName);
2322
+ }
2323
+ log5.debug(`call: model=${model} jsonSchema=- msgLen=${options.message.length}`);
2214
2324
  const body = {
2215
2325
  model,
2216
2326
  messages: [
@@ -2226,7 +2336,7 @@ class GitLabDuoExecutor extends LLMExecutor {
2226
2336
  if (!content) {
2227
2337
  throw new Error("Empty response from model");
2228
2338
  }
2229
- return jsonMode ? parseModelJson(content) : content;
2339
+ return content;
2230
2340
  }
2231
2341
  async chatWithTools(model, options) {
2232
2342
  const log5 = logger.child("llm:gitlab-duo");
@@ -2325,10 +2435,25 @@ class SnowflakeCortexExecutor extends LLMExecutor {
2325
2435
  return JSON.parse(responseRaw);
2326
2436
  }
2327
2437
  async call(model, options) {
2328
- const jsonMode = "jsonSchemaName" in options;
2329
- const reasoning = defaultReasoningEffort(options.reasoningEffort, model, this.models.identity);
2330
2438
  const log5 = logger.child("llm:snowflake-cortex");
2331
- log5.debug(`call: model=${model} jsonSchema=${jsonMode ? options.jsonSchemaName : "-"} msgLen=${options.message.length}`);
2439
+ if ("jsonSchemaName" in options) {
2440
+ log5.debug(`call: model=${model} jsonSchema=${options.jsonSchemaName} via tool`);
2441
+ const { toolName, tool, instruction } = buildStructuredJsonRequest({
2442
+ instruction: options.instruction,
2443
+ jsonSchemaName: options.jsonSchemaName,
2444
+ jsonSchema: options.jsonSchema
2445
+ });
2446
+ const choice = await this.chatWithTools(model, {
2447
+ caller: options.caller ?? options.jsonSchemaName,
2448
+ instruction,
2449
+ messages: [{ role: "user", content: options.message }],
2450
+ tools: [tool],
2451
+ reasoningEffort: "none"
2452
+ });
2453
+ return parseStructuredJsonResult(choice, toolName);
2454
+ }
2455
+ const reasoning = defaultReasoningEffort(options.reasoningEffort, model, this.models.identity);
2456
+ log5.debug(`call: model=${model} jsonSchema=- msgLen=${options.message.length}`);
2332
2457
  const body = {
2333
2458
  model,
2334
2459
  messages: [
@@ -2347,7 +2472,7 @@ class SnowflakeCortexExecutor extends LLMExecutor {
2347
2472
  if (!content) {
2348
2473
  throw new Error("Empty response from model");
2349
2474
  }
2350
- return jsonMode ? parseModelJson(content) : content;
2475
+ return content;
2351
2476
  }
2352
2477
  async chatWithTools(model, options) {
2353
2478
  const log5 = logger.child("llm:snowflake-cortex");
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@p-sw/brainbox",
3
- "version": "0.1.2-alpha.11",
3
+ "version": "0.1.2-alpha.12",
4
4
  "module": "dist/index.js",
5
5
  "main": "dist/index.js",
6
6
  "type": "module",