ユニファ開発者ブログ

ユニファ株式会社プロダクトデベロップメント本部メンバーによるブログです。

Google Gemini の REST API を Ruby から使ってみる

みなさんこんにちは、ユニファの赤沼です。 この記事は Unifa Advent Calendar 2023 の20日目の記事です。

adventar.org

先日 Google Gemini の API が公開されましたが、Ruby の SDK は公開されていないので Python のクライアントライブラリから触ってみた記事を Advent Calendar 17日目の記事で書きました。

tech.unifa-e.com

ユニファではサーバサイドの大半は Ruby で書いているということもあり、実際に本番でこういう使い方をするかはさておき、 Ruby で REST API を呼ぶ形で使ってみたのでその内容を書いてみます。

REST API のドキュメントは下記にありますので、詳細については下記をご参照ください。

cloud.google.com

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を色んな方法で触ってみたくなっちゃうような仲間も募集中です!少しでも興味をお持ちいただけた方は、ぜひ一度カジュアルにお話しましょう!

unifa-e.com