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. +

+
+ +
+ +
+ + + + + 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/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", }; // --------------------------------------------------------------------------- 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 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( 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"