Slack Bot に GPT-4V API を搭載する
NOT A HOTEL の Slack では GPT-4 の API を使った Slack Bot が動いています。これは以前、アイディアソンの司会進行をしながら、その裏で CTO とハッカソンを行い開発したものです。[1]
この時はまだ Firebase を使ってボットを運用していたのですが、ご存知の通り Cloudflare Workers が盛り上がってきている(早い、安い、開発体験 🚀)のでそこに移しました。
そして先日、ついに OpenAI から GPT-4V の機能を利用できる Vision API がリリースされました。この機能のおかげで GPT-4 は画像を理解することができるようになり、さらに活用の幅が広がりそうです。
その練習を兼ねて、Slack Bot に Vision API の機能を追加してみました。この記事では Vision API と Slack との連携をどうやったのか、一つの事例を紹介できればと思います!
Vision API の使い方
まず、この API へのリクエストはどのように行うのかを復習しましょう。画像の情報を GPT-4 へどう教えるのか curl の例をここに貼ります。
curl https://api.openai.com/v1/chat/completions \
-H "Content-Type: application/json" \
-H "Authorization: Bearer $OPENAI_API_KEY" \
-d '{
"model": "gpt-4-vision-preview",
"messages": [
{
"role": "user",
"content": [
{
"type": "text",
"text": "What’s in this image?"
},
{
"type": "image_url",
"image_url": {
"url": "https://upload.wikimedia.org/wikipedia/commons/thumb/d/dd/Gfp-wisconsin-madison-the-nature-boardwalk.jpg/2560px-Gfp-wisconsin-madison-the-nature-boardwalk.jpg"
}
}
]
}
],
"max_tokens": 300
}'
今までは role
と content
の情報を与えるだけでしたが、この API では content
が配列となり、構成される要素としてテキスト以外に image_url
というものが出てきました。
image_url
に許容できるものは 2 つです。
- 画像の URL
-
data:image/jpeg;base64,{base64_image}
といった base64 形式
渡せる画像の形式は PNG (.png), JPEG (.jpeg and .jpg), WEBP (.webp), そしてアニメーションのない GIF (.gif) です。
Slack から画像を取得する
NOT A HOTEL の Slack Bot は一つのスレッドが ChatGPT でいう一つのチャット部屋に相当します。つまり conversations.replies API を使用して、スレッドのメッセージ一覧を取得しているわけです。
構成されるメッセージオブジェクトの中には files
といったメッセージと共に投稿されたファイル情報も添付されます。沢山の情報が手に入りますが、今回使った項目は以下の通りになります。
size
mimetype
url_private_download
filetype
original_h
original_w
thumb_*
特に url_private_download
は必須でしょう。この URL がファイルをダウンロードできる URL になっています。[2]
しかし普通にアクセスすると <a href="リダイレクトリンク">...</a>
のような HTML のみが表示され、Slack アプリへリダイレクトされてしまいます。これが発生する理由は権限がないからです。
これを回避して、直接ダウンロードするには API リクエストに使う User OAuth Token もしくは Bot User OAuth Token を Authorization
ヘッダにセットする必要があります。また、そのトークンには files:read
スコープを割り当てる必要があります。
curl -H 'Authorization: Bearer xoxb-...' https://files.slack.com/.../hoge.png
Slack メッセージから GPT-4 メッセージへ変換
メッセージ変換の処理は今までと変わらず同様にできます。しかし、スレッド一覧の中から画像が添付されたメッセージを見つけた場合のみ頑張る必要があります。
- Slack に投稿された画像 URL を取得できるが、Vision API はこの URL へアクセスできない
- 画像サイズは 20MB が最大
- low res モードでは、512px × 512px の画像を想定。high res モードでは、画像の短辺は768px未満、長辺は2,000px未満にする必要がある[3]
2 は size
を使ってフィルタリングしてあげると良いでしょう。3 も難しいことはなく TypeScript であればこのようなコードを書いて確認できそうです。(high res 前提)
function checkImageSize(file: File): boolean {
const shortSide = Math.min(file.original_h, file.original_w);
const longSide = Math.max(file.original_h, file.original_w);
// 短辺が 768px 未満で、長辺が 2000px 未満かどうかを返す
return shortSide < 768 && longSide < 2000;
}
残った 1 について考えましょう。私が思いついたアイディアです(他のアイディアも募集しています):
- 変換のたびに画像をダウンロードする
- R2 へアップロードして次回もその URL を指定する
- base64 エンコードを毎回行う
- リバースプロキシの URL を渡す
今回は後者を選択しました。メリットとして自分たちのストレージを消費することなく、レイテンシも損なわない方法だからです。[4]
さてそうなると今度は専用のリバースプロキシをどう作るかを考えます。そこで hono の mount
を利用して作成しました。mount
を利用すると req.url
の内容は指定されているパスの部分を削除してくれます。
const allowedCidr = "40.84.182.32/28"
const allowedUA = "OpenAI Image Downloader"
hono.mount("/proxy/slack_files", async (req) => {
if (req.method !== "GET") {
return new Response(null, { status: 405 });
}
// 1. リクエストを許可するかどうかを判定する
const isAllowed = (() => {
const cfConnectingIp = req.headers.get("cf-connecting-ip");
const userAgent = req.headers.get("user-agent") ?? "";
const isIpAllowed =
cfConnectingIp !== null &&
isIpInCidr(cfConnectingIp, cidr);
return isIpAllowed && userAgent === allowedUA;
})();
if (!isAllowed) {
return new Response(null, { status: 403 });
}
// 2. リバースプロキシの対象となる URL を組み立てる
// req.url === "http://localhost:8787/files.slack.com/..."
// pathname === "/files.slack.com/..."
const urlWithoutProto = new URL(req.url).pathname.slice(1);
// e.g. https://files.slack.com/...
const url = `https://${urlWithoutProto}`;
// 3. リバースプロキシ
return await fetch(url, {
headers: {
Authorization: `Bearer ${env.SLACK_BOT_USER_TOKEN}`,
},
});
});
コードのコメントに記述した通りになりますが、こんな感じの URL へアクセスすると Slack のファイルへアクセスできるようになります。
http://localhost:8787/proxy/slack_files/files.slack.com/...
色々試してみてわかったこととして、Vision API が利用するクライアントは以下の特徴を持ってそうです。
- "OpenAI Image Downloader" という User-Agent のクライアントからリクエストする。
- リクエスト元となる IP アドレスは
40.84.182.32/28
[5] の範囲に収まってそう。 -
更新 2024-09-04:
13.65.138.96/27
,40.84.181.32/28
も追加されたそうです。
CIDR に関しては間違えていても、社内向けツールということもあって、一旦これでよしとすることにしました。とにかく Slack のファイルへのアクセスできる範囲を絞りたかったため User-Agent と IP アドレスを確認することで、Vision API クライアントからのリクエスト以外は許可しないようにしました。
知りたい人向け isIpInCidr 関数の実装
ipaddr.js
を使って実装しています。
import * as ipaddr from "ipaddr.js";
export function isIpInCidr(ip: string, cidr: string): boolean {
try {
const parsedIp = ipaddr.process(ip);
const parsedRange = ipaddr.parseCIDR(cidr);
return parsedIp.match(parsedRange);
} catch (err) {
return false;
}
}
まだまだ考えることは多い
Vision API はとても素晴らしいものですが、現状ではプレビュー扱いということもあり、Rate Limit が厳しく設定されています。20 RPM (Request per minute), 100 RPD (Request per day) ということもあり簡単に制限に引っかかってしまいます。
ベストエフォートですが、これらの条件を Durable Objects を用いて観測し、制限にかかりそうであれば GPT-4 へフォールバックするといった処理を入れるようにもしていますが、これはまたどこかでお話ししたいと思います。
-
https://files.slack.com/...
のような URL ↩︎ -
https://platform.openai.com/docs/guides/vision/managing-images ↩︎
-
For long running conversations, we suggest passing images via URL's instead of base64. とあるように URL を推奨しているとのことです。 ↩︎
-
公式ドキュメントに記載はないので確証はないです。ただ、issue として記載して欲しいとあげました。https://github.com/openai/openai-cookbook/issues/832 ↩︎
Discussion