tavily 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,69 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Tavily
4
+ # Holds configuration for a {Tavily::Client}. A global instance is available
5
+ # via {Tavily.configuration} / {Tavily.configure}; individual clients may
6
+ # override any value through keyword arguments to {Tavily::Client#initialize}.
7
+ class Configuration
8
+ # Default Tavily REST API base URL.
9
+ DEFAULT_BASE_URL = "https://api.tavily.com"
10
+ # Default read timeout, in seconds.
11
+ DEFAULT_TIMEOUT = 60
12
+ # Default connection-open timeout, in seconds.
13
+ DEFAULT_OPEN_TIMEOUT = 10
14
+ # Default number of automatic retries for transient failures.
15
+ DEFAULT_MAX_RETRIES = 2
16
+ # Base delay (seconds) for exponential backoff between retries.
17
+ DEFAULT_RETRY_BASE_DELAY = 0.5
18
+
19
+ # @return [String, nil] Tavily API key (e.g. "tvly-...").
20
+ attr_accessor :api_key
21
+ # @return [String] API base URL.
22
+ attr_accessor :base_url
23
+ # @return [Numeric] read timeout in seconds.
24
+ attr_accessor :timeout
25
+ # @return [Numeric] open timeout in seconds.
26
+ attr_accessor :open_timeout
27
+ # @return [Integer] maximum number of retries for transient errors.
28
+ attr_accessor :max_retries
29
+ # @return [Numeric] base delay for exponential backoff, in seconds.
30
+ attr_accessor :retry_base_delay
31
+ # @return [String, nil] proxy URL (e.g. "http://user:pass@host:port").
32
+ attr_accessor :proxy
33
+ # @return [String, nil] path to a PEM CA-certificate bundle. Defaults to
34
+ # ENV["SSL_CERT_FILE"]. Handy on Windows, where some Ruby builds ship
35
+ # without a usable default certificate store.
36
+ attr_accessor :ca_file
37
+ # @return [Logger, nil] optional logger for request/response diagnostics.
38
+ attr_accessor :logger
39
+ # @return [String] User-Agent header sent with every request.
40
+ attr_accessor :user_agent
41
+
42
+ def initialize
43
+ @api_key = ENV.fetch("TAVILY_API_KEY", nil)
44
+ @base_url = ENV.fetch("TAVILY_BASE_URL", DEFAULT_BASE_URL)
45
+ @timeout = Float(ENV.fetch("TAVILY_TIMEOUT", DEFAULT_TIMEOUT))
46
+ @open_timeout = DEFAULT_OPEN_TIMEOUT
47
+ @max_retries = Integer(ENV.fetch("TAVILY_MAX_RETRIES", DEFAULT_MAX_RETRIES))
48
+ @retry_base_delay = DEFAULT_RETRY_BASE_DELAY
49
+ @proxy = ENV.fetch("TAVILY_PROXY", nil)
50
+ @ca_file = ENV.fetch("SSL_CERT_FILE", nil)
51
+ @logger = nil
52
+ @user_agent = "tavily-ruby/#{Tavily::VERSION}"
53
+ end
54
+
55
+ # @return [Hash] a shallow copy of the configuration as a hash.
56
+ def to_h
57
+ {
58
+ base_url: base_url,
59
+ timeout: timeout,
60
+ open_timeout: open_timeout,
61
+ max_retries: max_retries,
62
+ retry_base_delay: retry_base_delay,
63
+ proxy: proxy,
64
+ ca_file: ca_file,
65
+ user_agent: user_agent
66
+ }
67
+ end
68
+ end
69
+ end
@@ -0,0 +1,298 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "net/http"
4
+ require "json"
5
+ require "uri"
6
+ require "openssl"
7
+
8
+ module Tavily
9
+ # Thin HTTP layer over Ruby's standard library {Net::HTTP}. Handles JSON
10
+ # encoding/decoding, authentication, timeouts, automatic retries with
11
+ # exponential backoff, and translation of HTTP errors into {APIError}s.
12
+ #
13
+ # @api private
14
+ class Connection
15
+ # HTTP statuses that are safe to retry (transient).
16
+ RETRIABLE_STATUSES = [408, 409, 425, 429, 500, 502, 503, 504].freeze
17
+
18
+ # Network-level exceptions that warrant a retry.
19
+ RETRIABLE_TIMEOUTS = [
20
+ Net::OpenTimeout,
21
+ Net::ReadTimeout,
22
+ defined?(Net::WriteTimeout) ? Net::WriteTimeout : nil
23
+ ].compact.freeze
24
+
25
+ RETRIABLE_NETWORK = [
26
+ SocketError,
27
+ SystemCallError,
28
+ IOError,
29
+ OpenSSL::SSL::SSLError,
30
+ EOFError
31
+ ].freeze
32
+
33
+ # @param config [Configuration]
34
+ def initialize(config)
35
+ @config = config
36
+ end
37
+
38
+ # Issue a POST request with a JSON body.
39
+ # @param path [String] request path (e.g. "/search")
40
+ # @param body [Hash] request body; nil values are removed before sending
41
+ # @return [Hash, Array, String] parsed response body
42
+ def post(path, body = {})
43
+ request(:post, path, body: body)
44
+ end
45
+
46
+ # Issue a GET request with query parameters.
47
+ # @param path [String]
48
+ # @param params [Hash]
49
+ # @return [Hash, Array, String] parsed response body
50
+ def get(path, params = {})
51
+ request(:get, path, query: params)
52
+ end
53
+
54
+ # Issue a POST request and yield Server-Sent Events as they arrive.
55
+ # Streaming responses are not retried.
56
+ #
57
+ # @param path [String]
58
+ # @param body [Hash]
59
+ # @yieldparam event [ResearchEvent]
60
+ # @return [nil]
61
+ def stream(path, body = {})
62
+ ensure_api_key!
63
+ uri = build_uri(path, nil)
64
+ http = build_http(uri)
65
+ req = build_request(:post, uri, body)
66
+ req["Accept"] = "text/event-stream"
67
+
68
+ buffer = +""
69
+ begin
70
+ http.start do |conn|
71
+ conn.request(req) do |response|
72
+ status = response.code.to_i
73
+ raise stream_error(response) unless status.between?(200, 299)
74
+
75
+ response.read_body do |chunk|
76
+ buffer << chunk.gsub("\r\n", "\n")
77
+ while (idx = buffer.index("\n\n"))
78
+ raw_event = buffer.slice!(0..(idx + 1))
79
+ event = parse_sse_event(raw_event)
80
+ yield event if event
81
+ end
82
+ end
83
+ end
84
+ end
85
+ rescue *RETRIABLE_TIMEOUTS => e
86
+ raise TimeoutError, "Tavily stream timed out: #{e.message}"
87
+ rescue *RETRIABLE_NETWORK => e
88
+ raise ConnectionError, "Tavily stream connection failed: #{e.message}"
89
+ end
90
+
91
+ event = parse_sse_event(buffer)
92
+ yield event if event
93
+ nil
94
+ end
95
+
96
+ private
97
+
98
+ def request(method, path, body: nil, query: nil)
99
+ ensure_api_key!
100
+ uri = build_uri(path, query)
101
+ attempt = 0
102
+
103
+ loop do
104
+ attempt += 1
105
+ response =
106
+ begin
107
+ perform(method, uri, body)
108
+ rescue *RETRIABLE_TIMEOUTS => e
109
+ if attempt > @config.max_retries
110
+ raise TimeoutError,
111
+ "Tavily request timed out after #{@config.timeout}s: #{e.message}"
112
+ end
113
+
114
+ log { "timeout (attempt #{attempt}), retrying: #{e.message}" }
115
+ sleep(backoff(attempt))
116
+ next
117
+ rescue *RETRIABLE_NETWORK => e
118
+ raise ConnectionError, "Tavily connection failed: #{e.message}" if attempt > @config.max_retries
119
+
120
+ log { "network error (attempt #{attempt}), retrying: #{e.message}" }
121
+ sleep(backoff(attempt))
122
+ next
123
+ end
124
+
125
+ status = response.code.to_i
126
+ if RETRIABLE_STATUSES.include?(status) && attempt <= @config.max_retries
127
+ log { "HTTP #{status} (attempt #{attempt}), retrying" }
128
+ sleep(backoff(attempt, response))
129
+ next
130
+ end
131
+
132
+ return handle(response, status)
133
+ end
134
+ end
135
+
136
+ def perform(method, uri, body)
137
+ http = build_http(uri)
138
+ req = build_request(method, uri, body)
139
+ started = monotonic_now
140
+ response = http.request(req)
141
+ log { "#{method.to_s.upcase} #{uri.path} -> #{response.code} in #{format("%.3f", monotonic_now - started)}s" }
142
+ response
143
+ end
144
+
145
+ def build_http(uri)
146
+ http =
147
+ if @config.proxy && !@config.proxy.empty?
148
+ proxy = URI.parse(@config.proxy)
149
+ Net::HTTP.new(uri.host, uri.port, proxy.host, proxy.port, proxy.user, proxy.password)
150
+ else
151
+ Net::HTTP.new(uri.host, uri.port)
152
+ end
153
+
154
+ http.use_ssl = uri.scheme == "https"
155
+ http.ca_file = @config.ca_file if http.use_ssl? && @config.ca_file && File.exist?(@config.ca_file)
156
+ http.open_timeout = @config.open_timeout
157
+ http.read_timeout = @config.timeout
158
+ http.write_timeout = @config.timeout if http.respond_to?(:write_timeout=)
159
+ http
160
+ end
161
+
162
+ def build_request(method, uri, body)
163
+ klass = method == :post ? Net::HTTP::Post : Net::HTTP::Get
164
+ req = klass.new(uri)
165
+ req["Authorization"] = "Bearer #{@config.api_key}"
166
+ req["Content-Type"] = "application/json"
167
+ req["Accept"] = "application/json"
168
+ req["User-Agent"] = @config.user_agent
169
+
170
+ payload = compact(body)
171
+ req.body = JSON.generate(payload) if payload && !payload.empty?
172
+ req
173
+ end
174
+
175
+ def build_uri(path, query)
176
+ uri = URI.join("#{@config.base_url.chomp("/")}/", path.to_s.sub(%r{\A/}, ""))
177
+ uri.query = URI.encode_www_form(compact(query)) if query && !query.empty?
178
+ uri
179
+ end
180
+
181
+ def handle(response, status)
182
+ parsed = parse_body(response.body)
183
+ return parsed if status.between?(200, 299)
184
+
185
+ raise build_error(status, parsed)
186
+ end
187
+
188
+ def parse_body(raw)
189
+ return {} if raw.nil? || raw.empty?
190
+
191
+ JSON.parse(raw)
192
+ rescue JSON::ParserError
193
+ raw
194
+ end
195
+
196
+ def build_error(status, parsed)
197
+ request_id = parsed.is_a?(Hash) ? parsed["request_id"] : nil
198
+ message = extract_error_message(parsed)
199
+ Errors.class_for(status).new(message, status: status, body: parsed, request_id: request_id)
200
+ end
201
+
202
+ # Tavily returns errors in a few shapes:
203
+ # {"detail": {"error": "..."}} (auth / bad request)
204
+ # {"detail": [{"loc": [...], "msg": "...", ...}]} (FastAPI validation)
205
+ # {"error": "..."} / {"message": "..."} (fallbacks)
206
+ def extract_error_message(parsed)
207
+ return parsed.to_s unless parsed.is_a?(Hash)
208
+
209
+ detail = parsed["detail"] || parsed["error"] || parsed["message"]
210
+ case detail
211
+ when String then detail
212
+ when Hash then detail["error"] || detail["message"] || detail.to_s
213
+ when Array then detail.map { |d| format_detail(d) }.join("; ")
214
+ else parsed.to_s
215
+ end
216
+ end
217
+
218
+ def format_detail(entry)
219
+ return entry.to_s unless entry.is_a?(Hash)
220
+
221
+ loc = Array(entry["loc"]).reject { |part| part == "body" }.join(".")
222
+ msg = entry["msg"] || entry["error"] || entry.to_s
223
+ loc.empty? ? msg : "#{loc}: #{msg}"
224
+ end
225
+
226
+ # Remove nil values so callers can pass optional params freely without
227
+ # accidentally sending nulls the API would reject.
228
+ def compact(hash)
229
+ return hash unless hash.is_a?(Hash)
230
+
231
+ hash.each_with_object({}) do |(key, value), acc|
232
+ acc[key] = value unless value.nil?
233
+ end
234
+ end
235
+
236
+ def backoff(attempt, response = nil)
237
+ if response && (retry_after = response["retry-after"])
238
+ seconds = retry_after.to_f
239
+ return seconds if seconds.positive?
240
+ end
241
+
242
+ base = @config.retry_base_delay * (2**(attempt - 1))
243
+ jitter = base * 0.25 * rand
244
+ base + jitter
245
+ end
246
+
247
+ def stream_error(response)
248
+ body = +""
249
+ response.read_body { |chunk| body << chunk }
250
+ build_error(response.code.to_i, parse_body(body))
251
+ end
252
+
253
+ def parse_sse_event(raw)
254
+ event_name = nil
255
+ data_lines = []
256
+ raw.each_line(chomp: true) do |line|
257
+ next if line.empty? || line.start_with?(":")
258
+
259
+ field, _separator, value = line.partition(":")
260
+ value = value.sub(/\A /, "")
261
+ case field
262
+ when "event" then event_name = value
263
+ when "data" then data_lines << value
264
+ end
265
+ end
266
+ return nil if event_name.nil? && data_lines.empty?
267
+
268
+ ResearchEvent.new(event: event_name, data: parse_sse_data(data_lines.join("\n")))
269
+ end
270
+
271
+ def parse_sse_data(raw)
272
+ return nil if raw.empty?
273
+
274
+ JSON.parse(raw)
275
+ rescue JSON::ParserError
276
+ raw
277
+ end
278
+
279
+ def ensure_api_key!
280
+ return unless @config.api_key.nil? || @config.api_key.to_s.empty?
281
+
282
+ raise ConfigurationError,
283
+ "Missing Tavily API key. Set ENV['TAVILY_API_KEY'], call " \
284
+ "Tavily.configure { |c| c.api_key = ... }, or pass api_key: to Tavily::Client.new."
285
+ end
286
+
287
+ def monotonic_now
288
+ Process.clock_gettime(Process::CLOCK_MONOTONIC)
289
+ end
290
+
291
+ def log
292
+ logger = @config.logger
293
+ return unless logger
294
+
295
+ logger.debug("[tavily] #{yield}")
296
+ end
297
+ end
298
+ end
@@ -0,0 +1,95 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Tavily
4
+ # Base class for every error raised by this library. Rescue this to catch
5
+ # anything the gem might raise.
6
+ class Error < StandardError; end
7
+
8
+ # Raised when the client is misconfigured (e.g. a missing API key).
9
+ class ConfigurationError < Error; end
10
+
11
+ # Raised when a request exceeds the configured timeout.
12
+ class TimeoutError < Error; end
13
+
14
+ # Raised when the connection to the API fails at the network/TLS layer.
15
+ class ConnectionError < Error; end
16
+
17
+ # Base class for errors returned by the Tavily API (non-2xx responses).
18
+ # Carries the HTTP status, parsed body, and request id when available.
19
+ class APIError < Error
20
+ # @return [Integer, nil] HTTP status code.
21
+ attr_reader :status
22
+ # @return [Object, nil] parsed response body (Hash, Array, or String).
23
+ attr_reader :body
24
+ # @return [String, nil] Tavily request id, useful for support tickets.
25
+ attr_reader :request_id
26
+
27
+ # @param message [String, nil] human-readable error message
28
+ # @param status [Integer, nil] HTTP status code
29
+ # @param body [Object, nil] parsed response body
30
+ # @param request_id [String, nil] Tavily request id
31
+ def initialize(message = nil, status: nil, body: nil, request_id: nil)
32
+ @status = status
33
+ @body = body
34
+ @request_id = request_id
35
+ super(build_message(message))
36
+ end
37
+
38
+ private
39
+
40
+ def build_message(message)
41
+ parts = []
42
+ parts << "[#{status}]" if status
43
+ parts << (message || "Tavily API error")
44
+ parts << "(request_id: #{request_id})" if request_id
45
+ parts.join(" ")
46
+ end
47
+ end
48
+
49
+ # 400 — the request was malformed or a parameter value was invalid.
50
+ class BadRequestError < APIError; end
51
+ # 401 — missing or invalid API key.
52
+ class AuthenticationError < APIError; end
53
+ # 403 — the key is valid but not permitted (plan/usage limit reached).
54
+ class ForbiddenError < APIError; end
55
+ # 404 — the requested resource does not exist.
56
+ class NotFoundError < APIError; end
57
+ # 422 — request body failed validation.
58
+ class UnprocessableEntityError < APIError; end
59
+ # 429 — rate limit exceeded.
60
+ class RateLimitError < APIError; end
61
+
62
+ # Base class for Tavily's non-standard usage/quota limit errors (432, 433).
63
+ class UsageLimitError < APIError; end
64
+ # 432 — plan/key credit quota exceeded (non-standard Tavily status code).
65
+ class PlanLimitError < UsageLimitError; end
66
+ # 433 — pay-as-you-go spending limit exceeded (non-standard Tavily status code).
67
+ class PayAsYouGoLimitError < UsageLimitError; end
68
+
69
+ # 5xx — Tavily server-side error.
70
+ class ServerError < APIError; end
71
+
72
+ # Maps HTTP status codes to {APIError} subclasses.
73
+ module Errors
74
+ # @api private
75
+ STATUS_MAP = {
76
+ 400 => BadRequestError,
77
+ 401 => AuthenticationError,
78
+ 403 => ForbiddenError,
79
+ 404 => NotFoundError,
80
+ 422 => UnprocessableEntityError,
81
+ 429 => RateLimitError,
82
+ 432 => PlanLimitError,
83
+ 433 => PayAsYouGoLimitError
84
+ }.freeze
85
+
86
+ # @param status [Integer] HTTP status code
87
+ # @return [Class] the most specific {APIError} subclass for +status+
88
+ def self.class_for(status)
89
+ return STATUS_MAP[status] if STATUS_MAP.key?(status)
90
+ return ServerError if status >= 500
91
+
92
+ APIError
93
+ end
94
+ end
95
+ end
@@ -0,0 +1,80 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Tavily
4
+ # Base class for every response object returned by the client. Wraps the
5
+ # parsed JSON hash, exposing typed accessors (declared with {.attribute})
6
+ # while remaining forward-compatible: any field the API adds is still
7
+ # reachable via {#[]}, {#dig}, and {#to_h}.
8
+ class Object
9
+ # @return [Hash] the raw, parsed response body with string keys.
10
+ attr_reader :attributes
11
+
12
+ # Declare a typed accessor for a response field.
13
+ #
14
+ # @param name [Symbol] method name to define
15
+ # @param key [String] the key in the raw hash (defaults to +name+)
16
+ # @param wrap [Class, String, nil] wrap the value in this {Tavily::Object}
17
+ # subclass (may be given as a String to allow forward references)
18
+ # @param collection [Boolean] when true, treat the value as an array and
19
+ # wrap each element
20
+ # @return [void]
21
+ def self.attribute(name, key: name.to_s, wrap: nil, collection: false)
22
+ define_method(name) do
23
+ raw = @attributes[key]
24
+ return raw if wrap.nil?
25
+
26
+ klass = wrap.is_a?(Class) ? wrap : Tavily.const_get(wrap)
27
+ if collection
28
+ Array(raw).map { |item| item.is_a?(Hash) ? klass.new(item) : item }
29
+ elsif raw.is_a?(Hash)
30
+ klass.new(raw)
31
+ else
32
+ raw
33
+ end
34
+ end
35
+ end
36
+
37
+ # @param attributes [Hash] parsed response body
38
+ def initialize(attributes = {})
39
+ @attributes = attributes.is_a?(Hash) ? attributes : {}
40
+ end
41
+
42
+ # Fetch a raw field by name (String or Symbol).
43
+ # @param key [String, Symbol]
44
+ # @return [Object, nil]
45
+ def [](key)
46
+ @attributes[key.to_s]
47
+ end
48
+
49
+ # @see Hash#dig
50
+ # @return [Object, nil]
51
+ def dig(*keys)
52
+ @attributes.dig(*keys.map(&:to_s))
53
+ end
54
+
55
+ # @param key [String, Symbol]
56
+ # @return [Boolean] whether the raw field is present
57
+ def key?(key)
58
+ @attributes.key?(key.to_s)
59
+ end
60
+
61
+ # @return [Hash] the raw response body
62
+ def to_h
63
+ @attributes
64
+ end
65
+ alias to_hash to_h
66
+
67
+ def ==(other)
68
+ other.is_a?(self.class) && other.attributes == attributes
69
+ end
70
+ alias eql? ==
71
+
72
+ def hash
73
+ [self.class, @attributes].hash
74
+ end
75
+
76
+ def inspect
77
+ "#<#{self.class.name} #{@attributes.inspect}>"
78
+ end
79
+ end
80
+ end