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,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
@@ -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