@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
@@ -1,1518 +0,0 @@
1
- import { type } from "arktype";
2
-
3
- import type {
4
- CodeExecutionRequestBlock,
5
- CodeExecutionResultBlock,
6
- ConversationTurn,
7
- ContentBlock,
8
- InferenceEvent,
9
- InferenceOptions,
10
- LastCycleSource,
11
- MediaSource,
12
- PartialMessage,
13
- TokenUsage,
14
- } from "@intx/types/runtime";
15
- import type { ProviderAdapter, BuiltRequest } from "../adapter";
16
- import { CREDENTIAL_SENTINEL } from "../auth";
17
- import { ProtocolMismatchError } from "../errors";
18
-
19
- // Runtime validator for "parsed JSON value is a plain object." Used
20
- // by `tryParseJSONObject` to narrow `JSON.parse(string)` from its
21
- // declared `unknown` return into a `Record<string, unknown>` without a
22
- // type assertion -- the assertion would be a compile-time lie about
23
- // runtime shape (per the project style guide), and arktype gives an
24
- // honest runtime check.
25
- const ParsedJSONObject = type("Record<string, unknown>");
26
-
27
- // ---------------------------------------------------------------------------
28
- // Request building
29
- //
30
- // Translates the internal ConversationTurn[] format into Gemini's
31
- // `generateContent` / `streamGenerateContent` request body. The harness
32
- // always streams, so the URL pins `:streamGenerateContent?alt=sse`.
33
- //
34
- // `parseResponse` throws unconditionally: a live call surfaces the
35
- // missing parser via the harness's standard inference.error path
36
- // rather than silently dropping events.
37
- // ---------------------------------------------------------------------------
38
-
39
- function buildRequest(
40
- messages: ConversationTurn[],
41
- model: string,
42
- options: InferenceOptions,
43
- ): BuiltRequest {
44
- const systemMessages = messages.filter((m) => m.role === "system");
45
- const conversationMessages = messages.filter((m) => m.role !== "system");
46
-
47
- // System text: concatenated from any system turns in history, unless
48
- // the caller overrides via `options.systemPrompt`. Matches the
49
- // precedence used by the Anthropic adapter. Non-text blocks in a
50
- // system turn surface as an error rather than a silent drop -- the
51
- // rest of the file fails loudly on unsupported block kinds and this
52
- // boundary holds the same discipline.
53
- const systemText = systemMessages
54
- .flatMap((m) =>
55
- m.content.map((b) => {
56
- if (b.type !== "text") {
57
- throw new Error(
58
- `Google GenAI adapter: system turn must contain only text blocks; got ${JSON.stringify(b.type)}.`,
59
- );
60
- }
61
- return b.text;
62
- }),
63
- )
64
- .join("\n\n");
65
- const effectiveSystem = options.systemPrompt
66
- ? options.systemPrompt
67
- : systemText || undefined;
68
-
69
- // A `callId -> functionName` lookup, built once per request from
70
- // every prior assistant `tool_call` block. Gemini's
71
- // `functionResponse` part requires the function name (Anthropic
72
- // requires the callId); the internal `ToolResultBlock` carries only
73
- // the callId, so the name comes from the assistant turn that
74
- // produced the matching `tool_call`. Built once because a per-block
75
- // walk would be O(N^2) in turn count.
76
- const callIdToFunctionName = buildCallIdToFunctionName(messages);
77
-
78
- const contents: GeminiContent[] = conversationMessages.map((msg) =>
79
- toGeminiContent(msg, callIdToFunctionName),
80
- );
81
-
82
- const body: Record<string, unknown> = { contents };
83
-
84
- if (effectiveSystem !== undefined) {
85
- body["systemInstruction"] = { parts: [{ text: effectiveSystem }] };
86
- }
87
-
88
- if (options.tools !== undefined && options.tools.length > 0) {
89
- body["tools"] = [
90
- {
91
- functionDeclarations: options.tools.map((t) => ({
92
- name: t.name,
93
- description: t.description,
94
- parameters: t.inputSchema,
95
- })),
96
- },
97
- ];
98
- }
99
-
100
- const generationConfig = buildGenerationConfig(options);
101
- if (generationConfig !== undefined) {
102
- body["generationConfig"] = generationConfig;
103
- }
104
-
105
- // Caller escape hatch. Documented as shallow-merge over the body
106
- // top-level: a caller passing `providerOptions.generationConfig`
107
- // wholesale replaces the object built above. Same shape semantics as
108
- // the `InferenceOptions.providerOptions` contract on every other
109
- // adapter -- the caller owns the consequences of clobbering a
110
- // structured key.
111
- if (options.providerOptions !== undefined) {
112
- Object.assign(body, options.providerOptions);
113
- }
114
-
115
- // Escape the model name in the URL path. `encodeURIComponent` is a
116
- // no-op on the legitimate Gemini model names in use today
117
- // (alphanumerics, hyphens, periods are all reserved-safe), but
118
- // guards against future model values that arrive from outside
119
- // trusted configuration. The trailing `:streamGenerateContent?alt=sse`
120
- // sits outside the substitution so its colon and query string
121
- // survive intact.
122
- const encodedModel = encodeURIComponent(model);
123
-
124
- return {
125
- url: `/v1beta/models/${encodedModel}:streamGenerateContent?alt=sse`,
126
- headers: {
127
- "content-type": "application/json",
128
- "x-goog-api-key": CREDENTIAL_SENTINEL,
129
- },
130
- body: JSON.stringify(body),
131
- };
132
- }
133
-
134
- // ---------------------------------------------------------------------------
135
- // Internal types
136
- // ---------------------------------------------------------------------------
137
-
138
- // Round-trip wire shapes. `thought` and `thoughtSignature` are
139
- // Gemini-specific metadata that ride alongside the payload-bearing
140
- // fields; both are optional on every part. The translation produces
141
- // a `text` part with `thought: true` for `ThinkingBlock`s, and
142
- // stashes signatures onto the follow-on non-thinking part per the
143
- // pairing logic in `toGeminiContent`.
144
- interface GeminiTextPart {
145
- text: string;
146
- thought?: boolean;
147
- thoughtSignature?: string;
148
- }
149
- interface GeminiInlineDataPart {
150
- inlineData: { mimeType: string; data: string };
151
- thoughtSignature?: string;
152
- }
153
- interface GeminiFileDataPart {
154
- fileData: { mimeType: string; fileUri: string };
155
- thoughtSignature?: string;
156
- }
157
- interface GeminiFunctionCallPart {
158
- functionCall: { name: string; args: Record<string, unknown> };
159
- thoughtSignature?: string;
160
- }
161
- interface GeminiFunctionResponsePart {
162
- functionResponse: { name: string; response: Record<string, unknown> };
163
- }
164
- type GeminiPart =
165
- | GeminiTextPart
166
- | GeminiInlineDataPart
167
- | GeminiFileDataPart
168
- | GeminiFunctionCallPart
169
- | GeminiFunctionResponsePart;
170
-
171
- interface GeminiContent {
172
- role: "user" | "model";
173
- parts: GeminiPart[];
174
- }
175
-
176
- // ---------------------------------------------------------------------------
177
- // Conversation-turn translation
178
- // ---------------------------------------------------------------------------
179
-
180
- function buildCallIdToFunctionName(
181
- messages: ConversationTurn[],
182
- ): Map<string, string> {
183
- const map = new Map<string, string>();
184
- for (const msg of messages) {
185
- if (msg.role !== "assistant") continue;
186
- for (const block of msg.content) {
187
- if (block.type === "tool_call") {
188
- map.set(block.id, block.name);
189
- }
190
- }
191
- }
192
- return map;
193
- }
194
-
195
- function toGeminiContent(
196
- msg: ConversationTurn,
197
- callIdToFunctionName: Map<string, string>,
198
- ): GeminiContent {
199
- const role: "user" | "model" = msg.role === "assistant" ? "model" : "user";
200
- // Role/block pairing: Gemini wants `functionCall` parts only on
201
- // `model`-role contents and `functionResponse` parts only on
202
- // `user`-role contents. The internal `ContentBlock` union does not
203
- // enforce the pairing on its own, so misrouted blocks (a `tool_call`
204
- // on a user turn, a `tool_result` on an assistant turn) would
205
- // otherwise reach Gemini and return an opaque 400. Catch them at
206
- // the marshaling boundary with diagnostic context instead.
207
- for (const block of msg.content) {
208
- if (role === "user" && block.type === "tool_call") {
209
- throw new Error(
210
- `Google GenAI adapter: tool_call blocks must appear on assistant turns, ` +
211
- `found one on a ${JSON.stringify(msg.role)} turn (id ${JSON.stringify(block.id)}).`,
212
- );
213
- }
214
- if (role === "model" && block.type === "tool_result") {
215
- throw new Error(
216
- `Google GenAI adapter: tool_result blocks must appear on user turns, ` +
217
- `found one on a ${JSON.stringify(msg.role)} turn (callId ${JSON.stringify(block.callId)}).`,
218
- );
219
- }
220
- }
221
-
222
- // Positional signature pairing: a `ThinkingBlock` with a signature
223
- // contributes both a `{text, thought: true}` part (no signature on
224
- // it) and a stashed signature that attaches to the NEXT
225
- // non-thinking part in the turn. The wire convention from the
226
- // captured fixtures places `thoughtSignature` on the follow-on
227
- // part (typically `functionCall`), not on the thinking text. Two
228
- // pending signatures in a row, or a turn ending with a signature
229
- // still pending, are encoded as errors: the corpus contains no
230
- // fixture for those shapes and a silent drop would corrupt the
231
- // signed-thinking round-trip Gemini requires.
232
- const parts: GeminiPart[] = [];
233
- let pendingSignature: string | null = null;
234
- for (const block of msg.content) {
235
- const part = toGeminiPart(block, callIdToFunctionName);
236
- const isThinkingPart =
237
- "text" in part && (part as GeminiTextPart).thought === true;
238
- if (isThinkingPart) {
239
- if (pendingSignature !== null) {
240
- throw new Error(
241
- `Google GenAI adapter: encountered a second thinking block on ` +
242
- `assistant turn while a prior thinking-block signature is ` +
243
- `still awaiting a carrier part; the wire convention pairs ` +
244
- `each signed thinking block 1:1 with the next non-thinking ` +
245
- `part.`,
246
- );
247
- }
248
- // Stash the signature off the thinking block (if any) for the
249
- // next non-thinking part to claim. `toGeminiPart` already
250
- // produced a thinking part WITHOUT the signature on it, per
251
- // the wire shape.
252
- if (block.type === "thinking" && block.signature !== undefined) {
253
- pendingSignature = block.signature;
254
- }
255
- parts.push(part);
256
- continue;
257
- }
258
-
259
- if (pendingSignature !== null) {
260
- // Attach the stashed signature to this non-thinking part. The
261
- // mutation matches Gemini's wire shape exactly: the part keeps
262
- // its existing payload and grows a `thoughtSignature` field.
263
- (part as GeminiPart & { thoughtSignature?: string }).thoughtSignature =
264
- pendingSignature;
265
- pendingSignature = null;
266
- }
267
- parts.push(part);
268
- }
269
-
270
- if (pendingSignature !== null) {
271
- throw new Error(
272
- `Google GenAI adapter: assistant turn ends with a thinking-block ` +
273
- `signature awaiting a carrier part. Gemini's wire convention ` +
274
- `requires the signature to ride on a follow-on non-thinking part ` +
275
- `(typically a functionCall); a signed thinking block with no ` +
276
- `follow-on part has no defined wire shape.`,
277
- );
278
- }
279
-
280
- return { role, parts };
281
- }
282
-
283
- function toGeminiPart(
284
- block: ContentBlock,
285
- callIdToFunctionName: Map<string, string>,
286
- ): GeminiPart {
287
- switch (block.type) {
288
- case "text":
289
- return { text: block.text };
290
-
291
- case "image":
292
- case "document":
293
- case "audio":
294
- case "video":
295
- return toGeminiMediaPart(block.source);
296
-
297
- case "tool_call":
298
- return {
299
- functionCall: { name: block.name, args: block.arguments },
300
- };
301
-
302
- case "tool_result":
303
- return toGeminiFunctionResponse(block, callIdToFunctionName);
304
-
305
- case "thinking":
306
- // Thinking text is translated WITHOUT the signature on this
307
- // part. `toGeminiContent`'s positional pairing logic stashes
308
- // the signature off the block and attaches it to the next
309
- // non-thinking part in the same turn (which is where Gemini's
310
- // wire format expects to see `thoughtSignature`). If the
311
- // signature were attached here, both this part and the
312
- // following part would carry it, producing a malformed
313
- // request.
314
- return { text: block.thinking, thought: true };
315
-
316
- case "redacted_thinking":
317
- // Gemini does not emit redacted-thinking blocks; a caller
318
- // passing one in is mixing wire formats. Surface the mismatch
319
- // loudly rather than dropping it silently.
320
- throw new Error(
321
- "Google GenAI adapter does not handle redacted_thinking blocks; " +
322
- "they are Anthropic-specific.",
323
- );
324
-
325
- case "citation":
326
- // Citations are output-only blocks: the model produces them as
327
- // grounding/source references for its own text. Echoing one
328
- // back in an input turn has no defined wire shape and is almost
329
- // certainly a caller bug -- fail rather than send a nonsense
330
- // request.
331
- throw new Error(
332
- "Google GenAI adapter does not echo citation blocks; citations " +
333
- "are emitted by the model, not sent to it.",
334
- );
335
-
336
- case "code_execution_request":
337
- case "code_execution_result":
338
- // Code-execution round-trip needs Gemini's
339
- // `executableCode`/`codeExecutionResult` part shapes, which
340
- // the adapter does not emit. Surface the gap rather than
341
- // produce a request with these blocks missing.
342
- throw new Error(
343
- `Google GenAI adapter does not handle ${block.type} content blocks.`,
344
- );
345
-
346
- case "refusal":
347
- // Refusal blocks are an OpenAI strict-mode output shape and have
348
- // no Gemini wire equivalent. Echoing one back into a Gemini
349
- // request has no defined translation; fail loudly at the
350
- // marshaling site rather than silently drop the block.
351
- throw new Error(
352
- "Google GenAI adapter does not handle refusal content blocks; " +
353
- "they are emitted by OpenAI strict-mode structured outputs.",
354
- );
355
- }
356
- }
357
-
358
- // Marshal an internal MediaSource into a Gemini part. `base64`
359
- // inlines the bytes; `file-reference` and `url` both target Gemini's
360
- // `fileData` with `fileUri` -- the Files API returns URIs, and Gemini
361
- // also accepts public HTTP(S) URLs through the same field.
362
- function toGeminiMediaPart(
363
- source: MediaSource,
364
- ): GeminiInlineDataPart | GeminiFileDataPart {
365
- if (source.kind === "base64") {
366
- return {
367
- inlineData: { mimeType: source.mimeType, data: source.data },
368
- };
369
- }
370
- if (source.kind === "file-reference") {
371
- return {
372
- fileData: { mimeType: source.mimeType, fileUri: source.reference },
373
- };
374
- }
375
- if (source.kind === "url") {
376
- return {
377
- fileData: { mimeType: source.mimeType, fileUri: source.url },
378
- };
379
- }
380
- // Exhaustiveness: a new MediaSource variant added without a case
381
- // here fails this compile-time check.
382
- source satisfies never;
383
- throw new Error(`unreachable: unknown MediaSource kind`);
384
- }
385
-
386
- // Marshal a tool_result into Gemini's functionResponse part shape.
387
- // The contract is deliberately strict: Gemini's `response` is a JSON
388
- // object, and a permissive "guess at the shape" mapping silently
389
- // reshapes payloads when callers don't intend it. The four accepted
390
- // shapes are:
391
- //
392
- // - exactly one text block whose text parses as a plain JSON object
393
- // -> that object becomes `response`
394
- // - exactly one text block whose text does not parse as an object
395
- // -> `{ result: text }` (or `{ error: text }` when isError is true)
396
- // - zero or multiple text blocks -> throw; the caller must collapse
397
- // to a single text block before handing the tool_result to the
398
- // adapter
399
- // - any non-text block (image/audio/video/document) inside the
400
- // tool_result -> throw; Gemini's functionResponse accepts no media
401
- //
402
- // The unknown-callId case throws with the unknown id and the set of
403
- // known ids so a malformed conversation surfaces at the marshaling
404
- // site instead of as an opaque HTTP 400 a round-trip later.
405
- function toGeminiFunctionResponse(
406
- block: Extract<ContentBlock, { type: "tool_result" }>,
407
- callIdToFunctionName: Map<string, string>,
408
- ): GeminiFunctionResponsePart {
409
- const name = callIdToFunctionName.get(block.callId);
410
- if (name === undefined) {
411
- const known = Array.from(callIdToFunctionName.keys());
412
- throw new Error(
413
- `Google GenAI adapter: tool_result.callId ${JSON.stringify(block.callId)} ` +
414
- `has no matching tool_call in the conversation history. ` +
415
- `Known callIds: ${known.length === 0 ? "(none)" : known.map((k) => JSON.stringify(k)).join(", ")}.`,
416
- );
417
- }
418
-
419
- if (block.content.length !== 1) {
420
- throw new Error(
421
- `Google GenAI adapter: tool_result must contain exactly one text block, ` +
422
- `got ${String(block.content.length)} blocks for callId ` +
423
- `${JSON.stringify(block.callId)}.`,
424
- );
425
- }
426
- const only = block.content[0];
427
- if (only === undefined || only.type !== "text") {
428
- const seenType = only?.type ?? "undefined";
429
- throw new Error(
430
- `Google GenAI adapter: tool_result content block must be of type "text", ` +
431
- `got ${JSON.stringify(seenType)} for callId ${JSON.stringify(block.callId)}.`,
432
- );
433
- }
434
-
435
- const text = only.text;
436
- const parsed = tryParseJSONObject(text);
437
-
438
- let response: Record<string, unknown>;
439
- if (parsed !== null) {
440
- response = parsed;
441
- } else if (block.isError === true) {
442
- response = { error: text };
443
- } else {
444
- response = { result: text };
445
- }
446
-
447
- return { functionResponse: { name, response } };
448
- }
449
-
450
- // Returns the parsed value when `text` is a JSON-encoded plain
451
- // object, or `null` for any other shape: arrays, primitives
452
- // (numbers, strings, booleans, null), and JSON parse errors all map
453
- // to `null`. Wrapping is the responsibility of the caller -- this
454
- // helper only confirms "is the text exactly a JSON object we can use
455
- // verbatim."
456
- function tryParseJSONObject(text: string): Record<string, unknown> | null {
457
- let parsed: unknown;
458
- try {
459
- parsed = JSON.parse(text);
460
- } catch {
461
- return null;
462
- }
463
- // `ParsedJSONObject` (arktype `Record<string, unknown>`) accepts
464
- // arrays -- in arktype's view an array IS a record with
465
- // numeric-string keys -- so the array-rejection has to happen
466
- // before the validator runs. Without this guard, a tool that
467
- // returns `"[1,2,3]"` would be silently promoted to a `response`
468
- // shape Gemini cannot consume.
469
- if (Array.isArray(parsed)) {
470
- return null;
471
- }
472
- const validated = ParsedJSONObject(parsed);
473
- if (validated instanceof type.errors) {
474
- return null;
475
- }
476
- return validated;
477
- }
478
-
479
- // ---------------------------------------------------------------------------
480
- // generationConfig
481
- // ---------------------------------------------------------------------------
482
-
483
- function buildGenerationConfig(
484
- options: InferenceOptions,
485
- ): Record<string, unknown> | undefined {
486
- const config: Record<string, unknown> = {};
487
-
488
- if (options.maxTokens !== undefined) {
489
- config["maxOutputTokens"] = options.maxTokens;
490
- }
491
- if (options.temperature !== undefined) {
492
- config["temperature"] = options.temperature;
493
- }
494
-
495
- // thinking.enabled === true -> include a budget (default 1024) and
496
- // ask Gemini to surface thought parts
497
- // thinking.enabled === false -> set the budget to 0 to disable
498
- // thinking; Gemini's 2.5-series default
499
- // is NOT zero, so "thinking off" needs
500
- // an explicit signal
501
- // thinking absent -> omit thinkingConfig entirely; Gemini
502
- // uses the model's default
503
- if (options.thinking !== undefined) {
504
- if (options.thinking.enabled) {
505
- const thinkingBudget = options.thinking.budgetTokens ?? 1024;
506
- config["thinkingConfig"] = {
507
- thinkingBudget,
508
- includeThoughts: true,
509
- };
510
- } else {
511
- config["thinkingConfig"] = { thinkingBudget: 0 };
512
- }
513
- }
514
-
515
- if (
516
- options.responseModalities !== undefined &&
517
- options.responseModalities.length > 0
518
- ) {
519
- config["responseModalities"] =
520
- options.responseModalities.map(toGeminiModality);
521
- }
522
-
523
- if (options.responseFormat !== undefined) {
524
- applyResponseFormat(config, options.responseFormat);
525
- }
526
-
527
- return Object.keys(config).length === 0 ? undefined : config;
528
- }
529
-
530
- // Translate the internal `responseFormat` union to Gemini's
531
- // generationConfig fields. Gemini exposes structured outputs through
532
- // the pair (`responseMimeType`, `responseSchema`) rather than a
533
- // dedicated union: setting the MIME type alone gives free-form JSON;
534
- // pairing it with a schema constrains the output to schema-conformant
535
- // JSON. The OpenAI-specific `name` and `strict` fields have no Gemini
536
- // equivalent and are ignored when present.
537
- //
538
- // The `schema` field is forwarded verbatim. Gemini enforces a JSON
539
- // Schema subset (no `oneOf`, limited `pattern`, no `$ref`, etc.); the
540
- // adapter does not pre-validate the caller's schema against that
541
- // subset and instead surfaces Gemini's HTTP error if the model
542
- // rejects it. INFERENCE.md documents the subset for callers.
543
- function applyResponseFormat(
544
- config: Record<string, unknown>,
545
- format: NonNullable<InferenceOptions["responseFormat"]>,
546
- ): void {
547
- switch (format.kind) {
548
- case "text":
549
- // Free-form text is Gemini's default; omitting the MIME type
550
- // produces the same behavior. Set nothing to keep the request
551
- // body minimal.
552
- return;
553
- case "json":
554
- config["responseMimeType"] = "application/json";
555
- return;
556
- case "json-schema":
557
- config["responseMimeType"] = "application/json";
558
- config["responseSchema"] = format.schema;
559
- return;
560
- }
561
- }
562
-
563
- function toGeminiModality(m: "text" | "image" | "audio"): string {
564
- switch (m) {
565
- case "text":
566
- return "TEXT";
567
- case "image":
568
- return "IMAGE";
569
- case "audio":
570
- return "AUDIO";
571
- }
572
- }
573
-
574
- // ---------------------------------------------------------------------------
575
- // Response parsing
576
- //
577
- // Each Gemini SSE event is one complete JSON object delivered through
578
- // `parseSSE` (event boundary `\n\n`); a partial JSON would mean the
579
- // SSE framing layer broke its contract, not a Gemini protocol
580
- // violation. Per the adapter contract in
581
- // `packages/inference/src/adapter.ts`, `ProtocolMismatchError` is the
582
- // only throw type the parser is allowed to raise -- the harness's
583
- // `classifyStreamError` recognizes it.
584
- //
585
- // Text deltas on the Gemini wire are incremental: each event carries
586
- // only the new tokens, not the accumulated text. The harness owns
587
- // partial-state accumulation; the parser emits placeholder
588
- // `EMPTY_PARTIAL` and the harness fills the real value in.
589
- // ---------------------------------------------------------------------------
590
-
591
- const EMPTY_PARTIAL: PartialMessage = { text: "" };
592
-
593
- // Wire shape: every field is optional. Gemini emits candidates without
594
- // content during safety-filter rejections, sends events with only
595
- // `usageMetadata` populated, and may omit `finishReason` on every
596
- // event except the terminal one. The parser handles the absences
597
- // directly rather than via schema-default coercion.
598
- //
599
- // The schema models the five payload kinds the parser handles:
600
- // `text`, `functionCall`, `inlineData` (image output),
601
- // `executableCode` (code-execution request), and
602
- // `codeExecutionResult` (code-execution result). They are mutually
603
- // exclusive on the wire: a single part is one kind of content.
604
- // Arktype's open-object semantics will accept multiple set
605
- // simultaneously, so `parseResponse` enforces the exclusivity at
606
- // the boundary via `assertSinglePayload` and throws
607
- // `ProtocolMismatchError` on a violation. `inlineData` is
608
- // additionally constrained to `image/*` MIME types at the
609
- // `emitPart` boundary; a non-image MIME on `inlineData` is treated
610
- // as a wire shape the parser does not handle (rather than silently
611
- // wrapping arbitrary bytes as an ImageBlock).
612
- //
613
- // `thought` and `thoughtSignature` are metadata that ride alongside
614
- // the payload: `thought: true` is only meaningful on a `text` part
615
- // (a non-text part with `thought: true` is a wire violation rejected
616
- // at the boundary), and `thoughtSignature` carries the opaque
617
- // per-thinking-block signature that Gemini requires echoed back on
618
- // follow-up turns. Both can be absent.
619
- const GeminiFunctionCallPayload = type({
620
- name: "string",
621
- args: "Record<string, unknown>",
622
- });
623
-
624
- const GeminiInlineDataPayload = type({
625
- mimeType: "string",
626
- data: "string",
627
- });
628
-
629
- const GeminiExecutableCodePayload = type({
630
- language: "string",
631
- code: "string",
632
- });
633
-
634
- const GeminiCodeExecutionResultPayload = type({
635
- outcome: "string",
636
- // The combined stdout/stderr stream. Gemini does not split the
637
- // streams; the parser routes this verbatim into the result
638
- // block's `stdout` and leaves `stderr` empty (per the contract
639
- // documented on `CodeExecutionResultBlock`).
640
- "output?": "string",
641
- });
642
-
643
- const GeminiPart = type({
644
- "text?": "string",
645
- "thought?": "boolean",
646
- "thoughtSignature?": "string",
647
- "functionCall?": GeminiFunctionCallPayload,
648
- "inlineData?": GeminiInlineDataPayload,
649
- "executableCode?": GeminiExecutableCodePayload,
650
- "codeExecutionResult?": GeminiCodeExecutionResultPayload,
651
- });
652
-
653
- const GeminiContent = type({
654
- "parts?": GeminiPart.array(),
655
- "role?": "string",
656
- });
657
-
658
- // Grounding metadata rides on a candidate whenever the request
659
- // enabled `tools: [{googleSearch: {}}]`. The captured fixture
660
- // shows `groundingMetadata: {}` present on every SSE event with
661
- // `groundingChunks`/`groundingSupports` populated only on the
662
- // terminal event; intermediate empty-metadata events short-circuit
663
- // in `emitGroundingCitations` via the `supports.length === 0`
664
- // early return. The two arrays the parser consumes are:
665
- //
666
- // - `groundingChunks[].web`: per-source `{uri, title}` entries.
667
- // Indexed positionally; the chunks are the citation sources.
668
- //
669
- // - `groundingSupports[]`: pairings between an output text span
670
- // (`segment: {startIndex, endIndex, text}`) and one or more
671
- // chunk indices (`groundingChunkIndices: number[]`). Each
672
- // index-into-chunks expands into one CitationBlock during
673
- // emission.
674
- //
675
- // `searchEntryPoint` (HTML rendering widget) and `webSearchQueries`
676
- // (the model-issued queries) carry no per-text-span attribution and
677
- // are not surfaced as citation blocks. Validating them here would
678
- // pin a wire shape the parser does not consume; the schema admits
679
- // them implicitly via arktype's open-object semantics.
680
- const GeminiGroundingChunk = type({
681
- // Each chunk currently arrives with a single `web` shape. Other
682
- // chunk kinds (e.g. document, retrieved-context) are not in the
683
- // captured corpus; admitting them as schema-validated absences
684
- // keeps `web`-shaped chunks well-typed without committing to a
685
- // discriminated union the parser cannot dispatch over.
686
- "web?": type({ uri: "string", title: "string" }),
687
- });
688
-
689
- const GeminiGroundingSupport = type({
690
- segment: {
691
- startIndex: "number",
692
- endIndex: "number",
693
- text: "string",
694
- },
695
- groundingChunkIndices: "number[]",
696
- });
697
-
698
- const GeminiGroundingMetadata = type({
699
- "groundingChunks?": GeminiGroundingChunk.array(),
700
- "groundingSupports?": GeminiGroundingSupport.array(),
701
- });
702
-
703
- const GeminiCandidate = type({
704
- "content?": GeminiContent,
705
- "finishReason?": "string",
706
- "index?": "number",
707
- "groundingMetadata?": GeminiGroundingMetadata,
708
- });
709
-
710
- // `thoughtsTokenCount` is populated on responses with thinking
711
- // enabled; it maps directly onto `TokenUsage.thinking`.
712
- // `cachedContentTokenCount` is populated when context caching is in
713
- // use and maps onto `TokenUsage.cacheRead`. Both are absent on
714
- // responses that don't exercise the corresponding feature, and the
715
- // parser treats absence as zero.
716
- const GeminiUsageMetadata = type({
717
- "promptTokenCount?": "number",
718
- "candidatesTokenCount?": "number",
719
- "totalTokenCount?": "number",
720
- "thoughtsTokenCount?": "number",
721
- "cachedContentTokenCount?": "number",
722
- });
723
-
724
- const GeminiSSEEvent = type({
725
- "candidates?": GeminiCandidate.array(),
726
- "usageMetadata?": GeminiUsageMetadata,
727
- // `modelVersion` and `responseId` are dropped at this layer. The
728
- // harness's `AssistantTurn.model` is set from the requested model
729
- // string, not from the served `modelVersion` -- which can differ
730
- // (`gemini-2.5-flash` requested may return `gemini-2.5-flash-001`).
731
- // Surfacing the served version is a separate concern; for now the
732
- // request-side identifier is what downstream consumers see.
733
- "modelVersion?": "string",
734
- "responseId?": "string",
735
- });
736
-
737
- // Per-request parser state. Gemini provides no explicit content-block
738
- // index on the wire -- block boundaries are positional, derived from
739
- // the order and kind of parts. The parser allocates indices itself
740
- // and coalesces consecutive same-kind parts into one logical block.
741
- //
742
- // - `nextBlockIndex` is the monotonic counter for newly allocated
743
- // blocks across the entire request (incremented on each
744
- // allocation, never reset).
745
- //
746
- // - `currentBlock` is the in-progress block that subsequent
747
- // same-kind parts extend. Reset to `null` when a different-kind
748
- // part appears -- the next part of any kind starts a fresh block.
749
- // Function-call blocks are atomic (a single part = a complete
750
- // tool call) and never become the `currentBlock`.
751
- //
752
- // - `pendingSignatureAnchor` is set when a thinking block closes
753
- // and cleared when the next non-thinking part that carries
754
- // `thoughtSignature` attaches its signature to that index. A
755
- // standalone signature-only part (no payload) also consumes the
756
- // anchor. The lifecycle is deliberately narrow: keeping a
757
- // long-lived "most recent thinking block" pointer would let a
758
- // signature on, say, the third functionCall attach to the first
759
- // thinking block when two unrelated functionCalls happened in
760
- // between. The wire convention is "the signature belongs to the
761
- // immediately preceding thinking," and the state encodes exactly
762
- // that.
763
- interface GeminiParserState {
764
- nextBlockIndex: number;
765
- currentBlock: { kind: "text" | "thinking"; index: number } | null;
766
- pendingSignatureAnchor: number | null;
767
- // Unmatched-request stack of depth 1: when the parser emits an
768
- // `inference.code_execution.start` for an `executableCode` part,
769
- // the synthetic request id lands here and is consumed by the
770
- // immediately-following `codeExecutionResult` part. The wire
771
- // convention (from the captured Gemini fixture) is strict LIFO
772
- // with depth 1: request, then result, then optional follow-on
773
- // text. The depth-1 invariant is enforced: a second request
774
- // arriving while the slot is occupied, a result arriving with
775
- // the slot empty, and a non-empty slot at the end of a response
776
- // all throw `ProtocolMismatchError`.
777
- pendingExecutionRequestId: string | null;
778
- }
779
-
780
- function createParserState(): GeminiParserState {
781
- return {
782
- nextBlockIndex: 0,
783
- currentBlock: null,
784
- pendingSignatureAnchor: null,
785
- pendingExecutionRequestId: null,
786
- };
787
- }
788
-
789
- // Open or extend a text/thinking block, returning the block index.
790
- // A part of the same kind as the current block extends it; a part of
791
- // a different kind closes the current block and allocates a new
792
- // index. Closing a thinking block stashes its index in
793
- // `pendingSignatureAnchor` so a subsequent non-thinking part's
794
- // `thoughtSignature` can attach to it.
795
- function openOrExtendBlock(
796
- state: GeminiParserState,
797
- kind: "text" | "thinking",
798
- rawForError: unknown,
799
- ): number {
800
- if (state.currentBlock !== null && state.currentBlock.kind === kind) {
801
- return state.currentBlock.index;
802
- }
803
- closeCurrentBlock(state, rawForError);
804
- const index = state.nextBlockIndex++;
805
- state.currentBlock = { kind, index };
806
- return index;
807
- }
808
-
809
- // Close the current text/thinking block. A thinking block being
810
- // closed sets `pendingSignatureAnchor` so the next non-thinking part
811
- // can claim it for its `thoughtSignature`. If two thinking blocks
812
- // close in a row without an intervening signature consumer, surface
813
- // it loudly -- the corpus has no fixture exercising that shape and
814
- // silently overwriting the anchor would route a signature to the
815
- // wrong block.
816
- function closeCurrentBlock(
817
- state: GeminiParserState,
818
- rawForError: unknown,
819
- ): void {
820
- if (state.currentBlock?.kind === "thinking") {
821
- if (state.pendingSignatureAnchor !== null) {
822
- throw new ProtocolMismatchError(
823
- `google-genai parseResponse: second thinking block closed with a ` +
824
- `prior signature anchor still pending (anchor block index ` +
825
- `${String(state.pendingSignatureAnchor)}); the wire convention ` +
826
- `pairs each thinking block 1:1 with the next non-thinking ` +
827
- `carrier and the corpus contains no fixture for the unpaired ` +
828
- `case.`,
829
- rawForError,
830
- );
831
- }
832
- state.pendingSignatureAnchor = state.currentBlock.index;
833
- }
834
- state.currentBlock = null;
835
- }
836
-
837
- // Enforce mutual exclusivity of payload-bearing fields and correct
838
- // placement of the `thought` flag on a single part. The schema
839
- // models five payload fields (`text`, `functionCall`, `inlineData`,
840
- // `executableCode`, `codeExecutionResult`); arktype's open-object
841
- // semantics would otherwise admit a part with more than one set,
842
- // or with `thought: true` on a non-text part. Both are wire
843
- // violations and surface as `ProtocolMismatchError` here. A part
844
- // with zero payload fields is only legal when a `thoughtSignature`
845
- // is present (signature-carrier-only part, not seen in the current
846
- // corpus but spec-permitted).
847
- function assertSinglePayload(
848
- part: typeof GeminiPart.infer,
849
- raw: unknown,
850
- ): void {
851
- const payloads: string[] = [];
852
- if (part.text !== undefined) payloads.push("text");
853
- if (part.functionCall !== undefined) payloads.push("functionCall");
854
- if (part.inlineData !== undefined) payloads.push("inlineData");
855
- if (part.executableCode !== undefined) payloads.push("executableCode");
856
- if (part.codeExecutionResult !== undefined) {
857
- payloads.push("codeExecutionResult");
858
- }
859
-
860
- if (payloads.length > 1) {
861
- throw new ProtocolMismatchError(
862
- `google-genai parseResponse: part has multiple payload fields set ` +
863
- `(${payloads.join("+")}); exactly one of ` +
864
- `{text, functionCall, inlineData, executableCode, ` +
865
- `codeExecutionResult} must be present per Gemini wire convention.`,
866
- raw,
867
- );
868
- }
869
- if (payloads.length === 0 && part.thoughtSignature === undefined) {
870
- throw new ProtocolMismatchError(
871
- `google-genai parseResponse: part has no payload and no ` +
872
- `thoughtSignature; an empty part is not a defined wire shape.`,
873
- raw,
874
- );
875
- }
876
- // `thought: true` is only meaningful on a text part; the flag's
877
- // sole purpose is to discriminate thinking text from regular
878
- // assistant text. A `thought` flag on a `functionCall` part or a
879
- // payload-free part has no defined wire interpretation.
880
- if (part.thought === true && part.text === undefined) {
881
- throw new ProtocolMismatchError(
882
- `google-genai parseResponse: \`thought: true\` set on a part with ` +
883
- `no \`text\` payload; the flag is only valid on text parts.`,
884
- raw,
885
- );
886
- }
887
- }
888
-
889
- function emitPart(
890
- part: typeof GeminiPart.infer,
891
- state: GeminiParserState,
892
- seq: number,
893
- out: InferenceEvent[],
894
- raw: unknown,
895
- ): void {
896
- assertSinglePayload(part, raw);
897
-
898
- // text part with `thought: true` -- belongs to a thinking block.
899
- if (part.text !== undefined && part.thought === true) {
900
- const index = openOrExtendBlock(state, "thinking", raw);
901
- // Anchor the block in the harness's per-index map. An empty
902
- // text part with only a `thoughtSignature` would otherwise route
903
- // the signature to an index the harness has never seen. The
904
- // empty-token delta mirrors the Anthropic adapter's anchoring
905
- // pattern for the same invariant.
906
- out.push({
907
- type: "inference.thinking.delta",
908
- seq,
909
- data: {
910
- token: part.text,
911
- partial: EMPTY_PARTIAL,
912
- index,
913
- },
914
- });
915
- // A thinking part may itself carry a signature (signature on the
916
- // thinking part rather than on a follow-on functionCall). Attach
917
- // it directly to this thinking block's index; it consumes any
918
- // pending anchor too because the signature on `this` thinking
919
- // part takes precedence.
920
- if (part.thoughtSignature !== undefined) {
921
- out.push({
922
- type: "inference.thinking.signature",
923
- seq,
924
- data: { signature: part.thoughtSignature, index },
925
- });
926
- state.pendingSignatureAnchor = null;
927
- }
928
- return;
929
- }
930
-
931
- // text part without `thought` -- belongs to a text block.
932
- if (part.text !== undefined) {
933
- if (part.text === "") {
934
- // Empty text parts emit no delta. A signature-bearing
935
- // empty-text part is still the carrier opportunity for any
936
- // open thinking block: close the current block first so the
937
- // thinking-block index lands in `pendingSignatureAnchor`,
938
- // then consume the signature against it. Without that claim
939
- // path, the signature would silently evaporate (the payload
940
- // has nowhere else to surface) -- the empty payload is the
941
- // ONLY signal Gemini sends for an authenticated empty-text
942
- // carrier. An empty-text part without a signature is a true
943
- // no-op -- it neither closes the current block nor consumes
944
- // the carrier opportunity, so a follow-on same-kind part
945
- // extends what was open.
946
- if (part.thoughtSignature !== undefined) {
947
- closeCurrentBlock(state, raw);
948
- consumeSignature(state, part.thoughtSignature, seq, out, raw);
949
- }
950
- return;
951
- }
952
- const index = openOrExtendBlock(state, "text", raw);
953
- out.push({
954
- type: "inference.text.delta",
955
- seq,
956
- data: {
957
- token: part.text,
958
- partial: EMPTY_PARTIAL,
959
- index,
960
- },
961
- });
962
- // Settle the carrier opportunity. A `thoughtSignature` on the
963
- // part consumes the pending anchor (the signature
964
- // authenticates the preceding thinking, not the text block);
965
- // a signature-less part still ends the carrier opportunity by
966
- // discarding the anchor. The wire convention is that the FIRST
967
- // non-thinking part after a thinking block is the only carrier
968
- // chance -- a later thinking block cannot retroactively claim
969
- // a stale anchor.
970
- settleCarrierOpportunity(state, part.thoughtSignature, seq, out, raw);
971
- return;
972
- }
973
-
974
- // functionCall part -- atomic block, allocates a fresh index and
975
- // does not become the `currentBlock` (a follow-on text or thinking
976
- // part starts a new block of that kind).
977
- if (part.functionCall !== undefined) {
978
- closeCurrentBlock(state, raw);
979
- const fc = part.functionCall;
980
- const index = state.nextBlockIndex++;
981
- // Synthetic callId: Gemini's `functionCall` has no wire-level id
982
- // field. The harness keys on this id end-to-end (start, delta,
983
- // round-trip lookup); `String(index)` matches the Anthropic
984
- // adapter's fallback when its wire id is absent. Block indices
985
- // are unique within a request by construction.
986
- const callId = String(index);
987
-
988
- // Settle the carrier opportunity BEFORE the tool_call.start/delta
989
- // pair. The signature event carries the thinking block's explicit
990
- // index in its data, so the harness routes it correctly regardless
991
- // of arrival order; the ordering here is for positional consumers
992
- // of the event stream (snapshot tests, debuggers, anything reading
993
- // the sequence by position rather than by index). The same settle
994
- // call also discards a stale anchor when no signature is present,
995
- // so a later thinking block does not trip the "two thinking
996
- // blocks closed" guard on an anchor the current carrier already
997
- // declined to claim.
998
- settleCarrierOpportunity(state, part.thoughtSignature, seq, out, raw);
999
-
1000
- out.push({
1001
- type: "inference.tool_call.start",
1002
- seq,
1003
- data: {
1004
- callId,
1005
- name: fc.name,
1006
- partial: EMPTY_PARTIAL,
1007
- index,
1008
- },
1009
- });
1010
- // Gemini delivers `args` complete in a single part -- no
1011
- // streaming JSON fragments. Emit the full serialized args in one
1012
- // delta so the harness's end-of-stream finalization (which keys
1013
- // on `openToolCalls` and re-parses the accumulated argsBuffer)
1014
- // produces a `tool_call.end` with the correct arguments. The
1015
- // harness owns the `tool_call.end` emission; adapters emit only
1016
- // `start` + `delta`.
1017
- out.push({
1018
- type: "inference.tool_call.delta",
1019
- seq,
1020
- data: {
1021
- callId,
1022
- argumentFragment: JSON.stringify(fc.args),
1023
- partial: EMPTY_PARTIAL,
1024
- index,
1025
- },
1026
- });
1027
- return;
1028
- }
1029
-
1030
- // inlineData part -- atomic image-output block. The image arrives
1031
- // complete in a single SSE event (no streaming chunks of base64),
1032
- // so a new block index is allocated and the ImageBlock is emitted
1033
- // in one `inference.image_output` event. The signature carrier
1034
- // semantics mirror the functionCall path: any pending thinking
1035
- // signature is settled BEFORE the image_output event so it
1036
- // attaches to the preceding thinking block, not the image block.
1037
- if (part.inlineData !== undefined) {
1038
- // The parser wraps inlineData as an `ImageBlock`, so a non-
1039
- // image MIME (e.g. audio/wav, application/pdf) would silently
1040
- // mistype the payload. Reject at the boundary rather than
1041
- // produce a confidently-wrong ContentBlock.
1042
- if (!part.inlineData.mimeType.startsWith("image/")) {
1043
- throw new ProtocolMismatchError(
1044
- `google-genai parseResponse: inlineData part has non-image ` +
1045
- `mimeType ${JSON.stringify(part.inlineData.mimeType)}; the ` +
1046
- `parser wraps inlineData as an ImageBlock and does not ` +
1047
- `handle other modalities on this code path.`,
1048
- raw,
1049
- );
1050
- }
1051
- closeCurrentBlock(state, raw);
1052
- const index = state.nextBlockIndex++;
1053
- settleCarrierOpportunity(state, part.thoughtSignature, seq, out, raw);
1054
- out.push({
1055
- type: "inference.image_output",
1056
- seq,
1057
- data: {
1058
- image: {
1059
- type: "image",
1060
- source: {
1061
- kind: "base64",
1062
- mimeType: part.inlineData.mimeType,
1063
- data: part.inlineData.data,
1064
- },
1065
- },
1066
- index,
1067
- },
1068
- });
1069
- return;
1070
- }
1071
-
1072
- // executableCode part -- atomic code-execution request block.
1073
- // Gemini delivers the full source in one part (no chunked code
1074
- // streaming), so a fresh block index is allocated and the request
1075
- // block is emitted in one `inference.code_execution.start` event.
1076
- // The synthetic id is `gemini-exec-<index>` where `index` is the
1077
- // content-block index allocated within THIS response (deterministic
1078
- // per-response so replays of the same response produce the same
1079
- // ids). It satisfies the `CodeExecutionRequestBlock.id` contract
1080
- // ("synthesized by the adapter for providers that don't emit one,
1081
- // using a deterministic per-response position-based scheme so
1082
- // replays match"). The id then lands in
1083
- // `pendingExecutionRequestId` so the next codeExecutionResult
1084
- // part can back-point its `requestId` to it.
1085
- if (part.executableCode !== undefined) {
1086
- // Precondition first, before any state mutation or event
1087
- // emission: a depth-1 violation must not leave a half-applied
1088
- // close/allocate/settle sequence in `state` and `out`. The
1089
- // caller discards `out` on throw today, so the difference is
1090
- // not observable, but the ordering keeps the throw faithful
1091
- // to "this part was rejected entirely."
1092
- if (state.pendingExecutionRequestId !== null) {
1093
- throw new ProtocolMismatchError(
1094
- `google-genai parseResponse: encountered a second executableCode ` +
1095
- `part while the prior code-execution request ` +
1096
- `${JSON.stringify(state.pendingExecutionRequestId)} is still ` +
1097
- `unmatched. The wire convention is strict LIFO with depth 1 ` +
1098
- `(request, then result); no fixture exercises depth > 1.`,
1099
- raw,
1100
- );
1101
- }
1102
- closeCurrentBlock(state, raw);
1103
- const index = state.nextBlockIndex++;
1104
- settleCarrierOpportunity(state, part.thoughtSignature, seq, out, raw);
1105
-
1106
- const requestId = `gemini-exec-${String(index)}`;
1107
- state.pendingExecutionRequestId = requestId;
1108
-
1109
- const ec = part.executableCode;
1110
- const request: CodeExecutionRequestBlock = {
1111
- type: "code_execution_request",
1112
- id: requestId,
1113
- code: ec.code,
1114
- // Pass `language` through verbatim. Gemini emits SCREAMING_CASE
1115
- // (e.g. `"PYTHON"`); the type contract is "adapters MUST NOT
1116
- // default this -- callers narrow on its presence." Comparing
1117
- // values cross-provider requires case-insensitive logic at
1118
- // the consumer.
1119
- language: ec.language,
1120
- };
1121
- out.push({
1122
- type: "inference.code_execution.start",
1123
- seq,
1124
- data: { request, index },
1125
- });
1126
- return;
1127
- }
1128
-
1129
- // codeExecutionResult part -- atomic result block. Pairs against
1130
- // the most recently emitted `executableCode` part via
1131
- // `pendingExecutionRequestId` (Gemini's wire carries no explicit
1132
- // back-pointer; the immediately-preceding request is the
1133
- // implicit owner). The slot read is destructive: clearing it
1134
- // here forces the depth-1 invariant on subsequent parts, and a
1135
- // result arriving with the slot empty throws.
1136
- if (part.codeExecutionResult !== undefined) {
1137
- // Precondition first, before any state mutation or event
1138
- // emission: an empty-slot violation must not leave a
1139
- // half-applied close/allocate/settle sequence behind. Same
1140
- // discipline as the executableCode branch above.
1141
- const requestId = state.pendingExecutionRequestId;
1142
- if (requestId === null) {
1143
- throw new ProtocolMismatchError(
1144
- `google-genai parseResponse: codeExecutionResult part has no ` +
1145
- `preceding executableCode part in this request to pair against.`,
1146
- raw,
1147
- );
1148
- }
1149
- // outcomeToStatus throws on an unknown outcome -- run it before
1150
- // any other state mutation so the throw cleanly rejects the
1151
- // part without partial side effects.
1152
- const cer = part.codeExecutionResult;
1153
- const status = outcomeToStatus(cer.outcome, raw);
1154
-
1155
- closeCurrentBlock(state, raw);
1156
- const index = state.nextBlockIndex++;
1157
- settleCarrierOpportunity(state, part.thoughtSignature, seq, out, raw);
1158
- state.pendingExecutionRequestId = null;
1159
-
1160
- const result: CodeExecutionResultBlock = {
1161
- type: "code_execution_result",
1162
- requestId,
1163
- status,
1164
- // Gemini's `output` is the combined stdout+stderr stream.
1165
- // Per the `CodeExecutionResultBlock.stdout` comment, providers
1166
- // that don't split the streams map their combined output here
1167
- // and leave `stderr` empty.
1168
- ...(cer.output !== undefined ? { stdout: cer.output } : {}),
1169
- providerOutcome: cer.outcome,
1170
- };
1171
- out.push({
1172
- type: "inference.code_execution.result",
1173
- seq,
1174
- data: { result, index },
1175
- });
1176
- return;
1177
- }
1178
-
1179
- // Signature-only part (no payload, signature set). A still-open
1180
- // thinking block is closed first so its index lands in
1181
- // `pendingSignatureAnchor` before `consumeSignature` claims it --
1182
- // same shape as the empty-text-with-signature branch above. No
1183
- // new block is opened.
1184
- if (part.thoughtSignature !== undefined) {
1185
- closeCurrentBlock(state, raw);
1186
- consumeSignature(state, part.thoughtSignature, seq, out, raw);
1187
- return;
1188
- }
1189
-
1190
- // `assertSinglePayload` above rules out the no-payload-no-signature
1191
- // case, so a part that lands here had a payload that no earlier
1192
- // branch claimed. The schema models five payload fields (`text`,
1193
- // `functionCall`, `inlineData`, `executableCode`,
1194
- // `codeExecutionResult`); all five have their own branches
1195
- // above. Reaching this line implies the schema has grown a new
1196
- // payload field without a matching branch in `emitPart`.
1197
- throw new ProtocolMismatchError(
1198
- `google-genai parseResponse: unhandled part shape; the schema admits ` +
1199
- `a payload field that emitPart has no branch for.`,
1200
- raw,
1201
- );
1202
- }
1203
-
1204
- // Emit `inference.citation` events from a candidate's
1205
- // `groundingMetadata`. Each `groundingSupport` expands into one
1206
- // citation per referenced chunk: a span that cites four sources
1207
- // produces four citations with the same `citedText` and
1208
- // `textOffset` but distinct `source` entries. Consumers see the
1209
- // full attribution list and can de-duplicate by URI if they want
1210
- // to collapse identical sources.
1211
- //
1212
- // The text-block anchor is read from `state.currentBlock` -- the
1213
- // just-processed text parts in this same event will have left it
1214
- // set to the running text block. If currentBlock is not text (or
1215
- // is null), Gemini delivered grounding without a preceding text
1216
- // anchor, which has no defined attribution per the
1217
- // `CitationBlock` contract; surface as a protocol mismatch
1218
- // rather than synthesize an arbitrary index.
1219
- //
1220
- // `groundingChunks` entries without the `web` shape (a future
1221
- // chunk kind) are skipped silently for now -- their source has no
1222
- // `uri`/`title` to populate `CitationSource`, and synthesizing a
1223
- // placeholder citation would misrepresent the wire. Supports that
1224
- // reference an out-of-range chunk index throw -- the wire is
1225
- // pointing at a chunk slot the response never delivered, which is
1226
- // a wire bug we want to see.
1227
- function emitGroundingCitations(
1228
- metadata: typeof GeminiGroundingMetadata.infer,
1229
- state: GeminiParserState,
1230
- seq: number,
1231
- out: InferenceEvent[],
1232
- raw: unknown,
1233
- ): void {
1234
- const supports = metadata.groundingSupports ?? [];
1235
- const chunks = metadata.groundingChunks ?? [];
1236
- if (supports.length === 0) {
1237
- return;
1238
- }
1239
-
1240
- const anchor = state.currentBlock;
1241
- if (anchor === null || anchor.kind !== "text") {
1242
- throw new ProtocolMismatchError(
1243
- `google-genai parseResponse: groundingMetadata arrived without a ` +
1244
- `current text block to anchor citations against (currentBlock=` +
1245
- `${anchor === null ? "null" : JSON.stringify(anchor.kind)}). The ` +
1246
- `wire convention places groundingMetadata on the terminal event ` +
1247
- `alongside the text it grounds.`,
1248
- raw,
1249
- );
1250
- }
1251
- const index = anchor.index;
1252
-
1253
- for (const support of supports) {
1254
- const { segment, groundingChunkIndices } = support;
1255
- for (const chunkIdx of groundingChunkIndices) {
1256
- const chunk = chunks[chunkIdx];
1257
- if (chunk === undefined) {
1258
- throw new ProtocolMismatchError(
1259
- `google-genai parseResponse: groundingSupport references ` +
1260
- `chunk index ${String(chunkIdx)} but the response has only ` +
1261
- `${String(chunks.length)} grounding chunk(s).`,
1262
- raw,
1263
- );
1264
- }
1265
- const web = chunk.web;
1266
- if (web === undefined) {
1267
- // Non-web chunk kinds (retrieved-context, document, etc.)
1268
- // have no `web.uri`/`web.title` to populate a
1269
- // CitationSource. Skipping rather than synthesizing keeps
1270
- // the citation faithful to the wire shape the parser
1271
- // actually models -- the schema admits non-web chunks
1272
- // implicitly so a wider chunk kind reaching the parser
1273
- // does not fail schema validation, but it has no defined
1274
- // mapping into `CitationSource` until its discriminator
1275
- // is modeled here.
1276
- continue;
1277
- }
1278
- const citation = {
1279
- type: "citation" as const,
1280
- citedText: segment.text,
1281
- source: {
1282
- uri: web.uri,
1283
- title: web.title,
1284
- },
1285
- textOffset: {
1286
- start: segment.startIndex,
1287
- end: segment.endIndex,
1288
- },
1289
- };
1290
- out.push({
1291
- type: "inference.citation",
1292
- seq,
1293
- data: { citation, index },
1294
- });
1295
- }
1296
- }
1297
- }
1298
-
1299
- // Map Gemini's `codeExecutionResult.outcome` enum onto the
1300
- // internal `CodeExecutionResultBlock.status` union. The switch is
1301
- // exhaustive over the three values Gemini documents today; an
1302
- // unknown outcome string surfaces as a `ProtocolMismatchError`
1303
- // naming the value verbatim rather than being bucketed into a
1304
- // fallback status. Adding a new outcome to this mapping is a
1305
- // deliberate code change, not an implicit acceptance of whatever
1306
- // Gemini sends next.
1307
- function outcomeToStatus(
1308
- outcome: string,
1309
- raw: unknown,
1310
- ): "ok" | "error" | "aborted" | "timeout" {
1311
- switch (outcome) {
1312
- case "OUTCOME_OK":
1313
- return "ok";
1314
- case "OUTCOME_FAILED":
1315
- return "error";
1316
- case "OUTCOME_DEADLINE_EXCEEDED":
1317
- return "timeout";
1318
- default:
1319
- throw new ProtocolMismatchError(
1320
- `google-genai parseResponse: unknown codeExecutionResult.outcome ` +
1321
- `${JSON.stringify(outcome)}; the mapping recognizes ` +
1322
- `OUTCOME_OK, OUTCOME_FAILED, OUTCOME_DEADLINE_EXCEEDED. ` +
1323
- `A new outcome value is a deliberate adapter change, not a ` +
1324
- `silent fallback.`,
1325
- raw,
1326
- );
1327
- }
1328
- }
1329
-
1330
- // Settle the carrier-opportunity lifecycle for a non-thinking part
1331
- // that has just been processed. If the part carries a signature, it
1332
- // is consumed against the pending anchor (which must exist, or the
1333
- // request is in a corrupt state). If it does not, the anchor is
1334
- // discarded: the FIRST non-thinking part after a thinking block is
1335
- // the only chance to claim that thinking block's signature, and a
1336
- // part that passes without claiming ends the opportunity. A later
1337
- // thinking block cannot retroactively re-open the claim, and the
1338
- // discard prevents a stale anchor from tripping the
1339
- // `closeCurrentBlock` guard when another thinking block closes.
1340
- function settleCarrierOpportunity(
1341
- state: GeminiParserState,
1342
- signature: string | undefined,
1343
- seq: number,
1344
- out: InferenceEvent[],
1345
- raw: unknown,
1346
- ): void {
1347
- if (signature !== undefined) {
1348
- consumeSignature(state, signature, seq, out, raw);
1349
- return;
1350
- }
1351
- state.pendingSignatureAnchor = null;
1352
- }
1353
-
1354
- // Emit `inference.thinking.signature` against the pending anchor and
1355
- // clear it. A signature with no pending anchor is a state-corruption
1356
- // case: Gemini placed a thoughtSignature on a part with no preceding
1357
- // thinking block in this request. Surface as a protocol mismatch.
1358
- function consumeSignature(
1359
- state: GeminiParserState,
1360
- signature: string,
1361
- seq: number,
1362
- out: InferenceEvent[],
1363
- raw: unknown,
1364
- ): void {
1365
- if (state.pendingSignatureAnchor === null) {
1366
- throw new ProtocolMismatchError(
1367
- `google-genai parseResponse: thoughtSignature present but no ` +
1368
- `preceding thinking block exists in this request to anchor it.`,
1369
- raw,
1370
- );
1371
- }
1372
- out.push({
1373
- type: "inference.thinking.signature",
1374
- seq,
1375
- data: {
1376
- signature,
1377
- index: state.pendingSignatureAnchor,
1378
- },
1379
- });
1380
- state.pendingSignatureAnchor = null;
1381
- }
1382
-
1383
- function parseResponse(
1384
- sseData: string,
1385
- state: GeminiParserState,
1386
- source: LastCycleSource,
1387
- ): InferenceEvent[] {
1388
- let parsed: unknown;
1389
- try {
1390
- parsed = JSON.parse(sseData);
1391
- } catch (cause) {
1392
- const message = cause instanceof Error ? cause.message : String(cause);
1393
- throw new ProtocolMismatchError(
1394
- `google-genai parseResponse: malformed JSON in SSE data payload: ${message}`,
1395
- sseData,
1396
- );
1397
- }
1398
-
1399
- const event = GeminiSSEEvent(parsed);
1400
- if (event instanceof type.errors) {
1401
- throw new ProtocolMismatchError(
1402
- `google-genai parseResponse: SSE event failed schema validation: ${event.summary}`,
1403
- parsed,
1404
- );
1405
- }
1406
-
1407
- const candidates = event.candidates ?? [];
1408
-
1409
- // The adapter's `buildRequest` never requests `candidateCount > 1`,
1410
- // so a multi-candidate response means the wire shape diverged from
1411
- // what was requested. Surface the mismatch loudly with the full
1412
- // payload in `error.raw` rather than silently picking `[0]`.
1413
- if (candidates.length > 1) {
1414
- throw new ProtocolMismatchError(
1415
- `google-genai parseResponse: expected at most one candidate, got ${String(candidates.length)}.`,
1416
- parsed,
1417
- );
1418
- }
1419
-
1420
- // The seq field is a placeholder 0 -- the harness assigns real
1421
- // sequence numbers.
1422
- const seq = 0;
1423
- const out: InferenceEvent[] = [];
1424
-
1425
- const candidate = candidates[0];
1426
- if (candidate?.content?.parts !== undefined) {
1427
- for (const part of candidate.content.parts) {
1428
- emitPart(part, state, seq, out, parsed);
1429
- }
1430
- }
1431
-
1432
- // `groundingMetadata` rides on the candidate alongside the parts
1433
- // and the finishReason. It is processed AFTER the parts have
1434
- // settled so any text deltas in the same event extend the
1435
- // currentBlock first; `emitGroundingCitations` reads the
1436
- // currentBlock's index to attribute each citation to the right
1437
- // text block. Citations precede the terminal usage emission --
1438
- // they belong to the model's output, not to the bookkeeping
1439
- // signal that closes the response.
1440
- if (candidate?.groundingMetadata !== undefined) {
1441
- emitGroundingCitations(
1442
- candidate.groundingMetadata,
1443
- state,
1444
- seq,
1445
- out,
1446
- parsed,
1447
- );
1448
- }
1449
-
1450
- // `finishReason` arrives only on the terminal event. Emit usage at
1451
- // exactly that point: Gemini's `usageMetadata` is cumulative in
1452
- // every event, so the terminal-event snapshot is the final count
1453
- // and intermediate emissions would be pure noise that the
1454
- // harness's `inference.done` would discard anyway.
1455
- //
1456
- // `MAX_TOKENS`, `SAFETY`, `RECITATION`, and `OTHER` reach this
1457
- // layer but do not yet surface as `inference.error` -- emitting
1458
- // those needs fixtures showing the full error envelope shape,
1459
- // which the plain-text path does not exercise.
1460
- if (candidate?.finishReason !== undefined) {
1461
- const usage = event.usageMetadata;
1462
- if (usage === undefined) {
1463
- throw new ProtocolMismatchError(
1464
- `google-genai parseResponse: terminal event (finishReason=${JSON.stringify(candidate.finishReason)}) missing usageMetadata.`,
1465
- parsed,
1466
- );
1467
- }
1468
- const tokenUsage: TokenUsage = {
1469
- input: usage.promptTokenCount ?? 0,
1470
- output: usage.candidatesTokenCount ?? 0,
1471
- // Gemini exposes context caching via `cachedContentTokenCount`
1472
- // (single counter; the API does not distinguish "read" from
1473
- // "write" the way Anthropic does). The plain-text path does
1474
- // not exercise caching, so the field is absent here. A future
1475
- // caching commit decides whether to route the count into
1476
- // `cacheRead` or carry both fields.
1477
- cacheRead: usage.cachedContentTokenCount ?? 0,
1478
- cacheWrite: 0,
1479
- thinking: usage.thoughtsTokenCount ?? 0,
1480
- };
1481
- out.push({
1482
- type: "inference.usage",
1483
- seq,
1484
- data: { usage: tokenUsage, source },
1485
- });
1486
-
1487
- // Terminal events seal the response. A still-pending
1488
- // code-execution request at this point would mean Gemini
1489
- // emitted an executableCode part without a matching
1490
- // codeExecutionResult before stopping -- a wire bug, not a
1491
- // case the harness should silently swallow.
1492
- if (state.pendingExecutionRequestId !== null) {
1493
- throw new ProtocolMismatchError(
1494
- `google-genai parseResponse: response terminated with an ` +
1495
- `unmatched code-execution request ` +
1496
- `${JSON.stringify(state.pendingExecutionRequestId)}; the wire ` +
1497
- `must deliver a codeExecutionResult part before the terminal ` +
1498
- `finishReason.`,
1499
- parsed,
1500
- );
1501
- }
1502
- }
1503
-
1504
- return out;
1505
- }
1506
-
1507
- export function createGoogleGenAIAdapter(
1508
- source: LastCycleSource,
1509
- ): ProviderAdapter {
1510
- // Per-request state lives in the closure: block-index allocation
1511
- // and signature-anchor pairing both need to span SSE events.
1512
- // `buildRequest` does not touch state; only `parseResponse` does.
1513
- const state = createParserState();
1514
- return {
1515
- buildRequest,
1516
- parseResponse: (sseData) => parseResponse(sseData, state, source),
1517
- };
1518
- }