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,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
|