parse-stack-next 5.7.4 → 5.7.6
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 +4 -0
- data/docs/atlas_vector_search_guide.md +134 -11
- data/docs/mcp_guide.md +82 -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/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/agent_tool.rb +65 -9
- 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/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
|
|
@@ -59,10 +59,12 @@ module Parse
|
|
|
59
59
|
|
|
60
60
|
# @param agent [Parse::Agent]
|
|
61
61
|
# @param text_field [String, Symbol, nil] which embedded text source to
|
|
62
|
-
# chunk and return as `content`.
|
|
63
|
-
#
|
|
64
|
-
#
|
|
65
|
-
#
|
|
62
|
+
# chunk and return as `content`. Must name one of the class's declared
|
|
63
|
+
# embed sources that is also inside its `agent_fields` allowlist: an
|
|
64
|
+
# arbitrary field is refused so chunk `content` can't disclose a
|
|
65
|
+
# non-embedded field, and an embedded-but-hidden field is refused with
|
|
66
|
+
# `:field_denied` so it can't disclose a field the agent may not read.
|
|
67
|
+
# When omitted, it is inferred from the readable embed sources.
|
|
66
68
|
# @param max_chunks_per_document [Integer, nil] cap on chunks emitted per
|
|
67
69
|
# matched document (forwarded to the chunker).
|
|
68
70
|
# @param max_total_tokens [Integer, nil] ceiling on total returned
|
|
@@ -329,22 +331,76 @@ module Parse
|
|
|
329
331
|
.flat_map(&:sources).map(&:to_s).uniq
|
|
330
332
|
end
|
|
331
333
|
|
|
334
|
+
# @!visibility private
|
|
335
|
+
# The embed text sources the agent may also READ: those whose wire
|
|
336
|
+
# column is inside the class's `agent_fields` allowlist. Chunk
|
|
337
|
+
# `content` (and any reranker input) is built from the chosen source's
|
|
338
|
+
# raw value BEFORE the per-record projection strips disallowed fields,
|
|
339
|
+
# so the source itself must be readable. With no allowlist every
|
|
340
|
+
# source is readable, as before.
|
|
341
|
+
#
|
|
342
|
+
# @return [Array<String>, nil] readable sources, or nil when the class
|
|
343
|
+
# declares no `agent_fields` allowlist.
|
|
344
|
+
def readable_text_fields(klass)
|
|
345
|
+
allowlist = Parse::Agent::MetadataRegistry.field_allowlist(klass.parse_class)
|
|
346
|
+
return nil if allowlist.nil? || allowlist.empty?
|
|
347
|
+
permitted = allowlist.map(&:to_s)
|
|
348
|
+
searchable_text_fields(klass).select do |source|
|
|
349
|
+
permitted.include?(Parse::Retrieval.send(:wire_name, klass, source))
|
|
350
|
+
end
|
|
351
|
+
end
|
|
352
|
+
|
|
332
353
|
# @!visibility private
|
|
333
354
|
# Validate a caller-supplied text_field against the embedded-source
|
|
334
|
-
#
|
|
335
|
-
#
|
|
336
|
-
#
|
|
355
|
+
# list and the `agent_fields` allowlist, or infer one.
|
|
356
|
+
#
|
|
357
|
+
# * Explicit field that is not an embed source: ValidationError.
|
|
358
|
+
# * Explicit embed source outside `agent_fields`: AccessDenied
|
|
359
|
+
# (`:field_denied`), raised before any search runs.
|
|
360
|
+
# * Omitted, class has no `agent_fields`: nil, so retrieve infers as
|
|
361
|
+
# before (single source) or raises AmbiguousTextField (several).
|
|
362
|
+
# * Omitted, class has `agent_fields`: the sole readable source; a
|
|
363
|
+
# `:field_denied` refusal when none is readable; a ValidationError
|
|
364
|
+
# asking for `text_field` when several are.
|
|
337
365
|
def normalize_text_field!(text_field, klass)
|
|
338
|
-
|
|
366
|
+
readable = readable_text_fields(klass)
|
|
367
|
+
|
|
368
|
+
if text_field.nil? || text_field.to_s.strip.empty?
|
|
369
|
+
return nil if readable.nil?
|
|
370
|
+
return readable.first.to_sym if readable.length == 1
|
|
371
|
+
if readable.empty?
|
|
372
|
+
raise text_field_denied(klass, searchable_text_fields(klass).first)
|
|
373
|
+
end
|
|
374
|
+
raise Parse::Agent::ValidationError,
|
|
375
|
+
"semantic_search: this class embeds several readable text sources; pass " \
|
|
376
|
+
"text_field (allowed: #{readable.inspect})."
|
|
377
|
+
end
|
|
378
|
+
|
|
339
379
|
allowed = searchable_text_fields(klass)
|
|
340
380
|
unless allowed.include?(text_field.to_s)
|
|
341
381
|
raise Parse::Agent::ValidationError,
|
|
342
382
|
"semantic_search: text_field #{text_field.to_s.inspect} is not an embedded " \
|
|
343
|
-
"text source for this class (allowed: #{allowed.inspect})."
|
|
383
|
+
"text source for this class (allowed: #{(readable || allowed).inspect})."
|
|
384
|
+
end
|
|
385
|
+
if readable && !readable.include?(text_field.to_s)
|
|
386
|
+
raise text_field_denied(klass, text_field.to_s)
|
|
344
387
|
end
|
|
345
388
|
text_field.to_sym
|
|
346
389
|
end
|
|
347
390
|
|
|
391
|
+
# @!visibility private
|
|
392
|
+
def text_field_denied(klass, source)
|
|
393
|
+
allowlist = Parse::Agent::MetadataRegistry.field_allowlist(klass.parse_class)
|
|
394
|
+
Parse::Agent::AccessDenied.new(
|
|
395
|
+
klass.parse_class,
|
|
396
|
+
"semantic_search: text source #{source.to_s.inspect} is outside the agent_fields " \
|
|
397
|
+
"allowlist for class '#{klass.parse_class}', so it cannot be returned as chunk content.",
|
|
398
|
+
kind: :field_denied,
|
|
399
|
+
denied_field: source.to_s,
|
|
400
|
+
allowed_fields: allowlist&.map(&:to_s),
|
|
401
|
+
)
|
|
402
|
+
end
|
|
403
|
+
|
|
348
404
|
# @!visibility private
|
|
349
405
|
# Refuse any top-level filter key not in the class's declared
|
|
350
406
|
# `filter_fields` allowlist (compound operators included — the
|
|
@@ -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.6
|
|
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
|