openrouter_client 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.
@@ -0,0 +1,136 @@
1
+ # frozen_string_literal: true
2
+
3
+ module OpenRouter
4
+ # Represents an API key and provides management operations.
5
+ # Provides helpers to list, create, update, and delete API keys.
6
+ class ApiKey
7
+ KEYS_PATH = "/keys"
8
+ AUTH_KEY_PATH = "/auth/key"
9
+
10
+ # @return [String] The key identifier
11
+ attr_reader :id
12
+ # @return [String, nil] The key name/label
13
+ attr_reader :name
14
+ # @return [String, nil] The key value (only available on creation)
15
+ attr_reader :key
16
+ # @return [Float, nil] Credit limit for this key
17
+ attr_reader :limit
18
+ # @return [Float, nil] Usage amount for this key
19
+ attr_reader :usage
20
+ # @return [Boolean, nil] Whether the key is disabled
21
+ attr_reader :disabled
22
+ # @return [String, nil] Created timestamp
23
+ attr_reader :created_at
24
+ # @return [String, nil] Updated timestamp
25
+ attr_reader :updated_at
26
+
27
+ # @param attributes [Hash] Raw attributes from OpenRouter API
28
+ # @param client [OpenRouter::Client] HTTP client
29
+ def initialize(attributes, client: OpenRouter.client)
30
+ @client = client
31
+ reset_attributes(attributes)
32
+ end
33
+
34
+ class << self
35
+ # Get information about the current API key being used.
36
+ # Corresponds to GET /api/v1/auth/key
37
+ # @param client [OpenRouter::Client] HTTP client
38
+ # @return [OpenRouter::ApiKey]
39
+ def current(client: OpenRouter.client)
40
+ response = client.get(AUTH_KEY_PATH)
41
+ new(response["data"] || response, client: client)
42
+ end
43
+
44
+ # List all API keys.
45
+ # @param client [OpenRouter::Client] HTTP client
46
+ # @return [Array<OpenRouter::ApiKey>]
47
+ def all(client: OpenRouter.client)
48
+ response = client.get(KEYS_PATH)
49
+ keys = Array(response && response["data"])
50
+ keys.map { |attributes| new(attributes, client: client) }
51
+ end
52
+
53
+ # Find an API key by ID.
54
+ # @param id [String] The key identifier
55
+ # @param client [OpenRouter::Client] HTTP client
56
+ # @return [OpenRouter::ApiKey, nil]
57
+ def find_by(id:, client: OpenRouter.client)
58
+ response = client.get("#{KEYS_PATH}/#{id}")
59
+ return nil unless response
60
+
61
+ new(response["data"] || response, client: client)
62
+ end
63
+
64
+ # Create a new API key.
65
+ # @param name [String] A name/label for the key
66
+ # @param limit [Float, nil] Credit limit for this key
67
+ # @param client [OpenRouter::Client] HTTP client
68
+ # @return [OpenRouter::ApiKey]
69
+ def create!(name:, limit: nil, client: OpenRouter.client)
70
+ payload = { name: name, limit: limit }.compact
71
+ response = client.post(KEYS_PATH, payload)
72
+ new(response["data"] || response, client: client)
73
+ end
74
+ end
75
+
76
+ # Update this API key.
77
+ # @param name [String, nil] New name for the key
78
+ # @param limit [Float, nil] New credit limit
79
+ # @param disabled [Boolean, nil] Whether to disable the key
80
+ # @return [OpenRouter::ApiKey]
81
+ def update!(name: nil, limit: nil, disabled: nil)
82
+ payload = { name: name, limit: limit, disabled: disabled }.compact
83
+ response = @client.patch("#{KEYS_PATH}/#{@id}", payload)
84
+ reset_attributes(response["data"] || response)
85
+ self
86
+ end
87
+
88
+ # Delete this API key.
89
+ # @return [Boolean] true if deletion was successful
90
+ def destroy!
91
+ @client.delete("#{KEYS_PATH}/#{@id}")
92
+ true
93
+ end
94
+
95
+ # Check if this key is active (not disabled).
96
+ # @return [Boolean]
97
+ def active?
98
+ !@disabled
99
+ end
100
+
101
+ # Check if this key has a limit set.
102
+ # @return [Boolean]
103
+ def limited?
104
+ !@limit.nil? && @limit.positive?
105
+ end
106
+
107
+ # Check if this key has exceeded its limit.
108
+ # @return [Boolean]
109
+ def exceeded_limit?
110
+ return false unless limited?
111
+
112
+ (@usage || 0) >= @limit
113
+ end
114
+
115
+ # Get remaining credit for this key.
116
+ # @return [Float, nil]
117
+ def remaining
118
+ return nil unless limited?
119
+
120
+ @limit - (@usage || 0)
121
+ end
122
+
123
+ private
124
+
125
+ def reset_attributes(attributes)
126
+ @id = attributes["id"] || attributes["hash"]
127
+ @name = attributes["name"] || attributes["label"]
128
+ @key = attributes["key"]
129
+ @limit = attributes["limit"]&.to_f
130
+ @usage = attributes["usage"]&.to_f
131
+ @disabled = attributes["disabled"]
132
+ @created_at = attributes["created_at"] || attributes["createdAt"]
133
+ @updated_at = attributes["updated_at"] || attributes["updatedAt"]
134
+ end
135
+ end
136
+ end
@@ -0,0 +1,157 @@
1
+ # frozen_string_literal: true
2
+
3
+ module OpenRouter
4
+ class Client
5
+ attr_accessor :configuration
6
+
7
+ def initialize(configuration = OpenRouter.configuration)
8
+ @configuration = configuration || OpenRouter::Configuration.new
9
+ end
10
+
11
+ # Perform a POST request to the OpenRouter API.
12
+ # @param path [String]
13
+ # @param payload [Hash]
14
+ # @param headers [Hash]
15
+ # @return [Hash, nil]
16
+ def post(path, payload = {}, headers: {})
17
+ response = connection.post(build_url(path)) do |request|
18
+ configure_request_headers(request, headers)
19
+ request.body = payload.compact.to_json
20
+ end
21
+
22
+ handle_error(response) unless response.success?
23
+
24
+ parse_json(response.body)
25
+ end
26
+
27
+ # Perform a POST request with SSE streaming support.
28
+ # The provided on_data Proc will be used to receive chunked data.
29
+ # @param path [String]
30
+ # @param payload [Hash]
31
+ # @param on_data [Proc] called with chunks as they arrive
32
+ # @return [void]
33
+ def post_stream(path, payload = {}, on_data:)
34
+ url = build_url(path)
35
+ connection.post(url) do |request|
36
+ configure_request_headers(request, {})
37
+ request.headers["Accept"] = "text/event-stream"
38
+ request.headers["Cache-Control"] = "no-store"
39
+ request.body = payload.compact.to_json
40
+ request.options.on_data = on_data
41
+ end
42
+ end
43
+
44
+ # Perform a GET request to the OpenRouter API.
45
+ # @param path [String]
46
+ # @param query [Hash, nil]
47
+ # @param headers [Hash]
48
+ # @return [Hash, nil]
49
+ def get(path, query: nil, headers: {})
50
+ url = build_url(path)
51
+ url = "#{url}?#{URI.encode_www_form(query)}" if query && !query.empty?
52
+
53
+ response = connection.get(url) do |request|
54
+ configure_request_headers(request, headers)
55
+ end
56
+
57
+ handle_error(response) unless response.success?
58
+
59
+ parse_json(response.body)
60
+ end
61
+
62
+ # Perform a DELETE request to the OpenRouter API.
63
+ # @param path [String]
64
+ # @param headers [Hash]
65
+ # @return [Hash, nil]
66
+ def delete(path, headers: {})
67
+ response = connection.delete(build_url(path)) do |request|
68
+ configure_request_headers(request, headers)
69
+ end
70
+
71
+ handle_error(response) unless response.success?
72
+
73
+ parse_json(response.body)
74
+ end
75
+
76
+ # Perform a PATCH request to the OpenRouter API.
77
+ # @param path [String]
78
+ # @param payload [Hash]
79
+ # @param headers [Hash]
80
+ # @return [Hash, nil]
81
+ def patch(path, payload = {}, headers: {})
82
+ response = connection.patch(build_url(path)) do |request|
83
+ configure_request_headers(request, headers)
84
+ request.body = payload.compact.to_json
85
+ end
86
+
87
+ handle_error(response) unless response.success?
88
+
89
+ parse_json(response.body)
90
+ end
91
+
92
+ # Handle HTTP error responses by raising appropriate exceptions.
93
+ # @param response [Faraday::Response]
94
+ # @raise [OpenRouter::Error]
95
+ def handle_error(response)
96
+ error_message = extract_error_message(response)
97
+
98
+ case response.status
99
+ when 400
100
+ raise BadRequestError, error_message
101
+ when 401
102
+ raise UnauthorizedError, error_message
103
+ when 402
104
+ raise PaymentRequiredError, error_message
105
+ when 403
106
+ raise ForbiddenError, error_message
107
+ when 404
108
+ raise NotFoundError, error_message
109
+ when 429
110
+ raise RateLimitError, error_message
111
+ else
112
+ raise ServerError, error_message
113
+ end
114
+ end
115
+
116
+ private
117
+
118
+ def configure_request_headers(request, custom_headers)
119
+ request.headers["Authorization"] = "Bearer #{@configuration.api_key}" if @configuration.api_key
120
+ request.headers["Content-Type"] = "application/json"
121
+ request.headers["Accept"] = "application/json"
122
+ request.headers["HTTP-Referer"] = @configuration.site_url if @configuration.site_url
123
+ request.headers["X-Title"] = @configuration.site_name if @configuration.site_name
124
+ request.headers.merge!(custom_headers)
125
+ end
126
+
127
+ def extract_error_message(response)
128
+ body = parse_json(response.body)
129
+ if body.is_a?(Hash) && body["error"]
130
+ error_data = body["error"]
131
+ error_data.is_a?(Hash) ? error_data["message"] || response.body : error_data
132
+ else
133
+ response.body
134
+ end
135
+ rescue JSON::ParserError
136
+ response.body
137
+ end
138
+
139
+ def parse_json(body)
140
+ return nil if body.nil? || body.strip.empty?
141
+
142
+ JSON.parse(body)
143
+ end
144
+
145
+ def build_url(path)
146
+ "#{@configuration.api_base}#{path}"
147
+ end
148
+
149
+ def connection
150
+ Faraday.new do |faraday|
151
+ faraday.request :url_encoded
152
+ faraday.options.timeout = @configuration.request_timeout
153
+ faraday.options.open_timeout = @configuration.request_timeout
154
+ end
155
+ end
156
+ end
157
+ end
@@ -0,0 +1,293 @@
1
+ # frozen_string_literal: true
2
+
3
+ module OpenRouter
4
+ # Represents a chat completion request and response from the OpenRouter API.
5
+ # Provides helpers to create completions, handle streaming, and access response data.
6
+ class Completion
7
+ COMPLETIONS_PATH = "/chat/completions"
8
+
9
+ # Role constants for messages.
10
+ module Role
11
+ SYSTEM = "system"
12
+ USER = "user"
13
+ ASSISTANT = "assistant"
14
+ TOOL = "tool"
15
+ end
16
+
17
+ # Finish reason constants.
18
+ module FinishReason
19
+ STOP = "stop"
20
+ LENGTH = "length"
21
+ TOOL_CALLS = "tool_calls"
22
+ CONTENT_FILTER = "content_filter"
23
+ ERROR = "error"
24
+ end
25
+
26
+ # @return [String] The completion identifier
27
+ attr_reader :id
28
+ # @return [String] The model used for this completion
29
+ attr_reader :model
30
+ # @return [Integer] Unix timestamp of when the completion was created
31
+ attr_reader :created
32
+ # @return [String] The object type (chat.completion or chat.completion.chunk)
33
+ attr_reader :object
34
+ # @return [Array<Hash>] The completion choices
35
+ attr_reader :choices
36
+ # @return [Hash, nil] Token usage information
37
+ attr_reader :usage
38
+ # @return [String, nil] System fingerprint from the provider
39
+ attr_reader :system_fingerprint
40
+
41
+ # @param attributes [Hash] Raw attributes from OpenRouter API
42
+ # @param client [OpenRouter::Client] HTTP client to use for subsequent calls
43
+ def initialize(attributes, client: OpenRouter.client)
44
+ @client = client
45
+ reset_attributes(attributes)
46
+ end
47
+
48
+ class << self
49
+ # Create a new chat completion.
50
+ # Corresponds to POST /api/v1/chat/completions
51
+ # @param messages [Array<Hash>] The messages to send
52
+ # @param model [String, nil] The model to use (optional, uses user's default if omitted)
53
+ # @param temperature [Float, nil] Sampling temperature (0-2)
54
+ # @param max_tokens [Integer, nil] Maximum tokens to generate
55
+ # @param top_p [Float, nil] Nucleus sampling parameter (0-1)
56
+ # @param top_k [Integer, nil] Top-k sampling parameter
57
+ # @param frequency_penalty [Float, nil] Frequency penalty (-2 to 2)
58
+ # @param presence_penalty [Float, nil] Presence penalty (-2 to 2)
59
+ # @param repetition_penalty [Float, nil] Repetition penalty (0-2)
60
+ # @param stop [String, Array<String>, nil] Stop sequences
61
+ # @param seed [Integer, nil] Random seed for reproducibility
62
+ # @param tools [Array<Hash>, nil] Tools available to the model
63
+ # @param tool_choice [String, Hash, nil] Tool choice preference
64
+ # @param response_format [Hash, nil] Response format specification
65
+ # @param transforms [Array<String>, nil] Message transforms to apply
66
+ # @param models [Array<String>, nil] Model fallback list
67
+ # @param route [String, nil] Routing strategy
68
+ # @param provider [Hash, nil] Provider preferences
69
+ # @param client [OpenRouter::Client] HTTP client
70
+ # @param options [Hash] Additional options (modalities, image_config, plugins, reasoning, etc.)
71
+ # @return [OpenRouter::Completion]
72
+ def create!(messages:, model: nil, temperature: nil, max_tokens: nil, top_p: nil,
73
+ top_k: nil, frequency_penalty: nil, presence_penalty: nil, repetition_penalty: nil,
74
+ stop: nil, seed: nil, tools: nil, tool_choice: nil, response_format: nil,
75
+ transforms: nil, models: nil, route: nil, provider: nil, client: OpenRouter.client, **)
76
+ payload = build_payload(
77
+ messages: messages,
78
+ model: model,
79
+ temperature: temperature,
80
+ max_tokens: max_tokens,
81
+ top_p: top_p,
82
+ top_k: top_k,
83
+ frequency_penalty: frequency_penalty,
84
+ presence_penalty: presence_penalty,
85
+ repetition_penalty: repetition_penalty,
86
+ stop: stop,
87
+ seed: seed,
88
+ tools: tools,
89
+ tool_choice: tool_choice,
90
+ response_format: response_format,
91
+ transforms: transforms,
92
+ models: models,
93
+ route: route,
94
+ provider: provider,
95
+ stream: false,
96
+ **
97
+ )
98
+
99
+ attributes = client.post(COMPLETIONS_PATH, payload)
100
+ new(attributes, client: client)
101
+ end
102
+
103
+ # Stream a chat completion using SSE and yield response chunks as they arrive.
104
+ # Returns a Completion initialized with the final response data.
105
+ # @param messages [Array<Hash>] The messages to send
106
+ # @param model [String, nil] The model to use
107
+ # @param client [OpenRouter::Client] HTTP client
108
+ # @param options [Hash] Additional options (modalities, image_config, plugins, reasoning, etc.)
109
+ # @yield [chunk] yields each parsed chunk Hash from the stream
110
+ # @yieldparam chunk [Hash]
111
+ # @return [OpenRouter::Completion]
112
+ def stream!(messages:, model: nil, temperature: nil, max_tokens: nil, top_p: nil,
113
+ top_k: nil, frequency_penalty: nil, presence_penalty: nil, repetition_penalty: nil,
114
+ stop: nil, seed: nil, tools: nil, tool_choice: nil, response_format: nil,
115
+ transforms: nil, models: nil, route: nil, provider: nil, client: OpenRouter.client, **, &block)
116
+ payload = build_payload(
117
+ messages: messages,
118
+ model: model,
119
+ temperature: temperature,
120
+ max_tokens: max_tokens,
121
+ top_p: top_p,
122
+ top_k: top_k,
123
+ frequency_penalty: frequency_penalty,
124
+ presence_penalty: presence_penalty,
125
+ repetition_penalty: repetition_penalty,
126
+ stop: stop,
127
+ seed: seed,
128
+ tools: tools,
129
+ tool_choice: tool_choice,
130
+ response_format: response_format,
131
+ transforms: transforms,
132
+ models: models,
133
+ route: route,
134
+ provider: provider,
135
+ stream: true,
136
+ **
137
+ )
138
+
139
+ last_data = nil
140
+ collected_content = ""
141
+ final_attributes = {}
142
+
143
+ Stream.new(path: COMPLETIONS_PATH, input: payload, client: client).each do |event|
144
+ data = event["data"]
145
+ next unless data
146
+
147
+ last_data = data
148
+ block&.call(data)
149
+
150
+ # Collect content from delta
151
+ collected_content += data["choices"].first["delta"]["content"] if data["choices"]&.first&.dig("delta", "content")
152
+
153
+ # Capture final attributes
154
+ final_attributes["id"] ||= data["id"]
155
+ final_attributes["model"] ||= data["model"]
156
+ final_attributes["created"] ||= data["created"]
157
+ final_attributes["object"] = "chat.completion"
158
+
159
+ # Capture usage if present (usually in the final chunk)
160
+ final_attributes["usage"] = data["usage"] if data["usage"]
161
+ end
162
+
163
+ # Build final response structure
164
+ final_attributes["choices"] = [{
165
+ "message" => {
166
+ "role" => "assistant",
167
+ "content" => collected_content
168
+ },
169
+ "finish_reason" => last_data&.dig("choices", 0, "finish_reason") || "stop"
170
+ }]
171
+
172
+ new(final_attributes, client: client)
173
+ end
174
+
175
+ private
176
+
177
+ def build_payload(messages:, model:, temperature:, max_tokens:, top_p:, top_k:,
178
+ frequency_penalty:, presence_penalty:, repetition_penalty:,
179
+ stop:, seed:, tools:, tool_choice:, response_format:,
180
+ transforms:, models:, route:, provider:, stream:, **options)
181
+ {
182
+ messages: messages,
183
+ model: model,
184
+ temperature: temperature,
185
+ max_tokens: max_tokens,
186
+ top_p: top_p,
187
+ top_k: top_k,
188
+ frequency_penalty: frequency_penalty,
189
+ presence_penalty: presence_penalty,
190
+ repetition_penalty: repetition_penalty,
191
+ stop: stop,
192
+ seed: seed,
193
+ tools: tools,
194
+ tool_choice: tool_choice,
195
+ response_format: response_format,
196
+ transforms: transforms,
197
+ models: models,
198
+ route: route,
199
+ provider: provider,
200
+ stream: stream,
201
+ **options
202
+ }.compact
203
+ end
204
+ end
205
+
206
+ # Get the content from the first choice's message.
207
+ # @return [String, nil]
208
+ def content
209
+ @choices&.first&.dig("message", "content")
210
+ end
211
+
212
+ # Get the role from the first choice's message.
213
+ # @return [String, nil]
214
+ def role
215
+ @choices&.first&.dig("message", "role")
216
+ end
217
+
218
+ # Get the finish reason from the first choice.
219
+ # @return [String, nil]
220
+ def finish_reason
221
+ @choices&.first&.dig("finish_reason")
222
+ end
223
+
224
+ # Get tool calls from the first choice's message.
225
+ # @return [Array<Hash>, nil]
226
+ def tool_calls
227
+ @choices&.first&.dig("message", "tool_calls")
228
+ end
229
+
230
+ # Get images from the first choice's message (for image generation).
231
+ # @return [Array<Hash>, nil]
232
+ def images
233
+ @choices&.first&.dig("message", "images")
234
+ end
235
+
236
+ # Check if the completion includes images.
237
+ # @return [Boolean]
238
+ def images?
239
+ !images.nil? && !images.empty?
240
+ end
241
+
242
+ # Get the prompt tokens used.
243
+ # @return [Integer, nil]
244
+ def prompt_tokens
245
+ @usage&.dig("prompt_tokens")
246
+ end
247
+
248
+ # Get the completion tokens used.
249
+ # @return [Integer, nil]
250
+ def completion_tokens
251
+ @usage&.dig("completion_tokens")
252
+ end
253
+
254
+ # Get the total tokens used.
255
+ # @return [Integer, nil]
256
+ def total_tokens
257
+ @usage&.dig("total_tokens")
258
+ end
259
+
260
+ # Check if the completion finished due to stop sequence.
261
+ # @return [Boolean]
262
+ def stopped?
263
+ finish_reason == FinishReason::STOP
264
+ end
265
+
266
+ # Check if the completion was truncated due to length.
267
+ # @return [Boolean]
268
+ def truncated?
269
+ finish_reason == FinishReason::LENGTH
270
+ end
271
+
272
+ # Check if the completion includes tool calls.
273
+ # @return [Boolean]
274
+ def tool_calls?
275
+ finish_reason == FinishReason::TOOL_CALLS || !tool_calls.nil?
276
+ end
277
+
278
+ # Alias for backwards compatibility.
279
+ alias has_tool_calls? tool_calls?
280
+
281
+ private
282
+
283
+ def reset_attributes(attributes)
284
+ @id = attributes["id"]
285
+ @model = attributes["model"]
286
+ @created = attributes["created"]
287
+ @object = attributes["object"]
288
+ @choices = attributes["choices"]
289
+ @usage = attributes["usage"]
290
+ @system_fingerprint = attributes["system_fingerprint"]
291
+ end
292
+ end
293
+ end
@@ -0,0 +1,71 @@
1
+ # frozen_string_literal: true
2
+
3
+ module OpenRouter
4
+ # Represents credit balance and usage information from the OpenRouter API.
5
+ # Provides helpers to check remaining credits.
6
+ class Credit
7
+ CREDITS_PATH = "/credits"
8
+
9
+ # @return [Float] Total credits available
10
+ attr_reader :total_credits
11
+ # @return [Float] Total credits used
12
+ attr_reader :total_usage
13
+ # @return [Float] Remaining credits
14
+ attr_reader :remaining
15
+
16
+ # @param attributes [Hash] Raw attributes from OpenRouter API
17
+ # @param client [OpenRouter::Client] HTTP client
18
+ def initialize(attributes, client: OpenRouter.client)
19
+ @client = client
20
+ reset_attributes(attributes)
21
+ end
22
+
23
+ class << self
24
+ # Fetch the current credit balance.
25
+ # Corresponds to GET /api/v1/credits
26
+ # @param client [OpenRouter::Client] HTTP client
27
+ # @return [OpenRouter::Credit]
28
+ def fetch(client: OpenRouter.client)
29
+ response = client.get(CREDITS_PATH)
30
+ new(response["data"] || response, client: client)
31
+ end
32
+
33
+ # Get remaining credits as a simple value.
34
+ # @param client [OpenRouter::Client] HTTP client
35
+ # @return [Float]
36
+ def remaining(client: OpenRouter.client)
37
+ fetch(client: client).remaining
38
+ end
39
+ end
40
+
41
+ # Check if credits are low (less than 10% remaining).
42
+ # @return [Boolean]
43
+ def low?
44
+ return false if @total_credits.nil? || @total_credits.zero?
45
+
46
+ (@remaining / @total_credits) < 0.1
47
+ end
48
+
49
+ # Check if credits are exhausted.
50
+ # @return [Boolean]
51
+ def exhausted?
52
+ @remaining.nil? || @remaining <= 0
53
+ end
54
+
55
+ # Get the usage percentage.
56
+ # @return [Float, nil]
57
+ def usage_percentage
58
+ return nil if @total_credits.nil? || @total_credits.zero?
59
+
60
+ (@total_usage / @total_credits) * 100
61
+ end
62
+
63
+ private
64
+
65
+ def reset_attributes(attributes)
66
+ @total_credits = attributes["total_credits"]&.to_f
67
+ @total_usage = attributes["total_usage"].to_f
68
+ @remaining = attributes["remaining"]&.to_f || (@total_credits.to_f - @total_usage.to_f)
69
+ end
70
+ end
71
+ end