Skip to content
4 min read · 827 words

Gemini generateContent

Google is not OpenAI with different header names. The native generateContent API has its own wire grammar: turns are contents[], the assistant role is model, system instructions sit outside the conversation stream, tool calls live on model turns, and tool results come back on user turns. Correlation between a call and its result happens by declared tool name, because the wire has no concept of a tool call ID. This battery speaks that native wire directly.

Pointing an OpenAI adapter at a compatibility proxy works until you need to reason about what Google actually received. Gateways silently merge turns, forge call IDs, and inject sentinels behind your back. When you need an honest wire-shape audit or an ordering guard that checks reality rather than proxy behaviour, you speak the native endpoint. This is the doctrine described in Which API surface a rule applies to.

ts
import { GeminiGenerateContentAdapter } from '@nhtio/adk/batteries/llm/gemini_generate_content'

const adapter = new GeminiGenerateContentAdapter({
  model: 'gemini-2.5-flash-lite',
  apiKey: process.env.GEMINI_API_KEY,
})

// adapter.executor() is the DispatchExecutorFn you hand to TurnRunner.

Routing through an OpenAI proxy?

If you access Gemini through an OpenAI-compatible gateway (LiteLLM, OpenRouter, or a cloud proxy) and want the gateway to handle turn translation, use the OpenAI Chat Completions adapter pointed at that gateway's /v1. Use this battery when you target Google directly or need unmediated control over the native wire shape.

What this wire buys you

  • Unmediated wire observability. The onPromptAssembled hook exposes the exact JSON payload about to be POSTed to :generateContent (or :streamGenerateContent). You see the real contents[], the systemInstruction, and the sanitized tools declarations before dispatch, making wire audits and ordering validation authentic.
  • Native tool correlation and formatting. ADK tools map to tools[].functionDeclarations[]. When the model invokes a tool, ADK executes it and returns a functionResponse part on a user turn. Result payloads are automatically wrapped in a JSON object ({ result: ... }) with untrusted-content tagging, because Gemini hard-rejects raw strings.
  • Automatic schema sanitization. Gemini's OpenAPI parameter parser rejects dozens of standard JSON-Schema keywords ($ref, $defs, additionalProperties, anyOf, oneOf, default, bounds constraints). The battery's sanitizeGeminiSchema helper recursively prunes them before dispatch, and omits empty parameters objects on zero-argument tools so Google does not fail the call with an opaque INVALID_ARGUMENT.
  • Thought-signature handling for Gemini 3+. Gemini 3+ models require a cryptographic thoughtSignature on historical functionCall parts. When replaying history or running multi-turn loops, the battery preserves genuine signatures. For history that originated outside Gemini (model handoffs or restored state), thoughtSignatureSentinel automatically injects Google's documented portable sentinel ('skip_thought_signature_validator') on the first call to prevent a hard 400 rejection.
  • Clean reasoning isolation. Model thoughts arrive flagged as thought: true. The extractor separates them into ADK Thought records rather than concatenating scratchpad reasoning into visible message text, preventing prompt leaks across turns.

The gotchas, because they will get you

Unmatched tool names yield silent emptiness. Gemini correlates tool results to tool declarations strictly by name — there is no call ID. If a historical functionResponse.name does not match any tool currently declared in tools[].functionDeclarations, Gemini does not throw a 400. It silently returns an empty candidate (candidates: [] or a turn with no parts). If a turn finishes instantly with no output and no error, check whether a tool was renamed or dropped from the active toolset between turns.

Reasoning models exhaust small token budgets on thoughts. Models with thinking enabled spend their output-token budget on reasoning parts before generating any user-visible text. If maxOutputTokens is set to a small value (such as 512 or 1024), the budget can be exhausted entirely inside the thinking phase. Google returns an HTTP 200 with finishReason: 'MAX_TOKENS' and zero text parts. To the caller, the response looks complete, but has no content. When using thinking models, set a generous maxOutputTokens or constrain thinkingConfig.thinkingBudget.

Gemini 3+ hard-rejects missing thought signatures. If history contains a functionCall part without a thoughtSignature and you set thoughtSignatureSentinel: false, Gemini 3+ returns an HTTP 400 INVALID_ARGUMENT. The adapter inspects the response and classifies it as E_GEMINI_MISSING_THOUGHT_SIGNATURE rather than a generic transport failure, making the condition actionable. If you switch models mid-conversation, keep the default sentinel enabled.

Auth headers differ between Google and gateways. Google's first-party endpoint requires the x-goog-api-key header (the default when apiKey is provided). Gateways fronting Gemini often accept only Authorization: Bearer <key>. Set useBearerAuth: true when routing through a bearer-authenticated proxy; the adapter switches headers rather than sending both.

Zero-argument tools must omit parameters. Gemini rejects an empty parameters schema ({ type: 'object', properties: {} }). The adapter's helper strips empty parameter objects entirely, sending only name and description. If you provide custom translation helpers via helpers, ensure your toolsToGeminiTools preserves this behavior.

Local tool-call recovery is fallback-only. If a model outputs tool-call JSON as plain text rather than structured functionCall parts, localToolCallParser attempts to extract calls against the turn's offered tool names. If the provider returned any native functionCall parts, the local parser is bypassed entirely; native calls always win.

Full option and exception details are in Assembly → LLM batteries.