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.
@@ -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
@@ -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
@@ -0,0 +1,5 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Gemini
4
+ VERSION = "0.1.0"
5
+ end