Class: Parse::Embeddings::Voyage

Inherits:
Provider
  • Object
show all
Defined in:
lib/parse/embeddings/voyage.rb

Overview

Voyage AI embeddings provider. Wraps POST /v1/embeddings for text-only models, POST /v1/multimodalembeddings for the multimodal text+image models (text via #embed_text, images via #embed_image), and POST /v1/contextualizedembeddings for the contextualized chunk models (single texts via #embed_text, whole chunked documents via #embed_chunks).

Supported models:

  • v4 family — voyage-4-large, voyage-4, voyage-4-lite, voyage-4-nano (Apache 2.0, open-weight on Hugging Face — also runnable through LocalHTTP when self-hosted on vLLM / Ollama / llama.cpp).
  • v3 family — voyage-3-large, voyage-3.5, voyage-3.5-lite, voyage-3, voyage-3-lite.
  • code models: voyage-code-4, voyage-code-3, and voyage-code-2 (the only 1536-dim one).
  • domain models — voyage-finance-2, voyage-law-2.
  • multimodal — voyage-multimodal-3 (text+image) and voyage-multimodal-3.5 (text+image+video). Unified vector space at the network boundary: text routes to /v1/multimodalembeddings with a { inputs: [{ content: [{ type: "text", text: … }] }] } envelope, images go through #embed_image, video through #embed_video. All three share the same space, so stored text vectors are comparable against image and video vectors without re-embedding.
  • contextualized chunk: voyage-context-4 and voyage-context-3. Each chunk's vector also encodes the document it came from, so chunks are embedded a document at a time through #embed_chunks. #embed_text sends every string as a one-chunk document. That is the right shape for queries, and it is also what the embed macro and BatchEmbedder send for stored fields, so those vectors carry no surrounding-document context; call #embed_chunks directly for chunk-level context.

Audio is not offered by any Voyage model, and neither PDF nor DOCX is accepted as a content type — render document pages to images and embed those instead.

Most models expose a Matryoshka ladder (MODEL_SUPPORTED_DIMENSIONS); note that the whole v4 family DEFAULTS to 1024 and reaches 2048 or 256 only when dimensions: asks for it.

== Endpoints

The same models are served by Voyage's own API and by MongoDB's Atlas Embedding and Reranking API. The wire contract is identical; the credentials are not interchangeable, and Voyage returns a 403 explaining as much if they are crossed. An Atlas model API key is recognized by its ATLAS_KEY_PREFIX and routes to ATLAS_BASE_URL automatically — pass endpoint: to be explicit. A few older models are absent from Atlas; see ATLAS_UNAVAILABLE_MODELS.

== Memory

Local images and video should be wrapped with MediaFile, which streams the file into the request body StreamingBody::READ_CHUNK bytes at a time. Passing a URL instead keeps the SDK out of the transfer entirely — the provider does the fetch. Only ImageFetch::FetchedImage holds a payload in memory, so prefer it for small images only.

== Asymmetric input types

Voyage's input_type field accepts "query" or "document" (mapped from the SDK-canonical :search_query / :search_document Symbols). The values are functionally analogous to Cohere's search_query / search_document — they're encoded by separately tuned heads, so re-using one type for both sides of a retrieval pair measurably degrades recall.

Voyage also accepts null (omit the field), which Voyage's docs recommend for "general purpose" embeddings unrelated to retrieval. We translate the absent / non-retrieval cases to null rather than picking a default — Voyage's training depends on the asymmetry, so guessing on the caller's behalf would be worse than passing-through.

== Security

  • The Faraday connection refuses proxy: unless the caller opts in via allow_faraday_proxy: true. Env-proxy autodiscovery (HTTPS_PROXY etc.) is suppressed by default.
  • #inspect (inherited from Provider) never surfaces @api_key.
  • Authorization and Voyage-Api-Key are in Middleware::BodyBuilder::REDACTED_HEADERS.

Examples:

registration

Parse::Embeddings.register(:voyage,
  Parse::Embeddings::Voyage.new(
    api_key: ENV.fetch("VOYAGE_API_KEY"),
    model:   "voyage-3.5",
  ))

Atlas model API key (endpoint inferred from the prefix)

Parse::Embeddings.register(:voyage,
  Parse::Embeddings::Voyage.new(
    api_key: ENV.fetch("ATLAS_MODEL_API_KEY"),  # "al-…"
    model:   "voyage-multimodal-3.5",
  ))

streaming local media

provider.embed_image([Parse::Embeddings::MediaFile.image("page.png")])
provider.embed_video([Parse::Embeddings::MediaFile.video("demo.mp4")])

Defined Under Namespace

Classes: AuthenticationError, BadRequestError, RateLimitError, TransientError

Constant Summary collapse

DEFAULT_BASE_URL =
"https://api.voyageai.com/v1"
ATLAS_BASE_URL =

MongoDB's Atlas Embedding and Reranking API re-exposes the same Voyage models under a MongoDB-operated host. The wire contract (request envelopes, response envelopes, error shapes) is identical — only the host and the credential differ.

"https://ai.mongodb.com/v1"
DEFAULT_MODEL =

Bumped from voyage-3 in 5.6.0: that model is retired from the Atlas endpoint, so an Atlas key used without naming a model failed at construction. voyage-3.5 is served by both endpoints and shares the 1024 native width.

"voyage-3.5"
DEFAULT_TIMEOUT =
30
DEFAULT_OPEN_TIMEOUT =
5
DEFAULT_MAX_RETRIES =
3
DEFAULT_BATCH_SIZE =

Voyage's documented per-request cap is 128 inputs.

128
MAX_RESPONSE_BYTES =
16 * 1024 * 1024
RESPONSE_BYTES_PER_VALUE =

Upper bounds on the JSON size of one returned vector, used to plan contextualized requests: a float such as -1.2345678901234567e-05, is under 32 bytes, and the {"object":"embedding","embedding":[], "index":N} wrapper is under 256.

32
RESPONSE_BYTES_PER_VECTOR_ENVELOPE =
256
MODEL_DEFAULT_DIMENSIONS =

Default (native) vector width per model — the width returned when output_dimension is omitted from the request.

NOTE: the whole v4 family defaults to 1024, NOT to a per-tier width. voyage-4-large reaches 2048 and voyage-4-lite reaches 512 only by explicitly requesting them via output_dimension (the constructor's dimensions: override) — those are Matryoshka options, not native widths. Verified against the live API for every model reachable through ATLAS_BASE_URL; see MODEL_SUPPORTED_DIMENSIONS.

{
  "voyage-4-large" => 1024,
  "voyage-4" => 1024,
  "voyage-4-lite" => 1024,
  "voyage-4-nano" => 1024,
  "voyage-3-large" => 1024,
  "voyage-3.5" => 1024,
  "voyage-3.5-lite" => 1024,
  "voyage-3" => 1024,
  "voyage-3-lite" => 512,
  "voyage-code-4" => 1024,
  "voyage-code-3" => 1024,
  "voyage-code-2" => 1536,
  "voyage-finance-2" => 1024,
  "voyage-law-2" => 1024,
  "voyage-multimodal-3" => 1024,
  "voyage-multimodal-3.5" => 1024,
  "voyage-context-4" => 1024,
  "voyage-context-3" => 1024,
}.freeze
MODEL_SUPPORTED_DIMENSIONS =

Every width a model's Matryoshka head will actually return. A model whose list has a single entry accepts no output_dimension override at all — requesting one is a 400.

This replaces the older "Matryoshka-capable models" boolean gate, which was too coarse: the v4 family, voyage-3-large, the v3.5 family, and voyage-code-3 all accept the full 256/512/1024/2048 ladder, and voyage-multimodal-3.5 accepts it too while voyage-multimodal-3 does not.

{
  "voyage-4-large" => [256, 512, 1024, 2048],
  "voyage-4" => [256, 512, 1024, 2048],
  "voyage-4-lite" => [256, 512, 1024, 2048],
  "voyage-4-nano" => [256, 512, 1024, 2048],
  "voyage-3-large" => [256, 512, 1024, 2048],
  "voyage-3.5" => [256, 512, 1024, 2048],
  "voyage-3.5-lite" => [256, 512, 1024, 2048],
  "voyage-3" => [1024],
  "voyage-3-lite" => [512],
  "voyage-code-4" => [256, 512, 1024, 2048],
  "voyage-code-3" => [256, 512, 1024, 2048],
  "voyage-code-2" => [1536],
  "voyage-finance-2" => [1024],
  "voyage-law-2" => [1024],
  "voyage-multimodal-3" => [1024],
  "voyage-multimodal-3.5" => [256, 512, 1024, 2048],
  "voyage-context-4" => [256, 512, 1024, 2048],
  "voyage-context-3" => [256, 512, 1024, 2048],
}.freeze
MATRYOSHKA_MODELS =

Back-compat alias: the set of models accepting any output_dimension other than their native width. Derived from MODEL_SUPPORTED_DIMENSIONS rather than hand-maintained.

MODEL_SUPPORTED_DIMENSIONS.select { |_m, dims| dims.length > 1 }.keys.freeze
MODEL_MAX_INPUT_TOKENS =
{
  "voyage-4-large" => 32_000,
  "voyage-4" => 32_000,
  "voyage-4-lite" => 32_000,
  "voyage-4-nano" => 32_000,
  "voyage-3-large" => 32_000,
  "voyage-3.5" => 32_000,
  "voyage-3.5-lite" => 32_000,
  "voyage-3" => 32_000,
  "voyage-3-lite" => 32_000,
  "voyage-code-4" => 32_000,
  "voyage-code-3" => 32_000,
  "voyage-code-2" => 16_000,
  "voyage-finance-2" => 32_000,
  "voyage-law-2" => 16_000,
  "voyage-multimodal-3" => 32_000,
  "voyage-multimodal-3.5" => 32_000,
  # Per document (one inner list of chunks). The request as a
  # whole is capped at 120k tokens across every document.
  "voyage-context-4" => 32_000,
  "voyage-context-3" => 32_000,
}.freeze
MULTIMODAL_MODELS =

Models that route to /v1/multimodalembeddings with the { inputs: [{ content: [...] }] } envelope rather than the standard /v1/embeddings { input: [String] } envelope. Text-only inputs from this provider are wrapped as { type: "text", text: s } content rows.

%w[voyage-multimodal-3 voyage-multimodal-3.5].freeze
MAX_MEDIA_BYTES =

Voyage's documented hard ceiling for a single image or video. Parse::Embeddings.max_media_bytes is a global convenience knob that may be lowered for any reason — but raising it above this cannot make Voyage accept a larger file, so the adapter enforces its own limit independently.

20 * 1024 * 1024
VIDEO_MODELS =

Multimodal models that additionally accept video content rows (video_url / video_base64) via #embed_video. voyage-multimodal-3 rejects video with an explicit "does not support video inputs" 400.

%w[voyage-multimodal-3.5].freeze
CONTEXTUALIZED_MODELS =

Models that route to /v1/contextualizedembeddings with the { inputs: [[chunk, ...], ...] } envelope: one inner list per document, each chunk embedded with the rest of its document as context. The endpoint has no truncation field, so it is never sent for these models.

%w[voyage-context-4 voyage-context-3].freeze
MAX_CONTEXT_DOCUMENTS =

Voyage's per-request limits for the contextualized endpoint: at most this many documents, and this many chunks summed across them. The endpoint also caps a request at 120k tokens summed across every document, which the SDK cannot check without a tokenizer.

1_000
MAX_CONTEXT_CHUNKS =
16_000
CONTEXT_DEFAULT_BATCH_SIZE =

Default embed_batch_size for CONTEXTUALIZED_MODELS. Each string is a whole document there, so 128 paragraph-sized inputs would overrun the 120k-token request cap; 32 leaves room for documents averaging under about 3,700 tokens.

32
ATLAS_UNAVAILABLE_MODELS =

Models Voyage's hosted API serves but the Atlas Embedding and Reranking API does not. Verified against both endpoints.

%w[voyage-3 voyage-3-lite].freeze
SELF_HOSTED_ONLY_MODELS =

Open-weight models that NO hosted endpoint serves — neither Voyage's nor Atlas's. voyage-4-nano ships under Apache 2.0 on Hugging Face and is meant to be self-hosted (vLLM / Ollama / llama.cpp), reached either through LocalHTTP or through this provider with an explicit base_url: pointing at the local server. Naming one against a hosted endpoint is always a mistake, so it is refused there rather than failing as an opaque provider 400.

%w[voyage-4-nano].freeze
ATLAS_KEY_PREFIX =

Atlas model API keys carry this prefix and authenticate ONLY against ATLAS_BASE_URL; Voyage's own endpoint rejects them with a 403. Used to infer the endpoint when the caller does not name one explicitly.

"al-"
INPUT_TYPE_WIRE_VALUES =

Map SDK-canonical input_type symbols to Voyage wire strings. :classification / :clustering map to nil (omitted) since Voyage only distinguishes retrieval halves — other intents should receive the unconditioned vector.

{
  search_query: "query",
  search_document: "document",
  classification: nil,
  clustering: nil,
}.freeze
MEDIA_MODALITIES =

Everything modality-specific about a non-text input, in one table. models names the constant gating which models accept the modality; url / base64 are the wire content-type keys.

Adding a modality (audio, when Voyage ships it) is a row here plus a MediaFile constructor and a one-line embed_audio delegating to #embed_media — the streaming body, row builder, batching, URL validation, and instrumentation are all modality-agnostic already.

{
  image: { url: "image_url", base64: "image_base64",
           models: :MULTIMODAL_MODELS, noun: "image" },
  video: { url: "video_url", base64: "video_base64",
           models: :VIDEO_MODELS, noun: "video" },
}.freeze

Constants inherited from Provider

Provider::AS_NOTIFICATION_NAME

Instance Method Summary collapse

Methods inherited from Provider

#cache_identity, #embed_text_batched, #inspect, #instrument_embed, #supports_modality?, #validate_response!

Constructor Details

#initialize(api_key:, model: DEFAULT_MODEL, endpoint: :auto, base_url: nil, timeout: DEFAULT_TIMEOUT, open_timeout: DEFAULT_OPEN_TIMEOUT, max_retries: DEFAULT_MAX_RETRIES, embed_batch_size: nil, dimensions: nil, truncation: true, allow_faraday_proxy: false, allow_insecure_base_url: false, connection: nil) ⇒ Voyage

Returns a new instance of Voyage.

Parameters:

  • api_key (String) —

    required. Sent as Authorization: Bearer ….

  • model (String) (defaults to: DEFAULT_MODEL) —

    one of MODEL_DEFAULT_DIMENSIONS's keys.

  • endpoint (Symbol) (defaults to: :auto) —

    :auto (default), :voyage, or :atlas. Selects the default base_url and enables endpoint-specific model validation. :auto infers :atlas when api_key carries the ATLAS_KEY_PREFIX, else :voyage. An explicit base_url: always wins; the endpoint is then inferred from its host.

  • base_url (String, nil) (defaults to: nil) —

    override. Must be HTTPS unless allow_insecure_base_url: true. Defaults to the resolved endpoint's host.

  • timeout (Integer) (defaults to: DEFAULT_TIMEOUT) —

    read timeout, seconds.

  • open_timeout (Integer) (defaults to: DEFAULT_OPEN_TIMEOUT) —

    connect timeout, seconds.

  • max_retries (Integer) (defaults to: DEFAULT_MAX_RETRIES) —

    retry attempts on 429/5xx/timeouts.

  • embed_batch_size (Integer, nil) (defaults to: nil) —

    inputs per request (max 128). Defaults to DEFAULT_BATCH_SIZE, or CONTEXT_DEFAULT_BATCH_SIZE for a contextualized model.

  • dimensions (Integer, nil) (defaults to: nil) —

    override output width via Voyage's output_dimension Matryoshka parameter. Must be one of the model's MODEL_SUPPORTED_DIMENSIONS; a model with a single supported width accepts only that width (or nil).

  • truncation (Boolean) (defaults to: true) —

    forward Voyage's truncation: field. Defaults true to match Voyage's API default. Set false to force the API to reject over-length inputs rather than silently truncating (useful when you want explicit chunking errors).

  • allow_faraday_proxy (Boolean) (defaults to: false) —

    opt in to proxy / env-proxy autodiscovery. Defaults false.

  • allow_insecure_base_url (Boolean) (defaults to: false) —

    permit http:// base.

  • connection (Faraday::Connection, nil) (defaults to: nil) —

    injection seam.



339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
# File 'lib/parse/embeddings/voyage.rb', line 339

def initialize(
  api_key:,
  model: DEFAULT_MODEL,
  endpoint: :auto,
  base_url: nil,
  timeout: DEFAULT_TIMEOUT,
  open_timeout: DEFAULT_OPEN_TIMEOUT,
  max_retries: DEFAULT_MAX_RETRIES,
  embed_batch_size: nil,
  dimensions: nil,
  truncation: true,
  allow_faraday_proxy: false,
  allow_insecure_base_url: false,
  connection: nil
)
  validate_api_key!(api_key)
  validate_model!(model)
  resolved_endpoint = resolve_endpoint!(endpoint, api_key, base_url)
  base_url ||= resolved_endpoint == :atlas ? ATLAS_BASE_URL : DEFAULT_BASE_URL
  validate_model_for_endpoint!(model, resolved_endpoint)
  sanitized_base_url = validate_base_url!(base_url, allow_insecure_base_url)
  validate_positive_integer!(:timeout, timeout)
  validate_positive_integer!(:open_timeout, open_timeout)
  validate_non_negative_integer!(:max_retries, max_retries)
  embed_batch_size ||= CONTEXTUALIZED_MODELS.include?(model) ? CONTEXT_DEFAULT_BATCH_SIZE : DEFAULT_BATCH_SIZE
  validate_positive_integer!(:embed_batch_size, embed_batch_size)
  if embed_batch_size > 128
    raise ArgumentError,
          "Parse::Embeddings::Voyage: embed_batch_size #{embed_batch_size} exceeds Voyage's per-request cap (128)."
  end
  unless [true, false].include?(truncation)
    raise ArgumentError,
          "Parse::Embeddings::Voyage: truncation must be true or false (got #{truncation.inspect})."
  end
  validate_dimensions!(model, dimensions)

  @api_key = api_key
  @model = model
  @endpoint = resolved_endpoint
  @dimensions = dimensions || MODEL_DEFAULT_DIMENSIONS.fetch(model)
  @base_url = sanitized_base_url
  @timeout = timeout
  @open_timeout = open_timeout
  @max_retries = max_retries
  @embed_batch_size = embed_batch_size
  @truncation = truncation
  @allow_faraday_proxy = allow_faraday_proxy
  @connection = connection || build_connection
end

Instance Method Details

#apply_output_dimension!(body) ⇒ Object (protected)

Forward output_dimension only when the configured width differs from the model's native default. Sending it to a model with a single supported width is a 400, and sending the native width needlessly is redundant — so both are omitted.

The constructor has already rejected any width outside MODEL_SUPPORTED_DIMENSIONS, so no re-validation is needed.



764
765
766
767
768
# File 'lib/parse/embeddings/voyage.rb', line 764

def apply_output_dimension!(body)
  return body if @dimensions == MODEL_DEFAULT_DIMENSIONS.fetch(@model)
  body[:output_dimension] = @dimensions
  body
end

#atlas? ⇒ Boolean

Returns true when routed through ATLAS_BASE_URL.

Returns:



405
406
407
# File 'lib/parse/embeddings/voyage.rb', line 405

def atlas?
  @endpoint == :atlas
end

#backoff_seconds(attempt) ⇒ Object (protected)



1107
1108
1109
# File 'lib/parse/embeddings/voyage.rb', line 1107

def backoff_seconds(attempt)
  [0.5 * (2 ** (attempt - 1)), 30.0].min
end

#build_connection ⇒ Object (protected)



640
641
642
643
644
645
646
647
648
649
650
651
652
653
654
655
656
657
658
# File 'lib/parse/embeddings/voyage.rb', line 640

def build_connection
  headers = {
    "Authorization" => "Bearer #{@api_key}",
    "Content-Type" => "application/json",
    "Accept" => "application/json",
    "User-Agent" => "parse-stack-embeddings/#{user_agent_version}",
  }

  faraday_opts = { url: @base_url, headers: headers }
  faraday_opts[:proxy] = nil unless @allow_faraday_proxy

  conn = Faraday.new(**faraday_opts) do |f|
    f.options.timeout = @timeout
    f.options.open_timeout = @open_timeout
    f.adapter Faraday.default_adapter
  end
  conn.proxy = nil if !@allow_faraday_proxy && conn.respond_to?(:proxy=)
  conn
end

#build_contextualized_body(documents, wire_input_type) ⇒ Object (protected)

Build the wire body for /v1/contextualizedembeddings. The endpoint documents no truncation field, so none is sent.



697
698
699
700
701
702
# File 'lib/parse/embeddings/voyage.rb', line 697

def build_contextualized_body(documents, wire_input_type)
  body = { inputs: documents, model: @model }
  body[:input_type] = wire_input_type if wire_input_type
  apply_output_dimension!(body)
  body
end

#build_media_rows(sources, kind:, allow_insecure:, caller_name:) ⇒ Array<Hash> (protected)

Build inputs[].content[] row descriptors for a batch of media sources, accepting URL Strings, in-memory ImageFetch::FetchedImage wrappers, and file-backed MediaFile wrappers.

Every URL is validated up-front so a malformed entry in slot N cannot get past validation while slots 0..N-1 are already in the wire body — no partial forwarding.

Returns descriptors rather than finished Hashes so #build_request_body can assemble the JSON structurally. A streamed row is { stream: MediaFile, key: String }; every other row is a ready-to-serialize Hash.

Returns:

  • (Array<Hash>) —

    one descriptor per source, in order.



878
879
880
881
882
883
884
885
886
887
888
889
890
891
892
893
894
895
896
897
898
899
900
901
902
903
904
905
906
907
908
909
910
911
# File 'lib/parse/embeddings/voyage.rb', line 878

def build_media_rows(sources, kind:, allow_insecure:, caller_name:)
  spec = MEDIA_MODALITIES.fetch(kind)
  url_key = spec[:url]
  b64_key = spec[:base64]

  sources.each_with_index.map do |src, i|
    case src
    when Parse::Embeddings::MediaFile
      unless src.kind == kind
        raise ArgumentError,
              "Parse::Embeddings::Voyage##{caller_name} sources[#{i}] is a " \
              "#{src.kind} MediaFile; expected #{kind}."
      end
      enforce_media_size!(src.byte_size, i, caller_name, src.path)
      { stream: src, key: b64_key }
    when Parse::Embeddings::ImageFetch::FetchedImage
      # The only in-memory wrapper the SDK ships is for images.
      unless kind == :image
        raise ArgumentError,
              "Parse::Embeddings::Voyage##{caller_name} sources[#{i}] is a FetchedImage; " \
              "wrap #{spec[:noun]} sources with Parse::Embeddings::MediaFile.#{kind}."
      end
      enforce_media_size!(src.bytes.bytesize, i, caller_name)
      { content: [{ type: b64_key, b64_key => src.to_data_uri }] }
    when String
      canonical = Parse::Embeddings.validate_image_url!(src, allow_insecure: allow_insecure)
      { content: [{ type: url_key, url_key => canonical }] }
    else
      raise ArgumentError,
            "Parse::Embeddings::Voyage##{caller_name} sources[#{i}] must be a URL String " \
            "or a Parse::Embeddings::MediaFile (got #{src.class})."
    end
  end
end

#build_multimodal_body(strings, wire_input_type) ⇒ Object (protected)

Build the wire body for /v1/multimodalembeddings for TEXT inputs: each string wraps as a single {type: "text", text:} content row. Image inputs build their content rows inline in #embed_image (image_url / image_base64) and do not pass through here.



681
682
683
684
685
686
687
688
689
690
691
692
693
# File 'lib/parse/embeddings/voyage.rb', line 681

def build_multimodal_body(strings, wire_input_type)
  body = {
    inputs: strings.map { |s| { content: [{ type: "text", text: s }] } },
    model: @model,
  }
  body[:input_type] = wire_input_type if wire_input_type
  # `truncation` is documented for the multimodal endpoint too —
  # forward it for parity with the text path so callers get the
  # same fail-on-overlength behavior across models.
  body[:truncation] = @truncation
  apply_output_dimension!(body)
  body
end

#build_request_body(rows, wire_input_type) ⇒ Object (protected)

Assemble the request body for a set of row descriptors.

With no streamed rows this returns a plain Hash, serialized later by #post_embeddings. With them it returns a StreamingBody whose JSON is built structurally — each fragment is serialized independently and concatenated in order, so the file payloads are spliced by position rather than by searching the serialized document.

Building it structurally is a correctness requirement, not a style preference: an earlier version emitted a sentinel token and located it with String#split, which let a caller-supplied URL containing that token capture a local file's bytes and ship them to the provider as a URL to fetch.



949
950
951
952
953
954
955
956
957
958
959
960
961
962
963
964
965
966
967
968
969
970
971
972
973
974
975
976
977
978
# File 'lib/parse/embeddings/voyage.rb', line 949

def build_request_body(rows, wire_input_type)
  trailer = { model: @model, truncation: @truncation }
  trailer[:input_type] = wire_input_type if wire_input_type
  apply_output_dimension!(trailer)

  return { inputs: rows }.merge(trailer) unless rows.any? { |r| r[:stream] }

  segments = [+'{"inputs":[']
  rows.each_with_index do |row, i|
    segments << "," if i.positive?
    if (media = row[:stream])
      key = row[:key]
      # Open the JSON string, emit the data: prefix, stream the
      # payload, then close it. Base64 needs no escaping, and the
      # prefix is escaped by #to_json before its closing quote is
      # trimmed.
      segments << %({"content":[{"type":#{key.to_json},#{key.to_json}:)
      segments << json_string_prefix(media.data_uri_prefix)
      segments << media.stream_segment
      segments << %("}]})
    else
      segments << row.to_json
    end
  end
  segments << "]"
  trailer.each { |k, v| segments << ",#{k.to_s.to_json}:#{v.to_json}" }
  segments << "}"

  Parse::Embeddings::StreamingBody.new(segments)
end

#build_text_body(strings, wire_input_type) ⇒ Object (protected)

Build the wire body for the standard /v1/embeddings endpoint (text-only models).



662
663
664
665
666
667
668
669
670
671
672
673
674
# File 'lib/parse/embeddings/voyage.rb', line 662

def build_text_body(strings, wire_input_type)
  body = {
    input: strings,
    model: @model,
    truncation: @truncation,
  }
  # Only forward input_type when it has a wire value. Voyage
  # treats absent and `null` identically (unconditioned head),
  # but absent is the spec-correct form for non-retrieval intent.
  body[:input_type] = wire_input_type if wire_input_type
  apply_output_dimension!(body)
  body
end

#dimensions ⇒ Object



389
390
391
# File 'lib/parse/embeddings/voyage.rb', line 389

def dimensions
  @dimensions
end

#dispatch_media(rows, input_type, wire_input_type, kind) ⇒ Object (protected)

Issue one multimodal request for a set of same-representation rows and return the vectors in row order.



849
850
851
852
853
854
855
856
857
858
859
860
861
# File 'lib/parse/embeddings/voyage.rb', line 849

def dispatch_media(rows, input_type, wire_input_type, kind)
  body = build_request_body(rows, wire_input_type)

  instrument_embed(rows.length, input_type, modality: kind) do |emit_payload|
    payload = post_embeddings(body, path: "multimodalembeddings")
    if payload.is_a?(Hash) && payload["usage"].is_a?(Hash)
      tt = payload["usage"]["total_tokens"]
      emit_payload[:total_tokens] = tt if tt.is_a?(Integer) && tt >= 0
    end
    vectors = extract_vectors!(payload, rows.length)
    validate_response!(rows.length, vectors)
  end
end

#embed_batch_size ⇒ Object



409
410
411
# File 'lib/parse/embeddings/voyage.rb', line 409

def embed_batch_size
  @embed_batch_size
end

#embed_chunks(documents, input_type: :search_document) ⇒ Array<Array<Array<Float>>>

Embed chunked documents through Voyage's /v1/contextualizedembeddings endpoint. Every chunk's vector encodes the surrounding document as well as the chunk itself, so pass the chunks of one document together, in order, rather than one call per chunk.

Contextualized model required. Only CONTEXTUALIZED_MODELS accept this shape; any other model raises BadRequestError before any network call.

Parameters:

Returns:

  • (Array<Array<Array<Float>>>) —

    one Array of chunk vectors per document, aligned 1:1 with documents and with each document's chunks.



586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
604
605
606
607
608
609
610
611
612
613
614
615
616
617
618
619
620
621
622
623
624
625
626
627
628
629
630
631
632
# File 'lib/parse/embeddings/voyage.rb', line 586

def embed_chunks(documents, input_type: :search_document)
  unless CONTEXTUALIZED_MODELS.include?(@model)
    raise BadRequestError,
          "Parse::Embeddings::Voyage#embed_chunks: model #{@model.inspect} does not " \
          "accept chunked documents. Configure the provider with a contextualized model " \
          "(supported: #{CONTEXTUALIZED_MODELS.inspect})."
  end
  unless documents.is_a?(Array)
    raise ArgumentError,
          "Parse::Embeddings::Voyage#embed_chunks expects Array<Array<String>> " \
          "(got #{documents.class})."
  end
  return [] if documents.empty?

  documents.each_with_index do |chunks, i|
    unless chunks.is_a?(Array) && !chunks.empty?
      raise ArgumentError,
            "Parse::Embeddings::Voyage#embed_chunks documents[#{i}] must be a non-empty " \
            "Array of chunk Strings."
    end
    chunks.each_with_index do |c, j|
      unless c.is_a?(String) && !c.empty?
        raise ArgumentError,
              "Parse::Embeddings::Voyage#embed_chunks documents[#{i}][#{j}] must be a " \
              "non-empty String."
      end
    end
  end
  if documents.length > MAX_CONTEXT_DOCUMENTS
    raise ArgumentError,
          "Parse::Embeddings::Voyage#embed_chunks: #{documents.length} documents exceeds " \
          "Voyage's per-request cap (#{MAX_CONTEXT_DOCUMENTS}). Split the input."
  end
  chunk_total = documents.sum(&:length)
  if chunk_total > MAX_CONTEXT_CHUNKS
    raise ArgumentError,
          "Parse::Embeddings::Voyage#embed_chunks: #{chunk_total} chunks exceeds " \
          "Voyage's per-request cap (#{MAX_CONTEXT_CHUNKS}). Split the input."
  end
  unless INPUT_TYPE_WIRE_VALUES.key?(input_type)
    raise ArgumentError,
          "Parse::Embeddings::Voyage#embed_chunks input_type #{input_type.inspect} not in " \
          "#{INPUT_TYPE_WIRE_VALUES.keys.inspect}."
  end

  embed_contextualized(documents, input_type, INPUT_TYPE_WIRE_VALUES[input_type])
end

#embed_contextualized(documents, input_type, wire_input_type) ⇒ Object (protected)

Embed documents through the contextualized endpoint and return one Array of chunk vectors per document, in input order.

Voyage accepts up to MAX_CONTEXT_CHUNKS chunks per request, but that many vectors serialize to far more than MAX_RESPONSE_BYTES. Whole documents are therefore grouped so each response's estimated size stays within the cap, and the groups are sent in turn. A single document too large for the cap on its own is sent alone with a response allowance sized to its chunk count.



713
714
715
716
717
718
719
720
721
722
723
724
725
726
# File 'lib/parse/embeddings/voyage.rb', line 713

def embed_contextualized(documents, input_type, wire_input_type)
  per_vector = response_bytes_per_vector
  chunks_per_request = [MAX_RESPONSE_BYTES / per_vector, 1].max
  groups = []
  documents.each do |doc|
    last = groups.last
    if last && last.sum(&:length) + doc.length <= chunks_per_request
      last << doc
    else
      groups << [doc]
    end
  end
  groups.flat_map { |group| embed_contextualized_request(group, input_type, wire_input_type) }
end

#embed_contextualized_request(documents, input_type, wire_input_type) ⇒ Object (protected)

Issue one contextualized request and return one Array of chunk vectors per document.



735
736
737
738
739
740
741
742
743
744
745
746
747
748
749
750
751
752
753
754
755
# File 'lib/parse/embeddings/voyage.rb', line 735

def embed_contextualized_request(documents, input_type, wire_input_type)
  body = build_contextualized_body(documents, wire_input_type)
  chunk_count = documents.sum(&:length)
  allowance = [MAX_RESPONSE_BYTES, (chunk_count * response_bytes_per_vector) + 65_536].max

  instrument_embed(chunk_count, input_type) do |emit_payload|
    payload = post_embeddings(body, path: "contextualizedembeddings", max_response_bytes: allowance)
    if payload.is_a?(Hash) && payload["usage"].is_a?(Hash)
      tt = payload["usage"]["total_tokens"]
      emit_payload[:total_tokens] = tt if tt.is_a?(Integer) && tt >= 0
    end
    # The response nests the standard envelope: the outer `data`
    # holds one `{ data: [...], index: }` list per document, and
    # each inner list is shaped like a `/v1/embeddings` response.
    per_document = extract_vectors!(payload, documents.length, value_key: "data")
    per_document.each_with_index.map do |entry, i|
      vectors = extract_vectors!({ "data" => entry }, documents[i].length)
      validate_response!(documents[i].length, vectors)
    end
  end
end

#embed_image(sources, input_type: :search_document, allow_insecure: false) ⇒ Array<Array<Float>>

Embed a batch of images through Voyage's /v1/multimodalembeddings endpoint. Two source forms:

  • String URL (v5.1 path) — the provider receives a public URL and issues its own fetch. The SDK does NOT download the image; it validates the URL through Parse::Embeddings.validate_image_url! (CIDR / port / host allowlist, sentinel-gated egress opt-in) and forwards the canonicalized URL string in a { type: "image_url", image_url: ... } content row.
  • ImageFetch::FetchedImage (v5.5 bytes path) — bytes the SDK already downloaded through File.safe_open_url, magic-byte-verified, and EXIF-stripped. Forwarded as a { type: "image_base64", image_base64: "data:<mime>;base64,..." } content row. No URL validation runs (there is no provider-side fetch) and the trust_provider_url_fetch sentinel is NOT required.

Multimodal model required. Voyage's text-only models (voyage-3, voyage-4, etc.) do not accept image inputs; calling embed_image on a provider configured with one of those raises BadRequestError before any network call.

Parameters:

Returns:

  • (Array<Array<Float>>) —

    vectors aligned 1:1 with sources.



533
534
535
536
# File 'lib/parse/embeddings/voyage.rb', line 533

def embed_image(sources, input_type: :search_document, allow_insecure: false)
  embed_media(sources, kind: :image, input_type: input_type,
                       allow_insecure: allow_insecure)
end

#embed_media(sources, kind:, input_type:, allow_insecure:) ⇒ Array<Array<Float>> (protected)

Shared implementation behind #embed_image and #embed_video.

Parameters:

Returns:

  • (Array<Array<Float>>) —

    vectors aligned 1:1 with sources.



790
791
792
793
794
795
796
797
798
799
800
801
802
803
804
805
806
807
808
809
810
811
812
813
814
815
816
817
818
819
820
821
822
823
824
825
826
827
828
829
830
831
832
833
834
835
836
837
838
839
840
841
842
843
844
845
# File 'lib/parse/embeddings/voyage.rb', line 790

def embed_media(sources, kind:, input_type:, allow_insecure:)
  spec = MEDIA_MODALITIES.fetch(kind)
  capable = self.class.const_get(spec[:models])
  caller_name = "embed_#{kind}"

  unless capable.include?(@model)
    raise BadRequestError,
          "Parse::Embeddings::Voyage##{caller_name}: model #{@model.inspect} does not " \
          "accept #{spec[:noun]} inputs. Configure the provider with a capable model " \
          "(supported: #{capable.inspect})."
  end
  unless sources.is_a?(Array)
    raise ArgumentError,
          "Parse::Embeddings::Voyage##{caller_name} expects Array of #{spec[:noun]} " \
          "URLs (got #{sources.class})."
  end
  return [] if sources.empty?

  unless INPUT_TYPE_WIRE_VALUES.key?(input_type)
    raise ArgumentError,
          "Parse::Embeddings::Voyage##{caller_name} input_type #{input_type.inspect} " \
          "not in #{INPUT_TYPE_WIRE_VALUES.keys.inspect}."
  end
  # Voyage caps multimodal requests at the same per-request size
  # as the text endpoint. The text path chunks automatically; the
  # media path has no chunker (every directive is a single
  # source), so guard the direct-API caller against a silent 400.
  if sources.length > @embed_batch_size
    raise ArgumentError,
          "Parse::Embeddings::Voyage##{caller_name}: batch size #{sources.length} " \
          "exceeds the configured cap #{@embed_batch_size} (Voyage per-request max: " \
          "128). Split the input and call #{caller_name} once per chunk."
  end

  rows = build_media_rows(
    sources, kind: kind, allow_insecure: allow_insecure, caller_name: caller_name,
  )
  wire_input_type = INPUT_TYPE_WIRE_VALUES[input_type]

  # Voyage requires a single representation per request: "each
  # request should use either image_base64/video_base64 or
  # image_url/video_url exclusively, not both." A mixed batch is
  # therefore split into one request per representation and
  # reassembled in the caller's original order, so the 1:1
  # alignment this method promises still holds.
  groups = rows.each_with_index.group_by { |row, _i| row_representation(row) }
  return dispatch_media(rows, input_type, wire_input_type, kind) if groups.size == 1

  results = Array.new(rows.length)
  groups.each_value do |pairs|
    subset = pairs.map(&:first)
    vectors = dispatch_media(subset, input_type, wire_input_type, kind)
    pairs.each_with_index { |(_row, original_index), n| results[original_index] = vectors[n] }
  end
  results
end

#embed_text(strings, input_type: :search_document) ⇒ Array<Array<Float>>

Returns vectors aligned 1:1 with strings.

Parameters:

Returns:

  • (Array<Array<Float>>) —

    vectors aligned 1:1 with strings.



429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
# File 'lib/parse/embeddings/voyage.rb', line 429

def embed_text(strings, input_type: :search_document)
  unless strings.is_a?(Array)
    raise ArgumentError,
          "Parse::Embeddings::Voyage#embed_text expects Array<String> (got #{strings.class})."
  end
  return [] if strings.empty?
  strings.each_with_index do |s, i|
    unless s.is_a?(String)
      raise ArgumentError,
            "Parse::Embeddings::Voyage#embed_text strings[#{i}] is not a String (#{s.class})."
    end
    if s.empty?
      raise ArgumentError,
            "Parse::Embeddings::Voyage#embed_text strings[#{i}] is empty; Voyage rejects empty inputs."
    end
  end
  unless INPUT_TYPE_WIRE_VALUES.key?(input_type)
    raise ArgumentError,
          "Parse::Embeddings::Voyage#embed_text input_type #{input_type.inspect} not in " \
          "#{INPUT_TYPE_WIRE_VALUES.keys.inspect}."
  end
  wire_input_type = INPUT_TYPE_WIRE_VALUES[input_type]

  if CONTEXTUALIZED_MODELS.include?(@model)
    if strings.length > MAX_CONTEXT_DOCUMENTS
      raise ArgumentError,
            "Parse::Embeddings::Voyage#embed_text: #{strings.length} inputs exceeds Voyage's " \
            "per-request cap for #{@model} (#{MAX_CONTEXT_DOCUMENTS}). Split the input."
    end
    # Each string is its own one-chunk document, so the response
    # carries exactly one vector per document.
    return embed_contextualized(strings.map { |s| [s] }, input_type, wire_input_type).map(&:first)
  end

  # Multimodal models route to a different endpoint with a
  # different request envelope. The response envelope shape is
  # the same (`{ data: [{ embedding, index }], usage: {...} }`)
  # so `extract_vectors!` is reused as-is.

  body = if MULTIMODAL_MODELS.include?(@model)
      build_multimodal_body(strings, wire_input_type)
    else
      build_text_body(strings, wire_input_type)
    end

  path = MULTIMODAL_MODELS.include?(@model) ? "multimodalembeddings" : "embeddings"

  instrument_embed(strings.length, input_type) do |emit_payload|
    payload = post_embeddings(body, path: path)
    # Voyage's response carries `usage: { total_tokens }`.
    if payload.is_a?(Hash) && payload["usage"].is_a?(Hash)
      tt = payload["usage"]["total_tokens"]
      emit_payload[:total_tokens] = tt if tt.is_a?(Integer) && tt >= 0
    end
    vectors = extract_vectors!(payload, strings.length)
    validate_response!(strings.length, vectors)
  end
end

#embed_video(sources, input_type: :search_document, allow_insecure: false) ⇒ Array<Array<Float>>

Embed a batch of videos through /v1/multimodalembeddings. Mirrors #embed_image's source forms and security posture exactly:

  • String URL — forwarded as a { type: "video_url", video_url: … } content row after Parse::Embeddings.validate_image_url! canonicalizes and screens it. The provider issues the fetch, so the trust_provider_url_fetch sentinel IS required and the SDK never downloads the video.
  • MediaFile — a local file, streamed into the request body as a { type: "video_base64", video_base64: "data:…" } row without ever being held in memory. No URL validation and no sentinel, because nothing is fetched.

Video-capable model required. Only VIDEO_MODELS accept video; voyage-multimodal-3 rejects it server-side, so this raises BadRequestError before any network call.

Parameters:

Returns:

  • (Array<Array<Float>>) —

    vectors aligned 1:1 with sources.



563
564
565
566
# File 'lib/parse/embeddings/voyage.rb', line 563

def embed_video(sources, input_type: :search_document, allow_insecure: false)
  embed_media(sources, kind: :video, input_type: input_type,
                       allow_insecure: allow_insecure)
end

#endpoint ⇒ Symbol

Returns :atlas when this provider targets MongoDB's Atlas Embedding and Reranking API, :voyage when it targets Voyage's own API, :custom for any other host.

Returns:

  • (Symbol) —

    :atlas when this provider targets MongoDB's Atlas Embedding and Reranking API, :voyage when it targets Voyage's own API, :custom for any other host.



400
401
402
# File 'lib/parse/embeddings/voyage.rb', line 400

def endpoint
  @endpoint
end

#enforce_media_size!(bytes, index, caller_name, path = nil) ⇒ Object (protected)

Refuse a payload Voyage will reject anyway. Enforced here rather than relying on Parse::Embeddings.max_media_bytes, which callers may legitimately raise for other providers.

Raises:



916
917
918
919
920
921
922
923
924
# File 'lib/parse/embeddings/voyage.rb', line 916

def enforce_media_size!(bytes, index, caller_name, path = nil)
  return if bytes <= MAX_MEDIA_BYTES

  where = path ? " (#{path})" : ""
  raise BadRequestError,
        "Parse::Embeddings::Voyage##{caller_name} sources[#{index}]#{where} is " \
        "#{bytes} bytes, over Voyage's #{MAX_MEDIA_BYTES}-byte per-file limit. " \
        "Downscale or re-encode before embedding."
end

#extract_vectors!(payload, input_count, value_key: "embedding") ⇒ Object (protected)

Voyage's response shape mirrors OpenAI:

{ "object": "list", "data": [ { "object": "embedding", "embedding": [...], "index": 0 }, ... ], "model": "voyage-3", "usage": { "total_tokens": N } }

value_key names the field read from each entry: "embedding" for the flat envelope, "data" for the outer list of the contextualized envelope.



1073
1074
1075
1076
1077
1078
1079
1080
1081
1082
1083
1084
1085
1086
1087
1088
1089
1090
1091
1092
1093
1094
1095
1096
1097
1098
1099
1100
1101
1102
1103
1104
1105
# File 'lib/parse/embeddings/voyage.rb', line 1073

def extract_vectors!(payload, input_count, value_key: "embedding")
  unless payload.is_a?(Hash)
    raise InvalidResponseError,
          "Parse::Embeddings::Voyage: response body is not a JSON object."
  end
  data = payload["data"]
  unless data.is_a?(Array)
    raise InvalidResponseError,
          "Parse::Embeddings::Voyage: response.data is not an Array."
  end
  if data.length != input_count
    raise InvalidResponseError,
          "Parse::Embeddings::Voyage: response.data.length #{data.length} != input count #{input_count}."
  end
  sorted = data.each_with_index.map do |entry, i|
    unless entry.is_a?(Hash)
      raise InvalidResponseError,
            "Parse::Embeddings::Voyage: response.data[#{i}] is not a JSON object."
    end
    idx = entry["index"]
    unless idx.is_a?(Integer) && idx >= 0 && idx < input_count
      raise InvalidResponseError,
            "Parse::Embeddings::Voyage: response.data[#{i}].index #{idx.inspect} out of range."
    end
    [idx, entry[value_key]]
  end
  indices = sorted.map(&:first)
  if indices.uniq.length != indices.length
    raise InvalidResponseError,
          "Parse::Embeddings::Voyage: duplicate index in response.data."
  end
  sorted.sort_by(&:first).map(&:last)
end

#inspect_attrs ⇒ Object



634
635
636
# File 'lib/parse/embeddings/voyage.rb', line 634

def inspect_attrs
  super.merge(base: safe_base_host, endpoint: @endpoint, retries: @max_retries)
end

#json_string_prefix(str) ⇒ Object (protected)

A JSON string literal with its opening quote and escaped contents but no closing quote, so a streamed payload can be appended before the string is closed.



983
984
985
986
# File 'lib/parse/embeddings/voyage.rb', line 983

def json_string_prefix(str)
  encoded = str.to_json
  encoded[0...-1]
end

#max_input_tokens ⇒ Object



413
414
415
# File 'lib/parse/embeddings/voyage.rb', line 413

def max_input_tokens
  MODEL_MAX_INPUT_TOKENS[@model]
end

#modalities ⇒ Array<Symbol>

Returns [:text, :image, :video] for voyage-multimodal-3.5, [:text, :image] for voyage-multimodal-3, and [:text] for text-only models. Audio is not offered by any Voyage model.

Returns:

  • (Array<Symbol>) —

    [:text, :image, :video] for voyage-multimodal-3.5, [:text, :image] for voyage-multimodal-3, and [:text] for text-only models. Audio is not offered by any Voyage model.



492
493
494
495
# File 'lib/parse/embeddings/voyage.rb', line 492

def modalities
  return [:text] unless MULTIMODAL_MODELS.include?(@model)
  VIDEO_MODELS.include?(@model) ? %i[text image video] : %i[text image]
end

#model_name ⇒ Object



393
394
395
# File 'lib/parse/embeddings/voyage.rb', line 393

def model_name
  @model
end

#normalize? ⇒ Boolean

Returns:

  • (Boolean)


417
418
419
420
# File 'lib/parse/embeddings/voyage.rb', line 417

def normalize?
  # Voyage's v3 embeddings are documented unit-normalized.
  true
end

#parse_json_body!(body, max_bytes = MAX_RESPONSE_BYTES) ⇒ Object (protected)



1045
1046
1047
1048
1049
1050
1051
1052
1053
1054
1055
1056
# File 'lib/parse/embeddings/voyage.rb', line 1045

def parse_json_body!(body, max_bytes = MAX_RESPONSE_BYTES)
  s = body.to_s
  if s.bytesize > max_bytes
    raise InvalidResponseError,
          "Parse::Embeddings::Voyage: response body exceeds #{max_bytes} bytes " \
          "(#{s.bytesize}). Refusing to parse."
  end
  JSON.parse(s, max_nesting: 32)
rescue JSON::ParserError => e
  raise InvalidResponseError,
        "Parse::Embeddings::Voyage: response is not valid JSON (#{e.message})."
end

#post_embeddings(body, path: "embeddings", max_response_bytes: MAX_RESPONSE_BYTES) ⇒ Object (protected)

Parameters:

  • max_response_bytes (Integer) (defaults to: MAX_RESPONSE_BYTES) —

    refuse a success body larger than this. Defaults to MAX_RESPONSE_BYTES.



990
991
992
993
994
995
996
997
998
999
1000
1001
1002
1003
1004
1005
1006
1007
1008
1009
1010
1011
1012
1013
1014
1015
1016
1017
1018
1019
1020
1021
1022
1023
1024
1025
1026
1027
1028
1029
1030
1031
1032
1033
1034
1035
1036
1037
1038
1039
1040
1041
1042
1043
# File 'lib/parse/embeddings/voyage.rb', line 990

def post_embeddings(body, path: "embeddings", max_response_bytes: MAX_RESPONSE_BYTES)
  attempts = 0
  loop do
    attempts += 1
    begin
      response = @connection.post(path) do |req|
        if body.is_a?(Parse::Embeddings::StreamingBody)
          # Faraday's net_http adapter routes an IO-shaped body
          # to Net::HTTP#body_stream. Rewind so a retry replays
          # from the start, and set Content-Length explicitly —
          # without it Net::HTTP falls back to chunked transfer
          # encoding, which some API gateways reject.
          body.rewind
          req.headers["Content-Length"] = body.size.to_s
          req.body = body
        else
          req.body = body.to_json
        end
      end
    rescue Faraday::TimeoutError, Faraday::ConnectionFailed => e
      if attempts > @max_retries
        raise TransientError, "Parse::Embeddings::Voyage: #{e.class} after #{attempts} attempt(s)."
      end
      sleep(backoff_seconds(attempts))
      next
    end

    status = response.status
    return parse_json_body!(response.body, max_response_bytes) if status >= 200 && status < 300

    if status == 401
      raise AuthenticationError,
            "Parse::Embeddings::Voyage: 401 Unauthorized — check api_key."
    end
    if status == 429
      if attempts > @max_retries
        raise RateLimitError,
              "Parse::Embeddings::Voyage: 429 rate limited after #{attempts} attempt(s)."
      end
      sleep(retry_after_seconds(response) || backoff_seconds(attempts))
      next
    end
    if status >= 500
      if attempts > @max_retries
        raise TransientError,
              "Parse::Embeddings::Voyage: #{status} after #{attempts} attempt(s)."
      end
      sleep(backoff_seconds(attempts))
      next
    end
    raise BadRequestError,
          "Parse::Embeddings::Voyage: #{status} from POST /#{path}."
  end
end

#response_bytes_per_vector ⇒ Integer (protected)

Returns the planning estimate for one vector's JSON size.

Returns:

  • (Integer) —

    the planning estimate for one vector's JSON size.



729
730
731
# File 'lib/parse/embeddings/voyage.rb', line 729

def response_bytes_per_vector
  (@dimensions * RESPONSE_BYTES_PER_VALUE) + RESPONSE_BYTES_PER_VECTOR_ENVELOPE
end

#retry_after_seconds(response) ⇒ Object (protected)



1111
1112
1113
1114
1115
1116
# File 'lib/parse/embeddings/voyage.rb', line 1111

def retry_after_seconds(response)
  ra = response.respond_to?(:headers) ? response.headers["retry-after"] || response.headers["Retry-After"] : nil
  return nil unless ra
  v = ra.to_f
  v.positive? ? [v, 60.0].min : nil
end

#row_representation(row) ⇒ Object (protected)

Which wire representation a row descriptor uses. Voyage requires a single representation per request, so this is what #embed_media partitions on.



929
930
931
932
933
# File 'lib/parse/embeddings/voyage.rb', line 929

def row_representation(row)
  return :base64 if row[:stream]
  type = row.dig(:content, 0, :type).to_s
  type.end_with?("_base64") ? :base64 : :url
end

#supports_input_type? ⇒ Boolean

Returns:

  • (Boolean)


422
423
424
# File 'lib/parse/embeddings/voyage.rb', line 422

def supports_input_type?
  true
end