parse-stack-next 5.7.3 → 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.
- checksums.yaml +4 -4
- data/CHANGELOG.md +190 -0
- data/README.md +3 -0
- data/docs/atlas_vector_search_guide.md +125 -10
- data/docs/mcp_guide.md +81 -0
- data/lib/parse/agent/log_levels.rb +11 -0
- data/lib/parse/agent/mcp_dispatcher.rb +250 -13
- data/lib/parse/agent/mcp_rack_app.rb +120 -17
- data/lib/parse/agent.rb +40 -0
- data/lib/parse/atlas_search.rb +5 -1
- data/lib/parse/client.rb +85 -12
- data/lib/parse/embeddings/cache.rb +17 -5
- data/lib/parse/embeddings/provider.rb +26 -0
- data/lib/parse/embeddings/voyage.rb +214 -15
- data/lib/parse/embeddings.rb +3 -1
- data/lib/parse/model/core/embed_managed.rb +8 -2
- data/lib/parse/mongodb.rb +49 -2
- data/lib/parse/query.rb +5 -5
- data/lib/parse/retrieval/reranker/voyage.rb +282 -0
- data/lib/parse/retrieval/reranker.rb +2 -0
- data/lib/parse/stack/version.rb +1 -1
- data/parse-stack-next.gemspec +1 -0
- metadata +3 -1
data/lib/parse/embeddings.rb
CHANGED
|
@@ -19,7 +19,9 @@ module Parse
|
|
|
19
19
|
# * {Cohere} — embed-{english,multilingual}-v3.0 and `*-light-v3.0`.
|
|
20
20
|
# Distinguishes `:search_query` / `:search_document` at the wire.
|
|
21
21
|
# * {Voyage} — voyage-4 family (incl. open-weight `voyage-4-nano`),
|
|
22
|
-
# voyage-3 family, voyage-code-
|
|
22
|
+
# voyage-3 family, voyage-code-4, voyage-code-3, voyage-finance-2,
|
|
23
|
+
# voyage-law-2, multimodal, and contextualized chunk models
|
|
24
|
+
# (voyage-context-4, voyage-context-3).
|
|
23
25
|
# Distinguishes input types.
|
|
24
26
|
# * {Jina} — jina-embeddings-v3/v4/v5 (text + omni-text mode),
|
|
25
27
|
# jina-code-embeddings-{0.5b,1.5b}. Matryoshka via `dimensions:`.
|
|
@@ -428,7 +428,10 @@ module Parse
|
|
|
428
428
|
#
|
|
429
429
|
# @param field [Symbol, nil] limit to one embed target; nil
|
|
430
430
|
# processes every declared directive.
|
|
431
|
-
# @param batch_size [Integer] rows fetched per
|
|
431
|
+
# @param batch_size [Integer] rows fetched per query page (default
|
|
432
|
+
# 100). This pages the records; it does NOT batch provider
|
|
433
|
+
# requests. Each record is saved individually and makes its own
|
|
434
|
+
# embedding call.
|
|
432
435
|
# @param limit [Integer, nil] stop after re-embedding at most
|
|
433
436
|
# this many records across all directives; nil = no cap.
|
|
434
437
|
# @param where [Hash, nil] extra query constraints (e.g.
|
|
@@ -527,7 +530,10 @@ module Parse
|
|
|
527
530
|
#
|
|
528
531
|
# @param field [Symbol, nil] limit the backfill to one embed
|
|
529
532
|
# target; nil processes every declared directive.
|
|
530
|
-
# @param batch_size [Integer] rows fetched per
|
|
533
|
+
# @param batch_size [Integer] rows fetched per query page (default
|
|
534
|
+
# 100). This pages the records; it does NOT batch provider
|
|
535
|
+
# requests. Each record is saved individually and makes its own
|
|
536
|
+
# embedding call.
|
|
531
537
|
# @param limit [Integer, nil] stop after embedding at most this
|
|
532
538
|
# many records across all directives; nil = no cap.
|
|
533
539
|
# @param where [Hash, nil] extra query constraints AND-ed with the
|
data/lib/parse/mongodb.rb
CHANGED
|
@@ -69,6 +69,9 @@ module Parse
|
|
|
69
69
|
#
|
|
70
70
|
# @note Requires the 'mongo' gem to be installed. Add to your Gemfile:
|
|
71
71
|
# gem 'mongo', '~> 2.18'
|
|
72
|
+
# Use 2.26 or newer against MongoDB 9.0, which is the first driver
|
|
73
|
+
# release that handles 9.0's overload (Intelligent Workload
|
|
74
|
+
# Management) errors.
|
|
72
75
|
module MongoDB
|
|
73
76
|
# Error raised when mongo gem is not available
|
|
74
77
|
class GemNotAvailable < StandardError; end
|
|
@@ -191,6 +194,17 @@ module Parse
|
|
|
191
194
|
find listDatabases connPoolStats serverStatus
|
|
192
195
|
].freeze
|
|
193
196
|
|
|
197
|
+
# MongoDB error code for QueryPlanKilled. MongoDB 9.0 kills a running
|
|
198
|
+
# query with it when an indexed field the query references becomes
|
|
199
|
+
# multikey (an insert or update stores an array there) mid-flight.
|
|
200
|
+
MONGO_QUERY_PLAN_KILLED_CODE = 175
|
|
201
|
+
|
|
202
|
+
# How many times a read killed by {MONGO_QUERY_PLAN_KILLED_CODE} is
|
|
203
|
+
# re-run before the error propagates. MongoDB's guidance is to re-run
|
|
204
|
+
# once the write that changed the index completes, so a small budget
|
|
205
|
+
# is enough; a persistent kill is a real failure the caller sees.
|
|
206
|
+
QUERY_KILLED_RETRIES = 2
|
|
207
|
+
|
|
194
208
|
class << self
|
|
195
209
|
# @!attribute [rw] enabled
|
|
196
210
|
# Feature flag to enable/disable direct MongoDB queries.
|
|
@@ -1862,7 +1876,7 @@ module Parse
|
|
|
1862
1876
|
if (mode = normalize_read_preference(read_preference))
|
|
1863
1877
|
coll = coll.with(read: { mode: mode })
|
|
1864
1878
|
end
|
|
1865
|
-
results = coll.aggregate(pipeline, agg_opts).to_a
|
|
1879
|
+
results = with_query_killed_retry(collection_name) { coll.aggregate(pipeline, agg_opts).to_a }
|
|
1866
1880
|
Parse::ACLScope.redact_results!(results, resolution)
|
|
1867
1881
|
|
|
1868
1882
|
# Post-fetch pointerFields filter: drop rows where none of the
|
|
@@ -2145,7 +2159,7 @@ module Parse
|
|
|
2145
2159
|
cursor = cursor.projection(options[:projection]) if options[:projection]
|
|
2146
2160
|
cursor = cursor.hint(options[:hint]) unless options[:hint].nil?
|
|
2147
2161
|
cursor = cursor.max_time_ms(max_time_ms) if max_time_ms
|
|
2148
|
-
results = cursor.to_a
|
|
2162
|
+
results = with_query_killed_retry(collection_name) { cursor.to_a }
|
|
2149
2163
|
|
|
2150
2164
|
if applied_default_limit && results.size > DEFAULT_FIND_LIMIT
|
|
2151
2165
|
# Trim the sentinel row and warn — the caller asked for everything
|
|
@@ -2451,6 +2465,39 @@ module Parse
|
|
|
2451
2465
|
# MongoDB error code for MaxTimeMSExpired
|
|
2452
2466
|
MONGO_MAX_TIME_MS_EXPIRED_CODE = 50
|
|
2453
2467
|
|
|
2468
|
+
# Run a read and re-run it when the server killed its plan (see
|
|
2469
|
+
# {MONGO_QUERY_PLAN_KILLED_CODE}). Only wraps reads: re-running a
|
|
2470
|
+
# find or aggregate that returned nothing has no side effects. Each
|
|
2471
|
+
# retry emits `parse.mongodb.query_killed_retry`.
|
|
2472
|
+
#
|
|
2473
|
+
# @param collection_name [String] for the notification payload.
|
|
2474
|
+
# @yieldreturn [Object] the read's result.
|
|
2475
|
+
def with_query_killed_retry(collection_name)
|
|
2476
|
+
attempts = 0
|
|
2477
|
+
begin
|
|
2478
|
+
yield
|
|
2479
|
+
rescue StandardError => e
|
|
2480
|
+
raise unless query_plan_killed?(e) && attempts < QUERY_KILLED_RETRIES
|
|
2481
|
+
attempts += 1
|
|
2482
|
+
ActiveSupport::Notifications.instrument(
|
|
2483
|
+
"parse.mongodb.query_killed_retry",
|
|
2484
|
+
collection: collection_name.to_s, attempt: attempts,
|
|
2485
|
+
)
|
|
2486
|
+
retry
|
|
2487
|
+
end
|
|
2488
|
+
end
|
|
2489
|
+
|
|
2490
|
+
# @return [Boolean] true when `err` is the driver's report of a
|
|
2491
|
+
# killed query plan. Matches the numeric code first and the code
|
|
2492
|
+
# name second, since older response-parsing paths have surfaced
|
|
2493
|
+
# only one of the two.
|
|
2494
|
+
def query_plan_killed?(err)
|
|
2495
|
+
return false unless defined?(::Mongo::Error::OperationFailure)
|
|
2496
|
+
return false unless err.is_a?(::Mongo::Error::OperationFailure)
|
|
2497
|
+
return true if err.respond_to?(:code) && err.code == MONGO_QUERY_PLAN_KILLED_CODE
|
|
2498
|
+
err.respond_to?(:code_name) && err.code_name == "QueryPlanKilled"
|
|
2499
|
+
end
|
|
2500
|
+
|
|
2454
2501
|
# Inspect a driver exception and raise {ExecutionTimeout} if it carries
|
|
2455
2502
|
# error code 50 (MaxTimeMSExpired). Otherwise, the original exception is
|
|
2456
2503
|
# re-raised by the caller.
|
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
|
-
|
|
2444
|
-
|
|
2445
|
-
|
|
2446
|
-
|
|
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
|
data/lib/parse/stack/version.rb
CHANGED
data/parse-stack-next.gemspec
CHANGED
|
@@ -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
|
+
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
|