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