exaonruby 1.1.0 → 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.
@@ -0,0 +1,226 @@
1
+ # frozen_string_literal: true
2
+
3
+ # typed: strict
4
+
5
+ require "digest"
6
+ require "json"
7
+
8
+ module Exa
9
+ module Middleware
10
+ # Response caching middleware for Exa API calls
11
+ #
12
+ # Caches GET requests and idempotent POST requests (like search)
13
+ # to reduce API costs and improve response times.
14
+ #
15
+ # @example Enable caching with memory store
16
+ # client = Exa::Client.new(api_key: key) do |config|
17
+ # config.cache = Exa::Cache::MemoryStore.new
18
+ # config.cache_ttl = 300 # 5 minutes
19
+ # end
20
+ #
21
+ # @example With Redis
22
+ # require 'redis'
23
+ # client = Exa::Client.new(api_key: key) do |config|
24
+ # config.cache = Exa::Cache::RedisStore.new(Redis.new)
25
+ # config.cache_ttl = 3600 # 1 hour
26
+ # end
27
+ class ResponseCache < Faraday::Middleware
28
+ # Cacheable endpoints (idempotent operations)
29
+ CACHEABLE_PATHS = %w[
30
+ /search
31
+ /contents
32
+ /findSimilar
33
+ ].freeze
34
+
35
+ # @param app [Faraday::Middleware] Next middleware
36
+ # @param cache [Object] Cache store (must respond to get/set)
37
+ # @param ttl [Integer] Time to live in seconds
38
+ # @param cacheable_paths [Array<String>] Paths to cache
39
+ def initialize(app, cache:, ttl: 300, cacheable_paths: CACHEABLE_PATHS)
40
+ super(app)
41
+ @cache = cache
42
+ @ttl = ttl
43
+ @cacheable_paths = cacheable_paths
44
+ end
45
+
46
+ def call(env)
47
+ return @app.call(env) unless cacheable?(env)
48
+
49
+ cache_key = build_cache_key(env)
50
+
51
+ # Try to get from cache
52
+ cached = @cache.get(cache_key)
53
+ if cached
54
+ return build_cached_response(env, cached)
55
+ end
56
+
57
+ # Make request and cache response
58
+ @app.call(env).on_complete do |response_env|
59
+ if response_env[:status] == 200
60
+ cache_response(cache_key, response_env)
61
+ end
62
+ end
63
+ end
64
+
65
+ private
66
+
67
+ def cacheable?(env)
68
+ path = env[:url].path
69
+ @cacheable_paths.any? { |p| path.include?(p) }
70
+ end
71
+
72
+ def build_cache_key(env)
73
+ # Include method, path, and body in cache key
74
+ components = [
75
+ env[:method].to_s,
76
+ env[:url].path,
77
+ env[:body].to_s
78
+ ]
79
+
80
+ digest = Digest::SHA256.hexdigest(components.join("|"))
81
+ "exa:cache:#{digest}"
82
+ end
83
+
84
+ def cache_response(key, env)
85
+ data = {
86
+ status: env[:status],
87
+ headers: env[:response_headers].to_h,
88
+ body: env[:body]
89
+ }
90
+
91
+ @cache.set(key, data.to_json, ttl: @ttl)
92
+ end
93
+
94
+ def build_cached_response(env, cached_data)
95
+ data = JSON.parse(cached_data, symbolize_names: true)
96
+
97
+ env[:status] = data[:status]
98
+ env[:response_headers] = Faraday::Utils::Headers.new(data[:headers])
99
+ env[:body] = data[:body]
100
+
101
+ # Add cache hit header
102
+ env[:response_headers]["X-Exa-Cache"] = "HIT"
103
+
104
+ Faraday::Response.new(env)
105
+ end
106
+ end
107
+
108
+ Faraday::Middleware.register_middleware(exa_cache: ResponseCache)
109
+ end
110
+
111
+ module Cache
112
+ # In-memory cache store with TTL support
113
+ #
114
+ # Thread-safe, suitable for single-process applications.
115
+ # For multi-process or distributed apps, use RedisStore.
116
+ class MemoryStore
117
+ def initialize
118
+ @store = {}
119
+ @expirations = {}
120
+ @mutex = Mutex.new
121
+ end
122
+
123
+ # Get value from cache
124
+ # @param key [String] Cache key
125
+ # @return [String, nil] Cached value or nil
126
+ def get(key)
127
+ @mutex.synchronize do
128
+ cleanup_expired
129
+ @store[key]
130
+ end
131
+ end
132
+
133
+ # Set value in cache
134
+ # @param key [String] Cache key
135
+ # @param value [String] Value to cache
136
+ # @param ttl [Integer] Time to live in seconds
137
+ def set(key, value, ttl: 300)
138
+ @mutex.synchronize do
139
+ @store[key] = value
140
+ @expirations[key] = Time.now + ttl
141
+ end
142
+ end
143
+
144
+ # Delete from cache
145
+ # @param key [String] Cache key
146
+ def delete(key)
147
+ @mutex.synchronize do
148
+ @store.delete(key)
149
+ @expirations.delete(key)
150
+ end
151
+ end
152
+
153
+ # Clear entire cache
154
+ def clear
155
+ @mutex.synchronize do
156
+ @store.clear
157
+ @expirations.clear
158
+ end
159
+ end
160
+
161
+ # Get cache statistics
162
+ # @return [Hash] Stats including size and hit count
163
+ def stats
164
+ @mutex.synchronize do
165
+ cleanup_expired
166
+ { size: @store.size }
167
+ end
168
+ end
169
+
170
+ private
171
+
172
+ def cleanup_expired
173
+ now = Time.now
174
+ expired_keys = @expirations.select { |_, exp| exp < now }.keys
175
+ expired_keys.each do |key|
176
+ @store.delete(key)
177
+ @expirations.delete(key)
178
+ end
179
+ end
180
+ end
181
+
182
+ # Redis cache store for distributed caching
183
+ #
184
+ # Requires redis gem to be installed.
185
+ #
186
+ # @example
187
+ # require 'redis'
188
+ # cache = Exa::Cache::RedisStore.new(Redis.new)
189
+ class RedisStore
190
+ # @param redis [Redis] Redis client instance
191
+ # @param prefix [String] Key prefix
192
+ def initialize(redis, prefix: "exa")
193
+ @redis = redis
194
+ @prefix = prefix
195
+ end
196
+
197
+ def get(key)
198
+ @redis.get(prefixed(key))
199
+ end
200
+
201
+ def set(key, value, ttl: 300)
202
+ @redis.setex(prefixed(key), ttl, value)
203
+ end
204
+
205
+ def delete(key)
206
+ @redis.del(prefixed(key))
207
+ end
208
+
209
+ def clear
210
+ keys = @redis.keys("#{@prefix}:*")
211
+ @redis.del(*keys) if keys.any?
212
+ end
213
+
214
+ def stats
215
+ keys = @redis.keys("#{@prefix}:*")
216
+ { size: keys.size }
217
+ end
218
+
219
+ private
220
+
221
+ def prefixed(key)
222
+ key.start_with?(@prefix) ? key : "#{@prefix}:#{key}"
223
+ end
224
+ end
225
+ end
226
+ end
data/lib/exa/rails.rb ADDED
@@ -0,0 +1,157 @@
1
+ # frozen_string_literal: true
2
+
3
+ # typed: strict
4
+
5
+ module Exa
6
+ # Rails integration for the Exa gem
7
+ #
8
+ # Provides generators, Active Job adapters, and Rails-specific configuration.
9
+ #
10
+ # Install with: rails generate exa:install
11
+ module Rails
12
+ class Railtie < ::Rails::Railtie
13
+ # Initialize Exa with Rails configuration
14
+ initializer "exa.configure" do |app|
15
+ # Load config from Rails credentials or environment
16
+ Exa.configure do |config|
17
+ config.api_key = rails_api_key(app)
18
+ config.logger = ::Rails.logger if defined?(::Rails.logger)
19
+ config.timeout = ENV.fetch("EXA_TIMEOUT", 60).to_i
20
+ end
21
+ end
22
+
23
+ # Add Exa rake tasks
24
+ rake_tasks do
25
+ namespace :exa do
26
+ desc "Verify Exa API connection"
27
+ task verify: :environment do
28
+ begin
29
+ client = Exa::Client.new
30
+ result = client.search("test", num_results: 1)
31
+ puts "✓ Exa API connection successful"
32
+ puts " Request ID: #{result.request_id}"
33
+ rescue Exa::Error => e
34
+ puts "✗ Exa API error: #{e.message}"
35
+ exit 1
36
+ end
37
+ end
38
+
39
+ desc "Show Exa configuration"
40
+ task config: :environment do
41
+ puts "Exa Configuration:"
42
+ puts " API Key: #{Exa.configuration&.api_key&.slice(0, 8)}..."
43
+ puts " Base URL: #{Exa.configuration&.base_url}"
44
+ puts " Timeout: #{Exa.configuration&.timeout}s"
45
+ end
46
+ end
47
+ end
48
+
49
+ private
50
+
51
+ def rails_api_key(app)
52
+ # Try Rails credentials first
53
+ if app.credentials.respond_to?(:exa)
54
+ app.credentials.exa[:api_key]
55
+ elsif app.credentials.respond_to?(:dig)
56
+ app.credentials.dig(:exa, :api_key)
57
+ end || ENV["EXA_API_KEY"]
58
+ end
59
+ end
60
+
61
+ # Active Job adapter for async research tasks
62
+ #
63
+ # @example Create a research job
64
+ # class ExaResearchJob < ApplicationJob
65
+ # include Exa::Rails::ResearchJob
66
+ #
67
+ # def perform(instructions)
68
+ # research(instructions) do |task|
69
+ # # Called when research completes
70
+ # save_results(task.output)
71
+ # end
72
+ # end
73
+ # end
74
+ module ResearchJob
75
+ extend ActiveSupport::Concern
76
+
77
+ included do
78
+ queue_as :exa_research
79
+ retry_on Exa::RateLimitError, wait: :exponentially_longer, attempts: 5
80
+ discard_on Exa::AuthenticationError
81
+ end
82
+
83
+ # Start a research task and poll until complete
84
+ #
85
+ # @param instructions [String] Research instructions
86
+ # @param model [String] Research model
87
+ # @param poll_interval [Integer] Seconds between status checks
88
+ #
89
+ # @yield [Exa::Resources::ResearchTask] Called when complete
90
+ def research(instructions, model: "exa-research", poll_interval: 5)
91
+ client = Exa::Client.new
92
+ task = client.create_research(instructions: instructions, model: model)
93
+
94
+ loop do
95
+ task = client.get_research(task.research_id)
96
+
97
+ case task.status
98
+ when "completed"
99
+ yield task if block_given?
100
+ return task
101
+ when "failed", "canceled"
102
+ raise Exa::Error, "Research task #{task.status}: #{task.error_message}"
103
+ else
104
+ sleep poll_interval
105
+ end
106
+ end
107
+ end
108
+ end
109
+
110
+ # Action Cable channel for real-time updates
111
+ #
112
+ # @example Create a channel
113
+ # class ExaSearchChannel < ApplicationCable::Channel
114
+ # include Exa::Rails::StreamingChannel
115
+ #
116
+ # def search(data)
117
+ # stream_answer(data["query"])
118
+ # end
119
+ # end
120
+ module StreamingChannel
121
+ extend ActiveSupport::Concern
122
+
123
+ # Stream answer tokens to the client
124
+ #
125
+ # @param query [String] Question to answer
126
+ def stream_answer(query)
127
+ Exa::Utils::SSEClient.stream_answer(
128
+ api_key: ENV["EXA_API_KEY"],
129
+ query: query
130
+ ) do |event|
131
+ case event[:type]
132
+ when :token
133
+ transmit({ type: "token", data: event[:data] })
134
+ when :citation
135
+ transmit({ type: "citation", data: event[:data] })
136
+ when :done
137
+ transmit({ type: "done" })
138
+ when :error
139
+ transmit({ type: "error", data: event[:data] })
140
+ end
141
+ end
142
+ end
143
+
144
+ # Stream research progress to the client
145
+ #
146
+ # @param instructions [String] Research instructions
147
+ def stream_research(instructions)
148
+ Exa::Utils::SSEClient.stream_research(
149
+ api_key: ENV["EXA_API_KEY"],
150
+ instructions: instructions
151
+ ) do |event|
152
+ transmit({ type: event[:type].to_s, data: event[:data] })
153
+ end
154
+ end
155
+ end
156
+ end
157
+ end
@@ -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
data/lib/exa/version.rb CHANGED
@@ -3,5 +3,5 @@
3
3
  # typed: strict
4
4
 
5
5
  module Exa
6
- VERSION = "1.1.0"
6
+ VERSION = "1.3.0"
7
7
  end
data/lib/exa.rb CHANGED
@@ -3,12 +3,21 @@
3
3
  # typed: strict
4
4
 
5
5
  require_relative "exa/version"
6
+ require "faraday"
7
+ require "faraday/retry"
6
8
  require_relative "exa/errors"
7
9
  require_relative "exa/configuration"
8
10
 
9
11
  require_relative "exa/utils/parameter_converter"
10
12
  require_relative "exa/utils/webhook_handler"
11
13
  require_relative "exa/utils/sse_client"
14
+ require_relative "exa/utils/parallel"
15
+
16
+ # Middleware (loaded after Faraday is available in Client)
17
+ require_relative "exa/middleware/request_logger"
18
+ require_relative "exa/middleware/rate_limiter"
19
+ require_relative "exa/middleware/response_cache"
20
+ require_relative "exa/middleware/instrumentation"
12
21
 
13
22
  # Optional: Sorbet types (only loaded if sorbet-runtime is available)
14
23
  begin
@@ -18,7 +27,17 @@ rescue LoadError
18
27
  # sorbet-runtime not installed, types module not available
19
28
  end
20
29
 
30
+ # Optional: Rails integration (only loaded if Rails is available)
31
+ begin
32
+ if defined?(::Rails::Railtie)
33
+ require_relative "exa/rails"
34
+ end
35
+ rescue LoadError
36
+ # Rails not available
37
+ end
38
+
21
39
  require_relative "exa/resources/base"
40
+ require_relative "exa/resources/paginated_response"
22
41
  require_relative "exa/resources/search_result"
23
42
  require_relative "exa/resources/search_response"
24
43
  require_relative "exa/resources/contents_response"
@@ -26,7 +45,6 @@ require_relative "exa/resources/answer_response"
26
45
  require_relative "exa/resources/research_task"
27
46
  require_relative "exa/resources/webset"
28
47
  require_relative "exa/resources/webset_item"
29
- require_relative "exa/resources/paginated_response"
30
48
  require_relative "exa/resources/monitor"
31
49
  require_relative "exa/resources/import"
32
50
  require_relative "exa/resources/webhook"
@@ -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