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.
- checksums.yaml +4 -4
- data/CHANGELOG.md +50 -0
- data/README.md +31 -4
- data/exaonruby.gemspec +6 -3
- data/lib/exa/configuration.rb +1 -1
- data/lib/exa/endpoints/events.rb +1 -1
- data/lib/exa/endpoints/imports.rb +1 -1
- data/lib/exa/endpoints/monitors.rb +1 -1
- data/lib/exa/endpoints/search.rb +5 -2
- data/lib/exa/endpoints/webhooks.rb +1 -1
- data/lib/exa/endpoints/webset_enrichments.rb +1 -1
- data/lib/exa/endpoints/webset_items.rb +1 -1
- data/lib/exa/endpoints/webset_searches.rb +1 -1
- data/lib/exa/endpoints/websets.rb +1 -1
- data/lib/exa/middleware/instrumentation.rb +97 -0
- data/lib/exa/middleware/rate_limiter.rb +72 -0
- data/lib/exa/middleware/request_logger.rb +170 -0
- data/lib/exa/middleware/response_cache.rb +226 -0
- data/lib/exa/rails.rb +157 -0
- data/lib/exa/utils/parallel.rb +135 -0
- data/lib/exa/version.rb +1 -1
- data/lib/exa.rb +19 -1
- data/lib/generators/exa/install_generator.rb +94 -0
- metadata +33 -4
|
@@ -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
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
|