savvy_openrouter 0.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 +7 -0
- data/.rspec +3 -0
- data/.rubocop.yml +50 -0
- data/CHANGELOG.md +23 -0
- data/CODE_OF_CONDUCT.md +132 -0
- data/LICENSE.txt +21 -0
- data/README.md +234 -0
- data/Rakefile +12 -0
- data/exe/savvy_openrouter +32 -0
- data/lib/generators/savvy_openrouter/install/install_generator.rb +17 -0
- data/lib/generators/savvy_openrouter/install/templates/savvy_openrouter.yml +53 -0
- data/lib/savvy_openrouter/api_call_logger.rb +93 -0
- data/lib/savvy_openrouter/client.rb +105 -0
- data/lib/savvy_openrouter/completion_retry_policy.rb +135 -0
- data/lib/savvy_openrouter/configuration.rb +156 -0
- data/lib/savvy_openrouter/connection.rb +316 -0
- data/lib/savvy_openrouter/connection_instrumentation.rb +133 -0
- data/lib/savvy_openrouter/errors.rb +35 -0
- data/lib/savvy_openrouter/resources/analytics.rb +13 -0
- data/lib/savvy_openrouter/resources/anthropic_messages.rb +14 -0
- data/lib/savvy_openrouter/resources/api_keys.rb +33 -0
- data/lib/savvy_openrouter/resources/audio.rb +19 -0
- data/lib/savvy_openrouter/resources/base.rb +23 -0
- data/lib/savvy_openrouter/resources/chat.rb +45 -0
- data/lib/savvy_openrouter/resources/credits.rb +13 -0
- data/lib/savvy_openrouter/resources/embeddings.rb +18 -0
- data/lib/savvy_openrouter/resources/endpoints.rb +13 -0
- data/lib/savvy_openrouter/resources/generations.rb +17 -0
- data/lib/savvy_openrouter/resources/guardrails.rb +61 -0
- data/lib/savvy_openrouter/resources/models.rb +57 -0
- data/lib/savvy_openrouter/resources/oauth.rb +17 -0
- data/lib/savvy_openrouter/resources/organization.rb +13 -0
- data/lib/savvy_openrouter/resources/providers.rb +13 -0
- data/lib/savvy_openrouter/resources/rerank.rb +14 -0
- data/lib/savvy_openrouter/resources/responses.rb +14 -0
- data/lib/savvy_openrouter/resources/videos.rb +53 -0
- data/lib/savvy_openrouter/resources/workspaces.rb +37 -0
- data/lib/savvy_openrouter/streaming.rb +32 -0
- data/lib/savvy_openrouter/version.rb +5 -0
- data/lib/savvy_openrouter.rb +13 -0
- data/savvy_openrouter-0.1.0.gem +0 -0
- data/sig/savvy_openrouter.rbs +150 -0
- metadata +165 -0
|
@@ -0,0 +1,316 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative "connection_instrumentation"
|
|
4
|
+
|
|
5
|
+
require "faraday"
|
|
6
|
+
require "json"
|
|
7
|
+
require "net/http"
|
|
8
|
+
require "uri"
|
|
9
|
+
|
|
10
|
+
module SavvyOpenrouter
|
|
11
|
+
class Connection
|
|
12
|
+
include Instrumentation
|
|
13
|
+
|
|
14
|
+
DEFAULT_SUCCESS = [200, 201, 202, 204].freeze
|
|
15
|
+
|
|
16
|
+
attr_reader :config
|
|
17
|
+
|
|
18
|
+
def initialize(config)
|
|
19
|
+
@config = config
|
|
20
|
+
@api_call_logger = ApiCallLogger.new(config.api_call_log)
|
|
21
|
+
base = normalize_base(config.base_url)
|
|
22
|
+
headers = build_headers
|
|
23
|
+
@conn = Faraday.new(url: base, headers: headers) do |faraday|
|
|
24
|
+
faraday.request :json
|
|
25
|
+
faraday.response :json, content_type: /\bjson/, parser_options: { symbolize_names: true }
|
|
26
|
+
faraday.adapter Faraday.default_adapter
|
|
27
|
+
end
|
|
28
|
+
@raw = Faraday.new(url: base, headers: headers) do |faraday|
|
|
29
|
+
faraday.adapter Faraday.default_adapter
|
|
30
|
+
end
|
|
31
|
+
end
|
|
32
|
+
|
|
33
|
+
def get(path, params: nil, success: DEFAULT_SUCCESS)
|
|
34
|
+
timed_json(:get, path, params: params, success: success)
|
|
35
|
+
end
|
|
36
|
+
|
|
37
|
+
def delete(path, params: nil, success: DEFAULT_SUCCESS)
|
|
38
|
+
timed_json(:delete, path, params: params, success: success)
|
|
39
|
+
end
|
|
40
|
+
|
|
41
|
+
def post(path, body:, success: DEFAULT_SUCCESS)
|
|
42
|
+
timed_json(:post, path, body: body, success: success)
|
|
43
|
+
end
|
|
44
|
+
|
|
45
|
+
def patch(path, body:, success: DEFAULT_SUCCESS)
|
|
46
|
+
timed_json(:patch, path, body: body, success: success)
|
|
47
|
+
end
|
|
48
|
+
|
|
49
|
+
def put(path, body:, success: DEFAULT_SUCCESS)
|
|
50
|
+
timed_json(:put, path, body: body, success: success)
|
|
51
|
+
end
|
|
52
|
+
|
|
53
|
+
def get_raw(path, params: nil, success: [200])
|
|
54
|
+
rel = rel_path(path)
|
|
55
|
+
started = monotonic_ms
|
|
56
|
+
response = @raw.get(rel) do |req|
|
|
57
|
+
req.params.update(params) if params
|
|
58
|
+
end
|
|
59
|
+
duration_ms = elapsed_ms(started)
|
|
60
|
+
record_faraday_raw(
|
|
61
|
+
method: "GET",
|
|
62
|
+
rel_path: rel,
|
|
63
|
+
params: params,
|
|
64
|
+
request_body: nil,
|
|
65
|
+
response: response,
|
|
66
|
+
duration_ms: duration_ms
|
|
67
|
+
)
|
|
68
|
+
status = response.status
|
|
69
|
+
return response.body.b.freeze if success.include?(status)
|
|
70
|
+
|
|
71
|
+
raise_api_error(status, response.body)
|
|
72
|
+
rescue SavvyOpenrouter::ApiError
|
|
73
|
+
raise
|
|
74
|
+
rescue StandardError => e
|
|
75
|
+
record_transport_error(
|
|
76
|
+
method: "GET",
|
|
77
|
+
rel_path: rel,
|
|
78
|
+
params: params,
|
|
79
|
+
request_body: nil,
|
|
80
|
+
duration_ms: elapsed_ms(started),
|
|
81
|
+
error: e
|
|
82
|
+
)
|
|
83
|
+
raise
|
|
84
|
+
end
|
|
85
|
+
|
|
86
|
+
def post_raw(path, body:, success: [200])
|
|
87
|
+
rel = rel_path(path)
|
|
88
|
+
started = monotonic_ms
|
|
89
|
+
response = @raw.post(rel) do |req|
|
|
90
|
+
req.headers["Content-Type"] = "application/json"
|
|
91
|
+
req.body = JSON.generate(stringify_body(body))
|
|
92
|
+
end
|
|
93
|
+
duration_ms = elapsed_ms(started)
|
|
94
|
+
record_faraday_raw(
|
|
95
|
+
method: "POST",
|
|
96
|
+
rel_path: rel,
|
|
97
|
+
params: nil,
|
|
98
|
+
request_body: body,
|
|
99
|
+
response: response,
|
|
100
|
+
duration_ms: duration_ms
|
|
101
|
+
)
|
|
102
|
+
status = response.status
|
|
103
|
+
return response.body.b.freeze if success.include?(status)
|
|
104
|
+
|
|
105
|
+
raise_api_error(status, response.body)
|
|
106
|
+
rescue SavvyOpenrouter::ApiError
|
|
107
|
+
raise
|
|
108
|
+
rescue StandardError => e
|
|
109
|
+
record_transport_error(
|
|
110
|
+
method: "POST",
|
|
111
|
+
rel_path: rel,
|
|
112
|
+
params: nil,
|
|
113
|
+
request_body: body,
|
|
114
|
+
duration_ms: elapsed_ms(started),
|
|
115
|
+
error: e
|
|
116
|
+
)
|
|
117
|
+
raise
|
|
118
|
+
end
|
|
119
|
+
|
|
120
|
+
def stream_get(path, params: nil, &block)
|
|
121
|
+
ensure_api_key!
|
|
122
|
+
uri = join_uri(path)
|
|
123
|
+
uri.query = URI.encode_www_form(params) if params && !params.empty?
|
|
124
|
+
|
|
125
|
+
req = Net::HTTP::Get.new(uri)
|
|
126
|
+
build_headers.each { |k, v| req[k] = v }
|
|
127
|
+
|
|
128
|
+
started = monotonic_ms
|
|
129
|
+
status, err_buf = stream_via_net_http(uri, req, &block)
|
|
130
|
+
duration_ms = elapsed_ms(started)
|
|
131
|
+
record_stream(
|
|
132
|
+
method: "GET",
|
|
133
|
+
rel_path: rel_path(path),
|
|
134
|
+
params: params,
|
|
135
|
+
request_body: nil,
|
|
136
|
+
status: status,
|
|
137
|
+
response_body: err_buf,
|
|
138
|
+
duration_ms: duration_ms
|
|
139
|
+
)
|
|
140
|
+
return if status == 200
|
|
141
|
+
|
|
142
|
+
raise_api_error(status, err_buf) if status
|
|
143
|
+
rescue SavvyOpenrouter::ApiError
|
|
144
|
+
raise
|
|
145
|
+
rescue StandardError => e
|
|
146
|
+
record_transport_error(
|
|
147
|
+
method: "GET",
|
|
148
|
+
rel_path: rel_path(path),
|
|
149
|
+
params: params,
|
|
150
|
+
request_body: nil,
|
|
151
|
+
duration_ms: elapsed_ms(started),
|
|
152
|
+
error: e
|
|
153
|
+
)
|
|
154
|
+
raise
|
|
155
|
+
end
|
|
156
|
+
|
|
157
|
+
def stream_post(path, body, &)
|
|
158
|
+
ensure_api_key!
|
|
159
|
+
uri = join_uri(path)
|
|
160
|
+
req = Net::HTTP::Post.new(uri)
|
|
161
|
+
build_headers.each { |k, v| req[k] = v }
|
|
162
|
+
req["Content-Type"] = "application/json"
|
|
163
|
+
req["Accept"] = "text/event-stream"
|
|
164
|
+
req.body = JSON.generate(stringify_body(body))
|
|
165
|
+
|
|
166
|
+
started = monotonic_ms
|
|
167
|
+
status, err_buf = stream_via_net_http(uri, req, &)
|
|
168
|
+
duration_ms = elapsed_ms(started)
|
|
169
|
+
record_stream(
|
|
170
|
+
method: "POST",
|
|
171
|
+
rel_path: rel_path(path),
|
|
172
|
+
params: nil,
|
|
173
|
+
request_body: body,
|
|
174
|
+
status: status,
|
|
175
|
+
response_body: err_buf,
|
|
176
|
+
duration_ms: duration_ms
|
|
177
|
+
)
|
|
178
|
+
return if status == 200
|
|
179
|
+
|
|
180
|
+
raise_api_error(status, err_buf) if status
|
|
181
|
+
rescue SavvyOpenrouter::ApiError
|
|
182
|
+
raise
|
|
183
|
+
rescue StandardError => e
|
|
184
|
+
record_transport_error(
|
|
185
|
+
method: "POST",
|
|
186
|
+
rel_path: rel_path(path),
|
|
187
|
+
params: nil,
|
|
188
|
+
request_body: body,
|
|
189
|
+
duration_ms: elapsed_ms(started),
|
|
190
|
+
error: e
|
|
191
|
+
)
|
|
192
|
+
raise
|
|
193
|
+
end
|
|
194
|
+
|
|
195
|
+
private
|
|
196
|
+
|
|
197
|
+
def stream_via_net_http(uri, req, &block)
|
|
198
|
+
status = nil
|
|
199
|
+
err_buf = nil
|
|
200
|
+
Net::HTTP.start(uri.hostname, uri.port, use_ssl: uri.scheme == "https") do |http|
|
|
201
|
+
http.request(req) do |res|
|
|
202
|
+
status = res.code.to_i
|
|
203
|
+
unless status == 200
|
|
204
|
+
buf = +""
|
|
205
|
+
res.read_body { |c| buf << c }
|
|
206
|
+
err_buf = buf
|
|
207
|
+
next
|
|
208
|
+
end
|
|
209
|
+
|
|
210
|
+
res.read_body(&block)
|
|
211
|
+
end
|
|
212
|
+
end
|
|
213
|
+
[status, err_buf]
|
|
214
|
+
end
|
|
215
|
+
|
|
216
|
+
def stringify_body(body)
|
|
217
|
+
case body
|
|
218
|
+
when Hash
|
|
219
|
+
body.transform_keys(&:to_s)
|
|
220
|
+
else
|
|
221
|
+
body
|
|
222
|
+
end
|
|
223
|
+
end
|
|
224
|
+
|
|
225
|
+
def parse_json_response(response, success:)
|
|
226
|
+
status = response.status
|
|
227
|
+
return response.body if success.include?(status)
|
|
228
|
+
return nil if status == 204
|
|
229
|
+
|
|
230
|
+
raise_api_error(status, response.body)
|
|
231
|
+
end
|
|
232
|
+
|
|
233
|
+
def raise_api_error(status, body)
|
|
234
|
+
payload = parse_error_payload(body)
|
|
235
|
+
message = extract_message(payload)
|
|
236
|
+
exception_class = error_class_for(status)
|
|
237
|
+
raise exception_class.new(
|
|
238
|
+
message,
|
|
239
|
+
status_code: status,
|
|
240
|
+
response_body: payload
|
|
241
|
+
)
|
|
242
|
+
end
|
|
243
|
+
|
|
244
|
+
def parse_error_payload(body)
|
|
245
|
+
case body
|
|
246
|
+
when Hash
|
|
247
|
+
body
|
|
248
|
+
when String
|
|
249
|
+
return {} if body.strip.empty?
|
|
250
|
+
|
|
251
|
+
JSON.parse(body, symbolize_names: true)
|
|
252
|
+
else
|
|
253
|
+
{}
|
|
254
|
+
end
|
|
255
|
+
rescue JSON::ParserError
|
|
256
|
+
{ raw: body.to_s }
|
|
257
|
+
end
|
|
258
|
+
|
|
259
|
+
def extract_message(payload)
|
|
260
|
+
err = payload[:error] || payload["error"]
|
|
261
|
+
case err
|
|
262
|
+
when Hash
|
|
263
|
+
err[:message] || err["message"] || err.inspect
|
|
264
|
+
when String
|
|
265
|
+
err
|
|
266
|
+
else
|
|
267
|
+
"HTTP error (#{payload.inspect})"
|
|
268
|
+
end
|
|
269
|
+
end
|
|
270
|
+
|
|
271
|
+
def error_class_for(status)
|
|
272
|
+
case status
|
|
273
|
+
when 400 then BadRequestError
|
|
274
|
+
when 401 then AuthenticationError
|
|
275
|
+
when 402 then PaymentRequiredError
|
|
276
|
+
when 403 then ForbiddenError
|
|
277
|
+
when 404 then NotFoundError
|
|
278
|
+
when 429 then RateLimitError
|
|
279
|
+
when 500, 501 then InternalServerError
|
|
280
|
+
when 502 then BadGatewayError
|
|
281
|
+
else ApiError
|
|
282
|
+
end
|
|
283
|
+
end
|
|
284
|
+
|
|
285
|
+
def ensure_api_key!
|
|
286
|
+
return if config.api_key && !config.api_key.to_s.empty?
|
|
287
|
+
|
|
288
|
+
raise ConfigurationError, "OpenRouter api_key is missing; set OPENROUTER_API_KEY or pass api_key: to SavvyOpenrouter::Client"
|
|
289
|
+
end
|
|
290
|
+
|
|
291
|
+
def build_headers
|
|
292
|
+
ensure_api_key!
|
|
293
|
+
h = {
|
|
294
|
+
"Authorization" => "Bearer #{config.api_key}",
|
|
295
|
+
"Accept" => "application/json"
|
|
296
|
+
}
|
|
297
|
+
h["HTTP-Referer"] = config.http_referer if config.http_referer && !config.http_referer.to_s.empty?
|
|
298
|
+
h["X-Title"] = config.app_title if config.app_title && !config.app_title.to_s.empty?
|
|
299
|
+
h
|
|
300
|
+
end
|
|
301
|
+
|
|
302
|
+
def normalize_base(url)
|
|
303
|
+
url.to_s.chomp("/")
|
|
304
|
+
end
|
|
305
|
+
|
|
306
|
+
def rel_path(path)
|
|
307
|
+
path.to_s.delete_prefix("/")
|
|
308
|
+
end
|
|
309
|
+
|
|
310
|
+
def join_uri(path)
|
|
311
|
+
base = normalize_base(config.base_url)
|
|
312
|
+
rel = rel_path(path)
|
|
313
|
+
URI.parse("#{base}/#{rel}")
|
|
314
|
+
end
|
|
315
|
+
end
|
|
316
|
+
end
|
|
@@ -0,0 +1,133 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module SavvyOpenrouter
|
|
4
|
+
class Connection
|
|
5
|
+
# Timing + persistence hooks for optional request logging ({api_call_log} config).
|
|
6
|
+
module Instrumentation
|
|
7
|
+
private
|
|
8
|
+
|
|
9
|
+
def timed_json(method_sym, path, params: nil, body: nil, success: DEFAULT_SUCCESS)
|
|
10
|
+
rel = rel_path(path)
|
|
11
|
+
started = monotonic_ms
|
|
12
|
+
response =
|
|
13
|
+
case method_sym
|
|
14
|
+
when :get then @conn.get(rel) { |req| req.params.update(params) if params }
|
|
15
|
+
when :delete then @conn.delete(rel) { |req| req.params.update(params) if params }
|
|
16
|
+
when :post then @conn.post(rel, body)
|
|
17
|
+
when :patch then @conn.patch(rel, body)
|
|
18
|
+
when :put then @conn.put(rel, body)
|
|
19
|
+
else raise ArgumentError, "unsupported #{method_sym}"
|
|
20
|
+
end
|
|
21
|
+
duration_ms = elapsed_ms(started)
|
|
22
|
+
record_faraday_json(method: method_sym.to_s.upcase, rel_path: rel, params: params, request_body: body, response: response,
|
|
23
|
+
duration_ms: duration_ms)
|
|
24
|
+
parse_json_response(response, success: success)
|
|
25
|
+
rescue SavvyOpenrouter::ApiError
|
|
26
|
+
raise
|
|
27
|
+
rescue StandardError => e
|
|
28
|
+
record_transport_error(method: method_sym.to_s.upcase, rel_path: rel, params: params, request_body: body,
|
|
29
|
+
duration_ms: elapsed_ms(started), error: e)
|
|
30
|
+
raise
|
|
31
|
+
end
|
|
32
|
+
|
|
33
|
+
def record_faraday_json(method:, rel_path:, params:, request_body:, response:, duration_ms:)
|
|
34
|
+
return unless @api_call_logger.enabled?
|
|
35
|
+
|
|
36
|
+
lim = @api_call_logger.max_body_limit
|
|
37
|
+
@api_call_logger.record(
|
|
38
|
+
"method" => method,
|
|
39
|
+
"path" => full_url(rel_path, params),
|
|
40
|
+
"status" => response.status,
|
|
41
|
+
"duration_ms" => duration_ms.round(3),
|
|
42
|
+
"request_body" => ApiCallLogger.format_body_for_log(request_body, max_bytes: lim),
|
|
43
|
+
"response_body" => ApiCallLogger.format_body_for_log(response.body, max_bytes: lim),
|
|
44
|
+
"error_class" => nil,
|
|
45
|
+
"error_message" => nil,
|
|
46
|
+
"streaming" => false
|
|
47
|
+
)
|
|
48
|
+
end
|
|
49
|
+
|
|
50
|
+
def record_faraday_raw(method:, rel_path:, params:, request_body:, response:, duration_ms:)
|
|
51
|
+
return unless @api_call_logger.enabled?
|
|
52
|
+
|
|
53
|
+
lim = @api_call_logger.max_body_limit
|
|
54
|
+
body_for_resp =
|
|
55
|
+
if response.body.is_a?(String) && response.body.encoding == Encoding::ASCII_8BIT
|
|
56
|
+
"[binary #{response.body.bytesize} bytes]"
|
|
57
|
+
else
|
|
58
|
+
response.body
|
|
59
|
+
end
|
|
60
|
+
@api_call_logger.record(
|
|
61
|
+
"method" => method,
|
|
62
|
+
"path" => full_url(rel_path, params),
|
|
63
|
+
"status" => response.status,
|
|
64
|
+
"duration_ms" => duration_ms.round(3),
|
|
65
|
+
"request_body" => ApiCallLogger.format_body_for_log(request_body, max_bytes: lim),
|
|
66
|
+
"response_body" => ApiCallLogger.format_body_for_log(body_for_resp, max_bytes: lim),
|
|
67
|
+
"error_class" => nil,
|
|
68
|
+
"error_message" => nil,
|
|
69
|
+
"streaming" => false
|
|
70
|
+
)
|
|
71
|
+
end
|
|
72
|
+
|
|
73
|
+
def record_stream(method:, rel_path:, params:, request_body:, status:, response_body:, duration_ms:)
|
|
74
|
+
return unless @api_call_logger.enabled?
|
|
75
|
+
|
|
76
|
+
lim = @api_call_logger.max_body_limit
|
|
77
|
+
preview =
|
|
78
|
+
if status == 200
|
|
79
|
+
"[stream completed]"
|
|
80
|
+
else
|
|
81
|
+
response_body
|
|
82
|
+
end
|
|
83
|
+
@api_call_logger.record(
|
|
84
|
+
"method" => method,
|
|
85
|
+
"path" => full_url(rel_path, params),
|
|
86
|
+
"status" => status,
|
|
87
|
+
"duration_ms" => duration_ms.round(3),
|
|
88
|
+
"request_body" => ApiCallLogger.format_body_for_log(request_body, max_bytes: lim),
|
|
89
|
+
"response_body" => ApiCallLogger.format_body_for_log(preview, max_bytes: lim),
|
|
90
|
+
"error_class" => nil,
|
|
91
|
+
"error_message" => nil,
|
|
92
|
+
"streaming" => true
|
|
93
|
+
)
|
|
94
|
+
end
|
|
95
|
+
|
|
96
|
+
def record_transport_error(method:, rel_path:, params:, request_body:, duration_ms:, error:)
|
|
97
|
+
return unless @api_call_logger.enabled?
|
|
98
|
+
|
|
99
|
+
lim = @api_call_logger.max_body_limit
|
|
100
|
+
msg = ApiCallLogger.format_body_for_log(error.message, max_bytes: lim)
|
|
101
|
+
|
|
102
|
+
@api_call_logger.record(
|
|
103
|
+
"method" => method,
|
|
104
|
+
"path" => full_url(rel_path, params),
|
|
105
|
+
"status" => nil,
|
|
106
|
+
"duration_ms" => duration_ms.round(3),
|
|
107
|
+
"request_body" => ApiCallLogger.format_body_for_log(request_body, max_bytes: lim),
|
|
108
|
+
"response_body" => "",
|
|
109
|
+
"error_class" => error.class.name,
|
|
110
|
+
"error_message" => msg,
|
|
111
|
+
"streaming" => false
|
|
112
|
+
)
|
|
113
|
+
end
|
|
114
|
+
|
|
115
|
+
def full_url(rel_path, params)
|
|
116
|
+
u = join_uri(rel_path)
|
|
117
|
+
if params && !params.empty?
|
|
118
|
+
flat = params.transform_keys(&:to_s)
|
|
119
|
+
u.query = URI.encode_www_form(flat)
|
|
120
|
+
end
|
|
121
|
+
u.to_s
|
|
122
|
+
end
|
|
123
|
+
|
|
124
|
+
def monotonic_ms
|
|
125
|
+
Process.clock_gettime(Process::CLOCK_MONOTONIC) * 1000
|
|
126
|
+
end
|
|
127
|
+
|
|
128
|
+
def elapsed_ms(started)
|
|
129
|
+
monotonic_ms - started
|
|
130
|
+
end
|
|
131
|
+
end
|
|
132
|
+
end
|
|
133
|
+
end
|
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module SavvyOpenrouter
|
|
4
|
+
class Error < StandardError
|
|
5
|
+
attr_reader :status_code, :response_body
|
|
6
|
+
|
|
7
|
+
def initialize(message = nil, status_code: nil, response_body: nil)
|
|
8
|
+
super(message || self.class.name)
|
|
9
|
+
@status_code = status_code
|
|
10
|
+
@response_body = response_body
|
|
11
|
+
end
|
|
12
|
+
end
|
|
13
|
+
|
|
14
|
+
class ConfigurationError < Error; end
|
|
15
|
+
|
|
16
|
+
class ApiError < Error; end
|
|
17
|
+
|
|
18
|
+
class BadRequestError < ApiError; end
|
|
19
|
+
|
|
20
|
+
class AuthenticationError < ApiError; end
|
|
21
|
+
|
|
22
|
+
class PaymentRequiredError < ApiError; end
|
|
23
|
+
|
|
24
|
+
class ForbiddenError < ApiError; end
|
|
25
|
+
|
|
26
|
+
class NotFoundError < ApiError; end
|
|
27
|
+
|
|
28
|
+
class RateLimitError < ApiError; end
|
|
29
|
+
|
|
30
|
+
class InternalServerError < ApiError; end
|
|
31
|
+
|
|
32
|
+
class BadGatewayError < ApiError; end
|
|
33
|
+
|
|
34
|
+
class TimeoutPollError < Error; end
|
|
35
|
+
end
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative "base"
|
|
4
|
+
|
|
5
|
+
module SavvyOpenrouter
|
|
6
|
+
module Resources
|
|
7
|
+
class AnthropicMessages < Base
|
|
8
|
+
def create(**params)
|
|
9
|
+
body = config.merge_chat_body(params)
|
|
10
|
+
conn.post("/messages", body: body)
|
|
11
|
+
end
|
|
12
|
+
end
|
|
13
|
+
end
|
|
14
|
+
end
|
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative "base"
|
|
4
|
+
|
|
5
|
+
module SavvyOpenrouter
|
|
6
|
+
module Resources
|
|
7
|
+
class ApiKeys < Base
|
|
8
|
+
def current
|
|
9
|
+
conn.get("/key")
|
|
10
|
+
end
|
|
11
|
+
|
|
12
|
+
def list(**params)
|
|
13
|
+
conn.get("/keys", params: params)
|
|
14
|
+
end
|
|
15
|
+
|
|
16
|
+
def create(**body)
|
|
17
|
+
conn.post("/keys", body: body, success: [201])
|
|
18
|
+
end
|
|
19
|
+
|
|
20
|
+
def get(hash)
|
|
21
|
+
conn.get("/keys/#{hash}")
|
|
22
|
+
end
|
|
23
|
+
|
|
24
|
+
def update(hash, **body)
|
|
25
|
+
conn.patch("/keys/#{hash}", body: body)
|
|
26
|
+
end
|
|
27
|
+
|
|
28
|
+
def delete(hash)
|
|
29
|
+
conn.delete("/keys/#{hash}")
|
|
30
|
+
end
|
|
31
|
+
end
|
|
32
|
+
end
|
|
33
|
+
end
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative "base"
|
|
4
|
+
|
|
5
|
+
module SavvyOpenrouter
|
|
6
|
+
module Resources
|
|
7
|
+
class Audio < Base
|
|
8
|
+
def speech(**params)
|
|
9
|
+
body = config.merge_chat_body(params)
|
|
10
|
+
conn.post_raw("/audio/speech", body: body)
|
|
11
|
+
end
|
|
12
|
+
|
|
13
|
+
def transcribe(**params)
|
|
14
|
+
body = config.merge_chat_body(params)
|
|
15
|
+
conn.post("/audio/transcriptions", body: body)
|
|
16
|
+
end
|
|
17
|
+
end
|
|
18
|
+
end
|
|
19
|
+
end
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module SavvyOpenrouter
|
|
4
|
+
module Resources
|
|
5
|
+
class Base
|
|
6
|
+
def initialize(client)
|
|
7
|
+
@client = client
|
|
8
|
+
end
|
|
9
|
+
|
|
10
|
+
private
|
|
11
|
+
|
|
12
|
+
attr_reader :client
|
|
13
|
+
|
|
14
|
+
def conn
|
|
15
|
+
client.connection
|
|
16
|
+
end
|
|
17
|
+
|
|
18
|
+
def config
|
|
19
|
+
client.config
|
|
20
|
+
end
|
|
21
|
+
end
|
|
22
|
+
end
|
|
23
|
+
end
|
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative "base"
|
|
4
|
+
require_relative "../streaming"
|
|
5
|
+
require_relative "../completion_retry_policy"
|
|
6
|
+
|
|
7
|
+
module SavvyOpenrouter
|
|
8
|
+
module Resources
|
|
9
|
+
class Chat < Base
|
|
10
|
+
def completions(messages:, **params)
|
|
11
|
+
policy = CompletionRetryPolicy.new(config.chat_retries)
|
|
12
|
+
body = config.merge_chat_body({ messages: messages }.merge(params))
|
|
13
|
+
attempt = 0
|
|
14
|
+
last_response = nil
|
|
15
|
+
loop do
|
|
16
|
+
attempt += 1
|
|
17
|
+
begin
|
|
18
|
+
last_response = conn.post("/chat/completions", body: body)
|
|
19
|
+
rescue SavvyOpenrouter::ApiError => e
|
|
20
|
+
raise e if attempt >= policy.max_attempts || !policy.retry_http_error?(e)
|
|
21
|
+
|
|
22
|
+
policy.wait_after_attempt(attempt)
|
|
23
|
+
next
|
|
24
|
+
end
|
|
25
|
+
|
|
26
|
+
return last_response unless policy.retry_response?(last_response)
|
|
27
|
+
return last_response if attempt >= policy.max_attempts
|
|
28
|
+
|
|
29
|
+
policy.wait_after_attempt(attempt)
|
|
30
|
+
end
|
|
31
|
+
end
|
|
32
|
+
|
|
33
|
+
# Yields each SSE `data:` payload string (JSON text from the model stream).
|
|
34
|
+
def completions_stream(messages:, **params, &block)
|
|
35
|
+
body = config.merge_chat_body({ messages: messages, stream: true }.merge(params))
|
|
36
|
+
return enum_for(:completions_stream, messages: messages, **params) unless block
|
|
37
|
+
|
|
38
|
+
chunk_enum = Enumerator.new do |y|
|
|
39
|
+
conn.stream_post("/chat/completions", body) { |ch| y << ch }
|
|
40
|
+
end
|
|
41
|
+
Streaming.each_sse_data(chunk_enum, &block)
|
|
42
|
+
end
|
|
43
|
+
end
|
|
44
|
+
end
|
|
45
|
+
end
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative "base"
|
|
4
|
+
|
|
5
|
+
module SavvyOpenrouter
|
|
6
|
+
module Resources
|
|
7
|
+
class Embeddings < Base
|
|
8
|
+
def create(**params)
|
|
9
|
+
body = config.merge_chat_body(params)
|
|
10
|
+
conn.post("/embeddings", body: body)
|
|
11
|
+
end
|
|
12
|
+
|
|
13
|
+
def models
|
|
14
|
+
conn.get("/embeddings/models")
|
|
15
|
+
end
|
|
16
|
+
end
|
|
17
|
+
end
|
|
18
|
+
end
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative "base"
|
|
4
|
+
|
|
5
|
+
module SavvyOpenrouter
|
|
6
|
+
module Resources
|
|
7
|
+
class Generations < Base
|
|
8
|
+
def get(id:)
|
|
9
|
+
conn.get("/generation", params: { id: id })
|
|
10
|
+
end
|
|
11
|
+
|
|
12
|
+
def content(**params)
|
|
13
|
+
conn.get("/generation/content", params: params)
|
|
14
|
+
end
|
|
15
|
+
end
|
|
16
|
+
end
|
|
17
|
+
end
|