genai-rb 0.0.2

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 ADDED
@@ -0,0 +1,7 @@
1
+ ---
2
+ SHA256:
3
+ metadata.gz: 1aec54cd52a2625f22438e6d5baa1418f71e923ace70ffca6d29e67f3aedc99b
4
+ data.tar.gz: c5dcdba349bc3930b2d353e43e07e88f2a384db499bafa133722e8d77e376d73
5
+ SHA512:
6
+ metadata.gz: 8df382a222223835262b53fa5175b569610b10042c113c2b0e40184aef851db017b51afb450911cb461d9d4b47ec5c01346ffceb4d751f36d83e9b55c6f123b1
7
+ data.tar.gz: d3d6a35dbbf0896c424e4a29aa9d8bbd25bb11632fb388c71449efcf0cde702a653149b2b38509f54d4d5442d920a184401f5ebf07cb58a9e607b72f4bee545c
data/CHANGELOG.md ADDED
@@ -0,0 +1,10 @@
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
+
10
+ - Initial release
data/LICENSE.txt ADDED
@@ -0,0 +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.
data/README.md ADDED
@@ -0,0 +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
+
107
+ Open source under the [MIT License](https://opensource.org/licenses/MIT).
data/lib/genai/chat.rb ADDED
@@ -0,0 +1,233 @@
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
+ module Genai
16
+ # Chat history validation methods
17
+ module ChatValidator
18
+ def self.validate_content(content)
19
+ return false unless content[:parts] && !content[:parts].empty?
20
+ content[:parts].each do |part|
21
+ return false if part.empty?
22
+ return false if part[:text] && part[:text].empty?
23
+ end
24
+ true
25
+ end
26
+
27
+ def self.validate_contents(contents)
28
+ return false if contents.empty?
29
+ contents.each do |content|
30
+ return false unless validate_content(content)
31
+ end
32
+ true
33
+ end
34
+
35
+ def self.validate_response(response)
36
+ return false unless response[:candidates] && !response[:candidates].empty?
37
+ return false unless response[:candidates][0][:content]
38
+ validate_content(response[:candidates][0][:content])
39
+ end
40
+
41
+ def self.extract_curated_history(comprehensive_history)
42
+ return [] if comprehensive_history.empty?
43
+
44
+ curated_history = []
45
+ length = comprehensive_history.length
46
+ i = 0
47
+
48
+ while i < length
49
+ unless ["user", "model"].include?(comprehensive_history[i][:role])
50
+ raise ArgumentError, "Role must be user or model, but got #{comprehensive_history[i][:role]}"
51
+ end
52
+
53
+ if comprehensive_history[i][:role] == "user"
54
+ current_input = comprehensive_history[i]
55
+ curated_history << current_input
56
+ i += 1
57
+ else
58
+ current_output = []
59
+ is_valid = true
60
+
61
+ while i < length && comprehensive_history[i][:role] == "model"
62
+ current_output << comprehensive_history[i]
63
+ if is_valid && !validate_content(comprehensive_history[i])
64
+ is_valid = false
65
+ end
66
+ i += 1
67
+ end
68
+
69
+ if is_valid
70
+ curated_history.concat(current_output)
71
+ elsif !curated_history.empty?
72
+ curated_history.pop
73
+ end
74
+ end
75
+ end
76
+
77
+ curated_history
78
+ end
79
+ end
80
+
81
+ class BaseChat
82
+ attr_reader :model, :config, :comprehensive_history, :curated_history
83
+
84
+ def initialize(model:, config: nil, history: [])
85
+ @model = model
86
+ @config = config
87
+
88
+ # Convert history items to proper format
89
+ content_models = history.map do |content|
90
+ content.is_a?(Hash) ? content : content
91
+ end
92
+
93
+ @comprehensive_history = content_models
94
+ @curated_history = ChatValidator.extract_curated_history(content_models)
95
+ end
96
+
97
+ 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
+ input_contents = if !automatic_function_calling_history.empty?
100
+ # Deduplicate existing chat history from AFC history
101
+ automatic_function_calling_history[@curated_history.length..-1] || [user_input]
102
+ else
103
+ [user_input]
104
+ end
105
+
106
+ # Use model output or create empty content if no output
107
+ output_contents = model_output.empty? ? [{ role: "model", parts: [] }] : model_output
108
+
109
+ # Update comprehensive history
110
+ @comprehensive_history.concat(input_contents)
111
+ @comprehensive_history.concat(output_contents)
112
+
113
+ # Update curated history only if valid
114
+ if is_valid
115
+ @curated_history.concat(input_contents)
116
+ @curated_history.concat(output_contents)
117
+ end
118
+ end
119
+
120
+ def get_history(curated: false)
121
+ curated ? @curated_history : @comprehensive_history
122
+ end
123
+ end
124
+
125
+ class Chat < BaseChat
126
+ def initialize(client:, model:, config: nil, history: [])
127
+ @client = client
128
+ super(model: model, config: config, history: history)
129
+ end
130
+
131
+ def send_message(message, config: nil)
132
+ # Convert message to proper content format
133
+ input_content = case message
134
+ when String
135
+ { role: "user", parts: [{ text: message }] }
136
+ when Array
137
+ { role: "user", parts: message }
138
+ when Hash
139
+ message
140
+ else
141
+ raise ArgumentError, "Message must be a String, Array, or Hash"
142
+ end
143
+
144
+ # Generate content using the model
145
+ model_instance = @client.model(@model)
146
+ response = model_instance.generate_content(
147
+ contents: @curated_history + [input_content],
148
+ config: config || @config
149
+ )
150
+
151
+ # Extract model output from response
152
+ model_output = if response[:candidates] && !response[:candidates].empty? && response[:candidates][0][:content]
153
+ [response[:candidates][0][:content]]
154
+ else
155
+ []
156
+ end
157
+
158
+ # Get automatic function calling history if available
159
+ automatic_function_calling_history = response[:automatic_function_calling_history] || []
160
+
161
+ # Record the conversation in history
162
+ record_history(
163
+ user_input: input_content,
164
+ model_output: model_output,
165
+ automatic_function_calling_history: automatic_function_calling_history,
166
+ is_valid: ChatValidator.validate_response(response)
167
+ )
168
+
169
+ response
170
+ end
171
+
172
+ def send_message_stream(message, config: nil)
173
+ # Convert message to proper content format
174
+ input_content = case message
175
+ when String
176
+ { role: "user", parts: [{ text: message }] }
177
+ when Array
178
+ { role: "user", parts: message }
179
+ when Hash
180
+ message
181
+ else
182
+ raise ArgumentError, "Message must be a String, Array, or Hash"
183
+ end
184
+
185
+ # This would need to be implemented in the Model class to support streaming
186
+ # For now, we'll use regular send_message
187
+ send_message(message, config: config)
188
+ end
189
+ end
190
+
191
+ class Chats
192
+ def initialize(client)
193
+ @client = client
194
+ @user_sessions = {}
195
+ end
196
+
197
+ def create(model:, config: nil, history: [])
198
+ Chat.new(client: @client, model: model, config: config, history: history)
199
+ end
200
+
201
+ # 사용자별 세션 관리 메소드들
202
+ def get_user_session(user_id, model: "gemini-2.0-flash", config: nil)
203
+ # 사용자별 세션이 없으면 새로 생성
204
+ unless @user_sessions[user_id]
205
+ @user_sessions[user_id] = create(
206
+ model: model,
207
+ config: config || {
208
+ temperature: 0.7,
209
+ max_output_tokens: 2048
210
+ }
211
+ )
212
+ end
213
+
214
+ @user_sessions[user_id]
215
+ end
216
+
217
+ def clear_user_session(user_id)
218
+ @user_sessions.delete(user_id)
219
+ end
220
+
221
+ def clear_all_sessions
222
+ @user_sessions.clear
223
+ end
224
+
225
+ def get_session_count
226
+ @user_sessions.length
227
+ end
228
+
229
+ def get_active_users
230
+ @user_sessions.keys
231
+ end
232
+ end
233
+ end
@@ -0,0 +1,32 @@
1
+ require "net/http"
2
+ require "json"
3
+
4
+ module Genai
5
+ class Config
6
+ attr_accessor :api_key, :base_url, :timeout, :max_retries
7
+
8
+ def initialize(api_key: nil, base_url: nil, timeout: 60, max_retries: 3)
9
+ @api_key = api_key || ENV["GEMINI_API_KEY"]
10
+ @base_url = base_url || "https://generativelanguage.googleapis.com"
11
+ @timeout = timeout
12
+ @max_retries = max_retries
13
+
14
+ validate_config!
15
+ end
16
+
17
+ def validate_config!
18
+ raise Error, "API key is required. Set GEMINI_API_KEY environment variable or pass api_key parameter." if @api_key.nil? || @api_key.empty?
19
+ end
20
+
21
+ def api_url(endpoint)
22
+ "#{@base_url}/v1beta/#{endpoint}"
23
+ end
24
+
25
+ def headers
26
+ {
27
+ "Content-Type" => "application/json",
28
+ "x-goog-api-key" => @api_key
29
+ }
30
+ end
31
+ end
32
+ end
@@ -0,0 +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
395
+ end
@@ -0,0 +1,3 @@
1
+ module Genai
2
+ VERSION = "0.0.2"
3
+ end
data/lib/genai.rb ADDED
@@ -0,0 +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
metadata ADDED
@@ -0,0 +1,93 @@
1
+ --- !ruby/object:Gem::Specification
2
+ name: genai-rb
3
+ version: !ruby/object:Gem::Version
4
+ version: 0.0.2
5
+ platform: ruby
6
+ authors:
7
+ - Siruu580
8
+ bindir: bin
9
+ cert_chain: []
10
+ date: 1980-01-02 00:00:00.000000000 Z
11
+ dependencies:
12
+ - !ruby/object:Gem::Dependency
13
+ name: rake
14
+ requirement: !ruby/object:Gem::Requirement
15
+ requirements:
16
+ - - "~>"
17
+ - !ruby/object:Gem::Version
18
+ version: '13.0'
19
+ type: :development
20
+ prerelease: false
21
+ version_requirements: !ruby/object:Gem::Requirement
22
+ requirements:
23
+ - - "~>"
24
+ - !ruby/object:Gem::Version
25
+ version: '13.0'
26
+ - !ruby/object:Gem::Dependency
27
+ name: rspec
28
+ requirement: !ruby/object:Gem::Requirement
29
+ requirements:
30
+ - - "~>"
31
+ - !ruby/object:Gem::Version
32
+ version: '3.0'
33
+ type: :development
34
+ prerelease: false
35
+ version_requirements: !ruby/object:Gem::Requirement
36
+ requirements:
37
+ - - "~>"
38
+ - !ruby/object:Gem::Version
39
+ version: '3.0'
40
+ - !ruby/object:Gem::Dependency
41
+ name: rubocop
42
+ requirement: !ruby/object:Gem::Requirement
43
+ requirements:
44
+ - - "~>"
45
+ - !ruby/object:Gem::Version
46
+ version: '1.0'
47
+ type: :development
48
+ prerelease: false
49
+ version_requirements: !ruby/object:Gem::Requirement
50
+ requirements:
51
+ - - "~>"
52
+ - !ruby/object:Gem::Version
53
+ version: '1.0'
54
+ description: ''
55
+ email:
56
+ - root@hina.cat
57
+ executables: []
58
+ extensions: []
59
+ extra_rdoc_files: []
60
+ files:
61
+ - CHANGELOG.md
62
+ - LICENSE.txt
63
+ - README.md
64
+ - lib/genai.rb
65
+ - lib/genai/chat.rb
66
+ - lib/genai/config.rb
67
+ - lib/genai/model.rb
68
+ - lib/genai/version.rb
69
+ homepage: https://github.com/Siruu580/genai
70
+ licenses:
71
+ - MIT
72
+ metadata:
73
+ allowed_push_host: https://rubygems.org
74
+ homepage_uri: https://github.com/Siruu580/genai
75
+ changelog_uri: https://github.com/Siruu580/genai/blob/main/CHANGELOG.md
76
+ rdoc_options: []
77
+ require_paths:
78
+ - lib
79
+ required_ruby_version: !ruby/object:Gem::Requirement
80
+ requirements:
81
+ - - ">="
82
+ - !ruby/object:Gem::Version
83
+ version: 3.0.0
84
+ required_rubygems_version: !ruby/object:Gem::Requirement
85
+ requirements:
86
+ - - ">="
87
+ - !ruby/object:Gem::Version
88
+ version: '0'
89
+ requirements: []
90
+ rubygems_version: 3.6.7
91
+ specification_version: 4
92
+ summary: genai for ruby
93
+ test_files: []