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.
- package/.gitattributes +2 -0
- package/LICENSE +21 -0
- package/README.md +95 -0
- package/assets/llaminate-256.webp +0 -0
- package/assets/llaminate.jpg +0 -0
- package/assets/llaminate.webp +0 -0
- package/dist/build-info.json +5 -0
- package/dist/config.schema.json +126 -0
- package/dist/llaminate.d.ts +202 -0
- package/dist/llaminate.min.js +9 -0
- package/dist/llaminate.min.js.map +1 -0
- package/dist/ratelimiter.d.ts +33 -0
- package/dist/ratelimiter.min.js +7 -0
- package/dist/ratelimiter.min.js.map +1 -0
- package/dist/system.hbs +3 -0
- package/docs/LICENSE +21 -0
- package/docs/Llaminate.html +1040 -0
- package/docs/Llaminate.module_Endpoints.html +278 -0
- package/docs/Llaminate.module_Llaminate.html +200 -0
- package/docs/Llaminate.module_MimeTypes.html +275 -0
- package/docs/LlaminateConfig.html +667 -0
- package/docs/LlaminateMessage.html +328 -0
- package/docs/LlaminateResponse.html +253 -0
- package/docs/assets/llaminate-256.webp +0 -0
- package/docs/fonts/OpenSans-Bold-webfont.eot +0 -0
- package/docs/fonts/OpenSans-Bold-webfont.svg +1830 -0
- package/docs/fonts/OpenSans-Bold-webfont.woff +0 -0
- package/docs/fonts/OpenSans-BoldItalic-webfont.eot +0 -0
- package/docs/fonts/OpenSans-BoldItalic-webfont.svg +1830 -0
- package/docs/fonts/OpenSans-BoldItalic-webfont.woff +0 -0
- package/docs/fonts/OpenSans-Italic-webfont.eot +0 -0
- package/docs/fonts/OpenSans-Italic-webfont.svg +1830 -0
- package/docs/fonts/OpenSans-Italic-webfont.woff +0 -0
- package/docs/fonts/OpenSans-Light-webfont.eot +0 -0
- package/docs/fonts/OpenSans-Light-webfont.svg +1831 -0
- package/docs/fonts/OpenSans-Light-webfont.woff +0 -0
- package/docs/fonts/OpenSans-LightItalic-webfont.eot +0 -0
- package/docs/fonts/OpenSans-LightItalic-webfont.svg +1835 -0
- package/docs/fonts/OpenSans-LightItalic-webfont.woff +0 -0
- package/docs/fonts/OpenSans-Regular-webfont.eot +0 -0
- package/docs/fonts/OpenSans-Regular-webfont.svg +1831 -0
- package/docs/fonts/OpenSans-Regular-webfont.woff +0 -0
- package/docs/index.html +142 -0
- package/docs/scripts/linenumber.js +25 -0
- package/docs/scripts/prettify/Apache-License-2.0.txt +202 -0
- package/docs/scripts/prettify/lang-css.js +2 -0
- package/docs/scripts/prettify/prettify.js +28 -0
- package/docs/styles/jsdoc-default.css +358 -0
- package/docs/styles/prettify-jsdoc.css +111 -0
- package/docs/styles/prettify-tomorrow.css +132 -0
- package/jsdoc.json +23 -0
- package/package.json +38 -0
- package/scripts/build.sh +21 -0
- package/scripts/docs.sh +28 -0
- package/scripts/prebuild.js +43 -0
- package/scripts/prepare.sh +11 -0
- package/scripts/pretest.js +14 -0
- package/src/config.schema.json +126 -0
- package/src/llaminate.d.ts +99 -0
- package/src/llaminate.ts +1326 -0
- package/src/llaminate.types.js +176 -0
- package/src/ratelimiter.ts +95 -0
- package/src/system.hbs +3 -0
- package/tests/attachments.test.js +66 -0
- package/tests/common/base64.js +13 -0
- package/tests/common/matches.js +69 -0
- package/tests/common/setup.js +45 -0
- package/tests/complete.test.js +27 -0
- package/tests/extensions/toMatchSchema.js +20 -0
- package/tests/history.test.js +86 -0
- package/tests/stream.test.js +67 -0
- package/tsconfig.json +12 -0
package/src/llaminate.ts
ADDED
|
@@ -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
|
+
}
|