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,131 @@
1
+ # frozen_string_literal: true
2
+
3
+ module OpenRouter
4
+ # Base class for every error raised by this library.
5
+ class Error < StandardError; end
6
+
7
+ # Raised when the client is missing something it needs to make a request.
8
+ class ConfigurationError < Error; end
9
+
10
+ # Raised when the request never produced a response (DNS, TCP, TLS, timeouts).
11
+ class APIConnectionError < Error
12
+ attr_reader :original_error
13
+
14
+ def initialize(message = "Connection error.", original_error: nil)
15
+ @original_error = original_error
16
+ super(message)
17
+ end
18
+ end
19
+
20
+ class APITimeoutError < APIConnectionError
21
+ def initialize(message = "Request timed out.", original_error: nil)
22
+ super
23
+ end
24
+ end
25
+
26
+ # Raised when a file cannot be read, encoded, or sent in the requested shape.
27
+ class AttachmentError < Error; end
28
+
29
+ # Raised when the stream is consumed after it has been closed.
30
+ class StreamClosedError < Error; end
31
+
32
+ # Raised for any non-2xx response, and for 200 responses whose body is an
33
+ # OpenRouter error envelope (which happens for mid-stream failures).
34
+ class APIError < Error
35
+ attr_reader :status, :body, :headers, :code, :metadata, :request_id
36
+
37
+ def initialize(message, status: nil, body: nil, headers: nil, code: nil, metadata: nil)
38
+ @status = status
39
+ @body = body
40
+ @headers = headers || {}
41
+ @code = code
42
+ @metadata = metadata
43
+ @request_id = @headers["x-request-id"] || @headers["X-Request-Id"]
44
+ super(message)
45
+ end
46
+
47
+ # Builds the most specific error subclass for a response.
48
+ #
49
+ # OpenRouter error bodies look like:
50
+ # {"error": {"code": 402, "message": "...", "metadata": {...}}}
51
+ def self.from_response(status:, body:, headers: {})
52
+ payload = parse_body(body)
53
+ error = payload.is_a?(Hash) ? payload[:error] : nil
54
+ error = {} unless error.is_a?(Hash)
55
+
56
+ code = error[:code] || status
57
+ message = error[:message] || default_message(status)
58
+ message = "#{message} (HTTP #{status})" if status
59
+
60
+ # Provider errors are nested under metadata.raw and are the actually
61
+ # useful part of a 502, so surface them in the message.
62
+ raw = error.dig(:metadata, :raw)
63
+ message = "#{message}: #{raw}" if raw.is_a?(String) && !raw.empty?
64
+
65
+ klass_for(status || code).new(
66
+ message,
67
+ status: status,
68
+ body: payload || body,
69
+ headers: headers,
70
+ code: code,
71
+ metadata: error[:metadata]
72
+ )
73
+ end
74
+
75
+ def self.klass_for(status)
76
+ case status.to_i
77
+ when 400 then BadRequestError
78
+ when 401 then AuthenticationError
79
+ when 402 then InsufficientCreditsError
80
+ when 403 then ModerationError
81
+ when 404 then NotFoundError
82
+ when 408 then RequestTimeoutError
83
+ when 409 then ConflictError
84
+ when 422 then UnprocessableEntityError
85
+ when 429 then RateLimitError
86
+ when 502 then BadGatewayError
87
+ when 503 then NoProviderAvailableError
88
+ when 500..599 then InternalServerError
89
+ else self
90
+ end
91
+ end
92
+
93
+ def self.default_message(status)
94
+ case status.to_i
95
+ when 400 then "Bad request"
96
+ when 401 then "Invalid credentials (no auth, or an invalid/expired API key)"
97
+ when 402 then "Insufficient credits"
98
+ when 403 then "Input flagged by moderation"
99
+ when 404 then "Not found"
100
+ when 408 then "Request timed out"
101
+ when 429 then "Rate limited"
102
+ when 502 then "The upstream provider returned an invalid response"
103
+ when 503 then "No available provider meets your routing requirements"
104
+ else "Unexpected API error"
105
+ end
106
+ end
107
+
108
+ def self.parse_body(body)
109
+ return body if body.is_a?(Hash)
110
+ return nil if body.nil? || body.to_s.strip.empty?
111
+
112
+ JSON.parse(body.to_s, symbolize_names: true)
113
+ rescue JSON::ParserError
114
+ nil
115
+ end
116
+ private_class_method :parse_body
117
+ end
118
+
119
+ class BadRequestError < APIError; end
120
+ class AuthenticationError < APIError; end
121
+ class InsufficientCreditsError < APIError; end
122
+ class ModerationError < APIError; end
123
+ class NotFoundError < APIError; end
124
+ class RequestTimeoutError < APIError; end
125
+ class ConflictError < APIError; end
126
+ class UnprocessableEntityError < APIError; end
127
+ class RateLimitError < APIError; end
128
+ class InternalServerError < APIError; end
129
+ class BadGatewayError < InternalServerError; end
130
+ class NoProviderAvailableError < InternalServerError; end
131
+ end
@@ -0,0 +1,53 @@
1
+ # frozen_string_literal: true
2
+
3
+ module OpenRouter
4
+ # Small constructors for the message hashes the API expects.
5
+ #
6
+ # Message.system("You are terse.")
7
+ # Message.user("What changed?", attach: ["before.png", "after.png"])
8
+ # Message.tool(result, tool_call_id: call.id, name: call.function.name)
9
+ #
10
+ # Everything returns a plain Hash, so these compose with hand-written
11
+ # messages and can be inspected or logged without ceremony.
12
+ module Message
13
+ module_function
14
+
15
+ def user(content = nil, attach: nil, **extra)
16
+ build("user", content, attach, extra)
17
+ end
18
+
19
+ def system(content = nil, attach: nil, **extra)
20
+ build("system", content, attach, extra)
21
+ end
22
+
23
+ def assistant(content = nil, attach: nil, **extra)
24
+ build("assistant", content, attach, extra)
25
+ end
26
+
27
+ def tool(content, tool_call_id:, name: nil)
28
+ message = { role: "tool", tool_call_id: tool_call_id, content: stringify(content) }
29
+ message[:name] = name if name
30
+ message
31
+ end
32
+
33
+ def build(role, content, attach, extra)
34
+ message = { role: role }.merge(extra)
35
+
36
+ if attach.nil? && !content.is_a?(Array)
37
+ message[:content] = content
38
+ else
39
+ message[:content] = Content.build_content(content, attach)
40
+ end
41
+
42
+ message
43
+ end
44
+
45
+ def stringify(content)
46
+ case content
47
+ when String then content
48
+ when nil then ""
49
+ else JSON.generate(content.is_a?(Structure) ? content.to_h : content)
50
+ end
51
+ end
52
+ end
53
+ end
@@ -0,0 +1,123 @@
1
+ # frozen_string_literal: true
2
+
3
+ module OpenRouter
4
+ module Resources
5
+ # client.chat.completions
6
+ class Chat
7
+ def initialize(client)
8
+ @client = client
9
+ end
10
+
11
+ def completions
12
+ @completions ||= Completions.new(@client)
13
+ end
14
+
15
+ # POST /chat/completions
16
+ class Completions
17
+ PATH = "chat/completions"
18
+
19
+ def initialize(client)
20
+ @client = client
21
+ end
22
+
23
+ # Creates a chat completion.
24
+ #
25
+ # client.chat.completions.create(
26
+ # model: "anthropic/claude-sonnet-4.5",
27
+ # messages: [{ role: "user", content: "Hi" }]
28
+ # )
29
+ #
30
+ # With `stream: true` it returns an OpenRouter::Stream. Passing a block
31
+ # streams too, yielding each chunk and returning the assembled
32
+ # completion once the stream ends.
33
+ #
34
+ # Any other keyword is forwarded to the API untouched, so
35
+ # provider-specific and newly released parameters (`provider:`,
36
+ # `reasoning:`, `plugins:`, `transforms:`, `models:`, ...) work without
37
+ # waiting on a gem release.
38
+ def create(messages:, model: nil, stream: false, request_options: {}, **params, &block)
39
+ body = build_body(messages: messages, model: model, **params)
40
+
41
+ return create_stream(body, request_options, &block) if stream || block
42
+
43
+ Structure.new(@client.post(PATH, body: body, **request_options))
44
+ end
45
+
46
+ # Always streams. Returns an OpenRouter::Stream, or — when a block is
47
+ # given — the assembled completion after the block has seen every chunk.
48
+ def stream(messages:, model: nil, request_options: {}, **params, &block)
49
+ create(messages: messages, model: model, stream: true, request_options: request_options, **params, &block)
50
+ end
51
+
52
+ private
53
+
54
+ def create_stream(body, request_options)
55
+ stream = @client.stream(PATH, body: body.merge(stream: true), **request_options)
56
+ return stream unless block_given?
57
+
58
+ begin
59
+ stream.each { |chunk| yield chunk }
60
+ stream.final_completion
61
+ ensure
62
+ stream.close
63
+ end
64
+ end
65
+
66
+ def build_body(messages:, model:, pdf_engine: nil, **params)
67
+ model ||= @client.default_model
68
+ raise ArgumentError, "model is required (pass model: or set default_model on the client)" if model.nil?
69
+ raise ArgumentError, "messages must be a non-empty Array" unless messages.is_a?(Array) && !messages.empty?
70
+
71
+ body = @client.extra_body.merge(params)
72
+ body[:plugins] = file_parser_plugin(body[:plugins], pdf_engine) if pdf_engine
73
+ body.merge(model: model, messages: Content.normalize_messages(messages))
74
+ end
75
+
76
+ # `pdf_engine:` is sugar for the file-parser plugin, which is how PDF
77
+ # parsing is selected: "native" (the model reads the file itself),
78
+ # "mistral-ocr" (scans and images), or "cloudflare-ai" (free, to markdown).
79
+ def file_parser_plugin(plugins, engine)
80
+ plugins = Array(plugins).map { |plugin| plugin.is_a?(Structure) ? plugin.to_h : plugin }
81
+ existing = plugins.find { |plugin| (plugin[:id] || plugin["id"]).to_s == "file-parser" }
82
+
83
+ if existing
84
+ existing[:pdf] = { engine: engine.to_s }
85
+ plugins
86
+ else
87
+ plugins + [{ id: "file-parser", pdf: { engine: engine.to_s } }]
88
+ end
89
+ end
90
+ end
91
+ end
92
+
93
+ # POST /completions — the legacy text-completion shape.
94
+ class TextCompletions
95
+ PATH = "completions"
96
+
97
+ def initialize(client)
98
+ @client = client
99
+ end
100
+
101
+ def create(prompt:, model: nil, stream: false, request_options: {}, **params, &block)
102
+ model ||= @client.default_model
103
+ raise ArgumentError, "model is required (pass model: or set default_model on the client)" if model.nil?
104
+
105
+ body = @client.extra_body.merge(params).merge(model: model, prompt: prompt)
106
+
107
+ if stream || block
108
+ stream = @client.stream(PATH, body: body.merge(stream: true), **request_options)
109
+ return stream unless block
110
+
111
+ begin
112
+ stream.each { |chunk| block.call(chunk) }
113
+ stream.final_completion
114
+ ensure
115
+ stream.close
116
+ end
117
+ else
118
+ Structure.new(@client.post(PATH, body: body, **request_options))
119
+ end
120
+ end
121
+ end
122
+ end
123
+ end
@@ -0,0 +1,77 @@
1
+ # frozen_string_literal: true
2
+
3
+ module OpenRouter
4
+ module Resources
5
+ # client.files — upload once, then reference the id from any number of
6
+ # messages instead of re-encoding the same document into every request.
7
+ #
8
+ # file = client.files.upload("report.pdf")
9
+ # client.chat.completions.create(
10
+ # model: model,
11
+ # messages: [Message.user("Summarize it", attach: [{ id: file.id }])]
12
+ # )
13
+ class Files
14
+ def initialize(client)
15
+ @client = client
16
+ end
17
+
18
+ # POST /files (multipart). Accepts a path, Pathname, IO or Attachment.
19
+ # `provider:` stores the file with "openai" or "anthropic" instead of
20
+ # OpenRouter's own storage.
21
+ def upload(source, filename: nil, mime_type: nil, provider: nil, workspace_id: nil, request_options: {})
22
+ attachment = Attachment.from(source, filename: filename, mime_type: mime_type)
23
+ if attachment.remote?
24
+ raise AttachmentError, "cannot upload a remote URL; download it first or attach it inline"
25
+ end
26
+
27
+ form = [["file", StringIO.new(attachment.bytes),
28
+ { filename: attachment.filename, content_type: attachment.mime_type }]]
29
+
30
+ response = @client.upload("files", form: form,
31
+ query: { provider: provider, workspace_id: workspace_id },
32
+ **request_options)
33
+ Structure.new(response[:data] || response)
34
+ end
35
+
36
+ # GET /files
37
+ def list(limit: nil, after: nil, workspace_id: nil, request_options: {})
38
+ response = @client.get("files", query: { limit: limit, after: after, workspace_id: workspace_id },
39
+ **request_options)
40
+ data = response[:data] || response[:files]
41
+ data.is_a?(Array) ? data.map { |file| Structure.new(file) } : Structure.new(response)
42
+ end
43
+
44
+ # GET /files/:id
45
+ def retrieve(id, workspace_id: nil, request_options: {})
46
+ response = @client.get("files/#{escape(id)}", query: { workspace_id: workspace_id }, **request_options)
47
+ Structure.new(response[:data] || response)
48
+ end
49
+
50
+ # DELETE /files/:id
51
+ def delete(id, workspace_id: nil, request_options: {})
52
+ response = @client.delete("files/#{escape(id)}", query: { workspace_id: workspace_id }, **request_options)
53
+ Structure.new(response.is_a?(Hash) ? (response[:data] || response) : {})
54
+ end
55
+
56
+ # GET /files/:id/content — raw bytes. Only files created server-side are
57
+ # downloadable; a file you uploaded yourself returns 400.
58
+ #
59
+ # Returns an Attachment, or the path when `to:` is given.
60
+ def download(id, to: nil, workspace_id: nil, provider: nil, request_options: {})
61
+ bytes = @client.download(
62
+ "files/#{escape(id)}/content",
63
+ query: { workspace_id: workspace_id, provider: provider },
64
+ **request_options
65
+ )
66
+ attachment = Attachment.from_bytes(bytes, filename: id.to_s)
67
+ to ? attachment.save(to) : attachment
68
+ end
69
+
70
+ private
71
+
72
+ def escape(id)
73
+ URI.encode_www_form_component(id.to_s)
74
+ end
75
+ end
76
+ end
77
+ end
@@ -0,0 +1,105 @@
1
+ # frozen_string_literal: true
2
+
3
+ module OpenRouter
4
+ module Resources
5
+ # client.models
6
+ class Models
7
+ def initialize(client)
8
+ @client = client
9
+ end
10
+
11
+ # GET /models — every model OpenRouter can route to.
12
+ # Returns an Array of OpenRouter::Structure.
13
+ #
14
+ # client.models.list(supported_parameters: "tools")
15
+ #
16
+ # `input_modalities:` / `output_modalities:` narrow the list to models
17
+ # that handle the media you are sending or expecting — the answer to
18
+ # "which models can read this PDF?".
19
+ #
20
+ # client.models.list(input_modalities: %w[text image])
21
+ # client.models.list(output_modalities: "image")
22
+ def list(category: nil, supported_parameters: nil, input_modalities: nil, output_modalities: nil,
23
+ request_options: {})
24
+ query = { category: category, supported_parameters: supported_parameters }
25
+ response = @client.get("models", query: query, **request_options)
26
+
27
+ models = Array(response[:data]).map { |model| Structure.new(model) }
28
+ models = with_modalities(models, :input_modalities, input_modalities)
29
+ with_modalities(models, :output_modalities, output_modalities)
30
+ end
31
+
32
+ # GET /models/user — the models available to the authenticated key.
33
+ def list_available(request_options: {})
34
+ response = @client.get("models/user", **request_options)
35
+ Array(response[:data]).map { |model| Structure.new(model) }
36
+ end
37
+
38
+ # GET /models/:author/:slug/endpoints — providers, pricing and limits for
39
+ # one model. Accepts either "author/slug" or the two parts separately.
40
+ def endpoints(model_id, slug = nil, request_options: {})
41
+ author, model_slug = slug ? [model_id, slug] : model_id.to_s.split("/", 2)
42
+ raise ArgumentError, "expected a model id like \"openai/gpt-4o\"" if model_slug.nil? || model_slug.empty?
43
+
44
+ response = @client.get("models/#{author}/#{model_slug}/endpoints", **request_options)
45
+ Structure.new(response[:data] || response)
46
+ end
47
+ alias retrieve endpoints
48
+
49
+ private
50
+
51
+ # Modality metadata lives under `architecture`; a model that does not
52
+ # declare the modality is excluded rather than assumed capable.
53
+ def with_modalities(models, key, wanted)
54
+ return models if wanted.nil?
55
+
56
+ wanted = Array(wanted).map(&:to_s)
57
+ models.select do |model|
58
+ available = Array(model.architecture&.public_send(key)).map(&:to_s)
59
+ (wanted - available).empty?
60
+ end
61
+ end
62
+ end
63
+
64
+ # GET /credits — total credits purchased and used on this account.
65
+ class Credits
66
+ def initialize(client)
67
+ @client = client
68
+ end
69
+
70
+ def retrieve(request_options: {})
71
+ response = @client.get("credits", **request_options)
72
+ Structure.new(response[:data] || response)
73
+ end
74
+ alias get retrieve
75
+ end
76
+
77
+ # GET /key — limits and usage for the API key in use.
78
+ class Key
79
+ def initialize(client)
80
+ @client = client
81
+ end
82
+
83
+ def retrieve(request_options: {})
84
+ response = @client.get("key", **request_options)
85
+ Structure.new(response[:data] || response)
86
+ end
87
+ alias get retrieve
88
+ end
89
+
90
+ # GET /generation?id= — cost and token accounting for one completion.
91
+ # The record lands a moment after the completion finishes, so a fresh id can
92
+ # briefly 404.
93
+ class Generations
94
+ def initialize(client)
95
+ @client = client
96
+ end
97
+
98
+ def retrieve(id, request_options: {})
99
+ response = @client.get("generation", query: { id: id }, **request_options)
100
+ Structure.new(response[:data] || response)
101
+ end
102
+ alias get retrieve
103
+ end
104
+ end
105
+ end
Binary file
@@ -0,0 +1,133 @@
1
+ # frozen_string_literal: true
2
+
3
+ module OpenRouter
4
+ # A lazy, re-enumerable iterator over streamed completion chunks.
5
+ #
6
+ # stream = client.chat.completions.stream(model: "openai/gpt-4o", messages: messages)
7
+ # stream.each_text { |text| print(text) }
8
+ # stream.final_completion.usage.total_tokens
9
+ #
10
+ # Nothing is read from the socket until iteration starts, and chunks are
11
+ # accumulated as they pass through, so #final_completion is available for free
12
+ # once the stream has been consumed. Iterating a second time replays the
13
+ # chunks already seen and then continues where the first pass stopped.
14
+ class Stream
15
+ include Enumerable
16
+
17
+ def initialize(source, connection: nil)
18
+ @source = source
19
+ @connection = connection
20
+ @chunks = []
21
+ @accumulator = Accumulator.new
22
+ @finished = false
23
+ @closed = false
24
+ end
25
+
26
+ # Yields each chunk as an OpenRouter::Structure.
27
+ def each
28
+ return enum_for(:each) unless block_given?
29
+
30
+ @chunks.each { |chunk| yield chunk }
31
+ return self if @finished
32
+
33
+ while (chunk = pull)
34
+ yield chunk
35
+ end
36
+
37
+ self
38
+ end
39
+
40
+ # Yields only the text deltas of the given choice, skipping the role-only
41
+ # and finish_reason chunks that would otherwise force a nil check.
42
+ def each_text(index: 0)
43
+ return enum_for(:each_text, index: index) unless block_given?
44
+
45
+ each do |chunk|
46
+ choice = chunk.choices&.find { |c| (c.index || 0) == index }
47
+ content = choice&.delta&.content
48
+ yield content if content.is_a?(String) && !content.empty?
49
+ end
50
+ end
51
+
52
+ # Yields reasoning-token deltas for models that emit them.
53
+ def each_reasoning(index: 0)
54
+ return enum_for(:each_reasoning, index: index) unless block_given?
55
+
56
+ each do |chunk|
57
+ choice = chunk.choices&.find { |c| (c.index || 0) == index }
58
+ reasoning = choice&.delta&.reasoning
59
+ yield reasoning if reasoning.is_a?(String) && !reasoning.empty?
60
+ end
61
+ end
62
+
63
+ # Consumes the rest of the stream and returns the assembled completion,
64
+ # shaped exactly like a non-streaming chat.completions response.
65
+ def final_completion
66
+ drain
67
+ Structure.new(@accumulator.snapshot)
68
+ end
69
+ alias completion final_completion
70
+
71
+ # Consumes the rest of the stream and returns the full text of a choice.
72
+ def text
73
+ drain
74
+ @accumulator.text
75
+ end
76
+
77
+ def final_message(index: 0)
78
+ final_completion.choices&.find { |c| (c.index || 0) == index }&.message
79
+ end
80
+
81
+ def usage
82
+ final_completion.usage
83
+ end
84
+
85
+ # The completion as assembled so far, without consuming anything further.
86
+ def snapshot
87
+ Structure.new(@accumulator.snapshot)
88
+ end
89
+
90
+ def chunks
91
+ drain
92
+ @chunks
93
+ end
94
+
95
+ # Stops the stream early and releases the underlying connection. Safe to
96
+ # call more than once, and safe to call from another thread.
97
+ def close
98
+ return self if @closed
99
+
100
+ @closed = true
101
+ @finished = true
102
+ @connection&.close
103
+ self
104
+ end
105
+
106
+ def closed?
107
+ @closed
108
+ end
109
+
110
+ def finished?
111
+ @finished
112
+ end
113
+
114
+ private
115
+
116
+ def drain
117
+ each { |_chunk| } unless @finished
118
+ end
119
+
120
+ def pull
121
+ raise StreamClosedError, "stream is closed" if @closed
122
+
123
+ raw = @source.next
124
+ @accumulator.add(raw)
125
+ chunk = Structure.new(raw)
126
+ @chunks << chunk
127
+ chunk
128
+ rescue StopIteration
129
+ @finished = true
130
+ nil
131
+ end
132
+ end
133
+ end