@qualve/ai 0.0.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/.prettierignore +2 -0
- package/.prettierrc +17 -0
- package/README.md +96 -0
- package/core/README.md +74 -0
- package/core/package.json +28 -0
- package/core/src/index.js +1 -0
- package/core/src/prompts.js +83 -0
- package/core/src/types/llm.js +404 -0
- package/core/src/util.js +111 -0
- package/package.json +44 -0
- package/providers/anthropic/README.md +52 -0
- package/providers/anthropic/package.json +29 -0
- package/providers/anthropic/src/index.js +164 -0
- package/providers/googleai/README.md +54 -0
- package/providers/googleai/package.json +29 -0
- package/providers/googleai/src/index.js +210 -0
- package/providers/openai/README.md +53 -0
- package/providers/openai/package.json +29 -0
- package/providers/openai/src/index.js +182 -0
- package/src/core.js +1 -0
- package/src/index.js +3 -0
- package/src/providers/anthropic.js +3 -0
- package/src/providers/googleai.js +3 -0
- package/src/providers/index.js +3 -0
- package/src/providers/openai.js +3 -0
|
@@ -0,0 +1,404 @@
|
|
|
1
|
+
import * as path from "node:path";
|
|
2
|
+
import { readFileSync } from "node:fs";
|
|
3
|
+
import { existsSync } from "node:fs";
|
|
4
|
+
import { loadEnvFile } from "node:process";
|
|
5
|
+
import Task from "qualve/task";
|
|
6
|
+
import { ProgressIndicator, addFilenameSuffix, readJSONSync } from "qualve/util";
|
|
7
|
+
import { handleStream, dedent } from "../util.js";
|
|
8
|
+
import * as prompts from "../prompts.js";
|
|
9
|
+
import options from "qualve/options";
|
|
10
|
+
|
|
11
|
+
Object.assign(options, {
|
|
12
|
+
// Options specific to LLM tasks
|
|
13
|
+
llm: {},
|
|
14
|
+
model: {},
|
|
15
|
+
thinking: {},
|
|
16
|
+
});
|
|
17
|
+
|
|
18
|
+
export default class LLMTask extends Task {
|
|
19
|
+
// Subclass must define these
|
|
20
|
+
client = null;
|
|
21
|
+
|
|
22
|
+
/**
|
|
23
|
+
* Normalized stop reason vocabulary, consistent across all providers.
|
|
24
|
+
* Each provider subclass maps its own provider-specific stop states to these values.
|
|
25
|
+
*
|
|
26
|
+
* - `COMPLETE` — The model finished generating its full response.
|
|
27
|
+
* - `MAX_TOKENS` — The response was truncated because it hit the output token limit.
|
|
28
|
+
* - `ABORTED` — The provider refused to produce output (safety, policy, PII, etc.).
|
|
29
|
+
* - `UNKNOWN` — The provider returned a stop state we don't have a mapping for.
|
|
30
|
+
* @enum {string}
|
|
31
|
+
*/
|
|
32
|
+
static stopReasons = {
|
|
33
|
+
COMPLETE: "complete",
|
|
34
|
+
MAX_TOKENS: "max_tokens",
|
|
35
|
+
ABORTED: "aborted",
|
|
36
|
+
UNKNOWN: "unknown",
|
|
37
|
+
};
|
|
38
|
+
|
|
39
|
+
/**
|
|
40
|
+
* Canonical ordered list of all thinking levels across all providers.
|
|
41
|
+
* Subclasses use {@link levelMap} to remap levels they don't natively support.
|
|
42
|
+
*/
|
|
43
|
+
static thinkingLevels = ["none", "minimal", "low", "medium", "high", "xhigh"];
|
|
44
|
+
|
|
45
|
+
static type = "llm";
|
|
46
|
+
static capabilities = {};
|
|
47
|
+
|
|
48
|
+
static #registry = new Map();
|
|
49
|
+
|
|
50
|
+
/**
|
|
51
|
+
* Register an LLM provider so LLMTask.create() can dispatch to it by `task.llm`.
|
|
52
|
+
* Reads `SubClass.id` as the registry key.
|
|
53
|
+
* Each provider calls this after its own definition to self-register.
|
|
54
|
+
* @param {typeof LLMTask} SubClass
|
|
55
|
+
*/
|
|
56
|
+
static register (SubClass) {
|
|
57
|
+
LLMTask.#registry.set(SubClass.id, SubClass);
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
/**
|
|
61
|
+
* Select and instantiate the right provider subclass based on `task.llm`.
|
|
62
|
+
* Overrides the base Task.create factory to add provider dispatch.
|
|
63
|
+
*/
|
|
64
|
+
static create (task, ...args) {
|
|
65
|
+
let id = task.llm ?? "gemini";
|
|
66
|
+
let Provider = LLMTask.#registry.get(id);
|
|
67
|
+
if (!Provider) {
|
|
68
|
+
throw new Error(
|
|
69
|
+
`Unknown LLM provider: "${id}". Available: ${[...LLMTask.#registry.keys()].join(", ")}`,
|
|
70
|
+
);
|
|
71
|
+
}
|
|
72
|
+
return new Provider(task, ...args);
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
get capabilities () {
|
|
76
|
+
return this.constructor.capabilities;
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
/** Provider display name (e.g., "Gemini"). */
|
|
80
|
+
get name () {
|
|
81
|
+
return this.constructor.name;
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
/**
|
|
85
|
+
* Provider ID string (e.g., "gemini", "claude").
|
|
86
|
+
* This getter also prevents the Task constructor from overwriting `this.llm`
|
|
87
|
+
* with the raw string from task config data.
|
|
88
|
+
*/
|
|
89
|
+
get llm () {
|
|
90
|
+
return this.constructor.id;
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
/**
|
|
94
|
+
* LLM tasks default to sequential for question expansion and bounded for batch,
|
|
95
|
+
* to avoid overwhelming the provider API.
|
|
96
|
+
* Task definitions can override this by setting `concurrency` explicitly.
|
|
97
|
+
*/
|
|
98
|
+
get concurrency () {
|
|
99
|
+
return this.task.concurrency ?? (this.batched ? 5 : 1);
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
constructor (task, args) {
|
|
103
|
+
super(task, args);
|
|
104
|
+
|
|
105
|
+
if (existsSync(".env")) {
|
|
106
|
+
loadEnvFile(".env");
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
// Default to the first model in the provider's list, also falling back
|
|
110
|
+
// if the task's hardcoded model isn't supported by this provider (e.g., when switching providers via --llm).
|
|
111
|
+
if (!this.constructor.models.includes(this.model)) {
|
|
112
|
+
this.model = this.constructor.models[0];
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
// Normalize thinking level for this provider, or unset it if not supported.
|
|
116
|
+
if (
|
|
117
|
+
this.constructor.capabilities.thinkingLevel &&
|
|
118
|
+
LLMTask.thinkingLevels.includes(this.thinking)
|
|
119
|
+
) {
|
|
120
|
+
this.thinking = this.constructor.levelMap?.[this.thinking] ?? this.thinking;
|
|
121
|
+
}
|
|
122
|
+
else {
|
|
123
|
+
this.thinking = undefined;
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
Object.assign(this.debug, {
|
|
127
|
+
llm: this.name,
|
|
128
|
+
model: this.model,
|
|
129
|
+
thinking: this.thinking,
|
|
130
|
+
itemsPerPage: this.itemsPerPage,
|
|
131
|
+
});
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
/**
|
|
135
|
+
* Read a file if no contents are provided and prepare it for upload.
|
|
136
|
+
* Mainly intended to be used internally.
|
|
137
|
+
* @protected
|
|
138
|
+
* @param {string} filepath
|
|
139
|
+
* @param {object} [options]
|
|
140
|
+
* @param {string} [options.mimeType="application/json"] - The MIME type of the file.
|
|
141
|
+
* @param {string|object|Array} [options.contents] - The file contents to upload. Reads filepath if not provided.
|
|
142
|
+
*/
|
|
143
|
+
readFile (filepath, options = {}) {
|
|
144
|
+
options.mimeType ??= "application/json";
|
|
145
|
+
let isJSON = options.mimeType === "application/json";
|
|
146
|
+
options.contents ??= isJSON ? readJSONSync(filepath) : readFileSync(filepath);
|
|
147
|
+
|
|
148
|
+
if (isJSON && typeof options.contents !== "string") {
|
|
149
|
+
options.contents = JSON.stringify(options.contents, (k, v) => v ?? undefined);
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
return options;
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
/**
|
|
156
|
+
* Read a file, prepare its contents, and upload it to the provider.
|
|
157
|
+
* For JSON files, this minifies the data and strips nulls to reduce token usage.
|
|
158
|
+
* @param {string} filepath
|
|
159
|
+
* @param {object} [options]
|
|
160
|
+
* @param {string} [options.mimeType="application/json"] - The MIME type of the file.
|
|
161
|
+
* @param {string|object|Array} [options.contents] - The file contents to upload. Reads filepath if not provided.
|
|
162
|
+
*/
|
|
163
|
+
sendData (filepath, options = {}) {
|
|
164
|
+
options = this.readFile(filepath, options);
|
|
165
|
+
return this.uploadFile(filepath, options);
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
/**
|
|
169
|
+
/**
|
|
170
|
+
* Resolve a local filepath to a stable remote filename, namespaced by entity.
|
|
171
|
+
* @param {string} filepath
|
|
172
|
+
* @returns {{ name: string, dirName: string }}
|
|
173
|
+
*/
|
|
174
|
+
getFileInfo (filepath) {
|
|
175
|
+
// FIXME there is an implicit assumption here that dirName is equal to an id, which is not always the case
|
|
176
|
+
let dirName = path.basename(path.dirname(filepath));
|
|
177
|
+
let prefix = this.entityModel?.truncatedIds?.[dirName];
|
|
178
|
+
let name = path.basename(filepath);
|
|
179
|
+
|
|
180
|
+
// Make sure the filename is unique per entity by prefixing it with the truncated parent directory name.
|
|
181
|
+
// For other files (e.g., shared data), no prefix is needed since they are already unique.
|
|
182
|
+
name = (prefix ? prefix + "-" : "") + name;
|
|
183
|
+
return { name, dirName };
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
/**
|
|
187
|
+
* Ensure a file is available on the provider, uploading it if necessary.
|
|
188
|
+
* Freshness is determined by the file-level `fresh` option, falling back to the task-level `this.fresh`.
|
|
189
|
+
* @param {string} filepath
|
|
190
|
+
* @param {object} [options]
|
|
191
|
+
* @param {string|object|Array} [options.contents] - In-memory file contents. Skips disk read when provided.
|
|
192
|
+
* @param {boolean} [options.fresh] - Force re-upload for this specific file.
|
|
193
|
+
*/
|
|
194
|
+
async getRemoteFile (filepath, options = {}) {
|
|
195
|
+
let fresh = options.fresh ?? this.fresh;
|
|
196
|
+
|
|
197
|
+
if (fresh) {
|
|
198
|
+
this.info(`Removing previously uploaded file ${filepath} ...`);
|
|
199
|
+
await this.deleteFile(filepath);
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
let ret = !fresh ? await this.getFile(filepath) : null;
|
|
203
|
+
if (!ret) {
|
|
204
|
+
this.info(`Uploading ${filepath} ...`);
|
|
205
|
+
ret = await this.sendData(filepath, options);
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
this.info(`Source file ${filepath} ready`);
|
|
209
|
+
return ret;
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
/**
|
|
213
|
+
* Ensure all input files are available on the provider, populating `entry.remoteFile`.
|
|
214
|
+
* @param {Array} input
|
|
215
|
+
*/
|
|
216
|
+
async getRemoteFiles (input) {
|
|
217
|
+
await Promise.all(
|
|
218
|
+
input.map(async entry => {
|
|
219
|
+
entry.remoteFile ??= await this.getRemoteFile(entry.filePath, {
|
|
220
|
+
contents: entry.contents,
|
|
221
|
+
fresh: entry.fresh,
|
|
222
|
+
});
|
|
223
|
+
}),
|
|
224
|
+
);
|
|
225
|
+
}
|
|
226
|
+
|
|
227
|
+
// Abstract — subclasses must override
|
|
228
|
+
|
|
229
|
+
/**
|
|
230
|
+
* Low-level: upload data to the provider.
|
|
231
|
+
* Use {@link sendData} for most things instead.
|
|
232
|
+
* @protected
|
|
233
|
+
* @param {string} filepath
|
|
234
|
+
* @param {object} options
|
|
235
|
+
* @param {string} options.contents - The file contents to upload.
|
|
236
|
+
* @param {string} options.mimeType - The MIME type of the file.
|
|
237
|
+
*/
|
|
238
|
+
uploadFile (filepath, options) {
|
|
239
|
+
throw this.notImplemented();
|
|
240
|
+
}
|
|
241
|
+
|
|
242
|
+
/** Retrieve a previously uploaded file from the provider, or null if not found. */
|
|
243
|
+
getFile (filepath) {
|
|
244
|
+
throw this.notImplemented();
|
|
245
|
+
}
|
|
246
|
+
|
|
247
|
+
/** Delete a previously uploaded file from the provider. */
|
|
248
|
+
deleteFile (filepath) {
|
|
249
|
+
throw this.notImplemented();
|
|
250
|
+
}
|
|
251
|
+
|
|
252
|
+
/** List all files currently uploaded to the provider. */
|
|
253
|
+
listFiles () {
|
|
254
|
+
throw this.notImplemented();
|
|
255
|
+
}
|
|
256
|
+
|
|
257
|
+
/** Create the streaming API call for this task. Returns stream + handler callbacks. */
|
|
258
|
+
createStream () {
|
|
259
|
+
throw this.notImplemented();
|
|
260
|
+
}
|
|
261
|
+
|
|
262
|
+
// To be overridden
|
|
263
|
+
|
|
264
|
+
/** Extract a human-readable status message from a streaming chunk. */
|
|
265
|
+
getStatus (chunk) {}
|
|
266
|
+
|
|
267
|
+
/** Count the total input tokens for this task. Returns undefined if unsupported. */
|
|
268
|
+
async countTokens () {}
|
|
269
|
+
|
|
270
|
+
/** Describe a single input file for inclusion in the prompt. */
|
|
271
|
+
inputFile (file) {
|
|
272
|
+
return prompts.inputFile.call(this, file);
|
|
273
|
+
}
|
|
274
|
+
|
|
275
|
+
/** Describe all input files for inclusion in the prompt. */
|
|
276
|
+
inputFiles (files) {
|
|
277
|
+
return prompts.inputFiles.call(this, files);
|
|
278
|
+
}
|
|
279
|
+
|
|
280
|
+
/** Describe the expected output file for inclusion in the prompt. */
|
|
281
|
+
outputFile (file) {
|
|
282
|
+
return prompts.outputFile.call(this, file);
|
|
283
|
+
}
|
|
284
|
+
|
|
285
|
+
/**
|
|
286
|
+
* Normalize a prompts value to a flat array of strings.
|
|
287
|
+
* Accepts a string, array, or function (called with the current entity).
|
|
288
|
+
*/
|
|
289
|
+
normalizePrompts (prompts) {
|
|
290
|
+
if (!prompts) {
|
|
291
|
+
return [];
|
|
292
|
+
}
|
|
293
|
+
|
|
294
|
+
if (typeof prompts === "function") {
|
|
295
|
+
prompts = prompts.call(this, this.entity);
|
|
296
|
+
}
|
|
297
|
+
|
|
298
|
+
// Do not convert to else if, function may return a string or an array
|
|
299
|
+
if (typeof prompts === "string") {
|
|
300
|
+
prompts = dedent(prompts);
|
|
301
|
+
}
|
|
302
|
+
else if (Array.isArray(prompts)) {
|
|
303
|
+
return prompts.flatMap(prompt => this.normalizePrompts(prompt));
|
|
304
|
+
}
|
|
305
|
+
|
|
306
|
+
return [prompts];
|
|
307
|
+
}
|
|
308
|
+
|
|
309
|
+
async postInit () {
|
|
310
|
+
await super.postInit();
|
|
311
|
+
|
|
312
|
+
this.system = this.normalizePrompts(this.system);
|
|
313
|
+
this.prompt = this.normalizePrompts(this.prompt);
|
|
314
|
+
|
|
315
|
+
const capabilities = this.capabilities;
|
|
316
|
+
|
|
317
|
+
if (
|
|
318
|
+
this.input?.length > 0 &&
|
|
319
|
+
!capabilities.inputSchema &&
|
|
320
|
+
!capabilities.inputDescriptions
|
|
321
|
+
) {
|
|
322
|
+
// Incorporate file descriptions and schemas into the prompt
|
|
323
|
+
this.prompt.push(this.inputFiles(this.input));
|
|
324
|
+
}
|
|
325
|
+
|
|
326
|
+
if (this.output) {
|
|
327
|
+
this.prompt.push(this.outputFile(this.output));
|
|
328
|
+
}
|
|
329
|
+
|
|
330
|
+
Object.assign(this.debug, { system: this.system, prompt: this.prompt });
|
|
331
|
+
}
|
|
332
|
+
|
|
333
|
+
async runTask () {
|
|
334
|
+
if (this.dryRun) {
|
|
335
|
+
if (!this.batched) {
|
|
336
|
+
this.debug.tokens = await this.countTokens();
|
|
337
|
+
}
|
|
338
|
+
|
|
339
|
+
return;
|
|
340
|
+
}
|
|
341
|
+
|
|
342
|
+
if (this.input) {
|
|
343
|
+
await this.getRemoteFiles(this.input);
|
|
344
|
+
}
|
|
345
|
+
|
|
346
|
+
// If no progress indicator exists (standalone task, not part of a multiple run),
|
|
347
|
+
// install one so chunk status gets logUpdate-based in-place display.
|
|
348
|
+
// Subtasks in a multiple run already have an indicator (child of the coordinator's).
|
|
349
|
+
let ownedIndicator = !this.progressIndicator;
|
|
350
|
+
if (ownedIndicator) {
|
|
351
|
+
this.progressIndicator = new ProgressIndicator({
|
|
352
|
+
status: `${this.title} with ${this.name}...`,
|
|
353
|
+
});
|
|
354
|
+
}
|
|
355
|
+
|
|
356
|
+
const streamParams = await this.createStream();
|
|
357
|
+
let chunksReceived = 0;
|
|
358
|
+
|
|
359
|
+
let text;
|
|
360
|
+
try {
|
|
361
|
+
text = await handleStream({
|
|
362
|
+
...streamParams,
|
|
363
|
+
outputPath: this.output?.filePath,
|
|
364
|
+
onChunk: chunk => {
|
|
365
|
+
chunksReceived++;
|
|
366
|
+
let status = this.getStatus(chunk);
|
|
367
|
+
status = status
|
|
368
|
+
? `Chunk ${chunksReceived}: ${status}`
|
|
369
|
+
: `${chunksReceived} chunks received...`;
|
|
370
|
+
this.info(status);
|
|
371
|
+
|
|
372
|
+
// The explicit onChunk above shadows the one from streamParams, so call it manually.
|
|
373
|
+
streamParams.onChunk?.(chunk);
|
|
374
|
+
},
|
|
375
|
+
});
|
|
376
|
+
}
|
|
377
|
+
catch (e) {
|
|
378
|
+
// var hoists `error` out of the catch block so it's accessible after the finally.
|
|
379
|
+
var error = e;
|
|
380
|
+
}
|
|
381
|
+
finally {
|
|
382
|
+
if (ownedIndicator) {
|
|
383
|
+
this.progressIndicator?.stop();
|
|
384
|
+
this.progressIndicator = null;
|
|
385
|
+
}
|
|
386
|
+
}
|
|
387
|
+
|
|
388
|
+
let outputPath = this.output?.filePath;
|
|
389
|
+
|
|
390
|
+
if (outputPath && error) {
|
|
391
|
+
outputPath = addFilenameSuffix(outputPath, ".tmp");
|
|
392
|
+
}
|
|
393
|
+
|
|
394
|
+
return {
|
|
395
|
+
outputPath,
|
|
396
|
+
size: chunksReceived,
|
|
397
|
+
sizeUnit: "chunk",
|
|
398
|
+
error,
|
|
399
|
+
result: text,
|
|
400
|
+
};
|
|
401
|
+
}
|
|
402
|
+
}
|
|
403
|
+
|
|
404
|
+
Task.register(LLMTask);
|
package/core/src/util.js
ADDED
|
@@ -0,0 +1,111 @@
|
|
|
1
|
+
import {
|
|
2
|
+
rmSync,
|
|
3
|
+
renameSync,
|
|
4
|
+
createWriteStream,
|
|
5
|
+
} from "node:fs";
|
|
6
|
+
import { once } from "node:events";
|
|
7
|
+
import { addFilenameSuffix, readJSONSync, writeJSONSync } from "qualve/util";
|
|
8
|
+
|
|
9
|
+
export { default as dedent } from "dedent";
|
|
10
|
+
|
|
11
|
+
/**
|
|
12
|
+
* @typedef {Object} StreamResult
|
|
13
|
+
* @property {boolean} complete - Whether the stream completed normally.
|
|
14
|
+
* @property {string} reason - Normalized stop reason (@see LLM.stopReasons).
|
|
15
|
+
* @property {string|null} reasonRaw - Provider-specific stop reason, for low-level handling.
|
|
16
|
+
*/
|
|
17
|
+
|
|
18
|
+
/**
|
|
19
|
+
* Safely handles an async iterable stream of chunks from an LLM response,
|
|
20
|
+
* writing them to a file with proper error handling and cleanup.
|
|
21
|
+
* When no outputPath is provided, collects the response text in memory and returns it.
|
|
22
|
+
* @param {Object} options
|
|
23
|
+
* @param {AsyncIterable<Object>} options.stream - An async iterable of chunks to be written.
|
|
24
|
+
* @param {string} [options.outputPath] - The path to the file where chunks will be written. If omitted, text is collected in memory.
|
|
25
|
+
* @param {(chunk: Object) => string} [options.transformChunk] - An optional transform function to apply to each chunk before writing.
|
|
26
|
+
* @param {(result: Object) => Object} [options.transformResult] - An optional transform function to apply to the final result after all chunks have been written and read back.
|
|
27
|
+
* @param {(chunk: Object) => void} [options.onChunk] - An optional callback to handle each chunk as it is processed (e.g. for progress updates).
|
|
28
|
+
* @param {() => (StreamResult | null | undefined)} [options.onFinish] - An optional callback invoked after the stream ends, before file promotion. Return a StreamResult with complete: false to prevent file promotion and throw.
|
|
29
|
+
* @returns {Promise<string|undefined>} The collected text when no outputPath is given, otherwise undefined.
|
|
30
|
+
*/
|
|
31
|
+
export async function handleStream ({
|
|
32
|
+
stream,
|
|
33
|
+
outputPath,
|
|
34
|
+
transformChunk,
|
|
35
|
+
transformResult,
|
|
36
|
+
onChunk = () => {},
|
|
37
|
+
onFinish = () => {},
|
|
38
|
+
} = {}) {
|
|
39
|
+
// No output file — collect text in memory
|
|
40
|
+
if (!outputPath) {
|
|
41
|
+
let chunks = [];
|
|
42
|
+
for await (let chunk of stream) {
|
|
43
|
+
onChunk(chunk);
|
|
44
|
+
chunks.push(transformChunk ? transformChunk(chunk) : chunk);
|
|
45
|
+
}
|
|
46
|
+
return chunks.join("");
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
const tmpFile = addFilenameSuffix(outputPath, ".tmp");
|
|
50
|
+
|
|
51
|
+
const ws = createWriteStream(tmpFile);
|
|
52
|
+
|
|
53
|
+
let writeError;
|
|
54
|
+
ws.on("error", err => {
|
|
55
|
+
writeError = err;
|
|
56
|
+
});
|
|
57
|
+
|
|
58
|
+
try {
|
|
59
|
+
for await (let chunk of stream) {
|
|
60
|
+
if (writeError) {
|
|
61
|
+
// Something went wrong while writing to disk.
|
|
62
|
+
// That shouldn't happen, but if it does, we stop processing further chunks.
|
|
63
|
+
throw writeError;
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
onChunk(chunk);
|
|
67
|
+
|
|
68
|
+
if (transformChunk) {
|
|
69
|
+
chunk = transformChunk(chunk);
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
if (!ws.write(chunk)) {
|
|
73
|
+
// Handle backpressure
|
|
74
|
+
await once(ws, "drain");
|
|
75
|
+
}
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
ws.end();
|
|
79
|
+
await once(ws, "finish");
|
|
80
|
+
|
|
81
|
+
// var (not let) hoists `streamResult` out of the try block so it's accessible below.
|
|
82
|
+
var streamResult = onFinish();
|
|
83
|
+
}
|
|
84
|
+
catch (e) {
|
|
85
|
+
throw new Error(`Stream handling failed for ${outputPath}`, { cause: e });
|
|
86
|
+
}
|
|
87
|
+
finally {
|
|
88
|
+
ws.destroy();
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
// Checked after stream I/O so the error isn't buried under "Stream handling failed".
|
|
92
|
+
// Callers can inspect error.cause.streamResult.reason (normalized) and error.cause.streamResult.reasonRaw (provider-specific).
|
|
93
|
+
if (streamResult && !streamResult.complete) {
|
|
94
|
+
let cause = new Error(`Provider stop reason: ${streamResult.reasonRaw}`);
|
|
95
|
+
cause.streamResult = streamResult;
|
|
96
|
+
throw new Error(`An error occurred while generating the response: ${streamResult.reason}`, {
|
|
97
|
+
cause,
|
|
98
|
+
});
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
// Clean up: prettify the result and write it to the final file
|
|
102
|
+
if (transformResult) {
|
|
103
|
+
let result = readJSONSync(tmpFile);
|
|
104
|
+
result = transformResult(result);
|
|
105
|
+
writeJSONSync(outputPath, result);
|
|
106
|
+
rmSync(tmpFile);
|
|
107
|
+
}
|
|
108
|
+
else {
|
|
109
|
+
renameSync(tmpFile, outputPath);
|
|
110
|
+
}
|
|
111
|
+
}
|
package/package.json
ADDED
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@qualve/ai",
|
|
3
|
+
"version": "0.0.1",
|
|
4
|
+
"description": "LLM task plugin for Qualve, with adapters for Gemini, OpenAI, and Claude.",
|
|
5
|
+
"type": "module",
|
|
6
|
+
"engines": {
|
|
7
|
+
"node": ">=23"
|
|
8
|
+
},
|
|
9
|
+
"main": "src/index.js",
|
|
10
|
+
"workspaces": [
|
|
11
|
+
"core",
|
|
12
|
+
"providers/*"
|
|
13
|
+
],
|
|
14
|
+
"exports": {
|
|
15
|
+
".": "./src/index.js",
|
|
16
|
+
"./anthropic": "./src/anthropic.js",
|
|
17
|
+
"./openai": "./src/openai.js",
|
|
18
|
+
"./googleai": "./src/googleai.js",
|
|
19
|
+
"./core": "./src/core.js"
|
|
20
|
+
},
|
|
21
|
+
"repository": {
|
|
22
|
+
"type": "git",
|
|
23
|
+
"url": "https://github.com/qualve/ai.git"
|
|
24
|
+
},
|
|
25
|
+
"contributors": [
|
|
26
|
+
"Dmitry Sharabin",
|
|
27
|
+
"Lea Verou"
|
|
28
|
+
],
|
|
29
|
+
"peerDependencies": {
|
|
30
|
+
"qualve": "*"
|
|
31
|
+
},
|
|
32
|
+
"devDependencies": {
|
|
33
|
+
"prettier": "^3.8.1",
|
|
34
|
+
"prettier-plugin-brace-style": "^0.10.0",
|
|
35
|
+
"prettier-plugin-merge": "^0.10.0",
|
|
36
|
+
"prettier-plugin-space-before-function-paren": "^0.1.0"
|
|
37
|
+
},
|
|
38
|
+
"dependencies": {
|
|
39
|
+
"@qualve/llm": "*",
|
|
40
|
+
"@qualve/anthropic": "*",
|
|
41
|
+
"@qualve/openai": "*",
|
|
42
|
+
"@qualve/googleai": "*"
|
|
43
|
+
}
|
|
44
|
+
}
|
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
# @qualve/anthropic
|
|
2
|
+
|
|
3
|
+
[Anthropic Claude](https://www.anthropic.com/) provider for [Qualve](https://npmjs.com/package/qualve) LLM tasks.
|
|
4
|
+
|
|
5
|
+
## Setup
|
|
6
|
+
|
|
7
|
+
Requires **Node.js v23+**.
|
|
8
|
+
|
|
9
|
+
```sh
|
|
10
|
+
npm install @qualve/anthropic
|
|
11
|
+
```
|
|
12
|
+
|
|
13
|
+
Set the `ANTHROPIC_API_KEY` environment variable (or add it to `.env`).
|
|
14
|
+
Get a key at https://platform.claude.com/settings/keys.
|
|
15
|
+
|
|
16
|
+
## Usage
|
|
17
|
+
|
|
18
|
+
```js
|
|
19
|
+
import "@qualve/anthropic";
|
|
20
|
+
```
|
|
21
|
+
|
|
22
|
+
Importing the package registers the `claude` provider with the Qualve task system.
|
|
23
|
+
|
|
24
|
+
Then use `llm: "claude"` in your task definitions:
|
|
25
|
+
|
|
26
|
+
```js
|
|
27
|
+
export default {
|
|
28
|
+
type: "llm",
|
|
29
|
+
llm: "claude",
|
|
30
|
+
system: "You are a helpful assistant.",
|
|
31
|
+
prompt: "Summarize this data.",
|
|
32
|
+
input: [{ name: "data", schema: mySchema }],
|
|
33
|
+
output: { name: "summary", schema: summarySchema },
|
|
34
|
+
};
|
|
35
|
+
```
|
|
36
|
+
|
|
37
|
+
## Models
|
|
38
|
+
|
|
39
|
+
| Model | Context window | Max output |
|
|
40
|
+
| --- | --- | --- |
|
|
41
|
+
| `claude-sonnet-4-6` (default) | 1M | 64K |
|
|
42
|
+
| `claude-haiku-4-6` | 200K | 64K |
|
|
43
|
+
| `claude-opus-4-5` | 1M | 128K |
|
|
44
|
+
|
|
45
|
+
## Capabilities
|
|
46
|
+
|
|
47
|
+
| Capability | Supported |
|
|
48
|
+
| --- | --- |
|
|
49
|
+
| Structured output (JSON schema) | Yes |
|
|
50
|
+
| Input file descriptions in prompt | Yes (automatic) |
|
|
51
|
+
| Thinking levels | No |
|
|
52
|
+
| Token counting | Yes |
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@qualve/anthropic",
|
|
3
|
+
"version": "0.0.1",
|
|
4
|
+
"description": "Anthropic Claude provider for Qualve LLM tasks.",
|
|
5
|
+
"type": "module",
|
|
6
|
+
"engines": {
|
|
7
|
+
"node": ">=23"
|
|
8
|
+
},
|
|
9
|
+
"main": "src/index.js",
|
|
10
|
+
"exports": {
|
|
11
|
+
".": "./src/index.js"
|
|
12
|
+
},
|
|
13
|
+
"repository": {
|
|
14
|
+
"type": "git",
|
|
15
|
+
"url": "https://github.com/qualve/ai.git",
|
|
16
|
+
"directory": "providers/anthropic"
|
|
17
|
+
},
|
|
18
|
+
"contributors": [
|
|
19
|
+
"Dmitry Sharabin",
|
|
20
|
+
"Lea Verou"
|
|
21
|
+
],
|
|
22
|
+
"peerDependencies": {
|
|
23
|
+
"qualve": "*"
|
|
24
|
+
},
|
|
25
|
+
"dependencies": {
|
|
26
|
+
"@qualve/llm": "*",
|
|
27
|
+
"@anthropic-ai/sdk": "^0.79.0"
|
|
28
|
+
}
|
|
29
|
+
}
|