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.
- checksums.yaml +4 -4
- data/CHANGELOG.md +225 -0
- data/lib/parse/agent/metadata_dsl.rb +10 -0
- data/lib/parse/embeddings/binding_audit.rb +211 -0
- data/lib/parse/embeddings/media_file.rb +136 -0
- data/lib/parse/embeddings/provider.rb +39 -0
- data/lib/parse/embeddings/streaming_body.rb +170 -0
- data/lib/parse/embeddings/video_source.rb +120 -0
- data/lib/parse/embeddings/voyage.rb +548 -135
- data/lib/parse/embeddings.rb +55 -0
- data/lib/parse/model/core/embed_managed.rb +26 -0
- data/lib/parse/model/core/properties.rb +37 -1
- data/lib/parse/model/core/vector_searchable.rb +30 -3
- data/lib/parse/stack/version.rb +1 -1
- data/lib/parse/vector_search/hybrid.rb +120 -12
- data/lib/parse/vector_search.rb +132 -7
- metadata +5 -1
|
@@ -15,29 +15,70 @@ module Parse
|
|
|
15
15
|
#
|
|
16
16
|
# Supported models:
|
|
17
17
|
#
|
|
18
|
-
# * **v4 family** — `voyage-4-large`
|
|
19
|
-
# `voyage-4
|
|
20
|
-
#
|
|
21
|
-
#
|
|
22
|
-
# * **v3 family** — `voyage-3-large`, `voyage-3`,
|
|
23
|
-
# `voyage-
|
|
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` (
|
|
26
|
-
# text+image
|
|
27
|
-
#
|
|
28
|
-
#
|
|
29
|
-
#
|
|
30
|
-
#
|
|
31
|
-
#
|
|
32
|
-
#
|
|
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
|
-
|
|
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
|
-
#
|
|
81
|
-
#
|
|
82
|
-
#
|
|
83
|
-
#
|
|
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"
|
|
86
|
-
"voyage-4"
|
|
87
|
-
"voyage-4-lite"
|
|
88
|
-
"voyage-4-nano"
|
|
89
|
-
"voyage-3-large"
|
|
90
|
-
"voyage-3" => 1024,
|
|
91
|
-
"voyage-3-lite" =>
|
|
92
|
-
"voyage-
|
|
93
|
-
"voyage-
|
|
94
|
-
"voyage-
|
|
95
|
-
"voyage-
|
|
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
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
"voyage-
|
|
109
|
-
"voyage-
|
|
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
|
-
#
|
|
113
|
-
#
|
|
114
|
-
#
|
|
115
|
-
MATRYOSHKA_MODELS =
|
|
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
|
|
138
|
-
# `
|
|
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
|
-
|
|
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>]
|
|
276
|
-
# `[:text, :image]
|
|
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)
|
|
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
|
-
|
|
319
|
-
|
|
320
|
-
|
|
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
|
-
|
|
376
|
-
|
|
377
|
-
|
|
378
|
-
|
|
379
|
-
|
|
380
|
-
|
|
381
|
-
|
|
382
|
-
|
|
383
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
590
|
-
if dimensions
|
|
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:
|
|
948
|
+
"Parse::Embeddings::Voyage: model #{model.inspect} does not support custom dimensions " \
|
|
949
|
+
"(only #{supported.first} is available)."
|
|
593
950
|
end
|
|
594
|
-
|
|
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:
|
|
597
|
-
"(
|
|
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)
|