parse-stack-next 5.4.1 → 5.5.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.
Files changed (41) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +489 -0
  3. data/Gemfile.lock +1 -1
  4. data/README.md +61 -9
  5. data/docs/atlas_vector_search_guide.md +318 -19
  6. data/lib/parse/acl_scope.rb +11 -0
  7. data/lib/parse/agent/mcp_rack_app.rb +53 -14
  8. data/lib/parse/agent/mcp_server.rb +19 -0
  9. data/lib/parse/api/path_segment.rb +31 -0
  10. data/lib/parse/api/users.rb +13 -0
  11. data/lib/parse/cache/redis.rb +55 -11
  12. data/lib/parse/client/caching.rb +12 -3
  13. data/lib/parse/client/logging.rb +9 -0
  14. data/lib/parse/client.rb +37 -3
  15. data/lib/parse/embeddings/batch_embedder.rb +188 -0
  16. data/lib/parse/embeddings/cache.rb +374 -0
  17. data/lib/parse/embeddings/cohere.rb +31 -18
  18. data/lib/parse/embeddings/image_fetch.rb +347 -0
  19. data/lib/parse/embeddings/provider.rb +17 -11
  20. data/lib/parse/embeddings/spend_cap.rb +117 -3
  21. data/lib/parse/embeddings/voyage.rb +34 -25
  22. data/lib/parse/embeddings.rb +40 -3
  23. data/lib/parse/model/acl.rb +15 -11
  24. data/lib/parse/model/core/embed_managed.rb +243 -14
  25. data/lib/parse/model/core/properties.rb +42 -5
  26. data/lib/parse/model/core/vector_searchable.rb +157 -8
  27. data/lib/parse/mongodb.rb +12 -0
  28. data/lib/parse/pipeline_security.rb +81 -15
  29. data/lib/parse/query/constraint.rb +22 -0
  30. data/lib/parse/query/constraints.rb +271 -250
  31. data/lib/parse/query.rb +284 -43
  32. data/lib/parse/retrieval/agent_tool.rb +21 -14
  33. data/lib/parse/retrieval/retriever.rb +84 -0
  34. data/lib/parse/schema/search_index_migrator.rb +48 -1
  35. data/lib/parse/stack/version.rb +1 -1
  36. data/lib/parse/stack.rb +12 -1
  37. data/lib/parse/vector_search/hybrid.rb +39 -1
  38. data/lib/parse/vector_search.rb +34 -0
  39. data/lib/parse/webhooks/payload.rb +7 -1
  40. data/lib/parse/webhooks.rb +107 -21
  41. metadata +4 -1
@@ -0,0 +1,374 @@
1
+ # encoding: UTF-8
2
+ # frozen_string_literal: true
3
+
4
+ require "digest"
5
+ require "monitor"
6
+ require "json"
7
+
8
+ module Parse
9
+ module Embeddings
10
+ # Process-local embedding cache keyed by
11
+ # `(provider, model, input_type, input_hash)`.
12
+ #
13
+ # Query-side embedding is the hot repeat path: the same natural-
14
+ # language query (an agent retrying a tool call, a user paging
15
+ # through results, a dashboard refreshing) re-embeds identical text
16
+ # on every call, paying provider latency and per-token cost each
17
+ # time. The cache short-circuits those repeats. Write-side managed
18
+ # embeds (`embed` / `embed_image` save callbacks) already have their
19
+ # own digest-tracked elision and do not use this cache.
20
+ #
21
+ # == Disabled by default
22
+ #
23
+ # With the cache disabled {.fetch_vector} is a pass-through. Opt in:
24
+ #
25
+ # Parse::Embeddings::Cache.enable!(max_entries: 2048, ttl: 600)
26
+ #
27
+ # The default store is an in-process LRU with per-entry TTL. A
28
+ # custom store (e.g. Redis-backed) can be supplied via
29
+ # `enable!(store: my_store)` — it must respond to `get(key)`
30
+ # (returning `Array<Float>` or nil) and `set(key, vector)`; TTL
31
+ # management is then the store's responsibility.
32
+ #
33
+ # == Key derivation
34
+ #
35
+ # `provider.class.name | model_name | input_type | SHA-256(input)`.
36
+ # The full input text never becomes part of the key, so a shared
37
+ # external store does not accumulate plaintext queries.
38
+ #
39
+ # == Observability
40
+ #
41
+ # A cache hit emits the same `parse.embeddings.embed` AS::N event a
42
+ # real provider call would, with `cached: true` — existing
43
+ # spend-tracking subscribers see hits and misses on one stream.
44
+ module Cache
45
+ # Internal LRU + TTL store. Access is synchronized by the module-
46
+ # level monitor in {Cache}; the store itself is not thread-safe.
47
+ # @!visibility private
48
+ class LRUStore
49
+ def initialize(max_entries:, ttl:)
50
+ @max_entries = max_entries
51
+ @ttl = ttl
52
+ @entries = {} # key => [vector, monotonic_expiry]
53
+ end
54
+
55
+ def get(key)
56
+ entry = @entries[key]
57
+ return nil if entry.nil?
58
+ if @ttl && entry[1] && entry[1] < Cache.monotonic
59
+ @entries.delete(key)
60
+ return nil
61
+ end
62
+ # Refresh recency (Hash preserves insertion order).
63
+ @entries.delete(key)
64
+ @entries[key] = entry
65
+ entry[0]
66
+ end
67
+
68
+ def set(key, vector)
69
+ @entries.delete(key)
70
+ expiry = @ttl ? Cache.monotonic + @ttl : nil
71
+ @entries[key] = [vector, expiry]
72
+ @entries.shift while @entries.length > @max_entries
73
+ vector
74
+ end
75
+
76
+ def size
77
+ @entries.length
78
+ end
79
+
80
+ def clear
81
+ @entries = {}
82
+ end
83
+ end
84
+
85
+ # Adapter exposing any Moneta-compatible key/value store (`[]` /
86
+ # `[]=`, optionally `store(key, value, expires:)`) through the
87
+ # `get`/`set` duck {Cache.enable!} expects — the persistent-L2
88
+ # option. Point it at the same Redis your `Parse.cache` uses and
89
+ # query-embed cache entries survive process restarts and are
90
+ # shared across processes:
91
+ #
92
+ # require "moneta"
93
+ # moneta = Moneta.new(:Redis, url: ENV["REDIS_URL"], value_serializer: nil)
94
+ # Parse::Embeddings::Cache.enable!(
95
+ # store: Parse::Embeddings::Cache::MonetaStore.new(moneta, ttl: 30 * 24 * 3600),
96
+ # )
97
+ #
98
+ # Keys are namespaced (`emb:` by default) so the entries are
99
+ # recognizable next to other application keys; values are
100
+ # JSON-encoded vector Arrays (see {#get}/{#set}).
101
+ #
102
+ # SECURITY — build the Moneta store with `value_serializer: nil`
103
+ # (as above). Moneta's default value serializer is Marshal, so a
104
+ # cache read would `Marshal.load` whatever bytes are in the backing
105
+ # store — an arbitrary-code-execution primitive if that store is
106
+ # shared, unauthenticated, or reachable over a plaintext `redis://`
107
+ # MITM, and the cache key is derived from (often user-supplied)
108
+ # embedded text. `MonetaStore` JSON-(de)serializes values itself, but
109
+ # that only closes the vector IF Moneta is not also Marshaling on top;
110
+ # `value_serializer: nil` ensures it is not. `MonetaStore` emits a
111
+ # one-time warning if it is handed a Marshal-serializing store.
112
+ # TTL is forwarded via Moneta's `expires:` option when the
113
+ # backend supports it, ignored otherwise.
114
+ #
115
+ # Fail-open by design: a backend error (Redis down, serialization
116
+ # hiccup) degrades to a cache miss / dropped write — the embed
117
+ # path must never fail because the CACHE is unhealthy.
118
+ #
119
+ # The cross-process race the in-process LRU doesn't have applies
120
+ # here: two processes missing the same key concurrently both call
121
+ # the provider and both write. That is correct (embeddings are
122
+ # deterministic per key) and bounded — no locking is attempted.
123
+ class MonetaStore
124
+ # @param moneta [#[], #[]=] a Moneta store (or anything with the
125
+ # same indexing duck).
126
+ # @param ttl [Numeric, nil] per-entry lifetime in seconds,
127
+ # forwarded as `expires:` when the backend supports
128
+ # `store(key, value, expires:)`. nil = no expiry.
129
+ # @param namespace [String] key prefix.
130
+ def initialize(moneta, ttl: nil, namespace: "emb:")
131
+ unless moneta.respond_to?(:[]) && moneta.respond_to?(:[]=)
132
+ raise ArgumentError,
133
+ "Parse::Embeddings::Cache::MonetaStore expects a Moneta-compatible " \
134
+ "store responding to #[] and #[]= (got #{moneta.class})."
135
+ end
136
+ if marshaling_value_store?(moneta)
137
+ warn "[Parse::Embeddings::Cache::MonetaStore] SECURITY: the supplied Moneta " \
138
+ "store deserializes values with Marshal. A cache read Marshal.loads bytes " \
139
+ "from the backing store, which is a remote-code-execution vector when the " \
140
+ "store is shared/untrusted. Rebuild it with value_serializer: nil, e.g. " \
141
+ "Moneta.new(:Redis, url: ..., value_serializer: nil)."
142
+ end
143
+ @moneta = moneta
144
+ @ttl = ttl && Float(ttl)
145
+ @namespace = namespace.to_s
146
+ end
147
+
148
+ # @return [Array<Float>, nil]
149
+ def get(key)
150
+ decode_vector(@moneta[@namespace + key])
151
+ rescue StandardError
152
+ nil
153
+ end
154
+
155
+ # @return [Array<Float>] the vector, unchanged.
156
+ def set(key, vector)
157
+ k = @namespace + key
158
+ encoded = encode_vector(vector)
159
+ if @ttl && @moneta.respond_to?(:store)
160
+ begin
161
+ @moneta.store(k, encoded, expires: @ttl)
162
+ rescue ArgumentError
163
+ # Hash-like backends define #store(key, value) with no
164
+ # options arg, so the expires: form raises ArgumentError.
165
+ # Fall back to a plain write (no expiry) rather than letting
166
+ # the fail-open rescue below silently drop every vector.
167
+ @moneta[k] = encoded
168
+ end
169
+ else
170
+ @moneta[k] = encoded
171
+ end
172
+ vector
173
+ rescue StandardError
174
+ vector
175
+ end
176
+
177
+ private
178
+
179
+ # Vectors are JSON-encoded here rather than left to the Moneta
180
+ # store's own (Marshal-by-default) value serializer. Combined with a
181
+ # store built with `value_serializer: nil`, this keeps Marshal off
182
+ # the read path entirely: a JSON parse of attacker-influenced backing-
183
+ # store bytes can at worst yield inert data or raise — never a
184
+ # deserialized Ruby gadget object graph (RCE-if-cache-compromised).
185
+ # Embedding vectors are Array<Float>, which round-trips losslessly
186
+ # through JSON.
187
+ def encode_vector(vector)
188
+ JSON.generate(vector)
189
+ end
190
+
191
+ def decode_vector(raw)
192
+ return raw if raw.is_a?(Array) # legacy/non-serializing store entry
193
+ return nil if raw.nil?
194
+ parsed = JSON.parse(raw)
195
+ parsed.is_a?(Array) ? parsed : nil
196
+ rescue JSON::ParserError, TypeError, EncodingError
197
+ nil
198
+ end
199
+
200
+ # Best-effort detection of a Moneta store that serializes VALUES with
201
+ # Marshal. Moneta names its transformer proxy after the active
202
+ # serializers (e.g. "...MarshalValue"); a store built with
203
+ # value_serializer: nil has no "...Value" segment. Used only to warn.
204
+ def marshaling_value_store?(moneta)
205
+ moneta.class.name.to_s.include?("MarshalValue")
206
+ rescue StandardError
207
+ false
208
+ end
209
+ end
210
+
211
+ MONITOR = Monitor.new
212
+ private_constant :MONITOR
213
+
214
+ class << self
215
+ # Enable the cache.
216
+ #
217
+ # @param max_entries [Integer] LRU capacity (default store only).
218
+ # @param ttl [Numeric, nil] per-entry lifetime in seconds; nil
219
+ # disables expiry (default store only). Default 600.
220
+ # @param store [#get, #set, nil] custom backing store; overrides
221
+ # the built-in LRU when given.
222
+ # @return [void]
223
+ def enable!(max_entries: 2048, ttl: 600, store: nil)
224
+ if store && !(store.respond_to?(:get) && store.respond_to?(:set))
225
+ raise ArgumentError,
226
+ "Parse::Embeddings::Cache.enable!: store must respond to #get and #set."
227
+ end
228
+ me = Integer(max_entries)
229
+ raise ArgumentError, "max_entries must be positive" if me <= 0
230
+ MONITOR.synchronize do
231
+ @store = store || LRUStore.new(max_entries: me, ttl: ttl && Float(ttl))
232
+ @enabled = true
233
+ @hits = 0
234
+ @misses = 0
235
+ end
236
+ nil
237
+ end
238
+
239
+ # Disable and drop the store.
240
+ # @return [void]
241
+ def disable!
242
+ MONITOR.synchronize do
243
+ @enabled = false
244
+ @store = nil
245
+ end
246
+ nil
247
+ end
248
+
249
+ # @return [Boolean]
250
+ def enabled?
251
+ MONITOR.synchronize { !!@enabled }
252
+ end
253
+
254
+ # Clear cached entries (default store) and reset hit/miss counters.
255
+ # @return [void]
256
+ def clear!
257
+ MONITOR.synchronize do
258
+ @store.clear if @store.respond_to?(:clear)
259
+ @hits = 0
260
+ @misses = 0
261
+ end
262
+ nil
263
+ end
264
+
265
+ # @return [Hash] `{ enabled:, hits:, misses:, size: }`. `size` is
266
+ # nil for custom stores that don't expose one.
267
+ def stats
268
+ MONITOR.synchronize do
269
+ {
270
+ enabled: !!@enabled,
271
+ hits: @hits.to_i,
272
+ misses: @misses.to_i,
273
+ size: (@store.respond_to?(:size) ? @store.size : nil),
274
+ }
275
+ end
276
+ end
277
+
278
+ # Embed a single input through `provider`, serving repeats from
279
+ # the cache. Pass-through (no caching, no instrumentation
280
+ # changes) when the cache is disabled.
281
+ #
282
+ # @param provider [Provider] the embedding provider.
283
+ # @param input [String] the text to embed.
284
+ # @param input_type [Symbol] forwarded to `embed_text`.
285
+ # @return [Array<Float>] the embedding vector.
286
+ def fetch_vector(provider, input, input_type: :search_query)
287
+ unless enabled?
288
+ return embed_single!(provider, input, input_type)
289
+ end
290
+ key = key_for(provider, input, input_type)
291
+ cached = MONITOR.synchronize { @store && @store.get(key) }
292
+ if cached
293
+ MONITOR.synchronize { @hits = @hits.to_i + 1 }
294
+ instrument_hit(provider, input_type)
295
+ return cached
296
+ end
297
+ vector = embed_single!(provider, input, input_type)
298
+ MONITOR.synchronize do
299
+ @misses = @misses.to_i + 1
300
+ @store.set(key, vector) if @store
301
+ end
302
+ vector
303
+ end
304
+
305
+ # @!visibility private
306
+ # Composite cache key. The input is hashed so plaintext never
307
+ # lands in a shared store; provider identity + model + dimensions
308
+ # + input_type namespace the hash (two models' vectors are never
309
+ # confused). Dimensions matter independently of the model name:
310
+ # Matryoshka-capable providers (OpenAI text-embedding-3-*, Cohere
311
+ # embed-v4, Voyage, Jina, Qwen) can register the same model at
312
+ # different output widths, and serving one width's cached vector
313
+ # to the other poisons the narrower/wider field.
314
+ def key_for(provider, input, input_type)
315
+ model = begin
316
+ provider.model_name
317
+ rescue NotImplementedError
318
+ "unknown"
319
+ end
320
+ dims = begin
321
+ provider.dimensions
322
+ rescue NotImplementedError
323
+ "unknown"
324
+ end
325
+ "#{provider.class.name}|#{model}|#{dims}|#{input_type}|#{Digest::SHA256.hexdigest(input.to_s)}"
326
+ end
327
+
328
+ # @!visibility private
329
+ def monotonic
330
+ Process.clock_gettime(Process::CLOCK_MONOTONIC)
331
+ end
332
+
333
+ private
334
+
335
+ def embed_single!(provider, input, input_type)
336
+ vectors = provider.embed_text([input], input_type: input_type)
337
+ unless vectors.is_a?(Array) && vectors.length == 1 && vectors.first.is_a?(Array)
338
+ raise InvalidResponseError,
339
+ "Parse::Embeddings::Cache: provider #{provider.class} did not return a " \
340
+ "single vector (got #{vectors.inspect[0, 80]})."
341
+ end
342
+ vectors.first
343
+ end
344
+
345
+ # Emit the standard embed event so spend subscribers see cache
346
+ # hits on the same stream as real calls.
347
+ def instrument_hit(provider, input_type)
348
+ return unless defined?(ActiveSupport::Notifications)
349
+ model = begin
350
+ provider.model_name
351
+ rescue NotImplementedError
352
+ nil
353
+ end
354
+ dims = begin
355
+ provider.dimensions
356
+ rescue NotImplementedError
357
+ nil
358
+ end
359
+ payload = {
360
+ provider: provider.class.name,
361
+ model: model,
362
+ dimensions: dims,
363
+ input_count: 1,
364
+ input_type: input_type,
365
+ total_tokens: nil,
366
+ cached: true,
367
+ error: nil,
368
+ }
369
+ ActiveSupport::Notifications.instrument(Provider::AS_NOTIFICATION_NAME, payload) {}
370
+ end
371
+ end
372
+ end
373
+ end
374
+ end
@@ -260,14 +260,23 @@ module Parse
260
260
  MULTIMODAL_MODELS.include?(@model) ? %i[text image] : [:text]
261
261
  end
262
262
 
263
- # Embed a batch of image URLs through Cohere's `/v2/embed`
264
- # multimodal endpoint. v5.1 ships URL-only — the provider
265
- # receives a public URL and issues its own fetch. The SDK does
266
- # NOT download the image; it validates the URL through
267
- # {Parse::Embeddings.validate_image_url!} (sentinel-gated egress
268
- # opt-in, CIDR / port / host allowlist) and forwards the
269
- # canonicalized URL string in the `{ type: "image_url",
270
- # image_url: { url: ... } }` content row.
263
+ # Embed a batch of images through Cohere's `/v2/embed`
264
+ # multimodal endpoint. Two source forms:
265
+ #
266
+ # * **String URL** (v5.1 path) the provider receives a public
267
+ # URL and issues its own fetch. The SDK does NOT download the
268
+ # image; it validates the URL through
269
+ # {Parse::Embeddings.validate_image_url!} (sentinel-gated
270
+ # egress opt-in, CIDR / port / host allowlist) and forwards
271
+ # the canonicalized URL string in the `{ type: "image_url",
272
+ # image_url: { url: ... } }` content row.
273
+ # * **{Parse::Embeddings::ImageFetch::FetchedImage}** (v5.5 bytes
274
+ # path) — bytes the SDK already downloaded through
275
+ # {Parse::File.safe_open_url}, magic-byte-verified, and
276
+ # EXIF-stripped. Forwarded as a base64 data URI in the same
277
+ # `image_url` content row (Cohere v2 accepts data URIs). No
278
+ # URL validation runs and the `trust_provider_url_fetch`
279
+ # sentinel is NOT required.
271
280
  #
272
281
  # **Multimodal model required.** Cohere's v3 models do not accept
273
282
  # image inputs; calling `embed_image` on a v3-configured provider
@@ -321,24 +330,28 @@ module Parse
321
330
 
322
331
  # Validate every URL up-front so a malformed entry in slot N
323
332
  # does not slip through after slots 0..N-1 are already in the
324
- # wire body. Forward the canonicalized URL the validator
325
- # returned — not the caller's raw input.
326
- canonical_urls = sources.each_with_index.map do |url, i|
327
- unless url.is_a?(String)
333
+ # wire body. URL entries forward the validator's canonicalized
334
+ # URL — not the caller's raw input; fetched-bytes entries skip
335
+ # URL validation (already downloaded + verified by ImageFetch)
336
+ # and forward as a base64 data URI.
337
+ content_rows = sources.each_with_index.map do |src, i|
338
+ if src.is_a?(Parse::Embeddings::ImageFetch::FetchedImage)
339
+ { content: [{ type: "image_url", image_url: { url: src.to_data_uri } }] }
340
+ elsif src.is_a?(String)
341
+ canonical = Parse::Embeddings.validate_image_url!(src, allow_insecure: allow_insecure)
342
+ { content: [{ type: "image_url", image_url: { url: canonical } }] }
343
+ else
328
344
  raise ArgumentError,
329
- "Parse::Embeddings::Cohere#embed_image sources[#{i}] is not a String " \
330
- "(#{url.class}). v5.1 ships URL-only — bytes/IO support is v5.3."
345
+ "Parse::Embeddings::Cohere#embed_image sources[#{i}] must be a URL String " \
346
+ "or Parse::Embeddings::ImageFetch::FetchedImage (got #{src.class})."
331
347
  end
332
- Parse::Embeddings.validate_image_url!(url, allow_insecure: allow_insecure)
333
348
  end
334
349
 
335
350
  body = {
336
351
  model: @model,
337
352
  input_type: wire_input_type,
338
353
  embedding_types: ["float"],
339
- inputs: canonical_urls.map { |u|
340
- { content: [{ type: "image_url", image_url: { url: u } }] }
341
- },
354
+ inputs: content_rows,
342
355
  }
343
356
 
344
357
  instrument_embed(sources.length, input_type, modality: :image) do |emit_payload|