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

Copy Markdown View Source

Voyage AI text-embeddings provider adapter — and the Anthropic track.

Anthropic ships no embeddings endpoint. It never has, and the Messages API exposes nothing analogous; Anthropic instead names Voyage AI as its recommended embeddings partner and publishes a cookbook for it (how_to_create_embeddings.md). This module is therefore what an Anthropic-stack application uses for embeddings, and there is deliberately no ALLM.Providers.Anthropic.Embeddings: that name would assert a wire that does not exist, and the key resolves from VOYAGE_API_KEY, not ANTHROPIC_API_KEY.

Layer B — runtime. Constructed via ALLM.Engine.new(embed_adapter: ALLM.Providers.Voyage.Embeddings, model: "voyage-3.5-lite") and consumed through ALLM.embed/3. Keys resolve via ALLM.Keys.fetch!(:voyage, opts) at request-build time — no key ever lives on the engine.

req = ALLM.EmbeddingRequest.new(input: ["hello"], model: "voyage-3.5-lite")
{:ok, resp} = ALLM.Providers.Voyage.Embeddings.embed(req, api_key: "pa-...")
[[_ | _]] = ALLM.EmbeddingResponse.vectors(resp)

Nothing about this adapter is Anthropic-specific beyond that recommendation: a caller with no Anthropic involvement at all can use it, and an Anthropic-stack caller is free to point :embed_adapter at OpenAI or Gemini instead. The pairing is a default, not a coupling.

Wire-field map

ConcernVoyage
EndpointPOST https://api.voyageai.com/v1/embeddings (not overridable)
Authauthorization: Bearer <key> — OpenAI-shaped
Inputinput — always sent as an array, even for one input
Modelmodel
Dimensionsoutput_dimensionsnake_case, and 256 / 512 / 1024 / 2048 on the models that support it
Task typeinput_type"query" / "document" only; see "Task types are lossy"
Truncatetruncation (boolean, provider default true)
Vectorsdata[].embedding
Indexdata[].index
Usagetop-level usagetotal_tokens only; see "Usage"
Response idnone — no live 200 body carries a top-level "id", so %ALLM.EmbeddingResponse{}'s :id is always nil. Correlation comes from the x-request-id response header instead
Errors{"detail": "<message>"} — a single top-level string, not an {"error": {...}} object
Batch cap1000 array items (max_batch_size/0)

Adapter-injected defaults

None. The wire requires model, and ALLM.EmbeddingRequest permits model: nil. This adapter does not invent a default model: a nil :model is OMITTED from the body and Voyage answers with a 400, surfaced as %ALLM.Error.EmbeddingAdapterError{reason: :invalid_request}. Guessing an embedding model would silently produce vectors of an unexpected dimensionality — or from a different vector space entirely — into a caller's vector column, which is unrecoverable after the fact. ALLM.embed/3 stamps the engine's resolved model onto the request before dispatch, so this only arises on a direct adapter call.

Task types are lossy, and the loss is documented rather than errored

ALLM.EmbeddingRequest's provider-neutral :task_type enum has five members; Voyage's input_type has two.

:task_typeinput_type
:search_document"document"
:search_query"query"
:classificationomitted
:clusteringomitted
:similarityomitted

The three omissions are not a degradation. Voyage documents an absent (null) input_type as "no retrieval prompt is prepended", which is exactly the right semantic for a symmetric task — classification, clustering, and similarity all compare embeddings against each other rather than queries against documents, and prepending an asymmetric retrieval prompt to one side would be wrong. The drop is logged at :debug.

Usage

Voyage reports usage.total_tokens and nothing else — there is no prompt_tokens counter, which is the one place this adapter's ALLM.Usage mapping diverges from its OpenAI sibling's:

%ALLM.Usage{input_tokens: nil, output_tokens: nil, total_tokens: 4}

:input_tokens is nil because Voyage does not report it, not because the value is zero. :output_tokens is nil because embeddings produce no completion tokens. :usage itself is never nil. Callers doing per-request cost accounting should read :total_tokens; on this provider every input token is a total token, since there is no output side.

Truncation

truncate: true is the provider-side default and is omitted from the wire entirely; truncate: false is emitted as truncation: false. Under the default, an over-length input is silently truncated to the model's context window and billed at the full window. With truncation: false the same input is a 400 naming the window size, which this adapter maps to %ALLM.Error.EmbeddingAdapterError{reason: :context_length_exceeded} — so setting truncate: false is how a caller finds out that their chunks are too long instead of silently embedding a prefix of each.

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) > 1000:batch_too_large with metadata: %{count: n, max: 1000}. An :input that is not a list at all — Voyage's own wire accepts a bare string, so it is the likeliest direct-adapter mistake — 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; see "Why the element gate exists here" below.

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.

Why the element gate exists here

The request-body builder passes :input to the JSON encoder verbatim, so a map or integer element would merely earn a provider 400 — a converted {:error, _}, which is fine. A tuple element is not: Jason has no encoder for it and raises Protocol.UndefinedError from inside Req.request/1, past every gate, which is a breach of ALLM.EmbeddingAdapter invariant 2 and surfaces two layers up as an ArgumentError out of the batcher. Rejecting every non-binary element up-front closes that hole and costs a round-trip nothing, since the alternative outcome for the encodable cases was a 400 anyway.

Response ordering

data[] is sorted by :index before it becomes :embeddings. Voyage publishes that field for the same reason OpenAI does — array order is not contractual — and ALLM.EmbeddingResponse's order-correspondence invariant depends on it.

Request-id preservation

opts[:request_id] is reflected onto response.request_id unchanged. When it is absent, the adapter falls back to Voyage's x-request-id response header, which this endpoint emits on both success and error responses. request.metadata round-trips onto response.metadata untouched.

The x-request-id fallback is unreachable through ALLM.embed/3

The façade always supplies opts[:request_id] (it generates one when the caller does not), so on that path the left branch always wins and Voyage's own correlation id is never observed. It surfaces only on a direct embed/2 / decode_response/4 call that omits opts[:request_id]. Stamping it into response.metadata instead would contradict ALLM.EmbeddingAdapter invariant 7's requirement that request.metadata round-trip unchanged, so the divergence is deliberate. Callers who need Voyage's request id for a support ticket should pass their own opts[:request_id] and correlate on that.

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 Authorization value into :cause, :metadata, or :message. Provider error messages pass through a redactor that replaces Voyage-shaped credentials (pa-…) with [REDACTED].

Voyage's real 401 text is "Provided API key is invalid." and does not echo the offending key back, so the redactor here is defence in depth rather than a response to observed leakage — but the error detail is untrusted provider prose either way, and the OpenAI sibling's sk-/rk-/org- pattern would match nothing on this provider.

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 Voyage 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 Voyage AI.

Return the maximum number of inputs Voyage accepts in one /v1/embeddings 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 Voyage AI.

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 lossy :task_type mapping, the usage.total_tokens-only asymmetry, 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.Voyage.Embeddings.embed(req, opts)
iex> ALLM.EmbeddingResponse.vectors(resp)
[[0.1, 0.2]]

iex> req = ALLM.EmbeddingRequest.new(input: [])
iex> {:error, err} = ALLM.Providers.Voyage.Embeddings.embed(req, [])
iex> err.reason
:invalid_request

max_batch_size()

@spec max_batch_size() :: pos_integer()

Return the maximum number of inputs Voyage accepts in one /v1/embeddings call.

Per-module and constant, not per-model.

Examples

iex> ALLM.Providers.Voyage.Embeddings.max_batch_size
1000

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 of strings no longer than max_batch_size/0.

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: "voyage-3.5-lite")
iex> {:ok, http} = ALLM.Providers.Voyage.Embeddings.prepare_request(req, api_key: "pa-x")
iex> URI.to_string(http.url)
"https://api.voyageai.com/v1/embeddings"