exa-ai 0.1.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 (41) hide show
  1. checksums.yaml +7 -0
  2. data/LICENSE +21 -0
  3. data/README.md +562 -0
  4. data/exe/exa-ai +95 -0
  5. data/exe/exa-ai-answer +131 -0
  6. data/exe/exa-ai-context +104 -0
  7. data/exe/exa-ai-get-contents +114 -0
  8. data/exe/exa-ai-research-get +110 -0
  9. data/exe/exa-ai-research-list +95 -0
  10. data/exe/exa-ai-research-start +175 -0
  11. data/exe/exa-ai-search +134 -0
  12. data/lib/exa/cli/base.rb +51 -0
  13. data/lib/exa/cli/error_handler.rb +98 -0
  14. data/lib/exa/cli/formatters/answer_formatter.rb +63 -0
  15. data/lib/exa/cli/formatters/contents_formatter.rb +50 -0
  16. data/lib/exa/cli/formatters/context_formatter.rb +58 -0
  17. data/lib/exa/cli/formatters/research_formatter.rb +120 -0
  18. data/lib/exa/cli/formatters/search_formatter.rb +44 -0
  19. data/lib/exa/cli/polling.rb +46 -0
  20. data/lib/exa/client.rb +132 -0
  21. data/lib/exa/connection.rb +32 -0
  22. data/lib/exa/error.rb +31 -0
  23. data/lib/exa/middleware/raise_error.rb +55 -0
  24. data/lib/exa/resources/answer.rb +20 -0
  25. data/lib/exa/resources/contents_result.rb +29 -0
  26. data/lib/exa/resources/context_result.rb +37 -0
  27. data/lib/exa/resources/find_similar_result.rb +28 -0
  28. data/lib/exa/resources/research_list.rb +18 -0
  29. data/lib/exa/resources/research_task.rb +39 -0
  30. data/lib/exa/resources/search_result.rb +30 -0
  31. data/lib/exa/services/answer.rb +23 -0
  32. data/lib/exa/services/context.rb +27 -0
  33. data/lib/exa/services/find_similar.rb +26 -0
  34. data/lib/exa/services/get_contents.rb +25 -0
  35. data/lib/exa/services/research_get.rb +30 -0
  36. data/lib/exa/services/research_list.rb +37 -0
  37. data/lib/exa/services/research_start.rb +26 -0
  38. data/lib/exa/services/search.rb +26 -0
  39. data/lib/exa/version.rb +5 -0
  40. data/lib/exa.rb +54 -0
  41. metadata +174 -0
@@ -0,0 +1,120 @@
1
+ module Exa
2
+ module CLI
3
+ module Formatters
4
+ class ResearchFormatter
5
+ def self.format_task(task, format, show_events: false)
6
+ case format
7
+ when "json"
8
+ JSON.pretty_generate(task.to_h)
9
+ when "pretty"
10
+ format_task_pretty(task, show_events: show_events)
11
+ when "text"
12
+ format_task_text(task, show_events: show_events)
13
+ else
14
+ JSON.pretty_generate(task.to_h)
15
+ end
16
+ end
17
+
18
+ def self.format_list(list, format)
19
+ case format
20
+ when "json"
21
+ JSON.pretty_generate(list.to_h)
22
+ when "pretty"
23
+ format_list_pretty(list)
24
+ when "text"
25
+ format_list_text(list)
26
+ else
27
+ JSON.pretty_generate(list.to_h)
28
+ end
29
+ end
30
+
31
+ private
32
+
33
+ def self.format_task_pretty(task, show_events: false)
34
+ output = []
35
+ output << "Research Task: #{task.research_id}"
36
+ output << "Status: #{task.status.upcase}"
37
+ output << "Created: #{task.created_at}"
38
+ output << ""
39
+
40
+ case task.status
41
+ when "pending"
42
+ output << "Task is pending execution..."
43
+ when "running"
44
+ output << "Task is running... ⚙️"
45
+ when "completed"
46
+ output << "Output:"
47
+ output << "--------"
48
+ output << task.output.to_s
49
+ output << ""
50
+ output << "Cost: $#{task.cost_dollars}" if task.cost_dollars
51
+ when "failed"
52
+ output << "Error: #{task.error}"
53
+ when "canceled"
54
+ output << "Task was canceled"
55
+ output << "Finished: #{task.finished_at}" if task.finished_at
56
+ end
57
+
58
+ if show_events && task.events && !task.events.empty?
59
+ output << ""
60
+ output << "Events:"
61
+ output << "-------"
62
+ task.events.each do |event|
63
+ output << "- #{event}"
64
+ end
65
+ end
66
+
67
+ output.join("\n")
68
+ end
69
+
70
+ def self.format_list_pretty(list)
71
+ output = []
72
+ output << "Research Tasks (#{list.data.length}):"
73
+ output << ""
74
+
75
+ if list.data.empty?
76
+ output << "No tasks found."
77
+ else
78
+ # Simple table format
79
+ output << "%-40s %-15s %s" % ["Task ID", "Status", "Created"]
80
+ output << "-" * 70
81
+
82
+ list.data.each do |task|
83
+ task_id = task.research_id.to_s[0..38]
84
+ status = task.status.upcase[0..14]
85
+ created = task.created_at.to_s[0..19]
86
+ output << "%-40s %-15s %s" % [task_id, status, created]
87
+ end
88
+ end
89
+
90
+ output << ""
91
+ if list.has_more
92
+ output << "More results available. Use --cursor #{list.next_cursor} for next page."
93
+ else
94
+ output << "End of results."
95
+ end
96
+
97
+ output.join("\n")
98
+ end
99
+
100
+ def self.format_task_text(task, show_events: false)
101
+ output = []
102
+ output << "#{task.research_id} #{task.status.upcase} #{task.created_at}"
103
+ if task.status == "completed"
104
+ output << task.output.to_s
105
+ elsif task.status == "failed"
106
+ output << "Error: #{task.error}"
107
+ end
108
+ output.join("\n")
109
+ end
110
+
111
+ def self.format_list_text(list)
112
+ output = list.data.map do |task|
113
+ "#{task.research_id} #{task.status.upcase} #{task.created_at}"
114
+ end
115
+ output.join("\n")
116
+ end
117
+ end
118
+ end
119
+ end
120
+ end
@@ -0,0 +1,44 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Exa
4
+ module CLI
5
+ module Formatters
6
+ class SearchFormatter
7
+ def self.format(result, format)
8
+ case format
9
+ when "json"
10
+ JSON.pretty_generate(result.to_h)
11
+ when "pretty"
12
+ format_pretty(result)
13
+ when "text"
14
+ format_text(result)
15
+ else
16
+ JSON.pretty_generate(result.to_h)
17
+ end
18
+ end
19
+
20
+ private
21
+
22
+ def self.format_pretty(result)
23
+ output = []
24
+ result.results.each_with_index do |item, idx|
25
+ output << "--- Result #{idx + 1} ---"
26
+ output << "Title: #{item['title']}"
27
+ output << "URL: #{item['url']}"
28
+ output << "Score: #{item['score']}" if item['score']
29
+ output << ""
30
+ end
31
+ output.join("\n")
32
+ end
33
+
34
+ def self.format_text(result)
35
+ output = []
36
+ result.results.each do |item|
37
+ output << "#{item['title']}\n#{item['url']}"
38
+ end
39
+ output.join("\n\n")
40
+ end
41
+ end
42
+ end
43
+ end
44
+ end
@@ -0,0 +1,46 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Exa
4
+ module CLI
5
+ class Polling
6
+ class TimeoutError < StandardError; end
7
+
8
+ # Poll a block until it returns done: true
9
+ # Implements exponential backoff with jitter
10
+ #
11
+ # Options:
12
+ # max_duration: Maximum time to poll (default: 300 seconds / 5 minutes)
13
+ # initial_delay: Initial delay in seconds (default: 1)
14
+ # max_delay: Maximum delay between polls (default: 30)
15
+ #
16
+ # Block should return a hash:
17
+ # { done: boolean, result: any, status: string }
18
+ #
19
+ # Returns the result value when done is true
20
+ def self.poll(max_duration: 300, initial_delay: 1, max_delay: 30)
21
+ start_time = Time.now
22
+ current_delay = initial_delay
23
+ attempt = 0
24
+
25
+ loop do
26
+ response = yield
27
+ return response[:result] if response[:done]
28
+
29
+ elapsed_time = Time.now - start_time
30
+ if elapsed_time > max_duration
31
+ raise TimeoutError,
32
+ "Polling timed out after #{elapsed_time.round(2)} seconds"
33
+ end
34
+
35
+ # Sleep before next attempt
36
+ sleep [current_delay, max_delay].min
37
+
38
+ # Exponential backoff: multiply by 2 for next iteration
39
+ current_delay *= 2
40
+
41
+ attempt += 1
42
+ end
43
+ end
44
+ end
45
+ end
46
+ end
data/lib/exa/client.rb ADDED
@@ -0,0 +1,132 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Exa
4
+ # Main client for interacting with the Exa.ai API
5
+ #
6
+ # Provides methods for all supported Exa.ai operations including search,
7
+ # content retrieval, answer generation, and async research tasks.
8
+ #
9
+ # @example Basic usage
10
+ # client = Exa::Client.new(api_key: "your-key")
11
+ # results = client.search("ruby on rails")
12
+ # results.results.each { |r| puts r.title }
13
+ #
14
+ # @example Using configuration
15
+ # Exa.configure { |config| config.api_key = "your-key" }
16
+ # client = Exa::Client.new
17
+ class Client
18
+ # Initialize a new Exa client
19
+ #
20
+ # @param api_key [String, nil] API key for authentication. Falls back to Exa.api_key if not provided
21
+ # @param options [Hash] Connection options
22
+ # @option options [String] :base_url Custom API base URL (default: https://api.exa.ai)
23
+ # @option options [Integer] :timeout Request timeout in seconds (default: 30)
24
+ # @raise [ConfigurationError] If API key is not provided and not configured
25
+ def initialize(api_key: nil, **options)
26
+ @api_key = api_key || Exa.api_key
27
+ @options = options
28
+
29
+ validate_api_key!
30
+ end
31
+
32
+ # Execute a search query
33
+ #
34
+ # @param query [String] Search query
35
+ # @param params [Hash] Additional search parameters
36
+ # @option params [String] :type Search type (web, news, etc.)
37
+ # @option params [Array<String>] :include_domains Domains to include in results
38
+ # @option params [Array<String>] :exclude_domains Domains to exclude from results
39
+ # @option params [Integer] :num_results Number of results to return
40
+ # @return [Resources::SearchResult] Search results with metadata
41
+ def search(query, **params)
42
+ Services::Search.new(connection, query: query, **params).call
43
+ end
44
+
45
+ # Find similar content to a given URL
46
+ #
47
+ # @param url [String] URL to find similar content for
48
+ # @param options [Hash] Search options
49
+ # @option options [Integer] :num_results Number of results to return
50
+ # @return [Resources::SearchResult] Similar results
51
+ def find_similar(url, **options)
52
+ Services::FindSimilar.new(connection, url: url, **options).call
53
+ end
54
+
55
+ # Get full page contents for URLs
56
+ #
57
+ # @param urls [Array<String>, String] URL or URLs to fetch contents for
58
+ # @param options [Hash] Fetch options
59
+ # @return [Resources::ContentCollection] Collection of page contents
60
+ def get_contents(urls, **options)
61
+ Services::GetContents.new(connection, urls: urls, **options).call
62
+ end
63
+
64
+ # Get AI-generated answers to a query
65
+ #
66
+ # @param query [String] Question or query
67
+ # @param options [Hash] Answer options
68
+ # @option options [String] :format Response format (default: standard)
69
+ # @return [Resources::Answer] AI-generated answer with sources
70
+ def answer(query, **options)
71
+ Services::Answer.new(connection, query: query, **options).call
72
+ end
73
+
74
+ # Start an asynchronous research task
75
+ #
76
+ # @param params [Hash] Research parameters
77
+ # @return [Resources::Research] Research task with ID
78
+ def research_start(**params)
79
+ Services::ResearchStart.new(connection, **params).call
80
+ end
81
+
82
+ # List all research tasks
83
+ #
84
+ # @param params [Hash] Listing parameters
85
+ # @option params [Integer] :limit Maximum number of tasks to return
86
+ # @return [Resources::ResearchList] List of research tasks
87
+ def research_list(**params)
88
+ Services::ResearchList.new(connection, **params).call
89
+ end
90
+
91
+ # Get status and results of a research task
92
+ #
93
+ # @param research_id [String] Research task ID
94
+ # @param params [Hash] Fetch options
95
+ # @return [Resources::Research] Research task with current status and results
96
+ def research_get(research_id, **params)
97
+ Services::ResearchGet.new(connection, research_id: research_id, **params).call
98
+ end
99
+
100
+ # Search code repositories
101
+ #
102
+ # @param query [String] Code search query
103
+ # @param params [Hash] Search parameters
104
+ # @option params [Array<String>] :languages Programming languages to search
105
+ # @return [Resources::SearchResult] Code search results
106
+ def context(query, **params)
107
+ Services::Context.new(connection, query: query, **params).call
108
+ end
109
+
110
+ private
111
+
112
+ def connection
113
+ @connection ||= Connection.build(
114
+ api_key: @api_key,
115
+ **connection_options
116
+ )
117
+ end
118
+
119
+ def connection_options
120
+ options = {}
121
+ options[:base_url] = @options[:base_url] if @options[:base_url]
122
+ options[:timeout] = @options[:timeout] if @options[:timeout]
123
+ options
124
+ end
125
+
126
+ def validate_api_key!
127
+ return if @api_key && !@api_key.empty?
128
+
129
+ raise ConfigurationError, "API key is required. Set it with Exa.configure or pass it to Client.new"
130
+ end
131
+ end
132
+ end
@@ -0,0 +1,32 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "faraday"
4
+
5
+ module Exa
6
+ class Connection
7
+ def self.build(api_key:, **options, &block)
8
+ Faraday.new(url: options[:base_url] || DEFAULT_BASE_URL) do |conn|
9
+ # Authentication
10
+ conn.request :authorization, "Bearer", api_key
11
+
12
+ # Request/Response JSON encoding
13
+ conn.request :json
14
+
15
+ # Custom error handling (registered before JSON so it runs after in response chain)
16
+ conn.response :raise_error
17
+ conn.response :json, content_type: /\bjson$/
18
+
19
+ # Timeouts
20
+ conn.options.timeout = options[:timeout] || 30
21
+ conn.options.open_timeout = options[:open_timeout] || 10
22
+
23
+ # Adapter (allow override for testing)
24
+ if block_given?
25
+ conn.adapter options[:adapter] || Faraday.default_adapter, &block
26
+ else
27
+ conn.adapter options[:adapter] || Faraday.default_adapter
28
+ end
29
+ end
30
+ end
31
+ end
32
+ end
data/lib/exa/error.rb ADDED
@@ -0,0 +1,31 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Exa
4
+ class Error < StandardError
5
+ attr_reader :response
6
+
7
+ def initialize(message = nil, response = nil)
8
+ @response = response
9
+ super(message)
10
+ end
11
+ end
12
+
13
+ # Client errors (4xx)
14
+ class ClientError < Error; end
15
+ class BadRequest < ClientError; end # 400
16
+ class Unauthorized < ClientError; end # 401
17
+ class Forbidden < ClientError; end # 403
18
+ class NotFound < ClientError; end # 404
19
+ class UnprocessableEntity < ClientError; end # 422
20
+ class TooManyRequests < ClientError; end # 429
21
+
22
+ # Server errors (5xx)
23
+ class ServerError < Error; end
24
+ class InternalServerError < ServerError; end # 500
25
+ class BadGateway < ServerError; end # 502
26
+ class ServiceUnavailable < ServerError; end # 503
27
+ class GatewayTimeout < ServerError; end # 504
28
+
29
+ # Configuration errors
30
+ class ConfigurationError < Error; end
31
+ end
@@ -0,0 +1,55 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "faraday"
4
+
5
+ module Exa
6
+ module Middleware
7
+ class RaiseError < Faraday::Middleware
8
+ def on_complete(env)
9
+ case env[:status]
10
+ when 400
11
+ handle_error(env, Exa::BadRequest)
12
+ when 401
13
+ handle_error(env, Exa::Unauthorized)
14
+ when 403
15
+ handle_error(env, Exa::Forbidden)
16
+ when 404
17
+ handle_error(env, Exa::NotFound)
18
+ when 422
19
+ handle_error(env, Exa::UnprocessableEntity)
20
+ when 429
21
+ handle_error(env, Exa::TooManyRequests)
22
+ when 500
23
+ handle_error(env, Exa::InternalServerError)
24
+ when 502
25
+ handle_error(env, Exa::BadGateway)
26
+ when 503
27
+ handle_error(env, Exa::ServiceUnavailable)
28
+ when 504
29
+ handle_error(env, Exa::GatewayTimeout)
30
+ end
31
+ end
32
+
33
+ private
34
+
35
+ def handle_error(env, error_class)
36
+ message = extract_error_message(env)
37
+ raise error_class.new(message, env.response)
38
+ end
39
+
40
+ def extract_error_message(env)
41
+ body = env[:body]
42
+
43
+ if body.is_a?(Hash)
44
+ return body["error"] if body["error"]
45
+ return body[:error] if body[:error]
46
+ end
47
+
48
+ "HTTP #{env[:status]}"
49
+ end
50
+ end
51
+ end
52
+ end
53
+
54
+ # Register the middleware with Faraday
55
+ Faraday::Response.register_middleware raise_error: Exa::Middleware::RaiseError
@@ -0,0 +1,20 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Exa
4
+ module Resources
5
+ # Represents an answer response from the Exa API
6
+ #
7
+ # This class wraps the JSON response from the /answer endpoint and provides
8
+ # a Ruby-friendly interface for accessing the generated answer and citations.
9
+ class Answer < Struct.new(:answer, :citations, :cost_dollars, keyword_init: true)
10
+ def initialize(answer:, citations: [], cost_dollars: nil)
11
+ super
12
+ freeze
13
+ end
14
+
15
+ def to_h
16
+ { answer: answer, citations: citations, cost_dollars: cost_dollars }
17
+ end
18
+ end
19
+ end
20
+ end
@@ -0,0 +1,29 @@
1
+ module Exa
2
+ module Resources
3
+ # Represents a contents response from the Exa API
4
+ #
5
+ # This class wraps the JSON response from the /contents endpoint and provides
6
+ # a Ruby-friendly interface for accessing content results and metadata.
7
+ class ContentsResult < Struct.new(
8
+ :results,
9
+ :request_id,
10
+ :context,
11
+ :statuses,
12
+ :cost_dollars,
13
+ keyword_init: true
14
+ )
15
+ def initialize(results:, request_id: nil, context: nil, statuses: nil, cost_dollars: nil)
16
+ super
17
+ freeze
18
+ end
19
+
20
+ def empty?
21
+ results.empty?
22
+ end
23
+
24
+ def first
25
+ results.first
26
+ end
27
+ end
28
+ end
29
+ end
@@ -0,0 +1,37 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Exa
4
+ module Resources
5
+ # Represents a Context API response from the Exa API
6
+ #
7
+ # This class wraps the JSON response from the /context endpoint and provides
8
+ # a Ruby-friendly interface for accessing code snippets and metadata.
9
+ class ContextResult < Struct.new(
10
+ :request_id,
11
+ :query,
12
+ :response,
13
+ :results_count,
14
+ :cost_dollars,
15
+ :search_time,
16
+ :output_tokens,
17
+ keyword_init: true
18
+ )
19
+ def initialize(**)
20
+ super
21
+ freeze
22
+ end
23
+
24
+ def to_h
25
+ {
26
+ request_id: request_id,
27
+ query: query,
28
+ response: response,
29
+ results_count: results_count,
30
+ cost_dollars: cost_dollars,
31
+ search_time: search_time,
32
+ output_tokens: output_tokens
33
+ }
34
+ end
35
+ end
36
+ end
37
+ end
@@ -0,0 +1,28 @@
1
+ module Exa
2
+ module Resources
3
+ # Represents a find similar response from the Exa API
4
+ #
5
+ # This class wraps the JSON response from the /findSimilar endpoint and provides
6
+ # a Ruby-friendly interface for accessing similar results and metadata.
7
+ class FindSimilarResult < Struct.new(
8
+ :results,
9
+ :request_id,
10
+ :context,
11
+ :cost_dollars,
12
+ keyword_init: true
13
+ )
14
+ def initialize(results:, request_id: nil, context: nil, cost_dollars: nil)
15
+ super
16
+ freeze
17
+ end
18
+
19
+ def empty?
20
+ results.empty?
21
+ end
22
+
23
+ def first
24
+ results.first
25
+ end
26
+ end
27
+ end
28
+ end
@@ -0,0 +1,18 @@
1
+ module Exa
2
+ module Resources
3
+ class ResearchList < Struct.new(:data, :has_more, :next_cursor, keyword_init: true)
4
+ def initialize(data:, has_more:, next_cursor: nil)
5
+ super
6
+ freeze
7
+ end
8
+
9
+ def to_h
10
+ {
11
+ data: data.map { |item| item.respond_to?(:to_h) ? item.to_h : item },
12
+ has_more: has_more,
13
+ next_cursor: next_cursor
14
+ }
15
+ end
16
+ end
17
+ end
18
+ end
@@ -0,0 +1,39 @@
1
+ module Exa
2
+ module Resources
3
+ class ResearchTask < Struct.new(
4
+ :research_id, :created_at, :status, :model, :instructions,
5
+ :output_schema, :events, :output, :cost_dollars, :finished_at,
6
+ :error, keyword_init: true
7
+ )
8
+ def initialize(research_id:, created_at:, status:, instructions:, model: nil, output_schema: nil, events: nil, output: nil, cost_dollars: nil, finished_at: nil, error: nil)
9
+ super
10
+ freeze
11
+ end
12
+
13
+ def pending? = status == 'pending'
14
+ def running? = status == 'running'
15
+ def completed? = status == 'completed'
16
+ def failed? = status == 'failed'
17
+ def canceled? = status == 'canceled'
18
+
19
+ def finished? = !running? && !pending?
20
+
21
+ def to_h
22
+ result = {
23
+ research_id: research_id,
24
+ created_at: created_at,
25
+ status: status,
26
+ instructions: instructions
27
+ }
28
+ result[:model] = model if model
29
+ result[:output_schema] = output_schema if output_schema
30
+ result[:events] = events if events
31
+ result[:output] = output if output
32
+ result[:cost_dollars] = cost_dollars if cost_dollars
33
+ result[:finished_at] = finished_at if finished_at
34
+ result[:error] = error if error
35
+ result
36
+ end
37
+ end
38
+ end
39
+ end
@@ -0,0 +1,30 @@
1
+ module Exa
2
+ module Resources
3
+ # Represents a search response from the Exa API
4
+ #
5
+ # This class wraps the JSON response from the /search endpoint and provides
6
+ # a Ruby-friendly interface for accessing search results and metadata.
7
+ class SearchResult < Struct.new(
8
+ :results,
9
+ :request_id,
10
+ :resolved_search_type,
11
+ :search_type,
12
+ :context,
13
+ :cost_dollars,
14
+ keyword_init: true
15
+ )
16
+ def initialize(results:, request_id: nil, resolved_search_type: nil, search_type: nil, context: nil, cost_dollars: nil)
17
+ super
18
+ freeze
19
+ end
20
+
21
+ def empty?
22
+ results.empty?
23
+ end
24
+
25
+ def first
26
+ results.first
27
+ end
28
+ end
29
+ end
30
+ end
@@ -0,0 +1,23 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Exa
4
+ module Services
5
+ class Answer
6
+ def initialize(connection, **params)
7
+ @connection = connection
8
+ @params = params
9
+ end
10
+
11
+ def call
12
+ response = @connection.post("/answer", @params)
13
+ body = response.body
14
+
15
+ Resources::Answer.new(
16
+ answer: body["answer"],
17
+ citations: body["citations"] || [],
18
+ cost_dollars: body["costDollars"]
19
+ )
20
+ end
21
+ end
22
+ end
23
+ end