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.
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
- 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)
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
- contents: normalize_contents(contents)
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[:generationConfig] = normalize_config(config) if config
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 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]
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
- raise Error, "Invalid contents format: #{contents.class}"
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 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) }
62
+ def message_from_value(value)
63
+ { role: "user", content: content_from_value(value) }
115
64
  end
116
65
 
117
- def is_base64_image?(text)
118
- text.match?(/^data:image\/[a-zA-Z]+;base64,/)
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 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]}"
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
- "image/jpeg"
97
+ value
145
98
  end
99
+ when Hash
100
+ content_from_part(value)
146
101
  else
147
- "text/plain"
102
+ raise Error, "Invalid content part format: #{value.class}"
148
103
  end
149
104
  end
150
105
 
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
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
- 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}"
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 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)]
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 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}"
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
- if config.is_a?(Hash)
213
- config
214
- else
215
- raise Error, "Config must be a Hash"
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("models/#{model_id}:generateContent"))
161
+ uri = URI(client.config.api_url("chat/completions"))
221
162
  http = Net::HTTP.new(uri.host, uri.port)
222
- http.use_ssl = true
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
- response = http.request(request)
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
- 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
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 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
204
+
205
+ def openai_message?(value)
206
+ value.is_a?(Hash) && (value.key?(:content) || value.key?("content"))
316
207
  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
208
+
209
+ def parts_message?(value)
210
+ value.is_a?(Hash) && (value.key?(:parts) || value.key?("parts"))
360
211
  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
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.2"
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:, 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
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