From b312ef0b42502ff2362a5ef254c97de5a02a0e06 Mon Sep 17 00:00:00 2001 From: Jordan Ritter Date: Fri, 1 May 2026 14:17:16 -0700 Subject: [PATCH 1/4] feat: add Gemini Interactions API record/replay support New handler for Google's stateful conversation endpoint (POST /v1beta/interactions). Supports string, Turn[], and Content[] input with content/parts and result/output backwards compatibility. Full streaming with data-only SSE format, stream collapse, chaos testing, interruption, and recorder integration. 12th LLM provider. --- src/gemini-interactions.ts | 930 +++++++++++++++++++++++++++++++++++++ src/index.ts | 7 + src/recorder.ts | 34 +- src/server.ts | 27 ++ src/stream-collapse.ts | 74 +++ src/types.ts | 1 + 6 files changed, 1071 insertions(+), 2 deletions(-) create mode 100644 src/gemini-interactions.ts diff --git a/src/gemini-interactions.ts b/src/gemini-interactions.ts new file mode 100644 index 00000000..c1592db3 --- /dev/null +++ b/src/gemini-interactions.ts @@ -0,0 +1,930 @@ +/** + * Google Gemini Interactions API support. + * + * Translates incoming Interactions requests into the ChatCompletionRequest + * format used by the fixture router, and converts fixture responses back + * into the Gemini Interactions format — either a single JSON response or + * an SSE stream with event_type-based framing. + */ + +import type * as http from "node:http"; +import type { + ChatCompletionRequest, + ChatMessage, + Fixture, + HandlerDefaults, + ResponseOverrides, + StreamingProfile, + ToolCall, + ToolDefinition, +} from "./types.js"; +import { + isTextResponse, + isToolCallResponse, + isContentWithToolCallsResponse, + isErrorResponse, + extractOverrides, + generateToolCallId, + flattenHeaders, + getTestId, +} from "./helpers.js"; +import { matchFixture } from "./router.js"; +import { writeErrorResponse, delay, calculateDelay } from "./sse-writer.js"; +import { createInterruptionSignal } from "./interruption.js"; +import type { Journal } from "./journal.js"; +import type { Logger } from "./logger.js"; +import { applyChaos } from "./chaos.js"; +import { proxyAndRecord } from "./recorder.js"; + +// ─── Interactions request types ──────────────────────────────────────────── + +interface InteractionsContentBlock { + type: string; + text?: string; + name?: string; + call_id?: string; + id?: string; + arguments?: Record; + output?: unknown; + result?: unknown; +} + +interface InteractionsTurn { + role: string; + content?: InteractionsContentBlock[]; + parts?: InteractionsContentBlock[]; +} + +interface InteractionsFunctionTool { + type: "function"; + name: string; + description?: string; + parameters?: object; +} + +interface InteractionsRequest { + model?: string; + input?: string | InteractionsTurn[] | InteractionsContentBlock[]; + system_instruction?: string; + tools?: InteractionsFunctionTool[]; + generation_config?: { + temperature?: number; + max_output_tokens?: number; + [key: string]: unknown; + }; + stream?: boolean; + previous_interaction_id?: string; + [key: string]: unknown; +} + +// ─── Input conversion: Interactions → ChatCompletionRequest ─────────────── + +export function geminiInteractionsToCompletionRequest( + req: InteractionsRequest, +): ChatCompletionRequest { + const messages: ChatMessage[] = []; + const model = req.model ?? "gemini-2.5-flash"; + + // system_instruction → system message + if (req.system_instruction) { + messages.push({ role: "system", content: req.system_instruction }); + } + + // Parse input + if (req.input !== undefined) { + if (typeof req.input === "string") { + // Simple string input → single user message + messages.push({ role: "user", content: req.input }); + } else if (Array.isArray(req.input)) { + // Could be Turn[] or Content[] + const firstItem = req.input[0]; + if (firstItem && "role" in firstItem) { + // Turn[] format + for (const turn of req.input as InteractionsTurn[]) { + const role = turn.role === "model" ? "assistant" : turn.role; + const blocks = turn.content ?? turn.parts; + if (!blocks || blocks.length === 0) { + if (role === "user" || role === "assistant") { + messages.push({ role: role as "user" | "assistant", content: "" }); + } + continue; + } + + // Check for function_call or function_result parts + const funcCallParts = blocks.filter((p) => p.type === "function_call"); + const funcResultParts = blocks.filter((p) => p.type === "function_result"); + const textParts = blocks.filter((p) => p.type === "text"); + + if (funcCallParts.length > 0) { + // Assistant tool call message + const textContent = textParts.map((p) => p.text ?? "").join(""); + messages.push({ + role: "assistant", + content: textContent || null, + tool_calls: funcCallParts.map((p) => ({ + id: p.id ?? p.call_id ?? generateToolCallId(), + type: "function" as const, + function: { + name: p.name ?? "", + arguments: JSON.stringify(p.arguments ?? {}), + }, + })), + }); + } else if (funcResultParts.length > 0) { + // Tool response messages + for (const part of funcResultParts) { + const resultValue = part.result ?? part.output; + messages.push({ + role: "tool", + content: + typeof resultValue === "string" ? resultValue : JSON.stringify(resultValue ?? ""), + tool_call_id: part.call_id ?? part.id ?? "", + }); + } + // Any text parts alongside → separate user message + if (textParts.length > 0) { + const text = textParts.map((p) => p.text ?? "").join(""); + if (text) { + messages.push({ role: "user", content: text }); + } + } + } else { + // Text-only turn + const text = textParts.map((p) => p.text ?? "").join(""); + if (role === "user" || role === "assistant" || role === "system") { + messages.push({ + role: role as "user" | "assistant" | "system", + content: text, + }); + } + } + } + } else { + // Content[] format — single user message with content blocks + const textParts = (req.input as InteractionsContentBlock[]).filter( + (p) => p.type === "text", + ); + const text = textParts.map((p) => p.text ?? "").join(""); + messages.push({ role: "user", content: text || "" }); + } + } + } + + // Convert tools + let tools: ToolDefinition[] | undefined; + if (req.tools && req.tools.length > 0) { + const funcTools = req.tools.filter((t) => t.type === "function"); + if (funcTools.length > 0) { + tools = funcTools.map((t) => ({ + type: "function" as const, + function: { + name: t.name, + description: t.description, + parameters: t.parameters, + }, + })); + } + } + + return { + model, + messages, + stream: req.stream !== false, // default true + temperature: req.generation_config?.temperature, + max_tokens: req.generation_config?.max_output_tokens, + tools, + }; +} + +// ─── Interaction ID generation ──────────────────────────────────────────── + +let interactionCounter = 0; + +export function resetInteractionCounter(): void { + interactionCounter = 0; +} + +function nextInteractionId(): string { + return `aimock-int-${interactionCounter++}`; +} + +// ─── Usage helpers ──────────────────────────────────────────────────────── + +function interactionsUsage(overrides?: ResponseOverrides): { + total_input_tokens: number; + total_output_tokens: number; + total_tokens: number; +} { + if (!overrides?.usage) return { total_input_tokens: 0, total_output_tokens: 0, total_tokens: 0 }; + const input = overrides.usage.input_tokens ?? overrides.usage.prompt_tokens ?? 0; + const output = overrides.usage.output_tokens ?? overrides.usage.completion_tokens ?? 0; + return { + total_input_tokens: input, + total_output_tokens: output, + total_tokens: input + output, + }; +} + +// ─── Response building: fixture → Interactions format ───────────────────── + +export function buildInteractionsTextResponse( + content: string, + model: string, + interactionId: string, + overrides?: ResponseOverrides, +): object { + return { + id: interactionId, + status: "completed", + model: overrides?.model ?? model, + role: "model", + outputs: [{ type: "text", text: content }], + usage: interactionsUsage(overrides), + }; +} + +export function buildInteractionsToolCallResponse( + toolCalls: ToolCall[], + model: string, + interactionId: string, + logger: Logger, + overrides?: ResponseOverrides, +): object { + return { + id: interactionId, + status: "requires_action", + model: overrides?.model ?? model, + role: "model", + outputs: toolCalls.map((tc) => { + let argsObj: unknown; + try { + argsObj = JSON.parse(tc.arguments || "{}"); + } catch { + logger.warn( + `Malformed JSON in fixture tool call arguments for "${tc.name}": ${tc.arguments}`, + ); + argsObj = {}; + } + return { + type: "function_call", + id: tc.id || generateToolCallId(), + name: tc.name, + arguments: argsObj, + }; + }), + usage: interactionsUsage(overrides), + }; +} + +export function buildInteractionsContentWithToolCallsResponse( + content: string, + toolCalls: ToolCall[], + model: string, + interactionId: string, + logger: Logger, + overrides?: ResponseOverrides, +): object { + const outputs: object[] = [{ type: "text", text: content }]; + for (const tc of toolCalls) { + let argsObj: unknown; + try { + argsObj = JSON.parse(tc.arguments || "{}"); + } catch { + logger.warn( + `Malformed JSON in fixture tool call arguments for "${tc.name}": ${tc.arguments}`, + ); + argsObj = {}; + } + outputs.push({ + type: "function_call", + id: tc.id || generateToolCallId(), + name: tc.name, + arguments: argsObj, + }); + } + + return { + id: interactionId, + status: "requires_action", + model: overrides?.model ?? model, + role: "model", + outputs, + usage: interactionsUsage(overrides), + }; +} + +function buildInteractionsErrorResponse(message: string, code?: string): object { + return { + error: { + code: code ?? "INVALID_ARGUMENT", + message, + }, + }; +} + +// ─── SSE event builders ────────────────────────────────────────────────── + +interface InteractionsSSEEvent { + event_type: string; + [key: string]: unknown; +} + +let eventIdCounter = 0; + +export function resetEventIdCounter(): void { + eventIdCounter = 0; +} + +function nextEventId(): string { + return `evt_${++eventIdCounter}`; +} + +export function buildInteractionsTextSSEEvents( + content: string, + interactionId: string, + chunkSize: number, + overrides?: ResponseOverrides, +): InteractionsSSEEvent[] { + const events: InteractionsSSEEvent[] = []; + + // interaction.start + events.push({ + event_type: "interaction.start", + interaction: { id: interactionId, status: "in_progress" }, + event_id: nextEventId(), + }); + + // content.start + events.push({ + event_type: "content.start", + index: 0, + content: { type: "text" }, + event_id: nextEventId(), + }); + + // content.delta(s) + if (content.length === 0) { + events.push({ + event_type: "content.delta", + index: 0, + delta: { type: "text", text: "" }, + event_id: nextEventId(), + }); + } else { + for (let i = 0; i < content.length; i += chunkSize) { + const slice = content.slice(i, i + chunkSize); + events.push({ + event_type: "content.delta", + index: 0, + delta: { type: "text", text: slice }, + event_id: nextEventId(), + }); + } + } + + // content.stop + events.push({ + event_type: "content.stop", + index: 0, + event_id: nextEventId(), + }); + + // interaction.complete + events.push({ + event_type: "interaction.complete", + interaction: { + id: interactionId, + status: "completed", + usage: interactionsUsage(overrides), + }, + event_id: nextEventId(), + }); + + return events; +} + +export function buildInteractionsToolCallSSEEvents( + toolCalls: ToolCall[], + interactionId: string, + logger: Logger, + overrides?: ResponseOverrides, +): InteractionsSSEEvent[] { + const events: InteractionsSSEEvent[] = []; + + // interaction.start + events.push({ + event_type: "interaction.start", + interaction: { id: interactionId, status: "in_progress" }, + event_id: nextEventId(), + }); + + // Each tool call gets its own content.start/delta/stop bracket + for (let idx = 0; idx < toolCalls.length; idx++) { + const tc = toolCalls[idx]; + let argsObj: unknown; + try { + argsObj = JSON.parse(tc.arguments || "{}"); + } catch { + logger.warn( + `Malformed JSON in fixture tool call arguments for "${tc.name}": ${tc.arguments}`, + ); + argsObj = {}; + } + + events.push({ + event_type: "content.start", + index: idx, + content: { type: "function_call" }, + event_id: nextEventId(), + }); + + events.push({ + event_type: "content.delta", + index: idx, + delta: { + type: "function_call", + id: tc.id || generateToolCallId(), + name: tc.name, + arguments: argsObj, + }, + event_id: nextEventId(), + }); + + events.push({ + event_type: "content.stop", + index: idx, + event_id: nextEventId(), + }); + } + + // interaction.complete + events.push({ + event_type: "interaction.complete", + interaction: { + id: interactionId, + status: "requires_action", + usage: interactionsUsage(overrides), + }, + event_id: nextEventId(), + }); + + return events; +} + +export function buildInteractionsContentWithToolCallsSSEEvents( + content: string, + toolCalls: ToolCall[], + interactionId: string, + chunkSize: number, + logger: Logger, + overrides?: ResponseOverrides, +): InteractionsSSEEvent[] { + const events: InteractionsSSEEvent[] = []; + + // interaction.start + events.push({ + event_type: "interaction.start", + interaction: { id: interactionId, status: "in_progress" }, + event_id: nextEventId(), + }); + + // Text content at index 0 + events.push({ + event_type: "content.start", + index: 0, + content: { type: "text" }, + event_id: nextEventId(), + }); + + if (content.length === 0) { + events.push({ + event_type: "content.delta", + index: 0, + delta: { type: "text", text: "" }, + event_id: nextEventId(), + }); + } else { + for (let i = 0; i < content.length; i += chunkSize) { + const slice = content.slice(i, i + chunkSize); + events.push({ + event_type: "content.delta", + index: 0, + delta: { type: "text", text: slice }, + event_id: nextEventId(), + }); + } + } + + events.push({ + event_type: "content.stop", + index: 0, + event_id: nextEventId(), + }); + + // Tool calls at index 1+ + for (let i = 0; i < toolCalls.length; i++) { + const tc = toolCalls[i]; + const idx = i + 1; // offset by 1 because text is index 0 + let argsObj: unknown; + try { + argsObj = JSON.parse(tc.arguments || "{}"); + } catch { + logger.warn( + `Malformed JSON in fixture tool call arguments for "${tc.name}": ${tc.arguments}`, + ); + argsObj = {}; + } + + events.push({ + event_type: "content.start", + index: idx, + content: { type: "function_call" }, + event_id: nextEventId(), + }); + + events.push({ + event_type: "content.delta", + index: idx, + delta: { + type: "function_call", + id: tc.id || generateToolCallId(), + name: tc.name, + arguments: argsObj, + }, + event_id: nextEventId(), + }); + + events.push({ + event_type: "content.stop", + index: idx, + event_id: nextEventId(), + }); + } + + // interaction.complete + events.push({ + event_type: "interaction.complete", + interaction: { + id: interactionId, + status: "requires_action", + usage: interactionsUsage(overrides), + }, + event_id: nextEventId(), + }); + + return events; +} + +// ─── SSE writer for Interactions streaming ──────────────────────────────── + +interface InteractionsStreamOptions { + latency?: number; + streamingProfile?: StreamingProfile; + signal?: AbortSignal; + onChunkSent?: () => void; +} + +export async function writeGeminiInteractionsSSEStream( + res: http.ServerResponse, + events: InteractionsSSEEvent[], + optionsOrLatency?: number | InteractionsStreamOptions, +): Promise { + const opts: InteractionsStreamOptions = + typeof optionsOrLatency === "number" ? { latency: optionsOrLatency } : (optionsOrLatency ?? {}); + const latency = opts.latency ?? 0; + const profile = opts.streamingProfile; + const signal = opts.signal; + const onChunkSent = opts.onChunkSent; + + if (res.writableEnded) return true; + res.setHeader("Content-Type", "text/event-stream"); + res.setHeader("Cache-Control", "no-cache"); + res.setHeader("Connection", "keep-alive"); + + let chunkIndex = 0; + for (const event of events) { + const chunkDelay = calculateDelay(chunkIndex, profile, latency); + if (chunkDelay > 0) await delay(chunkDelay, signal); + if (signal?.aborted) return false; + if (res.writableEnded) return true; + // Data-only SSE (no event: prefix, no [DONE]) + res.write(`data: ${JSON.stringify(event)}\n\n`); + onChunkSent?.(); + if (signal?.aborted) return false; + chunkIndex++; + } + + if (!res.writableEnded) { + res.end(); + } + return true; +} + +// ─── Request handler ────────────────────────────────────────────────────── + +export async function handleGeminiInteractions( + req: http.IncomingMessage, + res: http.ServerResponse, + raw: string, + fixtures: Fixture[], + journal: Journal, + defaults: HandlerDefaults, + setCorsHeaders: (res: http.ServerResponse) => void, +): Promise { + const { logger } = defaults; + setCorsHeaders(res); + + const urlPath = req.url ?? "/v1beta/interactions"; + + let interactionsReq: InteractionsRequest; + try { + interactionsReq = JSON.parse(raw) as InteractionsRequest; + } catch { + journal.add({ + method: req.method ?? "POST", + path: urlPath, + headers: flattenHeaders(req.headers), + body: null, + response: { status: 400, fixture: null }, + }); + writeErrorResponse( + res, + 400, + JSON.stringify(buildInteractionsErrorResponse("Malformed JSON", "INVALID_ARGUMENT")), + ); + return; + } + + // Convert to ChatCompletionRequest for fixture matching + const completionReq = geminiInteractionsToCompletionRequest(interactionsReq); + completionReq._endpointType = "chat"; + + const streaming = interactionsReq.stream !== false; // default true + const model = completionReq.model; + + const testId = getTestId(req); + const fixture = matchFixture( + fixtures, + completionReq, + journal.getFixtureMatchCountsForTest(testId), + defaults.requestTransform, + ); + + if (fixture) { + journal.incrementFixtureMatchCount(fixture, fixtures, testId); + } + + if ( + applyChaos( + res, + fixture, + defaults.chaos, + req.headers, + journal, + { + method: req.method ?? "POST", + path: urlPath, + headers: flattenHeaders(req.headers), + body: completionReq, + }, + defaults.registry, + defaults.logger, + ) + ) + return; + + if (!fixture) { + if (defaults.record) { + const proxied = await proxyAndRecord( + req, + res, + completionReq, + "gemini-interactions", + urlPath, + fixtures, + defaults, + raw, + ); + if (proxied) { + journal.add({ + method: req.method ?? "POST", + path: urlPath, + headers: flattenHeaders(req.headers), + body: completionReq, + response: { + status: res.statusCode ?? 200, + fixture: null, + source: "proxy", + }, + }); + return; + } + } + const strictStatus = defaults.strict ? 503 : 404; + const strictMessage = defaults.strict + ? "Strict mode: no fixture matched" + : "No fixture matched"; + if (defaults.strict) { + logger.error(`STRICT: No fixture matched for ${req.method ?? "POST"} ${urlPath}`); + } + journal.add({ + method: req.method ?? "POST", + path: urlPath, + headers: flattenHeaders(req.headers), + body: completionReq, + response: { status: strictStatus, fixture: null }, + }); + writeErrorResponse( + res, + strictStatus, + JSON.stringify( + buildInteractionsErrorResponse( + strictMessage, + defaults.strict ? "UNAVAILABLE" : "NOT_FOUND", + ), + ), + ); + return; + } + + const response = fixture.response; + const latency = fixture.latency ?? defaults.latency; + const chunkSize = Math.max(1, fixture.chunkSize ?? defaults.chunkSize); + + // Error response + if (isErrorResponse(response)) { + const status = response.status ?? 500; + journal.add({ + method: req.method ?? "POST", + path: urlPath, + headers: flattenHeaders(req.headers), + body: completionReq, + response: { status, fixture }, + }); + writeErrorResponse( + res, + status, + JSON.stringify( + buildInteractionsErrorResponse(response.error.message, response.error.type ?? "ERROR"), + ), + ); + return; + } + + const interactionId = nextInteractionId(); + + // Content + tool calls response + if (isContentWithToolCallsResponse(response)) { + if (response.webSearches?.length) { + logger.warn( + "webSearches in fixture response are not supported for Gemini Interactions API — ignoring", + ); + } + const overrides = extractOverrides(response); + const journalEntry = journal.add({ + method: req.method ?? "POST", + path: urlPath, + headers: flattenHeaders(req.headers), + body: completionReq, + response: { status: 200, fixture }, + }); + if (!streaming) { + const body = buildInteractionsContentWithToolCallsResponse( + response.content, + response.toolCalls, + model, + interactionId, + logger, + overrides, + ); + res.writeHead(200, { "Content-Type": "application/json" }); + res.end(JSON.stringify(body)); + } else { + const events = buildInteractionsContentWithToolCallsSSEEvents( + response.content, + response.toolCalls, + interactionId, + chunkSize, + logger, + overrides, + ); + const interruption = createInterruptionSignal(fixture); + const completed = await writeGeminiInteractionsSSEStream(res, events, { + latency, + streamingProfile: fixture.streamingProfile, + signal: interruption?.signal, + onChunkSent: interruption?.tick, + }); + if (!completed) { + if (!res.writableEnded) res.destroy(); + journalEntry.response.interrupted = true; + journalEntry.response.interruptReason = interruption?.reason(); + } + interruption?.cleanup(); + } + return; + } + + // Text response + if (isTextResponse(response)) { + if (response.webSearches?.length) { + logger.warn( + "webSearches in fixture response are not supported for Gemini Interactions API — ignoring", + ); + } + const overrides = extractOverrides(response); + const journalEntry = journal.add({ + method: req.method ?? "POST", + path: urlPath, + headers: flattenHeaders(req.headers), + body: completionReq, + response: { status: 200, fixture }, + }); + if (!streaming) { + const body = buildInteractionsTextResponse(response.content, model, interactionId, overrides); + res.writeHead(200, { "Content-Type": "application/json" }); + res.end(JSON.stringify(body)); + } else { + const events = buildInteractionsTextSSEEvents( + response.content, + interactionId, + chunkSize, + overrides, + ); + const interruption = createInterruptionSignal(fixture); + const completed = await writeGeminiInteractionsSSEStream(res, events, { + latency, + streamingProfile: fixture.streamingProfile, + signal: interruption?.signal, + onChunkSent: interruption?.tick, + }); + if (!completed) { + if (!res.writableEnded) res.destroy(); + journalEntry.response.interrupted = true; + journalEntry.response.interruptReason = interruption?.reason(); + } + interruption?.cleanup(); + } + return; + } + + // Tool call response + if (isToolCallResponse(response)) { + const overrides = extractOverrides(response); + const journalEntry = journal.add({ + method: req.method ?? "POST", + path: urlPath, + headers: flattenHeaders(req.headers), + body: completionReq, + response: { status: 200, fixture }, + }); + if (!streaming) { + const body = buildInteractionsToolCallResponse( + response.toolCalls, + model, + interactionId, + logger, + overrides, + ); + res.writeHead(200, { "Content-Type": "application/json" }); + res.end(JSON.stringify(body)); + } else { + const events = buildInteractionsToolCallSSEEvents( + response.toolCalls, + interactionId, + logger, + overrides, + ); + const interruption = createInterruptionSignal(fixture); + const completed = await writeGeminiInteractionsSSEStream(res, events, { + latency, + streamingProfile: fixture.streamingProfile, + signal: interruption?.signal, + onChunkSent: interruption?.tick, + }); + if (!completed) { + if (!res.writableEnded) res.destroy(); + journalEntry.response.interrupted = true; + journalEntry.response.interruptReason = interruption?.reason(); + } + interruption?.cleanup(); + } + return; + } + + // Unknown response type + journal.add({ + method: req.method ?? "POST", + path: urlPath, + headers: flattenHeaders(req.headers), + body: completionReq, + response: { status: 500, fixture }, + }); + writeErrorResponse( + res, + 500, + JSON.stringify( + buildInteractionsErrorResponse("Fixture response did not match any known type", "INTERNAL"), + ), + ); +} diff --git a/src/index.ts b/src/index.ts index 046908ec..b01dba77 100644 --- a/src/index.ts +++ b/src/index.ts @@ -43,6 +43,12 @@ export { converseToCompletionRequest, } from "./bedrock-converse.js"; +// Gemini Interactions +export { + handleGeminiInteractions, + geminiInteractionsToCompletionRequest, +} from "./gemini-interactions.js"; + // AWS Event Stream export { encodeEventStreamFrame, @@ -136,6 +142,7 @@ export { collapseOpenAISSE, collapseAnthropicSSE, collapseGeminiSSE, + collapseGeminiInteractionsSSE, collapseOllamaNDJSON, collapseCohereSSE, collapseBedrockEventStream, diff --git a/src/recorder.ts b/src/recorder.ts index 229a389a..0e4d56b3 100644 --- a/src/recorder.ts +++ b/src/recorder.ts @@ -62,7 +62,9 @@ export async function proxyAndRecord( if (!record) return false; const providers = record.providers; - const upstreamUrl = providers[providerKey]; + // gemini-interactions shares the same upstream config as gemini + const lookupKey = providerKey === "gemini-interactions" ? "gemini" : providerKey; + const upstreamUrl = providers[lookupKey]; if (!upstreamUrl) { defaults.logger.warn(`No upstream URL configured for provider "${providerKey}" — cannot proxy`); @@ -497,6 +499,33 @@ function buildFixtureResponse( }; } + // Gemini Interactions: { id, status, outputs: [{ type: "text", text }, { type: "function_call", name, arguments }] } + if (Array.isArray(obj.outputs) && obj.outputs.length > 0) { + const outputs = obj.outputs as Array>; + const fnCallOutputs = outputs.filter((o) => o.type === "function_call"); + const textOutputs = outputs.filter((o) => o.type === "text" && typeof o.text === "string"); + const hasToolCalls = fnCallOutputs.length > 0; + const joinedText = textOutputs.map((o) => String(o.text ?? "")).join(""); + const hasContent = joinedText.length > 0; + + if (hasToolCalls) { + const toolCalls: ToolCall[] = fnCallOutputs.map((o) => ({ + name: String(o.name), + arguments: typeof o.arguments === "string" ? o.arguments : JSON.stringify(o.arguments), + ...(o.id ? { id: String(o.id) } : {}), + })); + if (hasContent) { + return { content: joinedText, toolCalls }; + } + return { toolCalls }; + } + if (hasContent) { + return { content: joinedText }; + } + // Recognized Gemini Interactions shape but empty content + return { content: "" }; + } + // OpenAI video generation: { id, status, ... } // Guard against false positives: many API responses have `id` + `status` fields // (e.g. chat completions, Anthropic messages). Reject if the response has fields @@ -510,7 +539,8 @@ function buildFixtureResponse( !("candidates" in obj) && !("message" in obj) && !("data" in obj) && - !("object" in obj) + !("object" in obj) && + !("outputs" in obj) ) { if (obj.status === "completed" && obj.url) { return { diff --git a/src/server.ts b/src/server.ts index 5118c548..c809afdd 100644 --- a/src/server.ts +++ b/src/server.ts @@ -33,6 +33,7 @@ import { handleMessages } from "./messages.js"; import { handleGemini } from "./gemini.js"; import { handleBedrock, handleBedrockStream } from "./bedrock.js"; import { handleConverse, handleConverseStream } from "./bedrock-converse.js"; +import { handleGeminiInteractions } from "./gemini-interactions.js"; import { handleEmbeddings } from "./embeddings.js"; import { handleImages } from "./images.js"; import { handleSpeech } from "./speech.js"; @@ -119,6 +120,7 @@ function normalizeCompatPath(pathname: string, logger?: Logger): string { return pathname; } +const GEMINI_INTERACTIONS_PATH = "/v1beta/interactions"; const GEMINI_PATH_RE = /^\/v1beta\/models\/([^:]+):(generateContent|streamGenerateContent)$/; const AZURE_DEPLOYMENT_RE = /^\/openai\/deployments\/([^/]+)\/(chat\/completions|embeddings)$/; const BEDROCK_INVOKE_RE = /^\/model\/([^/]+)\/invoke$/; @@ -1232,6 +1234,31 @@ export async function createServer( return; } + // POST /v1beta/interactions — Google Gemini Interactions API + if (pathname === GEMINI_INTERACTIONS_PATH && req.method === "POST") { + try { + const raw = await readBody(req); + await handleGeminiInteractions(req, res, raw, fixtures, journal, defaults, setCorsHeaders); + } catch (err: unknown) { + const msg = err instanceof Error ? err.message : "Internal error"; + if (!res.headersSent) { + writeErrorResponse( + res, + 500, + JSON.stringify({ error: { message: msg, type: "server_error" } }), + ); + } else if (!res.writableEnded) { + try { + res.write(`data: ${JSON.stringify({ error: { message: msg } })}\n\n`); + } catch (writeErr) { + logger.debug("Failed to write error recovery response:", writeErr); + } + res.end(); + } + } + return; + } + // POST /v1beta/models/{model}:(generateContent|streamGenerateContent) — Google Gemini const geminiMatch = pathname.match(GEMINI_PATH_RE); if (geminiMatch && req.method === "POST") { diff --git a/src/stream-collapse.ts b/src/stream-collapse.ts index 31bad190..5634dd6e 100644 --- a/src/stream-collapse.ts +++ b/src/stream-collapse.ts @@ -658,6 +658,78 @@ export function collapseBedrockEventStream(body: Buffer): CollapseResult { }; } +// --------------------------------------------------------------------------- +// 7. Gemini Interactions SSE +// --------------------------------------------------------------------------- + +/** + * Collapse Gemini Interactions SSE stream into a single response. + * + * Format (data-only, event_type inside JSON): + * data: {"event_type":"content.delta","index":0,"delta":{"type":"text","text":"Hello"}}\n\n + * data: {"event_type":"interaction.complete","interaction":{"id":"...","usage":{...}}}\n\n + */ +export function collapseGeminiInteractionsSSE(body: string): CollapseResult { + const lines = body.split("\n\n").filter((l) => l.trim().length > 0); + let content = ""; + let reasoning = ""; + let droppedChunks = 0; + const toolCalls: ToolCall[] = []; + + for (const line of lines) { + const dataLine = line.split("\n").find((l) => l.startsWith("data:")); + if (!dataLine) continue; + + const payload = dataLine.slice(5).trim(); + + let parsed: Record; + try { + parsed = JSON.parse(payload) as Record; + } catch { + droppedChunks++; + continue; + } + + const eventType = parsed.event_type as string | undefined; + if (!eventType) continue; + + if (eventType === "content.delta") { + const delta = parsed.delta as Record | undefined; + if (!delta) continue; + + if (delta.type === "text" && typeof delta.text === "string") { + content += delta.text; + } else if (delta.type === "function_call") { + toolCalls.push({ + name: String(delta.name ?? ""), + arguments: + typeof delta.arguments === "string" + ? delta.arguments + : JSON.stringify(delta.arguments ?? {}), + ...(delta.id ? { id: String(delta.id) } : {}), + }); + } else if (delta.type === "thought_summary" && typeof delta.text === "string") { + reasoning += delta.text; + } + } + } + + if (toolCalls.length > 0) { + return { + ...(content ? { content } : {}), + toolCalls, + ...(reasoning ? { reasoning } : {}), + ...(droppedChunks > 0 ? { droppedChunks } : {}), + }; + } + + return { + content, + ...(reasoning ? { reasoning } : {}), + ...(droppedChunks > 0 ? { droppedChunks } : {}), + }; +} + // --------------------------------------------------------------------------- // Dispatch helper — pick the right collapse function by provider // --------------------------------------------------------------------------- @@ -696,6 +768,8 @@ export function collapseStreamingResponse( case "gemini": case "vertexai": return collapseGeminiSSE(str); + case "gemini-interactions": + return collapseGeminiInteractionsSSE(str); case "cohere": return collapseCohereSSE(str); case "bedrock": diff --git a/src/types.ts b/src/types.ts index 9d8a07c1..9413bed3 100644 --- a/src/types.ts +++ b/src/types.ts @@ -376,6 +376,7 @@ export type RecordProviderKey = | "openai" | "anthropic" | "gemini" + | "gemini-interactions" | "vertexai" | "bedrock" | "azure" From 696311fcc265743ebde0720474f436ba6fafd2ef Mon Sep 17 00:00:00 2001 From: Jordan Ritter Date: Fri, 1 May 2026 14:17:23 -0700 Subject: [PATCH 2/4] test: Gemini Interactions unit, integration, and drift tests 63 tests covering input conversion, response builders, SSE streaming, stream collapse, fixture matching, SDK wire-format conformance (content over parts, result over output), and unrecognized response fallback. --- src/__tests__/competitive-matrix.test.ts | 23 +- .../drift/gemini-interactions.drift.ts | 183 +++ src/__tests__/drift/helpers.ts | 21 + src/__tests__/drift/providers.ts | 78 ++ src/__tests__/drift/schema.ts | 8 + src/__tests__/drift/sdk-shapes.ts | 140 ++ src/__tests__/gemini-interactions.test.ts | 1248 +++++++++++++++++ src/__tests__/stream-collapse.test.ts | 10 + 8 files changed, 1700 insertions(+), 11 deletions(-) create mode 100644 src/__tests__/drift/gemini-interactions.drift.ts create mode 100644 src/__tests__/gemini-interactions.test.ts diff --git a/src/__tests__/competitive-matrix.test.ts b/src/__tests__/competitive-matrix.test.ts index a63b431f..bbbcd3d0 100644 --- a/src/__tests__/competitive-matrix.test.ts +++ b/src/__tests__/competitive-matrix.test.ts @@ -10,6 +10,7 @@ const PROVIDER_GROUPS: string[][] = [ ["openai"], ["claude", "anthropic"], ["gemini", "google.*ai"], + ["gemini.*interactions"], ["bedrock", "aws"], ["azure"], ["vertex"], @@ -412,12 +413,12 @@ describe("provider count extraction from README text", () => { expect(countProviders("This is a generic testing library.")).toBe(0); }); - it("counts all 12 provider groups when all are mentioned", () => { + it("counts all 13 provider groups when all are mentioned", () => { const readme = ` - OpenAI, Claude, Gemini, Bedrock, Azure, Vertex AI, + OpenAI, Claude, Gemini, Gemini Interactions, Bedrock, Azure, Vertex AI, Ollama, Cohere, Mistral, Groq, Together AI, Llama `; - expect(countProviders(readme)).toBe(12); + expect(countProviders(readme)).toBe(13); }); it("is case-insensitive", () => { @@ -548,7 +549,7 @@ describe("scoped provider count updates", () => { LLM providers 5 providers - 11 providers + 12 providers `; @@ -558,8 +559,8 @@ describe("scoped provider count updates", () => { // TestComp's cell should be updated expect(result).toContain("8 providers"); - // aimock's 11 providers should be left alone - expect(result).toContain("11 providers"); + // aimock's 12 providers should be left alone + expect(result).toContain("12 providers"); expect(changes.length).toBe(1); }); @@ -576,7 +577,7 @@ describe("scoped provider count updates", () => { Multi-provider support - 11 providers + 12 providers 5 providers @@ -585,8 +586,8 @@ describe("scoped provider count updates", () => { const result = updateProviderCounts(html, "TestComp", 8, changes); - // aimock's count must remain 11 - expect(result).toContain("11 providers"); + // aimock's count must remain 12 + expect(result).toContain("12 providers"); // TestComp's count should be updated to 8 expect(result).toContain("8 providers"); }); @@ -602,13 +603,13 @@ describe("scoped provider count updates", () => { }); it("does not update prose about aimock when updating competitor", () => { - const html = "

aimock supports 11 providers natively.

"; + const html = "

aimock supports 12 providers natively.

"; const changes: string[] = []; const result = updateProviderCounts(html, "TestComp", 15, changes); // aimock's claim in prose should not be touched - expect(result).toContain("11 providers"); + expect(result).toContain("12 providers"); expect(changes).toHaveLength(0); }); diff --git a/src/__tests__/drift/gemini-interactions.drift.ts b/src/__tests__/drift/gemini-interactions.drift.ts new file mode 100644 index 00000000..732d9d68 --- /dev/null +++ b/src/__tests__/drift/gemini-interactions.drift.ts @@ -0,0 +1,183 @@ +/** + * Google Gemini Interactions API drift tests. + * + * Three-way comparison: SDK types x real API x aimock output. + * + * The Interactions API is in Beta — shapes may shift as Google + * iterates on the endpoint. + */ + +import { describe, it, expect, beforeAll, afterAll } from "vitest"; +import type { ServerInstance } from "../../server.js"; +import { + extractShape, + triangulate, + compareSSESequences, + formatDriftReport, + shouldFail, +} from "./schema.js"; +import { + geminiInteractionsResponseShape, + geminiInteractionsToolCallResponseShape, + geminiInteractionsStreamEventShapes, + geminiInteractionsToolCallStreamEventShapes, +} from "./sdk-shapes.js"; +import { geminiInteractionsNonStreaming, geminiInteractionsStreaming } from "./providers.js"; +import { httpPost, parseInteractionsSSE, startDriftServer, stopDriftServer } from "./helpers.js"; + +// --------------------------------------------------------------------------- +// Server lifecycle +// --------------------------------------------------------------------------- + +let instance: ServerInstance; +const GOOGLE_API_KEY = process.env.GOOGLE_API_KEY; + +beforeAll(async () => { + instance = await startDriftServer(); +}); + +afterAll(async () => { + await stopDriftServer(instance); +}); + +// --------------------------------------------------------------------------- +// Tests +// --------------------------------------------------------------------------- + +describe.skipIf(!GOOGLE_API_KEY)("Gemini Interactions API drift", () => { + const config = { apiKey: GOOGLE_API_KEY! }; + + it("non-streaming text shape matches", async () => { + const sdkShape = geminiInteractionsResponseShape(); + + const [realRes, mockRes] = await Promise.all([ + geminiInteractionsNonStreaming(config, "Say hello"), + httpPost(`${instance.url}/v1beta/interactions`, { + model: "gemini-2.5-flash", + input: "Say hello", + stream: false, + }), + ]); + + const realShape = extractShape(realRes.body); + const mockShape = extractShape(JSON.parse(mockRes.body)); + + const diffs = triangulate(sdkShape, realShape, mockShape); + const report = formatDriftReport("Gemini Interactions (non-streaming text)", diffs); + + if (shouldFail(diffs)) { + expect.soft([], report).toEqual(diffs.filter((d) => d.severity === "critical")); + } + }); + + it("streaming text event sequence and shapes match", async () => { + const sdkEvents = geminiInteractionsStreamEventShapes(); + + const [realStream, mockStreamRes] = await Promise.all([ + geminiInteractionsStreaming(config, "Say hello"), + httpPost(`${instance.url}/v1beta/interactions`, { + model: "gemini-2.5-flash", + input: "Say hello", + stream: true, + }), + ]); + + expect(realStream.rawEvents.length, "Real API returned no SSE events").toBeGreaterThan(0); + + const mockEvents = parseInteractionsSSE(mockStreamRes.body); + expect(mockEvents.length, "Mock returned no SSE events").toBeGreaterThan(0); + + const mockSSEShapes = mockEvents.map((e) => ({ + type: e.event_type, + dataShape: extractShape(e.data), + })); + + const diffs = compareSSESequences(sdkEvents, realStream.events, mockSSEShapes); + const report = formatDriftReport("Gemini Interactions (streaming text events)", diffs); + + if (shouldFail(diffs)) { + expect.soft([], report).toEqual(diffs.filter((d) => d.severity === "critical")); + } + }); + + it("non-streaming tool call shape matches", async () => { + const sdkShape = geminiInteractionsToolCallResponseShape(); + + const tools = [ + { + type: "function", + name: "get_weather", + description: "Get weather", + parameters: { + type: "object", + properties: { city: { type: "string" } }, + required: ["city"], + }, + }, + ]; + + const [realRes, mockRes] = await Promise.all([ + geminiInteractionsNonStreaming(config, "Weather in Paris", tools), + httpPost(`${instance.url}/v1beta/interactions`, { + model: "gemini-2.5-flash", + input: "Weather in Paris", + stream: false, + tools, + }), + ]); + + const realShape = extractShape(realRes.body); + const mockShape = extractShape(JSON.parse(mockRes.body)); + + const diffs = triangulate(sdkShape, realShape, mockShape); + const report = formatDriftReport("Gemini Interactions (non-streaming tool call)", diffs); + + if (shouldFail(diffs)) { + expect.soft([], report).toEqual(diffs.filter((d) => d.severity === "critical")); + } + }); + + it("streaming tool call event sequence matches", async () => { + const sdkEvents = geminiInteractionsToolCallStreamEventShapes(); + + const tools = [ + { + type: "function", + name: "get_weather", + description: "Get weather", + parameters: { + type: "object", + properties: { city: { type: "string" } }, + required: ["city"], + }, + }, + ]; + + const [realStream, mockStreamRes] = await Promise.all([ + geminiInteractionsStreaming(config, "Weather in Paris", tools), + httpPost(`${instance.url}/v1beta/interactions`, { + model: "gemini-2.5-flash", + input: "Weather in Paris", + stream: true, + tools, + }), + ]); + + expect(realStream.rawEvents.length, "Real API returned no SSE events").toBeGreaterThan(0); + + const mockEvents = parseInteractionsSSE(mockStreamRes.body); + expect(mockEvents.length, "Mock returned no SSE events").toBeGreaterThan(0); + + const mockSSEShapes = mockEvents.map((e) => ({ + type: e.event_type, + dataShape: extractShape(e.data), + })); + + const diffs = compareSSESequences(sdkEvents, realStream.events, mockSSEShapes); + const report = formatDriftReport("Gemini Interactions (streaming tool call events)", diffs); + + if (shouldFail(diffs)) { + expect.soft([], report).toEqual(diffs.filter((d) => d.severity === "critical")); + } + }); +}); diff --git a/src/__tests__/drift/helpers.ts b/src/__tests__/drift/helpers.ts index 048627fc..fa170fed 100644 --- a/src/__tests__/drift/helpers.ts +++ b/src/__tests__/drift/helpers.ts @@ -77,6 +77,27 @@ export function parseTypedSSE(body: string): { type: string; data: Record }[] { + return body + .split("\n\n") + .filter((block) => block.startsWith("data: ") && !block.includes("[DONE]")) + .map((block) => { + const json = block.slice(6); + const data = JSON.parse(json) as Record; + return { + event_type: (data.event_type as string) ?? "unknown", + data, + }; + }); +} + // --------------------------------------------------------------------------- // Common fixtures // --------------------------------------------------------------------------- diff --git a/src/__tests__/drift/providers.ts b/src/__tests__/drift/providers.ts index dafced2b..cd43692b 100644 --- a/src/__tests__/drift/providers.ts +++ b/src/__tests__/drift/providers.ts @@ -374,6 +374,84 @@ export async function geminiStreaming( }; } +// --------------------------------------------------------------------------- +// Google Gemini Interactions API (Beta) +// --------------------------------------------------------------------------- + +export async function geminiInteractionsNonStreaming( + config: ProviderConfig, + input: string, + tools?: object[], +): Promise { + const body: Record = { + model: "gemini-2.5-flash", + input, + stream: false, + }; + if (tools) body.tools = tools; + + const res = await fetchWithRetry( + `https://generativelanguage.googleapis.com/v1beta/interactions`, + { + method: "POST", + headers: { + "Content-Type": "application/json", + "x-goog-api-key": config.apiKey, + }, + body: JSON.stringify(body), + }, + ); + + const raw = await res.text(); + return { + status: res.status, + body: parseJsonResponse(raw, res.status, "Gemini Interactions"), + raw, + }; +} + +export async function geminiInteractionsStreaming( + config: ProviderConfig, + input: string, + tools?: object[], +): Promise { + const body: Record = { + model: "gemini-2.5-flash", + input, + stream: true, + }; + if (tools) body.tools = tools; + + const res = await fetchWithRetry( + `https://generativelanguage.googleapis.com/v1beta/interactions`, + { + method: "POST", + headers: { + "Content-Type": "application/json", + "x-goog-api-key": config.apiKey, + }, + body: JSON.stringify(body), + }, + ); + + const raw = await res.text(); + assertOk(raw, res.status, "Gemini Interactions streaming"); + // Interactions uses data-only SSE (data: {...}\n\n) with event_type inside the JSON + const parsed = parseDataOnlySSE(raw); + const rawEvents = parsed.map((p) => { + const data = p.data as Record; + return { + type: (data.event_type as string) ?? "unknown", + data: data, + }; + }); + return { + status: res.status, + events: toSSEEventShapes(rawEvents), + rawEvents, + }; +} + // --------------------------------------------------------------------------- // OpenAI Embeddings // --------------------------------------------------------------------------- diff --git a/src/__tests__/drift/schema.ts b/src/__tests__/drift/schema.ts index 0fd851db..12a1d293 100644 --- a/src/__tests__/drift/schema.ts +++ b/src/__tests__/drift/schema.ts @@ -215,6 +215,14 @@ const ALLOWLISTED_PATHS = new Set([ // Gemini streaming metadata fields vary "modelVersion", "avgLogprobs", + // Gemini Interactions API — timestamps and synthetic event IDs + "created", + "updated", + "event_id", + "interaction.usage", + "interaction.usage.total_input_tokens", + "interaction.usage.total_output_tokens", + "interaction.usage.total_tokens", ]); function isAllowlisted(path: string): boolean { diff --git a/src/__tests__/drift/sdk-shapes.ts b/src/__tests__/drift/sdk-shapes.ts index cb025b1f..1c682e01 100644 --- a/src/__tests__/drift/sdk-shapes.ts +++ b/src/__tests__/drift/sdk-shapes.ts @@ -811,3 +811,143 @@ export function geminiStreamLastChunkShape(): ShapeNode { }, }); } + +// --------------------------------------------------------------------------- +// Google Gemini Interactions API (Beta) +// --------------------------------------------------------------------------- + +export function geminiInteractionsResponseShape(): ShapeNode { + return extractShape({ + id: "int_abc123", + status: "completed", + model: "gemini-2.5-flash", + role: "model", + outputs: [{ type: "text", text: "Hello!" }], + usage: { total_input_tokens: 0, total_output_tokens: 0, total_tokens: 0 }, + }); +} + +export function geminiInteractionsToolCallResponseShape(): ShapeNode { + return extractShape({ + id: "int_abc123", + status: "requires_action", + model: "gemini-2.5-flash", + role: "model", + outputs: [ + { + type: "function_call", + id: "call_abc123", + name: "get_weather", + arguments: { city: "Paris" }, + }, + ], + usage: { total_input_tokens: 0, total_output_tokens: 0, total_tokens: 0 }, + }); +} + +export function geminiInteractionsStreamEventShapes(): SSEEventShape[] { + return [ + { + type: "interaction.start", + dataShape: extractShape({ + event_type: "interaction.start", + interaction: { id: "int_abc123", status: "in_progress" }, + event_id: "evt_1", + }), + }, + { + type: "content.start", + dataShape: extractShape({ + event_type: "content.start", + index: 0, + content: { type: "text" }, + event_id: "evt_2", + }), + }, + { + type: "content.delta", + dataShape: extractShape({ + event_type: "content.delta", + index: 0, + delta: { type: "text", text: "Hello" }, + event_id: "evt_3", + }), + }, + { + type: "content.stop", + dataShape: extractShape({ + event_type: "content.stop", + index: 0, + event_id: "evt_4", + }), + }, + { + type: "interaction.complete", + dataShape: extractShape({ + event_type: "interaction.complete", + interaction: { + id: "int_abc123", + status: "completed", + usage: { total_input_tokens: 0, total_output_tokens: 0, total_tokens: 0 }, + }, + event_id: "evt_5", + }), + }, + ]; +} + +export function geminiInteractionsToolCallStreamEventShapes(): SSEEventShape[] { + return [ + { + type: "interaction.start", + dataShape: extractShape({ + event_type: "interaction.start", + interaction: { id: "int_abc123", status: "in_progress" }, + event_id: "evt_1", + }), + }, + { + type: "content.start", + dataShape: extractShape({ + event_type: "content.start", + index: 0, + content: { type: "function_call" }, + event_id: "evt_2", + }), + }, + { + type: "content.delta", + dataShape: extractShape({ + event_type: "content.delta", + index: 0, + delta: { + type: "function_call", + id: "call_abc123", + name: "get_weather", + arguments: { city: "Paris" }, + }, + event_id: "evt_3", + }), + }, + { + type: "content.stop", + dataShape: extractShape({ + event_type: "content.stop", + index: 0, + event_id: "evt_4", + }), + }, + { + type: "interaction.complete", + dataShape: extractShape({ + event_type: "interaction.complete", + interaction: { + id: "int_abc123", + status: "requires_action", + usage: { total_input_tokens: 0, total_output_tokens: 0, total_tokens: 0 }, + }, + event_id: "evt_5", + }), + }, + ]; +} diff --git a/src/__tests__/gemini-interactions.test.ts b/src/__tests__/gemini-interactions.test.ts new file mode 100644 index 00000000..1bb6a01e --- /dev/null +++ b/src/__tests__/gemini-interactions.test.ts @@ -0,0 +1,1248 @@ +import { describe, it, expect, afterEach, beforeEach } from "vitest"; +import * as http from "node:http"; +import type { Fixture } from "../types.js"; +import { createServer, type ServerInstance } from "../server.js"; +import { + geminiInteractionsToCompletionRequest, + resetInteractionCounter, + resetEventIdCounter, + buildInteractionsTextResponse, + buildInteractionsToolCallResponse, + buildInteractionsContentWithToolCallsResponse, + buildInteractionsTextSSEEvents, + buildInteractionsToolCallSSEEvents, + buildInteractionsContentWithToolCallsSSEEvents, +} from "../gemini-interactions.js"; +import { collapseGeminiInteractionsSSE } from "../stream-collapse.js"; +import { Logger } from "../logger.js"; + +// --- helpers --- + +function post( + url: string, + body: unknown, +): Promise<{ status: number; headers: http.IncomingHttpHeaders; body: string }> { + return new Promise((resolve, reject) => { + const data = JSON.stringify(body); + const parsed = new URL(url); + const req = http.request( + { + hostname: parsed.hostname, + port: parsed.port, + path: parsed.pathname, + method: "POST", + headers: { + "Content-Type": "application/json", + "Content-Length": Buffer.byteLength(data), + }, + }, + (res) => { + const chunks: Buffer[] = []; + res.on("data", (c: Buffer) => chunks.push(c)); + res.on("end", () => { + resolve({ + status: res.statusCode ?? 0, + headers: res.headers, + body: Buffer.concat(chunks).toString(), + }); + }); + }, + ); + req.on("error", reject); + req.write(data); + req.end(); + }); +} + +function postRaw(url: string, raw: string): Promise<{ status: number; body: string }> { + return new Promise((resolve, reject) => { + const parsed = new URL(url); + const req = http.request( + { + hostname: parsed.hostname, + port: parsed.port, + path: parsed.pathname, + method: "POST", + headers: { + "Content-Type": "application/json", + "Content-Length": Buffer.byteLength(raw), + }, + }, + (res) => { + const chunks: Buffer[] = []; + res.on("data", (c: Buffer) => chunks.push(c)); + res.on("end", () => { + resolve({ + status: res.statusCode ?? 0, + body: Buffer.concat(chunks).toString(), + }); + }); + }, + ); + req.on("error", reject); + req.write(raw); + req.end(); + }); +} + +function parseInteractionsSSEEvents(body: string): unknown[] { + const events: unknown[] = []; + for (const line of body.split("\n")) { + if (line.startsWith("data: ")) { + events.push(JSON.parse(line.slice(6))); + } + } + return events; +} + +// --- fixtures --- + +const textFixture: Fixture = { + match: { userMessage: "hello" }, + response: { content: "Hi there!" }, +}; + +const toolFixture: Fixture = { + match: { userMessage: "weather" }, + response: { + toolCalls: [ + { + name: "get_weather", + arguments: '{"city":"NYC"}', + id: "call_1", + }, + ], + }, +}; + +const contentWithToolsFixture: Fixture = { + match: { userMessage: "analyze" }, + response: { + content: "Let me help you", + toolCalls: [ + { + name: "analyze_data", + arguments: '{"dataset":"sales"}', + id: "call_2", + }, + ], + }, +}; + +const errorFixture: Fixture = { + match: { userMessage: "fail" }, + response: { + error: { + message: "Rate limited", + type: "RESOURCE_EXHAUSTED", + code: "rate_limit", + }, + status: 429, + }, +}; + +const sequenceFixture0: Fixture = { + match: { userMessage: "step", sequenceIndex: 0 }, + response: { content: "First" }, +}; + +const sequenceFixture1: Fixture = { + match: { userMessage: "step", sequenceIndex: 1 }, + response: { content: "Second" }, +}; + +const modelFixture: Fixture = { + match: { model: "gemini-2.5-pro" }, + response: { content: "Pro response" }, +}; + +const predicateFixture: Fixture = { + match: { + predicate: (req) => { + const lastMsg = req.messages[req.messages.length - 1]; + return lastMsg?.content === "custom-check"; + }, + }, + response: { content: "Predicate matched" }, +}; + +const toolNameFixture: Fixture = { + match: { toolName: "search_tool" }, + response: { + toolCalls: [{ name: "search_tool", arguments: '{"q":"test"}' }], + }, +}; + +const allFixtures: Fixture[] = [ + textFixture, + toolFixture, + contentWithToolsFixture, + errorFixture, + sequenceFixture0, + sequenceFixture1, + modelFixture, + predicateFixture, + toolNameFixture, +]; + +// --- tests --- + +let instance: ServerInstance | null = null; + +beforeEach(() => { + resetInteractionCounter(); + resetEventIdCounter(); +}); + +afterEach(async () => { + if (instance) { + await new Promise((resolve) => { + instance!.server.close(() => resolve()); + }); + instance = null; + } +}); + +// ─── Unit tests: input conversion ──────────────────────────────────────── + +describe("geminiInteractionsToCompletionRequest", () => { + it("converts string input to single user message", () => { + const result = geminiInteractionsToCompletionRequest({ + model: "gemini-2.5-flash", + input: "hello world", + }); + expect(result.messages).toEqual([{ role: "user", content: "hello world" }]); + expect(result.model).toBe("gemini-2.5-flash"); + expect(result.stream).toBe(true); // default + }); + + it("converts Turn[] input with role mapping", () => { + const result = geminiInteractionsToCompletionRequest({ + model: "gemini-2.5-flash", + input: [ + { role: "user", content: [{ type: "text", text: "hi" }] }, + { role: "model", content: [{ type: "text", text: "hello" }] }, + ], + }); + expect(result.messages).toEqual([ + { role: "user", content: "hi" }, + { role: "assistant", content: "hello" }, + ]); + }); + + it("converts Content[] input to single user message", () => { + const result = geminiInteractionsToCompletionRequest({ + model: "gemini-2.5-flash", + input: [ + { type: "text", text: "part one " }, + { type: "text", text: "part two" }, + ], + }); + expect(result.messages).toEqual([{ role: "user", content: "part one part two" }]); + }); + + it("converts function_result input to tool messages", () => { + const result = geminiInteractionsToCompletionRequest({ + model: "gemini-2.5-flash", + input: [ + { + role: "user", + content: [ + { + type: "function_result", + call_id: "call_abc", + result: { temperature: 72 }, + }, + ], + }, + ], + }); + expect(result.messages).toHaveLength(1); + expect(result.messages[0].role).toBe("tool"); + expect(result.messages[0].content).toBe('{"temperature":72}'); + expect(result.messages[0].tool_call_id).toBe("call_abc"); + }); + + it("converts system_instruction to system message", () => { + const result = geminiInteractionsToCompletionRequest({ + model: "gemini-2.5-flash", + system_instruction: "Be helpful and concise", + input: "hi", + }); + expect(result.messages[0]).toEqual({ + role: "system", + content: "Be helpful and concise", + }); + expect(result.messages[1]).toEqual({ role: "user", content: "hi" }); + }); + + it("converts function tool definitions", () => { + const result = geminiInteractionsToCompletionRequest({ + model: "gemini-2.5-flash", + input: "hi", + tools: [ + { + type: "function", + name: "get_weather", + description: "Get weather info", + parameters: { type: "object", properties: { city: { type: "string" } } }, + }, + ], + }); + expect(result.tools).toEqual([ + { + type: "function", + function: { + name: "get_weather", + description: "Get weather info", + parameters: { type: "object", properties: { city: { type: "string" } } }, + }, + }, + ]); + }); + + it("maps generation_config.temperature", () => { + const result = geminiInteractionsToCompletionRequest({ + model: "gemini-2.5-flash", + input: "hi", + generation_config: { temperature: 0.5 }, + }); + expect(result.temperature).toBe(0.5); + }); + + it("maps generation_config.max_output_tokens", () => { + const result = geminiInteractionsToCompletionRequest({ + model: "gemini-2.5-flash", + input: "hi", + generation_config: { max_output_tokens: 1024 }, + }); + expect(result.max_tokens).toBe(1024); + }); + + it("defaults model to gemini-2.5-flash when missing", () => { + const result = geminiInteractionsToCompletionRequest({ + input: "hi", + }); + expect(result.model).toBe("gemini-2.5-flash"); + }); + + it("handles empty input", () => { + const result = geminiInteractionsToCompletionRequest({ + model: "gemini-2.5-flash", + }); + expect(result.messages).toEqual([]); + }); + + it("handles mixed content blocks (text and function_call)", () => { + const result = geminiInteractionsToCompletionRequest({ + model: "gemini-2.5-flash", + input: [ + { + role: "model", + content: [ + { type: "text", text: "Calling tool..." }, + { + type: "function_call", + name: "search", + id: "call_x", + arguments: { query: "test" }, + }, + ], + }, + ], + }); + expect(result.messages).toHaveLength(1); + expect(result.messages[0].role).toBe("assistant"); + expect(result.messages[0].content).toBe("Calling tool..."); + expect(result.messages[0].tool_calls).toHaveLength(1); + expect(result.messages[0].tool_calls![0].function.name).toBe("search"); + }); + + it("respects stream=false", () => { + const result = geminiInteractionsToCompletionRequest({ + model: "gemini-2.5-flash", + input: "hi", + stream: false, + }); + expect(result.stream).toBe(false); + }); + + it("handles Turn with empty content array — user/assistant produce empty-content message", () => { + const result = geminiInteractionsToCompletionRequest({ + model: "gemini-2.5-flash", + input: [ + { role: "user", content: [] }, + { role: "model", content: [] }, + ], + }); + expect(result.messages).toEqual([ + { role: "user", content: "" }, + { role: "assistant", content: "" }, + ]); + }); + + it("handles Turn with empty content array — non-user/non-assistant role is skipped", () => { + const result = geminiInteractionsToCompletionRequest({ + model: "gemini-2.5-flash", + input: [ + { role: "system", content: [] }, + { role: "user", content: [{ type: "text", text: "hi" }] }, + ], + }); + // The system turn with empty content is skipped; only the user turn is kept + expect(result.messages).toEqual([{ role: "user", content: "hi" }]); + }); + + it("converts function_result with string result (passes through as-is)", () => { + const result = geminiInteractionsToCompletionRequest({ + model: "gemini-2.5-flash", + input: [ + { + role: "user", + content: [ + { + type: "function_result", + call_id: "call_str", + result: "plain string result", + }, + ], + }, + ], + }); + expect(result.messages).toHaveLength(1); + expect(result.messages[0].role).toBe("tool"); + expect(result.messages[0].content).toBe("plain string result"); + expect(result.messages[0].tool_call_id).toBe("call_str"); + }); + + // ─── Legacy parts fallback tests ────────────────────────────────────── + + it("handles Turn[] with legacy parts field for text (backwards compat)", () => { + const result = geminiInteractionsToCompletionRequest({ + model: "gemini-2.5-flash", + input: [ + { role: "user", parts: [{ type: "text", text: "hi from parts" }] }, + { role: "model", parts: [{ type: "text", text: "hello from parts" }] }, + ], + }); + expect(result.messages).toEqual([ + { role: "user", content: "hi from parts" }, + { role: "assistant", content: "hello from parts" }, + ]); + }); + + it("handles Turn[] with legacy parts field for function_call (backwards compat)", () => { + const result = geminiInteractionsToCompletionRequest({ + model: "gemini-2.5-flash", + input: [ + { + role: "model", + parts: [ + { + type: "function_call", + name: "legacy_tool", + id: "call_legacy", + arguments: { key: "value" }, + }, + ], + }, + ], + }); + expect(result.messages).toHaveLength(1); + expect(result.messages[0].role).toBe("assistant"); + expect(result.messages[0].tool_calls).toHaveLength(1); + expect(result.messages[0].tool_calls![0].function.name).toBe("legacy_tool"); + expect(result.messages[0].tool_calls![0].id).toBe("call_legacy"); + }); + + it("handles Turn[] with legacy parts field for function_result (backwards compat)", () => { + const result = geminiInteractionsToCompletionRequest({ + model: "gemini-2.5-flash", + input: [ + { + role: "user", + parts: [ + { + type: "function_result", + call_id: "call_legacy_result", + result: { status: "ok" }, + }, + ], + }, + ], + }); + expect(result.messages).toHaveLength(1); + expect(result.messages[0].role).toBe("tool"); + expect(result.messages[0].content).toBe('{"status":"ok"}'); + expect(result.messages[0].tool_call_id).toBe("call_legacy_result"); + }); + + // ─── result vs output preference tests ──────────────────────────────── + + it("falls back to output when result is not present (backwards compat)", () => { + const result = geminiInteractionsToCompletionRequest({ + model: "gemini-2.5-flash", + input: [ + { + role: "user", + content: [ + { + type: "function_result", + call_id: "call_old", + output: { legacy: true }, + }, + ], + }, + ], + }); + expect(result.messages).toHaveLength(1); + expect(result.messages[0].role).toBe("tool"); + expect(result.messages[0].content).toBe('{"legacy":true}'); + expect(result.messages[0].tool_call_id).toBe("call_old"); + }); + + it("prefers result over output when both are present on a function_result", () => { + const result = geminiInteractionsToCompletionRequest({ + model: "gemini-2.5-flash", + input: [ + { + role: "user", + content: [ + { + type: "function_result", + call_id: "call_both", + result: { from: "result" }, + output: { from: "output" }, + }, + ], + }, + ], + }); + expect(result.messages).toHaveLength(1); + expect(result.messages[0].role).toBe("tool"); + expect(result.messages[0].content).toBe('{"from":"result"}'); + expect(result.messages[0].tool_call_id).toBe("call_both"); + }); + + // ─── content vs parts preference test ───────────────────────────────── + + it("prefers content over parts when both are present on a Turn", () => { + const result = geminiInteractionsToCompletionRequest({ + model: "gemini-2.5-flash", + input: [ + { + role: "user", + content: [{ type: "text", text: "from-content" }], + parts: [{ type: "text", text: "from-parts" }], + }, + ], + }); + expect(result.messages).toHaveLength(1); + expect(result.messages[0].content).toBe("from-content"); + }); +}); + +// ─── Unit tests: response builders ────────────────────────────────────── + +describe("response builders", () => { + const logger = new Logger("silent"); + + it("builds text response", () => { + const resp = buildInteractionsTextResponse( + "Hello!", + "gemini-2.5-flash", + "aimock-int-0", + ) as Record; + expect(resp.id).toBe("aimock-int-0"); + expect(resp.status).toBe("completed"); + expect(resp.model).toBe("gemini-2.5-flash"); + expect(resp.role).toBe("model"); + expect(resp.outputs).toEqual([{ type: "text", text: "Hello!" }]); + }); + + it("builds tool call response", () => { + const resp = buildInteractionsToolCallResponse( + [{ name: "get_weather", arguments: '{"city":"NYC"}', id: "call_1" }], + "gemini-2.5-flash", + "aimock-int-0", + logger, + ) as Record; + expect(resp.status).toBe("requires_action"); + const outputs = resp.outputs as Array>; + expect(outputs).toHaveLength(1); + expect(outputs[0].type).toBe("function_call"); + expect(outputs[0].name).toBe("get_weather"); + expect(outputs[0].arguments).toEqual({ city: "NYC" }); + }); + + it("builds content+tools response", () => { + const resp = buildInteractionsContentWithToolCallsResponse( + "Here is the analysis", + [{ name: "analyze", arguments: '{"x":1}', id: "call_3" }], + "gemini-2.5-flash", + "aimock-int-0", + logger, + ) as Record; + expect(resp.status).toBe("requires_action"); + const outputs = resp.outputs as Array>; + expect(outputs).toHaveLength(2); + expect(outputs[0].type).toBe("text"); + expect(outputs[1].type).toBe("function_call"); + }); + + it("includes usage metadata", () => { + const resp = buildInteractionsTextResponse("Hello!", "gemini-2.5-flash", "aimock-int-0", { + usage: { input_tokens: 10, output_tokens: 5 }, + }) as Record; + expect(resp.usage).toEqual({ + total_input_tokens: 10, + total_output_tokens: 5, + total_tokens: 15, + }); + }); + + it("generates deterministic interactionIds", () => { + resetInteractionCounter(); + const r1 = buildInteractionsTextResponse("a", "m", "aimock-int-0"); + const r2 = buildInteractionsTextResponse("b", "m", "aimock-int-1"); + expect((r1 as Record).id).toBe("aimock-int-0"); + expect((r2 as Record).id).toBe("aimock-int-1"); + }); + + it("uses correct status values for different response types", () => { + const textResp = buildInteractionsTextResponse("Hello", "m", "id-0") as Record; + const toolResp = buildInteractionsToolCallResponse( + [{ name: "fn", arguments: "{}" }], + "m", + "id-1", + logger, + ) as Record; + expect(textResp.status).toBe("completed"); + expect(toolResp.status).toBe("requires_action"); + }); + + it("handles malformed JSON in tool call arguments gracefully", () => { + const resp = buildInteractionsToolCallResponse( + [{ name: "fn", arguments: "not-json", id: "call_x" }], + "m", + "id-0", + logger, + ) as Record; + const outputs = resp.outputs as Array>; + expect(outputs[0].arguments).toEqual({}); + }); +}); + +// ─── Unit tests: SSE event builders ───────────────────────────────────── + +describe("SSE event builders", () => { + const logger = new Logger("silent"); + + beforeEach(() => { + resetEventIdCounter(); + }); + + it("builds correct text SSE event sequence", () => { + const events = buildInteractionsTextSSEEvents("Hello!", "aimock-int-0", 100); + expect(events[0].event_type).toBe("interaction.start"); + expect(events[1].event_type).toBe("content.start"); + expect(events[1].index).toBe(0); + expect(events[2].event_type).toBe("content.delta"); + expect((events[2].delta as Record).type).toBe("text"); + expect((events[2].delta as Record).text).toBe("Hello!"); + expect(events[3].event_type).toBe("content.stop"); + expect(events[4].event_type).toBe("interaction.complete"); + }); + + it("builds correct tool call SSE event sequence", () => { + const events = buildInteractionsToolCallSSEEvents( + [{ name: "get_weather", arguments: '{"city":"NYC"}', id: "call_1" }], + "aimock-int-0", + logger, + ); + const eventTypes = events.map((e) => e.event_type); + expect(eventTypes).toEqual([ + "interaction.start", + "content.start", + "content.delta", + "content.stop", + "interaction.complete", + ]); + const delta = events[2].delta as Record; + expect(delta.type).toBe("function_call"); + expect(delta.name).toBe("get_weather"); + expect(delta.arguments).toEqual({ city: "NYC" }); + }); + + it("builds content+tools SSE with correct indices", () => { + const events = buildInteractionsContentWithToolCallsSSEEvents( + "Text", + [{ name: "fn", arguments: '{"a":1}', id: "call_1" }], + "aimock-int-0", + 100, + logger, + ); + // Find content.start events — should have indices 0 and 1 + const contentStarts = events.filter((e) => e.event_type === "content.start"); + expect(contentStarts).toHaveLength(2); + expect(contentStarts[0].index).toBe(0); // text + expect((contentStarts[0].content as Record).type).toBe("text"); + expect(contentStarts[1].index).toBe(1); // function_call + expect((contentStarts[1].content as Record).type).toBe("function_call"); + }); + + it("increments event_id correctly", () => { + const events = buildInteractionsTextSSEEvents("Hi", "aimock-int-0", 100); + const ids = events.map((e) => e.event_id); + expect(ids).toEqual(["evt_1", "evt_2", "evt_3", "evt_4", "evt_5"]); + }); + + it("includes usage in interaction.complete event", () => { + const events = buildInteractionsTextSSEEvents("Hi", "aimock-int-0", 100, { + usage: { input_tokens: 10, output_tokens: 5 }, + }); + const completeEvent = events.find((e) => e.event_type === "interaction.complete")!; + const interaction = completeEvent.interaction as Record; + expect(interaction.usage).toEqual({ + total_input_tokens: 10, + total_output_tokens: 5, + total_tokens: 15, + }); + }); + + it("chunks text by chunkSize", () => { + const events = buildInteractionsTextSSEEvents("ABCDEFGH", "aimock-int-0", 3); + const deltas = events.filter((e) => e.event_type === "content.delta"); + expect(deltas).toHaveLength(3); // ABC, DEF, GH + expect((deltas[0].delta as Record).text).toBe("ABC"); + expect((deltas[1].delta as Record).text).toBe("DEF"); + expect((deltas[2].delta as Record).text).toBe("GH"); + }); +}); + +// ─── Integration tests: non-streaming ─────────────────────────────────── + +describe("Gemini Interactions — non-streaming", () => { + it("returns text response", async () => { + instance = await createServer([...allFixtures]); + const res = await post(`${instance.url}/v1beta/interactions`, { + model: "gemini-2.5-flash", + input: "hello", + stream: false, + }); + expect(res.status).toBe(200); + const body = JSON.parse(res.body); + expect(body.status).toBe("completed"); + expect(body.role).toBe("model"); + expect(body.outputs).toEqual([{ type: "text", text: "Hi there!" }]); + expect(body.id).toMatch(/^aimock-int-/); + }); + + it("returns tool call response", async () => { + instance = await createServer([...allFixtures]); + const res = await post(`${instance.url}/v1beta/interactions`, { + model: "gemini-2.5-flash", + input: "weather", + stream: false, + }); + expect(res.status).toBe(200); + const body = JSON.parse(res.body); + expect(body.status).toBe("requires_action"); + const outputs = body.outputs; + expect(outputs).toHaveLength(1); + expect(outputs[0].type).toBe("function_call"); + expect(outputs[0].name).toBe("get_weather"); + expect(outputs[0].arguments).toEqual({ city: "NYC" }); + }); + + it("returns content + tool calls response", async () => { + instance = await createServer([...allFixtures]); + const res = await post(`${instance.url}/v1beta/interactions`, { + model: "gemini-2.5-flash", + input: "analyze", + stream: false, + }); + expect(res.status).toBe(200); + const body = JSON.parse(res.body); + expect(body.status).toBe("requires_action"); + expect(body.outputs).toHaveLength(2); + expect(body.outputs[0].type).toBe("text"); + expect(body.outputs[0].text).toBe("Let me help you"); + expect(body.outputs[1].type).toBe("function_call"); + expect(body.outputs[1].name).toBe("analyze_data"); + }); + + it("returns error response", async () => { + instance = await createServer([...allFixtures]); + const res = await post(`${instance.url}/v1beta/interactions`, { + model: "gemini-2.5-flash", + input: "fail", + stream: false, + }); + expect(res.status).toBe(429); + const body = JSON.parse(res.body); + expect(body.error.message).toBe("Rate limited"); + expect(body.error.code).toBe("RESOURCE_EXHAUSTED"); + }); + + it("returns 404 when no fixture matches", async () => { + instance = await createServer([...allFixtures]); + const res = await post(`${instance.url}/v1beta/interactions`, { + model: "gemini-2.5-flash", + input: "unmatched query", + stream: false, + }); + expect(res.status).toBe(404); + const body = JSON.parse(res.body); + expect(body.error.code).toBe("NOT_FOUND"); + }); + + it("returns 503 in strict mode", async () => { + instance = await createServer([...allFixtures], { strict: true }); + const res = await post(`${instance.url}/v1beta/interactions`, { + model: "gemini-2.5-flash", + input: "unmatched", + stream: false, + }); + expect(res.status).toBe(503); + const body = JSON.parse(res.body); + expect(body.error.code).toBe("UNAVAILABLE"); + }); + + it("handles sequenceIndex for multi-turn", async () => { + instance = await createServer([...allFixtures]); + const r1 = await post(`${instance.url}/v1beta/interactions`, { + model: "gemini-2.5-flash", + input: "step", + stream: false, + }); + const r2 = await post(`${instance.url}/v1beta/interactions`, { + model: "gemini-2.5-flash", + input: "step", + stream: false, + }); + expect(JSON.parse(r1.body).outputs[0].text).toBe("First"); + expect(JSON.parse(r2.body).outputs[0].text).toBe("Second"); + }); +}); + +// ─── Integration tests: streaming ─────────────────────────────────────── + +describe("Gemini Interactions — streaming", () => { + it("streams text response with correct SSE sequence", async () => { + instance = await createServer([...allFixtures]); + const res = await post(`${instance.url}/v1beta/interactions`, { + model: "gemini-2.5-flash", + input: "hello", + stream: true, + }); + expect(res.status).toBe(200); + expect(res.headers["content-type"]).toBe("text/event-stream"); + + const events = parseInteractionsSSEEvents(res.body); + expect(events.length).toBeGreaterThanOrEqual(5); + + const eventTypes = (events as Array>).map((e) => e.event_type); + expect(eventTypes[0]).toBe("interaction.start"); + expect(eventTypes[1]).toBe("content.start"); + expect(eventTypes).toContain("content.delta"); + expect(eventTypes).toContain("content.stop"); + expect(eventTypes[eventTypes.length - 1]).toBe("interaction.complete"); + }); + + it("accumulates content from text deltas", async () => { + instance = await createServer([ + { + match: { userMessage: "chunked" }, + response: { content: "ABCDEFGHIJ" }, + chunkSize: 3, + }, + ]); + const res = await post(`${instance.url}/v1beta/interactions`, { + model: "gemini-2.5-flash", + input: "chunked", + stream: true, + }); + const events = parseInteractionsSSEEvents(res.body) as Array>; + const textDeltas = events.filter( + (e) => + e.event_type === "content.delta" && (e.delta as Record).type === "text", + ); + const accumulated = textDeltas.map((e) => (e.delta as Record).text).join(""); + expect(accumulated).toBe("ABCDEFGHIJ"); + }); + + it("streams tool call deltas", async () => { + instance = await createServer([...allFixtures]); + const res = await post(`${instance.url}/v1beta/interactions`, { + model: "gemini-2.5-flash", + input: "weather", + stream: true, + }); + const events = parseInteractionsSSEEvents(res.body) as Array>; + const funcDeltas = events.filter( + (e) => + e.event_type === "content.delta" && + (e.delta as Record).type === "function_call", + ); + expect(funcDeltas).toHaveLength(1); + const delta = funcDeltas[0].delta as Record; + expect(delta.name).toBe("get_weather"); + expect(delta.arguments).toEqual({ city: "NYC" }); + }); + + it("assigns correct indices for content+tools stream", async () => { + instance = await createServer([...allFixtures]); + const res = await post(`${instance.url}/v1beta/interactions`, { + model: "gemini-2.5-flash", + input: "analyze", + stream: true, + }); + const events = parseInteractionsSSEEvents(res.body) as Array>; + + // Text at index 0, tool call at index 1 + const textDelta = events.find( + (e) => + e.event_type === "content.delta" && (e.delta as Record).type === "text", + ); + const toolDelta = events.find( + (e) => + e.event_type === "content.delta" && + (e.delta as Record).type === "function_call", + ); + expect(textDelta?.index).toBe(0); + expect(toolDelta?.index).toBe(1); + }); + + it("includes interactionId in lifecycle events", async () => { + instance = await createServer([...allFixtures]); + const res = await post(`${instance.url}/v1beta/interactions`, { + model: "gemini-2.5-flash", + input: "hello", + stream: true, + }); + const events = parseInteractionsSSEEvents(res.body) as Array>; + + const startEvent = events.find((e) => e.event_type === "interaction.start")!; + const completeEvent = events.find((e) => e.event_type === "interaction.complete")!; + + const startInteraction = startEvent.interaction as Record; + const completeInteraction = completeEvent.interaction as Record; + + expect(startInteraction.id).toMatch(/^aimock-int-/); + expect(completeInteraction.id).toBe(startInteraction.id); + expect(startInteraction.status).toBe("in_progress"); + expect(completeInteraction.status).toBe("completed"); + }); + + it("respects streaming profile", async () => { + instance = await createServer([ + { + match: { userMessage: "slow" }, + response: { content: "ABCD" }, + chunkSize: 1, + streamingProfile: { ttft: 50, tps: 100 }, + }, + ]); + const start = Date.now(); + const res = await post(`${instance.url}/v1beta/interactions`, { + model: "gemini-2.5-flash", + input: "slow", + stream: true, + }); + const elapsed = Date.now() - start; + expect(res.status).toBe(200); + // ttft=50ms + 4 chunks at ~10ms each ≈ 90ms; 40ms is a safe lower bound + expect(elapsed).toBeGreaterThanOrEqual(40); + }); + + it("defaults to streaming when stream field is omitted", async () => { + instance = await createServer([...allFixtures]); + const res = await post(`${instance.url}/v1beta/interactions`, { + model: "gemini-2.5-flash", + input: "hello", + // no stream field — defaults to true + }); + expect(res.headers["content-type"]).toBe("text/event-stream"); + }); + + it("handles interruption via truncateAfterChunks", async () => { + instance = await createServer([ + { + match: { userMessage: "interrupt" }, + response: { content: "A".repeat(100) }, + chunkSize: 1, + truncateAfterChunks: 3, + }, + ]); + // The server destroys the socket on truncation, so we may get a partial + // response or a connection reset. Either outcome is correct. + let body = ""; + try { + const res = await post(`${instance.url}/v1beta/interactions`, { + model: "gemini-2.5-flash", + input: "interrupt", + stream: true, + }); + body = res.body; + } catch (err: unknown) { + // socket hang up / ECONNRESET is expected when truncation destroys the connection + const code = (err as { code?: string }).code; + if (code !== "ECONNRESET") throw err; + // Interruption confirmed by connection being destroyed + return; + } + // If we got a response body, it should be truncated + const events = parseInteractionsSSEEvents(body); + expect(events.length).toBeLessThan(105); + }); +}); + +// ─── Fixture matching ─────────────────────────────────────────────────── + +describe("Gemini Interactions — fixture matching", () => { + it("matches by userMessage", async () => { + instance = await createServer([...allFixtures]); + const res = await post(`${instance.url}/v1beta/interactions`, { + model: "gemini-2.5-flash", + input: "hello", + stream: false, + }); + expect(JSON.parse(res.body).outputs[0].text).toBe("Hi there!"); + }); + + it("matches by sequenceIndex chaining", async () => { + instance = await createServer([...allFixtures]); + const r1 = await post(`${instance.url}/v1beta/interactions`, { + model: "gemini-2.5-flash", + input: "step", + stream: false, + }); + const r2 = await post(`${instance.url}/v1beta/interactions`, { + model: "gemini-2.5-flash", + input: "step", + stream: false, + }); + expect(JSON.parse(r1.body).outputs[0].text).toBe("First"); + expect(JSON.parse(r2.body).outputs[0].text).toBe("Second"); + }); + + it("matches by model", async () => { + instance = await createServer([...allFixtures]); + const res = await post(`${instance.url}/v1beta/interactions`, { + model: "gemini-2.5-pro", + input: "anything", + stream: false, + }); + expect(JSON.parse(res.body).outputs[0].text).toBe("Pro response"); + }); + + it("matches by predicate", async () => { + instance = await createServer([...allFixtures]); + const res = await post(`${instance.url}/v1beta/interactions`, { + model: "gemini-2.5-flash", + input: "custom-check", + stream: false, + }); + expect(JSON.parse(res.body).outputs[0].text).toBe("Predicate matched"); + }); + + it("matches by toolName for tool-related fixtures", async () => { + instance = await createServer([...allFixtures]); + const res = await post(`${instance.url}/v1beta/interactions`, { + model: "gemini-2.5-flash", + input: [ + { + role: "user", + content: [ + { + type: "function_result", + call_id: "call_abc", + result: "result", + }, + ], + }, + ], + tools: [{ type: "function", name: "search_tool", description: "Search" }], + stream: false, + }); + expect(res.status).toBe(200); + const body = JSON.parse(res.body); + expect(body.outputs[0].name).toBe("search_tool"); + }); +}); + +// ─── Stream collapse ──────────────────────────────────────────────────── + +describe("collapseGeminiInteractionsSSE", () => { + it("collapses text deltas", () => { + const sse = [ + 'data: {"event_type":"interaction.start","interaction":{"id":"int-0","status":"in_progress"},"event_id":"evt_1"}', + 'data: {"event_type":"content.start","index":0,"content":{"type":"text"},"event_id":"evt_2"}', + 'data: {"event_type":"content.delta","index":0,"delta":{"type":"text","text":"Hello "},"event_id":"evt_3"}', + 'data: {"event_type":"content.delta","index":0,"delta":{"type":"text","text":"World"},"event_id":"evt_4"}', + 'data: {"event_type":"content.stop","index":0,"event_id":"evt_5"}', + 'data: {"event_type":"interaction.complete","interaction":{"id":"int-0","status":"completed","usage":{"total_input_tokens":10,"total_output_tokens":5,"total_tokens":15}},"event_id":"evt_6"}', + ].join("\n\n"); + const result = collapseGeminiInteractionsSSE(sse); + expect(result.content).toBe("Hello World"); + expect(result.toolCalls).toBeUndefined(); + }); + + it("collapses tool call deltas", () => { + const sse = [ + 'data: {"event_type":"interaction.start","interaction":{"id":"int-0"},"event_id":"evt_1"}', + 'data: {"event_type":"content.start","index":0,"content":{"type":"function_call"},"event_id":"evt_2"}', + 'data: {"event_type":"content.delta","index":0,"delta":{"type":"function_call","id":"call_1","name":"get_weather","arguments":{"city":"NYC"}},"event_id":"evt_3"}', + 'data: {"event_type":"content.stop","index":0,"event_id":"evt_4"}', + 'data: {"event_type":"interaction.complete","interaction":{"id":"int-0","status":"requires_action"},"event_id":"evt_5"}', + ].join("\n\n"); + const result = collapseGeminiInteractionsSSE(sse); + expect(result.toolCalls).toHaveLength(1); + expect(result.toolCalls![0].name).toBe("get_weather"); + expect(result.toolCalls![0].arguments).toBe('{"city":"NYC"}'); + expect(result.toolCalls![0].id).toBe("call_1"); + }); + + it("collapses content + tool calls", () => { + const sse = [ + 'data: {"event_type":"content.delta","index":0,"delta":{"type":"text","text":"Help"},"event_id":"evt_1"}', + 'data: {"event_type":"content.delta","index":1,"delta":{"type":"function_call","id":"c1","name":"fn","arguments":{"x":1}},"event_id":"evt_2"}', + ].join("\n\n"); + const result = collapseGeminiInteractionsSSE(sse); + expect(result.content).toBe("Help"); + expect(result.toolCalls).toHaveLength(1); + expect(result.toolCalls![0].name).toBe("fn"); + }); + + it("collapses thought_summary deltas as reasoning", () => { + const sse = [ + 'data: {"event_type":"content.delta","index":0,"delta":{"type":"thought_summary","text":"Thinking..."},"event_id":"evt_1"}', + 'data: {"event_type":"content.delta","index":1,"delta":{"type":"text","text":"Answer"},"event_id":"evt_2"}', + ].join("\n\n"); + const result = collapseGeminiInteractionsSSE(sse); + expect(result.reasoning).toBe("Thinking..."); + expect(result.content).toBe("Answer"); + }); + + it("handles malformed chunks gracefully", () => { + const sse = [ + "data: not-json", + 'data: {"event_type":"content.delta","index":0,"delta":{"type":"text","text":"ok"},"event_id":"evt_1"}', + ].join("\n\n"); + const result = collapseGeminiInteractionsSSE(sse); + expect(result.content).toBe("ok"); + expect(result.droppedChunks).toBe(1); + }); + + it("handles incomplete stream (no interaction.complete)", () => { + const sse = [ + 'data: {"event_type":"content.delta","index":0,"delta":{"type":"text","text":"partial"},"event_id":"evt_1"}', + ].join("\n\n"); + const result = collapseGeminiInteractionsSSE(sse); + expect(result.content).toBe("partial"); + }); + + it("returns empty content for stream with no data events", () => { + const result = collapseGeminiInteractionsSSE(""); + expect(result.content).toBe(""); + }); +}); + +// ─── CORS ─────────────────────────────────────────────────────────────── + +describe("Gemini Interactions — CORS", () => { + it("sets CORS headers on response", async () => { + instance = await createServer([...allFixtures]); + const res = await post(`${instance.url}/v1beta/interactions`, { + model: "gemini-2.5-flash", + input: "hello", + stream: false, + }); + expect(res.headers["access-control-allow-origin"]).toBe("*"); + }); +}); + +// ─── Journal ──────────────────────────────────────────────────────────── + +describe("Gemini Interactions — journal", () => { + it("records request in journal", async () => { + instance = await createServer([...allFixtures]); + await post(`${instance.url}/v1beta/interactions`, { + model: "gemini-2.5-flash", + input: "hello", + stream: false, + }); + const entries = instance.journal.getAll(); + expect(entries.length).toBeGreaterThan(0); + const last = entries[entries.length - 1]; + expect(last.path).toBe("/v1beta/interactions"); + expect(last.response.status).toBe(200); + }); +}); + +// ─── Edge cases ───────────────────────────────────────────────────────── + +describe("Gemini Interactions — edge cases", () => { + it("returns 400 for malformed JSON", async () => { + instance = await createServer([...allFixtures]); + const res = await postRaw(`${instance.url}/v1beta/interactions`, "{bad json"); + expect(res.status).toBe(400); + const body = JSON.parse(res.body); + expect(body.error.code).toBe("INVALID_ARGUMENT"); + }); + + it("handles empty content text response", async () => { + instance = await createServer([ + { + match: { userMessage: "empty" }, + response: { content: "" }, + }, + ]); + const res = await post(`${instance.url}/v1beta/interactions`, { + model: "gemini-2.5-flash", + input: "empty", + stream: false, + }); + expect(res.status).toBe(200); + const body = JSON.parse(res.body); + expect(body.outputs[0].text).toBe(""); + }); + + it("streams empty content correctly", async () => { + instance = await createServer([ + { + match: { userMessage: "empty-stream" }, + response: { content: "" }, + }, + ]); + const res = await post(`${instance.url}/v1beta/interactions`, { + model: "gemini-2.5-flash", + input: "empty-stream", + stream: true, + }); + const events = parseInteractionsSSEEvents(res.body) as Array>; + const deltas = events.filter((e) => e.event_type === "content.delta"); + expect(deltas).toHaveLength(1); + expect((deltas[0].delta as Record).text).toBe(""); + }); + + it("returns 500 for unrecognized fixture response type", async () => { + instance = await createServer([ + { + match: { userMessage: "bad-shape" }, + // eslint-disable-next-line @typescript-eslint/no-explicit-any + response: { unknownField: true } as any, + }, + ]); + const res = await post(`${instance.url}/v1beta/interactions`, { + model: "gemini-2.5-flash", + input: "bad-shape", + stream: false, + }); + expect(res.status).toBe(500); + const body = JSON.parse(res.body); + expect(body.error.code).toBe("INTERNAL"); + expect(body.error.message).toBe("Fixture response did not match any known type"); + }); +}); diff --git a/src/__tests__/stream-collapse.test.ts b/src/__tests__/stream-collapse.test.ts index ddb7299e..d9f27fb6 100644 --- a/src/__tests__/stream-collapse.test.ts +++ b/src/__tests__/stream-collapse.test.ts @@ -693,6 +693,16 @@ describe("collapseStreamingResponse", () => { expect(result!.content).toBe("vertex-hi"); }); + it('dispatches text/event-stream with "gemini-interactions" to Gemini Interactions collapse', () => { + const body = [ + 'data: {"event_type":"content.delta","index":0,"delta":{"type":"text","text":"gi-hi"},"event_id":"evt_1"}', + "", + ].join("\n"); + const result = collapseStreamingResponse("text/event-stream", "gemini-interactions", body); + expect(result).not.toBeNull(); + expect(result!.content).toBe("gi-hi"); + }); + it('dispatches text/event-stream with "unknown-provider" to OpenAI collapse (fallback)', () => { const body = `data: ${JSON.stringify({ id: "c1", choices: [{ delta: { content: "fallback-hi" } }] })}\n\ndata: [DONE]\n\n`; const result = collapseStreamingResponse( From 351adc49802054c6755921c6fa7d448b05bd88ef Mon Sep 17 00:00:00 2001 From: Jordan Ritter Date: Fri, 1 May 2026 14:17:29 -0700 Subject: [PATCH 3/4] docs: Gemini Interactions API documentation and provider count update Dedicated docs page with request format, fixture matching, SSE events, recording, and TanStack AI integration. Provider count updated 11 to 12 across README, migration guides, and competitive matrix. --- DRIFT.md | 16 +- README.md | 18 +- docs/docs/index.html | 5 +- docs/fixtures/index.html | 8 + docs/gemini-interactions/index.html | 347 ++++++++++++++++++ docs/index.html | 8 +- docs/migrate-from-mock-llm/index.html | 8 +- docs/migrate-from-mokksy/index.html | 10 +- docs/migrate-from-msw/index.html | 8 +- docs/migrate-from-openai-responses/index.html | 8 +- docs/migrate-from-piyook/index.html | 6 +- docs/migrate-from-python-mocks/index.html | 10 +- docs/migrate-from-vidaimock/index.html | 4 +- docs/sidebar.js | 1 + skills/write-fixtures/SKILL.md | 20 +- 15 files changed, 424 insertions(+), 53 deletions(-) create mode 100644 docs/gemini-interactions/index.html diff --git a/DRIFT.md b/DRIFT.md index edf45a19..35ec2261 100644 --- a/DRIFT.md +++ b/DRIFT.md @@ -77,6 +77,7 @@ When a `critical` drift is detected: - OpenAI Responses API → `src/responses.ts` (`buildTextResponse`, `buildToolCallResponse`, `buildTextStreamEvents`, `buildToolCallStreamEvents`) - Anthropic Claude → `src/messages.ts` (`buildClaudeTextResponse`, `buildClaudeToolCallResponse`, `buildClaudeTextStreamEvents`, `buildClaudeToolCallStreamEvents`) - Google Gemini → `src/gemini.ts` (`buildGeminiTextResponse`, `buildGeminiToolCallResponse`, `buildGeminiTextStreamChunks`, `buildGeminiToolCallStreamChunks`) + - Gemini Interactions → `src/gemini-interactions.ts` (`buildInteractionsTextResponse`, `buildInteractionsToolCallResponse`, `buildInteractionsTextSSEEvents`, `buildInteractionsToolCallSSEEvents`) 2. **Update the builder** — add or modify the field to match the real API shape. @@ -106,7 +107,18 @@ When a model is deprecated: ## WebSocket Drift Coverage -In addition to the 19 existing drift tests (16 HTTP response-shape + 3 model deprecation), WebSocket drift tests cover aimock's WS protocols (4 verified + 2 canary = 6 WS tests): +In addition to the 23 existing drift tests (20 HTTP response-shape + 3 model deprecation), WebSocket drift tests cover aimock's WS protocols (4 verified + 2 canary = 6 WS tests): + +### Gemini Interactions API (Beta) + +The Gemini Interactions API (`/v1beta/interactions`) is covered by 4 drift tests in `gemini-interactions.drift.ts`: + +- Non-streaming text shape +- Streaming text event sequence +- Non-streaming tool call shape +- Streaming tool call event sequence + +Uses `describe.skipIf(!GOOGLE_API_KEY)` like other Gemini tests. The Interactions API is in Beta — shapes may shift as Google iterates on the endpoint. | Protocol | Text | Tool Call | Real Endpoint | Status | | ------------------- | ---- | --------- | ------------------------------------------------------------------- | ---------- | @@ -163,4 +175,4 @@ The fix workflow also supports `workflow_dispatch` for manual runs. ## Cost -~25 API calls per run (16 HTTP response-shape + 3 model listing + 6 WS including canaries) using the cheapest available models (`gpt-4o-mini`, `gpt-4o-mini-realtime-preview`, `claude-haiku-4-5-20251001`, `gemini-2.5-flash`) with 10-100 max tokens each. Under $0.15/week at daily cadence. When Gemini Live text-capable models become available, the 2 canary tests will become full drift tests, increasing real WS connections from 4 to 6. +~29 API calls per run (20 HTTP response-shape + 3 model listing + 6 WS including canaries) using the cheapest available models (`gpt-4o-mini`, `gpt-4o-mini-realtime-preview`, `claude-haiku-4-5-20251001`, `gemini-2.5-flash`) with 10-100 max tokens each. Under $0.20/week at daily cadence. When Gemini Live text-capable models become available, the 2 canary tests will become full drift tests, increasing real WS connections from 4 to 6. diff --git a/README.md b/README.md index 561d185f..18404d99 100644 --- a/README.md +++ b/README.md @@ -35,14 +35,14 @@ await mock.stop(); aimock mocks everything your AI app talks to: -| Tool | What it mocks | Docs | -| -------------- | ------------------------------------------------------------------------------------------------------- | --------------------------------------------------- | -| **LLMock** | OpenAI (Chat/Responses/Realtime), Claude, Gemini (REST/Live), Bedrock, Azure, Vertex AI, Ollama, Cohere | [Providers](https://aimock.copilotkit.dev/docs) | -| **MCPMock** | MCP tools, resources, prompts with session management | [MCP](https://aimock.copilotkit.dev/mcp-mock) | -| **A2AMock** | Agent-to-agent protocol with SSE streaming | [A2A](https://aimock.copilotkit.dev/a2a-mock) | -| **AGUIMock** | AG-UI agent-to-UI event streams for frontend testing | [AG-UI](https://aimock.copilotkit.dev/agui-mock) | -| **VectorMock** | Pinecone, Qdrant, ChromaDB compatible endpoints | [Vector](https://aimock.copilotkit.dev/vector-mock) | -| **Services** | Tavily search, Cohere rerank, OpenAI moderation | [Services](https://aimock.copilotkit.dev/services) | +| Tool | What it mocks | Docs | +| -------------- | -------------------------------------------------------------------------------------------------------------------- | --------------------------------------------------- | +| **LLMock** | OpenAI (Chat/Responses/Realtime), Claude, Gemini (REST/Live/Interactions), Bedrock, Azure, Vertex AI, Ollama, Cohere | [Providers](https://aimock.copilotkit.dev/docs) | +| **MCPMock** | MCP tools, resources, prompts with session management | [MCP](https://aimock.copilotkit.dev/mcp-mock) | +| **A2AMock** | Agent-to-agent protocol with SSE streaming | [A2A](https://aimock.copilotkit.dev/a2a-mock) | +| **AGUIMock** | AG-UI agent-to-UI event streams for frontend testing | [AG-UI](https://aimock.copilotkit.dev/agui-mock) | +| **VectorMock** | Pinecone, Qdrant, ChromaDB compatible endpoints | [Vector](https://aimock.copilotkit.dev/vector-mock) | +| **Services** | Tavily search, Cohere rerank, OpenAI moderation | [Services](https://aimock.copilotkit.dev/services) | Run them all on one port with `npx @copilotkit/aimock --config aimock.json`, or use the programmatic API to compose exactly what you need. @@ -50,7 +50,7 @@ Run them all on one port with `npx @copilotkit/aimock --config aimock.json`, or - **[Record & Replay](https://aimock.copilotkit.dev/record-replay)** — Proxy real APIs, save as fixtures, replay deterministically forever - **[Multi-turn Conversations](https://aimock.copilotkit.dev/multi-turn)** — Record and replay multi-turn traces with tool rounds; match distinct turns via `turnIndex`, `hasToolResult`, `toolCallId`, `sequenceIndex`, or custom predicates -- **[11 LLM Providers](https://aimock.copilotkit.dev/docs)** — OpenAI Chat, OpenAI Responses, OpenAI Realtime, Claude, Gemini, Gemini Live, Azure, Bedrock, Vertex AI, Ollama, Cohere — full streaming support +- **[12 LLM Providers](https://aimock.copilotkit.dev/docs)** — OpenAI Chat, OpenAI Responses, OpenAI Realtime, Claude, Gemini, Gemini Live, Gemini Interactions, Azure, Bedrock, Vertex AI, Ollama, Cohere — full streaming support - **Multimedia APIs** — [image generation](https://aimock.copilotkit.dev/images) (DALL-E, Imagen), [text-to-speech](https://aimock.copilotkit.dev/speech), [audio transcription](https://aimock.copilotkit.dev/transcription), [video generation](https://aimock.copilotkit.dev/video) - **[MCP](https://aimock.copilotkit.dev/mcp-mock) / [A2A](https://aimock.copilotkit.dev/a2a-mock) / [AG-UI](https://aimock.copilotkit.dev/agui-mock) / [Vector](https://aimock.copilotkit.dev/vector-mock)** — Mock every protocol your AI agents use - **[Chaos Testing](https://aimock.copilotkit.dev/chaos-testing)** — 500 errors, malformed JSON, mid-stream disconnects at any probability diff --git a/docs/docs/index.html b/docs/docs/index.html index 6d5e3156..6012c0f2 100644 --- a/docs/docs/index.html +++ b/docs/docs/index.html @@ -306,7 +306,10 @@

The Suite

LLM Providers - OpenAI, Claude, Gemini, Bedrock, Azure, Vertex AI, Ollama, Cohere + + OpenAI, Claude, Gemini, Gemini Interactions, Bedrock, Azure, Vertex AI, Ollama, + Cohere + Docs → diff --git a/docs/fixtures/index.html b/docs/fixtures/index.html index 8bbe0905..1c4fe0c4 100644 --- a/docs/fixtures/index.html +++ b/docs/fixtures/index.html @@ -547,6 +547,7 @@

Provider Support Matrix

OpenAI Responses Claude Gemini + Gemini Int. Vertex AI Bedrock Azure @@ -566,6 +567,7 @@

Provider Support Matrix

Yes Yes Yes + Yes Tool Calls @@ -578,6 +580,7 @@

Provider Support Matrix

Yes Yes Yes + Yes Content + Tool Calls @@ -590,6 +593,7 @@

Provider Support Matrix

Yes Yes Yes + Yes Streaming @@ -598,6 +602,7 @@

Provider Support Matrix

SSE SSE SSE + SSE Binary EventStream SSE NDJSON @@ -609,6 +614,7 @@

Provider Support Matrix

Yes Yes Yes + — Yes Yes Yes @@ -626,6 +632,7 @@

Provider Support Matrix

— — — + — Response Overrides @@ -634,6 +641,7 @@

Provider Support Matrix

Yes Yes Yes + Yes — Yes* — diff --git a/docs/gemini-interactions/index.html b/docs/gemini-interactions/index.html new file mode 100644 index 00000000..07d03450 --- /dev/null +++ b/docs/gemini-interactions/index.html @@ -0,0 +1,347 @@ + + + + + + Gemini Interactions — aimock + + + + + + + + + +
+ + +
+

Gemini Interactions API

+

+ The Gemini Interactions API is Google's stateful conversation endpoint. Unlike the + standard + generateContent/streamGenerateContent endpoints, Interactions + uses previous_interaction_id for server-side conversation state, flat + outputs[] instead of nested candidates[].content.parts[], and + typed SSE events with an event_type field inside each JSON payload. +

+ +

Endpoint

+ + + + + + + + + + + + + + + +
MethodPathFormat
POST/v1beta/interactionsSSE (typed JSON events)
+ +

Quick Start

+

Set up a minimal mock that responds to a Gemini Interactions request:

+ +
+
quick-start.ts ts
+
import { LLMock } from "@copilotkit/aimock";
+
+const mock = new LLMock();
+mock.on({ userMessage: "hello" }, { content: "Hi there!" });
+await mock.listen(4010);
+
+// Client sends:
+// POST /v1beta/interactions
+// { model: "gemini-2.5-flash", input: "hello", stream: true }
+
+ +

Request Format

+

+ The Interactions API accepts several input shapes. aimock normalizes all of them into the + unified fixture-matching format. +

+ +

String Input (Simple Prompt)

+
+
string-input.json json
+
{
+  "model": "gemini-2.5-flash",
+  "input": "What is the capital of France?",
+  "stream": true
+}
+
+ +

Turn[] Input (Multi-Turn)

+
+
+ multi-turn-input.json json +
+
{
+  "model": "gemini-2.5-flash",
+  "input": [
+    { "role": "user", "parts": [{ "type": "text", "text": "Hello" }] },
+    { "role": "model", "parts": [{ "type": "text", "text": "Hi there!" }] },
+    { "role": "user", "parts": [{ "type": "text", "text": "Tell me a joke" }] }
+  ],
+  "stream": true
+}
+
+ +

Content[] with Function Result (Tool Response)

+
+
+ tool-response-input.json json +
+
{
+  "model": "gemini-2.5-flash",
+  "input": [
+    {
+      "role": "user",
+      "parts": [
+        { "type": "function_result", "name": "get_weather", "call_id": "call_abc123", "result": "72F sunny" }
+      ]
+    }
+  ],
+  "previous_interaction_id": "interaction_abc123",
+  "stream": true
+}
+
+ +

Stateful Chaining

+

+ The previous_interaction_id field links turns together on the server side. + Each response includes an interaction_id that the client passes as + previous_interaction_id in the next request, eliminating the need to resend + full conversation history. +

+ +

Fixture Matching

+

+ Fixtures use the same match object as all other providers. The most common + matchers for Interactions are userMessage and sequenceIndex. +

+ +

userMessage Matching

+
+
+ user-message-match.ts ts +
+
const fixture = {
+  match: { userMessage: "hello" },
+  response: { content: "Hi from Gemini Interactions!" },
+};
+
+ +

sequenceIndex for Multi-Turn Chains

+

+ Since the Interactions API is stateful, multi-turn conversations are the primary use case. + Use sequenceIndex to match by turn position: +

+
+
sequence-match.ts ts
+
const fixtures = [
+  {
+    match: { sequenceIndex: 0 },
+    response: {
+      toolCalls: [{ name: "get_weather", arguments: { city: "NYC" } }]
+    },
+  },
+  {
+    match: { sequenceIndex: 1 },
+    response: { content: "The weather in NYC is 72F and sunny." },
+  },
+];
+
+const instance = await createServer(fixtures);
+
+// Turn 1: model calls get_weather tool
+// Turn 2: after tool result, model produces final text
+
+ +

SSE Event Format

+

+ Unlike standard Gemini SSE (bare data: chunks), the Interactions API uses + typed events. Each SSE line is a data: JSON object containing an + event_type field. +

+ +

Event Types

+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
Event TypeDescription
interaction.startOpening event with interaction_id and model metadata
content.startMarks beginning of an output part
content.deltaIncremental text or function_call chunk
content.stopMarks completion of an output part
interaction.completeFinal event with usage metadata and finish reason
+ +

Text Streaming Example

+
+
+ text-sse-events.txt text +
+
data: {"event_type":"interaction.start","interaction":{"id":"int_abc123","status":"in_progress"},"event_id":"evt_1"}
+
+data: {"event_type":"content.start","index":0,"content":{"type":"text"},"event_id":"evt_2"}
+
+data: {"event_type":"content.delta","index":0,"delta":{"type":"text","text":"Hi "},"event_id":"evt_3"}
+
+data: {"event_type":"content.delta","index":0,"delta":{"type":"text","text":"there!"},"event_id":"evt_4"}
+
+data: {"event_type":"content.stop","index":0,"event_id":"evt_5"}
+
+data: {"event_type":"interaction.complete","interaction":{"id":"int_abc123","status":"completed","usage":{"total_input_tokens":5,"total_output_tokens":3,"total_tokens":8}},"event_id":"evt_6"}
+
+ +

Tool Call Streaming Example

+
+
+ tool-sse-events.txt text +
+
data: {"event_type":"interaction.start","interaction":{"id":"int_def456","status":"in_progress"},"event_id":"evt_1"}
+
+data: {"event_type":"content.start","index":0,"content":{"type":"function_call"},"event_id":"evt_2"}
+
+data: {"event_type":"content.delta","index":0,"delta":{"type":"function_call","id":"tc_abc123","name":"get_weather","arguments":{"city":"NYC"}},"event_id":"evt_3"}
+
+data: {"event_type":"content.stop","index":0,"event_id":"evt_4"}
+
+data: {"event_type":"interaction.complete","interaction":{"id":"int_def456","status":"requires_action","usage":{"total_input_tokens":8,"total_output_tokens":12,"total_tokens":20}},"event_id":"evt_5"}
+
+ +

Recording

+

+ To record real Gemini Interactions API traffic, use the gemini provider + settings (same base URL, same API key). The Interactions endpoint shares the Gemini + infrastructure: +

+ +
+
record-config.ts ts
+
import { LLMock } from "@copilotkit/aimock";
+
+const mock = new LLMock({
+  record: {
+    providers: {
+      gemini: "https://generativelanguage.googleapis.com",
+    },
+  },
+});
+await mock.listen(4010);
+
+ +

+ Unmatched requests to /v1beta/interactions are proxied to the real API, and + the response is recorded as a new fixture. +

+ +

Integration with TanStack AI

+

+ TanStack AI provides a geminiTextInteractions() adapter for the Interactions + API. When your client uses this adapter, point it at your aimock instance and the same + fixtures will serve the responses. See the + TanStack AI docs for adapter + configuration. +

+ +

Known Limitations

+
+

+ The Gemini Interactions API is currently in beta. Both the real API and + aimock's support for it are subject to change as the API stabilizes. +

+
+
    +
  • + Beta API — event types and request shapes may shift between + releases. +
  • +
  • + Text output + function tools only — the current scope covers text + generation and function calling. +
  • +
  • + Built-in tools not supported — Google's built-in tools + (google_search, code_execution) are not yet mocked. +
  • +
  • + Non-text modalities not supported — image, audio, and video + inputs/outputs are not handled by this endpoint. +
  • +
+
+ +
+
+ +
+ + + + diff --git a/docs/index.html b/docs/index.html index c9f279ce..536f0248 100644 --- a/docs/index.html +++ b/docs/index.html @@ -1496,8 +1496,8 @@

Everything you need

📡

Every Major LLM Provider

- OpenAI, Claude, Gemini, Bedrock, Azure, Vertex AI, Ollama, Cohere — full - streaming and embeddings support for every provider. + OpenAI, Claude, Gemini, Gemini Interactions, Bedrock, Azure, Vertex AI, Ollama, Cohere + — full streaming and embeddings support for every provider.

@@ -1680,9 +1680,9 @@

How aimock compares

Multi-provider support - 11 providers ✓ + 12 providers ✓ manual - 11 providers + 12 providers OpenAI only OpenAI only 5 providers diff --git a/docs/migrate-from-mock-llm/index.html b/docs/migrate-from-mock-llm/index.html index c66cffb9..c10f23df 100644 --- a/docs/migrate-from-mock-llm/index.html +++ b/docs/migrate-from-mock-llm/index.html @@ -145,7 +145,7 @@

Switching from mock-llm to aimock

- mock-llm is solid for OpenAI mocking with Kubernetes. aimock gives you 10 more providers, + mock-llm is solid for OpenAI mocking with Kubernetes. aimock gives you 11 more providers, zero dependencies, and full MCP/A2A/AG-UI/Vector support—with the same Helm chart workflow you're used to.

@@ -229,10 +229,10 @@

What you gain

🌐
-

Multi-provider (11 vs 1)

+

Multi-provider (12 vs 1)

- OpenAI (Chat, Responses, Realtime), Claude, Gemini (REST and Live), Bedrock, Azure, - Vertex AI, Ollama, Cohere, and OpenAI-compatible providers. + OpenAI (Chat, Responses, Realtime), Claude, Gemini (REST, Live, and Interactions), + Bedrock, Azure, Vertex AI, Ollama, Cohere, and OpenAI-compatible providers.

diff --git a/docs/migrate-from-mokksy/index.html b/docs/migrate-from-mokksy/index.html index 0e84bfde..6507aedd 100644 --- a/docs/migrate-from-mokksy/index.html +++ b/docs/migrate-from-mokksy/index.html @@ -209,11 +209,11 @@

Record & replay

📡
-

More providers (11 vs 5)

+

More providers (12 vs 5)

- OpenAI (Chat, Responses, Realtime), Claude, Gemini (REST and Live), Bedrock, Azure, - Vertex AI, Ollama, Cohere, and more. Mokksy covers OpenAI, Anthropic, Google, Ollama, - and MistralAI. + OpenAI (Chat, Responses, Realtime), Claude, Gemini (REST, Live, and Interactions), + Bedrock, Azure, Vertex AI, Ollama, Cohere, and more. Mokksy covers OpenAI, Anthropic, + Google, Ollama, and MistralAI.

@@ -295,7 +295,7 @@

Comparison table

LLM providers 5 (OpenAI, Anthropic, Google, Ollama, MistralAI) - 11+ + 12+ Streaming SSE diff --git a/docs/migrate-from-msw/index.html b/docs/migrate-from-msw/index.html index 8169ec32..230a49e9 100644 --- a/docs/migrate-from-msw/index.html +++ b/docs/migrate-from-msw/index.html @@ -242,10 +242,10 @@

Cross-process interception

-

Built-in SSE for 10+ providers

+

Built-in SSE for 12 providers

- OpenAI (Chat, Responses, Realtime), Claude, Gemini (REST and Live), Bedrock, Azure, - Vertex AI, Ollama, Cohere. No manual chunk construction. + OpenAI (Chat, Responses, Realtime), Claude, Gemini (REST, Live, and Interactions), + Bedrock, Azure, Vertex AI, Ollama, Cohere. No manual chunk construction.

@@ -315,7 +315,7 @@

What you keep (or lose)

Streaming SSE Manual Built-in - 10+ providers + 12 providers WebSocket diff --git a/docs/migrate-from-openai-responses/index.html b/docs/migrate-from-openai-responses/index.html index 0426ea82..87e90c13 100644 --- a/docs/migrate-from-openai-responses/index.html +++ b/docs/migrate-from-openai-responses/index.html @@ -6,7 +6,7 @@ From openai-responses — aimock @@ -327,10 +327,10 @@

Cross-process, cross-language

-

Built-in SSE for 10+ providers

+

Built-in SSE for 12 providers

- OpenAI (Chat, Responses, Realtime), Claude, Gemini (REST and Live), Bedrock, Azure, - Vertex AI, Ollama, Cohere. No manual chunk construction. + OpenAI (Chat, Responses, Realtime), Claude, Gemini (REST, Live, and Interactions), + Bedrock, Azure, Vertex AI, Ollama, Cohere. No manual chunk construction.

diff --git a/docs/migrate-from-piyook/index.html b/docs/migrate-from-piyook/index.html index b50bf703..7652aece 100644 --- a/docs/migrate-from-piyook/index.html +++ b/docs/migrate-from-piyook/index.html @@ -223,10 +223,10 @@

Streaming SSE

🌐
-

10+ providers

+

12 providers

- OpenAI (Chat, Responses, Realtime), Claude, Gemini (REST and Live), Bedrock, Azure, - Vertex AI, Ollama, Cohere, and any OpenAI-compatible endpoint. + OpenAI (Chat, Responses, Realtime), Claude, Gemini (REST, Live, and Interactions), + Bedrock, Azure, Vertex AI, Ollama, Cohere, and any OpenAI-compatible endpoint.

diff --git a/docs/migrate-from-python-mocks/index.html b/docs/migrate-from-python-mocks/index.html index c0398e95..da3d8325 100644 --- a/docs/migrate-from-python-mocks/index.html +++ b/docs/migrate-from-python-mocks/index.html @@ -334,11 +334,11 @@

Cross-process, cross-language

📡
-

10+ LLM providers

+

12 LLM providers

- OpenAI (Chat, Responses, Realtime), Claude, Gemini (REST and Live), Bedrock, Azure, - Vertex AI, Ollama, Cohere. The Python libraries only cover OpenAI (and sometimes - Anthropic). + OpenAI (Chat, Responses, Realtime), Claude, Gemini (REST, Live, and Interactions), + Bedrock, Azure, Vertex AI, Ollama, Cohere. The Python libraries only cover OpenAI (and + sometimes Anthropic).

@@ -412,7 +412,7 @@

What you lose (honestly)

Multi-provider 1–2 providers - 10+ + 12 diff --git a/docs/migrate-from-vidaimock/index.html b/docs/migrate-from-vidaimock/index.html index 36e5ecb8..dd354243 100644 --- a/docs/migrate-from-vidaimock/index.html +++ b/docs/migrate-from-vidaimock/index.html @@ -246,8 +246,8 @@

Comparison table

LLM providers - 11+ - 11+ + 12+ + 12+ Prometheus metrics diff --git a/docs/sidebar.js b/docs/sidebar.js index 7c9901de..84acd47d 100644 --- a/docs/sidebar.js +++ b/docs/sidebar.js @@ -20,6 +20,7 @@ { label: "Responses API (OpenAI)", href: "/responses-api" }, { label: "Claude Messages", href: "/claude-messages" }, { label: "Gemini", href: "/gemini" }, + { label: "Gemini Interactions", href: "/gemini-interactions" }, { label: "Azure OpenAI", href: "/azure-openai" }, { label: "AWS Bedrock", href: "/aws-bedrock" }, { label: "Ollama", href: "/ollama" }, diff --git a/skills/write-fixtures/SKILL.md b/skills/write-fixtures/SKILL.md index 34e4f8dd..ed056a3b 100644 --- a/skills/write-fixtures/SKILL.md +++ b/skills/write-fixtures/SKILL.md @@ -7,7 +7,7 @@ description: Use when writing test fixtures for @copilotkit/aimock — mock LLM ## What aimock Is -aimock is a zero-dependency mock infrastructure for AI apps. Fixture-driven. Multi-provider (OpenAI, Anthropic, Gemini, AWS Bedrock, Azure OpenAI, Vertex AI, Ollama, Cohere). Multimedia endpoints (image generation, text-to-speech, audio transcription, video generation). MCP, A2A, AG-UI, and vector DB mocking. Runs a real HTTP server on a real port — works across processes, unlike MSW-style interceptors. WebSocket support for OpenAI Responses/Realtime and Gemini Live APIs. Record-and-replay for all endpoints including multimedia. Chaos testing and Prometheus metrics. +aimock is a zero-dependency mock infrastructure for AI apps. Fixture-driven. Multi-provider (OpenAI, Anthropic, Gemini, Gemini Interactions, AWS Bedrock, Azure OpenAI, Vertex AI, Ollama, Cohere). Multimedia endpoints (image generation, text-to-speech, audio transcription, video generation). MCP, A2A, AG-UI, and vector DB mocking. Runs a real HTTP server on a real port — works across processes, unlike MSW-style interceptors. WebSocket support for OpenAI Responses/Realtime and Gemini Live APIs. Record-and-replay for all endpoints including multimedia. Chaos testing and Prometheus metrics. ## Core Mental Model @@ -447,15 +447,15 @@ These fields map correctly across all provider formats — for example, `finishR ## Provider Support Matrix -| Feature | OpenAI Chat | OpenAI Responses | Claude | Gemini | Bedrock | Azure | Ollama | Cohere | -| -------------------- | ----------- | ---------------- | ------ | ------ | ------- | ----- | ------ | ------ | -| Text | Yes | Yes | Yes | Yes | Yes | Yes | Yes | Yes | -| Tool Calls | Yes | Yes | Yes | Yes | Yes | Yes | Yes | Yes | -| Content + Tool Calls | Yes | Yes | Yes | Yes | Yes | Yes | Yes | Yes | -| Streaming | SSE | SSE | SSE | SSE | Binary | SSE | NDJSON | SSE | -| Reasoning | Yes | Yes | Yes | Yes | Yes | Yes | -- | -- | -| Web Searches | -- | Yes | -- | -- | -- | -- | -- | -- | -| Response Overrides | Yes | Yes | Yes | Yes | -- | Yes | -- | -- | +| Feature | OpenAI Chat | OpenAI Responses | Claude | Gemini | Gemini Int. | Bedrock | Azure | Ollama | Cohere | +| -------------------- | ----------- | ---------------- | ------ | ------ | ----------- | ------- | ----- | ------ | ------ | +| Text | Yes | Yes | Yes | Yes | Yes | Yes | Yes | Yes | Yes | +| Tool Calls | Yes | Yes | Yes | Yes | Yes | Yes | Yes | Yes | Yes | +| Content + Tool Calls | Yes | Yes | Yes | Yes | Yes | Yes | Yes | Yes | Yes | +| Streaming | SSE | SSE | SSE | SSE | SSE | Binary | SSE | NDJSON | SSE | +| Reasoning | Yes | Yes | Yes | Yes | -- | Yes | Yes | -- | -- | +| Web Searches | -- | Yes | -- | -- | -- | -- | -- | -- | -- | +| Response Overrides | Yes | Yes | Yes | Yes | Yes | -- | Yes | -- | -- | ## Critical Gotchas From be60203cb9c2eac31746a503fae08b78dc257c09 Mon Sep 17 00:00:00 2001 From: Jordan Ritter Date: Fri, 1 May 2026 14:17:36 -0700 Subject: [PATCH 4/4] chore: add Gemini Interactions to drift report and fix scripts Register gemini-interactions provider in drift-report-collector and fix-drift scripts for automated SDK shape monitoring. --- scripts/drift-report-collector.ts | 10 ++++++++++ scripts/fix-drift.ts | 1 + 2 files changed, 11 insertions(+) diff --git a/scripts/drift-report-collector.ts b/scripts/drift-report-collector.ts index 02a6b896..8d2e8c98 100644 --- a/scripts/drift-report-collector.ts +++ b/scripts/drift-report-collector.ts @@ -132,6 +132,16 @@ const PROVIDER_MAP: Record = { typesFile: null, }, "OpenAI Embeddings": OPENAI_EMBEDDINGS_MAPPING, + "Gemini Interactions": { + builderFile: "src/gemini-interactions.ts", + builderFunctions: [ + "buildInteractionsTextResponse", + "buildInteractionsToolCallResponse", + "buildInteractionsTextSSEEvents", + "buildInteractionsToolCallSSEEvents", + ], + typesFile: null, + }, }; const SDK_SHAPES_FILE = "src/__tests__/drift/sdk-shapes.ts"; diff --git a/scripts/fix-drift.ts b/scripts/fix-drift.ts index f3f169ba..950a86cf 100644 --- a/scripts/fix-drift.ts +++ b/scripts/fix-drift.ts @@ -59,6 +59,7 @@ export const BUILDER_TO_SKILL_SECTION: Record = { "src/ws-responses.ts": "OpenAI Responses WebSocket", "src/ws-gemini-live.ts": "Gemini Live WebSocket", "src/helpers.ts": "OpenAI Chat Completions", + "src/gemini-interactions.ts": "Gemini Interactions", }; // ---------------------------------------------------------------------------