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,291 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "pathname"
|
|
4
|
+
require "stringio"
|
|
5
|
+
require "uri"
|
|
6
|
+
|
|
7
|
+
module OpenRouter
|
|
8
|
+
# A file on its way to (or back from) the API.
|
|
9
|
+
#
|
|
10
|
+
# Wraps a path, an IO, raw bytes, an https URL or a data URL, works out the
|
|
11
|
+
# MIME type, and renders itself as the content part shape the API expects for
|
|
12
|
+
# that kind of media.
|
|
13
|
+
#
|
|
14
|
+
# OpenRouter::Attachment.new("chart.png").to_part
|
|
15
|
+
# #=> {type: "image_url", image_url: {url: "data:image/png;base64,..."}}
|
|
16
|
+
class Attachment
|
|
17
|
+
DATA_URL = %r{\Adata:([^;,]+)(;[^,]*)?,}.freeze
|
|
18
|
+
REMOTE_URL = %r{\Ahttps?://}i.freeze
|
|
19
|
+
|
|
20
|
+
EXTENSION_TYPES = {
|
|
21
|
+
# images
|
|
22
|
+
"png" => "image/png", "jpg" => "image/jpeg", "jpeg" => "image/jpeg",
|
|
23
|
+
"webp" => "image/webp", "gif" => "image/gif", "bmp" => "image/bmp",
|
|
24
|
+
"heic" => "image/heic", "heif" => "image/heif", "svg" => "image/svg+xml",
|
|
25
|
+
# documents
|
|
26
|
+
"pdf" => "application/pdf", "txt" => "text/plain", "md" => "text/markdown",
|
|
27
|
+
"csv" => "text/csv", "json" => "application/json", "xml" => "application/xml",
|
|
28
|
+
"html" => "text/html", "yaml" => "application/yaml", "yml" => "application/yaml",
|
|
29
|
+
"docx" => "application/vnd.openxmlformats-officedocument.wordprocessingml.document",
|
|
30
|
+
"xlsx" => "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet",
|
|
31
|
+
"pptx" => "application/vnd.openxmlformats-officedocument.presentationml.presentation",
|
|
32
|
+
# audio
|
|
33
|
+
"wav" => "audio/wav", "mp3" => "audio/mpeg", "aiff" => "audio/aiff", "aif" => "audio/aiff",
|
|
34
|
+
"aac" => "audio/aac", "ogg" => "audio/ogg", "oga" => "audio/ogg", "opus" => "audio/ogg",
|
|
35
|
+
"flac" => "audio/flac", "m4a" => "audio/mp4", "pcm" => "audio/pcm",
|
|
36
|
+
# video
|
|
37
|
+
"mp4" => "video/mp4", "mpeg" => "video/mpeg", "mpg" => "video/mpeg",
|
|
38
|
+
"mov" => "video/quicktime", "webm" => "video/webm", "avi" => "video/x-msvideo",
|
|
39
|
+
"mkv" => "video/x-matroska"
|
|
40
|
+
}.freeze
|
|
41
|
+
|
|
42
|
+
# OpenRouter's `input_audio.format` vocabulary.
|
|
43
|
+
AUDIO_FORMATS = {
|
|
44
|
+
"audio/wav" => "wav", "audio/x-wav" => "wav", "audio/wave" => "wav",
|
|
45
|
+
"audio/mpeg" => "mp3", "audio/mp3" => "mp3",
|
|
46
|
+
"audio/aiff" => "aiff", "audio/x-aiff" => "aiff",
|
|
47
|
+
"audio/aac" => "aac",
|
|
48
|
+
"audio/ogg" => "ogg",
|
|
49
|
+
"audio/flac" => "flac", "audio/x-flac" => "flac",
|
|
50
|
+
"audio/mp4" => "m4a", "audio/m4a" => "m4a", "audio/x-m4a" => "m4a",
|
|
51
|
+
"audio/pcm" => "pcm16"
|
|
52
|
+
}.freeze
|
|
53
|
+
|
|
54
|
+
DEFAULT_TYPE = "application/octet-stream"
|
|
55
|
+
|
|
56
|
+
attr_reader :mime_type
|
|
57
|
+
|
|
58
|
+
class << self
|
|
59
|
+
# Coerces anything attachment-shaped into an Attachment.
|
|
60
|
+
def from(source, filename: nil, mime_type: nil)
|
|
61
|
+
return source if source.is_a?(Attachment)
|
|
62
|
+
|
|
63
|
+
new(source, filename: filename, mime_type: mime_type)
|
|
64
|
+
end
|
|
65
|
+
|
|
66
|
+
# Raw bytes already in memory. `mime_type` is sniffed when omitted.
|
|
67
|
+
def from_bytes(bytes, mime_type: nil, filename: nil)
|
|
68
|
+
allocate.send(:initialize_from_bytes, bytes.to_s, mime_type, filename)
|
|
69
|
+
end
|
|
70
|
+
|
|
71
|
+
# Decodes a `data:<mime>;base64,<payload>` URL, e.g. an image a model
|
|
72
|
+
# generated and returned inside a message.
|
|
73
|
+
def from_data_url(url, filename: nil)
|
|
74
|
+
match = DATA_URL.match(url.to_s)
|
|
75
|
+
raise ArgumentError, "not a data URL" unless match
|
|
76
|
+
|
|
77
|
+
payload = url.to_s[match.end(0)..].to_s
|
|
78
|
+
bytes = match[2].to_s.include?("base64") ? payload.unpack1("m") : URI.decode_www_form_component(payload)
|
|
79
|
+
from_bytes(bytes, mime_type: match[1], filename: filename)
|
|
80
|
+
end
|
|
81
|
+
|
|
82
|
+
def mime_type_for(path)
|
|
83
|
+
EXTENSION_TYPES[File.extname(path.to_s).delete_prefix(".").downcase]
|
|
84
|
+
end
|
|
85
|
+
end
|
|
86
|
+
|
|
87
|
+
def initialize(source, filename: nil, mime_type: nil)
|
|
88
|
+
@url = nil
|
|
89
|
+
@bytes = nil
|
|
90
|
+
|
|
91
|
+
case source
|
|
92
|
+
when Attachment
|
|
93
|
+
initialize_copy_of(source, filename, mime_type)
|
|
94
|
+
when Pathname
|
|
95
|
+
initialize_from_path(source.to_s, mime_type, filename)
|
|
96
|
+
when URI::Generic
|
|
97
|
+
initialize_from_url(source.to_s, mime_type, filename)
|
|
98
|
+
when IO, StringIO
|
|
99
|
+
initialize_from_io(source, mime_type, filename)
|
|
100
|
+
when String
|
|
101
|
+
initialize_from_string(source, mime_type, filename)
|
|
102
|
+
else
|
|
103
|
+
raise ArgumentError, "cannot attach a #{source.class}; pass a path, Pathname, IO, URL, or data URL"
|
|
104
|
+
end
|
|
105
|
+
|
|
106
|
+
check_size!
|
|
107
|
+
end
|
|
108
|
+
|
|
109
|
+
# True when the file stays where it is and only its URL is sent.
|
|
110
|
+
def remote?
|
|
111
|
+
!@url.nil?
|
|
112
|
+
end
|
|
113
|
+
|
|
114
|
+
def bytes
|
|
115
|
+
raise AttachmentError, "#{@url} is remote; download it before reading its bytes" if remote?
|
|
116
|
+
|
|
117
|
+
@bytes
|
|
118
|
+
end
|
|
119
|
+
|
|
120
|
+
def size
|
|
121
|
+
remote? ? nil : @bytes.bytesize
|
|
122
|
+
end
|
|
123
|
+
|
|
124
|
+
def filename
|
|
125
|
+
@filename ||= default_filename
|
|
126
|
+
end
|
|
127
|
+
|
|
128
|
+
def base64
|
|
129
|
+
[bytes].pack("m0")
|
|
130
|
+
end
|
|
131
|
+
|
|
132
|
+
# What goes in the wire format: either the original https URL or a data URL.
|
|
133
|
+
def url
|
|
134
|
+
@url || "data:#{mime_type};base64,#{base64}"
|
|
135
|
+
end
|
|
136
|
+
alias data_url url
|
|
137
|
+
|
|
138
|
+
# :image, :video, :audio or :file — decides which content part shape is used.
|
|
139
|
+
def kind
|
|
140
|
+
case mime_type
|
|
141
|
+
when %r{\Aimage/} then :image
|
|
142
|
+
when %r{\Avideo/} then :video
|
|
143
|
+
when %r{\Aaudio/} then :audio
|
|
144
|
+
else :file
|
|
145
|
+
end
|
|
146
|
+
end
|
|
147
|
+
|
|
148
|
+
def audio_format
|
|
149
|
+
AUDIO_FORMATS[mime_type] ||
|
|
150
|
+
AUDIO_FORMATS[Attachment.mime_type_for(filename)] ||
|
|
151
|
+
raise(AttachmentError, "unsupported audio type #{mime_type.inspect}; " \
|
|
152
|
+
"OpenRouter accepts #{AUDIO_FORMATS.values.uniq.join(', ')}")
|
|
153
|
+
end
|
|
154
|
+
|
|
155
|
+
# Renders the content part for this file. `as:` forces a shape when the
|
|
156
|
+
# MIME type would pick the wrong one.
|
|
157
|
+
def to_part(as: nil)
|
|
158
|
+
case as || kind
|
|
159
|
+
when :image then { type: "image_url", image_url: { url: url } }
|
|
160
|
+
when :video then { type: "video_url", video_url: { url: url } }
|
|
161
|
+
when :audio then audio_part
|
|
162
|
+
when :file then { type: "file", file: { filename: filename, file_data: url } }
|
|
163
|
+
else raise ArgumentError, "unknown content kind #{as.inspect}"
|
|
164
|
+
end
|
|
165
|
+
end
|
|
166
|
+
|
|
167
|
+
def save(path)
|
|
168
|
+
File.binwrite(path, bytes)
|
|
169
|
+
path
|
|
170
|
+
end
|
|
171
|
+
|
|
172
|
+
def inspect
|
|
173
|
+
"#<#{self.class.name} #{filename.inspect} #{mime_type} #{remote? ? @url : "#{size} bytes"}>"
|
|
174
|
+
end
|
|
175
|
+
|
|
176
|
+
private
|
|
177
|
+
|
|
178
|
+
# Audio has no URL form in the API, so a remote source has to be fetched by
|
|
179
|
+
# the caller first — better an explicit error than a silently ignored file.
|
|
180
|
+
def audio_part
|
|
181
|
+
if remote?
|
|
182
|
+
raise AttachmentError,
|
|
183
|
+
"audio must be sent as base64; download #{@url} and attach the bytes or a local path"
|
|
184
|
+
end
|
|
185
|
+
|
|
186
|
+
{ type: "input_audio", input_audio: { data: base64, format: audio_format } }
|
|
187
|
+
end
|
|
188
|
+
|
|
189
|
+
def initialize_copy_of(other, filename, mime_type)
|
|
190
|
+
@url = other.remote? ? other.url : nil
|
|
191
|
+
@bytes = other.remote? ? nil : other.bytes
|
|
192
|
+
@mime_type = mime_type || other.mime_type
|
|
193
|
+
@filename = filename || other.filename
|
|
194
|
+
end
|
|
195
|
+
|
|
196
|
+
def initialize_from_string(source, mime_type, filename)
|
|
197
|
+
if source.match?(DATA_URL)
|
|
198
|
+
decoded = Attachment.from_data_url(source, filename: filename)
|
|
199
|
+
@bytes = decoded.bytes
|
|
200
|
+
@mime_type = mime_type || decoded.mime_type
|
|
201
|
+
@filename = filename
|
|
202
|
+
elsif source.match?(REMOTE_URL)
|
|
203
|
+
initialize_from_url(source, mime_type, filename)
|
|
204
|
+
else
|
|
205
|
+
initialize_from_path(source, mime_type, filename)
|
|
206
|
+
end
|
|
207
|
+
end
|
|
208
|
+
|
|
209
|
+
def initialize_from_path(path, mime_type, filename)
|
|
210
|
+
unless File.file?(path)
|
|
211
|
+
raise AttachmentError, "no such file: #{path}"
|
|
212
|
+
end
|
|
213
|
+
|
|
214
|
+
@bytes = File.binread(path)
|
|
215
|
+
@filename = filename || File.basename(path)
|
|
216
|
+
@mime_type = mime_type || Attachment.mime_type_for(path) || sniff(@bytes) || DEFAULT_TYPE
|
|
217
|
+
end
|
|
218
|
+
|
|
219
|
+
def initialize_from_url(url, mime_type, filename)
|
|
220
|
+
@url = url
|
|
221
|
+
@bytes = nil
|
|
222
|
+
@filename = filename || File.basename(URI.parse(url).path.to_s)
|
|
223
|
+
@filename = nil if @filename.empty? || @filename == "/"
|
|
224
|
+
@mime_type = mime_type || Attachment.mime_type_for(@filename.to_s) || DEFAULT_TYPE
|
|
225
|
+
end
|
|
226
|
+
|
|
227
|
+
def initialize_from_io(io, mime_type, filename)
|
|
228
|
+
io.rewind if io.respond_to?(:rewind)
|
|
229
|
+
io.binmode if io.respond_to?(:binmode)
|
|
230
|
+
@bytes = io.read.to_s
|
|
231
|
+
path = io.respond_to?(:path) ? io.path : nil
|
|
232
|
+
@filename = filename || (path && File.basename(path))
|
|
233
|
+
@mime_type = mime_type || (path && Attachment.mime_type_for(path)) || sniff(@bytes) || DEFAULT_TYPE
|
|
234
|
+
end
|
|
235
|
+
|
|
236
|
+
def initialize_from_bytes(bytes, mime_type, filename)
|
|
237
|
+
@url = nil
|
|
238
|
+
@bytes = bytes
|
|
239
|
+
@filename = filename
|
|
240
|
+
@mime_type = mime_type || sniff(bytes) || DEFAULT_TYPE
|
|
241
|
+
check_size!
|
|
242
|
+
self
|
|
243
|
+
end
|
|
244
|
+
|
|
245
|
+
def default_filename
|
|
246
|
+
extension = EXTENSION_TYPES.key(mime_type)
|
|
247
|
+
extension ? "file.#{extension}" : "file"
|
|
248
|
+
end
|
|
249
|
+
|
|
250
|
+
def check_size!
|
|
251
|
+
limit = OpenRouter.config.max_attachment_bytes
|
|
252
|
+
return if limit.nil? || remote? || @bytes.bytesize <= limit
|
|
253
|
+
|
|
254
|
+
raise AttachmentError,
|
|
255
|
+
"#{filename} is #{@bytes.bytesize} bytes, over the configured max_attachment_bytes (#{limit})"
|
|
256
|
+
end
|
|
257
|
+
|
|
258
|
+
# Enough magic bytes to identify anything that arrives without a filename.
|
|
259
|
+
def sniff(bytes)
|
|
260
|
+
return nil if bytes.nil? || bytes.bytesize < 12
|
|
261
|
+
|
|
262
|
+
head = bytes.byteslice(0, 12).b
|
|
263
|
+
|
|
264
|
+
case head
|
|
265
|
+
when /\A\x89PNG\r\n\x1a\n/n then "image/png"
|
|
266
|
+
when /\A\xff\xd8\xff/n then "image/jpeg"
|
|
267
|
+
when /\AGIF8[79]a/n then "image/gif"
|
|
268
|
+
when /\A%PDF-/n then "application/pdf"
|
|
269
|
+
when /\AOggS/n then "audio/ogg"
|
|
270
|
+
when /\AfLaC/n then "audio/flac"
|
|
271
|
+
when /\AID3/n, /\A\xff[\xe0-\xff]/n then "audio/mpeg"
|
|
272
|
+
when /\AFORM....AIFF/n then "audio/aiff"
|
|
273
|
+
when /\A\x1a\x45\xdf\xa3/n then "video/webm"
|
|
274
|
+
when /\ARIFF/n then head.byteslice(8, 4) == "WEBP" ? "image/webp" : "audio/wav"
|
|
275
|
+
else sniff_iso_media(head)
|
|
276
|
+
end
|
|
277
|
+
end
|
|
278
|
+
|
|
279
|
+
# MP4/MOV/M4A all start with an `ftyp` box; the brand says which.
|
|
280
|
+
def sniff_iso_media(head)
|
|
281
|
+
return nil unless head.byteslice(4, 4) == "ftyp"
|
|
282
|
+
|
|
283
|
+
case head.byteslice(8, 4).to_s
|
|
284
|
+
when /qt/ then "video/quicktime"
|
|
285
|
+
when /M4A/ then "audio/mp4"
|
|
286
|
+
else "video/mp4"
|
|
287
|
+
end
|
|
288
|
+
end
|
|
289
|
+
|
|
290
|
+
end
|
|
291
|
+
end
|
|
@@ -0,0 +1,146 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module OpenRouter
|
|
4
|
+
# Entry point for the API.
|
|
5
|
+
#
|
|
6
|
+
# client = OpenRouter::Client.new(api_key: ENV["OPENROUTER_API_KEY"])
|
|
7
|
+
# client.chat.completions.create(model: "openai/gpt-4o-mini", messages: messages)
|
|
8
|
+
#
|
|
9
|
+
# Clients are thread-safe: every request builds its own connection, and the
|
|
10
|
+
# only shared state is configuration.
|
|
11
|
+
class Client
|
|
12
|
+
DEFAULT_BASE_URL = "https://openrouter.ai/api/v1"
|
|
13
|
+
|
|
14
|
+
attr_reader :api_key, :base_url, :default_model, :extra_body, :default_query, :transport
|
|
15
|
+
|
|
16
|
+
def initialize(api_key: nil, base_url: nil, site_url: nil, app_name: nil, default_model: nil,
|
|
17
|
+
timeout: nil, open_timeout: nil, write_timeout: nil, max_retries: nil,
|
|
18
|
+
default_headers: nil, default_query: nil, extra_body: nil,
|
|
19
|
+
logger: nil, debug_output: nil, sleeper: nil)
|
|
20
|
+
config = OpenRouter.config
|
|
21
|
+
|
|
22
|
+
@api_key = api_key || config.api_key || ENV["OPENROUTER_API_KEY"]
|
|
23
|
+
if @api_key.nil? || @api_key.to_s.strip.empty?
|
|
24
|
+
raise ConfigurationError,
|
|
25
|
+
"No API key. Pass api_key:, set OPENROUTER_API_KEY, or call OpenRouter.configure."
|
|
26
|
+
end
|
|
27
|
+
|
|
28
|
+
@base_url = base_url || config.base_url || ENV["OPENROUTER_BASE_URL"] || DEFAULT_BASE_URL
|
|
29
|
+
@default_model = default_model || config.default_model
|
|
30
|
+
@extra_body = symbolize(extra_body || config.extra_body || {})
|
|
31
|
+
@default_query = default_query || config.default_query || {}
|
|
32
|
+
|
|
33
|
+
@transport = Transport.new(
|
|
34
|
+
base_url: @base_url,
|
|
35
|
+
headers: build_headers(site_url || config.site_url, app_name || config.app_name,
|
|
36
|
+
default_headers || config.default_headers || {}),
|
|
37
|
+
timeout: timeout || config.timeout,
|
|
38
|
+
open_timeout: open_timeout || config.open_timeout,
|
|
39
|
+
write_timeout: write_timeout || config.write_timeout,
|
|
40
|
+
max_retries: max_retries || config.max_retries,
|
|
41
|
+
logger: logger || config.logger,
|
|
42
|
+
debug_output: debug_output || config.debug_output,
|
|
43
|
+
sleeper: sleeper
|
|
44
|
+
)
|
|
45
|
+
end
|
|
46
|
+
|
|
47
|
+
def chat
|
|
48
|
+
@chat ||= Resources::Chat.new(self)
|
|
49
|
+
end
|
|
50
|
+
|
|
51
|
+
def completions
|
|
52
|
+
@completions ||= Resources::TextCompletions.new(self)
|
|
53
|
+
end
|
|
54
|
+
|
|
55
|
+
def models
|
|
56
|
+
@models ||= Resources::Models.new(self)
|
|
57
|
+
end
|
|
58
|
+
|
|
59
|
+
def credits
|
|
60
|
+
@credits ||= Resources::Credits.new(self)
|
|
61
|
+
end
|
|
62
|
+
|
|
63
|
+
def key
|
|
64
|
+
@key ||= Resources::Key.new(self)
|
|
65
|
+
end
|
|
66
|
+
|
|
67
|
+
def generations
|
|
68
|
+
@generations ||= Resources::Generations.new(self)
|
|
69
|
+
end
|
|
70
|
+
|
|
71
|
+
def files
|
|
72
|
+
@files ||= Resources::Files.new(self)
|
|
73
|
+
end
|
|
74
|
+
|
|
75
|
+
# --- low-level escape hatches -------------------------------------------
|
|
76
|
+
# Useful for endpoints this gem has not wrapped yet.
|
|
77
|
+
|
|
78
|
+
def get(path, query: nil, headers: {}, timeout: nil)
|
|
79
|
+
@transport.request(:get, path, query: merge_query(query), headers: headers, timeout: timeout)
|
|
80
|
+
end
|
|
81
|
+
|
|
82
|
+
def post(path, body:, query: nil, headers: {}, timeout: nil)
|
|
83
|
+
@transport.request(:post, path, body: body, query: merge_query(query), headers: headers, timeout: timeout)
|
|
84
|
+
end
|
|
85
|
+
|
|
86
|
+
def delete(path, query: nil, headers: {}, timeout: nil)
|
|
87
|
+
@transport.request(:delete, path, query: merge_query(query), headers: headers, timeout: timeout)
|
|
88
|
+
end
|
|
89
|
+
|
|
90
|
+
# multipart/form-data, for file uploads.
|
|
91
|
+
def upload(path, form:, query: nil, headers: {}, timeout: nil)
|
|
92
|
+
@transport.request(:post, path, form: form, query: merge_query(query), headers: headers, timeout: timeout)
|
|
93
|
+
end
|
|
94
|
+
|
|
95
|
+
# Returns the response body as bytes instead of parsed JSON.
|
|
96
|
+
def download(path, query: nil, headers: {}, timeout: nil)
|
|
97
|
+
@transport.request(:get, path, query: merge_query(query), headers: headers, timeout: timeout, raw: true)
|
|
98
|
+
end
|
|
99
|
+
|
|
100
|
+
# Opens a server-sent-event stream. Returns an OpenRouter::Stream; the
|
|
101
|
+
# request is not sent until the stream is iterated.
|
|
102
|
+
def stream(path, body:, query: nil, headers: {}, timeout: nil)
|
|
103
|
+
enumerator, connection = @transport.stream_request(
|
|
104
|
+
:post, path, body: body, query: merge_query(query), headers: headers, timeout: timeout
|
|
105
|
+
)
|
|
106
|
+
Stream.new(enumerator, connection: connection)
|
|
107
|
+
end
|
|
108
|
+
|
|
109
|
+
def inspect
|
|
110
|
+
"#<#{self.class.name} base_url=#{@base_url.inspect} api_key=#{redacted_key.inspect}>"
|
|
111
|
+
end
|
|
112
|
+
|
|
113
|
+
private
|
|
114
|
+
|
|
115
|
+
def redacted_key
|
|
116
|
+
key = @api_key.to_s
|
|
117
|
+
key.length > 8 ? "#{key[0, 6]}...#{key[-4, 4]}" : "***"
|
|
118
|
+
end
|
|
119
|
+
|
|
120
|
+
def merge_query(query)
|
|
121
|
+
return @default_query if query.nil?
|
|
122
|
+
return query if @default_query.empty?
|
|
123
|
+
|
|
124
|
+
@default_query.merge(query)
|
|
125
|
+
end
|
|
126
|
+
|
|
127
|
+
def build_headers(site_url, app_name, extra)
|
|
128
|
+
headers = {
|
|
129
|
+
"Authorization" => "Bearer #{@api_key}",
|
|
130
|
+
"Content-Type" => "application/json",
|
|
131
|
+
"Accept" => "application/json",
|
|
132
|
+
"User-Agent" => "openrouter-ruby/#{VERSION} ruby/#{RUBY_VERSION}",
|
|
133
|
+
# Attribution headers: these are what put an app on the OpenRouter
|
|
134
|
+
# leaderboards and in the model's usage breakdown.
|
|
135
|
+
"HTTP-Referer" => site_url,
|
|
136
|
+
"X-Title" => app_name
|
|
137
|
+
}
|
|
138
|
+
headers.reject! { |_key, value| value.nil? || value.to_s.empty? }
|
|
139
|
+
headers.merge(extra)
|
|
140
|
+
end
|
|
141
|
+
|
|
142
|
+
def symbolize(hash)
|
|
143
|
+
hash.each_with_object({}) { |(key, value), memo| memo[key.to_sym] = value }
|
|
144
|
+
end
|
|
145
|
+
end
|
|
146
|
+
end
|
|
@@ -0,0 +1,161 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module OpenRouter
|
|
4
|
+
# Builders for multimodal message content.
|
|
5
|
+
#
|
|
6
|
+
# Content.text("What is in this?")
|
|
7
|
+
# Content.image("chart.png")
|
|
8
|
+
# Content.file("report.pdf")
|
|
9
|
+
# Content.audio("note.m4a")
|
|
10
|
+
# Content.video("https://example.com/clip.mp4")
|
|
11
|
+
# Content.attach("anything.ext") # picks the shape from the MIME type
|
|
12
|
+
#
|
|
13
|
+
# Also normalizes whole messages, so a content array can hold Pathnames, IOs,
|
|
14
|
+
# URIs and Attachments alongside plain strings.
|
|
15
|
+
module Content
|
|
16
|
+
PART_TYPES = %w[text image_url file input_audio video_url].freeze
|
|
17
|
+
|
|
18
|
+
module_function
|
|
19
|
+
|
|
20
|
+
def text(value)
|
|
21
|
+
{ type: "text", text: value.to_s }
|
|
22
|
+
end
|
|
23
|
+
|
|
24
|
+
def image(source, filename: nil, mime_type: nil)
|
|
25
|
+
Attachment.from(source, filename: filename, mime_type: mime_type).to_part(as: :image)
|
|
26
|
+
end
|
|
27
|
+
|
|
28
|
+
def video(source, filename: nil, mime_type: nil)
|
|
29
|
+
Attachment.from(source, filename: filename, mime_type: mime_type).to_part(as: :video)
|
|
30
|
+
end
|
|
31
|
+
|
|
32
|
+
def audio(source, filename: nil, mime_type: nil, format: nil)
|
|
33
|
+
part = Attachment.from(source, filename: filename, mime_type: mime_type).to_part(as: :audio)
|
|
34
|
+
part[:input_audio][:format] = format.to_s if format
|
|
35
|
+
part
|
|
36
|
+
end
|
|
37
|
+
|
|
38
|
+
# A document part. Pass `id:` to reference a file already uploaded through
|
|
39
|
+
# client.files, otherwise the file is inlined as a data URL (or its https
|
|
40
|
+
# URL, which OpenRouter will fetch itself).
|
|
41
|
+
def file(source = nil, id: nil, filename: nil, mime_type: nil)
|
|
42
|
+
if id
|
|
43
|
+
part = { type: "file", file: { file_id: id } }
|
|
44
|
+
part[:file][:filename] = filename if filename
|
|
45
|
+
return part
|
|
46
|
+
end
|
|
47
|
+
|
|
48
|
+
raise ArgumentError, "pass a file source or id:" if source.nil?
|
|
49
|
+
|
|
50
|
+
Attachment.from(source, filename: filename, mime_type: mime_type).to_part(as: :file)
|
|
51
|
+
end
|
|
52
|
+
|
|
53
|
+
# Builds whichever part suits the file: images become image_url, video
|
|
54
|
+
# becomes video_url, audio becomes input_audio, everything else a file part.
|
|
55
|
+
def attach(source, as: nil, filename: nil, mime_type: nil)
|
|
56
|
+
Attachment.from(source, filename: filename, mime_type: mime_type).to_part(as: as)
|
|
57
|
+
end
|
|
58
|
+
|
|
59
|
+
# Decodes a data URL — e.g. an image a model returned — into an Attachment
|
|
60
|
+
# you can inspect or #save.
|
|
61
|
+
def decode(source)
|
|
62
|
+
url = source.is_a?(Hash) || source.is_a?(Structure) ? extract_url(source) : source
|
|
63
|
+
Attachment.from_data_url(url)
|
|
64
|
+
end
|
|
65
|
+
|
|
66
|
+
# Coerces one item into a content part. Strings stay text: a string is never
|
|
67
|
+
# guessed to be a path, because a user's own words must never be read off
|
|
68
|
+
# the filesystem. Attach files explicitly, or with non-String types.
|
|
69
|
+
def part(item)
|
|
70
|
+
case item
|
|
71
|
+
when Hash then item
|
|
72
|
+
when Structure then item.to_h
|
|
73
|
+
when String then text(item)
|
|
74
|
+
when Symbol, Numeric then text(item.to_s)
|
|
75
|
+
when Attachment then item.to_part
|
|
76
|
+
when Pathname, IO, StringIO, URI::Generic then attach(item)
|
|
77
|
+
when nil then nil
|
|
78
|
+
else
|
|
79
|
+
raise ArgumentError, "cannot turn a #{item.class} into message content"
|
|
80
|
+
end
|
|
81
|
+
end
|
|
82
|
+
|
|
83
|
+
def parts(*items)
|
|
84
|
+
items.flatten(1).filter_map { |item| part(item) }
|
|
85
|
+
end
|
|
86
|
+
|
|
87
|
+
# Normalizes one message: expands an `attachments:` shorthand and converts
|
|
88
|
+
# every element of a content array into a content part.
|
|
89
|
+
def normalize_message(message)
|
|
90
|
+
message = message.to_h if message.is_a?(Structure)
|
|
91
|
+
return message unless message.is_a?(Hash)
|
|
92
|
+
|
|
93
|
+
attachments = message[:attachments] || message["attachments"]
|
|
94
|
+
content = message.key?(:content) ? message[:content] : message["content"]
|
|
95
|
+
|
|
96
|
+
return message if attachments.nil? && !content.is_a?(Array)
|
|
97
|
+
|
|
98
|
+
normalized = message.reject { |key, _| key.to_s == "attachments" }
|
|
99
|
+
normalized = symbolize(normalized)
|
|
100
|
+
normalized[:content] = build_content(content, attachments)
|
|
101
|
+
normalized
|
|
102
|
+
end
|
|
103
|
+
|
|
104
|
+
def normalize_messages(messages)
|
|
105
|
+
Array(messages).map { |message| normalize_message(message) }
|
|
106
|
+
end
|
|
107
|
+
|
|
108
|
+
# --- internals ----------------------------------------------------------
|
|
109
|
+
|
|
110
|
+
def build_content(content, attachments)
|
|
111
|
+
parts =
|
|
112
|
+
case content
|
|
113
|
+
when Array then parts(*content)
|
|
114
|
+
when nil then []
|
|
115
|
+
else [part(content)].compact
|
|
116
|
+
end
|
|
117
|
+
|
|
118
|
+
parts + Array(attachments).map { |attachment| part_for_attachment(attachment) }
|
|
119
|
+
end
|
|
120
|
+
|
|
121
|
+
# Inside `attachments:` a String IS a path or URL — that is what the key
|
|
122
|
+
# means, so the ambiguity that applies to content strings does not arise.
|
|
123
|
+
def part_for_attachment(item)
|
|
124
|
+
case item
|
|
125
|
+
when Hash
|
|
126
|
+
PART_TYPES.include?((item[:type] || item["type"]).to_s) ? item : attach_from_hash(item)
|
|
127
|
+
when Structure then part_for_attachment(item.to_h)
|
|
128
|
+
when String then attach(item)
|
|
129
|
+
else part(item)
|
|
130
|
+
end
|
|
131
|
+
end
|
|
132
|
+
|
|
133
|
+
def attach_from_hash(options)
|
|
134
|
+
options = symbolize(options)
|
|
135
|
+
source = options.delete(:source) || options.delete(:path) || options.delete(:url)
|
|
136
|
+
id = options.delete(:id) || options.delete(:file_id)
|
|
137
|
+
return file(id: id, filename: options[:filename]) if id
|
|
138
|
+
|
|
139
|
+
attach(source, **options.slice(:as, :filename, :mime_type))
|
|
140
|
+
end
|
|
141
|
+
|
|
142
|
+
def extract_url(item)
|
|
143
|
+
item = item.to_h if item.is_a?(Structure)
|
|
144
|
+
item[:url] || item["url"] ||
|
|
145
|
+
dig_url(item[:image_url] || item["image_url"]) ||
|
|
146
|
+
dig_url(item[:video_url] || item["video_url"]) ||
|
|
147
|
+
raise(ArgumentError, "no url found in #{item.inspect}")
|
|
148
|
+
end
|
|
149
|
+
|
|
150
|
+
def dig_url(value)
|
|
151
|
+
return nil if value.nil?
|
|
152
|
+
|
|
153
|
+
value = value.to_h if value.is_a?(Structure)
|
|
154
|
+
value.is_a?(Hash) ? (value[:url] || value["url"]) : value
|
|
155
|
+
end
|
|
156
|
+
|
|
157
|
+
def symbolize(hash)
|
|
158
|
+
hash.each_with_object({}) { |(key, value), memo| memo[key.to_sym] = value }
|
|
159
|
+
end
|
|
160
|
+
end
|
|
161
|
+
end
|