parse-stack-next 5.5.6 → 5.6.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 +225 -0
- data/lib/parse/agent/metadata_dsl.rb +10 -0
- data/lib/parse/embeddings/binding_audit.rb +211 -0
- data/lib/parse/embeddings/media_file.rb +136 -0
- data/lib/parse/embeddings/provider.rb +39 -0
- data/lib/parse/embeddings/streaming_body.rb +170 -0
- data/lib/parse/embeddings/video_source.rb +120 -0
- data/lib/parse/embeddings/voyage.rb +548 -135
- data/lib/parse/embeddings.rb +55 -0
- data/lib/parse/model/core/embed_managed.rb +26 -0
- data/lib/parse/model/core/properties.rb +37 -1
- data/lib/parse/model/core/vector_searchable.rb +30 -3
- data/lib/parse/stack/version.rb +1 -1
- data/lib/parse/vector_search/hybrid.rb +120 -12
- data/lib/parse/vector_search.rb +132 -7
- metadata +5 -1
|
@@ -0,0 +1,170 @@
|
|
|
1
|
+
# encoding: UTF-8
|
|
2
|
+
# frozen_string_literal: true
|
|
3
|
+
|
|
4
|
+
require "base64"
|
|
5
|
+
|
|
6
|
+
module Parse
|
|
7
|
+
module Embeddings
|
|
8
|
+
# An IO-shaped request body that splices base64-encoded files into
|
|
9
|
+
# a JSON envelope **without ever holding a whole file in memory**.
|
|
10
|
+
#
|
|
11
|
+
# Multimodal endpoints want one JSON document with the media inlined
|
|
12
|
+
# as a `data:` URI. Building that with `to_json` costs roughly 2.4x
|
|
13
|
+
# the media size resident (raw bytes + the 1.33x base64 copy + the
|
|
14
|
+
# serialized JSON String), which is enough to OOM a small dyno on a
|
|
15
|
+
# single moderate video. This class instead emits the body as a
|
|
16
|
+
# stream of segments — literal JSON fragments interleaved with
|
|
17
|
+
# files that are read and encoded {READ_CHUNK} bytes at a time —
|
|
18
|
+
# so peak memory is bounded by the chunk size regardless of how
|
|
19
|
+
# large the media is, and nothing is spilled to disk either.
|
|
20
|
+
#
|
|
21
|
+
# Faraday's net_http adapter assigns any body responding to `#read`
|
|
22
|
+
# to `Net::HTTP::Request#body_stream`, which pulls it incrementally.
|
|
23
|
+
# {#size} is exact, so callers can set `Content-Length` and avoid
|
|
24
|
+
# chunked transfer encoding (which some API gateways reject).
|
|
25
|
+
#
|
|
26
|
+
# Base64 is spliced directly into the JSON string literal with no
|
|
27
|
+
# escaping: the alphabet (`A-Za-z0-9+/=`) contains no character
|
|
28
|
+
# that JSON requires escaping, so this is safe by construction.
|
|
29
|
+
class StreamingBody
|
|
30
|
+
# Bytes read from a source file per fill. MUST stay a multiple of
|
|
31
|
+
# 3 so each chunk encodes to a padding-free base64 block and the
|
|
32
|
+
# concatenation is byte-identical to encoding the whole file at
|
|
33
|
+
# once. Only the final (short) chunk may carry `=` padding.
|
|
34
|
+
READ_CHUNK = 57 * 1024
|
|
35
|
+
|
|
36
|
+
# Raised when a segment's file changes size between the
|
|
37
|
+
# {#size} calculation and the actual read, which would desync
|
|
38
|
+
# `Content-Length` from the emitted body.
|
|
39
|
+
class SizeMismatch < Parse::Embeddings::Error; end
|
|
40
|
+
|
|
41
|
+
# @param segments [Array<String, Hash>] literal Strings are
|
|
42
|
+
# emitted verbatim; Hashes of the form
|
|
43
|
+
# `{ path: String, size: Integer }` are base64-streamed.
|
|
44
|
+
def initialize(segments)
|
|
45
|
+
@segments = segments.map do |seg|
|
|
46
|
+
case seg
|
|
47
|
+
when String then seg.dup.force_encoding(Encoding::BINARY)
|
|
48
|
+
when Hash
|
|
49
|
+
unless seg[:path].is_a?(String) && seg[:size].is_a?(Integer)
|
|
50
|
+
raise ArgumentError,
|
|
51
|
+
"Parse::Embeddings::StreamingBody: file segment needs :path and :size."
|
|
52
|
+
end
|
|
53
|
+
seg
|
|
54
|
+
else
|
|
55
|
+
raise ArgumentError,
|
|
56
|
+
"Parse::Embeddings::StreamingBody: segment must be a String or " \
|
|
57
|
+
"{path:, size:} Hash (got #{seg.class})."
|
|
58
|
+
end
|
|
59
|
+
end
|
|
60
|
+
rewind
|
|
61
|
+
end
|
|
62
|
+
|
|
63
|
+
# Exact byte length of the fully-emitted body. Base64 expands
|
|
64
|
+
# every 3 input bytes to 4 output bytes, padded up.
|
|
65
|
+
#
|
|
66
|
+
# @return [Integer]
|
|
67
|
+
def size
|
|
68
|
+
@size ||= @segments.sum do |seg|
|
|
69
|
+
seg.is_a?(String) ? seg.bytesize : 4 * ((seg[:size] + 2) / 3)
|
|
70
|
+
end
|
|
71
|
+
end
|
|
72
|
+
alias_method :length, :size
|
|
73
|
+
|
|
74
|
+
# @param len [Integer, nil] bytes wanted; nil reads to the end
|
|
75
|
+
# (which defeats the memory bound — Net::HTTP always passes a
|
|
76
|
+
# length, so this is only for completeness).
|
|
77
|
+
# @param out [String, nil] optional output buffer to fill.
|
|
78
|
+
# @return [String, nil] nil once exhausted, per IO#read semantics.
|
|
79
|
+
def read(len = nil, out = nil)
|
|
80
|
+
fill(len)
|
|
81
|
+
if @buffer.empty?
|
|
82
|
+
out&.clear
|
|
83
|
+
return len.nil? ? "" : nil
|
|
84
|
+
end
|
|
85
|
+
|
|
86
|
+
chunk =
|
|
87
|
+
if len.nil?
|
|
88
|
+
b = @buffer
|
|
89
|
+
@buffer = +""
|
|
90
|
+
b
|
|
91
|
+
else
|
|
92
|
+
@buffer.slice!(0, len)
|
|
93
|
+
end
|
|
94
|
+
|
|
95
|
+
if out
|
|
96
|
+
out.replace(chunk)
|
|
97
|
+
out
|
|
98
|
+
else
|
|
99
|
+
chunk
|
|
100
|
+
end
|
|
101
|
+
end
|
|
102
|
+
|
|
103
|
+
# Reset to the start so the body can be replayed (Net::HTTP
|
|
104
|
+
# rewinds `body_stream` when it retries a request).
|
|
105
|
+
#
|
|
106
|
+
# @return [void]
|
|
107
|
+
def rewind
|
|
108
|
+
close
|
|
109
|
+
@buffer = +""
|
|
110
|
+
@index = 0
|
|
111
|
+
@io = nil
|
|
112
|
+
nil
|
|
113
|
+
end
|
|
114
|
+
|
|
115
|
+
# @return [void]
|
|
116
|
+
def close
|
|
117
|
+
@io&.close
|
|
118
|
+
@io = nil
|
|
119
|
+
nil
|
|
120
|
+
end
|
|
121
|
+
|
|
122
|
+
private
|
|
123
|
+
|
|
124
|
+
# Top up @buffer until it holds `len` bytes or the segments run
|
|
125
|
+
# out. Reads at most one READ_CHUNK per iteration, so peak
|
|
126
|
+
# memory is READ_CHUNK * 4/3 plus whatever the caller asked for.
|
|
127
|
+
def fill(len)
|
|
128
|
+
loop do
|
|
129
|
+
return if len && @buffer.bytesize >= len
|
|
130
|
+
return if @index >= @segments.length
|
|
131
|
+
|
|
132
|
+
seg = @segments[@index]
|
|
133
|
+
if seg.is_a?(String)
|
|
134
|
+
@buffer << seg
|
|
135
|
+
@index += 1
|
|
136
|
+
next
|
|
137
|
+
end
|
|
138
|
+
|
|
139
|
+
@io ||= begin
|
|
140
|
+
f = ::File.open(seg[:path], "rb")
|
|
141
|
+
actual = f.size
|
|
142
|
+
if actual != seg[:size]
|
|
143
|
+
f.close
|
|
144
|
+
raise SizeMismatch,
|
|
145
|
+
"Parse::Embeddings::StreamingBody: #{seg[:path]} is #{actual} bytes but " \
|
|
146
|
+
"#{seg[:size]} was declared; the file changed underneath the request."
|
|
147
|
+
end
|
|
148
|
+
f
|
|
149
|
+
end
|
|
150
|
+
|
|
151
|
+
data = @io.read(READ_CHUNK)
|
|
152
|
+
if data.nil? || data.empty?
|
|
153
|
+
@io.close
|
|
154
|
+
@io = nil
|
|
155
|
+
@index += 1
|
|
156
|
+
next
|
|
157
|
+
end
|
|
158
|
+
@buffer << Base64.strict_encode64(data)
|
|
159
|
+
# A short read means EOF; close now so the padding lands and
|
|
160
|
+
# the next iteration advances to the following segment.
|
|
161
|
+
if data.bytesize < READ_CHUNK
|
|
162
|
+
@io.close
|
|
163
|
+
@io = nil
|
|
164
|
+
@index += 1
|
|
165
|
+
end
|
|
166
|
+
end
|
|
167
|
+
end
|
|
168
|
+
end
|
|
169
|
+
end
|
|
170
|
+
end
|
|
@@ -0,0 +1,120 @@
|
|
|
1
|
+
# encoding: UTF-8
|
|
2
|
+
# frozen_string_literal: true
|
|
3
|
+
|
|
4
|
+
require "base64"
|
|
5
|
+
|
|
6
|
+
module Parse
|
|
7
|
+
module Embeddings
|
|
8
|
+
# Value objects and magic-byte verification for video inputs to
|
|
9
|
+
# multimodal embedding providers.
|
|
10
|
+
#
|
|
11
|
+
# This mirrors {ImageFetch} deliberately — same sniff-then-verify
|
|
12
|
+
# discipline, same refusal to trust a `Content-Type` header — but
|
|
13
|
+
# it deliberately ships NO byte-holding value object and NO
|
|
14
|
+
# `fetch!` counterpart. Video payloads are large enough that
|
|
15
|
+
# holding one in memory can exhaust a small dyno, so both supported
|
|
16
|
+
# paths avoid it:
|
|
17
|
+
#
|
|
18
|
+
# * **local file** — wrap with {Parse::Embeddings::MediaFile.video},
|
|
19
|
+
# which reads only the 16-byte header. The bytes are base64-
|
|
20
|
+
# streamed into the request by {StreamingBody} at send time.
|
|
21
|
+
# * **URL** — pass the URL String straight to `embed_video`, which
|
|
22
|
+
# validates it through {Parse::Embeddings.validate_image_url!}
|
|
23
|
+
# (a generic URL-safety guard despite the name: CIDR screen, port
|
|
24
|
+
# and host allowlist, sentinel-gated egress) and lets the
|
|
25
|
+
# provider do the fetch. The SDK never downloads the video.
|
|
26
|
+
module VideoSource
|
|
27
|
+
# Raised when bytes fail verification — unknown magic, or a
|
|
28
|
+
# sniffed type outside the allowlist. Carries a `:reason` tag
|
|
29
|
+
# (`:empty`, `:unknown_magic`, `:type_not_allowed`) matching
|
|
30
|
+
# {ImageFetch::InvalidImageType}'s convention.
|
|
31
|
+
class InvalidVideoType < Parse::Embeddings::Error
|
|
32
|
+
# @return [Symbol] failure-mode tag.
|
|
33
|
+
attr_reader :reason
|
|
34
|
+
def initialize(reason, message)
|
|
35
|
+
@reason = reason
|
|
36
|
+
super(message)
|
|
37
|
+
end
|
|
38
|
+
end
|
|
39
|
+
|
|
40
|
+
# MIME types accepted by default. Voyage documents MP4 as the
|
|
41
|
+
# only supported video container, and the live API rejects WebM
|
|
42
|
+
# and QuickTime payloads outright — so accepting them here would
|
|
43
|
+
# only trade a clear local error for an opaque provider 400.
|
|
44
|
+
# {.sniff_mime} still *recognizes* those containers so the
|
|
45
|
+
# rejection can name what was actually supplied.
|
|
46
|
+
DEFAULT_ALLOWED_VIDEO_TYPES = %w[video/mp4].freeze
|
|
47
|
+
|
|
48
|
+
# ISO Base Media major brands, split by the MIME type they imply.
|
|
49
|
+
# An `ftyp` box alone does NOT mean MP4 — QuickTime and the
|
|
50
|
+
# audio-only profiles share the container — so brands are matched
|
|
51
|
+
# explicitly and an unknown brand sniffs as nil rather than being
|
|
52
|
+
# assumed to be MP4.
|
|
53
|
+
#
|
|
54
|
+
# `M4A` is deliberately absent: it is Apple's audio-only profile,
|
|
55
|
+
# and admitting it here would let an audio file pass as video and
|
|
56
|
+
# be sent to a provider that accepts neither.
|
|
57
|
+
MP4_BRANDS = %w[isom iso2 iso4 iso5 iso6 mp41 mp42 avc1 dash mmp4 M4V].freeze
|
|
58
|
+
QUICKTIME_BRANDS = %w[qt].freeze
|
|
59
|
+
|
|
60
|
+
module_function
|
|
61
|
+
|
|
62
|
+
# Determine a video's MIME type from its leading magic bytes.
|
|
63
|
+
# Returns nil for anything unrecognized — callers must treat nil
|
|
64
|
+
# as a refusal and never fall back to extension or header typing.
|
|
65
|
+
#
|
|
66
|
+
# @param bytes [String] raw video bytes (at least the first 12).
|
|
67
|
+
# @return [String, nil] sniffed MIME type, or nil when unknown.
|
|
68
|
+
def sniff_mime(bytes)
|
|
69
|
+
return nil unless bytes.is_a?(String) && bytes.bytesize >= 12
|
|
70
|
+
b = bytes.byteslice(0, 16).force_encoding(Encoding::BINARY)
|
|
71
|
+
|
|
72
|
+
# Matroska / WebM share an EBML header; WebM is the profile
|
|
73
|
+
# every multimodal provider documents, so report it as WebM.
|
|
74
|
+
return "video/webm" if b.start_with?("\x1A\x45\xDF\xA3".b)
|
|
75
|
+
|
|
76
|
+
# ISO Base Media File Format: a size-prefixed `ftyp` box. The
|
|
77
|
+
# 4-byte major brand at offset 8 separates QuickTime from MP4.
|
|
78
|
+
# Unknown brands return nil — guessing "MP4" for an arbitrary
|
|
79
|
+
# ISO-BMFF file sends the provider something it will reject.
|
|
80
|
+
if b.byteslice(4, 4) == "ftyp".b
|
|
81
|
+
brand = b.byteslice(8, 4).to_s.strip
|
|
82
|
+
return "video/quicktime" if QUICKTIME_BRANDS.include?(brand)
|
|
83
|
+
return "video/mp4" if MP4_BRANDS.include?(brand)
|
|
84
|
+
return nil
|
|
85
|
+
end
|
|
86
|
+
|
|
87
|
+
nil
|
|
88
|
+
end
|
|
89
|
+
|
|
90
|
+
# Sniff the magic bytes and check the allowlist. Only the leading
|
|
91
|
+
# bytes are inspected, so callers may pass a short header slice
|
|
92
|
+
# rather than the whole file — which is what
|
|
93
|
+
# {Parse::Embeddings::MediaFile.video} does.
|
|
94
|
+
#
|
|
95
|
+
# @param bytes [String] leading video bytes (at least 12).
|
|
96
|
+
# @return [String] the sniffed MIME type.
|
|
97
|
+
# @raise [InvalidVideoType]
|
|
98
|
+
def verify!(bytes)
|
|
99
|
+
if bytes.nil? || bytes.empty?
|
|
100
|
+
raise InvalidVideoType.new(:empty,
|
|
101
|
+
"Parse::Embeddings::VideoSource: video payload is empty.")
|
|
102
|
+
end
|
|
103
|
+
mime = sniff_mime(bytes)
|
|
104
|
+
if mime.nil?
|
|
105
|
+
raise InvalidVideoType.new(:unknown_magic,
|
|
106
|
+
"Parse::Embeddings::VideoSource: leading bytes match no supported video " \
|
|
107
|
+
"container (MP4/QuickTime/WebM). The Content-Type header is not consulted — " \
|
|
108
|
+
"unrecognized content is refused outright.")
|
|
109
|
+
end
|
|
110
|
+
allowed = Parse::Embeddings.allowed_video_types
|
|
111
|
+
unless allowed.include?(mime)
|
|
112
|
+
raise InvalidVideoType.new(:type_not_allowed,
|
|
113
|
+
"Parse::Embeddings::VideoSource: sniffed type #{mime.inspect} is not in " \
|
|
114
|
+
"Parse::Embeddings.allowed_video_types (#{allowed.inspect}).")
|
|
115
|
+
end
|
|
116
|
+
mime
|
|
117
|
+
end
|
|
118
|
+
end
|
|
119
|
+
end
|
|
120
|
+
end
|