llaminate 0.1.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.
Files changed (72) hide show
  1. package/.gitattributes +2 -0
  2. package/LICENSE +21 -0
  3. package/README.md +95 -0
  4. package/assets/llaminate-256.webp +0 -0
  5. package/assets/llaminate.jpg +0 -0
  6. package/assets/llaminate.webp +0 -0
  7. package/dist/build-info.json +5 -0
  8. package/dist/config.schema.json +126 -0
  9. package/dist/llaminate.d.ts +202 -0
  10. package/dist/llaminate.min.js +9 -0
  11. package/dist/llaminate.min.js.map +1 -0
  12. package/dist/ratelimiter.d.ts +33 -0
  13. package/dist/ratelimiter.min.js +7 -0
  14. package/dist/ratelimiter.min.js.map +1 -0
  15. package/dist/system.hbs +3 -0
  16. package/docs/LICENSE +21 -0
  17. package/docs/Llaminate.html +1040 -0
  18. package/docs/Llaminate.module_Endpoints.html +278 -0
  19. package/docs/Llaminate.module_Llaminate.html +200 -0
  20. package/docs/Llaminate.module_MimeTypes.html +275 -0
  21. package/docs/LlaminateConfig.html +667 -0
  22. package/docs/LlaminateMessage.html +328 -0
  23. package/docs/LlaminateResponse.html +253 -0
  24. package/docs/assets/llaminate-256.webp +0 -0
  25. package/docs/fonts/OpenSans-Bold-webfont.eot +0 -0
  26. package/docs/fonts/OpenSans-Bold-webfont.svg +1830 -0
  27. package/docs/fonts/OpenSans-Bold-webfont.woff +0 -0
  28. package/docs/fonts/OpenSans-BoldItalic-webfont.eot +0 -0
  29. package/docs/fonts/OpenSans-BoldItalic-webfont.svg +1830 -0
  30. package/docs/fonts/OpenSans-BoldItalic-webfont.woff +0 -0
  31. package/docs/fonts/OpenSans-Italic-webfont.eot +0 -0
  32. package/docs/fonts/OpenSans-Italic-webfont.svg +1830 -0
  33. package/docs/fonts/OpenSans-Italic-webfont.woff +0 -0
  34. package/docs/fonts/OpenSans-Light-webfont.eot +0 -0
  35. package/docs/fonts/OpenSans-Light-webfont.svg +1831 -0
  36. package/docs/fonts/OpenSans-Light-webfont.woff +0 -0
  37. package/docs/fonts/OpenSans-LightItalic-webfont.eot +0 -0
  38. package/docs/fonts/OpenSans-LightItalic-webfont.svg +1835 -0
  39. package/docs/fonts/OpenSans-LightItalic-webfont.woff +0 -0
  40. package/docs/fonts/OpenSans-Regular-webfont.eot +0 -0
  41. package/docs/fonts/OpenSans-Regular-webfont.svg +1831 -0
  42. package/docs/fonts/OpenSans-Regular-webfont.woff +0 -0
  43. package/docs/index.html +142 -0
  44. package/docs/scripts/linenumber.js +25 -0
  45. package/docs/scripts/prettify/Apache-License-2.0.txt +202 -0
  46. package/docs/scripts/prettify/lang-css.js +2 -0
  47. package/docs/scripts/prettify/prettify.js +28 -0
  48. package/docs/styles/jsdoc-default.css +358 -0
  49. package/docs/styles/prettify-jsdoc.css +111 -0
  50. package/docs/styles/prettify-tomorrow.css +132 -0
  51. package/jsdoc.json +23 -0
  52. package/package.json +38 -0
  53. package/scripts/build.sh +21 -0
  54. package/scripts/docs.sh +28 -0
  55. package/scripts/prebuild.js +43 -0
  56. package/scripts/prepare.sh +11 -0
  57. package/scripts/pretest.js +14 -0
  58. package/src/config.schema.json +126 -0
  59. package/src/llaminate.d.ts +99 -0
  60. package/src/llaminate.ts +1326 -0
  61. package/src/llaminate.types.js +176 -0
  62. package/src/ratelimiter.ts +95 -0
  63. package/src/system.hbs +3 -0
  64. package/tests/attachments.test.js +66 -0
  65. package/tests/common/base64.js +13 -0
  66. package/tests/common/matches.js +69 -0
  67. package/tests/common/setup.js +45 -0
  68. package/tests/complete.test.js +27 -0
  69. package/tests/extensions/toMatchSchema.js +20 -0
  70. package/tests/history.test.js +86 -0
  71. package/tests/stream.test.js +67 -0
  72. package/tsconfig.json +12 -0
@@ -0,0 +1,1326 @@
1
+ /**
2
+ * @projectname Llaminate
3
+ * @author Oliver Moran <oliver.moran@gmail.com>
4
+ * @license
5
+ * Copyright 2026 Oliver Moran <oliver.moran@gmail.com>
6
+ * This source code is licensed under the MIT license found in the
7
+ * LICENSE file at https://github.com/oliver-moran/llaminate
8
+ */
9
+
10
+ /// <reference path="./llaminate.d.ts" />
11
+
12
+ // To allow consumers to import the types from the main entry point
13
+ export type {
14
+ LlaminateConfig,
15
+ LlaminateResponse,
16
+ LlaminateMessage } from "./llaminate.types.js";
17
+
18
+ /* @ignore */
19
+ const os = require("os");
20
+ import Ajv from "ajv";
21
+ import { Buffer } from "buffer";
22
+
23
+ // @ts-ignore This will be replaced with a minified version in the buildprocess
24
+ import { RateLimiter } from "./ratelimiter.min.js";
25
+
26
+ const ajv = new Ajv();
27
+ const validate = {
28
+ config: ajv.compile(require("./config.schema.json"))
29
+ };
30
+
31
+ // Polyfill for environments that don't support structuredClone (like Node.js
32
+ // versions prior to 17 or some older browsers)
33
+ const structuredClone = globalThis.structuredClone || ((obj) => JSON.parse(JSON.stringify(obj)));
34
+ const noop = () => {};
35
+
36
+ // read the file system.hbs and compile it into a function that takes a schema
37
+ // and returns the system prompt string with the schema injected
38
+ const SYSTEM_HBS = (() => {
39
+ const fs = require("fs");
40
+ const Handlebars = require("handlebars");
41
+ const template = Handlebars.compile(fs.readFileSync(require.resolve("./system.hbs"), "utf-8"));
42
+ return (schema) => template({ schema });
43
+ })();
44
+
45
+ // Dynamically determine the Llaminate version and Node.js version
46
+ const { version: LLAMINATE_VERSION } = require("./build-info.json");
47
+ const NODE_TITLE = process.title || "Node.js";
48
+ const NODE_VERSION = process.version;
49
+ const OS_TYPE = os.type();
50
+ const OS_ARCH = os.arch();
51
+ const USER_AGENT = `Llaminate/${LLAMINATE_VERSION} (https://github.com/oliver-moran/llaminate; ${NODE_TITLE}/${NODE_VERSION}; ${OS_TYPE}/${OS_ARCH})`;
52
+
53
+ // Define the possible roles for messages in the conversation, which can be used
54
+ // to indicate the source or purpose of each message. These roles can help the
55
+ // LLM service understand the context of the conversation and respond
56
+ // appropriately. The roles include "assistant" for messages generated by the
57
+ // LLM, "developer" for messages from the developer or system, "system" for
58
+ // system-level messages, "user" for messages from the user, and "tool" for
59
+ // messages related to tool calls or responses.
60
+ const enum ROLE {
61
+ ASSISTANT = "assistant",
62
+ DEVELOPER = "developer",
63
+ SYSTEM = "system",
64
+ USER = "user",
65
+ TOOL = "tool",
66
+ }
67
+
68
+ const ROLES = [
69
+ ROLE.ASSISTANT,
70
+ ROLE.DEVELOPER,
71
+ ROLE.SYSTEM,
72
+ ROLE.USER,
73
+ ROLE.TOOL
74
+ ];
75
+
76
+ /**
77
+ * @classdesc Represents the Llaminate service for managing and interacting with AI models.
78
+ */
79
+ export class Llaminate {
80
+
81
+ // PUBLIC STATIC PROPERTIES
82
+
83
+ /**
84
+ * @property {string} Llaminate.GIF MIME type for GIF images (`image/gif`).
85
+ * @property {string} Llaminate.JPEG MIME type for JPEG images (`image/jpeg`).
86
+ * @property {string} Llaminate.PDF MIME type for PDF files (`application/pdf`).
87
+ * @property {string} Llaminate.PNG MIME type for PNG images (`image/png`).
88
+ * @property {string} Llaminate.WEBP MIME type for WEBP images (`image/webp`).
89
+ * @description Static MIME types for various file formats.
90
+ * @module MimeTypes
91
+ * @memberof Llaminate
92
+ * @example
93
+ * const response = await mistral.complete("Summarize the content of this image.", {
94
+ * attachments: [ { type: Llaminate.JPEG, url: "https://example.com/image.jpg" } ]
95
+ * });
96
+ */
97
+ public static readonly PDF = "application/pdf";
98
+ public static readonly JPEG = "image/jpeg";
99
+ public static readonly PNG = "image/png";
100
+ public static readonly GIF = "image/gif";
101
+ public static readonly WEBP = "image/webp";
102
+
103
+ /**
104
+ * @property {string} Llaminate.USER_AGENT The User-Agent string used in API
105
+ * requests.
106
+ * @property {string} Llaminate.VERSION The version of the Llaminate library.
107
+ * @description Static property for the Llaminate library version.
108
+ * @module Llaminate
109
+ * @memberof Llaminate
110
+ */
111
+ public static readonly VERSION = LLAMINATE_VERSION;
112
+ public static readonly USER_AGENT = USER_AGENT;
113
+
114
+ /**
115
+ * @property {string} Llaminate.ANTHROPIC Anthropic completions API endpoint.
116
+ * @property {string} Llaminate.DEEPSEEK Deepseek completions API endpoint.
117
+ * @property {string} Llaminate.GOOGLE Google completions API endpoint.
118
+ * @property {string} Llaminate.MISTRAL Mistral completions API endpoint.
119
+ * @property {string} Llaminate.OPENAI OpenAI completions API endpoint.
120
+ * @description Static properties for common LLM service endpoints, which
121
+ * can be used in the Llaminate configuration.
122
+ * @module Endpoints
123
+ * @memberof Llaminate
124
+ * @example
125
+ * const mistral = new Llaminate({
126
+ * endpoint: Llaminate.MISTRAL,
127
+ * key: "12345-abcde-67890-fghij-klm",
128
+ * model: "mistral-small-latest"
129
+ * });
130
+ */
131
+ public static readonly MISTRAL = "https://api.mistral.ai/v1/chat/completions";
132
+ public static readonly OPENAI = "https://api.openai.com/v1/chat/completions";
133
+ public static readonly ANTHROPIC = "https://api.anthropic.com/v1/messages";
134
+ public static readonly GOOGLE = "https://generativelanguage.googleapis.com/v1beta/openai/chat/completions";
135
+ public static readonly DEEPSEEK = "https://api.deepseek.com/chat/completions";
136
+
137
+ // PRIVATE PROPERTIES
138
+
139
+ // The history of messages exchanged with the service.
140
+ private history: LlaminateMessage[] = [];
141
+
142
+ // The rate limiter instance for managing API request rates.
143
+ private readonly limiter: RateLimiter;
144
+
145
+ // The configuration options for the Llaminate service.
146
+ private readonly config: LlaminateConfig = {
147
+ endpoint: null,
148
+ key: null,
149
+ model: null,
150
+ tools: [],
151
+ system: [],
152
+ attachments: [],
153
+ window: 12,
154
+ headers: {},
155
+ options: {
156
+ parallel_tool_calls: true,
157
+ response_format: { type: "text" }
158
+ },
159
+ handler: async (name: string, args: Record<string, any>): Promise<any> => {
160
+ throw new Error(`No \`handler\` method provided for \`${name}\` was provided in the Llaminate configuration.`)
161
+ },
162
+ fetch: globalThis.fetch.bind(globalThis),
163
+ rpm: Infinity,
164
+ };
165
+
166
+ // CONSTRUCTOR
167
+
168
+ /**
169
+ * Constructs a new instance of the Llaminate class.
170
+ * @param {LlaminateConfig} config The configuration options for the
171
+ * Llaminate instance.
172
+ * @throws Will throw an error if the provided configuration is invalid.
173
+ * @example
174
+ * const mistral = new Llaminate({
175
+ * endpoint: Llaminate.MISTRAL,
176
+ * key: "12345-abcde-67890-fghij-klm",
177
+ * model: "mistral-small-latest",
178
+ * system: ["You are a sarcastic assistant who answers very briefly and bluntly."]
179
+ * rpm: 720
180
+ * });
181
+ */
182
+ constructor(config: LlaminateConfig) {
183
+ validateConfig(config);
184
+
185
+ this.config.endpoint = config.endpoint;
186
+ this.config.key = config.key;
187
+ this.config.model = config.model;
188
+
189
+ this.config.schema = config.schema;
190
+
191
+ this.history = this.config.history || this.history;
192
+ // once the history is set from the config, we delete it to prevent it
193
+ // it being accidentially duplicated or merged with the internal
194
+ // history in future calls to complete or stream.
195
+ delete config.history;
196
+
197
+ this.config.tools = config.tools || this.config.tools;
198
+ this.config.system = config.system || this.config.system;
199
+ this.config.window = config.window || this.config.window;
200
+ this.config.headers = config.headers || this.config.headers;
201
+ this.config.options = {
202
+ ...this.config.options,
203
+ ...config.options,
204
+ };
205
+ this.config.handler = config.handler || this.config.handler;
206
+ this.config.fetch = config.fetch || this.config.fetch;
207
+
208
+ const quirks = getQuirks(this.config);
209
+ this.config.quirks = { ...quirks, ...config.quirks };
210
+
211
+ // Attachments are not allowed in the constructor
212
+ delete this.config.attachments;
213
+
214
+ deepFreeze(this.config);
215
+
216
+ this.limiter = new RateLimiter(config.rpm);
217
+ }
218
+
219
+ // PUBLIC METHODS
220
+
221
+ /**
222
+ * Sends a prompt to the LLM service and returns a chat completion response.
223
+ * @param { string | LlaminateMessage[] } prompt The input prompt or
224
+ * messages to send to the service.
225
+ * @param { LlaminateConfig } [config] Optional configuration settings for
226
+ * this completion.
227
+ * @returns { Promise<LlaminateResponse> } A promise resolving to a
228
+ * LlaminateResponse from the service.
229
+ * @throws Will throw an error if the prompt is invalid, the response is
230
+ * unsuccessful, or if the response does not conform to the expected format.
231
+ * @example
232
+ * // EXAMPLE 1: Simple prompt with default configuration
233
+ * const response = await mistral.complete("What's the capital of France?");
234
+ * console.log(response.message); // "Paris"
235
+ * @example
236
+ * // EXAMPLE 2: System messages set through configuration
237
+ * const response = await mistral.complete("What's the capital of France?",
238
+ * { system: [
239
+ * "You are a children's geography tutor.",
240
+ * "Always reply as if you are explaining to a child."
241
+ * ] } );
242
+ * @example
243
+ * // EXAMPLE 3: An image attachment (supported depends on LLM model)
244
+ * const response = await mistral.complete(
245
+ * "Generate a helpful HTML `alt` tag for this image.", {
246
+ * attachments: [ { type: Llaminate.JPEG, url: "https://example.com/image.jpg" } ]
247
+ * });
248
+ * @example
249
+ * // EXAMPLE 4: Prompt with a pre-rolled conversation history
250
+ * const history = [
251
+ * { role: "user", content: "What's a good name for a houseplant?" },
252
+ * { role: "assistant", content: "How about Fernie Sanders?" },
253
+ * { role: "user", content: "Nice. Any other suggestions?" },
254
+ * { role: "assistant", content: "How about Leaf Erickson?" }
255
+ * ];
256
+ * const prompt = "Great. What could be its nickname?";
257
+ * const response = await mistral.complete(prompt, { history });
258
+ * @example
259
+ * // EXAMPLE 5: Rolling the conversation history into the prompt
260
+ * const messages = history.concat({ role: "user", content: prompt });
261
+ * const response = await mistral.complete(messages);
262
+ */
263
+ async complete(prompt: string | LlaminateMessage[], config?: LlaminateConfig): Promise<LlaminateResponse> {
264
+ const _config = generateCompletionConfig.call(this, config, false);
265
+ const messages = prepareMessageWindow.call(this, prompt, _config);
266
+ const result: LlaminateMessage[] = [];
267
+ const subtotal: Tokens = { input: 0, output: 0, total: 0 };
268
+
269
+ return await _complete.call(this, messages);
270
+
271
+ async function _complete(): Promise<LlaminateResponse> {
272
+ const response = await this.limiter.queue(
273
+ () => sendMessages([...messages, ...result], _config) );
274
+ if (!response.ok)
275
+ throw new Error(`HTTP status ${response.status} from ${_config.endpoint}.\n\n${await response.text()}`);
276
+
277
+ const completion = await response.json();
278
+ const message = completion?.choices?.[0]?.message || completion?.content?.[0] || null;
279
+
280
+ const tokens = getUsageFromCompletion(completion);
281
+ subtotal.input += tokens.input || 0;
282
+ subtotal.output += tokens.output || 0;
283
+ subtotal.total += tokens.total || subtotal.input + subtotal.output || 0;
284
+
285
+ const calls = message.tool_calls || transformAnthropicToolCalls(completion?.content) || [];
286
+ const role = message.role;
287
+
288
+ const recursed = await handleTools.call(this, role, calls, {
289
+ messages, result, subtotal, config: _config, recurse: _complete
290
+ });
291
+ if (recursed) return recursed;
292
+ else {
293
+ const content = validateResponse(
294
+ message?.content || message?.text || "", _config
295
+ );
296
+ result.push({
297
+ role: role || ROLE.ASSISTANT,
298
+ content: content
299
+ });
300
+ updateHistory.call(this, prompt, result);
301
+ return generateOutputObject(content, result, subtotal, uuid_v4(), _config);
302
+ }
303
+ }
304
+ }
305
+
306
+ /**
307
+ * Sends a prompt to the LLM service and streams the response.
308
+ * @param { string | LlaminateMessage[] } prompt The input prompt or
309
+ * messages to send to the service.
310
+ * @param { LlaminateConfig } [config] Optional configuration settings for
311
+ * this completion.
312
+ * @yields { LlaminateResponse } An asynchronous generator yielding
313
+ * responses from the service.
314
+ * @throws Will throw an error if the prompt is invalid, the response is
315
+ * unsuccessful, or if the response does not conform to the expected format.
316
+ * @example
317
+ * // EXAMPLE 1: Streaming the response to a simple prompt
318
+ * const stream = mistral.stream("Tell me a joke and explain it.");
319
+ * for await (const response of stream) {
320
+ * console.log(response.message);
321
+ * }
322
+ * @example
323
+ * // EXAMPLE 2: Streaming a response with a structured output
324
+ * const stream = mistral.stream("Tell me a joke and explain it.", {
325
+ * schema: {
326
+ * type: "object",
327
+ * properties: {
328
+ * joke: {
329
+ * type: "string",
330
+ * description: "Your response to the user's query."
331
+ * },
332
+ * explanation: {
333
+ * type: "string",
334
+ * description: "Your internal thoughts about the user's query."
335
+ * },
336
+ * },
337
+ * required: ["joke", "explanation"],
338
+ * additionalProperties: false,
339
+ * }
340
+ * });
341
+ * for await (const response of stream) {
342
+ * // Initially streams as a string until the JSON schema can be validated
343
+ * console.log(response.message);
344
+ * console.log(response.message?.joke);
345
+ * console.log(response.message?.explanation);
346
+ * }
347
+ */
348
+ async *stream(prompt: string | LlaminateMessage[], config?: LlaminateConfig): AsyncGenerator<LlaminateResponse> {
349
+ const _config = generateCompletionConfig.call(this, config, true);
350
+ const messages = prepareMessageWindow.call(this, prompt, _config);
351
+ const result: LlaminateMessage[] = [];
352
+ const subtotal: Tokens = { input: 0, output: 0, total: 0 };
353
+
354
+ const stream = await _stream.call(this, messages);
355
+ for await (const result of stream) yield result;
356
+
357
+ async function* _stream(): AsyncGenerator<LlaminateResponse> {
358
+ const response = await this.limiter.queue(() => sendMessages(
359
+ [...messages, ...result], _config)
360
+ );
361
+
362
+ if (!response.ok)
363
+ throw new Error(`HTTP status ${response.status} from ${_config.endpoint}.\n\n${await response.text()}`);
364
+ if (!response.body)
365
+ throw new Error(`No readable stream from ${_config.endpoint}.\n\n${await response.text()}`);
366
+
367
+ const reader = response.body.getReader();
368
+ const decoder = new TextDecoder('utf-8');
369
+
370
+ let buffer = "";
371
+ let content = "";
372
+ let role = "" as any;
373
+ let tokens: Tokens = { input: 0, output: 0, total: 0 };
374
+ let tools = [];
375
+
376
+ const uuid = uuid_v4();
377
+
378
+ while (true) {
379
+ const { value, done } = await reader.read();
380
+ if (done) break;
381
+
382
+ buffer += decoder.decode(value, { stream: true });
383
+
384
+ // Process each line in the buffer
385
+ let lines = buffer.split('\n');
386
+ // Keep the last incomplete line in the buffer
387
+ buffer = lines.pop()!;
388
+
389
+ for (const line of lines) {
390
+ if (line.startsWith("data: ")) {
391
+
392
+ try {
393
+ // Extract data after "data: " and parse it as JSON
394
+ const json = line.slice(6).trim();
395
+ const completion = JSON.parse(json);
396
+ const delta = completion.choices?.[0]?.delta || completion.delta;
397
+
398
+ // Append the new content to the message
399
+ content += delta?.content || delta?.text || "";
400
+ // Update role, unless it is already complete
401
+ if (!ROLES.includes(role)) role += delta?.role || "";
402
+ // Merge tool calls (some LLM send these as deltas)
403
+ tools = mergeToolsDeltas(tools, delta?.tool_calls || transformAnthropicToolCalls(completion.content_block)); // Merge any new tool calls with the existing ones
404
+
405
+ const usage = getUsageFromCompletion(completion);
406
+ tokens.input = usage.input || tokens.input;
407
+ tokens.output = usage.output || tokens.output;
408
+ tokens.total = usage.total || tokens.total;
409
+
410
+ yield generateOutputObject(content, null, null, uuid);
411
+ } catch (error) { /* meh */ }
412
+ }
413
+ }
414
+ }
415
+
416
+ subtotal.input += tokens.input || 0;
417
+ subtotal.output += tokens.output || 0;
418
+ subtotal.total += tokens.total || subtotal.input + subtotal.output || 0;
419
+
420
+ const recursed = await handleTools.call(this, role, tools, {
421
+ messages, result, subtotal, config: _config, recurse: _stream
422
+ });
423
+ if (recursed) for await (const result of recursed) yield result;
424
+ else {
425
+ content = validateResponse(content, _config);
426
+ result.push({
427
+ role: role || ROLE.ASSISTANT,
428
+ content: content
429
+ });
430
+ updateHistory.call(this, prompt, result);
431
+ yield generateOutputObject(content, result, subtotal, uuid, _config);
432
+ }
433
+ }
434
+ }
435
+
436
+ /**
437
+ * Resets the chat history. This does not affect the configuration or any
438
+ * other settings.
439
+ * @returns void
440
+ * @example
441
+ * // EXAMPLE: Populating and then clearing the history
442
+ * mistral.complete("What's your name?"); // "John"
443
+ * mistral.complete("How do you spell that?"); // "J-O-H-N"
444
+ * mistral.clear();
445
+ * mistral.complete("Tell me again?"); // "Tell you what again?"
446
+ */
447
+ clear():void {
448
+ this.history = [];
449
+ }
450
+
451
+ /**
452
+ * Exports the chat history. By default, this exports the entire chat
453
+ * history. If a window is specified, only the most recent messages are
454
+ * returned. The window counts back each of the most recent user message, it
455
+ * includes assistant responses to these, and any system prompts set in the
456
+ * global configuration.
457
+ * @param { number } [window] The length of the window to retrieve.
458
+ * @returns { LlaminateMessage[] } An array of chat history messages.
459
+ * @example
460
+ * // EXAMPLE 1: Exporting the entire history of messages
461
+ * const history = mistral.export();
462
+ * localStorage.setItem('mistral-history', JSON.stringify(history));
463
+ * @example
464
+ * // EXAMPLE 2: Getting a window of recent messages
465
+ * // Returns the last 5 user-assistant interactions and all system prompts
466
+ * const history = mistral.export(5);
467
+ * console.log(history.length);
468
+ * // e.g. 14 = 5 user messages + 5 assistant replies (one that included a
469
+ * // tool call) + 2 system prompts
470
+ */
471
+ export(window: number = Infinity): LlaminateMessage[] {
472
+ // in the event of an invalid window value being passed (e.g. negative,
473
+ // zero, or NaN), set to 1 to return something rather than nothing
474
+ if (window && (isNaN(window) || window < 1)) window = 1;
475
+
476
+ const config = { ...this.config, window } as LlaminateConfig;
477
+ const history = getWindowFromHistory(this.history, config);
478
+ return structuredClone(history);
479
+ }
480
+
481
+ }
482
+
483
+ // PRIVATE METHODS
484
+
485
+ // These are defined as standalone functions rather than class methods so as to
486
+ // be fully private and not accessible on the instance, while still allowing
487
+ // them to be called with the instance context (i.e., using .call(this, ...))
488
+ // to access and modify the instance's history when necessary.
489
+
490
+ /**
491
+ * Generates a complete configuration object for a completion request by
492
+ * merging the instance's default configuration with any provided overrides,
493
+ * and setting the appropriate response format based on the presence of a schema.
494
+ * @param config A configuration override for this completion request.
495
+ * @param stream Whether to enable streaming for this completion request.
496
+ * @returns The complete configuration object for the completion request.
497
+ * @private
498
+ */
499
+ function generateCompletionConfig(config?: LlaminateConfig, stream: boolean = false): LlaminateConfig {
500
+ const _config = {
501
+ ...this.config,
502
+ ...config,
503
+ // Combine system messages from instance config and provided config
504
+ system: this.config.system.concat(config?.system || []),
505
+ options: {
506
+ ...this.config?.options,
507
+ ...config?.options,
508
+ stream: stream,
509
+ stream_options: stream ? {
510
+ include_usage: true // required by OpenAI
511
+ } : undefined
512
+ } as Record<string, any>
513
+ };
514
+
515
+ // If there are no tools delete all references, otherwise some LLMs complain
516
+ const tools = config?.tools || this.config?.tools || [];
517
+ if (tools.length > 0) {
518
+ _config.options.tools = tools.map(tool => ({ type: "function", function: tool.function }) );
519
+ } else {
520
+ delete _config.options.tools;
521
+ delete _config.options.parallel_tool_calls;
522
+ }
523
+
524
+ // RPM cannot be set on a per-request basis, so delete it from the override
525
+ delete _config.rpm;
526
+
527
+ validateConfig(_config);
528
+
529
+ if (_config.schema) {
530
+ _config.options.response_format = {
531
+ type: "json_schema",
532
+ json_schema: {
533
+ name: `schema_${uuid_v4()}`,
534
+ schema: _config.schema
535
+ }
536
+ };
537
+ }
538
+
539
+ return _config;
540
+ }
541
+
542
+ /**
543
+ * Validates the provided Llaminate configuration.
544
+ * @param config The configuration object to validate.
545
+ * @returns void
546
+ * @throws Will throw an error if the configuration is invalid.
547
+ * @private
548
+ */
549
+ function validateConfig(config: LlaminateConfig): void {
550
+ if (!validate.config(config))
551
+ throw new Error(`The \`config\` provided to Llaminate was not a valid configuration.\n\n${ajv.errorsText(validate.config.errors)}`);
552
+ }
553
+
554
+ /**
555
+ * Sends a fetch request to the LLM service identified in the configuration.
556
+ * @param messages The messages to include in the request.
557
+ * @param config The configuration object for the request.
558
+ * @returns A promise that resolves to the fetch Response object.
559
+ * @private
560
+ */
561
+ async function sendMessages(messages: LlaminateMessage[], config: LlaminateConfig): Promise<Response> {
562
+ const headers = {
563
+ "Authorization": `Bearer ${config.key}`,
564
+ "X-Api-Key": `${config.key}`,
565
+ "x-goog-api-key": `${config.key}`,
566
+ "anthropic-version": "2023-06-01",
567
+ "User-Agent": USER_AGENT,
568
+ "Content-Type": "application/json",
569
+ // Multiple types to handle both streaming and non-streaming responses
570
+ "Accept": "application/json, text/event-stream",
571
+ "Connection": "keep-alive",
572
+ ...config.headers
573
+ };
574
+
575
+ const body: Record<string, any> = await applyQuirks({
576
+ ...config.options,
577
+ model: config.model,
578
+ messages,
579
+ }, config);
580
+
581
+ const json = JSON.stringify(body, null, 2);
582
+
583
+ return config.fetch(config.endpoint, {
584
+ method: "POST",
585
+ headers: headers,
586
+ body: json,
587
+ });
588
+ }
589
+
590
+ /**
591
+ * Applies any necessary quirks to the request body based on the configuration
592
+ * and the target API's requirements, such as adjusting content formats,
593
+ * handling schema support, and modifying message structures.
594
+ * @param body The request body to apply quirks to.
595
+ * @param config The configuration object identifying quirks and other settings.
596
+ * @returns The modified request body with quirks applied.
597
+ * @private
598
+ */
599
+ async function applyQuirks(body: Record<string, any>, config: LlaminateConfig): Promise<Record<string, any>> {
600
+
601
+ // First clone the body so as not to mutate the original body object. The
602
+ // original will have references to the instance config and other objects
603
+ // that we don't want to modify.
604
+ const modified = structuredClone(body);
605
+
606
+ // Additional system prompts to be injected (e.g. for schema instructions)
607
+ const instructions = [];
608
+
609
+ for (const message of modified?.messages || []) {
610
+
611
+ /**
612
+ * Antropic don't have the concept of a "tool" role for tool responses,
613
+ * instead they require tool responses to be sent with the "user" role
614
+ * and a specific content format, so we transform any messages with the
615
+ * "tool" role into the format they require if the quirk is set.
616
+ */
617
+ if (message.role === ROLE.TOOL && config.quirks?.role?.tool === false) {
618
+ message.role = ROLE.USER;
619
+ message.content = [{
620
+ "type": "tool_result",
621
+ "tool_use_id": message.tool_call_id,
622
+ "content": message.content
623
+ }];
624
+
625
+ delete message.name;
626
+ delete message.tool_call_id;
627
+ }
628
+
629
+ /**
630
+ * We also need to reverse that process to transform tool calls from
631
+ * the assistant into the format that Anthropic requires.
632
+ */
633
+ if (message.role === ROLE.ASSISTANT && message.tool_calls
634
+ && config.quirks?.role?.tool === false) {
635
+ message.content = message.tool_calls?.map((call) => ({
636
+ "type": "tool_use",
637
+ "id": call.id,
638
+ "name": call.function.name,
639
+ "input": JSON.parse(call.function.arguments),
640
+ }));
641
+
642
+ delete message.tool_calls;
643
+ }
644
+
645
+ // Content might be a string, so check before trying to iterate over it.
646
+ if (Array.isArray(message.content)) {
647
+
648
+ for (const content of message?.content || []) {
649
+
650
+ /**
651
+ * Anthropic's API requires attachments to be sent in a unique
652
+ * format using a "source" property, so if the quirk is set we
653
+ * transform any content with the "image_url", "document_url",
654
+ * or "file" types into the format they require.
655
+ */
656
+ if (content.type === "image_url" && isHttpUrl(content.image_url?.url)
657
+ && config.quirks?.attachments?.source === true) {
658
+ content.type = "image";
659
+ content.source = {
660
+ type: "url",
661
+ url: content.image_url.url
662
+ };
663
+ delete content.image_url;
664
+ }
665
+
666
+ if (content.type === "document_url" && isHttpUrl(content.document_url)
667
+ && config.quirks?.attachments?.source === true) {
668
+ content.type = "document";
669
+ content.source = {
670
+ type: "url",
671
+ url: content.document_url
672
+ };
673
+ delete content.document_url;
674
+ }
675
+
676
+ if (["image_url", "document_url"].includes(content.type)
677
+ && isBase64DataUri(content[content.type]?.url || content[content.type])
678
+ && config.quirks?.attachments?.source === true) {
679
+ const uri = content[content.type]?.url || content[content.type];
680
+ const [meta, data] = uri.split(",");
681
+ const mime = meta.match(/data:(.*);base64/)?.[1] || "application/octet-stream";
682
+ content.type = mime === Llaminate.PDF ? "document" : "image";
683
+ content.source = {
684
+ type: "base64",
685
+ data: data,
686
+ media_type: mime
687
+ };
688
+ delete content.image_url;
689
+ delete content.document_url;
690
+ }
691
+
692
+ /**
693
+ * Google doesn't support the "document_url" content type, so
694
+ * if the quirk is set we transform any content with the
695
+ * document_url" type into an "image_url" (which seemingly works).
696
+ */
697
+ if (content.type === "document_url"
698
+ && config.quirks?.attachments?.document_url === "image_url") {
699
+ content.type = "image_url";
700
+ content.image_url = {
701
+ type: "url",
702
+ url: content.document_url
703
+ };
704
+ delete content.document_url;
705
+ }
706
+
707
+ /**
708
+ * OpenAI's API supports a "file" content type with base64
709
+ * data, so if the quirk is set we convert any "document_url"
710
+ * content into that format, using the URL as the base64 data.
711
+ */
712
+ if (content.type === "document_url"
713
+ && config.quirks?.attachments?.file === true) {
714
+ const data = isHttpUrl(content.document_url)
715
+ ? await fetchUrlAsBase64(content.document_url)
716
+ : content.document_url; // If the URL is already a data URI, we can use it directly without fetching
717
+
718
+ content.type = "file";
719
+ content.file = {
720
+ file_data: data,
721
+ filename: uuid_v4()
722
+ };
723
+ delete content.document_url;
724
+ }
725
+
726
+ }
727
+ }
728
+ }
729
+
730
+ modified.tools?.forEach((tool, i, tools) => {
731
+
732
+ /**
733
+ * Anthropic has a unique format for tools, so we transform the usual
734
+ * definition into the format they require.
735
+ */
736
+ if (config.quirks?.input_schema === true) {
737
+ tools[i] = {
738
+ name: tool.function.name,
739
+ description: tool.function.description,
740
+ input_schema: tool.function.parameters,
741
+ strict: tool.strict
742
+ };
743
+ }
744
+
745
+ });
746
+
747
+ /**
748
+ * Mistral doesn't support structured output with tools, so
749
+ * if both a schema and tools are provided we fall back to
750
+ * a text response and include instructions in the system
751
+ * prompt.
752
+ */
753
+ if (["json_schema", "json_object"].includes(modified.response_format?.type)) {
754
+ if (modified.tools?.length > 0
755
+ && config.quirks?.tools?.json_schema === false
756
+ && config.quirks?.tools?.json_object === false) {
757
+ const schema = JSON.stringify(
758
+ cleanse(modified.response_format?.json_schema), null, 2);
759
+ instructions.push(SYSTEM_HBS(schema));
760
+ modified.response_format.type = "text";
761
+ delete modified.response_format.json_schema;
762
+ }
763
+ }
764
+
765
+ /**
766
+ * Mistral and Deepseek don't support the "json_schema"
767
+ * response format, so if it's set in the options we
768
+ * convert it to a system prompt with instructions and
769
+ * set the content type to "json_object" instead.
770
+ */
771
+ if (modified.response_format?.type === "json_schema"
772
+ && config.quirks?.json_schema === false) {
773
+ const schema = JSON.stringify(
774
+ cleanse(modified.response_format?.json_schema), null, 2);
775
+ instructions.push(SYSTEM_HBS(schema));
776
+ modified.response_format.type = "json_object";
777
+ delete modified.response_format.json_schema;
778
+ }
779
+
780
+ /**
781
+ * Anthropic rejects tool-requests with the parallel_tool_calls property.
782
+ */
783
+ if (config.quirks?.parallel_tool_calls === false) {
784
+ delete modified.parallel_tool_calls;
785
+ }
786
+
787
+ /**
788
+ * Anthropic's API requires system messages to be included in the main
789
+ * message array with the role "system".
790
+ */
791
+ if (config.quirks?.role?.system === false) {
792
+ modified.system = [...instructions.map(
793
+ text => ({ type: "text", text: text })
794
+ ), ...modified.messages.filter(
795
+ message => message.role === ROLE.SYSTEM
796
+ ).map(message => ({ type: "text", text: message.content }))];
797
+ modified.messages = modified.messages.filter(
798
+ message => message.role !== ROLE.SYSTEM
799
+ );
800
+ } else {
801
+ modified.messages = [...instructions.map(
802
+ text => ({ role: ROLE.SYSTEM, content: text })
803
+ ), ...(modified.messages || [])];
804
+ }
805
+
806
+ /**
807
+ * Anthropic requires the "max_tokens" parameter, so we set it based on
808
+ * quirks if not set already.
809
+ */
810
+ if (typeof config.quirks?.max_tokens === "number") {
811
+ modified.max_tokens = modified.max_tokens || config.quirks?.max_tokens;
812
+ }
813
+
814
+ /**
815
+ * Anthropic doesn't support the "response_format" option and instead
816
+ * uses its own output_config property for structured output.
817
+ */
818
+ if (config.quirks?.output_config === true) {
819
+ if (modified.response_format.type === "json_schema") {
820
+ modified.output_config = {
821
+ format: {
822
+ type: "json_schema",
823
+ schema: modified.response_format.json_schema.schema
824
+ }
825
+ };
826
+ }
827
+
828
+ // Anthropic will throw an error for an unrecognized property
829
+ delete modified.response_format;
830
+ }
831
+
832
+ /**
833
+ * Anthropic doesn't support streaming options, and throws errors if it is
834
+ * included, so we remove it from the options.
835
+ */
836
+ if (config.quirks?.stream_options === false) {
837
+ delete modified.stream_options;
838
+ }
839
+
840
+ return cleanse(modified);
841
+ }
842
+
843
+ /**
844
+ * Transforms tool calls from the format used by Anthropic's API into the usual
845
+ * format expected by the rest of the code, allowing for compatibility with
846
+ * Anthropic's unique tool call structure.
847
+ * @param messages The messages from the completion response, which may contain
848
+ * tool calls in Anthropic's format.
849
+ * @returns An array of tool calls in the usual format, transformed from the
850
+ * provided messages.
851
+ * @private
852
+ */
853
+ function transformAnthropicToolCalls(messages: LlaminateMessage[] | any): LlaminateMessage[] {
854
+ if (!Array.isArray(messages)) messages = [messages]
855
+ return messages?.map((message) => {
856
+ // Anthropic has a unique format for tool calls, so transform
857
+ // it into the usual format here (if present in the response)
858
+ if (message?.type == "tool_use") return {
859
+ type: "function",
860
+ function: {
861
+ name: message.name,
862
+ arguments: JSON.stringify(message.input)
863
+ },
864
+ id: message.id
865
+ }
866
+ }).filter(Boolean);
867
+ };
868
+
869
+ /**
870
+ * Merges incoming tool call deltas with existing tool calls, combining their
871
+ * properties based on their index.
872
+ * @param existing The existing tool calls.
873
+ * @param incoming The incoming tool call deltas.
874
+ * @returns The merged tool calls.
875
+ * @private
876
+ */
877
+ function mergeToolsDeltas(existing: any[], incoming: any[] = []): Partial<LlaminateMessage["tool_calls"]>[] {
878
+ const merged = [...existing];
879
+ incoming?.forEach((delta, i) => {
880
+ // If there's no ID, we can't reliably merge, so skip this delta
881
+ if (!delta.id) return;
882
+
883
+ let found = existing.find((call) => call.id === delta.id);
884
+ if (!found) {
885
+ found = {
886
+ type: "",
887
+ function: {
888
+ name: "",
889
+ arguments: ""
890
+ },
891
+ id: "",
892
+ }
893
+ merged.push(found);
894
+ }
895
+ found.type += delta.type || "";
896
+ found.function.name += delta.function?.name || "";
897
+ found.function.arguments += delta.function?.arguments || "";
898
+ found.id += delta.id || "";
899
+
900
+ // Merge any extra_content properties (used by Google) but don't include
901
+ // for other APIs that don't support, which may throw an error.
902
+ if (delta.extra_content) {
903
+ found.extra_content = { ...found.extra_content, ...delta.extra_content };
904
+ }
905
+ });
906
+
907
+ return merged;
908
+ }
909
+
910
+ /**
911
+ * Handles tool calls discovered in the LLM response.
912
+ * @param role The role of the entity calling the tools (typically "assistant").
913
+ * @param calls The tool calls to handle in this function.
914
+ * @param context The completion context for handling the tools.
915
+ * @returns A promise resolving to a LlaminateResponse after handling tools.
916
+ * @private
917
+ */
918
+ async function handleTools(role: ROLE, calls: any, context: Context): Promise<LlaminateResponse> {
919
+ if (calls.length > 0) {
920
+ context.result.push({
921
+ role: role || ROLE.ASSISTANT,
922
+ tool_calls: calls.map(call => {
923
+ const cleansed = cleanse(call);
924
+ // Ensure type is set to "function" for backward compatibility
925
+ // with models that don't include the type in streaming deltas.
926
+ cleansed.type = cleansed.type || "function";
927
+ cleansed.function.arguments = sanatiseJSON(cleansed.function.arguments);
928
+ return cleansed;
929
+ })
930
+ });
931
+
932
+ for (const call of calls) {
933
+ const tool = context.config.tools.find((tool) => tool.function.name === call.function.name);
934
+ if (tool) {
935
+ try {
936
+ // Parse the arguments and execute the tool handler, then push
937
+ // the result onto the context for the next recursion.
938
+ const args = JSON.parse(call.function.arguments);
939
+ const response = await (tool.handler || context.config.handler || noop).call(globalThis, call.function.name, args);
940
+ context.result.push({
941
+ role: ROLE.TOOL,
942
+ name: tool.function.name,
943
+ content: serialize(response),
944
+ tool_call_id: call.id,
945
+ });
946
+ } catch (error) {
947
+ // Let the LLM know the tool call failed, and include the
948
+ // error message in the content. Typically the LLM will
949
+ // respond to this by apologizing, but it is useful
950
+ // information for the LLM to have.
951
+ context.result.push({
952
+ role: ROLE.TOOL,
953
+ name: tool.function.name,
954
+ content: JSON.stringify({ error: error.message }),
955
+ tool_call_id: call.id,
956
+ });
957
+ }
958
+ } else {
959
+ throw new Error(`The LLM response included a tool call for a tool that isn't defined in the Llaminate configuration (${call.function.name}).`);
960
+ }
961
+ };
962
+
963
+ return await context.recurse.call(this);
964
+ }
965
+ }
966
+
967
+ /**
968
+ * Extracts token usage details from a completion response.
969
+ * @param completion The completion response.
970
+ * @returns The token usage details.
971
+ * @private
972
+ */
973
+ function getUsageFromCompletion(completion: any): Tokens {
974
+ const input = completion?.usage?.prompt_tokens || completion?.usage?.input_tokens || null;
975
+ const output = completion?.usage?.completion_tokens || completion?.usage?.output_tokens || null;
976
+ const total = completion?.usage?.total_tokens || null;
977
+ return { input: parseInt(input), output: parseInt(output), total: parseInt(total) };
978
+ }
979
+
980
+ /**
981
+ * Generates a LlaminateResponse in response to a finalised completion.
982
+ * @param message The message content.
983
+ * @param result The list of messages generated in this completion, including
984
+ * any tool calls, which is added to the instance history.
985
+ * @param tokens The token usage details.
986
+ * @param uuid A unique identifier for the response.
987
+ * @param config The configuration for this completion, which may affect the
988
+ * structure of the response (e.g. if a schema is included).
989
+ * @returns The generated response object.
990
+ * @private
991
+ */
992
+ function generateOutputObject(
993
+ message: string | any,
994
+ result: LlaminateMessage[],
995
+ tokens: Tokens,
996
+ uuid: string,
997
+ config?: LlaminateConfig): LlaminateResponse {
998
+ if (config?.schema) return { message: JSON.parse(message), result, tokens, uuid };
999
+ else return { message, result, tokens, uuid };
1000
+ }
1001
+
1002
+ /**
1003
+ * Prepares a window of messages from the given prompt, modifying the message
1004
+ * history accordingly. This function MUST be called with the Llaminate instance
1005
+ * as its context (i.e. using .call(this, ...)) to access and modify the
1006
+ * instance's history.
1007
+ * @param prompt The input prompt, either a string or an array of messages.
1008
+ * @param config The configuration containing system messages and window size.
1009
+ * @returns A window of messages based on the history and configuration.
1010
+ * @throws Will throw an error if the prompt is not a string or array of messages.
1011
+ * @private
1012
+ */
1013
+ function prepareMessageWindow(prompt: string | LlaminateMessage[], config: LlaminateConfig): LlaminateMessage[] {
1014
+ let messages = [];
1015
+ if (Array.isArray(prompt)) messages = getWindowFromHistory(prompt, config); // Set history to the initial messages if an array is provided
1016
+ else if (typeof prompt === "string") messages = getWindowFromHistory(
1017
+ this.history.concat({ role: ROLE.USER, content: prompt } as LlaminateMessage
1018
+ ), config);
1019
+ else throw new Error(`The \`prompt\` provided to Llaminate was not a string or an array of messages.`);
1020
+
1021
+ // If attachments are provided in the config, append them to the content of
1022
+ // the last message.
1023
+ if (config.attachments && config.attachments.length > 0) {
1024
+ messages[messages.length - 1].content = [
1025
+ { type: "text", text: messages[messages.length - 1].content },
1026
+ ...config.attachments.map((attachment) => {
1027
+ return attachment.type?.startsWith("image") ? {
1028
+ type: "image_url",
1029
+ image_url: { url: attachment.url }
1030
+ } : {
1031
+ type: "document_url",
1032
+ document_url: attachment.url
1033
+ };
1034
+ })
1035
+ ]
1036
+ }
1037
+
1038
+ return messages;
1039
+ }
1040
+
1041
+ /**
1042
+ * Retrieves a window of messages from the history based on the configuration.
1043
+ * @param messages The message history.
1044
+ * @param config The configuration containing system messages and window size.
1045
+ * @returns The filtered list of messages within the window.
1046
+ * @private
1047
+ */
1048
+ function getWindowFromHistory(messages: LlaminateMessage[], config: LlaminateConfig): LlaminateMessage[] {
1049
+ let count = 0;
1050
+ return [
1051
+ ...(config.system || []).map(content => ({ role: ROLE.SYSTEM, content } as LlaminateMessage)),
1052
+ ...(config.history || []), // Include any additional history provided in the config (e.g. for tools)
1053
+ ...(messages.concat().reverse().map(message => {
1054
+ if (message.role === ROLE.SYSTEM) return message; // Always include system messages
1055
+ if (count < config.window) {
1056
+ if (message.role === ROLE.USER) count++;
1057
+ return message;
1058
+ }
1059
+ }).filter(Boolean).reverse())
1060
+ ];
1061
+ }
1062
+
1063
+ /**
1064
+ * Updates the instance's history with the new messages based on the prompt and
1065
+ * result. This function MUST be called with the Llaminate instance as its
1066
+ * context (i.e. using .call(this, ...)) to access and modify the instance's
1067
+ * history.
1068
+ * @param prompt The original prompt, either a string or an array of messages.
1069
+ * @param result The new messages to add to the history based on the prompt.
1070
+ * @returns void
1071
+ * @private
1072
+ */
1073
+ function updateHistory(prompt: string | LlaminateMessage[], result: LlaminateMessage[]): void {
1074
+ if (Array.isArray(prompt)) this.history = prompt.concat(result);
1075
+ else if (typeof prompt === "string") this.history.push({
1076
+ role: ROLE.USER, content: prompt
1077
+ } as LlaminateMessage, ...result);
1078
+ }
1079
+
1080
+ /**
1081
+ * Recursively "cleanses" an object, recursively removing null properties and
1082
+ * ensuring that any nested objects are also cleansed.
1083
+ * @param obj The object to be "cleansed".
1084
+ * @returns The "cleansed" object.
1085
+ * @private
1086
+ */
1087
+ function cleanse(obj: Record<string, any>): Record<string, any> {
1088
+ if (obj && obj.constructor !== Object) return Object.entries(obj).reduce((acc, [key, value]) => {
1089
+ if (value === null) {
1090
+ // Skip null properties
1091
+ return acc;
1092
+ } else if (typeof value === "object" && !Array.isArray(value) && value !== null) {
1093
+ // Recursively cleanse nested objects
1094
+ acc[key] = cleanse(value);
1095
+ } else {
1096
+ acc[key] = value;
1097
+ }
1098
+ return acc;
1099
+ }, {} as Record<string, any>);
1100
+ else return obj;
1101
+ }
1102
+
1103
+ /**
1104
+ * Validates a LLM response against a schema provided in the configuration. If a
1105
+ * schema is present, it attempts to parse the message as JSON and validate it
1106
+ * against the schema. If no schema is present, it checks the message is not
1107
+ * empty and returns it as is.
1108
+ * @param message The response message to validate.
1109
+ * @param config The configuration containing the schema for validation.
1110
+ * @returns The validated message as a string.
1111
+ * @throws Will throw an error if the message does not conform to the schema or
1112
+ * if the message is empty.
1113
+ * @private
1114
+ */
1115
+ function validateResponse(message:string, config: LlaminateConfig):string {
1116
+ if (config.schema) {
1117
+ const json:string = sanatiseJSON(message);
1118
+ const obj:any = JSON.parse(json);
1119
+
1120
+ const schema = ajv.compile(config.schema);
1121
+ const valid:any = schema(obj);
1122
+ if (!valid) throw new Error(`The LLM response did not conform to the \`schema\` provided in the Llaminate configuration.\n\n${ajv.errorsText(schema.errors)}`);
1123
+
1124
+ return JSON.stringify(obj);
1125
+ } else if (message) return message;
1126
+ else throw new Error("The LLM response was empty.");
1127
+ }
1128
+
1129
+ /**
1130
+ * Serializes an object to a JSON string, with error handling for
1131
+ * non-serializable objects (e.g. BigInt).
1132
+ * @param obj The object to serialize.
1133
+ * @returns A JSON representation of the object.
1134
+ * @throws Will throw an error if the object cannot be serialized to JSON.
1135
+ * @private
1136
+ */
1137
+ function serialize(obj: any): string {
1138
+ try {
1139
+ return JSON.stringify(obj);
1140
+ } catch (error) {
1141
+ try {
1142
+ // Handle non-serializable objects by trying to convert them to
1143
+ // strings, if they have a toString method. This is a best effort
1144
+ // attempt to provide some information about the object (as opposed
1145
+ // to [object Object] or throwing an error).
1146
+ if (obj && typeof obj.toString === "function") {
1147
+ const str = obj.toString();
1148
+ if (typeof str === "string") return JSON.stringify(str);
1149
+ }
1150
+ } catch (error) { /* meh, didn't work */ }
1151
+
1152
+ // This will be handled by the caller (which was trying to parse a tool
1153
+ // response) and returned as an error message to the LLM.
1154
+ throw error;
1155
+ }
1156
+ }
1157
+
1158
+ /**
1159
+ * Generates a UUIDv4 string.
1160
+ * @returns A UUIDv4 string.
1161
+ * @private
1162
+ */
1163
+ function uuid_v4(): string {
1164
+ return 'xxxxxxxx-xxxx-4xxx-yxxx-xxxxxxxxxxxx'.replace(/[xy]/g, function(c) {
1165
+ const r = Math.random() * 16 | 0, v = c === 'x' ? r : (r & 0x3 | 0x8);
1166
+ return v.toString(16);
1167
+ });
1168
+ }
1169
+
1170
+ /**
1171
+ * Deeply freezes an object, making it immutable by recursively freezing all
1172
+ * nested objects and arrays.
1173
+ * @param obj The object to be deeply frozen.
1174
+ * @returns The deeply frozen object.
1175
+ * @private
1176
+ */
1177
+ function deepFreeze(obj: any): any {
1178
+ const props = Object.getOwnPropertyNames(obj);
1179
+ for (const name of props) {
1180
+ const value = obj[name];
1181
+ if (value && typeof value === "object") deepFreeze(value);
1182
+ }
1183
+
1184
+ return Object.freeze(obj);
1185
+ }
1186
+
1187
+ /**
1188
+ * Sanitises a JSON string by extracting the JSON object from the string and
1189
+ * ensuring it is valid JSON. This is necessary because some models may return
1190
+ * JSON responses wrapped in additional text or formatting.
1191
+ * @param json The JSON string to sanitise.
1192
+ * @returns The sanitised JSON string.
1193
+ * @throws Will throw an error if the sanitised string is not valid JSON and
1194
+ * cannot be parsed or repaired.
1195
+ * @private
1196
+ */
1197
+ function sanatiseJSON(json: string): string {
1198
+ try {
1199
+ // Extract the JSON object from the string, or default to an empty
1200
+ // object if no JSON-like structure is found.
1201
+ const match = json.match(/{.*}/s)?.[0] || "{}";
1202
+ // Try to parse the JSON to ensure it's valid.
1203
+ JSON.parse(match);
1204
+ return match;
1205
+ } catch (error) {
1206
+ throw new Error(`The LLM response could not be parsed as JSON.\n\n${json}`);
1207
+ }
1208
+ }
1209
+
1210
+ /**
1211
+ * Defines specific quirks or requirements for different AI services endpoints,
1212
+ * such as how to format images URLs, etc.. Typically it is the options property
1213
+ * in the configuration object that will need to be modified based on these.
1214
+ * @param config The configuration object to determine the endpoint.
1215
+ * @returns An object containing an object of properties with boolean and string
1216
+ * flags indicating different "quirks" to handle for different endpoints.
1217
+ * @private
1218
+ */
1219
+ function getQuirks(config: LlaminateConfig): LlaminateQuirks {
1220
+ const endpoint = config.endpoint;
1221
+
1222
+ const isMistral = endpoint.startsWith(Llaminate.MISTRAL);
1223
+ const isOpenAI = endpoint.startsWith(Llaminate.OPENAI);
1224
+ const isGoogle = endpoint.startsWith(Llaminate.GOOGLE);
1225
+ const isDeepSeek = endpoint.startsWith(Llaminate.DEEPSEEK);
1226
+ const isAnthropic = endpoint.startsWith(Llaminate.ANTHROPIC);
1227
+
1228
+ return {
1229
+ attachments: {
1230
+ // Google doesn't support the "document_url" content type, but we
1231
+ // can transform it into an "image_url" (which seemingly works).
1232
+ document_url: isGoogle ? "image_url" : undefined,
1233
+ // Anthropic uses a "source" stynax for attachements instead of the
1234
+ // standard content array with "image_url".
1235
+ source: isAnthropic,
1236
+ // OpenAI on the other hand uses a "file" content type with base64
1237
+ // data for PDF files.
1238
+ file: isOpenAI
1239
+ },
1240
+ // Anthropic use an "input_schema" property for the arguments accepted
1241
+ // by tools.
1242
+ input_schema: isAnthropic,
1243
+ // DeepSeek doesn't support json_schema (and Mistral misbehaves), so we
1244
+ // fall back to using instructions in the system prompt and json_object.
1245
+ json_schema: !isDeepSeek && !isMistral,
1246
+ // Anthropic requires max_tokens in every call, so set this to a
1247
+ // maximum for the smallest model used by them.
1248
+ max_tokens: isAnthropic ? 64000 : undefined,
1249
+ // Anthropic uses "output_config" instead of "response_format"
1250
+ output_config: isAnthropic,
1251
+ // Anthropic complains if the parallel_tool_calls property is included,
1252
+ // even though it supports parallel tool calls.
1253
+ parallel_tool_calls: !isAnthropic,
1254
+ role: {
1255
+ // Anthropic doesn't support the "system" role for system messages,
1256
+ // and instead requires these to be included as a separate "system"
1257
+ // property in the request body.
1258
+ system: !isAnthropic,
1259
+ // Anthropic doesn't support the "tool" role for tool responses, and
1260
+ // instead the responses are sent back by the "user".
1261
+ tool: !isAnthropic
1262
+ },
1263
+ // OpenAI requires the stream_options property to send back tokens, but
1264
+ // Anthropic complains if this is included.
1265
+ stream_options: !isAnthropic,
1266
+ tools: {
1267
+ // Mistral misbehaves with streaming with tools and structured
1268
+ // output, so disable both modes of structured output and rely on
1269
+ // text output with instructions in the system prompt instead.
1270
+ json_schema: !isMistral,
1271
+ json_object: !isMistral
1272
+ },
1273
+ };
1274
+ }
1275
+
1276
+ /**
1277
+ * Fetches a file from the given URL and returns its content as a base64-encoded
1278
+ * string. This is to handle file URLs that need to be sent in base64 format,
1279
+ * such as when using OpenAI's file API for PDFs.
1280
+ * @param url The URL of the file to fetch and encode.
1281
+ * @returns A promise that resolves to the base64-encoded string of the file's
1282
+ * content.
1283
+ * @throws Will throw an error if the fetch request fails or if there is an
1284
+ * issue reading the file.
1285
+ * @private
1286
+ */
1287
+ async function fetchUrlAsBase64(url: string): Promise<string> {
1288
+ const response = await fetch(url, { headers: { "User-Agent": USER_AGENT } });
1289
+
1290
+ if (!response.ok) { throw new Error(`HTTP status ${response.status} from ${url}.\n\n${await response.text()}`); }
1291
+
1292
+ try {
1293
+ const buffer = await response.arrayBuffer();
1294
+ const base64 = Buffer.from(buffer).toString("base64");
1295
+
1296
+ const type = response.headers.get("content-type") || "application/octet-stream";
1297
+ return `data:${type};base64,${base64}`;
1298
+ } catch (error) {
1299
+ throw new Error(`Failed to encode from ${url} as base64.\n\n${error.message}`);
1300
+ }
1301
+ }
1302
+
1303
+ /**
1304
+ * Checks if a given URL is an HTTP or HTTPS URL.
1305
+ * @param url The URL string to check.
1306
+ * @returns A Boolean true if the URL is an HTTP or HTTPS URL, false otherwise.
1307
+ * @private
1308
+ */
1309
+ function isHttpUrl(url: string): boolean {
1310
+ try {
1311
+ const parsed = new URL(url);
1312
+ return ["http:", "https:"].includes(parsed.protocol);
1313
+ } catch (error) {
1314
+ return false;
1315
+ }
1316
+ }
1317
+
1318
+ /**
1319
+ * Checks if a given URI is a base64 data URI.
1320
+ * @param uri The URI string to check.
1321
+ * @returns A Boolean true if the URI is a base64 data URI, false otherwise.
1322
+ * @private
1323
+ */
1324
+ function isBase64DataUri(uri: string): boolean {
1325
+ return /^data:.*;base64,/.test(uri);
1326
+ }