@qualve/googleai 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.
Files changed (3) hide show
  1. package/README.md +54 -0
  2. package/package.json +29 -0
  3. package/src/index.js +210 -0
package/README.md ADDED
@@ -0,0 +1,54 @@
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
package/package.json ADDED
@@ -0,0 +1,29 @@
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
+ }
package/src/index.js ADDED
@@ -0,0 +1,210 @@
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);