Build

Gemini

gemini() — Google's models through the native @google/genai SDK, on Vertex or the Gemini API. Honest cached- and thinking-token counts, JSON Schema tools with no OpenAPI translation, and a forced single tool that is actually forced.

gemini() is the native adapter for Google's models. Two doors — Vertex (a project and Application Default Credentials) and the Gemini API (one key) — one adapter, and the agent code above it is identical to the code you run against Claude, GPT or a local model.

import { Agent } from 'agentfootprint';
import { gemini } from 'agentfootprint/providers';

const agent = Agent.create({
  provider: gemini({ project: 'my-project', location: 'us-central1' }),
  model: 'gemini',
})
  .tool(weatherTool)
  .build();
npm install @google/genai

That is the only peer dependency, and it is lazily required — importing agentfootprint/providers loads no Google code until you call the factory.

The two doors

You haveCallWhat authenticates
A Google Cloud projectgemini({ project, location })Application Default CredentialsGOOGLE_APPLICATION_CREDENTIALS, your gcloud user credentials, or the metadata server on GCE / Cloud Run
An AI Studio keygemini({ apiKey })The key. No cloud project involved

Neither is inferred from thin air. A project (option or GOOGLE_CLOUD_PROJECT) selects Vertex; otherwise a key (option, GEMINI_API_KEY or GOOGLE_API_KEY) selects the Gemini API; and configuring neither is refused at construction:

gemini: no Google project and no API key — this factory cannot tell which service you
meant, and the SDK would warn on stderr, construct anyway, and fail on the first call
with something that reads like a network problem.
  Vertex:      gemini({ project: "my-project", location: "us-central1" }) — or set …
  Gemini API:  gemini({ apiKey: process.env.GEMINI_API_KEY }) — or set GEMINI_API_KEY.
  Tests:       gemini({ _client }) with a { models: { … } } double — no SDK, no network.

An empty environment variable (GOOGLE_CLOUD_PROJECT=) reads as absent, because a variable set to nothing is not a setting — it is how a project comes to be "present" and unusable.

The doors do not run the same models, and the default follows the door

`model: 'gemini'` resolves on Vertex and is refused on the Gemini API door

An independent field trial, 2026-08, called the Gemini API door with this package's old shared default, gemini-2.5-flash, using a valid restricted key. Google answered 404: "This model models/gemini-2.5-flash is no longer available to new users." On Vertex, the identical model completed a real two-call tool loop the same day.

So the default is a Vertex default. On the key door the 'gemini' shorthand raises at the call, naming the door, quoting that 404 and giving both fixes — gemini({ apiKey, defaultModel: '…' }), or a model named per call. Shipping a second silent default that nobody has run would be this library guessing which models your key can reach.

Two more facts that belong in the same breath: Google lists gemini-2.5-flash for retirement on 2026-10-16, and the trial could not prove any model on the key door because that account's separate AI Studio prepayment was empty — a 429, not a code failure. The whole picture, every cell observed: the door/model matrix.

The API version is v1beta1 by default, and that is the SDK's choice

@google/genai 2.16.0 dials v1beta1 on Vertex and v1beta on the Gemini API unless you say otherwise. Pass apiVersion: 'v1' to overrule it. Both defaults are asserted against the installed package by the Google surface pin, so a change in the SDK reaches you as a failing test rather than as a different endpoint.

Options

OptionMeaning
project, locationThe Vertex door. location defaults to global (the multi-region endpoint)
apiKeyThe Gemini API door
vertexaiForce the door instead of inferring it
apiVersionPin the API version — see above
googleAuthOptionsGoogleAuthOptions for Vertex: a key file, scopes, or an AuthClient you built (workload identity federation, impersonation)
defaultModelModel used when the request says 'gemini'. Per door: Vertex defaults to gemini-2.5-flash; the Gemini API door has no default and refuses the shorthand by name — see below
defaultMaxTokensmaxOutputTokens when the request does not set one

What maps to what

agentfootprintGemini
systemPromptconfig.systemInstruction — a top-level field, outside the turn list
messages user{ role: 'user', parts: [{ text }] }
messages assistant{ role: 'model', parts: [{ text }, { functionCall }] }
messages toola functionResponse part inside a user turn; consecutive results coalesce into one turn
toolsfunctionDeclarations[].parametersJsonSchema — JSON Schema, untranslated
toolChoicetoolConfig.functionCallingConfig = { mode: 'ANY', allowedFunctionNames: [name] }
temperature, maxTokens, stop, signaltemperature, maxOutputTokens, stopSequences, abortSignal
thinking.budgetthinkingConfig.thinkingBudget
usage.input / .outputusageMetadata.promptTokenCount / .candidatesTokenCount
usage.cacheReadusageMetadata.cachedContentTokenCount
usage.thinkingusageMetadata.thoughtsTokenCount
stopReasoncandidates[0].finishReason, mapped conservatively — see below
providerRefresponseId

carriesInMessages is ['user', 'assistant']

Gemini takes the system prompt as a field outside the turn list, exactly as Anthropic does — so this is an Anthropic-family wire, not an OpenAI-family one. A slot: 'messages' injection with role: 'system' is refused at run start, naming this provider, rather than being recorded as delivered and silently dropped. See Instructions.

Tools go over as JSON Schema, untranslated

FunctionDeclaration has two mutually exclusive parameter fields: parameters, which takes an OpenAPI subset, and parametersJsonSchema, which takes JSON Schema. This adapter sends only the second, so $ref, oneOf, additionalProperties and nullable mean what your schema says they mean. The OpenAI-compatible endpoint offers only the first, which is the quiet divergence that makes a model ignore half your constraints without anything going wrong.

A forced tool choice that really is forced

carriesForcedToolChoice is true, on both doors: .outputSchema(parser, { strategy: 'tool-forced' }) becomes mode: 'ANY' plus a single allowedFunctionNames entry, which constrains the model to answer through exactly that function. Nothing is sent on a request that carries no tools — a tool choice naming a function the request does not carry is a request that cannot be served. See Output schema.

Stop reasons, mapped only where the mapping is unmistakable

STOPstop, MAX_TOKENSmax_tokens, and SAFETY / PROHIBITED_CONTENT / BLOCKLIST / SPIIcontent_filter. Everything else — RECITATION, MALFORMED_FUNCTION_CALL, TOO_MANY_TOOL_CALLS, and anything Google adds later — passes through in Google's own spelling, because mapping it onto the nearest word would be this adapter answering on the model's behalf.

One correction is made deliberately: Gemini has no tool_use finish reason — a turn that asks for a function still finishes STOP — so the presence of function calls is what produces stopReason: 'tool_use'. ollama() makes the same correction on its wire, for the same reason.

Tool-call ids are sometimes invented, and never sent back

FunctionCall.id is optional on Gemini's wire and Vertex routinely omits it, while LLMResponse.toolCalls[].id is not optional — the agent matches a tool result back to its call by id. So when Google sends no id the adapter invents one (gemini-call-1, in call order, per provider instance). Inventing it is safe; sending it back is not, so an invented id is never written into a functionResponse. Gemini matches a response to its call by NAME, which is the field that is always present.

Streaming

for await (const chunk of provider.stream!(request)) {
  if (!chunk.done) process.stdout.write(chunk.content);
  else console.log(chunk.response?.usage);
}

Text deltas arrive as they are produced; the terminal chunk carries the authoritative LLMResponse with the accumulated function calls, the finish reason and the usage.

If a stream reports no usage, this adapter reports zero — not an estimate

Gemini puts usageMetadata on the chunk that closes the stream, whose candidate carries no new text; the adapter reads it before any content guard, which is the bug that made streamed turns bill as zero on two other adapters before this one. If a stream carries no usage at all, input and output are 0 and stay 0. models.countTokens exists and is deliberately never called: it answers what a request tokenises to, not what the call was billed for, and a plausible number in the right range is worse than a zero somebody can see. Same law as openai() and ollama().

Thinking

.thinking({ budget }) becomes thinkingConfig.thinkingBudget, and usage.thinking comes back from thoughtsTokenCount as its own number — which is the concrete reason to prefer this adapter over the OpenAI-compatible endpoint, whose documented response has no such field.

Thought summaries are not requested. includeThoughts is deliberately left unset, so no rawThinking is produced — there is no Gemini ThinkingHandler in this release to normalize thought text, and asking for content nothing can carry back would be a leak, not a feature. A thought part that arrives anyway is kept out of the visible answer on both the streaming and non-streaming paths.

Thought signatures are carried, because a tool loop fails without them

A current Gemini model does not merely prefer its thoughtSignature back on the next turn — it refuses the turn without it. The 2026-08 field trial ran gemini-3.1-flash-lite on Vertex through one ordinary tool loop and the second call answered:

400 INVALID_ARGUMENT — "Function call is missing a thought_signature in
functionCall parts. This is required for tools to work correctly …"

The shape of that failure is what makes it expensive: it lands after your tool has already run, so a side-effecting call happened and the answer is unreachable. So the signature rides the round trip — read off the functionCall part it belongs to, parked on the port's neutral toolCalls[].providerMeta, and written back onto the reconstructed part byte for byte, because the service verifies it rather than reading it.

And it works, watched: the same trial re-ran that loop on live Vertex against the shipped fix (2026-08-14). gemini-3.1-flash-lite called the tool, the tool answered, the signature went back, and the second model call returned the right answer — one tool execution, two LLM calls, real token counts. The failure above is superseded.

What it does not cover, said plainly: a signature attached to a text part of an answer that carries no function call. LLMMessage.content is a string and a string has nowhere to keep one. The failing shape is the function-call shape, and that one is handled; the other is named here rather than half-handled quietly.

When it goes wrong

  • Over-long request — Google says "The input token count (1200293) exceeds the maximum number of tokens allowed (1048576)." That leaves this adapter as a typed ContextWindowExceededError carrying both numbers and the three fixes in the order they are worth trying, exactly as the OpenAI and Anthropic sentences do. See Error handling.
  • Anything else — a GeminiProviderError prefixed [gemini], keeping the HTTP status the SDK attached so a retry policy still classifies it correctly.
  • Your API key never appears in either. The adapter redacts the one secret it holds from the message, the stack and the wrapped cause — narrowly, by exact string, never by guessing what a key looks like. A thrown provider error reaches the model as a tool result and the commit log and every observability sink, so one interpolation would be a leak to all of them.

Honest limits

  • Text only. LLMMessage.content is a string, so no images, audio, video or generated media in either direction.

  • No grounding tools. Google Search, code execution, URL context, Google Maps and the SDK's mcpToTool bridge are not exposed; tools carries function declarations only.

  • No native structured output. responseJsonSchema is not sent — use .outputSchema(...), which goes over as a forced tool, a shape this adapter does carry.

  • No context-cache creation. usage.cacheRead is reported when Google reports it, but creating a cache is a separate caches.create call this adapter does not make — so cacheWrite is always undefined.

  • Status: field-validated on the Vertex door — validated in an independent field trial, 2026-08: gemini-2.5-flash through Application Default Credentials completed a real two-call tool loop, real incremental streaming (10 chunks, exact content reconstruction, non-zero usage) and a live call after a forced credential expiry. Built against @google/genai 2.16.0 and pinned by a surface test that checks the method names and the API version against the really-installed package.

  • The Gemini API door is not field-validated, and not for want of trying: one model 404'd as unavailable to new accounts and the current one returned a billing 429. See the matrix.

  • The thought-signature round trip above is field-proven. It was built from the trial's captured 400, and the same independent trial then re-ran it on live Vertex against the published fix (2026-08-14): gemini-3.1-flash-lite asked for a tool, the tool answered, the signature went back byte for byte, and the second model call returned the right answer — two LLM calls, one tool execution, real token counts. So this one has crossed from "we found the bug in the field" to "we watched the fix work there".

    What that run does not cover, still: the AI Studio key door (no key in that environment), and a signature attached to a text part with no function call beside it — the port's assistant turn is a string and has nowhere to keep one.

The OpenAI-compatible endpoint, and why it is not the path

Google publishes an OpenAI-compatible endpoint, and openai({ baseURL, apiKey }) does reach it. It is a good way to try something in ten seconds and a poor way to ship, for four reasons this library can point at:

ConcernOn the compat endpointOn gemini()
Auth lifetimeAn OAuth token that expires after an hour — mitigable by passing apiKey as a callback, which is re-read per request (a stream still keeps the key it started with)ADC refreshes itself, proved by a forced-expiry field probe; a Gemini API key does not expire
Tool schemasfunction.parameters is OpenAPI, so $ref / oneOf / additionalProperties diverge silentlyparametersJsonSchema — your schema, untranslated
Unknown parametersIgnored, not refusedThe adapter sends only fields the wire has
Cached / thinking tokensAbsent from the documented response, so usage.cacheRead and usage.thinking can only be undefinedReported as their own numbers

The forced-tool-choice case is already handled correctly by construction: openai() declares carriesForcedToolChoice: false behind any custom baseURL, so strategy: 'tool-forced' refuses at run start rather than sending a field that server may ignore.

Types

All on agentfootprint/providers.

TypeWhat it is
GeminiProviderOptionsthe factory's options — the connection half plus defaultModel, defaultMaxTokens, _client
GoogleGenAIConnectionOptionsthe connection half alone (project, location, apiKey, vertexai, apiVersion, googleAuthOptions), shared with geminiEmbedder() so both factories take the same two doors
GeminiClientLikethe { models: … } shape _client accepts — a real GoogleGenAI, a client shared with the rest of your app, or a test double
GeminiModelsLikethe models namespace narrowed to the two operations this adapter calls
GeminiGenerateParams · GeminiGenerateConfigwhat goes on the wire: the model, the turn list, and the config block
GeminiGenerateResponse · GeminiCandidate · GeminiUsageMetadatawhat comes back: candidates, their finish reason, and the token counts
GeminiContent · GeminiPartone turn and one part — the shapes the mapping table above produces
GeminiFunctionDeclarationa tool as Gemini takes it, with parametersJsonSchema

They are exported so you can type a double without importing @google/genai:

import { gemini, type GeminiClientLike } from 'agentfootprint/providers';

const double: GeminiClientLike = {
  models: {
    generateContent: async () => ({
      candidates: [{ content: { parts: [{ text: 'scripted' }] }, finishReason: 'STOP' }],
    }),
    generateContentStream: async () =>
      (async function* () {
        yield { candidates: [{ content: { parts: [{ text: 'scripted' }] } }] };
      })(),
  },
};

const provider = gemini({ _client: double }); // no SDK, no network, no credentials

Next steps

  • Google Cloud & Gemini — the provider column: every Google service, its port, and its honest status.
  • EmbeddersgeminiEmbedder(), the same two doors.
  • Output schemastrategy: 'tool-forced', which this provider carries.
  • Instructions — what carriesInMessages refuses, and why.
  • Custom provider — the two-method contract this adapter implements.

On this page