parse-stack-next 5.7.4 → 5.7.5

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.
data/lib/parse/query.rb CHANGED
@@ -2439,11 +2439,11 @@ module Parse
2439
2439
  original_limit = @limit
2440
2440
  @limit = count
2441
2441
 
2442
- begin
2443
- items = results_direct
2444
- ensure
2445
- @limit = original_limit
2446
- end
2442
+ items = begin
2443
+ results_direct
2444
+ ensure
2445
+ @limit = original_limit
2446
+ end
2447
2447
 
2448
2448
  count == 1 ? items.first : items.first(count)
2449
2449
  end
@@ -0,0 +1,282 @@
1
+ # encoding: UTF-8
2
+ # frozen_string_literal: true
3
+
4
+ require "json"
5
+ require "uri"
6
+ require "ipaddr"
7
+ require_relative "../reranker"
8
+
9
+ module Parse
10
+ module Retrieval
11
+ module Reranker
12
+ # Voyage AI cross-encoder reranker. Wraps `POST /v1/rerank`.
13
+ #
14
+ # Takes a query plus a list of document strings and returns a
15
+ # relevance-ordered list of `{ index, relevance_score }` objects.
16
+ # It is a distinct endpoint from `/v1/embeddings`; do NOT confuse it
17
+ # with {Parse::Embeddings::Voyage} (the embeddings provider).
18
+ #
19
+ # == Endpoints
20
+ #
21
+ # The same models are served by Voyage's own API and by MongoDB's
22
+ # Atlas Embedding and Reranking API, with an identical wire contract.
23
+ # An Atlas model API key (prefix {ATLAS_KEY_PREFIX}) routes to
24
+ # {ATLAS_BASE_URL} automatically, matching the embeddings provider.
25
+ # Pass `base_url:` to target either host (or a proxy) explicitly.
26
+ #
27
+ # The HTTP stack mirrors {Cohere}: explicit `proxy: nil` unless
28
+ # opted in, bounded timeouts, capped retries with backoff on
29
+ # 429/5xx, a response-size cap, and a redacted `#inspect`.
30
+ #
31
+ # @example
32
+ # reranker = Parse::Retrieval::Reranker::Voyage.new(
33
+ # api_key: ENV.fetch("VOYAGE_API_KEY"),
34
+ # model: "rerank-3",
35
+ # )
36
+ # reranker.rerank(query: "rain songs", documents: lyrics, top_n: 5)
37
+ class Voyage < Base
38
+ class AuthenticationError < Error; end
39
+ class RateLimitError < Error; end
40
+ class TransientError < Error; end
41
+ class BadRequestError < Error; end
42
+
43
+ DEFAULT_BASE_URL = "https://api.voyageai.com/v1"
44
+ ATLAS_BASE_URL = "https://ai.mongodb.com/v1"
45
+ # Atlas model API keys carry this prefix and authenticate only
46
+ # against {ATLAS_BASE_URL}.
47
+ ATLAS_KEY_PREFIX = "al-"
48
+ DEFAULT_MODEL = "rerank-3"
49
+ DEFAULT_TIMEOUT = 30
50
+ DEFAULT_OPEN_TIMEOUT = 5
51
+ DEFAULT_MAX_RETRIES = 2
52
+
53
+ # Current and still-served rerank models. Informational: the
54
+ # constructor accepts any non-empty model name so a newly released
55
+ # model works without an SDK upgrade.
56
+ MODELS = %w[rerank-3 rerank-3-lite rerank-2.5 rerank-2.5-lite rerank-2 rerank-2-lite].freeze
57
+
58
+ # Voyage documents a cap of 1000 documents per rerank call; the
59
+ # {Base::MAX_DOCUMENTS} cap (1000) already enforces this.
60
+ MAX_RESPONSE_BYTES = 5 * 1024 * 1024
61
+
62
+ # @param api_key [String] Voyage API key, or an Atlas model API key.
63
+ # @param model [String] rerank model (default {DEFAULT_MODEL}).
64
+ # @param base_url [String, nil] API base. Defaults to
65
+ # {ATLAS_BASE_URL} for an Atlas key, else {DEFAULT_BASE_URL}.
66
+ # @param truncation [Boolean] forward Voyage's `truncation:` field.
67
+ # Defaults `true` (Voyage's default). `false` makes over-length
68
+ # inputs a 400 instead of silently truncating them.
69
+ # @param timeout [Integer] read timeout (seconds).
70
+ # @param open_timeout [Integer] connect timeout (seconds).
71
+ # @param max_retries [Integer] retry budget for 429 / 5xx /
72
+ # transient connection errors.
73
+ # @param allow_faraday_proxy [Boolean] permit Faraday to honor
74
+ # `*_proxy` env vars (default false, explicit `proxy: nil`).
75
+ def initialize(api_key:, model: DEFAULT_MODEL, base_url: nil, truncation: true,
76
+ timeout: DEFAULT_TIMEOUT, open_timeout: DEFAULT_OPEN_TIMEOUT,
77
+ max_retries: DEFAULT_MAX_RETRIES, allow_faraday_proxy: false)
78
+ validate_api_key!(api_key)
79
+ @api_key = api_key
80
+ @model = model.to_s
81
+ raise ArgumentError, "Reranker::Voyage: model must be non-empty." if @model.empty?
82
+ base_url ||= api_key.start_with?(ATLAS_KEY_PREFIX) ? ATLAS_BASE_URL : DEFAULT_BASE_URL
83
+ @base_url = base_url.to_s
84
+ validate_base_url!(@base_url)
85
+ unless [true, false].include?(truncation)
86
+ raise ArgumentError, "Reranker::Voyage: truncation must be true or false (got #{truncation.inspect})."
87
+ end
88
+ @truncation = truncation
89
+ @timeout = Integer(timeout)
90
+ @open_timeout = Integer(open_timeout)
91
+ @max_retries = Integer(max_retries)
92
+ raise ArgumentError, "Reranker::Voyage: max_retries must be >= 0." if @max_retries.negative?
93
+ @allow_faraday_proxy = allow_faraday_proxy ? true : false
94
+ @connection = build_connection
95
+ end
96
+
97
+ # @return [String] the rerank model name.
98
+ attr_reader :model
99
+
100
+ # @return [Boolean] true when routed through {ATLAS_BASE_URL}.
101
+ def atlas?
102
+ safe_base_host == URI.parse(ATLAS_BASE_URL).host
103
+ end
104
+
105
+ def inspect
106
+ "#<#{self.class} model=#{@model.inspect} base=#{safe_base_host.inspect} " \
107
+ "retries=#{@max_retries} api_key=[REDACTED]>"
108
+ end
109
+
110
+ protected
111
+
112
+ def rerank_scores(query, documents, top_n)
113
+ require_faraday!
114
+ body = {
115
+ "model" => @model,
116
+ "query" => query,
117
+ "documents" => documents,
118
+ "top_k" => top_n,
119
+ "truncation" => @truncation,
120
+ }
121
+ payload = post_rerank(body)
122
+ extract_results!(payload)
123
+ end
124
+
125
+ private
126
+
127
+ def post_rerank(body)
128
+ attempts = 0
129
+ loop do
130
+ attempts += 1
131
+ begin
132
+ response = @connection.post("rerank") { |req| req.body = body.to_json }
133
+ rescue Faraday::TimeoutError, Faraday::ConnectionFailed => e
134
+ raise TransientError, "Reranker::Voyage: #{e.class} after #{attempts} attempt(s)." if attempts > @max_retries
135
+ sleep(backoff_seconds(attempts))
136
+ next
137
+ end
138
+
139
+ status = response.status
140
+ return parse_json_body!(response.body) if status >= 200 && status < 300
141
+
142
+ case status
143
+ when 401
144
+ raise AuthenticationError, "Reranker::Voyage: 401 Unauthorized. Check api_key."
145
+ when 403
146
+ # Voyage and Atlas keys are not interchangeable; a key sent to
147
+ # the other host is refused with a 403.
148
+ raise AuthenticationError,
149
+ "Reranker::Voyage: 403 Forbidden from #{safe_base_host}. Atlas model API keys " \
150
+ "(#{ATLAS_KEY_PREFIX}...) work only against #{ATLAS_BASE_URL}, and Voyage keys only " \
151
+ "against #{DEFAULT_BASE_URL}; check that base_url matches the key."
152
+ when 429
153
+ raise RateLimitError, "Reranker::Voyage: 429 rate limited after #{attempts} attempt(s)." if attempts > @max_retries
154
+ sleep(retry_after_seconds(response) || backoff_seconds(attempts))
155
+ when 500..599
156
+ raise TransientError, "Reranker::Voyage: #{status} after #{attempts} attempt(s)." if attempts > @max_retries
157
+ sleep(backoff_seconds(attempts))
158
+ else
159
+ raise BadRequestError, "Reranker::Voyage: #{status} from POST /rerank."
160
+ end
161
+ end
162
+ end
163
+
164
+ # Voyage /v1/rerank response shape:
165
+ # { "object": "list",
166
+ # "data": [ { "index": 0, "relevance_score": 0.45 }, ... ],
167
+ # "model": "rerank-3", "usage": { "total_tokens": 8 } }
168
+ def extract_results!(payload)
169
+ unless payload.is_a?(Hash)
170
+ raise InvalidResponseError, "Reranker::Voyage: response body is not a JSON object."
171
+ end
172
+ data = payload["data"]
173
+ unless data.is_a?(Array)
174
+ raise InvalidResponseError, "Reranker::Voyage: response.data is not an Array."
175
+ end
176
+ data.map do |r|
177
+ unless r.is_a?(Hash)
178
+ raise InvalidResponseError, "Reranker::Voyage: rerank result is not an object (#{r.inspect})."
179
+ end
180
+ Result.new(index: r["index"], relevance_score: r["relevance_score"])
181
+ end
182
+ end
183
+
184
+ def parse_json_body!(body)
185
+ s = body.to_s
186
+ if s.bytesize > MAX_RESPONSE_BYTES
187
+ raise InvalidResponseError,
188
+ "Reranker::Voyage: response body exceeds #{MAX_RESPONSE_BYTES} bytes (#{s.bytesize})."
189
+ end
190
+ JSON.parse(s, max_nesting: 32)
191
+ rescue JSON::ParserError => e
192
+ raise InvalidResponseError, "Reranker::Voyage: response is not valid JSON (#{e.message})."
193
+ end
194
+
195
+ def build_connection
196
+ require_faraday!
197
+ headers = {
198
+ "Authorization" => "Bearer #{@api_key}",
199
+ "Content-Type" => "application/json",
200
+ "Accept" => "application/json",
201
+ "User-Agent" => "parse-stack-reranker/#{Parse::Stack::VERSION rescue "0"}",
202
+ }
203
+ # base_url must end with a trailing slash so Faraday resolves the
204
+ # relative "rerank" path under /v1/ rather than replacing it.
205
+ base = @base_url.end_with?("/") ? @base_url : "#{@base_url}/"
206
+ faraday_opts = { url: base, headers: headers }
207
+ faraday_opts[:proxy] = nil unless @allow_faraday_proxy
208
+ conn = Faraday.new(**faraday_opts) do |f|
209
+ f.options.timeout = @timeout
210
+ f.options.open_timeout = @open_timeout
211
+ f.adapter Faraday.default_adapter
212
+ end
213
+ conn.proxy = nil if !@allow_faraday_proxy && conn.respond_to?(:proxy=)
214
+ conn
215
+ end
216
+
217
+ def backoff_seconds(attempt)
218
+ [0.5 * (2 ** (attempt - 1)), 30.0].min
219
+ end
220
+
221
+ def retry_after_seconds(response)
222
+ ra = response.respond_to?(:headers) ? response.headers["retry-after"] || response.headers["Retry-After"] : nil
223
+ return nil unless ra
224
+ v = ra.to_f
225
+ v.positive? ? [v, 60.0].min : nil
226
+ end
227
+
228
+ def validate_api_key!(api_key)
229
+ unless api_key.is_a?(String) && !api_key.empty?
230
+ raise ArgumentError, "Reranker::Voyage: api_key must be a non-empty String."
231
+ end
232
+ end
233
+
234
+ def validate_base_url!(base_url)
235
+ uri = URI.parse(base_url)
236
+ unless uri.is_a?(URI::HTTPS) || uri.is_a?(URI::HTTP)
237
+ raise ArgumentError, "Reranker::Voyage: base_url must be http(s) (got #{base_url.inspect})."
238
+ end
239
+ # Credentials in the URL leak into logs and error messages, and
240
+ # userinfo can mask the real host.
241
+ unless uri.userinfo.nil?
242
+ raise ArgumentError,
243
+ "Reranker::Voyage: base_url must not embed userinfo (credentials in the URL)."
244
+ end
245
+ # Plaintext http:// would send the API key in the clear. Permit it
246
+ # only for loopback hosts (a local dev proxy or sidecar).
247
+ if uri.scheme == "http" && !loopback_host?(uri.host)
248
+ raise ArgumentError,
249
+ "Reranker::Voyage: base_url must be https:// for non-loopback hosts " \
250
+ "(refusing to send the API key over plaintext http to #{uri.host.inspect})."
251
+ end
252
+ rescue URI::InvalidURIError => e
253
+ raise ArgumentError, "Reranker::Voyage: invalid base_url #{base_url.inspect} (#{e.message})."
254
+ end
255
+
256
+ # @return [Boolean] true for localhost / 127.0.0.0/8 / ::1 etc.
257
+ def loopback_host?(host)
258
+ return false if host.nil? || host.empty?
259
+ h = host.downcase.sub(/\A\[/, "").sub(/\]\z/, "")
260
+ return true if h == "localhost"
261
+ begin
262
+ IPAddr.new(h).loopback?
263
+ rescue IPAddr::Error
264
+ false
265
+ end
266
+ end
267
+
268
+ def safe_base_host
269
+ URI.parse(@base_url).host
270
+ rescue StandardError
271
+ "?"
272
+ end
273
+
274
+ def require_faraday!
275
+ require "faraday" unless defined?(Faraday)
276
+ rescue LoadError
277
+ raise Error, "Reranker::Voyage requires the `faraday` gem."
278
+ end
279
+ end
280
+ end
281
+ end
282
+ end
@@ -37,6 +37,8 @@ module Parse
37
37
  # The Cohere `/v2/rerank` adapter is loaded lazily — it requires
38
38
  # Faraday, which the core retrieval path does not.
39
39
  autoload :Cohere, ::File.expand_path("reranker/cohere", __dir__)
40
+ # The Voyage `/v1/rerank` adapter, also lazy for the same reason.
41
+ autoload :Voyage, ::File.expand_path("reranker/voyage", __dir__)
40
42
 
41
43
  # Base error for the reranker layer. Adapters raise subclasses.
42
44
  class Error < StandardError; end
@@ -6,6 +6,6 @@ module Parse
6
6
  # The Parse Server SDK for Ruby
7
7
  module Stack
8
8
  # The current version.
9
- VERSION = "5.7.4"
9
+ VERSION = "5.7.5"
10
10
  end
11
11
  end
@@ -71,6 +71,7 @@ Gem::Specification.new do |spec|
71
71
  # Required for: Parse::MongoDB, Parse::AtlasSearch, mongo_direct query methods
72
72
  # Users can add this to their Gemfile for direct MongoDB access:
73
73
  # gem 'mongo', '~> 2.18'
74
+ # Use mongo 2.26 or newer against MongoDB 9.0 servers.
74
75
  # Note: The gem is loaded at runtime only when MongoDB features are used
75
76
 
76
77
  # Optional dependency for GraphQL schema type generation
metadata CHANGED
@@ -1,7 +1,7 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: parse-stack-next
3
3
  version: !ruby/object:Gem::Version
4
- version: 5.7.4
4
+ version: 5.7.5
5
5
  platform: ruby
6
6
  authors:
7
7
  - Adrian Curtin
@@ -251,6 +251,7 @@ files:
251
251
  - lib/parse/agent/constraint_translator.rb
252
252
  - lib/parse/agent/describe.rb
253
253
  - lib/parse/agent/errors.rb
254
+ - lib/parse/agent/log_levels.rb
254
255
  - lib/parse/agent/mcp_client.rb
255
256
  - lib/parse/agent/mcp_dispatcher.rb
256
257
  - lib/parse/agent/mcp_rack_app.rb
@@ -406,6 +407,7 @@ files:
406
407
  - lib/parse/retrieval/chunker.rb
407
408
  - lib/parse/retrieval/reranker.rb
408
409
  - lib/parse/retrieval/reranker/cohere.rb
410
+ - lib/parse/retrieval/reranker/voyage.rb
409
411
  - lib/parse/retrieval/retriever.rb
410
412
  - lib/parse/schema.rb
411
413
  - lib/parse/schema/index_migrator.rb