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.
- checksums.yaml +4 -4
- data/.bundle/config +2 -0
- data/.env.sample +17 -3
- data/.github/workflows/codeql.yml +44 -0
- data/.github/workflows/docs.yml +39 -0
- data/.github/workflows/release.yml +32 -0
- data/.github/workflows/ruby.yml +8 -6
- data/.gitignore +4 -0
- data/.vscode/settings.json +3 -0
- data/CHANGELOG.md +305 -72
- data/Gemfile.lock +10 -3
- data/LICENSE.txt +1 -1
- data/README.md +190 -219
- data/Rakefile +1 -1
- data/SECURITY.md +30 -0
- data/assets/parse-stack-next-avatar.png +0 -0
- data/assets/parse-stack-next-avatar.svg +37 -0
- data/assets/parse-stack-next-banner.png +0 -0
- data/assets/parse-stack-next-banner.svg +45 -0
- data/assets/parse-stack-next-social-preview.png +0 -0
- data/docs/atlas_vector_search_guide.md +511 -0
- data/docs/client_sdk_guide.md +1320 -0
- data/docs/mcp_guide.md +225 -104
- data/docs/mongodb_direct_guide.md +21 -4
- data/docs/usage_guide.md +585 -0
- data/examples/transaction_example.rb +28 -28
- data/lib/parse/acl_scope.rb +2 -2
- data/lib/parse/agent/mcp_rack_app.rb +184 -16
- data/lib/parse/agent/metadata_dsl.rb +16 -16
- data/lib/parse/agent/pipeline_validator.rb +28 -1
- data/lib/parse/agent/prompts.rb +5 -5
- data/lib/parse/agent/tools.rb +287 -14
- data/lib/parse/agent.rb +209 -12
- data/lib/parse/api/analytics.rb +27 -5
- data/lib/parse/api/files.rb +6 -2
- data/lib/parse/api/push.rb +21 -4
- data/lib/parse/api/server.rb +59 -0
- data/lib/parse/api/users.rb +26 -2
- data/lib/parse/atlas_search/index_manager.rb +84 -0
- data/lib/parse/atlas_search.rb +37 -9
- data/lib/parse/cache/pool.rb +88 -0
- data/lib/parse/cache/redis.rb +249 -0
- data/lib/parse/client/body_builder.rb +94 -0
- data/lib/parse/client/caching.rb +109 -9
- data/lib/parse/client/response.rb +27 -0
- data/lib/parse/client.rb +74 -3
- data/lib/parse/console.rb +203 -0
- data/lib/parse/embeddings/cohere.rb +484 -0
- data/lib/parse/embeddings/fixture.rb +130 -0
- data/lib/parse/embeddings/jina.rb +454 -0
- data/lib/parse/embeddings/local_http.rb +492 -0
- data/lib/parse/embeddings/openai.rb +520 -0
- data/lib/parse/embeddings/provider.rb +264 -0
- data/lib/parse/embeddings/qwen.rb +431 -0
- data/lib/parse/embeddings/voyage.rb +550 -0
- data/lib/parse/embeddings.rb +225 -0
- data/lib/parse/graphql/scalars.rb +53 -0
- data/lib/parse/graphql/type_generator.rb +264 -0
- data/lib/parse/graphql.rb +48 -0
- data/lib/parse/live_query/client.rb +24 -5
- data/lib/parse/live_query/subscription.rb +17 -6
- data/lib/parse/live_query.rb +9 -4
- data/lib/parse/model/associations/collection_proxy.rb +2 -2
- data/lib/parse/model/associations/has_many.rb +32 -1
- data/lib/parse/model/associations/has_one.rb +17 -0
- data/lib/parse/model/associations/pointer_collection_proxy.rb +3 -3
- data/lib/parse/model/classes/user.rb +307 -11
- data/lib/parse/model/clp.rb +1 -1
- data/lib/parse/model/core/create_lock.rb +14 -2
- data/lib/parse/model/core/embed_managed.rb +296 -0
- data/lib/parse/model/core/fetching.rb +4 -4
- data/lib/parse/model/core/indexing.rb +53 -14
- data/lib/parse/model/core/parse_reference.rb +3 -3
- data/lib/parse/model/core/properties.rb +70 -1
- data/lib/parse/model/core/querying.rb +57 -1
- data/lib/parse/model/core/vector_searchable.rb +285 -0
- data/lib/parse/model/file.rb +16 -4
- data/lib/parse/model/model.rb +26 -10
- data/lib/parse/model/object.rb +63 -6
- data/lib/parse/model/pointer.rb +16 -2
- data/lib/parse/model/shortnames.rb +2 -0
- data/lib/parse/model/validations/uniqueness_validator.rb +3 -3
- data/lib/parse/model/vector.rb +102 -0
- data/lib/parse/mongodb.rb +90 -8
- data/lib/parse/pipeline_security.rb +59 -2
- data/lib/parse/query/constraints.rb +16 -14
- data/lib/parse/query/ordering.rb +1 -1
- data/lib/parse/query.rb +137 -64
- data/lib/parse/stack/generators/templates/model.erb +2 -2
- data/lib/parse/stack/generators/templates/model_installation.rb +1 -1
- data/lib/parse/stack/generators/templates/model_role.rb +1 -1
- data/lib/parse/stack/generators/templates/model_session.rb +1 -1
- data/lib/parse/stack/generators/templates/parse.rb +1 -1
- data/lib/parse/stack/generators/templates/webhooks.rb +1 -1
- data/lib/parse/stack/version.rb +1 -1
- data/lib/parse/stack.rb +375 -73
- data/lib/parse/two_factor_auth/user_extension.rb +5 -2
- data/lib/parse/vector_search.rb +341 -0
- data/parse-stack-next.gemspec +10 -9
- data/scripts/docker/docker-compose.test.yml +18 -0
- data/scripts/start-parse.sh +6 -0
- data/scripts/vector_prototype/create_vector_index.js +105 -0
- data/scripts/vector_prototype/fetch_embeddings.py +241 -0
- data/scripts/vector_prototype/fixture_manifest.json +9 -0
- data/scripts/vector_prototype/query_prototype.rb +84 -0
- data/scripts/vector_prototype/run.sh +34 -0
- metadata +77 -5
- 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
|