exaonruby 1.0.0 → 1.2.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.
@@ -0,0 +1,135 @@
1
+ # frozen_string_literal: true
2
+
3
+ # typed: strict
4
+
5
+ require "concurrent"
6
+
7
+ module Exa
8
+ module Utils
9
+ # Parallel request executor for batch operations
10
+ #
11
+ # Execute multiple Exa API calls concurrently with configurable
12
+ # concurrency limits and automatic error handling.
13
+ #
14
+ # @example Parallel searches
15
+ # queries = ["AI research", "ML trends", "NLP papers"]
16
+ #
17
+ # results = Exa::Utils::Parallel.map(queries, client: client) do |query|
18
+ # client.search(query, num_results: 10)
19
+ # end
20
+ #
21
+ # @example With concurrency limit
22
+ # results = Exa::Utils::Parallel.map(urls, concurrency: 5) do |url|
23
+ # client.get_contents([url])
24
+ # end
25
+ #
26
+ # @example Error handling
27
+ # results = Exa::Utils::Parallel.map(queries, on_error: :skip) do |query|
28
+ # client.search(query)
29
+ # end
30
+ class Parallel
31
+ # Default concurrency limit
32
+ DEFAULT_CONCURRENCY = 10
33
+
34
+ class << self
35
+ # Execute block for each item in parallel
36
+ #
37
+ # @param items [Array] Items to process
38
+ # @param concurrency [Integer] Max concurrent requests
39
+ # @param on_error [Symbol] Error handling: :raise, :skip, :return_nil
40
+ # @param timeout [Integer, nil] Timeout per request in seconds
41
+ #
42
+ # @yield [item] Block to execute for each item
43
+ # @return [Array] Results in same order as input
44
+ #
45
+ # @example
46
+ # Exa::Utils::Parallel.map(queries) { |q| client.search(q) }
47
+ def map(items, concurrency: DEFAULT_CONCURRENCY, on_error: :raise, timeout: nil)
48
+ return [] if items.empty?
49
+
50
+ pool = Concurrent::FixedThreadPool.new(concurrency)
51
+ futures = []
52
+
53
+ items.each_with_index do |item, idx|
54
+ future = Concurrent::Future.execute(executor: pool) do
55
+ if timeout
56
+ Concurrent::Promises.future { yield(item) }.value!(timeout)
57
+ else
58
+ yield(item)
59
+ end
60
+ end
61
+ futures << { index: idx, future: future }
62
+ end
63
+
64
+ # Collect results in order
65
+ results = Array.new(items.length)
66
+
67
+ futures.each do |entry|
68
+ begin
69
+ results[entry[:index]] = entry[:future].value!
70
+ rescue StandardError => e
71
+ case on_error
72
+ when :raise
73
+ pool.shutdown
74
+ raise
75
+ when :skip
76
+ next
77
+ when :return_nil
78
+ results[entry[:index]] = nil
79
+ else
80
+ raise
81
+ end
82
+ end
83
+ end
84
+
85
+ pool.shutdown
86
+ pool.wait_for_termination
87
+
88
+ results
89
+ end
90
+
91
+ # Execute block for each item in parallel, returning only successful results
92
+ #
93
+ # @param items [Array] Items to process
94
+ # @param concurrency [Integer] Max concurrent requests
95
+ #
96
+ # @yield [item] Block to execute for each item
97
+ # @return [Array] Successful results only
98
+ def map_compact(items, concurrency: DEFAULT_CONCURRENCY, &block)
99
+ map(items, concurrency: concurrency, on_error: :return_nil, &block).compact
100
+ end
101
+
102
+ # Execute block for each item in parallel, ignoring return values
103
+ #
104
+ # @param items [Array] Items to process
105
+ # @param concurrency [Integer] Max concurrent requests
106
+ #
107
+ # @yield [item] Block to execute for each item
108
+ # @return [void]
109
+ def each(items, concurrency: DEFAULT_CONCURRENCY, &block)
110
+ map(items, concurrency: concurrency, on_error: :skip, &block)
111
+ nil
112
+ end
113
+
114
+ # Execute multiple different operations in parallel
115
+ #
116
+ # @param operations [Array<Proc>] Procs to execute
117
+ # @param concurrency [Integer] Max concurrent requests
118
+ #
119
+ # @return [Array] Results from each proc
120
+ #
121
+ # @example
122
+ # results = Exa::Utils::Parallel.all(
123
+ # -> { client.search("AI") },
124
+ # -> { client.search("ML") },
125
+ # -> { client.get_contents([url]) }
126
+ # )
127
+ def all(*operations, concurrency: DEFAULT_CONCURRENCY)
128
+ operations = operations.first if operations.first.is_a?(Array)
129
+
130
+ map(operations, concurrency: concurrency) { |op| op.call }
131
+ end
132
+ end
133
+ end
134
+ end
135
+ end
@@ -0,0 +1,279 @@
1
+ # frozen_string_literal: true
2
+
3
+ # typed: strict
4
+
5
+ require "json"
6
+ require "net/http"
7
+ require "uri"
8
+
9
+ module Exa
10
+ module Utils
11
+ # Server-Sent Events (SSE) streaming client for Exa.ai API
12
+ #
13
+ # Provides real-time streaming for Answer and Research endpoints that support
14
+ # the stream=true parameter. Tokens are yielded as they're generated.
15
+ #
16
+ # @example Stream an answer
17
+ # Exa::Utils::SSEClient.stream_answer(
18
+ # api_key: ENV["EXA_API_KEY"],
19
+ # query: "What is quantum computing?"
20
+ # ) do |event|
21
+ # case event[:type]
22
+ # when :token
23
+ # print event[:data] # Print each token as it arrives
24
+ # when :citation
25
+ # puts "\nSource: #{event[:data][:url]}"
26
+ # when :done
27
+ # puts "\n\nComplete!"
28
+ # when :error
29
+ # puts "Error: #{event[:data]}"
30
+ # end
31
+ # end
32
+ #
33
+ # @example Stream research progress
34
+ # Exa::Utils::SSEClient.stream_research(
35
+ # api_key: ENV["EXA_API_KEY"],
36
+ # instructions: "Research latest AI developments"
37
+ # ) do |event|
38
+ # case event[:type]
39
+ # when :progress
40
+ # puts "Progress: #{event[:data][:percent]}%"
41
+ # when :output
42
+ # puts event[:data]
43
+ # end
44
+ # end
45
+ class SSEClient
46
+ DEFAULT_BASE_URL = "https://api.exa.ai"
47
+
48
+ # Event types that can be yielded
49
+ EVENT_TYPES = %i[token citation progress output done error ping].freeze
50
+
51
+ class << self
52
+ # Stream an answer with real-time token output
53
+ #
54
+ # @param api_key [String] Exa API key
55
+ # @param query [String] Question to answer
56
+ # @param base_url [String] API base URL
57
+ # @param options [Hash] Additional options (text, num_results, etc.)
58
+ #
59
+ # @yield [Hash] Event hash with :type and :data keys
60
+ # @yieldparam event [Hash] Event data
61
+ # @yieldparam event[:type] [Symbol] One of :token, :citation, :done, :error
62
+ # @yieldparam event[:data] [String, Hash] Event payload
63
+ #
64
+ # @return [void]
65
+ def stream_answer(api_key:, query:, base_url: DEFAULT_BASE_URL, **options, &block)
66
+ raise ArgumentError, "Block required for streaming" unless block_given?
67
+ raise InvalidRequestError, "query is required" if query.nil? || query.empty?
68
+
69
+ body = { query: query, stream: true }
70
+ body.merge!(options)
71
+
72
+ stream_request(
73
+ api_key: api_key,
74
+ url: "#{base_url}/answer",
75
+ body: body,
76
+ &block
77
+ )
78
+ end
79
+
80
+ # Stream research task output in real-time
81
+ #
82
+ # @param api_key [String] Exa API key
83
+ # @param instructions [String] Research instructions
84
+ # @param model [String] Model to use
85
+ # @param base_url [String] API base URL
86
+ # @param options [Hash] Additional options
87
+ #
88
+ # @yield [Hash] Event hash with :type and :data keys
89
+ # @yieldparam event [Hash] Event data
90
+ #
91
+ # @return [void]
92
+ def stream_research(api_key:, instructions:, model: "exa-research", base_url: DEFAULT_BASE_URL, **options, &block)
93
+ raise ArgumentError, "Block required for streaming" unless block_given?
94
+ raise InvalidRequestError, "instructions required" if instructions.nil? || instructions.empty?
95
+
96
+ body = {
97
+ instructions: instructions,
98
+ model: model,
99
+ stream: true
100
+ }
101
+ body.merge!(options)
102
+
103
+ stream_request(
104
+ api_key: api_key,
105
+ url: "#{base_url}/research/v1",
106
+ body: body,
107
+ &block
108
+ )
109
+ end
110
+
111
+ private
112
+
113
+ # Perform SSE streaming request
114
+ #
115
+ # @param api_key [String] API key
116
+ # @param url [String] Full endpoint URL
117
+ # @param body [Hash] Request body
118
+ #
119
+ # @yield [Hash] Parsed SSE events
120
+ def stream_request(api_key:, url:, body:)
121
+ uri = URI.parse(url)
122
+
123
+ http = Net::HTTP.new(uri.host, uri.port)
124
+ http.use_ssl = uri.scheme == "https"
125
+ http.read_timeout = 300 # 5 minutes for long streams
126
+ http.open_timeout = 30
127
+
128
+ request = Net::HTTP::Post.new(uri.request_uri)
129
+ request["Content-Type"] = "application/json"
130
+ request["Accept"] = "text/event-stream"
131
+ request["Cache-Control"] = "no-cache"
132
+ request["x-api-key"] = api_key
133
+ request.body = JSON.generate(body)
134
+
135
+ buffer = String.new
136
+
137
+ http.request(request) do |response|
138
+ unless response.is_a?(Net::HTTPSuccess)
139
+ yield({ type: :error, data: "HTTP #{response.code}: #{response.message}" })
140
+ return
141
+ end
142
+
143
+ response.read_body do |chunk|
144
+ buffer << chunk
145
+ events = parse_sse_buffer(buffer)
146
+
147
+ events.each do |event|
148
+ yield(event)
149
+ end
150
+ end
151
+ end
152
+
153
+ yield({ type: :done, data: nil })
154
+ rescue Net::OpenTimeout, Net::ReadTimeout => e
155
+ yield({ type: :error, data: "Timeout: #{e.message}" })
156
+ rescue IOError, Errno::ECONNRESET => e
157
+ yield({ type: :error, data: "Connection error: #{e.message}" })
158
+ rescue StandardError => e
159
+ yield({ type: :error, data: "Error: #{e.message}" })
160
+ end
161
+
162
+ # Parse SSE buffer and extract complete events
163
+ #
164
+ # @param buffer [String] Buffer to parse (modified in place)
165
+ # @return [Array<Hash>] Parsed events
166
+ def parse_sse_buffer(buffer)
167
+ events = []
168
+ event_data = {}
169
+
170
+ # Split on double newlines (event boundaries)
171
+ while (idx = buffer.index("\n\n"))
172
+ raw_event = buffer.slice!(0, idx + 2)
173
+
174
+ raw_event.each_line do |line|
175
+ line = line.strip
176
+ next if line.empty?
177
+
178
+ if line.start_with?("event:")
179
+ event_data[:event] = line[6..].strip
180
+ elsif line.start_with?("data:")
181
+ data_content = line[5..].strip
182
+ event_data[:data] = data_content
183
+ elsif line.start_with?("id:")
184
+ event_data[:id] = line[3..].strip
185
+ elsif line.start_with?("retry:")
186
+ event_data[:retry] = line[6..].strip.to_i
187
+ end
188
+ end
189
+
190
+ if event_data.any?
191
+ parsed = parse_event(event_data)
192
+ events << parsed if parsed
193
+ event_data = {}
194
+ end
195
+ end
196
+
197
+ events
198
+ end
199
+
200
+ # Parse a single SSE event into our format
201
+ #
202
+ # @param event_data [Hash] Raw event data
203
+ # @return [Hash, nil] Parsed event or nil
204
+ def parse_event(event_data)
205
+ event_type = event_data[:event]&.to_sym || :message
206
+ raw_data = event_data[:data]
207
+
208
+ return nil unless raw_data
209
+
210
+ # Try to parse as JSON
211
+ data = begin
212
+ JSON.parse(raw_data, symbolize_names: true)
213
+ rescue JSON::ParserError
214
+ raw_data
215
+ end
216
+
217
+ case event_type
218
+ when :message, :token, :delta
219
+ # Token/delta events contain partial answer text
220
+ if data.is_a?(Hash)
221
+ text = data[:delta] || data[:content] || data[:text] || data[:token]
222
+ { type: :token, data: text } if text
223
+ else
224
+ { type: :token, data: data }
225
+ end
226
+ when :citation, :source
227
+ { type: :citation, data: data }
228
+ when :progress
229
+ { type: :progress, data: data }
230
+ when :output, :result
231
+ { type: :output, data: data }
232
+ when :done, :complete, :end
233
+ { type: :done, data: data }
234
+ when :error
235
+ { type: :error, data: data }
236
+ when :ping, :heartbeat
237
+ { type: :ping, data: nil }
238
+ else
239
+ # Return raw data for unknown event types
240
+ { type: event_type, data: data }
241
+ end
242
+ end
243
+ end
244
+
245
+ # Instance-based streaming for more control
246
+ #
247
+ # @param api_key [String] Exa API key
248
+ # @param base_url [String] API base URL
249
+ def initialize(api_key:, base_url: DEFAULT_BASE_URL)
250
+ @api_key = api_key
251
+ @base_url = base_url
252
+ end
253
+
254
+ # Stream an answer
255
+ # @see SSEClient.stream_answer
256
+ def answer(query, **options, &block)
257
+ self.class.stream_answer(
258
+ api_key: @api_key,
259
+ query: query,
260
+ base_url: @base_url,
261
+ **options,
262
+ &block
263
+ )
264
+ end
265
+
266
+ # Stream research
267
+ # @see SSEClient.stream_research
268
+ def research(instructions, **options, &block)
269
+ self.class.stream_research(
270
+ api_key: @api_key,
271
+ instructions: instructions,
272
+ base_url: @base_url,
273
+ **options,
274
+ &block
275
+ )
276
+ end
277
+ end
278
+ end
279
+ end
data/lib/exa/version.rb CHANGED
@@ -3,5 +3,5 @@
3
3
  # typed: strict
4
4
 
5
5
  module Exa
6
- VERSION = "1.0.0"
6
+ VERSION = "1.2.0"
7
7
  end
data/lib/exa.rb CHANGED
@@ -8,6 +8,31 @@ require_relative "exa/configuration"
8
8
 
9
9
  require_relative "exa/utils/parameter_converter"
10
10
  require_relative "exa/utils/webhook_handler"
11
+ require_relative "exa/utils/sse_client"
12
+ require_relative "exa/utils/parallel"
13
+
14
+ # Middleware (loaded after Faraday is available in Client)
15
+ require_relative "exa/middleware/request_logger"
16
+ require_relative "exa/middleware/rate_limiter"
17
+ require_relative "exa/middleware/response_cache"
18
+ require_relative "exa/middleware/instrumentation"
19
+
20
+ # Optional: Sorbet types (only loaded if sorbet-runtime is available)
21
+ begin
22
+ require "sorbet-runtime"
23
+ require_relative "exa/types"
24
+ rescue LoadError
25
+ # sorbet-runtime not installed, types module not available
26
+ end
27
+
28
+ # Optional: Rails integration (only loaded if Rails is available)
29
+ begin
30
+ if defined?(::Rails::Railtie)
31
+ require_relative "exa/rails"
32
+ end
33
+ rescue LoadError
34
+ # Rails not available
35
+ end
11
36
 
12
37
  require_relative "exa/resources/base"
13
38
  require_relative "exa/resources/search_result"
@@ -0,0 +1,94 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "rails/generators/base"
4
+
5
+ module Exa
6
+ module Generators
7
+ # Rails generator for Exa gem installation
8
+ #
9
+ # Usage:
10
+ # rails generate exa:install
11
+ #
12
+ # This creates:
13
+ # - config/initializers/exa.rb
14
+ # - Adds EXA_API_KEY to .env if using dotenv
15
+ class InstallGenerator < ::Rails::Generators::Base
16
+ source_root File.expand_path("templates", __dir__)
17
+
18
+ desc "Creates an Exa initializer and credentials setup"
19
+
20
+ def create_initializer
21
+ create_file "config/initializers/exa.rb", <<~RUBY
22
+ # frozen_string_literal: true
23
+
24
+ # Exa API Configuration
25
+ # Documentation: https://docs.exa.ai
26
+ # Gem: https://github.com/tigel-agm/exaonruby
27
+
28
+ Exa.configure do |config|
29
+ # API Key (from Rails credentials or environment)
30
+ # Run: rails credentials:edit
31
+ # Add: exa: { api_key: "your-key" }
32
+ config.api_key = Rails.application.credentials.dig(:exa, :api_key) || ENV["EXA_API_KEY"]
33
+
34
+ # Request timeout in seconds (default: 60)
35
+ config.timeout = 60
36
+
37
+ # Maximum retry attempts for failed requests
38
+ config.max_retries = 3
39
+
40
+ # Optional: Enable request logging in development
41
+ if Rails.env.development?
42
+ config.logger = Rails.logger
43
+ end
44
+
45
+ # Optional: Enable caching to reduce API costs
46
+ # config.cache = Exa::Cache::MemoryStore.new
47
+ # config.cache_ttl = 300 # 5 minutes
48
+
49
+ # Optional: Rate limiting (requests per second)
50
+ # config.rate_limit = 10
51
+ # config.rate_limit_burst = 20
52
+ end
53
+ RUBY
54
+ say "Created config/initializers/exa.rb", :green
55
+ end
56
+
57
+ def add_to_env
58
+ if File.exist?(".env")
59
+ append_to_file ".env" do
60
+ "\n# Exa API Key\nEXA_API_KEY=your-api-key-here\n"
61
+ end
62
+ say "Added EXA_API_KEY to .env", :green
63
+ elsif File.exist?(".env.example")
64
+ append_to_file ".env.example" do
65
+ "\n# Exa API Key\nEXA_API_KEY=\n"
66
+ end
67
+ say "Added EXA_API_KEY to .env.example", :green
68
+ end
69
+ end
70
+
71
+ def show_instructions
72
+ say ""
73
+ say "Exa gem installed successfully!", :green
74
+ say ""
75
+ say "Next steps:"
76
+ say " 1. Add your API key to Rails credentials:"
77
+ say " rails credentials:edit"
78
+ say ""
79
+ say " exa:"
80
+ say " api_key: your-api-key-here"
81
+ say ""
82
+ say " 2. Or set the EXA_API_KEY environment variable"
83
+ say ""
84
+ say " 3. Test the connection:"
85
+ say " rails exa:verify"
86
+ say ""
87
+ say "Usage examples:"
88
+ say " client = Exa::Client.new"
89
+ say " results = client.search('AI research', num_results: 10)"
90
+ say ""
91
+ end
92
+ end
93
+ end
94
+ end
metadata CHANGED
@@ -1,7 +1,7 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: exaonruby
3
3
  version: !ruby/object:Gem::Version
4
- version: 1.0.0
4
+ version: 1.2.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - tigel-agm
@@ -69,16 +69,39 @@ dependencies:
69
69
  - - "<"
70
70
  - !ruby/object:Gem::Version
71
71
  version: '3.0'
72
+ - !ruby/object:Gem::Dependency
73
+ name: concurrent-ruby
74
+ requirement: !ruby/object:Gem::Requirement
75
+ requirements:
76
+ - - ">="
77
+ - !ruby/object:Gem::Version
78
+ version: '1.2'
79
+ - - "<"
80
+ - !ruby/object:Gem::Version
81
+ version: '2.0'
82
+ type: :runtime
83
+ prerelease: false
84
+ version_requirements: !ruby/object:Gem::Requirement
85
+ requirements:
86
+ - - ">="
87
+ - !ruby/object:Gem::Version
88
+ version: '1.2'
89
+ - - "<"
90
+ - !ruby/object:Gem::Version
91
+ version: '2.0'
72
92
  description: A production-ready Ruby gem wrapper for the Exa.ai Search and Websets
73
93
  APIs. Features neural search, LLM-powered answers, async research tasks, Websets
74
- management (monitors, imports, webhooks), and a beautiful CLI. Includes n8n/Zapier
75
- webhook signature verification utilities.
94
+ management (monitors, imports, webhooks), SSE streaming, request logging, rate limiting,
95
+ response caching, OpenTelemetry instrumentation, parallel requests, Rails integration,
96
+ Sorbet types, and a beautiful CLI. Includes n8n/Zapier webhook signature verification
97
+ utilities.
76
98
  email: []
77
99
  executables:
78
100
  - exa
79
101
  extensions: []
80
102
  extra_rdoc_files: []
81
103
  files:
104
+ - CHANGELOG.md
82
105
  - LICENSE.txt
83
106
  - README.md
84
107
  - exaonruby.gemspec
@@ -101,6 +124,11 @@ files:
101
124
  - lib/exa/endpoints/webset_searches.rb
102
125
  - lib/exa/endpoints/websets.rb
103
126
  - lib/exa/errors.rb
127
+ - lib/exa/middleware/instrumentation.rb
128
+ - lib/exa/middleware/rate_limiter.rb
129
+ - lib/exa/middleware/request_logger.rb
130
+ - lib/exa/middleware/response_cache.rb
131
+ - lib/exa/rails.rb
104
132
  - lib/exa/resources/answer_response.rb
105
133
  - lib/exa/resources/base.rb
106
134
  - lib/exa/resources/contents_response.rb
@@ -114,9 +142,13 @@ files:
114
142
  - lib/exa/resources/webhook.rb
115
143
  - lib/exa/resources/webset.rb
116
144
  - lib/exa/resources/webset_item.rb
145
+ - lib/exa/types.rb
146
+ - lib/exa/utils/parallel.rb
117
147
  - lib/exa/utils/parameter_converter.rb
148
+ - lib/exa/utils/sse_client.rb
118
149
  - lib/exa/utils/webhook_handler.rb
119
150
  - lib/exa/version.rb
151
+ - lib/generators/exa/install_generator.rb
120
152
  homepage: https://github.com/tigel-agm/exaonruby
121
153
  licenses:
122
154
  - MIT
@@ -142,5 +174,5 @@ required_rubygems_version: !ruby/object:Gem::Requirement
142
174
  requirements: []
143
175
  rubygems_version: 4.0.2
144
176
  specification_version: 4
145
- summary: Complete Ruby client for the Exa.ai API with beautiful CLI
177
+ summary: Complete Ruby client for the Exa.ai API with CLI, middleware, and Rails integration
146
178
  test_files: []