@intx/inference-discovery-google-genai 0.1.2

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/README.md ADDED
@@ -0,0 +1,66 @@
1
+ # @intx/inference-discovery-google-genai
2
+
3
+ Google GenAI provider plug-in for the discovery rig. Captures
4
+ Gemini wire responses across the capability matrix and writes them
5
+ into the shared fixture corpus.
6
+
7
+ See [`@intx/inference-discovery`](../inference-discovery/README.md)
8
+ for the runtime, the plug-in contract, and the `discover` CLI.
9
+
10
+ ## Models
11
+
12
+ - `gemini-2.5-flash` — text, vision, audio, video, document,
13
+ function calling (multi-turn and with-thinking), code execution,
14
+ grounding, and the files API. Streaming and non-streaming
15
+ variants of each.
16
+ - `gemini-2.5-flash-image` — image output, streaming and
17
+ non-streaming.
18
+
19
+ The full per-capability list is in `SUPPORT_MATRIX` in
20
+ `@intx/inference-discovery/catalog`.
21
+
22
+ ## Usage
23
+
24
+ ```ts
25
+ import { createGoogleGenaiPlugin } from "@intx/inference-discovery-google-genai";
26
+
27
+ const plugin = createGoogleGenaiPlugin({ apiKey: process.env.GOOGLE_API_KEY });
28
+ // Hand off to runCapture from @intx/inference-discovery.
29
+ ```
30
+
31
+ In practice the `bin/discover.ts` CLI does this wiring for you;
32
+ construct the plug-in directly only when writing tests or one-off
33
+ scripts.
34
+
35
+ ## Environment
36
+
37
+ | Variable | Purpose |
38
+ | ---------------- | ------------------------------------ |
39
+ | `GOOGLE_API_KEY` | Sent as the `x-goog-api-key` header. |
40
+
41
+ The key is redacted in captured fixtures.
42
+
43
+ ## Multi-step capabilities
44
+
45
+ Two capability families drive multi-step exchanges before the
46
+ runner writes the bundle:
47
+
48
+ - **Files API** (`files-api-reference`,
49
+ `files-api-reference-streaming`) — the plug-in first uploads the
50
+ intent's media asset to the `generativelanguage` upload endpoint,
51
+ then uses the returned file URI and MIME type to construct the
52
+ generate-content body for the second step. Each step writes its
53
+ own `upload/` and `generate/` subdirectory under the run root.
54
+ - **Multi-turn function calling**
55
+ (`function-calling-multi-turn`,
56
+ `function-calling-multi-turn-streaming`,
57
+ `function-calling-with-thinking`,
58
+ `function-calling-with-thinking-streaming`) — turn 1 is sent as
59
+ usual; the plug-in extracts the model's assistant content from
60
+ the parsed response, derives a tool follow-up from the intent's
61
+ `followUp` (or synthesises one from the intent's tools), and
62
+ sends a turn-2 body that echoes the assistant turn verbatim and
63
+ appends the tool response. Each turn writes its own `turn-1/` and
64
+ `turn-2/` subdirectory.
65
+
66
+ All other capabilities are single-step.
package/package.json ADDED
@@ -0,0 +1,16 @@
1
+ {
2
+ "name": "@intx/inference-discovery-google-genai",
3
+ "version": "0.1.2",
4
+ "license": "LGPL-2.1-only",
5
+ "type": "module",
6
+ "exports": {
7
+ ".": {
8
+ "types": "./src/index.ts",
9
+ "default": "./src/index.ts"
10
+ }
11
+ },
12
+ "dependencies": {
13
+ "@intx/inference-discovery": "0.0.0",
14
+ "arktype": "^2.1.29"
15
+ }
16
+ }
package/src/auth.ts ADDED
@@ -0,0 +1,8 @@
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
+ }
@@ -0,0 +1,20 @@
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
+ }
package/src/index.ts ADDED
@@ -0,0 +1,327 @@
1
+ import { readFileSync } from "node:fs";
2
+ import {
3
+ resolveMediaPath,
4
+ type Capability,
5
+ type CapabilityIntent,
6
+ type MediaRef,
7
+ } from "@intx/inference-discovery/catalog";
8
+ import type {
9
+ CaptureStep,
10
+ CapturedResponse,
11
+ IterateCaptureStepsOpts,
12
+ ProviderPlugin,
13
+ } from "@intx/inference-discovery";
14
+ import { buildAuthHeaders } from "./auth";
15
+ import { buildEndpointURL } from "./endpoint";
16
+ import { buildRequestBody } from "./request-body";
17
+
18
+ const PROVIDER_NAME = "google-genai";
19
+ const MODELS = ["gemini-2.5-flash", "gemini-2.5-flash-image"] as const;
20
+ const REDACT_REQUEST_HEADERS = ["x-goog-api-key"] as const;
21
+ const REDACT_RESPONSE_HEADERS: readonly string[] = [];
22
+
23
+ const FILES_API_UPLOAD_URL =
24
+ "https://generativelanguage.googleapis.com/upload/v1beta/files";
25
+
26
+ const MULTI_TURN_CAPABILITIES: ReadonlySet<Capability> = new Set<Capability>([
27
+ "function-calling-multi-turn",
28
+ "function-calling-multi-turn-streaming",
29
+ "function-calling-with-thinking",
30
+ "function-calling-with-thinking-streaming",
31
+ ]);
32
+
33
+ const FILES_API_CAPABILITIES: ReadonlySet<Capability> = new Set<Capability>([
34
+ "files-api-reference",
35
+ "files-api-reference-streaming",
36
+ ]);
37
+
38
+ export interface GoogleGenaiPluginOptions {
39
+ apiKey: string;
40
+ }
41
+
42
+ function isRecord(value: unknown): value is Record<string, unknown> {
43
+ return typeof value === "object" && value !== null && !Array.isArray(value);
44
+ }
45
+
46
+ interface UploadStepDescriptor {
47
+ url: string;
48
+ mimeType: string;
49
+ displayName: string;
50
+ bytes: Uint8Array;
51
+ }
52
+
53
+ function buildUploadDescriptor(intent: CapabilityIntent): UploadStepDescriptor {
54
+ const media = intent.media?.[0];
55
+ if (media === undefined) {
56
+ throw new Error(
57
+ "google-genai files-API: intent.media[0] is required; the catalog's " +
58
+ "files-api-reference intent must declare the document to upload.",
59
+ );
60
+ }
61
+ const bytes = readFileSync(resolveMediaPath(media));
62
+ return {
63
+ url: FILES_API_UPLOAD_URL,
64
+ mimeType: mimeTypeForMedia(media),
65
+ displayName: basename(media.path),
66
+ bytes: new Uint8Array(bytes.buffer, bytes.byteOffset, bytes.byteLength),
67
+ };
68
+ }
69
+
70
+ function basename(path: string): string {
71
+ const slash = path.lastIndexOf("/");
72
+ return slash < 0 ? path : path.slice(slash + 1);
73
+ }
74
+
75
+ function mimeTypeForMedia(ref: MediaRef): string {
76
+ if (ref.kind === "document") return "application/pdf";
77
+ if (ref.kind === "image") return "image/jpeg";
78
+ if (ref.kind === "audio") return "audio/wav";
79
+ if (ref.kind === "video") return "video/mp4";
80
+ throw new Error(`google-genai: unsupported media kind ${String(ref.kind)}`);
81
+ }
82
+
83
+ function extractFileUri(parsed: unknown): string {
84
+ if (!isRecord(parsed)) {
85
+ throw new Error(
86
+ "google-genai files-API: upload response is not a JSON object",
87
+ );
88
+ }
89
+ const file = parsed.file;
90
+ if (!isRecord(file)) {
91
+ throw new Error(
92
+ "google-genai files-API: upload response missing 'file' object",
93
+ );
94
+ }
95
+ const uri = file.uri;
96
+ if (typeof uri !== "string" || uri.length === 0) {
97
+ throw new Error(
98
+ "google-genai files-API: upload response has no string 'file.uri'",
99
+ );
100
+ }
101
+ return uri;
102
+ }
103
+
104
+ function extractMimeTypeFromUpload(parsed: unknown): string {
105
+ if (!isRecord(parsed)) {
106
+ throw new Error(
107
+ "google-genai files-API: upload response is not a JSON object",
108
+ );
109
+ }
110
+ const file = parsed.file;
111
+ if (!isRecord(file)) {
112
+ throw new Error(
113
+ "google-genai files-API: upload response missing 'file' object",
114
+ );
115
+ }
116
+ const mime = file.mimeType;
117
+ if (typeof mime !== "string" || mime.length === 0) {
118
+ throw new Error(
119
+ "google-genai files-API: upload response has no string 'file.mimeType'",
120
+ );
121
+ }
122
+ return mime;
123
+ }
124
+
125
+ function buildFilesApiGenerateBody(opts: {
126
+ intent: CapabilityIntent;
127
+ fileUri: string;
128
+ mimeType: string;
129
+ }): unknown {
130
+ return {
131
+ contents: [
132
+ {
133
+ role: "user",
134
+ parts: [
135
+ { text: opts.intent.prompt },
136
+ {
137
+ fileData: {
138
+ mimeType: opts.mimeType,
139
+ fileUri: opts.fileUri,
140
+ },
141
+ },
142
+ ],
143
+ },
144
+ ],
145
+ };
146
+ }
147
+
148
+ function extractAssistantContent(parsed: unknown): unknown {
149
+ if (!isRecord(parsed)) {
150
+ throw new Error(
151
+ "google-genai multi-turn: turn-1 response is not a JSON object",
152
+ );
153
+ }
154
+ const candidates = parsed.candidates;
155
+ if (!Array.isArray(candidates) || candidates.length === 0) {
156
+ throw new Error(
157
+ "google-genai multi-turn: turn-1 response has no candidates array",
158
+ );
159
+ }
160
+ const first = candidates[0];
161
+ if (!isRecord(first)) {
162
+ throw new Error(
163
+ "google-genai multi-turn: turn-1 response candidates[0] is not an object",
164
+ );
165
+ }
166
+ const content = first.content;
167
+ if (!isRecord(content)) {
168
+ throw new Error(
169
+ "google-genai multi-turn: turn-1 response candidates[0].content is not an object",
170
+ );
171
+ }
172
+ return content;
173
+ }
174
+
175
+ function deriveToolFollowUp(intent: CapabilityIntent): {
176
+ toolName: string;
177
+ content: string;
178
+ } {
179
+ const followUp = intent.followUp;
180
+ if (followUp !== undefined) {
181
+ for (const step of followUp) {
182
+ if (step.role === "tool") {
183
+ return { toolName: step.toolName, content: step.content };
184
+ }
185
+ }
186
+ }
187
+ const tools = intent.tools;
188
+ if (tools === undefined || tools.length === 0) {
189
+ throw new Error(
190
+ "google-genai multi-turn: intent has neither followUp nor tools",
191
+ );
192
+ }
193
+ const [tool] = tools;
194
+ if (tool === undefined) {
195
+ throw new Error("google-genai multi-turn: intent.tools[0] is undefined");
196
+ }
197
+ return { toolName: tool.name, content: "{}" };
198
+ }
199
+
200
+ function buildMultiTurnTurn2Body(opts: {
201
+ capability: Capability;
202
+ intent: CapabilityIntent;
203
+ turn1Body: unknown;
204
+ turn1Response: unknown;
205
+ }): unknown {
206
+ if (!isRecord(opts.turn1Body)) {
207
+ throw new Error("google-genai multi-turn: turn-1 body is not an object");
208
+ }
209
+ const turn1Contents = opts.turn1Body.contents;
210
+ if (!Array.isArray(turn1Contents)) {
211
+ throw new Error(
212
+ "google-genai multi-turn: turn-1 body.contents is not an array",
213
+ );
214
+ }
215
+ const assistantContent = extractAssistantContent(opts.turn1Response);
216
+ const tool = deriveToolFollowUp(opts.intent);
217
+ const toolResponseObject: unknown = JSON.parse(tool.content);
218
+
219
+ const turn1Tools = opts.turn1Body.tools;
220
+ const turn1GenerationConfig = opts.turn1Body.generationConfig;
221
+
222
+ const body: Record<string, unknown> = {
223
+ contents: [
224
+ ...turn1Contents,
225
+ assistantContent,
226
+ {
227
+ role: "user",
228
+ parts: [
229
+ {
230
+ functionResponse: {
231
+ name: tool.toolName,
232
+ response: toolResponseObject,
233
+ },
234
+ },
235
+ ],
236
+ },
237
+ ],
238
+ };
239
+ if (turn1Tools !== undefined) {
240
+ body.tools = turn1Tools;
241
+ }
242
+ if (turn1GenerationConfig !== undefined) {
243
+ body.generationConfig = turn1GenerationConfig;
244
+ }
245
+ return body;
246
+ }
247
+
248
+ export function* iterateCaptureSteps(
249
+ opts: IterateCaptureStepsOpts,
250
+ ): Generator<CaptureStep, void, CapturedResponse> {
251
+ const { model, capability, intent } = opts;
252
+
253
+ if (FILES_API_CAPABILITIES.has(capability)) {
254
+ const upload = buildUploadDescriptor(intent);
255
+ const uploadResponse = yield {
256
+ kind: "raw",
257
+ subdir: "upload",
258
+ url: upload.url,
259
+ method: "POST",
260
+ contentType: upload.mimeType,
261
+ headers: {
262
+ "X-Goog-Upload-Protocol": "raw",
263
+ "X-Goog-Upload-File-Name": upload.displayName,
264
+ },
265
+ body: upload.bytes,
266
+ };
267
+ const fileUri = extractFileUri(uploadResponse.parsed);
268
+ const mimeType = extractMimeTypeFromUpload(uploadResponse.parsed);
269
+ const generateBody = buildFilesApiGenerateBody({
270
+ intent,
271
+ fileUri,
272
+ mimeType,
273
+ });
274
+ yield {
275
+ kind: "json",
276
+ subdir: "generate",
277
+ url: buildEndpointURL({ model, capability }),
278
+ body: generateBody,
279
+ };
280
+ return;
281
+ }
282
+
283
+ if (MULTI_TURN_CAPABILITIES.has(capability)) {
284
+ const turn1Body = buildRequestBody({ model, capability, intent });
285
+ const url = buildEndpointURL({ model, capability });
286
+ const turn1Response = yield {
287
+ kind: "json",
288
+ subdir: "turn-1",
289
+ url,
290
+ body: turn1Body,
291
+ };
292
+ const turn2Body = buildMultiTurnTurn2Body({
293
+ capability,
294
+ intent,
295
+ turn1Body,
296
+ turn1Response: turn1Response.parsed,
297
+ });
298
+ yield {
299
+ kind: "json",
300
+ subdir: "turn-2",
301
+ url,
302
+ body: turn2Body,
303
+ };
304
+ return;
305
+ }
306
+
307
+ yield {
308
+ kind: "json",
309
+ subdir: null,
310
+ url: buildEndpointURL({ model, capability }),
311
+ body: buildRequestBody({ model, capability, intent }),
312
+ };
313
+ }
314
+
315
+ export function createGoogleGenaiPlugin(
316
+ opts: GoogleGenaiPluginOptions,
317
+ ): ProviderPlugin {
318
+ const apiKey = opts.apiKey;
319
+ return {
320
+ name: PROVIDER_NAME,
321
+ models: MODELS,
322
+ redactRequestHeaders: REDACT_REQUEST_HEADERS,
323
+ redactResponseHeaders: REDACT_RESPONSE_HEADERS,
324
+ buildAuthHeaders: () => buildAuthHeaders(apiKey),
325
+ iterateCaptureSteps,
326
+ };
327
+ }