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.
- checksums.yaml +4 -4
- data/CHANGELOG.md +134 -0
- data/README.md +84 -3
- data/exaonruby.gemspec +11 -3
- 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/types.rb +204 -0
- data/lib/exa/utils/parallel.rb +135 -0
- data/lib/exa/utils/sse_client.rb +279 -0
- data/lib/exa/version.rb +1 -1
- data/lib/exa.rb +25 -0
- data/lib/generators/exa/install_generator.rb +94 -0
- metadata +36 -4
|
@@ -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
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.
|
|
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),
|
|
75
|
-
|
|
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
|
|
177
|
+
summary: Complete Ruby client for the Exa.ai API with CLI, middleware, and Rails integration
|
|
146
178
|
test_files: []
|