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 +7 -0
- data/CHANGELOG.md +10 -0
- data/LICENSE.txt +21 -0
- data/README.md +107 -0
- data/lib/genai/chat.rb +233 -0
- data/lib/genai/config.rb +32 -0
- data/lib/genai/model.rb +395 -0
- data/lib/genai/version.rb +3 -0
- data/lib/genai.rb +32 -0
- metadata +93 -0
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
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
|
data/lib/genai/config.rb
ADDED
@@ -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
|
data/lib/genai/model.rb
ADDED
@@ -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
|
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: []
|