parse-stack-next 5.6.0 → 5.7.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 (121) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +489 -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 +7 -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 +219 -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/cache.rb +17 -17
  54. data/lib/parse/embeddings/cohere.rb +21 -22
  55. data/lib/parse/embeddings/image_fetch.rb +22 -21
  56. data/lib/parse/embeddings/jina.rb +21 -21
  57. data/lib/parse/embeddings/local_http.rb +6 -7
  58. data/lib/parse/embeddings/media_file.rb +5 -4
  59. data/lib/parse/embeddings/openai.rb +5 -5
  60. data/lib/parse/embeddings/provider.rb +1 -1
  61. data/lib/parse/embeddings/qwen.rb +10 -10
  62. data/lib/parse/embeddings/spend_cap.rb +2 -3
  63. data/lib/parse/embeddings/streaming_body.rb +11 -11
  64. data/lib/parse/embeddings/video_source.rb +7 -6
  65. data/lib/parse/embeddings/voyage.rb +59 -61
  66. data/lib/parse/embeddings.rb +28 -27
  67. data/lib/parse/graphql/type_generator.rb +1 -1
  68. data/lib/parse/graphql.rb +5 -5
  69. data/lib/parse/live_query/client.rb +1 -1
  70. data/lib/parse/live_query.rb +1 -1
  71. data/lib/parse/lock.rb +15 -18
  72. data/lib/parse/lock_backend.rb +1 -1
  73. data/lib/parse/lookup_rewriter.rb +6 -6
  74. data/lib/parse/model/associations/belongs_to.rb +4 -0
  75. data/lib/parse/model/associations/collection_proxy.rb +3 -0
  76. data/lib/parse/model/associations/has_many.rb +4 -0
  77. data/lib/parse/model/classes/role.rb +227 -28
  78. data/lib/parse/model/classes/user.rb +60 -1
  79. data/lib/parse/model/clp.rb +4 -4
  80. data/lib/parse/model/core/actions.rb +366 -113
  81. data/lib/parse/model/core/create_lock.rb +0 -2
  82. data/lib/parse/model/core/describe.rb +57 -57
  83. data/lib/parse/model/core/embed_managed.rb +21 -23
  84. data/lib/parse/model/core/fetching.rb +6 -0
  85. data/lib/parse/model/core/indexing.rb +14 -14
  86. data/lib/parse/model/core/parse_reference.rb +1 -1
  87. data/lib/parse/model/core/properties.rb +6 -2
  88. data/lib/parse/model/core/querying.rb +1 -1
  89. data/lib/parse/model/core/schema.rb +2 -2
  90. data/lib/parse/model/core/search_indexing.rb +2 -2
  91. data/lib/parse/model/core/vector_searchable.rb +4 -5
  92. data/lib/parse/model/file.rb +21 -15
  93. data/lib/parse/model/geojson.rb +2 -2
  94. data/lib/parse/model/geopoint.rb +1 -0
  95. data/lib/parse/model/object.rb +136 -66
  96. data/lib/parse/model/pointer.rb +2 -0
  97. data/lib/parse/model/polygon.rb +3 -6
  98. data/lib/parse/model/push.rb +2 -2
  99. data/lib/parse/model/vector.rb +3 -1
  100. data/lib/parse/mongodb.rb +512 -282
  101. data/lib/parse/pipeline_security.rb +3 -0
  102. data/lib/parse/query/constraints.rb +46 -46
  103. data/lib/parse/query.rb +146 -59
  104. data/lib/parse/retrieval/agent_tool.rb +34 -20
  105. data/lib/parse/retrieval/chunk.rb +1 -0
  106. data/lib/parse/retrieval/reranker/cohere.rb +11 -11
  107. data/lib/parse/retrieval/reranker.rb +3 -4
  108. data/lib/parse/retrieval/retriever.rb +4 -6
  109. data/lib/parse/schema/index_migrator.rb +26 -26
  110. data/lib/parse/schema/search_index_migrator.rb +19 -19
  111. data/lib/parse/stack/tasks.rb +6 -6
  112. data/lib/parse/stack/version.rb +1 -1
  113. data/lib/parse/stack.rb +10 -10
  114. data/lib/parse/vector_search/hybrid.rb +66 -41
  115. data/lib/parse/vector_search.rb +14 -11
  116. data/lib/parse/webhooks/payload.rb +1 -0
  117. data/lib/parse/webhooks/registration.rb +4 -4
  118. data/lib/parse/webhooks/trigger_audit.rb +50 -49
  119. data/lib/parse/webhooks.rb +120 -5
  120. data/parse-stack-next.gemspec +6 -6
  121. metadata +10 -1
@@ -79,19 +79,20 @@ module Parse
79
79
  parts << "─── usage ───" << " #{usage}" if usage && usage.total_tokens.positive?
80
80
  parts.join("\n")
81
81
  end
82
+
82
83
  alias_method :inspect, :to_s
83
84
  end
84
85
 
85
86
  DEFAULT_MODELS = {
86
- openai: "gpt-4o-mini",
87
+ openai: "gpt-4o-mini",
87
88
  anthropic: "claude-haiku-4-5",
88
- lmstudio: "qwen2.5-7b-instruct",
89
+ lmstudio: "qwen2.5-7b-instruct",
89
90
  }.freeze
90
91
 
91
92
  DEFAULT_BASE_URLS = {
92
- openai: "https://api.openai.com/v1",
93
+ openai: "https://api.openai.com/v1",
93
94
  anthropic: "https://api.anthropic.com/v1",
94
- lmstudio: "http://localhost:1234/v1",
95
+ lmstudio: "http://localhost:1234/v1",
95
96
  }.freeze
96
97
 
97
98
  # Per-1M-tokens list-price pricing (USD). Override via constructor's
@@ -99,13 +100,13 @@ module Parse
99
100
  # Local-model providers (LM Studio) default to zero. Update these
100
101
  # numbers as providers shift their pricing.
101
102
  DEFAULT_PRICING = {
102
- "gpt-4o-mini" => { input: 0.15, output: 0.60 },
103
- "gpt-4o" => { input: 2.50, output: 10.00 },
104
- "gpt-4.1-mini" => { input: 0.40, output: 1.60 },
105
- "gpt-4.1" => { input: 2.00, output: 8.00 },
106
- "claude-haiku-4-5" => { input: 1.00, output: 5.00 },
107
- "claude-sonnet-4-5" => { input: 3.00, output: 15.00 },
108
- "claude-opus-4-5" => { input: 15.00, output: 75.00 },
103
+ "gpt-4o-mini" => { input: 0.15, output: 0.60 },
104
+ "gpt-4o" => { input: 2.50, output: 10.00 },
105
+ "gpt-4.1-mini" => { input: 0.40, output: 1.60 },
106
+ "gpt-4.1" => { input: 2.00, output: 8.00 },
107
+ "claude-haiku-4-5" => { input: 1.00, output: 5.00 },
108
+ "claude-sonnet-4-5" => { input: 3.00, output: 15.00 },
109
+ "claude-opus-4-5" => { input: 15.00, output: 75.00 },
109
110
  }.freeze
110
111
 
111
112
  # Token + cost roll-up. `cost_usd` is computed from the model's pricing
@@ -114,10 +115,10 @@ module Parse
114
115
  Usage = Struct.new(:prompt_tokens, :completion_tokens, :total_tokens, :cost_usd, keyword_init: true) do
115
116
  def +(other)
116
117
  Usage.new(
117
- prompt_tokens: prompt_tokens + other.prompt_tokens,
118
+ prompt_tokens: prompt_tokens + other.prompt_tokens,
118
119
  completion_tokens: completion_tokens + other.completion_tokens,
119
- total_tokens: total_tokens + other.total_tokens,
120
- cost_usd: cost_usd + other.cost_usd,
120
+ total_tokens: total_tokens + other.total_tokens,
121
+ cost_usd: cost_usd + other.cost_usd,
121
122
  )
122
123
  end
123
124
 
@@ -125,6 +126,7 @@ module Parse
125
126
  format("%d in + %d out = %d tokens $%.6f",
126
127
  prompt_tokens, completion_tokens, total_tokens, cost_usd)
127
128
  end
129
+
128
130
  alias_method :inspect, :to_s
129
131
  end
130
132
 
@@ -150,8 +152,8 @@ module Parse
150
152
  def initialize(agent:, provider: nil, api_key: nil, model: nil, base_url: nil,
151
153
  max_iterations: 8, timeout: 90, system_prompt: nil,
152
154
  pricing: nil, auto_compact_at: nil)
153
- @agent = agent
154
- @provider = (provider || ENV["LLM_PROVIDER"])&.to_sym
155
+ @agent = agent
156
+ @provider = (provider || ENV["LLM_PROVIDER"])&.to_sym
155
157
  raise ArgumentError, "provider required: pass provider: or set LLM_PROVIDER (one of: #{DEFAULT_MODELS.keys.join(", ")})" unless @provider
156
158
  unless DEFAULT_MODELS.key?(@provider)
157
159
  raise ArgumentError, "unknown provider #{@provider.inspect}; expected one of #{DEFAULT_MODELS.keys.inspect}"
@@ -163,19 +165,19 @@ module Parse
163
165
  raise ArgumentError, "api_key required for #{@provider}: pass api_key: or set LLM_API_KEY"
164
166
  end
165
167
 
166
- @model = model || ENV["LLM_MODEL"] || DEFAULT_MODELS[@provider]
167
- @base_url = base_url || ENV["LLM_BASE_URL"] || DEFAULT_BASE_URLS[@provider]
168
+ @model = model || ENV["LLM_MODEL"] || DEFAULT_MODELS[@provider]
169
+ @base_url = base_url || ENV["LLM_BASE_URL"] || DEFAULT_BASE_URLS[@provider]
168
170
  Parse::Agent.assert_llm_endpoint_allowed!(@base_url) if Parse::Agent.respond_to?(:assert_llm_endpoint_allowed!)
169
- @max_iterations = max_iterations
170
- @timeout = timeout
171
- @system_prompt = system_prompt
172
- @pricing = pricing || DEFAULT_PRICING
171
+ @max_iterations = max_iterations
172
+ @timeout = timeout
173
+ @system_prompt = system_prompt
174
+ @pricing = pricing || DEFAULT_PRICING
173
175
  # When set, the round-trip will trigger compact! after a successful
174
176
  # call if `usage.total_tokens` exceeds this threshold. Useful for
175
177
  # long-running chat sessions to avoid blowing past context limits.
176
178
  @auto_compact_at = auto_compact_at
177
- @history = []
178
- @usage = ZERO_USAGE.dup
179
+ @history = []
180
+ @usage = ZERO_USAGE.dup
179
181
  @last_call_usage = nil
180
182
  end
181
183
 
@@ -225,12 +227,12 @@ module Parse
225
227
  # can re-price after the fact with a different rate table.
226
228
  def price(prompt_tokens, completion_tokens)
227
229
  rates = @pricing[@model] || @pricing[@model.to_s] || { input: 0.0, output: 0.0 }
228
- cost = (prompt_tokens * rates[:input] + completion_tokens * rates[:output]) / 1_000_000.0
230
+ cost = (prompt_tokens * rates[:input] + completion_tokens * rates[:output]) / 1_000_000.0
229
231
  Usage.new(
230
- prompt_tokens: prompt_tokens,
232
+ prompt_tokens: prompt_tokens,
231
233
  completion_tokens: completion_tokens,
232
- total_tokens: prompt_tokens + completion_tokens,
233
- cost_usd: cost,
234
+ total_tokens: prompt_tokens + completion_tokens,
235
+ cost_usd: cost,
234
236
  )
235
237
  end
236
238
 
@@ -283,7 +285,7 @@ module Parse
283
285
  unless entry.is_a?(Hash)
284
286
  raise ArgumentError, "restore_history!: entry #{i} is not a Hash (got #{entry.class})"
285
287
  end
286
- role = entry[:role] || entry["role"]
288
+ role = entry[:role] || entry["role"]
287
289
  content = entry[:content] || entry["content"]
288
290
  if role.to_s.empty?
289
291
  raise ArgumentError, "restore_history!: entry #{i} is missing :role"
@@ -314,7 +316,7 @@ module Parse
314
316
  # if tool lists grow large, but they're usually small).
315
317
  def tool_definitions
316
318
  envelope = Parse::Agent::MCPDispatcher.call(
317
- body: { "jsonrpc" => "2.0", "id" => SecureRandom.hex(4), "method" => "tools/list", "params" => {} },
319
+ body: { "jsonrpc" => "2.0", "id" => SecureRandom.hex(4), "method" => "tools/list", "params" => {} },
318
320
  agent: @agent,
319
321
  )
320
322
  tools = envelope.dig(:body, "result", "tools") || []
@@ -323,9 +325,9 @@ module Parse
323
325
  {
324
326
  type: "function",
325
327
  function: {
326
- name: h["name"],
328
+ name: h["name"],
327
329
  description: h["description"].to_s[0, 1024],
328
- parameters: h["inputSchema"] || { "type" => "object", "properties" => {} },
330
+ parameters: h["inputSchema"] || { "type" => "object", "properties" => {} },
329
331
  },
330
332
  }
331
333
  end
@@ -336,10 +338,10 @@ module Parse
336
338
  # a Result with the final-turn text, the ordered tool-call trace, and
337
339
  # the full transcript for debugging.
338
340
  def round_trip
339
- tools = tool_definitions
340
- messages = build_messages_for_provider
341
+ tools = tool_definitions
342
+ messages = build_messages_for_provider
341
343
  transcript = []
342
- all_calls = []
344
+ all_calls = []
343
345
  call_usage = ZERO_USAGE.dup
344
346
 
345
347
  @max_iterations.times do
@@ -354,18 +356,18 @@ module Parse
354
356
  dispatch_envelope = Parse::Agent::MCPDispatcher.call(
355
357
  body: {
356
358
  "jsonrpc" => "2.0",
357
- "id" => SecureRandom.hex(4),
358
- "method" => "tools/call",
359
- "params" => { "name" => tc[:name], "arguments" => tc[:arguments] },
359
+ "id" => SecureRandom.hex(4),
360
+ "method" => "tools/call",
361
+ "params" => { "name" => tc[:name], "arguments" => tc[:arguments] },
360
362
  },
361
363
  agent: @agent,
362
364
  )
363
365
  body = dispatch_envelope[:body] || {}
364
366
  tool_text = if body["result"]
365
- (body.dig("result", "content", 0, "text") || body["result"].to_json)
366
- else
367
- body.dig("error", "message").to_s
368
- end
367
+ (body.dig("result", "content", 0, "text") || body["result"].to_json)
368
+ else
369
+ body.dig("error", "message").to_s
370
+ end
369
371
  all_calls << { name: tc[:name], arguments: tc[:arguments], result: tool_text }
370
372
  messages << { role: "tool", tool_call_id: tc[:id], content: tool_text }
371
373
  transcript << { role: "tool", content: tool_text }
@@ -375,7 +377,7 @@ module Parse
375
377
  # The assistant's last content message is the answer. Walk the
376
378
  # transcript backwards to find it.
377
379
  final = transcript.reverse.find { |m| m[:role] == "assistant" && !m[:content].to_s.empty? }
378
- text = final ? final[:content].to_s : ""
380
+ text = final ? final[:content].to_s : ""
379
381
 
380
382
  # Append the assistant's final message to history so a follow-up
381
383
  # `ask(..., reset: false)` sees the prior context.
@@ -384,7 +386,7 @@ module Parse
384
386
  end
385
387
 
386
388
  @last_call_usage = call_usage
387
- @usage = @usage + call_usage
389
+ @usage = @usage + call_usage
388
390
 
389
391
  # Auto-compact when configured and we've crossed the threshold. The
390
392
  # compact call itself adds usage; that's reflected in @usage too.
@@ -408,7 +410,7 @@ module Parse
408
410
  def call_llm(messages:, tools:)
409
411
  case @provider
410
412
  when :anthropic then anthropic_chat(messages: messages, tools: tools)
411
- else openai_chat(messages: messages, tools: tools)
413
+ else openai_chat(messages: messages, tools: tools)
412
414
  end
413
415
  end
414
416
 
@@ -438,20 +440,20 @@ module Parse
438
440
  body = JSON.generate({ model: @model, messages: openai_messages, tools: tools, tool_choice: "auto" })
439
441
 
440
442
  req = Net::HTTP::Post.new(uri)
441
- req["Content-Type"] = "application/json"
443
+ req["Content-Type"] = "application/json"
442
444
  req["Authorization"] = "Bearer #{@api_key}"
443
445
  req.body = body
444
446
 
445
447
  res = Net::HTTP.start(uri.hostname, uri.port,
446
- use_ssl: uri.scheme == "https",
448
+ use_ssl: uri.scheme == "https",
447
449
  read_timeout: @timeout) { |h| h.request(req) }
448
450
  unless res.code.to_i.between?(200, 299)
449
451
  raise "LLM call failed: HTTP #{res.code} #{res.body}"
450
452
  end
451
453
 
452
454
  parsed = JSON.parse(res.body)
453
- msg = parsed.dig("choices", 0, "message") || {}
454
- calls = Array(msg["tool_calls"]).map do |tc|
455
+ msg = parsed.dig("choices", 0, "message") || {}
456
+ calls = Array(msg["tool_calls"]).map do |tc|
455
457
  args = tc.dig("function", "arguments")
456
458
  # Defensively normalize to a Hash. OpenAI returns a JSON-encoded
457
459
  # String here; some models occasionally emit an empty string when
@@ -466,15 +468,15 @@ module Parse
466
468
  { id: tc["id"] || SecureRandom.hex(4), name: tc.dig("function", "name"), arguments: args }
467
469
  end
468
470
  usage_h = parsed["usage"] || {}
469
- usage = price(usage_h["prompt_tokens"].to_i, usage_h["completion_tokens"].to_i)
471
+ usage = price(usage_h["prompt_tokens"].to_i, usage_h["completion_tokens"].to_i)
470
472
  { role: "assistant", content: msg["content"], tool_calls: calls, usage: usage }
471
473
  end
472
474
 
473
475
  def anthropic_chat(messages:, tools:)
474
476
  anth_tools = tools.map do |t|
475
477
  {
476
- name: t[:function][:name],
477
- description: t[:function][:description],
478
+ name: t[:function][:name],
479
+ description: t[:function][:description],
478
480
  input_schema: t[:function][:parameters],
479
481
  }
480
482
  end
@@ -487,13 +489,13 @@ module Parse
487
489
  body = JSON.generate(request_body)
488
490
 
489
491
  req = Net::HTTP::Post.new(uri)
490
- req["Content-Type"] = "application/json"
491
- req["x-api-key"] = @api_key
492
+ req["Content-Type"] = "application/json"
493
+ req["x-api-key"] = @api_key
492
494
  req["anthropic-version"] = "2023-06-01"
493
495
  req.body = body
494
496
 
495
497
  res = Net::HTTP.start(uri.hostname, uri.port,
496
- use_ssl: uri.scheme == "https",
498
+ use_ssl: uri.scheme == "https",
497
499
  read_timeout: @timeout) { |h| h.request(req) }
498
500
  unless res.code.to_i.between?(200, 299)
499
501
  raise "Anthropic call failed: HTTP #{res.code} #{res.body}"
@@ -501,13 +503,13 @@ module Parse
501
503
 
502
504
  parsed = JSON.parse(res.body)
503
505
  blocks = Array(parsed["content"])
504
- text = blocks.select { |b| b["type"] == "text" }.map { |b| b["text"] }.join("\n")
505
- calls = blocks.select { |b| b["type"] == "tool_use" }.map do |b|
506
+ text = blocks.select { |b| b["type"] == "text" }.map { |b| b["text"] }.join("\n")
507
+ calls = blocks.select { |b| b["type"] == "tool_use" }.map do |b|
506
508
  { id: b["id"], name: b["name"], arguments: b["input"] || {} }
507
509
  end
508
510
  usage_h = parsed["usage"] || {}
509
511
  # Anthropic returns input_tokens / output_tokens (not prompt/completion).
510
- usage = price(usage_h["input_tokens"].to_i, usage_h["output_tokens"].to_i)
512
+ usage = price(usage_h["input_tokens"].to_i, usage_h["output_tokens"].to_i)
511
513
  { role: "assistant", content: text, tool_calls: calls, usage: usage }
512
514
  end
513
515
 
@@ -555,7 +557,7 @@ module Parse
555
557
  messages.map do |m|
556
558
  case m[:role]
557
559
  when "user", "assistant" then { role: m[:role], content: m[:content].to_s }
558
- when "system" then { role: "user", content: "[Context] #{m[:content]}" }
560
+ when "system" then { role: "user", content: "[Context] #{m[:content]}" }
559
561
  when "tool"
560
562
  { role: "user", content: [{ type: "tool_result", tool_use_id: m[:tool_call_id], content: wrap_tool_content_for_llm(m[:content]) }] }
561
563
  end