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