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