Lambdaカクテル

京都在住Webエンジニアの日記です

Invite link for Scalaわいわいランド

OpenAI(ChatGPT)をScala3から呼び出せた

Tapirなどの実験がてら、OpenAIにアクセスする実験をしていた。とりあえず実験に成功したので解説する。

github.com

実行すると、とりあえず挨拶してもらうようにしている。

$ OPENAI_APIKEY="..." OPENAI_ORG="..." sbt run
こんにちは!ChatGPT APIへようこそ!私たちは、あなたがAPIへのアクセスに成功したことをとても嬉しく思っています。私たちは、あなたが私たちのAPIを使って素晴らしいアプリケーションやサービスを作成することを楽しみにしています。私たちは、あなたがAPIを使用する際に、手助けが必要な場合
はいつでもお手伝いしますので、お気軽にお問い合わせください。ありがとうございました。

OpenAI

今更説明不要だろうが、OpenAIはChatGPTとかを開発している団体。APIを使うことでChatGPTの中身であるText Completion APIなどを触れるようになる。

platform.openai.com

Tapir

OpenAIのAPIはOpenAPI(Swagger)でも提供されており(まぎらわしい!)、HTTPSアクセスができる環境であればすぐにアクセス可能だ。今回はそこに加えてエンドポイント定義ライブラリであるTapirを使うことにした。

皆様はこんな経験がないだろうか。

  • エンドポイントを呼び出すコードとHTTPライブラリとのコードが複雑に絡みあっていて、メンテナンビリティが下がっている。
  • HTTPライブラリを交換できない(ほぼ書きなおしになる)

Tapirは「HTTPエンドポイントにアクセスする・HTTPエンドポイントを提供する」という機能を分割し、「HTTPエンドポイントを定義する」「既存のHTTPサーバ/クライアントライブラリ向けに定義を書き出す」に特化した機能を提供する。要するに、型が付いたOpenAPIといった感じで、嬉しいことにOpenAPIのyamlを書き出す機能が装備されている(実験的機能として、既存のOpenAPIのyamlを読み込んでコード生成する機能もあるが、今回はうまく動作しなかったので採用しなかった)。

tapir.softwaremill.com

例えば、今回アクセスするのに使ったOpenAPIのエンドポイントは、次のように「定義」されている。

package com.github.windymelt.openapiscalaexperiment

import sttp.tapir._
import sttp.tapir.generic.auto._
import sttp.tapir.json.circe._
import sttp.model.Header
import sttp.model.MediaType
import io.circe.generic.auto._

object OpenAI {
  lazy val BasicEndpoint = endpoint
    .securityIn(auth.bearer[String]())
    .securityIn(header[String]("OpenAI-Organization"))

  lazy val completion =
    BasicEndpoint.post // Watch! you may forget to write `post` here
      .in("v1" / "chat" / "completions")
      .in(jsonBody[CompletionParams])
      .out(jsonBody[CompletionResult])
      .errorOut(stringBody)

  case class CompletionParams(
      model: String,
      messages: Seq[CompletionMessage],
      temperature: Double
  )

  case class CompletionMessage(role: String, content: String)

  case class CompletionResult(
      id: String,
      `object`: String,
      created: Long,
      model: String,
      usage: CompletionResultUsage,
      choices: Seq[CompletionResultChoice]
  )
  case class CompletionResultUsage(
      prompt_tokens: Int,
      completion_tokens: Int,
      total_tokens: Int
  )
  case class CompletionResultChoice(
      message: CompletionResultChoiceMessage,
      finish_reason: String,
      index: Int
  )
  case class CompletionResultChoiceMessage(role: String, content: String)
}

共通定義としてBasicEndpointを定義し、そこにbearer認証などがあることを宣言しており、実際に使うcompletionBasicEndpointを発展させる形式で記述している。Scalaはイミュータブルなので安全に既存のリソースを再利用できる。

.in(...)としているのは入力にかかわる項目を色々指定できるメソッド。例えばパスだとか、JSONでCompletionParamsをbodyとして受け取るぞ、といったことを宣言している。

他方、.out(...)としているのはエンドポイントが出力する内容を宣言するためのもので、JSONでCompletionResultが返されるよ、といったことを宣言している。エラーが起こったらStringで返してね、といったことも書いてある。

残りはcase classの定義に費しているが、これはJSONライブラリであるCirceが勝手に自動変換用のコードを生成してくれるので、特になにもせずにJSONでやりとりできるようになる。

内部では、エンドポイントは全てのEndpoint[A, I, E, O, R]という型で管理されていて、必要に応じて認証情報や起こりうるエラー情報をオミットしたりして使う。

tapir.softwaremill.com

Http4s

実際のHTTPリクエストは、Http4sが行う。

http4s.org

このライブラリはサーバもクライアントもこなせる器用なやつで、Cats Effectといった非同期基盤に最初から対応する前提で設計されているので、他のライブラリとの食い合わせが非常に良い。要するに、IOモナドをがちゃがちゃすると勝手にランタイムがうまいことやってくれるようなやつ。

Http4sにTapirのエンドポイントを食わせて、クライアントとして使うには以下のようなコードを書いた。

package com.github.windymelt.openapiscalaexperiment

import cats.data.Kleisli
import cats.effect.IO
import cats.effect.IOApp.Simple
import cats.implicits._
import org.http4s.Uri
import org.http4s.client.Client
import org.http4s.ember.client.EmberClientBuilder
import sttp.tapir.DecodeResult

object Main extends Simple {
  def run: IO[Unit] = {
    // Tapirのエンドポイント定義をHttp4sのリクエストとレスポンス変換関数として出力してくれる君
    import sttp.tapir.client.http4s.Http4sClientInterpreter
    val (req, resParser) =
      Http4sClientInterpreter[IO]()
        .toSecureRequest(
          OpenAI.completion, // ここで定義済みのエンドポイントを渡す
          baseUri = Some(Uri.fromString("https://api.openai.com/").toOption.get) // baseとなるURLを指示する
        )
        .apply(
          sys.env.get("OPENAI_APIKEY").getOrElse(""), // セキュアな要素はここでまとめて渡す(自動的にログから隠蔽される)
          sys.env.get("OPENAI_ORG").getOrElse("")
        )
        .apply(
          OpenAI.CompletionParams( // 最後に通常の変数要素を渡す。単にcase classをそのまま渡すだけでよい
            "gpt-3.5-turbo",
            Seq(
              OpenAI.CompletionMessage(
                "user",
                "あなたはChatGPTのAPIです。あなたにAPIを介して初めてアクセスに成功したユーザに対して、数センテンスで歓迎の挨拶を述べてください。"
              )
            ),
            0.7
          )
        )
    // http4sに通信ログを取れる仕組みがあるので、DEBUG=1のときに有効化する
    val httpClientResource = EmberClientBuilder.default[IO].build
    import org.http4s.client.middleware.Logger
    val liftClientToLog: Client[IO] => Client[IO] =
      Logger(logBody = true, logHeaders = true)(_)
    val loggingClientResource = sys.env.get("DEBUG").getOrElse("") match {
      case "1" => httpClientResource.map(liftClientToLog)
      case _   => httpClientResource
    }

    // リクエストの送信とレスポンスの受信・変換を行うコード
    val requesting: Client[IO] => IO[
      DecodeResult[Either[String, OpenAI.CompletionResult]]
    ] = _.run(req).use(resParser)

    // 変換結果をうけて結果を表示するコード
    // case classへの変換失敗も考慮されているので少しコードが多い
    val show
        : DecodeResult[Either[String, OpenAI.CompletionResult]] => IO[Unit] = {
      import sttp.tapir.DecodeResult.Value
      import sttp.tapir.DecodeResult.Missing
      import sttp.tapir.DecodeResult.Multiple
      import sttp.tapir.DecodeResult.Mismatch
      import sttp.tapir.DecodeResult.InvalidValue
      _ match {
        case Value(Right(v)) => IO.println(v.choices.head.message.content) // 正常ルートではここを通る
        case Value(Left(_))  => IO.println("access failure")
        case Missing         => IO.println(s"missing")
        case Multiple(vs)    => IO.println(s"mul $vs")
        case sttp.tapir.DecodeResult.Error(original, error) =>
          IO.println(s"orig: ${original}, err: ${error.getMessage()}")
        case Mismatch(expected, actual) =>
          IO.println(s"missmatch: exp: $expected, act: $actual")
        case InvalidValue(errors) => IO.println(s"invalid: $errors")
      }
    }
    // use client resource
    loggingClientResource.use(
      (Kleisli(requesting) >>> Kleisli(show)).run // なんか合成するのに必要っぽかったからKleisli呼び出したけど不要かも
    )
  }
}

JSONとの交換などのやりとりは完全に隠蔽されており、Tapirがくれる情報をhttp4sに渡すだけでリクエストを飛ばせるようになっているのがわかる。

Tapir困りどころ

  • これは完全に自分が悪いけど、.postと明示的に書かないとPOSTリクエストにならない。ぜんぜんAPIが動かずに詰まってしまった。
    • curlは-dを使うと勝手にPOSTになるので気付かなかった!!!
  • OpenAPIからコードを生成する機能は実験段階なのであまりアテにならない。逆向きは安定しているっぽい。
  • 当然ながらCats Effectまわりの基本的な造詣が必要になる。
  • 今回は認証情報を含むAPIだったのでhttp4sへの変換にtoSecureRequestを使ったけど、使い分けが必要。
    • ちょっとした型パズルの様相を呈していた

割とこれらの気付いた箇所以外は非常に素直で、かなり筋が良さそうな雰囲気があった。次回はクライアントとしてではなく、サーバサイドのエンドポイントを生成するというユースケースで使ってみたい。

参考文献

dev.classmethod.jp

stackoverflow.com

dev.classmethod.jp

★記事をRTしてもらえると喜びます
Webアプリケーション開発関連の記事を投稿しています.読者になってみませんか?