parse-stack-next 5.5.6 → 5.7.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.
Files changed (119) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +634 -0
  3. data/README.md +378 -6
  4. data/docs/caching.md +748 -0
  5. data/examples/basic_client.rb +3 -3
  6. data/examples/basic_server.rb +3 -3
  7. data/examples/live_query_listener.rb +12 -12
  8. data/examples/rag_chatbot.rb +14 -14
  9. data/examples/transaction_example.rb +44 -45
  10. data/examples/webhook_server.rb +3 -3
  11. data/lib/parse/access.rb +493 -0
  12. data/lib/parse/acl_scope.rb +125 -52
  13. data/lib/parse/agent/approval_gate.rb +0 -0
  14. data/lib/parse/agent/cancellation_token.rb +3 -3
  15. data/lib/parse/agent/constraint_translator.rb +4 -4
  16. data/lib/parse/agent/describe.rb +34 -34
  17. data/lib/parse/agent/errors.rb +9 -9
  18. data/lib/parse/agent/mcp_client.rb +61 -59
  19. data/lib/parse/agent/mcp_dispatcher.rb +89 -101
  20. data/lib/parse/agent/mcp_rack_app.rb +92 -93
  21. data/lib/parse/agent/mcp_server.rb +1 -1
  22. data/lib/parse/agent/mcp_subscriptions.rb +22 -22
  23. data/lib/parse/agent/metadata_audit.rb +1 -2
  24. data/lib/parse/agent/metadata_dsl.rb +17 -7
  25. data/lib/parse/agent/metadata_registry.rb +12 -12
  26. data/lib/parse/agent/prompt_hardening.rb +4 -4
  27. data/lib/parse/agent/prompts.rb +10 -10
  28. data/lib/parse/agent/result_formatter.rb +4 -4
  29. data/lib/parse/agent/tools.rb +493 -400
  30. data/lib/parse/agent.rb +157 -116
  31. data/lib/parse/api/hooks.rb +1 -1
  32. data/lib/parse/api/server.rb +2 -2
  33. data/lib/parse/api/users.rb +2 -2
  34. data/lib/parse/atlas_search/index_manager.rb +1 -1
  35. data/lib/parse/atlas_search/session.rb +40 -218
  36. data/lib/parse/atlas_search.rb +181 -60
  37. data/lib/parse/authorization.rb +466 -0
  38. data/lib/parse/cache/invalidation.rb +167 -0
  39. data/lib/parse/cache/keyspace.rb +306 -0
  40. data/lib/parse/cache/moneta_surface.rb +126 -0
  41. data/lib/parse/cache/pool.rb +48 -5
  42. data/lib/parse/cache/redis.rb +425 -10
  43. data/lib/parse/cache/scoped_view.rb +443 -0
  44. data/lib/parse/cache/sub_cache.rb +264 -0
  45. data/lib/parse/cache/upstream_roles.rb +230 -0
  46. data/lib/parse/client/authentication.rb +1 -1
  47. data/lib/parse/client/body_builder.rb +1 -2
  48. data/lib/parse/client/caching.rb +84 -5
  49. data/lib/parse/client.rb +327 -189
  50. data/lib/parse/clp_scope.rb +225 -28
  51. data/lib/parse/console.rb +3 -3
  52. data/lib/parse/embeddings/batch_embedder.rb +3 -3
  53. data/lib/parse/embeddings/binding_audit.rb +211 -0
  54. data/lib/parse/embeddings/cache.rb +17 -17
  55. data/lib/parse/embeddings/cohere.rb +21 -22
  56. data/lib/parse/embeddings/image_fetch.rb +22 -21
  57. data/lib/parse/embeddings/jina.rb +21 -21
  58. data/lib/parse/embeddings/local_http.rb +6 -7
  59. data/lib/parse/embeddings/media_file.rb +137 -0
  60. data/lib/parse/embeddings/openai.rb +5 -5
  61. data/lib/parse/embeddings/provider.rb +40 -1
  62. data/lib/parse/embeddings/qwen.rb +10 -10
  63. data/lib/parse/embeddings/spend_cap.rb +2 -3
  64. data/lib/parse/embeddings/streaming_body.rb +170 -0
  65. data/lib/parse/embeddings/video_source.rb +121 -0
  66. data/lib/parse/embeddings/voyage.rb +554 -143
  67. data/lib/parse/embeddings.rb +83 -27
  68. data/lib/parse/graphql/type_generator.rb +1 -1
  69. data/lib/parse/graphql.rb +5 -5
  70. data/lib/parse/live_query/client.rb +1 -1
  71. data/lib/parse/live_query.rb +1 -1
  72. data/lib/parse/lock.rb +15 -18
  73. data/lib/parse/lock_backend.rb +1 -1
  74. data/lib/parse/lookup_rewriter.rb +6 -6
  75. data/lib/parse/model/classes/role.rb +227 -28
  76. data/lib/parse/model/classes/user.rb +60 -1
  77. data/lib/parse/model/clp.rb +4 -4
  78. data/lib/parse/model/core/actions.rb +97 -15
  79. data/lib/parse/model/core/create_lock.rb +0 -2
  80. data/lib/parse/model/core/describe.rb +57 -57
  81. data/lib/parse/model/core/embed_managed.rb +43 -19
  82. data/lib/parse/model/core/fetching.rb +2 -0
  83. data/lib/parse/model/core/indexing.rb +14 -14
  84. data/lib/parse/model/core/parse_reference.rb +1 -1
  85. data/lib/parse/model/core/properties.rb +39 -3
  86. data/lib/parse/model/core/querying.rb +1 -1
  87. data/lib/parse/model/core/schema.rb +2 -2
  88. data/lib/parse/model/core/search_indexing.rb +2 -2
  89. data/lib/parse/model/core/vector_searchable.rb +34 -8
  90. data/lib/parse/model/file.rb +21 -15
  91. data/lib/parse/model/geojson.rb +2 -2
  92. data/lib/parse/model/geopoint.rb +1 -0
  93. data/lib/parse/model/object.rb +132 -66
  94. data/lib/parse/model/pointer.rb +2 -0
  95. data/lib/parse/model/polygon.rb +3 -6
  96. data/lib/parse/model/push.rb +2 -2
  97. data/lib/parse/model/vector.rb +3 -1
  98. data/lib/parse/mongodb.rb +512 -282
  99. data/lib/parse/pipeline_security.rb +3 -0
  100. data/lib/parse/query/constraints.rb +46 -46
  101. data/lib/parse/query.rb +146 -59
  102. data/lib/parse/retrieval/agent_tool.rb +34 -20
  103. data/lib/parse/retrieval/chunk.rb +1 -0
  104. data/lib/parse/retrieval/reranker/cohere.rb +11 -11
  105. data/lib/parse/retrieval/reranker.rb +3 -4
  106. data/lib/parse/retrieval/retriever.rb +4 -6
  107. data/lib/parse/schema/index_migrator.rb +26 -26
  108. data/lib/parse/schema/search_index_migrator.rb +19 -19
  109. data/lib/parse/stack/tasks.rb +6 -6
  110. data/lib/parse/stack/version.rb +1 -1
  111. data/lib/parse/stack.rb +10 -10
  112. data/lib/parse/vector_search/hybrid.rb +166 -33
  113. data/lib/parse/vector_search.rb +141 -13
  114. data/lib/parse/webhooks/payload.rb +1 -0
  115. data/lib/parse/webhooks/registration.rb +4 -4
  116. data/lib/parse/webhooks/trigger_audit.rb +50 -49
  117. data/lib/parse/webhooks.rb +22 -4
  118. data/parse-stack-next.gemspec +6 -6
  119. metadata +14 -1
@@ -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"`
@@ -68,74 +109,165 @@ module Parse
68
109
  class RateLimitError < Error; end
69
110
  class TransientError < Error; end
70
111
 
71
- DEFAULT_BASE_URL = "https://api.voyageai.com/v1"
72
- DEFAULT_MODEL = "voyage-3"
73
- DEFAULT_TIMEOUT = 30
112
+ DEFAULT_BASE_URL = "https://api.voyageai.com/v1"
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"
123
+ DEFAULT_TIMEOUT = 30
74
124
  DEFAULT_OPEN_TIMEOUT = 5
75
125
  DEFAULT_MAX_RETRIES = 3
76
126
  # Voyage's documented per-request cap is 128 inputs.
77
- DEFAULT_BATCH_SIZE = 128
78
- MAX_RESPONSE_BYTES = 16 * 1024 * 1024
127
+ DEFAULT_BATCH_SIZE = 128
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,
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,
95
154
  "voyage-multimodal-3" => 1024,
155
+ "voyage-multimodal-3.5" => 1024,
156
+ }.freeze
157
+
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],
96
183
  }.freeze
97
184
 
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
+
98
191
  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,
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,
109
205
  "voyage-multimodal-3" => 32_000,
206
+ "voyage-multimodal-3.5" => 32_000,
110
207
  }.freeze
111
208
 
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
116
-
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
126
251
  # Voyage only distinguishes retrieval halves — other intents
127
252
  # should receive the unconditioned vector.
128
253
  INPUT_TYPE_WIRE_VALUES = {
129
- search_query: "query",
254
+ search_query: "query",
130
255
  search_document: "document",
131
- classification: nil,
132
- clustering: nil,
256
+ classification: nil,
257
+ clustering: nil,
133
258
  }.freeze
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
@@ -251,8 +400,7 @@ module Parse
251
400
  # different request envelope. The response envelope shape is
252
401
  # the same (`{ data: [{ embedding, index }], usage: {...} }`)
253
402
  # so `extract_vectors!` is reused as-is.
254
- body =
255
- if MULTIMODAL_MODELS.include?(@model)
403
+ body = if MULTIMODAL_MODELS.include?(@model)
256
404
  build_multimodal_body(strings, wire_input_type)
257
405
  else
258
406
  build_text_body(strings, wire_input_type)
@@ -272,10 +420,13 @@ module Parse
272
420
  end
273
421
  end
274
422
 
275
- # @return [Array<Symbol>] Voyage's multimodal models support
276
- # `[:text, :image]`; text-only models report `[:text]`.
423
+ # @return [Array<Symbol>] `[:text, :image, :video]` for
424
+ # `voyage-multimodal-3.5`, `[:text, :image]` for
425
+ # `voyage-multimodal-3`, and `[:text]` for text-only models.
426
+ # Audio is not offered by any Voyage model.
277
427
  def modalities
278
- MULTIMODAL_MODELS.include?(@model) ? %i[text image] : [:text]
428
+ return [:text] unless MULTIMODAL_MODELS.include?(@model)
429
+ VIDEO_MODELS.include?(@model) ? %i[text image video] : %i[text image]
279
430
  end
280
431
 
281
432
  # Embed a batch of images through Voyage's
@@ -315,76 +466,42 @@ module Parse
315
466
  # validator; permit `http://` for local-dev CDN proxies.
316
467
  # @return [Array<Array<Float>>] vectors aligned 1:1 with `sources`.
317
468
  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
469
+ embed_media(sources, kind: :image, input_type: input_type,
470
+ allow_insecure: allow_insecure)
471
+ end
374
472
 
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
473
+ # Embed a batch of videos through
474
+ # `/v1/multimodalembeddings`. Mirrors {#embed_image}'s source
475
+ # forms and security posture exactly:
476
+ #
477
+ # * **String URL** forwarded as a `{ type: "video_url",
478
+ # video_url: … }` content row after
479
+ # {Parse::Embeddings.validate_image_url!} canonicalizes and
480
+ # screens it. The provider issues the fetch, so the
481
+ # `trust_provider_url_fetch` sentinel IS required and the SDK
482
+ # never downloads the video.
483
+ # * **{Parse::Embeddings::MediaFile}** — a local file, streamed
484
+ # into the request body as a `{ type: "video_base64",
485
+ # video_base64: "data:…" }` row without ever being held in
486
+ # memory. No URL validation and no sentinel, because nothing is
487
+ # fetched.
488
+ #
489
+ # **Video-capable model required.** Only {VIDEO_MODELS} accept
490
+ # video; `voyage-multimodal-3` rejects it server-side, so this
491
+ # raises {BadRequestError} before any network call.
492
+ #
493
+ # @param sources [Array<String, Parse::Embeddings::MediaFile>]
494
+ # video URLs and/or file-backed wrappers (forms may be mixed).
495
+ # @param input_type [Symbol] one of {INPUT_TYPE_WIRE_VALUES}'s keys.
496
+ # @param allow_insecure [Boolean] forwarded to the URL validator.
497
+ # @return [Array<Array<Float>>] vectors aligned 1:1 with `sources`.
498
+ def embed_video(sources, input_type: :search_document, allow_insecure: false)
499
+ embed_media(sources, kind: :video, input_type: input_type,
500
+ allow_insecure: allow_insecure)
384
501
  end
385
502
 
386
503
  def inspect_attrs
387
- super.merge(base: safe_base_host, retries: @max_retries)
504
+ super.merge(base: safe_base_host, endpoint: @endpoint, retries: @max_retries)
388
505
  end
389
506
 
390
507
  protected
@@ -421,14 +538,7 @@ module Parse
421
538
  # treats absent and `null` identically (unconditioned head),
422
539
  # but absent is the spec-correct form for non-retrieval intent.
423
540
  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
541
+ apply_output_dimension!(body)
432
542
  body
433
543
  end
434
544
 
@@ -447,16 +557,259 @@ module Parse
447
557
  # forward it for parity with the text path so callers get the
448
558
  # same fail-on-overlength behavior across models.
449
559
  body[:truncation] = @truncation
560
+ apply_output_dimension!(body)
450
561
  body
451
562
  end
452
563
 
564
+ # Forward `output_dimension` only when the configured width
565
+ # differs from the model's native default. Sending it to a model
566
+ # with a single supported width is a 400, and sending the native
567
+ # width needlessly is redundant — so both are omitted.
568
+ #
569
+ # The constructor has already rejected any width outside
570
+ # {MODEL_SUPPORTED_DIMENSIONS}, so no re-validation is needed.
571
+ def apply_output_dimension!(body)
572
+ return body if @dimensions == MODEL_DEFAULT_DIMENSIONS.fetch(@model)
573
+ body[:output_dimension] = @dimensions
574
+ body
575
+ end
576
+
577
+ # Everything modality-specific about a non-text input, in one
578
+ # table. `models` names the constant gating which models accept
579
+ # the modality; `url` / `base64` are the wire content-type keys.
580
+ #
581
+ # Adding a modality (audio, when Voyage ships it) is a row here
582
+ # plus a {Parse::Embeddings::MediaFile} constructor and a
583
+ # one-line `embed_audio` delegating to {#embed_media} — the
584
+ # streaming body, row builder, batching, URL validation, and
585
+ # instrumentation are all modality-agnostic already.
586
+ MEDIA_MODALITIES = {
587
+ image: { url: "image_url", base64: "image_base64",
588
+ models: :MULTIMODAL_MODELS, noun: "image" },
589
+ video: { url: "video_url", base64: "video_base64",
590
+ models: :VIDEO_MODELS, noun: "video" },
591
+ }.freeze
592
+
593
+ # Shared implementation behind {#embed_image} and {#embed_video}.
594
+ #
595
+ # @param kind [Symbol] a {MEDIA_MODALITIES} key.
596
+ # @return [Array<Array<Float>>] vectors aligned 1:1 with `sources`.
597
+ def embed_media(sources, kind:, input_type:, allow_insecure:)
598
+ spec = MEDIA_MODALITIES.fetch(kind)
599
+ capable = self.class.const_get(spec[:models])
600
+ caller_name = "embed_#{kind}"
601
+
602
+ unless capable.include?(@model)
603
+ raise BadRequestError,
604
+ "Parse::Embeddings::Voyage##{caller_name}: model #{@model.inspect} does not " \
605
+ "accept #{spec[:noun]} inputs. Configure the provider with a capable model " \
606
+ "(supported: #{capable.inspect})."
607
+ end
608
+ unless sources.is_a?(Array)
609
+ raise ArgumentError,
610
+ "Parse::Embeddings::Voyage##{caller_name} expects Array of #{spec[:noun]} " \
611
+ "URLs (got #{sources.class})."
612
+ end
613
+ return [] if sources.empty?
614
+
615
+ unless INPUT_TYPE_WIRE_VALUES.key?(input_type)
616
+ raise ArgumentError,
617
+ "Parse::Embeddings::Voyage##{caller_name} input_type #{input_type.inspect} " \
618
+ "not in #{INPUT_TYPE_WIRE_VALUES.keys.inspect}."
619
+ end
620
+ # Voyage caps multimodal requests at the same per-request size
621
+ # as the text endpoint. The text path chunks automatically; the
622
+ # media path has no chunker (every directive is a single
623
+ # source), so guard the direct-API caller against a silent 400.
624
+ if sources.length > @embed_batch_size
625
+ raise ArgumentError,
626
+ "Parse::Embeddings::Voyage##{caller_name}: batch size #{sources.length} " \
627
+ "exceeds the configured cap #{@embed_batch_size} (Voyage per-request max: " \
628
+ "128). Split the input and call #{caller_name} once per chunk."
629
+ end
630
+
631
+ rows = build_media_rows(
632
+ sources, kind: kind, allow_insecure: allow_insecure, caller_name: caller_name,
633
+ )
634
+ wire_input_type = INPUT_TYPE_WIRE_VALUES[input_type]
635
+
636
+ # Voyage requires a single representation per request: "each
637
+ # request should use either image_base64/video_base64 or
638
+ # image_url/video_url exclusively, not both." A mixed batch is
639
+ # therefore split into one request per representation and
640
+ # reassembled in the caller's original order, so the 1:1
641
+ # alignment this method promises still holds.
642
+ groups = rows.each_with_index.group_by { |row, _i| row_representation(row) }
643
+ return dispatch_media(rows, input_type, wire_input_type, kind) if groups.size == 1
644
+
645
+ results = Array.new(rows.length)
646
+ groups.each_value do |pairs|
647
+ subset = pairs.map(&:first)
648
+ vectors = dispatch_media(subset, input_type, wire_input_type, kind)
649
+ pairs.each_with_index { |(_row, original_index), n| results[original_index] = vectors[n] }
650
+ end
651
+ results
652
+ end
653
+
654
+ # Issue one multimodal request for a set of same-representation
655
+ # rows and return the vectors in row order.
656
+ def dispatch_media(rows, input_type, wire_input_type, kind)
657
+ body = build_request_body(rows, wire_input_type)
658
+
659
+ instrument_embed(rows.length, input_type, modality: kind) do |emit_payload|
660
+ payload = post_embeddings(body, path: "multimodalembeddings")
661
+ if payload.is_a?(Hash) && payload["usage"].is_a?(Hash)
662
+ tt = payload["usage"]["total_tokens"]
663
+ emit_payload[:total_tokens] = tt if tt.is_a?(Integer) && tt >= 0
664
+ end
665
+ vectors = extract_vectors!(payload, rows.length)
666
+ validate_response!(rows.length, vectors)
667
+ end
668
+ end
669
+
670
+ # Build `inputs[].content[]` row descriptors for a batch of media
671
+ # sources, accepting URL Strings, in-memory
672
+ # {ImageFetch::FetchedImage} wrappers, and file-backed
673
+ # {MediaFile} wrappers.
674
+ #
675
+ # Every URL is validated up-front so a malformed entry in slot N
676
+ # cannot get past validation while slots 0..N-1 are already in
677
+ # the wire body — no partial forwarding.
678
+ #
679
+ # Returns descriptors rather than finished Hashes so
680
+ # {#build_request_body} can assemble the JSON structurally. A
681
+ # streamed row is `{ stream: MediaFile, key: String }`; every
682
+ # other row is a ready-to-serialize Hash.
683
+ #
684
+ # @return [Array<Hash>] one descriptor per source, in order.
685
+ def build_media_rows(sources, kind:, allow_insecure:, caller_name:)
686
+ spec = MEDIA_MODALITIES.fetch(kind)
687
+ url_key = spec[:url]
688
+ b64_key = spec[:base64]
689
+
690
+ sources.each_with_index.map do |src, i|
691
+ case src
692
+ when Parse::Embeddings::MediaFile
693
+ unless src.kind == kind
694
+ raise ArgumentError,
695
+ "Parse::Embeddings::Voyage##{caller_name} sources[#{i}] is a " \
696
+ "#{src.kind} MediaFile; expected #{kind}."
697
+ end
698
+ enforce_media_size!(src.byte_size, i, caller_name, src.path)
699
+ { stream: src, key: b64_key }
700
+ when Parse::Embeddings::ImageFetch::FetchedImage
701
+ # The only in-memory wrapper the SDK ships is for images.
702
+ unless kind == :image
703
+ raise ArgumentError,
704
+ "Parse::Embeddings::Voyage##{caller_name} sources[#{i}] is a FetchedImage; " \
705
+ "wrap #{spec[:noun]} sources with Parse::Embeddings::MediaFile.#{kind}."
706
+ end
707
+ enforce_media_size!(src.bytes.bytesize, i, caller_name)
708
+ { content: [{ type: b64_key, b64_key => src.to_data_uri }] }
709
+ when String
710
+ canonical = Parse::Embeddings.validate_image_url!(src, allow_insecure: allow_insecure)
711
+ { content: [{ type: url_key, url_key => canonical }] }
712
+ else
713
+ raise ArgumentError,
714
+ "Parse::Embeddings::Voyage##{caller_name} sources[#{i}] must be a URL String " \
715
+ "or a Parse::Embeddings::MediaFile (got #{src.class})."
716
+ end
717
+ end
718
+ end
719
+
720
+ # Refuse a payload Voyage will reject anyway. Enforced here
721
+ # rather than relying on {Parse::Embeddings.max_media_bytes},
722
+ # which callers may legitimately raise for other providers.
723
+ def enforce_media_size!(bytes, index, caller_name, path = nil)
724
+ return if bytes <= MAX_MEDIA_BYTES
725
+
726
+ where = path ? " (#{path})" : ""
727
+ raise BadRequestError,
728
+ "Parse::Embeddings::Voyage##{caller_name} sources[#{index}]#{where} is " \
729
+ "#{bytes} bytes, over Voyage's #{MAX_MEDIA_BYTES}-byte per-file limit. " \
730
+ "Downscale or re-encode before embedding."
731
+ end
732
+
733
+ # Which wire representation a row descriptor uses. Voyage
734
+ # requires a single representation per request, so this is what
735
+ # {#embed_media} partitions on.
736
+ def row_representation(row)
737
+ return :base64 if row[:stream]
738
+ type = row.dig(:content, 0, :type).to_s
739
+ type.end_with?("_base64") ? :base64 : :url
740
+ end
741
+
742
+ # Assemble the request body for a set of row descriptors.
743
+ #
744
+ # With no streamed rows this returns a plain Hash, serialized
745
+ # later by {#post_embeddings}. With them it returns a
746
+ # {StreamingBody} whose JSON is built **structurally** — each
747
+ # fragment is serialized independently and concatenated in
748
+ # order, so the file payloads are spliced by position rather than
749
+ # by searching the serialized document.
750
+ #
751
+ # Building it structurally is a correctness requirement, not a
752
+ # style preference: an earlier version emitted a sentinel token
753
+ # and located it with `String#split`, which let a caller-supplied
754
+ # URL containing that token capture a local file's bytes and ship
755
+ # them to the provider as a URL to fetch.
756
+ def build_request_body(rows, wire_input_type)
757
+ trailer = { model: @model, truncation: @truncation }
758
+ trailer[:input_type] = wire_input_type if wire_input_type
759
+ apply_output_dimension!(trailer)
760
+
761
+ return { inputs: rows }.merge(trailer) unless rows.any? { |r| r[:stream] }
762
+
763
+ segments = [+'{"inputs":[']
764
+ rows.each_with_index do |row, i|
765
+ segments << "," if i.positive?
766
+ if (media = row[:stream])
767
+ key = row[:key]
768
+ # Open the JSON string, emit the data: prefix, stream the
769
+ # payload, then close it. Base64 needs no escaping, and the
770
+ # prefix is escaped by #to_json before its closing quote is
771
+ # trimmed.
772
+ segments << %({"content":[{"type":#{key.to_json},#{key.to_json}:)
773
+ segments << json_string_prefix(media.data_uri_prefix)
774
+ segments << media.stream_segment
775
+ segments << %("}]})
776
+ else
777
+ segments << row.to_json
778
+ end
779
+ end
780
+ segments << "]"
781
+ trailer.each { |k, v| segments << ",#{k.to_s.to_json}:#{v.to_json}" }
782
+ segments << "}"
783
+
784
+ Parse::Embeddings::StreamingBody.new(segments)
785
+ end
786
+
787
+ # A JSON string literal with its opening quote and escaped
788
+ # contents but no closing quote, so a streamed payload can be
789
+ # appended before the string is closed.
790
+ def json_string_prefix(str)
791
+ encoded = str.to_json
792
+ encoded[0...-1]
793
+ end
794
+
453
795
  def post_embeddings(body, path: "embeddings")
454
796
  attempts = 0
455
797
  loop do
456
798
  attempts += 1
457
799
  begin
458
800
  response = @connection.post(path) do |req|
459
- req.body = body.to_json
801
+ if body.is_a?(Parse::Embeddings::StreamingBody)
802
+ # Faraday's net_http adapter routes an IO-shaped body
803
+ # to Net::HTTP#body_stream. Rewind so a retry replays
804
+ # from the start, and set Content-Length explicitly —
805
+ # without it Net::HTTP falls back to chunked transfer
806
+ # encoding, which some API gateways reject.
807
+ body.rewind
808
+ req.headers["Content-Length"] = body.size.to_s
809
+ req.body = body
810
+ else
811
+ req.body = body.to_json
812
+ end
460
813
  end
461
814
  rescue Faraday::TimeoutError, Faraday::ConnectionFailed => e
462
815
  if attempts > @max_retries
@@ -553,7 +906,7 @@ module Parse
553
906
  end
554
907
 
555
908
  def backoff_seconds(attempt)
556
- [0.5 * (2**(attempt - 1)), 30.0].min
909
+ [0.5 * (2 ** (attempt - 1)), 30.0].min
557
910
  end
558
911
 
559
912
  def retry_after_seconds(response)
@@ -586,16 +939,74 @@ module Parse
586
939
  raise ArgumentError,
587
940
  "Parse::Embeddings::Voyage: dimensions must be a positive Integer (got #{dimensions.inspect})."
588
941
  end
589
- native = MODEL_DEFAULT_DIMENSIONS.fetch(model)
590
- if dimensions > native
942
+ supported = MODEL_SUPPORTED_DIMENSIONS.fetch(model)
943
+ return if supported.include?(dimensions)
944
+
945
+ if supported.length == 1
591
946
  raise ArgumentError,
592
- "Parse::Embeddings::Voyage: dimensions #{dimensions} exceeds native #{native} for #{model}."
947
+ "Parse::Embeddings::Voyage: model #{model.inspect} does not support custom dimensions " \
948
+ "(only #{supported.first} is available)."
593
949
  end
594
- if !MATRYOSHKA_MODELS.include?(model) && dimensions != native
950
+ raise ArgumentError,
951
+ "Parse::Embeddings::Voyage: dimensions #{dimensions} is not supported by #{model} " \
952
+ "(supported: #{supported.inspect})."
953
+ end
954
+
955
+ # Resolve the target endpoint. An explicit `base_url:` always
956
+ # wins — the endpoint is then inferred from its host so that
957
+ # model validation still applies to a caller who points at Atlas
958
+ # by URL rather than by name.
959
+ def resolve_endpoint!(endpoint, api_key, base_url)
960
+ unless %i[auto voyage atlas].include?(endpoint)
595
961
  raise ArgumentError,
596
- "Parse::Embeddings::Voyage: model #{model.inspect} does not support custom dimensions " \
597
- "(Matryoshka-capable models: #{MATRYOSHKA_MODELS.inspect})."
962
+ "Parse::Embeddings::Voyage: endpoint must be :auto, :voyage, or :atlas " \
963
+ "(got #{endpoint.inspect})."
598
964
  end
965
+
966
+ if base_url
967
+ host = begin
968
+ URI.parse(base_url).host
969
+ rescue URI::InvalidURIError
970
+ nil
971
+ end
972
+ inferred = case host
973
+ when URI.parse(ATLAS_BASE_URL).host then :atlas
974
+ when URI.parse(DEFAULT_BASE_URL).host then :voyage
975
+ else :custom
976
+ end
977
+ # A named endpoint that contradicts the URL is a
978
+ # configuration error, not something to silently reconcile.
979
+ if endpoint != :auto && inferred != :custom && inferred != endpoint
980
+ raise ArgumentError,
981
+ "Parse::Embeddings::Voyage: endpoint #{endpoint.inspect} contradicts " \
982
+ "base_url host #{host.inspect}. Pass one or the other."
983
+ end
984
+ return endpoint == :auto ? inferred : endpoint
985
+ end
986
+
987
+ return endpoint unless endpoint == :auto
988
+ api_key.start_with?(ATLAS_KEY_PREFIX) ? :atlas : :voyage
989
+ end
990
+
991
+ def validate_model_for_endpoint!(model, endpoint)
992
+ # A custom base_url may well point at a self-hosted server, so
993
+ # only the two known hosted endpoints are policed.
994
+ if %i[voyage atlas].include?(endpoint) && SELF_HOSTED_ONLY_MODELS.include?(model)
995
+ raise ArgumentError,
996
+ "Parse::Embeddings::Voyage: model #{model.inspect} is open-weight and is not " \
997
+ "served by any hosted endpoint (neither #{DEFAULT_BASE_URL} nor " \
998
+ "#{ATLAS_BASE_URL}). Self-host it and pass an explicit base_url:, or use " \
999
+ "Parse::Embeddings::LocalHTTP."
1000
+ end
1001
+
1002
+ return unless endpoint == :atlas
1003
+ return unless ATLAS_UNAVAILABLE_MODELS.include?(model)
1004
+
1005
+ raise ArgumentError,
1006
+ "Parse::Embeddings::Voyage: model #{model.inspect} is not available on the Atlas " \
1007
+ "Embedding and Reranking API (#{ATLAS_BASE_URL}). Atlas-unavailable models: " \
1008
+ "#{ATLAS_UNAVAILABLE_MODELS.inspect}. Use a current model such as \"voyage-3.5\" " \
1009
+ "or \"voyage-4\", or target Voyage's own API with endpoint: :voyage."
599
1010
  end
600
1011
 
601
1012
  def validate_base_url!(base_url, allow_insecure)