@saltcorn/large-language-model 0.9.12 → 1.0.1

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/constants.js CHANGED
@@ -7,9 +7,7 @@ const OPENAI_MODELS = [
7
7
  "gpt-5",
8
8
  "gpt-5-mini",
9
9
  "gpt-5-nano",
10
- "gpt-5.1",
11
- "gpt-5.1-mini",
12
- "gpt-5.1-nano",
10
+ "gpt-5.1",
13
11
  "gpt-5.2",
14
12
  "gpt-5.2-pro",
15
13
  "o3",
package/generate.js CHANGED
@@ -38,7 +38,7 @@ const getEmbedding = async (config, opts) => {
38
38
  apiKey: config.api_key,
39
39
  embed_model: opts?.embed_model || config.embed_model || config.model,
40
40
  },
41
- opts
41
+ opts,
42
42
  );
43
43
  case "OpenAI":
44
44
  return await getEmbeddingOpenAICompatible(
@@ -47,7 +47,7 @@ const getEmbedding = async (config, opts) => {
47
47
  bearer: opts?.api_key || config.api_key,
48
48
  embed_model: opts?.model || config.embed_model,
49
49
  },
50
- opts
50
+ opts,
51
51
  );
52
52
  case "OpenAI-compatible API":
53
53
  return await getEmbeddingOpenAICompatible(
@@ -61,7 +61,7 @@ const getEmbedding = async (config, opts) => {
61
61
  config.embed_model ||
62
62
  config.model,
63
63
  },
64
- opts
64
+ opts,
65
65
  );
66
66
  case "Local Ollama":
67
67
  if (config.embed_endpoint) {
@@ -74,14 +74,14 @@ const getEmbedding = async (config, opts) => {
74
74
  config.embed_model ||
75
75
  config.model,
76
76
  },
77
- opts
77
+ opts,
78
78
  );
79
79
  } else {
80
80
  if (!ollamaMod) throw new Error("Not implemented for this backend");
81
81
 
82
82
  const { Ollama } = ollamaMod;
83
83
  const ollama = new Ollama(
84
- config.ollama_host ? { host: config.ollama_host } : undefined
84
+ config.ollama_host ? { host: config.ollama_host } : undefined,
85
85
  );
86
86
  const olres = await ollama.embeddings({
87
87
  model: opts?.model || config.embed_model || config.model,
@@ -112,7 +112,7 @@ const getImageGeneration = async (config, opts) => {
112
112
  model: opts?.model || config.model,
113
113
  responses_api: config.responses_api,
114
114
  },
115
- opts
115
+ opts,
116
116
  );
117
117
  default:
118
118
  throw new Error("Image generation not implemented for this backend");
@@ -121,7 +121,7 @@ const getImageGeneration = async (config, opts) => {
121
121
 
122
122
  const getAudioTranscription = async (
123
123
  { backend, apiKey, api_key, provider, ai_sdk_provider },
124
- opts
124
+ opts,
125
125
  ) => {
126
126
  switch (opts.backend || backend) {
127
127
  case "ElevenLabs":
@@ -134,7 +134,7 @@ const getAudioTranscription = async (
134
134
  languageCode: opts.languageCode || "eng", // Language of the audio file. If set to null, the model will detect the language automatically.
135
135
  numSpeakers: opts.numSpeakers || null, // Language of the audio file. If set to null, the model will detect the language automatically.
136
136
  diarize: !!opts.diarize, // Whether to annotate who is speaking
137
- diarizationThreshold: opts.diarizationThreshold || null
137
+ diarizationThreshold: opts.diarizationThreshold || null,
138
138
  });
139
139
  return transcription;
140
140
  case "OpenAI":
@@ -144,10 +144,10 @@ const getAudioTranscription = async (
144
144
  const fp = opts.file.location
145
145
  ? opts.file.location
146
146
  : typeof opts.file === "string"
147
- ? await (
148
- await File.findOne(opts.file)
149
- ).location
150
- : null;
147
+ ? await (
148
+ await File.findOne(opts.file)
149
+ ).location
150
+ : null;
151
151
  const model = opts?.model || "whisper-1";
152
152
  const diarize = model === "gpt-4o-transcribe-diarize";
153
153
  const transcript1 = await client.audio.transcriptions.create({
@@ -171,8 +171,8 @@ const getAudioTranscription = async (
171
171
  (Buffer.isBuffer(opts.file)
172
172
  ? opts.file
173
173
  : typeof opts.file === "string"
174
- ? await (await File.findOne(opts.file)).get_contents()
175
- : await opts.file.get_contents());
174
+ ? await (await File.findOne(opts.file)).get_contents()
175
+ : await opts.file.get_contents());
176
176
  const extra = {};
177
177
  if (opts.prompt)
178
178
  extra.providerOptions = {
@@ -193,6 +193,156 @@ const getAudioTranscription = async (
193
193
  }
194
194
  };
195
195
 
196
+ const last = (xs) => xs[xs.length - 1];
197
+
198
+ const toolResponse = async (
199
+ { backend, apiKey, api_key, provider, ai_sdk_provider, responses_api },
200
+ opts,
201
+ ) => {
202
+ let chat = opts.chat;
203
+ let result = opts.prompt;
204
+ //console.log("chat", JSON.stringify(chat, null, 2));
205
+ switch (opts.backend || backend) {
206
+ case "OpenAI":
207
+ {
208
+ let tool_call_chat, tool_call;
209
+ if (!((opts.tool_call_id && opts.tool_name) || opts.tool_call)) {
210
+ if (opts.tool_call) tool_call_chat = opts.tool_call;
211
+ else
212
+ tool_call_chat = last(
213
+ chat.filter((c) => c.tool_calls || c.type === "function_call"),
214
+ );
215
+
216
+ tool_call = tool_call_chat.tool_calls
217
+ ? tool_call_chat.tool_calls[0] //original api
218
+ : tool_call_chat; //responses api
219
+ }
220
+ const content =
221
+ result && typeof result !== "string"
222
+ ? JSON.stringify(result)
223
+ : result || "Action run";
224
+ const new_chat_item = responses_api
225
+ ? {
226
+ type: "function_call_output",
227
+ call_id:
228
+ opts.tool_call?.tool_call_id ||
229
+ opts.tool_call_id ||
230
+ tool_call.call_id,
231
+ output: content,
232
+ }
233
+ : {
234
+ role: "tool",
235
+ tool_call_id:
236
+ opts.tool_call?.tool_call_id ||
237
+ opts.tool_call_id ||
238
+ tool_call.toolCallId ||
239
+ tool_call.id,
240
+ content,
241
+ };
242
+
243
+ chat.push(new_chat_item);
244
+ }
245
+ break;
246
+ case "AI SDK":
247
+ {
248
+ let tool_call, tc;
249
+ if (!((opts.tool_call_id && opts.tool_name) || opts.tool_call)) {
250
+ if (opts.tool_call) tool_call = opts.tool_call;
251
+ else
252
+ tool_call = last(
253
+ chat.filter(
254
+ (c) =>
255
+ c.role === "assistant" &&
256
+ Array.isArray(c.content) &&
257
+ c.content.some((cc) => cc.type === "tool-call"),
258
+ ),
259
+ );
260
+
261
+ tc = tool_call.content[0];
262
+ }
263
+
264
+ chat.push({
265
+ role: "tool",
266
+ content: [
267
+ {
268
+ type: "tool-result",
269
+ toolCallId:
270
+ opts.tool_call?.tool_call_id ||
271
+ opts.tool_call_id ||
272
+ tc.toolCallId,
273
+ toolName:
274
+ opts.tool_call?.tool_name || opts.tool_name || tc.toolName,
275
+ output:
276
+ !result || typeof result === "string"
277
+ ? {
278
+ type: "text",
279
+ value: result || "Action run",
280
+ }
281
+ : {
282
+ type: "json",
283
+ value: JSON.parse(JSON.stringify(result)),
284
+ },
285
+ },
286
+ ],
287
+ });
288
+ }
289
+ break;
290
+ default:
291
+ }
292
+ };
293
+
294
+ const addImageMesssage = async (
295
+ { backend, apiKey, api_key, provider, ai_sdk_provider, responses_api },
296
+ opts,
297
+ ) => {
298
+ let chat = opts.chat;
299
+ let result = opts.prompt;
300
+ //console.log("chat", JSON.stringify(chat, null, 2));
301
+ let imageurl = opts.prompt;
302
+ switch (opts.backend || backend) {
303
+ case "OpenAI":
304
+ {
305
+ const new_chat_item = responses_api
306
+ ? {
307
+ role: "user",
308
+ content: [
309
+ {
310
+ type: "input_image",
311
+ image_url: imageurl,
312
+ },
313
+ ],
314
+ }
315
+ : {
316
+ role: "user",
317
+ content: [
318
+ {
319
+ type: "image_url",
320
+ image_url: {
321
+ url: imageurl,
322
+ },
323
+ },
324
+ ],
325
+ };
326
+
327
+ chat.push(new_chat_item);
328
+ }
329
+ break;
330
+ case "AI SDK":
331
+ chat.push({
332
+ role: "user",
333
+ content: [
334
+ {
335
+ type: "image",
336
+ image: imageurl,
337
+ },
338
+ ],
339
+ });
340
+
341
+ break;
342
+ default:
343
+ }
344
+ };
345
+
196
346
  const getCompletion = async (config, opts) => {
197
347
  switch (config.backend) {
198
348
  case "AI SDK":
@@ -202,7 +352,7 @@ const getCompletion = async (config, opts) => {
202
352
  apiKey: config.api_key,
203
353
  model: opts?.model || config.model,
204
354
  },
205
- opts
355
+ opts,
206
356
  );
207
357
  case "OpenAI":
208
358
  return await getCompletionOpenAICompatible(
@@ -214,7 +364,7 @@ const getCompletion = async (config, opts) => {
214
364
  model: opts?.model || config.model,
215
365
  responses_api: config.responses_api,
216
366
  },
217
- opts
367
+ opts,
218
368
  );
219
369
  case "OpenAI-compatible API":
220
370
  return await getCompletionOpenAICompatible(
@@ -228,7 +378,7 @@ const getCompletion = async (config, opts) => {
228
378
  apiKey: opts?.api_key || config.api_key,
229
379
  model: opts?.model || config.model,
230
380
  },
231
- opts
381
+ opts,
232
382
  );
233
383
  case "Local Ollama":
234
384
  return await getCompletionOpenAICompatible(
@@ -238,14 +388,14 @@ const getCompletion = async (config, opts) => {
238
388
  : "http://localhost:11434/v1/chat/completions",
239
389
  model: opts?.model || config.model,
240
390
  },
241
- opts
391
+ opts,
242
392
  );
243
393
  case "Local llama.cpp":
244
394
  //TODO only check if unsafe plugins not allowed
245
395
  const isRoot = db.getTenantSchema() === db.connectObj.default_schema;
246
396
  if (!isRoot)
247
397
  throw new Error(
248
- "llama.cpp inference is not permitted on subdomain tenants"
398
+ "llama.cpp inference is not permitted on subdomain tenants",
249
399
  );
250
400
  let hyperStr = "";
251
401
  if (opts.temperature) hyperStr += ` --temp ${opts.temperature}`;
@@ -255,7 +405,7 @@ const getCompletion = async (config, opts) => {
255
405
 
256
406
  const { stdout, stderr } = await exec(
257
407
  `./main -m ${config.model_path} -p "${opts.prompt}" ${nstr}${hyperStr}`,
258
- { cwd: config.llama_dir }
408
+ { cwd: config.llama_dir },
259
409
  );
260
410
  return stdout;
261
411
  case "Google Vertex AI":
@@ -286,12 +436,13 @@ const getCompletionAISDK = async (
286
436
  systemPrompt,
287
437
  prompt,
288
438
  debugResult,
439
+ appendToChat,
289
440
  debugCollector,
290
441
  chat = [],
291
442
  api_key,
292
443
  endpoint,
293
444
  ...rest
294
- }
445
+ },
295
446
  ) => {
296
447
  const use_model_name = rest.model || model;
297
448
  let model_obj = getAiSdkModel({
@@ -313,7 +464,7 @@ const getCompletionAISDK = async (
313
464
  ...(Array.isArray(chat.content) ? { content: chat.content.map(f) } : {}),
314
465
  };
315
466
  };
316
- const newChat = chat.map(modifyChat);
467
+ const newChat = appendToChat ? chat : chat.map(modifyChat);
317
468
 
318
469
  const body = {
319
470
  ...rest,
@@ -327,6 +478,9 @@ const getCompletionAISDK = async (
327
478
  ...(prompt ? [{ role: "user", content: prompt }] : []),
328
479
  ],
329
480
  };
481
+ if (appendToChat && chat && prompt) {
482
+ chat.push({ role: "user", content: prompt });
483
+ }
330
484
  if (rest.temperature || temperature) {
331
485
  const str_or_num = rest.temperature || temperature;
332
486
  body.temperature = +str_or_num;
@@ -342,6 +496,9 @@ const getCompletionAISDK = async (
342
496
  "gpt-5",
343
497
  "gpt-5-nano",
344
498
  "gpt-5-mini",
499
+ "gpt-5.1",
500
+ "gpt-5.1-codex",
501
+ "gpt-5.2",
345
502
  ].includes(use_model_name)
346
503
  )
347
504
  body.temperature = 0.7;
@@ -350,9 +507,9 @@ const getCompletionAISDK = async (
350
507
  const prevTools = [...body.tools];
351
508
  body.tools = {};
352
509
  prevTools.forEach((t) => {
353
- body.tools[t.function.name] = tool({
354
- description: t.function.description,
355
- inputSchema: jsonSchema(t.function.parameters),
510
+ body.tools[t.name || t.function.name] = tool({
511
+ description: t.description || t.function.description,
512
+ inputSchema: jsonSchema(t.parameters || t.function.parameters),
356
513
  });
357
514
  });
358
515
  }
@@ -367,11 +524,21 @@ const getCompletionAISDK = async (
367
524
  let results;
368
525
  if (rest.streamCallback) {
369
526
  delete body.streamCallback;
370
- results = await streamText(body);
371
- for await (const textPart of results.textStream) {
527
+ const results1 = await streamText(body);
528
+ for await (const textPart of results1.textStream) {
372
529
  rest.streamCallback(textPart);
373
530
  }
531
+ results = {
532
+ response: await results1.response,
533
+ text: await results1.text,
534
+ steps: await results1.steps,
535
+ };
374
536
  } else results = await generateText(body);
537
+
538
+ if (appendToChat && chat) {
539
+ chat.push(...results.response.messages);
540
+ }
541
+
375
542
  if (debugResult)
376
543
  console.log("AI SDK response", JSON.stringify(results, null, 2));
377
544
  else getState().log(6, `AI SDK response ${JSON.stringify(results)}`);
@@ -387,6 +554,14 @@ const getCompletionAISDK = async (
387
554
  content: await results.text,
388
555
  messages: (await results.response).messages,
389
556
  ai_sdk: true,
557
+ hasToolCalls: allToolCalls.length,
558
+ getToolCalls() {
559
+ return allToolCalls.map((tc) => ({
560
+ tool_name: tc.toolName,
561
+ input: tc.input,
562
+ tool_call_id: tc.toolCallId,
563
+ }));
564
+ },
390
565
  };
391
566
  } else return results.text;
392
567
  };
@@ -399,10 +574,11 @@ const getCompletionOpenAICompatible = async (
399
574
  debugResult,
400
575
  debugCollector,
401
576
  chat = [],
577
+ appendToChat,
402
578
  api_key,
403
579
  endpoint,
404
580
  ...rest
405
- }
581
+ },
406
582
  ) => {
407
583
  const headers = {
408
584
  "Content-Type": "application/json",
@@ -440,63 +616,67 @@ const getCompletionOpenAICompatible = async (
440
616
  delete body.streamCallback;
441
617
  }
442
618
  if (responses_api) {
619
+ delete body.tool_choice;
443
620
  for (const tool of body.tools || []) {
444
- if (tool.type !== "function") continue;
621
+ if (tool.type !== "function" || !tool.function) continue;
445
622
  tool.name = tool.function.name;
446
623
  tool.description = tool.function.description;
447
624
  tool.parameters = tool.function.parameters;
448
625
  if (tool.function.required) tool.required = tool.function.required;
449
626
  delete tool.function;
450
627
  }
451
- const newChat = [];
452
- (chat || []).forEach((c) => {
453
- if (c.tool_calls) {
454
- c.tool_calls.forEach((tc) => {
455
- newChat.push({
456
- id: tc.id,
457
- type: "function_call",
458
- call_id: tc.call_id,
459
- name: tc.name,
460
- arguments: tc.arguments,
628
+ let newChat;
629
+ if (!appendToChat) {
630
+ newChat = [];
631
+ (chat || []).forEach((c) => {
632
+ if (c.tool_calls) {
633
+ c.tool_calls.forEach((tc) => {
634
+ newChat.push({
635
+ id: tc.id,
636
+ type: "function_call",
637
+ call_id: tc.call_id,
638
+ name: tc.name,
639
+ arguments: tc.arguments,
640
+ });
461
641
  });
462
- });
463
- } else if (c.content?.image_calls) {
464
- c.content.image_calls.forEach((ic) => {
642
+ } else if (c.content?.image_calls) {
643
+ c.content.image_calls.forEach((ic) => {
644
+ newChat.push({
645
+ ...ic,
646
+ result: undefined,
647
+ filename: undefined,
648
+ });
649
+ });
650
+ } else if (c.content?.mcp_calls) {
651
+ c.content.mcp_calls.forEach((ic) => {
652
+ newChat.push({
653
+ ...ic,
654
+ });
655
+ });
656
+ } else if (c.role === "tool") {
465
657
  newChat.push({
466
- ...ic,
467
- result: undefined,
468
- filename: undefined,
658
+ type: "function_call_output",
659
+ call_id: c.call_id,
660
+ output: c.content,
469
661
  });
470
- });
471
- } else if (c.content?.mcp_calls) {
472
- c.content.mcp_calls.forEach((ic) => {
662
+ } else {
663
+ const fcontent = (c) => {
664
+ if (c.type === "image_url")
665
+ return {
666
+ type: "input_image",
667
+ image_url: c.image_url.url,
668
+ };
669
+ else return c;
670
+ };
473
671
  newChat.push({
474
- ...ic,
672
+ ...c,
673
+ content: Array.isArray(c.content)
674
+ ? c.content.map(fcontent)
675
+ : c.content,
475
676
  });
476
- });
477
- } else if (c.role === "tool") {
478
- newChat.push({
479
- type: "function_call_output",
480
- call_id: c.call_id,
481
- output: c.content,
482
- });
483
- } else {
484
- const fcontent = (c) => {
485
- if (c.type === "image_url")
486
- return {
487
- type: "input_image",
488
- image_url: c.image_url.url,
489
- };
490
- else return c;
491
- };
492
- newChat.push({
493
- ...c,
494
- content: Array.isArray(c.content)
495
- ? c.content.map(fcontent)
496
- : c.content,
497
- });
498
- }
499
- });
677
+ }
678
+ });
679
+ } else newChat = chat;
500
680
  body.input = [
501
681
  {
502
682
  role: "system",
@@ -517,6 +697,9 @@ const getCompletionOpenAICompatible = async (
517
697
  ...(prompt ? [{ role: "user", content: prompt }] : []),
518
698
  ];
519
699
  }
700
+ if (appendToChat && chat && prompt) {
701
+ chat.push({ role: "user", content: prompt });
702
+ }
520
703
  if (debugResult)
521
704
  console.log(
522
705
  "OpenAI request",
@@ -524,14 +707,14 @@ const getCompletionOpenAICompatible = async (
524
707
  "to",
525
708
  chatCompleteEndpoint,
526
709
  "headers",
527
- JSON.stringify(headers)
710
+ JSON.stringify(headers),
528
711
  );
529
712
  else
530
713
  getState().log(
531
714
  6,
532
715
  `OpenAI request ${JSON.stringify(
533
- body
534
- )} to ${chatCompleteEndpoint} headers ${JSON.stringify(headers)}`
716
+ body,
717
+ )} to ${chatCompleteEndpoint} headers ${JSON.stringify(headers)}`,
535
718
  );
536
719
  if (debugCollector) debugCollector.request = body;
537
720
  const reqTimeStart = Date.now();
@@ -623,7 +806,7 @@ const getCompletionOpenAICompatible = async (
623
806
  : streamParts.join("");
624
807
  }
625
808
  const results = await rawResponse.json();
626
- //console.log("results", results);
809
+
627
810
  if (debugResult)
628
811
  console.log("OpenAI response", JSON.stringify(results, null, 2));
629
812
  else getState().log(6, `OpenAI response ${JSON.stringify(results)}`);
@@ -633,36 +816,49 @@ const getCompletionOpenAICompatible = async (
633
816
  }
634
817
 
635
818
  if (results.error) throw new Error(`OpenAI error: ${results.error.message}`);
819
+ if (appendToChat && chat) {
820
+ if (responses_api) chat.push(...results.output);
821
+ else chat.push(results.choices[0].message);
822
+ }
636
823
  if (responses_api) {
637
824
  const textOutput = results.output
638
825
  .filter((o) => o.type === "message")
639
826
  .map((o) => o.content.map((c) => c.text).join(""))
640
827
  .join("");
828
+ const tool_calls = emptyToUndefined(
829
+ results.output
830
+ .filter((o) => o.type === "function_call")
831
+ .map((o) => ({
832
+ function: { name: o.name, arguments: o.arguments },
833
+ ...o,
834
+ })),
835
+ );
641
836
  return results.output.some(
642
837
  (o) =>
643
838
  o.type === "function_call" ||
644
839
  o.type === "image_generation_call" ||
645
840
  o.type === "mcp_list_tools" ||
646
- o.type === "mcp_call"
841
+ o.type === "mcp_call",
647
842
  )
648
843
  ? {
649
- tool_calls: emptyToUndefined(
650
- results.output
651
- .filter((o) => o.type === "function_call")
652
- .map((o) => ({
653
- function: { name: o.name, arguments: o.arguments },
654
- ...o,
655
- }))
656
- ),
844
+ tool_calls,
657
845
  image_calls: emptyToUndefined(
658
- results.output.filter((o) => o.type === "image_generation_call")
846
+ results.output.filter((o) => o.type === "image_generation_call"),
659
847
  ),
660
848
  mcp_calls: emptyToUndefined(
661
849
  results.output.filter(
662
- (o) => o.type === "mcp_call" || o.type === "mcp_list_tools"
663
- )
850
+ (o) => o.type === "mcp_call" || o.type === "mcp_list_tools",
851
+ ),
664
852
  ),
665
853
  content: textOutput || null,
854
+ hasToolCalls: tool_calls?.length,
855
+ getToolCalls() {
856
+ return tool_calls.map((tc) => ({
857
+ tool_name: tc.function.name,
858
+ input: JSON.parse(tc.function.arguments),
859
+ tool_call_id: tc.call_id,
860
+ }));
861
+ },
666
862
  }
667
863
  : textOutput || null;
668
864
  } else
@@ -670,6 +866,14 @@ const getCompletionOpenAICompatible = async (
670
866
  ? {
671
867
  tool_calls: results?.choices?.[0]?.message?.tool_calls,
672
868
  content: results?.choices?.[0]?.message?.content || null,
869
+ hasToolCalls: results?.choices?.[0]?.message?.tool_calls.length,
870
+ getToolCalls() {
871
+ return results?.choices?.[0]?.message?.tool_calls.map((tc) => ({
872
+ tool_name: tc.function.name,
873
+ input: JSON.parse(tc.function.arguments),
874
+ tool_call_id: tc.id,
875
+ }));
876
+ },
673
877
  }
674
878
  : results?.choices?.[0]?.message?.content || null;
675
879
  };
@@ -688,7 +892,7 @@ const getImageGenOpenAICompatible = async (
688
892
  n,
689
893
  output_format,
690
894
  response_format,
691
- }
895
+ },
692
896
  ) => {
693
897
  const { imageEndpoint, bearer, apiKey, image_model } = config;
694
898
  const headers = {
@@ -725,7 +929,7 @@ const getImageGenOpenAICompatible = async (
725
929
 
726
930
  const getEmbeddingOpenAICompatible = async (
727
931
  config,
728
- { prompt, model, debugResult }
932
+ { prompt, model, debugResult },
729
933
  ) => {
730
934
  const { embeddingsEndpoint, bearer, apiKey, embed_model } = config;
731
935
  const headers = {
@@ -762,7 +966,7 @@ const getEmbeddingAISDK = async (config, { prompt, model, debugResult }) => {
762
966
  case "OpenAI":
763
967
  const openai = createOpenAI({ apiKey: apiKey });
764
968
  model_obj = openai.textEmbeddingModel(
765
- model_name || "text-embedding-3-small"
969
+ model_name || "text-embedding-3-small",
766
970
  );
767
971
  //providerOptions.openai = {};
768
972
  break;
@@ -815,7 +1019,7 @@ const initOAuth2Client = async (config) => {
815
1019
  const oauth2Client = new google.auth.OAuth2(
816
1020
  client_id,
817
1021
  client_secret,
818
- redirect_uri
1022
+ redirect_uri,
819
1023
  );
820
1024
  oauth2Client.setCredentials(pluginCfg.tokens);
821
1025
  return oauth2Client;
@@ -883,7 +1087,7 @@ const getCompletionGoogleVertex = async (config, opts, oauth2Client) => {
883
1087
  chatParams.tools = [
884
1088
  {
885
1089
  functionDeclarations: opts.tools.map((t) =>
886
- prepFuncArgsForChat(t.function)
1090
+ prepFuncArgsForChat(t.function),
887
1091
  ),
888
1092
  },
889
1093
  ];
@@ -925,7 +1129,7 @@ const getEmbeddingGoogleVertex = async (config, opts, oauth2Client) => {
925
1129
  helpers.toValue({
926
1130
  content: p,
927
1131
  task_type: config.task_type || "RETRIEVAL_QUERY",
928
- })
1132
+ }),
929
1133
  );
930
1134
  } else {
931
1135
  instances = [
@@ -957,4 +1161,6 @@ module.exports = {
957
1161
  getEmbedding,
958
1162
  getImageGeneration,
959
1163
  getAudioTranscription,
1164
+ toolResponse,
1165
+ addImageMesssage,
960
1166
  };
package/index.js CHANGED
@@ -11,6 +11,8 @@ const {
11
11
  getEmbedding,
12
12
  getImageGeneration,
13
13
  getAudioTranscription,
14
+ toolResponse,
15
+ addImageMesssage,
14
16
  } = require("./generate");
15
17
  const { OPENAI_MODELS } = require("./constants.js");
16
18
  const { eval_expression } = require("@saltcorn/data/models/expression");
@@ -381,7 +383,10 @@ const functions = (config) => {
381
383
  },
382
384
  isAsync: true,
383
385
  description: "Generate text with GPT",
384
- arguments: [{ name: "prompt", type: "String" }],
386
+ arguments: [
387
+ { name: "prompt", type: "String", required: true },
388
+ { name: "options", type: "JSON", tstype: "any" },
389
+ ],
385
390
  },
386
391
  llm_image_generate: {
387
392
  run: async (prompt, opts) => {
@@ -390,7 +395,10 @@ const functions = (config) => {
390
395
  },
391
396
  isAsync: true,
392
397
  description: "Generate image",
393
- arguments: [{ name: "prompt", type: "String" }],
398
+ arguments: [
399
+ { name: "prompt", type: "String", required: true },
400
+ { name: "options", type: "JSON", tstype: "any" },
401
+ ],
394
402
  },
395
403
  llm_embedding: {
396
404
  run: async (prompt, opts) => {
@@ -399,7 +407,10 @@ const functions = (config) => {
399
407
  },
400
408
  isAsync: true,
401
409
  description: "Get vector embedding",
402
- arguments: [{ name: "prompt", type: "String" }],
410
+ arguments: [
411
+ { name: "prompt", type: "String", required: true },
412
+ { name: "options", type: "JSON", tstype: "any" },
413
+ ],
403
414
  },
404
415
  llm_transcribe: {
405
416
  run: async (opts) => {
@@ -408,7 +419,33 @@ const functions = (config) => {
408
419
  },
409
420
  isAsync: true,
410
421
  description: "Get vector embedding",
411
- arguments: [{ name: "prompt", type: "String" }],
422
+ arguments: [
423
+ { name: "options", type: "JSON", tstype: "any", required: true },
424
+ ],
425
+ },
426
+ llm_add_message: {
427
+ run: async (what, prompt, opts) => {
428
+ switch (what) {
429
+ case "tool_response":
430
+ return await toolResponse(config, { prompt, ...opts });
431
+ case "image":
432
+ return await addImageMesssage(config, { prompt, ...opts });
433
+ default:
434
+ break;
435
+ }
436
+ },
437
+ isAsync: true,
438
+ description: "Insert a tool response or an image in a chat",
439
+ arguments: [
440
+ {
441
+ name: "what",
442
+ type: "String",
443
+ tstype: '"tool_response"|"image"',
444
+ required: true,
445
+ },
446
+ { name: "prompt", type: "String", required: true },
447
+ { name: "options", type: "JSON", tstype: "any" },
448
+ ],
412
449
  },
413
450
  };
414
451
  };
@@ -432,7 +469,7 @@ const routes = (config) => {
432
469
  const oauth2Client = new google.auth.OAuth2(
433
470
  client_id,
434
471
  client_secret,
435
- redirect_uri
472
+ redirect_uri,
436
473
  );
437
474
  const authUrl = oauth2Client.generateAuthUrl({
438
475
  access_type: "offline",
@@ -459,7 +496,7 @@ const routes = (config) => {
459
496
  const oauth2Client = new google.auth.OAuth2(
460
497
  client_id,
461
498
  client_secret,
462
- redirect_uri
499
+ redirect_uri,
463
500
  );
464
501
  let plugin = await Plugin.findOne({ name: "large-language-model" });
465
502
  if (!plugin) {
@@ -475,8 +512,8 @@ const routes = (config) => {
475
512
  req.flash(
476
513
  "warning",
477
514
  req.__(
478
- "No refresh token received. Please revoke the plugin's access and try again."
479
- )
515
+ "No refresh token received. Please revoke the plugin's access and try again.",
516
+ ),
480
517
  );
481
518
  } else {
482
519
  const newConfig = { ...(plugin.configuration || {}), tokens };
@@ -488,7 +525,7 @@ const routes = (config) => {
488
525
  });
489
526
  req.flash(
490
527
  "success",
491
- req.__("Authentication successful! You can now use Vertex AI.")
528
+ req.__("Authentication successful! You can now use Vertex AI."),
492
529
  );
493
530
  }
494
531
  } catch (error) {
@@ -615,13 +652,13 @@ module.exports = {
615
652
  prompt_formula,
616
653
  row,
617
654
  user,
618
- "llm_generate prompt formula"
655
+ "llm_generate prompt formula",
619
656
  );
620
657
  else prompt = row[prompt_field];
621
658
  const opts = {};
622
659
  if (override_config) {
623
660
  const altcfg = config.altconfigs.find(
624
- (c) => c.name === override_config
661
+ (c) => c.name === override_config,
625
662
  );
626
663
  opts.endpoint = altcfg.endpoint;
627
664
  opts.model = altcfg.model;
@@ -679,7 +716,8 @@ module.exports = {
679
716
  {
680
717
  name: "answer_field",
681
718
  label: "Response variable",
682
- sublabel: "Set the generated response object to this context variable. The subfield <code>text</code> holds the string transcription",
719
+ sublabel:
720
+ "Set the generated response object to this context variable. The subfield <code>text</code> holds the string transcription",
683
721
  type: "String",
684
722
  required: true,
685
723
  },
@@ -766,7 +804,7 @@ module.exports = {
766
804
  else
767
805
  await table.updateRow(
768
806
  { [answer_field]: ans.text },
769
- row[table.pk_name]
807
+ row[table.pk_name],
770
808
  );
771
809
  },
772
810
  },
@@ -879,7 +917,7 @@ module.exports = {
879
917
  prompt_formula,
880
918
  row,
881
919
  user,
882
- "llm_generate prompt formula"
920
+ "llm_generate prompt formula",
883
921
  );
884
922
  else prompt = row[prompt_field];
885
923
 
@@ -906,7 +944,7 @@ module.exports = {
906
944
  "image/png",
907
945
  imgContents,
908
946
  user?.id,
909
- min_role || 1
947
+ min_role || 1,
910
948
  );
911
949
  upd[answer_field] = file.path_to_serve;
912
950
  }
@@ -998,7 +1036,7 @@ module.exports = {
998
1036
  sublabel:
999
1037
  "Use this context variable to store the chat history for subsequent prompts",
1000
1038
  type: "String",
1001
- }
1039
+ },
1002
1040
  );
1003
1041
  } else if (table) {
1004
1042
  const jsonFields = table.fields
@@ -1022,7 +1060,7 @@ module.exports = {
1022
1060
  type: "String",
1023
1061
  required: true,
1024
1062
  attributes: { options: jsonFields },
1025
- }
1063
+ },
1026
1064
  );
1027
1065
  }
1028
1066
 
@@ -1051,7 +1089,7 @@ module.exports = {
1051
1089
  input_type: "section_header",
1052
1090
  label: "JSON fields to generate",
1053
1091
  },
1054
- fieldsField
1092
+ fieldsField,
1055
1093
  );
1056
1094
  return cfgFields;
1057
1095
  },
@@ -1077,7 +1115,7 @@ module.exports = {
1077
1115
  if (model) opts.model = model;
1078
1116
  if (override_config) {
1079
1117
  const altcfg = config.altconfigs.find(
1080
- (c) => c.name === override_config
1118
+ (c) => c.name === override_config,
1081
1119
  );
1082
1120
  opts.endpoint = altcfg.endpoint;
1083
1121
  opts.model = altcfg.model;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@saltcorn/large-language-model",
3
- "version": "0.9.12",
3
+ "version": "1.0.1",
4
4
  "description": "Large language models and functionality for Saltcorn",
5
5
  "main": "index.js",
6
6
  "dependencies": {
@@ -16,17 +16,24 @@
16
16
  "openai": "6.16.0",
17
17
  "@elevenlabs/elevenlabs-js": "2.31.0"
18
18
  },
19
+ "devDependencies": {
20
+ "jest": "^29.7.0"
21
+ },
22
+ "scripts": {
23
+ "test": "jest tests --runInBand"
24
+ },
19
25
  "author": "Tom Nielsen",
20
26
  "license": "MIT",
21
27
  "repository": "github:saltcorn/large-language-model",
22
28
  "eslintConfig": {
23
29
  "extends": "eslint:recommended",
24
30
  "parserOptions": {
25
- "ecmaVersion": 2020
31
+ "ecmaVersion": 2024
26
32
  },
27
33
  "env": {
28
34
  "node": true,
29
- "es6": true
35
+ "es6": true,
36
+ "jest/globals": true
30
37
  },
31
38
  "rules": {
32
39
  "no-unused-vars": "off",
@@ -0,0 +1,34 @@
1
+ module.exports = [
2
+ {
3
+ name: "OpenAI completions",
4
+ model: "gpt-5.1",
5
+ api_key: process.env.OPENAI_API_KEY,
6
+ backend: "OpenAI",
7
+ embed_model: "text-embedding-3-small",
8
+ image_model: "gpt-image-1",
9
+ temperature: 0.7,
10
+ responses_api: false,
11
+ ai_sdk_provider: "OpenAI",
12
+ },
13
+ {
14
+ name: "OpenAI responses",
15
+ model: "gpt-5.1",
16
+ api_key: process.env.OPENAI_API_KEY,
17
+ backend: "OpenAI",
18
+ embed_model: "text-embedding-3-small",
19
+ image_model: "gpt-image-1",
20
+ temperature: 0.7,
21
+ responses_api: true,
22
+ ai_sdk_provider: "OpenAI",
23
+ },
24
+ {
25
+ name: "AI SDK OpenAI",
26
+ model: "gpt-5.1",
27
+ api_key: process.env.OPENAI_API_KEY,
28
+ backend: "AI SDK",
29
+ embed_model: "text-embedding-3-small",
30
+ image_model: "gpt-image-1",
31
+ temperature: 0.7,
32
+ ai_sdk_provider: "OpenAI",
33
+ },
34
+ ];
@@ -0,0 +1,209 @@
1
+ const { getState } = require("@saltcorn/data/db/state");
2
+ const View = require("@saltcorn/data/models/view");
3
+ const Table = require("@saltcorn/data/models/table");
4
+ const Plugin = require("@saltcorn/data/models/plugin");
5
+
6
+ const { mockReqRes } = require("@saltcorn/data/tests/mocks");
7
+ const { afterAll, beforeAll, describe, it, expect } = require("@jest/globals");
8
+
9
+ afterAll(require("@saltcorn/data/db").close);
10
+ beforeAll(async () => {
11
+ await require("@saltcorn/data/db/reset_schema")();
12
+ await require("@saltcorn/data/db/fixtures")();
13
+
14
+ getState().registerPlugin("base", require("@saltcorn/data/base-plugin"));
15
+ });
16
+
17
+ // run with:
18
+ // saltcorn dev:plugin-test -d ~/large-language-model/
19
+
20
+ jest.setTimeout(30000);
21
+
22
+ for (const nameconfig of require("./configs")) {
23
+ const { name, ...config } = nameconfig;
24
+ describe("llm_generate function with " + name, () => {
25
+ beforeAll(async () => {
26
+ getState().registerPlugin(
27
+ "@saltcorn/large-language-model",
28
+ require(".."),
29
+ config,
30
+ );
31
+ });
32
+
33
+ it("generates text", async () => {
34
+ const answer = await getState().functions.llm_generate.run(
35
+ "What is the Capital of France?",
36
+ );
37
+ //console.log({ answer });
38
+
39
+ expect(typeof answer).toBe("string");
40
+ expect(answer).toContain("Paris");
41
+ });
42
+ it("generates text with system prompt", async () => {
43
+ const answer = await getState().functions.llm_generate.run(
44
+ "What is the name of the last week day in a normal work week?",
45
+ {
46
+ systemPrompt: "Answer in German, even when questions are in English",
47
+ },
48
+ );
49
+ //console.log({ answer });
50
+
51
+ expect(typeof answer).toBe("string");
52
+ expect(answer).toContain("Freitag");
53
+ });
54
+ it("generates text with chat history", async () => {
55
+ const chat = [
56
+ {
57
+ role: "user",
58
+ content: "What is the capital of France?",
59
+ },
60
+ {
61
+ role: "assistant",
62
+ content: "Paris.",
63
+ },
64
+ ];
65
+ const answer = await getState().functions.llm_generate.run(
66
+ "What is the name of the river running through this city?",
67
+ {
68
+ chat,
69
+ },
70
+ );
71
+ //console.log({ answer });
72
+
73
+ expect(typeof answer).toBe("string");
74
+ expect(answer).toContain("Seine");
75
+ expect(chat.length).toBe(2);
76
+ });
77
+ it("generates text with chat history and no prompt", async () => {
78
+ const answer = await getState().functions.llm_generate.run("", {
79
+ chat: [
80
+ {
81
+ role: "user",
82
+ content: "What is the capital of France?",
83
+ },
84
+ {
85
+ role: "assistant",
86
+ content: "Paris.",
87
+ },
88
+ {
89
+ role: "user",
90
+ content: "What is the name of the river running through this city?",
91
+ },
92
+ ],
93
+ });
94
+ //console.log({ answer });
95
+
96
+ expect(typeof answer).toBe("string");
97
+ expect(answer).toContain("Seine");
98
+ });
99
+ it("uses tools", async () => {
100
+ const answer = await getState().functions.llm_generate.run(
101
+ "Generate a list of EU capitals in a structured format using the provided tool",
102
+ cities_tool,
103
+ );
104
+ expect(typeof answer).toBe("object");
105
+ const cities = answer.ai_sdk
106
+ ? answer.tool_calls[0].input?.cities
107
+ : JSON.parse(answer.tool_calls[0].function.arguments).cities;
108
+ expect(cities.length).toBe(27);
109
+ });
110
+ it("appends to chat history", async () => {
111
+ const chat = [];
112
+ const answer1 = await getState().functions.llm_generate.run(
113
+ "What is the Capital of France?",
114
+ {
115
+ chat,
116
+ appendToChat: true,
117
+ },
118
+ );
119
+ const answer2 = await getState().functions.llm_generate.run(
120
+ "What is the name of the river running through this city?",
121
+ {
122
+ chat,
123
+ appendToChat: true,
124
+ },
125
+ );
126
+ //console.log({ answer });
127
+
128
+ expect(typeof answer2).toBe("string");
129
+ expect(answer2).toContain("Seine");
130
+ expect(chat.length).toBe(4);
131
+ });
132
+ it("tool use sequence", async () => {
133
+ const chat = [];
134
+ const answer = await getState().functions.llm_generate.run(
135
+ "Generate a list of EU capitals in a structured format using the provided tool",
136
+ {
137
+ chat,
138
+ appendToChat: true,
139
+ ...cities_tool,
140
+ //streamCallback() {}
141
+ },
142
+ );
143
+ expect(typeof answer).toBe("object");
144
+
145
+ const tc = answer.getToolCalls()[0];
146
+
147
+ const cities = tc.input.cities;
148
+ expect(cities.length).toBe(27);
149
+
150
+ await getState().functions.llm_add_message.run(
151
+ "tool_response",
152
+ "List received",
153
+ {
154
+ chat,
155
+ tool_call: tc,
156
+ },
157
+ );
158
+
159
+ const answer1 = await getState().functions.llm_generate.run(
160
+ "Make the same list in a structured format using the provided tool but for the original 12 member countries of the EU",
161
+ { chat, appendToChat: true, ...cities_tool },
162
+ );
163
+
164
+ const cities1 = answer1.getToolCalls()[0].input?.cities;
165
+
166
+ expect(cities1.length).toBe(12);
167
+ });
168
+ });
169
+ }
170
+
171
+ const cities_tool = {
172
+ tools: [
173
+ {
174
+ type: "function",
175
+ function: {
176
+ name: "cities",
177
+ description: "Provide a list of cities by country and city name",
178
+ parameters: {
179
+ type: "object",
180
+ properties: {
181
+ cities: {
182
+ type: "array",
183
+ items: {
184
+ type: "object",
185
+ properties: {
186
+ country_name: {
187
+ type: "string",
188
+ description: "Country name",
189
+ },
190
+ city_name: {
191
+ type: "string",
192
+ description: "City name",
193
+ },
194
+ },
195
+ required: ["country_name", "city_name"],
196
+ },
197
+ },
198
+ },
199
+ },
200
+ },
201
+ },
202
+ ],
203
+ tool_choice: {
204
+ type: "function",
205
+ function: {
206
+ name: "cities",
207
+ },
208
+ },
209
+ };