genai-rb 0.0.2 → 0.1.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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 1aec54cd52a2625f22438e6d5baa1418f71e923ace70ffca6d29e67f3aedc99b
4
- data.tar.gz: c5dcdba349bc3930b2d353e43e07e88f2a384db499bafa133722e8d77e376d73
3
+ metadata.gz: ba4f68bb9f9fb0a8aedbe47bc8e81f2859f7418f049a6d9610b7f32982a09e0f
4
+ data.tar.gz: '08bb6995fb0b749cdadde7a6be83373c53db9033ec03b8d8bc5228cf7f74ed4a'
5
5
  SHA512:
6
- metadata.gz: 8df382a222223835262b53fa5175b569610b10042c113c2b0e40184aef851db017b51afb450911cb461d9d4b47ec5c01346ffceb4d751f36d83e9b55c6f123b1
7
- data.tar.gz: d3d6a35dbbf0896c424e4a29aa9d8bbd25bb11632fb388c71449efcf0cde702a653149b2b38509f54d4d5442d920a184401f5ebf07cb58a9e607b72f4bee545c
6
+ metadata.gz: 6fb0326f7a91add9c63a3fc1876b0360345bb6176763b708fca4a7c1a9408a77c0d422ad658fce4127458a876cb8727a67eccbb4c71f1ebe60704eacd519cf84
7
+ data.tar.gz: 4ebf4cfc65b6d21be25ced6dc666795b4110e9d4adc3d2538e32760bbf69e8af61cdb441fd692e98969aef51993a05c3da04f7f7be209334f15903fc9995e940
data/CHANGELOG.md CHANGED
@@ -1,10 +1,5 @@
1
- ## [Release]
2
-
3
- ## [0.0.2] - 2025-08-16 (Me gustas tu)
4
-
5
- - Fix typos
6
- - Add support for Korean Unicode decoding
7
-
8
- ## [0.0.1] - 2025-08-15 (Glass Bead)
9
-
1
+ ## [Release]
2
+
3
+ ## [0.1.0] - 2025-08-16 (Glass Bead)
4
+
10
5
  - Initial release
data/LICENSE.txt CHANGED
@@ -1,21 +1,21 @@
1
- The MIT License (MIT)
2
-
3
- Copyright (c) 2025 Siruu580
4
-
5
- Permission is hereby granted, free of charge, to any person obtaining a copy
6
- of this software and associated documentation files (the "Software"), to deal
7
- in the Software without restriction, including without limitation the rights
8
- to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
- copies of the Software, and to permit persons to whom the Software is
10
- furnished to do so, subject to the following conditions:
11
-
12
- The above copyright notice and this permission notice shall be included in
13
- all copies or substantial portions of the Software.
14
-
15
- THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
- IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
- FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
- AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
- LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
- OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
21
- THE SOFTWARE.
1
+ The MIT License (MIT)
2
+
3
+ Copyright (c) 2025 Siruu580
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in
13
+ all copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
21
+ THE SOFTWARE.
data/README.md CHANGED
@@ -1,107 +1,107 @@
1
-
2
- # Genai
3
-
4
- An unofficial Ruby package for Google Gemini. Easily generate text and images, analyze web content, and integrate Google Search—all with a simple Ruby API.
5
-
6
- ## Installation
7
-
8
- Add to your Gemfile:
9
-
10
- ```ruby
11
- gem 'genai-rb'
12
- ```
13
-
14
- Then run:
15
-
16
- ```bash
17
- bundle install
18
- ```
19
-
20
- Or install directly:
21
-
22
- ```bash
23
- gem install genai-rb
24
- ```
25
-
26
- ## Usage
27
-
28
- ### Setup
29
-
30
- Get your Gemini API key from [Google AI Studio](https://makersuite.google.com/app/apikey).
31
-
32
- Set your API key:
33
-
34
- ```bash
35
- export GEMINI_API_KEY="api_key"
36
- ```
37
-
38
- Or pass it directly:
39
-
40
- ```ruby
41
- require 'genai'
42
- client = Genai.new(api_key: "api_key")
43
- ```
44
-
45
- ### Text Generation
46
-
47
- ```ruby
48
- response = client.generate_content(contents: "Hello, how are you?")
49
- puts response
50
- ```
51
-
52
- ### Use a Specific Model
53
-
54
- ```ruby
55
- response = client.generate_content(model_id: "gemini-2.5-flash", contents: "Explain quantum computing.")
56
- ```
57
-
58
- ### URL Context
59
-
60
- ```ruby
61
- response = client.generate_content(contents: "Summarize: https://example.com/article", tools: [:url_context])
62
- ```
63
-
64
- ### Google Search Integration
65
-
66
- ```ruby
67
- response = client.generate_content(contents: "Latest AI news?", tools: [:google_search, :url_context])
68
- ```
69
-
70
- ### Advanced Configuration
71
-
72
- ```ruby
73
- client = Genai.new(api_key: "api_key", timeout: 120, max_retries: 5)
74
- response = client.model("gemini-2.0-flash").generate_content(
75
- contents: "Write a creative story about a robot learning to paint",
76
- config: { temperature: 0.8, max_output_tokens: 1000 }
77
- )
78
- ```
79
-
80
- ### Model Instances
81
-
82
- ```ruby
83
- model = client.model("gemini-2.5-flash")
84
- response = model.generate_content(contents: "Explain the benefits of renewable energy")
85
- ```
86
-
87
- ## Supported Models
88
-
89
- - All Gemini models
90
-
91
- ## Features
92
-
93
- - Text and image generation
94
- - URL context analysis
95
- - Google Search integration
96
- - Multiple model support
97
- - Customizable generation config
98
- - Robust error handling and retry logic
99
- - Simple, intuitive Ruby API
100
-
101
- ## Contributing
102
-
103
- Pull requests are always welcome.
104
-
105
- ## License
106
-
1
+
2
+ # Genai
3
+
4
+ An unofficial Ruby package for Google Gemini. Easily generate text and images, analyze web content, and integrate Google Search—all with a simple Ruby API.
5
+
6
+ ## Installation
7
+
8
+ Add to your Gemfile:
9
+
10
+ ```ruby
11
+ gem 'genai-rb'
12
+ ```
13
+
14
+ Then run:
15
+
16
+ ```bash
17
+ bundle install
18
+ ```
19
+
20
+ Or install directly:
21
+
22
+ ```bash
23
+ gem install genai-rb
24
+ ```
25
+
26
+ ## Usage
27
+
28
+ ### Setup
29
+
30
+ Get your Gemini API key from [Google AI Studio](https://makersuite.google.com/app/apikey).
31
+
32
+ Set your API key:
33
+
34
+ ```bash
35
+ export GEMINI_API_KEY="api_key"
36
+ ```
37
+
38
+ Or pass it directly:
39
+
40
+ ```ruby
41
+ require 'genai'
42
+ client = Genai.new(api_key: "api_key")
43
+ ```
44
+
45
+ ### Text Generation
46
+
47
+ ```ruby
48
+ response = client.generate_content(contents: "Hello, how are you?")
49
+ puts response
50
+ ```
51
+
52
+ ### Use a Specific Model
53
+
54
+ ```ruby
55
+ response = client.generate_content(model_id: "gemini-2.5-flash", contents: "Explain quantum computing.")
56
+ ```
57
+
58
+ ### URL Context
59
+
60
+ ```ruby
61
+ response = client.generate_content(contents: "Summarize: https://example.com/article", tools: [:url_context])
62
+ ```
63
+
64
+ ### Google Search Integration
65
+
66
+ ```ruby
67
+ response = client.generate_content(contents: "Latest AI news?", tools: [:google_search, :url_context])
68
+ ```
69
+
70
+ ### Advanced Configuration
71
+
72
+ ```ruby
73
+ client = Genai.new(api_key: "api_key", timeout: 120, max_retries: 5)
74
+ response = client.model("gemini-2.0-flash").generate_content(
75
+ contents: "Write a creative story about a robot learning to paint",
76
+ config: { temperature: 0.8, max_output_tokens: 1000 }
77
+ )
78
+ ```
79
+
80
+ ### Model Instances
81
+
82
+ ```ruby
83
+ model = client.model("gemini-2.5-flash")
84
+ response = model.generate_content(contents: "Explain the benefits of renewable energy")
85
+ ```
86
+
87
+ ## Supported Models
88
+
89
+ - All Gemini models
90
+
91
+ ## Features
92
+
93
+ - Text and image generation
94
+ - URL context analysis
95
+ - Google Search integration
96
+ - Multiple model support
97
+ - Customizable generation config
98
+ - Robust error handling and retry logic
99
+ - Simple, intuitive Ruby API
100
+
101
+ ## Contributing
102
+
103
+ Pull requests are always welcome.
104
+
105
+ ## License
106
+
107
107
  Open source under the [MIT License](https://opensource.org/licenses/MIT).
data/lib/genai/chat.rb CHANGED
@@ -1,19 +1,4 @@
1
- # Copyright 2025 Google LLC
2
- #
3
- # Licensed under the Apache License, Version 2.0 (the "License");
4
- # you may not use this file except in compliance with the License.
5
- # You may obtain a copy of the License at
6
- #
7
- # http://www.apache.org/licenses/LICENSE-2.0
8
- #
9
- # Unless required by applicable law or agreed to in writing, software
10
- # distributed under the License is distributed on an "AS IS" BASIS,
11
- # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
12
- # See the License for the specific language governing permissions and
13
- # limitations under the License.
14
-
15
1
  module Genai
16
- # Chat history validation methods
17
2
  module ChatValidator
18
3
  def self.validate_content(content)
19
4
  return false unless content[:parts] && !content[:parts].empty?
@@ -85,7 +70,6 @@ module Genai
85
70
  @model = model
86
71
  @config = config
87
72
 
88
- # Convert history items to proper format
89
73
  content_models = history.map do |content|
90
74
  content.is_a?(Hash) ? content : content
91
75
  end
@@ -95,22 +79,17 @@ module Genai
95
79
  end
96
80
 
97
81
  def record_history(user_input:, model_output:, automatic_function_calling_history: [], is_valid: true)
98
- # Extract input contents from automatic function calling history or use user input
99
82
  input_contents = if !automatic_function_calling_history.empty?
100
- # Deduplicate existing chat history from AFC history
101
83
  automatic_function_calling_history[@curated_history.length..-1] || [user_input]
102
84
  else
103
85
  [user_input]
104
86
  end
105
87
 
106
- # Use model output or create empty content if no output
107
88
  output_contents = model_output.empty? ? [{ role: "model", parts: [] }] : model_output
108
89
 
109
- # Update comprehensive history
110
90
  @comprehensive_history.concat(input_contents)
111
91
  @comprehensive_history.concat(output_contents)
112
92
 
113
- # Update curated history only if valid
114
93
  if is_valid
115
94
  @curated_history.concat(input_contents)
116
95
  @curated_history.concat(output_contents)
@@ -129,7 +108,6 @@ module Genai
129
108
  end
130
109
 
131
110
  def send_message(message, config: nil)
132
- # Convert message to proper content format
133
111
  input_content = case message
134
112
  when String
135
113
  { role: "user", parts: [{ text: message }] }
@@ -141,24 +119,20 @@ module Genai
141
119
  raise ArgumentError, "Message must be a String, Array, or Hash"
142
120
  end
143
121
 
144
- # Generate content using the model
145
122
  model_instance = @client.model(@model)
146
123
  response = model_instance.generate_content(
147
124
  contents: @curated_history + [input_content],
148
125
  config: config || @config
149
126
  )
150
127
 
151
- # Extract model output from response
152
128
  model_output = if response[:candidates] && !response[:candidates].empty? && response[:candidates][0][:content]
153
129
  [response[:candidates][0][:content]]
154
130
  else
155
131
  []
156
132
  end
157
133
 
158
- # Get automatic function calling history if available
159
134
  automatic_function_calling_history = response[:automatic_function_calling_history] || []
160
135
 
161
- # Record the conversation in history
162
136
  record_history(
163
137
  user_input: input_content,
164
138
  model_output: model_output,
@@ -170,7 +144,6 @@ module Genai
170
144
  end
171
145
 
172
146
  def send_message_stream(message, config: nil)
173
- # Convert message to proper content format
174
147
  input_content = case message
175
148
  when String
176
149
  { role: "user", parts: [{ text: message }] }
@@ -182,8 +155,7 @@ module Genai
182
155
  raise ArgumentError, "Message must be a String, Array, or Hash"
183
156
  end
184
157
 
185
- # This would need to be implemented in the Model class to support streaming
186
- # For now, we'll use regular send_message
158
+
187
159
  send_message(message, config: config)
188
160
  end
189
161
  end
@@ -198,9 +170,7 @@ module Genai
198
170
  Chat.new(client: @client, model: model, config: config, history: history)
199
171
  end
200
172
 
201
- # 사용자별 세션 관리 메소드들
202
173
  def get_user_session(user_id, model: "gemini-2.0-flash", config: nil)
203
- # 사용자별 세션이 없으면 새로 생성
204
174
  unless @user_sessions[user_id]
205
175
  @user_sessions[user_id] = create(
206
176
  model: model,
data/lib/genai/model.rb CHANGED
@@ -1,395 +1,395 @@
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
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
395
  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.1.0"
3
3
  end
metadata CHANGED
@@ -1,7 +1,7 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: genai-rb
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.0.2
4
+ version: 0.1.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - Siruu580
@@ -89,5 +89,5 @@ required_rubygems_version: !ruby/object:Gem::Requirement
89
89
  requirements: []
90
90
  rubygems_version: 3.6.7
91
91
  specification_version: 4
92
- summary: genai for ruby
92
+ summary: gemini module for ruby
93
93
  test_files: []