genai-rb 0.1.0 → 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 +4 -4
- data/CHANGELOG.md +7 -1
- data/LICENSE.txt +1 -1
- data/README.md +156 -107
- data/lib/genai/chat.rb +206 -203
- data/lib/genai/config.rb +9 -9
- data/lib/genai/model.rb +229 -395
- data/lib/genai/version.rb +1 -1
- data/lib/genai.rb +32 -32
- metadata +11 -54
data/lib/genai/model.rb
CHANGED
|
@@ -1,395 +1,229 @@
|
|
|
1
|
-
require "net/http"
|
|
2
|
-
require "json"
|
|
3
|
-
require "uri"
|
|
4
|
-
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
response = http.request(request)
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
end
|
|
208
|
-
|
|
209
|
-
def
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
|
|
227
|
-
|
|
228
|
-
|
|
229
|
-
|
|
230
|
-
response = http.request(request)
|
|
231
|
-
|
|
232
|
-
case response
|
|
233
|
-
when Net::HTTPSuccess
|
|
234
|
-
response
|
|
235
|
-
when Net::HTTPClientError
|
|
236
|
-
raise Error, "Client error: #{response.code} - #{response.body}"
|
|
237
|
-
when Net::HTTPServerError
|
|
238
|
-
raise Error, "Server error: #{response.code} - #{response.body}"
|
|
239
|
-
else
|
|
240
|
-
raise Error, "Unexpected response: #{response.code} - #{response.body}"
|
|
241
|
-
end
|
|
242
|
-
end
|
|
243
|
-
|
|
244
|
-
def parse_response(response)
|
|
245
|
-
data = JSON.parse(response.body)
|
|
246
|
-
|
|
247
|
-
candidates = data["candidates"] || []
|
|
248
|
-
return "" if candidates.empty?
|
|
249
|
-
|
|
250
|
-
content = candidates.first["content"]
|
|
251
|
-
parts = content["parts"] || []
|
|
252
|
-
|
|
253
|
-
text_parts = parts.map { |part| part["text"] }.compact
|
|
254
|
-
text = text_parts.join
|
|
255
|
-
|
|
256
|
-
if candidates.first["groundingMetadata"]
|
|
257
|
-
grounding_info = candidates.first["groundingMetadata"]
|
|
258
|
-
if grounding_info["groundingChunks"]
|
|
259
|
-
text += "\n\n참고한 URL:\n"
|
|
260
|
-
grounding_info["groundingChunks"].each_with_index do |chunk, index|
|
|
261
|
-
if chunk["web"]
|
|
262
|
-
original_url = extract_original_url(chunk["web"]["uri"])
|
|
263
|
-
decoded_url = original_url.gsub(/\\u([0-9a-fA-F]{4})/) { |m| [$1.to_i(16)].pack('U') }
|
|
264
|
-
decoded_url = CGI.unescape(decoded_url)
|
|
265
|
-
encoded_url = decoded_url.gsub(' ', '%20')
|
|
266
|
-
text += "#{index + 1}. #{encoded_url}\n"
|
|
267
|
-
end
|
|
268
|
-
end
|
|
269
|
-
end
|
|
270
|
-
end
|
|
271
|
-
|
|
272
|
-
text
|
|
273
|
-
end
|
|
274
|
-
|
|
275
|
-
def extract_original_url(redirect_url)
|
|
276
|
-
return redirect_url unless redirect_url.include?("vertexaisearch.cloud.google.com")
|
|
277
|
-
|
|
278
|
-
final_url = follow_redirects(redirect_url)
|
|
279
|
-
return final_url if final_url && final_url != redirect_url
|
|
280
|
-
|
|
281
|
-
begin
|
|
282
|
-
uri = URI(redirect_url)
|
|
283
|
-
|
|
284
|
-
path_parts = uri.path.split("/")
|
|
285
|
-
if path_parts.include?("grounding-api-redirect")
|
|
286
|
-
encoded_url = path_parts.last
|
|
287
|
-
|
|
288
|
-
decoded_url = try_decode_url(encoded_url)
|
|
289
|
-
return decoded_url if decoded_url && decoded_url.start_with?("http")
|
|
290
|
-
end
|
|
291
|
-
|
|
292
|
-
if uri.query
|
|
293
|
-
params = URI.decode_www_form(uri.query)
|
|
294
|
-
original_url = params.find { |k, v| k == "url" || k == "original_url" || k == "target" }&.last
|
|
295
|
-
return original_url if original_url && original_url.start_with?("http")
|
|
296
|
-
end
|
|
297
|
-
|
|
298
|
-
if uri.query && uri.query.include?("http")
|
|
299
|
-
url_match = uri.query.match(/https?:\/\/[^\s&]+/)
|
|
300
|
-
return url_match[0] if url_match
|
|
301
|
-
end
|
|
302
|
-
|
|
303
|
-
if ENV['DEBUG']
|
|
304
|
-
puts "URL 파싱 실패 - 구조:"
|
|
305
|
-
puts " 전체 URL: #{redirect_url}"
|
|
306
|
-
puts " 경로: #{uri.path}"
|
|
307
|
-
puts " 쿼리: #{uri.query}"
|
|
308
|
-
puts " 인코딩된 부분: #{path_parts.last}" if path_parts.last
|
|
309
|
-
end
|
|
310
|
-
|
|
311
|
-
rescue => e
|
|
312
|
-
puts "URL 파싱 오류: #{e.message}" if ENV['DEBUG']
|
|
313
|
-
end
|
|
314
|
-
|
|
315
|
-
redirect_url
|
|
316
|
-
end
|
|
317
|
-
|
|
318
|
-
def follow_redirects(url, max_redirects = 5)
|
|
319
|
-
current_url = url
|
|
320
|
-
redirect_count = 0
|
|
321
|
-
|
|
322
|
-
while redirect_count < max_redirects
|
|
323
|
-
begin
|
|
324
|
-
uri = URI(current_url)
|
|
325
|
-
http = Net::HTTP.new(uri.host, uri.port)
|
|
326
|
-
http.use_ssl = true if uri.scheme == 'https'
|
|
327
|
-
http.open_timeout = 10
|
|
328
|
-
http.read_timeout = 10
|
|
329
|
-
|
|
330
|
-
request = Net::HTTP::Get.new(uri)
|
|
331
|
-
request['User-Agent'] = 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36'
|
|
332
|
-
|
|
333
|
-
response = http.request(request)
|
|
334
|
-
|
|
335
|
-
case response
|
|
336
|
-
when Net::HTTPRedirection
|
|
337
|
-
redirect_count += 1
|
|
338
|
-
location = response['location']
|
|
339
|
-
if location
|
|
340
|
-
if location.start_with?('/')
|
|
341
|
-
current_url = "#{uri.scheme}://#{uri.host}#{location}"
|
|
342
|
-
elsif location.start_with?('http')
|
|
343
|
-
current_url = location
|
|
344
|
-
else
|
|
345
|
-
current_url = "#{uri.scheme}://#{uri.host}/#{location}"
|
|
346
|
-
end
|
|
347
|
-
else
|
|
348
|
-
break
|
|
349
|
-
end
|
|
350
|
-
else
|
|
351
|
-
return current_url
|
|
352
|
-
end
|
|
353
|
-
rescue => e
|
|
354
|
-
puts "리다이렉트 추적 오류: #{e.message}" if ENV['DEBUG']
|
|
355
|
-
return url
|
|
356
|
-
end
|
|
357
|
-
end
|
|
358
|
-
|
|
359
|
-
current_url
|
|
360
|
-
end
|
|
361
|
-
|
|
362
|
-
def try_decode_url(encoded_url)
|
|
363
|
-
begin
|
|
364
|
-
require 'base64'
|
|
365
|
-
decoded_bytes = Base64.urlsafe_decode64(encoded_url)
|
|
366
|
-
decoded_url = decoded_bytes.force_encoding('UTF-8')
|
|
367
|
-
return decoded_url if decoded_url.start_with?("http")
|
|
368
|
-
rescue
|
|
369
|
-
end
|
|
370
|
-
|
|
371
|
-
begin
|
|
372
|
-
decoded_bytes = Base64.decode64(encoded_url)
|
|
373
|
-
decoded_url = decoded_bytes.force_encoding('UTF-8')
|
|
374
|
-
return decoded_url if decoded_url.start_with?("http")
|
|
375
|
-
rescue
|
|
376
|
-
end
|
|
377
|
-
|
|
378
|
-
begin
|
|
379
|
-
decoded_url = URI.decode(encoded_url)
|
|
380
|
-
return decoded_url if decoded_url.start_with?("http")
|
|
381
|
-
rescue
|
|
382
|
-
end
|
|
383
|
-
|
|
384
|
-
begin
|
|
385
|
-
padded_url = encoded_url + "=" * (4 - encoded_url.length % 4)
|
|
386
|
-
decoded_bytes = Base64.decode64(padded_url)
|
|
387
|
-
decoded_url = decoded_bytes.force_encoding('UTF-8')
|
|
388
|
-
return decoded_url if decoded_url.start_with?("http")
|
|
389
|
-
rescue
|
|
390
|
-
end
|
|
391
|
-
|
|
392
|
-
nil
|
|
393
|
-
end
|
|
394
|
-
end
|
|
395
|
-
end
|
|
1
|
+
require "net/http"
|
|
2
|
+
require "json"
|
|
3
|
+
require "uri"
|
|
4
|
+
|
|
5
|
+
module Genai
|
|
6
|
+
class Model
|
|
7
|
+
attr_reader :client, :model_id
|
|
8
|
+
|
|
9
|
+
OPENAI_ROLES = {
|
|
10
|
+
"model" => "assistant",
|
|
11
|
+
"assistant" => "assistant",
|
|
12
|
+
"user" => "user",
|
|
13
|
+
"system" => "system",
|
|
14
|
+
"developer" => "developer",
|
|
15
|
+
"tool" => "tool"
|
|
16
|
+
}.freeze
|
|
17
|
+
|
|
18
|
+
def initialize(client, model_id)
|
|
19
|
+
@client = client
|
|
20
|
+
@model_id = model_id
|
|
21
|
+
end
|
|
22
|
+
|
|
23
|
+
def generate_content(contents:, tools: nil, config: nil, grounding: nil, **options)
|
|
24
|
+
raise Error, "grounding is not supported by OpenAI-compatible chat completions." if grounding
|
|
25
|
+
|
|
26
|
+
request_body = build_request_body(contents: contents, tools: tools, config: config, **options)
|
|
27
|
+
response = make_request(request_body)
|
|
28
|
+
parse_response(response)
|
|
29
|
+
end
|
|
30
|
+
|
|
31
|
+
private
|
|
32
|
+
|
|
33
|
+
def build_request_body(contents:, tools: nil, config: nil, **options)
|
|
34
|
+
body = {
|
|
35
|
+
model: model_id,
|
|
36
|
+
messages: normalize_messages(contents)
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
body.merge!(normalize_config(config))
|
|
40
|
+
body[:tools] = normalize_tools(tools) if tools
|
|
41
|
+
body.merge!(normalize_config(options))
|
|
42
|
+
body
|
|
43
|
+
end
|
|
44
|
+
|
|
45
|
+
def normalize_messages(contents)
|
|
46
|
+
case contents
|
|
47
|
+
when String
|
|
48
|
+
[message_from_value(contents)]
|
|
49
|
+
when Array
|
|
50
|
+
if contents.all? { |content| openai_message?(content) || parts_message?(content) }
|
|
51
|
+
contents.map { |content| normalize_message_hash(content) }
|
|
52
|
+
else
|
|
53
|
+
[message_from_parts(contents)]
|
|
54
|
+
end
|
|
55
|
+
when Hash
|
|
56
|
+
[normalize_message_hash(contents)]
|
|
57
|
+
else
|
|
58
|
+
raise Error, "Invalid contents format: #{contents.class}"
|
|
59
|
+
end
|
|
60
|
+
end
|
|
61
|
+
|
|
62
|
+
def message_from_value(value)
|
|
63
|
+
{ role: "user", content: content_from_value(value) }
|
|
64
|
+
end
|
|
65
|
+
|
|
66
|
+
def message_from_parts(parts)
|
|
67
|
+
content = parts.flat_map { |part| Array(content_from_value(part)) }
|
|
68
|
+
{ role: "user", content: compact_content(content) }
|
|
69
|
+
end
|
|
70
|
+
|
|
71
|
+
def normalize_message_hash(message)
|
|
72
|
+
normalized = stringify_keys(message)
|
|
73
|
+
role = OPENAI_ROLES.fetch(normalized["role"] || "user") do |unknown_role|
|
|
74
|
+
raise Error, "Unsupported role for OpenAI-compatible chat completions: #{unknown_role}"
|
|
75
|
+
end
|
|
76
|
+
|
|
77
|
+
if normalized.key?("content")
|
|
78
|
+
normalized["role"] = role
|
|
79
|
+
return normalized
|
|
80
|
+
end
|
|
81
|
+
|
|
82
|
+
parts = normalized["parts"]
|
|
83
|
+
raise Error, "Message hash must include content or parts" unless parts
|
|
84
|
+
|
|
85
|
+
{
|
|
86
|
+
role: role,
|
|
87
|
+
content: compact_content(parts.flat_map { |part| Array(content_from_part(part)) })
|
|
88
|
+
}
|
|
89
|
+
end
|
|
90
|
+
|
|
91
|
+
def content_from_value(value)
|
|
92
|
+
case value
|
|
93
|
+
when String
|
|
94
|
+
if image_url?(value) || data_image?(value)
|
|
95
|
+
{ type: "image_url", image_url: { url: value } }
|
|
96
|
+
else
|
|
97
|
+
value
|
|
98
|
+
end
|
|
99
|
+
when Hash
|
|
100
|
+
content_from_part(value)
|
|
101
|
+
else
|
|
102
|
+
raise Error, "Invalid content part format: #{value.class}"
|
|
103
|
+
end
|
|
104
|
+
end
|
|
105
|
+
|
|
106
|
+
def content_from_part(part)
|
|
107
|
+
normalized = stringify_keys(part)
|
|
108
|
+
|
|
109
|
+
return normalized if normalized["type"]
|
|
110
|
+
return normalized["text"] if normalized.key?("text")
|
|
111
|
+
|
|
112
|
+
if normalized["inline_data"]
|
|
113
|
+
inline_data = normalized["inline_data"]
|
|
114
|
+
mime_type = inline_data["mime_type"] || inline_data[:mime_type] || "image/jpeg"
|
|
115
|
+
data = inline_data["data"] || inline_data[:data]
|
|
116
|
+
return { type: "image_url", image_url: { url: "data:#{mime_type};base64,#{data}" } }
|
|
117
|
+
end
|
|
118
|
+
|
|
119
|
+
if normalized["image_url"]
|
|
120
|
+
image_url = normalized["image_url"]
|
|
121
|
+
url = image_url.is_a?(Hash) ? image_url["url"] || image_url[:url] : image_url
|
|
122
|
+
return { type: "image_url", image_url: { url: url } }
|
|
123
|
+
end
|
|
124
|
+
|
|
125
|
+
raise Error, "Invalid content part: #{part.inspect}"
|
|
126
|
+
end
|
|
127
|
+
|
|
128
|
+
def compact_content(content)
|
|
129
|
+
flattened = content.flatten
|
|
130
|
+
return flattened.first if flattened.length == 1 && flattened.first.is_a?(String)
|
|
131
|
+
|
|
132
|
+
flattened.map do |part|
|
|
133
|
+
part.is_a?(String) ? { type: "text", text: part } : part
|
|
134
|
+
end
|
|
135
|
+
end
|
|
136
|
+
|
|
137
|
+
def normalize_tools(tools)
|
|
138
|
+
Array(tools).map do |tool|
|
|
139
|
+
raise Error, "Tool shortcuts are not supported. Pass OpenAI-compatible tool definitions as hashes." unless tool.is_a?(Hash)
|
|
140
|
+
|
|
141
|
+
stringify_keys(tool)
|
|
142
|
+
end
|
|
143
|
+
end
|
|
144
|
+
|
|
145
|
+
def normalize_config(config)
|
|
146
|
+
return {} if config.nil?
|
|
147
|
+
raise Error, "Config must be a Hash" unless config.is_a?(Hash)
|
|
148
|
+
|
|
149
|
+
config.each_with_object({}) do |(key, value), normalized|
|
|
150
|
+
openai_key = case key.to_sym
|
|
151
|
+
when :max_output_tokens
|
|
152
|
+
:max_tokens
|
|
153
|
+
else
|
|
154
|
+
key
|
|
155
|
+
end
|
|
156
|
+
normalized[openai_key] = value
|
|
157
|
+
end
|
|
158
|
+
end
|
|
159
|
+
|
|
160
|
+
def make_request(request_body)
|
|
161
|
+
uri = URI(client.config.api_url("chat/completions"))
|
|
162
|
+
http = Net::HTTP.new(uri.host, uri.port)
|
|
163
|
+
http.use_ssl = uri.scheme == "https"
|
|
164
|
+
http.open_timeout = client.config.timeout
|
|
165
|
+
http.read_timeout = client.config.timeout
|
|
166
|
+
|
|
167
|
+
request = Net::HTTP::Post.new(uri)
|
|
168
|
+
client.config.headers.each { |key, value| request[key] = value }
|
|
169
|
+
request.body = request_body.to_json
|
|
170
|
+
|
|
171
|
+
begin
|
|
172
|
+
response = http.request(request)
|
|
173
|
+
rescue OpenSSL::SSL::SSLError => e
|
|
174
|
+
raise Error, "SSL error while connecting to #{uri.host}:#{uri.port}. If this is a local OpenAI-compatible server, use http:// instead of https:// in base_url. #{e.message}"
|
|
175
|
+
rescue Net::OpenTimeout, Net::ReadTimeout => e
|
|
176
|
+
raise Error, "Request timed out while connecting to #{uri.host}:#{uri.port}. Check that the server is running and responding. #{e.message}"
|
|
177
|
+
rescue Errno::ECONNREFUSED, Errno::EHOSTUNREACH, SocketError => e
|
|
178
|
+
raise Error, "Could not connect to #{uri.host}:#{uri.port}. Check base_url and server status. #{e.message}"
|
|
179
|
+
end
|
|
180
|
+
|
|
181
|
+
case response
|
|
182
|
+
when Net::HTTPSuccess
|
|
183
|
+
response
|
|
184
|
+
when Net::HTTPClientError
|
|
185
|
+
raise Error, "Client error: #{response.code} - #{response.body}"
|
|
186
|
+
when Net::HTTPServerError
|
|
187
|
+
raise Error, "Server error: #{response.code} - #{response.body}"
|
|
188
|
+
else
|
|
189
|
+
raise Error, "Unexpected response: #{response.code} - #{response.body}"
|
|
190
|
+
end
|
|
191
|
+
end
|
|
192
|
+
|
|
193
|
+
def parse_response(response)
|
|
194
|
+
data = JSON.parse(response.body)
|
|
195
|
+
choice = data.fetch("choices", []).first
|
|
196
|
+
return "" unless choice
|
|
197
|
+
|
|
198
|
+
message = choice["message"] || {}
|
|
199
|
+
content = message["content"]
|
|
200
|
+
return content if content.is_a?(String)
|
|
201
|
+
|
|
202
|
+
Array(content).map { |part| part["text"] || part.dig("text", "value") }.compact.join
|
|
203
|
+
end
|
|
204
|
+
|
|
205
|
+
def openai_message?(value)
|
|
206
|
+
value.is_a?(Hash) && (value.key?(:content) || value.key?("content"))
|
|
207
|
+
end
|
|
208
|
+
|
|
209
|
+
def parts_message?(value)
|
|
210
|
+
value.is_a?(Hash) && (value.key?(:parts) || value.key?("parts"))
|
|
211
|
+
end
|
|
212
|
+
|
|
213
|
+
def image_url?(text)
|
|
214
|
+
image_extensions = %w[.jpg .jpeg .png .gif .webp .bmp .tiff]
|
|
215
|
+
uri = URI.parse(text)
|
|
216
|
+
uri.is_a?(URI::HTTP) && image_extensions.any? { |ext| uri.path.downcase.end_with?(ext) }
|
|
217
|
+
rescue URI::InvalidURIError
|
|
218
|
+
false
|
|
219
|
+
end
|
|
220
|
+
|
|
221
|
+
def data_image?(text)
|
|
222
|
+
text.match?(/\Adata:image\/[a-zA-Z0-9.+-]+;base64,/)
|
|
223
|
+
end
|
|
224
|
+
|
|
225
|
+
def stringify_keys(hash)
|
|
226
|
+
hash.each_with_object({}) { |(key, value), result| result[key.to_s] = value }
|
|
227
|
+
end
|
|
228
|
+
end
|
|
229
|
+
end
|
data/lib/genai/version.rb
CHANGED