rails-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/CHANGELOG.md +31 -0
- data/LICENSE.txt +21 -0
- data/README.md +373 -0
- data/lib/openrouter/accumulator.rb +138 -0
- data/lib/openrouter/attachment.rb +291 -0
- data/lib/openrouter/client.rb +146 -0
- data/lib/openrouter/content.rb +161 -0
- data/lib/openrouter/errors.rb +131 -0
- data/lib/openrouter/message.rb +53 -0
- data/lib/openrouter/resources/chat.rb +123 -0
- data/lib/openrouter/resources/files.rb +77 -0
- data/lib/openrouter/resources/models.rb +105 -0
- data/lib/openrouter/sse.rb +0 -0
- data/lib/openrouter/stream.rb +133 -0
- data/lib/openrouter/structure.rb +143 -0
- data/lib/openrouter/transport.rb +374 -0
- data/lib/openrouter/version.rb +5 -0
- data/lib/openrouter.rb +81 -0
- data/lib/rails-openrouter.rb +9 -0
- metadata +93 -0
|
@@ -0,0 +1,143 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module OpenRouter
|
|
4
|
+
# A thin, dot-accessible wrapper around a decoded JSON object.
|
|
5
|
+
#
|
|
6
|
+
# The API surface is intentionally permissive: unknown or absent keys return
|
|
7
|
+
# nil rather than raising, because nearly every field in a streaming delta is
|
|
8
|
+
# optional (`chunk.choices.first.delta.content` is nil on the role-only chunk
|
|
9
|
+
# and on the final finish_reason chunk).
|
|
10
|
+
#
|
|
11
|
+
# completion.choices.first.message.content #=> "Hello"
|
|
12
|
+
# completion[:usage][:total_tokens] #=> 42
|
|
13
|
+
# completion.to_h #=> plain Hash, symbol keys
|
|
14
|
+
class Structure
|
|
15
|
+
include Enumerable
|
|
16
|
+
|
|
17
|
+
def self.wrap(value)
|
|
18
|
+
case value
|
|
19
|
+
when Structure then value
|
|
20
|
+
when Hash then new(value)
|
|
21
|
+
when Array then value.map { |item| wrap(item) }
|
|
22
|
+
else value
|
|
23
|
+
end
|
|
24
|
+
end
|
|
25
|
+
|
|
26
|
+
def initialize(data = {})
|
|
27
|
+
raise ArgumentError, "expected a Hash, got #{data.class}" unless data.is_a?(Hash)
|
|
28
|
+
|
|
29
|
+
@data = data
|
|
30
|
+
end
|
|
31
|
+
|
|
32
|
+
def [](key)
|
|
33
|
+
symbol = key.to_sym if key.respond_to?(:to_sym)
|
|
34
|
+
value = if symbol && @data.key?(symbol)
|
|
35
|
+
@data[symbol]
|
|
36
|
+
else
|
|
37
|
+
@data[key.to_s]
|
|
38
|
+
end
|
|
39
|
+
self.class.wrap(value)
|
|
40
|
+
end
|
|
41
|
+
|
|
42
|
+
def fetch(key, *default, &block)
|
|
43
|
+
value = @data.fetch(key.to_sym) { @data.fetch(key.to_s, *default, &block) }
|
|
44
|
+
self.class.wrap(value)
|
|
45
|
+
end
|
|
46
|
+
|
|
47
|
+
def dig(*keys)
|
|
48
|
+
keys.reduce(self) do |acc, key|
|
|
49
|
+
case acc
|
|
50
|
+
when Structure, Hash then acc[key]
|
|
51
|
+
when Array then key.is_a?(Integer) ? Structure.wrap(acc[key]) : nil
|
|
52
|
+
else nil
|
|
53
|
+
end
|
|
54
|
+
end
|
|
55
|
+
end
|
|
56
|
+
|
|
57
|
+
def key?(key)
|
|
58
|
+
@data.key?(key.to_sym) || @data.key?(key.to_s)
|
|
59
|
+
end
|
|
60
|
+
alias has_key? key?
|
|
61
|
+
alias include? key?
|
|
62
|
+
|
|
63
|
+
def keys
|
|
64
|
+
@data.keys
|
|
65
|
+
end
|
|
66
|
+
|
|
67
|
+
def each(&block)
|
|
68
|
+
return enum_for(:each) unless block
|
|
69
|
+
|
|
70
|
+
@data.each { |key, value| block.call(key, self.class.wrap(value)) }
|
|
71
|
+
self
|
|
72
|
+
end
|
|
73
|
+
|
|
74
|
+
def empty?
|
|
75
|
+
@data.empty?
|
|
76
|
+
end
|
|
77
|
+
|
|
78
|
+
def to_h
|
|
79
|
+
@data
|
|
80
|
+
end
|
|
81
|
+
alias to_hash to_h
|
|
82
|
+
|
|
83
|
+
def as_json
|
|
84
|
+
@data
|
|
85
|
+
end
|
|
86
|
+
|
|
87
|
+
def to_json(*args)
|
|
88
|
+
@data.to_json(*args)
|
|
89
|
+
end
|
|
90
|
+
|
|
91
|
+
def ==(other)
|
|
92
|
+
case other
|
|
93
|
+
when Structure then to_h == other.to_h
|
|
94
|
+
when Hash then to_h == other
|
|
95
|
+
else false
|
|
96
|
+
end
|
|
97
|
+
end
|
|
98
|
+
alias eql? ==
|
|
99
|
+
|
|
100
|
+
def hash
|
|
101
|
+
@data.hash
|
|
102
|
+
end
|
|
103
|
+
|
|
104
|
+
def inspect
|
|
105
|
+
"#<#{self.class.name} #{@data.inspect}>"
|
|
106
|
+
end
|
|
107
|
+
alias to_s inspect
|
|
108
|
+
|
|
109
|
+
def pretty_print(pp)
|
|
110
|
+
pp.text("#<#{self.class.name} ")
|
|
111
|
+
pp.pp(@data)
|
|
112
|
+
pp.text(">")
|
|
113
|
+
end
|
|
114
|
+
|
|
115
|
+
# Ruby asks about these when it wants an implicit conversion. Answering
|
|
116
|
+
# "yes, and here is nil" makes a Structure blow up in surprising places
|
|
117
|
+
# (Array(), string interpolation, flatten), so they stay undefined.
|
|
118
|
+
PROTOCOL_METHODS = %i[to_ary to_str to_int to_io to_proc coerce].freeze
|
|
119
|
+
|
|
120
|
+
# Reports only what is actually in the payload. Absent keys still answer
|
|
121
|
+
# through #method_missing, but duck-typing checks get an honest answer.
|
|
122
|
+
def respond_to_missing?(name, include_private = false)
|
|
123
|
+
key = name.to_s
|
|
124
|
+
return false if key.end_with?("=", "!")
|
|
125
|
+
|
|
126
|
+
key?(key.chomp("?")) || super
|
|
127
|
+
end
|
|
128
|
+
|
|
129
|
+
def method_missing(name, *args)
|
|
130
|
+
return super if PROTOCOL_METHODS.include?(name)
|
|
131
|
+
|
|
132
|
+
key = name.to_s
|
|
133
|
+
if key.end_with?("?")
|
|
134
|
+
value = self[key.chomp("?")]
|
|
135
|
+
return !!value && value != 0 && value != ""
|
|
136
|
+
end
|
|
137
|
+
|
|
138
|
+
raise NoMethodError, "undefined method `#{name}' for #{inspect}" unless args.empty?
|
|
139
|
+
|
|
140
|
+
self[key]
|
|
141
|
+
end
|
|
142
|
+
end
|
|
143
|
+
end
|
|
@@ -0,0 +1,374 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "json"
|
|
4
|
+
require "net/http"
|
|
5
|
+
require "openssl"
|
|
6
|
+
require "time"
|
|
7
|
+
require "uri"
|
|
8
|
+
|
|
9
|
+
module OpenRouter
|
|
10
|
+
# Net::HTTP transport: JSON in, JSON or server-sent events out, with retries.
|
|
11
|
+
#
|
|
12
|
+
# Deliberately dependency-free — the gem installs with nothing but the
|
|
13
|
+
# standard library, which matters for a client people drop into an existing
|
|
14
|
+
# Rails app that already pins its own HTTP stack.
|
|
15
|
+
class Transport
|
|
16
|
+
RETRIABLE_STATUSES = [408, 409, 429, 500, 502, 503, 504].freeze
|
|
17
|
+
|
|
18
|
+
RETRIABLE_EXCEPTIONS = [
|
|
19
|
+
Errno::ECONNABORTED, Errno::ECONNREFUSED, Errno::ECONNRESET, Errno::EHOSTUNREACH,
|
|
20
|
+
Errno::ENETUNREACH, Errno::EPIPE, Errno::ETIMEDOUT,
|
|
21
|
+
EOFError, IOError, SocketError, Timeout::Error,
|
|
22
|
+
Net::HTTPBadResponse, Net::HTTPHeaderSyntaxError,
|
|
23
|
+
OpenSSL::SSL::SSLError
|
|
24
|
+
].freeze
|
|
25
|
+
|
|
26
|
+
TIMEOUT_EXCEPTIONS = [Net::OpenTimeout, Net::ReadTimeout, Timeout::Error].freeze
|
|
27
|
+
|
|
28
|
+
# Handle on the socket behind a live stream, so a consumer can hang up early.
|
|
29
|
+
class Connection
|
|
30
|
+
def initialize
|
|
31
|
+
@mutex = Mutex.new
|
|
32
|
+
@http = nil
|
|
33
|
+
@closed = false
|
|
34
|
+
end
|
|
35
|
+
|
|
36
|
+
def attach(http)
|
|
37
|
+
@mutex.synchronize do
|
|
38
|
+
raise StreamClosedError, "stream was closed before it started" if @closed
|
|
39
|
+
|
|
40
|
+
@http = http
|
|
41
|
+
end
|
|
42
|
+
end
|
|
43
|
+
|
|
44
|
+
def closed?
|
|
45
|
+
@mutex.synchronize { @closed }
|
|
46
|
+
end
|
|
47
|
+
|
|
48
|
+
def close
|
|
49
|
+
http = nil
|
|
50
|
+
@mutex.synchronize do
|
|
51
|
+
return false if @closed
|
|
52
|
+
|
|
53
|
+
@closed = true
|
|
54
|
+
http = @http
|
|
55
|
+
@http = nil
|
|
56
|
+
end
|
|
57
|
+
|
|
58
|
+
begin
|
|
59
|
+
http.finish if http.respond_to?(:started?) && http.started?
|
|
60
|
+
rescue StandardError
|
|
61
|
+
nil # the socket is going away either way
|
|
62
|
+
end
|
|
63
|
+
true
|
|
64
|
+
end
|
|
65
|
+
end
|
|
66
|
+
|
|
67
|
+
def initialize(base_url:, headers: {}, timeout: 600, open_timeout: 10, write_timeout: 30,
|
|
68
|
+
max_retries: 2, initial_retry_delay: 0.5, max_retry_delay: 8.0,
|
|
69
|
+
logger: nil, debug_output: nil, sleeper: nil)
|
|
70
|
+
@base_uri = URI.parse(base_url.to_s.sub(%r{/+\z}, "") + "/")
|
|
71
|
+
@headers = stringify_headers(headers)
|
|
72
|
+
@timeout = timeout
|
|
73
|
+
@open_timeout = open_timeout
|
|
74
|
+
@write_timeout = write_timeout
|
|
75
|
+
@max_retries = max_retries.to_i
|
|
76
|
+
@initial_retry_delay = initial_retry_delay
|
|
77
|
+
@max_retry_delay = max_retry_delay
|
|
78
|
+
@logger = logger
|
|
79
|
+
@debug_output = debug_output
|
|
80
|
+
@sleeper = sleeper || ->(seconds) { sleep(seconds) }
|
|
81
|
+
end
|
|
82
|
+
|
|
83
|
+
attr_reader :base_uri, :max_retries
|
|
84
|
+
|
|
85
|
+
# Performs a request and returns the decoded body as a Hash.
|
|
86
|
+
#
|
|
87
|
+
# `form` sends multipart/form-data instead of JSON (see Net::HTTPHeader#set_form).
|
|
88
|
+
# `raw` returns the response body as bytes, for endpoints that answer with a
|
|
89
|
+
# file rather than JSON.
|
|
90
|
+
def request(method, path, body: nil, form: nil, query: nil, headers: {}, timeout: nil, raw: false)
|
|
91
|
+
uri = build_uri(path, query)
|
|
92
|
+
|
|
93
|
+
with_retries do
|
|
94
|
+
http = build_http(uri, timeout: timeout)
|
|
95
|
+
response =
|
|
96
|
+
begin
|
|
97
|
+
http.start { |connection| connection.request(build_request(method, uri, body, headers, form)) }
|
|
98
|
+
rescue *RETRIABLE_EXCEPTIONS => e
|
|
99
|
+
raise connection_error(e)
|
|
100
|
+
end
|
|
101
|
+
|
|
102
|
+
raw ? raw_response(response) : parse_response(response)
|
|
103
|
+
end
|
|
104
|
+
end
|
|
105
|
+
|
|
106
|
+
# Performs a streaming request. Returns [Enumerator, Connection]; nothing
|
|
107
|
+
# touches the network until the enumerator is first advanced.
|
|
108
|
+
def stream_request(method, path, body: nil, query: nil, headers: {}, timeout: nil)
|
|
109
|
+
uri = build_uri(path, query)
|
|
110
|
+
headers = headers.merge("Accept" => "text/event-stream", "Cache-Control" => "no-cache")
|
|
111
|
+
connection = Connection.new
|
|
112
|
+
|
|
113
|
+
enumerator = Enumerator.new do |yielder|
|
|
114
|
+
attempt = 0
|
|
115
|
+
started = false
|
|
116
|
+
|
|
117
|
+
begin
|
|
118
|
+
perform_stream(method, uri, body, headers, timeout, connection) do |payload|
|
|
119
|
+
started = true
|
|
120
|
+
yielder << payload
|
|
121
|
+
end
|
|
122
|
+
rescue APIConnectionError, APIError => e
|
|
123
|
+
# Once chunks have reached the caller a retry would duplicate output,
|
|
124
|
+
# so only retry a stream that never produced anything.
|
|
125
|
+
raise e if started || connection.closed? || !retriable?(e) || attempt >= @max_retries
|
|
126
|
+
|
|
127
|
+
log_retry(e, attempt)
|
|
128
|
+
@sleeper.call(retry_delay(attempt, e))
|
|
129
|
+
attempt += 1
|
|
130
|
+
retry
|
|
131
|
+
ensure
|
|
132
|
+
connection.close
|
|
133
|
+
end
|
|
134
|
+
end
|
|
135
|
+
|
|
136
|
+
[enumerator, connection]
|
|
137
|
+
end
|
|
138
|
+
|
|
139
|
+
private
|
|
140
|
+
|
|
141
|
+
def perform_stream(method, uri, body, headers, timeout, connection)
|
|
142
|
+
http = build_http(uri, timeout: timeout)
|
|
143
|
+
connection.attach(http)
|
|
144
|
+
|
|
145
|
+
http.start do |session|
|
|
146
|
+
session.request(build_request(method, uri, body, headers)) do |response|
|
|
147
|
+
status = response.code.to_i
|
|
148
|
+
unless (200..299).cover?(status)
|
|
149
|
+
raise APIError.from_response(
|
|
150
|
+
status: status,
|
|
151
|
+
body: safe_read_body(response),
|
|
152
|
+
headers: normalize_headers(response)
|
|
153
|
+
)
|
|
154
|
+
end
|
|
155
|
+
|
|
156
|
+
parser = SSEParser.new
|
|
157
|
+
|
|
158
|
+
catch(:openrouter_stream_end) do
|
|
159
|
+
response.read_body do |bytes|
|
|
160
|
+
throw :openrouter_stream_end if connection.closed?
|
|
161
|
+
|
|
162
|
+
parser.feed(bytes) do |event|
|
|
163
|
+
handle_event(event) { |payload| yield payload }
|
|
164
|
+
end
|
|
165
|
+
end
|
|
166
|
+
|
|
167
|
+
parser.finish { |event| handle_event(event) { |payload| yield payload } }
|
|
168
|
+
end
|
|
169
|
+
end
|
|
170
|
+
end
|
|
171
|
+
rescue *RETRIABLE_EXCEPTIONS => e
|
|
172
|
+
raise connection_error(e)
|
|
173
|
+
end
|
|
174
|
+
|
|
175
|
+
def handle_event(event)
|
|
176
|
+
return if event.comment?
|
|
177
|
+
throw :openrouter_stream_end if event.done?
|
|
178
|
+
|
|
179
|
+
payload = decode_event_data(event.data)
|
|
180
|
+
yield payload if payload
|
|
181
|
+
end
|
|
182
|
+
|
|
183
|
+
def decode_event_data(data)
|
|
184
|
+
data = data.to_s
|
|
185
|
+
return nil if data.empty?
|
|
186
|
+
|
|
187
|
+
payload =
|
|
188
|
+
begin
|
|
189
|
+
JSON.parse(data, symbolize_names: true)
|
|
190
|
+
rescue JSON::ParserError
|
|
191
|
+
nil
|
|
192
|
+
end
|
|
193
|
+
return nil unless payload.is_a?(Hash)
|
|
194
|
+
|
|
195
|
+
raise error_from_payload(payload) if payload[:error]
|
|
196
|
+
|
|
197
|
+
payload
|
|
198
|
+
end
|
|
199
|
+
|
|
200
|
+
def error_from_payload(payload)
|
|
201
|
+
error = payload[:error]
|
|
202
|
+
status = error.is_a?(Hash) && error[:code].is_a?(Integer) ? error[:code] : 500
|
|
203
|
+
APIError.from_response(status: status, body: payload)
|
|
204
|
+
end
|
|
205
|
+
|
|
206
|
+
def with_retries
|
|
207
|
+
attempt = 0
|
|
208
|
+
|
|
209
|
+
begin
|
|
210
|
+
yield
|
|
211
|
+
rescue APIConnectionError, APIError => e
|
|
212
|
+
raise e unless retriable?(e) && attempt < @max_retries
|
|
213
|
+
|
|
214
|
+
log_retry(e, attempt)
|
|
215
|
+
@sleeper.call(retry_delay(attempt, e))
|
|
216
|
+
attempt += 1
|
|
217
|
+
retry
|
|
218
|
+
end
|
|
219
|
+
end
|
|
220
|
+
|
|
221
|
+
def retriable?(error)
|
|
222
|
+
case error
|
|
223
|
+
when APIConnectionError then true
|
|
224
|
+
when APIError then RETRIABLE_STATUSES.include?(error.status.to_i)
|
|
225
|
+
else false
|
|
226
|
+
end
|
|
227
|
+
end
|
|
228
|
+
|
|
229
|
+
def retry_delay(attempt, error)
|
|
230
|
+
after = retry_after_seconds(error)
|
|
231
|
+
return [after, @max_retry_delay].min if after
|
|
232
|
+
|
|
233
|
+
delay = [@initial_retry_delay * (2**attempt), @max_retry_delay].min
|
|
234
|
+
# Full-ish jitter, so a fleet of workers does not retry in lockstep.
|
|
235
|
+
delay * (0.75 + (rand * 0.5))
|
|
236
|
+
end
|
|
237
|
+
|
|
238
|
+
def retry_after_seconds(error)
|
|
239
|
+
headers = error.respond_to?(:headers) ? error.headers : nil
|
|
240
|
+
return nil unless headers.is_a?(Hash)
|
|
241
|
+
|
|
242
|
+
value = headers["retry-after"]
|
|
243
|
+
return nil if value.nil?
|
|
244
|
+
return value.to_f if value.to_s.match?(/\A\d+(\.\d+)?\z/)
|
|
245
|
+
|
|
246
|
+
begin
|
|
247
|
+
seconds = Time.parse(value.to_s) - Time.now
|
|
248
|
+
seconds.positive? ? seconds : nil
|
|
249
|
+
rescue StandardError
|
|
250
|
+
nil
|
|
251
|
+
end
|
|
252
|
+
end
|
|
253
|
+
|
|
254
|
+
def log_retry(error, attempt)
|
|
255
|
+
return unless @logger
|
|
256
|
+
|
|
257
|
+
@logger.warn("[openrouter] retrying after #{error.class}: #{error.message} (attempt #{attempt + 1}/#{@max_retries})")
|
|
258
|
+
end
|
|
259
|
+
|
|
260
|
+
def raw_response(response)
|
|
261
|
+
status = response.code.to_i
|
|
262
|
+
return response.body.to_s if (200..299).cover?(status)
|
|
263
|
+
|
|
264
|
+
raise APIError.from_response(status: status, body: response.body, headers: normalize_headers(response))
|
|
265
|
+
end
|
|
266
|
+
|
|
267
|
+
def parse_response(response)
|
|
268
|
+
status = response.code.to_i
|
|
269
|
+
headers = normalize_headers(response)
|
|
270
|
+
raw = response.body
|
|
271
|
+
|
|
272
|
+
unless (200..299).cover?(status)
|
|
273
|
+
raise APIError.from_response(status: status, body: raw, headers: headers)
|
|
274
|
+
end
|
|
275
|
+
|
|
276
|
+
return {} if raw.nil? || raw.to_s.strip.empty?
|
|
277
|
+
|
|
278
|
+
payload =
|
|
279
|
+
begin
|
|
280
|
+
JSON.parse(raw, symbolize_names: true)
|
|
281
|
+
rescue JSON::ParserError => e
|
|
282
|
+
raise APIError.new(
|
|
283
|
+
"Could not parse response as JSON: #{e.message}",
|
|
284
|
+
status: status, body: raw, headers: headers
|
|
285
|
+
)
|
|
286
|
+
end
|
|
287
|
+
|
|
288
|
+
# A 200 can still carry an error envelope instead of a completion.
|
|
289
|
+
if payload.is_a?(Hash) && payload[:error]
|
|
290
|
+
error = error_from_payload(payload)
|
|
291
|
+
raise APIError.from_response(status: error.status, body: payload, headers: headers)
|
|
292
|
+
end
|
|
293
|
+
|
|
294
|
+
payload
|
|
295
|
+
end
|
|
296
|
+
|
|
297
|
+
def build_uri(path, query)
|
|
298
|
+
uri = @base_uri.merge(path.to_s.sub(%r{\A/+}, ""))
|
|
299
|
+
params = compact_query(query)
|
|
300
|
+
uri.query = URI.encode_www_form(params) unless params.empty?
|
|
301
|
+
uri
|
|
302
|
+
end
|
|
303
|
+
|
|
304
|
+
def compact_query(query)
|
|
305
|
+
return {} unless query.is_a?(Hash)
|
|
306
|
+
|
|
307
|
+
query.reject { |_key, value| value.nil? }
|
|
308
|
+
end
|
|
309
|
+
|
|
310
|
+
def build_http(uri, timeout: nil)
|
|
311
|
+
http = Net::HTTP.new(uri.host, uri.port)
|
|
312
|
+
http.use_ssl = uri.scheme == "https"
|
|
313
|
+
http.open_timeout = @open_timeout
|
|
314
|
+
http.read_timeout = timeout || @timeout
|
|
315
|
+
http.write_timeout = @write_timeout if http.respond_to?(:write_timeout=)
|
|
316
|
+
http.set_debug_output(@debug_output) if @debug_output
|
|
317
|
+
http
|
|
318
|
+
end
|
|
319
|
+
|
|
320
|
+
REQUEST_CLASSES = {
|
|
321
|
+
"get" => Net::HTTP::Get,
|
|
322
|
+
"post" => Net::HTTP::Post,
|
|
323
|
+
"put" => Net::HTTP::Put,
|
|
324
|
+
"patch" => Net::HTTP::Patch,
|
|
325
|
+
"delete" => Net::HTTP::Delete
|
|
326
|
+
}.freeze
|
|
327
|
+
|
|
328
|
+
def build_request(method, uri, body, headers, form = nil)
|
|
329
|
+
klass = REQUEST_CLASSES.fetch(method.to_s.downcase) do
|
|
330
|
+
raise ArgumentError, "unsupported HTTP method: #{method}"
|
|
331
|
+
end
|
|
332
|
+
|
|
333
|
+
request = klass.new(uri)
|
|
334
|
+
@headers.merge(stringify_headers(headers)).each do |key, value|
|
|
335
|
+
request[key] = value.to_s unless value.nil?
|
|
336
|
+
end
|
|
337
|
+
|
|
338
|
+
if form
|
|
339
|
+
# A retry re-reads the same IOs, so rewind before handing them over.
|
|
340
|
+
form.each { |(_name, value, _opts)| value.rewind if value.respond_to?(:rewind) }
|
|
341
|
+
request.set_form(form, "multipart/form-data")
|
|
342
|
+
elsif body
|
|
343
|
+
request["Content-Type"] ||= "application/json"
|
|
344
|
+
request.body = body.is_a?(String) ? body : JSON.generate(body)
|
|
345
|
+
end
|
|
346
|
+
|
|
347
|
+
request
|
|
348
|
+
end
|
|
349
|
+
|
|
350
|
+
def safe_read_body(response)
|
|
351
|
+
response.read_body
|
|
352
|
+
rescue StandardError
|
|
353
|
+
nil
|
|
354
|
+
end
|
|
355
|
+
|
|
356
|
+
def connection_error(error)
|
|
357
|
+
if TIMEOUT_EXCEPTIONS.any? { |klass| error.is_a?(klass) }
|
|
358
|
+
APITimeoutError.new("Request timed out: #{error.message}", original_error: error)
|
|
359
|
+
else
|
|
360
|
+
APIConnectionError.new("Connection error: #{error.message}", original_error: error)
|
|
361
|
+
end
|
|
362
|
+
end
|
|
363
|
+
|
|
364
|
+
def normalize_headers(response)
|
|
365
|
+
response.each_header.to_h { |key, value| [key.to_s.downcase, value] }
|
|
366
|
+
rescue StandardError
|
|
367
|
+
{}
|
|
368
|
+
end
|
|
369
|
+
|
|
370
|
+
def stringify_headers(headers)
|
|
371
|
+
(headers || {}).each_with_object({}) { |(key, value), memo| memo[key.to_s] = value }
|
|
372
|
+
end
|
|
373
|
+
end
|
|
374
|
+
end
|
data/lib/openrouter.rb
ADDED
|
@@ -0,0 +1,81 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "json"
|
|
4
|
+
|
|
5
|
+
require_relative "openrouter/version"
|
|
6
|
+
require_relative "openrouter/errors"
|
|
7
|
+
require_relative "openrouter/structure"
|
|
8
|
+
require_relative "openrouter/attachment"
|
|
9
|
+
require_relative "openrouter/content"
|
|
10
|
+
require_relative "openrouter/message"
|
|
11
|
+
require_relative "openrouter/sse"
|
|
12
|
+
require_relative "openrouter/accumulator"
|
|
13
|
+
require_relative "openrouter/stream"
|
|
14
|
+
require_relative "openrouter/transport"
|
|
15
|
+
require_relative "openrouter/resources/chat"
|
|
16
|
+
require_relative "openrouter/resources/models"
|
|
17
|
+
require_relative "openrouter/resources/files"
|
|
18
|
+
require_relative "openrouter/client"
|
|
19
|
+
|
|
20
|
+
# Ruby client for the OpenRouter API, with first-class streaming.
|
|
21
|
+
#
|
|
22
|
+
# OpenRouter.configure do |config|
|
|
23
|
+
# config.api_key = ENV["OPENROUTER_API_KEY"]
|
|
24
|
+
# config.app_name = "My App"
|
|
25
|
+
# config.site_url = "https://example.com"
|
|
26
|
+
# end
|
|
27
|
+
#
|
|
28
|
+
# OpenRouter.chat.completions.stream(
|
|
29
|
+
# model: "anthropic/claude-sonnet-4.5",
|
|
30
|
+
# messages: [{ role: "user", content: "Write a haiku about sockets" }]
|
|
31
|
+
# ).each_text { |text| print(text) }
|
|
32
|
+
module OpenRouter
|
|
33
|
+
# Process-wide defaults for clients built without explicit arguments.
|
|
34
|
+
class Configuration
|
|
35
|
+
attr_accessor :api_key, :base_url, :site_url, :app_name, :default_model,
|
|
36
|
+
:timeout, :open_timeout, :write_timeout, :max_retries,
|
|
37
|
+
:default_headers, :default_query, :extra_body, :logger, :debug_output,
|
|
38
|
+
:max_attachment_bytes
|
|
39
|
+
|
|
40
|
+
def initialize
|
|
41
|
+
@base_url = nil
|
|
42
|
+
@timeout = 600 # generous: a long completion holds the socket open
|
|
43
|
+
@open_timeout = 10
|
|
44
|
+
@write_timeout = 30
|
|
45
|
+
@max_retries = 2
|
|
46
|
+
@default_headers = {}
|
|
47
|
+
@default_query = {}
|
|
48
|
+
@extra_body = {}
|
|
49
|
+
end
|
|
50
|
+
end
|
|
51
|
+
|
|
52
|
+
class << self
|
|
53
|
+
def config
|
|
54
|
+
@config ||= Configuration.new
|
|
55
|
+
end
|
|
56
|
+
|
|
57
|
+
def configure
|
|
58
|
+
yield config
|
|
59
|
+
@client = nil # rebuild the shared client with the new settings
|
|
60
|
+
config
|
|
61
|
+
end
|
|
62
|
+
|
|
63
|
+
# A lazily built client using the global configuration.
|
|
64
|
+
def client
|
|
65
|
+
@client ||= Client.new
|
|
66
|
+
end
|
|
67
|
+
|
|
68
|
+
def reset!
|
|
69
|
+
@config = Configuration.new
|
|
70
|
+
@client = nil
|
|
71
|
+
end
|
|
72
|
+
|
|
73
|
+
def chat
|
|
74
|
+
client.chat
|
|
75
|
+
end
|
|
76
|
+
|
|
77
|
+
def models
|
|
78
|
+
client.models
|
|
79
|
+
end
|
|
80
|
+
end
|
|
81
|
+
end
|
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
# The gem is published as "rails-openrouter", so Bundler requires this file by
|
|
4
|
+
# default. The library itself is namespaced OpenRouter and lives in
|
|
5
|
+
# lib/openrouter.rb, which stays the canonical entry point:
|
|
6
|
+
#
|
|
7
|
+
# require "openrouter" # or
|
|
8
|
+
# require "rails-openrouter" # what `gem "rails-openrouter"` loads
|
|
9
|
+
require_relative "openrouter"
|
metadata
ADDED
|
@@ -0,0 +1,93 @@
|
|
|
1
|
+
--- !ruby/object:Gem::Specification
|
|
2
|
+
name: rails-openrouter
|
|
3
|
+
version: !ruby/object:Gem::Version
|
|
4
|
+
version: 0.2.0
|
|
5
|
+
platform: ruby
|
|
6
|
+
authors:
|
|
7
|
+
- Afshin
|
|
8
|
+
bindir: bin
|
|
9
|
+
cert_chain: []
|
|
10
|
+
date: 1980-01-02 00:00:00.000000000 Z
|
|
11
|
+
dependencies:
|
|
12
|
+
- !ruby/object:Gem::Dependency
|
|
13
|
+
name: minitest
|
|
14
|
+
requirement: !ruby/object:Gem::Requirement
|
|
15
|
+
requirements:
|
|
16
|
+
- - "~>"
|
|
17
|
+
- !ruby/object:Gem::Version
|
|
18
|
+
version: '5.0'
|
|
19
|
+
type: :development
|
|
20
|
+
prerelease: false
|
|
21
|
+
version_requirements: !ruby/object:Gem::Requirement
|
|
22
|
+
requirements:
|
|
23
|
+
- - "~>"
|
|
24
|
+
- !ruby/object:Gem::Version
|
|
25
|
+
version: '5.0'
|
|
26
|
+
- !ruby/object:Gem::Dependency
|
|
27
|
+
name: rake
|
|
28
|
+
requirement: !ruby/object:Gem::Requirement
|
|
29
|
+
requirements:
|
|
30
|
+
- - "~>"
|
|
31
|
+
- !ruby/object:Gem::Version
|
|
32
|
+
version: '13.0'
|
|
33
|
+
type: :development
|
|
34
|
+
prerelease: false
|
|
35
|
+
version_requirements: !ruby/object:Gem::Requirement
|
|
36
|
+
requirements:
|
|
37
|
+
- - "~>"
|
|
38
|
+
- !ruby/object:Gem::Version
|
|
39
|
+
version: '13.0'
|
|
40
|
+
description: |
|
|
41
|
+
A dependency-free Ruby client for OpenRouter. Chat completions, model and
|
|
42
|
+
credit endpoints, and server-sent-event streaming that accumulates chunks
|
|
43
|
+
back into a final completion, with typed errors and automatic retries.
|
|
44
|
+
email:
|
|
45
|
+
- afshmini@gmail.com
|
|
46
|
+
executables: []
|
|
47
|
+
extensions: []
|
|
48
|
+
extra_rdoc_files: []
|
|
49
|
+
files:
|
|
50
|
+
- CHANGELOG.md
|
|
51
|
+
- LICENSE.txt
|
|
52
|
+
- README.md
|
|
53
|
+
- lib/openrouter.rb
|
|
54
|
+
- lib/openrouter/accumulator.rb
|
|
55
|
+
- lib/openrouter/attachment.rb
|
|
56
|
+
- lib/openrouter/client.rb
|
|
57
|
+
- lib/openrouter/content.rb
|
|
58
|
+
- lib/openrouter/errors.rb
|
|
59
|
+
- lib/openrouter/message.rb
|
|
60
|
+
- lib/openrouter/resources/chat.rb
|
|
61
|
+
- lib/openrouter/resources/files.rb
|
|
62
|
+
- lib/openrouter/resources/models.rb
|
|
63
|
+
- lib/openrouter/sse.rb
|
|
64
|
+
- lib/openrouter/stream.rb
|
|
65
|
+
- lib/openrouter/structure.rb
|
|
66
|
+
- lib/openrouter/transport.rb
|
|
67
|
+
- lib/openrouter/version.rb
|
|
68
|
+
- lib/rails-openrouter.rb
|
|
69
|
+
homepage: https://github.com/afshmini/rails-openrouter
|
|
70
|
+
licenses:
|
|
71
|
+
- MIT
|
|
72
|
+
metadata:
|
|
73
|
+
homepage_uri: https://github.com/afshmini/rails-openrouter
|
|
74
|
+
changelog_uri: https://github.com/afshmini/rails-openrouter/blob/main/CHANGELOG.md
|
|
75
|
+
rubygems_mfa_required: 'true'
|
|
76
|
+
rdoc_options: []
|
|
77
|
+
require_paths:
|
|
78
|
+
- lib
|
|
79
|
+
required_ruby_version: !ruby/object:Gem::Requirement
|
|
80
|
+
requirements:
|
|
81
|
+
- - ">="
|
|
82
|
+
- !ruby/object:Gem::Version
|
|
83
|
+
version: 3.0.0
|
|
84
|
+
required_rubygems_version: !ruby/object:Gem::Requirement
|
|
85
|
+
requirements:
|
|
86
|
+
- - ">="
|
|
87
|
+
- !ruby/object:Gem::Version
|
|
88
|
+
version: '0'
|
|
89
|
+
requirements: []
|
|
90
|
+
rubygems_version: 4.0.15
|
|
91
|
+
specification_version: 4
|
|
92
|
+
summary: Ruby client for the OpenRouter API, with first-class streaming.
|
|
93
|
+
test_files: []
|