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.
- checksums.yaml +7 -0
- data/AGENTS.md +23 -0
- data/CHANGELOG.md +30 -0
- data/CONTRIBUTING.md +60 -0
- data/Gemfile +6 -0
- data/LICENSE +201 -0
- data/Makefile +64 -0
- data/README.md +266 -0
- data/Rakefile +32 -0
- data/docs/api_reference.md +156 -0
- data/docs/configuration.md +84 -0
- data/docs/error_handling.md +87 -0
- data/docs/getting_started.md +92 -0
- data/docs/index.md +56 -0
- data/examples/identities.rb +35 -0
- data/examples/list_runs.rb +31 -0
- data/examples/run_agent.rb +38 -0
- data/examples/schedules.rb +31 -0
- data/lib/oz/client.rb +299 -0
- data/lib/oz/configuration.rb +46 -0
- data/lib/oz/cursor_page.rb +89 -0
- data/lib/oz/errors.rb +72 -0
- data/lib/oz/model.rb +116 -0
- data/lib/oz/resources/agent.rb +95 -0
- data/lib/oz/resources/base.rb +33 -0
- data/lib/oz/resources/conversations.rb +16 -0
- data/lib/oz/resources/identities.rb +46 -0
- data/lib/oz/resources/runs.rb +55 -0
- data/lib/oz/resources/schedules.rb +64 -0
- data/lib/oz/resources/sessions.rb +16 -0
- data/lib/oz/version.rb +5 -0
- data/lib/oz.rb +72 -0
- data/mise.toml +2 -0
- data/oz-agent-sdk.gemspec +55 -0
- metadata +184 -0
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
|