parse-stack-next 5.5.6 → 5.7.0
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 +634 -0
- data/README.md +378 -6
- data/docs/caching.md +748 -0
- data/examples/basic_client.rb +3 -3
- data/examples/basic_server.rb +3 -3
- data/examples/live_query_listener.rb +12 -12
- data/examples/rag_chatbot.rb +14 -14
- data/examples/transaction_example.rb +44 -45
- data/examples/webhook_server.rb +3 -3
- data/lib/parse/access.rb +493 -0
- data/lib/parse/acl_scope.rb +125 -52
- data/lib/parse/agent/approval_gate.rb +0 -0
- data/lib/parse/agent/cancellation_token.rb +3 -3
- data/lib/parse/agent/constraint_translator.rb +4 -4
- data/lib/parse/agent/describe.rb +34 -34
- data/lib/parse/agent/errors.rb +9 -9
- data/lib/parse/agent/mcp_client.rb +61 -59
- data/lib/parse/agent/mcp_dispatcher.rb +89 -101
- data/lib/parse/agent/mcp_rack_app.rb +92 -93
- data/lib/parse/agent/mcp_server.rb +1 -1
- data/lib/parse/agent/mcp_subscriptions.rb +22 -22
- data/lib/parse/agent/metadata_audit.rb +1 -2
- data/lib/parse/agent/metadata_dsl.rb +17 -7
- data/lib/parse/agent/metadata_registry.rb +12 -12
- data/lib/parse/agent/prompt_hardening.rb +4 -4
- data/lib/parse/agent/prompts.rb +10 -10
- data/lib/parse/agent/result_formatter.rb +4 -4
- data/lib/parse/agent/tools.rb +493 -400
- data/lib/parse/agent.rb +157 -116
- data/lib/parse/api/hooks.rb +1 -1
- data/lib/parse/api/server.rb +2 -2
- data/lib/parse/api/users.rb +2 -2
- data/lib/parse/atlas_search/index_manager.rb +1 -1
- data/lib/parse/atlas_search/session.rb +40 -218
- data/lib/parse/atlas_search.rb +181 -60
- data/lib/parse/authorization.rb +466 -0
- data/lib/parse/cache/invalidation.rb +167 -0
- data/lib/parse/cache/keyspace.rb +306 -0
- data/lib/parse/cache/moneta_surface.rb +126 -0
- data/lib/parse/cache/pool.rb +48 -5
- data/lib/parse/cache/redis.rb +425 -10
- data/lib/parse/cache/scoped_view.rb +443 -0
- data/lib/parse/cache/sub_cache.rb +264 -0
- data/lib/parse/cache/upstream_roles.rb +230 -0
- data/lib/parse/client/authentication.rb +1 -1
- data/lib/parse/client/body_builder.rb +1 -2
- data/lib/parse/client/caching.rb +84 -5
- data/lib/parse/client.rb +327 -189
- data/lib/parse/clp_scope.rb +225 -28
- data/lib/parse/console.rb +3 -3
- data/lib/parse/embeddings/batch_embedder.rb +3 -3
- data/lib/parse/embeddings/binding_audit.rb +211 -0
- data/lib/parse/embeddings/cache.rb +17 -17
- data/lib/parse/embeddings/cohere.rb +21 -22
- data/lib/parse/embeddings/image_fetch.rb +22 -21
- data/lib/parse/embeddings/jina.rb +21 -21
- data/lib/parse/embeddings/local_http.rb +6 -7
- data/lib/parse/embeddings/media_file.rb +137 -0
- data/lib/parse/embeddings/openai.rb +5 -5
- data/lib/parse/embeddings/provider.rb +40 -1
- data/lib/parse/embeddings/qwen.rb +10 -10
- data/lib/parse/embeddings/spend_cap.rb +2 -3
- data/lib/parse/embeddings/streaming_body.rb +170 -0
- data/lib/parse/embeddings/video_source.rb +121 -0
- data/lib/parse/embeddings/voyage.rb +554 -143
- data/lib/parse/embeddings.rb +83 -27
- data/lib/parse/graphql/type_generator.rb +1 -1
- data/lib/parse/graphql.rb +5 -5
- data/lib/parse/live_query/client.rb +1 -1
- data/lib/parse/live_query.rb +1 -1
- data/lib/parse/lock.rb +15 -18
- data/lib/parse/lock_backend.rb +1 -1
- data/lib/parse/lookup_rewriter.rb +6 -6
- data/lib/parse/model/classes/role.rb +227 -28
- data/lib/parse/model/classes/user.rb +60 -1
- data/lib/parse/model/clp.rb +4 -4
- data/lib/parse/model/core/actions.rb +97 -15
- data/lib/parse/model/core/create_lock.rb +0 -2
- data/lib/parse/model/core/describe.rb +57 -57
- data/lib/parse/model/core/embed_managed.rb +43 -19
- data/lib/parse/model/core/fetching.rb +2 -0
- data/lib/parse/model/core/indexing.rb +14 -14
- data/lib/parse/model/core/parse_reference.rb +1 -1
- data/lib/parse/model/core/properties.rb +39 -3
- data/lib/parse/model/core/querying.rb +1 -1
- data/lib/parse/model/core/schema.rb +2 -2
- data/lib/parse/model/core/search_indexing.rb +2 -2
- data/lib/parse/model/core/vector_searchable.rb +34 -8
- data/lib/parse/model/file.rb +21 -15
- data/lib/parse/model/geojson.rb +2 -2
- data/lib/parse/model/geopoint.rb +1 -0
- data/lib/parse/model/object.rb +132 -66
- data/lib/parse/model/pointer.rb +2 -0
- data/lib/parse/model/polygon.rb +3 -6
- data/lib/parse/model/push.rb +2 -2
- data/lib/parse/model/vector.rb +3 -1
- data/lib/parse/mongodb.rb +512 -282
- data/lib/parse/pipeline_security.rb +3 -0
- data/lib/parse/query/constraints.rb +46 -46
- data/lib/parse/query.rb +146 -59
- data/lib/parse/retrieval/agent_tool.rb +34 -20
- data/lib/parse/retrieval/chunk.rb +1 -0
- data/lib/parse/retrieval/reranker/cohere.rb +11 -11
- data/lib/parse/retrieval/reranker.rb +3 -4
- data/lib/parse/retrieval/retriever.rb +4 -6
- data/lib/parse/schema/index_migrator.rb +26 -26
- data/lib/parse/schema/search_index_migrator.rb +19 -19
- data/lib/parse/stack/tasks.rb +6 -6
- data/lib/parse/stack/version.rb +1 -1
- data/lib/parse/stack.rb +10 -10
- data/lib/parse/vector_search/hybrid.rb +166 -33
- data/lib/parse/vector_search.rb +141 -13
- data/lib/parse/webhooks/payload.rb +1 -0
- data/lib/parse/webhooks/registration.rb +4 -4
- data/lib/parse/webhooks/trigger_audit.rb +50 -49
- data/lib/parse/webhooks.rb +22 -4
- data/parse-stack-next.gemspec +6 -6
- metadata +14 -1
|
@@ -68,29 +68,29 @@ module Parse
|
|
|
68
68
|
class RateLimitError < Error; end
|
|
69
69
|
class TransientError < Error; end
|
|
70
70
|
|
|
71
|
-
DEFAULT_BASE_URL
|
|
72
|
-
DEFAULT_MODEL
|
|
73
|
-
DEFAULT_TIMEOUT
|
|
71
|
+
DEFAULT_BASE_URL = "https://api.cohere.com/v1"
|
|
72
|
+
DEFAULT_MODEL = "embed-english-v3.0"
|
|
73
|
+
DEFAULT_TIMEOUT = 30
|
|
74
74
|
DEFAULT_OPEN_TIMEOUT = 5
|
|
75
75
|
DEFAULT_MAX_RETRIES = 3
|
|
76
76
|
# Cohere documents a hard cap of 96 inputs per `/embed` call.
|
|
77
|
-
DEFAULT_BATCH_SIZE
|
|
78
|
-
MAX_RESPONSE_BYTES
|
|
77
|
+
DEFAULT_BATCH_SIZE = 96
|
|
78
|
+
MAX_RESPONSE_BYTES = 16 * 1024 * 1024
|
|
79
79
|
|
|
80
80
|
MODEL_DEFAULT_DIMENSIONS = {
|
|
81
|
-
"embed-v4.0"
|
|
82
|
-
"embed-english-v3.0"
|
|
83
|
-
"embed-multilingual-v3.0"
|
|
84
|
-
"embed-english-light-v3.0"
|
|
85
|
-
"embed-multilingual-light-v3.0"
|
|
81
|
+
"embed-v4.0" => 1536,
|
|
82
|
+
"embed-english-v3.0" => 1024,
|
|
83
|
+
"embed-multilingual-v3.0" => 1024,
|
|
84
|
+
"embed-english-light-v3.0" => 384,
|
|
85
|
+
"embed-multilingual-light-v3.0" => 384,
|
|
86
86
|
}.freeze
|
|
87
87
|
|
|
88
88
|
MODEL_MAX_INPUT_TOKENS = {
|
|
89
|
-
"embed-v4.0"
|
|
90
|
-
"embed-english-v3.0"
|
|
91
|
-
"embed-multilingual-v3.0"
|
|
92
|
-
"embed-english-light-v3.0"
|
|
93
|
-
"embed-multilingual-light-v3.0"
|
|
89
|
+
"embed-v4.0" => 128_000,
|
|
90
|
+
"embed-english-v3.0" => 512,
|
|
91
|
+
"embed-multilingual-v3.0" => 512,
|
|
92
|
+
"embed-english-light-v3.0" => 512,
|
|
93
|
+
"embed-multilingual-light-v3.0" => 512,
|
|
94
94
|
}.freeze
|
|
95
95
|
|
|
96
96
|
# Models that accept Cohere's `output_dimension` Matryoshka
|
|
@@ -115,10 +115,10 @@ module Parse
|
|
|
115
115
|
# `:unknown_type` to `"search_document"` would mask cache-key
|
|
116
116
|
# bugs in higher layers (the value participates in cache keys).
|
|
117
117
|
INPUT_TYPE_WIRE_VALUES = {
|
|
118
|
-
search_query:
|
|
118
|
+
search_query: "search_query",
|
|
119
119
|
search_document: "search_document",
|
|
120
|
-
classification:
|
|
121
|
-
clustering:
|
|
120
|
+
classification: "classification",
|
|
121
|
+
clustering: "clustering",
|
|
122
122
|
}.freeze
|
|
123
123
|
|
|
124
124
|
# @param api_key [String] required. Sent as `Authorization: Bearer …`.
|
|
@@ -462,7 +462,7 @@ module Parse
|
|
|
462
462
|
next
|
|
463
463
|
end
|
|
464
464
|
raise BadRequestError,
|
|
465
|
-
"Parse::Embeddings::Cohere: #{status} from POST #{path.start_with?(
|
|
465
|
+
"Parse::Embeddings::Cohere: #{status} from POST #{path.start_with?("/") ? path : "/#{path}"}."
|
|
466
466
|
end
|
|
467
467
|
end
|
|
468
468
|
|
|
@@ -498,8 +498,7 @@ module Parse
|
|
|
498
498
|
"Parse::Embeddings::Cohere: response body is not a JSON object."
|
|
499
499
|
end
|
|
500
500
|
embeddings = payload["embeddings"]
|
|
501
|
-
vectors =
|
|
502
|
-
case embeddings
|
|
501
|
+
vectors = case embeddings
|
|
503
502
|
when Hash
|
|
504
503
|
f = embeddings["float"]
|
|
505
504
|
unless f.is_a?(Array)
|
|
@@ -521,7 +520,7 @@ module Parse
|
|
|
521
520
|
end
|
|
522
521
|
|
|
523
522
|
def backoff_seconds(attempt)
|
|
524
|
-
[0.5 * (2**(attempt - 1)), 30.0].min
|
|
523
|
+
[0.5 * (2 ** (attempt - 1)), 30.0].min
|
|
525
524
|
end
|
|
526
525
|
|
|
527
526
|
def retry_after_seconds(response)
|
|
@@ -56,6 +56,7 @@ module Parse
|
|
|
56
56
|
class InvalidImageType < Parse::Embeddings::Error
|
|
57
57
|
# @return [Symbol] failure-mode tag.
|
|
58
58
|
attr_reader :reason
|
|
59
|
+
|
|
59
60
|
def initialize(reason, message)
|
|
60
61
|
@reason = reason
|
|
61
62
|
super(message)
|
|
@@ -76,6 +77,7 @@ module Parse
|
|
|
76
77
|
"#<Parse::Embeddings::ImageFetch::FetchedImage mime_type=#{mime_type.inspect} " \
|
|
77
78
|
"bytes=#{bytes.respond_to?(:bytesize) ? bytes.bytesize : 0} url=#{url.inspect}>"
|
|
78
79
|
end
|
|
80
|
+
|
|
79
81
|
alias_method :to_s, :inspect
|
|
80
82
|
end
|
|
81
83
|
|
|
@@ -88,11 +90,11 @@ module Parse
|
|
|
88
90
|
# against the sniffed type. Extensions not listed here are ignored
|
|
89
91
|
# (the magic bytes alone govern).
|
|
90
92
|
EXTENSION_MIME = {
|
|
91
|
-
".jpg"
|
|
93
|
+
".jpg" => "image/jpeg",
|
|
92
94
|
".jpeg" => "image/jpeg",
|
|
93
|
-
".jpe"
|
|
94
|
-
".png"
|
|
95
|
-
".gif"
|
|
95
|
+
".jpe" => "image/jpeg",
|
|
96
|
+
".png" => "image/png",
|
|
97
|
+
".gif" => "image/gif",
|
|
96
98
|
".webp" => "image/webp",
|
|
97
99
|
}.freeze
|
|
98
100
|
|
|
@@ -109,8 +111,8 @@ module Parse
|
|
|
109
111
|
return nil unless bytes.is_a?(String) && bytes.bytesize >= 12
|
|
110
112
|
b = bytes.byteslice(0, 16).force_encoding(Encoding::BINARY)
|
|
111
113
|
return "image/jpeg" if b.start_with?("\xFF\xD8\xFF".b)
|
|
112
|
-
return "image/png"
|
|
113
|
-
return "image/gif"
|
|
114
|
+
return "image/png" if b.start_with?("\x89PNG\r\n\x1A\n".b)
|
|
115
|
+
return "image/gif" if b.start_with?("GIF87a".b) || b.start_with?("GIF89a".b)
|
|
114
116
|
if b.start_with?("RIFF".b) && b.byteslice(8, 4) == "WEBP".b
|
|
115
117
|
return "image/webp"
|
|
116
118
|
end
|
|
@@ -177,26 +179,26 @@ module Parse
|
|
|
177
179
|
def verify!(bytes, url: nil)
|
|
178
180
|
if bytes.nil? || bytes.empty?
|
|
179
181
|
raise InvalidImageType.new(:empty,
|
|
180
|
-
|
|
182
|
+
"Parse::Embeddings::ImageFetch: downloaded body is empty.")
|
|
181
183
|
end
|
|
182
184
|
mime = sniff_mime(bytes)
|
|
183
185
|
if mime.nil?
|
|
184
186
|
raise InvalidImageType.new(:unknown_magic,
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
187
|
+
"Parse::Embeddings::ImageFetch: leading bytes match no supported image " \
|
|
188
|
+
"format (JPEG/PNG/GIF/WebP). The Content-Type header is not consulted — " \
|
|
189
|
+
"unrecognized content is refused outright.")
|
|
188
190
|
end
|
|
189
191
|
allowed = Parse::Embeddings.allowed_image_types
|
|
190
192
|
unless allowed.include?(mime)
|
|
191
193
|
raise InvalidImageType.new(:type_not_allowed,
|
|
192
|
-
|
|
193
|
-
|
|
194
|
+
"Parse::Embeddings::ImageFetch: sniffed type #{mime.inspect} is not in " \
|
|
195
|
+
"Parse::Embeddings.allowed_image_types (#{allowed.inspect}).")
|
|
194
196
|
end
|
|
195
197
|
ext_mime = extension_mime(url)
|
|
196
198
|
if ext_mime && ext_mime != mime
|
|
197
199
|
raise InvalidImageType.new(:extension_mismatch,
|
|
198
|
-
|
|
199
|
-
|
|
200
|
+
"Parse::Embeddings::ImageFetch: URL extension implies #{ext_mime.inspect} " \
|
|
201
|
+
"but the magic bytes are #{mime.inspect} — refusing MIME-laundered content.")
|
|
200
202
|
end
|
|
201
203
|
mime
|
|
202
204
|
end
|
|
@@ -210,10 +212,10 @@ module Parse
|
|
|
210
212
|
def extension_mime(url)
|
|
211
213
|
return nil unless url.is_a?(String)
|
|
212
214
|
path = begin
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
|
|
215
|
+
URI.parse(url).path.to_s
|
|
216
|
+
rescue URI::InvalidURIError
|
|
217
|
+
return nil
|
|
218
|
+
end
|
|
217
219
|
dot = path.rindex(".")
|
|
218
220
|
return nil if dot.nil?
|
|
219
221
|
EXTENSION_MIME[path[dot..].to_s.downcase]
|
|
@@ -231,10 +233,9 @@ module Parse
|
|
|
231
233
|
# @param mime [String] sniffed MIME type.
|
|
232
234
|
# @return [String] bytes with metadata removed.
|
|
233
235
|
def strip_metadata(bytes, mime)
|
|
234
|
-
stripped =
|
|
235
|
-
case mime
|
|
236
|
+
stripped = case mime
|
|
236
237
|
when "image/jpeg" then strip_jpeg_app1(bytes)
|
|
237
|
-
when "image/png"
|
|
238
|
+
when "image/png" then strip_png_exif(bytes)
|
|
238
239
|
when "image/webp" then strip_webp_metadata(bytes)
|
|
239
240
|
else return bytes
|
|
240
241
|
end
|
|
@@ -63,36 +63,36 @@ module Parse
|
|
|
63
63
|
class RateLimitError < Error; end
|
|
64
64
|
class TransientError < Error; end
|
|
65
65
|
|
|
66
|
-
DEFAULT_BASE_URL
|
|
67
|
-
DEFAULT_MODEL
|
|
68
|
-
DEFAULT_TIMEOUT
|
|
66
|
+
DEFAULT_BASE_URL = "https://api.jina.ai/v1"
|
|
67
|
+
DEFAULT_MODEL = "jina-embeddings-v3"
|
|
68
|
+
DEFAULT_TIMEOUT = 30
|
|
69
69
|
DEFAULT_OPEN_TIMEOUT = 5
|
|
70
70
|
DEFAULT_MAX_RETRIES = 3
|
|
71
|
-
DEFAULT_BATCH_SIZE
|
|
72
|
-
MAX_RESPONSE_BYTES
|
|
71
|
+
DEFAULT_BATCH_SIZE = 100
|
|
72
|
+
MAX_RESPONSE_BYTES = 16 * 1024 * 1024
|
|
73
73
|
|
|
74
74
|
# Native vector widths. The Matryoshka-capable rows allow the
|
|
75
75
|
# caller to truncate via the `dimensions:` kwarg.
|
|
76
76
|
MODEL_DEFAULT_DIMENSIONS = {
|
|
77
77
|
"jina-embeddings-v5-omni-small" => 1024,
|
|
78
|
-
"jina-embeddings-v5-omni-nano"
|
|
78
|
+
"jina-embeddings-v5-omni-nano" => 512,
|
|
79
79
|
"jina-embeddings-v5-text-small" => 1024,
|
|
80
|
-
"jina-embeddings-v5-text-nano"
|
|
81
|
-
"jina-embeddings-v4"
|
|
82
|
-
"jina-embeddings-v3"
|
|
83
|
-
"jina-code-embeddings-1.5b"
|
|
84
|
-
"jina-code-embeddings-0.5b"
|
|
80
|
+
"jina-embeddings-v5-text-nano" => 512,
|
|
81
|
+
"jina-embeddings-v4" => 2048,
|
|
82
|
+
"jina-embeddings-v3" => 1024,
|
|
83
|
+
"jina-code-embeddings-1.5b" => 1024,
|
|
84
|
+
"jina-code-embeddings-0.5b" => 1024,
|
|
85
85
|
}.freeze
|
|
86
86
|
|
|
87
87
|
MODEL_MAX_INPUT_TOKENS = {
|
|
88
88
|
"jina-embeddings-v5-omni-small" => 32_000,
|
|
89
|
-
"jina-embeddings-v5-omni-nano"
|
|
89
|
+
"jina-embeddings-v5-omni-nano" => 32_000,
|
|
90
90
|
"jina-embeddings-v5-text-small" => 32_000,
|
|
91
|
-
"jina-embeddings-v5-text-nano"
|
|
92
|
-
"jina-embeddings-v4"
|
|
93
|
-
"jina-embeddings-v3"
|
|
94
|
-
"jina-code-embeddings-1.5b"
|
|
95
|
-
"jina-code-embeddings-0.5b"
|
|
91
|
+
"jina-embeddings-v5-text-nano" => 32_000,
|
|
92
|
+
"jina-embeddings-v4" => 32_000,
|
|
93
|
+
"jina-embeddings-v3" => 8_192,
|
|
94
|
+
"jina-code-embeddings-1.5b" => 32_000,
|
|
95
|
+
"jina-code-embeddings-0.5b" => 32_000,
|
|
96
96
|
}.freeze
|
|
97
97
|
|
|
98
98
|
# Models that accept the Matryoshka `dimensions` field. Other
|
|
@@ -108,10 +108,10 @@ module Parse
|
|
|
108
108
|
|
|
109
109
|
# Map SDK-canonical input_type symbols to Jina `task` strings.
|
|
110
110
|
INPUT_TYPE_WIRE_VALUES = {
|
|
111
|
-
search_query:
|
|
111
|
+
search_query: "retrieval.query",
|
|
112
112
|
search_document: "retrieval.passage",
|
|
113
|
-
classification:
|
|
114
|
-
clustering:
|
|
113
|
+
classification: "classification",
|
|
114
|
+
clustering: "separation",
|
|
115
115
|
}.freeze
|
|
116
116
|
|
|
117
117
|
# @param api_key [String] required. Sent as `Authorization: Bearer …`.
|
|
@@ -352,7 +352,7 @@ module Parse
|
|
|
352
352
|
end
|
|
353
353
|
|
|
354
354
|
def backoff_seconds(attempt)
|
|
355
|
-
[0.5 * (2**(attempt - 1)), 30.0].min
|
|
355
|
+
[0.5 * (2 ** (attempt - 1)), 30.0].min
|
|
356
356
|
end
|
|
357
357
|
|
|
358
358
|
def retry_after_seconds(response)
|
|
@@ -78,11 +78,11 @@ module Parse
|
|
|
78
78
|
class RateLimitError < Error; end
|
|
79
79
|
class TransientError < Error; end
|
|
80
80
|
|
|
81
|
-
DEFAULT_TIMEOUT
|
|
81
|
+
DEFAULT_TIMEOUT = 30
|
|
82
82
|
DEFAULT_OPEN_TIMEOUT = 5
|
|
83
|
-
DEFAULT_MAX_RETRIES
|
|
84
|
-
DEFAULT_BATCH_SIZE
|
|
85
|
-
MAX_RESPONSE_BYTES
|
|
83
|
+
DEFAULT_MAX_RETRIES = 3
|
|
84
|
+
DEFAULT_BATCH_SIZE = 32
|
|
85
|
+
MAX_RESPONSE_BYTES = 16 * 1024 * 1024
|
|
86
86
|
|
|
87
87
|
# @param base_url [String] required. Must be http(s):// with a host.
|
|
88
88
|
# @param model [String] required. Identifier the local server expects
|
|
@@ -353,7 +353,7 @@ module Parse
|
|
|
353
353
|
end
|
|
354
354
|
|
|
355
355
|
def backoff_seconds(attempt)
|
|
356
|
-
[0.5 * (2**(attempt - 1)), 30.0].min
|
|
356
|
+
[0.5 * (2 ** (attempt - 1)), 30.0].min
|
|
357
357
|
end
|
|
358
358
|
|
|
359
359
|
def retry_after_seconds(response)
|
|
@@ -414,8 +414,7 @@ module Parse
|
|
|
414
414
|
# Empty-resolution under allow_private_endpoint is treated as
|
|
415
415
|
# private for the http:// scheme gate below, since the operator
|
|
416
416
|
# has already asserted local-class trust.
|
|
417
|
-
is_private =
|
|
418
|
-
if resolved.empty?
|
|
417
|
+
is_private = if resolved.empty?
|
|
419
418
|
allow_private_endpoint
|
|
420
419
|
else
|
|
421
420
|
resolved.any? { |ip| Parse::File::BLOCKED_CIDRS.any? { |cidr| cidr.include?(ip) } }
|
|
@@ -0,0 +1,137 @@
|
|
|
1
|
+
# encoding: UTF-8
|
|
2
|
+
# frozen_string_literal: true
|
|
3
|
+
|
|
4
|
+
module Parse
|
|
5
|
+
module Embeddings
|
|
6
|
+
# A file-backed image or video input that is **streamed** into the
|
|
7
|
+
# request body rather than read into memory.
|
|
8
|
+
#
|
|
9
|
+
# This is the memory-safe counterpart to
|
|
10
|
+
# {ImageFetch::FetchedImage}, which holds raw bytes. A MediaFile
|
|
11
|
+
# holds only a path, a sniffed MIME type, and a byte count; the
|
|
12
|
+
# bytes are read and base64-encoded incrementally by
|
|
13
|
+
# {StreamingBody} while the request is being written to the socket.
|
|
14
|
+
# Peak memory stays at {StreamingBody::READ_CHUNK} no matter how
|
|
15
|
+
# large the file is, which is what makes video viable on a small
|
|
16
|
+
# dyno.
|
|
17
|
+
#
|
|
18
|
+
# Only the first 16 bytes are read at construction time, to sniff
|
|
19
|
+
# the container. The `Content-Type` header and the filename
|
|
20
|
+
# extension are never consulted.
|
|
21
|
+
#
|
|
22
|
+
# @example stream a local image
|
|
23
|
+
# img = Parse::Embeddings::MediaFile.image("diagram.png")
|
|
24
|
+
# provider.embed_image([img])
|
|
25
|
+
#
|
|
26
|
+
# @example stream a local video
|
|
27
|
+
# clip = Parse::Embeddings::MediaFile.video("demo.mp4")
|
|
28
|
+
# provider.embed_video([clip])
|
|
29
|
+
class MediaFile
|
|
30
|
+
# Voyage documents 20 MB per image and 20 MB per video.
|
|
31
|
+
# Overridable via {Parse::Embeddings.max_media_bytes=}.
|
|
32
|
+
DEFAULT_MAX_MEDIA_BYTES = 20 * 1024 * 1024
|
|
33
|
+
|
|
34
|
+
# Raised when a file exceeds {Parse::Embeddings.max_media_bytes}.
|
|
35
|
+
class TooLarge < Parse::Embeddings::Error; end
|
|
36
|
+
|
|
37
|
+
# @return [String] absolute path to the backing file.
|
|
38
|
+
attr_reader :path
|
|
39
|
+
# @return [String] sniffed MIME type.
|
|
40
|
+
attr_reader :mime_type
|
|
41
|
+
# @return [Integer] size in bytes, captured at construction.
|
|
42
|
+
attr_reader :byte_size
|
|
43
|
+
# @return [Symbol] `:image` or `:video`.
|
|
44
|
+
attr_reader :kind
|
|
45
|
+
|
|
46
|
+
class << self
|
|
47
|
+
# Wrap a local image file. Verifies the magic bytes against
|
|
48
|
+
# {Parse::Embeddings.allowed_image_types}.
|
|
49
|
+
#
|
|
50
|
+
# @param path [String]
|
|
51
|
+
# @return [MediaFile]
|
|
52
|
+
# @raise [ImageFetch::InvalidImageType]
|
|
53
|
+
def image(path)
|
|
54
|
+
header = read_header(path)
|
|
55
|
+
mime = ImageFetch.sniff_mime(header)
|
|
56
|
+
if mime.nil?
|
|
57
|
+
raise ImageFetch::InvalidImageType.new(:unknown_magic,
|
|
58
|
+
"Parse::Embeddings::MediaFile.image: #{path} matches no supported image " \
|
|
59
|
+
"format (JPEG/PNG/GIF/WebP).")
|
|
60
|
+
end
|
|
61
|
+
allowed = Parse::Embeddings.allowed_image_types
|
|
62
|
+
unless allowed.include?(mime)
|
|
63
|
+
raise ImageFetch::InvalidImageType.new(:type_not_allowed,
|
|
64
|
+
"Parse::Embeddings::MediaFile.image: sniffed type #{mime.inspect} is not in " \
|
|
65
|
+
"Parse::Embeddings.allowed_image_types (#{allowed.inspect}).")
|
|
66
|
+
end
|
|
67
|
+
new(path: path, mime_type: mime, kind: :image)
|
|
68
|
+
end
|
|
69
|
+
|
|
70
|
+
# Wrap a local video file. Verifies the magic bytes against
|
|
71
|
+
# {Parse::Embeddings.allowed_video_types}.
|
|
72
|
+
#
|
|
73
|
+
# @param path [String]
|
|
74
|
+
# @return [MediaFile]
|
|
75
|
+
# @raise [VideoSource::InvalidVideoType]
|
|
76
|
+
def video(path)
|
|
77
|
+
header = read_header(path)
|
|
78
|
+
new(path: path, mime_type: VideoSource.verify!(header), kind: :video)
|
|
79
|
+
end
|
|
80
|
+
|
|
81
|
+
private
|
|
82
|
+
|
|
83
|
+
# Read only enough bytes to sniff a container. Deliberately
|
|
84
|
+
# tiny — the whole point of this class is to never hold the
|
|
85
|
+
# file. A file shorter than this is passed through as-is so the
|
|
86
|
+
# sniffers can reject it with their own error.
|
|
87
|
+
def read_header(path)
|
|
88
|
+
unless ::File.file?(path)
|
|
89
|
+
raise ArgumentError,
|
|
90
|
+
"Parse::Embeddings::MediaFile: #{path.inspect} is not a readable file."
|
|
91
|
+
end
|
|
92
|
+
::File.open(path, "rb") { |f| f.read(16).to_s }
|
|
93
|
+
end
|
|
94
|
+
end
|
|
95
|
+
|
|
96
|
+
def initialize(path:, mime_type:, kind:)
|
|
97
|
+
@path = ::File.expand_path(path)
|
|
98
|
+
@mime_type = mime_type
|
|
99
|
+
@kind = kind
|
|
100
|
+
@byte_size = ::File.size(@path)
|
|
101
|
+
if @byte_size.zero?
|
|
102
|
+
raise ArgumentError,
|
|
103
|
+
"Parse::Embeddings::MediaFile: #{path.inspect} is empty."
|
|
104
|
+
end
|
|
105
|
+
cap = Parse::Embeddings.max_media_bytes
|
|
106
|
+
if @byte_size > cap
|
|
107
|
+
raise TooLarge,
|
|
108
|
+
"Parse::Embeddings::MediaFile: #{path.inspect} is #{@byte_size} bytes, over " \
|
|
109
|
+
"the #{cap}-byte limit (Parse::Embeddings.max_media_bytes). Voyage rejects " \
|
|
110
|
+
"media above 20 MB — downscale or re-encode before embedding."
|
|
111
|
+
end
|
|
112
|
+
end
|
|
113
|
+
|
|
114
|
+
# The `data:` URI prefix that precedes the streamed base64 in the
|
|
115
|
+
# wire body. The payload itself is never concatenated here.
|
|
116
|
+
#
|
|
117
|
+
# @return [String]
|
|
118
|
+
def data_uri_prefix
|
|
119
|
+
"data:#{mime_type};base64,"
|
|
120
|
+
end
|
|
121
|
+
|
|
122
|
+
# Segment descriptor consumed by {StreamingBody}.
|
|
123
|
+
#
|
|
124
|
+
# @return [Hash]
|
|
125
|
+
def stream_segment
|
|
126
|
+
{ path: @path, size: @byte_size }
|
|
127
|
+
end
|
|
128
|
+
|
|
129
|
+
def inspect
|
|
130
|
+
"#<Parse::Embeddings::MediaFile kind=#{@kind} mime_type=#{@mime_type.inspect} " \
|
|
131
|
+
"bytes=#{@byte_size} path=#{@path.inspect}>"
|
|
132
|
+
end
|
|
133
|
+
|
|
134
|
+
alias_method :to_s, :inspect
|
|
135
|
+
end
|
|
136
|
+
end
|
|
137
|
+
end
|
|
@@ -49,12 +49,12 @@ module Parse
|
|
|
49
49
|
class RateLimitError < Error; end
|
|
50
50
|
class TransientError < Error; end
|
|
51
51
|
|
|
52
|
-
DEFAULT_BASE_URL
|
|
53
|
-
DEFAULT_MODEL
|
|
54
|
-
DEFAULT_TIMEOUT
|
|
52
|
+
DEFAULT_BASE_URL = "https://api.openai.com/v1"
|
|
53
|
+
DEFAULT_MODEL = "text-embedding-3-small"
|
|
54
|
+
DEFAULT_TIMEOUT = 30
|
|
55
55
|
DEFAULT_OPEN_TIMEOUT = 5
|
|
56
56
|
DEFAULT_MAX_RETRIES = 3
|
|
57
|
-
DEFAULT_BATCH_SIZE
|
|
57
|
+
DEFAULT_BATCH_SIZE = 100
|
|
58
58
|
|
|
59
59
|
# Hard ceiling on the response body we'll parse. A legitimate
|
|
60
60
|
# OpenAI embeddings response for the worst-case configuration
|
|
@@ -397,7 +397,7 @@ module Parse
|
|
|
397
397
|
# synchronize the retry storm exponentially.
|
|
398
398
|
def backoff_seconds(attempt)
|
|
399
399
|
# 0.5, 1.0, 2.0, 4.0, 8.0 … capped at 30s
|
|
400
|
-
[0.5 * (2**(attempt - 1)), 30.0].min
|
|
400
|
+
[0.5 * (2 ** (attempt - 1)), 30.0].min
|
|
401
401
|
end
|
|
402
402
|
|
|
403
403
|
def retry_after_seconds(response)
|
|
@@ -24,6 +24,7 @@ module Parse
|
|
|
24
24
|
# Subclasses MAY override:
|
|
25
25
|
#
|
|
26
26
|
# * {#embed_image} — v5.1 (multimodal); default `NotImplementedError`
|
|
27
|
+
# * {#embed_video} — v5.6 (multimodal); default `NotImplementedError`
|
|
27
28
|
# * {#embed_batch_size} — provider-recommended batch size hint
|
|
28
29
|
# * {#max_input_tokens} — chunker hint
|
|
29
30
|
# * {#normalize?} — whether output is unit-normalized
|
|
@@ -76,6 +77,44 @@ module Parse
|
|
|
76
77
|
raise NotImplementedError, "#{self.class} does not support image embedding"
|
|
77
78
|
end
|
|
78
79
|
|
|
80
|
+
# Embed video sources. Same contract and default posture as
|
|
81
|
+
# {#embed_image}: providers that do not offer video leave this
|
|
82
|
+
# raising, and callers discover support through {#modalities}
|
|
83
|
+
# rather than by rescuing.
|
|
84
|
+
#
|
|
85
|
+
# Video payloads are large enough that holding one in memory is a
|
|
86
|
+
# real operational risk, so the two source forms concrete
|
|
87
|
+
# providers should accept are:
|
|
88
|
+
#
|
|
89
|
+
# * a URL String, forwarded for provider-side fetch after
|
|
90
|
+
# {Parse::Embeddings.validate_image_url!} screens it (the SDK
|
|
91
|
+
# never downloads the video), and
|
|
92
|
+
# * a {Parse::Embeddings::MediaFile}, streamed into the request
|
|
93
|
+
# body by {Parse::Embeddings::StreamingBody} without ever being
|
|
94
|
+
# fully resident.
|
|
95
|
+
#
|
|
96
|
+
# Concrete overrides must accept `allow_insecure:` explicitly or
|
|
97
|
+
# absorb it via `**opts` — see {#embed_image} for why.
|
|
98
|
+
#
|
|
99
|
+
# @param sources [Array<String, Parse::Embeddings::MediaFile>]
|
|
100
|
+
# @param input_type [Symbol] `:search_query` or `:search_document`.
|
|
101
|
+
# @param allow_insecure [Boolean] forwarded to the URL validator.
|
|
102
|
+
# @param opts [Hash] provider-specific options.
|
|
103
|
+
# @return [Array<Array<Float>>] vectors aligned 1:1 with `sources`.
|
|
104
|
+
# @raise [NotImplementedError] unless the provider offers video.
|
|
105
|
+
def embed_video(sources, input_type: :search_document, allow_insecure: false, **opts)
|
|
106
|
+
raise NotImplementedError, "#{self.class} does not support video embedding"
|
|
107
|
+
end
|
|
108
|
+
|
|
109
|
+
# @return [Boolean] whether this provider accepts `modality`.
|
|
110
|
+
# Prefer this over rescuing {NotImplementedError} — it answers
|
|
111
|
+
# the question without issuing a call.
|
|
112
|
+
#
|
|
113
|
+
# @param modality [Symbol] one of `:text`, `:image`, `:video`.
|
|
114
|
+
def supports_modality?(modality)
|
|
115
|
+
modalities.include?(modality.to_sym)
|
|
116
|
+
end
|
|
117
|
+
|
|
79
118
|
# Batched text embedding. Splits `strings` into chunks of size
|
|
80
119
|
# {#embed_batch_size} (or returns a single-shot call when nil) and
|
|
81
120
|
# concatenates results. Concrete providers should override only
|
|
@@ -206,7 +245,7 @@ module Parse
|
|
|
206
245
|
def inspect_attrs
|
|
207
246
|
out = {}
|
|
208
247
|
out[:model] = safe_call(:model_name)
|
|
209
|
-
out[:dim]
|
|
248
|
+
out[:dim] = safe_call(:dimensions)
|
|
210
249
|
out.compact
|
|
211
250
|
end
|
|
212
251
|
|
|
@@ -60,27 +60,27 @@ module Parse
|
|
|
60
60
|
# Default to the international compatible-mode host. Operators
|
|
61
61
|
# in mainland China should override to
|
|
62
62
|
# `https://dashscope.aliyuncs.com/compatible-mode/v1`.
|
|
63
|
-
DEFAULT_BASE_URL
|
|
64
|
-
DEFAULT_MODEL
|
|
65
|
-
DEFAULT_TIMEOUT
|
|
63
|
+
DEFAULT_BASE_URL = "https://dashscope-intl.aliyuncs.com/compatible-mode/v1"
|
|
64
|
+
DEFAULT_MODEL = "qwen3-embedding-8b"
|
|
65
|
+
DEFAULT_TIMEOUT = 30
|
|
66
66
|
DEFAULT_OPEN_TIMEOUT = 5
|
|
67
67
|
DEFAULT_MAX_RETRIES = 3
|
|
68
68
|
# DashScope's compatible endpoint caps embedding requests at 25
|
|
69
69
|
# inputs per call (smaller than OpenAI's 2048). Default below
|
|
70
70
|
# the cap so callers don't have to tune.
|
|
71
|
-
DEFAULT_BATCH_SIZE
|
|
72
|
-
MAX_RESPONSE_BYTES
|
|
71
|
+
DEFAULT_BATCH_SIZE = 10
|
|
72
|
+
MAX_RESPONSE_BYTES = 16 * 1024 * 1024
|
|
73
73
|
|
|
74
74
|
MODEL_DEFAULT_DIMENSIONS = {
|
|
75
75
|
"qwen3-embedding-0.6b" => 1024,
|
|
76
|
-
"qwen3-embedding-4b"
|
|
77
|
-
"qwen3-embedding-8b"
|
|
76
|
+
"qwen3-embedding-4b" => 2560,
|
|
77
|
+
"qwen3-embedding-8b" => 4096,
|
|
78
78
|
}.freeze
|
|
79
79
|
|
|
80
80
|
MODEL_MAX_INPUT_TOKENS = {
|
|
81
81
|
"qwen3-embedding-0.6b" => 32_000,
|
|
82
|
-
"qwen3-embedding-4b"
|
|
83
|
-
"qwen3-embedding-8b"
|
|
82
|
+
"qwen3-embedding-4b" => 32_000,
|
|
83
|
+
"qwen3-embedding-8b" => 32_000,
|
|
84
84
|
}.freeze
|
|
85
85
|
|
|
86
86
|
# Every Qwen3-Embedding row is Matryoshka-capable. Kept as an
|
|
@@ -329,7 +329,7 @@ module Parse
|
|
|
329
329
|
end
|
|
330
330
|
|
|
331
331
|
def backoff_seconds(attempt)
|
|
332
|
-
[0.5 * (2**(attempt - 1)), 30.0].min
|
|
332
|
+
[0.5 * (2 ** (attempt - 1)), 30.0].min
|
|
333
333
|
end
|
|
334
334
|
|
|
335
335
|
def retry_after_seconds(response)
|
|
@@ -118,8 +118,7 @@ module Parse
|
|
|
118
118
|
end
|
|
119
119
|
end
|
|
120
120
|
mutex.synchronize do
|
|
121
|
-
limits[key] =
|
|
122
|
-
if limit_tokens.nil?
|
|
121
|
+
limits[key] = if limit_tokens.nil?
|
|
123
122
|
nil
|
|
124
123
|
else
|
|
125
124
|
cfg = { limit: Integer(limit_tokens), window: w }
|
|
@@ -357,7 +356,7 @@ module Parse
|
|
|
357
356
|
# must not serialize every other tenant's charge).
|
|
358
357
|
def emit_soft_cap_warning(payload)
|
|
359
358
|
return unless defined?(ActiveSupport::Notifications)
|
|
360
|
-
ActiveSupport::Notifications.instrument(AS_NOTIFICATION_NAME, payload) {}
|
|
359
|
+
ActiveSupport::Notifications.instrument(AS_NOTIFICATION_NAME, payload) { }
|
|
361
360
|
rescue StandardError
|
|
362
361
|
# A raising subscriber must not turn a successful (admitted)
|
|
363
362
|
# charge into a caller-visible failure.
|