parse-stack-next 5.5.6 → 5.6.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -15,29 +15,70 @@ module Parse
15
15
  #
16
16
  # Supported models:
17
17
  #
18
- # * **v4 family** — `voyage-4-large` (MoE flagship, Matryoshka-capable),
19
- # `voyage-4`, `voyage-4-lite`, `voyage-4-nano` (Apache 2.0,
20
- # open-weight on Hugging Face also runnable through
21
- # {LocalHTTP} when self-hosted on vLLM / Ollama / llama.cpp).
22
- # * **v3 family** — `voyage-3-large`, `voyage-3`, `voyage-3-lite`,
23
- # `voyage-code-3`.
18
+ # * **v4 family** — `voyage-4-large`, `voyage-4`, `voyage-4-lite`,
19
+ # `voyage-4-nano` (Apache 2.0, open-weight on Hugging Face — also
20
+ # runnable through {LocalHTTP} when self-hosted on vLLM / Ollama /
21
+ # llama.cpp).
22
+ # * **v3 family** — `voyage-3-large`, `voyage-3.5`,
23
+ # `voyage-3.5-lite`, `voyage-3`, `voyage-3-lite`.
24
+ # * **code models** — `voyage-code-3`, `voyage-code-2` (1536-dim).
24
25
  # * **domain models** — `voyage-finance-2`, `voyage-law-2`.
25
- # * **multimodal** — `voyage-multimodal-3` (1024-dim). Unified
26
- # text+image vector space at the network boundary. Text inputs
27
- # route to `/v1/multimodalembeddings` with a `{ inputs: [{ content:
28
- # [{ type: "text", text: … }] }] }` envelope; image inputs go
29
- # through {#embed_image} as `image_url` rows (provider-side fetch,
30
- # v5.1) or `image_base64` rows (SDK-fetched bytes, v5.5). Text and
31
- # image vectors share the same space, so stored text vectors are
32
- # comparable against image vectors without re-embedding.
26
+ # * **multimodal** — `voyage-multimodal-3` (text+image) and
27
+ # `voyage-multimodal-3.5` (text+image+video). Unified vector
28
+ # space at the network boundary: text routes to
29
+ # `/v1/multimodalembeddings` with a `{ inputs: [{ content:
30
+ # [{ type: "text", text: … }] }] }` envelope, images go through
31
+ # {#embed_image}, video through {#embed_video}. All three share
32
+ # the same space, so stored text vectors are comparable against
33
+ # image and video vectors without re-embedding.
34
+ #
35
+ # Audio is not offered by any Voyage model, and neither PDF nor
36
+ # DOCX is accepted as a content type — render document pages to
37
+ # images and embed those instead.
38
+ #
39
+ # Most models expose a Matryoshka ladder
40
+ # ({MODEL_SUPPORTED_DIMENSIONS}); note that the whole v4 family
41
+ # DEFAULTS to 1024 and reaches 2048 or 256 only when `dimensions:`
42
+ # asks for it.
43
+ #
44
+ # == Endpoints
45
+ #
46
+ # The same models are served by Voyage's own API and by MongoDB's
47
+ # Atlas Embedding and Reranking API. The wire contract is
48
+ # identical; the credentials are not interchangeable, and Voyage
49
+ # returns a 403 explaining as much if they are crossed. An Atlas
50
+ # model API key is recognized by its {ATLAS_KEY_PREFIX} and routes
51
+ # to {ATLAS_BASE_URL} automatically — pass `endpoint:` to be
52
+ # explicit. A few older models are absent from Atlas; see
53
+ # {ATLAS_UNAVAILABLE_MODELS}.
54
+ #
55
+ # == Memory
56
+ #
57
+ # Local images and video should be wrapped with
58
+ # {Parse::Embeddings::MediaFile}, which streams the file into the
59
+ # request body {StreamingBody::READ_CHUNK} bytes at a time. Passing
60
+ # a URL instead keeps the SDK out of the transfer entirely — the
61
+ # provider does the fetch. Only {ImageFetch::FetchedImage} holds a
62
+ # payload in memory, so prefer it for small images only.
33
63
  #
34
64
  # @example registration
35
65
  # Parse::Embeddings.register(:voyage,
36
66
  # Parse::Embeddings::Voyage.new(
37
67
  # api_key: ENV.fetch("VOYAGE_API_KEY"),
38
- # model: "voyage-3",
68
+ # model: "voyage-3.5",
69
+ # ))
70
+ #
71
+ # @example Atlas model API key (endpoint inferred from the prefix)
72
+ # Parse::Embeddings.register(:voyage,
73
+ # Parse::Embeddings::Voyage.new(
74
+ # api_key: ENV.fetch("ATLAS_MODEL_API_KEY"), # "al-…"
75
+ # model: "voyage-multimodal-3.5",
39
76
  # ))
40
77
  #
78
+ # @example streaming local media
79
+ # provider.embed_image([Parse::Embeddings::MediaFile.image("page.png")])
80
+ # provider.embed_video([Parse::Embeddings::MediaFile.video("demo.mp4")])
81
+ #
41
82
  # == Asymmetric input types
42
83
  #
43
84
  # Voyage's `input_type` field accepts `"query"` or `"document"`
@@ -69,7 +110,16 @@ module Parse
69
110
  class TransientError < Error; end
70
111
 
71
112
  DEFAULT_BASE_URL = "https://api.voyageai.com/v1"
72
- DEFAULT_MODEL = "voyage-3"
113
+ # MongoDB's Atlas Embedding and Reranking API re-exposes the same
114
+ # Voyage models under a MongoDB-operated host. The wire contract
115
+ # (request envelopes, response envelopes, error shapes) is
116
+ # identical — only the host and the credential differ.
117
+ ATLAS_BASE_URL = "https://ai.mongodb.com/v1"
118
+ # Bumped from `voyage-3` in 5.6.0: that model is retired from the
119
+ # Atlas endpoint, so an Atlas key used without naming a model
120
+ # failed at construction. `voyage-3.5` is served by both
121
+ # endpoints and shares the 1024 native width.
122
+ DEFAULT_MODEL = "voyage-3.5"
73
123
  DEFAULT_TIMEOUT = 30
74
124
  DEFAULT_OPEN_TIMEOUT = 5
75
125
  DEFAULT_MAX_RETRIES = 3
@@ -77,49 +127,124 @@ module Parse
77
127
  DEFAULT_BATCH_SIZE = 128
78
128
  MAX_RESPONSE_BYTES = 16 * 1024 * 1024
79
129
 
80
- # Native vector widths per model. The v4 family is Voyage's
81
- # current flagship line (MoE for `voyage-4-large`, open-weight
82
- # nano under Apache 2.0). `voyage-4-large` supports Matryoshka
83
- # truncation via the constructor's `dimensions:` override.
130
+ # Default (native) vector width per model the width returned
131
+ # when `output_dimension` is omitted from the request.
132
+ #
133
+ # NOTE: the whole v4 family defaults to 1024, NOT to a
134
+ # per-tier width. `voyage-4-large` reaches 2048 and
135
+ # `voyage-4-lite` reaches 512 only by explicitly requesting them
136
+ # via `output_dimension` (the constructor's `dimensions:`
137
+ # override) — those are Matryoshka options, not native widths.
138
+ # Verified against the live API for every model reachable
139
+ # through {ATLAS_BASE_URL}; see {MODEL_SUPPORTED_DIMENSIONS}.
84
140
  MODEL_DEFAULT_DIMENSIONS = {
85
- "voyage-4-large" => 2048,
86
- "voyage-4" => 1024,
87
- "voyage-4-lite" => 512,
88
- "voyage-4-nano" => 256,
89
- "voyage-3-large" => 1024,
90
- "voyage-3" => 1024,
91
- "voyage-3-lite" => 512,
92
- "voyage-code-3" => 1024,
93
- "voyage-finance-2" => 1024,
94
- "voyage-law-2" => 1024,
95
- "voyage-multimodal-3" => 1024,
141
+ "voyage-4-large" => 1024,
142
+ "voyage-4" => 1024,
143
+ "voyage-4-lite" => 1024,
144
+ "voyage-4-nano" => 1024,
145
+ "voyage-3-large" => 1024,
146
+ "voyage-3.5" => 1024,
147
+ "voyage-3.5-lite" => 1024,
148
+ "voyage-3" => 1024,
149
+ "voyage-3-lite" => 512,
150
+ "voyage-code-3" => 1024,
151
+ "voyage-code-2" => 1536,
152
+ "voyage-finance-2" => 1024,
153
+ "voyage-law-2" => 1024,
154
+ "voyage-multimodal-3" => 1024,
155
+ "voyage-multimodal-3.5" => 1024,
96
156
  }.freeze
97
157
 
98
- MODEL_MAX_INPUT_TOKENS = {
99
- "voyage-4-large" => 32_000,
100
- "voyage-4" => 32_000,
101
- "voyage-4-lite" => 32_000,
102
- "voyage-4-nano" => 32_000,
103
- "voyage-3-large" => 32_000,
104
- "voyage-3" => 32_000,
105
- "voyage-3-lite" => 32_000,
106
- "voyage-code-3" => 32_000,
107
- "voyage-finance-2" => 16_000,
108
- "voyage-law-2" => 16_000,
109
- "voyage-multimodal-3" => 32_000,
158
+ # Every width a model's Matryoshka head will actually return.
159
+ # A model whose list has a single entry accepts no
160
+ # `output_dimension` override at all — requesting one is a 400.
161
+ #
162
+ # This replaces the older "Matryoshka-capable models" boolean
163
+ # gate, which was too coarse: the v4 family, `voyage-3-large`,
164
+ # the v3.5 family, and `voyage-code-3` all accept the full
165
+ # 256/512/1024/2048 ladder, and `voyage-multimodal-3.5` accepts
166
+ # it too while `voyage-multimodal-3` does not.
167
+ MODEL_SUPPORTED_DIMENSIONS = {
168
+ "voyage-4-large" => [256, 512, 1024, 2048],
169
+ "voyage-4" => [256, 512, 1024, 2048],
170
+ "voyage-4-lite" => [256, 512, 1024, 2048],
171
+ "voyage-4-nano" => [256, 512, 1024, 2048],
172
+ "voyage-3-large" => [256, 512, 1024, 2048],
173
+ "voyage-3.5" => [256, 512, 1024, 2048],
174
+ "voyage-3.5-lite" => [256, 512, 1024, 2048],
175
+ "voyage-3" => [1024],
176
+ "voyage-3-lite" => [512],
177
+ "voyage-code-3" => [256, 512, 1024, 2048],
178
+ "voyage-code-2" => [1536],
179
+ "voyage-finance-2" => [1024],
180
+ "voyage-law-2" => [1024],
181
+ "voyage-multimodal-3" => [1024],
182
+ "voyage-multimodal-3.5" => [256, 512, 1024, 2048],
110
183
  }.freeze
111
184
 
112
- # Models that accept Voyage's `output_dimension` Matryoshka
113
- # truncation parameter. Sending the field for other models is
114
- # rejected with a 400 by Voyage, so we gate it explicitly.
115
- MATRYOSHKA_MODELS = %w[voyage-4-large].freeze
185
+ # Back-compat alias: the set of models accepting any
186
+ # `output_dimension` other than their native width. Derived from
187
+ # {MODEL_SUPPORTED_DIMENSIONS} rather than hand-maintained.
188
+ MATRYOSHKA_MODELS =
189
+ MODEL_SUPPORTED_DIMENSIONS.select { |_m, dims| dims.length > 1 }.keys.freeze
190
+
191
+ MODEL_MAX_INPUT_TOKENS = {
192
+ "voyage-4-large" => 32_000,
193
+ "voyage-4" => 32_000,
194
+ "voyage-4-lite" => 32_000,
195
+ "voyage-4-nano" => 32_000,
196
+ "voyage-3-large" => 32_000,
197
+ "voyage-3.5" => 32_000,
198
+ "voyage-3.5-lite" => 32_000,
199
+ "voyage-3" => 32_000,
200
+ "voyage-3-lite" => 32_000,
201
+ "voyage-code-3" => 32_000,
202
+ "voyage-code-2" => 16_000,
203
+ "voyage-finance-2" => 32_000,
204
+ "voyage-law-2" => 16_000,
205
+ "voyage-multimodal-3" => 32_000,
206
+ "voyage-multimodal-3.5" => 32_000,
207
+ }.freeze
116
208
 
117
209
  # Models that route to `/v1/multimodalembeddings` with the
118
210
  # `{ inputs: [{ content: [...] }] }` envelope rather than the
119
211
  # standard `/v1/embeddings` `{ input: [String] }` envelope.
120
212
  # Text-only inputs from this provider are wrapped as
121
213
  # `{ type: "text", text: s }` content rows.
122
- MULTIMODAL_MODELS = %w[voyage-multimodal-3].freeze
214
+ MULTIMODAL_MODELS = %w[voyage-multimodal-3 voyage-multimodal-3.5].freeze
215
+
216
+ # Voyage's documented hard ceiling for a single image or video.
217
+ # {Parse::Embeddings.max_media_bytes} is a global convenience
218
+ # knob that may be lowered for any reason — but raising it above
219
+ # this cannot make Voyage accept a larger file, so the adapter
220
+ # enforces its own limit independently.
221
+ MAX_MEDIA_BYTES = 20 * 1024 * 1024
222
+
223
+ # Multimodal models that additionally accept video content rows
224
+ # (`video_url` / `video_base64`) via {#embed_video}.
225
+ # `voyage-multimodal-3` rejects video with an explicit
226
+ # "does not support video inputs" 400.
227
+ VIDEO_MODELS = %w[voyage-multimodal-3.5].freeze
228
+
229
+ # Models Voyage's hosted API serves but the Atlas Embedding and
230
+ # Reranking API does not. Verified against both endpoints.
231
+ ATLAS_UNAVAILABLE_MODELS = %w[voyage-3 voyage-3-lite].freeze
232
+
233
+ # Open-weight models that NO hosted endpoint serves — neither
234
+ # Voyage's nor Atlas's. `voyage-4-nano` ships under Apache 2.0 on
235
+ # Hugging Face and is meant to be self-hosted (vLLM / Ollama /
236
+ # llama.cpp), reached either through {LocalHTTP} or through this
237
+ # provider with an explicit `base_url:` pointing at the local
238
+ # server. Naming one against a hosted endpoint is always a
239
+ # mistake, so it is refused there rather than failing as an
240
+ # opaque provider 400.
241
+ SELF_HOSTED_ONLY_MODELS = %w[voyage-4-nano].freeze
242
+
243
+ # Atlas model API keys carry this prefix and authenticate ONLY
244
+ # against {ATLAS_BASE_URL}; Voyage's own endpoint rejects them
245
+ # with a 403. Used to infer the endpoint when the caller does
246
+ # not name one explicitly.
247
+ ATLAS_KEY_PREFIX = "al-"
123
248
 
124
249
  # Map SDK-canonical input_type symbols to Voyage wire strings.
125
250
  # `:classification` / `:clustering` map to `nil` (omitted) since
@@ -134,8 +259,15 @@ module Parse
134
259
 
135
260
  # @param api_key [String] required. Sent as `Authorization: Bearer …`.
136
261
  # @param model [String] one of {MODEL_DEFAULT_DIMENSIONS}'s keys.
137
- # @param base_url [String] override. Must be HTTPS unless
138
- # `allow_insecure_base_url: true`.
262
+ # @param endpoint [Symbol] `:auto` (default), `:voyage`, or
263
+ # `:atlas`. Selects the default `base_url` and enables
264
+ # endpoint-specific model validation. `:auto` infers `:atlas`
265
+ # when `api_key` carries the {ATLAS_KEY_PREFIX}, else
266
+ # `:voyage`. An explicit `base_url:` always wins; the endpoint
267
+ # is then inferred from its host.
268
+ # @param base_url [String, nil] override. Must be HTTPS unless
269
+ # `allow_insecure_base_url: true`. Defaults to the resolved
270
+ # endpoint's host.
139
271
  # @param timeout [Integer] read timeout, seconds.
140
272
  # @param open_timeout [Integer] connect timeout, seconds.
141
273
  # @param max_retries [Integer] retry attempts on 429/5xx/timeouts.
@@ -155,7 +287,8 @@ module Parse
155
287
  def initialize(
156
288
  api_key:,
157
289
  model: DEFAULT_MODEL,
158
- base_url: DEFAULT_BASE_URL,
290
+ endpoint: :auto,
291
+ base_url: nil,
159
292
  timeout: DEFAULT_TIMEOUT,
160
293
  open_timeout: DEFAULT_OPEN_TIMEOUT,
161
294
  max_retries: DEFAULT_MAX_RETRIES,
@@ -168,6 +301,9 @@ module Parse
168
301
  )
169
302
  validate_api_key!(api_key)
170
303
  validate_model!(model)
304
+ resolved_endpoint = resolve_endpoint!(endpoint, api_key, base_url)
305
+ base_url ||= resolved_endpoint == :atlas ? ATLAS_BASE_URL : DEFAULT_BASE_URL
306
+ validate_model_for_endpoint!(model, resolved_endpoint)
171
307
  sanitized_base_url = validate_base_url!(base_url, allow_insecure_base_url)
172
308
  validate_positive_integer!(:timeout, timeout)
173
309
  validate_positive_integer!(:open_timeout, open_timeout)
@@ -185,6 +321,7 @@ module Parse
185
321
 
186
322
  @api_key = api_key
187
323
  @model = model
324
+ @endpoint = resolved_endpoint
188
325
  @dimensions = dimensions || MODEL_DEFAULT_DIMENSIONS.fetch(model)
189
326
  @base_url = sanitized_base_url
190
327
  @timeout = timeout
@@ -204,6 +341,18 @@ module Parse
204
341
  @model
205
342
  end
206
343
 
344
+ # @return [Symbol] `:atlas` when this provider targets MongoDB's
345
+ # Atlas Embedding and Reranking API, `:voyage` when it targets
346
+ # Voyage's own API, `:custom` for any other host.
347
+ def endpoint
348
+ @endpoint
349
+ end
350
+
351
+ # @return [Boolean] true when routed through {ATLAS_BASE_URL}.
352
+ def atlas?
353
+ @endpoint == :atlas
354
+ end
355
+
207
356
  def embed_batch_size
208
357
  @embed_batch_size
209
358
  end
@@ -272,10 +421,13 @@ module Parse
272
421
  end
273
422
  end
274
423
 
275
- # @return [Array<Symbol>] Voyage's multimodal models support
276
- # `[:text, :image]`; text-only models report `[:text]`.
424
+ # @return [Array<Symbol>] `[:text, :image, :video]` for
425
+ # `voyage-multimodal-3.5`, `[:text, :image]` for
426
+ # `voyage-multimodal-3`, and `[:text]` for text-only models.
427
+ # Audio is not offered by any Voyage model.
277
428
  def modalities
278
- MULTIMODAL_MODELS.include?(@model) ? %i[text image] : [:text]
429
+ return [:text] unless MULTIMODAL_MODELS.include?(@model)
430
+ VIDEO_MODELS.include?(@model) ? %i[text image video] : %i[text image]
279
431
  end
280
432
 
281
433
  # Embed a batch of images through Voyage's
@@ -315,76 +467,42 @@ module Parse
315
467
  # validator; permit `http://` for local-dev CDN proxies.
316
468
  # @return [Array<Array<Float>>] vectors aligned 1:1 with `sources`.
317
469
  def embed_image(sources, input_type: :search_document, allow_insecure: false)
318
- unless MULTIMODAL_MODELS.include?(@model)
319
- raise BadRequestError,
320
- "Parse::Embeddings::Voyage#embed_image: model #{@model.inspect} does not " \
321
- "accept image inputs. Configure the provider with a multimodal model " \
322
- "(supported: #{MULTIMODAL_MODELS.inspect})."
323
- end
324
- unless sources.is_a?(Array)
325
- raise ArgumentError,
326
- "Parse::Embeddings::Voyage#embed_image expects Array of image URLs " \
327
- "(got #{sources.class})."
328
- end
329
- return [] if sources.empty?
330
-
331
- unless INPUT_TYPE_WIRE_VALUES.key?(input_type)
332
- raise ArgumentError,
333
- "Parse::Embeddings::Voyage#embed_image input_type #{input_type.inspect} not in " \
334
- "#{INPUT_TYPE_WIRE_VALUES.keys.inspect}."
335
- end
336
- # Voyage caps multimodal requests at the same per-request size
337
- # as the text endpoint. The text path goes through
338
- # `embed_text_batched` which chunks automatically; the image
339
- # path has no chunker yet (every directive is a single image
340
- # source), so guard the direct-API caller against a silent 400.
341
- if sources.length > @embed_batch_size
342
- raise ArgumentError,
343
- "Parse::Embeddings::Voyage#embed_image: batch size #{sources.length} exceeds " \
344
- "the configured cap #{@embed_batch_size} (Voyage per-request max: 128). " \
345
- "Split the input and call embed_image once per chunk."
346
- end
347
-
348
- # Validate every URL up-front so a malformed entry in slot N
349
- # does not get past validation while slots 0..N-1 are already
350
- # in the wire body. URL entries forward the validator's
351
- # canonicalized URL (never the caller's raw input); fetched-
352
- # bytes entries skip URL validation (the bytes were already
353
- # downloaded + verified by ImageFetch) and forward as base64.
354
- content_rows = sources.each_with_index.map do |src, i|
355
- if src.is_a?(Parse::Embeddings::ImageFetch::FetchedImage)
356
- { content: [{ type: "image_base64", image_base64: src.to_data_uri }] }
357
- elsif src.is_a?(String)
358
- canonical = Parse::Embeddings.validate_image_url!(src, allow_insecure: allow_insecure)
359
- { content: [{ type: "image_url", image_url: canonical }] }
360
- else
361
- raise ArgumentError,
362
- "Parse::Embeddings::Voyage#embed_image sources[#{i}] must be a URL String " \
363
- "or Parse::Embeddings::ImageFetch::FetchedImage (got #{src.class})."
364
- end
365
- end
366
-
367
- wire_input_type = INPUT_TYPE_WIRE_VALUES[input_type]
368
- body = {
369
- inputs: content_rows,
370
- model: @model,
371
- truncation: @truncation,
372
- }
373
- body[:input_type] = wire_input_type if wire_input_type
470
+ embed_media(sources, kind: :image, input_type: input_type,
471
+ allow_insecure: allow_insecure)
472
+ end
374
473
 
375
- instrument_embed(sources.length, input_type, modality: :image) do |emit_payload|
376
- payload = post_embeddings(body, path: "multimodalembeddings")
377
- if payload.is_a?(Hash) && payload["usage"].is_a?(Hash)
378
- tt = payload["usage"]["total_tokens"]
379
- emit_payload[:total_tokens] = tt if tt.is_a?(Integer) && tt >= 0
380
- end
381
- vectors = extract_vectors!(payload, sources.length)
382
- validate_response!(sources.length, vectors)
383
- end
474
+ # Embed a batch of videos through
475
+ # `/v1/multimodalembeddings`. Mirrors {#embed_image}'s source
476
+ # forms and security posture exactly:
477
+ #
478
+ # * **String URL** forwarded as a `{ type: "video_url",
479
+ # video_url: … }` content row after
480
+ # {Parse::Embeddings.validate_image_url!} canonicalizes and
481
+ # screens it. The provider issues the fetch, so the
482
+ # `trust_provider_url_fetch` sentinel IS required and the SDK
483
+ # never downloads the video.
484
+ # * **{Parse::Embeddings::MediaFile}** — a local file, streamed
485
+ # into the request body as a `{ type: "video_base64",
486
+ # video_base64: "data:…" }` row without ever being held in
487
+ # memory. No URL validation and no sentinel, because nothing is
488
+ # fetched.
489
+ #
490
+ # **Video-capable model required.** Only {VIDEO_MODELS} accept
491
+ # video; `voyage-multimodal-3` rejects it server-side, so this
492
+ # raises {BadRequestError} before any network call.
493
+ #
494
+ # @param sources [Array<String, Parse::Embeddings::MediaFile>]
495
+ # video URLs and/or file-backed wrappers (forms may be mixed).
496
+ # @param input_type [Symbol] one of {INPUT_TYPE_WIRE_VALUES}'s keys.
497
+ # @param allow_insecure [Boolean] forwarded to the URL validator.
498
+ # @return [Array<Array<Float>>] vectors aligned 1:1 with `sources`.
499
+ def embed_video(sources, input_type: :search_document, allow_insecure: false)
500
+ embed_media(sources, kind: :video, input_type: input_type,
501
+ allow_insecure: allow_insecure)
384
502
  end
385
503
 
386
504
  def inspect_attrs
387
- super.merge(base: safe_base_host, retries: @max_retries)
505
+ super.merge(base: safe_base_host, endpoint: @endpoint, retries: @max_retries)
388
506
  end
389
507
 
390
508
  protected
@@ -421,14 +539,7 @@ module Parse
421
539
  # treats absent and `null` identically (unconditioned head),
422
540
  # but absent is the spec-correct form for non-retrieval intent.
423
541
  body[:input_type] = wire_input_type if wire_input_type
424
- # `output_dimension` is only valid for the Matryoshka-capable
425
- # models. Forward when the configured model is in the
426
- # Matryoshka set and the active dimensions differ from native.
427
- # Sending it elsewhere would yield a 400.
428
- if MATRYOSHKA_MODELS.include?(@model) &&
429
- @dimensions != MODEL_DEFAULT_DIMENSIONS.fetch(@model)
430
- body[:output_dimension] = @dimensions
431
- end
542
+ apply_output_dimension!(body)
432
543
  body
433
544
  end
434
545
 
@@ -447,16 +558,259 @@ module Parse
447
558
  # forward it for parity with the text path so callers get the
448
559
  # same fail-on-overlength behavior across models.
449
560
  body[:truncation] = @truncation
561
+ apply_output_dimension!(body)
450
562
  body
451
563
  end
452
564
 
565
+ # Forward `output_dimension` only when the configured width
566
+ # differs from the model's native default. Sending it to a model
567
+ # with a single supported width is a 400, and sending the native
568
+ # width needlessly is redundant — so both are omitted.
569
+ #
570
+ # The constructor has already rejected any width outside
571
+ # {MODEL_SUPPORTED_DIMENSIONS}, so no re-validation is needed.
572
+ def apply_output_dimension!(body)
573
+ return body if @dimensions == MODEL_DEFAULT_DIMENSIONS.fetch(@model)
574
+ body[:output_dimension] = @dimensions
575
+ body
576
+ end
577
+
578
+ # Everything modality-specific about a non-text input, in one
579
+ # table. `models` names the constant gating which models accept
580
+ # the modality; `url` / `base64` are the wire content-type keys.
581
+ #
582
+ # Adding a modality (audio, when Voyage ships it) is a row here
583
+ # plus a {Parse::Embeddings::MediaFile} constructor and a
584
+ # one-line `embed_audio` delegating to {#embed_media} — the
585
+ # streaming body, row builder, batching, URL validation, and
586
+ # instrumentation are all modality-agnostic already.
587
+ MEDIA_MODALITIES = {
588
+ image: { url: "image_url", base64: "image_base64",
589
+ models: :MULTIMODAL_MODELS, noun: "image" },
590
+ video: { url: "video_url", base64: "video_base64",
591
+ models: :VIDEO_MODELS, noun: "video" },
592
+ }.freeze
593
+
594
+ # Shared implementation behind {#embed_image} and {#embed_video}.
595
+ #
596
+ # @param kind [Symbol] a {MEDIA_MODALITIES} key.
597
+ # @return [Array<Array<Float>>] vectors aligned 1:1 with `sources`.
598
+ def embed_media(sources, kind:, input_type:, allow_insecure:)
599
+ spec = MEDIA_MODALITIES.fetch(kind)
600
+ capable = self.class.const_get(spec[:models])
601
+ caller_name = "embed_#{kind}"
602
+
603
+ unless capable.include?(@model)
604
+ raise BadRequestError,
605
+ "Parse::Embeddings::Voyage##{caller_name}: model #{@model.inspect} does not " \
606
+ "accept #{spec[:noun]} inputs. Configure the provider with a capable model " \
607
+ "(supported: #{capable.inspect})."
608
+ end
609
+ unless sources.is_a?(Array)
610
+ raise ArgumentError,
611
+ "Parse::Embeddings::Voyage##{caller_name} expects Array of #{spec[:noun]} " \
612
+ "URLs (got #{sources.class})."
613
+ end
614
+ return [] if sources.empty?
615
+
616
+ unless INPUT_TYPE_WIRE_VALUES.key?(input_type)
617
+ raise ArgumentError,
618
+ "Parse::Embeddings::Voyage##{caller_name} input_type #{input_type.inspect} " \
619
+ "not in #{INPUT_TYPE_WIRE_VALUES.keys.inspect}."
620
+ end
621
+ # Voyage caps multimodal requests at the same per-request size
622
+ # as the text endpoint. The text path chunks automatically; the
623
+ # media path has no chunker (every directive is a single
624
+ # source), so guard the direct-API caller against a silent 400.
625
+ if sources.length > @embed_batch_size
626
+ raise ArgumentError,
627
+ "Parse::Embeddings::Voyage##{caller_name}: batch size #{sources.length} " \
628
+ "exceeds the configured cap #{@embed_batch_size} (Voyage per-request max: " \
629
+ "128). Split the input and call #{caller_name} once per chunk."
630
+ end
631
+
632
+ rows = build_media_rows(
633
+ sources, kind: kind, allow_insecure: allow_insecure, caller_name: caller_name
634
+ )
635
+ wire_input_type = INPUT_TYPE_WIRE_VALUES[input_type]
636
+
637
+ # Voyage requires a single representation per request: "each
638
+ # request should use either image_base64/video_base64 or
639
+ # image_url/video_url exclusively, not both." A mixed batch is
640
+ # therefore split into one request per representation and
641
+ # reassembled in the caller's original order, so the 1:1
642
+ # alignment this method promises still holds.
643
+ groups = rows.each_with_index.group_by { |row, _i| row_representation(row) }
644
+ return dispatch_media(rows, input_type, wire_input_type, kind) if groups.size == 1
645
+
646
+ results = Array.new(rows.length)
647
+ groups.each_value do |pairs|
648
+ subset = pairs.map(&:first)
649
+ vectors = dispatch_media(subset, input_type, wire_input_type, kind)
650
+ pairs.each_with_index { |(_row, original_index), n| results[original_index] = vectors[n] }
651
+ end
652
+ results
653
+ end
654
+
655
+ # Issue one multimodal request for a set of same-representation
656
+ # rows and return the vectors in row order.
657
+ def dispatch_media(rows, input_type, wire_input_type, kind)
658
+ body = build_request_body(rows, wire_input_type)
659
+
660
+ instrument_embed(rows.length, input_type, modality: kind) do |emit_payload|
661
+ payload = post_embeddings(body, path: "multimodalembeddings")
662
+ if payload.is_a?(Hash) && payload["usage"].is_a?(Hash)
663
+ tt = payload["usage"]["total_tokens"]
664
+ emit_payload[:total_tokens] = tt if tt.is_a?(Integer) && tt >= 0
665
+ end
666
+ vectors = extract_vectors!(payload, rows.length)
667
+ validate_response!(rows.length, vectors)
668
+ end
669
+ end
670
+
671
+ # Build `inputs[].content[]` row descriptors for a batch of media
672
+ # sources, accepting URL Strings, in-memory
673
+ # {ImageFetch::FetchedImage} wrappers, and file-backed
674
+ # {MediaFile} wrappers.
675
+ #
676
+ # Every URL is validated up-front so a malformed entry in slot N
677
+ # cannot get past validation while slots 0..N-1 are already in
678
+ # the wire body — no partial forwarding.
679
+ #
680
+ # Returns descriptors rather than finished Hashes so
681
+ # {#build_request_body} can assemble the JSON structurally. A
682
+ # streamed row is `{ stream: MediaFile, key: String }`; every
683
+ # other row is a ready-to-serialize Hash.
684
+ #
685
+ # @return [Array<Hash>] one descriptor per source, in order.
686
+ def build_media_rows(sources, kind:, allow_insecure:, caller_name:)
687
+ spec = MEDIA_MODALITIES.fetch(kind)
688
+ url_key = spec[:url]
689
+ b64_key = spec[:base64]
690
+
691
+ sources.each_with_index.map do |src, i|
692
+ case src
693
+ when Parse::Embeddings::MediaFile
694
+ unless src.kind == kind
695
+ raise ArgumentError,
696
+ "Parse::Embeddings::Voyage##{caller_name} sources[#{i}] is a " \
697
+ "#{src.kind} MediaFile; expected #{kind}."
698
+ end
699
+ enforce_media_size!(src.byte_size, i, caller_name, src.path)
700
+ { stream: src, key: b64_key }
701
+ when Parse::Embeddings::ImageFetch::FetchedImage
702
+ # The only in-memory wrapper the SDK ships is for images.
703
+ unless kind == :image
704
+ raise ArgumentError,
705
+ "Parse::Embeddings::Voyage##{caller_name} sources[#{i}] is a FetchedImage; " \
706
+ "wrap #{spec[:noun]} sources with Parse::Embeddings::MediaFile.#{kind}."
707
+ end
708
+ enforce_media_size!(src.bytes.bytesize, i, caller_name)
709
+ { content: [{ type: b64_key, b64_key => src.to_data_uri }] }
710
+ when String
711
+ canonical = Parse::Embeddings.validate_image_url!(src, allow_insecure: allow_insecure)
712
+ { content: [{ type: url_key, url_key => canonical }] }
713
+ else
714
+ raise ArgumentError,
715
+ "Parse::Embeddings::Voyage##{caller_name} sources[#{i}] must be a URL String " \
716
+ "or a Parse::Embeddings::MediaFile (got #{src.class})."
717
+ end
718
+ end
719
+ end
720
+
721
+ # Refuse a payload Voyage will reject anyway. Enforced here
722
+ # rather than relying on {Parse::Embeddings.max_media_bytes},
723
+ # which callers may legitimately raise for other providers.
724
+ def enforce_media_size!(bytes, index, caller_name, path = nil)
725
+ return if bytes <= MAX_MEDIA_BYTES
726
+
727
+ where = path ? " (#{path})" : ""
728
+ raise BadRequestError,
729
+ "Parse::Embeddings::Voyage##{caller_name} sources[#{index}]#{where} is " \
730
+ "#{bytes} bytes, over Voyage's #{MAX_MEDIA_BYTES}-byte per-file limit. " \
731
+ "Downscale or re-encode before embedding."
732
+ end
733
+
734
+ # Which wire representation a row descriptor uses. Voyage
735
+ # requires a single representation per request, so this is what
736
+ # {#embed_media} partitions on.
737
+ def row_representation(row)
738
+ return :base64 if row[:stream]
739
+ type = row.dig(:content, 0, :type).to_s
740
+ type.end_with?("_base64") ? :base64 : :url
741
+ end
742
+
743
+ # Assemble the request body for a set of row descriptors.
744
+ #
745
+ # With no streamed rows this returns a plain Hash, serialized
746
+ # later by {#post_embeddings}. With them it returns a
747
+ # {StreamingBody} whose JSON is built **structurally** — each
748
+ # fragment is serialized independently and concatenated in
749
+ # order, so the file payloads are spliced by position rather than
750
+ # by searching the serialized document.
751
+ #
752
+ # Building it structurally is a correctness requirement, not a
753
+ # style preference: an earlier version emitted a sentinel token
754
+ # and located it with `String#split`, which let a caller-supplied
755
+ # URL containing that token capture a local file's bytes and ship
756
+ # them to the provider as a URL to fetch.
757
+ def build_request_body(rows, wire_input_type)
758
+ trailer = { model: @model, truncation: @truncation }
759
+ trailer[:input_type] = wire_input_type if wire_input_type
760
+ apply_output_dimension!(trailer)
761
+
762
+ return { inputs: rows }.merge(trailer) unless rows.any? { |r| r[:stream] }
763
+
764
+ segments = [+'{"inputs":[']
765
+ rows.each_with_index do |row, i|
766
+ segments << "," if i.positive?
767
+ if (media = row[:stream])
768
+ key = row[:key]
769
+ # Open the JSON string, emit the data: prefix, stream the
770
+ # payload, then close it. Base64 needs no escaping, and the
771
+ # prefix is escaped by #to_json before its closing quote is
772
+ # trimmed.
773
+ segments << %({"content":[{"type":#{key.to_json},#{key.to_json}:)
774
+ segments << json_string_prefix(media.data_uri_prefix)
775
+ segments << media.stream_segment
776
+ segments << %("}]})
777
+ else
778
+ segments << row.to_json
779
+ end
780
+ end
781
+ segments << "]"
782
+ trailer.each { |k, v| segments << ",#{k.to_s.to_json}:#{v.to_json}" }
783
+ segments << "}"
784
+
785
+ Parse::Embeddings::StreamingBody.new(segments)
786
+ end
787
+
788
+ # A JSON string literal with its opening quote and escaped
789
+ # contents but no closing quote, so a streamed payload can be
790
+ # appended before the string is closed.
791
+ def json_string_prefix(str)
792
+ encoded = str.to_json
793
+ encoded[0...-1]
794
+ end
795
+
453
796
  def post_embeddings(body, path: "embeddings")
454
797
  attempts = 0
455
798
  loop do
456
799
  attempts += 1
457
800
  begin
458
801
  response = @connection.post(path) do |req|
459
- req.body = body.to_json
802
+ if body.is_a?(Parse::Embeddings::StreamingBody)
803
+ # Faraday's net_http adapter routes an IO-shaped body
804
+ # to Net::HTTP#body_stream. Rewind so a retry replays
805
+ # from the start, and set Content-Length explicitly —
806
+ # without it Net::HTTP falls back to chunked transfer
807
+ # encoding, which some API gateways reject.
808
+ body.rewind
809
+ req.headers["Content-Length"] = body.size.to_s
810
+ req.body = body
811
+ else
812
+ req.body = body.to_json
813
+ end
460
814
  end
461
815
  rescue Faraday::TimeoutError, Faraday::ConnectionFailed => e
462
816
  if attempts > @max_retries
@@ -586,16 +940,75 @@ module Parse
586
940
  raise ArgumentError,
587
941
  "Parse::Embeddings::Voyage: dimensions must be a positive Integer (got #{dimensions.inspect})."
588
942
  end
589
- native = MODEL_DEFAULT_DIMENSIONS.fetch(model)
590
- if dimensions > native
943
+ supported = MODEL_SUPPORTED_DIMENSIONS.fetch(model)
944
+ return if supported.include?(dimensions)
945
+
946
+ if supported.length == 1
591
947
  raise ArgumentError,
592
- "Parse::Embeddings::Voyage: dimensions #{dimensions} exceeds native #{native} for #{model}."
948
+ "Parse::Embeddings::Voyage: model #{model.inspect} does not support custom dimensions " \
949
+ "(only #{supported.first} is available)."
593
950
  end
594
- if !MATRYOSHKA_MODELS.include?(model) && dimensions != native
951
+ raise ArgumentError,
952
+ "Parse::Embeddings::Voyage: dimensions #{dimensions} is not supported by #{model} " \
953
+ "(supported: #{supported.inspect})."
954
+ end
955
+
956
+ # Resolve the target endpoint. An explicit `base_url:` always
957
+ # wins — the endpoint is then inferred from its host so that
958
+ # model validation still applies to a caller who points at Atlas
959
+ # by URL rather than by name.
960
+ def resolve_endpoint!(endpoint, api_key, base_url)
961
+ unless %i[auto voyage atlas].include?(endpoint)
595
962
  raise ArgumentError,
596
- "Parse::Embeddings::Voyage: model #{model.inspect} does not support custom dimensions " \
597
- "(Matryoshka-capable models: #{MATRYOSHKA_MODELS.inspect})."
963
+ "Parse::Embeddings::Voyage: endpoint must be :auto, :voyage, or :atlas " \
964
+ "(got #{endpoint.inspect})."
598
965
  end
966
+
967
+ if base_url
968
+ host = begin
969
+ URI.parse(base_url).host
970
+ rescue URI::InvalidURIError
971
+ nil
972
+ end
973
+ inferred =
974
+ case host
975
+ when URI.parse(ATLAS_BASE_URL).host then :atlas
976
+ when URI.parse(DEFAULT_BASE_URL).host then :voyage
977
+ else :custom
978
+ end
979
+ # A named endpoint that contradicts the URL is a
980
+ # configuration error, not something to silently reconcile.
981
+ if endpoint != :auto && inferred != :custom && inferred != endpoint
982
+ raise ArgumentError,
983
+ "Parse::Embeddings::Voyage: endpoint #{endpoint.inspect} contradicts " \
984
+ "base_url host #{host.inspect}. Pass one or the other."
985
+ end
986
+ return endpoint == :auto ? inferred : endpoint
987
+ end
988
+
989
+ return endpoint unless endpoint == :auto
990
+ api_key.start_with?(ATLAS_KEY_PREFIX) ? :atlas : :voyage
991
+ end
992
+
993
+ def validate_model_for_endpoint!(model, endpoint)
994
+ # A custom base_url may well point at a self-hosted server, so
995
+ # only the two known hosted endpoints are policed.
996
+ if %i[voyage atlas].include?(endpoint) && SELF_HOSTED_ONLY_MODELS.include?(model)
997
+ raise ArgumentError,
998
+ "Parse::Embeddings::Voyage: model #{model.inspect} is open-weight and is not " \
999
+ "served by any hosted endpoint (neither #{DEFAULT_BASE_URL} nor " \
1000
+ "#{ATLAS_BASE_URL}). Self-host it and pass an explicit base_url:, or use " \
1001
+ "Parse::Embeddings::LocalHTTP."
1002
+ end
1003
+
1004
+ return unless endpoint == :atlas
1005
+ return unless ATLAS_UNAVAILABLE_MODELS.include?(model)
1006
+
1007
+ raise ArgumentError,
1008
+ "Parse::Embeddings::Voyage: model #{model.inspect} is not available on the Atlas " \
1009
+ "Embedding and Reranking API (#{ATLAS_BASE_URL}). Atlas-unavailable models: " \
1010
+ "#{ATLAS_UNAVAILABLE_MODELS.inspect}. Use a current model such as \"voyage-3.5\" " \
1011
+ "or \"voyage-4\", or target Voyage's own API with endpoint: :voyage."
599
1012
  end
600
1013
 
601
1014
  def validate_base_url!(base_url, allow_insecure)