はじめに
OpenAI ChatGPTで有料APIを呼び出すには、APIキーを使う必要があります。
しかし、ブラウザからAPIを直接呼び出すとAPIキーがデバッガで見えてしまうため、APIキーを勝手に使われてしまう恐れがあります。それを防ぐには、サーバでAPIを中継して呼び出す必要があります。
普通のAPI呼び出しを中継するだけなら簡単ですが、ChatGPTはServer-Sent Events(SSE)という仕組みを使い、単語など細かな単位で何度かに別れて結果が返ってくるため、特殊な処理が必要です。
Servletを使ってSSEを中継しようとしたのですが、情報が乏しく実装に苦労したため、実装方法をまとめてみました。
ServletでSSEを中継する方法
SSEを中継する方法を探してみると、下記のようにTypeScriptなどの言語の記事は見つかりますが、Java/Servletで実装した例が見つかりません。
OpenAIのChat APIの返答をストリーミングする(Node.js)
ServletでChatGPTのSSEをイベント毎に受信する部分と、ServletからSSEのイベントをクライアントに送信する部分をそれぞれ作って繋げればよいのですが、そもそもそれぞれの部分の実装例もなかなか見つかりません。
試行錯誤でだいぶ苦労しましたが、以下で動作することが確認できました。
ServletでChatGPTのSSEをイベント毎に受信する方法
OkHttpClientとRealEventSourceを使うことで、onEventのイベントリスナーでイベントを受信できます。
OkHttpClient azureClient = new OkHttpClient();
MediaType mimeType= MediaType.parse("application/json");
String bodyText = buffer.toString();
RequestBody requestBody = RequestBody.create (bodyText, mimeType);
// serlvetからChatGPTを呼び出すリクエスト
Request azureRequest = new Request.Builder()
.url(mURL_AzURE)
.header("Accept", "application/json")
.header("Content-Type", "application/json")
.header("Api-Key", mAPI_KEY_AZURE)
.post(requestBody)
.build();
realEventSource = new RealEventSource(azureRequest, new EventSourceListener() {
@Override
public void onEvent(EventSource eventSource, String id, String type, String data) {
// ChatGPTからEventを受信するとonEventが呼ばれる
}
});
realEventSource.connect(azureClient);
ServletからSSEのイベントをクライアントに送信する方法
// SSE用にcontentTypeを設定
response.setContentType("text/event-stream");
response.setCharacterEncoding("UTF-8");
PrintWriter writer = response.getWriter();
// クライアントにEventとして返す
writer.println("data: " + data1 + "\n\n");
writer.flush();
writer.println("data: " + data2 + "\n\n");
writer.flush();
....
改行コードを2つ連続で送信しないといけないことに気が付かず、ハマりました。こちらの仕様をみると、
event = *( comment / field ) end-of-line
comment = colon any-char end-of-line
field = 1name-char [ colon [ space ] *any-char ] end-of-line
end-of-line = ( cr lf / cr / lf )
と確かに2連続と定義されている。
全体ソース
ServletでChatGPTのSSEをイベント毎に受信する部分と、ServletからSSEのイベントをクライアントに送信する部分をそれぞれ作って繋げた全体ソースは以下です。
import java.util.concurrent.ConcurrentHashMap;
import javax.servlet.ServletException;
import javax.servlet.annotation.WebServlet;
import javax.servlet.http.HttpServlet;
import javax.servlet.http.HttpServletRequest;
import javax.servlet.http.HttpServletResponse;
import javax.servlet.http.HttpSession;
import okhttp3.Interceptor;
import okhttp3.MediaType;
import okhttp3.OkHttpClient;
import okhttp3.Request;
import okhttp3.RequestBody;
import okhttp3.Response;
import okhttp3.internal.sse.RealEventSource;
import okhttp3.sse.EventSource;
import okhttp3.sse.EventSourceListener;
public class ChatGPTServlet extends BaseServlet {
private String mURL_AZURE = "xxxxxxxxx";
private String mAPI_KEY_AZURE = "yyyyyyyyy";
protected void doPost(HttpServletRequest request, HttpServletResponse response) throws ServletException, IOException {
try {
request.setCharacterEncoding("utf-8");
// クライアントからのリクエストを読み込む
StringBuffer buffer = new StringBuffer();
String line = null;
BufferedReader reader = request.getReader();
while ((line = reader.readLine()) != null) {
buffer.append(line);
}
// SSE用にcontentTypeを設定
response.setContentType("text/event-stream");
response.setCharacterEncoding("UTF-8");
PrintWriter writer = response.getWriter();
RealEventSource realEventSource = null;
try {
OkHttpClient azureClient = new OkHttpClient();
MediaType mimeType= MediaType.parse("application/json");
String bodyText = buffer.toString();
RequestBody requestBody = RequestBody.create (bodyText, mimeType);
// serlvetからChatGPTを呼び出すリクエスト。
// 何故かContent-Typeがうまく設定できないことがたまにあるので注意。
Request azureRequest = new Request.Builder()
.url(mURL_AzURE)
.header("Accept", "application/json")
.header("Content-Type", "application/json")
.header("Api-Key", mAPI_KEY_AZURE)
.post(requestBody)
.build();
realEventSource = new RealEventSource(azureRequest, new EventSourceListener() {
@Override
public void onEvent(EventSource eventSource, String id, String type, String data) {
// ChatGPTからEventを受信するとonEventが呼ばれる
try {
// 受信したEventをクライアントにEventとして返す
writer.println("data: " + data + "\n\n");
writer.flush();
if (data.equals("[DONE]")) {
// ChatGPTでは [DONE] が来たら終了
}
} catch (Exception e) {
// エラー処理
}
}
@Override
public void onFailure(EventSource eventSource, Throwable throwable, Response response) {
// エラー処理
}
});
realEventSource.connect(azureClient);
} catch (Exception e) {
writer.println("data: error\n\n");
writer.flush();
} finally {
realEventSource = null; // 念の為
}
} catch (ServletException e) {
throw e;
} catch (Exception e) {
throw e;
}
}
}
補足1:APIキー
なお、OpenAI ChatGPTではAPIキーしか使えないようですが、Azure ChatGTP APIではMicrosoft Entra ID認証も使えます。tokenベースなのでAPIキーよりは安全に使えるようです。
最初はOpenAI ChatGPT APIを使い、APIキーを使って中継する方法を苦労して実装し、後にAzure ChatGPT APIに切り替えました。最初からAzureでMicrosoft Entra ID認証を使っていれば、中継しなくても良かったかもしれません。
補足2:Azureのイベント間隔問題
イベント間隔の問題
OpenAI ChatGPT APIのSSEは、文字単位ぐらいの細かな粒度でイベントが逐次返されます。イベントを受信するたびに表示することで、結果が少しずつ滑らかに返ってくるようにユーザーに見えます。
しかし、Azure ChatGPT APIのSSEは、イベント自体の粒度はOpenAIと同様なのですが、イベントが逐次送信されず、複数のイベントがまとめて返されてくるようです。それをそのまま表示すると、文やパラグラフといった大きな単位でしか表示が更新されないようにユーザーには見えてしまい、ユーザーに待ちストレスが発生しがちです。
回避策
Azureで複数のイベントがまとめて返ってくるのは、どうやらレスポンスに不適切な内容が含まれていないかをチェックしているためのようです。APIの呼び出し方で回避することができないのかを調べてみましたが、これはAzureのポリシーのようで根本的な回避策は無いようです。
別の回避策として、クライアント側で複数のイベントを一度に表示せず、イベント間で少しウェイトを入れて表示を更新することで、結果が少しずつ滑らかに返ってくるようにユーザーに見せる方法があります。
この回避策はユーザーに情報をあえて遅らせて提示しているので、本当はおかしな方法のように思えますが、結果的にはユーザーの待ちストレスが軽減されるようです。