parse-stack-next 5.5.6 → 5.7.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.
Files changed (119) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +634 -0
  3. data/README.md +378 -6
  4. data/docs/caching.md +748 -0
  5. data/examples/basic_client.rb +3 -3
  6. data/examples/basic_server.rb +3 -3
  7. data/examples/live_query_listener.rb +12 -12
  8. data/examples/rag_chatbot.rb +14 -14
  9. data/examples/transaction_example.rb +44 -45
  10. data/examples/webhook_server.rb +3 -3
  11. data/lib/parse/access.rb +493 -0
  12. data/lib/parse/acl_scope.rb +125 -52
  13. data/lib/parse/agent/approval_gate.rb +0 -0
  14. data/lib/parse/agent/cancellation_token.rb +3 -3
  15. data/lib/parse/agent/constraint_translator.rb +4 -4
  16. data/lib/parse/agent/describe.rb +34 -34
  17. data/lib/parse/agent/errors.rb +9 -9
  18. data/lib/parse/agent/mcp_client.rb +61 -59
  19. data/lib/parse/agent/mcp_dispatcher.rb +89 -101
  20. data/lib/parse/agent/mcp_rack_app.rb +92 -93
  21. data/lib/parse/agent/mcp_server.rb +1 -1
  22. data/lib/parse/agent/mcp_subscriptions.rb +22 -22
  23. data/lib/parse/agent/metadata_audit.rb +1 -2
  24. data/lib/parse/agent/metadata_dsl.rb +17 -7
  25. data/lib/parse/agent/metadata_registry.rb +12 -12
  26. data/lib/parse/agent/prompt_hardening.rb +4 -4
  27. data/lib/parse/agent/prompts.rb +10 -10
  28. data/lib/parse/agent/result_formatter.rb +4 -4
  29. data/lib/parse/agent/tools.rb +493 -400
  30. data/lib/parse/agent.rb +157 -116
  31. data/lib/parse/api/hooks.rb +1 -1
  32. data/lib/parse/api/server.rb +2 -2
  33. data/lib/parse/api/users.rb +2 -2
  34. data/lib/parse/atlas_search/index_manager.rb +1 -1
  35. data/lib/parse/atlas_search/session.rb +40 -218
  36. data/lib/parse/atlas_search.rb +181 -60
  37. data/lib/parse/authorization.rb +466 -0
  38. data/lib/parse/cache/invalidation.rb +167 -0
  39. data/lib/parse/cache/keyspace.rb +306 -0
  40. data/lib/parse/cache/moneta_surface.rb +126 -0
  41. data/lib/parse/cache/pool.rb +48 -5
  42. data/lib/parse/cache/redis.rb +425 -10
  43. data/lib/parse/cache/scoped_view.rb +443 -0
  44. data/lib/parse/cache/sub_cache.rb +264 -0
  45. data/lib/parse/cache/upstream_roles.rb +230 -0
  46. data/lib/parse/client/authentication.rb +1 -1
  47. data/lib/parse/client/body_builder.rb +1 -2
  48. data/lib/parse/client/caching.rb +84 -5
  49. data/lib/parse/client.rb +327 -189
  50. data/lib/parse/clp_scope.rb +225 -28
  51. data/lib/parse/console.rb +3 -3
  52. data/lib/parse/embeddings/batch_embedder.rb +3 -3
  53. data/lib/parse/embeddings/binding_audit.rb +211 -0
  54. data/lib/parse/embeddings/cache.rb +17 -17
  55. data/lib/parse/embeddings/cohere.rb +21 -22
  56. data/lib/parse/embeddings/image_fetch.rb +22 -21
  57. data/lib/parse/embeddings/jina.rb +21 -21
  58. data/lib/parse/embeddings/local_http.rb +6 -7
  59. data/lib/parse/embeddings/media_file.rb +137 -0
  60. data/lib/parse/embeddings/openai.rb +5 -5
  61. data/lib/parse/embeddings/provider.rb +40 -1
  62. data/lib/parse/embeddings/qwen.rb +10 -10
  63. data/lib/parse/embeddings/spend_cap.rb +2 -3
  64. data/lib/parse/embeddings/streaming_body.rb +170 -0
  65. data/lib/parse/embeddings/video_source.rb +121 -0
  66. data/lib/parse/embeddings/voyage.rb +554 -143
  67. data/lib/parse/embeddings.rb +83 -27
  68. data/lib/parse/graphql/type_generator.rb +1 -1
  69. data/lib/parse/graphql.rb +5 -5
  70. data/lib/parse/live_query/client.rb +1 -1
  71. data/lib/parse/live_query.rb +1 -1
  72. data/lib/parse/lock.rb +15 -18
  73. data/lib/parse/lock_backend.rb +1 -1
  74. data/lib/parse/lookup_rewriter.rb +6 -6
  75. data/lib/parse/model/classes/role.rb +227 -28
  76. data/lib/parse/model/classes/user.rb +60 -1
  77. data/lib/parse/model/clp.rb +4 -4
  78. data/lib/parse/model/core/actions.rb +97 -15
  79. data/lib/parse/model/core/create_lock.rb +0 -2
  80. data/lib/parse/model/core/describe.rb +57 -57
  81. data/lib/parse/model/core/embed_managed.rb +43 -19
  82. data/lib/parse/model/core/fetching.rb +2 -0
  83. data/lib/parse/model/core/indexing.rb +14 -14
  84. data/lib/parse/model/core/parse_reference.rb +1 -1
  85. data/lib/parse/model/core/properties.rb +39 -3
  86. data/lib/parse/model/core/querying.rb +1 -1
  87. data/lib/parse/model/core/schema.rb +2 -2
  88. data/lib/parse/model/core/search_indexing.rb +2 -2
  89. data/lib/parse/model/core/vector_searchable.rb +34 -8
  90. data/lib/parse/model/file.rb +21 -15
  91. data/lib/parse/model/geojson.rb +2 -2
  92. data/lib/parse/model/geopoint.rb +1 -0
  93. data/lib/parse/model/object.rb +132 -66
  94. data/lib/parse/model/pointer.rb +2 -0
  95. data/lib/parse/model/polygon.rb +3 -6
  96. data/lib/parse/model/push.rb +2 -2
  97. data/lib/parse/model/vector.rb +3 -1
  98. data/lib/parse/mongodb.rb +512 -282
  99. data/lib/parse/pipeline_security.rb +3 -0
  100. data/lib/parse/query/constraints.rb +46 -46
  101. data/lib/parse/query.rb +146 -59
  102. data/lib/parse/retrieval/agent_tool.rb +34 -20
  103. data/lib/parse/retrieval/chunk.rb +1 -0
  104. data/lib/parse/retrieval/reranker/cohere.rb +11 -11
  105. data/lib/parse/retrieval/reranker.rb +3 -4
  106. data/lib/parse/retrieval/retriever.rb +4 -6
  107. data/lib/parse/schema/index_migrator.rb +26 -26
  108. data/lib/parse/schema/search_index_migrator.rb +19 -19
  109. data/lib/parse/stack/tasks.rb +6 -6
  110. data/lib/parse/stack/version.rb +1 -1
  111. data/lib/parse/stack.rb +10 -10
  112. data/lib/parse/vector_search/hybrid.rb +166 -33
  113. data/lib/parse/vector_search.rb +141 -13
  114. data/lib/parse/webhooks/payload.rb +1 -0
  115. data/lib/parse/webhooks/registration.rb +4 -4
  116. data/lib/parse/webhooks/trigger_audit.rb +50 -49
  117. data/lib/parse/webhooks.rb +22 -4
  118. data/parse-stack-next.gemspec +6 -6
  119. metadata +14 -1
@@ -68,29 +68,29 @@ module Parse
68
68
  class RateLimitError < Error; end
69
69
  class TransientError < Error; end
70
70
 
71
- DEFAULT_BASE_URL = "https://api.cohere.com/v1"
72
- DEFAULT_MODEL = "embed-english-v3.0"
73
- DEFAULT_TIMEOUT = 30
71
+ DEFAULT_BASE_URL = "https://api.cohere.com/v1"
72
+ DEFAULT_MODEL = "embed-english-v3.0"
73
+ DEFAULT_TIMEOUT = 30
74
74
  DEFAULT_OPEN_TIMEOUT = 5
75
75
  DEFAULT_MAX_RETRIES = 3
76
76
  # Cohere documents a hard cap of 96 inputs per `/embed` call.
77
- DEFAULT_BATCH_SIZE = 96
78
- MAX_RESPONSE_BYTES = 16 * 1024 * 1024
77
+ DEFAULT_BATCH_SIZE = 96
78
+ MAX_RESPONSE_BYTES = 16 * 1024 * 1024
79
79
 
80
80
  MODEL_DEFAULT_DIMENSIONS = {
81
- "embed-v4.0" => 1536,
82
- "embed-english-v3.0" => 1024,
83
- "embed-multilingual-v3.0" => 1024,
84
- "embed-english-light-v3.0" => 384,
85
- "embed-multilingual-light-v3.0" => 384,
81
+ "embed-v4.0" => 1536,
82
+ "embed-english-v3.0" => 1024,
83
+ "embed-multilingual-v3.0" => 1024,
84
+ "embed-english-light-v3.0" => 384,
85
+ "embed-multilingual-light-v3.0" => 384,
86
86
  }.freeze
87
87
 
88
88
  MODEL_MAX_INPUT_TOKENS = {
89
- "embed-v4.0" => 128_000,
90
- "embed-english-v3.0" => 512,
91
- "embed-multilingual-v3.0" => 512,
92
- "embed-english-light-v3.0" => 512,
93
- "embed-multilingual-light-v3.0" => 512,
89
+ "embed-v4.0" => 128_000,
90
+ "embed-english-v3.0" => 512,
91
+ "embed-multilingual-v3.0" => 512,
92
+ "embed-english-light-v3.0" => 512,
93
+ "embed-multilingual-light-v3.0" => 512,
94
94
  }.freeze
95
95
 
96
96
  # Models that accept Cohere's `output_dimension` Matryoshka
@@ -115,10 +115,10 @@ module Parse
115
115
  # `:unknown_type` to `"search_document"` would mask cache-key
116
116
  # bugs in higher layers (the value participates in cache keys).
117
117
  INPUT_TYPE_WIRE_VALUES = {
118
- search_query: "search_query",
118
+ search_query: "search_query",
119
119
  search_document: "search_document",
120
- classification: "classification",
121
- clustering: "clustering",
120
+ classification: "classification",
121
+ clustering: "clustering",
122
122
  }.freeze
123
123
 
124
124
  # @param api_key [String] required. Sent as `Authorization: Bearer …`.
@@ -462,7 +462,7 @@ module Parse
462
462
  next
463
463
  end
464
464
  raise BadRequestError,
465
- "Parse::Embeddings::Cohere: #{status} from POST #{path.start_with?('/') ? path : "/#{path}"}."
465
+ "Parse::Embeddings::Cohere: #{status} from POST #{path.start_with?("/") ? path : "/#{path}"}."
466
466
  end
467
467
  end
468
468
 
@@ -498,8 +498,7 @@ module Parse
498
498
  "Parse::Embeddings::Cohere: response body is not a JSON object."
499
499
  end
500
500
  embeddings = payload["embeddings"]
501
- vectors =
502
- case embeddings
501
+ vectors = case embeddings
503
502
  when Hash
504
503
  f = embeddings["float"]
505
504
  unless f.is_a?(Array)
@@ -521,7 +520,7 @@ module Parse
521
520
  end
522
521
 
523
522
  def backoff_seconds(attempt)
524
- [0.5 * (2**(attempt - 1)), 30.0].min
523
+ [0.5 * (2 ** (attempt - 1)), 30.0].min
525
524
  end
526
525
 
527
526
  def retry_after_seconds(response)
@@ -56,6 +56,7 @@ module Parse
56
56
  class InvalidImageType < Parse::Embeddings::Error
57
57
  # @return [Symbol] failure-mode tag.
58
58
  attr_reader :reason
59
+
59
60
  def initialize(reason, message)
60
61
  @reason = reason
61
62
  super(message)
@@ -76,6 +77,7 @@ module Parse
76
77
  "#<Parse::Embeddings::ImageFetch::FetchedImage mime_type=#{mime_type.inspect} " \
77
78
  "bytes=#{bytes.respond_to?(:bytesize) ? bytes.bytesize : 0} url=#{url.inspect}>"
78
79
  end
80
+
79
81
  alias_method :to_s, :inspect
80
82
  end
81
83
 
@@ -88,11 +90,11 @@ module Parse
88
90
  # against the sniffed type. Extensions not listed here are ignored
89
91
  # (the magic bytes alone govern).
90
92
  EXTENSION_MIME = {
91
- ".jpg" => "image/jpeg",
93
+ ".jpg" => "image/jpeg",
92
94
  ".jpeg" => "image/jpeg",
93
- ".jpe" => "image/jpeg",
94
- ".png" => "image/png",
95
- ".gif" => "image/gif",
95
+ ".jpe" => "image/jpeg",
96
+ ".png" => "image/png",
97
+ ".gif" => "image/gif",
96
98
  ".webp" => "image/webp",
97
99
  }.freeze
98
100
 
@@ -109,8 +111,8 @@ module Parse
109
111
  return nil unless bytes.is_a?(String) && bytes.bytesize >= 12
110
112
  b = bytes.byteslice(0, 16).force_encoding(Encoding::BINARY)
111
113
  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
+ return "image/png" if b.start_with?("\x89PNG\r\n\x1A\n".b)
115
+ return "image/gif" if b.start_with?("GIF87a".b) || b.start_with?("GIF89a".b)
114
116
  if b.start_with?("RIFF".b) && b.byteslice(8, 4) == "WEBP".b
115
117
  return "image/webp"
116
118
  end
@@ -177,26 +179,26 @@ module Parse
177
179
  def verify!(bytes, url: nil)
178
180
  if bytes.nil? || bytes.empty?
179
181
  raise InvalidImageType.new(:empty,
180
- "Parse::Embeddings::ImageFetch: downloaded body is empty.")
182
+ "Parse::Embeddings::ImageFetch: downloaded body is empty.")
181
183
  end
182
184
  mime = sniff_mime(bytes)
183
185
  if mime.nil?
184
186
  raise InvalidImageType.new(:unknown_magic,
185
- "Parse::Embeddings::ImageFetch: leading bytes match no supported image " \
186
- "format (JPEG/PNG/GIF/WebP). The Content-Type header is not consulted — " \
187
- "unrecognized content is refused outright.")
187
+ "Parse::Embeddings::ImageFetch: leading bytes match no supported image " \
188
+ "format (JPEG/PNG/GIF/WebP). The Content-Type header is not consulted — " \
189
+ "unrecognized content is refused outright.")
188
190
  end
189
191
  allowed = Parse::Embeddings.allowed_image_types
190
192
  unless allowed.include?(mime)
191
193
  raise InvalidImageType.new(:type_not_allowed,
192
- "Parse::Embeddings::ImageFetch: sniffed type #{mime.inspect} is not in " \
193
- "Parse::Embeddings.allowed_image_types (#{allowed.inspect}).")
194
+ "Parse::Embeddings::ImageFetch: sniffed type #{mime.inspect} is not in " \
195
+ "Parse::Embeddings.allowed_image_types (#{allowed.inspect}).")
194
196
  end
195
197
  ext_mime = extension_mime(url)
196
198
  if ext_mime && ext_mime != mime
197
199
  raise InvalidImageType.new(:extension_mismatch,
198
- "Parse::Embeddings::ImageFetch: URL extension implies #{ext_mime.inspect} " \
199
- "but the magic bytes are #{mime.inspect} — refusing MIME-laundered content.")
200
+ "Parse::Embeddings::ImageFetch: URL extension implies #{ext_mime.inspect} " \
201
+ "but the magic bytes are #{mime.inspect} — refusing MIME-laundered content.")
200
202
  end
201
203
  mime
202
204
  end
@@ -210,10 +212,10 @@ module Parse
210
212
  def extension_mime(url)
211
213
  return nil unless url.is_a?(String)
212
214
  path = begin
213
- URI.parse(url).path.to_s
214
- rescue URI::InvalidURIError
215
- return nil
216
- end
215
+ URI.parse(url).path.to_s
216
+ rescue URI::InvalidURIError
217
+ return nil
218
+ end
217
219
  dot = path.rindex(".")
218
220
  return nil if dot.nil?
219
221
  EXTENSION_MIME[path[dot..].to_s.downcase]
@@ -231,10 +233,9 @@ module Parse
231
233
  # @param mime [String] sniffed MIME type.
232
234
  # @return [String] bytes with metadata removed.
233
235
  def strip_metadata(bytes, mime)
234
- stripped =
235
- case mime
236
+ stripped = case mime
236
237
  when "image/jpeg" then strip_jpeg_app1(bytes)
237
- when "image/png" then strip_png_exif(bytes)
238
+ when "image/png" then strip_png_exif(bytes)
238
239
  when "image/webp" then strip_webp_metadata(bytes)
239
240
  else return bytes
240
241
  end
@@ -63,36 +63,36 @@ module Parse
63
63
  class RateLimitError < Error; end
64
64
  class TransientError < Error; end
65
65
 
66
- DEFAULT_BASE_URL = "https://api.jina.ai/v1"
67
- DEFAULT_MODEL = "jina-embeddings-v3"
68
- DEFAULT_TIMEOUT = 30
66
+ DEFAULT_BASE_URL = "https://api.jina.ai/v1"
67
+ DEFAULT_MODEL = "jina-embeddings-v3"
68
+ DEFAULT_TIMEOUT = 30
69
69
  DEFAULT_OPEN_TIMEOUT = 5
70
70
  DEFAULT_MAX_RETRIES = 3
71
- DEFAULT_BATCH_SIZE = 100
72
- MAX_RESPONSE_BYTES = 16 * 1024 * 1024
71
+ DEFAULT_BATCH_SIZE = 100
72
+ MAX_RESPONSE_BYTES = 16 * 1024 * 1024
73
73
 
74
74
  # Native vector widths. The Matryoshka-capable rows allow the
75
75
  # caller to truncate via the `dimensions:` kwarg.
76
76
  MODEL_DEFAULT_DIMENSIONS = {
77
77
  "jina-embeddings-v5-omni-small" => 1024,
78
- "jina-embeddings-v5-omni-nano" => 512,
78
+ "jina-embeddings-v5-omni-nano" => 512,
79
79
  "jina-embeddings-v5-text-small" => 1024,
80
- "jina-embeddings-v5-text-nano" => 512,
81
- "jina-embeddings-v4" => 2048,
82
- "jina-embeddings-v3" => 1024,
83
- "jina-code-embeddings-1.5b" => 1024,
84
- "jina-code-embeddings-0.5b" => 1024,
80
+ "jina-embeddings-v5-text-nano" => 512,
81
+ "jina-embeddings-v4" => 2048,
82
+ "jina-embeddings-v3" => 1024,
83
+ "jina-code-embeddings-1.5b" => 1024,
84
+ "jina-code-embeddings-0.5b" => 1024,
85
85
  }.freeze
86
86
 
87
87
  MODEL_MAX_INPUT_TOKENS = {
88
88
  "jina-embeddings-v5-omni-small" => 32_000,
89
- "jina-embeddings-v5-omni-nano" => 32_000,
89
+ "jina-embeddings-v5-omni-nano" => 32_000,
90
90
  "jina-embeddings-v5-text-small" => 32_000,
91
- "jina-embeddings-v5-text-nano" => 32_000,
92
- "jina-embeddings-v4" => 32_000,
93
- "jina-embeddings-v3" => 8_192,
94
- "jina-code-embeddings-1.5b" => 32_000,
95
- "jina-code-embeddings-0.5b" => 32_000,
91
+ "jina-embeddings-v5-text-nano" => 32_000,
92
+ "jina-embeddings-v4" => 32_000,
93
+ "jina-embeddings-v3" => 8_192,
94
+ "jina-code-embeddings-1.5b" => 32_000,
95
+ "jina-code-embeddings-0.5b" => 32_000,
96
96
  }.freeze
97
97
 
98
98
  # Models that accept the Matryoshka `dimensions` field. Other
@@ -108,10 +108,10 @@ module Parse
108
108
 
109
109
  # Map SDK-canonical input_type symbols to Jina `task` strings.
110
110
  INPUT_TYPE_WIRE_VALUES = {
111
- search_query: "retrieval.query",
111
+ search_query: "retrieval.query",
112
112
  search_document: "retrieval.passage",
113
- classification: "classification",
114
- clustering: "separation",
113
+ classification: "classification",
114
+ clustering: "separation",
115
115
  }.freeze
116
116
 
117
117
  # @param api_key [String] required. Sent as `Authorization: Bearer …`.
@@ -352,7 +352,7 @@ module Parse
352
352
  end
353
353
 
354
354
  def backoff_seconds(attempt)
355
- [0.5 * (2**(attempt - 1)), 30.0].min
355
+ [0.5 * (2 ** (attempt - 1)), 30.0].min
356
356
  end
357
357
 
358
358
  def retry_after_seconds(response)
@@ -78,11 +78,11 @@ module Parse
78
78
  class RateLimitError < Error; end
79
79
  class TransientError < Error; end
80
80
 
81
- DEFAULT_TIMEOUT = 30
81
+ DEFAULT_TIMEOUT = 30
82
82
  DEFAULT_OPEN_TIMEOUT = 5
83
- DEFAULT_MAX_RETRIES = 3
84
- DEFAULT_BATCH_SIZE = 32
85
- MAX_RESPONSE_BYTES = 16 * 1024 * 1024
83
+ DEFAULT_MAX_RETRIES = 3
84
+ DEFAULT_BATCH_SIZE = 32
85
+ MAX_RESPONSE_BYTES = 16 * 1024 * 1024
86
86
 
87
87
  # @param base_url [String] required. Must be http(s):// with a host.
88
88
  # @param model [String] required. Identifier the local server expects
@@ -353,7 +353,7 @@ module Parse
353
353
  end
354
354
 
355
355
  def backoff_seconds(attempt)
356
- [0.5 * (2**(attempt - 1)), 30.0].min
356
+ [0.5 * (2 ** (attempt - 1)), 30.0].min
357
357
  end
358
358
 
359
359
  def retry_after_seconds(response)
@@ -414,8 +414,7 @@ module Parse
414
414
  # Empty-resolution under allow_private_endpoint is treated as
415
415
  # private for the http:// scheme gate below, since the operator
416
416
  # has already asserted local-class trust.
417
- is_private =
418
- if resolved.empty?
417
+ is_private = if resolved.empty?
419
418
  allow_private_endpoint
420
419
  else
421
420
  resolved.any? { |ip| Parse::File::BLOCKED_CIDRS.any? { |cidr| cidr.include?(ip) } }
@@ -0,0 +1,137 @@
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
+
134
+ alias_method :to_s, :inspect
135
+ end
136
+ end
137
+ end
@@ -49,12 +49,12 @@ module Parse
49
49
  class RateLimitError < Error; end
50
50
  class TransientError < Error; end
51
51
 
52
- DEFAULT_BASE_URL = "https://api.openai.com/v1"
53
- DEFAULT_MODEL = "text-embedding-3-small"
54
- DEFAULT_TIMEOUT = 30
52
+ DEFAULT_BASE_URL = "https://api.openai.com/v1"
53
+ DEFAULT_MODEL = "text-embedding-3-small"
54
+ DEFAULT_TIMEOUT = 30
55
55
  DEFAULT_OPEN_TIMEOUT = 5
56
56
  DEFAULT_MAX_RETRIES = 3
57
- DEFAULT_BATCH_SIZE = 100
57
+ DEFAULT_BATCH_SIZE = 100
58
58
 
59
59
  # Hard ceiling on the response body we'll parse. A legitimate
60
60
  # OpenAI embeddings response for the worst-case configuration
@@ -397,7 +397,7 @@ module Parse
397
397
  # synchronize the retry storm exponentially.
398
398
  def backoff_seconds(attempt)
399
399
  # 0.5, 1.0, 2.0, 4.0, 8.0 … capped at 30s
400
- [0.5 * (2**(attempt - 1)), 30.0].min
400
+ [0.5 * (2 ** (attempt - 1)), 30.0].min
401
401
  end
402
402
 
403
403
  def retry_after_seconds(response)
@@ -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
@@ -206,7 +245,7 @@ module Parse
206
245
  def inspect_attrs
207
246
  out = {}
208
247
  out[:model] = safe_call(:model_name)
209
- out[:dim] = safe_call(:dimensions)
248
+ out[:dim] = safe_call(:dimensions)
210
249
  out.compact
211
250
  end
212
251
 
@@ -60,27 +60,27 @@ module Parse
60
60
  # Default to the international compatible-mode host. Operators
61
61
  # in mainland China should override to
62
62
  # `https://dashscope.aliyuncs.com/compatible-mode/v1`.
63
- DEFAULT_BASE_URL = "https://dashscope-intl.aliyuncs.com/compatible-mode/v1"
64
- DEFAULT_MODEL = "qwen3-embedding-8b"
65
- DEFAULT_TIMEOUT = 30
63
+ DEFAULT_BASE_URL = "https://dashscope-intl.aliyuncs.com/compatible-mode/v1"
64
+ DEFAULT_MODEL = "qwen3-embedding-8b"
65
+ DEFAULT_TIMEOUT = 30
66
66
  DEFAULT_OPEN_TIMEOUT = 5
67
67
  DEFAULT_MAX_RETRIES = 3
68
68
  # DashScope's compatible endpoint caps embedding requests at 25
69
69
  # inputs per call (smaller than OpenAI's 2048). Default below
70
70
  # the cap so callers don't have to tune.
71
- DEFAULT_BATCH_SIZE = 10
72
- MAX_RESPONSE_BYTES = 16 * 1024 * 1024
71
+ DEFAULT_BATCH_SIZE = 10
72
+ MAX_RESPONSE_BYTES = 16 * 1024 * 1024
73
73
 
74
74
  MODEL_DEFAULT_DIMENSIONS = {
75
75
  "qwen3-embedding-0.6b" => 1024,
76
- "qwen3-embedding-4b" => 2560,
77
- "qwen3-embedding-8b" => 4096,
76
+ "qwen3-embedding-4b" => 2560,
77
+ "qwen3-embedding-8b" => 4096,
78
78
  }.freeze
79
79
 
80
80
  MODEL_MAX_INPUT_TOKENS = {
81
81
  "qwen3-embedding-0.6b" => 32_000,
82
- "qwen3-embedding-4b" => 32_000,
83
- "qwen3-embedding-8b" => 32_000,
82
+ "qwen3-embedding-4b" => 32_000,
83
+ "qwen3-embedding-8b" => 32_000,
84
84
  }.freeze
85
85
 
86
86
  # Every Qwen3-Embedding row is Matryoshka-capable. Kept as an
@@ -329,7 +329,7 @@ module Parse
329
329
  end
330
330
 
331
331
  def backoff_seconds(attempt)
332
- [0.5 * (2**(attempt - 1)), 30.0].min
332
+ [0.5 * (2 ** (attempt - 1)), 30.0].min
333
333
  end
334
334
 
335
335
  def retry_after_seconds(response)
@@ -118,8 +118,7 @@ module Parse
118
118
  end
119
119
  end
120
120
  mutex.synchronize do
121
- limits[key] =
122
- if limit_tokens.nil?
121
+ limits[key] = if limit_tokens.nil?
123
122
  nil
124
123
  else
125
124
  cfg = { limit: Integer(limit_tokens), window: w }
@@ -357,7 +356,7 @@ module Parse
357
356
  # must not serialize every other tenant's charge).
358
357
  def emit_soft_cap_warning(payload)
359
358
  return unless defined?(ActiveSupport::Notifications)
360
- ActiveSupport::Notifications.instrument(AS_NOTIFICATION_NAME, payload) {}
359
+ ActiveSupport::Notifications.instrument(AS_NOTIFICATION_NAME, payload) { }
361
360
  rescue StandardError
362
361
  # A raising subscriber must not turn a successful (admitted)
363
362
  # charge into a caller-visible failure.