oz-agent-sdk 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.
data/lib/oz/client.rb ADDED
@@ -0,0 +1,299 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'faraday'
4
+ require 'json'
5
+ require 'time'
6
+ require 'date'
7
+
8
+ module Oz
9
+ # HTTP client for the Oz API.
10
+ #
11
+ # client = Oz::Client.new(api_key: ENV["WARP_API_KEY"])
12
+ # run = client.agent.run(prompt: "Fix the bug in auth.rb")
13
+ # puts run.run_id
14
+ #
15
+ # The API key defaults to the +WARP_API_KEY+ environment variable and the base
16
+ # URL to +OZ_API_BASE_URL+ (falling back to https://app.warp.dev/api/v1).
17
+ # Transient failures (timeouts, connection errors, HTTP 408/409/429/5xx) are
18
+ # retried automatically with exponential backoff.
19
+ class Client
20
+ # Initial backoff before the first retry, in seconds.
21
+ INITIAL_RETRY_DELAY = 0.5
22
+ # Maximum backoff between retries, in seconds.
23
+ MAX_RETRY_DELAY = 8.0
24
+ # Statuses (besides 5xx) that trigger an automatic retry.
25
+ RETRYABLE_STATUSES = [408, 409, 429].freeze
26
+
27
+ attr_reader :api_key, :base_url, :timeout, :max_retries, :default_headers
28
+
29
+ # @param api_key [String, nil] Bearer token (defaults to +WARP_API_KEY+)
30
+ # @param base_url [String, nil] API base URL (defaults to +OZ_API_BASE_URL+)
31
+ # @param timeout [Integer, Float, nil] per-request timeout in seconds
32
+ # @param max_retries [Integer, nil] retries for transient failures
33
+ # @param default_headers [Hash, nil] extra headers for every request
34
+ # @param logger [Logger, nil] enables Faraday request/response logging
35
+ # @param adapter [Symbol, nil] Faraday adapter (defaults to net_http)
36
+ def initialize(api_key: nil, base_url: nil, timeout: nil, max_retries: nil,
37
+ default_headers: nil, logger: nil, adapter: nil)
38
+ config = Oz.configuration
39
+ @api_key = api_key || ENV.fetch('WARP_API_KEY', nil) || config.api_key
40
+ @base_url = normalize_base_url(base_url || ENV.fetch('OZ_API_BASE_URL', nil) || config.base_url)
41
+ @timeout = timeout || config.timeout
42
+ @max_retries = max_retries || config.max_retries
43
+ @logger = logger || config.logger
44
+ @adapter = adapter || config.adapter || Faraday.default_adapter
45
+ @default_headers = build_default_headers(default_headers || config.default_headers)
46
+
47
+ if @api_key.nil? || @api_key.to_s.empty?
48
+ raise AuthenticationError,
49
+ 'The api_key client option must be set either by passing api_key to the client ' \
50
+ 'or by setting the WARP_API_KEY environment variable'
51
+ end
52
+
53
+ @connection = build_connection
54
+ end
55
+
56
+ # @return [Oz::Resources::Agent] the agent resource and its sub-resources.
57
+ def agent
58
+ @agent ||= Resources::Agent.new(self)
59
+ end
60
+
61
+ # @!group Low-level HTTP verbs
62
+
63
+ def get(path, query: nil, headers: nil)
64
+ request(:get, path, query: query, headers: headers)
65
+ end
66
+
67
+ def post(path, body: nil, query: nil, headers: nil)
68
+ request(:post, path, body: body, query: query, headers: headers)
69
+ end
70
+
71
+ def put(path, body: nil, query: nil, headers: nil)
72
+ request(:put, path, body: body, query: query, headers: headers)
73
+ end
74
+
75
+ def delete(path, query: nil, headers: nil)
76
+ request(:delete, path, query: query, headers: headers)
77
+ end
78
+
79
+ # @!endgroup
80
+
81
+ # Performs an HTTP request with automatic retries and returns the decoded
82
+ # response body (a Hash, Array, String, or nil for empty/204 responses).
83
+ # @raise [Oz::APIError] on transport failures and non-2xx responses.
84
+ def request(method, path, body: nil, query: nil, headers: nil)
85
+ attempt = 0
86
+ loop do
87
+ attempt += 1
88
+ begin
89
+ response = execute(method, path, body, query, headers)
90
+ rescue APIConnectionError
91
+ raise if attempt > @max_retries
92
+
93
+ sleep(retry_delay(attempt, nil))
94
+ next
95
+ end
96
+
97
+ if should_retry?(response.status) && attempt <= @max_retries
98
+ sleep(retry_delay(attempt, response))
99
+ next
100
+ end
101
+
102
+ return process_response(response)
103
+ end
104
+ end
105
+
106
+ def inspect
107
+ "#<Oz::Client base_url=#{@base_url.inspect} timeout=#{@timeout} max_retries=#{@max_retries}>"
108
+ end
109
+ alias to_s inspect
110
+
111
+ private
112
+
113
+ def execute(method, path, body, query, headers)
114
+ @connection.run_request(method, build_url(path), nil, nil) do |req|
115
+ apply_headers(req, headers)
116
+ apply_query(req, query)
117
+ apply_body(req, body)
118
+ end
119
+ rescue Faraday::TimeoutError => e
120
+ raise APITimeoutError, "Request timed out: #{e.message}"
121
+ rescue Faraday::ConnectionFailed, Faraday::SSLError => e
122
+ raise APIConnectionError, "Connection failed: #{e.message}"
123
+ end
124
+
125
+ def build_url(path)
126
+ "#{@base_url}/#{path.to_s.sub(%r{\A/+}, '')}"
127
+ end
128
+
129
+ def apply_headers(req, headers)
130
+ @default_headers.each { |key, value| req.headers[key.to_s] = value }
131
+ return unless headers
132
+
133
+ headers.each { |key, value| req.headers[key.to_s] = value }
134
+ end
135
+
136
+ def apply_query(req, query)
137
+ prepared = prepare_query(query)
138
+ req.params.update(prepared) unless prepared.empty?
139
+ end
140
+
141
+ def apply_body(req, body)
142
+ return if body.nil?
143
+
144
+ prepared = prepare_value(body)
145
+ req.body = prepared unless prepared.nil?
146
+ end
147
+
148
+ # Decoded 2xx body, or raises a mapped error for >= 400.
149
+ def process_response(response)
150
+ raise_error(response) if response.status >= 400
151
+ return nil if response.status == 204
152
+
153
+ body = response.body
154
+ return nil if body.nil? || (body.is_a?(String) && body.strip.empty?)
155
+
156
+ body
157
+ end
158
+
159
+ def raise_error(response)
160
+ status = response.status
161
+ body = response.body
162
+ klass = Oz.error_class_for(status)
163
+ raise klass.new(
164
+ error_message(status, body),
165
+ status_code: status,
166
+ body: body,
167
+ code: error_code(body),
168
+ request_id: response.headers['x-request-id'],
169
+ response: response
170
+ )
171
+ end
172
+
173
+ def error_message(status, body)
174
+ detail =
175
+ if body.is_a?(Hash)
176
+ body['detail'] || body['message'] || body['title'] || body['error']
177
+ elsif body.is_a?(String) && !body.strip.empty?
178
+ body.strip
179
+ end
180
+ base = "Oz API error (HTTP #{status})"
181
+ detail ? "#{base}: #{detail}" : base
182
+ end
183
+
184
+ def error_code(body)
185
+ return nil unless body.is_a?(Hash)
186
+
187
+ explicit = body['code'] || body['error_code']
188
+ return explicit if explicit
189
+
190
+ type = body['type']
191
+ return unless type.is_a?(String) && !type.empty?
192
+
193
+ segment = type.split('/').last
194
+ segment unless segment.nil? || segment.empty?
195
+ end
196
+
197
+ def should_retry?(status)
198
+ RETRYABLE_STATUSES.include?(status) || status >= 500
199
+ end
200
+
201
+ # Exponential backoff with jitter; honours a numeric +Retry-After+ header.
202
+ def retry_delay(attempt, response)
203
+ if response
204
+ retry_after = response.headers['retry-after']
205
+ seconds = retry_after.to_f if retry_after
206
+ return [seconds, MAX_RETRY_DELAY].min if seconds&.positive?
207
+ end
208
+
209
+ delay = INITIAL_RETRY_DELAY * (2**(attempt - 1))
210
+ delay = [delay, MAX_RETRY_DELAY].min
211
+ delay + (delay * 0.25 * rand)
212
+ end
213
+
214
+ # Recursively prepares a value for JSON encoding: drops nil hash values,
215
+ # serializes Time/Date as ISO-8601, and leaves everything else intact.
216
+ def prepare_value(value)
217
+ case value
218
+ when Hash
219
+ value.each_with_object({}) do |(key, val), acc|
220
+ prepared = prepare_value(val)
221
+ acc[key] = prepared unless prepared.nil?
222
+ end
223
+ when Array
224
+ value.map { |item| prepare_value(item) }
225
+ when Time
226
+ value.utc.iso8601
227
+ when Date # also matches DateTime (a Date subclass); #iso8601 keeps the time part
228
+ value.iso8601
229
+ else
230
+ value
231
+ end
232
+ end
233
+
234
+ def prepare_query(query)
235
+ return {} if query.nil? || query.empty?
236
+
237
+ query.each_with_object({}) do |(key, value), acc|
238
+ next if value.nil?
239
+
240
+ acc[key] = prepare_query_value(value)
241
+ end
242
+ end
243
+
244
+ def prepare_query_value(value)
245
+ case value
246
+ when Time then value.utc.iso8601
247
+ when DateTime, Date then value.iso8601
248
+ when Array then value.map { |item| prepare_query_value(item) }
249
+ else value
250
+ end
251
+ end
252
+
253
+ def normalize_base_url(url)
254
+ (url || Configuration::DEFAULT_BASE_URL).to_s.sub(%r{/+\z}, '')
255
+ end
256
+
257
+ def build_default_headers(custom)
258
+ headers = {
259
+ 'Accept' => 'application/json',
260
+ 'User-Agent' => "oz-agent-sdk-ruby/#{Oz::VERSION}",
261
+ 'X-Stainless-Lang' => 'ruby',
262
+ 'X-Stainless-Package-Version' => Oz::VERSION
263
+ }
264
+ headers.merge!(parse_env_headers(ENV.fetch('OZ_API_CUSTOM_HEADERS', nil)))
265
+ headers.merge!(stringify_headers(custom)) if custom
266
+ headers['Authorization'] = "Bearer #{@api_key}"
267
+ headers
268
+ end
269
+
270
+ # Parses OZ_API_CUSTOM_HEADERS: newline-separated "Key: Value" lines.
271
+ def parse_env_headers(raw)
272
+ return {} if raw.nil? || raw.empty?
273
+
274
+ raw.split("\n").each_with_object({}) do |line, acc|
275
+ colon = line.index(':')
276
+ next unless colon && colon >= 0
277
+
278
+ key = line[0...colon].strip
279
+ acc[key] = line[(colon + 1)..].strip unless key.empty?
280
+ end
281
+ end
282
+
283
+ def stringify_headers(headers)
284
+ headers.each_with_object({}) { |(key, value), acc| acc[key.to_s] = value }
285
+ end
286
+
287
+ def build_connection
288
+ Faraday.new(url: @base_url) do |conn|
289
+ conn.request :json
290
+ conn.response :json, content_type: /\bjson/
291
+ conn.options.timeout = @timeout
292
+ conn.options.open_timeout = [@timeout, 10].min
293
+ conn.options.params_encoder = Faraday::FlatParamsEncoder
294
+ conn.response :logger, @logger, headers: false, bodies: false if @logger
295
+ conn.adapter @adapter
296
+ end
297
+ end
298
+ end
299
+ end
@@ -0,0 +1,46 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Oz
4
+ # Global configuration for the SDK.
5
+ #
6
+ # Values set here act as defaults for every {Oz::Client} created without
7
+ # explicit overrides. Configure it once at boot time:
8
+ #
9
+ # Oz.configure do |config|
10
+ # config.api_key = ENV.fetch("WARP_API_KEY")
11
+ # config.max_retries = 3
12
+ # end
13
+ class Configuration
14
+ # Default base URL for the Oz API.
15
+ DEFAULT_BASE_URL = 'https://app.warp.dev/api/v1'
16
+ # Default request timeout, in seconds.
17
+ DEFAULT_TIMEOUT = 60
18
+ # Default number of automatic retries for transient failures.
19
+ DEFAULT_MAX_RETRIES = 2
20
+
21
+ # @return [String, nil] Bearer token used to authenticate requests.
22
+ attr_accessor :api_key
23
+ # @return [String] base URL the client points at.
24
+ attr_accessor :base_url
25
+ # @return [Integer, Float] per-request timeout in seconds.
26
+ attr_accessor :timeout
27
+ # @return [Integer] number of retries for retryable failures.
28
+ attr_accessor :max_retries
29
+ # @return [Hash] extra headers sent on every request.
30
+ attr_accessor :default_headers
31
+ # @return [Logger, nil] optional logger; enables Faraday request logging.
32
+ attr_accessor :logger
33
+ # @return [Symbol, nil] Faraday adapter override (defaults to net_http).
34
+ attr_accessor :adapter
35
+
36
+ def initialize
37
+ @api_key = nil
38
+ @base_url = DEFAULT_BASE_URL
39
+ @timeout = DEFAULT_TIMEOUT
40
+ @max_retries = DEFAULT_MAX_RETRIES
41
+ @default_headers = {}
42
+ @logger = nil
43
+ @adapter = nil
44
+ end
45
+ end
46
+ end
@@ -0,0 +1,89 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Oz
4
+ # An Enumerable page of results from a cursor-paginated endpoint
5
+ # (currently +GET /agent/runs+).
6
+ #
7
+ # Iterate a single page directly, or use {#auto_paging_each} to transparently
8
+ # walk every page:
9
+ #
10
+ # page = client.agent.runs.list(limit: 50, state: ["INPROGRESS"])
11
+ # page.each { |run| puts run.run_id } # this page only
12
+ # page.auto_paging_each { |run| ... } # every page
13
+ # page.next_page if page.next_page?
14
+ class CursorPage
15
+ include Enumerable
16
+
17
+ # @return [Array<Oz::Model>] the items on this page.
18
+ attr_reader :data
19
+ # @return [Boolean, nil] server hint on whether more pages exist.
20
+ attr_reader :has_next_page
21
+ # @return [String, nil] cursor to fetch the next page.
22
+ attr_reader :next_cursor
23
+ # @return [Hash] the raw decoded response body.
24
+ attr_reader :raw
25
+
26
+ # @param body [Hash] decoded +{ "runs" => [...], "page_info" => {...} }+
27
+ # @param resource [#list] the resource used to fetch subsequent pages
28
+ # @param params [Hash] the filter params used for this request
29
+ # @param items_key [String] the key under which items live in +body+
30
+ def initialize(body, resource:, params: {}, items_key: 'runs')
31
+ @raw = body.is_a?(Hash) ? body : {}
32
+ @resource = resource
33
+ @params = params || {}
34
+ @items_key = items_key
35
+ @data = Array(@raw[items_key]).map { |item| Model.build(item) }
36
+ page_info = @raw['page_info'] || {}
37
+ @has_next_page = page_info['has_next_page']
38
+ @next_cursor = page_info['next_cursor']
39
+ end
40
+
41
+ # Iterates over the items on this page only.
42
+ def each(&)
43
+ return enum_for(:each) unless block_given?
44
+
45
+ @data.each(&)
46
+ end
47
+
48
+ # @return [Boolean] whether a further page can be fetched.
49
+ def next_page?
50
+ return false if @has_next_page == false
51
+
52
+ !(@next_cursor.nil? || @next_cursor.to_s.empty?)
53
+ end
54
+
55
+ # Fetches the next page, reusing the original filters with the new cursor.
56
+ # @return [CursorPage]
57
+ # @raise [Oz::Error] if there is no next page.
58
+ def next_page
59
+ raise Oz::Error, 'No next page available' unless next_page?
60
+
61
+ @resource.list(**@params, cursor: @next_cursor)
62
+ end
63
+
64
+ # Iterates over every item across all pages, fetching them lazily.
65
+ # Returns an Enumerator when no block is given.
66
+ def auto_paging_each(&block)
67
+ return enum_for(:auto_paging_each) unless block_given?
68
+
69
+ page = self
70
+ loop do
71
+ page.each(&block)
72
+ break unless page.next_page?
73
+
74
+ page = page.next_page
75
+ end
76
+ end
77
+
78
+ # @return [Boolean] whether this page has no items.
79
+ def empty?
80
+ @data.empty?
81
+ end
82
+
83
+ # @return [Integer] number of items on this page.
84
+ def size
85
+ @data.size
86
+ end
87
+ alias length size
88
+ end
89
+ end
data/lib/oz/errors.rb ADDED
@@ -0,0 +1,72 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Oz
4
+ # Base class for every error raised by the SDK.
5
+ class Error < StandardError; end
6
+
7
+ # Raised when the SDK is misconfigured before any request is made
8
+ # (e.g. a missing API key).
9
+ class ConfigurationError < Error; end
10
+
11
+ # Base class for all errors that originate from interacting with the API.
12
+ #
13
+ # Carries the HTTP status code, the (parsed) response body, a machine readable
14
+ # error +code+ when the API provides one, and the +request_id+ header so that
15
+ # failures can be correlated with server side logs.
16
+ class APIError < Error
17
+ attr_reader :status_code, :body, :code, :request_id, :response
18
+
19
+ def initialize(message = nil, status_code: nil, body: nil, code: nil, request_id: nil, response: nil)
20
+ super(message)
21
+ @status_code = status_code
22
+ @body = body
23
+ @code = code
24
+ @request_id = request_id
25
+ @response = response
26
+ end
27
+ end
28
+
29
+ # Raised when the request could not reach the API at all (DNS failure,
30
+ # connection refused, TLS error, ...).
31
+ class APIConnectionError < APIError; end
32
+
33
+ # Raised when a request exceeds the configured timeout.
34
+ class APITimeoutError < APIConnectionError; end
35
+
36
+ # Raised for any non-2xx response that is not mapped to a more specific
37
+ # subclass below.
38
+ class APIStatusError < APIError; end
39
+
40
+ # 400
41
+ class BadRequestError < APIStatusError; end
42
+ # 401 (also raised locally when no API key is configured)
43
+ class AuthenticationError < APIStatusError; end
44
+ # 403
45
+ class PermissionDeniedError < APIStatusError; end
46
+ # 404
47
+ class NotFoundError < APIStatusError; end
48
+ # 409
49
+ class ConflictError < APIStatusError; end
50
+ # 422
51
+ class UnprocessableEntityError < APIStatusError; end
52
+ # 429
53
+ class RateLimitError < APIStatusError; end
54
+ # 5xx
55
+ class InternalServerError < APIStatusError; end
56
+
57
+ # Maps an HTTP status code to the most specific error class.
58
+ STATUS_ERROR_CLASSES = {
59
+ 400 => BadRequestError,
60
+ 401 => AuthenticationError,
61
+ 403 => PermissionDeniedError,
62
+ 404 => NotFoundError,
63
+ 409 => ConflictError,
64
+ 422 => UnprocessableEntityError,
65
+ 429 => RateLimitError
66
+ }.freeze
67
+
68
+ # Returns the error class that should be raised for +status+.
69
+ def self.error_class_for(status)
70
+ STATUS_ERROR_CLASSES[status] || (status >= 500 ? InternalServerError : APIStatusError)
71
+ end
72
+ end
data/lib/oz/model.rb ADDED
@@ -0,0 +1,116 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Oz
4
+ # A light-weight, read-only wrapper around the JSON returned by the API.
5
+ #
6
+ # Rather than hand-maintaining a class for every response shape, the SDK wraps
7
+ # decoded JSON objects in {Model}. Attributes are reachable both as methods and
8
+ # via +[]+, and nested objects/arrays are wrapped recursively:
9
+ #
10
+ # run = client.agent.run(prompt: "Fix the bug")
11
+ # run.run_id # => "abc123"
12
+ # run.state # => "QUEUED"
13
+ # run["task_id"] # => "abc123"
14
+ # run.at_capacity? # => false (predicate form for booleans)
15
+ # run.to_h # => plain Hash with string keys
16
+ #
17
+ # Unknown attributes return +nil+ instead of raising, because the API omits
18
+ # optional fields. Use {#key?} when you need to distinguish "absent" from
19
+ # "present but null".
20
+ class Model
21
+ include Enumerable
22
+
23
+ # Recursively wraps +value+: Hashes become {Model}, Arrays are mapped, and
24
+ # scalars are returned untouched.
25
+ def self.build(value)
26
+ case value
27
+ when Hash then new(value)
28
+ when Array then value.map { |item| build(item) }
29
+ else value # scalars and existing Models pass through unchanged
30
+ end
31
+ end
32
+
33
+ def initialize(attributes = {})
34
+ @attributes = {}
35
+ (attributes || {}).each do |key, value|
36
+ @attributes[key.to_s] = Model.build(value)
37
+ end
38
+ end
39
+
40
+ # @return [Object, nil] the value stored under +key+ (symbol or string).
41
+ def [](key)
42
+ @attributes[key.to_s]
43
+ end
44
+
45
+ # @return [Boolean] whether +key+ is present in the payload.
46
+ def key?(key)
47
+ @attributes.key?(key.to_s)
48
+ end
49
+ alias has_key? key?
50
+ alias member? key?
51
+
52
+ # @return [Array<String>] the attribute names present in the payload.
53
+ def keys
54
+ @attributes.keys
55
+ end
56
+
57
+ # Iterates over +[key, value]+ pairs (values stay wrapped).
58
+ def each(&)
59
+ return enum_for(:each) unless block_given?
60
+
61
+ @attributes.each(&)
62
+ end
63
+
64
+ # @return [Hash] a deep copy as plain Ruby Hashes/Arrays with string keys.
65
+ def to_h
66
+ @attributes.transform_values { |value| unwrap(value) }
67
+ end
68
+ alias to_hash to_h
69
+
70
+ def ==(other)
71
+ other.is_a?(Model) && other.to_h == to_h
72
+ end
73
+ alias eql? ==
74
+
75
+ def hash
76
+ to_h.hash
77
+ end
78
+
79
+ def inspect
80
+ pairs = @attributes.map { |key, value| "#{key}=#{value.inspect}" }
81
+ "#<Oz::Model #{pairs.join(' ')}>"
82
+ end
83
+ alias to_s inspect
84
+
85
+ def respond_to_missing?(name, include_private = false)
86
+ method = name.to_s
87
+ return true if method.end_with?('?')
88
+ return true if reader?(method)
89
+
90
+ super
91
+ end
92
+
93
+ def method_missing(name, *args)
94
+ method = name.to_s
95
+ return !!@attributes[method.chomp('?')] if method.end_with?('?') && args.empty?
96
+ return @attributes[method] if reader?(method) && args.empty?
97
+
98
+ super
99
+ end
100
+
101
+ private
102
+
103
+ # Plain attribute readers (snake/camel case, no assignment or punctuation).
104
+ def reader?(method)
105
+ method.match?(/\A[a-zA-Z_]\w*\z/)
106
+ end
107
+
108
+ def unwrap(value)
109
+ case value
110
+ when Model then value.to_h
111
+ when Array then value.map { |item| unwrap(item) }
112
+ else value
113
+ end
114
+ end
115
+ end
116
+ end