みなさんこんにちは、ユニファの赤沼です。 この記事は Unifa Advent Calendar 2023 の20日目の記事です。
先日 Google Gemini の API が公開されましたが、Ruby の SDK は公開されていないので Python のクライアントライブラリから触ってみた記事を Advent Calendar 17日目の記事で書きました。
ユニファではサーバサイドの大半は Ruby で書いているということもあり、実際に本番でこういう使い方をするかはさておき、 Ruby で REST API を呼ぶ形で使ってみたのでその内容を書いてみます。
REST API のドキュメントは下記にありますので、詳細については下記をご参照ください。
Gemfile
SKD使用時は環境変数に API Key の credentials のファイルパスを指定しておけば勝手に認証してくれましたが、REST API ではそうもいかないので、認証処理に使う jwt を Gemfile に追記して bundle install
を実行しておきます。
gem "jwt"
Chat Sample
まずは認証のための処理を実装します。ローカルに置いた credentails ファイルを読み取って OAuth2 の認証を行い、アクセストークンを取得します。以降のリクエストにはこのアクセストークンを使用します。
def get_access_token(key_file_path) key_json = JSON.parse(File.read(key_file_path)) private_key = OpenSSL::PKey::RSA.new(key_json['private_key']) client_email = key_json['client_email'] now = Time.now.to_i expiration = now + 3600 payload = { iss: client_email, scope: 'https://www.googleapis.com/auth/cloud-platform', aud: 'https://oauth2.googleapis.com/token', exp: expiration, iat: now } jwt = JWT.encode(payload, private_key, 'RS256') uri = URI.parse('https://oauth2.googleapis.com/token') https = Net::HTTP.new(uri.host, uri.port) https.use_ssl = true request = Net::HTTP::Post.new(uri.path) request.set_form_data({ grant_type: 'urn:ietf:params:oauth:grant-type:jwt-bearer', assertion: jwt }) response = https.request(request) JSON.parse(response.body)['access_token'] end
次にリクエスト情報を作成するメソッドを実装します。 Authorization ヘッダーに先ほど取得したアクセストークンを設定することで認証します。
リクエストボディにはプロンプトなどの情報を含む contents と、生成時のパラメータを指定する generation_config を設定しています。
def make_request(uri, contents, generation_config, access_token) request = Net::HTTP::Post.new(uri) request.content_type = 'application/json' request['Authorization'] = "Bearer #{access_token}" request.body = { contents: contents, generation_config: generation_config }.to_json request end
上記のメソッドに渡す contents と generation_config は下記のように Hash で定義しておきます。
contents = [ { role: :user, parts: [ { text: "こんにちは。私はジョンといいます" } ] }, ] generation_config = { maxOutputTokens: 2048, temperature: 0.9, topP: 1 }
ここまでに定義したメソッドとパラメータを使ってAPIにリクエストします。API の URL に含むメソッドとして streamGenerateContent
を指定しているのでストリーミングのレスポンスになるため、 response.read_body
メソッドで chunk を処理する形にしています。
KEY_FILE_PATH = 'credentials.json'.freeze LOCATION_ID = "asia-northeast1".freeze PROJECT_ID = "akanumatest".freeze MODEL_ID = "gemini-pro".freeze API_ENDPOINT = "#{LOCATION_ID}-aiplatform.googleapis.com".freeze API_METHOD = "streamGenerateContent".freeze API_URI = "https://#{API_ENDPOINT}/v1beta1/projects/#{PROJECT_ID}/locations/#{LOCATION_ID}/publishers/google/models/#{MODEL_ID}:#{API_METHOD}".freeze uri = URI(API_URI) request = make_request(uri, contents, generation_config, get_access_token(KEY_FILE_PATH)) Net::HTTP.start(uri.hostname, uri.port, use_ssl: uri.scheme == 'https') do |http| http.request(request) do |response| response.read_body do |chunk| JSON.parse(chunk).each do |c| print c.dig("candidates", 0, "content", "parts", 0, "text") end end end end
これを実行すると下記のような結果になります。
ジョンさん、初めまして。あなたの名前はジョンなのですね。素敵ですね。ジョンさんはどのような人なのですか?ご友人やご家族について教えていただけますか?趣味や好きなことはありますか?他に何か知りたいことはありますか?
期待する動きとしては順次 chunk が返されてその内容が随時出力されていくことなのですが、実際にやってみると下記のように最初の chunk に複数のレスポンスがまとめて Array 形式で返されてしまうので、 chunk を each でループしてすべての text を出力するようにしていますが、挙動としてはストリーミングではなくまとめて出力される形になってしまいました。これについては少し調べてみましたが原因はわからずでしたので、誰か知っている方は教えて下さい。
[ { "candidates": [ { "content": { "role": "model", "parts": [ { "text": "ジョンさん、初めまして。あなたの名前はジョンなのですね。素敵ですね。" } ] }, "safetyRatings": [ { "category": "HARM_CATEGORY_HARASSMENT", "probability": "NEGLIGIBLE" }, { "category": "HARM_CATEGORY_HATE_SPEECH", "probability": "NEGLIGIBLE" }, { "category": "HARM_CATEGORY_SEXUALLY_EXPLICIT", "probability": "NEGLIGIBLE" }, { "category": "HARM_CATEGORY_DANGEROUS_CONTENT", "probability": "NEGLIGIBLE" } ] } ] }, { "candidates": [ { "content": { "role": "model", "parts": [ { "text": "ジョンさんはどのような人なのですか?ご友人やご家族から、ジョンさんはどんな人だと言われますか?ジョンさんは何をしているときが一番幸せですか?" } ] }, "finishReason": "STOP", "safetyRatings": [ { "category": "HARM_CATEGORY_HARASSMENT", "probability": "NEGLIGIBLE" }, { "category": "HARM_CATEGORY_HATE_SPEECH", "probability": "NEGLIGIBLE" }, { "category": "HARM_CATEGORY_SEXUALLY_EXPLICIT", "probability": "NEGLIGIBLE" }, { "category": "HARM_CATEGORY_DANGEROUS_CONTENT", "probability": "NEGLIGIBLE" } ] } ], "usageMetadata": { "promptTokenCount": 5, "candidatesTokenCount": 48, "totalTokenCount": 53 } } ]
最後に改めて実装の全体を記載しておきます。
require 'jwt' require 'json' require 'net/http' require 'uri' KEY_FILE_PATH = 'credentials.json'.freeze LOCATION_ID = "asia-northeast1".freeze PROJECT_ID = "akanumatest".freeze MODEL_ID = "gemini-pro".freeze API_ENDPOINT = "#{LOCATION_ID}-aiplatform.googleapis.com".freeze API_METHOD = "streamGenerateContent".freeze API_URI = "https://#{API_ENDPOINT}/v1beta1/projects/#{PROJECT_ID}/locations/#{LOCATION_ID}/publishers/google/models/#{MODEL_ID}:#{API_METHOD}".freeze def get_access_token(key_file_path) key_json = JSON.parse(File.read(key_file_path)) private_key = OpenSSL::PKey::RSA.new(key_json['private_key']) client_email = key_json['client_email'] now = Time.now.to_i expiration = now + 3600 payload = { iss: client_email, scope: 'https://www.googleapis.com/auth/cloud-platform', aud: 'https://oauth2.googleapis.com/token', exp: expiration, iat: now } jwt = JWT.encode(payload, private_key, 'RS256') uri = URI.parse('https://oauth2.googleapis.com/token') https = Net::HTTP.new(uri.host, uri.port) https.use_ssl = true request = Net::HTTP::Post.new(uri.path) request.set_form_data({ grant_type: 'urn:ietf:params:oauth:grant-type:jwt-bearer', assertion: jwt }) response = https.request(request) JSON.parse(response.body)['access_token'] end def make_request(uri, contents, generation_config, access_token) request = Net::HTTP::Post.new(uri) request.content_type = 'application/json' request['Authorization'] = "Bearer #{access_token}" request.body = { contents: contents, generation_config: generation_config }.to_json request end contents = [ { role: :user, parts: [ { text: "こんにちは。私はジョンといいます" } ] }, ] generation_config = { maxOutputTokens: 2048, temperature: 0.9, topP: 1 } uri = URI(API_URI) request = make_request(uri, contents, generation_config, get_access_token(KEY_FILE_PATH)) Net::HTTP.start(uri.hostname, uri.port, use_ssl: uri.scheme == 'https') do |http| http.request(request) do |response| response.read_body do |chunk| JSON.parse(chunk).each do |c| print c.dig("candidates", 0, "content", "parts", 0, "text") end end end end
Image Input Sample
続いて画像を読み込ませてみます。といっても変更点は多くなく、画像を Base64 でエンコードしたデータを渡すのと、モデルとして gemini-pro-vision
を使用するという点です。
まずは画像のエンコーディング用メソッドを下記のように実装します。 Base64.strict_encode64
メソッドを使用していますが、これを Base64.encode64
メソッドを使うと API にリクエストした際にデコードがうまく行かないようで Base64 decoding failed
というエラーになってしまいます。 Base64.encode64
メソッドは60文字ごとに改行が挿入されるようなので、それが問題になると思われます。
def encode_image(image_path) image = File.binread(image_path) Base64.strict_encode64(image) end
contents では inlineData
パートで mimeType と上記メソッドでエンコードした画像データを設定します。
contents = [ { role: :user, parts: [ { inlineData: { mimeType: "image/jpeg", data: encode_image("cat.jpg") } }, { text: "この写真について詳しく説明してください" } ] }, ]
モデルは下記のように gemini-pro-vision
に変更します。
MODEL_ID = "gemini-pro-vision".freeze
使用する画像データは前回の記事と同様に下記の画像になります。
実装の全体としては下記のようになります。
require 'jwt' require 'json' require 'net/http' require 'uri' KEY_FILE_PATH = 'credentials.json'.freeze LOCATION_ID = "asia-northeast1".freeze PROJECT_ID = "akanumatest".freeze MODEL_ID = "gemini-pro-vision".freeze API_ENDPOINT = "#{LOCATION_ID}-aiplatform.googleapis.com".freeze API_METHOD = "streamGenerateContent".freeze API_URI = "https://#{API_ENDPOINT}/v1beta1/projects/#{PROJECT_ID}/locations/#{LOCATION_ID}/publishers/google/models/#{MODEL_ID}:#{API_METHOD}".freeze def get_access_token(key_file_path) key_json = JSON.parse(File.read(key_file_path)) private_key = OpenSSL::PKey::RSA.new(key_json['private_key']) client_email = key_json['client_email'] now = Time.now.to_i expiration = now + 3600 payload = { iss: client_email, scope: 'https://www.googleapis.com/auth/cloud-platform', aud: 'https://oauth2.googleapis.com/token', exp: expiration, iat: now } jwt = JWT.encode(payload, private_key, 'RS256') uri = URI.parse('https://oauth2.googleapis.com/token') https = Net::HTTP.new(uri.host, uri.port) https.use_ssl = true request = Net::HTTP::Post.new(uri.path) request.set_form_data({ grant_type: 'urn:ietf:params:oauth:grant-type:jwt-bearer', assertion: jwt }) response = https.request(request) JSON.parse(response.body)['access_token'] end def make_request(uri, contents, generation_config, access_token) request = Net::HTTP::Post.new(uri) request.content_type = 'application/json' request['Authorization'] = "Bearer #{access_token}" request.body = { contents: contents, generation_config: generation_config }.to_json request end def encode_image(image_path) image = File.binread(image_path) Base64.strict_encode64(image) end contents = [ { role: :user, parts: [ { inlineData: { mimeType: "image/jpeg", data: encode_image("cat.jpg") } }, { text: "この写真について詳しく説明してください" } ] }, ] generation_config = { maxOutputTokens: 2048, temperature: 0.9, topP: 1 } uri = URI(API_URI) request = make_request(uri, contents, generation_config, get_access_token(KEY_FILE_PATH)) Net::HTTP.start(uri.hostname, uri.port, use_ssl: uri.scheme == 'https') do |http| http.request(request) do |response| response.read_body do |chunk| JSON.parse(chunk).each do |c| print c.dig("candidates", 0, "content", "parts", 0, "text") end end end end
これを実行すると下記のような結果になります。
これは白と茶色の毛並みの長毛猫のポートレートです。猫は黒い背景の前に座っています。猫は緑の目で、カメラの方を見ています。
レスポンスの返り方は Chat Sample の場合と同様で、最初の chunk にまとまって入ってきてしまう点は解消できませんでした。
ちなみに動画ファイルを使う場合には mimeType の指定を video/mp4
に変更するだけです。
まとめ
今回は Ruby 用の SDK が公開されていないということで REST API を使用してみましたが、認証処理のシンプルさやストリーミングの挙動など、やはりSDKが利用できた方が便利です。 OpenAI などもそうですが、これからガッツリ機能を使い込むプロダクトを作るつもりであれば、 Python ベースのアーキテクチャにするというのも十分に考えられるかと思います。
ユニファではAPIを色んな方法で触ってみたくなっちゃうような仲間も募集中です!少しでも興味をお持ちいただけた方は、ぜひ一度カジュアルにお話しましょう!