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
| Concern | Voyage |
|---|---|
| Endpoint | POST https://api.voyageai.com/v1/embeddings (not overridable) |
| Auth | authorization: Bearer <key> — OpenAI-shaped |
| Input | input — always sent as an array, even for one input |
| Model | model |
| Dimensions | output_dimension — snake_case, and 256 / 512 / 1024 / 2048 on the models that support it |
| Task type | input_type — "query" / "document" only; see "Task types are lossy" |
| Truncate | truncation (boolean, provider default true) |
| Vectors | data[].embedding |
| Index | data[].index |
| Usage | top-level usage → total_tokens only; see "Usage" |
| Response id | none — 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 cap | 1000 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_type | input_type |
|---|---|
:search_document | "document" |
:search_query | "query" |
:classification | omitted |
:clustering | omitted |
:similarity | omitted |
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:
- Empty input.
input: []→:invalid_request. - Batch size.
length(input) > 1000→:batch_too_largewithmetadata: %{count: n, max: 1000}. An:inputthat 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_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; 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
@spec embed( ALLM.EmbeddingRequest.t(), keyword() ) :: {:ok, ALLM.EmbeddingResponse.t()} | {:error, ALLM.Error.EmbeddingAdapterError.t()}
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
@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
@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"