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.
@@ -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<URI, IO, String>] image sources — URI for
43
- # remote, IO for streamed bytes, String for base64. Concrete
44
- # providers document which forms they accept. In v5.1 (URL-only
45
- # path), every source is a raw `String` URL forwarded unchanged
46
- # from the managed path: {Parse::Core::EmbedManaged} deliberately
47
- # does NOT validate before calling the provider (validating there
48
- # would double-resolve every URL). The concrete `embed_image`
49
- # override is therefore responsible for calling
50
- # {Parse::Embeddings.validate_image_url!} (passing `allow_insecure:`
51
- # through) before egress — see the bundled Voyage/Cohere providers,
52
- # which validate internally.
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] = limit_tokens.nil? ? nil : { limit: Integer(limit_tokens), window: w }
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
- mutex.synchronize do
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 image URLs through Voyage's
282
- # `/v1/multimodalembeddings` endpoint. v5.1 ships URL-only — the
283
- # provider receives a public URL and issues its own fetch. The
284
- # SDK does NOT download the image; it validates the URL through
285
- # {Parse::Embeddings.validate_image_url!} (CIDR / port / host
286
- # allowlist, sentinel-gated egress opt-in) and forwards the
287
- # canonicalized URL string in the `{ type: "image_url",
288
- # image_url: ... }` content row.
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
- # **Bytes-fetch path is v5.3.** A future `bytes:` option will
296
- # download via {Parse::File.safe_open_url}, MIME-sniff the
297
- # leading bytes, optionally EXIF-strip, and forward as
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. The validator returns the canonicalized
346
- # URL we forward exactly that, not the caller's raw input.
347
- canonical_urls = sources.each_with_index.map do |url, i|
348
- unless url.is_a?(String)
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}] is not a String " \
351
- "(#{url.class}). v5.1 ships URL-only — bytes/IO support is v5.3."
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: canonical_urls.map { |u|
359
- { content: [{ type: "image_url", image_url: u }] }
360
- },
369
+ inputs: content_rows,
361
370
  model: @model,
362
371
  truncation: @truncation,
363
372
  }