Tapirなどの実験がてら、OpenAIにアクセスする実験をしていた。とりあえず実験に成功したので解説する。
実行すると、とりあえず挨拶してもらうようにしている。
$ OPENAI_APIKEY="..." OPENAI_ORG="..." sbt run こんにちは!ChatGPT APIへようこそ!私たちは、あなたがAPIへのアクセスに成功したことをとても嬉しく思っています。私たちは、あなたが私たちのAPIを使って素晴らしいアプリケーションやサービスを作成することを楽しみにしています。私たちは、あなたがAPIを使用する際に、手助けが必要な場合 はいつでもお手伝いしますので、お気軽にお問い合わせください。ありがとうございました。
OpenAI
今更説明不要だろうが、OpenAIはChatGPTとかを開発している団体。APIを使うことでChatGPTの中身であるText Completion APIなどを触れるようになる。
Tapir
OpenAIのAPIはOpenAPI(Swagger)でも提供されており(まぎらわしい!)、HTTPSアクセスができる環境であればすぐにアクセス可能だ。今回はそこに加えてエンドポイント定義ライブラリであるTapirを使うことにした。
皆様はこんな経験がないだろうか。
- エンドポイントを呼び出すコードとHTTPライブラリとのコードが複雑に絡みあっていて、メンテナンビリティが下がっている。
- HTTPライブラリを交換できない(ほぼ書きなおしになる)
Tapirは「HTTPエンドポイントにアクセスする・HTTPエンドポイントを提供する」という機能を分割し、「HTTPエンドポイントを定義する」「既存のHTTPサーバ/クライアントライブラリ向けに定義を書き出す」に特化した機能を提供する。要するに、型が付いたOpenAPIといった感じで、嬉しいことにOpenAPIのyamlを書き出す機能が装備されている(実験的機能として、既存のOpenAPIのyamlを読み込んでコード生成する機能もあるが、今回はうまく動作しなかったので採用しなかった)。
例えば、今回アクセスするのに使った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認証などがあることを宣言しており、実際に使うcompletion
はBasicEndpoint
を発展させる形式で記述している。Scalaはイミュータブルなので安全に既存のリソースを再利用できる。
.in(...)
としているのは入力にかかわる項目を色々指定できるメソッド。例えばパスだとか、JSONでCompletionParams
をbodyとして受け取るぞ、といったことを宣言している。
他方、.out(...)
としているのはエンドポイントが出力する内容を宣言するためのもので、JSONでCompletionResult
が返されるよ、といったことを宣言している。エラーが起こったらStringで返してね、といったことも書いてある。
残りはcase classの定義に費しているが、これはJSONライブラリであるCirceが勝手に自動変換用のコードを生成してくれるので、特になにもせずにJSONでやりとりできるようになる。
内部では、エンドポイントは全てのEndpoint[A, I, E, O, R]
という型で管理されていて、必要に応じて認証情報や起こりうるエラー情報をオミットしたりして使う。
Http4s
実際のHTTPリクエストは、Http4sが行う。
このライブラリはサーバもクライアントもこなせる器用なやつで、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になるので気付かなかった!!!
- curlは
- OpenAPIからコードを生成する機能は実験段階なのであまりアテにならない。逆向きは安定しているっぽい。
- 当然ながらCats Effectまわりの基本的な造詣が必要になる。
- 今回は認証情報を含むAPIだったのでhttp4sへの変換に
toSecureRequest
を使ったけど、使い分けが必要。- ちょっとした型パズルの様相を呈していた
割とこれらの気付いた箇所以外は非常に素直で、かなり筋が良さそうな雰囲気があった。次回はクライアントとしてではなく、サーバサイドのエンドポイントを生成するというユースケースで使ってみたい。