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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 66972bcd1f042b148cd8dc5f004dad3fa38d0a2f242ba8727ded974866cb502c
4
- data.tar.gz: d4cc2dada13aa407b22fbb168cc9683b8018c8ae3b75f386c5fe7f85bc624a1c
3
+ metadata.gz: bc02adeea0f6941eca67efd47e5cfb78dbaa07bf59acfaf441f5e63fe86839ce
4
+ data.tar.gz: cc85d78c4754424b3755857e81528f7eb537754f79a3a1cd789613cce28adce4
5
5
  SHA512:
6
- metadata.gz: 9ded95dff3837d3c2e827468bdca4d300d802ac318357d0b1b4ced3946a0578ec4c9eb4d97daeb5cf0f4cf3fe1199444a6f95b02caf5ae6aa6a9d512f5773d7f
7
- data.tar.gz: cc208c8492d43dc2a48d9072e5d402d24de5fee3945e5db60dcc761755332c54168d3dc77a3eda3b22e39af937ee20df57e932d308116e746f76835ca4419b3f
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. [Testing & TDD Plan](#testing--tdd-plan)
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: Array(path).join("/"),
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
- _, response, stream_enum = send_request(req, max_retries: options[:max_retries] || max_retries)
60
- parsed_headers = Exa::Internal::Util.normalized_headers(response.each_header.to_h)
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
- if stream
63
- Exa::Internal::Transport::Stream.new(headers: parsed_headers, stream: stream_enum)
64
- else
65
- decoded = Exa::Internal::Util.decode_content(parsed_headers, stream: stream_enum)
66
- coerced = coerce_response(response_model, decoded)
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(Float)
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]&.to_f
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(T.any(Float, T::Hash[Symbol, T.untyped]))
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: normalize_cost(sym[:costDollars])
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(Float)
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]&.to_f
38
+ cost_dollars: CostDollars.from_hash(sym[:costDollars])
52
39
  )
53
40
  end
54
41
  end
data/lib/exa/responses.rb CHANGED
@@ -1,6 +1,7 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  require_relative "responses/helpers"
4
+ require_relative "responses/cost"
4
5
  require_relative "responses/result"
5
6
  require_relative "responses/search_response"
6
7
  require_relative "responses/contents_response"
data/lib/exa/version.rb CHANGED
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module Exa
4
- VERSION = "1.2.2"
4
+ VERSION = "1.3.0"
5
5
  end
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.2.2
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