parse-stack-next 5.5.5 → 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 +338 -0
- data/lib/parse/agent/mcp_rack_app.rb +29 -11
- 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,211 @@
|
|
|
1
|
+
# encoding: UTF-8
|
|
2
|
+
# frozen_string_literal: true
|
|
3
|
+
|
|
4
|
+
module Parse
|
|
5
|
+
module Embeddings
|
|
6
|
+
# Checks that a `:vector` property's declared provider binding
|
|
7
|
+
# still matches the provider actually registered under that name.
|
|
8
|
+
#
|
|
9
|
+
# A `:vector` property may declare `provider:`, `model:`, and
|
|
10
|
+
# `dimensions:`. Only `provider:` was ever enforced. `dimensions:`
|
|
11
|
+
# is verified — but only against the vector a provider already
|
|
12
|
+
# returned, i.e. after the call has been made and paid for. And
|
|
13
|
+
# `model:` was never checked at all, which is the dangerous one: two
|
|
14
|
+
# generations of the same model family usually share a width
|
|
15
|
+
# (`voyage-3` and `voyage-3.5` are both 1024), so swapping the
|
|
16
|
+
# registered provider's model silently mixes incompatible
|
|
17
|
+
# embeddings into one index. Nothing raises, recall just quietly
|
|
18
|
+
# degrades, and the damage is only repairable by re-embedding.
|
|
19
|
+
#
|
|
20
|
+
# This module closes both gaps by comparing the declaration against
|
|
21
|
+
# the live provider BEFORE any request is issued.
|
|
22
|
+
#
|
|
23
|
+
# Auditing cannot happen at class-definition time: providers are
|
|
24
|
+
# registered by name and, as {Parse::Core::EmbedManaged} documents,
|
|
25
|
+
# registration may legitimately happen any time before the first
|
|
26
|
+
# save. So the audit runs lazily on each use and is also exposed as
|
|
27
|
+
# {.audit_all!} for an explicit boot-time or CI check.
|
|
28
|
+
module BindingAudit
|
|
29
|
+
# Raised when a property's declared binding disagrees with the
|
|
30
|
+
# registered provider.
|
|
31
|
+
class BindingMismatch < Parse::Embeddings::Error; end
|
|
32
|
+
|
|
33
|
+
# Raised when the audit cannot enumerate the classes it is meant
|
|
34
|
+
# to check. Distinct from {BindingMismatch}: nothing was found to
|
|
35
|
+
# be wrong, but nothing was verified either.
|
|
36
|
+
class DiscoveryFailed < Parse::Embeddings::Error; end
|
|
37
|
+
|
|
38
|
+
class << self
|
|
39
|
+
# Verify one property binding against a resolved provider.
|
|
40
|
+
#
|
|
41
|
+
# Deliberately NOT memoized. The check is a pair of comparisons
|
|
42
|
+
# against values already in memory, so caching it saves nothing
|
|
43
|
+
# measurable — while any cache key cheap enough to be worth
|
|
44
|
+
# computing (class name, provider object id) can go stale when a
|
|
45
|
+
# class is unloaded and redefined with a changed declaration, or
|
|
46
|
+
# when object ids are recycled. A validator that silently skips
|
|
47
|
+
# after a reload is worse than no validator, so correctness wins
|
|
48
|
+
# over an optimization with no observable benefit.
|
|
49
|
+
#
|
|
50
|
+
# @param klass [Class] the Parse::Object subclass.
|
|
51
|
+
# @param field [Symbol] the `:vector` property name.
|
|
52
|
+
# @param provider [Parse::Embeddings::Provider]
|
|
53
|
+
# @raise [BindingMismatch]
|
|
54
|
+
# @return [void]
|
|
55
|
+
def verify!(klass, field, provider)
|
|
56
|
+
declared = klass.vector_properties[field.to_sym]
|
|
57
|
+
return if declared.nil?
|
|
58
|
+
|
|
59
|
+
check!(klass, field, provider, declared)
|
|
60
|
+
nil
|
|
61
|
+
end
|
|
62
|
+
|
|
63
|
+
# Audit every declared binding whose provider is registered.
|
|
64
|
+
# Intended for boot or CI: it surfaces a drifted declaration
|
|
65
|
+
# before a single embedding is written, rather than on the
|
|
66
|
+
# first save that happens to touch it.
|
|
67
|
+
#
|
|
68
|
+
# @param classes [Array<Class>, nil] defaults to every
|
|
69
|
+
# Parse::Object subclass carrying `:vector` properties.
|
|
70
|
+
# @param strict [Boolean] when true, an unregistered provider
|
|
71
|
+
# is itself a failure; otherwise those bindings are skipped
|
|
72
|
+
# (a provider may be registered later in boot).
|
|
73
|
+
# @return [Array<String>] human-readable problems, empty when clean.
|
|
74
|
+
def audit_all!(classes: nil, strict: false)
|
|
75
|
+
problems = []
|
|
76
|
+
begin
|
|
77
|
+
bindings = collect_bindings(classes)
|
|
78
|
+
rescue DiscoveryFailed => e
|
|
79
|
+
return [e.message]
|
|
80
|
+
end
|
|
81
|
+
|
|
82
|
+
bindings.each do |klass, field, declared|
|
|
83
|
+
provider_name = declared[:provider]
|
|
84
|
+
next if provider_name.nil?
|
|
85
|
+
|
|
86
|
+
begin
|
|
87
|
+
provider = Parse::Embeddings.provider(provider_name)
|
|
88
|
+
rescue Parse::Embeddings::ProviderNotRegistered => e
|
|
89
|
+
problems << "#{klass}##{field}: #{e.message}" if strict
|
|
90
|
+
next
|
|
91
|
+
end
|
|
92
|
+
|
|
93
|
+
begin
|
|
94
|
+
check!(klass, field, provider, declared)
|
|
95
|
+
rescue BindingMismatch => e
|
|
96
|
+
problems << e.message
|
|
97
|
+
end
|
|
98
|
+
end
|
|
99
|
+
problems
|
|
100
|
+
end
|
|
101
|
+
|
|
102
|
+
# {.audit_all!} that raises instead of returning problems.
|
|
103
|
+
#
|
|
104
|
+
# @raise [BindingMismatch] when any binding disagrees.
|
|
105
|
+
# @return [void]
|
|
106
|
+
def audit_all_or_raise!(classes: nil, strict: false)
|
|
107
|
+
problems = audit_all!(classes: classes, strict: strict)
|
|
108
|
+
return if problems.empty?
|
|
109
|
+
|
|
110
|
+
raise BindingMismatch,
|
|
111
|
+
"Parse::Embeddings binding audit failed:\n - #{problems.join("\n - ")}"
|
|
112
|
+
end
|
|
113
|
+
|
|
114
|
+
# Retained as a no-op for callers that invoked it when this
|
|
115
|
+
# module memoized verdicts.
|
|
116
|
+
# @return [void]
|
|
117
|
+
def reset!
|
|
118
|
+
nil
|
|
119
|
+
end
|
|
120
|
+
|
|
121
|
+
private
|
|
122
|
+
|
|
123
|
+
# Read a provider accessor, distinguishing "not implemented"
|
|
124
|
+
# from a legitimate nil. Returns `:unavailable` for the former
|
|
125
|
+
# so {#check!} can fail closed rather than skip the comparison.
|
|
126
|
+
def accessor(provider, method)
|
|
127
|
+
value = provider.public_send(method)
|
|
128
|
+
value.nil? ? :unavailable : value
|
|
129
|
+
rescue NotImplementedError, NoMethodError
|
|
130
|
+
:unavailable
|
|
131
|
+
end
|
|
132
|
+
|
|
133
|
+
def check!(klass, field, provider, declared)
|
|
134
|
+
declared_model = declared[:model]
|
|
135
|
+
if declared_model
|
|
136
|
+
actual_model = accessor(provider, :model_name)
|
|
137
|
+
# A declaration states a requirement. A provider that cannot
|
|
138
|
+
# answer what model it runs cannot satisfy it, so this fails
|
|
139
|
+
# closed — otherwise a custom provider without `model_name`
|
|
140
|
+
# would write same-width embeddings that are never checked
|
|
141
|
+
# against the declaration at all.
|
|
142
|
+
if actual_model == :unavailable
|
|
143
|
+
raise BindingMismatch,
|
|
144
|
+
"#{klass}##{field} declares model: #{declared_model.inspect} but the " \
|
|
145
|
+
"provider registered as #{declared[:provider].inspect} " \
|
|
146
|
+
"(#{provider.class}) does not report a usable #model_name, so the " \
|
|
147
|
+
"binding cannot be verified. Implement #model_name on the provider, " \
|
|
148
|
+
"or drop `model:` from the property to opt out of the check."
|
|
149
|
+
end
|
|
150
|
+
if declared_model.to_s != actual_model.to_s
|
|
151
|
+
raise BindingMismatch,
|
|
152
|
+
"#{klass}##{field} declares model: #{declared_model.inspect} but the " \
|
|
153
|
+
"provider registered as #{declared[:provider].inspect} is running " \
|
|
154
|
+
"#{actual_model.inspect}. Vectors from different models are not " \
|
|
155
|
+
"comparable; embedding with the current provider would corrupt this " \
|
|
156
|
+
"index. Update the declaration and re-embed, or register the declared " \
|
|
157
|
+
"model."
|
|
158
|
+
end
|
|
159
|
+
end
|
|
160
|
+
|
|
161
|
+
declared_dims = declared[:dimensions]
|
|
162
|
+
if declared_dims
|
|
163
|
+
actual_dims = accessor(provider, :dimensions)
|
|
164
|
+
if actual_dims == :unavailable
|
|
165
|
+
raise BindingMismatch,
|
|
166
|
+
"#{klass}##{field} declares dimensions: #{declared_dims} but the " \
|
|
167
|
+
"provider registered as #{declared[:provider].inspect} " \
|
|
168
|
+
"(#{provider.class}) does not report a usable #dimensions, so the " \
|
|
169
|
+
"binding cannot be verified. #dimensions is required by the provider " \
|
|
170
|
+
"protocol."
|
|
171
|
+
end
|
|
172
|
+
if declared_dims != actual_dims
|
|
173
|
+
raise BindingMismatch,
|
|
174
|
+
"#{klass}##{field} declares dimensions: #{declared_dims} but the provider " \
|
|
175
|
+
"registered as #{declared[:provider].inspect} emits #{actual_dims}-dim " \
|
|
176
|
+
"vectors. Fix the declaration or configure the provider's width before " \
|
|
177
|
+
"embedding."
|
|
178
|
+
end
|
|
179
|
+
end
|
|
180
|
+
nil
|
|
181
|
+
end
|
|
182
|
+
|
|
183
|
+
def collect_bindings(classes)
|
|
184
|
+
list = classes || default_classes
|
|
185
|
+
list.flat_map do |klass|
|
|
186
|
+
next [] unless klass.respond_to?(:vector_properties)
|
|
187
|
+
klass.vector_properties.map { |field, declared| [klass, field, declared] }
|
|
188
|
+
end
|
|
189
|
+
end
|
|
190
|
+
|
|
191
|
+
# Discovery must NOT swallow its own failure: an empty list from
|
|
192
|
+
# a crashed sweep is indistinguishable from a clean audit, so
|
|
193
|
+
# `audit_all_or_raise!` would report success having checked
|
|
194
|
+
# nothing. Failures propagate as {DiscoveryFailed} and
|
|
195
|
+
# {.audit_all!} converts them into a reported problem.
|
|
196
|
+
def default_classes
|
|
197
|
+
return [] unless defined?(Parse::Object)
|
|
198
|
+
ObjectSpace.each_object(Class).select do |k|
|
|
199
|
+
k < Parse::Object && k.respond_to?(:vector_properties) &&
|
|
200
|
+
!k.vector_properties.empty?
|
|
201
|
+
end
|
|
202
|
+
rescue StandardError => e
|
|
203
|
+
raise DiscoveryFailed,
|
|
204
|
+
"Parse::Embeddings::BindingAudit could not enumerate Parse::Object " \
|
|
205
|
+
"subclasses (#{e.class}: #{e.message}); the audit checked nothing. " \
|
|
206
|
+
"Pass `classes:` explicitly to audit a known set."
|
|
207
|
+
end
|
|
208
|
+
end
|
|
209
|
+
end
|
|
210
|
+
end
|
|
211
|
+
end
|
|
@@ -0,0 +1,136 @@
|
|
|
1
|
+
# encoding: UTF-8
|
|
2
|
+
# frozen_string_literal: true
|
|
3
|
+
|
|
4
|
+
module Parse
|
|
5
|
+
module Embeddings
|
|
6
|
+
# A file-backed image or video input that is **streamed** into the
|
|
7
|
+
# request body rather than read into memory.
|
|
8
|
+
#
|
|
9
|
+
# This is the memory-safe counterpart to
|
|
10
|
+
# {ImageFetch::FetchedImage}, which holds raw bytes. A MediaFile
|
|
11
|
+
# holds only a path, a sniffed MIME type, and a byte count; the
|
|
12
|
+
# bytes are read and base64-encoded incrementally by
|
|
13
|
+
# {StreamingBody} while the request is being written to the socket.
|
|
14
|
+
# Peak memory stays at {StreamingBody::READ_CHUNK} no matter how
|
|
15
|
+
# large the file is, which is what makes video viable on a small
|
|
16
|
+
# dyno.
|
|
17
|
+
#
|
|
18
|
+
# Only the first 16 bytes are read at construction time, to sniff
|
|
19
|
+
# the container. The `Content-Type` header and the filename
|
|
20
|
+
# extension are never consulted.
|
|
21
|
+
#
|
|
22
|
+
# @example stream a local image
|
|
23
|
+
# img = Parse::Embeddings::MediaFile.image("diagram.png")
|
|
24
|
+
# provider.embed_image([img])
|
|
25
|
+
#
|
|
26
|
+
# @example stream a local video
|
|
27
|
+
# clip = Parse::Embeddings::MediaFile.video("demo.mp4")
|
|
28
|
+
# provider.embed_video([clip])
|
|
29
|
+
class MediaFile
|
|
30
|
+
# Voyage documents 20 MB per image and 20 MB per video.
|
|
31
|
+
# Overridable via {Parse::Embeddings.max_media_bytes=}.
|
|
32
|
+
DEFAULT_MAX_MEDIA_BYTES = 20 * 1024 * 1024
|
|
33
|
+
|
|
34
|
+
# Raised when a file exceeds {Parse::Embeddings.max_media_bytes}.
|
|
35
|
+
class TooLarge < Parse::Embeddings::Error; end
|
|
36
|
+
|
|
37
|
+
# @return [String] absolute path to the backing file.
|
|
38
|
+
attr_reader :path
|
|
39
|
+
# @return [String] sniffed MIME type.
|
|
40
|
+
attr_reader :mime_type
|
|
41
|
+
# @return [Integer] size in bytes, captured at construction.
|
|
42
|
+
attr_reader :byte_size
|
|
43
|
+
# @return [Symbol] `:image` or `:video`.
|
|
44
|
+
attr_reader :kind
|
|
45
|
+
|
|
46
|
+
class << self
|
|
47
|
+
# Wrap a local image file. Verifies the magic bytes against
|
|
48
|
+
# {Parse::Embeddings.allowed_image_types}.
|
|
49
|
+
#
|
|
50
|
+
# @param path [String]
|
|
51
|
+
# @return [MediaFile]
|
|
52
|
+
# @raise [ImageFetch::InvalidImageType]
|
|
53
|
+
def image(path)
|
|
54
|
+
header = read_header(path)
|
|
55
|
+
mime = ImageFetch.sniff_mime(header)
|
|
56
|
+
if mime.nil?
|
|
57
|
+
raise ImageFetch::InvalidImageType.new(:unknown_magic,
|
|
58
|
+
"Parse::Embeddings::MediaFile.image: #{path} matches no supported image " \
|
|
59
|
+
"format (JPEG/PNG/GIF/WebP).")
|
|
60
|
+
end
|
|
61
|
+
allowed = Parse::Embeddings.allowed_image_types
|
|
62
|
+
unless allowed.include?(mime)
|
|
63
|
+
raise ImageFetch::InvalidImageType.new(:type_not_allowed,
|
|
64
|
+
"Parse::Embeddings::MediaFile.image: sniffed type #{mime.inspect} is not in " \
|
|
65
|
+
"Parse::Embeddings.allowed_image_types (#{allowed.inspect}).")
|
|
66
|
+
end
|
|
67
|
+
new(path: path, mime_type: mime, kind: :image)
|
|
68
|
+
end
|
|
69
|
+
|
|
70
|
+
# Wrap a local video file. Verifies the magic bytes against
|
|
71
|
+
# {Parse::Embeddings.allowed_video_types}.
|
|
72
|
+
#
|
|
73
|
+
# @param path [String]
|
|
74
|
+
# @return [MediaFile]
|
|
75
|
+
# @raise [VideoSource::InvalidVideoType]
|
|
76
|
+
def video(path)
|
|
77
|
+
header = read_header(path)
|
|
78
|
+
new(path: path, mime_type: VideoSource.verify!(header), kind: :video)
|
|
79
|
+
end
|
|
80
|
+
|
|
81
|
+
private
|
|
82
|
+
|
|
83
|
+
# Read only enough bytes to sniff a container. Deliberately
|
|
84
|
+
# tiny — the whole point of this class is to never hold the
|
|
85
|
+
# file. A file shorter than this is passed through as-is so the
|
|
86
|
+
# sniffers can reject it with their own error.
|
|
87
|
+
def read_header(path)
|
|
88
|
+
unless ::File.file?(path)
|
|
89
|
+
raise ArgumentError,
|
|
90
|
+
"Parse::Embeddings::MediaFile: #{path.inspect} is not a readable file."
|
|
91
|
+
end
|
|
92
|
+
::File.open(path, "rb") { |f| f.read(16).to_s }
|
|
93
|
+
end
|
|
94
|
+
end
|
|
95
|
+
|
|
96
|
+
def initialize(path:, mime_type:, kind:)
|
|
97
|
+
@path = ::File.expand_path(path)
|
|
98
|
+
@mime_type = mime_type
|
|
99
|
+
@kind = kind
|
|
100
|
+
@byte_size = ::File.size(@path)
|
|
101
|
+
if @byte_size.zero?
|
|
102
|
+
raise ArgumentError,
|
|
103
|
+
"Parse::Embeddings::MediaFile: #{path.inspect} is empty."
|
|
104
|
+
end
|
|
105
|
+
cap = Parse::Embeddings.max_media_bytes
|
|
106
|
+
if @byte_size > cap
|
|
107
|
+
raise TooLarge,
|
|
108
|
+
"Parse::Embeddings::MediaFile: #{path.inspect} is #{@byte_size} bytes, over " \
|
|
109
|
+
"the #{cap}-byte limit (Parse::Embeddings.max_media_bytes). Voyage rejects " \
|
|
110
|
+
"media above 20 MB — downscale or re-encode before embedding."
|
|
111
|
+
end
|
|
112
|
+
end
|
|
113
|
+
|
|
114
|
+
# The `data:` URI prefix that precedes the streamed base64 in the
|
|
115
|
+
# wire body. The payload itself is never concatenated here.
|
|
116
|
+
#
|
|
117
|
+
# @return [String]
|
|
118
|
+
def data_uri_prefix
|
|
119
|
+
"data:#{mime_type};base64,"
|
|
120
|
+
end
|
|
121
|
+
|
|
122
|
+
# Segment descriptor consumed by {StreamingBody}.
|
|
123
|
+
#
|
|
124
|
+
# @return [Hash]
|
|
125
|
+
def stream_segment
|
|
126
|
+
{ path: @path, size: @byte_size }
|
|
127
|
+
end
|
|
128
|
+
|
|
129
|
+
def inspect
|
|
130
|
+
"#<Parse::Embeddings::MediaFile kind=#{@kind} mime_type=#{@mime_type.inspect} " \
|
|
131
|
+
"bytes=#{@byte_size} path=#{@path.inspect}>"
|
|
132
|
+
end
|
|
133
|
+
alias_method :to_s, :inspect
|
|
134
|
+
end
|
|
135
|
+
end
|
|
136
|
+
end
|
|
@@ -24,6 +24,7 @@ module Parse
|
|
|
24
24
|
# Subclasses MAY override:
|
|
25
25
|
#
|
|
26
26
|
# * {#embed_image} — v5.1 (multimodal); default `NotImplementedError`
|
|
27
|
+
# * {#embed_video} — v5.6 (multimodal); default `NotImplementedError`
|
|
27
28
|
# * {#embed_batch_size} — provider-recommended batch size hint
|
|
28
29
|
# * {#max_input_tokens} — chunker hint
|
|
29
30
|
# * {#normalize?} — whether output is unit-normalized
|
|
@@ -76,6 +77,44 @@ module Parse
|
|
|
76
77
|
raise NotImplementedError, "#{self.class} does not support image embedding"
|
|
77
78
|
end
|
|
78
79
|
|
|
80
|
+
# Embed video sources. Same contract and default posture as
|
|
81
|
+
# {#embed_image}: providers that do not offer video leave this
|
|
82
|
+
# raising, and callers discover support through {#modalities}
|
|
83
|
+
# rather than by rescuing.
|
|
84
|
+
#
|
|
85
|
+
# Video payloads are large enough that holding one in memory is a
|
|
86
|
+
# real operational risk, so the two source forms concrete
|
|
87
|
+
# providers should accept are:
|
|
88
|
+
#
|
|
89
|
+
# * a URL String, forwarded for provider-side fetch after
|
|
90
|
+
# {Parse::Embeddings.validate_image_url!} screens it (the SDK
|
|
91
|
+
# never downloads the video), and
|
|
92
|
+
# * a {Parse::Embeddings::MediaFile}, streamed into the request
|
|
93
|
+
# body by {Parse::Embeddings::StreamingBody} without ever being
|
|
94
|
+
# fully resident.
|
|
95
|
+
#
|
|
96
|
+
# Concrete overrides must accept `allow_insecure:` explicitly or
|
|
97
|
+
# absorb it via `**opts` — see {#embed_image} for why.
|
|
98
|
+
#
|
|
99
|
+
# @param sources [Array<String, Parse::Embeddings::MediaFile>]
|
|
100
|
+
# @param input_type [Symbol] `:search_query` or `:search_document`.
|
|
101
|
+
# @param allow_insecure [Boolean] forwarded to the URL validator.
|
|
102
|
+
# @param opts [Hash] provider-specific options.
|
|
103
|
+
# @return [Array<Array<Float>>] vectors aligned 1:1 with `sources`.
|
|
104
|
+
# @raise [NotImplementedError] unless the provider offers video.
|
|
105
|
+
def embed_video(sources, input_type: :search_document, allow_insecure: false, **opts)
|
|
106
|
+
raise NotImplementedError, "#{self.class} does not support video embedding"
|
|
107
|
+
end
|
|
108
|
+
|
|
109
|
+
# @return [Boolean] whether this provider accepts `modality`.
|
|
110
|
+
# Prefer this over rescuing {NotImplementedError} — it answers
|
|
111
|
+
# the question without issuing a call.
|
|
112
|
+
#
|
|
113
|
+
# @param modality [Symbol] one of `:text`, `:image`, `:video`.
|
|
114
|
+
def supports_modality?(modality)
|
|
115
|
+
modalities.include?(modality.to_sym)
|
|
116
|
+
end
|
|
117
|
+
|
|
79
118
|
# Batched text embedding. Splits `strings` into chunks of size
|
|
80
119
|
# {#embed_batch_size} (or returns a single-shot call when nil) and
|
|
81
120
|
# concatenates results. Concrete providers should override only
|
|
@@ -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
|