exaonruby 1.1.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 +40 -0
- data/README.md +13 -4
- data/exaonruby.gemspec +6 -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/utils/parallel.rb +135 -0
- data/lib/exa/version.rb +1 -1
- data/lib/exa.rb +16 -0
- data/lib/generators/exa/install_generator.rb +94 -0
- metadata +33 -4
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: f7fa267a3b26d29aff9a6e5886cd43568ad0820aad17e54c8760141bbe208aa8
|
|
4
|
+
data.tar.gz: 2a1823d83853927fee270c1070b4ead7801d1d2df2d6e5c8769d644cefb31459
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: d128dce03cfee863a805039fcb4e9d4b12b93bff213ae0e03be21702040422a284864910df6916183c6aca5b59a91811d4cc3589c348733c31b441eee46db518
|
|
7
|
+
data.tar.gz: 87fb73c8641ef0a78af2f426d0a9499a934d4149ac6c482acab4a688575b87449a8d4c1feb23d46cf9f0b9c7853c787cf052acda30367c9c6fa8f38946559c47
|
data/CHANGELOG.md
CHANGED
|
@@ -5,6 +5,46 @@ All notable changes to this project will be documented in this file.
|
|
|
5
5
|
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/),
|
|
6
6
|
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
|
|
7
7
|
|
|
8
|
+
## [1.2.0] - 2025-12-18
|
|
9
|
+
|
|
10
|
+
### Added
|
|
11
|
+
|
|
12
|
+
- **Request Logging Middleware** - Pretty HTTP request/response logging
|
|
13
|
+
- Configurable log levels and body logging
|
|
14
|
+
- Sensitive data filtering (API keys, passwords)
|
|
15
|
+
- Request timing with status indicators (✓ ⚠ ✗)
|
|
16
|
+
|
|
17
|
+
- **Rate Limiter Middleware** - Client-side rate limiting
|
|
18
|
+
- Token bucket algorithm for smooth rate control
|
|
19
|
+
- Configurable requests per second and burst size
|
|
20
|
+
- Thread-safe implementation
|
|
21
|
+
|
|
22
|
+
- **Response Cache Middleware** - Reduce API costs
|
|
23
|
+
- `Exa::Cache::MemoryStore` for single-process apps
|
|
24
|
+
- `Exa::Cache::RedisStore` for distributed caching
|
|
25
|
+
- Configurable TTL and cacheable endpoints
|
|
26
|
+
|
|
27
|
+
- **OpenTelemetry Instrumentation** - Distributed tracing
|
|
28
|
+
- Automatic span creation for all requests
|
|
29
|
+
- HTTP attributes and status codes
|
|
30
|
+
- Error recording
|
|
31
|
+
|
|
32
|
+
- **Parallel Requests Utility** - Batch operations
|
|
33
|
+
- `Exa::Utils::Parallel.map` for concurrent requests
|
|
34
|
+
- Configurable concurrency limits
|
|
35
|
+
- Multiple error handling strategies
|
|
36
|
+
|
|
37
|
+
- **Rails Integration** - First-class Rails support
|
|
38
|
+
- Railtie for automatic configuration
|
|
39
|
+
- `rails g exa:install` generator
|
|
40
|
+
- ActiveJob adapter for async research
|
|
41
|
+
- ActionCable integration for streaming
|
|
42
|
+
- Rake tasks (`rails exa:verify`, `rails exa:config`)
|
|
43
|
+
|
|
44
|
+
### Changed
|
|
45
|
+
|
|
46
|
+
- Added `concurrent-ruby` as a core dependency for parallel requests
|
|
47
|
+
|
|
8
48
|
## [1.1.0] - 2025-12-18
|
|
9
49
|
|
|
10
50
|
### Added
|
data/README.md
CHANGED
|
@@ -1,6 +1,8 @@
|
|
|
1
1
|
# Exa Ruby
|
|
2
2
|
|
|
3
|
-
A
|
|
3
|
+
A Ruby gem wrapper for the [Exa.ai](https://exa.ai) API, providing intelligent web search, content extraction, and structured data collection capabilities.
|
|
4
|
+
|
|
5
|
+
Want to say thanks? Click the ⭐ at the top of the page.
|
|
4
6
|
|
|
5
7
|
## Features
|
|
6
8
|
|
|
@@ -14,11 +16,16 @@ A production-ready Ruby gem wrapper for the [Exa.ai](https://exa.ai) API, provid
|
|
|
14
16
|
- **Imports**: Upload CSV data into Websets
|
|
15
17
|
- **Webhooks & Events**: Real-time notifications for Websets activity
|
|
16
18
|
- **SSE Streaming**: Real-time token streaming for Answer and Research APIs
|
|
17
|
-
- **
|
|
19
|
+
- **Request Logging**: Pretty HTTP logging with timing and status indicators
|
|
20
|
+
- **Rate Limiting**: Client-side token bucket rate limiting
|
|
21
|
+
- **Response Caching**: Memory and Redis caching to reduce API costs
|
|
22
|
+
- **Instrumentation**: OpenTelemetry distributed tracing
|
|
23
|
+
- **Parallel Requests**: Concurrent batch operations
|
|
24
|
+
- **Rails Integration**: Railtie, generator, ActiveJob, ActionCable
|
|
25
|
+
- **Sorbet Types**: Optional T::Struct type definitions
|
|
18
26
|
- **Beautiful CLI**: Colorful command-line interface
|
|
19
27
|
- **n8n/Zapier Integration**: Webhook signature verification utilities
|
|
20
28
|
- **Automatic Retries**: Built-in retry logic for transient failures
|
|
21
|
-
- **Rate Limit Handling**: Proper error handling with retry information
|
|
22
29
|
- **Type Documentation**: Comprehensive YARD documentation
|
|
23
30
|
|
|
24
31
|
## Installation
|
|
@@ -670,7 +677,10 @@ params = Exa::Types::SearchParams.new(
|
|
|
670
677
|
- faraday >= 2.0
|
|
671
678
|
- faraday-retry >= 2.0
|
|
672
679
|
- thor >= 1.0
|
|
680
|
+
- concurrent-ruby >= 1.2
|
|
673
681
|
- sorbet-runtime >= 0.5 (optional, for type definitions)
|
|
682
|
+
- opentelemetry-sdk (optional, for instrumentation)
|
|
683
|
+
- redis (optional, for distributed caching)
|
|
674
684
|
|
|
675
685
|
## Development
|
|
676
686
|
|
|
@@ -683,4 +693,3 @@ The gem is available as open source under the terms of the [MIT License](https:/
|
|
|
683
693
|
## Contributing
|
|
684
694
|
|
|
685
695
|
Bug reports and pull requests are welcome on GitHub at https://github.com/exa-labs/exa-ruby.
|
|
686
|
-
|
data/exaonruby.gemspec
CHANGED
|
@@ -8,11 +8,12 @@ Gem::Specification.new do |spec|
|
|
|
8
8
|
spec.authors = ["tigel-agm"]
|
|
9
9
|
spec.email = []
|
|
10
10
|
|
|
11
|
-
spec.summary = "Complete Ruby client for the Exa.ai API with CLI and
|
|
11
|
+
spec.summary = "Complete Ruby client for the Exa.ai API with CLI, middleware, and Rails integration"
|
|
12
12
|
spec.description = "A production-ready Ruby gem wrapper for the Exa.ai Search and Websets APIs. " \
|
|
13
13
|
"Features neural search, LLM-powered answers, async research tasks, " \
|
|
14
14
|
"Websets management (monitors, imports, webhooks), SSE streaming, " \
|
|
15
|
-
"
|
|
15
|
+
"request logging, rate limiting, response caching, OpenTelemetry instrumentation, " \
|
|
16
|
+
"parallel requests, Rails integration, Sorbet types, and a beautiful CLI. " \
|
|
16
17
|
"Includes n8n/Zapier webhook signature verification utilities."
|
|
17
18
|
spec.homepage = "https://github.com/tigel-agm/exaonruby"
|
|
18
19
|
spec.license = "MIT"
|
|
@@ -36,7 +37,9 @@ Gem::Specification.new do |spec|
|
|
|
36
37
|
spec.add_dependency "faraday", ">= 2.0", "< 3.0"
|
|
37
38
|
spec.add_dependency "faraday-retry", ">= 2.0", "< 3.0"
|
|
38
39
|
spec.add_dependency "thor", ">= 1.0", "< 3.0"
|
|
40
|
+
spec.add_dependency "concurrent-ruby", ">= 1.2", "< 2.0"
|
|
39
41
|
|
|
40
42
|
# Optional: Sorbet types (install sorbet-runtime for type checking)
|
|
41
|
-
#
|
|
43
|
+
# Optional: OpenTelemetry (install opentelemetry-sdk for instrumentation)
|
|
44
|
+
# Optional: Redis (install redis gem for distributed caching)
|
|
42
45
|
end
|
|
@@ -0,0 +1,97 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
# typed: strict
|
|
4
|
+
|
|
5
|
+
module Exa
|
|
6
|
+
module Middleware
|
|
7
|
+
# OpenTelemetry instrumentation middleware for distributed tracing
|
|
8
|
+
#
|
|
9
|
+
# Integrates with OpenTelemetry for comprehensive request tracing.
|
|
10
|
+
# Requires opentelemetry-sdk gem to be installed.
|
|
11
|
+
#
|
|
12
|
+
# @example Enable instrumentation
|
|
13
|
+
# require 'opentelemetry/sdk'
|
|
14
|
+
# OpenTelemetry::SDK.configure
|
|
15
|
+
#
|
|
16
|
+
# client = Exa::Client.new(api_key: key) do |config|
|
|
17
|
+
# config.instrumentation = true
|
|
18
|
+
# end
|
|
19
|
+
class Instrumentation < Faraday::Middleware
|
|
20
|
+
# @param app [Faraday::Middleware] Next middleware
|
|
21
|
+
# @param tracer_name [String] Name for the tracer
|
|
22
|
+
def initialize(app, tracer_name: "exa.api")
|
|
23
|
+
super(app)
|
|
24
|
+
@tracer_name = tracer_name
|
|
25
|
+
@tracer = nil
|
|
26
|
+
end
|
|
27
|
+
|
|
28
|
+
def call(env)
|
|
29
|
+
return @app.call(env) unless otel_available?
|
|
30
|
+
|
|
31
|
+
tracer.in_span(span_name(env), attributes: span_attributes(env), kind: :client) do |span|
|
|
32
|
+
begin
|
|
33
|
+
response = @app.call(env)
|
|
34
|
+
|
|
35
|
+
response.on_complete do |response_env|
|
|
36
|
+
span.set_attribute("http.status_code", response_env[:status])
|
|
37
|
+
span.status = otel_status(response_env[:status])
|
|
38
|
+
end
|
|
39
|
+
|
|
40
|
+
response
|
|
41
|
+
rescue StandardError => e
|
|
42
|
+
span.record_exception(e)
|
|
43
|
+
span.status = OpenTelemetry::Trace::Status.error(e.message)
|
|
44
|
+
raise
|
|
45
|
+
end
|
|
46
|
+
end
|
|
47
|
+
end
|
|
48
|
+
|
|
49
|
+
private
|
|
50
|
+
|
|
51
|
+
def otel_available?
|
|
52
|
+
defined?(OpenTelemetry) && OpenTelemetry.tracer_provider
|
|
53
|
+
rescue StandardError
|
|
54
|
+
false
|
|
55
|
+
end
|
|
56
|
+
|
|
57
|
+
def tracer
|
|
58
|
+
@tracer ||= OpenTelemetry.tracer_provider.tracer(@tracer_name)
|
|
59
|
+
end
|
|
60
|
+
|
|
61
|
+
def span_name(env)
|
|
62
|
+
method = env[:method].to_s.upcase
|
|
63
|
+
path = env[:url].path.split("/").reject(&:empty?).first || "request"
|
|
64
|
+
"Exa #{method} /#{path}"
|
|
65
|
+
end
|
|
66
|
+
|
|
67
|
+
def span_attributes(env)
|
|
68
|
+
{
|
|
69
|
+
"http.method" => env[:method].to_s.upcase,
|
|
70
|
+
"http.url" => sanitize_url(env[:url].to_s),
|
|
71
|
+
"http.target" => env[:url].path,
|
|
72
|
+
"http.host" => env[:url].host,
|
|
73
|
+
"http.scheme" => env[:url].scheme,
|
|
74
|
+
"net.peer.name" => env[:url].host,
|
|
75
|
+
"net.peer.port" => env[:url].port
|
|
76
|
+
}
|
|
77
|
+
end
|
|
78
|
+
|
|
79
|
+
def sanitize_url(url)
|
|
80
|
+
url.gsub(/api_key=[^&]+/, "api_key=[REDACTED]")
|
|
81
|
+
end
|
|
82
|
+
|
|
83
|
+
def otel_status(status_code)
|
|
84
|
+
case status_code
|
|
85
|
+
when 200..299
|
|
86
|
+
OpenTelemetry::Trace::Status.ok
|
|
87
|
+
when 400..599
|
|
88
|
+
OpenTelemetry::Trace::Status.error("HTTP #{status_code}")
|
|
89
|
+
else
|
|
90
|
+
OpenTelemetry::Trace::Status.unset
|
|
91
|
+
end
|
|
92
|
+
end
|
|
93
|
+
end
|
|
94
|
+
|
|
95
|
+
Faraday::Middleware.register_middleware(exa_instrumentation: Instrumentation)
|
|
96
|
+
end
|
|
97
|
+
end
|
|
@@ -0,0 +1,72 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
# typed: strict
|
|
4
|
+
|
|
5
|
+
module Exa
|
|
6
|
+
module Middleware
|
|
7
|
+
# Client-side rate limiter using token bucket algorithm
|
|
8
|
+
#
|
|
9
|
+
# Prevents 429 errors by limiting requests before they're sent.
|
|
10
|
+
# Uses a thread-safe token bucket with configurable rates.
|
|
11
|
+
#
|
|
12
|
+
# @example Enable rate limiting
|
|
13
|
+
# client = Exa::Client.new(api_key: key) do |config|
|
|
14
|
+
# config.rate_limit = 10 # requests per second
|
|
15
|
+
# config.rate_limit_burst = 20 # max burst size
|
|
16
|
+
# end
|
|
17
|
+
#
|
|
18
|
+
# @example Lower limits for safety
|
|
19
|
+
# client = Exa::Client.new(api_key: key) do |config|
|
|
20
|
+
# config.rate_limit = 5 # conservative 5 req/sec
|
|
21
|
+
# end
|
|
22
|
+
class RateLimiter < Faraday::Middleware
|
|
23
|
+
# @param app [Faraday::Middleware] Next middleware
|
|
24
|
+
# @param rate [Float] Tokens per second (requests per second)
|
|
25
|
+
# @param burst [Integer] Maximum tokens (burst capacity)
|
|
26
|
+
def initialize(app, rate: 10.0, burst: 20)
|
|
27
|
+
super(app)
|
|
28
|
+
@rate = rate.to_f
|
|
29
|
+
@burst = burst
|
|
30
|
+
@tokens = burst.to_f
|
|
31
|
+
@last_time = Process.clock_gettime(Process::CLOCK_MONOTONIC)
|
|
32
|
+
@mutex = Mutex.new
|
|
33
|
+
end
|
|
34
|
+
|
|
35
|
+
def call(env)
|
|
36
|
+
wait_for_token
|
|
37
|
+
@app.call(env)
|
|
38
|
+
end
|
|
39
|
+
|
|
40
|
+
private
|
|
41
|
+
|
|
42
|
+
# Wait until a token is available
|
|
43
|
+
def wait_for_token
|
|
44
|
+
@mutex.synchronize do
|
|
45
|
+
refill_tokens
|
|
46
|
+
|
|
47
|
+
if @tokens < 1.0
|
|
48
|
+
# Calculate wait time needed
|
|
49
|
+
wait_time = (1.0 - @tokens) / @rate
|
|
50
|
+
sleep(wait_time) if wait_time > 0
|
|
51
|
+
refill_tokens
|
|
52
|
+
end
|
|
53
|
+
|
|
54
|
+
@tokens -= 1.0
|
|
55
|
+
end
|
|
56
|
+
end
|
|
57
|
+
|
|
58
|
+
# Refill tokens based on time elapsed
|
|
59
|
+
def refill_tokens
|
|
60
|
+
now = Process.clock_gettime(Process::CLOCK_MONOTONIC)
|
|
61
|
+
elapsed = now - @last_time
|
|
62
|
+
@last_time = now
|
|
63
|
+
|
|
64
|
+
# Add tokens based on elapsed time
|
|
65
|
+
@tokens = [@tokens + (elapsed * @rate), @burst.to_f].min
|
|
66
|
+
end
|
|
67
|
+
end
|
|
68
|
+
|
|
69
|
+
# Faraday middleware registration
|
|
70
|
+
Faraday::Middleware.register_middleware(exa_rate_limiter: RateLimiter)
|
|
71
|
+
end
|
|
72
|
+
end
|
|
@@ -0,0 +1,170 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
# typed: strict
|
|
4
|
+
|
|
5
|
+
require "logger"
|
|
6
|
+
require "json"
|
|
7
|
+
|
|
8
|
+
module Exa
|
|
9
|
+
module Middleware
|
|
10
|
+
# Pretty request/response logging middleware for Faraday
|
|
11
|
+
#
|
|
12
|
+
# Logs all HTTP requests and responses with timing, status codes, and
|
|
13
|
+
# optional body logging. Useful for debugging and monitoring API usage.
|
|
14
|
+
#
|
|
15
|
+
# @example Enable logging on client
|
|
16
|
+
# client = Exa::Client.new(api_key: key) do |config|
|
|
17
|
+
# config.logger = Logger.new($stdout)
|
|
18
|
+
# config.log_level = :info
|
|
19
|
+
# config.log_bodies = true
|
|
20
|
+
# end
|
|
21
|
+
#
|
|
22
|
+
# @example Custom logger
|
|
23
|
+
# client = Exa::Client.new(api_key: key) do |config|
|
|
24
|
+
# config.logger = Rails.logger
|
|
25
|
+
# config.log_level = :debug
|
|
26
|
+
# end
|
|
27
|
+
class RequestLogger < Faraday::Middleware
|
|
28
|
+
DEFAULT_OPTIONS = {
|
|
29
|
+
log_level: :info,
|
|
30
|
+
log_headers: false,
|
|
31
|
+
log_bodies: false,
|
|
32
|
+
filter_keys: %w[api_key x-api-key authorization password secret token].freeze,
|
|
33
|
+
max_body_size: 2000
|
|
34
|
+
}.freeze
|
|
35
|
+
|
|
36
|
+
# @param app [Faraday::Middleware] Next middleware in chain
|
|
37
|
+
# @param logger [Logger] Logger instance
|
|
38
|
+
# @param options [Hash] Configuration options
|
|
39
|
+
def initialize(app, logger: nil, **options)
|
|
40
|
+
super(app)
|
|
41
|
+
@logger = logger || default_logger
|
|
42
|
+
@options = DEFAULT_OPTIONS.merge(options)
|
|
43
|
+
end
|
|
44
|
+
|
|
45
|
+
def call(env)
|
|
46
|
+
start_time = Process.clock_gettime(Process::CLOCK_MONOTONIC)
|
|
47
|
+
request_id = generate_request_id
|
|
48
|
+
|
|
49
|
+
log_request(env, request_id)
|
|
50
|
+
|
|
51
|
+
@app.call(env).on_complete do |response_env|
|
|
52
|
+
elapsed = ((Process.clock_gettime(Process::CLOCK_MONOTONIC) - start_time) * 1000).round(2)
|
|
53
|
+
log_response(response_env, request_id, elapsed)
|
|
54
|
+
end
|
|
55
|
+
rescue StandardError => e
|
|
56
|
+
elapsed = ((Process.clock_gettime(Process::CLOCK_MONOTONIC) - start_time) * 1000).round(2)
|
|
57
|
+
log_error(env, request_id, elapsed, e)
|
|
58
|
+
raise
|
|
59
|
+
end
|
|
60
|
+
|
|
61
|
+
private
|
|
62
|
+
|
|
63
|
+
def log_request(env, request_id)
|
|
64
|
+
method = env[:method].to_s.upcase
|
|
65
|
+
url = env[:url].to_s.gsub(/api_key=[^&]+/, "api_key=[FILTERED]")
|
|
66
|
+
|
|
67
|
+
message = "[Exa] #{request_id} → #{method} #{url}"
|
|
68
|
+
|
|
69
|
+
if @options[:log_headers]
|
|
70
|
+
headers = filter_sensitive(env[:request_headers] || {})
|
|
71
|
+
message += "\n Headers: #{headers.to_json}"
|
|
72
|
+
end
|
|
73
|
+
|
|
74
|
+
if @options[:log_bodies] && env[:body]
|
|
75
|
+
body = truncate_body(filter_body(env[:body]))
|
|
76
|
+
message += "\n Body: #{body}"
|
|
77
|
+
end
|
|
78
|
+
|
|
79
|
+
log(message)
|
|
80
|
+
end
|
|
81
|
+
|
|
82
|
+
def log_response(env, request_id, elapsed)
|
|
83
|
+
status = env[:status]
|
|
84
|
+
status_emoji = status_indicator(status)
|
|
85
|
+
|
|
86
|
+
message = "[Exa] #{request_id} ← #{status_emoji} #{status} (#{elapsed}ms)"
|
|
87
|
+
|
|
88
|
+
if @options[:log_bodies] && env[:body]
|
|
89
|
+
body = truncate_body(env[:body].to_s)
|
|
90
|
+
message += "\n Body: #{body}"
|
|
91
|
+
end
|
|
92
|
+
|
|
93
|
+
log(message)
|
|
94
|
+
end
|
|
95
|
+
|
|
96
|
+
def log_error(env, request_id, elapsed, error)
|
|
97
|
+
message = "[Exa] #{request_id} ✗ ERROR after #{elapsed}ms: #{error.class}: #{error.message}"
|
|
98
|
+
log(message, :error)
|
|
99
|
+
end
|
|
100
|
+
|
|
101
|
+
def log(message, level = nil)
|
|
102
|
+
level ||= @options[:log_level]
|
|
103
|
+
|
|
104
|
+
case level
|
|
105
|
+
when :debug
|
|
106
|
+
@logger.debug(message)
|
|
107
|
+
when :info
|
|
108
|
+
@logger.info(message)
|
|
109
|
+
when :warn
|
|
110
|
+
@logger.warn(message)
|
|
111
|
+
when :error
|
|
112
|
+
@logger.error(message)
|
|
113
|
+
else
|
|
114
|
+
@logger.info(message)
|
|
115
|
+
end
|
|
116
|
+
end
|
|
117
|
+
|
|
118
|
+
def status_indicator(status)
|
|
119
|
+
case status
|
|
120
|
+
when 200..299 then "✓"
|
|
121
|
+
when 300..399 then "↻"
|
|
122
|
+
when 400..499 then "⚠"
|
|
123
|
+
when 500..599 then "✗"
|
|
124
|
+
else "?"
|
|
125
|
+
end
|
|
126
|
+
end
|
|
127
|
+
|
|
128
|
+
def generate_request_id
|
|
129
|
+
format("%06x", rand(0xFFFFFF))
|
|
130
|
+
end
|
|
131
|
+
|
|
132
|
+
def filter_sensitive(hash)
|
|
133
|
+
hash.each_with_object({}) do |(key, value), result|
|
|
134
|
+
if @options[:filter_keys].any? { |k| key.to_s.downcase.include?(k.downcase) }
|
|
135
|
+
result[key] = "[FILTERED]"
|
|
136
|
+
else
|
|
137
|
+
result[key] = value
|
|
138
|
+
end
|
|
139
|
+
end
|
|
140
|
+
end
|
|
141
|
+
|
|
142
|
+
def filter_body(body)
|
|
143
|
+
return body unless body.is_a?(Hash)
|
|
144
|
+
|
|
145
|
+
body.each_with_object({}) do |(key, value), result|
|
|
146
|
+
if @options[:filter_keys].any? { |k| key.to_s.downcase.include?(k.downcase) }
|
|
147
|
+
result[key] = "[FILTERED]"
|
|
148
|
+
else
|
|
149
|
+
result[key] = value
|
|
150
|
+
end
|
|
151
|
+
end.to_json
|
|
152
|
+
end
|
|
153
|
+
|
|
154
|
+
def truncate_body(body)
|
|
155
|
+
return body if body.length <= @options[:max_body_size]
|
|
156
|
+
|
|
157
|
+
"#{body[0, @options[:max_body_size]]}... (truncated)"
|
|
158
|
+
end
|
|
159
|
+
|
|
160
|
+
def default_logger
|
|
161
|
+
logger = Logger.new($stdout)
|
|
162
|
+
logger.formatter = proc { |_, _, _, msg| "#{msg}\n" }
|
|
163
|
+
logger
|
|
164
|
+
end
|
|
165
|
+
end
|
|
166
|
+
|
|
167
|
+
# Register middleware with Faraday
|
|
168
|
+
Faraday::Middleware.register_middleware(exa_logger: RequestLogger)
|
|
169
|
+
end
|
|
170
|
+
end
|
|
@@ -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
|
@@ -9,6 +9,13 @@ require_relative "exa/configuration"
|
|
|
9
9
|
require_relative "exa/utils/parameter_converter"
|
|
10
10
|
require_relative "exa/utils/webhook_handler"
|
|
11
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"
|
|
12
19
|
|
|
13
20
|
# Optional: Sorbet types (only loaded if sorbet-runtime is available)
|
|
14
21
|
begin
|
|
@@ -18,6 +25,15 @@ rescue LoadError
|
|
|
18
25
|
# sorbet-runtime not installed, types module not available
|
|
19
26
|
end
|
|
20
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
|
|
36
|
+
|
|
21
37
|
require_relative "exa/resources/base"
|
|
22
38
|
require_relative "exa/resources/search_result"
|
|
23
39
|
require_relative "exa/resources/search_response"
|
|
@@ -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,10 +69,32 @@ 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), SSE streaming,
|
|
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
|
|
@@ -102,6 +124,11 @@ files:
|
|
|
102
124
|
- lib/exa/endpoints/webset_searches.rb
|
|
103
125
|
- lib/exa/endpoints/websets.rb
|
|
104
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
|
|
105
132
|
- lib/exa/resources/answer_response.rb
|
|
106
133
|
- lib/exa/resources/base.rb
|
|
107
134
|
- lib/exa/resources/contents_response.rb
|
|
@@ -116,10 +143,12 @@ files:
|
|
|
116
143
|
- lib/exa/resources/webset.rb
|
|
117
144
|
- lib/exa/resources/webset_item.rb
|
|
118
145
|
- lib/exa/types.rb
|
|
146
|
+
- lib/exa/utils/parallel.rb
|
|
119
147
|
- lib/exa/utils/parameter_converter.rb
|
|
120
148
|
- lib/exa/utils/sse_client.rb
|
|
121
149
|
- lib/exa/utils/webhook_handler.rb
|
|
122
150
|
- lib/exa/version.rb
|
|
151
|
+
- lib/generators/exa/install_generator.rb
|
|
123
152
|
homepage: https://github.com/tigel-agm/exaonruby
|
|
124
153
|
licenses:
|
|
125
154
|
- MIT
|
|
@@ -145,5 +174,5 @@ required_rubygems_version: !ruby/object:Gem::Requirement
|
|
|
145
174
|
requirements: []
|
|
146
175
|
rubygems_version: 4.0.2
|
|
147
176
|
specification_version: 4
|
|
148
|
-
summary: Complete Ruby client for the Exa.ai API with CLI and
|
|
177
|
+
summary: Complete Ruby client for the Exa.ai API with CLI, middleware, and Rails integration
|
|
149
178
|
test_files: []
|