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.
@@ -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
@@ -0,0 +1,5 @@
1
+ # frozen_string_literal: true
2
+
3
+ module OpenRouter
4
+ VERSION = "0.2.0"
5
+ 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: []