@intx/inference 0.2.2 → 0.4.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/adapter.d.ts +4 -2
- package/dist/adapter.js +2 -2
- package/dist/assembly.d.ts +16 -1
- package/dist/assembly.js +4 -1
- package/dist/auth.d.ts +2 -1
- package/dist/auth.js +14 -5
- package/dist/authz-extension.d.ts +15 -1
- package/dist/authz-extension.js +93 -9
- package/dist/correlation.d.ts +1 -0
- package/dist/correlation.js +8 -1
- package/dist/default-director.d.ts +1 -1
- package/dist/default-director.js +37 -8
- package/dist/gates.d.ts +1 -0
- package/dist/gates.js +24 -1
- package/dist/harness.d.ts +2 -0
- package/dist/harness.js +118 -23
- package/dist/index.d.ts +1 -1
- package/dist/index.js +1 -1
- package/dist/providers/anthropic.d.ts +5 -1
- package/dist/providers/anthropic.js +265 -23
- package/dist/providers/google-genai.d.ts +3 -1
- package/dist/providers/google-genai.js +224 -215
- package/dist/providers/index.d.ts +3 -3
- package/dist/providers/index.js +3 -3
- package/dist/providers/openai.d.ts +7 -1
- package/dist/providers/openai.js +353 -59
- package/dist/reactor.d.ts +18 -0
- package/dist/reactor.js +510 -116
- package/dist/transform.d.ts +1 -1
- package/dist/transform.js +19 -4
- package/package.json +5 -4
|
@@ -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
|
-
|
|
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
|
-
//
|
|
140
|
-
//
|
|
141
|
-
//
|
|
142
|
-
//
|
|
143
|
-
//
|
|
144
|
-
//
|
|
145
|
-
|
|
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 {
|
|
195
|
-
|
|
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
|
-
//
|
|
211
|
-
//
|
|
212
|
-
//
|
|
213
|
-
//
|
|
214
|
-
//
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
|
|
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 ->
|
|
374
|
-
//
|
|
375
|
-
//
|
|
376
|
-
//
|
|
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"] = {
|
|
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
|
-
|
|
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
|
|
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
|
|
613
|
-
//
|
|
614
|
-
//
|
|
615
|
-
|
|
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
|
|
641
|
-
// is present
|
|
642
|
-
//
|
|
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"
|
|
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
|
|
695
|
-
// thinking
|
|
696
|
-
|
|
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"
|
|
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
|
-
|
|
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
|
|
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.
|
|
808
|
-
//
|
|
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
|
|
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
|
|
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
|
-
|
|
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
|
|
938
|
-
//
|
|
939
|
-
//
|
|
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
|
-
|
|
944
|
-
|
|
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
|
-
|
|
1188
|
-
|
|
1189
|
-
|
|
1190
|
-
|
|
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
|
}
|