exa-ai-ruby 1.0.0 → 1.1.1

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.
data/lib/exa/cli.rb ADDED
@@ -0,0 +1,5 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "cli/config_store"
4
+ require_relative "cli/account_resolver"
5
+ require_relative "cli/root"
@@ -44,16 +44,19 @@ module Exa
44
44
  @requester = requester
45
45
  end
46
46
 
47
- def request(method:, path:, query: nil, headers: nil, body: nil, unwrap: nil, stream: false, response_model: nil)
47
+ def request(method:, path:, query: nil, headers: nil, body: nil, unwrap: nil, stream: false, response_model: nil, request_options: nil)
48
+ options = normalize_request_options(request_options)
48
49
  req = build_request(
49
50
  method: method,
50
51
  path: Array(path).join("/"),
51
52
  query: query,
52
53
  headers: headers,
53
- body: body
54
+ body: body,
55
+ request_timeout: options[:timeout],
56
+ idempotency_key: options[:idempotency_key]
54
57
  )
55
58
 
56
- _, response, stream_enum = send_request(req)
59
+ _, response, stream_enum = send_request(req, max_retries: options[:max_retries] || max_retries)
57
60
  parsed_headers = Exa::Internal::Util.normalized_headers(response.each_header.to_h)
58
61
 
59
62
  if stream
@@ -77,13 +80,14 @@ module Exa
77
80
  cleaned.join("/")
78
81
  end
79
82
 
80
- def build_request(method:, path:, query:, headers:, body:)
83
+ def build_request(method:, path:, query:, headers:, body:, request_timeout:, idempotency_key:)
81
84
  normalized_path = normalize_path(path)
82
85
  url = @base_url + normalized_path
83
86
  url.query = Exa::Internal::Util.build_query(query)
84
87
 
85
88
  header_overrides = headers ? headers.each_with_object({}) { |(k, v), acc| acc[k] = v unless v.nil? } : {}
86
89
  final_headers = PLATFORM_HEADERS.merge(default_headers).merge(header_overrides)
90
+ final_headers["idempotency-key"] ||= idempotency_key if idempotency_key
87
91
 
88
92
  payload = case body
89
93
  when nil
@@ -96,12 +100,14 @@ module Exa
96
100
  JSON.generate(body)
97
101
  end
98
102
 
103
+ effective_timeout = request_timeout || timeout
104
+
99
105
  {
100
106
  method: method,
101
107
  url: url,
102
108
  headers: final_headers,
103
109
  body: payload,
104
- deadline: Process.clock_gettime(Process::CLOCK_MONOTONIC) + timeout,
110
+ deadline: Process.clock_gettime(Process::CLOCK_MONOTONIC) + effective_timeout,
105
111
  max_retries: max_retries
106
112
  }
107
113
  end
@@ -118,11 +124,11 @@ module Exa
118
124
  {}
119
125
  end
120
126
 
121
- def send_request(request, retry_count: 0)
127
+ def send_request(request, retry_count: 0, max_retries: @max_retries)
122
128
  status, response, body_enum = @requester.execute(request)
123
129
  if should_retry?(status) && retry_count < max_retries
124
- sleep(retry_delay(retry_count))
125
- return send_request(request, retry_count: retry_count + 1)
130
+ sleep(retry_delay(retry_count, response))
131
+ return send_request(request, retry_count: retry_count + 1, max_retries: max_retries)
126
132
  end
127
133
 
128
134
  if status >= 400
@@ -149,11 +155,36 @@ module Exa
149
155
  [408, 409, 429].include?(status) || status >= 500
150
156
  end
151
157
 
152
- def retry_delay(retry_count)
158
+ def retry_delay(retry_count, response = nil)
159
+ header_delay = retry_after_delay(response)
160
+ return header_delay if header_delay
161
+
153
162
  delay = initial_retry_delay * (2**retry_count)
154
163
  [delay, max_retry_delay].min
155
164
  end
156
165
 
166
+ def retry_after_delay(response)
167
+ return nil unless response
168
+ value = nil
169
+ if response.respond_to?(:[])
170
+ value = response["Retry-After"] || response["retry-after"]
171
+ end
172
+ unless value
173
+ if response.respond_to?(:each_header)
174
+ response.each_header do |k, v|
175
+ if k.downcase == "retry-after"
176
+ value = v
177
+ break
178
+ end
179
+ end
180
+ end
181
+ end
182
+ return nil unless value
183
+ parsed = Integer(value) rescue Float(value) rescue nil
184
+ return nil unless parsed
185
+ [parsed.to_f, max_retry_delay].min
186
+ end
187
+
157
188
  def dig(obj, path)
158
189
  Array(path).reduce(obj) do |memo, key|
159
190
  memo.is_a?(Hash) ? memo[key] : nil
@@ -165,6 +196,15 @@ module Exa
165
196
  return model.from_hash(data) if model.respond_to?(:from_hash)
166
197
  model.new(data)
167
198
  end
199
+
200
+ def normalize_request_options(options)
201
+ return {} if options.nil?
202
+ opts = {}
203
+ opts[:timeout] = options[:timeout] if options[:timeout]
204
+ opts[:max_retries] = options[:max_retries] if options[:max_retries]
205
+ opts[:idempotency_key] = options[:idempotency_key] if options[:idempotency_key]
206
+ opts
207
+ end
168
208
  end
169
209
  end
170
210
  end
@@ -47,7 +47,16 @@ module Exa
47
47
  end
48
48
 
49
49
  _, response = enum.next
50
- body = Exa::Internal::Util.fused_enum(enum)
50
+ body_stream = Enumerator.new do |y|
51
+ loop do
52
+ begin
53
+ y << enum.next
54
+ rescue StopIteration
55
+ break
56
+ end
57
+ end
58
+ end
59
+ body = Exa::Internal::Util.fused_enum(body_stream)
51
60
  [Integer(response.code), response, body]
52
61
  end
53
62
  end
@@ -21,9 +21,16 @@ module Exa
21
21
  end
22
22
 
23
23
  def answer(params)
24
+ stream = stream_requested?(params)
24
25
  normalized = normalize_nested_struct(params, :search_options, Exa::Types::AnswerSearchOptions)
25
26
  payload = serialize(Exa::Types::AnswerRequest, normalized)
26
- client.request(method: :post, path: "answer", body: payload)
27
+ client.request(
28
+ method: :post,
29
+ path: "answer",
30
+ body: payload,
31
+ stream: stream,
32
+ response_model: stream ? nil : Exa::Responses::AnswerResponse
33
+ )
27
34
  end
28
35
 
29
36
  private
@@ -39,6 +46,19 @@ module Exa
39
46
  merged.delete(key.to_s)
40
47
  merged
41
48
  end
49
+
50
+ def stream_requested?(params)
51
+ case params
52
+ when Hash
53
+ value = params[:stream]
54
+ value = params["stream"] if value.nil?
55
+ !!value
56
+ when Exa::Types::AnswerRequest
57
+ !!params.stream
58
+ else
59
+ false
60
+ end
61
+ end
42
62
  end
43
63
  end
44
64
  end
@@ -61,6 +61,23 @@ module Exa
61
61
  )
62
62
  end
63
63
 
64
+ def cancel(webset_id)
65
+ client.request(
66
+ method: :post,
67
+ path: websets_path(webset_id, "cancel"),
68
+ response_model: Exa::Responses::Webset
69
+ )
70
+ end
71
+
72
+ def preview(params)
73
+ client.request(
74
+ method: :post,
75
+ path: ["v0", "websets", "preview"],
76
+ body: params,
77
+ response_model: Exa::Responses::Webset
78
+ )
79
+ end
80
+
64
81
  private
65
82
 
66
83
  def websets_path(*parts)
@@ -0,0 +1,77 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Exa
4
+ module Responses
5
+ class AnswerCitation < T::Struct
6
+ const :id, T.nilable(String)
7
+ const :url, T.nilable(String)
8
+ const :title, T.nilable(String)
9
+ const :author, T.nilable(String)
10
+ const :published_date, T.nilable(String)
11
+ const :text, T.nilable(String)
12
+ const :image, T.nilable(String)
13
+ const :favicon, T.nilable(String)
14
+
15
+ def self.from_hash(hash)
16
+ sym = Helpers.symbolize_keys(hash)
17
+ new(
18
+ id: sym[:id],
19
+ url: sym[:url],
20
+ title: sym[:title],
21
+ author: sym[:author],
22
+ published_date: sym[:publishedDate],
23
+ text: sym[:text],
24
+ image: sym[:image],
25
+ favicon: sym[:favicon]
26
+ )
27
+ end
28
+ end
29
+
30
+ class CostBreakdown < T::Struct
31
+ const :search, T.nilable(Float)
32
+ const :contents, T.nilable(Float)
33
+ const :breakdown, T.nilable(T::Hash[Symbol, T.nilable(Float)])
34
+
35
+ def self.from_hash(hash)
36
+ return nil unless hash
37
+ sym = Helpers.symbolize_keys(hash)
38
+ new(
39
+ search: sym[:search]&.to_f,
40
+ contents: sym[:contents]&.to_f,
41
+ breakdown: sym[:breakdown]&.transform_keys(&:to_sym)
42
+ )
43
+ end
44
+ end
45
+
46
+ class CostDollars < T::Struct
47
+ const :total, T.nilable(Float)
48
+ const :break_down, T.nilable(T::Array[CostBreakdown])
49
+ const :per_request_prices, T.nilable(T::Hash[Symbol, T.untyped])
50
+
51
+ def self.from_hash(hash)
52
+ return nil unless hash
53
+ sym = Helpers.symbolize_keys(hash)
54
+ new(
55
+ total: sym[:total]&.to_f,
56
+ break_down: sym[:breakDown]&.map { CostBreakdown.from_hash(_1) },
57
+ per_request_prices: sym[:perRequestPrices]&.transform_keys(&:to_sym)
58
+ )
59
+ end
60
+ end
61
+
62
+ class AnswerResponse < T::Struct
63
+ const :answer, T.nilable(String)
64
+ const :citations, T::Array[AnswerCitation]
65
+ const :cost_dollars, T.nilable(CostDollars)
66
+
67
+ def self.from_hash(hash)
68
+ sym = Helpers.symbolize_keys(hash)
69
+ new(
70
+ answer: sym[:answer],
71
+ citations: Array(sym[:citations]).map { AnswerCitation.from_hash(_1) },
72
+ cost_dollars: CostDollars.from_hash(sym[:costDollars])
73
+ )
74
+ end
75
+ end
76
+ end
77
+ end
@@ -8,7 +8,7 @@ module Exa
8
8
  const :search_type, T.nilable(String)
9
9
  const :results, T::Array[ResultWithContent]
10
10
  const :context, T.nilable(String)
11
- const :cost_dollars, T.nilable(Float)
11
+ const :cost_dollars, T.nilable(T.any(Float, T::Hash[Symbol, T.untyped]))
12
12
 
13
13
  def self.from_hash(hash)
14
14
  sym = Helpers.symbolize_keys(hash)
@@ -18,9 +18,22 @@ module Exa
18
18
  search_type: sym[:searchType],
19
19
  results: Array(sym[:results]).map { ResultWithContent.from_hash(_1) },
20
20
  context: sym[:context],
21
- cost_dollars: sym[:costDollars]&.to_f
21
+ cost_dollars: normalize_cost(sym[:costDollars])
22
22
  )
23
23
  end
24
+
25
+ def self.normalize_cost(value)
26
+ case value
27
+ when nil
28
+ nil
29
+ when Numeric
30
+ value.to_f
31
+ when Hash
32
+ Exa::Responses::Helpers.symbolize_keys(value)
33
+ else
34
+ value
35
+ end
36
+ end
24
37
  end
25
38
 
26
39
  class FindSimilarResponse < T::Struct
data/lib/exa/responses.rb CHANGED
@@ -10,4 +10,5 @@ require_relative "responses/webhook_response"
10
10
  require_relative "responses/import_response"
11
11
  require_relative "responses/webset_response"
12
12
  require_relative "responses/research_response"
13
+ require_relative "responses/answer_response"
13
14
  require_relative "responses/raw_response"
@@ -17,6 +17,7 @@ module Exa
17
17
  const :query, String
18
18
  const :summary, T.nilable(AnswerSummaryOptions)
19
19
  const :search_options, T.nilable(AnswerSearchOptions)
20
+ const :stream, T.nilable(T::Boolean)
20
21
 
21
22
  def to_payload
22
23
  payload = Serializer.to_payload(self)
data/lib/exa/version.rb CHANGED
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module Exa
4
- VERSION = "1.0.0"
4
+ VERSION = "1.1.1"
5
5
  end
metadata CHANGED
@@ -1,7 +1,7 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: exa-ai-ruby
3
3
  version: !ruby/object:Gem::Version
4
- version: 1.0.0
4
+ version: 1.1.1
5
5
  platform: ruby
6
6
  authors:
7
7
  - Vicente Reig Rincon de Arellano
@@ -51,6 +51,48 @@ dependencies:
51
51
  - - "~>"
52
52
  - !ruby/object:Gem::Version
53
53
  version: '1.0'
54
+ - !ruby/object:Gem::Dependency
55
+ name: thor
56
+ requirement: !ruby/object:Gem::Requirement
57
+ requirements:
58
+ - - "~>"
59
+ - !ruby/object:Gem::Version
60
+ version: '1.3'
61
+ type: :runtime
62
+ prerelease: false
63
+ version_requirements: !ruby/object:Gem::Requirement
64
+ requirements:
65
+ - - "~>"
66
+ - !ruby/object:Gem::Version
67
+ version: '1.3'
68
+ - !ruby/object:Gem::Dependency
69
+ name: tty-table
70
+ requirement: !ruby/object:Gem::Requirement
71
+ requirements:
72
+ - - "~>"
73
+ - !ruby/object:Gem::Version
74
+ version: '0.12'
75
+ type: :runtime
76
+ prerelease: false
77
+ version_requirements: !ruby/object:Gem::Requirement
78
+ requirements:
79
+ - - "~>"
80
+ - !ruby/object:Gem::Version
81
+ version: '0.12'
82
+ - !ruby/object:Gem::Dependency
83
+ name: pastel
84
+ requirement: !ruby/object:Gem::Requirement
85
+ requirements:
86
+ - - "~>"
87
+ - !ruby/object:Gem::Version
88
+ version: '0.8'
89
+ type: :runtime
90
+ prerelease: false
91
+ version_requirements: !ruby/object:Gem::Requirement
92
+ requirements:
93
+ - - "~>"
94
+ - !ruby/object:Gem::Version
95
+ version: '0.8'
54
96
  - !ruby/object:Gem::Dependency
55
97
  name: minitest
56
98
  requirement: !ruby/object:Gem::Requirement
@@ -93,17 +135,66 @@ dependencies:
93
135
  - - "~>"
94
136
  - !ruby/object:Gem::Version
95
137
  version: '1.64'
138
+ - !ruby/object:Gem::Dependency
139
+ name: aruba
140
+ requirement: !ruby/object:Gem::Requirement
141
+ requirements:
142
+ - - "~>"
143
+ - !ruby/object:Gem::Version
144
+ version: '2.2'
145
+ type: :development
146
+ prerelease: false
147
+ version_requirements: !ruby/object:Gem::Requirement
148
+ requirements:
149
+ - - "~>"
150
+ - !ruby/object:Gem::Version
151
+ version: '2.2'
152
+ - !ruby/object:Gem::Dependency
153
+ name: webmock
154
+ requirement: !ruby/object:Gem::Requirement
155
+ requirements:
156
+ - - "~>"
157
+ - !ruby/object:Gem::Version
158
+ version: '3.23'
159
+ type: :development
160
+ prerelease: false
161
+ version_requirements: !ruby/object:Gem::Requirement
162
+ requirements:
163
+ - - "~>"
164
+ - !ruby/object:Gem::Version
165
+ version: '3.23'
166
+ - !ruby/object:Gem::Dependency
167
+ name: webrick
168
+ requirement: !ruby/object:Gem::Requirement
169
+ requirements:
170
+ - - "~>"
171
+ - !ruby/object:Gem::Version
172
+ version: '1.8'
173
+ type: :development
174
+ prerelease: false
175
+ version_requirements: !ruby/object:Gem::Requirement
176
+ requirements:
177
+ - - "~>"
178
+ - !ruby/object:Gem::Version
179
+ version: '1.8'
96
180
  description: Exa API client in Ruby, Sorbet-friendly and inspired by openai-ruby.
97
181
  email:
98
182
  - hey@vicente.services
99
- executables: []
183
+ executables:
184
+ - exa
100
185
  extensions: []
101
186
  extra_rdoc_files: []
102
187
  files:
103
188
  - CHANGELOG.md
104
189
  - LICENSE
105
190
  - README.md
191
+ - exe/exa
106
192
  - lib/exa.rb
193
+ - lib/exa/cli.rb
194
+ - lib/exa/cli/account_resolver.rb
195
+ - lib/exa/cli/config_store.rb
196
+ - lib/exa/cli/formatters.rb
197
+ - lib/exa/cli/root.rb
107
198
  - lib/exa/client.rb
108
199
  - lib/exa/errors.rb
109
200
  - lib/exa/internal/transport/base_client.rb
@@ -122,6 +213,7 @@ files:
122
213
  - lib/exa/resources/websets/items.rb
123
214
  - lib/exa/resources/websets/monitors.rb
124
215
  - lib/exa/responses.rb
216
+ - lib/exa/responses/answer_response.rb
125
217
  - lib/exa/responses/contents_response.rb
126
218
  - lib/exa/responses/event_response.rb
127
219
  - lib/exa/responses/helpers.rb