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/genaiThat 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 have | Call | What authenticates |
|---|---|---|
| A Google Cloud project | gemini({ project, location }) | Application Default Credentials — GOOGLE_APPLICATION_CREDENTIALS, your gcloud user credentials, or the metadata server on GCE / Cloud Run |
| An AI Studio key | gemini({ 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
| Option | Meaning |
|---|---|
project, location | The Vertex door. location defaults to global (the multi-region endpoint) |
apiKey | The Gemini API door |
vertexai | Force the door instead of inferring it |
apiVersion | Pin the API version — see above |
googleAuthOptions | GoogleAuthOptions for Vertex: a key file, scopes, or an AuthClient you built (workload identity federation, impersonation) |
defaultModel | Model 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 |
defaultMaxTokens | maxOutputTokens when the request does not set one |
What maps to what
| agentfootprint | Gemini |
|---|---|
systemPrompt | config.systemInstruction — a top-level field, outside the turn list |
messages user | { role: 'user', parts: [{ text }] } |
messages assistant | { role: 'model', parts: [{ text }, { functionCall }] } |
messages tool | a functionResponse part inside a user turn; consecutive results coalesce into one turn |
tools | functionDeclarations[].parametersJsonSchema — JSON Schema, untranslated |
toolChoice | toolConfig.functionCallingConfig = { mode: 'ANY', allowedFunctionNames: [name] } |
temperature, maxTokens, stop, signal | temperature, maxOutputTokens, stopSequences, abortSignal |
thinking.budget | thinkingConfig.thinkingBudget |
usage.input / .output | usageMetadata.promptTokenCount / .candidatesTokenCount |
usage.cacheRead | usageMetadata.cachedContentTokenCount |
usage.thinking | usageMetadata.thoughtsTokenCount |
stopReason | candidates[0].finishReason, mapped conservatively — see below |
providerRef | responseId |
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
STOP → stop, MAX_TOKENS → max_tokens, and SAFETY / PROHIBITED_CONTENT /
BLOCKLIST / SPII → content_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
ContextWindowExceededErrorcarrying 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
GeminiProviderErrorprefixed[gemini], keeping the HTTPstatusthe 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.contentis 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
mcpToToolbridge are not exposed;toolscarries function declarations only. -
No native structured output.
responseJsonSchemais not sent — use.outputSchema(...), which goes over as a forced tool, a shape this adapter does carry. -
No context-cache creation.
usage.cacheReadis reported when Google reports it, but creating a cache is a separatecaches.createcall this adapter does not make — socacheWriteis always undefined. -
Status: field-validated on the Vertex door — validated in an independent field trial, 2026-08:
gemini-2.5-flashthrough 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/genai2.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-liteasked 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:
| Concern | On the compat endpoint | On gemini() |
|---|---|---|
| Auth lifetime | An 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 schemas | function.parameters is OpenAPI, so $ref / oneOf / additionalProperties diverge silently | parametersJsonSchema — your schema, untranslated |
| Unknown parameters | Ignored, not refused | The adapter sends only fields the wire has |
| Cached / thinking tokens | Absent from the documented response, so usage.cacheRead and usage.thinking can only be undefined | Reported 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.
| Type | What it is |
|---|---|
GeminiProviderOptions | the factory's options — the connection half plus defaultModel, defaultMaxTokens, _client |
GoogleGenAIConnectionOptions | the connection half alone (project, location, apiKey, vertexai, apiVersion, googleAuthOptions), shared with geminiEmbedder() so both factories take the same two doors |
GeminiClientLike | the { models: … } shape _client accepts — a real GoogleGenAI, a client shared with the rest of your app, or a test double |
GeminiModelsLike | the models namespace narrowed to the two operations this adapter calls |
GeminiGenerateParams · GeminiGenerateConfig | what goes on the wire: the model, the turn list, and the config block |
GeminiGenerateResponse · GeminiCandidate · GeminiUsageMetadata | what comes back: candidates, their finish reason, and the token counts |
GeminiContent · GeminiPart | one turn and one part — the shapes the mapping table above produces |
GeminiFunctionDeclaration | a 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 credentialsNext steps
- Google Cloud & Gemini — the provider column: every Google service, its port, and its honest status.
- Embedders —
geminiEmbedder(), the same two doors. - Output schema —
strategy: 'tool-forced', which this provider carries. - Instructions — what
carriesInMessagesrefuses, and why. - Custom provider — the two-method contract this adapter implements.
AWS Bedrock
bedrock() — model-agnostic provider via AWS Bedrock's Converse API. One adapter covers Claude, Llama, Mistral, Titan, Mixtral on Bedrock.
Ollama
ollama() — run a real model on your laptop. Free, no API key, no vendor SDK. The middle rung between mock() and a paid API, with the same agent code on all three.
