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.
- checksums.yaml +4 -4
- data/.bundle/config +2 -0
- data/.env.sample +17 -3
- data/.github/workflows/codeql.yml +44 -0
- data/.github/workflows/docs.yml +39 -0
- data/.github/workflows/release.yml +32 -0
- data/.github/workflows/ruby.yml +8 -6
- data/.gitignore +4 -0
- data/.vscode/settings.json +3 -0
- data/CHANGELOG.md +305 -72
- data/Gemfile.lock +10 -3
- data/LICENSE.txt +1 -1
- data/README.md +190 -219
- data/Rakefile +1 -1
- data/SECURITY.md +30 -0
- data/assets/parse-stack-next-avatar.png +0 -0
- data/assets/parse-stack-next-avatar.svg +37 -0
- data/assets/parse-stack-next-banner.png +0 -0
- data/assets/parse-stack-next-banner.svg +45 -0
- data/assets/parse-stack-next-social-preview.png +0 -0
- data/docs/atlas_vector_search_guide.md +511 -0
- data/docs/client_sdk_guide.md +1320 -0
- data/docs/mcp_guide.md +225 -104
- data/docs/mongodb_direct_guide.md +21 -4
- data/docs/usage_guide.md +585 -0
- data/examples/transaction_example.rb +28 -28
- data/lib/parse/acl_scope.rb +2 -2
- data/lib/parse/agent/mcp_rack_app.rb +184 -16
- data/lib/parse/agent/metadata_dsl.rb +16 -16
- data/lib/parse/agent/pipeline_validator.rb +28 -1
- data/lib/parse/agent/prompts.rb +5 -5
- data/lib/parse/agent/tools.rb +287 -14
- data/lib/parse/agent.rb +209 -12
- data/lib/parse/api/analytics.rb +27 -5
- data/lib/parse/api/files.rb +6 -2
- data/lib/parse/api/push.rb +21 -4
- data/lib/parse/api/server.rb +59 -0
- data/lib/parse/api/users.rb +26 -2
- data/lib/parse/atlas_search/index_manager.rb +84 -0
- data/lib/parse/atlas_search.rb +37 -9
- data/lib/parse/cache/pool.rb +88 -0
- data/lib/parse/cache/redis.rb +249 -0
- data/lib/parse/client/body_builder.rb +94 -0
- data/lib/parse/client/caching.rb +109 -9
- data/lib/parse/client/response.rb +27 -0
- data/lib/parse/client.rb +74 -3
- data/lib/parse/console.rb +203 -0
- data/lib/parse/embeddings/cohere.rb +484 -0
- data/lib/parse/embeddings/fixture.rb +130 -0
- data/lib/parse/embeddings/jina.rb +454 -0
- data/lib/parse/embeddings/local_http.rb +492 -0
- data/lib/parse/embeddings/openai.rb +520 -0
- data/lib/parse/embeddings/provider.rb +264 -0
- data/lib/parse/embeddings/qwen.rb +431 -0
- data/lib/parse/embeddings/voyage.rb +550 -0
- data/lib/parse/embeddings.rb +225 -0
- data/lib/parse/graphql/scalars.rb +53 -0
- data/lib/parse/graphql/type_generator.rb +264 -0
- data/lib/parse/graphql.rb +48 -0
- data/lib/parse/live_query/client.rb +24 -5
- data/lib/parse/live_query/subscription.rb +17 -6
- data/lib/parse/live_query.rb +9 -4
- data/lib/parse/model/associations/collection_proxy.rb +2 -2
- data/lib/parse/model/associations/has_many.rb +32 -1
- data/lib/parse/model/associations/has_one.rb +17 -0
- data/lib/parse/model/associations/pointer_collection_proxy.rb +3 -3
- data/lib/parse/model/classes/user.rb +307 -11
- data/lib/parse/model/clp.rb +1 -1
- data/lib/parse/model/core/create_lock.rb +14 -2
- data/lib/parse/model/core/embed_managed.rb +296 -0
- data/lib/parse/model/core/fetching.rb +4 -4
- data/lib/parse/model/core/indexing.rb +53 -14
- data/lib/parse/model/core/parse_reference.rb +3 -3
- data/lib/parse/model/core/properties.rb +70 -1
- data/lib/parse/model/core/querying.rb +57 -1
- data/lib/parse/model/core/vector_searchable.rb +285 -0
- data/lib/parse/model/file.rb +16 -4
- data/lib/parse/model/model.rb +26 -10
- data/lib/parse/model/object.rb +63 -6
- data/lib/parse/model/pointer.rb +16 -2
- data/lib/parse/model/shortnames.rb +2 -0
- data/lib/parse/model/validations/uniqueness_validator.rb +3 -3
- data/lib/parse/model/vector.rb +102 -0
- data/lib/parse/mongodb.rb +90 -8
- data/lib/parse/pipeline_security.rb +59 -2
- data/lib/parse/query/constraints.rb +16 -14
- data/lib/parse/query/ordering.rb +1 -1
- data/lib/parse/query.rb +137 -64
- data/lib/parse/stack/generators/templates/model.erb +2 -2
- data/lib/parse/stack/generators/templates/model_installation.rb +1 -1
- data/lib/parse/stack/generators/templates/model_role.rb +1 -1
- data/lib/parse/stack/generators/templates/model_session.rb +1 -1
- data/lib/parse/stack/generators/templates/parse.rb +1 -1
- data/lib/parse/stack/generators/templates/webhooks.rb +1 -1
- data/lib/parse/stack/version.rb +1 -1
- data/lib/parse/stack.rb +375 -73
- data/lib/parse/two_factor_auth/user_extension.rb +5 -2
- data/lib/parse/vector_search.rb +341 -0
- data/parse-stack-next.gemspec +10 -9
- data/scripts/docker/docker-compose.test.yml +18 -0
- data/scripts/start-parse.sh +6 -0
- data/scripts/vector_prototype/create_vector_index.js +105 -0
- data/scripts/vector_prototype/fetch_embeddings.py +241 -0
- data/scripts/vector_prototype/fixture_manifest.json +9 -0
- data/scripts/vector_prototype/query_prototype.rb +84 -0
- data/scripts/vector_prototype/run.sh +34 -0
- metadata +77 -5
- 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
|