「先月の Google Cloud の請求書を開いたら、Gemini API の項目だけ予想の倍だった」— 個人開発で Gemini API を使い始めて半年ほどが経った頃、私はこの体験をしました。原因を調べようと管理コンソールを掘り下げても、リクエスト単位の内訳までは出てきません。結局はアプリ側で usageMetadata を記録していなかったことが、再発防止できない最大の理由でした。
usageMetadata は Gemini API のレスポンスに常に含まれている地味なフィールドですが、これを 1 リクエストずつ保存しておくだけで、後から「どのエンドポイント」「どのユーザー」「どのモデル」が請求の何割を占めているのか、ほぼ完璧に再現できます。ここでは私が個人プロジェクトで実際に運用している記録パターンと、Google Cloud の請求書と突き合わせる方法を共有します。
usageMetadata に含まれるフィールドを正確に把握する
最初につまずくのは、usageMetadata のフィールドが思った以上に多いことです。Python SDK(google-genai)でレスポンスを print するとこのような構造になっています。
from google import genai
client = genai.Client( api_key = "YOUR_API_KEY" )
resp = client.models.generate_content(
model = "gemini-2.5-flash" ,
contents = "Gemini API のコスト管理について200字で教えてください" ,
)
print (resp.usage_metadata)
# UsageMetadata(
# prompt_token_count=18,
# candidates_token_count=210,
# cached_content_token_count=0,
# thoughts_token_count=0,
# tool_use_prompt_token_count=0,
# total_token_count=228
# )
それぞれの意味は次の通りです。
prompt_token_count — 入力プロンプト全体のトークン数。System Instructions や履歴も含む
cached_content_token_count — Context Caching を使ったときにキャッシュから読み込んだトークン数(通常入力の 25% 程度の単価)
candidates_token_count — モデルが生成した出力トークン数。candidate_count が 2 以上なら全候補の合計
thoughts_token_count — Gemini 2.5 系の Thinking 出力トークン数。料金は出力トークンと同じ単価で課金される
tool_use_prompt_token_count — Function Calling や Code Execution の内部処理で消費した入力トークン
total_token_count — 上記すべての合計
ここで重要なのは「total_token_count を見るだけでは料金は計算できない」という事実です。同じ 1,000 トークンでも、入力・出力・キャッシュ済み・思考でそれぞれ単価が違うため、フィールドごとに分けて記録しないと請求書と一致しません。
1 リクエストずつ JSON ログに残す
私が運用している方法はシンプルで、API 呼び出しのラッパー関数で usageMetadata を JSONL ファイルに追記するだけです。「あとで集計するから今は保存しておく」という割り切りが、月末になって最も助かります。
import json
import time
from pathlib import Path
from google import genai
LOG_PATH = Path( "logs/gemini_usage.jsonl" )
LOG_PATH .parent.mkdir( exist_ok = True )
client = genai.Client( api_key = "YOUR_API_KEY" )
def call_gemini_logged (model: str , prompt: str , * , user_id: str , endpoint: str ):
"""usageMetadata を必ず JSONL に追記してから返すラッパー"""
started = time.time()
resp = client.models.generate_content( model = model, contents = prompt)
um = resp.usage_metadata
record = {
"ts" : time.time(),
"elapsed_ms" : int ((time.time() - started) * 1000 ),
"user_id" : user_id,
"endpoint" : endpoint,
"model" : model,
"prompt" : um.prompt_token_count,
"cached" : um.cached_content_token_count or 0 ,
"candidates" : um.candidates_token_count or 0 ,
"thoughts" : um.thoughts_token_count or 0 ,
"tool_use" : um.tool_use_prompt_token_count or 0 ,
"total" : um.total_token_count,
}
with LOG_PATH .open( "a" , encoding = "utf-8" ) as f:
f.write(json.dumps(record, ensure_ascii = False ) + " \n " )
return resp
# 使用例
resp = call_gemini_logged(
model = "gemini-2.5-flash" ,
prompt = "今日の天気を一言で" ,
user_id = "u_001" ,
endpoint = "/api/weather/summary" ,
)
print (resp.text)
JSONL を選ぶ理由は 2 つあります。1 つは追記書き込みが原子的に近く、複数プロセスから書いてもログが壊れにくいこと。もう 1 つは、後で SQLite や DuckDB に取り込むのが read_json_auto() 一発で済むことです。
モデルごとの単価表をコードに埋め込んで日本円で集計する
ログが貯まったら、モデルごとの単価表と突き合わせて円換算します。価格は変動するので、私は単価を 1 つの Python モジュールに切り出しています。
# pricing.py
# 1M トークンあたりの USD(2026 年 4 月時点・必ず公式の最新表で確認)
PRICING = {
"gemini-2.5-pro" : {
"input" : 1.25 , "cached" : 0.31 , "output" : 10.00 , "thoughts" : 10.00 ,
},
"gemini-2.5-flash" : {
"input" : 0.30 , "cached" : 0.075 , "output" : 2.50 , "thoughts" : 2.50 ,
},
"gemini-2.5-flash-lite" : {
"input" : 0.10 , "cached" : 0.025 , "output" : 0.40 , "thoughts" : 0.40 ,
},
}
def calc_usd (record: dict ) -> float :
p = PRICING .get(record[ "model" ])
if p is None :
# 未知のモデルは安全側に倒して 0 にせず Pro 単価で見積もる
p = PRICING [ "gemini-2.5-pro" ]
input_billable = max ( 0 , record[ "prompt" ] - record.get( "cached" , 0 ))
return (
input_billable / 1_000_000 * p[ "input" ]
+ record.get( "cached" , 0 ) / 1_000_000 * p[ "cached" ]
+ record.get( "candidates" , 0 ) / 1_000_000 * p[ "output" ]
+ record.get( "thoughts" , 0 ) / 1_000_000 * p[ "thoughts" ]
)
注意点が 1 つあります。prompt_token_count には キャッシュ済みのトークンも含まれている ため、prompt - cached で実際に課金される入力トークン数を求める必要があります。これを忘れるとキャッシュを使っているはずなのに見積もりが下がらず、最適化の効果が見えなくなります。
集計はこんな具合です。
import json
from collections import defaultdict
from pricing import calc_usd
usd_by_endpoint = defaultdict( float )
with open ( "logs/gemini_usage.jsonl" ) as f:
for line in f:
rec = json.loads(line)
usd_by_endpoint[rec[ "endpoint" ]] += calc_usd(rec)
USD_TO_JPY = 156 # 月初の TTM レートで固定するのが運用上ラク
for ep, usd in sorted (usd_by_endpoint.items(), key =lambda x: - x[ 1 ]):
print ( f " { ep :40s } $ { usd :>8.4f } ¥ { usd * USD_TO_JPY :>9.2f } " )
エンドポイント別に並べるだけで、どの機能が課金の何割を食っているかが瞬時に見えます。実際にこれを始めてから「画像分類の前処理プロンプトが意外と高い」とか「翻訳だけは Pro でなく Flash でいい」といった、予想と違う発見が毎月のように出てきます。
月末に Google Cloud の請求書と突き合わせる
月初に「ログ集計の合計」と「Google Cloud の実請求」を突き合わせる作業は 5 分で済みますが、これをやっているかどうかで運用の安心感が大きく変わります。
突き合わせの手順は次の通りです。
Google Cloud Console → 請求 → レポート で対象プロジェクトの「Generative Language API」または「Vertex AI」の費用を表示する
ログ集計の月合計(USD)を計算する
差額が請求の 5% 以内に収まっていれば OK。それ以上ズレていたら原因を調べる
私の経験で、5% 以上ずれる主な原因は次の 3 つです。
無料枠の引き忘れ — 無料枠で消費したリクエストはログには載るが請求には来ない
Failed リクエストの扱い — 4xx で失敗したリクエストでも一部のトークンは課金されることがある
モデル名の不一致 — gemini-2.5-pro-latest のようなエイリアスを使っていると、ログ側のモデル名と請求側の課金単価が食い違う場合がある
このうち最後のモデル名の問題は地味ですが頻出します。エイリアスではなく gemini-2.5-pro-001 のような固定 ID を本番では使うようにすると、請求とログがきれいに一致するようになります。
アラート閾値は「日次予算」で持つのが現実的
月末になって突き合わせる運用だけでは、月の途中で暴走したケースを止められません。私はログに記録した時点で「日次の累計が予算を超えたら通知」を出すようにしています。
import json
from datetime import datetime, timezone, timedelta
from pricing import calc_usd
DAILY_BUDGET_USD = 3.0 # 月 90 ドルを想定
def today_total_usd () -> float :
today = datetime.now(timezone(timedelta( hours = 9 ))).date()
total = 0.0
with open ( "logs/gemini_usage.jsonl" ) as f:
for line in f:
rec = json.loads(line)
d = datetime.fromtimestamp(rec[ "ts" ], tz = timezone(timedelta( hours = 9 ))).date()
if d == today:
total += calc_usd(rec)
return total
# 各リクエストの直後に呼ぶ
if today_total_usd() > DAILY_BUDGET_USD :
# 例: Slack Webhook へ通知、または当日の API 呼び出しを停止
raise RuntimeError ( "Daily Gemini budget exceeded" )
この閾値を月予算 ÷ 30 にしておくだけで、想定外の暴走を当日中に検知できます。月末の請求書を見て驚く回数が、私の場合はこの仕組みを入れてからゼロになりました。
Context Caching を併用するときの単価設計は Gemini API Context Caching でコストを下げる実践ガイド に詳しくまとめています。
ログを SQLite に取り込んで自由にクエリする
JSONL は記録には便利ですが、「先週いちばんお金を使ったユーザーは?」のような問いに即答するには SQL のほうが速いです。変換は 2 行で済みます。
sqlite3 usage.db << 'SQL'
.mode json
CREATE TABLE IF NOT EXISTS usage AS SELECT * FROM read_json('logs/gemini_usage.jsonl');
SQL
テーブルになれば、実際に投げたい問い合わせは短く書けます。
-- 直近 7 日のエンドポイント別コスト Top 10(gemini-2.5-flash)
SELECT endpoint ,
SUM (prompt - cached) * 0 . 30 / 1e6 AS input_usd,
SUM (candidates + thoughts) * 2 . 50 / 1e6 AS output_usd
FROM usage
WHERE ts > strftime( '%s' , 'now' ) - 7 * 86400
AND model = 'gemini-2.5-flash'
GROUP BY endpoint
ORDER BY input_usd + output_usd DESC
LIMIT 10 ;
個人開発レベルでは、JSONL → SQLite の変換は数十万行あっても 1 秒以内で終わります。私の場合、ログが 5GB を超えてから本格的なデータウェアハウスに移行しましたが、個人アプリではほぼ起きない規模です。
Node SDK で気をつけたいこと
Node SDK(@google/genai)を使う場合、フィールド名は camelCase になります。構造自体は同じですが、いくつかハマりやすい点があります。
import { GoogleGenAI } from "@google/genai" ;
const ai = new GoogleGenAI ({ apiKey: process.env. GEMINI_API_KEY });
const resp = await ai.models. generateContent ({
model: "gemini-2.5-flash" ,
contents: "Hello" ,
});
const um = resp.usageMetadata;
console. log ({
prompt: um.promptTokenCount,
cached: um.cachedContentTokenCount ?? 0 ,
candidates: um.candidatesTokenCount ?? 0 ,
thoughts: um.thoughtsTokenCount ?? 0 ,
});
私が実際に踏んだ落とし穴は次の 2 つです。
ストリーミングのチャンクでは usageMetadata が undefined のことが多く、最終チャンクにだけ値が乗ります。チャンクをまたいで足し算するとダブルカウントになるため、値が現れた 1 回だけ加算してください
cachedContentTokenCount はキャッシュを使っていないときに 0 ではなく 未定義 のことがあります。?? 0 で吸収しないと TypeScript 側で実行時エラーになります
既定モデルが差し替わると単価表が静かにズレる
ここまでは記録した model(=こちらが要求したモデル名)を単価表の引き当てキーにしてきました。半年ほどはこれで請求とぴったり合っていたのですが、2026 年 6 月に Gemini 3.5 Flash が一般提供(GA)となり、環境によっては既定モデルがこの新しいティアへ静かに寄せられるようになってから、前提が崩れました。具体的には「gemini-flash-latest のようなエイリアスで呼んでいたリクエストが、実際には別の世代のモデルで応答されていた」というケースです。要求モデル名で集計している限り、この差はログには一切現れません。
頼りになるのは、レスポンス自身が返してくる「実際に応答したモデル」です。google-genai SDK では resp.model_version、Node SDK では resp.modelVersion に入っています。ここを記録の基点に切り替えると、既定モデルが差し替わってもコストの引き当てがズレなくなります。
def call_gemini_logged (model: str , prompt: str , * , user_id: str , endpoint: str ):
started = time.time()
resp = client.models.generate_content( model = model, contents = prompt)
um = resp.usage_metadata
record = {
"ts" : time.time(),
"elapsed_ms" : int ((time.time() - started) * 1000 ),
"user_id" : user_id,
"endpoint" : endpoint,
"requested_model" : model, # こちらが要求した名前(エイリアス含む)
"served_model" : resp.model_version, # 実際に応答したモデル ← 単価引き当てはこちら
"prompt" : um.prompt_token_count,
"cached" : um.cached_content_token_count or 0 ,
"candidates" : um.candidates_token_count or 0 ,
"thoughts" : um.thoughts_token_count or 0 ,
"tool_use" : um.tool_use_prompt_token_count or 0 ,
"total" : um.total_token_count,
}
with LOG_PATH .open( "a" , encoding = "utf-8" ) as f:
f.write(json.dumps(record, ensure_ascii = False ) + " \n " )
return resp
単価計算側も served_model を見るように直します。ここで大事なのは、model_version が gemini-2.5-flash のように単価表のキーと完全一致しないこともある点です。世代を表す部分で引き当てる前処理を挟んでおくと、細かなサフィックス差で取りこぼしません。flash-lite が flash に誤って吸われないよう、キーは長い順に照合します。
def resolve_price_key (served_model: str ) -> str | None :
"""model_version を PRICING のキーへ正規化する。未知なら None を返す"""
if not served_model:
return None
m = served_model.lower()
for key in sorted ( PRICING , key = len , reverse = True ): # 長い順に部分一致を取る
if m.startswith(key) or key in m:
return key
return None
def calc_usd (record: dict ) -> float :
key = resolve_price_key(record.get( "served_model" ) or record.get( "requested_model" ))
if key is None :
# 黙って Pro 単価に倒さず、気づける形で止める
raise KeyError ( f "unknown model: { record.get( 'served_model' ) } " )
p = PRICING [key]
input_billable = max ( 0 , record[ "prompt" ] - record.get( "cached" , 0 ))
return (
input_billable / 1_000_000 * p[ "input" ]
+ record.get( "cached" , 0 ) / 1_000_000 * p[ "cached" ]
+ record.get( "candidates" , 0 ) / 1_000_000 * p[ "output" ]
+ record.get( "thoughts" , 0 ) / 1_000_000 * p[ "thoughts" ]
)
元の実装では「未知のモデルは安全側に倒して Pro 単価で見積もる」としていました。けれども既定モデルが頻繁に動く時期には、この親切心がかえって毒になります。新しいティアが既定になった初日に、全リクエストが Pro 単価で計上されて見積もりが数倍に膨らみ、本当のコスト変化が埋もれてしまうからです。未知モデルは黙って代替せず、例外で気づける形にしておくほうが、結局は早く正しい単価表へ追従できます。
ログと請求のズレを毎晩監査する
served_model を記録し始めると、月末を待たずに「要求とは違うモデルで応答された割合」を毎日見張れます。私自身、複数のサービスを並行して回すなかで一度この差に足をすくわれてからは、夜間バッチで次の監査を回し、ドリフトと未知モデルだけを通知するようにしています。
import json
from collections import Counter
from datetime import datetime, timezone
def audit_recent (days: int = 1 ) -> dict :
since = datetime.now(timezone.utc).timestamp() - days * 86400
drift = Counter() # (requested, served) の食い違い
unknown = Counter() # PRICING に無い served_model
total = 0
with open ( "logs/gemini_usage.jsonl" ) as f:
for line in f:
rec = json.loads(line)
if rec[ "ts" ] < since:
continue
total += 1
served = rec.get( "served_model" )
if resolve_price_key(served) is None :
unknown[served] += 1
if served and rec.get( "requested_model" ) and \
resolve_price_key(served) != resolve_price_key(rec[ "requested_model" ]):
drift[(rec[ "requested_model" ], served)] += 1
return { "total" : total, "drift" : drift, "unknown" : unknown}
report = audit_recent( days = 1 )
if report[ "unknown" ]:
print ( "単価表に無いモデルが応答しています:" , dict (report[ "unknown" ]))
if report[ "drift" ]:
ratio = sum (report[ "drift" ].values()) / max (report[ "total" ], 1 )
print ( f "要求と異なるモデルでの応答が { ratio :.1% } :" , dict (report[ "drift" ]))
unknown に何か出たら、それは新しい既定モデルが入ってきた合図です。PRICING に行を 1 つ足すだけで、翌日からの集計は再び請求と一致します。drift の比率は平常時はゼロに張り付き、既定モデルが切り替わった日にだけ跳ねます。月末に請求書を見て驚く代わりに、切り替わった「その日」に気づける——この一手間が、複数のサービスを並行して回している個人開発では効いてきます。
全体を振り返って — 今日できる最初の一歩
ここまでの内容を全部入れる必要はありません。今日この記事を読み終えたら、まず usageMetadata をそのまま JSONL に追記する 5 行のラッパー関数だけ追加してみてください。データさえ貯まっていれば、集計やアラートはあとからいくらでも書けます。
1 ヶ月分のログが貯まったときに「どの機能が一番お金を使っているか」を初めて知る瞬間は、個人開発者にとっては小さなお祭りのようなものです。請求書を開くのが少しだけ楽しみになる、そんな運用を始めるのに今日はちょうどよい日かもしれません。