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.
data/lib/genai/model.rb CHANGED
@@ -1,395 +1,229 @@
1
- require "net/http"
2
- require "json"
3
- require "uri"
4
- require 'base64'
5
- require 'cgi'
6
-
7
- module Genai
8
- class Model
9
- attr_reader :client, :model_id
10
-
11
- def initialize(client, model_id)
12
- @client = client
13
- @model_id = model_id
14
- end
15
-
16
- def generate_content(contents:, tools: nil, config: nil, grounding: nil, **options)
17
- tools = Array(tools).dup if tools
18
- if grounding
19
- if grounding.is_a?(Hash) && grounding[:dynamic_threshold]
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)
30
- response = make_request(request_body)
31
- parse_response(response)
32
- end
33
-
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
- 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
-
69
- def build_request_body(contents:, tools: nil, config: nil, **options)
70
- body = {
71
- contents: normalize_contents(contents)
72
- }
73
-
74
- body[:tools] = normalize_tools(tools) if tools
75
- body[:generationConfig] = normalize_config(config) if config
76
-
77
- options.each { |key, value| body[key] = value }
78
-
79
- body
80
- end
81
-
82
- def normalize_contents(contents)
83
- if contents.is_a?(String)
84
- if is_image_url?(contents) || is_base64_image?(contents)
85
- [{ role: "user", parts: [{ inline_data: { mime_type: detect_mime_type(contents), data: extract_image_data(contents) } }] }]
86
- else
87
- [{ role: "user", parts: [{ text: contents }] }]
88
- end
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]
107
- else
108
- raise Error, "Invalid contents format: #{contents.class}"
109
- end
110
- end
111
-
112
- def is_image_url?(text)
113
- image_extensions = %w[.jpg .jpeg .png .gif .webp .bmp .tiff]
114
- text.match?(/^https?:\/\/.+/i) && image_extensions.any? { |ext| text.downcase.include?(ext) }
115
- end
116
-
117
- def is_base64_image?(text)
118
- text.match?(/^data:image\/[a-zA-Z]+;base64,/)
119
- end
120
-
121
- def detect_mime_type(content)
122
- if is_image_url?(content)
123
- case content.downcase
124
- when /\.(jpg|jpeg)$/
125
- "image/jpeg"
126
- when /\.png$/
127
- "image/png"
128
- when /\.gif$/
129
- "image/gif"
130
- when /\.webp$/
131
- "image/webp"
132
- when /\.bmp$/
133
- "image/bmp"
134
- when /\.tiff$/
135
- "image/tiff"
136
- else
137
- "image/jpeg"
138
- end
139
- elsif is_base64_image?(content)
140
- match = content.match(/^data:image\/([a-zA-Z]+);base64,/)
141
- if match
142
- "image/#{match[1]}"
143
- else
144
- "image/jpeg"
145
- end
146
- else
147
- "text/plain"
148
- end
149
- end
150
-
151
- def extract_image_data(content)
152
- if is_image_url?(content)
153
- download_and_encode_image(content)
154
- elsif is_base64_image?(content)
155
- content.match(/^data:image\/[a-zA-Z]+;base64,(.+)$/)[1]
156
- else
157
- content
158
- end
159
- end
160
-
161
- def download_and_encode_image(url)
162
- begin
163
- uri = URI(url)
164
- http = Net::HTTP.new(uri.host, uri.port)
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}"
181
- end
182
- end
183
-
184
- def normalize_tools(tools)
185
- return [] if tools.nil?
186
-
187
- if tools.is_a?(Array)
188
- tools.map { |tool| normalize_tool(tool) }
189
- else
190
- [normalize_tool(tools)]
191
- end
192
- end
193
-
194
- def normalize_tool(tool)
195
- case tool
196
- when Hash
197
- tool
198
- when :url_context, "url_context"
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}"
206
- end
207
- end
208
-
209
- def normalize_config(config)
210
- return {} if config.nil?
211
-
212
- if config.is_a?(Hash)
213
- config
214
- else
215
- raise Error, "Config must be a Hash"
216
- end
217
- end
218
-
219
- def make_request(request_body)
220
- uri = URI(client.config.api_url("models/#{model_id}:generateContent"))
221
- http = Net::HTTP.new(uri.host, uri.port)
222
- http.use_ssl = true
223
- http.open_timeout = client.config.timeout
224
- http.read_timeout = client.config.timeout
225
-
226
- request = Net::HTTP::Post.new(uri)
227
- client.config.headers.each { |key, value| request[key] = value }
228
- request.body = request_body.to_json
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
@@ -1,3 +1,3 @@
1
1
  module Genai
2
- VERSION = "0.1.0"
2
+ VERSION = "0.2.0"
3
3
  end