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.
@@ -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