Gemini 3.6 Flashのusage契約を検査するPython
Gemini 3.6 Flash へ切り替えるなら、モデルの回答だけでなく、利用量ログのキーも先に検査した方がいい。新しい Interactions API は usage.total_input_tokens のような snake_case を返す。一方、従来の GenerateContent API のログは usageMetadata.promptTokenCount のような camelCase だ。集計側が前者だけ、あるいは後者だけを前提にしていると、コストのダッシュボードが静かに 0 になる。
今回は、両方の JSON を一つの形式に寄せ、未知の形は例外にする小さな契約テストを作る。
Q. なぜ移行前に利用量を見るのか
7月21日に出た Gemini 3.6 Flash は、Google の発表では 3.5 Flash より出力トークンを17%減らし、入力100万トークンあたり $1.50、出力100万トークンあたり $7.50 としている。ここは魅力的だけど、アプリ側で見るべき数値はモデル名や請求単価だけじゃない。実際にどれだけ入力、出力、thinking、キャッシュ、ツール利用が出ているかだ。公式のトークン説明も、レスポンスの usage から入力、出力、thinking、キャッシュ、ツール利用、合計を取得できるとしている。
自分は移行用のログを見ていて、旧形式の promptTokenCount を読む集計が残っているのに気づいた。エラーにはならない。辞書の get() が 0 を返すだけで、グラフだけが妙にきれいになる。この手の壊れ方が一番見つけにくい。
Q. 何を契約にするのか
Google は現在、最新機能とモデルには Interactions API を推奨している。同じ公式ドキュメントの Python 例でも gemini-3.6-flash に対して interaction.usage を読む形になっている。旧 API の履歴をすぐ消せないチームでは、二つの形を受け入れる境界を一か所に置くのが扱いやすい。
次のコードは API を呼ばない。保存済みのレスポンス JSON を受け取り、集計に渡せる Usage に正規化する。total_tokens を各項目の足し算で再計算しない点も意図的だ。キャッシュ済みトークンは入力トークンに含まれる扱いになることがあるため、雑に足すと二重計上になる。API が返した合計をそのまま記録する。
from dataclasses import dataclass
@dataclass(frozen=True)
class Usage:
input_tokens: int
output_tokens: int
thought_tokens: int
cached_tokens: int
tool_use_tokens: int
total_tokens: int
def as_non_negative_int(value: object, name: str) -> int:
if not isinstance(value, int) or value < 0:
raise ValueError(f"{name} must be a non-negative integer: {value!r}")
return value
def normalize_usage(response: dict) -> Usage:
if "usage" in response: # Interactions API
raw = response["usage"]
keys = {
"input_tokens": "total_input_tokens",
"output_tokens": "total_output_tokens",
"thought_tokens": "total_thought_tokens",
"cached_tokens": "total_cached_tokens",
"tool_use_tokens": "total_tool_use_tokens",
"total_tokens": "total_tokens",
}
elif "usageMetadata" in response: # GenerateContent API
raw = response["usageMetadata"]
keys = {
"input_tokens": "promptTokenCount",
"output_tokens": "candidatesTokenCount",
"thought_tokens": "thoughtsTokenCount",
"cached_tokens": "cachedContentTokenCount",
"tool_use_tokens": "toolUsePromptTokenCount",
"total_tokens": "totalTokenCount",
}
else:
raise ValueError("usage or usageMetadata is required")
if not isinstance(raw, dict):
raise ValueError("usage must be an object")
values = {
field: as_non_negative_int(raw.get(key, 0), key)
for field, key in keys.items()
}
if values["total_tokens"] < values["input_tokens"] + values["output_tokens"]:
raise ValueError("total tokens is smaller than input + output")
return Usage(**values)
legacy = {"usageMetadata": {
"promptTokenCount": 1200, "candidatesTokenCount": 280,
"thoughtsTokenCount": 200, "cachedContentTokenCount": 900,
"toolUsePromptTokenCount": 0, "totalTokenCount": 1680,
}}
interaction = {"usage": {
"total_input_tokens": 1200, "total_output_tokens": 280,
"total_thought_tokens": 200, "total_cached_tokens": 900,
"total_tool_use_tokens": 0, "total_tokens": 1680,
}}
assert normalize_usage(legacy) == normalize_usage(interaction)
print(normalize_usage(interaction))
手元の Python 3.9.6 で実行した出力はこれだった。
Usage(input_tokens=1200, output_tokens=280, thought_tokens=200, cached_tokens=900, tool_use_tokens=0, total_tokens=1680)
raw.get(key, 0) は、任意項目が省略されたレスポンスを許すために使っている。ただし必須にしたい項目があるなら、ここを raw[key] に変えればよい。重要なのは、キー名の変更を集計 SQL や可視化コードまで漏らさないこと。ここを通らないレスポンスは例外にして、モデル切り替えの PR で止める。
Q. このテストだけで移行してよいか
よくない。これは利用量の契約だけを守るテストだ。次に、実サービスの代表リクエストを旧モデルと 3.6 Flash へ同じ条件で送り、正答率、p95 レイテンシ、Usage を同じ行に保存する。公式発表の17%は比較の出発点であって、自分のプロンプトやツール呼び出し回数まで保証しない。
モデル更新では「回答が返った」を合格にしがちだが、運用コストの観測が壊れた状態では比較そのものができない。レスポンス境界を一つ作っておくと、次の API 移行でも直す場所が決まる。小さいコードだけど、コスト最適化はここから始めるのが堅いと思う。