parse-stack-next 4.5.0 → 5.0.1

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 (108) hide show
  1. checksums.yaml +4 -4
  2. data/.bundle/config +2 -0
  3. data/.env.sample +17 -3
  4. data/.github/workflows/codeql.yml +44 -0
  5. data/.github/workflows/docs.yml +39 -0
  6. data/.github/workflows/release.yml +32 -0
  7. data/.github/workflows/ruby.yml +8 -6
  8. data/.gitignore +4 -0
  9. data/.vscode/settings.json +3 -0
  10. data/CHANGELOG.md +305 -72
  11. data/Gemfile.lock +10 -3
  12. data/LICENSE.txt +1 -1
  13. data/README.md +190 -219
  14. data/Rakefile +1 -1
  15. data/SECURITY.md +30 -0
  16. data/assets/parse-stack-next-avatar.png +0 -0
  17. data/assets/parse-stack-next-avatar.svg +37 -0
  18. data/assets/parse-stack-next-banner.png +0 -0
  19. data/assets/parse-stack-next-banner.svg +45 -0
  20. data/assets/parse-stack-next-social-preview.png +0 -0
  21. data/docs/atlas_vector_search_guide.md +511 -0
  22. data/docs/client_sdk_guide.md +1320 -0
  23. data/docs/mcp_guide.md +225 -104
  24. data/docs/mongodb_direct_guide.md +21 -4
  25. data/docs/usage_guide.md +585 -0
  26. data/examples/transaction_example.rb +28 -28
  27. data/lib/parse/acl_scope.rb +2 -2
  28. data/lib/parse/agent/mcp_rack_app.rb +184 -16
  29. data/lib/parse/agent/metadata_dsl.rb +16 -16
  30. data/lib/parse/agent/pipeline_validator.rb +28 -1
  31. data/lib/parse/agent/prompts.rb +5 -5
  32. data/lib/parse/agent/tools.rb +287 -14
  33. data/lib/parse/agent.rb +209 -12
  34. data/lib/parse/api/analytics.rb +27 -5
  35. data/lib/parse/api/files.rb +6 -2
  36. data/lib/parse/api/push.rb +21 -4
  37. data/lib/parse/api/server.rb +59 -0
  38. data/lib/parse/api/users.rb +26 -2
  39. data/lib/parse/atlas_search/index_manager.rb +84 -0
  40. data/lib/parse/atlas_search.rb +37 -9
  41. data/lib/parse/cache/pool.rb +88 -0
  42. data/lib/parse/cache/redis.rb +249 -0
  43. data/lib/parse/client/body_builder.rb +94 -0
  44. data/lib/parse/client/caching.rb +109 -9
  45. data/lib/parse/client/response.rb +27 -0
  46. data/lib/parse/client.rb +74 -3
  47. data/lib/parse/console.rb +203 -0
  48. data/lib/parse/embeddings/cohere.rb +484 -0
  49. data/lib/parse/embeddings/fixture.rb +130 -0
  50. data/lib/parse/embeddings/jina.rb +454 -0
  51. data/lib/parse/embeddings/local_http.rb +492 -0
  52. data/lib/parse/embeddings/openai.rb +520 -0
  53. data/lib/parse/embeddings/provider.rb +264 -0
  54. data/lib/parse/embeddings/qwen.rb +431 -0
  55. data/lib/parse/embeddings/voyage.rb +550 -0
  56. data/lib/parse/embeddings.rb +225 -0
  57. data/lib/parse/graphql/scalars.rb +53 -0
  58. data/lib/parse/graphql/type_generator.rb +264 -0
  59. data/lib/parse/graphql.rb +48 -0
  60. data/lib/parse/live_query/client.rb +24 -5
  61. data/lib/parse/live_query/subscription.rb +17 -6
  62. data/lib/parse/live_query.rb +9 -4
  63. data/lib/parse/model/associations/collection_proxy.rb +2 -2
  64. data/lib/parse/model/associations/has_many.rb +32 -1
  65. data/lib/parse/model/associations/has_one.rb +17 -0
  66. data/lib/parse/model/associations/pointer_collection_proxy.rb +3 -3
  67. data/lib/parse/model/classes/user.rb +307 -11
  68. data/lib/parse/model/clp.rb +1 -1
  69. data/lib/parse/model/core/create_lock.rb +14 -2
  70. data/lib/parse/model/core/embed_managed.rb +296 -0
  71. data/lib/parse/model/core/fetching.rb +4 -4
  72. data/lib/parse/model/core/indexing.rb +53 -14
  73. data/lib/parse/model/core/parse_reference.rb +3 -3
  74. data/lib/parse/model/core/properties.rb +70 -1
  75. data/lib/parse/model/core/querying.rb +57 -1
  76. data/lib/parse/model/core/vector_searchable.rb +285 -0
  77. data/lib/parse/model/file.rb +16 -4
  78. data/lib/parse/model/model.rb +26 -10
  79. data/lib/parse/model/object.rb +63 -6
  80. data/lib/parse/model/pointer.rb +16 -2
  81. data/lib/parse/model/shortnames.rb +2 -0
  82. data/lib/parse/model/validations/uniqueness_validator.rb +3 -3
  83. data/lib/parse/model/vector.rb +102 -0
  84. data/lib/parse/mongodb.rb +90 -8
  85. data/lib/parse/pipeline_security.rb +59 -2
  86. data/lib/parse/query/constraints.rb +16 -14
  87. data/lib/parse/query/ordering.rb +1 -1
  88. data/lib/parse/query.rb +137 -64
  89. data/lib/parse/stack/generators/templates/model.erb +2 -2
  90. data/lib/parse/stack/generators/templates/model_installation.rb +1 -1
  91. data/lib/parse/stack/generators/templates/model_role.rb +1 -1
  92. data/lib/parse/stack/generators/templates/model_session.rb +1 -1
  93. data/lib/parse/stack/generators/templates/parse.rb +1 -1
  94. data/lib/parse/stack/generators/templates/webhooks.rb +1 -1
  95. data/lib/parse/stack/version.rb +1 -1
  96. data/lib/parse/stack.rb +375 -73
  97. data/lib/parse/two_factor_auth/user_extension.rb +5 -2
  98. data/lib/parse/vector_search.rb +341 -0
  99. data/parse-stack-next.gemspec +10 -9
  100. data/scripts/docker/docker-compose.test.yml +18 -0
  101. data/scripts/start-parse.sh +6 -0
  102. data/scripts/vector_prototype/create_vector_index.js +105 -0
  103. data/scripts/vector_prototype/fetch_embeddings.py +241 -0
  104. data/scripts/vector_prototype/fixture_manifest.json +9 -0
  105. data/scripts/vector_prototype/query_prototype.rb +84 -0
  106. data/scripts/vector_prototype/run.sh +34 -0
  107. metadata +77 -5
  108. data/parse-stack.png +0 -0
@@ -0,0 +1,550 @@
1
+ # encoding: UTF-8
2
+ # frozen_string_literal: true
3
+
4
+ require "faraday"
5
+ require "json"
6
+ require "uri"
7
+ require_relative "provider"
8
+
9
+ module Parse
10
+ module Embeddings
11
+ # Voyage AI embeddings provider. Wraps `POST /v1/embeddings` for
12
+ # text-only models and `POST /v1/multimodalembeddings` for the
13
+ # multimodal text+image models (text-input path only in v5.0; the
14
+ # image-input path lands with {Provider#embed_image} in v5.1).
15
+ #
16
+ # Supported models:
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`.
24
+ # * **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. This provider
27
+ # exposes the text-input path only: routes to
28
+ # `/v1/multimodalembeddings` with a `{ inputs: [{ content:
29
+ # [{ type: "text", text: … }] }] }` envelope. The same model will
30
+ # accept image inputs in v5.1 when the `embed_image` hook ships;
31
+ # text vectors stored today will sit in the same space as the
32
+ # eventual image vectors (no re-embed required).
33
+ #
34
+ # @example registration
35
+ # Parse::Embeddings.register(:voyage,
36
+ # Parse::Embeddings::Voyage.new(
37
+ # api_key: ENV.fetch("VOYAGE_API_KEY"),
38
+ # model: "voyage-3",
39
+ # ))
40
+ #
41
+ # == Asymmetric input types
42
+ #
43
+ # Voyage's `input_type` field accepts `"query"` or `"document"`
44
+ # (mapped from the SDK-canonical `:search_query` / `:search_document`
45
+ # Symbols). The values are functionally analogous to Cohere's
46
+ # `search_query` / `search_document` — they're encoded by separately
47
+ # tuned heads, so re-using one type for both sides of a retrieval
48
+ # pair measurably degrades recall.
49
+ #
50
+ # Voyage also accepts `null` (omit the field), which Voyage's docs
51
+ # recommend for "general purpose" embeddings unrelated to retrieval.
52
+ # We translate the absent / non-retrieval cases to `null` rather
53
+ # than picking a default — Voyage's training depends on the
54
+ # asymmetry, so guessing on the caller's behalf would be worse than
55
+ # passing-through.
56
+ #
57
+ # == Security
58
+ #
59
+ # * The Faraday connection refuses `proxy:` unless the caller opts
60
+ # in via `allow_faraday_proxy: true`. Env-proxy autodiscovery
61
+ # (`HTTPS_PROXY` etc.) is suppressed by default.
62
+ # * `#inspect` (inherited from {Provider}) never surfaces `@api_key`.
63
+ # * `Authorization` and `Voyage-Api-Key` are in
64
+ # {Parse::Middleware::BodyBuilder::REDACTED_HEADERS}.
65
+ class Voyage < Provider
66
+ class AuthenticationError < Error; end
67
+ class BadRequestError < Error; end
68
+ class RateLimitError < Error; end
69
+ class TransientError < Error; end
70
+
71
+ DEFAULT_BASE_URL = "https://api.voyageai.com/v1"
72
+ DEFAULT_MODEL = "voyage-3"
73
+ DEFAULT_TIMEOUT = 30
74
+ DEFAULT_OPEN_TIMEOUT = 5
75
+ DEFAULT_MAX_RETRIES = 3
76
+ # Voyage's documented per-request cap is 128 inputs.
77
+ DEFAULT_BATCH_SIZE = 128
78
+ MAX_RESPONSE_BYTES = 16 * 1024 * 1024
79
+
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.
84
+ 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,
96
+ }.freeze
97
+
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,
110
+ }.freeze
111
+
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
+ # Models that route to `/v1/multimodalembeddings` with the
118
+ # `{ inputs: [{ content: [...] }] }` envelope rather than the
119
+ # standard `/v1/embeddings` `{ input: [String] }` envelope.
120
+ # Text-only inputs from this provider are wrapped as
121
+ # `{ type: "text", text: s }` content rows.
122
+ MULTIMODAL_MODELS = %w[voyage-multimodal-3].freeze
123
+
124
+ # Map SDK-canonical input_type symbols to Voyage wire strings.
125
+ # `:classification` / `:clustering` map to `nil` (omitted) since
126
+ # Voyage only distinguishes retrieval halves — other intents
127
+ # should receive the unconditioned vector.
128
+ INPUT_TYPE_WIRE_VALUES = {
129
+ search_query: "query",
130
+ search_document: "document",
131
+ classification: nil,
132
+ clustering: nil,
133
+ }.freeze
134
+
135
+ # @param api_key [String] required. Sent as `Authorization: Bearer …`.
136
+ # @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`.
139
+ # @param timeout [Integer] read timeout, seconds.
140
+ # @param open_timeout [Integer] connect timeout, seconds.
141
+ # @param max_retries [Integer] retry attempts on 429/5xx/timeouts.
142
+ # @param embed_batch_size [Integer] inputs per request (max 128).
143
+ # @param dimensions [Integer, nil] override output width via
144
+ # Voyage's `output_dimension` Matryoshka parameter. Only
145
+ # `voyage-4-large` accepts the field; for every other model the
146
+ # override must equal the native width or be omitted.
147
+ # @param truncation [Boolean] forward Voyage's `truncation:` field.
148
+ # Defaults `true` to match Voyage's API default. Set `false` to
149
+ # force the API to reject over-length inputs rather than silently
150
+ # truncating (useful when you want explicit chunking errors).
151
+ # @param allow_faraday_proxy [Boolean] opt in to proxy / env-proxy
152
+ # autodiscovery. Defaults `false`.
153
+ # @param allow_insecure_base_url [Boolean] permit `http://` base.
154
+ # @param connection [Faraday::Connection, nil] injection seam.
155
+ def initialize(
156
+ api_key:,
157
+ model: DEFAULT_MODEL,
158
+ base_url: DEFAULT_BASE_URL,
159
+ timeout: DEFAULT_TIMEOUT,
160
+ open_timeout: DEFAULT_OPEN_TIMEOUT,
161
+ max_retries: DEFAULT_MAX_RETRIES,
162
+ embed_batch_size: DEFAULT_BATCH_SIZE,
163
+ dimensions: nil,
164
+ truncation: true,
165
+ allow_faraday_proxy: false,
166
+ allow_insecure_base_url: false,
167
+ connection: nil
168
+ )
169
+ validate_api_key!(api_key)
170
+ validate_model!(model)
171
+ sanitized_base_url = validate_base_url!(base_url, allow_insecure_base_url)
172
+ validate_positive_integer!(:timeout, timeout)
173
+ validate_positive_integer!(:open_timeout, open_timeout)
174
+ validate_non_negative_integer!(:max_retries, max_retries)
175
+ validate_positive_integer!(:embed_batch_size, embed_batch_size)
176
+ if embed_batch_size > 128
177
+ raise ArgumentError,
178
+ "Parse::Embeddings::Voyage: embed_batch_size #{embed_batch_size} exceeds Voyage's per-request cap (128)."
179
+ end
180
+ unless [true, false].include?(truncation)
181
+ raise ArgumentError,
182
+ "Parse::Embeddings::Voyage: truncation must be true or false (got #{truncation.inspect})."
183
+ end
184
+ validate_dimensions!(model, dimensions)
185
+
186
+ @api_key = api_key
187
+ @model = model
188
+ @dimensions = dimensions || MODEL_DEFAULT_DIMENSIONS.fetch(model)
189
+ @base_url = sanitized_base_url
190
+ @timeout = timeout
191
+ @open_timeout = open_timeout
192
+ @max_retries = max_retries
193
+ @embed_batch_size = embed_batch_size
194
+ @truncation = truncation
195
+ @allow_faraday_proxy = allow_faraday_proxy
196
+ @connection = connection || build_connection
197
+ end
198
+
199
+ def dimensions
200
+ @dimensions
201
+ end
202
+
203
+ def model_name
204
+ @model
205
+ end
206
+
207
+ def embed_batch_size
208
+ @embed_batch_size
209
+ end
210
+
211
+ def max_input_tokens
212
+ MODEL_MAX_INPUT_TOKENS[@model]
213
+ end
214
+
215
+ def normalize?
216
+ # Voyage's v3 embeddings are documented unit-normalized.
217
+ true
218
+ end
219
+
220
+ def supports_input_type?
221
+ true
222
+ end
223
+
224
+ # @param strings [Array<String>] inputs.
225
+ # @param input_type [Symbol] one of {INPUT_TYPE_WIRE_VALUES}'s keys.
226
+ # @return [Array<Array<Float>>] vectors aligned 1:1 with `strings`.
227
+ def embed_text(strings, input_type: :search_document)
228
+ unless strings.is_a?(Array)
229
+ raise ArgumentError,
230
+ "Parse::Embeddings::Voyage#embed_text expects Array<String> (got #{strings.class})."
231
+ end
232
+ return [] if strings.empty?
233
+ strings.each_with_index do |s, i|
234
+ unless s.is_a?(String)
235
+ raise ArgumentError,
236
+ "Parse::Embeddings::Voyage#embed_text strings[#{i}] is not a String (#{s.class})."
237
+ end
238
+ if s.empty?
239
+ raise ArgumentError,
240
+ "Parse::Embeddings::Voyage#embed_text strings[#{i}] is empty; Voyage rejects empty inputs."
241
+ end
242
+ end
243
+ unless INPUT_TYPE_WIRE_VALUES.key?(input_type)
244
+ raise ArgumentError,
245
+ "Parse::Embeddings::Voyage#embed_text input_type #{input_type.inspect} not in " \
246
+ "#{INPUT_TYPE_WIRE_VALUES.keys.inspect}."
247
+ end
248
+ wire_input_type = INPUT_TYPE_WIRE_VALUES[input_type]
249
+
250
+ # Multimodal models route to a different endpoint with a
251
+ # different request envelope. The response envelope shape is
252
+ # the same (`{ data: [{ embedding, index }], usage: {...} }`)
253
+ # so `extract_vectors!` is reused as-is.
254
+ body =
255
+ if MULTIMODAL_MODELS.include?(@model)
256
+ build_multimodal_body(strings, wire_input_type)
257
+ else
258
+ build_text_body(strings, wire_input_type)
259
+ end
260
+
261
+ path = MULTIMODAL_MODELS.include?(@model) ? "multimodalembeddings" : "embeddings"
262
+
263
+ instrument_embed(strings.length, input_type) do |emit_payload|
264
+ payload = post_embeddings(body, path: path)
265
+ # Voyage's response carries `usage: { total_tokens }`.
266
+ if payload.is_a?(Hash) && payload["usage"].is_a?(Hash)
267
+ tt = payload["usage"]["total_tokens"]
268
+ emit_payload[:total_tokens] = tt if tt.is_a?(Integer) && tt >= 0
269
+ end
270
+ vectors = extract_vectors!(payload, strings.length)
271
+ validate_response!(strings.length, vectors)
272
+ end
273
+ end
274
+
275
+ def inspect_attrs
276
+ super.merge(base: safe_base_host, retries: @max_retries)
277
+ end
278
+
279
+ protected
280
+
281
+ def build_connection
282
+ headers = {
283
+ "Authorization" => "Bearer #{@api_key}",
284
+ "Content-Type" => "application/json",
285
+ "Accept" => "application/json",
286
+ "User-Agent" => "parse-stack-embeddings/#{user_agent_version}",
287
+ }
288
+
289
+ faraday_opts = { url: @base_url, headers: headers }
290
+ faraday_opts[:proxy] = nil unless @allow_faraday_proxy
291
+
292
+ conn = Faraday.new(**faraday_opts) do |f|
293
+ f.options.timeout = @timeout
294
+ f.options.open_timeout = @open_timeout
295
+ f.adapter Faraday.default_adapter
296
+ end
297
+ conn.proxy = nil if !@allow_faraday_proxy && conn.respond_to?(:proxy=)
298
+ conn
299
+ end
300
+
301
+ # Build the wire body for the standard `/v1/embeddings` endpoint
302
+ # (text-only models).
303
+ def build_text_body(strings, wire_input_type)
304
+ body = {
305
+ input: strings,
306
+ model: @model,
307
+ truncation: @truncation,
308
+ }
309
+ # Only forward input_type when it has a wire value. Voyage
310
+ # treats absent and `null` identically (unconditioned head),
311
+ # but absent is the spec-correct form for non-retrieval intent.
312
+ body[:input_type] = wire_input_type if wire_input_type
313
+ # `output_dimension` is only valid for the Matryoshka-capable
314
+ # models. Forward when the configured model is in the
315
+ # Matryoshka set and the active dimensions differ from native.
316
+ # Sending it elsewhere would yield a 400.
317
+ if MATRYOSHKA_MODELS.include?(@model) &&
318
+ @dimensions != MODEL_DEFAULT_DIMENSIONS.fetch(@model)
319
+ body[:output_dimension] = @dimensions
320
+ end
321
+ body
322
+ end
323
+
324
+ # Build the wire body for `/v1/multimodalembeddings`. The text
325
+ # path wraps each input string as a single `{type: "text", text:}`
326
+ # content row. Image inputs will land in v5.1 alongside
327
+ # {Provider#embed_image}; for now the provider is text-only and
328
+ # the multimodal envelope's `content` array always contains a
329
+ # single text row per input.
330
+ def build_multimodal_body(strings, wire_input_type)
331
+ body = {
332
+ inputs: strings.map { |s| { content: [{ type: "text", text: s }] } },
333
+ model: @model,
334
+ }
335
+ body[:input_type] = wire_input_type if wire_input_type
336
+ # `truncation` is documented for the multimodal endpoint too —
337
+ # forward it for parity with the text path so callers get the
338
+ # same fail-on-overlength behavior across models.
339
+ body[:truncation] = @truncation
340
+ body
341
+ end
342
+
343
+ def post_embeddings(body, path: "embeddings")
344
+ attempts = 0
345
+ loop do
346
+ attempts += 1
347
+ begin
348
+ response = @connection.post(path) do |req|
349
+ req.body = body.to_json
350
+ end
351
+ rescue Faraday::TimeoutError, Faraday::ConnectionFailed => e
352
+ if attempts > @max_retries
353
+ raise TransientError, "Parse::Embeddings::Voyage: #{e.class} after #{attempts} attempt(s)."
354
+ end
355
+ sleep(backoff_seconds(attempts))
356
+ next
357
+ end
358
+
359
+ status = response.status
360
+ return parse_json_body!(response.body) if status >= 200 && status < 300
361
+
362
+ if status == 401
363
+ raise AuthenticationError,
364
+ "Parse::Embeddings::Voyage: 401 Unauthorized — check api_key."
365
+ end
366
+ if status == 429
367
+ if attempts > @max_retries
368
+ raise RateLimitError,
369
+ "Parse::Embeddings::Voyage: 429 rate limited after #{attempts} attempt(s)."
370
+ end
371
+ sleep(retry_after_seconds(response) || backoff_seconds(attempts))
372
+ next
373
+ end
374
+ if status >= 500
375
+ if attempts > @max_retries
376
+ raise TransientError,
377
+ "Parse::Embeddings::Voyage: #{status} after #{attempts} attempt(s)."
378
+ end
379
+ sleep(backoff_seconds(attempts))
380
+ next
381
+ end
382
+ raise BadRequestError,
383
+ "Parse::Embeddings::Voyage: #{status} from POST /#{path}."
384
+ end
385
+ end
386
+
387
+ def parse_json_body!(body)
388
+ s = body.to_s
389
+ if s.bytesize > MAX_RESPONSE_BYTES
390
+ raise InvalidResponseError,
391
+ "Parse::Embeddings::Voyage: response body exceeds #{MAX_RESPONSE_BYTES} bytes " \
392
+ "(#{s.bytesize}). Refusing to parse."
393
+ end
394
+ JSON.parse(s, max_nesting: 32)
395
+ rescue JSON::ParserError => e
396
+ raise InvalidResponseError,
397
+ "Parse::Embeddings::Voyage: response is not valid JSON (#{e.message})."
398
+ end
399
+
400
+ # Voyage's response shape mirrors OpenAI:
401
+ #
402
+ # {
403
+ # "object": "list",
404
+ # "data": [
405
+ # { "object": "embedding", "embedding": [...], "index": 0 },
406
+ # ...
407
+ # ],
408
+ # "model": "voyage-3",
409
+ # "usage": { "total_tokens": N }
410
+ # }
411
+ def extract_vectors!(payload, input_count)
412
+ unless payload.is_a?(Hash)
413
+ raise InvalidResponseError,
414
+ "Parse::Embeddings::Voyage: response body is not a JSON object."
415
+ end
416
+ data = payload["data"]
417
+ unless data.is_a?(Array)
418
+ raise InvalidResponseError,
419
+ "Parse::Embeddings::Voyage: response.data is not an Array."
420
+ end
421
+ if data.length != input_count
422
+ raise InvalidResponseError,
423
+ "Parse::Embeddings::Voyage: response.data.length #{data.length} != input count #{input_count}."
424
+ end
425
+ sorted = data.each_with_index.map do |entry, i|
426
+ unless entry.is_a?(Hash)
427
+ raise InvalidResponseError,
428
+ "Parse::Embeddings::Voyage: response.data[#{i}] is not a JSON object."
429
+ end
430
+ idx = entry["index"]
431
+ unless idx.is_a?(Integer) && idx >= 0 && idx < input_count
432
+ raise InvalidResponseError,
433
+ "Parse::Embeddings::Voyage: response.data[#{i}].index #{idx.inspect} out of range."
434
+ end
435
+ [idx, entry["embedding"]]
436
+ end
437
+ indices = sorted.map(&:first)
438
+ if indices.uniq.length != indices.length
439
+ raise InvalidResponseError,
440
+ "Parse::Embeddings::Voyage: duplicate index in response.data."
441
+ end
442
+ sorted.sort_by(&:first).map(&:last)
443
+ end
444
+
445
+ def backoff_seconds(attempt)
446
+ [0.5 * (2**(attempt - 1)), 30.0].min
447
+ end
448
+
449
+ def retry_after_seconds(response)
450
+ ra = response.respond_to?(:headers) ? response.headers["retry-after"] || response.headers["Retry-After"] : nil
451
+ return nil unless ra
452
+ v = ra.to_f
453
+ v.positive? ? [v, 60.0].min : nil
454
+ end
455
+
456
+ private
457
+
458
+ def validate_api_key!(api_key)
459
+ unless api_key.is_a?(String) && !api_key.empty?
460
+ raise ArgumentError,
461
+ "Parse::Embeddings::Voyage: api_key must be a non-empty String."
462
+ end
463
+ end
464
+
465
+ def validate_model!(model)
466
+ unless MODEL_DEFAULT_DIMENSIONS.key?(model)
467
+ raise ArgumentError,
468
+ "Parse::Embeddings::Voyage: unknown model #{model.inspect}. " \
469
+ "Supported: #{MODEL_DEFAULT_DIMENSIONS.keys.inspect}."
470
+ end
471
+ end
472
+
473
+ def validate_dimensions!(model, dimensions)
474
+ return if dimensions.nil?
475
+ unless dimensions.is_a?(Integer) && dimensions.positive?
476
+ raise ArgumentError,
477
+ "Parse::Embeddings::Voyage: dimensions must be a positive Integer (got #{dimensions.inspect})."
478
+ end
479
+ native = MODEL_DEFAULT_DIMENSIONS.fetch(model)
480
+ if dimensions > native
481
+ raise ArgumentError,
482
+ "Parse::Embeddings::Voyage: dimensions #{dimensions} exceeds native #{native} for #{model}."
483
+ end
484
+ if !MATRYOSHKA_MODELS.include?(model) && dimensions != native
485
+ raise ArgumentError,
486
+ "Parse::Embeddings::Voyage: model #{model.inspect} does not support custom dimensions " \
487
+ "(Matryoshka-capable models: #{MATRYOSHKA_MODELS.inspect})."
488
+ end
489
+ end
490
+
491
+ def validate_base_url!(base_url, allow_insecure)
492
+ unless base_url.is_a?(String) && !base_url.empty?
493
+ raise ArgumentError,
494
+ "Parse::Embeddings::Voyage: base_url must be a non-empty String."
495
+ end
496
+ begin
497
+ uri = URI.parse(base_url)
498
+ rescue URI::InvalidURIError => e
499
+ raise ArgumentError,
500
+ "Parse::Embeddings::Voyage: base_url is not a valid URL (#{e.message})."
501
+ end
502
+ unless %w[http https].include?(uri.scheme)
503
+ raise ArgumentError,
504
+ "Parse::Embeddings::Voyage: base_url must be http(s):// (got scheme #{uri.scheme.inspect})."
505
+ end
506
+ if uri.scheme == "http" && !allow_insecure
507
+ raise ArgumentError,
508
+ "Parse::Embeddings::Voyage: refusing http:// base_url. " \
509
+ "Pass allow_insecure_base_url: true to opt in."
510
+ end
511
+ if uri.host.nil? || uri.host.empty?
512
+ raise ArgumentError,
513
+ "Parse::Embeddings::Voyage: base_url must include a host."
514
+ end
515
+ if uri.userinfo
516
+ raise ArgumentError,
517
+ "Parse::Embeddings::Voyage: base_url must not contain userinfo (credentials). " \
518
+ "Use the api_key parameter and a clean URL."
519
+ end
520
+ uri.to_s
521
+ end
522
+
523
+ def validate_positive_integer!(name, value)
524
+ unless value.is_a?(Integer) && value.positive?
525
+ raise ArgumentError,
526
+ "Parse::Embeddings::Voyage: #{name} must be a positive Integer (got #{value.inspect})."
527
+ end
528
+ end
529
+
530
+ def validate_non_negative_integer!(name, value)
531
+ unless value.is_a?(Integer) && value >= 0
532
+ raise ArgumentError,
533
+ "Parse::Embeddings::Voyage: #{name} must be a non-negative Integer (got #{value.inspect})."
534
+ end
535
+ end
536
+
537
+ def user_agent_version
538
+ defined?(Parse::Stack::VERSION) ? Parse::Stack::VERSION : "unknown"
539
+ end
540
+
541
+ def safe_base_host
542
+ uri = URI.parse(@base_url)
543
+ host = uri.host
544
+ host && !host.empty? ? "#{uri.scheme}://#{host}" : nil
545
+ rescue URI::InvalidURIError
546
+ nil
547
+ end
548
+ end
549
+ end
550
+ end