exa-ai-ruby 1.2.2 → 1.3.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.
- checksums.yaml +4 -4
- data/README.md +146 -1
- data/lib/exa/instrumentation/cost_tracker.rb +109 -0
- data/lib/exa/instrumentation/events.rb +109 -0
- data/lib/exa/instrumentation.rb +134 -0
- data/lib/exa/internal/transport/base_client.rb +80 -9
- data/lib/exa/responses/answer_response.rb +0 -32
- data/lib/exa/responses/contents_response.rb +2 -2
- data/lib/exa/responses/cost.rb +94 -0
- data/lib/exa/responses/search_response.rb +4 -17
- data/lib/exa/responses.rb +1 -0
- data/lib/exa/version.rb +1 -1
- data/lib/exa.rb +27 -0
- metadata +5 -1
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: bc02adeea0f6941eca67efd47e5cfb78dbaa07bf59acfaf441f5e63fe86839ce
|
|
4
|
+
data.tar.gz: cc85d78c4754424b3755857e81528f7eb537754f79a3a1cd789613cce28adce4
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: 1d929f07c2b3e235d5beacbeb68fee05f0b39217c925a4d92866552daa8e15f985ff3ad30e1c788c12ac1c1f12595b49e9f093d156b2524954216170f4b537e4
|
|
7
|
+
data.tar.gz: ebd90c1384c99d842895721a0025451df8689872e961f20e8f8e5022e4e457687b9a2dac72238b2a7506d300ea06dc5442d0ceef8df2bd6267a944c83a0308a0
|
data/README.md
CHANGED
|
@@ -23,7 +23,8 @@ This README is intentionally exhaustive—LLM agents and humans alike should be
|
|
|
23
23
|
- [Events, Imports, Webhooks](#events-imports-webhooks)
|
|
24
24
|
6. [Structured Output via Sorbet + dspy-schema](#structured-output-via-sorbet--dspy-schema)
|
|
25
25
|
7. [Streaming & Transport Helpers](#streaming--transport-helpers)
|
|
26
|
-
8. [
|
|
26
|
+
8. [Instrumentation & Cost Tracking](#instrumentation--cost-tracking)
|
|
27
|
+
9. [Testing & TDD Plan](#testing--tdd-plan)
|
|
27
28
|
|
|
28
29
|
---
|
|
29
30
|
|
|
@@ -367,6 +368,150 @@ See `test/transport/stream_test.rb` for examples.
|
|
|
367
368
|
|
|
368
369
|
---
|
|
369
370
|
|
|
371
|
+
## Instrumentation & Cost Tracking
|
|
372
|
+
|
|
373
|
+
The gem includes a built-in instrumentation system for tracking API usage and costs. Events are emitted around every API request, and you can subscribe to them for logging, monitoring, or cost management.
|
|
374
|
+
|
|
375
|
+
### Basic Cost Tracking
|
|
376
|
+
|
|
377
|
+
```ruby
|
|
378
|
+
require "exa"
|
|
379
|
+
|
|
380
|
+
client = Exa::Client.new(api_key: ENV.fetch("EXA_API_KEY"))
|
|
381
|
+
|
|
382
|
+
# Create and subscribe a cost tracker
|
|
383
|
+
tracker = Exa::Instrumentation::CostTracker.new
|
|
384
|
+
tracker.subscribe
|
|
385
|
+
|
|
386
|
+
# Make API calls - costs are tracked automatically
|
|
387
|
+
response = client.search.search(query: "AI papers", num_results: 10)
|
|
388
|
+
puts response.cost_dollars&.total # => 0.005
|
|
389
|
+
|
|
390
|
+
client.search.contents(urls: ["https://example.com"], text: true)
|
|
391
|
+
|
|
392
|
+
# Check accumulated costs
|
|
393
|
+
puts tracker.total_cost # => 0.006
|
|
394
|
+
puts tracker.request_count # => 2
|
|
395
|
+
puts tracker.average_cost # => 0.003
|
|
396
|
+
|
|
397
|
+
# Get breakdown by endpoint
|
|
398
|
+
tracker.summary.each do |endpoint, cost|
|
|
399
|
+
puts "#{endpoint.serialize}: $#{cost}"
|
|
400
|
+
end
|
|
401
|
+
# => search: $0.005
|
|
402
|
+
# => contents: $0.001
|
|
403
|
+
|
|
404
|
+
# Print a formatted report
|
|
405
|
+
puts tracker.report
|
|
406
|
+
|
|
407
|
+
# Reset tracking
|
|
408
|
+
tracker.reset!
|
|
409
|
+
|
|
410
|
+
# Unsubscribe when done
|
|
411
|
+
tracker.unsubscribe
|
|
412
|
+
```
|
|
413
|
+
|
|
414
|
+
### Custom Event Subscribers
|
|
415
|
+
|
|
416
|
+
Subscribe to specific events using wildcard patterns:
|
|
417
|
+
|
|
418
|
+
```ruby
|
|
419
|
+
# Subscribe to all request events
|
|
420
|
+
Exa.instrumentation.subscribe("exa.request.*") do |event_name, payload|
|
|
421
|
+
case event_name
|
|
422
|
+
when "exa.request.start"
|
|
423
|
+
puts "Starting #{payload.endpoint.serialize} request..."
|
|
424
|
+
when "exa.request.complete"
|
|
425
|
+
puts "Completed in #{payload.duration_ms.round(2)}ms, cost: $#{payload.cost_dollars}"
|
|
426
|
+
when "exa.request.error"
|
|
427
|
+
puts "Error: #{payload.error_class} - #{payload.error_message}"
|
|
428
|
+
end
|
|
429
|
+
end
|
|
430
|
+
|
|
431
|
+
# Subscribe to specific events
|
|
432
|
+
Exa.instrumentation.subscribe("exa.request.error") do |_name, payload|
|
|
433
|
+
ErrorTracker.notify(payload.error_class, payload.error_message)
|
|
434
|
+
end
|
|
435
|
+
```
|
|
436
|
+
|
|
437
|
+
### Building Custom Subscribers
|
|
438
|
+
|
|
439
|
+
Extend `BaseSubscriber` for reusable instrumentation:
|
|
440
|
+
|
|
441
|
+
```ruby
|
|
442
|
+
class BudgetGuard < Exa::Instrumentation::BaseSubscriber
|
|
443
|
+
def initialize(budget_limit)
|
|
444
|
+
@budget = budget_limit
|
|
445
|
+
@spent = 0.0
|
|
446
|
+
@mutex = Mutex.new
|
|
447
|
+
super()
|
|
448
|
+
end
|
|
449
|
+
|
|
450
|
+
def subscribe
|
|
451
|
+
add_subscription("exa.request.complete") do |_name, payload|
|
|
452
|
+
next unless payload.cost_dollars
|
|
453
|
+
|
|
454
|
+
@mutex.synchronize do
|
|
455
|
+
@spent += payload.cost_dollars
|
|
456
|
+
raise "Budget exceeded! Spent $#{@spent} of $#{@budget}" if @spent > @budget
|
|
457
|
+
end
|
|
458
|
+
end
|
|
459
|
+
end
|
|
460
|
+
|
|
461
|
+
attr_reader :spent
|
|
462
|
+
end
|
|
463
|
+
|
|
464
|
+
guard = BudgetGuard.new(1.00) # $1 budget
|
|
465
|
+
guard.subscribe
|
|
466
|
+
# ... make API calls ...
|
|
467
|
+
guard.unsubscribe
|
|
468
|
+
```
|
|
469
|
+
|
|
470
|
+
### Event Types
|
|
471
|
+
|
|
472
|
+
| Event | Payload | Description |
|
|
473
|
+
|-------|---------|-------------|
|
|
474
|
+
| `exa.request.start` | `RequestStart` | Emitted when a request begins |
|
|
475
|
+
| `exa.request.complete` | `RequestComplete` | Emitted on successful completion |
|
|
476
|
+
| `exa.request.error` | `RequestError` | Emitted when a request fails |
|
|
477
|
+
|
|
478
|
+
**RequestStart** fields: `request_id`, `endpoint`, `http_method`, `path`, `timestamp`
|
|
479
|
+
|
|
480
|
+
**RequestComplete** fields: `request_id`, `endpoint`, `duration_ms`, `status`, `cost_dollars`, `timestamp`
|
|
481
|
+
|
|
482
|
+
**RequestError** fields: `request_id`, `endpoint`, `duration_ms`, `error_class`, `error_message`, `timestamp`
|
|
483
|
+
|
|
484
|
+
### Async Compatibility
|
|
485
|
+
|
|
486
|
+
The instrumentation system is thread-safe and works inside `Async` blocks:
|
|
487
|
+
|
|
488
|
+
```ruby
|
|
489
|
+
require "async"
|
|
490
|
+
require "exa/internal/transport/async_requester"
|
|
491
|
+
|
|
492
|
+
tracker = Exa::Instrumentation::CostTracker.new
|
|
493
|
+
tracker.subscribe
|
|
494
|
+
|
|
495
|
+
Async do
|
|
496
|
+
requester = Exa::Internal::Transport::AsyncRequester.new
|
|
497
|
+
client = Exa::Client.new(api_key: ENV.fetch("EXA_API_KEY"), requester: requester)
|
|
498
|
+
|
|
499
|
+
# Concurrent requests - all tracked safely
|
|
500
|
+
tasks = 5.times.map do |i|
|
|
501
|
+
Async { client.search.search(query: "query #{i}", num_results: 3) }
|
|
502
|
+
end
|
|
503
|
+
tasks.each(&:wait)
|
|
504
|
+
|
|
505
|
+
puts "Total cost for #{tracker.request_count} requests: $#{tracker.total_cost}"
|
|
506
|
+
ensure
|
|
507
|
+
requester.close
|
|
508
|
+
end
|
|
509
|
+
|
|
510
|
+
tracker.unsubscribe
|
|
511
|
+
```
|
|
512
|
+
|
|
513
|
+
---
|
|
514
|
+
|
|
370
515
|
## Testing & TDD Plan
|
|
371
516
|
|
|
372
517
|
Run the suite:
|
|
@@ -0,0 +1,109 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Exa
|
|
4
|
+
module Instrumentation
|
|
5
|
+
# Built-in subscriber for tracking API costs from actual response data.
|
|
6
|
+
# Thread-safe and works with both sync and async requests.
|
|
7
|
+
#
|
|
8
|
+
# @example Basic usage
|
|
9
|
+
# tracker = Exa::Instrumentation::CostTracker.new
|
|
10
|
+
# tracker.subscribe
|
|
11
|
+
#
|
|
12
|
+
# client.search.search(query: "AI papers", num_results: 10)
|
|
13
|
+
# client.search.contents(urls: ["https://example.com"], text: true)
|
|
14
|
+
#
|
|
15
|
+
# puts tracker.total_cost # => 0.006
|
|
16
|
+
# puts tracker.summary # => { Search => 0.005, Contents => 0.001 }
|
|
17
|
+
#
|
|
18
|
+
# tracker.unsubscribe # Clean up when done
|
|
19
|
+
class CostTracker < BaseSubscriber
|
|
20
|
+
# @return [Float] Total accumulated cost in dollars
|
|
21
|
+
attr_reader :total_cost
|
|
22
|
+
|
|
23
|
+
# @return [Integer] Number of requests tracked
|
|
24
|
+
attr_reader :request_count
|
|
25
|
+
|
|
26
|
+
# @return [Array<Hash>] All tracked requests with their costs
|
|
27
|
+
attr_reader :requests
|
|
28
|
+
|
|
29
|
+
def initialize
|
|
30
|
+
@total_cost = 0.0
|
|
31
|
+
@request_count = 0
|
|
32
|
+
@requests = []
|
|
33
|
+
@mutex = Mutex.new
|
|
34
|
+
super()
|
|
35
|
+
end
|
|
36
|
+
|
|
37
|
+
# Subscribe to request completion events.
|
|
38
|
+
# Call this after initialization to start tracking.
|
|
39
|
+
def subscribe
|
|
40
|
+
add_subscription("exa.request.complete") do |_event_name, payload|
|
|
41
|
+
next unless payload.cost_dollars
|
|
42
|
+
|
|
43
|
+
@mutex.synchronize do
|
|
44
|
+
@total_cost += payload.cost_dollars
|
|
45
|
+
@request_count += 1
|
|
46
|
+
@requests << {
|
|
47
|
+
endpoint: payload.endpoint,
|
|
48
|
+
cost: payload.cost_dollars,
|
|
49
|
+
request_id: payload.request_id,
|
|
50
|
+
timestamp: payload.timestamp
|
|
51
|
+
}
|
|
52
|
+
end
|
|
53
|
+
end
|
|
54
|
+
end
|
|
55
|
+
|
|
56
|
+
# Returns a summary of costs grouped by endpoint.
|
|
57
|
+
# @return [Hash<Endpoint, Float>] Cost per endpoint
|
|
58
|
+
def summary
|
|
59
|
+
@mutex.synchronize do
|
|
60
|
+
@requests
|
|
61
|
+
.group_by { |req| req[:endpoint] }
|
|
62
|
+
.transform_values { |reqs| reqs.sum { |r| r[:cost] } }
|
|
63
|
+
end
|
|
64
|
+
end
|
|
65
|
+
|
|
66
|
+
# Returns the average cost per request.
|
|
67
|
+
# @return [Float] Average cost or 0.0 if no requests
|
|
68
|
+
def average_cost
|
|
69
|
+
@mutex.synchronize do
|
|
70
|
+
return 0.0 if @request_count.zero?
|
|
71
|
+
@total_cost / @request_count
|
|
72
|
+
end
|
|
73
|
+
end
|
|
74
|
+
|
|
75
|
+
# Reset all tracked data.
|
|
76
|
+
def reset!
|
|
77
|
+
@mutex.synchronize do
|
|
78
|
+
@total_cost = 0.0
|
|
79
|
+
@request_count = 0
|
|
80
|
+
@requests.clear
|
|
81
|
+
end
|
|
82
|
+
end
|
|
83
|
+
|
|
84
|
+
# Returns a formatted report of costs.
|
|
85
|
+
# @return [String] Human-readable cost report
|
|
86
|
+
def report
|
|
87
|
+
@mutex.synchronize do
|
|
88
|
+
lines = ["Exa API Cost Report"]
|
|
89
|
+
lines << "-" * 40
|
|
90
|
+
lines << "Total Cost: $#{format('%.6f', @total_cost)}"
|
|
91
|
+
lines << "Request Count: #{@request_count}"
|
|
92
|
+
lines << "Average Cost: $#{format('%.6f', @request_count.zero? ? 0.0 : @total_cost / @request_count)}"
|
|
93
|
+
lines << ""
|
|
94
|
+
lines << "By Endpoint:"
|
|
95
|
+
|
|
96
|
+
@requests
|
|
97
|
+
.group_by { |req| req[:endpoint] }
|
|
98
|
+
.transform_values { |reqs| reqs.sum { |r| r[:cost] } }
|
|
99
|
+
.sort_by { |_endpoint, cost| -cost }
|
|
100
|
+
.each do |endpoint, cost|
|
|
101
|
+
lines << " #{endpoint.serialize}: $#{format('%.6f', cost)}"
|
|
102
|
+
end
|
|
103
|
+
|
|
104
|
+
lines.join("\n")
|
|
105
|
+
end
|
|
106
|
+
end
|
|
107
|
+
end
|
|
108
|
+
end
|
|
109
|
+
end
|
|
@@ -0,0 +1,109 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Exa
|
|
4
|
+
module Instrumentation
|
|
5
|
+
# Enum representing API endpoints for instrumentation
|
|
6
|
+
class Endpoint < T::Enum
|
|
7
|
+
enums do
|
|
8
|
+
Search = new("search")
|
|
9
|
+
Contents = new("contents")
|
|
10
|
+
FindSimilar = new("findSimilar")
|
|
11
|
+
Answer = new("answer")
|
|
12
|
+
Research = new("research")
|
|
13
|
+
ResearchList = new("research/list")
|
|
14
|
+
ResearchCancel = new("research/cancel")
|
|
15
|
+
WebsetCreate = new("websets/create")
|
|
16
|
+
WebsetGet = new("websets/get")
|
|
17
|
+
WebsetList = new("websets/list")
|
|
18
|
+
WebsetDelete = new("websets/delete")
|
|
19
|
+
WebsetUpdate = new("websets/update")
|
|
20
|
+
WebsetSearch = new("websets/search")
|
|
21
|
+
WebsetCancel = new("websets/cancel")
|
|
22
|
+
WebsetItems = new("websets/items")
|
|
23
|
+
WebsetEnrichments = new("websets/enrichments")
|
|
24
|
+
Events = new("events")
|
|
25
|
+
Webhooks = new("webhooks")
|
|
26
|
+
Imports = new("imports")
|
|
27
|
+
Unknown = new("unknown")
|
|
28
|
+
end
|
|
29
|
+
|
|
30
|
+
# Map a path string to an Endpoint enum value
|
|
31
|
+
def self.from_path(path)
|
|
32
|
+
normalized = Array(path).join("/").downcase
|
|
33
|
+
|
|
34
|
+
case normalized
|
|
35
|
+
when "search" then Search
|
|
36
|
+
when "contents" then Contents
|
|
37
|
+
when "findsimilar" then FindSimilar
|
|
38
|
+
when "answer" then Answer
|
|
39
|
+
when /^research$/ then Research
|
|
40
|
+
when /^research\/[^\/]+$/ then Research
|
|
41
|
+
when /^research\/[^\/]+\/cancel$/ then ResearchCancel
|
|
42
|
+
when /^websets$/ then WebsetCreate
|
|
43
|
+
when /^websets\/[^\/]+$/ then WebsetGet
|
|
44
|
+
when /^websets\/[^\/]+\/items/ then WebsetItems
|
|
45
|
+
when /^websets\/[^\/]+\/enrichments/ then WebsetEnrichments
|
|
46
|
+
when /^websets\/[^\/]+\/search$/ then WebsetSearch
|
|
47
|
+
when /^websets\/[^\/]+\/cancel$/ then WebsetCancel
|
|
48
|
+
when /^events/ then Events
|
|
49
|
+
when /^webhooks/ then Webhooks
|
|
50
|
+
when /^imports/ then Imports
|
|
51
|
+
else Unknown
|
|
52
|
+
end
|
|
53
|
+
end
|
|
54
|
+
end
|
|
55
|
+
|
|
56
|
+
# Type alias for responses that include cost information
|
|
57
|
+
ResponseWithCost = T.type_alias do
|
|
58
|
+
T.any(
|
|
59
|
+
Exa::Responses::SearchResponse,
|
|
60
|
+
Exa::Responses::FindSimilarResponse,
|
|
61
|
+
Exa::Responses::ContentsResponse,
|
|
62
|
+
Exa::Responses::AnswerResponse
|
|
63
|
+
)
|
|
64
|
+
end
|
|
65
|
+
|
|
66
|
+
# Type alias for all response types
|
|
67
|
+
AnyResponse = T.type_alias do
|
|
68
|
+
T.any(
|
|
69
|
+
Exa::Responses::SearchResponse,
|
|
70
|
+
Exa::Responses::FindSimilarResponse,
|
|
71
|
+
Exa::Responses::ContentsResponse,
|
|
72
|
+
Exa::Responses::AnswerResponse,
|
|
73
|
+
Exa::Responses::Research,
|
|
74
|
+
Exa::Responses::ResearchListResponse
|
|
75
|
+
)
|
|
76
|
+
end
|
|
77
|
+
|
|
78
|
+
module Events
|
|
79
|
+
# Emitted when a request starts
|
|
80
|
+
class RequestStart < T::Struct
|
|
81
|
+
const :request_id, String
|
|
82
|
+
const :endpoint, Endpoint
|
|
83
|
+
const :http_method, Symbol
|
|
84
|
+
const :path, String
|
|
85
|
+
const :timestamp, Float
|
|
86
|
+
end
|
|
87
|
+
|
|
88
|
+
# Emitted when a request completes successfully
|
|
89
|
+
class RequestComplete < T::Struct
|
|
90
|
+
const :request_id, String
|
|
91
|
+
const :endpoint, Endpoint
|
|
92
|
+
const :duration_ms, Float
|
|
93
|
+
const :status, Integer
|
|
94
|
+
const :cost_dollars, T.nilable(Float)
|
|
95
|
+
const :timestamp, Float
|
|
96
|
+
end
|
|
97
|
+
|
|
98
|
+
# Emitted when a request fails with an error
|
|
99
|
+
class RequestError < T::Struct
|
|
100
|
+
const :request_id, String
|
|
101
|
+
const :endpoint, Endpoint
|
|
102
|
+
const :duration_ms, Float
|
|
103
|
+
const :error_class, String
|
|
104
|
+
const :error_message, String
|
|
105
|
+
const :timestamp, Float
|
|
106
|
+
end
|
|
107
|
+
end
|
|
108
|
+
end
|
|
109
|
+
end
|
|
@@ -0,0 +1,134 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "securerandom"
|
|
4
|
+
|
|
5
|
+
module Exa
|
|
6
|
+
module Instrumentation
|
|
7
|
+
# Thread-safe event registry for subscribing to and emitting API events.
|
|
8
|
+
# Supports wildcard pattern matching (e.g., 'exa.request.*').
|
|
9
|
+
class EventRegistry
|
|
10
|
+
def initialize
|
|
11
|
+
@listeners = {}
|
|
12
|
+
@mutex = Mutex.new
|
|
13
|
+
end
|
|
14
|
+
|
|
15
|
+
# Subscribe to events matching a pattern.
|
|
16
|
+
# @param pattern [String] Event pattern (supports '*' wildcard)
|
|
17
|
+
# @yield [event_name, payload] Block called when matching events are emitted
|
|
18
|
+
# @return [String] Subscription ID for later unsubscription
|
|
19
|
+
def subscribe(pattern, &block)
|
|
20
|
+
return unless block_given?
|
|
21
|
+
|
|
22
|
+
subscription_id = SecureRandom.uuid
|
|
23
|
+
@mutex.synchronize do
|
|
24
|
+
@listeners[subscription_id] = {
|
|
25
|
+
pattern: pattern,
|
|
26
|
+
block: block
|
|
27
|
+
}
|
|
28
|
+
end
|
|
29
|
+
|
|
30
|
+
subscription_id
|
|
31
|
+
end
|
|
32
|
+
|
|
33
|
+
# Unsubscribe from events.
|
|
34
|
+
# @param subscription_id [String] The ID returned from subscribe
|
|
35
|
+
def unsubscribe(subscription_id)
|
|
36
|
+
@mutex.synchronize do
|
|
37
|
+
@listeners.delete(subscription_id)
|
|
38
|
+
end
|
|
39
|
+
end
|
|
40
|
+
|
|
41
|
+
# Clear all listeners.
|
|
42
|
+
def clear_listeners
|
|
43
|
+
@mutex.synchronize do
|
|
44
|
+
@listeners.clear
|
|
45
|
+
end
|
|
46
|
+
end
|
|
47
|
+
|
|
48
|
+
# Emit an event to all matching subscribers.
|
|
49
|
+
# @param event_name [String] The event name (e.g., 'exa.request.complete')
|
|
50
|
+
# @param payload [Object] The event payload (usually a typed struct)
|
|
51
|
+
def notify(event_name, payload)
|
|
52
|
+
# Take a snapshot of current listeners to avoid holding the mutex during execution
|
|
53
|
+
matching_listeners = @mutex.synchronize do
|
|
54
|
+
@listeners.select do |_id, listener|
|
|
55
|
+
pattern_matches?(listener[:pattern], event_name)
|
|
56
|
+
end.dup
|
|
57
|
+
end
|
|
58
|
+
|
|
59
|
+
matching_listeners.each do |_id, listener|
|
|
60
|
+
listener[:block].call(event_name, payload)
|
|
61
|
+
rescue StandardError
|
|
62
|
+
# Silently ignore listener errors to avoid breaking the main flow
|
|
63
|
+
end
|
|
64
|
+
end
|
|
65
|
+
|
|
66
|
+
# Returns the count of registered listeners.
|
|
67
|
+
def listener_count
|
|
68
|
+
@mutex.synchronize { @listeners.size }
|
|
69
|
+
end
|
|
70
|
+
|
|
71
|
+
private
|
|
72
|
+
|
|
73
|
+
def pattern_matches?(pattern, event_name)
|
|
74
|
+
if pattern.include?("*")
|
|
75
|
+
# Convert wildcard pattern to regex
|
|
76
|
+
# exa.request.* becomes ^exa\.request\..*$
|
|
77
|
+
regex_pattern = "^#{Regexp.escape(pattern).gsub('\\*', '.*')}$"
|
|
78
|
+
Regexp.new(regex_pattern).match?(event_name)
|
|
79
|
+
else
|
|
80
|
+
# Exact match
|
|
81
|
+
pattern == event_name
|
|
82
|
+
end
|
|
83
|
+
end
|
|
84
|
+
end
|
|
85
|
+
|
|
86
|
+
# Base class for creating event subscribers.
|
|
87
|
+
# Subclasses should implement #subscribe to add subscriptions.
|
|
88
|
+
#
|
|
89
|
+
# @example
|
|
90
|
+
# class MyCostTracker < Exa::Instrumentation::BaseSubscriber
|
|
91
|
+
# def initialize
|
|
92
|
+
# @total = 0.0
|
|
93
|
+
# super()
|
|
94
|
+
# end
|
|
95
|
+
#
|
|
96
|
+
# def subscribe
|
|
97
|
+
# add_subscription('exa.request.complete') do |_, payload|
|
|
98
|
+
# @total += payload.cost_dollars || 0
|
|
99
|
+
# end
|
|
100
|
+
# end
|
|
101
|
+
# end
|
|
102
|
+
class BaseSubscriber
|
|
103
|
+
def initialize
|
|
104
|
+
@subscriptions = []
|
|
105
|
+
end
|
|
106
|
+
|
|
107
|
+
# Override to add subscriptions. Called automatically after initialize.
|
|
108
|
+
def subscribe
|
|
109
|
+
raise NotImplementedError, "Subclasses must implement #subscribe"
|
|
110
|
+
end
|
|
111
|
+
|
|
112
|
+
# Unsubscribe from all registered subscriptions.
|
|
113
|
+
def unsubscribe
|
|
114
|
+
@subscriptions.each { |id| Exa.instrumentation.unsubscribe(id) }
|
|
115
|
+
@subscriptions.clear
|
|
116
|
+
end
|
|
117
|
+
|
|
118
|
+
protected
|
|
119
|
+
|
|
120
|
+
# Add a subscription to the global event registry.
|
|
121
|
+
# @param pattern [String] Event pattern to match
|
|
122
|
+
# @yield [event_name, payload] Block called for matching events
|
|
123
|
+
# @return [String] Subscription ID
|
|
124
|
+
def add_subscription(pattern, &block)
|
|
125
|
+
subscription_id = Exa.instrumentation.subscribe(pattern, &block)
|
|
126
|
+
@subscriptions << subscription_id
|
|
127
|
+
subscription_id
|
|
128
|
+
end
|
|
129
|
+
end
|
|
130
|
+
end
|
|
131
|
+
end
|
|
132
|
+
|
|
133
|
+
require_relative "instrumentation/events"
|
|
134
|
+
require_relative "instrumentation/cost_tracker"
|
|
@@ -3,6 +3,7 @@
|
|
|
3
3
|
require "uri"
|
|
4
4
|
require "cgi"
|
|
5
5
|
require "json"
|
|
6
|
+
require "securerandom"
|
|
6
7
|
|
|
7
8
|
require_relative "../util"
|
|
8
9
|
require_relative "pooled_net_requester"
|
|
@@ -45,10 +46,17 @@ module Exa
|
|
|
45
46
|
end
|
|
46
47
|
|
|
47
48
|
def request(method:, path:, query: nil, headers: nil, body: nil, unwrap: nil, stream: false, response_model: nil, request_options: nil)
|
|
49
|
+
request_id = SecureRandom.uuid
|
|
50
|
+
path_str = Array(path).join("/")
|
|
51
|
+
endpoint = Exa::Instrumentation::Endpoint.from_path(path_str)
|
|
52
|
+
start_time = Process.clock_gettime(Process::CLOCK_MONOTONIC)
|
|
53
|
+
|
|
54
|
+
emit_request_start(request_id, endpoint, method, path_str)
|
|
55
|
+
|
|
48
56
|
options = normalize_request_options(request_options)
|
|
49
57
|
req = build_request(
|
|
50
58
|
method: method,
|
|
51
|
-
path:
|
|
59
|
+
path: path_str,
|
|
52
60
|
query: query,
|
|
53
61
|
headers: headers,
|
|
54
62
|
body: body,
|
|
@@ -56,15 +64,23 @@ module Exa
|
|
|
56
64
|
idempotency_key: options[:idempotency_key]
|
|
57
65
|
)
|
|
58
66
|
|
|
59
|
-
|
|
60
|
-
|
|
67
|
+
begin
|
|
68
|
+
status, response, stream_enum = send_request(req, max_retries: options[:max_retries] || max_retries)
|
|
69
|
+
parsed_headers = Exa::Internal::Util.normalized_headers(response.each_header.to_h)
|
|
70
|
+
|
|
71
|
+
result = if stream
|
|
72
|
+
Exa::Internal::Transport::Stream.new(headers: parsed_headers, stream: stream_enum)
|
|
73
|
+
else
|
|
74
|
+
decoded = Exa::Internal::Util.decode_content(parsed_headers, stream: stream_enum)
|
|
75
|
+
coerced = coerce_response(response_model, decoded)
|
|
76
|
+
unwrap ? dig(coerced, unwrap) : coerced
|
|
77
|
+
end
|
|
61
78
|
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
unwrap ? dig(coerced, unwrap) : coerced
|
|
79
|
+
emit_request_complete(request_id, endpoint, start_time, status, result)
|
|
80
|
+
result
|
|
81
|
+
rescue StandardError => e
|
|
82
|
+
emit_request_error(request_id, endpoint, start_time, e)
|
|
83
|
+
raise
|
|
68
84
|
end
|
|
69
85
|
end
|
|
70
86
|
|
|
@@ -205,6 +221,61 @@ module Exa
|
|
|
205
221
|
opts[:idempotency_key] = options[:idempotency_key] if options[:idempotency_key]
|
|
206
222
|
opts
|
|
207
223
|
end
|
|
224
|
+
|
|
225
|
+
def emit_request_start(request_id, endpoint, http_method, path)
|
|
226
|
+
Exa.emit(
|
|
227
|
+
"exa.request.start",
|
|
228
|
+
Exa::Instrumentation::Events::RequestStart.new(
|
|
229
|
+
request_id: request_id,
|
|
230
|
+
endpoint: endpoint,
|
|
231
|
+
http_method: http_method,
|
|
232
|
+
path: path,
|
|
233
|
+
timestamp: Process.clock_gettime(Process::CLOCK_MONOTONIC)
|
|
234
|
+
)
|
|
235
|
+
)
|
|
236
|
+
end
|
|
237
|
+
|
|
238
|
+
def emit_request_complete(request_id, endpoint, start_time, status, result)
|
|
239
|
+
duration_ms = (Process.clock_gettime(Process::CLOCK_MONOTONIC) - start_time) * 1000
|
|
240
|
+
cost_dollars = extract_cost_dollars(result)
|
|
241
|
+
|
|
242
|
+
Exa.emit(
|
|
243
|
+
"exa.request.complete",
|
|
244
|
+
Exa::Instrumentation::Events::RequestComplete.new(
|
|
245
|
+
request_id: request_id,
|
|
246
|
+
endpoint: endpoint,
|
|
247
|
+
duration_ms: duration_ms,
|
|
248
|
+
status: status,
|
|
249
|
+
cost_dollars: cost_dollars,
|
|
250
|
+
timestamp: Process.clock_gettime(Process::CLOCK_MONOTONIC)
|
|
251
|
+
)
|
|
252
|
+
)
|
|
253
|
+
end
|
|
254
|
+
|
|
255
|
+
def emit_request_error(request_id, endpoint, start_time, error)
|
|
256
|
+
duration_ms = (Process.clock_gettime(Process::CLOCK_MONOTONIC) - start_time) * 1000
|
|
257
|
+
|
|
258
|
+
Exa.emit(
|
|
259
|
+
"exa.request.error",
|
|
260
|
+
Exa::Instrumentation::Events::RequestError.new(
|
|
261
|
+
request_id: request_id,
|
|
262
|
+
endpoint: endpoint,
|
|
263
|
+
duration_ms: duration_ms,
|
|
264
|
+
error_class: error.class.name,
|
|
265
|
+
error_message: error.message,
|
|
266
|
+
timestamp: Process.clock_gettime(Process::CLOCK_MONOTONIC)
|
|
267
|
+
)
|
|
268
|
+
)
|
|
269
|
+
end
|
|
270
|
+
|
|
271
|
+
def extract_cost_dollars(result)
|
|
272
|
+
return nil unless result.respond_to?(:cost_dollars)
|
|
273
|
+
cost = result.cost_dollars
|
|
274
|
+
return nil unless cost
|
|
275
|
+
|
|
276
|
+
# CostDollars struct has a total field
|
|
277
|
+
cost.respond_to?(:total) ? cost.total : nil
|
|
278
|
+
end
|
|
208
279
|
end
|
|
209
280
|
end
|
|
210
281
|
end
|
|
@@ -27,38 +27,6 @@ module Exa
|
|
|
27
27
|
end
|
|
28
28
|
end
|
|
29
29
|
|
|
30
|
-
class CostBreakdown < T::Struct
|
|
31
|
-
const :search, T.nilable(Float)
|
|
32
|
-
const :contents, T.nilable(Float)
|
|
33
|
-
const :breakdown, T.nilable(T::Hash[Symbol, T.nilable(Float)])
|
|
34
|
-
|
|
35
|
-
def self.from_hash(hash)
|
|
36
|
-
return nil unless hash
|
|
37
|
-
sym = Helpers.symbolize_keys(hash)
|
|
38
|
-
new(
|
|
39
|
-
search: sym[:search]&.to_f,
|
|
40
|
-
contents: sym[:contents]&.to_f,
|
|
41
|
-
breakdown: sym[:breakdown]&.transform_keys(&:to_sym)
|
|
42
|
-
)
|
|
43
|
-
end
|
|
44
|
-
end
|
|
45
|
-
|
|
46
|
-
class CostDollars < T::Struct
|
|
47
|
-
const :total, T.nilable(Float)
|
|
48
|
-
const :break_down, T.nilable(T::Array[CostBreakdown])
|
|
49
|
-
const :per_request_prices, T.nilable(T::Hash[Symbol, T.untyped])
|
|
50
|
-
|
|
51
|
-
def self.from_hash(hash)
|
|
52
|
-
return nil unless hash
|
|
53
|
-
sym = Helpers.symbolize_keys(hash)
|
|
54
|
-
new(
|
|
55
|
-
total: sym[:total]&.to_f,
|
|
56
|
-
break_down: sym[:breakDown]&.map { CostBreakdown.from_hash(_1) },
|
|
57
|
-
per_request_prices: sym[:perRequestPrices]&.transform_keys(&:to_sym)
|
|
58
|
-
)
|
|
59
|
-
end
|
|
60
|
-
end
|
|
61
|
-
|
|
62
30
|
class AnswerResponse < T::Struct
|
|
63
31
|
const :answer, T.nilable(String)
|
|
64
32
|
const :citations, T::Array[AnswerCitation]
|
|
@@ -18,7 +18,7 @@ module Exa
|
|
|
18
18
|
const :results, T::Array[ResultWithContent]
|
|
19
19
|
const :context, T.nilable(String)
|
|
20
20
|
const :statuses, T.nilable(T::Array[ContentStatus])
|
|
21
|
-
const :cost_dollars, T.nilable(
|
|
21
|
+
const :cost_dollars, T.nilable(CostDollars)
|
|
22
22
|
|
|
23
23
|
def self.from_hash(hash)
|
|
24
24
|
sym = Helpers.symbolize_keys(hash)
|
|
@@ -27,7 +27,7 @@ module Exa
|
|
|
27
27
|
results: Array(sym[:results]).map { ResultWithContent.from_hash(_1) },
|
|
28
28
|
context: sym[:context],
|
|
29
29
|
statuses: sym[:statuses]&.map { ContentStatus.from_hash(_1) },
|
|
30
|
-
cost_dollars: sym[:costDollars]
|
|
30
|
+
cost_dollars: CostDollars.from_hash(sym[:costDollars])
|
|
31
31
|
)
|
|
32
32
|
end
|
|
33
33
|
end
|
|
@@ -0,0 +1,94 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Exa
|
|
4
|
+
module Responses
|
|
5
|
+
# Detailed breakdown of costs by operation type within a single request iteration
|
|
6
|
+
class CostBreakdownDetail < T::Struct
|
|
7
|
+
const :neural_search, T.nilable(Float)
|
|
8
|
+
const :deep_search, T.nilable(Float)
|
|
9
|
+
const :content_text, T.nilable(Float)
|
|
10
|
+
const :content_highlight, T.nilable(Float)
|
|
11
|
+
const :content_summary, T.nilable(Float)
|
|
12
|
+
|
|
13
|
+
def self.from_hash(hash)
|
|
14
|
+
return nil unless hash
|
|
15
|
+
sym = Helpers.symbolize_keys(hash)
|
|
16
|
+
new(
|
|
17
|
+
neural_search: sym[:neuralSearch]&.to_f,
|
|
18
|
+
deep_search: sym[:deepSearch]&.to_f,
|
|
19
|
+
content_text: sym[:contentText]&.to_f,
|
|
20
|
+
content_highlight: sym[:contentHighlight]&.to_f,
|
|
21
|
+
content_summary: sym[:contentSummary]&.to_f
|
|
22
|
+
)
|
|
23
|
+
end
|
|
24
|
+
end
|
|
25
|
+
|
|
26
|
+
# Cost breakdown for a single request iteration (search + contents)
|
|
27
|
+
class CostBreakdown < T::Struct
|
|
28
|
+
const :search, T.nilable(Float)
|
|
29
|
+
const :contents, T.nilable(Float)
|
|
30
|
+
const :breakdown, T.nilable(CostBreakdownDetail)
|
|
31
|
+
|
|
32
|
+
def self.from_hash(hash)
|
|
33
|
+
return nil unless hash
|
|
34
|
+
sym = Helpers.symbolize_keys(hash)
|
|
35
|
+
new(
|
|
36
|
+
search: sym[:search]&.to_f,
|
|
37
|
+
contents: sym[:contents]&.to_f,
|
|
38
|
+
breakdown: CostBreakdownDetail.from_hash(sym[:breakdown])
|
|
39
|
+
)
|
|
40
|
+
end
|
|
41
|
+
end
|
|
42
|
+
|
|
43
|
+
# Standard price per request for different search operations
|
|
44
|
+
class PerRequestPrices < T::Struct
|
|
45
|
+
const :neural_search, T.nilable(Float)
|
|
46
|
+
const :deep_search, T.nilable(Float)
|
|
47
|
+
|
|
48
|
+
def self.from_hash(hash)
|
|
49
|
+
return nil unless hash
|
|
50
|
+
sym = Helpers.symbolize_keys(hash)
|
|
51
|
+
new(
|
|
52
|
+
neural_search: sym[:neuralSearch]&.to_f,
|
|
53
|
+
deep_search: sym[:deepSearch]&.to_f
|
|
54
|
+
)
|
|
55
|
+
end
|
|
56
|
+
end
|
|
57
|
+
|
|
58
|
+
# Standard price per page for different content operations
|
|
59
|
+
class PerPagePrices < T::Struct
|
|
60
|
+
const :text, T.nilable(Float)
|
|
61
|
+
const :highlight, T.nilable(Float)
|
|
62
|
+
const :summary, T.nilable(Float)
|
|
63
|
+
|
|
64
|
+
def self.from_hash(hash)
|
|
65
|
+
return nil unless hash
|
|
66
|
+
sym = Helpers.symbolize_keys(hash)
|
|
67
|
+
new(
|
|
68
|
+
text: sym[:text]&.to_f,
|
|
69
|
+
highlight: sym[:highlight]&.to_f,
|
|
70
|
+
summary: sym[:summary]&.to_f
|
|
71
|
+
)
|
|
72
|
+
end
|
|
73
|
+
end
|
|
74
|
+
|
|
75
|
+
# Complete cost information returned by Exa API responses
|
|
76
|
+
class CostDollars < T::Struct
|
|
77
|
+
const :total, T.nilable(Float)
|
|
78
|
+
const :break_down, T.nilable(T::Array[CostBreakdown])
|
|
79
|
+
const :per_request_prices, T.nilable(PerRequestPrices)
|
|
80
|
+
const :per_page_prices, T.nilable(PerPagePrices)
|
|
81
|
+
|
|
82
|
+
def self.from_hash(hash)
|
|
83
|
+
return nil unless hash
|
|
84
|
+
sym = Helpers.symbolize_keys(hash)
|
|
85
|
+
new(
|
|
86
|
+
total: sym[:total]&.to_f,
|
|
87
|
+
break_down: sym[:breakDown]&.map { CostBreakdown.from_hash(_1) },
|
|
88
|
+
per_request_prices: PerRequestPrices.from_hash(sym[:perRequestPrices]),
|
|
89
|
+
per_page_prices: PerPagePrices.from_hash(sym[:perPagePrices])
|
|
90
|
+
)
|
|
91
|
+
end
|
|
92
|
+
end
|
|
93
|
+
end
|
|
94
|
+
end
|
|
@@ -8,7 +8,7 @@ module Exa
|
|
|
8
8
|
const :search_type, T.nilable(String)
|
|
9
9
|
const :results, T::Array[ResultWithContent]
|
|
10
10
|
const :context, T.nilable(String)
|
|
11
|
-
const :cost_dollars, T.nilable(
|
|
11
|
+
const :cost_dollars, T.nilable(CostDollars)
|
|
12
12
|
|
|
13
13
|
def self.from_hash(hash)
|
|
14
14
|
sym = Helpers.symbolize_keys(hash)
|
|
@@ -18,29 +18,16 @@ module Exa
|
|
|
18
18
|
search_type: sym[:searchType],
|
|
19
19
|
results: Array(sym[:results]).map { ResultWithContent.from_hash(_1) },
|
|
20
20
|
context: sym[:context],
|
|
21
|
-
cost_dollars:
|
|
21
|
+
cost_dollars: CostDollars.from_hash(sym[:costDollars])
|
|
22
22
|
)
|
|
23
23
|
end
|
|
24
|
-
|
|
25
|
-
def self.normalize_cost(value)
|
|
26
|
-
case value
|
|
27
|
-
when nil
|
|
28
|
-
nil
|
|
29
|
-
when Numeric
|
|
30
|
-
value.to_f
|
|
31
|
-
when Hash
|
|
32
|
-
Exa::Responses::Helpers.symbolize_keys(value)
|
|
33
|
-
else
|
|
34
|
-
value
|
|
35
|
-
end
|
|
36
|
-
end
|
|
37
24
|
end
|
|
38
25
|
|
|
39
26
|
class FindSimilarResponse < T::Struct
|
|
40
27
|
const :request_id, T.nilable(String)
|
|
41
28
|
const :results, T::Array[ResultWithContent]
|
|
42
29
|
const :context, T.nilable(String)
|
|
43
|
-
const :cost_dollars, T.nilable(
|
|
30
|
+
const :cost_dollars, T.nilable(CostDollars)
|
|
44
31
|
|
|
45
32
|
def self.from_hash(hash)
|
|
46
33
|
sym = Helpers.symbolize_keys(hash)
|
|
@@ -48,7 +35,7 @@ module Exa
|
|
|
48
35
|
request_id: sym[:requestId],
|
|
49
36
|
results: Array(sym[:results]).map { ResultWithContent.from_hash(_1) },
|
|
50
37
|
context: sym[:context],
|
|
51
|
-
cost_dollars: sym[:costDollars]
|
|
38
|
+
cost_dollars: CostDollars.from_hash(sym[:costDollars])
|
|
52
39
|
)
|
|
53
40
|
end
|
|
54
41
|
end
|
data/lib/exa/responses.rb
CHANGED
data/lib/exa/version.rb
CHANGED
data/lib/exa.rb
CHANGED
|
@@ -12,3 +12,30 @@ require_relative "exa/client"
|
|
|
12
12
|
require_relative "exa/types"
|
|
13
13
|
require_relative "exa/responses"
|
|
14
14
|
require_relative "exa/resources"
|
|
15
|
+
require_relative "exa/instrumentation"
|
|
16
|
+
|
|
17
|
+
module Exa
|
|
18
|
+
class << self
|
|
19
|
+
# Returns the global instrumentation event registry.
|
|
20
|
+
# Use this to subscribe to API events for cost tracking, logging, etc.
|
|
21
|
+
#
|
|
22
|
+
# @example Subscribe to all request events
|
|
23
|
+
# Exa.instrumentation.subscribe('exa.request.*') do |event_name, payload|
|
|
24
|
+
# puts "#{event_name}: #{payload.inspect}"
|
|
25
|
+
# end
|
|
26
|
+
#
|
|
27
|
+
# @return [Exa::Instrumentation::EventRegistry]
|
|
28
|
+
def instrumentation
|
|
29
|
+
@instrumentation ||= Instrumentation::EventRegistry.new
|
|
30
|
+
end
|
|
31
|
+
|
|
32
|
+
# Emit an event to all subscribers.
|
|
33
|
+
# Primarily used internally by the transport layer.
|
|
34
|
+
#
|
|
35
|
+
# @param event_name [String] The event name (e.g., 'exa.request.complete')
|
|
36
|
+
# @param payload [Object] The event payload
|
|
37
|
+
def emit(event_name, payload)
|
|
38
|
+
instrumentation.notify(event_name, payload)
|
|
39
|
+
end
|
|
40
|
+
end
|
|
41
|
+
end
|
metadata
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
--- !ruby/object:Gem::Specification
|
|
2
2
|
name: exa-ai-ruby
|
|
3
3
|
version: !ruby/object:Gem::Version
|
|
4
|
-
version: 1.
|
|
4
|
+
version: 1.3.0
|
|
5
5
|
platform: ruby
|
|
6
6
|
authors:
|
|
7
7
|
- Vicente Reig Rincon de Arellano
|
|
@@ -203,6 +203,9 @@ files:
|
|
|
203
203
|
- lib/exa/cli/root.rb
|
|
204
204
|
- lib/exa/client.rb
|
|
205
205
|
- lib/exa/errors.rb
|
|
206
|
+
- lib/exa/instrumentation.rb
|
|
207
|
+
- lib/exa/instrumentation/cost_tracker.rb
|
|
208
|
+
- lib/exa/instrumentation/events.rb
|
|
206
209
|
- lib/exa/internal/transport/async_requester.rb
|
|
207
210
|
- lib/exa/internal/transport/base_client.rb
|
|
208
211
|
- lib/exa/internal/transport/pooled_net_requester.rb
|
|
@@ -222,6 +225,7 @@ files:
|
|
|
222
225
|
- lib/exa/responses.rb
|
|
223
226
|
- lib/exa/responses/answer_response.rb
|
|
224
227
|
- lib/exa/responses/contents_response.rb
|
|
228
|
+
- lib/exa/responses/cost.rb
|
|
225
229
|
- lib/exa/responses/event_response.rb
|
|
226
230
|
- lib/exa/responses/helpers.rb
|
|
227
231
|
- lib/exa/responses/import_response.rb
|