genai-rb 0.0.2 → 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 +11 -10
- data/LICENSE.txt +21 -21
- data/README.md +92 -43
- data/lib/genai/chat.rb +206 -233
- data/lib/genai/config.rb +9 -9
- data/lib/genai/model.rb +155 -321
- data/lib/genai/version.rb +2 -2
- data/lib/genai.rb +32 -32
- metadata +11 -54
data/lib/genai/model.rb
CHANGED
|
@@ -1,234 +1,183 @@
|
|
|
1
1
|
require "net/http"
|
|
2
2
|
require "json"
|
|
3
3
|
require "uri"
|
|
4
|
-
require 'base64'
|
|
5
|
-
require 'cgi'
|
|
6
4
|
|
|
7
5
|
module Genai
|
|
8
6
|
class Model
|
|
9
7
|
attr_reader :client, :model_id
|
|
10
|
-
|
|
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
|
+
|
|
11
18
|
def initialize(client, model_id)
|
|
12
19
|
@client = client
|
|
13
20
|
@model_id = model_id
|
|
14
21
|
end
|
|
15
|
-
|
|
22
|
+
|
|
16
23
|
def generate_content(contents:, tools: nil, config: nil, grounding: nil, **options)
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
tools ||= []
|
|
21
|
-
tools << self.class.grounding_with_dynamic_threshold(grounding[:dynamic_threshold])
|
|
22
|
-
else
|
|
23
|
-
tools ||= []
|
|
24
|
-
tools << self.class.grounding_tool
|
|
25
|
-
end
|
|
26
|
-
end
|
|
27
|
-
|
|
28
|
-
contents_with_urls = extract_and_add_urls(contents)
|
|
29
|
-
request_body = build_request_body(contents: contents_with_urls, tools: tools, config: config, **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)
|
|
30
27
|
response = make_request(request_body)
|
|
31
28
|
parse_response(response)
|
|
32
29
|
end
|
|
33
30
|
|
|
34
|
-
def self.grounding_tool
|
|
35
|
-
{ google_search: {} }
|
|
36
|
-
end
|
|
37
|
-
|
|
38
|
-
def self.grounding_with_dynamic_threshold(threshold)
|
|
39
|
-
{
|
|
40
|
-
google_search_retrieval: {
|
|
41
|
-
dynamic_retrieval_config: {
|
|
42
|
-
mode: "MODE_DYNAMIC",
|
|
43
|
-
dynamic_threshold: threshold
|
|
44
|
-
}
|
|
45
|
-
}
|
|
46
|
-
}
|
|
47
|
-
end
|
|
48
|
-
|
|
49
31
|
private
|
|
50
|
-
|
|
51
|
-
def extract_and_add_urls(contents)
|
|
52
|
-
if contents.is_a?(String)
|
|
53
|
-
urls = extract_urls_from_text(contents)
|
|
54
|
-
if urls.any?
|
|
55
|
-
return [
|
|
56
|
-
{ role: "user", parts: [{ text: contents }] },
|
|
57
|
-
*urls.map { |url| { role: "user", parts: [{ text: url }] } }
|
|
58
|
-
]
|
|
59
|
-
end
|
|
60
|
-
end
|
|
61
|
-
contents
|
|
62
|
-
end
|
|
63
|
-
|
|
64
|
-
def extract_urls_from_text(text)
|
|
65
|
-
url_pattern = /https?:\/\/[^\s]+/
|
|
66
|
-
text.scan(url_pattern)
|
|
67
|
-
end
|
|
68
|
-
|
|
32
|
+
|
|
69
33
|
def build_request_body(contents:, tools: nil, config: nil, **options)
|
|
70
34
|
body = {
|
|
71
|
-
|
|
35
|
+
model: model_id,
|
|
36
|
+
messages: normalize_messages(contents)
|
|
72
37
|
}
|
|
73
|
-
|
|
38
|
+
|
|
39
|
+
body.merge!(normalize_config(config))
|
|
74
40
|
body[:tools] = normalize_tools(tools) if tools
|
|
75
|
-
body
|
|
76
|
-
|
|
77
|
-
options.each { |key, value| body[key] = value }
|
|
78
|
-
|
|
41
|
+
body.merge!(normalize_config(options))
|
|
79
42
|
body
|
|
80
43
|
end
|
|
81
|
-
|
|
82
|
-
def
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
elsif contents.is_a?(Array)
|
|
90
|
-
contents.map do |content|
|
|
91
|
-
if content.is_a?(String)
|
|
92
|
-
if is_image_url?(content) || is_base64_image?(content)
|
|
93
|
-
{ role: "user", parts: [{ inline_data: { mime_type: detect_mime_type(content), data: extract_image_data(content) } }] }
|
|
94
|
-
else
|
|
95
|
-
{ role: "user", parts: [{ text: content }] }
|
|
96
|
-
end
|
|
97
|
-
elsif content.is_a?(Hash)
|
|
98
|
-
content[:role] ||= "user"
|
|
99
|
-
content
|
|
100
|
-
else
|
|
101
|
-
raise Error, "Invalid content format: #{content.class}"
|
|
102
|
-
end
|
|
103
|
-
end
|
|
104
|
-
elsif contents.is_a?(Hash)
|
|
105
|
-
contents[:role] ||= "user"
|
|
106
|
-
[contents]
|
|
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) }
|
|
107
52
|
else
|
|
108
|
-
|
|
53
|
+
[message_from_parts(contents)]
|
|
109
54
|
end
|
|
55
|
+
when Hash
|
|
56
|
+
[normalize_message_hash(contents)]
|
|
57
|
+
else
|
|
58
|
+
raise Error, "Invalid contents format: #{contents.class}"
|
|
59
|
+
end
|
|
110
60
|
end
|
|
111
61
|
|
|
112
|
-
def
|
|
113
|
-
|
|
114
|
-
text.match?(/^https?:\/\/.+/i) && image_extensions.any? { |ext| text.downcase.include?(ext) }
|
|
62
|
+
def message_from_value(value)
|
|
63
|
+
{ role: "user", content: content_from_value(value) }
|
|
115
64
|
end
|
|
116
65
|
|
|
117
|
-
def
|
|
118
|
-
|
|
66
|
+
def message_from_parts(parts)
|
|
67
|
+
content = parts.flat_map { |part| Array(content_from_value(part)) }
|
|
68
|
+
{ role: "user", content: compact_content(content) }
|
|
119
69
|
end
|
|
120
70
|
|
|
121
|
-
def
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
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 } }
|
|
143
96
|
else
|
|
144
|
-
|
|
97
|
+
value
|
|
145
98
|
end
|
|
99
|
+
when Hash
|
|
100
|
+
content_from_part(value)
|
|
146
101
|
else
|
|
147
|
-
"
|
|
102
|
+
raise Error, "Invalid content part format: #{value.class}"
|
|
148
103
|
end
|
|
149
104
|
end
|
|
150
105
|
|
|
151
|
-
def
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
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}" } }
|
|
158
117
|
end
|
|
159
|
-
end
|
|
160
118
|
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
http.use_ssl = true if uri.scheme == 'https'
|
|
166
|
-
http.open_timeout = 10
|
|
167
|
-
http.read_timeout = 10
|
|
168
|
-
|
|
169
|
-
request = Net::HTTP::Get.new(uri)
|
|
170
|
-
request['User-Agent'] = 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36'
|
|
171
|
-
|
|
172
|
-
response = http.request(request)
|
|
173
|
-
|
|
174
|
-
if response.is_a?(Net::HTTPSuccess)
|
|
175
|
-
Base64.strict_encode64(response.body)
|
|
176
|
-
else
|
|
177
|
-
raise Error, "Failed to download image: #{response.code}"
|
|
178
|
-
end
|
|
179
|
-
rescue => e
|
|
180
|
-
raise Error, "Error downloading image: #{e.message}"
|
|
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 } }
|
|
181
123
|
end
|
|
124
|
+
|
|
125
|
+
raise Error, "Invalid content part: #{part.inspect}"
|
|
182
126
|
end
|
|
183
|
-
|
|
184
|
-
def
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
[normalize_tool(tools)]
|
|
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
|
|
191
134
|
end
|
|
192
135
|
end
|
|
193
|
-
|
|
194
|
-
def
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
{ url_context: {} }
|
|
200
|
-
when :google_search, "google_search"
|
|
201
|
-
{ google_search: {} }
|
|
202
|
-
when :google_search_retrieval, "google_search_retrieval"
|
|
203
|
-
{ google_search_retrieval: {} }
|
|
204
|
-
else
|
|
205
|
-
raise Error, "Unknown tool: #{tool}"
|
|
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)
|
|
206
142
|
end
|
|
207
143
|
end
|
|
208
|
-
|
|
144
|
+
|
|
209
145
|
def normalize_config(config)
|
|
210
146
|
return {} if config.nil?
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
|
|
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
|
|
216
157
|
end
|
|
217
158
|
end
|
|
218
|
-
|
|
159
|
+
|
|
219
160
|
def make_request(request_body)
|
|
220
|
-
uri = URI(client.config.api_url("
|
|
161
|
+
uri = URI(client.config.api_url("chat/completions"))
|
|
221
162
|
http = Net::HTTP.new(uri.host, uri.port)
|
|
222
|
-
http.use_ssl =
|
|
163
|
+
http.use_ssl = uri.scheme == "https"
|
|
223
164
|
http.open_timeout = client.config.timeout
|
|
224
165
|
http.read_timeout = client.config.timeout
|
|
225
|
-
|
|
166
|
+
|
|
226
167
|
request = Net::HTTP::Post.new(uri)
|
|
227
168
|
client.config.headers.each { |key, value| request[key] = value }
|
|
228
169
|
request.body = request_body.to_json
|
|
229
|
-
|
|
230
|
-
|
|
231
|
-
|
|
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
|
+
|
|
232
181
|
case response
|
|
233
182
|
when Net::HTTPSuccess
|
|
234
183
|
response
|
|
@@ -240,156 +189,41 @@ module Genai
|
|
|
240
189
|
raise Error, "Unexpected response: #{response.code} - #{response.body}"
|
|
241
190
|
end
|
|
242
191
|
end
|
|
243
|
-
|
|
192
|
+
|
|
244
193
|
def parse_response(response)
|
|
245
194
|
data = JSON.parse(response.body)
|
|
246
|
-
|
|
247
|
-
|
|
248
|
-
|
|
249
|
-
|
|
250
|
-
content =
|
|
251
|
-
|
|
252
|
-
|
|
253
|
-
|
|
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
|
|
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
|
|
273
203
|
end
|
|
274
|
-
|
|
275
|
-
def
|
|
276
|
-
|
|
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
|
|
204
|
+
|
|
205
|
+
def openai_message?(value)
|
|
206
|
+
value.is_a?(Hash) && (value.key?(:content) || value.key?("content"))
|
|
316
207
|
end
|
|
317
|
-
|
|
318
|
-
def
|
|
319
|
-
|
|
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
|
|
208
|
+
|
|
209
|
+
def parts_message?(value)
|
|
210
|
+
value.is_a?(Hash) && (value.key?(:parts) || value.key?("parts"))
|
|
360
211
|
end
|
|
361
|
-
|
|
362
|
-
def
|
|
363
|
-
|
|
364
|
-
|
|
365
|
-
|
|
366
|
-
|
|
367
|
-
|
|
368
|
-
|
|
369
|
-
|
|
370
|
-
|
|
371
|
-
|
|
372
|
-
|
|
373
|
-
|
|
374
|
-
|
|
375
|
-
|
|
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
|
|
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 }
|
|
393
227
|
end
|
|
394
228
|
end
|
|
395
|
-
end
|
|
229
|
+
end
|
data/lib/genai/version.rb
CHANGED
|
@@ -1,3 +1,3 @@
|
|
|
1
|
-
module Genai
|
|
2
|
-
VERSION = "0.0
|
|
1
|
+
module Genai
|
|
2
|
+
VERSION = "0.2.0"
|
|
3
3
|
end
|
data/lib/genai.rb
CHANGED
|
@@ -1,32 +1,32 @@
|
|
|
1
|
-
require_relative "genai/config"
|
|
2
|
-
require_relative "genai/model"
|
|
3
|
-
require_relative "genai/chat"
|
|
4
|
-
require_relative "genai/version"
|
|
5
|
-
|
|
6
|
-
module Genai
|
|
7
|
-
class Error < StandardError; end
|
|
8
|
-
|
|
9
|
-
class Client
|
|
10
|
-
attr_reader :config
|
|
11
|
-
|
|
12
|
-
def initialize(api_key: nil, **options)
|
|
13
|
-
@config = Config.new(api_key: api_key, **options)
|
|
14
|
-
end
|
|
15
|
-
|
|
16
|
-
def model(model_id)
|
|
17
|
-
Model.new(self, model_id)
|
|
18
|
-
end
|
|
19
|
-
|
|
20
|
-
def chats
|
|
21
|
-
Chats.new(self)
|
|
22
|
-
end
|
|
23
|
-
|
|
24
|
-
def generate_content(model
|
|
25
|
-
self.model(model).generate_content(contents: contents, **options)
|
|
26
|
-
end
|
|
27
|
-
end
|
|
28
|
-
|
|
29
|
-
def self.new(**options)
|
|
30
|
-
Client.new(**options)
|
|
31
|
-
end
|
|
32
|
-
end
|
|
1
|
+
require_relative "genai/config"
|
|
2
|
+
require_relative "genai/model"
|
|
3
|
+
require_relative "genai/chat"
|
|
4
|
+
require_relative "genai/version"
|
|
5
|
+
|
|
6
|
+
module Genai
|
|
7
|
+
class Error < StandardError; end
|
|
8
|
+
|
|
9
|
+
class Client
|
|
10
|
+
attr_reader :config
|
|
11
|
+
|
|
12
|
+
def initialize(api_key: nil, **options)
|
|
13
|
+
@config = Config.new(api_key: api_key, **options)
|
|
14
|
+
end
|
|
15
|
+
|
|
16
|
+
def model(model_id)
|
|
17
|
+
Model.new(self, model_id)
|
|
18
|
+
end
|
|
19
|
+
|
|
20
|
+
def chats
|
|
21
|
+
Chats.new(self)
|
|
22
|
+
end
|
|
23
|
+
|
|
24
|
+
def generate_content(model: "gpt-5.5", contents:, **options)
|
|
25
|
+
self.model(model).generate_content(contents: contents, **options)
|
|
26
|
+
end
|
|
27
|
+
end
|
|
28
|
+
|
|
29
|
+
def self.new(**options)
|
|
30
|
+
Client.new(**options)
|
|
31
|
+
end
|
|
32
|
+
end
|