ALLM.Providers.Gemini.Embeddings (allm v0.5.0)

Copy Markdown View Source

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

ConcernGemini
EndpointPOST {base}/models/<model>:batchEmbedContents
Base URLhttps://generativelanguage.googleapis.com/v1beta, overridable via adapter_opts[:endpoint]
Authx-goog-api-key header, not a ?key= query parameter
Body{"requests": [{"model": ..., "content": {"parts": [{"text": ...}]}, ...}]}
Modelrequired on every sub-request, prefixed models/
DimensionsoutputDimensionality (camelCase), per sub-request
Task typetaskType, per sub-request
TruncateembedContentConfig.autoTruncate, per sub-request — see "Truncation"
Vectorsembeddings[].valuesvalues, not embedding
Indexnone — order is positional; see "Positional indexing"
UsageusageMetadata.promptTokenCount when present — see "Usage"
Batch cap100 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:

  1. Empty input. input: []:invalid_request.
  2. Batch size. length(input) > 100:batch_too_large with metadata: %{count: n, max: 100}. An :input that is not a list at all is rejected here as :invalid_request with metadata: %{field: :input} rather than raising.
  3. Input elements. An :input list containing a non-string element → :invalid_request with metadata: %{field: :input}. Also a conversion rather than a raise: the elements reach the request-body builder, and a raise there would surface as an ArgumentError from ALLM.EmbeddingBatch.dispatch_chunk/2 two layers up.
  4. Model. A missing or non-binary :model:invalid_request with metadata: %{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

embed(request, opts)

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

max_batch_size()

@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

prepare_request(request, opts)

@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"