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