parse-stack-next 5.4.1 → 5.5.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 +344 -0
- data/Gemfile.lock +1 -1
- data/README.md +45 -6
- data/docs/atlas_vector_search_guide.md +314 -19
- data/lib/parse/api/users.rb +10 -0
- data/lib/parse/client.rb +19 -1
- data/lib/parse/embeddings/batch_embedder.rb +188 -0
- data/lib/parse/embeddings/cache.rb +322 -0
- data/lib/parse/embeddings/cohere.rb +31 -18
- data/lib/parse/embeddings/image_fetch.rb +347 -0
- data/lib/parse/embeddings/provider.rb +17 -11
- data/lib/parse/embeddings/spend_cap.rb +117 -3
- data/lib/parse/embeddings/voyage.rb +34 -25
- data/lib/parse/embeddings.rb +40 -3
- data/lib/parse/model/acl.rb +15 -11
- data/lib/parse/model/core/embed_managed.rb +243 -14
- data/lib/parse/model/core/vector_searchable.rb +157 -8
- data/lib/parse/query/constraint.rb +22 -0
- data/lib/parse/query/constraints.rb +271 -250
- data/lib/parse/query.rb +233 -42
- data/lib/parse/retrieval/agent_tool.rb +21 -14
- data/lib/parse/retrieval/retriever.rb +84 -0
- data/lib/parse/schema/search_index_migrator.rb +48 -1
- data/lib/parse/stack/version.rb +1 -1
- data/lib/parse/vector_search/hybrid.rb +39 -1
- data/lib/parse/vector_search.rb +34 -0
- data/lib/parse/webhooks/payload.rb +7 -1
- data/lib/parse/webhooks.rb +107 -21
- metadata +4 -1
|
@@ -0,0 +1,347 @@
|
|
|
1
|
+
# encoding: UTF-8
|
|
2
|
+
# frozen_string_literal: true
|
|
3
|
+
|
|
4
|
+
require "base64"
|
|
5
|
+
require "uri"
|
|
6
|
+
|
|
7
|
+
module Parse
|
|
8
|
+
module Embeddings
|
|
9
|
+
# SDK-side image download for the bytes-fetch embedding path (v5.5).
|
|
10
|
+
#
|
|
11
|
+
# Where the URL-forwarding path (v5.1) hands a validated URL to the
|
|
12
|
+
# embedding provider and lets the provider issue its own fetch, the
|
|
13
|
+
# bytes path downloads the image through the SDK's own SSRF-hardened
|
|
14
|
+
# primitive ({Parse::File.safe_open_url} — CIDR blocks, port
|
|
15
|
+
# allowlist, DNS-rebinding re-check, size caps, timeouts; NO parallel
|
|
16
|
+
# SSRF mechanism is introduced here), verifies the content, and
|
|
17
|
+
# forwards the bytes to the provider as a base64 data URI.
|
|
18
|
+
#
|
|
19
|
+
# == Content verification (closes NEW-NET-4, "File MIME laundering")
|
|
20
|
+
#
|
|
21
|
+
# The HTTP `Content-Type` header is **never trusted**. The MIME type
|
|
22
|
+
# is determined exclusively by magic-byte sniffing of the leading
|
|
23
|
+
# bytes ({.sniff_mime}), then:
|
|
24
|
+
#
|
|
25
|
+
# 1. The sniffed type must be in {Parse::Embeddings.allowed_image_types}
|
|
26
|
+
# (default: JPEG / PNG / GIF / WebP).
|
|
27
|
+
# 2. When the URL path carries a recognized image extension, the
|
|
28
|
+
# extension's implied type must AGREE with the sniffed type —
|
|
29
|
+
# a `.png` URL serving JPEG bytes (or an `.html` payload with an
|
|
30
|
+
# image extension) is refused as a laundering attempt.
|
|
31
|
+
#
|
|
32
|
+
# Unknown magic bytes are always refused: there is no fallthrough to
|
|
33
|
+
# header- or extension-derived typing.
|
|
34
|
+
#
|
|
35
|
+
# == EXIF stripping (default ON)
|
|
36
|
+
#
|
|
37
|
+
# User-uploaded photos commonly carry GPS coordinates and device
|
|
38
|
+
# serial numbers in EXIF. Forwarding those to a third-party embedding
|
|
39
|
+
# provider is a PII egress, so metadata is stripped by default:
|
|
40
|
+
#
|
|
41
|
+
# * JPEG — APP1 segments (Exif and XMP) are removed.
|
|
42
|
+
# * PNG — `eXIf` chunks are removed.
|
|
43
|
+
# * WebP — `EXIF` / `XMP ` RIFF chunks are removed and the VP8X
|
|
44
|
+
# EXIF/XMP flag bits cleared.
|
|
45
|
+
# * GIF — no EXIF container; pass-through.
|
|
46
|
+
#
|
|
47
|
+
# Callers that need orientation metadata preserved opt out per call
|
|
48
|
+
# with `exif_strip: false` (the `embed_image source: :bytes`
|
|
49
|
+
# directive forwards its own `exif_strip:` declaration).
|
|
50
|
+
module ImageFetch
|
|
51
|
+
# Raised when downloaded bytes fail content verification — unknown
|
|
52
|
+
# magic bytes, sniffed type outside the allowlist, or an
|
|
53
|
+
# extension / magic-byte disagreement. Carries a `:reason` tag
|
|
54
|
+
# (`:unknown_magic`, `:type_not_allowed`, `:extension_mismatch`,
|
|
55
|
+
# `:empty`) so callers can branch on the failure mode.
|
|
56
|
+
class InvalidImageType < Parse::Embeddings::Error
|
|
57
|
+
# @return [Symbol] failure-mode tag.
|
|
58
|
+
attr_reader :reason
|
|
59
|
+
def initialize(reason, message)
|
|
60
|
+
@reason = reason
|
|
61
|
+
super(message)
|
|
62
|
+
end
|
|
63
|
+
end
|
|
64
|
+
|
|
65
|
+
# Value object for a fetched-and-verified image. `mime_type` is the
|
|
66
|
+
# SNIFFED type (never the server-reported `Content-Type`). The
|
|
67
|
+
# provider adapters consume this via {#to_data_uri}.
|
|
68
|
+
FetchedImage = Struct.new(:bytes, :mime_type, :url, keyword_init: true) do
|
|
69
|
+
# @return [String] `data:<mime>;base64,<payload>` for provider wire bodies.
|
|
70
|
+
def to_data_uri
|
|
71
|
+
"data:#{mime_type};base64,#{Base64.strict_encode64(bytes)}"
|
|
72
|
+
end
|
|
73
|
+
|
|
74
|
+
# Keep multi-MB image payloads out of exception messages and logs.
|
|
75
|
+
def inspect
|
|
76
|
+
"#<Parse::Embeddings::ImageFetch::FetchedImage mime_type=#{mime_type.inspect} " \
|
|
77
|
+
"bytes=#{bytes.respond_to?(:bytesize) ? bytes.bytesize : 0} url=#{url.inspect}>"
|
|
78
|
+
end
|
|
79
|
+
alias_method :to_s, :inspect
|
|
80
|
+
end
|
|
81
|
+
|
|
82
|
+
# MIME types the bytes path accepts by default. Operators extend
|
|
83
|
+
# via {Parse::Embeddings.allowed_image_types=}. SVG is deliberately
|
|
84
|
+
# absent — it is active content (script-capable), not a bitmap.
|
|
85
|
+
DEFAULT_ALLOWED_IMAGE_TYPES = %w[image/jpeg image/png image/gif image/webp].freeze
|
|
86
|
+
|
|
87
|
+
# URL-path extensions whose implied MIME type is cross-checked
|
|
88
|
+
# against the sniffed type. Extensions not listed here are ignored
|
|
89
|
+
# (the magic bytes alone govern).
|
|
90
|
+
EXTENSION_MIME = {
|
|
91
|
+
".jpg" => "image/jpeg",
|
|
92
|
+
".jpeg" => "image/jpeg",
|
|
93
|
+
".jpe" => "image/jpeg",
|
|
94
|
+
".png" => "image/png",
|
|
95
|
+
".gif" => "image/gif",
|
|
96
|
+
".webp" => "image/webp",
|
|
97
|
+
}.freeze
|
|
98
|
+
|
|
99
|
+
module_function
|
|
100
|
+
|
|
101
|
+
# Determine an image's MIME type from its leading magic bytes.
|
|
102
|
+
# The first ~16 bytes are sufficient for every supported format.
|
|
103
|
+
# Returns nil for anything unrecognized — callers must treat nil
|
|
104
|
+
# as a refusal, never fall back to header/extension typing.
|
|
105
|
+
#
|
|
106
|
+
# @param bytes [String] raw image bytes (at least the first 16).
|
|
107
|
+
# @return [String, nil] sniffed MIME type, or nil when unknown.
|
|
108
|
+
def sniff_mime(bytes)
|
|
109
|
+
return nil unless bytes.is_a?(String) && bytes.bytesize >= 12
|
|
110
|
+
b = bytes.byteslice(0, 16).force_encoding(Encoding::BINARY)
|
|
111
|
+
return "image/jpeg" if b.start_with?("\xFF\xD8\xFF".b)
|
|
112
|
+
return "image/png" if b.start_with?("\x89PNG\r\n\x1A\n".b)
|
|
113
|
+
return "image/gif" if b.start_with?("GIF87a".b) || b.start_with?("GIF89a".b)
|
|
114
|
+
if b.start_with?("RIFF".b) && b.byteslice(8, 4) == "WEBP".b
|
|
115
|
+
return "image/webp"
|
|
116
|
+
end
|
|
117
|
+
nil
|
|
118
|
+
end
|
|
119
|
+
|
|
120
|
+
# Download, verify, and (by default) EXIF-strip an image.
|
|
121
|
+
#
|
|
122
|
+
# The URL is validated through
|
|
123
|
+
# {Parse::Embeddings.validate_image_url!} in `:fetch` mode — host
|
|
124
|
+
# allowlist ({Parse::Embeddings.allowed_image_hosts}, deny-all when
|
|
125
|
+
# empty), obfuscated-IP-literal screen, port allowlist, CIDR check
|
|
126
|
+
# — but WITHOUT the {Parse::Embeddings.trust_provider_url_fetch=}
|
|
127
|
+
# sentinel, because no URL is forwarded to a third party: the SDK
|
|
128
|
+
# itself performs the fetch through {Parse::File.safe_open_url}.
|
|
129
|
+
#
|
|
130
|
+
# @param url [String] image URL (host must be allowlisted).
|
|
131
|
+
# @param allow_insecure [Boolean] permit `http://` (local dev only).
|
|
132
|
+
# @param exif_strip [Boolean] strip EXIF/XMP metadata (default true).
|
|
133
|
+
# @param max_bytes [Integer, nil] additional size cap below
|
|
134
|
+
# {Parse::File.max_remote_size}; nil applies only the global cap.
|
|
135
|
+
# @return [FetchedImage] verified bytes + sniffed MIME type.
|
|
136
|
+
# @raise [Parse::Embeddings::InvalidImageURL] URL validation failure.
|
|
137
|
+
# @raise [InvalidImageType] content verification failure.
|
|
138
|
+
# @raise [ArgumentError] from {Parse::File.safe_open_url} (SSRF /
|
|
139
|
+
# size / timeout refusals).
|
|
140
|
+
def fetch!(url, allow_insecure: false, exif_strip: true, max_bytes: nil)
|
|
141
|
+
canonical = Parse::Embeddings.validate_image_url!(
|
|
142
|
+
url, allow_insecure: allow_insecure, mode: :fetch,
|
|
143
|
+
)
|
|
144
|
+
io = Parse::File.safe_open_url(canonical)
|
|
145
|
+
begin
|
|
146
|
+
bytes = io.read
|
|
147
|
+
ensure
|
|
148
|
+
io.close if io.respond_to?(:close)
|
|
149
|
+
end
|
|
150
|
+
bytes = bytes.to_s.dup.force_encoding(Encoding::BINARY)
|
|
151
|
+
|
|
152
|
+
if max_bytes && bytes.bytesize > Integer(max_bytes)
|
|
153
|
+
raise ArgumentError,
|
|
154
|
+
"Parse::Embeddings::ImageFetch: image exceeds max_bytes " \
|
|
155
|
+
"(#{bytes.bytesize} > #{Integer(max_bytes)})."
|
|
156
|
+
end
|
|
157
|
+
|
|
158
|
+
mime = verify!(bytes, url: canonical)
|
|
159
|
+
bytes = strip_metadata(bytes, mime) if exif_strip
|
|
160
|
+
FetchedImage.new(bytes: bytes, mime_type: mime, url: canonical)
|
|
161
|
+
end
|
|
162
|
+
|
|
163
|
+
# Verify raw bytes: sniff the magic, check the allowlist, and
|
|
164
|
+
# cross-check the URL extension. Public so the upload-side
|
|
165
|
+
# validation path can reuse the same check.
|
|
166
|
+
#
|
|
167
|
+
# @param bytes [String] raw image bytes.
|
|
168
|
+
# @param url [String, nil] source URL for the extension cross-check
|
|
169
|
+
# (nil skips it — e.g. caller-supplied byte payloads).
|
|
170
|
+
# @return [String] the sniffed MIME type.
|
|
171
|
+
# @raise [InvalidImageType]
|
|
172
|
+
def verify!(bytes, url: nil)
|
|
173
|
+
if bytes.nil? || bytes.empty?
|
|
174
|
+
raise InvalidImageType.new(:empty,
|
|
175
|
+
"Parse::Embeddings::ImageFetch: downloaded body is empty.")
|
|
176
|
+
end
|
|
177
|
+
mime = sniff_mime(bytes)
|
|
178
|
+
if mime.nil?
|
|
179
|
+
raise InvalidImageType.new(:unknown_magic,
|
|
180
|
+
"Parse::Embeddings::ImageFetch: leading bytes match no supported image " \
|
|
181
|
+
"format (JPEG/PNG/GIF/WebP). The Content-Type header is not consulted — " \
|
|
182
|
+
"unrecognized content is refused outright.")
|
|
183
|
+
end
|
|
184
|
+
allowed = Parse::Embeddings.allowed_image_types
|
|
185
|
+
unless allowed.include?(mime)
|
|
186
|
+
raise InvalidImageType.new(:type_not_allowed,
|
|
187
|
+
"Parse::Embeddings::ImageFetch: sniffed type #{mime.inspect} is not in " \
|
|
188
|
+
"Parse::Embeddings.allowed_image_types (#{allowed.inspect}).")
|
|
189
|
+
end
|
|
190
|
+
ext_mime = extension_mime(url)
|
|
191
|
+
if ext_mime && ext_mime != mime
|
|
192
|
+
raise InvalidImageType.new(:extension_mismatch,
|
|
193
|
+
"Parse::Embeddings::ImageFetch: URL extension implies #{ext_mime.inspect} " \
|
|
194
|
+
"but the magic bytes are #{mime.inspect} — refusing MIME-laundered content.")
|
|
195
|
+
end
|
|
196
|
+
mime
|
|
197
|
+
end
|
|
198
|
+
|
|
199
|
+
# @!visibility private
|
|
200
|
+
# MIME type implied by the URL path's extension, or nil when the
|
|
201
|
+
# extension is absent / unrecognized. Only the URI *path* is
|
|
202
|
+
# consulted — a dot in the hostname (`https://cdn.v2.example.com/blob`)
|
|
203
|
+
# must not be mistaken for an extension. Unparseable URLs skip the
|
|
204
|
+
# cross-check (magic-byte verification still applies).
|
|
205
|
+
def extension_mime(url)
|
|
206
|
+
return nil unless url.is_a?(String)
|
|
207
|
+
path = begin
|
|
208
|
+
URI.parse(url).path.to_s
|
|
209
|
+
rescue URI::InvalidURIError
|
|
210
|
+
return nil
|
|
211
|
+
end
|
|
212
|
+
dot = path.rindex(".")
|
|
213
|
+
return nil if dot.nil?
|
|
214
|
+
EXTENSION_MIME[path[dot..].to_s.downcase]
|
|
215
|
+
end
|
|
216
|
+
|
|
217
|
+
# Strip embedded metadata for the formats that carry it. Unknown /
|
|
218
|
+
# metadata-free formats pass through unchanged. Never raises on a
|
|
219
|
+
# malformed container — falls back to the original bytes (the
|
|
220
|
+
# provider will reject genuinely corrupt input) — but the fallback
|
|
221
|
+
# is no longer silent: a container the walker could not parse may
|
|
222
|
+
# still carry EXIF/XMP to a third-party provider, so the
|
|
223
|
+
# PII-egress protection not running is warned about.
|
|
224
|
+
#
|
|
225
|
+
# @param bytes [String] verified image bytes.
|
|
226
|
+
# @param mime [String] sniffed MIME type.
|
|
227
|
+
# @return [String] bytes with metadata removed.
|
|
228
|
+
def strip_metadata(bytes, mime)
|
|
229
|
+
stripped =
|
|
230
|
+
case mime
|
|
231
|
+
when "image/jpeg" then strip_jpeg_app1(bytes)
|
|
232
|
+
when "image/png" then strip_png_exif(bytes)
|
|
233
|
+
when "image/webp" then strip_webp_metadata(bytes)
|
|
234
|
+
else return bytes
|
|
235
|
+
end
|
|
236
|
+
# The format walkers return the *original object* when they bail
|
|
237
|
+
# on a structure they cannot parse; a successful walk always
|
|
238
|
+
# returns a fresh copy (even when nothing was removed).
|
|
239
|
+
if stripped.equal?(bytes)
|
|
240
|
+
warn "[Parse::Embeddings::ImageFetch] could not parse the #{mime} " \
|
|
241
|
+
"container for metadata stripping; passing bytes through with " \
|
|
242
|
+
"embedded EXIF/XMP (if any) intact."
|
|
243
|
+
end
|
|
244
|
+
stripped
|
|
245
|
+
rescue StandardError
|
|
246
|
+
warn "[Parse::Embeddings::ImageFetch] metadata stripping raised while " \
|
|
247
|
+
"parsing the #{mime} container; passing bytes through with " \
|
|
248
|
+
"embedded EXIF/XMP (if any) intact."
|
|
249
|
+
bytes
|
|
250
|
+
end
|
|
251
|
+
|
|
252
|
+
# @!visibility private
|
|
253
|
+
# Remove APP1 (0xFFE1) segments — Exif and XMP both ride in APP1 —
|
|
254
|
+
# by walking the JPEG marker stream up to SOS and copying every
|
|
255
|
+
# other segment verbatim. Entropy-coded data after SOS is appended
|
|
256
|
+
# untouched.
|
|
257
|
+
def strip_jpeg_app1(bytes)
|
|
258
|
+
b = bytes
|
|
259
|
+
return b unless b.byteslice(0, 2) == "\xFF\xD8".b
|
|
260
|
+
out = +"\xFF\xD8".b
|
|
261
|
+
pos = 2
|
|
262
|
+
len = b.bytesize
|
|
263
|
+
while pos + 4 <= len
|
|
264
|
+
return bytes unless b.getbyte(pos) == 0xFF
|
|
265
|
+
marker = b.getbyte(pos + 1)
|
|
266
|
+
# Standalone markers (RST/SOI/EOI/TEM) carry no length, but none
|
|
267
|
+
# legally appear between SOI and SOS in the header stream.
|
|
268
|
+
break if marker == 0xD9 # EOI with no SOS — malformed; bail to copy
|
|
269
|
+
seg_len = (b.getbyte(pos + 2) << 8) | b.getbyte(pos + 3)
|
|
270
|
+
return bytes if seg_len < 2
|
|
271
|
+
if marker == 0xDA # SOS — header walk ends; copy the rest verbatim
|
|
272
|
+
out << b.byteslice(pos, len - pos)
|
|
273
|
+
return out
|
|
274
|
+
end
|
|
275
|
+
out << b.byteslice(pos, 2 + seg_len) unless marker == 0xE1
|
|
276
|
+
pos += 2 + seg_len
|
|
277
|
+
end
|
|
278
|
+
# No SOS found — structurally odd; return the original untouched.
|
|
279
|
+
bytes
|
|
280
|
+
end
|
|
281
|
+
|
|
282
|
+
# @!visibility private
|
|
283
|
+
# Remove `eXIf` chunks from a PNG chunk stream. Chunk layout:
|
|
284
|
+
# 4-byte length, 4-byte type, payload, 4-byte CRC. A truncated
|
|
285
|
+
# chunk bails to the original `bytes` object — an `eXIf` chunk
|
|
286
|
+
# past the abort point would otherwise slip through, and the
|
|
287
|
+
# identity check in strip_metadata only warns on the original.
|
|
288
|
+
def strip_png_exif(bytes)
|
|
289
|
+
sig_len = 8
|
|
290
|
+
b = bytes
|
|
291
|
+
out = b.byteslice(0, sig_len).dup
|
|
292
|
+
pos = sig_len
|
|
293
|
+
len = b.bytesize
|
|
294
|
+
while pos + 8 <= len
|
|
295
|
+
chunk_len = (b.getbyte(pos) << 24) | (b.getbyte(pos + 1) << 16) |
|
|
296
|
+
(b.getbyte(pos + 2) << 8) | b.getbyte(pos + 3)
|
|
297
|
+
type = b.byteslice(pos + 4, 4)
|
|
298
|
+
total = 8 + chunk_len + 4
|
|
299
|
+
return bytes if pos + total > len
|
|
300
|
+
out << b.byteslice(pos, total) unless type == "eXIf".b
|
|
301
|
+
pos += total
|
|
302
|
+
break if type == "IEND".b
|
|
303
|
+
end
|
|
304
|
+
# Trailing bytes after IEND (uncommon) are dropped with the copy;
|
|
305
|
+
# a sub-header tail (< 8 bytes) cannot hold another chunk.
|
|
306
|
+
out
|
|
307
|
+
end
|
|
308
|
+
|
|
309
|
+
# @!visibility private
|
|
310
|
+
# Remove `EXIF` / `XMP ` chunks from a WebP RIFF container, patch
|
|
311
|
+
# the RIFF size field, and clear the VP8X EXIF/XMP flag bits so the
|
|
312
|
+
# header stays consistent with the chunk list. A truncated chunk
|
|
313
|
+
# bails to the original `bytes` object — an `EXIF` / `XMP ` chunk
|
|
314
|
+
# past the abort point would otherwise slip through, and the
|
|
315
|
+
# identity check in strip_metadata only warns on the original.
|
|
316
|
+
def strip_webp_metadata(bytes)
|
|
317
|
+
b = bytes
|
|
318
|
+
out_chunks = +"".b
|
|
319
|
+
pos = 12 # past "RIFF" + size + "WEBP"
|
|
320
|
+
len = b.bytesize
|
|
321
|
+
while pos + 8 <= len
|
|
322
|
+
type = b.byteslice(pos, 4)
|
|
323
|
+
chunk_len = b.getbyte(pos + 4) | (b.getbyte(pos + 5) << 8) |
|
|
324
|
+
(b.getbyte(pos + 6) << 16) | (b.getbyte(pos + 7) << 24)
|
|
325
|
+
padded = chunk_len + (chunk_len.odd? ? 1 : 0)
|
|
326
|
+
total = 8 + padded
|
|
327
|
+
return bytes if pos + 8 + chunk_len > len
|
|
328
|
+
unless type == "EXIF".b || type == "XMP ".b
|
|
329
|
+
chunk = b.byteslice(pos, [total, len - pos].min).dup
|
|
330
|
+
if type == "VP8X".b && chunk.bytesize >= 9
|
|
331
|
+
flags = chunk.getbyte(8)
|
|
332
|
+
chunk.setbyte(8, flags & ~0x0C) # clear EXIF (0x08) + XMP (0x04)
|
|
333
|
+
end
|
|
334
|
+
out_chunks << chunk
|
|
335
|
+
end
|
|
336
|
+
pos += total
|
|
337
|
+
end
|
|
338
|
+
riff_size = 4 + out_chunks.bytesize # "WEBP" + chunks
|
|
339
|
+
out = +"RIFF".b
|
|
340
|
+
out << [riff_size].pack("V")
|
|
341
|
+
out << "WEBP".b
|
|
342
|
+
out << out_chunks
|
|
343
|
+
out
|
|
344
|
+
end
|
|
345
|
+
end
|
|
346
|
+
end
|
|
347
|
+
end
|
|
@@ -39,17 +39,23 @@ module Parse
|
|
|
39
39
|
raise NotImplementedError, "#{self.class}#embed_text must be implemented"
|
|
40
40
|
end
|
|
41
41
|
|
|
42
|
-
# @param sources [Array<
|
|
43
|
-
#
|
|
44
|
-
#
|
|
45
|
-
#
|
|
46
|
-
#
|
|
47
|
-
#
|
|
48
|
-
#
|
|
49
|
-
#
|
|
50
|
-
#
|
|
51
|
-
#
|
|
52
|
-
#
|
|
42
|
+
# @param sources [Array<String, Parse::Embeddings::ImageFetch::FetchedImage>]
|
|
43
|
+
# image sources. Two supported forms (mixable within a batch):
|
|
44
|
+
#
|
|
45
|
+
# * `String` URL (v5.1 URL-forwarding path) — forwarded
|
|
46
|
+
# unchanged from the managed path: {Parse::Core::EmbedManaged}
|
|
47
|
+
# deliberately does NOT validate before calling the provider
|
|
48
|
+
# (validating there would double-resolve every URL). The
|
|
49
|
+
# concrete `embed_image` override is therefore responsible for
|
|
50
|
+
# calling {Parse::Embeddings.validate_image_url!} (passing
|
|
51
|
+
# `allow_insecure:` through) before egress — see the bundled
|
|
52
|
+
# Voyage/Cohere providers, which validate internally.
|
|
53
|
+
# * {Parse::Embeddings::ImageFetch::FetchedImage} (v5.5 bytes
|
|
54
|
+
# path) — bytes the SDK already downloaded via
|
|
55
|
+
# {Parse::File.safe_open_url}, magic-byte-verified, and
|
|
56
|
+
# EXIF-stripped. Concrete overrides forward
|
|
57
|
+
# `fetched.to_data_uri` in their wire body's base64 slot and
|
|
58
|
+
# skip URL validation (there is no provider-side fetch).
|
|
53
59
|
# @param input_type [Symbol] `:search_query` or `:search_document`,
|
|
54
60
|
# parallel to {#embed_text}.
|
|
55
61
|
# @param allow_insecure [Boolean] **contract kwarg** —
|
|
@@ -64,9 +64,24 @@ module Parse
|
|
|
64
64
|
# default limit applied to every tenant lacking an override.
|
|
65
65
|
DEFAULT_KEY = :__default__
|
|
66
66
|
|
|
67
|
+
# Thread-local key marking that the current call stack has already
|
|
68
|
+
# charged the spend cap (or deliberately exempted itself). Set by
|
|
69
|
+
# {.with_precharged}; read by {.charge_query!} so the inner
|
|
70
|
+
# query-embed paths (`find_similar(text:)`, `hybrid_search`,
|
|
71
|
+
# `Parse::Retrieval.retrieve`) don't double-bill a query the agent
|
|
72
|
+
# tool already charged with proper tenant identity.
|
|
73
|
+
PRECHARGED_KEY = :parse_embed_spend_precharged
|
|
74
|
+
|
|
67
75
|
# Default sliding window (seconds) when none is configured.
|
|
68
76
|
DEFAULT_WINDOW = 3600
|
|
69
77
|
|
|
78
|
+
# AS::N event emitted when a tenant's in-window usage crosses the
|
|
79
|
+
# configured `warn_at:` fraction of its hard limit. Payload:
|
|
80
|
+
# `{ tenant_id:, used:, limit:, window:, warn_at:, threshold: }`.
|
|
81
|
+
# Emitted once per window-crossing (re-arms as usage rolls off),
|
|
82
|
+
# never on the hard-refuse itself (that raises {Exceeded}).
|
|
83
|
+
AS_NOTIFICATION_NAME = "parse.embeddings.spend_cap_warning"
|
|
84
|
+
|
|
70
85
|
class << self
|
|
71
86
|
# Configure the cap. Two forms:
|
|
72
87
|
#
|
|
@@ -80,8 +95,15 @@ module Parse
|
|
|
80
95
|
# the global default.
|
|
81
96
|
# @param limit_tokens [Integer, nil] token ceiling per window.
|
|
82
97
|
# @param window [Integer] sliding window length in seconds.
|
|
98
|
+
# @param warn_at [Numeric, nil] soft-cap fraction of
|
|
99
|
+
# `limit_tokens` (exclusive 0...1). When a charge pushes a
|
|
100
|
+
# tenant's in-window usage across `limit * warn_at`, a
|
|
101
|
+
# {AS_NOTIFICATION_NAME} ActiveSupport::Notifications event is
|
|
102
|
+
# emitted (once per crossing — re-arms as the window rolls
|
|
103
|
+
# off). Gives operators an alerting hook BEFORE the hard
|
|
104
|
+
# refuse trips. nil (default) disables the soft cap.
|
|
83
105
|
# @return [void]
|
|
84
|
-
def configure(tenant_id = nil, limit_tokens:, window: DEFAULT_WINDOW)
|
|
106
|
+
def configure(tenant_id = nil, limit_tokens:, window: DEFAULT_WINDOW, warn_at: nil)
|
|
85
107
|
key = tenant_id.nil? ? DEFAULT_KEY : tenant_id
|
|
86
108
|
unless limit_tokens.nil?
|
|
87
109
|
li = Integer(limit_tokens)
|
|
@@ -89,8 +111,21 @@ module Parse
|
|
|
89
111
|
end
|
|
90
112
|
w = Integer(window)
|
|
91
113
|
raise ArgumentError, "SpendCap: window must be positive (got #{w})." if w <= 0
|
|
114
|
+
unless warn_at.nil?
|
|
115
|
+
wa = Float(warn_at)
|
|
116
|
+
unless wa > 0.0 && wa < 1.0
|
|
117
|
+
raise ArgumentError, "SpendCap: warn_at must be between 0 and 1 exclusive (got #{warn_at})."
|
|
118
|
+
end
|
|
119
|
+
end
|
|
92
120
|
mutex.synchronize do
|
|
93
|
-
limits[key] =
|
|
121
|
+
limits[key] =
|
|
122
|
+
if limit_tokens.nil?
|
|
123
|
+
nil
|
|
124
|
+
else
|
|
125
|
+
cfg = { limit: Integer(limit_tokens), window: w }
|
|
126
|
+
cfg[:warn_at] = Float(warn_at) unless warn_at.nil?
|
|
127
|
+
cfg
|
|
128
|
+
end
|
|
94
129
|
end
|
|
95
130
|
nil
|
|
96
131
|
end
|
|
@@ -111,7 +146,8 @@ module Parse
|
|
|
111
146
|
raise ArgumentError, "SpendCap: tokens must be >= 0 (got #{t})." if t.negative?
|
|
112
147
|
key = tenant_id.nil? ? DEFAULT_KEY : tenant_id
|
|
113
148
|
|
|
114
|
-
|
|
149
|
+
warn_payload = nil
|
|
150
|
+
total = mutex.synchronize do
|
|
115
151
|
cfg = limit_for(key)
|
|
116
152
|
return nil if cfg.nil? # uncapped
|
|
117
153
|
|
|
@@ -128,8 +164,24 @@ module Parse
|
|
|
128
164
|
)
|
|
129
165
|
end
|
|
130
166
|
entries << [now, t] if t.positive?
|
|
167
|
+
# Soft-cap crossing: fire only when THIS charge moves usage
|
|
168
|
+
# from below the threshold to at-or-above it, so a tenant
|
|
169
|
+
# hovering over the line doesn't spam an event per charge.
|
|
170
|
+
# Pruned entries re-arm the warning naturally as the window
|
|
171
|
+
# rolls off.
|
|
172
|
+
if (wa = cfg[:warn_at])
|
|
173
|
+
threshold = limit * wa
|
|
174
|
+
if used < threshold && used + t >= threshold
|
|
175
|
+
warn_payload = {
|
|
176
|
+
tenant_id: key, used: used + t, limit: limit,
|
|
177
|
+
window: window, warn_at: wa, threshold: threshold,
|
|
178
|
+
}
|
|
179
|
+
end
|
|
180
|
+
end
|
|
131
181
|
used + t
|
|
132
182
|
end
|
|
183
|
+
emit_soft_cap_warning(warn_payload) if warn_payload
|
|
184
|
+
total
|
|
133
185
|
end
|
|
134
186
|
|
|
135
187
|
# Current in-window token usage for a tenant (0 when uncapped or
|
|
@@ -146,6 +198,56 @@ module Parse
|
|
|
146
198
|
end
|
|
147
199
|
end
|
|
148
200
|
|
|
201
|
+
# Run a block with the inner query-embed charge suppressed.
|
|
202
|
+
# Callers that have ALREADY charged the cap with better tenant
|
|
203
|
+
# identity (the `semantic_search` agent tool charges per-tenant
|
|
204
|
+
# before calling retrieve) — or that deliberately exempt the
|
|
205
|
+
# call (trusted admin agents) — wrap their downstream embed in
|
|
206
|
+
# this so {.charge_query!} inside `find_similar` / `retrieve`
|
|
207
|
+
# is a no-op. Restores the prior flag on exit (nesting-safe).
|
|
208
|
+
#
|
|
209
|
+
# @return [Object] the block's return value.
|
|
210
|
+
def with_precharged
|
|
211
|
+
prev = Thread.current[PRECHARGED_KEY]
|
|
212
|
+
Thread.current[PRECHARGED_KEY] = true
|
|
213
|
+
yield
|
|
214
|
+
ensure
|
|
215
|
+
Thread.current[PRECHARGED_KEY] = prev
|
|
216
|
+
end
|
|
217
|
+
|
|
218
|
+
# @return [Boolean] whether the current call stack is inside
|
|
219
|
+
# {.with_precharged}.
|
|
220
|
+
def precharged?
|
|
221
|
+
!!Thread.current[PRECHARGED_KEY]
|
|
222
|
+
end
|
|
223
|
+
|
|
224
|
+
# Charge a query-embed against the cap from a non-agent path.
|
|
225
|
+
# This is the v5.5 closure of "spend-cap coverage on all embed
|
|
226
|
+
# paths": `find_similar(text:)`, `hybrid_search(text:)`, and
|
|
227
|
+
# `Parse::Retrieval.retrieve` route their query text through
|
|
228
|
+
# here before embedding.
|
|
229
|
+
#
|
|
230
|
+
# * No-op inside {.with_precharged} (the agent tool charged
|
|
231
|
+
# already, with per-tenant identity).
|
|
232
|
+
# * Tenant identity falls back to the ambient cache-tenant
|
|
233
|
+
# scope ({Parse.with_cache_tenant}) when set, else the shared
|
|
234
|
+
# {DEFAULT_KEY} bucket.
|
|
235
|
+
# * No-op (like {.charge!}) when no limit is configured.
|
|
236
|
+
#
|
|
237
|
+
# @param text [String] the query text about to be embedded.
|
|
238
|
+
# @param tenant_id [Object, nil] explicit tenant identity;
|
|
239
|
+
# nil resolves the ambient cache tenant, then DEFAULT_KEY.
|
|
240
|
+
# @return [Integer, nil] new in-window total, or nil when
|
|
241
|
+
# uncapped / precharged.
|
|
242
|
+
# @raise [Exceeded]
|
|
243
|
+
def charge_query!(text, tenant_id: nil)
|
|
244
|
+
return nil if precharged?
|
|
245
|
+
if tenant_id.nil? && defined?(Parse) && Parse.respond_to?(:current_cache_tenant)
|
|
246
|
+
tenant_id = Parse.current_cache_tenant
|
|
247
|
+
end
|
|
248
|
+
charge!(tenant_id: tenant_id, tokens: estimate_tokens(text))
|
|
249
|
+
end
|
|
250
|
+
|
|
149
251
|
# Estimate token count from a String.
|
|
150
252
|
#
|
|
151
253
|
# The familiar "~4 characters per token" ratio only holds for
|
|
@@ -249,6 +351,18 @@ module Parse
|
|
|
249
351
|
def monotonic
|
|
250
352
|
Process.clock_gettime(Process::CLOCK_MONOTONIC)
|
|
251
353
|
end
|
|
354
|
+
|
|
355
|
+
# Emit the soft-cap AS::N event OUTSIDE the mutex (subscribers
|
|
356
|
+
# run synchronously on the calling thread; a slow subscriber
|
|
357
|
+
# must not serialize every other tenant's charge).
|
|
358
|
+
def emit_soft_cap_warning(payload)
|
|
359
|
+
return unless defined?(ActiveSupport::Notifications)
|
|
360
|
+
ActiveSupport::Notifications.instrument(AS_NOTIFICATION_NAME, payload) {}
|
|
361
|
+
rescue StandardError
|
|
362
|
+
# A raising subscriber must not turn a successful (admitted)
|
|
363
|
+
# charge into a caller-visible failure.
|
|
364
|
+
nil
|
|
365
|
+
end
|
|
252
366
|
end
|
|
253
367
|
end
|
|
254
368
|
end
|
|
@@ -278,27 +278,32 @@ module Parse
|
|
|
278
278
|
MULTIMODAL_MODELS.include?(@model) ? %i[text image] : [:text]
|
|
279
279
|
end
|
|
280
280
|
|
|
281
|
-
# Embed a batch of
|
|
282
|
-
# `/v1/multimodalembeddings` endpoint.
|
|
283
|
-
#
|
|
284
|
-
#
|
|
285
|
-
#
|
|
286
|
-
#
|
|
287
|
-
#
|
|
288
|
-
#
|
|
281
|
+
# Embed a batch of images through Voyage's
|
|
282
|
+
# `/v1/multimodalembeddings` endpoint. Two source forms:
|
|
283
|
+
#
|
|
284
|
+
# * **String URL** (v5.1 path) — the provider receives a public
|
|
285
|
+
# URL and issues its own fetch. The SDK does NOT download the
|
|
286
|
+
# image; it validates the URL through
|
|
287
|
+
# {Parse::Embeddings.validate_image_url!} (CIDR / port / host
|
|
288
|
+
# allowlist, sentinel-gated egress opt-in) and forwards the
|
|
289
|
+
# canonicalized URL string in a `{ type: "image_url",
|
|
290
|
+
# image_url: ... }` content row.
|
|
291
|
+
# * **{Parse::Embeddings::ImageFetch::FetchedImage}** (v5.5 bytes
|
|
292
|
+
# path) — bytes the SDK already downloaded through
|
|
293
|
+
# {Parse::File.safe_open_url}, magic-byte-verified, and
|
|
294
|
+
# EXIF-stripped. Forwarded as a `{ type: "image_base64",
|
|
295
|
+
# image_base64: "data:<mime>;base64,..." }` content row. No URL
|
|
296
|
+
# validation runs (there is no provider-side fetch) and the
|
|
297
|
+
# `trust_provider_url_fetch` sentinel is NOT required.
|
|
289
298
|
#
|
|
290
299
|
# **Multimodal model required.** Voyage's text-only models
|
|
291
300
|
# (`voyage-3`, `voyage-4`, etc.) do not accept image inputs;
|
|
292
301
|
# calling `embed_image` on a provider configured with one of
|
|
293
302
|
# those raises {BadRequestError} before any network call.
|
|
294
303
|
#
|
|
295
|
-
#
|
|
296
|
-
#
|
|
297
|
-
#
|
|
298
|
-
# base64. URL-only ships first because it sidesteps EXIF /
|
|
299
|
-
# MIME-confusion class issues entirely.
|
|
300
|
-
#
|
|
301
|
-
# @param sources [Array<String>] image URLs. Each must satisfy
|
|
304
|
+
# @param sources [Array<String, Parse::Embeddings::ImageFetch::FetchedImage>]
|
|
305
|
+
# image URLs and/or fetched-bytes wrappers (forms may be
|
|
306
|
+
# mixed). Each URL must satisfy
|
|
302
307
|
# {Parse::Embeddings.validate_image_url!} — failing entries
|
|
303
308
|
# raise the corresponding {Parse::Embeddings::InvalidImageURL}
|
|
304
309
|
# / {Parse::Embeddings::ConfirmationRequired} and ABORT the
|
|
@@ -342,22 +347,26 @@ module Parse
|
|
|
342
347
|
|
|
343
348
|
# Validate every URL up-front so a malformed entry in slot N
|
|
344
349
|
# does not get past validation while slots 0..N-1 are already
|
|
345
|
-
# in the wire body.
|
|
346
|
-
# URL
|
|
347
|
-
|
|
348
|
-
|
|
350
|
+
# in the wire body. URL entries forward the validator's
|
|
351
|
+
# canonicalized URL (never the caller's raw input); fetched-
|
|
352
|
+
# bytes entries skip URL validation (the bytes were already
|
|
353
|
+
# downloaded + verified by ImageFetch) and forward as base64.
|
|
354
|
+
content_rows = sources.each_with_index.map do |src, i|
|
|
355
|
+
if src.is_a?(Parse::Embeddings::ImageFetch::FetchedImage)
|
|
356
|
+
{ content: [{ type: "image_base64", image_base64: src.to_data_uri }] }
|
|
357
|
+
elsif src.is_a?(String)
|
|
358
|
+
canonical = Parse::Embeddings.validate_image_url!(src, allow_insecure: allow_insecure)
|
|
359
|
+
{ content: [{ type: "image_url", image_url: canonical }] }
|
|
360
|
+
else
|
|
349
361
|
raise ArgumentError,
|
|
350
|
-
"Parse::Embeddings::Voyage#embed_image sources[#{i}]
|
|
351
|
-
"(#{
|
|
362
|
+
"Parse::Embeddings::Voyage#embed_image sources[#{i}] must be a URL String " \
|
|
363
|
+
"or Parse::Embeddings::ImageFetch::FetchedImage (got #{src.class})."
|
|
352
364
|
end
|
|
353
|
-
Parse::Embeddings.validate_image_url!(url, allow_insecure: allow_insecure)
|
|
354
365
|
end
|
|
355
366
|
|
|
356
367
|
wire_input_type = INPUT_TYPE_WIRE_VALUES[input_type]
|
|
357
368
|
body = {
|
|
358
|
-
inputs:
|
|
359
|
-
{ content: [{ type: "image_url", image_url: u }] }
|
|
360
|
-
},
|
|
369
|
+
inputs: content_rows,
|
|
361
370
|
model: @model,
|
|
362
371
|
truncation: @truncation,
|
|
363
372
|
}
|