個人開発で複数アプリの日次ダイジェストを Claude で回しているのですが、ある月、自前のコスト集計とコンソールに表示された請求額が一割ほど食い違っていました。
私自身、ログを何度も追いました。リクエスト数もトークン数も間違っていません。それでも合わない。
原因はひとつでした。コストを input_tokens + output_tokens だけで計算していたのです。プロンプトキャッシュを有効にした瞬間、この素朴な式は静かに壊れます。
input_tokens にキャッシュ分は入っていない
最初の誤解はここでした。
usage オブジェクトは、入力トークンを役割ごとに分けて返します。キャッシュから読まれたトークンは input_tokens には含まれません。別のフィールドに振り分けられます。
# 実際の usage の形(キャッシュ有効時)
usage = {
"input_tokens": 412, # キャッシュにヒットしなかった通常入力のみ
"cache_creation_input_tokens": 18500, # キャッシュへの書き込み(高い)
"cache_read_input_tokens": 17800, # キャッシュからの読み込み(安い)
"output_tokens": 1240,
}
つまり、長いシステムプロンプトをキャッシュしている場合、その本体は input_tokens には一切現れません。input_tokens だけを見て課金額を出すと、キャッシュに乗っている数万トークンを丸ごと見落とします。
私の場合、ダイジェストの共通プロンプトが約 18,000 トークン。これがコールドスタートのたびに cache_creation として書き込まれ、その後のコールで cache_read として読まれていました。素朴な式はこの両方を無視していたわけです。
4つのバケットには、それぞれ別の単価がかかる
会計を合わせる鍵は、4つのバケットが同じ単価ではないと理解することです。
キャッシュの読み書きは、基本入力単価に対する倍率で課金されます。倍率は安定していて、価格改定があっても比率自体はめったに変わりません。
| バケット | usage フィールド | 基本入力単価に対する倍率 |
| 通常入力 | input_tokens | 1.0× |
| キャッシュ書き込み(5分TTL) | cache_creation_input_tokens | 1.25× |
| キャッシュ書き込み(1時間TTL) | cache_creation_input_tokens | 2.0× |
| キャッシュ読み込み | cache_read_input_tokens | 0.1× |
| 出力 | output_tokens | 出力単価(別系統) |
読み込みは基本入力の十分の一。書き込みは 1.25 倍から 2 倍。ここを一律で扱うと、キャッシュが効いているコールほど大きくずれます。読みを高く見積もれば過大計上、書きを基本単価で見積もれば過小計上になります。
usage からコストを出す関数
まず、料金表をひとつの設定にまとめます。実際の単価は必ず最新の料金ページで確認し、ここに入れてください。 以下の数値は構造を示すための例です。
from dataclasses import dataclass
# 100万トークンあたりの単価(USD)— 最新の公式料金で必ず置き換える
RATES = {
"claude-sonnet-4-6": {"input": 3.00, "output": 15.00},
"claude-haiku-4-5": {"input": 0.80, "output": 4.00},
}
# キャッシュ倍率(基本入力単価に対する比率・比較的安定)
CACHE_WRITE_5M = 1.25
CACHE_WRITE_1H = 2.00
CACHE_READ = 0.10
@dataclass
class CostBreakdown:
input: float
cache_write: float
cache_read: float
output: float
@property
def total(self) -> float:
return self.input + self.cache_write + self.cache_read + self.output
def cost_from_usage(usage: dict, model: str, cache_ttl: str = "5m") -> CostBreakdown:
"""1回のレスポンスの usage から、バケット別のコストを USD で返す。"""
if model not in RATES:
raise ValueError(f"未登録のモデル: {model}(RATES に単価を追加してください)")
in_rate = RATES[model]["input"] / 1_000_000
out_rate = RATES[model]["output"] / 1_000_000
write_mult = CACHE_WRITE_1H if cache_ttl == "1h" else CACHE_WRITE_5M
return CostBreakdown(
input=usage.get("input_tokens", 0) * in_rate,
cache_write=usage.get("cache_creation_input_tokens", 0) * in_rate * write_mult,
cache_read=usage.get("cache_read_input_tokens", 0) * in_rate * CACHE_READ,
output=usage.get("output_tokens", 0) * out_rate,
)
このコードのポイントは、4つのバケットをそれぞれ独立した単価で足していることです。input_tokens と cache_creation_input_tokens は同じ「入力」でも単価が違うため、絶対に合算してから掛けてはいけません。
先ほどの usage で試してみます。
usage = {
"input_tokens": 412,
"cache_creation_input_tokens": 18500,
"cache_read_input_tokens": 17800,
"output_tokens": 1240,
}
b = cost_from_usage(usage, "claude-sonnet-4-6")
print(f"通常入力 : ${b.input:.5f}")
print(f"キャッシュ書込: ${b.cache_write:.5f}")
print(f"キャッシュ読込: ${b.cache_read:.5f}")
print(f"出力 : ${b.output:.5f}")
print(f"合計 : ${b.total:.5f}")
素朴な式((412 + 1240) だけで計算)なら、キャッシュ書き込みの約 18,500 トークンが完全に抜け落ちます。これがコールドスタートのたびに積み上がると、月末には無視できない差になります。私のずれの正体は、ほぼこの一点でした。
1時間TTLと cache_creation の内訳に対応する
1時間TTLを併用していると、書き込み倍率が変わります。新しめの API では、cache_creation が内訳つきで返ることがあります。
# 内訳が返る場合の usage 例
usage = {
"input_tokens": 412,
"cache_creation": {
"ephemeral_5m_input_tokens": 12000,
"ephemeral_1h_input_tokens": 6500,
},
"cache_read_input_tokens": 17800,
"output_tokens": 1240,
}
def cache_write_cost(usage: dict, in_rate: float) -> float:
"""5分と1時間の書き込みを、それぞれの倍率で会計する。"""
detail = usage.get("cache_creation")
if isinstance(detail, dict):
five_m = detail.get("ephemeral_5m_input_tokens", 0)
one_h = detail.get("ephemeral_1h_input_tokens", 0)
return five_m * in_rate * CACHE_WRITE_5M + one_h * in_rate * CACHE_WRITE_1H
# 内訳がなければ合算値に単一倍率を当てる
flat = usage.get("cache_creation_input_tokens", 0)
return flat * in_rate * CACHE_WRITE_5M
内訳があるときは TTL ごとに倍率を分けて足し、ないときは従来どおり合算値に当てる。この二段構えにしておくと、API の応答形式が混在しても落ちません。cache_creation を一律 1.25 倍で固定していると、1時間TTLの分を約四割ぶん安く見積もってしまいます。
1コール=1台帳行として記録する
コストを正確に出せたら、次は突合できる形で残します。私はレスポンスごとに1行を追記しています。
import json, time
def log_cost_row(path: str, request_id: str, model: str,
usage: dict, breakdown: CostBreakdown,
feature: str) -> None:
row = {
"ts": time.time(),
"request_id": request_id, # response.id を入れる
"model": model,
"feature": feature, # どの機能の呼び出しか(後で機能別に集計できる)
"tokens": {
"input": usage.get("input_tokens", 0),
"cache_write": usage.get("cache_creation_input_tokens", 0),
"cache_read": usage.get("cache_read_input_tokens", 0),
"output": usage.get("output_tokens", 0),
},
"usd": round(breakdown.total, 6),
}
with open(path, "a") as f:
f.write(json.dumps(row, ensure_ascii=False) + "\n")
request_id に response.id を入れておくのが要点です。コンソールやログ側と突き合わせるとき、行を一意に辿れます。feature を添えておくと、月末に「どの機能がコストの大半を食っているか」を機能別に出せます。私の場合、ダイジェストの要約段が全体の七割を占めていることが、この集計で初めて見えました。
月次の突合はシンプルです。台帳の usd を合計し、コンソールの請求額と並べる。数パーセント以内に収まっていれば、会計式は正しく組めています。大きくずれていれば、たいていキャッシュ倍率の取り違えか、未登録モデルの単価漏れです。
公式の料金表には書かれていない運用上の注意
実際に運用してわかった、見落としやすい点をいくつか挙げます。
ひとつめ。キャッシュ書き込みは、ヒットしてもしなくても書いた分だけかかります。キャッシュ対象を頻繁に変えると、読み込みの節約より書き込みのコストが上回ることがあります。安定したシステムプロンプトにだけキャッシュを当てるのが基本です。
ふたつめ。input_tokens がやけに小さいのにコストが下がらないときは、cache_creation が膨らんでいます。コールドスタートが多い証拠なので、TTL の選び方を見直します。
みっつめ。モデルを混在させているなら、RATES の未登録モデルで ValueError を投げる設計にしておくと安全です。新モデルへ移行したのに古い単価で計算し続ける事故を、その場で止められます。
状況別のおすすめ
呼び出しがほぼ単一機能で、キャッシュも使っていないなら、input_tokens と output_tokens だけの素朴な式で十分です。無理に台帳を組む必要はありません。
キャッシュを有効にした時点で、4バケットの会計に切り替えてください。ここが分かれ目です。
複数の機能やアプリでひとつの API キーを共有しているなら、feature 付きの台帳を最初から入れておくことをおすすめします。あとから機能別に遡るのは、ほぼ不可能だからです。
まずは手元の直近のレスポンスをひとつ取り出し、usage の4フィールドを実際に表示してみてください。cache_read_input_tokens に数字が乗っているのに自前の集計に反映されていなければ、それがずれの出発点です。
同じように請求額の食い違いに悩んでいる方の、突合の最初の一歩になれば嬉しいです。