parse-stack-next 4.5.0 → 5.0.1

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 (108) hide show
  1. checksums.yaml +4 -4
  2. data/.bundle/config +2 -0
  3. data/.env.sample +17 -3
  4. data/.github/workflows/codeql.yml +44 -0
  5. data/.github/workflows/docs.yml +39 -0
  6. data/.github/workflows/release.yml +32 -0
  7. data/.github/workflows/ruby.yml +8 -6
  8. data/.gitignore +4 -0
  9. data/.vscode/settings.json +3 -0
  10. data/CHANGELOG.md +305 -72
  11. data/Gemfile.lock +10 -3
  12. data/LICENSE.txt +1 -1
  13. data/README.md +190 -219
  14. data/Rakefile +1 -1
  15. data/SECURITY.md +30 -0
  16. data/assets/parse-stack-next-avatar.png +0 -0
  17. data/assets/parse-stack-next-avatar.svg +37 -0
  18. data/assets/parse-stack-next-banner.png +0 -0
  19. data/assets/parse-stack-next-banner.svg +45 -0
  20. data/assets/parse-stack-next-social-preview.png +0 -0
  21. data/docs/atlas_vector_search_guide.md +511 -0
  22. data/docs/client_sdk_guide.md +1320 -0
  23. data/docs/mcp_guide.md +225 -104
  24. data/docs/mongodb_direct_guide.md +21 -4
  25. data/docs/usage_guide.md +585 -0
  26. data/examples/transaction_example.rb +28 -28
  27. data/lib/parse/acl_scope.rb +2 -2
  28. data/lib/parse/agent/mcp_rack_app.rb +184 -16
  29. data/lib/parse/agent/metadata_dsl.rb +16 -16
  30. data/lib/parse/agent/pipeline_validator.rb +28 -1
  31. data/lib/parse/agent/prompts.rb +5 -5
  32. data/lib/parse/agent/tools.rb +287 -14
  33. data/lib/parse/agent.rb +209 -12
  34. data/lib/parse/api/analytics.rb +27 -5
  35. data/lib/parse/api/files.rb +6 -2
  36. data/lib/parse/api/push.rb +21 -4
  37. data/lib/parse/api/server.rb +59 -0
  38. data/lib/parse/api/users.rb +26 -2
  39. data/lib/parse/atlas_search/index_manager.rb +84 -0
  40. data/lib/parse/atlas_search.rb +37 -9
  41. data/lib/parse/cache/pool.rb +88 -0
  42. data/lib/parse/cache/redis.rb +249 -0
  43. data/lib/parse/client/body_builder.rb +94 -0
  44. data/lib/parse/client/caching.rb +109 -9
  45. data/lib/parse/client/response.rb +27 -0
  46. data/lib/parse/client.rb +74 -3
  47. data/lib/parse/console.rb +203 -0
  48. data/lib/parse/embeddings/cohere.rb +484 -0
  49. data/lib/parse/embeddings/fixture.rb +130 -0
  50. data/lib/parse/embeddings/jina.rb +454 -0
  51. data/lib/parse/embeddings/local_http.rb +492 -0
  52. data/lib/parse/embeddings/openai.rb +520 -0
  53. data/lib/parse/embeddings/provider.rb +264 -0
  54. data/lib/parse/embeddings/qwen.rb +431 -0
  55. data/lib/parse/embeddings/voyage.rb +550 -0
  56. data/lib/parse/embeddings.rb +225 -0
  57. data/lib/parse/graphql/scalars.rb +53 -0
  58. data/lib/parse/graphql/type_generator.rb +264 -0
  59. data/lib/parse/graphql.rb +48 -0
  60. data/lib/parse/live_query/client.rb +24 -5
  61. data/lib/parse/live_query/subscription.rb +17 -6
  62. data/lib/parse/live_query.rb +9 -4
  63. data/lib/parse/model/associations/collection_proxy.rb +2 -2
  64. data/lib/parse/model/associations/has_many.rb +32 -1
  65. data/lib/parse/model/associations/has_one.rb +17 -0
  66. data/lib/parse/model/associations/pointer_collection_proxy.rb +3 -3
  67. data/lib/parse/model/classes/user.rb +307 -11
  68. data/lib/parse/model/clp.rb +1 -1
  69. data/lib/parse/model/core/create_lock.rb +14 -2
  70. data/lib/parse/model/core/embed_managed.rb +296 -0
  71. data/lib/parse/model/core/fetching.rb +4 -4
  72. data/lib/parse/model/core/indexing.rb +53 -14
  73. data/lib/parse/model/core/parse_reference.rb +3 -3
  74. data/lib/parse/model/core/properties.rb +70 -1
  75. data/lib/parse/model/core/querying.rb +57 -1
  76. data/lib/parse/model/core/vector_searchable.rb +285 -0
  77. data/lib/parse/model/file.rb +16 -4
  78. data/lib/parse/model/model.rb +26 -10
  79. data/lib/parse/model/object.rb +63 -6
  80. data/lib/parse/model/pointer.rb +16 -2
  81. data/lib/parse/model/shortnames.rb +2 -0
  82. data/lib/parse/model/validations/uniqueness_validator.rb +3 -3
  83. data/lib/parse/model/vector.rb +102 -0
  84. data/lib/parse/mongodb.rb +90 -8
  85. data/lib/parse/pipeline_security.rb +59 -2
  86. data/lib/parse/query/constraints.rb +16 -14
  87. data/lib/parse/query/ordering.rb +1 -1
  88. data/lib/parse/query.rb +137 -64
  89. data/lib/parse/stack/generators/templates/model.erb +2 -2
  90. data/lib/parse/stack/generators/templates/model_installation.rb +1 -1
  91. data/lib/parse/stack/generators/templates/model_role.rb +1 -1
  92. data/lib/parse/stack/generators/templates/model_session.rb +1 -1
  93. data/lib/parse/stack/generators/templates/parse.rb +1 -1
  94. data/lib/parse/stack/generators/templates/webhooks.rb +1 -1
  95. data/lib/parse/stack/version.rb +1 -1
  96. data/lib/parse/stack.rb +375 -73
  97. data/lib/parse/two_factor_auth/user_extension.rb +5 -2
  98. data/lib/parse/vector_search.rb +341 -0
  99. data/parse-stack-next.gemspec +10 -9
  100. data/scripts/docker/docker-compose.test.yml +18 -0
  101. data/scripts/start-parse.sh +6 -0
  102. data/scripts/vector_prototype/create_vector_index.js +105 -0
  103. data/scripts/vector_prototype/fetch_embeddings.py +241 -0
  104. data/scripts/vector_prototype/fixture_manifest.json +9 -0
  105. data/scripts/vector_prototype/query_prototype.rb +84 -0
  106. data/scripts/vector_prototype/run.sh +34 -0
  107. metadata +77 -5
  108. data/parse-stack.png +0 -0
@@ -0,0 +1,484 @@
1
+ # encoding: UTF-8
2
+ # frozen_string_literal: true
3
+
4
+ require "faraday"
5
+ require "json"
6
+ require "uri"
7
+ require_relative "provider"
8
+
9
+ module Parse
10
+ module Embeddings
11
+ # Cohere embeddings provider. Wraps `POST /v1/embed`.
12
+ #
13
+ # Supported models:
14
+ #
15
+ # * **v4** — `embed-v4.0` (1536 native, Matryoshka {256, 512, 1024,
16
+ # 1536}, 128k-token context). Unified text + image model at the
17
+ # network boundary; this provider exposes the text-input path
18
+ # only — image inputs will land in v5.1 alongside the
19
+ # {Provider#embed_image} hook.
20
+ # * **v3** — `embed-english-v3.0`, `embed-multilingual-v3.0` (both
21
+ # 1024-dim), `embed-english-light-v3.0`,
22
+ # `embed-multilingual-light-v3.0` (both 384-dim). Text-only.
23
+ #
24
+ # @example registration
25
+ # Parse::Embeddings.register(:cohere,
26
+ # Parse::Embeddings::Cohere.new(
27
+ # api_key: ENV.fetch("COHERE_API_KEY"),
28
+ # model: "embed-english-v3.0",
29
+ # ))
30
+ #
31
+ # == Asymmetric input types
32
+ #
33
+ # Cohere is one of the providers that DOES distinguish queries from
34
+ # documents at the wire level via the `input_type` request field.
35
+ # Sending `input_type: "search_query"` for a query and
36
+ # `"search_document"` for a corpus item is required for good recall
37
+ # on Cohere's v3 models — using the same type for both halves of a
38
+ # retrieval pair degrades nDCG by a noticeable margin (Cohere's own
39
+ # benchmarks). `Provider#supports_input_type?` returns `true` here
40
+ # so callers / cache-keying middleware can branch on this.
41
+ #
42
+ # The accepted Symbol values map to the Cohere wire strings:
43
+ #
44
+ # * `:search_query` → `"search_query"`
45
+ # * `:search_document` → `"search_document"`
46
+ # * `:classification` → `"classification"`
47
+ # * `:clustering` → `"clustering"`
48
+ #
49
+ # == Security
50
+ #
51
+ # * The Faraday connection refuses `proxy:` unless the caller opts
52
+ # in via `allow_faraday_proxy: true`. Env-proxy autodiscovery
53
+ # (`HTTPS_PROXY` etc.) is suppressed by default — same model as
54
+ # `Parse::Client` and {OpenAI}.
55
+ # * `#inspect` (inherited from {Provider}) never surfaces `@api_key`.
56
+ # * `Authorization` and `Cohere-Api-Key` are in
57
+ # {Parse::Middleware::BodyBuilder::REDACTED_HEADERS}.
58
+ class Cohere < Provider
59
+ # Per-provider error subclasses. Mirror OpenAI's split so retry
60
+ # middleware can `rescue Parse::Embeddings::Cohere::RateLimitError`
61
+ # without picking up unrelated providers.
62
+ class AuthenticationError < Error; end
63
+ class BadRequestError < Error; end
64
+ class RateLimitError < Error; end
65
+ class TransientError < Error; end
66
+
67
+ DEFAULT_BASE_URL = "https://api.cohere.com/v1"
68
+ DEFAULT_MODEL = "embed-english-v3.0"
69
+ DEFAULT_TIMEOUT = 30
70
+ DEFAULT_OPEN_TIMEOUT = 5
71
+ DEFAULT_MAX_RETRIES = 3
72
+ # Cohere documents a hard cap of 96 inputs per `/embed` call.
73
+ DEFAULT_BATCH_SIZE = 96
74
+ MAX_RESPONSE_BYTES = 16 * 1024 * 1024
75
+
76
+ MODEL_DEFAULT_DIMENSIONS = {
77
+ "embed-v4.0" => 1536,
78
+ "embed-english-v3.0" => 1024,
79
+ "embed-multilingual-v3.0" => 1024,
80
+ "embed-english-light-v3.0" => 384,
81
+ "embed-multilingual-light-v3.0" => 384,
82
+ }.freeze
83
+
84
+ MODEL_MAX_INPUT_TOKENS = {
85
+ "embed-v4.0" => 128_000,
86
+ "embed-english-v3.0" => 512,
87
+ "embed-multilingual-v3.0" => 512,
88
+ "embed-english-light-v3.0" => 512,
89
+ "embed-multilingual-light-v3.0" => 512,
90
+ }.freeze
91
+
92
+ # Models that accept Cohere's `output_dimension` Matryoshka
93
+ # truncation parameter. v4.0 is the only such row today; v3
94
+ # models reject the field with a 400.
95
+ MATRYOSHKA_MODELS = %w[embed-v4.0].freeze
96
+
97
+ # Allowed Matryoshka widths per model (Cohere quantizes the
98
+ # available truncations rather than accepting any integer ≤
99
+ # native). Empty allowlist = any integer ≤ native is fine, but
100
+ # for v4.0 Cohere documents exactly these four widths.
101
+ MATRYOSHKA_WIDTHS = {
102
+ "embed-v4.0" => [256, 512, 1024, 1536].freeze,
103
+ }.freeze
104
+
105
+ # Map SDK-canonical input_type symbols to Cohere wire strings.
106
+ # Symbols outside this set raise — silently downgrading
107
+ # `:unknown_type` to `"search_document"` would mask cache-key
108
+ # bugs in higher layers (the value participates in cache keys).
109
+ INPUT_TYPE_WIRE_VALUES = {
110
+ search_query: "search_query",
111
+ search_document: "search_document",
112
+ classification: "classification",
113
+ clustering: "clustering",
114
+ }.freeze
115
+
116
+ # @param api_key [String] required. Sent as `Authorization: Bearer …`.
117
+ # @param model [String] one of {MODEL_DEFAULT_DIMENSIONS}'s keys.
118
+ # @param base_url [String] override. Must be HTTPS unless
119
+ # `allow_insecure_base_url: true`.
120
+ # @param timeout [Integer] read timeout, seconds.
121
+ # @param open_timeout [Integer] connect timeout, seconds.
122
+ # @param max_retries [Integer] retry attempts on 429/5xx/timeouts.
123
+ # @param embed_batch_size [Integer] inputs per request (max 96).
124
+ # @param allow_faraday_proxy [Boolean] opt in to proxy / env-proxy
125
+ # autodiscovery. Defaults `false`.
126
+ # @param allow_insecure_base_url [Boolean] permit `http://` base
127
+ # (local proxies). Defaults `false`.
128
+ # @param connection [Faraday::Connection, nil] injection seam for
129
+ # tests.
130
+ def initialize(
131
+ api_key:,
132
+ model: DEFAULT_MODEL,
133
+ dimensions: nil,
134
+ base_url: DEFAULT_BASE_URL,
135
+ timeout: DEFAULT_TIMEOUT,
136
+ open_timeout: DEFAULT_OPEN_TIMEOUT,
137
+ max_retries: DEFAULT_MAX_RETRIES,
138
+ embed_batch_size: DEFAULT_BATCH_SIZE,
139
+ allow_faraday_proxy: false,
140
+ allow_insecure_base_url: false,
141
+ connection: nil
142
+ )
143
+ validate_api_key!(api_key)
144
+ validate_model!(model)
145
+ validate_dimensions!(model, dimensions)
146
+ sanitized_base_url = validate_base_url!(base_url, allow_insecure_base_url)
147
+ validate_positive_integer!(:timeout, timeout)
148
+ validate_positive_integer!(:open_timeout, open_timeout)
149
+ validate_non_negative_integer!(:max_retries, max_retries)
150
+ validate_positive_integer!(:embed_batch_size, embed_batch_size)
151
+ if embed_batch_size > 96
152
+ raise ArgumentError,
153
+ "Parse::Embeddings::Cohere: embed_batch_size #{embed_batch_size} exceeds Cohere's per-request cap (96)."
154
+ end
155
+
156
+ @api_key = api_key
157
+ @model = model
158
+ @dimensions = dimensions || MODEL_DEFAULT_DIMENSIONS.fetch(model)
159
+ @base_url = sanitized_base_url
160
+ @timeout = timeout
161
+ @open_timeout = open_timeout
162
+ @max_retries = max_retries
163
+ @embed_batch_size = embed_batch_size
164
+ @allow_faraday_proxy = allow_faraday_proxy
165
+ @connection = connection || build_connection
166
+ end
167
+
168
+ def dimensions
169
+ @dimensions
170
+ end
171
+
172
+ def model_name
173
+ @model
174
+ end
175
+
176
+ def embed_batch_size
177
+ @embed_batch_size
178
+ end
179
+
180
+ def max_input_tokens
181
+ MODEL_MAX_INPUT_TOKENS[@model]
182
+ end
183
+
184
+ def normalize?
185
+ # Cohere v3 embeddings are documented unit-normalized.
186
+ true
187
+ end
188
+
189
+ def supports_input_type?
190
+ true
191
+ end
192
+
193
+ # @param strings [Array<String>] inputs.
194
+ # @param input_type [Symbol] one of {INPUT_TYPE_WIRE_VALUES}'s keys.
195
+ # @return [Array<Array<Float>>] vectors aligned 1:1 with `strings`.
196
+ def embed_text(strings, input_type: :search_document)
197
+ unless strings.is_a?(Array)
198
+ raise ArgumentError,
199
+ "Parse::Embeddings::Cohere#embed_text expects Array<String> (got #{strings.class})."
200
+ end
201
+ return [] if strings.empty?
202
+ strings.each_with_index do |s, i|
203
+ unless s.is_a?(String)
204
+ raise ArgumentError,
205
+ "Parse::Embeddings::Cohere#embed_text strings[#{i}] is not a String (#{s.class})."
206
+ end
207
+ if s.empty?
208
+ raise ArgumentError,
209
+ "Parse::Embeddings::Cohere#embed_text strings[#{i}] is empty; Cohere rejects empty inputs."
210
+ end
211
+ end
212
+ wire_input_type = INPUT_TYPE_WIRE_VALUES[input_type]
213
+ unless wire_input_type
214
+ raise ArgumentError,
215
+ "Parse::Embeddings::Cohere#embed_text input_type #{input_type.inspect} not in " \
216
+ "#{INPUT_TYPE_WIRE_VALUES.keys.inspect}."
217
+ end
218
+
219
+ body = {
220
+ texts: strings,
221
+ model: @model,
222
+ input_type: wire_input_type,
223
+ embedding_types: ["float"],
224
+ }
225
+ # Forward `output_dimension` only for Matryoshka-capable models
226
+ # whose active width differs from native. Sending it to a v3
227
+ # row would yield a 400 from Cohere.
228
+ if MATRYOSHKA_MODELS.include?(@model) &&
229
+ @dimensions != MODEL_DEFAULT_DIMENSIONS.fetch(@model)
230
+ body[:output_dimension] = @dimensions
231
+ end
232
+
233
+ instrument_embed(strings.length, input_type) do |emit_payload|
234
+ payload = post_embeddings(body)
235
+ # Cohere's response carries `meta.billed_units.input_tokens`
236
+ # (and `output_tokens`, though for embeddings it's 0). Forward
237
+ # input_tokens as the operator-facing cost number on the AS::N
238
+ # payload so cost subscribers can budget across providers.
239
+ if payload.is_a?(Hash) && payload["meta"].is_a?(Hash) &&
240
+ payload["meta"]["billed_units"].is_a?(Hash)
241
+ tt = payload["meta"]["billed_units"]["input_tokens"]
242
+ emit_payload[:total_tokens] = tt if tt.is_a?(Integer) && tt >= 0
243
+ end
244
+ vectors = extract_vectors!(payload, strings.length)
245
+ validate_response!(strings.length, vectors)
246
+ end
247
+ end
248
+
249
+ def inspect_attrs
250
+ super.merge(base: safe_base_host, retries: @max_retries)
251
+ end
252
+
253
+ protected
254
+
255
+ def build_connection
256
+ headers = {
257
+ "Authorization" => "Bearer #{@api_key}",
258
+ "Content-Type" => "application/json",
259
+ "Accept" => "application/json",
260
+ "User-Agent" => "parse-stack-embeddings/#{user_agent_version}",
261
+ }
262
+
263
+ faraday_opts = { url: @base_url, headers: headers }
264
+ faraday_opts[:proxy] = nil unless @allow_faraday_proxy
265
+
266
+ conn = Faraday.new(**faraday_opts) do |f|
267
+ f.options.timeout = @timeout
268
+ f.options.open_timeout = @open_timeout
269
+ f.adapter Faraday.default_adapter
270
+ end
271
+ conn.proxy = nil if !@allow_faraday_proxy && conn.respond_to?(:proxy=)
272
+ conn
273
+ end
274
+
275
+ def post_embeddings(body)
276
+ attempts = 0
277
+ loop do
278
+ attempts += 1
279
+ begin
280
+ response = @connection.post("embed") do |req|
281
+ req.body = body.to_json
282
+ end
283
+ rescue Faraday::TimeoutError, Faraday::ConnectionFailed => e
284
+ if attempts > @max_retries
285
+ raise TransientError, "Parse::Embeddings::Cohere: #{e.class} after #{attempts} attempt(s)."
286
+ end
287
+ sleep(backoff_seconds(attempts))
288
+ next
289
+ end
290
+
291
+ status = response.status
292
+ return parse_json_body!(response.body) if status >= 200 && status < 300
293
+
294
+ if status == 401
295
+ raise AuthenticationError,
296
+ "Parse::Embeddings::Cohere: 401 Unauthorized — check api_key."
297
+ end
298
+ if status == 429
299
+ if attempts > @max_retries
300
+ raise RateLimitError,
301
+ "Parse::Embeddings::Cohere: 429 rate limited after #{attempts} attempt(s)."
302
+ end
303
+ sleep(retry_after_seconds(response) || backoff_seconds(attempts))
304
+ next
305
+ end
306
+ if status >= 500
307
+ if attempts > @max_retries
308
+ raise TransientError,
309
+ "Parse::Embeddings::Cohere: #{status} after #{attempts} attempt(s)."
310
+ end
311
+ sleep(backoff_seconds(attempts))
312
+ next
313
+ end
314
+ raise BadRequestError,
315
+ "Parse::Embeddings::Cohere: #{status} from POST /embed."
316
+ end
317
+ end
318
+
319
+ def parse_json_body!(body)
320
+ s = body.to_s
321
+ if s.bytesize > MAX_RESPONSE_BYTES
322
+ raise InvalidResponseError,
323
+ "Parse::Embeddings::Cohere: response body exceeds #{MAX_RESPONSE_BYTES} bytes " \
324
+ "(#{s.bytesize}). Refusing to parse."
325
+ end
326
+ JSON.parse(s, max_nesting: 32)
327
+ rescue JSON::ParserError => e
328
+ raise InvalidResponseError,
329
+ "Parse::Embeddings::Cohere: response is not valid JSON (#{e.message})."
330
+ end
331
+
332
+ # Cohere's v1 /embed response shape:
333
+ #
334
+ # {
335
+ # "id": "...",
336
+ # "embeddings": { "float": [[...], [...]] }, # when embedding_types=["float"]
337
+ # "texts": [...],
338
+ # "meta": { "billed_units": { "input_tokens": N } }
339
+ # }
340
+ #
341
+ # A legacy/no-embedding_types call returns `embeddings: [[...]]`
342
+ # as a bare Array. We accept both shapes — the request always
343
+ # sends `embedding_types: ["float"]`, but proxies / Cohere's
344
+ # versioned endpoints may strip it.
345
+ def extract_vectors!(payload, input_count)
346
+ unless payload.is_a?(Hash)
347
+ raise InvalidResponseError,
348
+ "Parse::Embeddings::Cohere: response body is not a JSON object."
349
+ end
350
+ embeddings = payload["embeddings"]
351
+ vectors =
352
+ case embeddings
353
+ when Hash
354
+ f = embeddings["float"]
355
+ unless f.is_a?(Array)
356
+ raise InvalidResponseError,
357
+ "Parse::Embeddings::Cohere: response.embeddings.float is not an Array."
358
+ end
359
+ f
360
+ when Array
361
+ embeddings
362
+ else
363
+ raise InvalidResponseError,
364
+ "Parse::Embeddings::Cohere: response.embeddings is neither Hash nor Array."
365
+ end
366
+ if vectors.length != input_count
367
+ raise InvalidResponseError,
368
+ "Parse::Embeddings::Cohere: response embeddings count #{vectors.length} != input count #{input_count}."
369
+ end
370
+ vectors
371
+ end
372
+
373
+ def backoff_seconds(attempt)
374
+ [0.5 * (2**(attempt - 1)), 30.0].min
375
+ end
376
+
377
+ def retry_after_seconds(response)
378
+ ra = response.respond_to?(:headers) ? response.headers["retry-after"] || response.headers["Retry-After"] : nil
379
+ return nil unless ra
380
+ v = ra.to_f
381
+ v.positive? ? [v, 60.0].min : nil
382
+ end
383
+
384
+ private
385
+
386
+ def validate_api_key!(api_key)
387
+ unless api_key.is_a?(String) && !api_key.empty?
388
+ raise ArgumentError,
389
+ "Parse::Embeddings::Cohere: api_key must be a non-empty String."
390
+ end
391
+ end
392
+
393
+ def validate_model!(model)
394
+ unless MODEL_DEFAULT_DIMENSIONS.key?(model)
395
+ raise ArgumentError,
396
+ "Parse::Embeddings::Cohere: unknown model #{model.inspect}. " \
397
+ "Supported: #{MODEL_DEFAULT_DIMENSIONS.keys.inspect}."
398
+ end
399
+ end
400
+
401
+ def validate_dimensions!(model, dimensions)
402
+ return if dimensions.nil?
403
+ unless dimensions.is_a?(Integer) && dimensions.positive?
404
+ raise ArgumentError,
405
+ "Parse::Embeddings::Cohere: dimensions must be a positive Integer (got #{dimensions.inspect})."
406
+ end
407
+ native = MODEL_DEFAULT_DIMENSIONS.fetch(model)
408
+ if dimensions > native
409
+ raise ArgumentError,
410
+ "Parse::Embeddings::Cohere: dimensions #{dimensions} exceeds native #{native} for #{model}."
411
+ end
412
+ if !MATRYOSHKA_MODELS.include?(model) && dimensions != native
413
+ raise ArgumentError,
414
+ "Parse::Embeddings::Cohere: model #{model.inspect} does not support custom dimensions " \
415
+ "(Matryoshka-capable models: #{MATRYOSHKA_MODELS.inspect})."
416
+ end
417
+ allowlist = MATRYOSHKA_WIDTHS[model]
418
+ if allowlist && !allowlist.include?(dimensions)
419
+ raise ArgumentError,
420
+ "Parse::Embeddings::Cohere: model #{model.inspect} only accepts Matryoshka widths " \
421
+ "#{allowlist.inspect} (got #{dimensions})."
422
+ end
423
+ end
424
+
425
+ def validate_base_url!(base_url, allow_insecure)
426
+ unless base_url.is_a?(String) && !base_url.empty?
427
+ raise ArgumentError,
428
+ "Parse::Embeddings::Cohere: base_url must be a non-empty String."
429
+ end
430
+ begin
431
+ uri = URI.parse(base_url)
432
+ rescue URI::InvalidURIError => e
433
+ raise ArgumentError,
434
+ "Parse::Embeddings::Cohere: base_url is not a valid URL (#{e.message})."
435
+ end
436
+ unless %w[http https].include?(uri.scheme)
437
+ raise ArgumentError,
438
+ "Parse::Embeddings::Cohere: base_url must be http(s):// (got scheme #{uri.scheme.inspect})."
439
+ end
440
+ if uri.scheme == "http" && !allow_insecure
441
+ raise ArgumentError,
442
+ "Parse::Embeddings::Cohere: refusing http:// base_url. " \
443
+ "Pass allow_insecure_base_url: true to opt in."
444
+ end
445
+ if uri.host.nil? || uri.host.empty?
446
+ raise ArgumentError,
447
+ "Parse::Embeddings::Cohere: base_url must include a host."
448
+ end
449
+ if uri.userinfo
450
+ raise ArgumentError,
451
+ "Parse::Embeddings::Cohere: base_url must not contain userinfo (credentials). " \
452
+ "Use the api_key parameter and a clean URL."
453
+ end
454
+ uri.to_s
455
+ end
456
+
457
+ def validate_positive_integer!(name, value)
458
+ unless value.is_a?(Integer) && value.positive?
459
+ raise ArgumentError,
460
+ "Parse::Embeddings::Cohere: #{name} must be a positive Integer (got #{value.inspect})."
461
+ end
462
+ end
463
+
464
+ def validate_non_negative_integer!(name, value)
465
+ unless value.is_a?(Integer) && value >= 0
466
+ raise ArgumentError,
467
+ "Parse::Embeddings::Cohere: #{name} must be a non-negative Integer (got #{value.inspect})."
468
+ end
469
+ end
470
+
471
+ def user_agent_version
472
+ defined?(Parse::Stack::VERSION) ? Parse::Stack::VERSION : "unknown"
473
+ end
474
+
475
+ def safe_base_host
476
+ uri = URI.parse(@base_url)
477
+ host = uri.host
478
+ host && !host.empty? ? "#{uri.scheme}://#{host}" : nil
479
+ rescue URI::InvalidURIError
480
+ nil
481
+ end
482
+ end
483
+ end
484
+ end
@@ -0,0 +1,130 @@
1
+ # encoding: UTF-8
2
+ # frozen_string_literal: true
3
+
4
+ require "digest"
5
+ require_relative "provider"
6
+
7
+ module Parse
8
+ module Embeddings
9
+ # Deterministic, zero-network embedding provider for tests.
10
+ #
11
+ # Vectors are derived from a SHA-256 of `(model_name, input_type, input)`:
12
+ # the same input always produces the same vector, different inputs
13
+ # produce different vectors, and `:search_query` vs `:search_document`
14
+ # produce different vectors for the same string (so cache-key bugs and
15
+ # input-type confusion in higher layers surface in tests rather than
16
+ # only against Cohere / Voyage in production).
17
+ #
18
+ # Output is unit-normalized so similarity tests don't need to know
19
+ # the magnitude of the seed expansion.
20
+ #
21
+ # @example zero-config
22
+ # Parse::Embeddings.provider(:fixture).embed_text(["hello"])
23
+ # # => [[0.012, -0.043, ...]] # length == 64 (default)
24
+ #
25
+ # @example custom dimensions
26
+ # provider = Parse::Embeddings::Fixture.new(dimensions: 1536)
27
+ # Parse::Embeddings.register(:openai_stub, provider)
28
+ class Fixture < Provider
29
+ DEFAULT_DIMENSIONS = 64
30
+ DEFAULT_MODEL_NAME = "fixture-deterministic"
31
+ # Matches Parse::Vector::MAX_DIMENSIONS — keeps a runaway test
32
+ # constructor (`Fixture.new(dimensions: 10_000_000)`) from hanging
33
+ # the suite on the SHA-256 chain expansion.
34
+ MAX_DIMENSIONS = 16_384
35
+
36
+ # @param dimensions [Integer] output vector width (1..16384). Choose
37
+ # to match the production provider you're stubbing.
38
+ # @param model_name [String] identifier persisted to `embedding_meta`
39
+ # and used in cache keys.
40
+ def initialize(dimensions: DEFAULT_DIMENSIONS, model_name: DEFAULT_MODEL_NAME)
41
+ unless dimensions.is_a?(Integer) && dimensions.positive?
42
+ raise ArgumentError,
43
+ "Parse::Embeddings::Fixture: dimensions must be a positive Integer (got #{dimensions.inspect})."
44
+ end
45
+ if dimensions > MAX_DIMENSIONS
46
+ raise ArgumentError,
47
+ "Parse::Embeddings::Fixture: dimensions #{dimensions} exceeds MAX_DIMENSIONS (#{MAX_DIMENSIONS})."
48
+ end
49
+ @dimensions = dimensions
50
+ @model_name = model_name.to_s
51
+ end
52
+
53
+ def dimensions
54
+ @dimensions
55
+ end
56
+
57
+ def model_name
58
+ @model_name
59
+ end
60
+
61
+ def normalize?
62
+ true
63
+ end
64
+
65
+ def supports_input_type?
66
+ true
67
+ end
68
+
69
+ # @param strings [Array<String>] inputs.
70
+ # @param input_type [Symbol] `:search_query` or `:search_document`
71
+ # (or any symbol — Fixture treats them as independent seeds).
72
+ # @return [Array<Array<Float>>] one unit vector per input.
73
+ def embed_text(strings, input_type: :search_document)
74
+ unless strings.is_a?(Array)
75
+ raise ArgumentError,
76
+ "Parse::Embeddings::Fixture#embed_text expects Array<String> (got #{strings.class})."
77
+ end
78
+ return [] if strings.empty?
79
+ type_tag = input_type.to_s
80
+ # Validate inputs BEFORE entering the instrument block so a
81
+ # caller-shape error isn't recorded as a successful embed in
82
+ # AS::N. The fixture has no network call, but emitting the
83
+ # event keeps subscriber wiring uniform across providers —
84
+ # operators developing against the Fixture see the same event
85
+ # tree they'll see in production against OpenAI.
86
+ strings.each do |s|
87
+ unless s.is_a?(String)
88
+ raise ArgumentError,
89
+ "Parse::Embeddings::Fixture#embed_text element must be String (got #{s.class})."
90
+ end
91
+ end
92
+ instrument_embed(strings.length, input_type) do |_emit_payload|
93
+ vectors = strings.map { |s| seeded_unit_vector("#{@model_name}\0#{type_tag}\0#{s}") }
94
+ validate_response!(strings.length, vectors)
95
+ end
96
+ end
97
+
98
+ private
99
+
100
+ # Expand a UTF-8 input string into `dimensions` deterministic floats
101
+ # in [-1, 1], then unit-normalize. The expansion stretches a
102
+ # SHA-256 by chaining successive digests of (prev_digest || input)
103
+ # so we get >32 bytes of entropy without depending on Ruby OpenSSL
104
+ # specifics. Floats are derived from 32-bit unsigned big-endian
105
+ # slices, mapped to [-1, 1].
106
+ def seeded_unit_vector(seed_input)
107
+ needed_bytes = @dimensions * 4
108
+ bytes = +""
109
+ digest = Digest::SHA256.digest(seed_input)
110
+ while bytes.bytesize < needed_bytes
111
+ bytes << digest
112
+ digest = Digest::SHA256.digest(digest + seed_input)
113
+ end
114
+ bytes = bytes.byteslice(0, needed_bytes)
115
+ words = bytes.unpack("N*") # @dimensions × Integer in [0, 2^32)
116
+ scale = 2.0 / 0xFFFFFFFF
117
+ floats = words.map { |w| (w * scale) - 1.0 }
118
+ norm = Math.sqrt(floats.inject(0.0) { |a, f| a + (f * f) })
119
+ # Defensive: degenerate zero vector is astronomically unlikely
120
+ # from SHA-256 output, but guard so a downstream similarity
121
+ # division never sees 1/0.
122
+ if norm.zero?
123
+ floats[0] = 1.0
124
+ norm = 1.0
125
+ end
126
+ floats.map { |f| f / norm }
127
+ end
128
+ end
129
+ end
130
+ end