Firebase AI Logicのサーバーサイドフック — generateContentStreamは素通りする

はじめに

Firebase AI Logic の売りは、バックエンドを持たずにクライアント SDK から直接 Gemini を叩けることだ。その代償として、プロンプトの整形・モデレーション・トークン上限・レスポンスの伏字化といった「制御」は全部クライアント側のコードに書くしかなかった。ストアに出した後で方針を変えたければ、アップデートを配って全ユーザーが上げ終わるのを待つことになる。

ここに beforeGenerateContent と afterGenerateContent という2つの Cloud Functions トリガーが Preview で入った。Gemini API リクエストの前後に自分のコードを挟める。ただし発火するのは generateContent だけで、generateContentStream と Live API は素通りする。この記事では入ったものの中身と、この「素通り」が設計に何を要求するかを見ていく。

入ったのは Cloud Functions の2トリガー

実体は Cloud Functions for Firebase(第2世代)のイベントハンドラで、firebase-functions の 6.3.0 以降が要る。セットアップは firebase init functions から TypeScript を選ぶ流れになっている。

前段のハンドラはこういう形をとる。

beforeGenerateContent((event) => {
  // event.data.api     : geminiV1Beta か vertexV1Beta1
  // event.data.model   : モデルのフルリソースパス
  // event.data.request : リクエスト本体(形は api の値で変わる)
  // 編集したリクエスト全体を返す。何も返さなければそのまま通る
});

後段の afterGenerateContent は同じイベントに event.data.response が生えたものを受け取り、レスポンスを書き換えるか、そのまま通すか、ログだけ取って素通しするかを選べる。

挙動の要点は3つある。

返り値の意味: ドキュメントの表現は「編集したリクエスト全体を返す。何も返さなければ手つかずのまま」。部分的な差分を返す API ではなく、丸ごと返す設計だ。undefined を返すのが「変更なし」を意味する。

ブロックは例外で行う: HttpsError を投げるとリクエストは拒否され、「リクエストが Gemini API に送られることは一切ない」。クライアントはレスポンスの代わりにその例外を受け取る。モデレーションで弾く場合の出口はここになる。

誰が呼んだかは分かる: イベントには authType(app_user / unauthenticated / unknown)、authId(Firebase Auth の UID)、authClaims、appId、androidPackageName / iosBundleId が乗る。ユーザー単位・アプリ単位で条件を変える判断材料は揃っている。

制約として、1プロジェクトにつき beforeGenerateContent と afterGenerateContent は各1つまで。関数を増やして責務を分ける形にはできないので、モデレーションもロギングも上限チェックも1つのハンドラの中で分岐させることになる。デプロイ先は既定で us-central1 だが、トリガーとしての登録は配置場所に関係なく global リージョンに対して行われる。削除するときは Firebase CLI の functions:delete を使う必要があり、Google Cloud コンソールから消すとトリガー登録が外れずに残る。

なぜクライアントに書くしかなかったのか

Firebase AI Logic のアーキテクチャは、クライアント SDK が Firebase のプロキシ経由で Gemini を呼ぶというものだ。自前のサーバーが経路上にいないので、これまでの防御は「入口を絞る」層に集中していた。

  • App Check: 本物のアプリ・本物の端末からのリクエストかを検証する。2026年11月2日からは Firebase AI Logic を使うのに App Check の enforcement が必須になる。
  • 認証ユーザー限定モード: プロジェクト全体の設定で、未認証リクエストを 401 で弾く。
  • ユーザーあたりのレート制限: 既定で毎分100リクエスト。設定で変更できる。
  • サーバープロンプトテンプレート: システム指示・モデル設定・ツール定義をサーバー側に置き、クライアントのバイナリから読み取れなくする。テンプレート必須モードを有効にすると、テンプレートを使わないリクエストは 403 で拒否される(countTokens と管理系リクエストは対象外)。

これらはいずれも「誰が・どれだけ・どのテンプレートで呼べるか」の制御であって、個々のリクエストの中身を見て判断する層ではなかった。今回のトリガーが埋めたのはそこだ。プロンプトの本文を検査して弾く、レスポンスの中の個人情報をマスクする、生成内容を監査ログに落とす——アプリを再配布せずに、この種のポリシーを後から差し替えられるようになった。先日入った spend caps が「金額の蛇口」を後から締められるようにしたのと同じ方向の変化で、出荷後に効く制御の層が両側から埋まってきている。

落とし穴: ストリーミングと Live API は発火しない

ここが実務上いちばん重い。ドキュメントは2文で明言している。

generateContentStream へのリクエストはこれらの関数をトリガーしません。

Gemini Live API へのリクエストはこれらの関数をトリガーしません。

問題は、チャット UI を作るなら普通は generateContentStream を選ぶという点だ。Flutter の firebase_ai でも書き分けは以下のようになる。

// トリガーが発火する
final response = await model.generateContent(prompt);

// トリガーは発火しない
final stream = model.generateContentStream(prompt);
await for (final chunk in stream) {
  print(chunk.text);
}

つまり、beforeGenerateContent にプロンプトモデレーションを書いて「サーバー側で制御できるようになった」と考えたまま、画面側がトークンを逐次表示する体験を追求して generateContentStream に切り替えると、モデレーションは黙って無効になる。エラーも警告も出ない。片方のコードパスだけが素通りするという意味で、これは典型的な「沈黙する失敗」の形をしている。

対処は現実的には3通りに分かれる。

  1. 検査が必須の経路だけ非ストリーミングに寄せる。ユーザー生成プロンプトを外部に出す機能、未成年向けの機能など、ポリシー違反が事故になる経路は体感速度を捨てて generateContent に固定する。
  2. ストリーミング経路は別の層で守る。テンプレート必須モードで自由入力そのものを封じるか、ストリーミングだけ自前の Cloud Functions / Cloud Run をプロキシとして挟む。後者は AI Logic を使う旨みを半分手放すことになるが、一貫した検査層は手に入る。
  3. トリガーは監査・ロギング用途に限定し、ブロックの責務を持たせない。「Preview であり SLA も非互換変更の予告対象外」という位置づけとも整合する使い方だ。

どれを選ぶにせよ、「ストリーミングは検査されない」という事実をアーキテクチャ図の上に明示的に書いておかないと、半年後に機能追加した人が気づかないまま穴を開ける。

エンジニアへの影響

まず前提条件として、Blaze(従量課金)プランと Firebase CLI / gcloud CLI が要る。デフォルトのコンピュートサービスアカウントに Cloud Build Service Account ロール(roles/cloudbuild.builds.builder)を付ける手順も入っており、既存の Functions プロジェクトでも IAM の追加作業が発生しうる。

レイテンシについては、ドキュメント自身が前段・後段の両方で「関数の内容によっては遅延を追加しユーザー体験に影響しうる」と繰り返し注意している。実際にはここに Cloud Functions のコールドスタートが乗る。生成に数秒かかる処理の前後に数百ミリ秒が足されること自体は許容範囲でも、ブロック判定のために外部 API を叩くような実装をすると、AI 呼び出し1回あたりのレイテンシが素直に増える。

コストも二重になる。Gemini の課金に加えて、全リクエスト分の Cloud Functions 実行が乗る。ドキュメントが「Cloud Run functions に予算アラートと spend caps を設定せよ」と明記しているのは、トラフィックが増えたときに増える先が2つになるからだ。

個人開発の実感で言えば、最初に置く価値があるのは afterGenerateContent のロギングだと考えている。ブロックの判断基準は運用してみないと決まらないが、「どんなプロンプトが来て、どれだけトークンを食い、何が返っているか」はクライアント側のログからは正確に取れない。まず観測を置き、判断基準が固まってから beforeGenerateContent にルールを移す順番のほうが、Preview の機能への依存度としても妥当な深さに収まる。

まとめ

  • Firebase AI Logic に beforeGenerateContent / afterGenerateContent の2トリガーが Preview で追加され、Gemini リクエストの前後に Cloud Functions を挟めるようになった。
  • リクエスト/レスポンスを丸ごと返して書き換え、HttpsError を投げてブロックする。プロジェクトあたり各1つまで。
  • generateContentStream と Live API では発火しない。 チャット UI の既定経路が検査を素通りする構図になるため、ここを前提に置かないとモデレーションが黙って無効になる。
  • 前提は Blaze プラン、追加コストは全リクエスト分の関数実行、レイテンシ増は公式も警告している。

クライアント直叩きという Firebase AI Logic の出発点は変えずに、出荷後に差し替えられる制御層を後から足す——という方向で機能が積まれている。ストリーミング対応が入った時点でこの記事の注意点は消えるので、Preview が取れるタイミングでリリースノートを見直したい。

今日のその他のニュース

Anthropic が Claude Opus 5.5 を発表(Hacker News 625pt)。本日の HN 最上位。Artificial Analysis による第三者ベンチも同時に上がっており、価格対性能の位置づけはそちらで確認できる。

WordPress に未認証のパストラバーサル、条件付きで RCE(公式 security advisory)。認証なしで到達できる経路のため、インターネットに露出している WordPress は全て対象と考えたほうがいい。

AI コーディングで CI がボトルネックになった(Linear)。エージェントが PR を量産した結果、人間のレビューより先に CI のキューが詰まり、CI 基盤そのものを作り直すことになったという実例。個人開発でも GitHub Actions の無料枠で同じ構図が起きる。

ソース