Gemini client
GeminiClient speaks the Google Gemini protocol, and the source is src/provider/gemini.rs.
It is a well-behaved client with three quirks worth knowing. The model lives in the URL, the API key never does, and thinking is a per-request opt-in.
Construction
Section titled “Construction”let client = GeminiClient::from_env()?; // GEMINI_API_KEY or GOOGLE_API_KEY, // optional GEMINI_BASE_URL / GEMINI_MODEL
let client = GeminiClient::builder() .with_api_key(key) .with_model("gemini-2.0-flash") // the default .with_include_thoughts(true) // default false — see below .build()?;Quirk 1: the model lives in the URL
Section titled “Quirk 1: the model lives in the URL”Your requests go to {base}/models/{model}:streamGenerateContent?alt=sse, or to :generateContent for a non-streaming call.
A per-request model override through RequestOptions::with_model therefore changes the URL for that request. The client builds it correctly, and that is why your override behaves differently than on a body-model provider.
Quirk 2: the key never travels in the URL
Section titled “Quirk 2: the key never travels in the URL”Your key travels in a header, x-goog-api-key, and never as a ?key= query parameter, which a test pins, because keys in URLs leak into logs.
Quirk 3: thinking is opt-in per client
Section titled “Quirk 3: thinking is opt-in per client”Reasoning models need thinkingConfig.includeThoughts = true on the request, and non-reasoning models reject that field with a 400.
So set with_include_thoughts(true) when your model supports thoughts. Its thought: true parts then stream to on_thinking_delta, and its thought tokens are counted in your output usage. Leave the flag off otherwise.
The response parser routes thought parts correctly either way.
What this client translates for you
Section titled “What this client translates for you”- System prompts. Gemini rejects inline
systemroles, so they are folded into the top-levelsystemInstructionfield, which is omitted entirely when empty. - Tool calls. These become
functionCallparts, and your results becomefunctionResponseparts carrying both name and id. Gemini correlates by function name, which is why your tool results always carry the name.is_errorhas no wire form here, so error text conveys it. - Usage.
promptTokenCountbecomes your input, andcandidatesTokenCount + thoughtsTokenCountbecomes your output. - Finish reasons.
MAX_TOKENSbecomes MaxTokens, and everything else, including STOP and SAFETY, becomes EndTurn.
Structured output
Section titled “Structured output”Three behaviors match the pattern you saw on the other clients:
response_formatmaps togenerationConfig.responseMimeTypeset toapplication/jsonplusresponseJsonSchema. Astrict: trueis rejected up front, rather than running silently non-strict, because the API has no strict switch.ToolConstraint::Grammaris rejected, because there is no grammar-constrained decoding.ToolConstraint::Strictis supported through schema tightening, same as the others.
Streaming specifics
Section titled “Streaming specifics”Gemini streams JSON chunks over SSE without a [DONE] sentinel. Your terminal signal is a chunk carrying finishReason, and only that completes the turn, so a stream without it is truncation.
Proxies sometimes split the finish reason and the usage into separate final chunks. The client latches the latest non-empty usageMetadata and emits stop plus usage together.
Tool calls arrive as complete functionCall parts. The client emits them as open-fill-close sequences, meaning part start, one JSON delta, then part stop, so your downstream consumers see the same shape as on other providers.
Errors embedded in chunks classify by code or status. Codes 429, 503, and 529 plus RESOURCE_EXHAUSTED and UNAVAILABLE become rate limits, and anything else becomes a plain API error.
Gotchas
Section titled “Gotchas”-
Images are dropped by the converter, same as the other shipped clients.
-
A non-reasoning model with
include_thoughts(true)gives you a 400INVALID_ARGUMENT, so match the flag to the model. -
Streaming usage from Gemini proxies can be partial. The client latches whatever arrives, so your
usagemay beNonebehind a quirky proxy. -
An empty
functionCall.argsnormalizes to{}, and a malformed args value passes through verbatim so you see it rather than having it hidden.
Related pages
Section titled “Related pages”- Overview is the provider map.
- Structured output covers the constraint modes.