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,540 @@
1
+ module Gemini
2
+ class Client
3
+ include Gemini::HTTP
4
+
5
+ SENSITIVE_ATTRIBUTES = %i[@api_key @extra_headers].freeze
6
+ CONFIG_KEYS = %i[api_key uri_base extra_headers log_errors request_timeout].freeze
7
+
8
+ attr_reader(*CONFIG_KEYS, :faraday_middleware)
9
+ attr_writer :api_key
10
+
11
+ def initialize(api_key = nil, config = {}, &faraday_middleware)
12
+ # Handle API key passed directly as argument
13
+ config[:api_key] = api_key if api_key
14
+
15
+ CONFIG_KEYS.each do |key|
16
+ # Set instance variables. Use global config if no setting provided
17
+ instance_variable_set(
18
+ "@#{key}",
19
+ config[key].nil? ? Gemini.configuration.send(key) : config[key]
20
+ )
21
+ end
22
+
23
+ @api_key ||= ENV["GEMINI_API_KEY"]
24
+ @faraday_middleware = faraday_middleware
25
+
26
+ raise ConfigurationError, "API key is not set" unless @api_key
27
+ end
28
+
29
+ # Thread management accessor
30
+ def threads
31
+ @threads ||= Gemini::Threads.new(client: self)
32
+ end
33
+
34
+ # Message management accessor
35
+ def messages
36
+ @messages ||= Gemini::Messages.new(client: self)
37
+ end
38
+
39
+ # Run management accessor
40
+ def runs
41
+ @runs ||= Gemini::Runs.new(client: self)
42
+ end
43
+
44
+ def audio
45
+ @audio ||= Gemini::Audio.new(client: self)
46
+ end
47
+
48
+ def files
49
+ @files ||= Gemini::Files.new(client: self)
50
+ end
51
+
52
+ # 画像生成アクセサ
53
+ def images
54
+ @images ||= Gemini::Images.new(client: self)
55
+ end
56
+
57
+ # ドキュメント処理アクセサ
58
+ def documents
59
+ @documents ||= Gemini::Documents.new(client: self)
60
+ end
61
+
62
+ # キャッシュ管理アクセサ
63
+ def cached_content
64
+ @cached_content ||= Gemini::CachedContent.new(client: self)
65
+ end
66
+
67
+ def reset_headers
68
+ @extra_headers = {}
69
+ end
70
+
71
+ # Access to conn (Faraday connection) for Audio features
72
+ # Wrapper to allow using private methods from HTTP module externally
73
+ def conn(multipart: false)
74
+ super(multipart: multipart)
75
+ end
76
+
77
+ # OpenAI chat-like text generation method for Gemini API
78
+ # Extended to support streaming callbacks
79
+ def chat(parameters: {}, &stream_callback)
80
+ model = parameters.delete(:model) || "gemini-2.0-flash-lite"
81
+
82
+ # If streaming callback is provided
83
+ if block_given?
84
+ path = "models/#{model}:streamGenerateContent"
85
+ # Set up stream callback
86
+ stream_params = parameters.dup
87
+ stream_params[:stream] = proc { |chunk| process_stream_chunk(chunk, &stream_callback) }
88
+ response = json_post(path: path, parameters: stream_params)
89
+ return Gemini::Response.new(response)
90
+ else
91
+ # Normal batch response mode
92
+ path = "models/#{model}:generateContent"
93
+ response = json_post(path: path, parameters: parameters)
94
+ return Gemini::Response.new(response)
95
+ end
96
+ end
97
+
98
+ # Method corresponding to OpenAI's embeddings
99
+ def embeddings(parameters: {})
100
+ model = parameters.delete(:model) || "text-embedding-model"
101
+ path = "models/#{model}:embedContent"
102
+ response = json_post(path: path, parameters: parameters)
103
+ Gemini::Response.new(response)
104
+ end
105
+
106
+ # Method corresponding to OpenAI's completions
107
+ # Uses same endpoint as chat in Gemini API
108
+ def completions(parameters: {}, &stream_callback)
109
+ chat(parameters: parameters, &stream_callback)
110
+ end
111
+
112
+ # Accessor for sub-clients
113
+ def models
114
+ @models ||= Gemini::Models.new(client: self)
115
+ end
116
+
117
+ # Helper methods for convenience
118
+
119
+ # Method with usage similar to OpenAI's chat
120
+ def generate_content(prompt, model: "gemini-2.0-flash-lite", system_instruction: nil,
121
+ response_mime_type: nil, response_schema: nil, **parameters, &stream_callback)
122
+ # For image/text combinations, the prompt is passed as an array
123
+ # example: [{type: "text", text: "What is this?"}, {type: "image_url", image_url: {url: "https://example.com/image.jpg"}}]
124
+ content = format_content(prompt)
125
+ params = {
126
+ contents: [content],
127
+ model: model
128
+ }
129
+
130
+ if system_instruction
131
+ params[:system_instruction] = format_content(system_instruction)
132
+ end
133
+
134
+ if response_mime_type || response_schema
135
+ params[:generation_config] ||= {}
136
+
137
+ if response_mime_type
138
+ params[:generation_config]["response_mime_type"] = response_mime_type
139
+ end
140
+
141
+ if response_schema
142
+ params[:generation_config]["response_schema"] = response_schema
143
+ end
144
+ end
145
+
146
+ # Merge other parameters
147
+ params.merge!(parameters)
148
+
149
+ if block_given?
150
+ chat(parameters: params, &stream_callback)
151
+ else
152
+ chat(parameters: params)
153
+ end
154
+ end
155
+
156
+ # Streaming text generation
157
+ def generate_content_stream(prompt, model: "gemini-2.0-flash-lite", system_instruction: nil,
158
+ response_mime_type: nil, response_schema: nil, **parameters, &block)
159
+ raise ArgumentError, "Block is required for streaming" unless block_given?
160
+
161
+ content = format_content(prompt)
162
+ params = {
163
+ contents: [content],
164
+ model: model
165
+ }
166
+
167
+ if system_instruction
168
+ params[:system_instruction] = format_content(system_instruction)
169
+ end
170
+
171
+ if response_mime_type || response_schema
172
+ params[:generation_config] ||= {}
173
+
174
+ if response_mime_type
175
+ params[:generation_config][:response_mime_type] = response_mime_type
176
+ end
177
+
178
+ if response_schema
179
+ params[:generation_config][:response_schema] = response_schema
180
+ end
181
+ end
182
+
183
+ # Merge other parameters
184
+ params.merge!(parameters)
185
+
186
+ chat(parameters: params, &block)
187
+ end
188
+
189
+ # ファイルを使った会話(複数ファイル対応)
190
+ def chat_with_multimodal(file_paths, prompt, model: "gemini-1.5-flash", **parameters)
191
+ # スレッドを作成
192
+ thread = threads.create(parameters: { model: model })
193
+ thread_id = thread["id"]
194
+
195
+ # 複数のファイルをアップロードして追加
196
+ file_infos = []
197
+
198
+ begin
199
+ # ファイルをアップロードしてメッセージとして追加
200
+ file_paths.each do |file_path|
201
+ file = File.open(file_path, "rb")
202
+ begin
203
+ upload_result = files.upload(file: file)
204
+ file_uri = upload_result["file"]["uri"]
205
+ file_name = upload_result["file"]["name"]
206
+ mime_type = determine_mime_type(file_path)
207
+
208
+ # ファイル情報を保存
209
+ file_infos << {
210
+ uri: file_uri,
211
+ name: file_name,
212
+ mime_type: mime_type
213
+ }
214
+
215
+ # ファイルをメッセージとして追加
216
+ messages.create(
217
+ thread_id: thread_id,
218
+ parameters: {
219
+ role: "user",
220
+ content: [
221
+ { file_data: { mime_type: mime_type, file_uri: file_uri } }
222
+ ]
223
+ }
224
+ )
225
+ ensure
226
+ file.close
227
+ end
228
+ end
229
+
230
+ # プロンプトメッセージを追加
231
+ messages.create(
232
+ thread_id: thread_id,
233
+ parameters: {
234
+ role: "user",
235
+ content: prompt
236
+ }
237
+ )
238
+
239
+ # 実行
240
+ run = runs.create(thread_id: thread_id, parameters: parameters)
241
+
242
+ # メッセージを取得
243
+ messages_list = messages.list(thread_id: thread_id)
244
+
245
+ # 結果とファイル情報を返す
246
+ {
247
+ messages: messages_list,
248
+ run: run,
249
+ file_infos: file_infos,
250
+ thread_id: thread_id
251
+ }
252
+ rescue => e
253
+ # エラー処理
254
+ { error: e.message, file_infos: file_infos }
255
+ end
256
+ end
257
+
258
+ def generate_content_with_cache(prompt, cached_content:, model: "gemini-1.5-flash", **parameters)
259
+ # モデル名にmodels/プレフィックスを追加
260
+ model_name = model.start_with?("models/") ? model : "models/#{model}"
261
+
262
+ # リクエストパラメータを構築
263
+ params = {
264
+ contents: [
265
+ {
266
+ parts: [{ text: prompt }],
267
+ role: "user"
268
+ }
269
+ ],
270
+ cachedContent: cached_content
271
+ }
272
+
273
+ # その他のパラメータをマージ
274
+ params.merge!(parameters)
275
+
276
+ # 直接エンドポイントURLを構築
277
+ endpoint = "#{model_name}:generateContent"
278
+
279
+ # APIリクエスト
280
+ response = json_post(
281
+ path: endpoint,
282
+ parameters: params
283
+ )
284
+
285
+ Gemini::Response.new(response)
286
+ end
287
+
288
+ # 単一ファイルのヘルパー
289
+ def chat_with_file(file_path, prompt, model: "gemini-1.5-flash", **parameters)
290
+ chat_with_multimodal([file_path], prompt, model: model, **parameters)
291
+ end
292
+
293
+ # ファイルをアップロードして質問するシンプルなヘルパー
294
+ def upload_and_process_file(file_path, prompt, content_type: nil, model: "gemini-1.5-flash", **parameters)
295
+ # MIMEタイプを自動判定
296
+ mime_type = content_type || determine_mime_type(file_path)
297
+
298
+ # ファイルをアップロード
299
+ file = File.open(file_path, "rb")
300
+ begin
301
+ upload_result = files.upload(file: file)
302
+ file_uri = upload_result["file"]["uri"]
303
+ file_name = upload_result["file"]["name"]
304
+
305
+ # コンテンツを生成
306
+ response = generate_content(
307
+ [
308
+ { text: prompt },
309
+ { file_data: { mime_type: mime_type, file_uri: file_uri } }
310
+ ],
311
+ model: model,
312
+ **parameters
313
+ )
314
+
315
+ # レスポンスと一緒にファイル情報も返す
316
+ {
317
+ response: response,
318
+ file_uri: file_uri,
319
+ file_name: file_name
320
+ }
321
+ ensure
322
+ file.close
323
+ end
324
+ end
325
+
326
+ # Debug inspect method
327
+ def inspect
328
+ vars = instance_variables.map do |var|
329
+ value = instance_variable_get(var)
330
+ SENSITIVE_ATTRIBUTES.include?(var) ? "#{var}=[REDACTED]" : "#{var}=#{value.inspect}"
331
+ end
332
+ "#<#{self.class}:#{object_id} #{vars.join(', ')}>"
333
+ end
334
+
335
+ # MIMEタイプを判定するメソッド(パブリックに変更)
336
+ def determine_mime_type(path_or_url)
337
+ extension = File.extname(path_or_url).downcase
338
+
339
+ # ドキュメント形式
340
+ document_types = {
341
+ ".pdf" => "application/pdf",
342
+ ".js" => "application/x-javascript",
343
+ ".py" => "application/x-python",
344
+ ".txt" => "text/plain",
345
+ ".html" => "text/html",
346
+ ".htm" => "text/html",
347
+ ".css" => "text/css",
348
+ ".md" => "text/md",
349
+ ".csv" => "text/csv",
350
+ ".xml" => "text/xml",
351
+ ".rtf" => "text/rtf"
352
+ }
353
+
354
+ # 画像形式
355
+ image_types = {
356
+ ".jpg" => "image/jpeg",
357
+ ".jpeg" => "image/jpeg",
358
+ ".png" => "image/png",
359
+ ".gif" => "image/gif",
360
+ ".webp" => "image/webp",
361
+ ".heic" => "image/heic",
362
+ ".heif" => "image/heif"
363
+ }
364
+
365
+ # 音声形式
366
+ audio_types = {
367
+ ".wav" => "audio/wav",
368
+ ".mp3" => "audio/mp3",
369
+ ".aiff" => "audio/aiff",
370
+ ".aac" => "audio/aac",
371
+ ".ogg" => "audio/ogg",
372
+ ".flac" => "audio/flac"
373
+ }
374
+
375
+ # 拡張子からMIMEタイプを判定
376
+ mime_type = document_types[extension] || image_types[extension] || audio_types[extension]
377
+ return mime_type if mime_type
378
+
379
+ # ファイルの内容から判定を試みる
380
+ if File.exist?(path_or_url)
381
+ # ファイルの最初の数バイトを読み込んで判定
382
+ first_bytes = File.binread(path_or_url, 8).bytes
383
+ case
384
+ when first_bytes[0..1] == [0xFF, 0xD8]
385
+ return "image/jpeg" # JPEG
386
+ when first_bytes[0..7] == [0x89, 0x50, 0x4E, 0x47, 0x0D, 0x0A, 0x1A, 0x0A]
387
+ return "image/png" # PNG
388
+ when first_bytes[0..2] == [0x47, 0x49, 0x46]
389
+ return "image/gif" # GIF
390
+ when first_bytes[0..3] == [0x52, 0x49, 0x46, 0x46] && first_bytes[8..11] == [0x57, 0x45, 0x42, 0x50]
391
+ return "image/webp" # WEBP
392
+ when first_bytes[0..3] == [0x25, 0x50, 0x44, 0x46]
393
+ return "application/pdf" # PDF
394
+ when first_bytes[0..1] == [0x49, 0x44]
395
+ return "audio/mp3" # MP3
396
+ when first_bytes[0..3] == [0x52, 0x49, 0x46, 0x46]
397
+ return "audio/wav" # WAV
398
+ end
399
+ end
400
+
401
+ # URLまたは判定できない場合
402
+ if path_or_url.start_with?("http://", "https://")
403
+ "application/octet-stream"
404
+ else
405
+ "application/octet-stream"
406
+ end
407
+ end
408
+
409
+ private
410
+
411
+ # Process stream chunk and pass to callback
412
+ def process_stream_chunk(chunk, &callback)
413
+ if chunk.respond_to?(:dig) && chunk.dig("candidates", 0, "content", "parts", 0, "text")
414
+ chunk_text = chunk.dig("candidates", 0, "content", "parts", 0, "text")
415
+ callback.call(chunk_text, chunk)
416
+ elsif chunk.respond_to?(:dig) && chunk.dig("candidates", 0, "content", "parts")
417
+ # Pass empty part to callback if no text
418
+ callback.call("", chunk)
419
+ else
420
+ # Treat other chunk types (metadata, etc.) as empty string
421
+ callback.call("", chunk)
422
+ end
423
+ end
424
+
425
+ # Convert input to Gemini API format with support for image inputs and file data
426
+ def format_content(input)
427
+ case input
428
+ when String
429
+ { parts: [{ text: input }] }
430
+ when Array
431
+ # For arrays, convert each element to part form
432
+ processed_parts = input.map do |part|
433
+ if part.is_a?(Hash)
434
+ if part[:type]
435
+ case part[:type]
436
+ when "text"
437
+ { text: part[:text] }
438
+ when "image_url"
439
+ # Convert to Gemini API format
440
+ {
441
+ inline_data: {
442
+ mime_type: determine_mime_type(part[:image_url][:url]),
443
+ data: encode_image_from_url(part[:image_url][:url])
444
+ }
445
+ }
446
+ when "image_file"
447
+ {
448
+ inline_data: {
449
+ mime_type: determine_mime_type(part[:image_file][:file_path]),
450
+ data: encode_image_from_file(part[:image_file][:file_path])
451
+ }
452
+ }
453
+ when "image_base64"
454
+ {
455
+ inline_data: {
456
+ mime_type: part[:image_base64][:mime_type],
457
+ data: part[:image_base64][:data]
458
+ }
459
+ }
460
+ when "file_data"
461
+ # Support for uploaded files via file_data
462
+ {
463
+ file_data: part[:file_data]
464
+ }
465
+ # 新しいタイプを追加
466
+ when "document"
467
+ {
468
+ file_data: {
469
+ mime_type: part[:document][:mime_type] || determine_mime_type(part[:document][:file_path]),
470
+ file_uri: part[:document][:file_uri]
471
+ }
472
+ }
473
+ when "audio"
474
+ {
475
+ file_data: {
476
+ mime_type: part[:audio][:mime_type] || determine_mime_type(part[:audio][:file_path]),
477
+ file_uri: part[:audio][:file_uri]
478
+ }
479
+ }
480
+ else
481
+ # Other types return as is
482
+ part
483
+ end
484
+ elsif part[:file_data]
485
+ # Direct file_data reference without type (for compatibility)
486
+ {
487
+ file_data: part[:file_data]
488
+ }
489
+ elsif part[:inline_data]
490
+ # Direct inline_data reference without type
491
+ {
492
+ inline_data: part[:inline_data]
493
+ }
494
+ elsif part[:text]
495
+ # Direct text reference without type
496
+ { text: part[:text] }
497
+ else
498
+ # Return hash as is if no recognized keys
499
+ part
500
+ end
501
+ elsif part.respond_to?(:to_s)
502
+ { text: part.to_s }
503
+ else
504
+ part
505
+ end
506
+ end
507
+ { parts: processed_parts }
508
+ when Hash
509
+ if input.key?(:parts)
510
+ input # If already in proper format, return as is
511
+ else
512
+ { parts: [input] } # Wrapping the hash in parts
513
+ end
514
+ else
515
+ { parts: [{ text: input.to_s }] }
516
+ end
517
+ end
518
+
519
+ def encode_image_from_url(url)
520
+ require 'open-uri'
521
+ require 'base64'
522
+ begin
523
+ # Explicitly read in binary mode
524
+ data = URI.open(url, 'rb').read
525
+ Base64.strict_encode64(data)
526
+ rescue => e
527
+ raise Error.new("Failed to load image from URL: #{e.message}")
528
+ end
529
+ end
530
+
531
+ def encode_image_from_file(file_path)
532
+ require 'base64'
533
+ begin
534
+ Base64.strict_encode64(File.binread(file_path))
535
+ rescue => e
536
+ raise Error.new("Failed to load image from file: #{e.message}")
537
+ end
538
+ end
539
+ end
540
+ end
@@ -0,0 +1,141 @@
1
+ module Gemini
2
+ class Documents
3
+ def initialize(client:)
4
+ @client = client
5
+ end
6
+
7
+ # ドキュメントをアップロードして質問する基本メソッド
8
+ def process(file: nil, file_path: nil, prompt:, model: "gemini-1.5-flash", **parameters)
9
+ # ファイルパスが指定されている場合はファイルを開く
10
+ if file_path && !file
11
+ file = File.open(file_path, "rb")
12
+ close_file = true
13
+ else
14
+ close_file = false
15
+ end
16
+
17
+ begin
18
+ # ファイルが指定されていない場合はエラー
19
+ raise ArgumentError, "file or file_path parameter is required" unless file
20
+
21
+ # MIMEタイプを判定
22
+ mime_type = parameters[:mime_type] || determine_document_mime_type(file)
23
+
24
+ # ファイルをアップロード
25
+ upload_result = @client.files.upload(file: file)
26
+ file_uri = upload_result["file"]["uri"]
27
+ file_name = upload_result["file"]["name"]
28
+
29
+ # コンテンツを生成
30
+ response = @client.generate_content(
31
+ [
32
+ { text: prompt },
33
+ { file_data: { mime_type: mime_type, file_uri: file_uri } }
34
+ ],
35
+ model: model,
36
+ **parameters.reject { |k, _| [:mime_type].include?(k) }
37
+ )
38
+
39
+ # レスポンスと一緒にファイル情報も返す
40
+ {
41
+ response: response,
42
+ file_uri: file_uri,
43
+ file_name: file_name
44
+ }
45
+ ensure
46
+ file.close if file && close_file
47
+ end
48
+ end
49
+
50
+ # ドキュメントをキャッシュに保存するメソッド
51
+ def cache(file: nil, file_path: nil, system_instruction: nil, ttl: "86400s", **parameters)
52
+ # ファイルパスが指定されている場合はファイルを開く
53
+ if file_path && !file
54
+ file = File.open(file_path, "rb")
55
+ close_file = true
56
+ else
57
+ close_file = false
58
+ end
59
+
60
+ begin
61
+ # ファイルが指定されていない場合はエラー
62
+ raise ArgumentError, "file or file_path parameter is required" unless file
63
+
64
+ # MIMEタイプを判定
65
+ mime_type = parameters[:mime_type] || determine_document_mime_type(file)
66
+
67
+ # ファイルをアップロード
68
+ upload_result = @client.files.upload(file: file)
69
+ file_uri = upload_result["file"]["uri"]
70
+ file_name = upload_result["file"]["name"]
71
+
72
+ # モデル名の取得と調整
73
+ model = parameters[:model] || "gemini-1.5-flash"
74
+ model = "models/#{model}" unless model.start_with?("models/")
75
+
76
+ # キャッシュに保存(パラメータの名前に注意)
77
+ cache_result = @client.cached_content.create(
78
+ file_uri: file_uri,
79
+ mime_type: mime_type,
80
+ system_instruction: system_instruction,
81
+ model: model,
82
+ ttl: ttl,
83
+ **parameters.reject { |k, _| [:mime_type, :model].include?(k) }
84
+ )
85
+
86
+ # 結果とファイル情報を返す
87
+ {
88
+ cache: cache_result,
89
+ file_uri: file_uri,
90
+ file_name: file_name
91
+ }
92
+ ensure
93
+ file.close if file && close_file
94
+ end
95
+ end
96
+
97
+ private
98
+
99
+ # ドキュメントのMIMEタイプを判定するヘルパーメソッド
100
+ def determine_document_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 ".pdf"
106
+ "application/pdf"
107
+ when ".js"
108
+ "application/x-javascript"
109
+ when ".py"
110
+ "application/x-python"
111
+ when ".txt"
112
+ "text/plain"
113
+ when ".html", ".htm"
114
+ "text/html"
115
+ when ".css"
116
+ "text/css"
117
+ when ".md"
118
+ "text/md"
119
+ when ".csv"
120
+ "text/csv"
121
+ when ".xml"
122
+ "text/xml"
123
+ when ".rtf"
124
+ "text/rtf"
125
+ else
126
+ # PDFのマジックナンバーを確認
127
+ file.rewind
128
+ header = file.read(4)
129
+ file.rewind
130
+
131
+ # PDFのシグネチャ: %PDF
132
+ if header && header.bytes.to_a[0..3] == [37, 80, 68, 70]
133
+ return "application/pdf"
134
+ end
135
+
136
+ # デフォルト
137
+ "application/octet-stream"
138
+ end
139
+ end
140
+ end
141
+ end
@@ -0,0 +1,27 @@
1
+ module Gemini
2
+ class Embeddings
3
+ def initialize(client:)
4
+ @client = client
5
+ end
6
+
7
+ def create(input:, model: "text-embedding-model", **parameters)
8
+ content = case input
9
+ when String
10
+ { parts: [{ text: input }] }
11
+ when Array
12
+ { parts: input.map { |text| { text: text.to_s } } }
13
+ else
14
+ { parts: [{ text: input.to_s }] }
15
+ end
16
+
17
+ payload = {
18
+ content: content
19
+ }.merge(parameters)
20
+
21
+ @client.json_post(
22
+ path: "models/#{model}:embedContent",
23
+ parameters: payload
24
+ )
25
+ end
26
+ end
27
+ end