無料ユーザーが一気に増えた週末のことでした。個人で動かしているアプリのバックエンドで、Gemini API のコストが見積もりの曲線から静かにずれ始めていました。Project Spend Caps を設定してあったので、上限に当たればそこで止まる——そう考えて、私は一度安心しました。
けれど落ち着いて見直すと、その「止まる」は無料ユーザーだけを止めるわけではありません。同じプロジェクトのキーで動いている有料ユーザーの呼び出しも、まとめて止まります。守りたかったはずの相手まで巻き添えにする。上限を当てたはずの設計が、いちばん大事な導線を断つ寸前でした。
胸の奥が少し冷たくなる感覚。安全網のつもりで張ったものが、当たり方を間違えると刃になる。この記事は、Project Spend Caps を最後の安全網として正しく置きつつ、その影響範囲を有料・無料で分け、ハードな遮断の手前で段階的に縮退させるまでの設計メモです。
Project Spend Caps が止めるのは「プロジェクトの全部」
まず機能の輪郭を正確に押さえます。Project Spend Caps は 2026 年 3 月 16 日に AI Studio で提供が始まった、プロジェクト単位の月額ドル上限です。設定はコンソール操作で完結します。AI Studio で対象プロジェクトを選び、サイドバーの「Spend」を開き、「Monthly spend cap」の「Edit spend cap」から金額を入れるだけです。
公式が示す出発点の目安は、用途ごとにこう整理されています。
用途 推奨する月額上限の出発点
個人の実験 $10
プロトタイプ $50
小規模な本番 $200
成長中のアプリ $500
これとは別に、課金アカウント単位のティア上限が 2026 年 4 月 1 日から有効になっています。Tier 1 が $250、Tier 2 が $2,000、Tier 3 が $20,000 以上という段階です。Project Spend Caps はこのアカウント上限の内側で、プロジェクトごとにもっと細かく天井を切るための仕組みだと捉えると整理しやすいです。
肝心なのは挙動です。プロジェクトが上限に達すると、そのプロジェクトからの API リクエストは、次の請求サイクルに入るか上限を引き上げるまでブロックされます。そして反映には約 10 分の遅延があり、その間に発生した超過分は利用者の負担です。
ここまでが公式に書かれている範囲です。詳細はGemini API の費用管理に関する公式アナウンス とBilling のドキュメント が一次情報になります。問題は、ここに書かれていない「当たったあとに何が道連れになるか」です。
1プロジェクト1上限が危ういのは、影響範囲が広すぎるから
多くの個人開発は、1 つの GCP プロジェクトに 1 本の API キーを置き、無料ユーザーも有料ユーザーも同じキーで捌きます。シンプルで、最初は何の問題もありません。
その構成に Project Spend Caps を 1 つ当てると、上限は「プロジェクト全体の合計支出」に対して効きます。つまり上限に当たる原因が無料ユーザーの大量アクセスであっても、止まるのはプロジェクトの全リクエストです。月に数百円のチップや購読で支えてくれている有料ユーザーの呼び出しまで、同じ瞬間に 4xx/5xx を返し始めます。
これは可用性の言葉で言えば、影響範囲(blast radius)が広すぎる状態です。コストの暴走源は無料ティアなのに、被害は有料ティアにまで及ぶ。守りたい収益導線と、コストを垂れ流すリスク源が、同じ天井の下で運命を共にしてしまっています。
私自身、複数の個人開発プロジェクトを並行で回している立場なので、ここは身につまされました。コストを止めること自体は正しい。けれど「誰のために止め、誰を止めないか」を設計していなければ、安全網はいちばん大切な相手を落とします。
ティア別にプロジェクトを分けて、天井を分ける
解き方はシンプルです。コストの暴走源と、守りたい収益導線を、別々の GCP プロジェクトに分離します。
具体的には、無料ティア用のプロジェクトと有料ティア用のプロジェクトを分け、それぞれに独立した API キーと独立した Project Spend Cap を当てます。無料ティアには低めの上限(たとえば $30)、有料ティアにはゆとりのある上限(たとえば $300)を設定する。こうすると、無料ユーザーの暴走で無料プロジェクトが天井に当たっても、有料プロジェクトは無傷で動き続けます。影響範囲がティアの内側に閉じ込められるわけです。
アプリ側は、ユーザーのティアに応じてどのキーを使うかを選ぶだけです。
// project-router.ts
// ユーザーのティアごとに、独立した GCP プロジェクトのキーへ振り分ける。
// 各プロジェクトには別々の Project Spend Cap を当てておく。
type Tier = "free" | "paid" ;
interface ProjectBinding {
apiKey : string ; // プロジェクトごとに発行した API キー
projectId : string ; // 観測・照合のためのラベル
softBudgetUsd : number ; // ソフト予算(後述。プラットフォーム上限より低く置く)
}
const PROJECTS : Record < Tier , ProjectBinding > = {
free: {
apiKey: process.env. GEMINI_KEY_FREE ! ,
projectId: "myapp-free" ,
softBudgetUsd: 25 , // ハード上限 $30 に対し、手前の $25 で先に絞る
},
paid: {
apiKey: process.env. GEMINI_KEY_PAID ! ,
projectId: "myapp-paid" ,
softBudgetUsd: 270 , // ハード上限 $300 に対し $270 で警戒に入る
},
};
export function resolveProject ( tier : Tier ) : ProjectBinding {
return PROJECTS [tier];
}
ここで一つだけ運用上の注意があります。プロジェクトを分けると、無料・有料それぞれのコストが独立した請求として読めるようになります。これは副産物として、どのティアが粗利を食っているのかを月次で正確に把握できるという利点も生みます。上限のためだけでなく、事業判断のための計器としても効いてきます。
約10分の遅延を埋める「ソフト予算ゲート」
プロジェクトを分けても、まだ穴があります。Project Spend Caps の反映には約 10 分の遅延があり、その間の超過は自分の負担です。つまりハード上限は「リアルタイムの制御装置」ではなく「遅れて効く安全網」です。秒間に呼び出しが集中するような場面では、10 分あれば上限を大きく踏み越えられます。
そこで、プラットフォームの上限に当たる手前で発火する、アプリ側のソフト予算ゲートを置きます。考え方はこうです。各呼び出しの直後に usageMetadata から概算コストを計算し、プロジェクトごとの当月累計をカウンタに積む。累計がソフト予算を超えたら、ハードに遮断する代わりに段階的に縮退させる——上位モデルを下位モデルに降格する、キャッシュ済みの応答を返す、あるいは丁重なメッセージで待っていただく。
まず、usageMetadata から概算コストを出す部分です。料金はモデルとリージョンで変わるため、価格表は設定として外に出しておきます。
// cost.ts
// 100万トークンあたりの単価(USD)。最新の料金に合わせて更新する。
interface ModelPrice { inPerM : number ; outPerM : number ; }
const PRICES : Record < string , ModelPrice > = {
"gemini-3.5-flash" : { inPerM: 0.30 , outPerM: 2.50 },
"gemini-3-flash" : { inPerM: 0.15 , outPerM: 0.60 },
// 価格は必ず公式の Billing ドキュメントで確認して更新すること
};
interface UsageMetadata {
promptTokenCount ?: number ;
candidatesTokenCount ?: number ;
}
export function estimateCostUsd ( model : string , usage : UsageMetadata ) : number {
const p = PRICES [model];
if ( ! p) return 0 ; // 未知モデルは 0 でなく別途アラートを上げる運用が望ましい
const inTok = usage.promptTokenCount ?? 0 ;
const outTok = usage.candidatesTokenCount ?? 0 ;
return (inTok / 1_000_000 ) * p.inPerM + (outTok / 1_000_000 ) * p.outPerM;
}
次に、当月累計を積んでソフト予算と突き合わせるゲートです。ここでは Cloudflare KV のような外部カウンタを使う前提で書きますが、Redis でも DB でも構いません。要は「プロジェクト横断で当月の概算累計を共有できる」ことが条件です。
// budget-gate.ts
import { resolveProject, type Tier } from "./project-router" ;
import { estimateCostUsd } from "./cost" ;
interface Counter {
get ( key : string ) : Promise < number >;
add ( key : string , delta : number ) : Promise < void >;
}
function monthKey ( projectId : string ) : string {
const now = new Date ();
const ym = `${ now . getUTCFullYear () }-${ String ( now . getUTCMonth () + 1 ). padStart ( 2 , "0" ) }` ;
return `spend:${ projectId }:${ ym }` ;
}
export type Decision =
| { action : "proceed" ; model : string }
| { action : "degrade" ; model : string } // 下位モデルへ降格
| { action : "serve_cache" } // キャッシュ応答に切り替え
| { action : "soft_block" }; // 丁重に待っていただく
export async function decideBeforeCall (
tier : Tier ,
requestedModel : string ,
counter : Counter ,
) : Promise < Decision > {
const proj = resolveProject (tier);
const spent = await counter. get ( monthKey (proj.projectId));
const ratio = spent / proj.softBudgetUsd;
if (ratio < 0.8 ) return { action: "proceed" , model: requestedModel };
if (ratio < 0.95 ) return { action: "degrade" , model: "gemini-3-flash" };
if (ratio < 1.0 ) return { action: "serve_cache" };
return { action: "soft_block" };
}
// 呼び出し後に概算コストを積む
export async function recordCost (
tier : Tier ,
model : string ,
usage : { promptTokenCount ?: number ; candidatesTokenCount ?: number },
counter : Counter ,
) : Promise < void > {
const proj = resolveProject (tier);
const cost = estimateCostUsd (model, usage);
await counter. add ( monthKey (proj.projectId), cost);
}
このゲートの利点は、ハードな 4xx ではなく「体験の質を少しだけ落とす」方向に逃がせることです。無料ユーザーには下位モデルやキャッシュで応えつつ、上限の手前で支出の傾きを自分から寝かせられます。約 10 分の遅延に怯えるのではなく、その遅延が問題にならない速度まで、当月の消費を能動的に絞っていくわけです。
推定値とのズレを前提に、請求の正値と照合する
ソフト予算ゲートが積んでいるのは、あくまで usageMetadata からの概算です。実際の請求とは必ずズレます。キャッシュ済みトークンの割引、リクエストの失敗分、価格表の更新漏れ——ズレる理由はいくつもあります。
ですから、概算カウンタは「リアルタイムで速いが推定」、請求と Project Spend Caps の表示は「遅れるが正」という二層で持ち、定期的に突き合わせます。乖離が一定の幅を超えたら、価格表か計装にバグがある兆候として扱います。
// reconcile.ts
// 1日1回などの頻度で、概算累計と請求の実値を突き合わせる。
// 請求側の実値は Cloud Billing の集計や、AI Studio の Spend 表示から取得する。
interface ReconcileInput {
projectId : string ;
estimatedUsd : number ; // 自前カウンタの当月累計
billedUsd : number ; // 請求側の当月実値
hardCapUsd : number ; // 当てている Project Spend Cap
}
export function reconcile ( input : ReconcileInput ) : {
divergencePct : number ;
alerts : string [];
} {
const { estimatedUsd , billedUsd , hardCapUsd } = input;
const base = Math. max (billedUsd, 1e-6 );
const divergencePct = Math. abs (estimatedUsd - billedUsd) / base * 100 ;
const alerts : string [] = [];
if (divergencePct > 15 ) {
alerts. push ( `推定と請求の乖離が ${ divergencePct . toFixed ( 1 ) }% — 価格表か計装を点検` );
}
if (billedUsd / hardCapUsd > 0.85 ) {
alerts. push ( `請求実値がハード上限の ${ ( billedUsd / hardCapUsd * 100 ). toFixed ( 0 ) }% — 上限調整を検討` );
}
return { divergencePct, alerts };
}
この照合をやっておくと、ソフト予算ゲートが当てにならなくなる前に気づけます。概算がいつの間にか実値から大きく外れていれば、ゲートは早すぎる、あるいは遅すぎるタイミングで発火し、無料ユーザーを不必要に絞ったり、逆に上限を踏み越えたりします。計器そのものを定期的に校正する、という発想です。
上限に近づいた時にやること・やらないこと
最後に、運用の手順を短くまとめます。
請求実値がハード上限の 85% を超えたら、まず原因のティアを切り分けます。プロジェクトを分けてあれば、どちらの天井に近づいているかは請求を見ればすぐに分かります。無料ティア側であれば、ソフト予算ゲートの閾値を下げる、降格先をさらに軽いモデルにする、キャッシュ範囲を広げる、といった縮退を先に効かせます。
有料ティア側が近づいている場合は、それは事業が伸びている良い兆候でもあります。慌てて遮断する前に、Project Spend Cap を引き上げる判断をします。引き上げは即時に効きますが、引き上げたらソフト予算とアラート閾値も合わせて上げ直すことを忘れないでください。片方だけ動かすと、計器の目盛りがずれます。
やってはいけないのは、ハード上限を「日々の制御装置」として使うことです。約 10 分の遅延と請求サイクル単位の解除を考えると、ハード上限は事故を一定額で止める最終ブレーキであって、アクセル調整の道具ではありません。日々の調整はソフト予算ゲートに任せ、ハード上限は本当に手に負えなくなった時の天井として、静かに置いておくのが健全です。
次の一歩として、まずは無料・有料のプロジェクト分離から始めてみてください。キーを 2 本に分け、それぞれに上限を当てるだけでも、影響範囲はティアの内側に閉じます。ソフト予算ゲートと照合はその上に、少しずつ載せていけば十分です。同じように個人で複数のプロジェクトを並行で回している方の、運用設計の手がかりになれば嬉しく思います。