Google Gemini text-embeddings provider adapter — implements
ALLM.EmbeddingAdapter against batchEmbedContents.
Layer B — runtime. Constructed via
ALLM.Engine.new(embed_adapter: ALLM.Providers.Gemini.Embeddings, model: "gemini-embedding-001")
and consumed through ALLM.embed/3. Keys resolve via
ALLM.Keys.fetch!(:gemini, opts) at request-build time — no key ever lives
on the engine.
req = ALLM.EmbeddingRequest.new(input: ["hello"], model: "gemini-embedding-001")
{:ok, resp} = ALLM.Providers.Gemini.Embeddings.embed(req, api_key: "AIza...")
[[_ | _]] = ALLM.EmbeddingResponse.vectors(resp)Wire-field map
| Concern | Gemini |
|---|---|
| Endpoint | POST {base}/models/<model>:batchEmbedContents |
| Base URL | https://generativelanguage.googleapis.com/v1beta, overridable via adapter_opts[:endpoint] |
| Auth | x-goog-api-key header, not a ?key= query parameter |
| Body | {"requests": [{"model": ..., "content": {"parts": [{"text": ...}]}, ...}]} |
| Model | required on every sub-request, prefixed models/ |
| Dimensions | outputDimensionality (camelCase), per sub-request |
| Task type | taskType, per sub-request |
| Truncate | embedContentConfig.autoTruncate, per sub-request — see "Truncation" |
| Vectors | embeddings[].values — values, not embedding |
| Index | none — order is positional; see "Positional indexing" |
| Usage | usageMetadata.promptTokenCount when present — see "Usage" |
| Batch cap | 100 sub-requests (max_batch_size/0) |
Per-sub-request model is required, and must be prefixed
Unlike OpenAI's single top-level model, every element of requests carries
its own. Omitting it answers
"BatchEmbedContentsRequest.requests[0].model: model is not specified", and
passing a bare model id without the models/ prefix answers
"unexpected model name format" — both 400s. The prefix is applied
idempotently, so a caller who already wrote "models/gemini-embedding-001"
is not double-prefixed.
Adapter-injected defaults
None, and the nil :model case is an outright rejection rather than a
substitution. Both the URL and every sub-request's required model field
derive from request.model, so a request with model: nil cannot be
expressed on this wire at all; it is rejected pre-flight with
%ALLM.Error.EmbeddingAdapterError{reason: :invalid_request, metadata: %{field: :model}}.
This deliberately diverges from ALLM.Providers.Gemini.Images, which
substitutes a hardcoded default model. A wrong image model yields a wrong
picture; a wrong embedding model yields vectors in a different vector
space, which is unrecoverable once they are written to a vector column
alongside vectors from the intended model. ALLM.embed/3 stamps the
engine's resolved model onto the request before dispatch, so this only
arises on a direct adapter call.
Positional indexing
Gemini's response carries no index field: embeddings[] is positional, and
the adapter assigns :index from list position. There is consequently no
sort step and no way to detect a dropped sub-response — one missing entry
shifts every subsequent index silently. That is why this adapter's decoder
carries an explicit cardinality test against a recorded batch fixture rather
than relying on the conformance suite, which never reaches the decoder.
Normalization
dimensions other than nil and 3072 are L2-normalized in-adapter,
unconditionally across models. Google returns pre-normalized vectors at
gemini-embedding-001's native 3072 dimensions but not at truncated
dimensionalities (a recorded 768-wide response measures ~0.585, not 1.0),
while newer models auto-normalize truncated output too.
The rule does not branch on model id on purpose. Re-normalizing an
already-unit vector is a no-op to within 1.0e-9, so the unconditional form
is safe on the models that self-normalize, correct on the ones that do not,
and does not go stale when Google ships the next model — where a hard-coded
model allow-list would silently mis-handle it. Mixed-normalization data is
unrecoverable after the fact: cosine distance tolerates unnormalized input
but inner-product operators silently return wrong rankings.
Normalization is a scale, not a reshuffle — ALLM.Embedding.normalize/1
leaves a zero-magnitude vector unchanged rather than producing NaN.
Truncation
truncate: false is emitted as embedContentConfig: {"autoTruncate": false}
on each sub-request. truncate: true is the provider-side default and is
omitted from the wire entirely.
The nesting is load-bearing and was resolved empirically, not from the
published schema: the flat sub-request form and the top-level form are both
400s, and so is an invented field name — which is what establishes that the
nested form's acceptance reflects schema membership rather than tolerance of
unknown keys. scripts/record_gemini_embeddings_fixtures.exs re-runs that
four-way discrimination on every recording pass.
Usage
ALLM.Usage is populated from usageMetadata.promptTokenCount onto both
:input_tokens and :total_tokens; :output_tokens is always nil,
because embeddings produce no completion tokens.
In practice batchEmbedContents returns no usageMetadata — a 200 body
carries exactly {"embeddings": [{"values": [...]}, ...]} — so :usage is
an all-nil %ALLM.Usage{} today. It is never nil itself. The decoder
reads the field defensively so that token counts appear automatically if
Google starts reporting them, and callers who need per-request token costs
should not depend on this provider supplying them.
Pre-flight gates
Before any HTTP I/O — and, deliberately, before ALLM.Keys.fetch!/2, so a
request that is going to be rejected never needs a valid API key:
- Empty input.
input: []→:invalid_request. - Batch size.
length(input) > 100→:batch_too_largewithmetadata: %{count: n, max: 100}. An:inputthat is not a list at all is rejected here as:invalid_requestwithmetadata: %{field: :input}rather than raising. - Input elements. An
:inputlist containing a non-string element →:invalid_requestwithmetadata: %{field: :input}. Also a conversion rather than a raise: the elements reach the request-body builder, and a raise there would surface as anArgumentErrorfromALLM.EmbeddingBatch.dispatch_chunk/2two layers up. - Model. A missing or non-binary
:model→:invalid_requestwithmetadata: %{field: :model}.
Capability pre-flight against a model catalog is NOT performed here — it
lives in ALLM.embed/3. A direct adapter call bypasses it by design.
Request-id preservation
opts[:request_id] is reflected onto response.request_id.
request.metadata round-trips onto response.metadata untouched.
There is no provider-side request id to fall back to
ALLM.Providers.OpenAI.Embeddings falls back to OpenAI's x-request-id
response header when opts[:request_id] is absent.
batchEmbedContents emits no correlation header of any kind, so this
adapter has nothing to fall back to and leaves response.request_id nil.
Pass your own opts[:request_id] — which ALLM.embed/3 always does, and
generates when the caller omits it.
Error-struct hygiene
%ALLM.Error.EmbeddingAdapterError{} derives Jason.Encoder and is commonly
logged and persisted, so this adapter never copies a raw response body, a
request header, or any API-key value into :cause, :metadata, or
:message. Provider error messages pass through a redactor that replaces
Google-shaped credentials (AIza… API keys and ya29.… OAuth tokens) with
[REDACTED].
The HTTP status → reason table is not duplicated here: the chat adapter
ALLM.Providers.Gemini owns it and this adapter delegates to it, translating
the resulting reason atom into the narrower embeddings error enum.
Retry integration
HTTP-error closures return {:retry, delay_ms, error} for 429 (honouring
Retry-After), 5xx, timeouts, and transport failures; ALLM.Retry.run/3 is
wrapped around each attempt. The closure returns real reason atoms rather
than swapping in HTTP status codes, so for :rate_limited,
:provider_unavailable, and :network_error the façade's widened
retry_on list is what decides — none of them appears in this adapter's own
opts[:retry] policy, which defaults to :default.
:timeout is the documented exception: it is a member of both lists, so
through ALLM.embed/3 the adapter's inner ALLM.Retry.run/3 and the
façade's outer one both retry it and the attempt budgets multiply
(3 × 3 = 9 HTTP attempts at the default policy, against 3 for every other
retryable reason). A direct embed/2 call takes the inner loop only and
makes 3. Every bundled image and embeddings adapter has the identical shape,
so this is a library-wide characteristic rather than a Gemini one, and
correcting it per-adapter would make the adapters diverge from each other.
Test-injection escape hatch
embed/2 honours opts[:adapter_opts][:embedding_script] as a documented
test-only short-circuit: when the key is present, the call delegates to
ALLM.Providers.FakeEmbeddings.embed/2 BEFORE any pre-flight gate runs and
returns its result verbatim. This is what lets the injectable
ALLM.EmbeddingAdapter conformance suite drive a real adapter without an
HTTP stub library.
The switch keys on the presence of that per-call key and nothing else —
no environment variable, no application config, no :persistent_term — so it
stays confined to an explicit argument. Setting adapter_opts already
implies full control of the call. Production callers do not populate it.
prepare_request/2 deliberately does NOT delegate under the same key: a
scripted response has no Req.Request analogue, so it returns a stub error
instead.
Summary
Functions
Execute a text-embedding request synchronously against Gemini.
Return the maximum number of inputs Gemini accepts in one
batchEmbedContents call.
Return an unfired Req.Request configured exactly as embed/2 would fire
it, for callers who need to add headers, middleware, or their own retry
wrapper before dispatch.
Functions
@spec embed( ALLM.EmbeddingRequest.t(), keyword() ) :: {:ok, ALLM.EmbeddingResponse.t()} | {:error, ALLM.Error.EmbeddingAdapterError.t()}
Execute a text-embedding request synchronously against Gemini.
Returns {:ok, %ALLM.EmbeddingResponse{}} or
{:error, %ALLM.Error.EmbeddingAdapterError{}}; every HTTP-shaped failure
converts, including transport errors. The one documented exception is
ALLM.Keys.fetch!/2, which raises
%ALLM.Error.EngineError{reason: :missing_key} by design and is not rescued
here — all three pre-flight gates run ahead of it, so a request rejected
pre-flight never needs a key.
See the module documentation for the gate order, the wire-field map, the
no-injected-defaults policy for a nil :model, the normalization rule, and
the adapter_opts[:embedding_script] test-injection short-circuit.
Examples
iex> e = ALLM.Embedding.new(vector: [0.1, 0.2])
iex> req = ALLM.EmbeddingRequest.new(input: ["a kestrel"])
iex> opts = [adapter_opts: [embedding_script: [{:ok, [e]}]]]
iex> {:ok, resp} = ALLM.Providers.Gemini.Embeddings.embed(req, opts)
iex> ALLM.EmbeddingResponse.vectors(resp)
[[0.1, 0.2]]
iex> req = ALLM.EmbeddingRequest.new(input: [], model: "gemini-embedding-001")
iex> {:error, err} = ALLM.Providers.Gemini.Embeddings.embed(req, [])
iex> err.reason
:invalid_request
@spec max_batch_size() :: pos_integer()
Return the maximum number of inputs Gemini accepts in one
batchEmbedContents call.
Per-module and constant, not per-model.
Examples
iex> ALLM.Providers.Gemini.Embeddings.max_batch_size
100
@spec prepare_request( ALLM.EmbeddingRequest.t(), keyword() ) :: {:ok, Req.Request.t()} | {:error, ALLM.Error.EmbeddingAdapterError.t()}
Return an unfired Req.Request configured exactly as embed/2 would fire
it, for callers who need to add headers, middleware, or their own retry
wrapper before dispatch.
The pre-flight gates run first, so this is defined only for a request whose
input is a non-empty list no longer than max_batch_size/0 and whose
:model is set.
Under opts[:adapter_opts][:embedding_script] this returns a stub error
rather than delegating to ALLM.Providers.FakeEmbeddings — a scripted
response has no Req.Request analogue. That asymmetry with embed/2 is
deliberate.
Examples
iex> req = ALLM.EmbeddingRequest.new(input: ["hi"], model: "gemini-embedding-001")
iex> {:ok, http} = ALLM.Providers.Gemini.Embeddings.prepare_request(req, api_key: "AIza-x")
iex> URI.to_string(http.url)
"https://generativelanguage.googleapis.com/v1beta/models/gemini-embedding-001:batchEmbedContents"