@intx/inference-discovery-google-genai 0.2.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.
package/README.md CHANGED
@@ -9,12 +9,17 @@ for the runtime, the plug-in contract, and the `discover` CLI.
9
9
 
10
10
  ## Models
11
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.
12
+ - `gemini-2.5-flash` / `gemini-3.5-flash` — text multimodal surface
13
+ (vision, audio, video, document, function calling, code execution,
14
+ grounding, safety classification, structured output, files API).
15
+ Streaming and non-streaming variants of each. These models accept
16
+ a zero thinking budget when the probe wants thinking suppressed.
17
+ - `gemini-3.6-flash` / `gemini-2.5-pro` — same text multimodal
18
+ surface, but they reject a zero thinking budget, so the request
19
+ builder uses the dynamic thinking budget (`-1`) on the suppress
20
+ path.
21
+ - `gemini-2.5-flash-image` / `gemini-3.1-flash-image` — image
22
+ output, streaming and non-streaming.
18
23
 
19
24
  The full per-capability list is in `SUPPORT_MATRIX` in
20
25
  `@intx/inference-discovery/catalog`.
@@ -57,7 +62,8 @@ runner writes the bundle:
57
62
  `function-calling-with-thinking`,
58
63
  `function-calling-with-thinking-streaming`) — turn 1 is sent as
59
64
  usual; the plug-in extracts the model's assistant content from
60
- the parsed response, derives a tool follow-up from the intent's
65
+ the parsed response (reconstructing it from the SSE stream when
66
+ turn-1 is streamed), derives a tool follow-up from the intent's
61
67
  `followUp` (or synthesises one from the intent's tools), and
62
68
  sends a turn-2 body that echoes the assistant turn verbatim and
63
69
  appends the tool response. Each turn writes its own `turn-1/` and
package/dist/index.d.ts CHANGED
@@ -1,6 +1,12 @@
1
- import type { CaptureStep, CapturedResponse, IterateCaptureStepsOpts, ProviderPlugin } from "@intx/inference-discovery";
1
+ import { type CaptureStep, type CapturedResponse, type IterateCaptureStepsOpts, type ProviderPlugin } from "@intx/inference-discovery";
2
+ import { type GeminiModelClass } from "./request-body.js";
3
+ export type { GeminiModelClass } from "./request-body.js";
4
+ export { isKnownModel } from "./request-body.js";
2
5
  export interface GoogleGenaiPluginOptions {
3
6
  apiKey: string;
7
+ modelClass?: GeminiModelClass | undefined;
4
8
  }
5
- export declare function iterateCaptureSteps(opts: IterateCaptureStepsOpts): Generator<CaptureStep, void, CapturedResponse>;
9
+ export declare function iterateCaptureSteps(opts: IterateCaptureStepsOpts & {
10
+ modelClass?: GeminiModelClass | undefined;
11
+ }): Generator<CaptureStep, void, CapturedResponse>;
6
12
  export declare function createGoogleGenaiPlugin(opts: GoogleGenaiPluginOptions): ProviderPlugin;
package/dist/index.js CHANGED
@@ -1,10 +1,22 @@
1
1
  import { readFileSync } from "node:fs";
2
2
  import { resolveMediaPath, } from "@intx/inference-discovery/catalog";
3
+ import { resolveTurn1Response, } from "@intx/inference-discovery";
3
4
  import { buildAuthHeaders } from "./auth.js";
4
5
  import { buildEndpointURL } from "./endpoint.js";
5
6
  import { buildRequestBody } from "./request-body.js";
7
+ import { reconstructResponseFromSSE } from "./sse.js";
8
+ export { isKnownModel } from "./request-body.js";
6
9
  const PROVIDER_NAME = "google-genai";
7
- const MODELS = ["gemini-2.5-flash", "gemini-2.5-flash-image"];
10
+ const MODELS = [
11
+ "gemini-2.5-flash",
12
+ "gemini-2.5-flash-image",
13
+ "gemini-2.5-pro",
14
+ "gemini-3.6-flash",
15
+ "gemini-3.5-flash",
16
+ "gemini-3.1-flash-image",
17
+ "gemini-3-flash-preview",
18
+ "gemini-3.1-pro-preview",
19
+ ];
8
20
  const REDACT_REQUEST_HEADERS = ["x-goog-api-key"];
9
21
  const REDACT_RESPONSE_HEADERS = [];
10
22
  const FILES_API_UPLOAD_URL = "https://generativelanguage.googleapis.com/upload/v1beta/files";
@@ -172,12 +184,11 @@ function buildMultiTurnTurn2Body(opts) {
172
184
  return body;
173
185
  }
174
186
  export function* iterateCaptureSteps(opts) {
175
- const { model, capability, intent } = opts;
187
+ const { model, capability, intent, modelClass } = opts;
176
188
  if (FILES_API_CAPABILITIES.has(capability)) {
177
189
  const upload = buildUploadDescriptor(intent);
178
190
  const uploadResponse = yield {
179
191
  kind: "raw",
180
- subdir: "upload",
181
192
  url: upload.url,
182
193
  method: "POST",
183
194
  contentType: upload.mimeType,
@@ -196,18 +207,21 @@ export function* iterateCaptureSteps(opts) {
196
207
  });
197
208
  yield {
198
209
  kind: "json",
199
- subdir: "generate",
200
210
  url: buildEndpointURL({ model, capability }),
201
211
  body: generateBody,
202
212
  };
203
213
  return;
204
214
  }
205
215
  if (MULTI_TURN_CAPABILITIES.has(capability)) {
206
- const turn1Body = buildRequestBody({ model, capability, intent });
216
+ const turn1Body = buildRequestBody({
217
+ model,
218
+ capability,
219
+ intent,
220
+ modelClass,
221
+ });
207
222
  const url = buildEndpointURL({ model, capability });
208
223
  const turn1Response = yield {
209
224
  kind: "json",
210
- subdir: "turn-1",
211
225
  url,
212
226
  body: turn1Body,
213
227
  };
@@ -215,11 +229,10 @@ export function* iterateCaptureSteps(opts) {
215
229
  capability,
216
230
  intent,
217
231
  turn1Body,
218
- turn1Response: turn1Response.parsed,
232
+ turn1Response: resolveTurn1Response(turn1Response, reconstructResponseFromSSE),
219
233
  });
220
234
  yield {
221
235
  kind: "json",
222
- subdir: "turn-2",
223
236
  url,
224
237
  body: turn2Body,
225
238
  };
@@ -227,19 +240,18 @@ export function* iterateCaptureSteps(opts) {
227
240
  }
228
241
  yield {
229
242
  kind: "json",
230
- subdir: null,
231
243
  url: buildEndpointURL({ model, capability }),
232
- body: buildRequestBody({ model, capability, intent }),
244
+ body: buildRequestBody({ model, capability, intent, modelClass }),
233
245
  };
234
246
  }
235
247
  export function createGoogleGenaiPlugin(opts) {
236
- const apiKey = opts.apiKey;
248
+ const { apiKey, modelClass } = opts;
237
249
  return {
238
250
  name: PROVIDER_NAME,
239
251
  models: MODELS,
240
252
  redactRequestHeaders: REDACT_REQUEST_HEADERS,
241
253
  redactResponseHeaders: REDACT_RESPONSE_HEADERS,
242
254
  buildAuthHeaders: () => buildAuthHeaders(apiKey),
243
- iterateCaptureSteps,
255
+ iterateCaptureSteps: (stepOpts) => iterateCaptureSteps({ ...stepOpts, modelClass }),
244
256
  };
245
257
  }
@@ -1,4 +1,6 @@
1
1
  import { type Capability, type CapabilityIntent, type ToolDecl } from "@intx/inference-discovery/catalog";
2
+ export declare const TEXT_MODELS: ReadonlySet<string>;
3
+ export declare const IMAGE_MODELS: ReadonlySet<string>;
2
4
  interface GeminiTextPart {
3
5
  text: string;
4
6
  }
@@ -57,9 +59,12 @@ interface GeminiRequestBody {
57
59
  toolConfig?: GeminiToolConfig;
58
60
  generationConfig?: GeminiGenerationConfig;
59
61
  }
62
+ export type GeminiModelClass = "text" | "image";
63
+ export declare function isKnownModel(model: string): boolean;
60
64
  export declare function buildRequestBody(opts: {
61
65
  model: string;
62
66
  capability: Capability;
63
67
  intent: CapabilityIntent;
68
+ modelClass?: GeminiModelClass | undefined;
64
69
  }): GeminiRequestBody;
65
70
  export {};
@@ -1,7 +1,20 @@
1
1
  import { readFileSync } from "node:fs";
2
- import { resolveMediaPath, } from "@intx/inference-discovery/catalog";
3
- const TEXT_MODEL = "gemini-2.5-flash";
4
- const IMAGE_MODEL = "gemini-2.5-flash-image";
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
+ ]);
5
18
  const TEXT_MODEL_CAPABILITIES = new Set([
6
19
  "plain-text",
7
20
  "plain-text-streaming",
@@ -47,20 +60,26 @@ const EXTENSION_TO_MIME_TYPE = {
47
60
  webm: "video/webm",
48
61
  pdf: "application/pdf",
49
62
  };
50
- function modelSupportsCapability(model, capability) {
51
- if (model === TEXT_MODEL) {
52
- if (!TEXT_MODEL_CAPABILITIES.has(capability)) {
53
- throw new Error(`google-genai: model ${model} does not support capability ${capability}`);
54
- }
55
- return;
56
- }
57
- if (model === IMAGE_MODEL) {
58
- if (!IMAGE_MODEL_CAPABILITIES.has(capability)) {
59
- throw new Error(`google-genai: model ${model} does not support capability ${capability}`);
60
- }
61
- return;
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}`);
62
82
  }
63
- throw new Error(`google-genai: unknown model ${model}`);
64
83
  }
65
84
  function extensionFor(path) {
66
85
  const dot = path.lastIndexOf(".");
@@ -114,18 +133,38 @@ function userTextContent(prompt) {
114
133
  parts: [{ text: prompt }],
115
134
  };
116
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
+ }
117
156
  function plainTextBody(intent) {
118
157
  return {
119
158
  contents: [userTextContent(intent.prompt)],
120
159
  };
121
160
  }
122
- function plainTextStreamingBody(intent) {
161
+ function plainTextStreamingBody(intent, model) {
123
162
  return {
124
163
  contents: [userTextContent(intent.prompt)],
125
164
  generationConfig: {
126
165
  maxOutputTokens: 400,
127
166
  thinkingConfig: {
128
- thinkingBudget: 0,
167
+ thinkingBudget: minimalThinkingBudget(model),
129
168
  },
130
169
  },
131
170
  };
@@ -238,16 +277,16 @@ function structuredOutputBody(intent) {
238
277
  return body;
239
278
  }
240
279
  export function buildRequestBody(opts) {
241
- modelSupportsCapability(opts.model, opts.capability);
280
+ assertCapabilityBuildable(opts.model, opts.capability, opts.modelClass);
242
281
  switch (opts.capability) {
243
282
  case "plain-text":
244
283
  return plainTextBody(opts.intent);
245
284
  case "plain-text-streaming":
246
- return plainTextStreamingBody(opts.intent);
285
+ return plainTextStreamingBody(opts.intent, opts.model);
247
286
  case "function-calling-multi-turn":
248
287
  case "function-calling-multi-turn-streaming":
249
288
  return functionCallingBody(opts.intent, {
250
- budget: 0,
289
+ budget: minimalThinkingBudget(opts.model),
251
290
  includeThoughts: false,
252
291
  });
253
292
  case "function-calling-with-thinking":
@@ -288,7 +327,8 @@ export function buildRequestBody(opts) {
288
327
  case "reasoning-content-streaming":
289
328
  case "redacted-thinking":
290
329
  case "redacted-thinking-streaming":
291
- throw new Error(`google-genai: capability ${opts.capability} is not supported by any google-genai model`);
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`);
292
332
  default: {
293
333
  const exhaustive = opts.capability;
294
334
  throw new Error(`google-genai: unhandled capability ${String(exhaustive)}`);
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,6 +1,7 @@
1
1
  {
2
2
  "name": "@intx/inference-discovery-google-genai",
3
- "version": "0.2.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": {
@@ -11,7 +12,7 @@
11
12
  }
12
13
  },
13
14
  "dependencies": {
14
- "@intx/inference-discovery": "0.2.2",
15
+ "@intx/inference-discovery": "0.3.0",
15
16
  "arktype": "^2.1.29"
16
17
  },
17
18
  "files": [