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
@@ -0,0 +1,402 @@
|
|
1
|
+
module Gemini
|
2
|
+
class Response
|
3
|
+
# Raw response data from API
|
4
|
+
attr_reader :raw_data
|
5
|
+
|
6
|
+
def initialize(response_data)
|
7
|
+
@raw_data = response_data
|
8
|
+
end
|
9
|
+
|
10
|
+
# Get simple text response (combines multiple parts if present)
|
11
|
+
def text
|
12
|
+
return nil unless valid?
|
13
|
+
|
14
|
+
first_candidate&.dig("content", "parts")
|
15
|
+
&.select { |part| part.key?("text") }
|
16
|
+
&.map { |part| part["text"] }
|
17
|
+
&.join("\n") || ""
|
18
|
+
end
|
19
|
+
|
20
|
+
# Get formatted text (HTML/markdown, etc.)
|
21
|
+
def formatted_text
|
22
|
+
return nil unless valid?
|
23
|
+
|
24
|
+
text # Currently returns plain text, but could add formatting in the future
|
25
|
+
end
|
26
|
+
|
27
|
+
# Get all content parts
|
28
|
+
def parts
|
29
|
+
return [] unless valid?
|
30
|
+
|
31
|
+
first_candidate&.dig("content", "parts") || []
|
32
|
+
end
|
33
|
+
|
34
|
+
# Get all text parts as an array
|
35
|
+
def text_parts
|
36
|
+
return [] unless valid?
|
37
|
+
|
38
|
+
parts.select { |part| part.key?("text") }.map { |part| part["text"] }
|
39
|
+
end
|
40
|
+
|
41
|
+
# Get image parts (if any)
|
42
|
+
def image_parts
|
43
|
+
return [] unless valid?
|
44
|
+
|
45
|
+
parts.select { |part| part.key?("inline_data") && part["inline_data"]["mime_type"].start_with?("image/") }
|
46
|
+
end
|
47
|
+
|
48
|
+
# Get all content with string representation
|
49
|
+
def full_content
|
50
|
+
parts.map do |part|
|
51
|
+
if part.key?("text")
|
52
|
+
part["text"]
|
53
|
+
elsif part.key?("inline_data") && part["inline_data"]["mime_type"].start_with?("image/")
|
54
|
+
"[IMAGE: #{part["inline_data"]["mime_type"]}]"
|
55
|
+
else
|
56
|
+
"[UNKNOWN CONTENT]"
|
57
|
+
end
|
58
|
+
end.join("\n")
|
59
|
+
end
|
60
|
+
|
61
|
+
# Get the first candidate
|
62
|
+
def first_candidate
|
63
|
+
@raw_data&.dig("candidates", 0)
|
64
|
+
end
|
65
|
+
|
66
|
+
# Get all candidates (if multiple candidates are present)
|
67
|
+
def candidates
|
68
|
+
@raw_data&.dig("candidates") || []
|
69
|
+
end
|
70
|
+
|
71
|
+
# Check if response is valid
|
72
|
+
def valid?
|
73
|
+
!@raw_data.nil? &&
|
74
|
+
((@raw_data.key?("candidates") && !@raw_data["candidates"].empty?) ||
|
75
|
+
(@raw_data.key?("predictions") && !@raw_data["predictions"].empty?))
|
76
|
+
end
|
77
|
+
|
78
|
+
# Get error message if any
|
79
|
+
def error
|
80
|
+
return nil if valid?
|
81
|
+
|
82
|
+
# Return nil for empty responses (to display "Empty response" in to_s method)
|
83
|
+
return nil if @raw_data.nil? || @raw_data.empty?
|
84
|
+
|
85
|
+
@raw_data&.dig("error", "message") || "Unknown error"
|
86
|
+
end
|
87
|
+
|
88
|
+
# Check if response was successful
|
89
|
+
def success?
|
90
|
+
valid? && !@raw_data.key?("error")
|
91
|
+
end
|
92
|
+
|
93
|
+
# Get finish reason (STOP, SAFETY, etc.)
|
94
|
+
def finish_reason
|
95
|
+
first_candidate&.dig("finishReason")
|
96
|
+
end
|
97
|
+
|
98
|
+
# Check if response was blocked for safety reasons
|
99
|
+
def safety_blocked?
|
100
|
+
finish_reason == "SAFETY"
|
101
|
+
end
|
102
|
+
|
103
|
+
# Get token usage information
|
104
|
+
def usage
|
105
|
+
@raw_data&.dig("usage") || {}
|
106
|
+
end
|
107
|
+
|
108
|
+
# Get number of prompt tokens used
|
109
|
+
def prompt_tokens
|
110
|
+
usage&.dig("promptTokens") || 0
|
111
|
+
end
|
112
|
+
|
113
|
+
# Get number of tokens used for completion
|
114
|
+
def completion_tokens
|
115
|
+
usage&.dig("candidateTokens") || 0
|
116
|
+
end
|
117
|
+
|
118
|
+
# Get total tokens used
|
119
|
+
def total_tokens
|
120
|
+
usage&.dig("totalTokens") || 0
|
121
|
+
end
|
122
|
+
|
123
|
+
# Process chunks for streaming responses
|
124
|
+
def stream_chunks
|
125
|
+
return [] unless @raw_data.is_a?(Array)
|
126
|
+
|
127
|
+
@raw_data
|
128
|
+
end
|
129
|
+
|
130
|
+
# Get image URLs from multimodal responses (if any)
|
131
|
+
def image_urls
|
132
|
+
return [] unless valid?
|
133
|
+
|
134
|
+
first_candidate&.dig("content", "parts")
|
135
|
+
&.select { |part| part.key?("image_url") }
|
136
|
+
&.map { |part| part.dig("image_url", "url") } || []
|
137
|
+
end
|
138
|
+
|
139
|
+
# Get function call information
|
140
|
+
def function_calls
|
141
|
+
return [] unless valid?
|
142
|
+
|
143
|
+
first_candidate&.dig("content", "parts")
|
144
|
+
&.select { |part| part.key?("functionCall") }
|
145
|
+
&.map { |part| part["functionCall"] } || []
|
146
|
+
end
|
147
|
+
|
148
|
+
# Get response role (usually "model")
|
149
|
+
def role
|
150
|
+
first_candidate&.dig("content", "role")
|
151
|
+
end
|
152
|
+
|
153
|
+
# Get safety ratings
|
154
|
+
def safety_ratings
|
155
|
+
first_candidate&.dig("safetyRatings") || []
|
156
|
+
end
|
157
|
+
|
158
|
+
# 画像生成結果から最初の画像を取得(Base64エンコード形式)
|
159
|
+
def image
|
160
|
+
images.first
|
161
|
+
end
|
162
|
+
|
163
|
+
# 画像生成結果からすべての画像を取得(Base64エンコード形式の配列)
|
164
|
+
def images
|
165
|
+
image_array = []
|
166
|
+
return image_array unless @raw_data
|
167
|
+
|
168
|
+
# Gemini 2.0スタイルレスポンスを正確に解析
|
169
|
+
# キーはcamelCase形式で使用されているので注意(inlineDataなど)
|
170
|
+
if @raw_data.key?('candidates') && !@raw_data['candidates'].empty?
|
171
|
+
candidate = @raw_data['candidates'][0]
|
172
|
+
if candidate.key?('content') && candidate['content'].key?('parts')
|
173
|
+
parts = candidate['content']['parts']
|
174
|
+
|
175
|
+
parts.each do |part|
|
176
|
+
# キャメルケースでアクセス(inlineData)
|
177
|
+
if part.key?('inlineData')
|
178
|
+
inline_data = part['inlineData']
|
179
|
+
if inline_data.key?('mimeType') &&
|
180
|
+
inline_data['mimeType'].to_s.start_with?('image/') &&
|
181
|
+
inline_data.key?('data')
|
182
|
+
|
183
|
+
# 画像データを追加
|
184
|
+
image_array << inline_data['data']
|
185
|
+
puts "画像データを検出しました: #{inline_data['mimeType']}" if ENV["DEBUG"]
|
186
|
+
end
|
187
|
+
end
|
188
|
+
end
|
189
|
+
end
|
190
|
+
# Imagen 3スタイルレスポンスのチェック
|
191
|
+
elsif @raw_data.key?('predictions')
|
192
|
+
@raw_data['predictions'].each do |pred|
|
193
|
+
if pred.key?('bytesBase64Encoded')
|
194
|
+
image_array << pred['bytesBase64Encoded']
|
195
|
+
puts "Imagen 3形式の画像データを検出しました" if ENV["DEBUG"]
|
196
|
+
end
|
197
|
+
end
|
198
|
+
end
|
199
|
+
|
200
|
+
# フォールバック:直接JSONから抽出
|
201
|
+
if image_array.empty?
|
202
|
+
puts "標準的な方法で画像データが見つかりませんでした。正規表現による抽出を試みます..." if ENV["DEBUG"]
|
203
|
+
raw_json = @raw_data.to_json
|
204
|
+
|
205
|
+
# "data"キーで長いBase64文字列を検索
|
206
|
+
base64_matches = raw_json.scan(/"data":"([A-Za-z0-9+\/=]{100,})"/)
|
207
|
+
if !base64_matches.empty?
|
208
|
+
puts "検出したBase64データ: #{base64_matches.size}個" if ENV["DEBUG"]
|
209
|
+
base64_matches.each do |match|
|
210
|
+
image_array << match[0]
|
211
|
+
end
|
212
|
+
end
|
213
|
+
end
|
214
|
+
|
215
|
+
puts "検出した画像データ数: #{image_array.size}" if ENV["DEBUG"]
|
216
|
+
image_array
|
217
|
+
end
|
218
|
+
|
219
|
+
# 画像のMIMEタイプを取得
|
220
|
+
def image_mime_types
|
221
|
+
return [] unless valid?
|
222
|
+
|
223
|
+
if first_candidate&.dig("content", "parts")
|
224
|
+
first_candidate["content"]["parts"]
|
225
|
+
.select { |part| part.key?("inline_data") && part["inline_data"]["mime_type"].start_with?("image/") }
|
226
|
+
.map { |part| part["inline_data"]["mime_type"] }
|
227
|
+
else
|
228
|
+
# Imagen 3のデフォルトはPNG
|
229
|
+
Array.new(images.size, "image/png")
|
230
|
+
end
|
231
|
+
end
|
232
|
+
|
233
|
+
# 最初の画像をファイルに保存
|
234
|
+
def save_image(filepath)
|
235
|
+
save_images([filepath]).first
|
236
|
+
end
|
237
|
+
|
238
|
+
# 複数の画像をファイルに保存
|
239
|
+
def save_images(filepaths)
|
240
|
+
require 'base64'
|
241
|
+
|
242
|
+
result = []
|
243
|
+
image_data = images
|
244
|
+
|
245
|
+
puts "保存する画像データ数: #{image_data.size}" if ENV["DEBUG"]
|
246
|
+
|
247
|
+
# ファイルパスと画像データの数が一致しない場合
|
248
|
+
if filepaths.size < image_data.size
|
249
|
+
puts "警告: ファイルパスの数(#{filepaths.size})が画像データの数(#{image_data.size})より少ないです" if ENV["DEBUG"]
|
250
|
+
# ファイルパスの数に合わせて画像データを切り詰める
|
251
|
+
image_data = image_data[0...filepaths.size]
|
252
|
+
elsif filepaths.size > image_data.size
|
253
|
+
puts "警告: ファイルパスの数(#{filepaths.size})が画像データの数(#{image_data.size})より多いです" if ENV["DEBUG"]
|
254
|
+
# 画像データの数に合わせてファイルパスを切り詰める
|
255
|
+
filepaths = filepaths[0...image_data.size]
|
256
|
+
end
|
257
|
+
|
258
|
+
image_data.each_with_index do |data, i|
|
259
|
+
begin
|
260
|
+
if !data || data.empty?
|
261
|
+
puts "警告: インデックス #{i} の画像データが空です" if ENV["DEBUG"]
|
262
|
+
result << nil
|
263
|
+
next
|
264
|
+
end
|
265
|
+
|
266
|
+
# データがBase64エンコードされていることを確認
|
267
|
+
if data.match?(/^[A-Za-z0-9+\/=]+$/)
|
268
|
+
# 一般的なBase64データ
|
269
|
+
decoded_data = Base64.strict_decode64(data)
|
270
|
+
else
|
271
|
+
# データプレフィックスがある場合など(例: data:image/png;base64,xxxxx)
|
272
|
+
if data.include?('base64,')
|
273
|
+
base64_part = data.split('base64,').last
|
274
|
+
decoded_data = Base64.strict_decode64(base64_part)
|
275
|
+
else
|
276
|
+
puts "警告: インデックス #{i} のデータはBase64形式ではありません" if ENV["DEBUG"]
|
277
|
+
decoded_data = data # 既にバイナリかもしれない
|
278
|
+
end
|
279
|
+
end
|
280
|
+
|
281
|
+
File.open(filepaths[i], 'wb') do |f|
|
282
|
+
f.write(decoded_data)
|
283
|
+
end
|
284
|
+
result << filepaths[i]
|
285
|
+
rescue => e
|
286
|
+
puts "エラー: 画像 #{i} の保存中にエラーが発生しました: #{e.message}" if ENV["DEBUG"]
|
287
|
+
puts e.backtrace.join("\n") if ENV["DEBUG"]
|
288
|
+
result << nil
|
289
|
+
end
|
290
|
+
end
|
291
|
+
|
292
|
+
result
|
293
|
+
end
|
294
|
+
|
295
|
+
# Override to_s method to return text
|
296
|
+
def to_s
|
297
|
+
text || error || "Empty response"
|
298
|
+
end
|
299
|
+
|
300
|
+
# Inspection method for debugging
|
301
|
+
def inspect
|
302
|
+
"#<Gemini::Response text=#{text ? text[0..30] + (text.length > 30 ? '...' : '') : 'nil'} success=#{success?}>"
|
303
|
+
end
|
304
|
+
|
305
|
+
def json
|
306
|
+
return nil unless valid?
|
307
|
+
|
308
|
+
text_content = text
|
309
|
+
return nil unless text_content
|
310
|
+
|
311
|
+
begin
|
312
|
+
if text_content.strip.start_with?('{') || text_content.strip.start_with?('[')
|
313
|
+
JSON.parse(text_content)
|
314
|
+
else
|
315
|
+
nil
|
316
|
+
end
|
317
|
+
rescue JSON::ParserError => e
|
318
|
+
nil
|
319
|
+
end
|
320
|
+
end
|
321
|
+
|
322
|
+
def json?
|
323
|
+
!json.nil?
|
324
|
+
end
|
325
|
+
|
326
|
+
def as_json_object(model_class)
|
327
|
+
json_data = json
|
328
|
+
return nil unless json_data
|
329
|
+
|
330
|
+
begin
|
331
|
+
if model_class.respond_to?(:from_json)
|
332
|
+
model_class.from_json(json_data)
|
333
|
+
elsif defined?(ActiveModel::Model) && model_class.ancestors.include?(ActiveModel::Model)
|
334
|
+
model_class.new(json_data)
|
335
|
+
else
|
336
|
+
instance = model_class.new
|
337
|
+
|
338
|
+
json_data.each do |key, value|
|
339
|
+
setter_method = "#{key}="
|
340
|
+
if instance.respond_to?(setter_method)
|
341
|
+
instance.send(setter_method, value)
|
342
|
+
end
|
343
|
+
end
|
344
|
+
|
345
|
+
instance
|
346
|
+
end
|
347
|
+
rescue => e
|
348
|
+
nil
|
349
|
+
end
|
350
|
+
end
|
351
|
+
|
352
|
+
def as_json_array(model_class)
|
353
|
+
json_data = json
|
354
|
+
return [] unless json_data && json_data.is_a?(Array)
|
355
|
+
|
356
|
+
begin
|
357
|
+
json_data.map do |item|
|
358
|
+
if model_class.respond_to?(:from_json)
|
359
|
+
model_class.from_json(item)
|
360
|
+
elsif defined?(ActiveModel::Model) && model_class.ancestors.include?(ActiveModel::Model)
|
361
|
+
model_class.new(item)
|
362
|
+
else
|
363
|
+
instance = model_class.new
|
364
|
+
|
365
|
+
item.each do |key, value|
|
366
|
+
setter_method = "#{key}="
|
367
|
+
if instance.respond_to?(setter_method)
|
368
|
+
instance.send(setter_method, value)
|
369
|
+
end
|
370
|
+
end
|
371
|
+
|
372
|
+
instance
|
373
|
+
end
|
374
|
+
end
|
375
|
+
rescue => e
|
376
|
+
[]
|
377
|
+
end
|
378
|
+
end
|
379
|
+
|
380
|
+
def as_json_with_keys(*keys)
|
381
|
+
json_data = json
|
382
|
+
return [] unless json_data && json_data.is_a?(Array)
|
383
|
+
|
384
|
+
json_data.map do |item|
|
385
|
+
keys.each_with_object({}) do |key, result|
|
386
|
+
result[key.to_s] = item[key.to_s] if item.key?(key.to_s)
|
387
|
+
end
|
388
|
+
end
|
389
|
+
end
|
390
|
+
|
391
|
+
def to_formatted_json(pretty: false)
|
392
|
+
json_data = json
|
393
|
+
return nil unless json_data
|
394
|
+
|
395
|
+
if pretty
|
396
|
+
JSON.pretty_generate(json_data)
|
397
|
+
else
|
398
|
+
JSON.generate(json_data)
|
399
|
+
end
|
400
|
+
end
|
401
|
+
end
|
402
|
+
end
|
data/lib/gemini/runs.rb
ADDED
@@ -0,0 +1,158 @@
|
|
1
|
+
module Gemini
|
2
|
+
class Runs
|
3
|
+
def initialize(client:)
|
4
|
+
@client = client
|
5
|
+
@runs = {}
|
6
|
+
end
|
7
|
+
|
8
|
+
# Create a run (with streaming callback support)
|
9
|
+
def create(thread_id:, parameters: {}, &stream_callback)
|
10
|
+
# Check if thread exists
|
11
|
+
begin
|
12
|
+
@client.threads.retrieve(id: thread_id)
|
13
|
+
rescue => e
|
14
|
+
raise Error.new("Thread not found", "thread_not_found")
|
15
|
+
end
|
16
|
+
|
17
|
+
# Get messages and convert to Gemini format
|
18
|
+
messages_response = @client.messages.list(thread_id: thread_id)
|
19
|
+
messages = messages_response["data"]
|
20
|
+
|
21
|
+
# Extract system prompt
|
22
|
+
system_instruction = parameters[:system_instruction]
|
23
|
+
|
24
|
+
# Build contents array for Gemini API
|
25
|
+
contents = messages.map do |msg|
|
26
|
+
{
|
27
|
+
"role" => msg["role"],
|
28
|
+
"parts" => msg["content"].map do |content|
|
29
|
+
{ "text" => content["text"]["value"] }
|
30
|
+
end
|
31
|
+
}
|
32
|
+
end
|
33
|
+
|
34
|
+
# Get model
|
35
|
+
model = parameters[:model] || @client.threads.get_model(id: thread_id)
|
36
|
+
|
37
|
+
# Prepare parameters for Gemini API request
|
38
|
+
api_params = {
|
39
|
+
contents: contents,
|
40
|
+
model: model
|
41
|
+
}
|
42
|
+
|
43
|
+
# Add system instruction if provided
|
44
|
+
if system_instruction
|
45
|
+
api_params[:system_instruction] = {
|
46
|
+
parts: [
|
47
|
+
{ text: system_instruction.is_a?(String) ? system_instruction : system_instruction.to_s }
|
48
|
+
]
|
49
|
+
}
|
50
|
+
end
|
51
|
+
|
52
|
+
# Add other parameters (update exclusion list)
|
53
|
+
api_params.merge!(parameters.reject { |k, _| [:assistant_id, :instructions, :system_instruction, :model].include?(k) })
|
54
|
+
|
55
|
+
|
56
|
+
# Create run info in advance
|
57
|
+
run_id = SecureRandom.uuid
|
58
|
+
created_at = Time.now.to_i
|
59
|
+
|
60
|
+
run = {
|
61
|
+
"id" => run_id,
|
62
|
+
"object" => "thread.run",
|
63
|
+
"created_at" => created_at,
|
64
|
+
"thread_id" => thread_id,
|
65
|
+
"status" => "running",
|
66
|
+
"model" => model,
|
67
|
+
"metadata" => parameters[:metadata] || {},
|
68
|
+
"response" => nil
|
69
|
+
}
|
70
|
+
|
71
|
+
# Temporarily store run info
|
72
|
+
@runs[run_id] = run
|
73
|
+
|
74
|
+
# If streaming callback is provided
|
75
|
+
if block_given?
|
76
|
+
# Variable to accumulate complete response text
|
77
|
+
response_text = ""
|
78
|
+
|
79
|
+
# API request with streaming mode
|
80
|
+
response = @client.chat(parameters: api_params) do |chunk_text, raw_chunk|
|
81
|
+
# Call user-provided callback
|
82
|
+
stream_callback.call(chunk_text) if stream_callback
|
83
|
+
|
84
|
+
# Accumulate complete response text
|
85
|
+
response_text += chunk_text
|
86
|
+
end
|
87
|
+
|
88
|
+
# After streaming completion, save as message
|
89
|
+
if !response_text.empty?
|
90
|
+
@client.messages.create(
|
91
|
+
thread_id: thread_id,
|
92
|
+
parameters: {
|
93
|
+
role: "model",
|
94
|
+
content: response_text
|
95
|
+
}
|
96
|
+
)
|
97
|
+
end
|
98
|
+
|
99
|
+
# Update run info
|
100
|
+
run["status"] = "completed"
|
101
|
+
run["response"] = response
|
102
|
+
else
|
103
|
+
# Traditional batch response mode
|
104
|
+
response = @client.chat(parameters: api_params)
|
105
|
+
|
106
|
+
# Add response as model message
|
107
|
+
if response["candidates"] && !response["candidates"].empty?
|
108
|
+
candidate = response["candidates"][0]
|
109
|
+
content = candidate["content"]
|
110
|
+
|
111
|
+
if content && content["parts"] && !content["parts"].empty?
|
112
|
+
model_text = content["parts"][0]["text"]
|
113
|
+
|
114
|
+
@client.messages.create(
|
115
|
+
thread_id: thread_id,
|
116
|
+
parameters: {
|
117
|
+
role: "model",
|
118
|
+
content: model_text
|
119
|
+
}
|
120
|
+
)
|
121
|
+
end
|
122
|
+
end
|
123
|
+
|
124
|
+
# Update run info
|
125
|
+
run["status"] = "completed"
|
126
|
+
run["response"] = response
|
127
|
+
end
|
128
|
+
|
129
|
+
# Remove private information for response
|
130
|
+
run_response = run.dup
|
131
|
+
run_response.delete("response")
|
132
|
+
run_response
|
133
|
+
end
|
134
|
+
|
135
|
+
# Retrieve run information
|
136
|
+
def retrieve(thread_id:, id:)
|
137
|
+
run = @runs[id]
|
138
|
+
raise Error.new("Run not found", "run_not_found") unless run
|
139
|
+
raise Error.new("Run does not belong to thread", "invalid_thread_run") unless run["thread_id"] == thread_id
|
140
|
+
|
141
|
+
# Remove private information for response
|
142
|
+
run_response = run.dup
|
143
|
+
run_response.delete("response")
|
144
|
+
run_response
|
145
|
+
end
|
146
|
+
|
147
|
+
# Cancel a run (unimplemented feature, but provided for interface compatibility)
|
148
|
+
def cancel(thread_id:, id:)
|
149
|
+
run = retrieve(thread_id: thread_id, id: id)
|
150
|
+
|
151
|
+
# Gemini has no actual cancel function, but provide interface
|
152
|
+
# Return error for already completed runs
|
153
|
+
raise Error.new("Run is already completed", "run_already_completed") if run["status"] == "completed"
|
154
|
+
|
155
|
+
run
|
156
|
+
end
|
157
|
+
end
|
158
|
+
end
|
@@ -0,0 +1,74 @@
|
|
1
|
+
module Gemini
|
2
|
+
class Threads
|
3
|
+
def initialize(client:)
|
4
|
+
@client = client
|
5
|
+
@threads = {}
|
6
|
+
end
|
7
|
+
|
8
|
+
# Retrieve a thread
|
9
|
+
def retrieve(id:)
|
10
|
+
thread = @threads[id]
|
11
|
+
raise Error.new("Thread not found", "thread_not_found") unless thread
|
12
|
+
|
13
|
+
{
|
14
|
+
"id" => thread[:id],
|
15
|
+
"object" => "thread",
|
16
|
+
"created_at" => thread[:created_at],
|
17
|
+
"metadata" => thread[:metadata]
|
18
|
+
}
|
19
|
+
end
|
20
|
+
|
21
|
+
# Create a new thread
|
22
|
+
def create(parameters: {})
|
23
|
+
thread_id = SecureRandom.uuid
|
24
|
+
created_at = Time.now.to_i
|
25
|
+
|
26
|
+
@threads[thread_id] = {
|
27
|
+
id: thread_id,
|
28
|
+
created_at: created_at,
|
29
|
+
metadata: parameters[:metadata] || {},
|
30
|
+
model: parameters[:model] || "gemini-2.0-flash-lite"
|
31
|
+
}
|
32
|
+
|
33
|
+
{
|
34
|
+
"id" => thread_id,
|
35
|
+
"object" => "thread",
|
36
|
+
"created_at" => created_at,
|
37
|
+
"metadata" => @threads[thread_id][:metadata]
|
38
|
+
}
|
39
|
+
end
|
40
|
+
|
41
|
+
# Modify a thread
|
42
|
+
def modify(id:, parameters: {})
|
43
|
+
thread = @threads[id]
|
44
|
+
raise Error.new("Thread not found", "thread_not_found") unless thread
|
45
|
+
|
46
|
+
# Apply modifiable parameters
|
47
|
+
thread[:metadata] = parameters[:metadata] if parameters[:metadata]
|
48
|
+
thread[:model] = parameters[:model] if parameters[:model]
|
49
|
+
|
50
|
+
{
|
51
|
+
"id" => thread[:id],
|
52
|
+
"object" => "thread",
|
53
|
+
"created_at" => thread[:created_at],
|
54
|
+
"metadata" => thread[:metadata]
|
55
|
+
}
|
56
|
+
end
|
57
|
+
|
58
|
+
# Delete a thread
|
59
|
+
def delete(id:)
|
60
|
+
raise Error.new("Thread not found", "thread_not_found") unless @threads[id]
|
61
|
+
@threads.delete(id)
|
62
|
+
|
63
|
+
{ "id" => id, "object" => "thread.deleted", "deleted" => true }
|
64
|
+
end
|
65
|
+
|
66
|
+
# Internal use: Get thread model
|
67
|
+
def get_model(id:)
|
68
|
+
thread = @threads[id]
|
69
|
+
raise Error.new("Thread not found", "thread_not_found") unless thread
|
70
|
+
|
71
|
+
thread[:model]
|
72
|
+
end
|
73
|
+
end
|
74
|
+
end
|