ruby-gemini-api 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 +7 -0
- data/CHANGELOG.md +5 -0
- data/LICENSE.txt +21 -0
- data/README.md +764 -0
- data/lib/gemini/audio.rb +127 -0
- data/lib/gemini/cached_content.rb +151 -0
- data/lib/gemini/client.rb +540 -0
- data/lib/gemini/documents.rb +141 -0
- data/lib/gemini/embeddings.rb +27 -0
- data/lib/gemini/files.rb +149 -0
- data/lib/gemini/http.rb +226 -0
- data/lib/gemini/http_headers.rb +24 -0
- data/lib/gemini/images.rb +122 -0
- data/lib/gemini/messages.rb +131 -0
- data/lib/gemini/models.rb +20 -0
- data/lib/gemini/response.rb +402 -0
- data/lib/gemini/runs.rb +158 -0
- data/lib/gemini/threads.rb +74 -0
- data/lib/gemini/version.rb +5 -0
- data/lib/gemini.rb +81 -0
- data/lib/ruby/gemini.rb +1 -0
- metadata +193 -0
data/lib/gemini/files.rb
ADDED
@@ -0,0 +1,149 @@
|
|
1
|
+
module Gemini
|
2
|
+
class Files
|
3
|
+
# Base URL for File API
|
4
|
+
FILE_API_BASE_PATH = "files".freeze
|
5
|
+
|
6
|
+
def initialize(client:)
|
7
|
+
@client = client
|
8
|
+
end
|
9
|
+
|
10
|
+
# Method to upload a file
|
11
|
+
def upload(file:, display_name: nil)
|
12
|
+
# Check if file is valid
|
13
|
+
raise ArgumentError, "No file specified" unless file
|
14
|
+
|
15
|
+
# Get MIME type and size of the file
|
16
|
+
mime_type = determine_mime_type(file)
|
17
|
+
file.rewind
|
18
|
+
file_size = file.size
|
19
|
+
|
20
|
+
# Use filename as display_name if not specified
|
21
|
+
display_name ||= File.basename(file.path) if file.respond_to?(:path)
|
22
|
+
display_name ||= "uploaded_file"
|
23
|
+
|
24
|
+
# Headers for initial upload request (metadata definition)
|
25
|
+
headers = {
|
26
|
+
"X-Goog-Upload-Protocol" => "resumable",
|
27
|
+
"X-Goog-Upload-Command" => "start",
|
28
|
+
"X-Goog-Upload-Header-Content-Length" => file_size.to_s,
|
29
|
+
"X-Goog-Upload-Header-Content-Type" => mime_type,
|
30
|
+
"Content-Type" => "application/json"
|
31
|
+
}
|
32
|
+
|
33
|
+
# Add debug output
|
34
|
+
if ENV["DEBUG"]
|
35
|
+
puts "Request URL: https://generativelanguage.googleapis.com/upload/v1beta/files"
|
36
|
+
puts "Headers: #{headers.inspect}"
|
37
|
+
puts "API Key: #{@client.api_key[0..5]}..." if @client.api_key
|
38
|
+
end
|
39
|
+
|
40
|
+
# Send initial request to get upload URL
|
41
|
+
response = @client.conn.post("https://generativelanguage.googleapis.com/upload/v1beta/files") do |req|
|
42
|
+
req.headers = headers
|
43
|
+
req.params = { key: @client.api_key }
|
44
|
+
req.body = { file: { display_name: display_name } }.to_json
|
45
|
+
end
|
46
|
+
|
47
|
+
# Get upload URL from response headers
|
48
|
+
upload_url = response.headers["x-goog-upload-url"]
|
49
|
+
raise "Failed to obtain upload URL" unless upload_url
|
50
|
+
|
51
|
+
# Upload the file
|
52
|
+
file.rewind
|
53
|
+
file_data = file.read
|
54
|
+
upload_response = @client.conn.post(upload_url) do |req|
|
55
|
+
req.headers = {
|
56
|
+
"Content-Length" => file_size.to_s,
|
57
|
+
"X-Goog-Upload-Offset" => "0",
|
58
|
+
"X-Goog-Upload-Command" => "upload, finalize"
|
59
|
+
}
|
60
|
+
req.body = file_data
|
61
|
+
end
|
62
|
+
|
63
|
+
# Parse response as JSON
|
64
|
+
if upload_response.body.is_a?(String)
|
65
|
+
JSON.parse(upload_response.body)
|
66
|
+
elsif upload_response.body.is_a?(Hash)
|
67
|
+
upload_response.body
|
68
|
+
else
|
69
|
+
raise "Invalid response format: #{upload_response.body.class}"
|
70
|
+
end
|
71
|
+
end
|
72
|
+
|
73
|
+
# Method to get file metadata
|
74
|
+
def get(name:)
|
75
|
+
path = name.start_with?("files/") ? name : "files/#{name}"
|
76
|
+
@client.get(path: path)
|
77
|
+
end
|
78
|
+
|
79
|
+
# Method to get list of uploaded files
|
80
|
+
def list(page_size: nil, page_token: nil)
|
81
|
+
parameters = {}
|
82
|
+
parameters[:pageSize] = page_size if page_size
|
83
|
+
parameters[:pageToken] = page_token if page_token
|
84
|
+
|
85
|
+
@client.get(
|
86
|
+
path: FILE_API_BASE_PATH,
|
87
|
+
parameters: parameters
|
88
|
+
)
|
89
|
+
end
|
90
|
+
|
91
|
+
# Method to delete a file
|
92
|
+
def delete(name:)
|
93
|
+
path = name.start_with?("files/") ? name : "files/#{name}"
|
94
|
+
@client.delete(path: path)
|
95
|
+
end
|
96
|
+
|
97
|
+
private
|
98
|
+
|
99
|
+
# Simple MIME type determination from file extension
|
100
|
+
def determine_mime_type(file)
|
101
|
+
return "application/octet-stream" unless file.respond_to?(:path)
|
102
|
+
|
103
|
+
ext = File.extname(file.path).downcase
|
104
|
+
case ext
|
105
|
+
when ".jpg", ".jpeg"
|
106
|
+
"image/jpeg"
|
107
|
+
when ".png"
|
108
|
+
"image/png"
|
109
|
+
when ".gif"
|
110
|
+
"image/gif"
|
111
|
+
when ".webp"
|
112
|
+
"image/webp"
|
113
|
+
when ".wav"
|
114
|
+
"audio/wav"
|
115
|
+
when ".mp3"
|
116
|
+
"audio/mp3"
|
117
|
+
when ".aiff"
|
118
|
+
"audio/aiff"
|
119
|
+
when ".aac"
|
120
|
+
"audio/aac"
|
121
|
+
when ".ogg"
|
122
|
+
"audio/ogg"
|
123
|
+
when ".flac"
|
124
|
+
"audio/flac"
|
125
|
+
when ".mp4"
|
126
|
+
"video/mp4"
|
127
|
+
when ".avi"
|
128
|
+
"video/avi"
|
129
|
+
when ".mov"
|
130
|
+
"video/quicktime"
|
131
|
+
when ".mkv"
|
132
|
+
"video/x-matroska"
|
133
|
+
when ".pdf"
|
134
|
+
"application/pdf"
|
135
|
+
when ".txt"
|
136
|
+
"text/plain"
|
137
|
+
when ".doc", ".docx"
|
138
|
+
"application/msword"
|
139
|
+
when ".xlsx", ".xls"
|
140
|
+
"application/vnd.ms-excel"
|
141
|
+
when ".pptx", ".ppt"
|
142
|
+
"application/vnd.ms-powerpoint"
|
143
|
+
else
|
144
|
+
# Default value
|
145
|
+
"application/octet-stream"
|
146
|
+
end
|
147
|
+
end
|
148
|
+
end
|
149
|
+
end
|
data/lib/gemini/http.rb
ADDED
@@ -0,0 +1,226 @@
|
|
1
|
+
require_relative "http_headers"
|
2
|
+
|
3
|
+
module Gemini
|
4
|
+
module HTTP
|
5
|
+
include HTTPHeaders
|
6
|
+
|
7
|
+
def get(path:, parameters: nil)
|
8
|
+
# Gemini API requires API key as a parameter
|
9
|
+
params = (parameters || {}).merge(key: @api_key)
|
10
|
+
parse_json(conn.get(uri(path: path), params) do |req|
|
11
|
+
req.headers = headers
|
12
|
+
end&.body)
|
13
|
+
end
|
14
|
+
|
15
|
+
def post(path:)
|
16
|
+
parse_json(conn.post(uri(path: path)) do |req|
|
17
|
+
req.headers = headers
|
18
|
+
req.params = { key: @api_key }
|
19
|
+
end&.body)
|
20
|
+
end
|
21
|
+
|
22
|
+
def json_post(path:, parameters:, query_parameters: {})
|
23
|
+
# Check if there are streaming parameters
|
24
|
+
stream_proc = parameters[:stream] if parameters[:stream].respond_to?(:call)
|
25
|
+
|
26
|
+
# Determine if we're in streaming mode
|
27
|
+
is_streaming = !stream_proc.nil?
|
28
|
+
|
29
|
+
# For SSE streaming, add alt=sse to query parameters
|
30
|
+
if is_streaming
|
31
|
+
query_parameters = query_parameters.merge(alt: 'sse')
|
32
|
+
end
|
33
|
+
|
34
|
+
# In Gemini API, API key is passed as a query parameter
|
35
|
+
query_params = query_parameters.merge(key: @api_key)
|
36
|
+
|
37
|
+
# Streaming mode
|
38
|
+
if is_streaming
|
39
|
+
handle_streaming_request(path, parameters, query_params, stream_proc)
|
40
|
+
else
|
41
|
+
# Normal batch response mode
|
42
|
+
parse_json(conn.post(uri(path: path)) do |req|
|
43
|
+
configure_json_post_request(req, parameters)
|
44
|
+
req.params = req.params.merge(query_params)
|
45
|
+
end&.body)
|
46
|
+
end
|
47
|
+
end
|
48
|
+
|
49
|
+
def multipart_post(path:, parameters: nil)
|
50
|
+
parse_json(conn(multipart: true).post(uri(path: path)) do |req|
|
51
|
+
req.headers = headers.merge({ "Content-Type" => "multipart/form-data" })
|
52
|
+
req.params = { key: @api_key }
|
53
|
+
req.body = multipart_parameters(parameters)
|
54
|
+
end&.body)
|
55
|
+
end
|
56
|
+
|
57
|
+
def delete(path:)
|
58
|
+
parse_json(conn.delete(uri(path: path)) do |req|
|
59
|
+
req.headers = headers
|
60
|
+
req.params = { key: @api_key }
|
61
|
+
end&.body)
|
62
|
+
end
|
63
|
+
|
64
|
+
private
|
65
|
+
|
66
|
+
# Process streaming request
|
67
|
+
def handle_streaming_request(path, parameters, query_params, stream_proc)
|
68
|
+
# Create a copy of request parameters
|
69
|
+
req_parameters = parameters.dup
|
70
|
+
|
71
|
+
# Remove the streaming procedure (it would fail JSON serialization)
|
72
|
+
req_parameters.delete(:stream)
|
73
|
+
|
74
|
+
# Variable to accumulate response for SSE streaming
|
75
|
+
accumulated_json = nil
|
76
|
+
|
77
|
+
# Execute Faraday request
|
78
|
+
connection = conn
|
79
|
+
|
80
|
+
begin
|
81
|
+
response = connection.post(uri(path: path)) do |req|
|
82
|
+
req.headers = headers
|
83
|
+
req.params = query_params
|
84
|
+
req.body = req_parameters.to_json
|
85
|
+
|
86
|
+
# Callback to process SSE streaming events
|
87
|
+
req.options.on_data = proc do |chunk, _bytes, env|
|
88
|
+
if env && env.status != 200
|
89
|
+
raise_error = Faraday::Response::RaiseError.new
|
90
|
+
raise_error.on_complete(env.merge(body: try_parse_json(chunk)))
|
91
|
+
end
|
92
|
+
|
93
|
+
# Process SSE format lines
|
94
|
+
process_sse_chunk(chunk, stream_proc) do |parsed_json|
|
95
|
+
# Save the first valid JSON
|
96
|
+
accumulated_json ||= parsed_json
|
97
|
+
end
|
98
|
+
end
|
99
|
+
end
|
100
|
+
|
101
|
+
# Return the complete response
|
102
|
+
return accumulated_json || {}
|
103
|
+
rescue => e
|
104
|
+
log_streaming_error(e) if @log_errors
|
105
|
+
raise e
|
106
|
+
end
|
107
|
+
end
|
108
|
+
|
109
|
+
# Process SSE chunk
|
110
|
+
def process_sse_chunk(chunk, user_proc)
|
111
|
+
# Split chunk into lines
|
112
|
+
chunk.each_line do |line|
|
113
|
+
# Only process lines that start with "data:"
|
114
|
+
if line.start_with?("data:")
|
115
|
+
# Remove "data:" prefix
|
116
|
+
data = line[5..-1].strip
|
117
|
+
|
118
|
+
# Check for end marker
|
119
|
+
next if data == "[DONE]"
|
120
|
+
|
121
|
+
begin
|
122
|
+
# Parse JSON
|
123
|
+
parsed_json = JSON.parse(data)
|
124
|
+
|
125
|
+
# Pass parsed JSON to user procedure
|
126
|
+
user_proc.call(parsed_json)
|
127
|
+
|
128
|
+
# Pass to caller
|
129
|
+
yield parsed_json if block_given?
|
130
|
+
rescue JSON::ParserError => e
|
131
|
+
log_json_error(e, data) if @log_errors
|
132
|
+
end
|
133
|
+
end
|
134
|
+
end
|
135
|
+
end
|
136
|
+
|
137
|
+
# Log streaming error
|
138
|
+
def log_streaming_error(error)
|
139
|
+
STDERR.puts "[Gemini::HTTP] Streaming error: #{error.message}"
|
140
|
+
STDERR.puts error.backtrace.join("\n") if ENV["DEBUG"]
|
141
|
+
end
|
142
|
+
|
143
|
+
# Log JSON parsing error
|
144
|
+
def log_json_error(error, data)
|
145
|
+
STDERR.puts "[Gemini::HTTP] JSON parsing error: #{error.message}, data: #{data[0..100]}..." if ENV["DEBUG"]
|
146
|
+
end
|
147
|
+
|
148
|
+
def parse_json(response)
|
149
|
+
return unless response
|
150
|
+
return response unless response.is_a?(String)
|
151
|
+
|
152
|
+
original_response = response.dup
|
153
|
+
if response.include?("}\n{")
|
154
|
+
# Convert multi-line JSON objects to JSON array
|
155
|
+
response = response.gsub("}\n{", "},{").prepend("[").concat("]")
|
156
|
+
end
|
157
|
+
|
158
|
+
JSON.parse(response)
|
159
|
+
rescue JSON::ParserError
|
160
|
+
original_response
|
161
|
+
end
|
162
|
+
|
163
|
+
# Generate procedure to handle streaming response
|
164
|
+
def to_json_stream(user_proc:)
|
165
|
+
proc do |chunk, _bytes, env|
|
166
|
+
if env && env.status != 200
|
167
|
+
raise_error = Faraday::Response::RaiseError.new
|
168
|
+
raise_error.on_complete(env.merge(body: try_parse_json(chunk)))
|
169
|
+
end
|
170
|
+
|
171
|
+
# Process according to Gemini API streaming response format
|
172
|
+
parsed_chunk = try_parse_json(chunk)
|
173
|
+
user_proc.call(parsed_chunk) if parsed_chunk
|
174
|
+
end
|
175
|
+
end
|
176
|
+
|
177
|
+
def conn(multipart: false)
|
178
|
+
connection = Faraday.new do |f|
|
179
|
+
f.options[:timeout] = @request_timeout
|
180
|
+
f.request(:multipart) if multipart
|
181
|
+
f.use Gemini::MiddlewareErrors if @log_errors
|
182
|
+
f.response :raise_error
|
183
|
+
f.response :json
|
184
|
+
end
|
185
|
+
|
186
|
+
@faraday_middleware&.call(connection)
|
187
|
+
|
188
|
+
connection
|
189
|
+
end
|
190
|
+
|
191
|
+
def uri(path:)
|
192
|
+
File.join(@uri_base, path)
|
193
|
+
end
|
194
|
+
|
195
|
+
def multipart_parameters(parameters)
|
196
|
+
parameters&.transform_values do |value|
|
197
|
+
next value unless value.respond_to?(:close) # File or IO object
|
198
|
+
|
199
|
+
# Get file path if available
|
200
|
+
path = value.respond_to?(:path) ? value.path : nil
|
201
|
+
# Pass empty string for MIME type
|
202
|
+
Faraday::UploadIO.new(value, "", path)
|
203
|
+
end
|
204
|
+
end
|
205
|
+
|
206
|
+
def configure_json_post_request(req, parameters)
|
207
|
+
req_parameters = parameters.dup
|
208
|
+
|
209
|
+
if parameters[:stream].respond_to?(:call)
|
210
|
+
req.options.on_data = to_json_stream(user_proc: parameters[:stream])
|
211
|
+
req_parameters[:stream] = true # Instruct Gemini API to stream
|
212
|
+
elsif parameters[:stream]
|
213
|
+
raise ArgumentError, "stream parameter must be a Proc or have a #call method"
|
214
|
+
end
|
215
|
+
|
216
|
+
req.headers = headers
|
217
|
+
req.body = req_parameters.to_json
|
218
|
+
end
|
219
|
+
|
220
|
+
def try_parse_json(maybe_json)
|
221
|
+
JSON.parse(maybe_json)
|
222
|
+
rescue JSON::ParserError
|
223
|
+
maybe_json
|
224
|
+
end
|
225
|
+
end
|
226
|
+
end
|
@@ -0,0 +1,24 @@
|
|
1
|
+
module Gemini
|
2
|
+
module HTTPHeaders
|
3
|
+
def add_headers(headers)
|
4
|
+
@extra_headers = extra_headers.merge(headers.transform_keys(&:to_s))
|
5
|
+
end
|
6
|
+
|
7
|
+
private
|
8
|
+
|
9
|
+
def headers
|
10
|
+
default_headers.merge(extra_headers)
|
11
|
+
end
|
12
|
+
|
13
|
+
def default_headers
|
14
|
+
{
|
15
|
+
"Content-Type" => "application/json",
|
16
|
+
"User-Agent" => "ruby-gemini/#{Gemini::VERSION}"
|
17
|
+
}
|
18
|
+
end
|
19
|
+
|
20
|
+
def extra_headers
|
21
|
+
@extra_headers ||= {}
|
22
|
+
end
|
23
|
+
end
|
24
|
+
end
|
@@ -0,0 +1,122 @@
|
|
1
|
+
module Gemini
|
2
|
+
class Images
|
3
|
+
def initialize(client:)
|
4
|
+
@client = client
|
5
|
+
end
|
6
|
+
|
7
|
+
# 画像を生成するメインメソッド
|
8
|
+
def generate(parameters: {})
|
9
|
+
prompt = parameters[:prompt]
|
10
|
+
raise ArgumentError, "prompt parameter is required" unless prompt
|
11
|
+
|
12
|
+
# モデルの決定(デフォルトはGemini 2.0)
|
13
|
+
model = parameters[:model] || "gemini-2.0-flash-exp-image-generation"
|
14
|
+
|
15
|
+
# モデルに応じた画像生成処理
|
16
|
+
if model.start_with?("imagen")
|
17
|
+
# Imagen 3を使用
|
18
|
+
response = imagen_generate(prompt, parameters)
|
19
|
+
else
|
20
|
+
# Gemini 2.0を使用
|
21
|
+
response = gemini_generate(prompt, parameters)
|
22
|
+
end
|
23
|
+
|
24
|
+
# レスポンスをラップして返す
|
25
|
+
Gemini::Response.new(response)
|
26
|
+
end
|
27
|
+
|
28
|
+
private
|
29
|
+
|
30
|
+
# Gemini 2.0モデルを使用した画像生成
|
31
|
+
def gemini_generate(prompt, parameters)
|
32
|
+
# パラメータの準備
|
33
|
+
model = parameters[:model] || "gemini-2.0-flash-exp-image-generation"
|
34
|
+
|
35
|
+
# サイズパラメータの処理(現在はGemini APIでは使用しない)
|
36
|
+
# aspect_ratio = process_size_parameter(parameters[:size])
|
37
|
+
|
38
|
+
# 生成設定の構築
|
39
|
+
generation_config = {
|
40
|
+
"responseModalities" => ["Text", "Image"]
|
41
|
+
}
|
42
|
+
|
43
|
+
# リクエストパラメータの構築
|
44
|
+
request_params = {
|
45
|
+
"contents" => [{
|
46
|
+
"parts" => [
|
47
|
+
{"text" => prompt}
|
48
|
+
]
|
49
|
+
}],
|
50
|
+
"generationConfig" => generation_config
|
51
|
+
}
|
52
|
+
|
53
|
+
# API呼び出し
|
54
|
+
@client.json_post(
|
55
|
+
path: "models/#{model}:generateContent",
|
56
|
+
parameters: request_params
|
57
|
+
)
|
58
|
+
end
|
59
|
+
|
60
|
+
# Imagen 3モデルを使用した画像生成
|
61
|
+
def imagen_generate(prompt, parameters)
|
62
|
+
# モデル名の取得(デフォルトはImagen 3の標準モデル)
|
63
|
+
model = parameters[:model] || "imagen-3.0-generate-002"
|
64
|
+
|
65
|
+
# サイズパラメータからアスペクト比を取得
|
66
|
+
aspect_ratio = process_size_parameter(parameters[:size])
|
67
|
+
|
68
|
+
# 画像生成数の設定
|
69
|
+
sample_count = parameters[:n] || parameters[:sample_count] || 1
|
70
|
+
sample_count = [[sample_count.to_i, 1].max, 4].min # 1〜4の範囲に制限
|
71
|
+
|
72
|
+
# 人物生成の設定
|
73
|
+
person_generation = parameters[:person_generation] || "ALLOW_ADULT"
|
74
|
+
|
75
|
+
# リクエストパラメータの構築
|
76
|
+
request_params = {
|
77
|
+
"instances" => [
|
78
|
+
{
|
79
|
+
"prompt" => prompt
|
80
|
+
}
|
81
|
+
],
|
82
|
+
"parameters" => {
|
83
|
+
"sampleCount" => sample_count
|
84
|
+
}
|
85
|
+
}
|
86
|
+
|
87
|
+
# アスペクト比が指定されている場合は追加
|
88
|
+
request_params["parameters"]["aspectRatio"] = aspect_ratio if aspect_ratio
|
89
|
+
|
90
|
+
# 人物生成設定を追加
|
91
|
+
request_params["parameters"]["personGeneration"] = person_generation
|
92
|
+
|
93
|
+
# API呼び出し
|
94
|
+
@client.json_post(
|
95
|
+
path: "models/#{model}:predict",
|
96
|
+
parameters: request_params
|
97
|
+
)
|
98
|
+
end
|
99
|
+
|
100
|
+
# サイズパラメータからアスペクト比を決定
|
101
|
+
def process_size_parameter(size)
|
102
|
+
return nil unless size
|
103
|
+
|
104
|
+
case size.to_s
|
105
|
+
when "256x256", "512x512", "1024x1024"
|
106
|
+
"1:1"
|
107
|
+
when "256x384", "512x768", "1024x1536"
|
108
|
+
"3:4"
|
109
|
+
when "384x256", "768x512", "1536x1024"
|
110
|
+
"4:3"
|
111
|
+
when "256x448", "512x896", "1024x1792"
|
112
|
+
"9:16"
|
113
|
+
when "448x256", "896x512", "1792x1024"
|
114
|
+
"16:9"
|
115
|
+
when "1:1", "3:4", "4:3", "9:16", "16:9"
|
116
|
+
size.to_s
|
117
|
+
else
|
118
|
+
"1:1" # デフォルト
|
119
|
+
end
|
120
|
+
end
|
121
|
+
end
|
122
|
+
end
|
@@ -0,0 +1,131 @@
|
|
1
|
+
module Gemini
|
2
|
+
class Messages
|
3
|
+
def initialize(client:)
|
4
|
+
@client = client
|
5
|
+
@message_store = {} # Store messages by thread ID
|
6
|
+
end
|
7
|
+
|
8
|
+
# List messages in a thread
|
9
|
+
def list(thread_id:, parameters: {})
|
10
|
+
# Internal implementation: Get messages for the thread from message store
|
11
|
+
messages = get_thread_messages(thread_id)
|
12
|
+
|
13
|
+
# OpenAI-like response format
|
14
|
+
{
|
15
|
+
"object" => "list",
|
16
|
+
"data" => messages,
|
17
|
+
"first_id" => messages.first&.dig("id"),
|
18
|
+
"last_id" => messages.last&.dig("id"),
|
19
|
+
"has_more" => false
|
20
|
+
}
|
21
|
+
end
|
22
|
+
|
23
|
+
# Retrieve a specific message
|
24
|
+
def retrieve(thread_id:, id:)
|
25
|
+
messages = get_thread_messages(thread_id)
|
26
|
+
message = messages.find { |m| m["id"] == id }
|
27
|
+
|
28
|
+
raise Error.new("Message not found", "message_not_found") unless message
|
29
|
+
message
|
30
|
+
end
|
31
|
+
|
32
|
+
# Create a new message
|
33
|
+
def create(thread_id:, parameters: {})
|
34
|
+
# Check if thread exists (raise exception if not)
|
35
|
+
validate_thread_exists(thread_id)
|
36
|
+
|
37
|
+
message_id = SecureRandom.uuid
|
38
|
+
created_at = Time.now.to_i
|
39
|
+
|
40
|
+
# Build message data from parameters
|
41
|
+
message = {
|
42
|
+
"id" => message_id,
|
43
|
+
"object" => "thread.message",
|
44
|
+
"created_at" => created_at,
|
45
|
+
"thread_id" => thread_id,
|
46
|
+
"role" => parameters[:role] || "user",
|
47
|
+
"content" => format_content(parameters[:content])
|
48
|
+
}
|
49
|
+
|
50
|
+
# Add message to thread
|
51
|
+
add_message_to_thread(thread_id, message)
|
52
|
+
|
53
|
+
message
|
54
|
+
end
|
55
|
+
|
56
|
+
# Modify a message
|
57
|
+
def modify(thread_id:, id:, parameters: {})
|
58
|
+
message = retrieve(thread_id: thread_id, id: id)
|
59
|
+
|
60
|
+
# Apply modifiable parameters
|
61
|
+
message["metadata"] = parameters[:metadata] if parameters[:metadata]
|
62
|
+
|
63
|
+
message
|
64
|
+
end
|
65
|
+
|
66
|
+
# Delete a message (logical deletion)
|
67
|
+
def delete(thread_id:, id:)
|
68
|
+
message = retrieve(thread_id: thread_id, id: id)
|
69
|
+
|
70
|
+
# Set logical deletion flag
|
71
|
+
message["deleted"] = true
|
72
|
+
|
73
|
+
{ "id" => id, "object" => "thread.message.deleted", "deleted" => true }
|
74
|
+
end
|
75
|
+
|
76
|
+
private
|
77
|
+
|
78
|
+
# Get thread messages (internal method)
|
79
|
+
def get_thread_messages(thread_id)
|
80
|
+
validate_thread_exists(thread_id)
|
81
|
+
@message_store[thread_id] ||= []
|
82
|
+
@message_store[thread_id].reject { |m| m["deleted"] }
|
83
|
+
end
|
84
|
+
|
85
|
+
# Add message to thread (internal method)
|
86
|
+
def add_message_to_thread(thread_id, message)
|
87
|
+
@message_store[thread_id] ||= []
|
88
|
+
@message_store[thread_id] << message
|
89
|
+
message
|
90
|
+
end
|
91
|
+
|
92
|
+
# Validate thread exists (internal method)
|
93
|
+
def validate_thread_exists(thread_id)
|
94
|
+
begin
|
95
|
+
@client.threads.retrieve(id: thread_id)
|
96
|
+
rescue => e
|
97
|
+
raise Error.new("Thread not found", "thread_not_found")
|
98
|
+
end
|
99
|
+
end
|
100
|
+
|
101
|
+
# Convert content to Gemini API format (internal method)
|
102
|
+
def format_content(content)
|
103
|
+
case content
|
104
|
+
when String
|
105
|
+
[{ "type" => "text", "text" => { "value" => content } }]
|
106
|
+
when Array
|
107
|
+
content.map do |item|
|
108
|
+
if item.is_a?(String)
|
109
|
+
{ "type" => "text", "text" => { "value" => item } }
|
110
|
+
else
|
111
|
+
item
|
112
|
+
end
|
113
|
+
end
|
114
|
+
when Hash
|
115
|
+
[content]
|
116
|
+
else
|
117
|
+
[{ "type" => "text", "text" => { "value" => content.to_s } }]
|
118
|
+
end
|
119
|
+
end
|
120
|
+
end
|
121
|
+
|
122
|
+
# Error class
|
123
|
+
class Error < StandardError
|
124
|
+
attr_reader :code
|
125
|
+
|
126
|
+
def initialize(message, code = nil)
|
127
|
+
super(message)
|
128
|
+
@code = code
|
129
|
+
end
|
130
|
+
end
|
131
|
+
end
|
@@ -0,0 +1,20 @@
|
|
1
|
+
module Gemini
|
2
|
+
class Models
|
3
|
+
def initialize(client:)
|
4
|
+
@client = client
|
5
|
+
end
|
6
|
+
|
7
|
+
def list
|
8
|
+
@client.get(path: "models")
|
9
|
+
end
|
10
|
+
|
11
|
+
def retrieve(id:)
|
12
|
+
@client.get(path: "models/#{id}")
|
13
|
+
end
|
14
|
+
|
15
|
+
# Stub for compatibility, as Gemini API currently doesn't provide model deletion
|
16
|
+
def delete(id:)
|
17
|
+
raise NotImplementedError, "Model deletion is not supported in Gemini API"
|
18
|
+
end
|
19
|
+
end
|
20
|
+
end
|