@intx/inference-discovery-google-genai 0.1.2 → 0.3.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.
@@ -0,0 +1,337 @@
1
+ import { readFileSync } from "node:fs";
2
+ import { CapabilityNotBuildableError, resolveMediaPath, } from "@intx/inference-discovery/catalog";
3
+ // Text models share the full multimodal text capability surface; only
4
+ // thinking-budget handling differs for models that cannot disable thinking
5
+ // (see minimalThinkingBudget). Image models are output-only.
6
+ export const TEXT_MODELS = new Set([
7
+ "gemini-2.5-flash",
8
+ "gemini-2.5-pro",
9
+ "gemini-3.6-flash",
10
+ "gemini-3.5-flash",
11
+ "gemini-3-flash-preview",
12
+ "gemini-3.1-pro-preview",
13
+ ]);
14
+ export const IMAGE_MODELS = new Set([
15
+ "gemini-2.5-flash-image",
16
+ "gemini-3.1-flash-image",
17
+ ]);
18
+ const TEXT_MODEL_CAPABILITIES = new Set([
19
+ "plain-text",
20
+ "plain-text-streaming",
21
+ "function-calling-multi-turn",
22
+ "function-calling-multi-turn-streaming",
23
+ "function-calling-with-thinking",
24
+ "function-calling-with-thinking-streaming",
25
+ "vision-input",
26
+ "vision-input-streaming",
27
+ "audio-input",
28
+ "audio-input-streaming",
29
+ "video-input",
30
+ "video-input-streaming",
31
+ "document-input",
32
+ "document-input-streaming",
33
+ "code-execution",
34
+ "code-execution-streaming",
35
+ "grounding",
36
+ "grounding-streaming",
37
+ "files-api-reference",
38
+ "files-api-reference-streaming",
39
+ "safety-classification",
40
+ "safety-classification-streaming",
41
+ "structured-output",
42
+ "structured-output-streaming",
43
+ ]);
44
+ const IMAGE_MODEL_CAPABILITIES = new Set([
45
+ "image-output",
46
+ "image-output-streaming",
47
+ ]);
48
+ const EXTENSION_TO_MIME_TYPE = {
49
+ jpg: "image/jpeg",
50
+ jpeg: "image/jpeg",
51
+ png: "image/png",
52
+ gif: "image/gif",
53
+ webp: "image/webp",
54
+ wav: "audio/wav",
55
+ mp3: "audio/mpeg",
56
+ ogg: "audio/ogg",
57
+ flac: "audio/flac",
58
+ mp4: "video/mp4",
59
+ mov: "video/quicktime",
60
+ webm: "video/webm",
61
+ pdf: "application/pdf",
62
+ };
63
+ function classifyModel(model, override) {
64
+ if (TEXT_MODELS.has(model))
65
+ return "text";
66
+ if (IMAGE_MODELS.has(model))
67
+ return "image";
68
+ return override ?? "text";
69
+ }
70
+ // True when the model's class is known from set membership, so its request
71
+ // shape is not a guess. A caller (the probe) uses this to tell an operator when
72
+ // it is defaulting an unknown model to the text class rather than classifying a
73
+ // known one — the default is deliberate, but it should never be silent.
74
+ export function isKnownModel(model) {
75
+ return TEXT_MODELS.has(model) || IMAGE_MODELS.has(model);
76
+ }
77
+ function assertCapabilityBuildable(model, capability, modelClass) {
78
+ const resolved = classifyModel(model, modelClass);
79
+ const supported = resolved === "image" ? IMAGE_MODEL_CAPABILITIES : TEXT_MODEL_CAPABILITIES;
80
+ if (!supported.has(capability)) {
81
+ throw new CapabilityNotBuildableError(capability, `google-genai: ${resolved}-class model ${model} does not support capability ${capability}`);
82
+ }
83
+ }
84
+ function extensionFor(path) {
85
+ const dot = path.lastIndexOf(".");
86
+ if (dot < 0 || dot === path.length - 1) {
87
+ throw new Error(`google-genai: cannot infer media MIME type, no extension in path: ${path}`);
88
+ }
89
+ return path.slice(dot + 1).toLowerCase();
90
+ }
91
+ function mimeTypeFor(ref) {
92
+ const ext = extensionFor(ref.path);
93
+ const mime = EXTENSION_TO_MIME_TYPE[ext];
94
+ if (mime === undefined) {
95
+ throw new Error(`google-genai: no MIME type mapping for extension .${ext} (path ${ref.path})`);
96
+ }
97
+ return mime;
98
+ }
99
+ function readMediaBase64(ref) {
100
+ const absolute = resolveMediaPath(ref);
101
+ const bytes = readFileSync(absolute);
102
+ return bytes.toString("base64");
103
+ }
104
+ function expectSingleMedia(intent) {
105
+ if (intent.media === undefined || intent.media.length === 0) {
106
+ throw new Error("google-genai: media-input capability requires intent.media to be non-empty");
107
+ }
108
+ if (intent.media.length !== 1) {
109
+ throw new Error(`google-genai: media-input capability expects exactly one media reference, got ${String(intent.media.length)}`);
110
+ }
111
+ const [media] = intent.media;
112
+ if (media === undefined) {
113
+ throw new Error("google-genai: media-input capability: media[0] is unexpectedly undefined");
114
+ }
115
+ return media;
116
+ }
117
+ function expectSingleTool(intent) {
118
+ if (intent.tools === undefined || intent.tools.length === 0) {
119
+ throw new Error("google-genai: function-calling capability requires intent.tools to be non-empty");
120
+ }
121
+ if (intent.tools.length !== 1) {
122
+ throw new Error(`google-genai: function-calling capability expects exactly one tool declaration, got ${String(intent.tools.length)}`);
123
+ }
124
+ const [tool] = intent.tools;
125
+ if (tool === undefined) {
126
+ throw new Error("google-genai: function-calling capability: tools[0] is unexpectedly undefined");
127
+ }
128
+ return tool;
129
+ }
130
+ function userTextContent(prompt) {
131
+ return {
132
+ role: "user",
133
+ parts: [{ text: prompt }],
134
+ };
135
+ }
136
+ // Gemini's "dynamic" thinking budget sentinel: the model decides how much to
137
+ // think and no cap is imposed.
138
+ const DYNAMIC_THINKING_BUDGET = -1;
139
+ // gemini-2.5-pro rejects thinkingConfig.thinkingBudget: 0 with HTTP 400
140
+ // "Budget 0 is invalid. This model only works in thinking mode." The budget can
141
+ // only be chosen by model identity, because the API surfaces the constraint
142
+ // solely as a runtime 400 with no build-time signal. Add a model here when its
143
+ // API rejects a zero thinking budget.
144
+ const THINKING_MANDATORY_MODELS = new Set([
145
+ "gemini-2.5-pro",
146
+ "gemini-3.6-flash",
147
+ "gemini-3.1-pro-preview",
148
+ ]);
149
+ // The thinking budget to request when a probe wants thinking suppressed: 0
150
+ // (fully off) for models that allow it, or the dynamic budget for models that
151
+ // cannot be set to 0. includeThoughts stays false in both cases, so no thought
152
+ // parts are returned either way.
153
+ function minimalThinkingBudget(model) {
154
+ return THINKING_MANDATORY_MODELS.has(model) ? DYNAMIC_THINKING_BUDGET : 0;
155
+ }
156
+ function plainTextBody(intent) {
157
+ return {
158
+ contents: [userTextContent(intent.prompt)],
159
+ };
160
+ }
161
+ function plainTextStreamingBody(intent, model) {
162
+ return {
163
+ contents: [userTextContent(intent.prompt)],
164
+ generationConfig: {
165
+ maxOutputTokens: 400,
166
+ thinkingConfig: {
167
+ thinkingBudget: minimalThinkingBudget(model),
168
+ },
169
+ },
170
+ };
171
+ }
172
+ function functionToolFromDecl(decl) {
173
+ return {
174
+ functionDeclarations: [
175
+ {
176
+ name: decl.name,
177
+ description: decl.description,
178
+ parameters: decl.parameters,
179
+ },
180
+ ],
181
+ };
182
+ }
183
+ function functionCallingBody(intent, thinking) {
184
+ const decl = expectSingleTool(intent);
185
+ const thinkingConfig = thinking.includeThoughts
186
+ ? { thinkingBudget: thinking.budget, includeThoughts: true }
187
+ : { thinkingBudget: thinking.budget };
188
+ return {
189
+ contents: [userTextContent(intent.prompt)],
190
+ tools: [functionToolFromDecl(decl)],
191
+ toolConfig: {
192
+ functionCallingConfig: {
193
+ mode: "ANY",
194
+ allowedFunctionNames: [decl.name],
195
+ },
196
+ },
197
+ generationConfig: {
198
+ thinkingConfig,
199
+ },
200
+ };
201
+ }
202
+ function inlineMediaBody(intent) {
203
+ const media = expectSingleMedia(intent);
204
+ return {
205
+ contents: [
206
+ {
207
+ role: "user",
208
+ parts: [
209
+ { text: intent.prompt },
210
+ {
211
+ inlineData: {
212
+ mimeType: mimeTypeFor(media),
213
+ data: readMediaBase64(media),
214
+ },
215
+ },
216
+ ],
217
+ },
218
+ ],
219
+ };
220
+ }
221
+ function imageOutputBody(intent) {
222
+ return {
223
+ contents: [userTextContent(intent.prompt)],
224
+ generationConfig: {
225
+ responseModalities: ["TEXT", "IMAGE"],
226
+ },
227
+ };
228
+ }
229
+ function codeExecutionBody(intent) {
230
+ return {
231
+ contents: [userTextContent(intent.prompt)],
232
+ tools: [{ codeExecution: {} }],
233
+ };
234
+ }
235
+ function groundingBody(intent) {
236
+ return {
237
+ contents: [userTextContent(intent.prompt)],
238
+ tools: [{ googleSearch: {} }],
239
+ };
240
+ }
241
+ // Shape-identical between streaming and non-streaming variants — only the
242
+ // endpoint differs (handled by buildEndpointURL). The streaming variant
243
+ // deliberately does NOT clamp `thinkingBudget: 0` the way plain-text
244
+ // streaming does: the safety classifier's engagement may depend on
245
+ // whether the model goes through a thinking phase, and the probe's job
246
+ // is to observe natural classifier behavior at default generation
247
+ // settings, not constrained ones.
248
+ function safetyClassificationBody(intent) {
249
+ return {
250
+ contents: [userTextContent(intent.prompt)],
251
+ };
252
+ }
253
+ function structuredOutputBody(intent) {
254
+ const format = intent.responseFormat;
255
+ if (format === undefined) {
256
+ throw new Error("google-genai: structured-output intent has no responseFormat");
257
+ }
258
+ const generationConfig = {};
259
+ switch (format.kind) {
260
+ case "text":
261
+ // Free-form text is Gemini's default; emit no responseMimeType.
262
+ break;
263
+ case "json":
264
+ generationConfig.responseMimeType = "application/json";
265
+ break;
266
+ case "json-schema":
267
+ generationConfig.responseMimeType = "application/json";
268
+ generationConfig.responseSchema = format.schema;
269
+ break;
270
+ }
271
+ const body = {
272
+ contents: [userTextContent(intent.prompt)],
273
+ };
274
+ if (Object.keys(generationConfig).length > 0) {
275
+ body.generationConfig = generationConfig;
276
+ }
277
+ return body;
278
+ }
279
+ export function buildRequestBody(opts) {
280
+ assertCapabilityBuildable(opts.model, opts.capability, opts.modelClass);
281
+ switch (opts.capability) {
282
+ case "plain-text":
283
+ return plainTextBody(opts.intent);
284
+ case "plain-text-streaming":
285
+ return plainTextStreamingBody(opts.intent, opts.model);
286
+ case "function-calling-multi-turn":
287
+ case "function-calling-multi-turn-streaming":
288
+ return functionCallingBody(opts.intent, {
289
+ budget: minimalThinkingBudget(opts.model),
290
+ includeThoughts: false,
291
+ });
292
+ case "function-calling-with-thinking":
293
+ case "function-calling-with-thinking-streaming":
294
+ return functionCallingBody(opts.intent, {
295
+ budget: 1024,
296
+ includeThoughts: true,
297
+ });
298
+ case "vision-input":
299
+ case "vision-input-streaming":
300
+ case "audio-input":
301
+ case "audio-input-streaming":
302
+ case "video-input":
303
+ case "video-input-streaming":
304
+ case "document-input":
305
+ case "document-input-streaming":
306
+ return inlineMediaBody(opts.intent);
307
+ case "image-output":
308
+ case "image-output-streaming":
309
+ return imageOutputBody(opts.intent);
310
+ case "code-execution":
311
+ case "code-execution-streaming":
312
+ return codeExecutionBody(opts.intent);
313
+ case "grounding":
314
+ case "grounding-streaming":
315
+ return groundingBody(opts.intent);
316
+ case "safety-classification":
317
+ case "safety-classification-streaming":
318
+ return safetyClassificationBody(opts.intent);
319
+ case "structured-output":
320
+ case "structured-output-streaming":
321
+ return structuredOutputBody(opts.intent);
322
+ case "files-api-reference":
323
+ case "files-api-reference-streaming":
324
+ throw new Error(`google-genai: capability ${opts.capability} is multi-step; use iterateCaptureSteps from the plug-in, not buildRequestBody.`);
325
+ case "function-calling":
326
+ case "reasoning-content":
327
+ case "reasoning-content-streaming":
328
+ case "redacted-thinking":
329
+ case "redacted-thinking-streaming":
330
+ case "structured-output-refusal-streaming":
331
+ throw new CapabilityNotBuildableError(opts.capability, `google-genai: capability ${opts.capability} is not supported by any google-genai model`);
332
+ default: {
333
+ const exhaustive = opts.capability;
334
+ throw new Error(`google-genai: unhandled capability ${String(exhaustive)}`);
335
+ }
336
+ }
337
+ }
package/dist/sse.d.ts ADDED
@@ -0,0 +1 @@
1
+ export declare function reconstructResponseFromSSE(bytes: Uint8Array): unknown;
package/dist/sse.js ADDED
@@ -0,0 +1,109 @@
1
+ // Gemini streams generateContent as Server-Sent Events: each `data: {json}`
2
+ // line is a full GenerateContentResponse chunk whose candidates[0].content.parts
3
+ // carry incremental deltas. To build a turn-2 multi-turn body for a streaming
4
+ // capability, the assistant content has to be reconstructed from those chunks.
5
+ //
6
+ // Reconstruction flattens every chunk's parts in order and coalesces
7
+ // consecutive text deltas that share the same shape — plain text (`{text}`)
8
+ // with plain text, and thought text (`{text, thought: true}`) with thought
9
+ // text — but never across shapes. Every non-text part (a functionCall, or any
10
+ // part carrying a thoughtSignature) is emitted as-is, in order, so the
11
+ // signature the API requires on an echoed thinking turn survives.
12
+ function isRecord(value) {
13
+ return typeof value === "object" && value !== null && !Array.isArray(value);
14
+ }
15
+ // The coalescing signature of a streamed text delta, or null when the part is
16
+ // not a plain text delta (a functionCall, a thoughtSignature-bearing part, …)
17
+ // and must therefore stand as its own part.
18
+ function textDeltaSignature(part) {
19
+ if (typeof part.text !== "string")
20
+ return null;
21
+ const keys = Object.keys(part);
22
+ if (keys.length === 1)
23
+ return "text";
24
+ if (keys.length === 2 && part.thought === true)
25
+ return "thought-text";
26
+ // A text delta carrying any further key (notably a thoughtSignature) is not
27
+ // coalescible: it stays its own part so the signature's exact placement in
28
+ // the thought stream survives verbatim into the echoed turn-2 content.
29
+ return null;
30
+ }
31
+ function sseDataPayloads(bytes) {
32
+ const text = new TextDecoder().decode(bytes);
33
+ const payloads = [];
34
+ for (const line of text.split(/\r?\n/)) {
35
+ if (!line.startsWith("data:"))
36
+ continue;
37
+ const payload = line.slice("data:".length).trim();
38
+ if (payload.length > 0 && payload !== "[DONE]")
39
+ payloads.push(payload);
40
+ }
41
+ return payloads;
42
+ }
43
+ // Reconstructs the shape a non-streaming turn-1 response would have —
44
+ // `{ candidates: [{ content: { role, parts } }] }` — from a Gemini SSE stream,
45
+ // so the multi-turn turn-2 builder consumes it unchanged.
46
+ export function reconstructResponseFromSSE(bytes) {
47
+ const payloads = sseDataPayloads(bytes);
48
+ if (payloads.length === 0) {
49
+ throw new Error("google-genai SSE: stream carried no data payloads to reconstruct");
50
+ }
51
+ let role;
52
+ const parts = [];
53
+ let lastSignature = null;
54
+ for (const payload of payloads) {
55
+ const chunk = JSON.parse(payload);
56
+ if (!isRecord(chunk)) {
57
+ throw new Error("google-genai SSE: chunk is not a JSON object");
58
+ }
59
+ const candidates = chunk.candidates;
60
+ // Some trailing chunks carry only usageMetadata and no candidates.
61
+ if (candidates === undefined)
62
+ continue;
63
+ if (!Array.isArray(candidates) || candidates.length === 0) {
64
+ throw new Error("google-genai SSE: chunk.candidates is not a non-empty array");
65
+ }
66
+ const first = candidates[0];
67
+ if (!isRecord(first)) {
68
+ throw new Error("google-genai SSE: candidates[0] is not an object");
69
+ }
70
+ const content = first.content;
71
+ // A finishReason-only chunk closes the candidate without new content.
72
+ if (content === undefined)
73
+ continue;
74
+ if (!isRecord(content)) {
75
+ throw new Error("google-genai SSE: candidates[0].content is not an object");
76
+ }
77
+ if (typeof content.role === "string")
78
+ role = content.role;
79
+ const chunkParts = content.parts;
80
+ if (chunkParts === undefined)
81
+ continue;
82
+ if (!Array.isArray(chunkParts)) {
83
+ throw new Error("google-genai SSE: candidates[0].content.parts is not an array");
84
+ }
85
+ for (const part of chunkParts) {
86
+ if (!isRecord(part)) {
87
+ throw new Error("google-genai SSE: a content part is not an object");
88
+ }
89
+ const signature = textDeltaSignature(part);
90
+ const previous = parts[parts.length - 1];
91
+ if (signature !== null &&
92
+ signature === lastSignature &&
93
+ previous !== undefined) {
94
+ previous.text = `${String(previous.text)}${String(part.text)}`;
95
+ }
96
+ else {
97
+ parts.push({ ...part });
98
+ lastSignature = signature;
99
+ }
100
+ }
101
+ }
102
+ // The assistant role must come off the wire; defaulting it would fabricate a
103
+ // turn-1 shape the model never sent and mask a provider change the probe
104
+ // exists to catch. Gemini emits role on the first content-bearing chunk.
105
+ if (role === undefined) {
106
+ throw new Error("google-genai SSE: no candidate content carried a role; cannot reconstruct the assistant turn");
107
+ }
108
+ return { candidates: [{ content: { role, parts } }] };
109
+ }
package/package.json CHANGED
@@ -1,16 +1,27 @@
1
1
  {
2
2
  "name": "@intx/inference-discovery-google-genai",
3
- "version": "0.1.2",
3
+ "description": "Google GenAI provider plug-in for the inference discovery rig",
4
+ "version": "0.3.0",
4
5
  "license": "LGPL-2.1-only",
5
6
  "type": "module",
6
7
  "exports": {
7
8
  ".": {
8
- "types": "./src/index.ts",
9
- "default": "./src/index.ts"
9
+ "intx-src": "./src/index.ts",
10
+ "types": "./dist/index.d.ts",
11
+ "default": "./dist/index.js"
10
12
  }
11
13
  },
12
14
  "dependencies": {
13
- "@intx/inference-discovery": "0.0.0",
15
+ "@intx/inference-discovery": "0.3.0",
14
16
  "arktype": "^2.1.29"
17
+ },
18
+ "files": [
19
+ "dist",
20
+ "README.md",
21
+ "LICENSE"
22
+ ],
23
+ "sideEffects": false,
24
+ "publishConfig": {
25
+ "access": "public"
15
26
  }
16
27
  }
package/src/auth.ts DELETED
@@ -1,8 +0,0 @@
1
- export const AUTH_HEADER = "x-goog-api-key";
2
-
3
- export function buildAuthHeaders(apiKey: string): Record<string, string> {
4
- if (apiKey.length === 0) {
5
- throw new Error("google-genai: apiKey must be a non-empty string");
6
- }
7
- return { [AUTH_HEADER]: apiKey };
8
- }
package/src/endpoint.ts DELETED
@@ -1,20 +0,0 @@
1
- import type { Capability } from "@intx/inference-discovery/catalog";
2
-
3
- export const GEMINI_BASE = "https://generativelanguage.googleapis.com/v1beta";
4
-
5
- export function isStreamingCapability(capability: Capability): boolean {
6
- return capability.endsWith("-streaming");
7
- }
8
-
9
- export function buildEndpointURL(opts: {
10
- model: string;
11
- capability: Capability;
12
- }): string {
13
- if (opts.model.length === 0) {
14
- throw new Error("google-genai: model must be a non-empty string");
15
- }
16
- if (isStreamingCapability(opts.capability)) {
17
- return `${GEMINI_BASE}/models/${opts.model}:streamGenerateContent?alt=sse`;
18
- }
19
- return `${GEMINI_BASE}/models/${opts.model}:generateContent`;
20
- }