exaonruby 1.0.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.
Files changed (40) hide show
  1. checksums.yaml +7 -0
  2. data/LICENSE.txt +21 -0
  3. data/README.md +614 -0
  4. data/exaonruby.gemspec +37 -0
  5. data/exe/exa +7 -0
  6. data/lib/exa/cli.rb +458 -0
  7. data/lib/exa/client.rb +210 -0
  8. data/lib/exa/configuration.rb +81 -0
  9. data/lib/exa/endpoints/answer.rb +109 -0
  10. data/lib/exa/endpoints/contents.rb +141 -0
  11. data/lib/exa/endpoints/events.rb +71 -0
  12. data/lib/exa/endpoints/find_similar.rb +154 -0
  13. data/lib/exa/endpoints/imports.rb +145 -0
  14. data/lib/exa/endpoints/monitors.rb +193 -0
  15. data/lib/exa/endpoints/research.rb +158 -0
  16. data/lib/exa/endpoints/search.rb +195 -0
  17. data/lib/exa/endpoints/webhooks.rb +161 -0
  18. data/lib/exa/endpoints/webset_enrichments.rb +162 -0
  19. data/lib/exa/endpoints/webset_items.rb +90 -0
  20. data/lib/exa/endpoints/webset_searches.rb +137 -0
  21. data/lib/exa/endpoints/websets.rb +214 -0
  22. data/lib/exa/errors.rb +180 -0
  23. data/lib/exa/resources/answer_response.rb +101 -0
  24. data/lib/exa/resources/base.rb +56 -0
  25. data/lib/exa/resources/contents_response.rb +123 -0
  26. data/lib/exa/resources/event.rb +84 -0
  27. data/lib/exa/resources/import.rb +137 -0
  28. data/lib/exa/resources/monitor.rb +205 -0
  29. data/lib/exa/resources/paginated_response.rb +87 -0
  30. data/lib/exa/resources/research_task.rb +165 -0
  31. data/lib/exa/resources/search_response.rb +111 -0
  32. data/lib/exa/resources/search_result.rb +95 -0
  33. data/lib/exa/resources/webhook.rb +152 -0
  34. data/lib/exa/resources/webset.rb +491 -0
  35. data/lib/exa/resources/webset_item.rb +256 -0
  36. data/lib/exa/utils/parameter_converter.rb +159 -0
  37. data/lib/exa/utils/webhook_handler.rb +239 -0
  38. data/lib/exa/version.rb +7 -0
  39. data/lib/exa.rb +130 -0
  40. metadata +146 -0
@@ -0,0 +1,214 @@
1
+ # frozen_string_literal: true
2
+
3
+ # typed: strict
4
+
5
+ module Exa
6
+ module Endpoints
7
+ module Websets
8
+ VALID_ENTITY_TYPES = %w[company person research_paper].freeze
9
+ VALID_ENRICHMENT_FORMATS = %w[text enum number boolean date].freeze
10
+
11
+ # Creates a new Webset with optional search, import, and enrichment configurations
12
+ #
13
+ # The Webset will automatically begin processing once created.
14
+ #
15
+ # @param search [Hash] Optional search configuration
16
+ # @option search [String] :query Search query
17
+ # @option search [Integer] :count Target number of results (default 10)
18
+ # @option search [Hash] :entity Entity type configuration { type: "company"|"person"|"research_paper" }
19
+ # @option search [Array<Hash>] :criteria Criteria for filtering [{ description: "..." }]
20
+ # @option search [Boolean] :recall Enable recall estimation
21
+ # @option search [Array<Hash>] :exclude Exclusion rules
22
+ # @option search [Array<Hash>] :scope Scope configuration
23
+ #
24
+ # @param import [Array<Hash>] Optional import sources to attach
25
+ # @param enrichments [Array<Hash>] Optional enrichments to add
26
+ # @option enrichments [String] :description What to extract
27
+ # @option enrichments [String] :format Format: text, enum, number, boolean, date
28
+ # @option enrichments [Array<Hash>] :options Options for enum format
29
+ #
30
+ # @param exclude [Array<Hash>] Global exclusion sources
31
+ # @param external_id [String] External identifier for the Webset
32
+ # @param metadata [Hash] Custom metadata key-value pairs
33
+ #
34
+ # @return [Exa::Resources::Webset] Created Webset
35
+ #
36
+ # @example Create a basic Webset with search
37
+ # client.create_webset(
38
+ # search: {
39
+ # query: "AI startups founded in 2024",
40
+ # count: 50,
41
+ # entity: { type: "company" },
42
+ # criteria: [{ description: "Company must be focused on artificial intelligence" }]
43
+ # }
44
+ # )
45
+ #
46
+ # @example Create a Webset with enrichments
47
+ # client.create_webset(
48
+ # search: { query: "Series A startups", entity: { type: "company" } },
49
+ # enrichments: [
50
+ # { description: "Company's total funding amount", format: "number" },
51
+ # { description: "Company's industry", format: "text" }
52
+ # ]
53
+ # )
54
+ def create_webset(**options)
55
+ validate_webset_options!(options)
56
+
57
+ params = build_webset_params(options)
58
+ response = websets_post("/websets", params)
59
+
60
+ Resources::Webset.new(
61
+ Utils::ParameterConverter.from_api_response(response)
62
+ )
63
+ end
64
+
65
+ # Retrieves a Webset by ID or external ID
66
+ #
67
+ # @param id [String] Webset ID or external ID
68
+ # @return [Exa::Resources::Webset] The Webset
69
+ #
70
+ # @raise [Exa::NotFoundError] if Webset not found
71
+ #
72
+ # @example Get a Webset
73
+ # webset = client.get_webset("webset_abc123")
74
+ def get_webset(id)
75
+ raise InvalidRequestError, "id must be a non-empty string" if !id.is_a?(String) || id.empty?
76
+
77
+ response = websets_get("/websets/#{id}")
78
+
79
+ Resources::Webset.new(
80
+ Utils::ParameterConverter.from_api_response(response)
81
+ )
82
+ end
83
+
84
+ # Lists all Websets with pagination
85
+ #
86
+ # @param cursor [String, nil] Cursor for pagination
87
+ # @param limit [Integer, nil] Number of results per page (1-100)
88
+ #
89
+ # @return [Exa::Resources::WebsetsListResponse] Paginated list of Websets
90
+ #
91
+ # @example List all Websets
92
+ # response = client.list_websets
93
+ # response.data.each { |webset| puts webset.title }
94
+ #
95
+ # @example Paginate through Websets
96
+ # response = client.list_websets(limit: 10)
97
+ # while response.has_more?
98
+ # response = client.list_websets(cursor: response.next_cursor, limit: 10)
99
+ # end
100
+ def list_websets(cursor: nil, limit: nil)
101
+ params = {}
102
+ params[:cursor] = cursor if cursor
103
+ params[:limit] = limit if limit
104
+
105
+ response = websets_get("/websets", params)
106
+
107
+ Resources::WebsetsListResponse.new(
108
+ Utils::ParameterConverter.from_api_response(response)
109
+ )
110
+ end
111
+
112
+ # Deletes a Webset and all its Items
113
+ #
114
+ # @param id [String] Webset ID to delete
115
+ # @return [Exa::Resources::Webset] The deleted Webset
116
+ #
117
+ # @raise [Exa::NotFoundError] if Webset not found
118
+ #
119
+ # @example Delete a Webset
120
+ # client.delete_webset("webset_abc123")
121
+ def delete_webset(id)
122
+ raise InvalidRequestError, "id must be a non-empty string" if !id.is_a?(String) || id.empty?
123
+
124
+ response = websets_delete("/websets/#{id}")
125
+
126
+ Resources::Webset.new(
127
+ Utils::ParameterConverter.from_api_response(response)
128
+ )
129
+ end
130
+
131
+ private
132
+
133
+ # @param options [Hash] Options to validate
134
+ # @raise [Exa::InvalidRequestError] if validation fails
135
+ def validate_webset_options!(options)
136
+ if options[:search]
137
+ validate_search_config!(options[:search])
138
+ end
139
+
140
+ if options[:enrichments]
141
+ options[:enrichments].each { |e| validate_enrichment_config!(e) }
142
+ end
143
+ end
144
+
145
+ # @param search [Hash] Search configuration to validate
146
+ def validate_search_config!(search)
147
+ if search[:entity] && search[:entity][:type]
148
+ entity_type = search[:entity][:type].to_s
149
+ unless VALID_ENTITY_TYPES.include?(entity_type)
150
+ raise InvalidRequestError, "Invalid entity type: #{entity_type}. Valid types: #{VALID_ENTITY_TYPES.join(", ")}"
151
+ end
152
+ end
153
+
154
+ if search[:count] && (search[:count] < 1 || search[:count] > 10_000)
155
+ raise InvalidRequestError, "count must be between 1 and 10000"
156
+ end
157
+ end
158
+
159
+ # @param enrichment [Hash] Enrichment configuration to validate
160
+ def validate_enrichment_config!(enrichment)
161
+ return unless enrichment[:format]
162
+
163
+ format = enrichment[:format].to_s
164
+ unless VALID_ENRICHMENT_FORMATS.include?(format)
165
+ raise InvalidRequestError, "Invalid enrichment format: #{format}. Valid formats: #{VALID_ENRICHMENT_FORMATS.join(", ")}"
166
+ end
167
+ end
168
+
169
+ # @param options [Hash] Webset creation options
170
+ # @return [Hash] API-formatted parameters
171
+ def build_webset_params(options)
172
+ params = {}
173
+
174
+ params[:search] = build_search_config(options[:search]) if options[:search]
175
+ params[:import] = options[:import] if options[:import]
176
+ params[:enrichments] = options[:enrichments].map { |e| build_enrichment_config(e) } if options[:enrichments]
177
+ params[:exclude] = options[:exclude] if options[:exclude]
178
+ params[:externalId] = options[:external_id] if options[:external_id]
179
+ params[:metadata] = options[:metadata] if options[:metadata]
180
+
181
+ params
182
+ end
183
+
184
+ # @param search [Hash] Search configuration
185
+ # @return [Hash] API-formatted search config
186
+ def build_search_config(search)
187
+ config = {}
188
+
189
+ config[:query] = search[:query] if search[:query]
190
+ config[:count] = search[:count] if search[:count]
191
+ config[:entity] = search[:entity] if search[:entity]
192
+ config[:criteria] = search[:criteria] if search[:criteria]
193
+ config[:recall] = search[:recall] if search.key?(:recall)
194
+ config[:exclude] = search[:exclude] if search[:exclude]
195
+ config[:scope] = search[:scope] if search[:scope]
196
+
197
+ config
198
+ end
199
+
200
+ # @param enrichment [Hash] Enrichment configuration
201
+ # @return [Hash] API-formatted enrichment config
202
+ def build_enrichment_config(enrichment)
203
+ config = {}
204
+
205
+ config[:description] = enrichment[:description] if enrichment[:description]
206
+ config[:format] = enrichment[:format] if enrichment[:format]
207
+ config[:options] = enrichment[:options] if enrichment[:options]
208
+ config[:metadata] = enrichment[:metadata] if enrichment[:metadata]
209
+
210
+ config
211
+ end
212
+ end
213
+ end
214
+ end
data/lib/exa/errors.rb ADDED
@@ -0,0 +1,180 @@
1
+ # frozen_string_literal: true
2
+
3
+ # typed: strict
4
+
5
+ module Exa
6
+ class Error < StandardError
7
+ # @return [Integer, nil] HTTP status code if applicable
8
+ attr_reader :status
9
+
10
+ # @return [Hash, nil] Response body if available
11
+ attr_reader :response_body
12
+
13
+ # @return [String, nil] Request ID for debugging
14
+ attr_reader :request_id
15
+
16
+ # @param message [String] Error message
17
+ # @param status [Integer, nil] HTTP status code
18
+ # @param response_body [Hash, nil] Parsed response body
19
+ # @param request_id [String, nil] Request ID from response
20
+ def initialize(message = nil, status: nil, response_body: nil, request_id: nil)
21
+ @status = status
22
+ @response_body = response_body
23
+ @request_id = request_id
24
+ super(message)
25
+ end
26
+
27
+ # @return [String] Formatted error message with details
28
+ def to_s
29
+ parts = [super]
30
+ parts << "(status: #{status})" if status
31
+ parts << "(request_id: #{request_id})" if request_id
32
+ parts.join(" ")
33
+ end
34
+ end
35
+
36
+ class AuthenticationError < Error
37
+ end
38
+
39
+ class RateLimitError < Error
40
+ # @return [Integer, nil] Seconds until rate limit resets
41
+ attr_reader :retry_after
42
+
43
+ # @param message [String] Error message
44
+ # @param retry_after [Integer, nil] Seconds until retry
45
+ # @param kwargs [Hash] Additional error options
46
+ def initialize(message = nil, retry_after: nil, **kwargs)
47
+ @retry_after = retry_after
48
+ super(message, **kwargs)
49
+ end
50
+ end
51
+
52
+ class InvalidRequestError < Error
53
+ # @return [Array<Hash>, nil] Validation errors if available
54
+ attr_reader :validation_errors
55
+
56
+ # @param message [String] Error message
57
+ # @param validation_errors [Array<Hash>, nil] List of validation errors
58
+ # @param kwargs [Hash] Additional error options
59
+ def initialize(message = nil, validation_errors: nil, **kwargs)
60
+ @validation_errors = validation_errors
61
+ super(message, **kwargs)
62
+ end
63
+ end
64
+
65
+ class NotFoundError < Error
66
+ end
67
+
68
+ class ConflictError < Error
69
+ end
70
+
71
+ class UnprocessableEntityError < Error
72
+ end
73
+
74
+ class ServerError < Error
75
+ end
76
+
77
+ class ServiceUnavailableError < ServerError
78
+ end
79
+
80
+ class TimeoutError < Error
81
+ end
82
+
83
+ class ConnectionError < Error
84
+ end
85
+
86
+ class << self
87
+ # Maps HTTP status codes to appropriate error classes
88
+ # @param status [Integer] HTTP status code
89
+ # @param message [String, nil] Error message
90
+ # @param response_body [Hash, nil] Response body
91
+ # @param headers [Hash, nil] Response headers
92
+ # @return [Exa::Error] Appropriate error instance
93
+ def error_for_status(status, message: nil, response_body: nil, headers: nil)
94
+ request_id = response_body&.dig("requestId")
95
+ error_message = message || extract_error_message(response_body)
96
+
97
+ case status
98
+ when 400
99
+ validation_errors = response_body&.dig("errors")
100
+ InvalidRequestError.new(
101
+ error_message,
102
+ status: status,
103
+ response_body: response_body,
104
+ request_id: request_id,
105
+ validation_errors: validation_errors
106
+ )
107
+ when 401
108
+ AuthenticationError.new(
109
+ error_message || "Invalid API key",
110
+ status: status,
111
+ response_body: response_body,
112
+ request_id: request_id
113
+ )
114
+ when 404
115
+ NotFoundError.new(
116
+ error_message || "Resource not found",
117
+ status: status,
118
+ response_body: response_body,
119
+ request_id: request_id
120
+ )
121
+ when 409
122
+ ConflictError.new(
123
+ error_message,
124
+ status: status,
125
+ response_body: response_body,
126
+ request_id: request_id
127
+ )
128
+ when 422
129
+ UnprocessableEntityError.new(
130
+ error_message,
131
+ status: status,
132
+ response_body: response_body,
133
+ request_id: request_id
134
+ )
135
+ when 429
136
+ retry_after = headers&.dig("retry-after")&.to_i
137
+ RateLimitError.new(
138
+ error_message || "Rate limit exceeded",
139
+ status: status,
140
+ response_body: response_body,
141
+ request_id: request_id,
142
+ retry_after: retry_after
143
+ )
144
+ when 500
145
+ ServerError.new(
146
+ error_message || "Internal server error",
147
+ status: status,
148
+ response_body: response_body,
149
+ request_id: request_id
150
+ )
151
+ when 503
152
+ ServiceUnavailableError.new(
153
+ error_message || "Service temporarily unavailable",
154
+ status: status,
155
+ response_body: response_body,
156
+ request_id: request_id
157
+ )
158
+ else
159
+ Error.new(
160
+ error_message || "HTTP #{status} error",
161
+ status: status,
162
+ response_body: response_body,
163
+ request_id: request_id
164
+ )
165
+ end
166
+ end
167
+
168
+ private
169
+
170
+ # @param response_body [Hash, nil] Response body to extract message from
171
+ # @return [String, nil] Extracted error message
172
+ def extract_error_message(response_body)
173
+ return nil unless response_body.is_a?(Hash)
174
+
175
+ response_body["message"] ||
176
+ response_body["error"] ||
177
+ response_body.dig("error", "message")
178
+ end
179
+ end
180
+ end
@@ -0,0 +1,101 @@
1
+ # frozen_string_literal: true
2
+
3
+ # typed: strict
4
+
5
+ module Exa
6
+ module Resources
7
+ class AnswerResponse < Base
8
+ # @return [String] The generated answer based on search results
9
+ def answer
10
+ get(:answer)
11
+ end
12
+
13
+ # @return [Array<Citation>] Search results used to generate the answer
14
+ def citations
15
+ @citations ||= (get(:citations, []) || []).map { |data| Citation.new(data) }
16
+ end
17
+
18
+ # @return [CostInfo, nil] Cost information for the request
19
+ def cost
20
+ cost_data = get(:cost_dollars)
21
+ return nil unless cost_data
22
+
23
+ CostInfo.new(cost_data)
24
+ end
25
+
26
+ # @return [Float] Total cost in dollars
27
+ def total_cost
28
+ cost&.total || 0.0
29
+ end
30
+
31
+ # @return [Integer] Number of citations
32
+ def citation_count
33
+ citations.length
34
+ end
35
+
36
+ # @return [Boolean] Whether answer has citations
37
+ def has_citations?
38
+ !citations.empty?
39
+ end
40
+
41
+ private
42
+
43
+ def inspectable_attributes
44
+ { answer: answer&.slice(0, 50), citation_count: citation_count }
45
+ end
46
+ end
47
+
48
+ class Citation < Base
49
+ # @return [String] Unique identifier
50
+ def id
51
+ get(:id)
52
+ end
53
+
54
+ # @return [String] URL of the source
55
+ def url
56
+ get(:url)
57
+ end
58
+
59
+ # @return [String] Title of the source
60
+ def title
61
+ get(:title)
62
+ end
63
+
64
+ # @return [String, nil] Author of the source
65
+ def author
66
+ get(:author)
67
+ end
68
+
69
+ # @return [Time, nil] Published date
70
+ def published_date
71
+ date_str = get(:published_date)
72
+ return nil unless date_str
73
+
74
+ Time.parse(date_str)
75
+ rescue ArgumentError
76
+ nil
77
+ end
78
+
79
+ # @return [String, nil] Text content from the source
80
+ def text
81
+ get(:text)
82
+ end
83
+
84
+ # @return [String, nil] Image URL
85
+ def image
86
+ get(:image)
87
+ end
88
+
89
+ # @return [String, nil] Favicon URL
90
+ def favicon
91
+ get(:favicon)
92
+ end
93
+
94
+ private
95
+
96
+ def inspectable_attributes
97
+ { url: url, title: title }
98
+ end
99
+ end
100
+ end
101
+ end
@@ -0,0 +1,56 @@
1
+ # frozen_string_literal: true
2
+
3
+ # typed: strict
4
+
5
+ module Exa
6
+ module Resources
7
+ class Base
8
+ # @return [Hash] Raw response data
9
+ attr_reader :raw_data
10
+
11
+ # @param data [Hash] Response data from API
12
+ def initialize(data)
13
+ @raw_data = data.freeze
14
+ end
15
+
16
+ # @return [String] String representation
17
+ def to_s
18
+ "#<#{self.class.name}>"
19
+ end
20
+
21
+ # @return [String] Inspect representation
22
+ def inspect
23
+ "#<#{self.class.name} #{inspectable_attributes.map { |k, v| "#{k}=#{v.inspect}" }.join(" ")}>"
24
+ end
25
+
26
+ # @return [Hash] Hash representation
27
+ def to_h
28
+ raw_data
29
+ end
30
+
31
+ alias to_hash to_h
32
+
33
+ private
34
+
35
+ # @return [Hash] Attributes to include in inspect output
36
+ def inspectable_attributes
37
+ {}
38
+ end
39
+
40
+ # Safely retrieves a value from raw_data
41
+ # @param key [Symbol] Key to retrieve
42
+ # @param default [Object] Default value if key not found
43
+ # @return [Object] Value or default
44
+ def get(key, default = nil)
45
+ raw_data.fetch(key, default)
46
+ end
47
+
48
+ # Safely retrieves a nested value from raw_data
49
+ # @param keys [Array<Symbol>] Keys to traverse
50
+ # @return [Object, nil] Value or nil
51
+ def dig(*keys)
52
+ raw_data.dig(*keys)
53
+ end
54
+ end
55
+ end
56
+ end
@@ -0,0 +1,123 @@
1
+ # frozen_string_literal: true
2
+
3
+ # typed: strict
4
+
5
+ module Exa
6
+ module Resources
7
+ class ContentsResponse < Base
8
+ # @return [String] Unique request identifier
9
+ def request_id
10
+ get(:request_id)
11
+ end
12
+
13
+ # @return [Array<SearchResult>] Content results
14
+ def results
15
+ @results ||= (get(:results, []) || []).map { |data| SearchResult.new(data) }
16
+ end
17
+
18
+ # @return [String, nil] Combined context string for LLM consumption
19
+ def context
20
+ get(:context)
21
+ end
22
+
23
+ # @return [Array<ContentStatus>] Status information for each URL
24
+ def statuses
25
+ @statuses ||= (get(:statuses, []) || []).map { |data| ContentStatus.new(data) }
26
+ end
27
+
28
+ # @return [CostInfo, nil] Cost information
29
+ def cost
30
+ cost_data = get(:cost_dollars)
31
+ return nil unless cost_data
32
+
33
+ CostInfo.new(cost_data)
34
+ end
35
+
36
+ # @return [Float] Total cost in dollars
37
+ def total_cost
38
+ cost&.total || 0.0
39
+ end
40
+
41
+ # @return [Integer] Number of results
42
+ def count
43
+ results.length
44
+ end
45
+
46
+ # @return [Boolean] Whether results are empty
47
+ def empty?
48
+ results.empty?
49
+ end
50
+
51
+ # Iterates over each result
52
+ # @yield [SearchResult] Each result
53
+ # @return [Enumerator, self]
54
+ def each(&block)
55
+ return results.each unless block
56
+
57
+ results.each(&block)
58
+ self
59
+ end
60
+
61
+ # @param index [Integer] Index of result
62
+ # @return [SearchResult, nil] Result at index
63
+ def [](index)
64
+ results[index]
65
+ end
66
+
67
+ # @return [SearchResult, nil] First result
68
+ def first
69
+ results.first
70
+ end
71
+
72
+ # Checks if all content fetches were successful
73
+ # @return [Boolean]
74
+ def all_success?
75
+ statuses.all?(&:success?)
76
+ end
77
+
78
+ # Returns results that failed to fetch
79
+ # @return [Array<ContentStatus>]
80
+ def failed_statuses
81
+ statuses.reject(&:success?)
82
+ end
83
+
84
+ private
85
+
86
+ def inspectable_attributes
87
+ { request_id: request_id, count: count }
88
+ end
89
+ end
90
+
91
+ class ContentStatus < Base
92
+ # @return [String] URL/ID that was requested
93
+ def id
94
+ get(:id)
95
+ end
96
+
97
+ # @return [String] Status of the fetch (success, error)
98
+ def status
99
+ get(:status)
100
+ end
101
+
102
+ # @return [Boolean] Whether the fetch was successful
103
+ def success?
104
+ status == "success"
105
+ end
106
+
107
+ # @return [Hash, nil] Error information if failed
108
+ def error
109
+ get(:error)
110
+ end
111
+
112
+ # @return [String, nil] Error tag if failed
113
+ def error_tag
114
+ dig(:error, :tag)
115
+ end
116
+
117
+ # @return [Integer, nil] HTTP status code if failed
118
+ def http_status_code
119
+ dig(:error, :http_status_code)
120
+ end
121
+ end
122
+ end
123
+ end