@intx/inference 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.
Files changed (97) hide show
  1. package/LICENSE +176 -0
  2. package/dist/actions.d.ts +16 -0
  3. package/dist/actions.js +200 -0
  4. package/dist/adapter.d.ts +40 -0
  5. package/dist/adapter.js +31 -0
  6. package/dist/assembly.d.ts +75 -0
  7. package/dist/assembly.js +133 -0
  8. package/dist/audit-collector.d.ts +10 -0
  9. package/dist/audit-collector.js +139 -0
  10. package/dist/auth.d.ts +24 -0
  11. package/{src/auth.ts → dist/auth.js} +13 -19
  12. package/dist/authz-extension.d.ts +46 -0
  13. package/dist/authz-extension.js +184 -0
  14. package/dist/correlation.d.ts +26 -0
  15. package/dist/correlation.js +39 -0
  16. package/dist/default-director.d.ts +111 -0
  17. package/dist/default-director.js +228 -0
  18. package/dist/director.d.ts +6 -0
  19. package/dist/director.js +56 -0
  20. package/dist/errors.d.ts +18 -0
  21. package/dist/errors.js +83 -0
  22. package/dist/gates.d.ts +28 -0
  23. package/dist/gates.js +103 -0
  24. package/dist/harness.d.ts +147 -0
  25. package/dist/harness.js +1407 -0
  26. package/dist/index.d.ts +37 -0
  27. package/dist/index.js +21 -0
  28. package/dist/manifest.d.ts +31 -0
  29. package/dist/manifest.js +44 -0
  30. package/dist/providers/anthropic.d.ts +37 -0
  31. package/dist/providers/anthropic.js +917 -0
  32. package/dist/providers/google-genai-files.d.ts +48 -0
  33. package/dist/providers/google-genai-files.js +205 -0
  34. package/dist/providers/google-genai.d.ts +5 -0
  35. package/dist/providers/google-genai.js +1205 -0
  36. package/dist/providers/index.d.ts +38 -0
  37. package/dist/providers/index.js +56 -0
  38. package/dist/providers/openai.d.ts +9 -0
  39. package/dist/providers/openai.js +903 -0
  40. package/dist/reactor.d.ts +50 -0
  41. package/dist/reactor.js +1233 -0
  42. package/dist/retry-policy.d.ts +31 -0
  43. package/{src/retry-policy.ts → dist/retry-policy.js} +41 -53
  44. package/dist/sse.d.ts +1 -0
  45. package/dist/sse.js +63 -0
  46. package/dist/state.d.ts +23 -0
  47. package/dist/state.js +100 -0
  48. package/dist/tool-name.d.ts +6 -0
  49. package/dist/tool-name.js +110 -0
  50. package/dist/transform.d.ts +11 -0
  51. package/dist/transform.js +132 -0
  52. package/dist/transforms/index.d.ts +2 -0
  53. package/dist/transforms/index.js +1 -0
  54. package/dist/transforms/size-cap.d.ts +12 -0
  55. package/dist/transforms/size-cap.js +80 -0
  56. package/dist/turns.d.ts +21 -0
  57. package/dist/turns.js +135 -0
  58. package/package.json +22 -6
  59. package/src/actions.ts +0 -245
  60. package/src/adapter.ts +0 -57
  61. package/src/assembly.test.ts +0 -728
  62. package/src/assembly.ts +0 -250
  63. package/src/audit-collector.test.ts +0 -332
  64. package/src/audit-collector.ts +0 -172
  65. package/src/auth.test.ts +0 -117
  66. package/src/authz-extension.test.ts +0 -269
  67. package/src/authz-extension.ts +0 -145
  68. package/src/correlation.ts +0 -61
  69. package/src/default-director.test.ts +0 -314
  70. package/src/default-director.ts +0 -344
  71. package/src/director.ts +0 -87
  72. package/src/errors.test.ts +0 -133
  73. package/src/errors.ts +0 -115
  74. package/src/gates.ts +0 -128
  75. package/src/harness.test.ts +0 -655
  76. package/src/harness.ts +0 -1571
  77. package/src/index.ts +0 -76
  78. package/src/providers/anthropic.test.ts +0 -771
  79. package/src/providers/anthropic.ts +0 -810
  80. package/src/providers/google-genai-files.ts +0 -289
  81. package/src/providers/google-genai.ts +0 -1518
  82. package/src/providers/openai.ts +0 -719
  83. package/src/providers/registry.ts +0 -33
  84. package/src/reactor.test.ts +0 -3660
  85. package/src/reactor.ts +0 -1058
  86. package/src/scheduler.test.ts +0 -41
  87. package/src/sse.test.ts +0 -133
  88. package/src/sse.ts +0 -76
  89. package/src/state.ts +0 -135
  90. package/src/transform.test.ts +0 -207
  91. package/src/transform.ts +0 -159
  92. package/src/transforms/index.ts +0 -2
  93. package/src/transforms/size-cap.test.ts +0 -172
  94. package/src/transforms/size-cap.ts +0 -110
  95. package/src/turns.ts +0 -54
  96. package/tsconfig.json +0 -4
  97. package/tsconfig.tsbuildinfo +0 -1
@@ -0,0 +1,1205 @@
1
+ import { type } from "arktype";
2
+ import { formatSafetyRatingText } from "@intx/types/runtime";
3
+ import { CREDENTIAL_SENTINEL } from "../auth.js";
4
+ import { ProtocolMismatchError } from "../errors.js";
5
+ import { decodeToolName, encodeToolName, } from "../tool-name.js";
6
+ // Gemini rejects function names with out-of-charset characters and requires a
7
+ // letter/underscore leading character; the raw package-qualified names fail
8
+ // both. The documented function-name limit is 64 characters.
9
+ const GOOGLE_TOOL_NAME_LIMIT = {
10
+ provider: "google-genai",
11
+ maxLength: 64,
12
+ };
13
+ // Models that reject thinkingConfig.thinkingBudget: 0 with HTTP 400.
14
+ // Keep aligned with the discovery plug-in's THINKING_MANDATORY_MODELS.
15
+ const THINKING_MANDATORY_MODELS = new Set([
16
+ "gemini-2.5-pro",
17
+ "gemini-3.6-flash",
18
+ ]);
19
+ // Dynamic thinking budget sentinel: the model decides how much to
20
+ // think. Used when suppressing thought parts on thinking-mandatory
21
+ // models that reject a zero budget.
22
+ const DYNAMIC_THINKING_BUDGET = -1;
23
+ function minimalThinkingBudget(model) {
24
+ return THINKING_MANDATORY_MODELS.has(model) ? DYNAMIC_THINKING_BUDGET : 0;
25
+ }
26
+ // Runtime validator for "parsed JSON value is a plain object." Used
27
+ // by `tryParseJSONObject` to narrow `JSON.parse(string)` from its
28
+ // declared `unknown` return into a `Record<string, unknown>` without a
29
+ // type assertion -- the assertion would be a compile-time lie about
30
+ // runtime shape (per the project style guide), and arktype gives an
31
+ // honest runtime check.
32
+ const ParsedJSONObject = type("Record<string, unknown>");
33
+ // ---------------------------------------------------------------------------
34
+ // Request building
35
+ //
36
+ // Translates the internal ConversationTurn[] format into Gemini's
37
+ // `generateContent` / `streamGenerateContent` request body. The harness
38
+ // always streams, so the URL pins `:streamGenerateContent?alt=sse`.
39
+ // ---------------------------------------------------------------------------
40
+ function buildRequest(messages, model, options) {
41
+ const systemMessages = messages.filter((m) => m.role === "system");
42
+ const conversationMessages = messages.filter((m) => m.role !== "system");
43
+ // System text: concatenated from any system turns in history, unless
44
+ // the caller overrides via `options.systemPrompt`. Matches the
45
+ // precedence used by the Anthropic adapter. Non-text blocks in a
46
+ // system turn surface as an error rather than a silent drop -- the
47
+ // rest of the file fails loudly on unsupported block kinds and this
48
+ // boundary holds the same discipline.
49
+ const systemText = systemMessages
50
+ .flatMap((m) => m.content.map((b) => {
51
+ if (b.type !== "text") {
52
+ throw new Error(`Google GenAI adapter: system turn must contain only text blocks; got ${JSON.stringify(b.type)}.`);
53
+ }
54
+ return b.text;
55
+ }))
56
+ .join("\n\n");
57
+ const effectiveSystem = options.systemPrompt
58
+ ? options.systemPrompt
59
+ : systemText || undefined;
60
+ // A `callId -> functionName` lookup, built once per request from
61
+ // every prior assistant `tool_call` block. Gemini's
62
+ // `functionResponse` part requires the function name (Anthropic
63
+ // requires the callId); the internal `ToolResultBlock` carries only
64
+ // the callId, so the name comes from the assistant turn that
65
+ // produced the matching `tool_call`. Built once because a per-block
66
+ // walk would be O(N^2) in turn count.
67
+ const callIdToFunctionName = buildCallIdToFunctionName(messages);
68
+ // safety_rating is output-only. Rewrite to text so multi-turn history
69
+ // keeps role alternation and a model-visible block reason (same
70
+ // policy as Anthropic/OpenAI/transform).
71
+ const contents = conversationMessages.map((msg) => {
72
+ const rewritten = {
73
+ ...msg,
74
+ content: msg.content.map((b) => b.type === "safety_rating"
75
+ ? { type: "text", text: formatSafetyRatingText(b) }
76
+ : b),
77
+ };
78
+ return toGeminiContent(rewritten, callIdToFunctionName);
79
+ });
80
+ const body = { contents };
81
+ if (effectiveSystem !== undefined) {
82
+ body["systemInstruction"] = { parts: [{ text: effectiveSystem }] };
83
+ }
84
+ if (options.tools !== undefined && options.tools.length > 0) {
85
+ body["tools"] = [
86
+ {
87
+ functionDeclarations: options.tools.map((t) => ({
88
+ name: encodeToolName(t.name, GOOGLE_TOOL_NAME_LIMIT),
89
+ description: t.description,
90
+ parameters: t.inputSchema,
91
+ })),
92
+ },
93
+ ];
94
+ }
95
+ const generationConfig = buildGenerationConfig(model, options);
96
+ if (generationConfig !== undefined) {
97
+ body["generationConfig"] = generationConfig;
98
+ }
99
+ // Caller escape hatch. Documented as shallow-merge over the body
100
+ // top-level: a caller passing `providerOptions.generationConfig`
101
+ // wholesale replaces the object built above. Same shape semantics as
102
+ // the `InferenceOptions.providerOptions` contract on every other
103
+ // adapter -- the caller owns the consequences of clobbering a
104
+ // structured key.
105
+ if (options.providerOptions !== undefined) {
106
+ Object.assign(body, options.providerOptions);
107
+ }
108
+ // Escape the model name in the URL path. `encodeURIComponent` is a
109
+ // no-op on the legitimate Gemini model names in use today
110
+ // (alphanumerics, hyphens, periods are all reserved-safe), but
111
+ // guards against future model values that arrive from outside
112
+ // trusted configuration. The trailing `:streamGenerateContent?alt=sse`
113
+ // sits outside the substitution so its colon and query string
114
+ // survive intact.
115
+ const encodedModel = encodeURIComponent(model);
116
+ return {
117
+ url: `/v1beta/models/${encodedModel}:streamGenerateContent?alt=sse`,
118
+ headers: {
119
+ "content-type": "application/json",
120
+ "x-goog-api-key": CREDENTIAL_SENTINEL,
121
+ },
122
+ body: JSON.stringify(body),
123
+ };
124
+ }
125
+ // ---------------------------------------------------------------------------
126
+ // Conversation-turn translation
127
+ // ---------------------------------------------------------------------------
128
+ function buildCallIdToFunctionName(messages) {
129
+ const map = new Map();
130
+ for (const msg of messages) {
131
+ if (msg.role !== "assistant")
132
+ continue;
133
+ for (const block of msg.content) {
134
+ if (block.type === "tool_call") {
135
+ map.set(block.id, block.name);
136
+ }
137
+ }
138
+ }
139
+ return map;
140
+ }
141
+ function toGeminiContent(msg, callIdToFunctionName) {
142
+ const role = msg.role === "assistant" ? "model" : "user";
143
+ // Role/block pairing: Gemini wants `functionCall` parts only on
144
+ // `model`-role contents and `functionResponse` parts only on
145
+ // `user`-role contents. The internal `ContentBlock` union does not
146
+ // enforce the pairing on its own, so misrouted blocks (a `tool_call`
147
+ // on a user turn, a `tool_result` on an assistant turn) would
148
+ // otherwise reach Gemini and return an opaque 400. Catch them at
149
+ // the marshaling boundary with diagnostic context instead.
150
+ for (const block of msg.content) {
151
+ if (role === "user" && block.type === "tool_call") {
152
+ throw new Error(`Google GenAI adapter: tool_call blocks must appear on assistant turns, ` +
153
+ `found one on a ${JSON.stringify(msg.role)} turn (id ${JSON.stringify(block.id)}).`);
154
+ }
155
+ if (role === "model" && block.type === "tool_result") {
156
+ throw new Error(`Google GenAI adapter: tool_result blocks must appear on user turns, ` +
157
+ `found one on a ${JSON.stringify(msg.role)} turn (callId ${JSON.stringify(block.callId)}).`);
158
+ }
159
+ }
160
+ // Each block carries its own signature; `toGeminiPart` rides it back
161
+ // onto that block's own part as a `thoughtSignature`. The captured
162
+ // wire places the signature on whichever part the model signed (for a
163
+ // signed thinking turn, that is the follow-on functionCall part, which
164
+ // reverse-parsing attributed to the tool_call block), so a per-block
165
+ // round-trip reproduces the wire without any cross-part pairing.
166
+ const parts = msg.content.map((block) => toGeminiPart(block, callIdToFunctionName));
167
+ return { role, parts };
168
+ }
169
+ function toGeminiPart(block, callIdToFunctionName) {
170
+ switch (block.type) {
171
+ case "text":
172
+ return {
173
+ text: block.text,
174
+ ...(block.signature !== undefined
175
+ ? { thoughtSignature: block.signature }
176
+ : {}),
177
+ };
178
+ case "image": {
179
+ // Only ImageBlock among the media kinds carries a signature; the
180
+ // others have no signature field to ride back.
181
+ const part = toGeminiMediaPart(block.source);
182
+ return block.signature !== undefined
183
+ ? { ...part, thoughtSignature: block.signature }
184
+ : part;
185
+ }
186
+ case "document":
187
+ case "audio":
188
+ case "video":
189
+ return toGeminiMediaPart(block.source);
190
+ case "tool_call":
191
+ return {
192
+ functionCall: {
193
+ name: encodeToolName(block.name, GOOGLE_TOOL_NAME_LIMIT),
194
+ args: block.arguments,
195
+ },
196
+ ...(block.signature !== undefined
197
+ ? { thoughtSignature: block.signature }
198
+ : {}),
199
+ };
200
+ case "tool_result":
201
+ return toGeminiFunctionResponse(block, callIdToFunctionName);
202
+ case "thinking":
203
+ // A thinking block rides its own signature on its part, the same
204
+ // as any other block. Gemini most often signs the follow-on
205
+ // functionCall part instead, which reverse-parsing attributes to
206
+ // the tool_call block, so a signed thinking part here is the rare
207
+ // case where Gemini signed the thought itself.
208
+ return {
209
+ text: block.thinking,
210
+ thought: true,
211
+ ...(block.signature !== undefined
212
+ ? { thoughtSignature: block.signature }
213
+ : {}),
214
+ };
215
+ case "redacted_thinking":
216
+ // Gemini does not emit redacted-thinking blocks; a caller
217
+ // passing one in is mixing wire formats. Surface the mismatch
218
+ // loudly rather than dropping it silently.
219
+ throw new Error("Google GenAI adapter does not handle redacted_thinking blocks; " +
220
+ "they are Anthropic-specific.");
221
+ case "safety_rating":
222
+ // Rewritten to text in buildRequest before toGeminiPart is called.
223
+ throw new Error("Google GenAI adapter: safety_rating blocks must be rewritten " +
224
+ "to text before toGeminiPart.");
225
+ case "citation":
226
+ // Citations are output-only blocks: the model produces them as
227
+ // grounding/source references for its own text. Echoing one
228
+ // back in an input turn has no defined wire shape and is almost
229
+ // certainly a caller bug -- fail rather than send a nonsense
230
+ // request.
231
+ throw new Error("Google GenAI adapter does not echo citation blocks; citations " +
232
+ "are emitted by the model, not sent to it.");
233
+ case "code_execution_request":
234
+ case "code_execution_result":
235
+ // Code-execution round-trip needs Gemini's
236
+ // `executableCode`/`codeExecutionResult` part shapes, which
237
+ // the adapter does not emit. Surface the gap rather than
238
+ // produce a request with these blocks missing.
239
+ throw new Error(`Google GenAI adapter does not handle ${block.type} content blocks.`);
240
+ case "refusal":
241
+ // Refusal blocks are an OpenAI strict-mode output shape and have
242
+ // no Gemini wire equivalent. Echoing one back into a Gemini
243
+ // request has no defined translation; fail loudly at the
244
+ // marshaling site rather than silently drop the block.
245
+ throw new Error("Google GenAI adapter does not handle refusal content blocks; " +
246
+ "they are emitted by OpenAI strict-mode structured outputs.");
247
+ }
248
+ }
249
+ // Marshal an internal MediaSource into a Gemini part. `base64`
250
+ // inlines the bytes; `file-reference` and `url` both target Gemini's
251
+ // `fileData` with `fileUri` -- the Files API returns URIs, and Gemini
252
+ // also accepts public HTTP(S) URLs through the same field.
253
+ function toGeminiMediaPart(source) {
254
+ if (source.kind === "base64") {
255
+ return {
256
+ inlineData: { mimeType: source.mimeType, data: source.data },
257
+ };
258
+ }
259
+ if (source.kind === "file-reference") {
260
+ return {
261
+ fileData: { mimeType: source.mimeType, fileUri: source.reference },
262
+ };
263
+ }
264
+ if (source.kind === "url") {
265
+ return {
266
+ fileData: { mimeType: source.mimeType, fileUri: source.url },
267
+ };
268
+ }
269
+ // Exhaustiveness: a new MediaSource variant added without a case
270
+ // here fails this compile-time check.
271
+ source;
272
+ throw new Error(`unreachable: unknown MediaSource kind`);
273
+ }
274
+ // Marshal a tool_result into Gemini's functionResponse part shape.
275
+ // The contract is deliberately strict: Gemini's `response` is a JSON
276
+ // object, and a permissive "guess at the shape" mapping silently
277
+ // reshapes payloads when callers don't intend it. The four accepted
278
+ // shapes are:
279
+ //
280
+ // - exactly one text block whose text parses as a plain JSON object
281
+ // -> that object becomes `response`
282
+ // - exactly one text block whose text does not parse as an object
283
+ // -> `{ result: text }` (or `{ error: text }` when isError is true)
284
+ // - zero or multiple text blocks -> throw; the caller must collapse
285
+ // to a single text block before handing the tool_result to the
286
+ // adapter
287
+ // - any non-text block (image/audio/video/document) inside the
288
+ // tool_result -> throw; Gemini's functionResponse accepts no media
289
+ //
290
+ // The unknown-callId case throws with the unknown id and the set of
291
+ // known ids so a malformed conversation surfaces at the marshaling
292
+ // site instead of as an opaque HTTP 400 a round-trip later.
293
+ function toGeminiFunctionResponse(block, callIdToFunctionName) {
294
+ const name = callIdToFunctionName.get(block.callId);
295
+ if (name === undefined) {
296
+ const known = Array.from(callIdToFunctionName.keys());
297
+ throw new Error(`Google GenAI adapter: tool_result.callId ${JSON.stringify(block.callId)} ` +
298
+ `has no matching tool_call in the conversation history. ` +
299
+ `Known callIds: ${known.length === 0 ? "(none)" : known.map((k) => JSON.stringify(k)).join(", ")}.`);
300
+ }
301
+ if (block.content.length !== 1) {
302
+ throw new Error(`Google GenAI adapter: tool_result must contain exactly one text block, ` +
303
+ `got ${String(block.content.length)} blocks for callId ` +
304
+ `${JSON.stringify(block.callId)}.`);
305
+ }
306
+ const only = block.content[0];
307
+ if (only === undefined || only.type !== "text") {
308
+ const seenType = only?.type ?? "undefined";
309
+ throw new Error(`Google GenAI adapter: tool_result content block must be of type "text", ` +
310
+ `got ${JSON.stringify(seenType)} for callId ${JSON.stringify(block.callId)}.`);
311
+ }
312
+ const text = only.text;
313
+ const parsed = tryParseJSONObject(text);
314
+ let response;
315
+ if (parsed !== null) {
316
+ response = parsed;
317
+ }
318
+ else if (block.isError === true) {
319
+ response = { error: text };
320
+ }
321
+ else {
322
+ response = { result: text };
323
+ }
324
+ return {
325
+ functionResponse: {
326
+ name: encodeToolName(name, GOOGLE_TOOL_NAME_LIMIT),
327
+ response,
328
+ },
329
+ };
330
+ }
331
+ // Returns the parsed value when `text` is a JSON-encoded plain
332
+ // object, or `null` for any other shape: arrays, primitives
333
+ // (numbers, strings, booleans, null), and JSON parse errors all map
334
+ // to `null`. Wrapping is the responsibility of the caller -- this
335
+ // helper only confirms "is the text exactly a JSON object we can use
336
+ // verbatim."
337
+ function tryParseJSONObject(text) {
338
+ let parsed;
339
+ try {
340
+ parsed = JSON.parse(text);
341
+ }
342
+ catch {
343
+ return null;
344
+ }
345
+ // `ParsedJSONObject` (arktype `Record<string, unknown>`) accepts
346
+ // arrays -- in arktype's view an array IS a record with
347
+ // numeric-string keys -- so the array-rejection has to happen
348
+ // before the validator runs. Without this guard, a tool that
349
+ // returns `"[1,2,3]"` would be silently promoted to a `response`
350
+ // shape Gemini cannot consume.
351
+ if (Array.isArray(parsed)) {
352
+ return null;
353
+ }
354
+ const validated = ParsedJSONObject(parsed);
355
+ if (validated instanceof type.errors) {
356
+ return null;
357
+ }
358
+ return validated;
359
+ }
360
+ // ---------------------------------------------------------------------------
361
+ // generationConfig
362
+ // ---------------------------------------------------------------------------
363
+ function buildGenerationConfig(model, options) {
364
+ const config = {};
365
+ if (options.maxTokens !== undefined) {
366
+ config["maxOutputTokens"] = options.maxTokens;
367
+ }
368
+ if (options.temperature !== undefined) {
369
+ config["temperature"] = options.temperature;
370
+ }
371
+ // thinking.enabled === true -> include a budget (default 1024) and
372
+ // ask Gemini to surface thought parts
373
+ // thinking.enabled === false -> suppress thoughts: budget 0 when the
374
+ // model allows it, or dynamic (-1) for
375
+ // thinking-mandatory models that reject
376
+ // a zero budget with HTTP 400
377
+ // thinking absent -> omit thinkingConfig entirely; Gemini
378
+ // uses the model's default
379
+ if (options.thinking !== undefined) {
380
+ if (options.thinking.enabled) {
381
+ const thinkingBudget = options.thinking.budgetTokens ?? 1024;
382
+ config["thinkingConfig"] = {
383
+ thinkingBudget,
384
+ includeThoughts: true,
385
+ };
386
+ }
387
+ else {
388
+ config["thinkingConfig"] = {
389
+ thinkingBudget: minimalThinkingBudget(model),
390
+ };
391
+ }
392
+ }
393
+ if (options.responseModalities !== undefined &&
394
+ options.responseModalities.length > 0) {
395
+ config["responseModalities"] =
396
+ options.responseModalities.map(toGeminiModality);
397
+ }
398
+ if (options.responseFormat !== undefined) {
399
+ applyResponseFormat(config, options.responseFormat);
400
+ }
401
+ return Object.keys(config).length === 0 ? undefined : config;
402
+ }
403
+ // Translate the internal `responseFormat` union to Gemini's
404
+ // generationConfig fields. Gemini exposes structured outputs through
405
+ // the pair (`responseMimeType`, `responseSchema`) rather than a
406
+ // dedicated union: setting the MIME type alone gives free-form JSON;
407
+ // pairing it with a schema constrains the output to schema-conformant
408
+ // JSON. The OpenAI-specific `name` and `strict` fields have no Gemini
409
+ // equivalent and are ignored when present.
410
+ //
411
+ // The `schema` field is forwarded verbatim. Gemini enforces a JSON
412
+ // Schema subset (no `oneOf`, limited `pattern`, no `$ref`, etc.); the
413
+ // adapter does not pre-validate the caller's schema against that
414
+ // subset and instead surfaces Gemini's HTTP error if the model
415
+ // rejects it. INFERENCE.md documents the subset for callers.
416
+ function applyResponseFormat(config, format) {
417
+ switch (format.kind) {
418
+ case "text":
419
+ // Free-form text is Gemini's default; omitting the MIME type
420
+ // produces the same behavior. Set nothing to keep the request
421
+ // body minimal.
422
+ return;
423
+ case "json":
424
+ config["responseMimeType"] = "application/json";
425
+ return;
426
+ case "json-schema":
427
+ config["responseMimeType"] = "application/json";
428
+ config["responseSchema"] = format.schema;
429
+ return;
430
+ }
431
+ }
432
+ function toGeminiModality(m) {
433
+ switch (m) {
434
+ case "text":
435
+ return "TEXT";
436
+ case "image":
437
+ return "IMAGE";
438
+ case "audio":
439
+ return "AUDIO";
440
+ }
441
+ }
442
+ // ---------------------------------------------------------------------------
443
+ // Response parsing
444
+ //
445
+ // Each Gemini SSE event is one complete JSON object delivered through
446
+ // `parseSSE` (event boundary `\n\n`); a partial JSON would mean the
447
+ // SSE framing layer broke its contract, not a Gemini protocol
448
+ // violation. Per the adapter contract in
449
+ // `packages/inference/src/adapter.ts`, `ProtocolMismatchError` is the
450
+ // only throw type the parser is allowed to raise -- the harness's
451
+ // `classifyStreamError` recognizes it.
452
+ //
453
+ // Text deltas on the Gemini wire are incremental: each event carries
454
+ // only the new tokens, not the accumulated text. The harness owns
455
+ // partial-state accumulation; the parser emits placeholder
456
+ // `EMPTY_PARTIAL` and the harness fills the real value in.
457
+ // ---------------------------------------------------------------------------
458
+ const EMPTY_PARTIAL = { text: "" };
459
+ // Wire shape: every field is optional. Gemini emits candidates without
460
+ // content during safety-filter rejections, sends events with only
461
+ // `usageMetadata` populated, and may omit `finishReason` on every
462
+ // event except the terminal one. The parser handles the absences
463
+ // directly rather than via schema-default coercion.
464
+ //
465
+ // The schema models the five payload kinds the parser handles:
466
+ // `text`, `functionCall`, `inlineData` (image output),
467
+ // `executableCode` (code-execution request), and
468
+ // `codeExecutionResult` (code-execution result). They are mutually
469
+ // exclusive on the wire: a single part is one kind of content.
470
+ // Arktype's open-object semantics will accept multiple set
471
+ // simultaneously, so `parseResponse` enforces the exclusivity at
472
+ // the boundary via `assertSinglePayload` and throws
473
+ // `ProtocolMismatchError` on a violation. `inlineData` is
474
+ // additionally constrained to `image/*` MIME types at the
475
+ // `emitPart` boundary; a non-image MIME on `inlineData` is treated
476
+ // as a wire shape the parser does not handle (rather than silently
477
+ // wrapping arbitrary bytes as an ImageBlock).
478
+ //
479
+ // `thought` and `thoughtSignature` are metadata that ride alongside
480
+ // the payload: `thought: true` is only meaningful on a `text` part
481
+ // (a non-text part with `thought: true` is a wire violation rejected
482
+ // at the boundary), and `thoughtSignature` carries the opaque
483
+ // per-thinking-block signature that Gemini requires echoed back on
484
+ // follow-up turns. Both can be absent.
485
+ const GeminiFunctionCallPayload = type({
486
+ name: "string",
487
+ args: "Record<string, unknown>",
488
+ });
489
+ const GeminiInlineDataPayload = type({
490
+ mimeType: "string",
491
+ data: "string",
492
+ });
493
+ const GeminiExecutableCodePayload = type({
494
+ language: "string",
495
+ code: "string",
496
+ });
497
+ const GeminiCodeExecutionResultPayload = type({
498
+ outcome: "string",
499
+ // The combined stdout/stderr stream. Gemini does not split the
500
+ // streams; the parser routes this verbatim into the result
501
+ // block's `stdout` and leaves `stderr` empty (per the contract
502
+ // documented on `CodeExecutionResultBlock`).
503
+ "output?": "string",
504
+ });
505
+ const GeminiPart = type({
506
+ "text?": "string",
507
+ "thought?": "boolean",
508
+ "thoughtSignature?": "string",
509
+ "functionCall?": GeminiFunctionCallPayload,
510
+ "inlineData?": GeminiInlineDataPayload,
511
+ "executableCode?": GeminiExecutableCodePayload,
512
+ "codeExecutionResult?": GeminiCodeExecutionResultPayload,
513
+ });
514
+ const GeminiContent = type({
515
+ "parts?": GeminiPart.array(),
516
+ "role?": "string",
517
+ });
518
+ // Grounding metadata rides on a candidate whenever the request
519
+ // enabled `tools: [{googleSearch: {}}]`. The captured fixture
520
+ // shows `groundingMetadata: {}` present on every SSE event with
521
+ // `groundingChunks`/`groundingSupports` populated only on the
522
+ // terminal event; intermediate empty-metadata events short-circuit
523
+ // in `emitGroundingCitations` via the `supports.length === 0`
524
+ // early return. The two arrays the parser consumes are:
525
+ //
526
+ // - `groundingChunks[].web`: per-source `{uri, title}` entries.
527
+ // Indexed positionally; the chunks are the citation sources.
528
+ //
529
+ // - `groundingSupports[]`: pairings between an output text span
530
+ // (`segment: {startIndex, endIndex, text}`) and one or more
531
+ // chunk indices (`groundingChunkIndices: number[]`). Each
532
+ // index-into-chunks expands into one CitationBlock during
533
+ // emission.
534
+ //
535
+ // `searchEntryPoint` (HTML rendering widget) and `webSearchQueries`
536
+ // (the model-issued queries) carry no per-text-span attribution and
537
+ // are not surfaced as citation blocks. Validating them here would
538
+ // pin a wire shape the parser does not consume; the schema admits
539
+ // them implicitly via arktype's open-object semantics.
540
+ const GeminiGroundingChunk = type({
541
+ // Each chunk currently arrives with a single `web` shape. Other
542
+ // chunk kinds (e.g. document, retrieved-context) are not in the
543
+ // captured corpus; admitting them as schema-validated absences
544
+ // keeps `web`-shaped chunks well-typed without committing to a
545
+ // discriminated union the parser cannot dispatch over.
546
+ "web?": type({ uri: "string", title: "string" }),
547
+ });
548
+ const GeminiGroundingSupport = type({
549
+ segment: {
550
+ startIndex: "number",
551
+ endIndex: "number",
552
+ text: "string",
553
+ },
554
+ groundingChunkIndices: "number[]",
555
+ });
556
+ const GeminiGroundingMetadata = type({
557
+ "groundingChunks?": GeminiGroundingChunk.array(),
558
+ "groundingSupports?": GeminiGroundingSupport.array(),
559
+ });
560
+ const GeminiCandidate = type({
561
+ "content?": GeminiContent,
562
+ "finishReason?": "string",
563
+ "index?": "number",
564
+ "groundingMetadata?": GeminiGroundingMetadata,
565
+ });
566
+ // `thoughtsTokenCount` is populated on responses with thinking
567
+ // enabled; it maps directly onto `TokenUsage.thinking`.
568
+ // `cachedContentTokenCount` is populated when context caching is in
569
+ // use and maps onto `TokenUsage.cacheRead`. Both are absent on
570
+ // responses that don't exercise the corresponding feature, and the
571
+ // parser treats absence as zero.
572
+ const GeminiUsageMetadata = type({
573
+ "promptTokenCount?": "number",
574
+ "candidatesTokenCount?": "number",
575
+ "totalTokenCount?": "number",
576
+ "thoughtsTokenCount?": "number",
577
+ "cachedContentTokenCount?": "number",
578
+ });
579
+ // Prompt-level safety signal. Captured 2026-07-28 on
580
+ // safety-classification fixtures: `{ blockReason: "PROHIBITED_CONTENT" }`
581
+ // with no candidates. Only fields we consume are validated.
582
+ const GeminiPromptFeedback = type({
583
+ "blockReason?": "string > 0",
584
+ });
585
+ const GeminiSSEEvent = type({
586
+ "candidates?": GeminiCandidate.array(),
587
+ "usageMetadata?": GeminiUsageMetadata,
588
+ "promptFeedback?": GeminiPromptFeedback,
589
+ // `modelVersion` and `responseId` are dropped at this layer. The
590
+ // harness's `AssistantTurn.model` is set from the requested model
591
+ // string, not from the served `modelVersion` -- which can differ
592
+ // (`gemini-2.5-flash` requested may return `gemini-2.5-flash-001`).
593
+ // Surfacing the served version is a separate concern; for now the
594
+ // request-side identifier is what downstream consumers see.
595
+ "modelVersion?": "string",
596
+ "responseId?": "string",
597
+ });
598
+ function createParserState() {
599
+ return {
600
+ nextBlockIndex: 0,
601
+ currentBlock: null,
602
+ pendingExecutionRequestId: null,
603
+ };
604
+ }
605
+ // A `thoughtSignature` authenticates the block whose part carries it.
606
+ // Emit an `inference.block.signature` against that block's own index;
607
+ // providers that do not sign this part leave `signature` undefined and
608
+ // this emits nothing.
609
+ function emitBlockSignature(signature, index, seq, out) {
610
+ if (signature === undefined)
611
+ return;
612
+ out.push({
613
+ type: "inference.block.signature",
614
+ seq,
615
+ data: { signature, index },
616
+ });
617
+ }
618
+ // Open or extend a text/thinking block, returning the block index.
619
+ // A part of the same kind as the current block extends it; a part of
620
+ // a different kind closes the current block and allocates a new index.
621
+ function openOrExtendBlock(state, kind) {
622
+ if (state.currentBlock !== null && state.currentBlock.kind === kind) {
623
+ return state.currentBlock.index;
624
+ }
625
+ closeCurrentBlock(state);
626
+ const index = state.nextBlockIndex++;
627
+ state.currentBlock = { kind, index };
628
+ return index;
629
+ }
630
+ // Close the current text/thinking block so the next part of any kind
631
+ // starts a fresh block. A signature rides on its own part and attaches
632
+ // to that part's block, so closing carries no signature state.
633
+ function closeCurrentBlock(state) {
634
+ state.currentBlock = null;
635
+ }
636
+ // Enforce mutual exclusivity of payload-bearing fields and correct
637
+ // placement of the `thought` flag on a single part. The schema
638
+ // models five payload fields (`text`, `functionCall`, `inlineData`,
639
+ // `executableCode`, `codeExecutionResult`); arktype's open-object
640
+ // semantics would otherwise admit a part with more than one set,
641
+ // or with `thought: true` on a non-text part. Both are wire
642
+ // violations and surface as `ProtocolMismatchError` here. A part
643
+ // with zero payload fields passes this structural check only when a
644
+ // `thoughtSignature` is present; `emitPart` then rejects that
645
+ // signature-only part separately, since a signature with no payload
646
+ // has no block to authenticate.
647
+ function assertSinglePayload(part, raw) {
648
+ const payloads = [];
649
+ if (part.text !== undefined)
650
+ payloads.push("text");
651
+ if (part.functionCall !== undefined)
652
+ payloads.push("functionCall");
653
+ if (part.inlineData !== undefined)
654
+ payloads.push("inlineData");
655
+ if (part.executableCode !== undefined)
656
+ payloads.push("executableCode");
657
+ if (part.codeExecutionResult !== undefined) {
658
+ payloads.push("codeExecutionResult");
659
+ }
660
+ if (payloads.length > 1) {
661
+ throw new ProtocolMismatchError(`google-genai parseResponse: part has multiple payload fields set ` +
662
+ `(${payloads.join("+")}); exactly one of ` +
663
+ `{text, functionCall, inlineData, executableCode, ` +
664
+ `codeExecutionResult} must be present per Gemini wire convention.`, raw);
665
+ }
666
+ if (payloads.length === 0 && part.thoughtSignature === undefined) {
667
+ throw new ProtocolMismatchError(`google-genai parseResponse: part has no payload and no ` +
668
+ `thoughtSignature; an empty part is not a defined wire shape.`, raw);
669
+ }
670
+ // `thought: true` is only meaningful on a text part; the flag's
671
+ // sole purpose is to discriminate thinking text from regular
672
+ // assistant text. A `thought` flag on a `functionCall` part or a
673
+ // payload-free part has no defined wire interpretation.
674
+ if (part.thought === true && part.text === undefined) {
675
+ throw new ProtocolMismatchError(`google-genai parseResponse: \`thought: true\` set on a part with ` +
676
+ `no \`text\` payload; the flag is only valid on text parts.`, raw);
677
+ }
678
+ }
679
+ function emitPart(part, state, seq, out, raw) {
680
+ assertSinglePayload(part, raw);
681
+ // text part with `thought: true` -- belongs to a thinking block.
682
+ if (part.text !== undefined && part.thought === true) {
683
+ const index = openOrExtendBlock(state, "thinking");
684
+ // Anchor the block in the harness's per-index map. An empty
685
+ // text part with only a `thoughtSignature` would otherwise route
686
+ // the signature to an index the harness has never seen. The
687
+ // empty-token delta mirrors the Anthropic adapter's anchoring
688
+ // pattern for the same invariant.
689
+ out.push({
690
+ type: "inference.thinking.delta",
691
+ seq,
692
+ data: {
693
+ token: part.text,
694
+ partial: EMPTY_PARTIAL,
695
+ index,
696
+ },
697
+ });
698
+ // A thinking part may carry its own signature; attach it to this
699
+ // thinking block's index.
700
+ emitBlockSignature(part.thoughtSignature, index, seq, out);
701
+ return;
702
+ }
703
+ // text part without `thought` -- belongs to a text block. An empty
704
+ // text part with no signature is a true no-op: it neither opens nor
705
+ // closes a block, so a follow-on same-kind part extends what was
706
+ // open. An empty text part that carries a signature still opens (or
707
+ // extends) a text block so the signature has its own block to sign.
708
+ if (part.text !== undefined) {
709
+ if (part.text === "" && part.thoughtSignature === undefined) {
710
+ return;
711
+ }
712
+ const index = openOrExtendBlock(state, "text");
713
+ out.push({
714
+ type: "inference.text.delta",
715
+ seq,
716
+ data: {
717
+ token: part.text,
718
+ partial: EMPTY_PARTIAL,
719
+ index,
720
+ },
721
+ });
722
+ emitBlockSignature(part.thoughtSignature, index, seq, out);
723
+ return;
724
+ }
725
+ // functionCall part -- atomic block, allocates a fresh index and
726
+ // does not become the `currentBlock` (a follow-on text or thinking
727
+ // part starts a new block of that kind).
728
+ if (part.functionCall !== undefined) {
729
+ closeCurrentBlock(state);
730
+ const fc = part.functionCall;
731
+ const index = state.nextBlockIndex++;
732
+ // Synthetic callId: Gemini's `functionCall` has no wire-level id
733
+ // field. The harness keys on this id end-to-end (start, delta,
734
+ // round-trip lookup); `String(index)` matches the Anthropic
735
+ // adapter's fallback when its wire id is absent. Block indices
736
+ // are unique within a request by construction.
737
+ const callId = String(index);
738
+ out.push({
739
+ type: "inference.tool_call.start",
740
+ seq,
741
+ data: {
742
+ callId,
743
+ name: decodeToolName(fc.name),
744
+ partial: EMPTY_PARTIAL,
745
+ index,
746
+ },
747
+ });
748
+ // Gemini delivers `args` complete in a single part -- no
749
+ // streaming JSON fragments. Emit the full serialized args in one
750
+ // delta so the harness's end-of-stream finalization (which keys
751
+ // on `openToolCalls` and re-parses the accumulated argsBuffer)
752
+ // produces a `tool_call.end` with the correct arguments. The
753
+ // harness owns the `tool_call.end` emission; adapters emit only
754
+ // `start` + `delta`.
755
+ out.push({
756
+ type: "inference.tool_call.delta",
757
+ seq,
758
+ data: {
759
+ callId,
760
+ argumentFragment: JSON.stringify(fc.args),
761
+ partial: EMPTY_PARTIAL,
762
+ index,
763
+ },
764
+ });
765
+ // A `thoughtSignature` on the functionCall part authenticates the
766
+ // tool_call block; emit it after the block is open at this index.
767
+ emitBlockSignature(part.thoughtSignature, index, seq, out);
768
+ return;
769
+ }
770
+ // inlineData part -- atomic image-output block. The image arrives
771
+ // complete in a single SSE event (no streaming chunks of base64),
772
+ // so a new block index is allocated and the ImageBlock is emitted
773
+ // in one `inference.image_output` event. A `thoughtSignature` on the
774
+ // part authenticates the image block and is emitted against its index.
775
+ if (part.inlineData !== undefined) {
776
+ // The parser wraps inlineData as an `ImageBlock`, so a non-
777
+ // image MIME (e.g. audio/wav, application/pdf) would silently
778
+ // mistype the payload. Reject at the boundary rather than
779
+ // produce a confidently-wrong ContentBlock.
780
+ if (!part.inlineData.mimeType.startsWith("image/")) {
781
+ throw new ProtocolMismatchError(`google-genai parseResponse: inlineData part has non-image ` +
782
+ `mimeType ${JSON.stringify(part.inlineData.mimeType)}; the ` +
783
+ `parser wraps inlineData as an ImageBlock and does not ` +
784
+ `handle other modalities on this code path.`, raw);
785
+ }
786
+ closeCurrentBlock(state);
787
+ const index = state.nextBlockIndex++;
788
+ out.push({
789
+ type: "inference.image_output",
790
+ seq,
791
+ data: {
792
+ image: {
793
+ type: "image",
794
+ source: {
795
+ kind: "base64",
796
+ mimeType: part.inlineData.mimeType,
797
+ data: part.inlineData.data,
798
+ },
799
+ },
800
+ index,
801
+ },
802
+ });
803
+ emitBlockSignature(part.thoughtSignature, index, seq, out);
804
+ return;
805
+ }
806
+ // executableCode part -- atomic code-execution request block.
807
+ // Gemini delivers the full source in one part (no chunked code
808
+ // streaming), so a fresh block index is allocated and the request
809
+ // block is emitted in one `inference.code_execution.start` event.
810
+ // The synthetic id is `gemini-exec-<index>` where `index` is the
811
+ // content-block index allocated within THIS response (deterministic
812
+ // per-response so replays of the same response produce the same
813
+ // ids). It satisfies the `CodeExecutionRequestBlock.id` contract
814
+ // ("synthesized by the adapter for providers that don't emit one,
815
+ // using a deterministic per-response position-based scheme so
816
+ // replays match"). The id then lands in
817
+ // `pendingExecutionRequestId` so the next codeExecutionResult
818
+ // part can back-point its `requestId` to it.
819
+ if (part.executableCode !== undefined) {
820
+ // Precondition first, before any state mutation or event
821
+ // emission: a depth-1 violation must not leave a half-applied
822
+ // close/allocate/settle sequence in `state` and `out`. The
823
+ // caller discards `out` on throw today, so the difference is
824
+ // not observable, but the ordering keeps the throw faithful
825
+ // to "this part was rejected entirely."
826
+ if (state.pendingExecutionRequestId !== null) {
827
+ throw new ProtocolMismatchError(`google-genai parseResponse: encountered a second executableCode ` +
828
+ `part while the prior code-execution request ` +
829
+ `${JSON.stringify(state.pendingExecutionRequestId)} is still ` +
830
+ `unmatched. The wire convention is strict LIFO with depth 1 ` +
831
+ `(request, then result); no fixture exercises depth > 1.`, raw);
832
+ }
833
+ closeCurrentBlock(state);
834
+ const index = state.nextBlockIndex++;
835
+ const requestId = `gemini-exec-${String(index)}`;
836
+ state.pendingExecutionRequestId = requestId;
837
+ const ec = part.executableCode;
838
+ const request = {
839
+ type: "code_execution_request",
840
+ id: requestId,
841
+ code: ec.code,
842
+ // Pass `language` through verbatim. Gemini emits SCREAMING_CASE
843
+ // (e.g. `"PYTHON"`); the type contract is "adapters MUST NOT
844
+ // default this -- callers narrow on its presence." Comparing
845
+ // values cross-provider requires case-insensitive logic at
846
+ // the consumer.
847
+ language: ec.language,
848
+ };
849
+ out.push({
850
+ type: "inference.code_execution.start",
851
+ seq,
852
+ data: { request, index },
853
+ });
854
+ // A `thoughtSignature` on the executableCode part authenticates the
855
+ // code-execution-request block; emit it against its index.
856
+ emitBlockSignature(part.thoughtSignature, index, seq, out);
857
+ return;
858
+ }
859
+ // codeExecutionResult part -- atomic result block. Pairs against
860
+ // the most recently emitted `executableCode` part via
861
+ // `pendingExecutionRequestId` (Gemini's wire carries no explicit
862
+ // back-pointer; the immediately-preceding request is the
863
+ // implicit owner). The slot read is destructive: clearing it
864
+ // here forces the depth-1 invariant on subsequent parts, and a
865
+ // result arriving with the slot empty throws.
866
+ if (part.codeExecutionResult !== undefined) {
867
+ // Precondition first, before any state mutation or event
868
+ // emission: an empty-slot violation must not leave a
869
+ // half-applied close/allocate/settle sequence behind. Same
870
+ // discipline as the executableCode branch above.
871
+ const requestId = state.pendingExecutionRequestId;
872
+ if (requestId === null) {
873
+ throw new ProtocolMismatchError(`google-genai parseResponse: codeExecutionResult part has no ` +
874
+ `preceding executableCode part in this request to pair against.`, raw);
875
+ }
876
+ // outcomeToStatus throws on an unknown outcome -- run it before
877
+ // any other state mutation so the throw cleanly rejects the
878
+ // part without partial side effects.
879
+ const cer = part.codeExecutionResult;
880
+ const status = outcomeToStatus(cer.outcome, raw);
881
+ // A code_execution_result block carries no signature field, and the
882
+ // corpus never signs a result part; a signature here is an
883
+ // unmodeled wire shape. Reject before mutating state.
884
+ if (part.thoughtSignature !== undefined) {
885
+ throw new ProtocolMismatchError(`google-genai parseResponse: codeExecutionResult part carries a ` +
886
+ `thoughtSignature; the code_execution_result block is not signable.`, raw);
887
+ }
888
+ closeCurrentBlock(state);
889
+ const index = state.nextBlockIndex++;
890
+ state.pendingExecutionRequestId = null;
891
+ const result = {
892
+ type: "code_execution_result",
893
+ requestId,
894
+ status,
895
+ // Gemini's `output` is the combined stdout+stderr stream.
896
+ // Per the `CodeExecutionResultBlock.stdout` comment, providers
897
+ // that don't split the streams map their combined output here
898
+ // and leave `stderr` empty.
899
+ ...(cer.output !== undefined ? { stdout: cer.output } : {}),
900
+ providerOutcome: cer.outcome,
901
+ };
902
+ out.push({
903
+ type: "inference.code_execution.result",
904
+ seq,
905
+ data: { result, index },
906
+ });
907
+ return;
908
+ }
909
+ // Signature-only part (no payload, signature set). A signature
910
+ // authenticates a block; a part with no payload has no block to own
911
+ // it, so this is an unmodeled wire shape the corpus never exercises.
912
+ if (part.thoughtSignature !== undefined) {
913
+ throw new ProtocolMismatchError(`google-genai parseResponse: part carries a thoughtSignature but no ` +
914
+ `payload; there is no block for the signature to authenticate.`, raw);
915
+ }
916
+ // `assertSinglePayload` above rules out the no-payload-no-signature
917
+ // case, so a part that lands here had a payload that no earlier
918
+ // branch claimed. The schema models five payload fields (`text`,
919
+ // `functionCall`, `inlineData`, `executableCode`,
920
+ // `codeExecutionResult`); all five have their own branches
921
+ // above. Reaching this line implies the schema has grown a new
922
+ // payload field without a matching branch in `emitPart`.
923
+ throw new ProtocolMismatchError(`google-genai parseResponse: unhandled part shape; the schema admits ` +
924
+ `a payload field that emitPart has no branch for.`, raw);
925
+ }
926
+ // Emit `inference.citation` events from a candidate's
927
+ // `groundingMetadata`. Each `groundingSupport` expands into one
928
+ // citation per referenced chunk: a span that cites four sources
929
+ // produces four citations with the same `citedText` and
930
+ // `textOffset` but distinct `source` entries. Consumers see the
931
+ // full attribution list and can de-duplicate by URI if they want
932
+ // to collapse identical sources.
933
+ //
934
+ // The text-block anchor is read from `state.currentBlock` -- the
935
+ // just-processed text parts in this same event will have left it
936
+ // set to the running text block. If currentBlock is not text (or
937
+ // is null), Gemini delivered grounding without a preceding text
938
+ // anchor, which has no defined attribution per the
939
+ // `CitationBlock` contract; surface as a protocol mismatch
940
+ // rather than synthesize an arbitrary index.
941
+ //
942
+ // `groundingChunks` entries without the `web` shape (a future
943
+ // chunk kind) are skipped silently for now -- their source has no
944
+ // `uri`/`title` to populate `CitationSource`, and synthesizing a
945
+ // placeholder citation would misrepresent the wire. Supports that
946
+ // reference an out-of-range chunk index throw -- the wire is
947
+ // pointing at a chunk slot the response never delivered, which is
948
+ // a wire bug we want to see.
949
+ function emitGroundingCitations(metadata, state, seq, out, raw) {
950
+ const supports = metadata.groundingSupports ?? [];
951
+ const chunks = metadata.groundingChunks ?? [];
952
+ if (supports.length === 0) {
953
+ return;
954
+ }
955
+ const anchor = state.currentBlock;
956
+ if (anchor === null || anchor.kind !== "text") {
957
+ throw new ProtocolMismatchError(`google-genai parseResponse: groundingMetadata arrived without a ` +
958
+ `current text block to anchor citations against (currentBlock=` +
959
+ `${anchor === null ? "null" : JSON.stringify(anchor.kind)}). The ` +
960
+ `wire convention places groundingMetadata on the terminal event ` +
961
+ `alongside the text it grounds.`, raw);
962
+ }
963
+ const index = anchor.index;
964
+ for (const support of supports) {
965
+ const { segment, groundingChunkIndices } = support;
966
+ for (const chunkIdx of groundingChunkIndices) {
967
+ const chunk = chunks[chunkIdx];
968
+ if (chunk === undefined) {
969
+ throw new ProtocolMismatchError(`google-genai parseResponse: groundingSupport references ` +
970
+ `chunk index ${String(chunkIdx)} but the response has only ` +
971
+ `${String(chunks.length)} grounding chunk(s).`, raw);
972
+ }
973
+ const web = chunk.web;
974
+ if (web === undefined) {
975
+ // Non-web chunk kinds (retrieved-context, document, etc.)
976
+ // have no `web.uri`/`web.title` to populate a
977
+ // CitationSource. Skipping rather than synthesizing keeps
978
+ // the citation faithful to the wire shape the parser
979
+ // actually models -- the schema admits non-web chunks
980
+ // implicitly so a wider chunk kind reaching the parser
981
+ // does not fail schema validation, but it has no defined
982
+ // mapping into `CitationSource` until its discriminator
983
+ // is modeled here.
984
+ continue;
985
+ }
986
+ const citation = {
987
+ type: "citation",
988
+ citedText: segment.text,
989
+ source: {
990
+ uri: web.uri,
991
+ title: web.title,
992
+ },
993
+ textOffset: {
994
+ start: segment.startIndex,
995
+ end: segment.endIndex,
996
+ },
997
+ };
998
+ out.push({
999
+ type: "inference.citation",
1000
+ seq,
1001
+ data: { citation, index },
1002
+ });
1003
+ }
1004
+ }
1005
+ }
1006
+ // Map Gemini's `codeExecutionResult.outcome` enum onto the
1007
+ // internal `CodeExecutionResultBlock.status` union. The switch is
1008
+ // exhaustive over the three values Gemini documents today; an
1009
+ // unknown outcome string surfaces as a `ProtocolMismatchError`
1010
+ // naming the value verbatim rather than being bucketed into a
1011
+ // fallback status. Adding a new outcome to this mapping is a
1012
+ // deliberate code change, not an implicit acceptance of whatever
1013
+ // Gemini sends next.
1014
+ function outcomeToStatus(outcome, raw) {
1015
+ switch (outcome) {
1016
+ case "OUTCOME_OK":
1017
+ return "ok";
1018
+ case "OUTCOME_FAILED":
1019
+ return "error";
1020
+ case "OUTCOME_DEADLINE_EXCEEDED":
1021
+ return "timeout";
1022
+ default:
1023
+ throw new ProtocolMismatchError(`google-genai parseResponse: unknown codeExecutionResult.outcome ` +
1024
+ `${JSON.stringify(outcome)}; the mapping recognizes ` +
1025
+ `OUTCOME_OK, OUTCOME_FAILED, OUTCOME_DEADLINE_EXCEEDED. ` +
1026
+ `A new outcome value is a deliberate adapter change, not a ` +
1027
+ `silent fallback.`, raw);
1028
+ }
1029
+ }
1030
+ function parseResponse(sseData, state, source) {
1031
+ let parsed;
1032
+ try {
1033
+ parsed = JSON.parse(sseData);
1034
+ }
1035
+ catch (cause) {
1036
+ const message = cause instanceof Error ? cause.message : String(cause);
1037
+ throw new ProtocolMismatchError(`google-genai parseResponse: malformed JSON in SSE data payload: ${message}`, sseData);
1038
+ }
1039
+ const event = GeminiSSEEvent(parsed);
1040
+ if (event instanceof type.errors) {
1041
+ throw new ProtocolMismatchError(`google-genai parseResponse: SSE event failed schema validation: ${event.summary}`, parsed);
1042
+ }
1043
+ const candidates = event.candidates ?? [];
1044
+ // The adapter's `buildRequest` never requests `candidateCount > 1`,
1045
+ // so a multi-candidate response means the wire shape diverged from
1046
+ // what was requested. Surface the mismatch loudly with the full
1047
+ // payload in `error.raw` rather than silently picking `[0]`.
1048
+ if (candidates.length > 1) {
1049
+ throw new ProtocolMismatchError(`google-genai parseResponse: expected at most one candidate, got ${String(candidates.length)}.`, parsed);
1050
+ }
1051
+ // The seq field is a placeholder 0 -- the harness assigns real
1052
+ // sequence numbers.
1053
+ const seq = 0;
1054
+ const out = [];
1055
+ const candidate = candidates[0];
1056
+ if (candidate?.content?.parts !== undefined) {
1057
+ for (const part of candidate.content.parts) {
1058
+ emitPart(part, state, seq, out, parsed);
1059
+ }
1060
+ }
1061
+ // `groundingMetadata` rides on the candidate alongside the parts
1062
+ // and the finishReason. It is processed AFTER the parts have
1063
+ // settled so any text deltas in the same event extend the
1064
+ // currentBlock first; `emitGroundingCitations` reads the
1065
+ // currentBlock's index to attribute each citation to the right
1066
+ // text block. Citations precede the terminal usage emission --
1067
+ // they belong to the model's output, not to the bookkeeping
1068
+ // signal that closes the response.
1069
+ if (candidate?.groundingMetadata !== undefined) {
1070
+ emitGroundingCitations(candidate.groundingMetadata, state, seq, out, parsed);
1071
+ }
1072
+ // Prompt-level structured safety signal. Observed capture shape
1073
+ // (safety-classification fixtures, 2026-07-28): HTTP 200 with
1074
+ // `promptFeedback.blockReason` and zero candidates. Treat as a
1075
+ // terminal parse path: emit the safety event, then usage from
1076
+ // `usageMetadata` (which is present on the capture). This is not
1077
+ // an `inference.error` — the transport succeeded and the wire
1078
+ // carries a structured signal.
1079
+ const blockReason = event.promptFeedback?.blockReason;
1080
+ if (blockReason !== undefined) {
1081
+ out.push({
1082
+ type: "inference.safety_rating",
1083
+ seq,
1084
+ data: {
1085
+ safetyRating: {
1086
+ type: "safety_rating",
1087
+ blockReason,
1088
+ },
1089
+ },
1090
+ });
1091
+ const usage = event.usageMetadata;
1092
+ if (usage === undefined) {
1093
+ throw new ProtocolMismatchError(`google-genai parseResponse: promptFeedback.blockReason terminal event missing usageMetadata.`, parsed);
1094
+ }
1095
+ out.push({
1096
+ type: "inference.usage",
1097
+ seq,
1098
+ data: {
1099
+ usage: {
1100
+ input: usage.promptTokenCount ?? 0,
1101
+ output: usage.candidatesTokenCount ?? 0,
1102
+ cacheRead: usage.cachedContentTokenCount ?? 0,
1103
+ cacheWrite: 0,
1104
+ thinking: usage.thoughtsTokenCount ?? 0,
1105
+ },
1106
+ source,
1107
+ },
1108
+ });
1109
+ return out;
1110
+ }
1111
+ // `finishReason` arrives only on the terminal event. Emit usage at
1112
+ // exactly that point: Gemini's `usageMetadata` is cumulative in
1113
+ // every event, so the terminal-event snapshot is the final count
1114
+ // and intermediate emissions would be pure noise that the
1115
+ // harness's `inference.done` would discard anyway.
1116
+ //
1117
+ // `MAX_TOKENS`, `SAFETY`, `RECITATION`, and `OTHER` reach this
1118
+ // layer but do not yet surface as `inference.error` -- emitting
1119
+ // those needs fixtures showing the full error envelope shape,
1120
+ // which the plain-text path does not exercise. Candidate-level
1121
+ // `safetyRatings` arrays have also not been observed on the
1122
+ // discovery corpus; extend emission when a capture carries them.
1123
+ if (candidate?.finishReason !== undefined) {
1124
+ const usage = event.usageMetadata;
1125
+ if (usage === undefined) {
1126
+ throw new ProtocolMismatchError(`google-genai parseResponse: terminal event (finishReason=${JSON.stringify(candidate.finishReason)}) missing usageMetadata.`, parsed);
1127
+ }
1128
+ const tokenUsage = {
1129
+ input: usage.promptTokenCount ?? 0,
1130
+ output: usage.candidatesTokenCount ?? 0,
1131
+ // Gemini exposes context caching via `cachedContentTokenCount`
1132
+ // (single counter; the API does not distinguish "read" from
1133
+ // "write" the way Anthropic does). The plain-text path does
1134
+ // not exercise caching, so the field is absent here. A future
1135
+ // caching commit decides whether to route the count into
1136
+ // `cacheRead` or carry both fields.
1137
+ cacheRead: usage.cachedContentTokenCount ?? 0,
1138
+ cacheWrite: 0,
1139
+ thinking: usage.thoughtsTokenCount ?? 0,
1140
+ };
1141
+ out.push({
1142
+ type: "inference.usage",
1143
+ seq,
1144
+ data: { usage: tokenUsage, source },
1145
+ });
1146
+ // Terminal events seal the response. A still-pending
1147
+ // code-execution request at this point would mean Gemini
1148
+ // emitted an executableCode part without a matching
1149
+ // codeExecutionResult before stopping -- a wire bug, not a
1150
+ // case the harness should silently swallow.
1151
+ if (state.pendingExecutionRequestId !== null) {
1152
+ throw new ProtocolMismatchError(`google-genai parseResponse: response terminated with an ` +
1153
+ `unmatched code-execution request ` +
1154
+ `${JSON.stringify(state.pendingExecutionRequestId)}; the wire ` +
1155
+ `must deliver a codeExecutionResult part before the terminal ` +
1156
+ `finishReason.`, parsed);
1157
+ }
1158
+ }
1159
+ return out;
1160
+ }
1161
+ // A non-streaming generateContent response is shaped exactly like a single
1162
+ // terminal streaming SSE event: one GeminiSSEEvent carrying the full parts
1163
+ // array and a terminal finishReason (or a promptFeedback.blockReason). Decode
1164
+ // it through the same parser with a fresh per-call state, so a replayed
1165
+ // non-streaming capture feeds the harness accumulator identically to its
1166
+ // streaming sibling — parity by construction, since the parser's state machine
1167
+ // is boundary-agnostic (nothing in it branches on SSE-event boundaries). The
1168
+ // "malformed JSON in SSE data payload" message parseResponse throws on a bad
1169
+ // body is path-neutral in substance (the body is JSON either way), so it is
1170
+ // left shared rather than forking the streaming parser's signature.
1171
+ function parseJSONResponse(body, source) {
1172
+ const events = parseResponse(body, createParserState(), source);
1173
+ // A complete non-streaming body MUST be terminal. The shared parser
1174
+ // tolerates non-terminal events (correct mid-stream, where an intermediate
1175
+ // event legitimately carries no finishReason), but here a body with no
1176
+ // finishReason and no promptFeedback.blockReason is a truncated or malformed
1177
+ // capture, not a silent empty decode. Both terminal paths emit
1178
+ // inference.usage, so its absence is the faithful terminality signal.
1179
+ if (!events.some((e) => e.type === "inference.usage")) {
1180
+ throw new ProtocolMismatchError(`google-genai parseJSONResponse: non-streaming body carried no terminal ` +
1181
+ `finishReason or promptFeedback.blockReason; a complete ` +
1182
+ `generateContent response must be terminal and emit usage.`, body);
1183
+ }
1184
+ return events;
1185
+ }
1186
+ // The google-genai adapter carries no per-source accommodations today, so its
1187
+ // quirks shape is empty. A quirks bag is deployment configuration crossing
1188
+ // into the system at this boundary; rejecting unknown keys makes a
1189
+ // misconfigured bag fail loudly here rather than run silently ignored.
1190
+ export const GoogleGenAIQuirks = type({ "+": "reject" });
1191
+ export function createGoogleGenAIAdapter(source, quirks) {
1192
+ const parsedQuirks = GoogleGenAIQuirks(quirks ?? {});
1193
+ if (parsedQuirks instanceof type.errors) {
1194
+ throw new Error(`google-genai adapter: invalid quirks: ${parsedQuirks.summary}`);
1195
+ }
1196
+ // Per-request state lives in the closure: block-index allocation and
1197
+ // the code-execution request/result pairing both need to span SSE
1198
+ // events. `buildRequest` does not touch state; only `parseResponse` does.
1199
+ const state = createParserState();
1200
+ return {
1201
+ buildRequest,
1202
+ parseResponse: (sseData) => parseResponse(sseData, state, source),
1203
+ parseJSONResponse: (body) => parseJSONResponse(body, source),
1204
+ };
1205
+ }