@qualve/ai 0.0.1 → 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/package.json +13 -3
- package/.prettierignore +0 -2
- package/.prettierrc +0 -17
- package/core/README.md +0 -74
- package/core/package.json +0 -28
- package/core/src/index.js +0 -1
- package/core/src/prompts.js +0 -83
- package/core/src/types/llm.js +0 -404
- package/core/src/util.js +0 -111
- package/providers/anthropic/README.md +0 -52
- package/providers/anthropic/package.json +0 -29
- package/providers/anthropic/src/index.js +0 -164
- package/providers/googleai/README.md +0 -54
- package/providers/googleai/package.json +0 -29
- package/providers/googleai/src/index.js +0 -210
- package/providers/openai/README.md +0 -53
- package/providers/openai/package.json +0 -29
- package/providers/openai/src/index.js +0 -182
package/core/src/util.js
DELETED
|
@@ -1,111 +0,0 @@
|
|
|
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
|
-
}
|
|
@@ -1,52 +0,0 @@
|
|
|
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 |
|
|
@@ -1,29 +0,0 @@
|
|
|
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
|
-
}
|
|
@@ -1,164 +0,0 @@
|
|
|
1
|
-
import { LLMTask } from "@qualve/llm";
|
|
2
|
-
import Anthropic, { toFile } from "@anthropic-ai/sdk";
|
|
3
|
-
|
|
4
|
-
export default class Claude extends LLMTask {
|
|
5
|
-
static id = "claude";
|
|
6
|
-
static name = "Claude";
|
|
7
|
-
static models = ["claude-sonnet-4-6", "claude-haiku-4-6", "claude-opus-4-5"];
|
|
8
|
-
static capabilities = {
|
|
9
|
-
inputDescriptions: true,
|
|
10
|
-
outputSchema: true,
|
|
11
|
-
};
|
|
12
|
-
|
|
13
|
-
client = new Anthropic({
|
|
14
|
-
apiKey: process.env.ANTHROPIC_API_KEY,
|
|
15
|
-
timeout: 30 * 60_000, // 30 minutes — LLM tasks with thinking can be very slow
|
|
16
|
-
});
|
|
17
|
-
|
|
18
|
-
async uploadFile (filepath, { mimeType, contents }) {
|
|
19
|
-
let { name } = this.getFileInfo(filepath);
|
|
20
|
-
|
|
21
|
-
return this.client.beta.files.upload(
|
|
22
|
-
{
|
|
23
|
-
// The Claude Files API doesn't support JSON files directly,
|
|
24
|
-
// so to use them in prompts, we upload them with a text/plain MIME type that Claude supports.
|
|
25
|
-
// See https://platform.claude.com/docs/en/build-with-claude/files#file-types-and-content-blocks
|
|
26
|
-
file: await toFile(new Blob([contents], { type: mimeType }), name, {
|
|
27
|
-
type: "text/plain",
|
|
28
|
-
}),
|
|
29
|
-
},
|
|
30
|
-
{
|
|
31
|
-
betas: ["files-api-2025-04-14"],
|
|
32
|
-
},
|
|
33
|
-
);
|
|
34
|
-
}
|
|
35
|
-
|
|
36
|
-
async getFile (filepath) {
|
|
37
|
-
let { name } = this.getFileInfo(filepath);
|
|
38
|
-
const list = await this.listFiles();
|
|
39
|
-
return list.find(f => f.filename === name);
|
|
40
|
-
}
|
|
41
|
-
|
|
42
|
-
async deleteFile (filepath) {
|
|
43
|
-
let fileId = (await this.getFile(filepath))?.id;
|
|
44
|
-
if (!fileId) {
|
|
45
|
-
// Not found
|
|
46
|
-
return;
|
|
47
|
-
}
|
|
48
|
-
return this.client.beta.files.delete(fileId, {
|
|
49
|
-
betas: ["files-api-2025-04-14"],
|
|
50
|
-
});
|
|
51
|
-
}
|
|
52
|
-
|
|
53
|
-
async listFiles () {
|
|
54
|
-
const meta = [];
|
|
55
|
-
|
|
56
|
-
for await (const file of this.client.beta.files.list({
|
|
57
|
-
betas: ["files-api-2025-04-14"],
|
|
58
|
-
})) {
|
|
59
|
-
meta.push(file);
|
|
60
|
-
}
|
|
61
|
-
|
|
62
|
-
return meta;
|
|
63
|
-
}
|
|
64
|
-
|
|
65
|
-
async countTokens () {
|
|
66
|
-
let { system, prompt, input = [] } = this;
|
|
67
|
-
let result = await this.client.messages.countTokens({
|
|
68
|
-
model: this.model,
|
|
69
|
-
system: system?.join("\n"),
|
|
70
|
-
messages: [
|
|
71
|
-
{
|
|
72
|
-
role: "user",
|
|
73
|
-
content: [
|
|
74
|
-
...prompt.map(t => ({ type: "text", text: t })),
|
|
75
|
-
...input.map(f => ({
|
|
76
|
-
type: "document",
|
|
77
|
-
context: this.inputFile(f),
|
|
78
|
-
source: {
|
|
79
|
-
type: "text",
|
|
80
|
-
media_type: "text/plain",
|
|
81
|
-
data:
|
|
82
|
-
this.readFile(f.filePath, { contents: f.contents })?.contents ??
|
|
83
|
-
"",
|
|
84
|
-
},
|
|
85
|
-
})),
|
|
86
|
-
],
|
|
87
|
-
},
|
|
88
|
-
],
|
|
89
|
-
});
|
|
90
|
-
|
|
91
|
-
return result.input_tokens;
|
|
92
|
-
}
|
|
93
|
-
|
|
94
|
-
async createStream () {
|
|
95
|
-
let { system, prompt, output, input = [] } = this;
|
|
96
|
-
let responseSchema = output?.schema;
|
|
97
|
-
let output_format = responseSchema
|
|
98
|
-
? {
|
|
99
|
-
type: "json_schema",
|
|
100
|
-
schema: responseSchema.schema,
|
|
101
|
-
}
|
|
102
|
-
: undefined;
|
|
103
|
-
const stream = this.client.beta.messages.stream({
|
|
104
|
-
model: this.model,
|
|
105
|
-
max_tokens: 64000, // maximum for claude-sonnet-4-5
|
|
106
|
-
betas: ["structured-outputs-2025-11-13", "files-api-2025-04-14"],
|
|
107
|
-
system: system?.join("\n"),
|
|
108
|
-
messages: [
|
|
109
|
-
{
|
|
110
|
-
role: "user",
|
|
111
|
-
content: [
|
|
112
|
-
...prompt.map(t => ({ type: "text", text: t })),
|
|
113
|
-
...input.map(f => ({
|
|
114
|
-
type: "document",
|
|
115
|
-
context: this.inputFile(f),
|
|
116
|
-
source: {
|
|
117
|
-
type: "file",
|
|
118
|
-
file_id: f.remoteFile.id,
|
|
119
|
-
},
|
|
120
|
-
})),
|
|
121
|
-
],
|
|
122
|
-
},
|
|
123
|
-
],
|
|
124
|
-
// Claude API doesn't allow extra properties in the schema root.
|
|
125
|
-
// It throws an "invalid_request_error" error (output_format.description: Extra inputs are not permitted)
|
|
126
|
-
output_format,
|
|
127
|
-
});
|
|
128
|
-
|
|
129
|
-
let stopReason;
|
|
130
|
-
return {
|
|
131
|
-
stream,
|
|
132
|
-
transformChunk: chunk =>
|
|
133
|
-
chunk.type === "content_block_delta" && chunk.delta?.type === "text_delta"
|
|
134
|
-
? chunk.delta.text
|
|
135
|
-
: "",
|
|
136
|
-
onChunk: chunk => {
|
|
137
|
-
if (chunk.type === "message_delta") {
|
|
138
|
-
stopReason = chunk.delta?.stop_reason;
|
|
139
|
-
}
|
|
140
|
-
},
|
|
141
|
-
onFinish: () => {
|
|
142
|
-
// See https://platform.claude.com/docs/en/agent-sdk/stop-reasons#available-stop-reasons
|
|
143
|
-
if (!stopReason || ["end_turn", "stop_sequence"].includes(stopReason)) {
|
|
144
|
-
return {
|
|
145
|
-
complete: true,
|
|
146
|
-
reason: LLMTask.stopReasons.COMPLETE,
|
|
147
|
-
reasonRaw: stopReason ?? null,
|
|
148
|
-
};
|
|
149
|
-
}
|
|
150
|
-
|
|
151
|
-
return {
|
|
152
|
-
complete: false,
|
|
153
|
-
reason:
|
|
154
|
-
stopReason === "max_tokens"
|
|
155
|
-
? LLMTask.stopReasons.MAX_TOKENS
|
|
156
|
-
: LLMTask.stopReasons.UNKNOWN,
|
|
157
|
-
reasonRaw: stopReason,
|
|
158
|
-
};
|
|
159
|
-
},
|
|
160
|
-
};
|
|
161
|
-
}
|
|
162
|
-
}
|
|
163
|
-
|
|
164
|
-
LLMTask.register(Claude);
|
|
@@ -1,54 +0,0 @@
|
|
|
1
|
-
# @qualve/googleai
|
|
2
|
-
|
|
3
|
-
[Google Gemini](https://ai.google.dev/) 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/googleai
|
|
11
|
-
```
|
|
12
|
-
|
|
13
|
-
Set the `GEMINI_API_KEY` environment variable (or add it to `.env`).
|
|
14
|
-
Get a key at https://aistudio.google.com/api-keys.
|
|
15
|
-
|
|
16
|
-
## Usage
|
|
17
|
-
|
|
18
|
-
```js
|
|
19
|
-
import "@qualve/googleai";
|
|
20
|
-
```
|
|
21
|
-
|
|
22
|
-
Importing the package registers the `gemini` provider with the Qualve task system.
|
|
23
|
-
|
|
24
|
-
Then use `llm: "gemini"` in your task definitions:
|
|
25
|
-
|
|
26
|
-
```js
|
|
27
|
-
export default {
|
|
28
|
-
type: "llm",
|
|
29
|
-
llm: "gemini",
|
|
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
|
-
| `gemini-3.1-pro-preview` (default) | 1,048,576 | 65,536 |
|
|
42
|
-
| `gemini-3.1-flash-preview` | 1,048,576 | 65,536 |
|
|
43
|
-
| `gemini-3.1-flash-lite-preview` | 1,048,576 | 65,536 |
|
|
44
|
-
|
|
45
|
-
## Capabilities
|
|
46
|
-
|
|
47
|
-
| Capability | Supported |
|
|
48
|
-
| --- | --- |
|
|
49
|
-
| Structured output (JSON schema) | Yes |
|
|
50
|
-
| Thinking levels | Yes (`minimal`, `low`, `medium`, `high`\*) |
|
|
51
|
-
| Web search | Yes (pro models only) |
|
|
52
|
-
| Token counting | Yes |
|
|
53
|
-
|
|
54
|
-
\* Default
|
|
@@ -1,29 +0,0 @@
|
|
|
1
|
-
{
|
|
2
|
-
"name": "@qualve/googleai",
|
|
3
|
-
"version": "0.0.1",
|
|
4
|
-
"description": "Google Gemini 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/googleai"
|
|
17
|
-
},
|
|
18
|
-
"contributors": [
|
|
19
|
-
"Dmitry Sharabin",
|
|
20
|
-
"Lea Verou"
|
|
21
|
-
],
|
|
22
|
-
"peerDependencies": {
|
|
23
|
-
"qualve": "*"
|
|
24
|
-
},
|
|
25
|
-
"dependencies": {
|
|
26
|
-
"@qualve/llm": "*",
|
|
27
|
-
"@google/genai": "^1.45.0"
|
|
28
|
-
}
|
|
29
|
-
}
|
|
@@ -1,210 +0,0 @@
|
|
|
1
|
-
import { createHash } from "node:crypto";
|
|
2
|
-
import { LLMTask } from "@qualve/llm";
|
|
3
|
-
import { createUserContent, createPartFromUri, GoogleGenAI } from "@google/genai";
|
|
4
|
-
|
|
5
|
-
export default class Gemini extends LLMTask {
|
|
6
|
-
static id = "gemini";
|
|
7
|
-
static name = "Gemini";
|
|
8
|
-
static models = [
|
|
9
|
-
"gemini-3.1-pro-preview",
|
|
10
|
-
"gemini-3.1-flash-preview",
|
|
11
|
-
"gemini-3.1-flash-lite-preview",
|
|
12
|
-
];
|
|
13
|
-
static levelMap = { none: "minimal", xhigh: "high" };
|
|
14
|
-
static capabilities = {
|
|
15
|
-
outputSchema: true,
|
|
16
|
-
thinkingLevel: true,
|
|
17
|
-
};
|
|
18
|
-
|
|
19
|
-
get capabilities () {
|
|
20
|
-
return {
|
|
21
|
-
...super.capabilities,
|
|
22
|
-
webSearch: /-pro(?:-|$)/.test(this.model),
|
|
23
|
-
};
|
|
24
|
-
}
|
|
25
|
-
|
|
26
|
-
client = new GoogleGenAI({
|
|
27
|
-
apiKey: process.env.GEMINI_API_KEY,
|
|
28
|
-
httpOptions: { timeout: 30 * 60_000 }, // 30 minutes — LLM tasks with thinking can be very slow
|
|
29
|
-
});
|
|
30
|
-
|
|
31
|
-
getFileInfo (filepath) {
|
|
32
|
-
let { name, dirName } = super.getFileInfo(filepath);
|
|
33
|
-
let displayName = name;
|
|
34
|
-
|
|
35
|
-
// Gemini file ID: lowercase alphanumeric or dashes, no leading/trailing dashes.
|
|
36
|
-
name = name.replace(/[_.]/g, "-").replace(/^-|-$/g, "");
|
|
37
|
-
|
|
38
|
-
// Gemini file IDs are limited to 40 chars.
|
|
39
|
-
// Batch slice inputs can exceed this (e.g. "ba-answers-normalized-unique-500-999-json").
|
|
40
|
-
// Truncate with a hash suffix to preserve uniqueness.
|
|
41
|
-
let maxLength = 40;
|
|
42
|
-
if (name.length > maxLength) {
|
|
43
|
-
let hash = createHash("sha256").update(name).digest("hex").slice(0, 6);
|
|
44
|
-
name = name.slice(0, maxLength - 7) + "-" + hash;
|
|
45
|
-
}
|
|
46
|
-
|
|
47
|
-
return { name, dirName, displayName };
|
|
48
|
-
}
|
|
49
|
-
|
|
50
|
-
async uploadFile (filepath, { mimeType, contents }) {
|
|
51
|
-
let { name, displayName } = this.getFileInfo(filepath);
|
|
52
|
-
return this.client.files.upload({
|
|
53
|
-
file: new Blob([contents], { type: mimeType }),
|
|
54
|
-
config: { name, displayName, mimeType },
|
|
55
|
-
});
|
|
56
|
-
}
|
|
57
|
-
|
|
58
|
-
/**
|
|
59
|
-
* Execute a file operation with shared error handling for not-found cases.
|
|
60
|
-
* Gemini returns 403 (not 404) when a file doesn't exist, so we disambiguate
|
|
61
|
-
* by listing files to check whether it's a real permission error.
|
|
62
|
-
* @param {string} filepath - The local file path (used for name resolution and error messages).
|
|
63
|
-
* @param {"get" | "delete"} method - The method name on `this.client.files` to call.
|
|
64
|
-
* @returns {Promise<object|null>} The operation result, or null if the file was not found.
|
|
65
|
-
*/
|
|
66
|
-
async #safeFileOp (filepath, method) {
|
|
67
|
-
let { name } = this.getFileInfo(filepath);
|
|
68
|
-
name = "files/" + name;
|
|
69
|
-
|
|
70
|
-
try {
|
|
71
|
-
// If we don't await here, the error is unhandled
|
|
72
|
-
return await this.client.files[method]({ name });
|
|
73
|
-
}
|
|
74
|
-
catch (e) {
|
|
75
|
-
if (e.status === 403 || e.status === 404) {
|
|
76
|
-
// 403 can mean "not found" on Gemini — verify by listing files.
|
|
77
|
-
// 404 is a straightforward not-found.
|
|
78
|
-
if (e.status === 403) {
|
|
79
|
-
let files = await this.client.files.list();
|
|
80
|
-
for await (let file of files) {
|
|
81
|
-
if (file.name === name) {
|
|
82
|
-
throw new Error(
|
|
83
|
-
`You don't have permission to access file ${filepath}`,
|
|
84
|
-
{
|
|
85
|
-
cause: e,
|
|
86
|
-
},
|
|
87
|
-
);
|
|
88
|
-
}
|
|
89
|
-
}
|
|
90
|
-
}
|
|
91
|
-
}
|
|
92
|
-
else {
|
|
93
|
-
throw new Error(`Failed to ${method} file ${filepath}`, { cause: e });
|
|
94
|
-
}
|
|
95
|
-
}
|
|
96
|
-
|
|
97
|
-
// Not found
|
|
98
|
-
return null;
|
|
99
|
-
}
|
|
100
|
-
|
|
101
|
-
async getFile (filepath) {
|
|
102
|
-
return this.#safeFileOp(filepath, "get");
|
|
103
|
-
}
|
|
104
|
-
|
|
105
|
-
async deleteFile (filepath) {
|
|
106
|
-
return this.#safeFileOp(filepath, "delete");
|
|
107
|
-
}
|
|
108
|
-
|
|
109
|
-
async listFiles () {
|
|
110
|
-
return [...(await this.client.files.list())];
|
|
111
|
-
}
|
|
112
|
-
|
|
113
|
-
async countTokens () {
|
|
114
|
-
let { system, prompt, input = [] } = this;
|
|
115
|
-
const result = await this.client.models.countTokens({
|
|
116
|
-
model: this.model,
|
|
117
|
-
contents: createUserContent([
|
|
118
|
-
// FIXME: Correctly pass system instructions via `config.systemInstruction` instead of including them in contents once countTokens supports it.
|
|
119
|
-
...system,
|
|
120
|
-
...prompt,
|
|
121
|
-
...input
|
|
122
|
-
.map(f => this.readFile(f.filePath, { contents: f.contents })?.contents)
|
|
123
|
-
.filter(Boolean),
|
|
124
|
-
]),
|
|
125
|
-
});
|
|
126
|
-
|
|
127
|
-
return result.totalTokens;
|
|
128
|
-
}
|
|
129
|
-
|
|
130
|
-
async createStream () {
|
|
131
|
-
let { system, prompt, output, input = [] } = this;
|
|
132
|
-
let responseSchema;
|
|
133
|
-
if (output?.schema) {
|
|
134
|
-
responseSchema = {
|
|
135
|
-
responseMimeType: "application/json",
|
|
136
|
-
responseJsonSchema: output?.schema.schema,
|
|
137
|
-
};
|
|
138
|
-
}
|
|
139
|
-
|
|
140
|
-
const stream = await this.client.models.generateContentStream({
|
|
141
|
-
model: this.model,
|
|
142
|
-
contents: createUserContent([
|
|
143
|
-
...prompt,
|
|
144
|
-
...input.map(f => createPartFromUri(f.remoteFile.uri, f.remoteFile.mimeType)),
|
|
145
|
-
]),
|
|
146
|
-
config: {
|
|
147
|
-
systemInstruction: system?.join("\n"),
|
|
148
|
-
tools: this.capabilities.webSearch ? [{ googleSearch: {} }] : undefined,
|
|
149
|
-
...responseSchema,
|
|
150
|
-
thinkingConfig: {
|
|
151
|
-
thinkingLevel: this.thinking ?? "high",
|
|
152
|
-
},
|
|
153
|
-
},
|
|
154
|
-
});
|
|
155
|
-
|
|
156
|
-
let finishReason;
|
|
157
|
-
return {
|
|
158
|
-
stream,
|
|
159
|
-
transformChunk: chunk => {
|
|
160
|
-
// Filter out thought-parts so thinking text is never written to the output
|
|
161
|
-
let part = chunk.candidates?.[0]?.content?.parts?.find(p => !p.thought);
|
|
162
|
-
return part?.text ?? "";
|
|
163
|
-
},
|
|
164
|
-
onChunk: chunk => {
|
|
165
|
-
// See https://googleapis.github.io/js-genai/release_docs/enums/types.FinishReason.html
|
|
166
|
-
finishReason = chunk.candidates?.[0]?.finishReason ?? finishReason;
|
|
167
|
-
},
|
|
168
|
-
onFinish: () => {
|
|
169
|
-
if (!finishReason) {
|
|
170
|
-
// No finishReason means no evidence of failure — treat as complete.
|
|
171
|
-
return {
|
|
172
|
-
complete: true,
|
|
173
|
-
reason: LLMTask.stopReasons.COMPLETE,
|
|
174
|
-
reasonRaw: null,
|
|
175
|
-
};
|
|
176
|
-
}
|
|
177
|
-
|
|
178
|
-
// Gemini finish reasons → normalized stop reasons.
|
|
179
|
-
// See https://googleapis.github.io/js-genai/release_docs/enums/types.FinishReason.html
|
|
180
|
-
// STOP: Natural stop or configured stop sequence reached.
|
|
181
|
-
// MAX_TOKENS: Configured maximum output tokens reached.
|
|
182
|
-
// SAFETY: Content potentially contains safety violations.
|
|
183
|
-
// RECITATION: Content potentially recites training data.
|
|
184
|
-
// LANGUAGE: Unsupported language detected.
|
|
185
|
-
// BLOCKLIST: Content contains forbidden terms.
|
|
186
|
-
// PROHIBITED_CONTENT: Content potentially contains prohibited material.
|
|
187
|
-
// SPII: Content potentially contains Sensitive Personally Identifiable Information.
|
|
188
|
-
let reasons = {
|
|
189
|
-
STOP: LLMTask.stopReasons.COMPLETE,
|
|
190
|
-
MAX_TOKENS: LLMTask.stopReasons.MAX_TOKENS,
|
|
191
|
-
SAFETY: LLMTask.stopReasons.ABORTED,
|
|
192
|
-
RECITATION: LLMTask.stopReasons.ABORTED,
|
|
193
|
-
LANGUAGE: LLMTask.stopReasons.ABORTED,
|
|
194
|
-
BLOCKLIST: LLMTask.stopReasons.ABORTED,
|
|
195
|
-
PROHIBITED_CONTENT: LLMTask.stopReasons.ABORTED,
|
|
196
|
-
SPII: LLMTask.stopReasons.ABORTED,
|
|
197
|
-
};
|
|
198
|
-
|
|
199
|
-
let normalized = reasons[finishReason] ?? LLMTask.stopReasons.UNKNOWN;
|
|
200
|
-
return {
|
|
201
|
-
complete: normalized === LLMTask.stopReasons.COMPLETE,
|
|
202
|
-
reason: normalized,
|
|
203
|
-
reasonRaw: finishReason,
|
|
204
|
-
};
|
|
205
|
-
},
|
|
206
|
-
};
|
|
207
|
-
}
|
|
208
|
-
}
|
|
209
|
-
|
|
210
|
-
LLMTask.register(Gemini);
|
|
@@ -1,53 +0,0 @@
|
|
|
1
|
-
# @qualve/openai
|
|
2
|
-
|
|
3
|
-
[OpenAI](https://openai.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/openai
|
|
11
|
-
```
|
|
12
|
-
|
|
13
|
-
Set the `OPENAI_API_KEY` environment variable (or add it to `.env`).
|
|
14
|
-
Get a key at https://platform.openai.com/api-keys.
|
|
15
|
-
|
|
16
|
-
## Usage
|
|
17
|
-
|
|
18
|
-
```js
|
|
19
|
-
import "@qualve/openai";
|
|
20
|
-
```
|
|
21
|
-
|
|
22
|
-
Importing the package registers the `openai` provider with the Qualve task system.
|
|
23
|
-
|
|
24
|
-
Then use `llm: "openai"` in your task definitions:
|
|
25
|
-
|
|
26
|
-
```js
|
|
27
|
-
export default {
|
|
28
|
-
type: "llm",
|
|
29
|
-
llm: "openai",
|
|
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
|
-
| `gpt-5.4` (default) | 1,050,000 | 128K |
|
|
42
|
-
| `gpt-5-mini` | 400K | 128K |
|
|
43
|
-
| `gpt-5-nano` | 400K | 128K |
|
|
44
|
-
|
|
45
|
-
## Capabilities
|
|
46
|
-
|
|
47
|
-
| Capability | Supported |
|
|
48
|
-
| --- | --- |
|
|
49
|
-
| Structured output (JSON schema) | Yes |
|
|
50
|
-
| Thinking levels | Yes (`none`, `minimal`, `low`, `medium`\*, `high`, `xhigh`) |
|
|
51
|
-
| Token counting | No |
|
|
52
|
-
|
|
53
|
-
\* Default
|