@intx/inference 0.2.2 → 0.3.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -1,4 +1,5 @@
1
1
  import { type } from "arktype";
2
+ import { formatSafetyRatingText } from "@intx/types/runtime";
2
3
  import { CREDENTIAL_SENTINEL } from "../auth.js";
3
4
  import { ProtocolMismatchError } from "../errors.js";
4
5
  import { decodeToolName, encodeToolName, } from "../tool-name.js";
@@ -9,6 +10,19 @@ const GOOGLE_TOOL_NAME_LIMIT = {
9
10
  provider: "google-genai",
10
11
  maxLength: 64,
11
12
  };
13
+ // Models that reject thinkingConfig.thinkingBudget: 0 with HTTP 400.
14
+ // Keep aligned with the discovery plug-in's THINKING_MANDATORY_MODELS.
15
+ const THINKING_MANDATORY_MODELS = new Set([
16
+ "gemini-2.5-pro",
17
+ "gemini-3.6-flash",
18
+ ]);
19
+ // Dynamic thinking budget sentinel: the model decides how much to
20
+ // think. Used when suppressing thought parts on thinking-mandatory
21
+ // models that reject a zero budget.
22
+ const DYNAMIC_THINKING_BUDGET = -1;
23
+ function minimalThinkingBudget(model) {
24
+ return THINKING_MANDATORY_MODELS.has(model) ? DYNAMIC_THINKING_BUDGET : 0;
25
+ }
12
26
  // Runtime validator for "parsed JSON value is a plain object." Used
13
27
  // by `tryParseJSONObject` to narrow `JSON.parse(string)` from its
14
28
  // declared `unknown` return into a `Record<string, unknown>` without a
@@ -22,10 +36,6 @@ const ParsedJSONObject = type("Record<string, unknown>");
22
36
  // Translates the internal ConversationTurn[] format into Gemini's
23
37
  // `generateContent` / `streamGenerateContent` request body. The harness
24
38
  // 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
39
  // ---------------------------------------------------------------------------
30
40
  function buildRequest(messages, model, options) {
31
41
  const systemMessages = messages.filter((m) => m.role === "system");
@@ -55,7 +65,18 @@ function buildRequest(messages, model, options) {
55
65
  // produced the matching `tool_call`. Built once because a per-block
56
66
  // walk would be O(N^2) in turn count.
57
67
  const callIdToFunctionName = buildCallIdToFunctionName(messages);
58
- const contents = conversationMessages.map((msg) => toGeminiContent(msg, callIdToFunctionName));
68
+ // safety_rating is output-only. Rewrite to text so multi-turn history
69
+ // keeps role alternation and a model-visible block reason (same
70
+ // policy as Anthropic/OpenAI/transform).
71
+ const contents = conversationMessages.map((msg) => {
72
+ const rewritten = {
73
+ ...msg,
74
+ content: msg.content.map((b) => b.type === "safety_rating"
75
+ ? { type: "text", text: formatSafetyRatingText(b) }
76
+ : b),
77
+ };
78
+ return toGeminiContent(rewritten, callIdToFunctionName);
79
+ });
59
80
  const body = { contents };
60
81
  if (effectiveSystem !== undefined) {
61
82
  body["systemInstruction"] = { parts: [{ text: effectiveSystem }] };
@@ -71,7 +92,7 @@ function buildRequest(messages, model, options) {
71
92
  },
72
93
  ];
73
94
  }
74
- const generationConfig = buildGenerationConfig(options);
95
+ const generationConfig = buildGenerationConfig(model, options);
75
96
  if (generationConfig !== undefined) {
76
97
  body["generationConfig"] = generationConfig;
77
98
  }
@@ -136,63 +157,32 @@ function toGeminiContent(msg, callIdToFunctionName) {
136
157
  `found one on a ${JSON.stringify(msg.role)} turn (callId ${JSON.stringify(block.callId)}).`);
137
158
  }
138
159
  }
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
- }
160
+ // Each block carries its own signature; `toGeminiPart` rides it back
161
+ // onto that block's own part as a `thoughtSignature`. The captured
162
+ // wire places the signature on whichever part the model signed (for a
163
+ // signed thinking turn, that is the follow-on functionCall part, which
164
+ // reverse-parsing attributed to the tool_call block), so a per-block
165
+ // round-trip reproduces the wire without any cross-part pairing.
166
+ const parts = msg.content.map((block) => toGeminiPart(block, callIdToFunctionName));
189
167
  return { role, parts };
190
168
  }
191
169
  function toGeminiPart(block, callIdToFunctionName) {
192
170
  switch (block.type) {
193
171
  case "text":
194
- return { text: block.text };
195
- case "image":
172
+ return {
173
+ text: block.text,
174
+ ...(block.signature !== undefined
175
+ ? { thoughtSignature: block.signature }
176
+ : {}),
177
+ };
178
+ case "image": {
179
+ // Only ImageBlock among the media kinds carries a signature; the
180
+ // others have no signature field to ride back.
181
+ const part = toGeminiMediaPart(block.source);
182
+ return block.signature !== undefined
183
+ ? { ...part, thoughtSignature: block.signature }
184
+ : part;
185
+ }
196
186
  case "document":
197
187
  case "audio":
198
188
  case "video":
@@ -203,25 +193,35 @@ function toGeminiPart(block, callIdToFunctionName) {
203
193
  name: encodeToolName(block.name, GOOGLE_TOOL_NAME_LIMIT),
204
194
  args: block.arguments,
205
195
  },
196
+ ...(block.signature !== undefined
197
+ ? { thoughtSignature: block.signature }
198
+ : {}),
206
199
  };
207
200
  case "tool_result":
208
201
  return toGeminiFunctionResponse(block, callIdToFunctionName);
209
202
  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 };
203
+ // A thinking block rides its own signature on its part, the same
204
+ // as any other block. Gemini most often signs the follow-on
205
+ // functionCall part instead, which reverse-parsing attributes to
206
+ // the tool_call block, so a signed thinking part here is the rare
207
+ // case where Gemini signed the thought itself.
208
+ return {
209
+ text: block.thinking,
210
+ thought: true,
211
+ ...(block.signature !== undefined
212
+ ? { thoughtSignature: block.signature }
213
+ : {}),
214
+ };
219
215
  case "redacted_thinking":
220
216
  // Gemini does not emit redacted-thinking blocks; a caller
221
217
  // passing one in is mixing wire formats. Surface the mismatch
222
218
  // loudly rather than dropping it silently.
223
219
  throw new Error("Google GenAI adapter does not handle redacted_thinking blocks; " +
224
220
  "they are Anthropic-specific.");
221
+ case "safety_rating":
222
+ // Rewritten to text in buildRequest before toGeminiPart is called.
223
+ throw new Error("Google GenAI adapter: safety_rating blocks must be rewritten " +
224
+ "to text before toGeminiPart.");
225
225
  case "citation":
226
226
  // Citations are output-only blocks: the model produces them as
227
227
  // grounding/source references for its own text. Echoing one
@@ -360,7 +360,7 @@ function tryParseJSONObject(text) {
360
360
  // ---------------------------------------------------------------------------
361
361
  // generationConfig
362
362
  // ---------------------------------------------------------------------------
363
- function buildGenerationConfig(options) {
363
+ function buildGenerationConfig(model, options) {
364
364
  const config = {};
365
365
  if (options.maxTokens !== undefined) {
366
366
  config["maxOutputTokens"] = options.maxTokens;
@@ -370,10 +370,10 @@ function buildGenerationConfig(options) {
370
370
  }
371
371
  // thinking.enabled === true -> include a budget (default 1024) and
372
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
373
+ // thinking.enabled === false -> suppress thoughts: budget 0 when the
374
+ // model allows it, or dynamic (-1) for
375
+ // thinking-mandatory models that reject
376
+ // a zero budget with HTTP 400
377
377
  // thinking absent -> omit thinkingConfig entirely; Gemini
378
378
  // uses the model's default
379
379
  if (options.thinking !== undefined) {
@@ -385,7 +385,9 @@ function buildGenerationConfig(options) {
385
385
  };
386
386
  }
387
387
  else {
388
- config["thinkingConfig"] = { thinkingBudget: 0 };
388
+ config["thinkingConfig"] = {
389
+ thinkingBudget: minimalThinkingBudget(model),
390
+ };
389
391
  }
390
392
  }
391
393
  if (options.responseModalities !== undefined &&
@@ -574,9 +576,16 @@ const GeminiUsageMetadata = type({
574
576
  "thoughtsTokenCount?": "number",
575
577
  "cachedContentTokenCount?": "number",
576
578
  });
579
+ // Prompt-level safety signal. Captured 2026-07-28 on
580
+ // safety-classification fixtures: `{ blockReason: "PROHIBITED_CONTENT" }`
581
+ // with no candidates. Only fields we consume are validated.
582
+ const GeminiPromptFeedback = type({
583
+ "blockReason?": "string > 0",
584
+ });
577
585
  const GeminiSSEEvent = type({
578
586
  "candidates?": GeminiCandidate.array(),
579
587
  "usageMetadata?": GeminiUsageMetadata,
588
+ "promptFeedback?": GeminiPromptFeedback,
580
589
  // `modelVersion` and `responseId` are dropped at this layer. The
581
590
  // harness's `AssistantTurn.model` is set from the requested model
582
591
  // string, not from the served `modelVersion` -- which can differ
@@ -590,44 +599,38 @@ function createParserState() {
590
599
  return {
591
600
  nextBlockIndex: 0,
592
601
  currentBlock: null,
593
- pendingSignatureAnchor: null,
594
602
  pendingExecutionRequestId: null,
595
603
  };
596
604
  }
605
+ // A `thoughtSignature` authenticates the block whose part carries it.
606
+ // Emit an `inference.block.signature` against that block's own index;
607
+ // providers that do not sign this part leave `signature` undefined and
608
+ // this emits nothing.
609
+ function emitBlockSignature(signature, index, seq, out) {
610
+ if (signature === undefined)
611
+ return;
612
+ out.push({
613
+ type: "inference.block.signature",
614
+ seq,
615
+ data: { signature, index },
616
+ });
617
+ }
597
618
  // Open or extend a text/thinking block, returning the block index.
598
619
  // 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) {
620
+ // a different kind closes the current block and allocates a new index.
621
+ function openOrExtendBlock(state, kind) {
604
622
  if (state.currentBlock !== null && state.currentBlock.kind === kind) {
605
623
  return state.currentBlock.index;
606
624
  }
607
- closeCurrentBlock(state, rawForError);
625
+ closeCurrentBlock(state);
608
626
  const index = state.nextBlockIndex++;
609
627
  state.currentBlock = { kind, index };
610
628
  return index;
611
629
  }
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
- }
630
+ // Close the current text/thinking block so the next part of any kind
631
+ // starts a fresh block. A signature rides on its own part and attaches
632
+ // to that part's block, so closing carries no signature state.
633
+ function closeCurrentBlock(state) {
631
634
  state.currentBlock = null;
632
635
  }
633
636
  // Enforce mutual exclusivity of payload-bearing fields and correct
@@ -637,9 +640,10 @@ function closeCurrentBlock(state, rawForError) {
637
640
  // semantics would otherwise admit a part with more than one set,
638
641
  // or with `thought: true` on a non-text part. Both are wire
639
642
  // 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
+ // with zero payload fields passes this structural check only when a
644
+ // `thoughtSignature` is present; `emitPart` then rejects that
645
+ // signature-only part separately, since a signature with no payload
646
+ // has no block to authenticate.
643
647
  function assertSinglePayload(part, raw) {
644
648
  const payloads = [];
645
649
  if (part.text !== undefined)
@@ -676,7 +680,7 @@ function emitPart(part, state, seq, out, raw) {
676
680
  assertSinglePayload(part, raw);
677
681
  // text part with `thought: true` -- belongs to a thinking block.
678
682
  if (part.text !== undefined && part.thought === true) {
679
- const index = openOrExtendBlock(state, "thinking", raw);
683
+ const index = openOrExtendBlock(state, "thinking");
680
684
  // Anchor the block in the harness's per-index map. An empty
681
685
  // text part with only a `thoughtSignature` would otherwise route
682
686
  // the signature to an index the harness has never seen. The
@@ -691,43 +695,21 @@ function emitPart(part, state, seq, out, raw) {
691
695
  index,
692
696
  },
693
697
  });
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
- }
698
+ // A thinking part may carry its own signature; attach it to this
699
+ // thinking block's index.
700
+ emitBlockSignature(part.thoughtSignature, index, seq, out);
707
701
  return;
708
702
  }
709
- // text part without `thought` -- belongs to a text block.
703
+ // text part without `thought` -- belongs to a text block. An empty
704
+ // text part with no signature is a true no-op: it neither opens nor
705
+ // closes a block, so a follow-on same-kind part extends what was
706
+ // open. An empty text part that carries a signature still opens (or
707
+ // extends) a text block so the signature has its own block to sign.
710
708
  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
- }
709
+ if (part.text === "" && part.thoughtSignature === undefined) {
728
710
  return;
729
711
  }
730
- const index = openOrExtendBlock(state, "text", raw);
712
+ const index = openOrExtendBlock(state, "text");
731
713
  out.push({
732
714
  type: "inference.text.delta",
733
715
  seq,
@@ -737,22 +719,14 @@ function emitPart(part, state, seq, out, raw) {
737
719
  index,
738
720
  },
739
721
  });
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);
722
+ emitBlockSignature(part.thoughtSignature, index, seq, out);
749
723
  return;
750
724
  }
751
725
  // functionCall part -- atomic block, allocates a fresh index and
752
726
  // does not become the `currentBlock` (a follow-on text or thinking
753
727
  // part starts a new block of that kind).
754
728
  if (part.functionCall !== undefined) {
755
- closeCurrentBlock(state, raw);
729
+ closeCurrentBlock(state);
756
730
  const fc = part.functionCall;
757
731
  const index = state.nextBlockIndex++;
758
732
  // Synthetic callId: Gemini's `functionCall` has no wire-level id
@@ -761,17 +735,6 @@ function emitPart(part, state, seq, out, raw) {
761
735
  // adapter's fallback when its wire id is absent. Block indices
762
736
  // are unique within a request by construction.
763
737
  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
738
  out.push({
776
739
  type: "inference.tool_call.start",
777
740
  seq,
@@ -799,15 +762,16 @@ function emitPart(part, state, seq, out, raw) {
799
762
  index,
800
763
  },
801
764
  });
765
+ // A `thoughtSignature` on the functionCall part authenticates the
766
+ // tool_call block; emit it after the block is open at this index.
767
+ emitBlockSignature(part.thoughtSignature, index, seq, out);
802
768
  return;
803
769
  }
804
770
  // inlineData part -- atomic image-output block. The image arrives
805
771
  // complete in a single SSE event (no streaming chunks of base64),
806
772
  // 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.
773
+ // in one `inference.image_output` event. A `thoughtSignature` on the
774
+ // part authenticates the image block and is emitted against its index.
811
775
  if (part.inlineData !== undefined) {
812
776
  // The parser wraps inlineData as an `ImageBlock`, so a non-
813
777
  // image MIME (e.g. audio/wav, application/pdf) would silently
@@ -819,9 +783,8 @@ function emitPart(part, state, seq, out, raw) {
819
783
  `parser wraps inlineData as an ImageBlock and does not ` +
820
784
  `handle other modalities on this code path.`, raw);
821
785
  }
822
- closeCurrentBlock(state, raw);
786
+ closeCurrentBlock(state);
823
787
  const index = state.nextBlockIndex++;
824
- settleCarrierOpportunity(state, part.thoughtSignature, seq, out, raw);
825
788
  out.push({
826
789
  type: "inference.image_output",
827
790
  seq,
@@ -837,6 +800,7 @@ function emitPart(part, state, seq, out, raw) {
837
800
  index,
838
801
  },
839
802
  });
803
+ emitBlockSignature(part.thoughtSignature, index, seq, out);
840
804
  return;
841
805
  }
842
806
  // executableCode part -- atomic code-execution request block.
@@ -866,9 +830,8 @@ function emitPart(part, state, seq, out, raw) {
866
830
  `unmatched. The wire convention is strict LIFO with depth 1 ` +
867
831
  `(request, then result); no fixture exercises depth > 1.`, raw);
868
832
  }
869
- closeCurrentBlock(state, raw);
833
+ closeCurrentBlock(state);
870
834
  const index = state.nextBlockIndex++;
871
- settleCarrierOpportunity(state, part.thoughtSignature, seq, out, raw);
872
835
  const requestId = `gemini-exec-${String(index)}`;
873
836
  state.pendingExecutionRequestId = requestId;
874
837
  const ec = part.executableCode;
@@ -888,6 +851,9 @@ function emitPart(part, state, seq, out, raw) {
888
851
  seq,
889
852
  data: { request, index },
890
853
  });
854
+ // A `thoughtSignature` on the executableCode part authenticates the
855
+ // code-execution-request block; emit it against its index.
856
+ emitBlockSignature(part.thoughtSignature, index, seq, out);
891
857
  return;
892
858
  }
893
859
  // codeExecutionResult part -- atomic result block. Pairs against
@@ -912,9 +878,15 @@ function emitPart(part, state, seq, out, raw) {
912
878
  // part without partial side effects.
913
879
  const cer = part.codeExecutionResult;
914
880
  const status = outcomeToStatus(cer.outcome, raw);
915
- closeCurrentBlock(state, raw);
881
+ // A code_execution_result block carries no signature field, and the
882
+ // corpus never signs a result part; a signature here is an
883
+ // unmodeled wire shape. Reject before mutating state.
884
+ if (part.thoughtSignature !== undefined) {
885
+ throw new ProtocolMismatchError(`google-genai parseResponse: codeExecutionResult part carries a ` +
886
+ `thoughtSignature; the code_execution_result block is not signable.`, raw);
887
+ }
888
+ closeCurrentBlock(state);
916
889
  const index = state.nextBlockIndex++;
917
- settleCarrierOpportunity(state, part.thoughtSignature, seq, out, raw);
918
890
  state.pendingExecutionRequestId = null;
919
891
  const result = {
920
892
  type: "code_execution_result",
@@ -934,15 +906,12 @@ function emitPart(part, state, seq, out, raw) {
934
906
  });
935
907
  return;
936
908
  }
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.
909
+ // Signature-only part (no payload, signature set). A signature
910
+ // authenticates a block; a part with no payload has no block to own
911
+ // it, so this is an unmodeled wire shape the corpus never exercises.
942
912
  if (part.thoughtSignature !== undefined) {
943
- closeCurrentBlock(state, raw);
944
- consumeSignature(state, part.thoughtSignature, seq, out, raw);
945
- return;
913
+ throw new ProtocolMismatchError(`google-genai parseResponse: part carries a thoughtSignature but no ` +
914
+ `payload; there is no block for the signature to authenticate.`, raw);
946
915
  }
947
916
  // `assertSinglePayload` above rules out the no-payload-no-signature
948
917
  // case, so a part that lands here had a payload that no earlier
@@ -1058,42 +1027,6 @@ function outcomeToStatus(outcome, raw) {
1058
1027
  `silent fallback.`, raw);
1059
1028
  }
1060
1029
  }
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
1030
  function parseResponse(sseData, state, source) {
1098
1031
  let parsed;
1099
1032
  try {
@@ -1136,6 +1069,45 @@ function parseResponse(sseData, state, source) {
1136
1069
  if (candidate?.groundingMetadata !== undefined) {
1137
1070
  emitGroundingCitations(candidate.groundingMetadata, state, seq, out, parsed);
1138
1071
  }
1072
+ // Prompt-level structured safety signal. Observed capture shape
1073
+ // (safety-classification fixtures, 2026-07-28): HTTP 200 with
1074
+ // `promptFeedback.blockReason` and zero candidates. Treat as a
1075
+ // terminal parse path: emit the safety event, then usage from
1076
+ // `usageMetadata` (which is present on the capture). This is not
1077
+ // an `inference.error` — the transport succeeded and the wire
1078
+ // carries a structured signal.
1079
+ const blockReason = event.promptFeedback?.blockReason;
1080
+ if (blockReason !== undefined) {
1081
+ out.push({
1082
+ type: "inference.safety_rating",
1083
+ seq,
1084
+ data: {
1085
+ safetyRating: {
1086
+ type: "safety_rating",
1087
+ blockReason,
1088
+ },
1089
+ },
1090
+ });
1091
+ const usage = event.usageMetadata;
1092
+ if (usage === undefined) {
1093
+ throw new ProtocolMismatchError(`google-genai parseResponse: promptFeedback.blockReason terminal event missing usageMetadata.`, parsed);
1094
+ }
1095
+ out.push({
1096
+ type: "inference.usage",
1097
+ seq,
1098
+ data: {
1099
+ usage: {
1100
+ input: usage.promptTokenCount ?? 0,
1101
+ output: usage.candidatesTokenCount ?? 0,
1102
+ cacheRead: usage.cachedContentTokenCount ?? 0,
1103
+ cacheWrite: 0,
1104
+ thinking: usage.thoughtsTokenCount ?? 0,
1105
+ },
1106
+ source,
1107
+ },
1108
+ });
1109
+ return out;
1110
+ }
1139
1111
  // `finishReason` arrives only on the terminal event. Emit usage at
1140
1112
  // exactly that point: Gemini's `usageMetadata` is cumulative in
1141
1113
  // every event, so the terminal-event snapshot is the final count
@@ -1145,7 +1117,9 @@ function parseResponse(sseData, state, source) {
1145
1117
  // `MAX_TOKENS`, `SAFETY`, `RECITATION`, and `OTHER` reach this
1146
1118
  // layer but do not yet surface as `inference.error` -- emitting
1147
1119
  // those needs fixtures showing the full error envelope shape,
1148
- // which the plain-text path does not exercise.
1120
+ // which the plain-text path does not exercise. Candidate-level
1121
+ // `safetyRatings` arrays have also not been observed on the
1122
+ // discovery corpus; extend emission when a capture carries them.
1149
1123
  if (candidate?.finishReason !== undefined) {
1150
1124
  const usage = event.usageMetadata;
1151
1125
  if (usage === undefined) {
@@ -1184,13 +1158,48 @@ function parseResponse(sseData, state, source) {
1184
1158
  }
1185
1159
  return out;
1186
1160
  }
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.
1161
+ // A non-streaming generateContent response is shaped exactly like a single
1162
+ // terminal streaming SSE event: one GeminiSSEEvent carrying the full parts
1163
+ // array and a terminal finishReason (or a promptFeedback.blockReason). Decode
1164
+ // it through the same parser with a fresh per-call state, so a replayed
1165
+ // non-streaming capture feeds the harness accumulator identically to its
1166
+ // streaming sibling — parity by construction, since the parser's state machine
1167
+ // is boundary-agnostic (nothing in it branches on SSE-event boundaries). The
1168
+ // "malformed JSON in SSE data payload" message parseResponse throws on a bad
1169
+ // body is path-neutral in substance (the body is JSON either way), so it is
1170
+ // left shared rather than forking the streaming parser's signature.
1171
+ function parseJSONResponse(body, source) {
1172
+ const events = parseResponse(body, createParserState(), source);
1173
+ // A complete non-streaming body MUST be terminal. The shared parser
1174
+ // tolerates non-terminal events (correct mid-stream, where an intermediate
1175
+ // event legitimately carries no finishReason), but here a body with no
1176
+ // finishReason and no promptFeedback.blockReason is a truncated or malformed
1177
+ // capture, not a silent empty decode. Both terminal paths emit
1178
+ // inference.usage, so its absence is the faithful terminality signal.
1179
+ if (!events.some((e) => e.type === "inference.usage")) {
1180
+ throw new ProtocolMismatchError(`google-genai parseJSONResponse: non-streaming body carried no terminal ` +
1181
+ `finishReason or promptFeedback.blockReason; a complete ` +
1182
+ `generateContent response must be terminal and emit usage.`, body);
1183
+ }
1184
+ return events;
1185
+ }
1186
+ // The google-genai adapter carries no per-source accommodations today, so its
1187
+ // quirks shape is empty. A quirks bag is deployment configuration crossing
1188
+ // into the system at this boundary; rejecting unknown keys makes a
1189
+ // misconfigured bag fail loudly here rather than run silently ignored.
1190
+ export const GoogleGenAIQuirks = type({ "+": "reject" });
1191
+ export function createGoogleGenAIAdapter(source, quirks) {
1192
+ const parsedQuirks = GoogleGenAIQuirks(quirks ?? {});
1193
+ if (parsedQuirks instanceof type.errors) {
1194
+ throw new Error(`google-genai adapter: invalid quirks: ${parsedQuirks.summary}`);
1195
+ }
1196
+ // Per-request state lives in the closure: block-index allocation and
1197
+ // the code-execution request/result pairing both need to span SSE
1198
+ // events. `buildRequest` does not touch state; only `parseResponse` does.
1191
1199
  const state = createParserState();
1192
1200
  return {
1193
1201
  buildRequest,
1194
1202
  parseResponse: (sseData) => parseResponse(sseData, state, source),
1203
+ parseJSONResponse: (body) => parseJSONResponse(body, source),
1195
1204
  };
1196
1205
  }