ruby-openrouter 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 ADDED
@@ -0,0 +1,7 @@
1
+ ---
2
+ SHA256:
3
+ metadata.gz: 40e456245000d212db0fb5c0c4eec0aa832d090a18b6b3b716065dd46145b303
4
+ data.tar.gz: 1fbb0f12e01b79875184a02ddd8419da8a56338d10d6cf97c369590a7f4c9094
5
+ SHA512:
6
+ metadata.gz: 8284009feb1cfdd07cddd1e465639e162552e89aed58e47a8c07eeed10198e86ee888372fb53574a33248e06c82e9f52e9dd841df11d7ceef174e5526a65ef73
7
+ data.tar.gz: 439e38643558e050df3d598f961993e08350b18836665296feb00b75ebf8a6e5ac9a5d2eeabb80abe42914a94b49dda7324a08c3518899f38b42d45f57f6623b
data/README.md ADDED
@@ -0,0 +1,230 @@
1
+ # ruby-openrouter
2
+
3
+ A minimal, conversational Ruby client for the [OpenRouter](https://openrouter.ai) API — access hundreds of LLMs (Claude, GPT-4, Gemini, Llama, and more) through a single, clean interface.
4
+
5
+ ```ruby
6
+ client = RubyOpenrouter::Client.new(model: "anthropic/claude-3.5-sonnet")
7
+ client.system("You are a concise assistant.")
8
+
9
+ puts client.user("What is Ruby?")
10
+ #=> "Ruby is a dynamic, open-source programming language..."
11
+ ```
12
+
13
+ ---
14
+
15
+ ## Features
16
+
17
+ - **Conversational by default** — message history is maintained automatically across turns
18
+ - **Streaming support** — receive tokens in real time via a simple block
19
+ - **Multi-turn context** — system, user, and assistant messages compose naturally
20
+ - **Error hierarchy** — typed exceptions for auth, rate limits, and server errors
21
+ - **Zero magic** — one client, one model, clear methods
22
+
23
+ ---
24
+
25
+ ## Installation
26
+
27
+ Add to your Gemfile:
28
+
29
+ ```ruby
30
+ gem "ruby-openrouter"
31
+ ```
32
+
33
+ Or install directly:
34
+
35
+ ```sh
36
+ gem install ruby-openrouter
37
+ ```
38
+
39
+ ---
40
+
41
+ ## Quick Start
42
+
43
+ ```ruby
44
+ require "ruby_openrouter"
45
+
46
+ client = RubyOpenrouter::Client.new(
47
+ model: "openai/gpt-4o",
48
+ api_key: ENV["OPENROUTER_API_KEY"]
49
+ )
50
+
51
+ reply = client.user("Explain quantum entanglement in one sentence.")
52
+ puts reply
53
+ ```
54
+
55
+ ---
56
+
57
+ ## Configuration
58
+
59
+ ### Per-client
60
+
61
+ Pass options directly when instantiating:
62
+
63
+ ```ruby
64
+ client = RubyOpenrouter::Client.new(
65
+ model: "anthropic/claude-3.5-sonnet",
66
+ api_key: ENV["OPENROUTER_API_KEY"],
67
+ site_url: "https://myapp.com", # sent as HTTP-Referer for attribution
68
+ site_name: "MyApp", # sent as X-Title
69
+ timeout: 60
70
+ )
71
+ ```
72
+
73
+ ### Global
74
+
75
+ Set defaults once at startup (e.g., in an initializer):
76
+
77
+ ```ruby
78
+ RubyOpenrouter.configure do |config|
79
+ config.api_key = ENV["OPENROUTER_API_KEY"]
80
+ config.site_url = "https://myapp.com"
81
+ config.site_name = "MyApp"
82
+ config.timeout = 30
83
+ end
84
+
85
+ # All clients created afterwards will use these defaults
86
+ client = RubyOpenrouter::Client.new(model: "openai/gpt-4o-mini")
87
+ ```
88
+
89
+ ---
90
+
91
+ ## Usage
92
+
93
+ ### System prompt
94
+
95
+ ```ruby
96
+ client.system("You are a Ruby expert. Keep answers short.")
97
+ ```
98
+
99
+ Calling `#system` again replaces the existing system message — there is always at most one.
100
+
101
+ ### Chat
102
+
103
+ ```ruby
104
+ reply = client.user("What is a Proc?")
105
+ puts reply #=> "A Proc is a block of code..."
106
+
107
+ # Continue the conversation — history is kept automatically
108
+ reply = client.user("How does it differ from a lambda?")
109
+ puts reply #=> "Unlike a lambda, a Proc does not..."
110
+ ```
111
+
112
+ ### Streaming
113
+
114
+ Pass a block to `#user` to receive tokens as they arrive:
115
+
116
+ ```ruby
117
+ client.user("Write a haiku about Ruby.") do |chunk|
118
+ print chunk
119
+ end
120
+ # Streams: "Elegant syntax flows / Objects dance in harmony / Matz smiles warmly"
121
+ puts
122
+ ```
123
+
124
+ The full response is appended to conversation history even when streaming.
125
+
126
+ ### Few-shot examples
127
+
128
+ Seed the conversation with pre-written assistant turns using `#assistant`:
129
+
130
+ ```ruby
131
+ client.user("Translate: hello")
132
+ client.assistant("Hola")
133
+
134
+ client.user("Translate: goodbye")
135
+ client.assistant("Adiós")
136
+
137
+ puts client.user("Translate: thank you")
138
+ #=> "Gracias"
139
+ ```
140
+
141
+ ### Reset
142
+
143
+ Clear the conversation history while keeping the system prompt:
144
+
145
+ ```ruby
146
+ client.system("You are a chef.")
147
+ client.user("What is mise en place?")
148
+
149
+ client.reset # clears user/assistant turns, keeps system prompt
150
+
151
+ client.user("What is a roux?") # fresh start, still a chef
152
+ ```
153
+
154
+ ### List available models
155
+
156
+ ```ruby
157
+ # From a client instance
158
+ models = client.models
159
+ models.each { |m| puts m["id"] }
160
+
161
+ # Or at the module level
162
+ models = RubyOpenrouter.models(api_key: ENV["OPENROUTER_API_KEY"])
163
+ models.first(5).each { |m| puts "#{m["id"]} — #{m["name"]}" }
164
+ ```
165
+
166
+ ---
167
+
168
+ ## Error Handling
169
+
170
+ ```ruby
171
+ begin
172
+ reply = client.user("Hello!")
173
+ rescue RubyOpenrouter::AuthenticationError => e
174
+ puts "Invalid API key: #{e.message}"
175
+ rescue RubyOpenrouter::RateLimitError => e
176
+ puts "Rate limited (status #{e.status}), retry later"
177
+ rescue RubyOpenrouter::ServerError => e
178
+ puts "OpenRouter server error: #{e.message}"
179
+ rescue RubyOpenrouter::APIError => e
180
+ puts "API error #{e.status}: #{e.message}"
181
+ rescue RubyOpenrouter::ConfigurationError => e
182
+ puts "Configuration problem: #{e.message}"
183
+ end
184
+ ```
185
+
186
+ | Exception | HTTP status |
187
+ |---|---|
188
+ | `AuthenticationError` | 401 |
189
+ | `BadRequestError` | 400 |
190
+ | `RateLimitError` | 429 |
191
+ | `ServerError` | 5xx |
192
+ | `APIError` | any other 4xx/5xx |
193
+ | `ConfigurationError` | — (missing `api_key`) |
194
+
195
+ ---
196
+
197
+ ## Model IDs
198
+
199
+ OpenRouter uses `provider/model` identifiers. Some popular ones:
200
+
201
+ | Model | ID |
202
+ |---|---|
203
+ | Claude 3.5 Sonnet | `anthropic/claude-3.5-sonnet` |
204
+ | GPT-4o | `openai/gpt-4o` |
205
+ | GPT-4o mini | `openai/gpt-4o-mini` |
206
+ | Gemini 1.5 Pro | `google/gemini-pro-1.5` |
207
+ | Llama 3.3 70B | `meta-llama/llama-3.3-70b-instruct` |
208
+
209
+ See all available models at [openrouter.ai/models](https://openrouter.ai/models) or via `client.models`.
210
+
211
+ ---
212
+
213
+ ## Development
214
+
215
+ ```sh
216
+ git clone https://github.com/deyvin/ruby-openrouter
217
+ cd ruby-openrouter
218
+ bundle install
219
+
220
+ bundle exec rspec # run tests
221
+ bundle exec rubocop # lint
222
+ ```
223
+
224
+ Tests use [WebMock](https://github.com/bblimke/webmock) — no real API calls are made.
225
+
226
+ ---
227
+
228
+ ## License
229
+
230
+ MIT
@@ -0,0 +1,72 @@
1
+ module RubyOpenrouter
2
+ class Client
3
+ def initialize(model:, api_key: nil, site_url: nil, site_name: nil, timeout: nil)
4
+ @model = model
5
+ @config = build_config(api_key: api_key, site_url: site_url, site_name: site_name, timeout: timeout)
6
+ @messages = []
7
+ end
8
+
9
+ def system(content)
10
+ @messages.reject! { |m| m[:role] == "system" }
11
+ @messages.unshift(role: "system", content: content)
12
+ self
13
+ end
14
+
15
+ def user(content, &block)
16
+ @messages << { role: "user", content: content }
17
+ if block
18
+ accumulated = ""
19
+ Http.stream("/chat/completions", body: request_body(stream: true), config: @config) do |chunk|
20
+ accumulated += chunk
21
+ block.call(chunk)
22
+ end
23
+ @messages << { role: "assistant", content: accumulated } unless accumulated.empty?
24
+ nil
25
+ else
26
+ text = complete
27
+ @messages << { role: "assistant", content: text }
28
+ text
29
+ end
30
+ end
31
+
32
+ def assistant(content)
33
+ @messages << { role: "assistant", content: content }
34
+ self
35
+ end
36
+
37
+ def reset
38
+ system_msg = @messages.find { |m| m[:role] == "system" }
39
+ @messages = system_msg ? [system_msg] : []
40
+ self
41
+ end
42
+
43
+ def models
44
+ Http.get("/models", config: @config).fetch("data", [])
45
+ end
46
+
47
+ private
48
+
49
+ def complete
50
+ body = request_body
51
+ resp = Http.post("/chat/completions", body: body, config: @config)
52
+ resp.dig("choices", 0, "message", "content").to_s
53
+ end
54
+
55
+ def request_body(stream: false)
56
+ body = { model: @model, messages: @messages }
57
+ body[:stream] = true if stream
58
+ body
59
+ end
60
+
61
+ def build_config(api_key:, site_url:, site_name:, timeout:)
62
+ base = RubyOpenrouter.configuration
63
+ cfg = Configuration.new
64
+ cfg.api_key = api_key || base.api_key || raise(ConfigurationError, "api_key is required")
65
+ cfg.base_url = base.base_url
66
+ cfg.timeout = timeout || base.timeout
67
+ cfg.site_url = site_url || base.site_url
68
+ cfg.site_name = site_name || base.site_name
69
+ cfg
70
+ end
71
+ end
72
+ end
@@ -0,0 +1,10 @@
1
+ module RubyOpenrouter
2
+ class Configuration
3
+ attr_accessor :api_key, :base_url, :timeout, :site_url, :site_name
4
+
5
+ def initialize
6
+ @base_url = "https://openrouter.ai/api/v1"
7
+ @timeout = 30
8
+ end
9
+ end
10
+ end
@@ -0,0 +1,20 @@
1
+ module RubyOpenrouter
2
+ class Error < StandardError; end
3
+
4
+ class ConfigurationError < Error; end
5
+
6
+ class APIError < Error
7
+ attr_reader :status, :code
8
+
9
+ def initialize(message, status: nil, code: nil)
10
+ super(message)
11
+ @status = status
12
+ @code = code
13
+ end
14
+ end
15
+
16
+ class AuthenticationError < APIError; end
17
+ class BadRequestError < APIError; end
18
+ class RateLimitError < APIError; end
19
+ class ServerError < APIError; end
20
+ end
@@ -0,0 +1,96 @@
1
+ require "faraday"
2
+ require "faraday/retry"
3
+ require "net/http"
4
+ require "uri"
5
+ require "json"
6
+
7
+ module RubyOpenrouter
8
+ module Http
9
+ def self.connection(config)
10
+ Faraday.new(url: config.base_url.chomp("/") + "/") do |conn|
11
+ conn.request :retry, max: 2, interval: 0.5, retry_statuses: [429, 500, 502, 503]
12
+ conn.request :json
13
+ conn.response :json
14
+ conn.headers["Authorization"] = "Bearer #{config.api_key}"
15
+ conn.headers["Content-Type"] = "application/json"
16
+ conn.headers["HTTP-Referer"] = config.site_url if config.site_url
17
+ conn.headers["X-Title"] = config.site_name if config.site_name
18
+ conn.options.timeout = config.timeout
19
+ conn.adapter Faraday.default_adapter
20
+ end
21
+ end
22
+
23
+ def self.post(path, body:, config:)
24
+ resp = connection(config).post(relative(path), body)
25
+ handle_error(resp)
26
+ resp.body
27
+ end
28
+
29
+ def self.get(path, config:)
30
+ resp = connection(config).get(relative(path))
31
+ handle_error(resp)
32
+ resp.body
33
+ end
34
+
35
+ def self.relative(path)
36
+ path.sub(%r{^/}, "")
37
+ end
38
+ private_class_method :relative
39
+
40
+ def self.stream(path, body:, config:, &block)
41
+ uri = URI.join(config.base_url + "/", path.sub(%r{^/}, ""))
42
+ http = Net::HTTP.new(uri.host, uri.port)
43
+ http.use_ssl = uri.scheme == "https"
44
+ http.read_timeout = config.timeout
45
+
46
+ request = Net::HTTP::Post.new(uri.request_uri)
47
+ request["Authorization"] = "Bearer #{config.api_key}"
48
+ request["Content-Type"] = "application/json"
49
+ request["HTTP-Referer"] = config.site_url if config.site_url
50
+ request["X-Title"] = config.site_name if config.site_name
51
+ request.body = JSON.generate(body)
52
+
53
+ buffer = ""
54
+ http.request(request) do |response|
55
+ raise_for_status(response.code.to_i, nil)
56
+ response.read_body do |raw_chunk|
57
+ buffer += raw_chunk
58
+ while (line_end = buffer.index("\n"))
59
+ line = buffer.slice!(0..line_end).strip
60
+ buffer = buffer.lstrip
61
+ next unless line.start_with?("data: ")
62
+
63
+ payload = line.delete_prefix("data: ")
64
+ next if payload == "[DONE]"
65
+
66
+ chunk = JSON.parse(payload) rescue next
67
+ text = chunk.dig("choices", 0, "delta", "content")
68
+ block.call(text) if text && !text.empty?
69
+ end
70
+ end
71
+ end
72
+ end
73
+
74
+ def self.handle_error(resp)
75
+ raise_for_status(resp.status, resp.body)
76
+ end
77
+ private_class_method :handle_error
78
+
79
+ def self.raise_for_status(status, body)
80
+ return if status < 400
81
+
82
+ message = body.is_a?(Hash) ? body.dig("error", "message").to_s : body.to_s
83
+ code = body.is_a?(Hash) ? body.dig("error", "code") : nil
84
+
85
+ klass = case status
86
+ when 401 then AuthenticationError
87
+ when 400 then BadRequestError
88
+ when 429 then RateLimitError
89
+ when 500..599 then ServerError
90
+ else APIError
91
+ end
92
+ raise klass.new(message, status: status, code: code)
93
+ end
94
+ private_class_method :raise_for_status
95
+ end
96
+ end
@@ -0,0 +1,3 @@
1
+ module RubyOpenrouter
2
+ VERSION = "0.1.0"
3
+ end
@@ -0,0 +1,25 @@
1
+ require_relative "ruby_openrouter/version"
2
+ require_relative "ruby_openrouter/configuration"
3
+ require_relative "ruby_openrouter/error"
4
+ require_relative "ruby_openrouter/http"
5
+ require_relative "ruby_openrouter/client"
6
+
7
+ module RubyOpenrouter
8
+ class << self
9
+ def configure
10
+ yield(configuration)
11
+ end
12
+
13
+ def configuration
14
+ @configuration ||= Configuration.new
15
+ end
16
+
17
+ def models(api_key: nil)
18
+ cfg = Configuration.new
19
+ cfg.api_key = api_key || configuration.api_key || raise(ConfigurationError, "api_key is required")
20
+ cfg.base_url = configuration.base_url
21
+ cfg.timeout = configuration.timeout
22
+ Http.get("/models", config: cfg).fetch("data", [])
23
+ end
24
+ end
25
+ end
metadata ADDED
@@ -0,0 +1,76 @@
1
+ --- !ruby/object:Gem::Specification
2
+ name: ruby-openrouter
3
+ version: !ruby/object:Gem::Version
4
+ version: 0.1.0
5
+ platform: ruby
6
+ authors:
7
+ - Deyvin
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: faraday
14
+ requirement: !ruby/object:Gem::Requirement
15
+ requirements:
16
+ - - "~>"
17
+ - !ruby/object:Gem::Version
18
+ version: '2.7'
19
+ type: :runtime
20
+ prerelease: false
21
+ version_requirements: !ruby/object:Gem::Requirement
22
+ requirements:
23
+ - - "~>"
24
+ - !ruby/object:Gem::Version
25
+ version: '2.7'
26
+ - !ruby/object:Gem::Dependency
27
+ name: faraday-retry
28
+ requirement: !ruby/object:Gem::Requirement
29
+ requirements:
30
+ - - "~>"
31
+ - !ruby/object:Gem::Version
32
+ version: '2.2'
33
+ type: :runtime
34
+ prerelease: false
35
+ version_requirements: !ruby/object:Gem::Requirement
36
+ requirements:
37
+ - - "~>"
38
+ - !ruby/object:Gem::Version
39
+ version: '2.2'
40
+ description: A minimal conversational Ruby client for OpenRouter — chat completions
41
+ with streaming, model listing, and multi-turn history.
42
+ email:
43
+ - deyvin@gmail.com
44
+ executables: []
45
+ extensions: []
46
+ extra_rdoc_files: []
47
+ files:
48
+ - README.md
49
+ - lib/ruby_openrouter.rb
50
+ - lib/ruby_openrouter/client.rb
51
+ - lib/ruby_openrouter/configuration.rb
52
+ - lib/ruby_openrouter/error.rb
53
+ - lib/ruby_openrouter/http.rb
54
+ - lib/ruby_openrouter/version.rb
55
+ homepage: https://github.com/deyvin/ruby-openrouter
56
+ licenses:
57
+ - MIT
58
+ metadata: {}
59
+ rdoc_options: []
60
+ require_paths:
61
+ - lib
62
+ required_ruby_version: !ruby/object:Gem::Requirement
63
+ requirements:
64
+ - - ">="
65
+ - !ruby/object:Gem::Version
66
+ version: '3.1'
67
+ required_rubygems_version: !ruby/object:Gem::Requirement
68
+ requirements:
69
+ - - ">="
70
+ - !ruby/object:Gem::Version
71
+ version: '0'
72
+ requirements: []
73
+ rubygems_version: 3.6.9
74
+ specification_version: 4
75
+ summary: Ruby client for the OpenRouter API
76
+ test_files: []