0
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

Claude Code の利用ログを SQLite に取り込んで API 換算したら302万円だった

0
Posted at

Claude Code などのサブスクリプションプランで使っていると、実際にどれだけ使ったのかが見えません。

そこで、ローカルに残っている利用ログを全部 SQLite に取り込み、Anthropic API の公式単価で換算してみました。4か月弱、73,548ターンで API 換算302万円 でした(1 USD = 159円)。

使ったのは phuryn/claude-usage(MIT)をフォークして、自分用にレート制限の可視化などを足したものです。

この記事では、集計の手順と、途中で踏んだ単価計算の落とし穴を書きます。落とし穴のほうは、手元のフォークが総額の33%を見落としていたという実害が出たので、同種の集計を書く人には役に立つと思います。

ログはどこにあるか

Claude Code は会話を JSONL でローカルに残します。

~/.claude/projects/<プロジェクトのパスをエンコードしたディレクトリ>/<session-id>.jsonl

1行が1レコードで、typeassistant のレコードに課金対象のトークン数が入っています。

{
  "type": "assistant",
  "timestamp": "2026-08-17T10:00:00.000Z",
  "cwd": "/path/to/project",
  "message": {
    "model": "claude-opus-4-8",
    "usage": {
      "input_tokens": 12,
      "output_tokens": 843,
      "cache_creation_input_tokens": 5120,
      "cache_read_input_tokens": 210344
    }
  }
}

重要なのはトークンが4種類あることです。入力・出力に加えて、キャッシュの書き込みと読み出しが別枠で記録されます。あとで見るように、金額の大部分はこの2つが占めます。

cleanupPeriodDays~/.claude/settings.json)を過ぎたファイルは削除されます。未設定だとデフォルト(約30日)が効くので、集計したい場合は先に伸ばしておくか、別の場所へ退避してください。私は気づくのが遅く、古い履歴を失っています。

SQLite に取り込む

JSONL のまま集計してもいいのですが、何度も条件を変えて見たいので、テーブルに落とします。

import json, sqlite3
from pathlib import Path

db = sqlite3.connect("usage.db")
db.execute("""
CREATE TABLE IF NOT EXISTS turns (
  message_id TEXT PRIMARY KEY,
  session_id TEXT,
  timestamp  TEXT,
  model      TEXT,
  input_tokens          INTEGER,
  output_tokens         INTEGER,
  cache_creation_tokens INTEGER,
  cache_read_tokens     INTEGER,
  cwd        TEXT
)""")

for fp in Path.home().joinpath(".claude/projects").rglob("*.jsonl"):
    for line in fp.open(encoding="utf-8", errors="replace"):
        try:
            rec = json.loads(line)
        except ValueError:
            continue
        if rec.get("type") != "assistant":
            continue
        msg = rec.get("message") or {}
        u = msg.get("usage") or {}
        if not u:
            continue
        db.execute(
            "INSERT OR IGNORE INTO turns VALUES (?,?,?,?,?,?,?,?,?)",
            (msg.get("id"), fp.stem, rec.get("timestamp"), msg.get("model"),
             u.get("input_tokens", 0), u.get("output_tokens", 0),
             u.get("cache_creation_input_tokens", 0), u.get("cache_read_input_tokens", 0),
             rec.get("cwd")))
db.commit()

message_id を主キーにしているのは、同じセッションを再スキャンしたときの二重計上を防ぐためです。ログは追記されるので、再取り込みは日常的に起きます。

金額に換算する

単価は公式の料金表を使います。100万トークンあたりの USD で持っておくと計算が楽です。

PRICING = {
    "claude-opus-4-8": {"input": 5.0, "output": 25.0, "cache_read": 0.50, "cache_write": 6.25},
    # ... 使っているモデルを列挙する
}

def calc_cost(model, inp, out, cache_read, cache_write):
    p = PRICING.get(model)
    if p is None:
        return None          # ← ここが重要。0.0 を返さない
    return (inp * p["input"] + out * p["output"]
            + cache_read * p["cache_read"] + cache_write * p["cache_write"]) / 1_000_000

PRICING に無いモデルで None を返しているのが、この記事のもうひとつの主題です。理由は後述します。

結果

73,548ターン、436会話、33プロジェクト分の集計結果です。

種別 トークン数 量の割合 金額の割合
入力 6,203,046 0.03% 0.2%
出力 83,387,632 0.4% 13.1%
キャッシュ読み出し 21,235,024,583 97.3% 66.5%
キャッシュ書き込み 510,024,816 2.3% 20.2%

自分が打ち込んだ指示は、金額の0.2%しかありませんでした。 プロンプトを短く書く節約は、金額の観点ではほぼ意味がなかったことになります。

金額の3分の2はキャッシュの読み出しです。単価は入力の10分の1ですが、流れる量が255倍あるので逆転します。

会話の中の位置で分けると、もう少しはっきりします。

会話の中の位置 1ターンのコスト 1ターンあたりのキャッシュ読み出し
最初の10ターン $0.1232 49,693
1000ターン目以降 $0.3157 412,423

同じ質問でも、長く続いた会話の中で聞くと2.6倍かかります。読み直されるコンテキストが8.3倍に膨らむためです。

(1000ターンを超えた会話は15本しかなく、比率は1.7〜27.1倍とばらつきます。最長の1本を除いても傾向は変わりませんでした)

落とし穴:単価表にないモデルを 0 円にすると、静かに壊れる

ここからが本題です。

使っていたツールは、PRICING に無いモデルを 0 円 として扱う実装になっていました。ローカルで動かしているモデルや他社のモデルが混ざったときに、Claude の単価で課金しないためです。設計としては妥当だと思います。

問題は、新しく出た Anthropic のモデルも「知らないモデル」に見えることです。

実際、あるモデルの 15,784ターン・42億トークンが $0.00 と表示されていました。正しく計算すると $6,272.75。全ターンの21%、総額の33%です。総額は191万円と出ていて、正しくは302万円でした。

フォークして自分の道具として使っている以上、単価表が現実に追いつかなくなる部分は自分で見張るしかありません。ここを上流任せにしていたのが敗因でした。

警告は出ません。 エラーも異常終了もなく、集計表に $0.00 と並ぶだけです。見た目には「そのモデルは使っていない」か「使ったが無料だった」のどちらかにしか見えません。

原因は、「0円だった」と「いくらか分からない」を同じ 0 で表していたことです。この2つはまったく別の情報で、前者は事実、後者は計測の失敗です。同じ値で出力した時点で、受け取る側から区別できなくなります。

対策は単価表への追記ではありません。追記は次のモデルが出た日にまた破れます。最低限、次のどちらかを入れてください。

unknown = set()

def calc_cost(model, ...):
    p = PRICING.get(model)
    if p is None:
        unknown.add(model)
        return None
    ...

# 実行の最後に
if unknown:
    print(f"warning: 単価未定義のモデルがあります: {sorted(unknown)}", file=sys.stderr)
    print(" 合計は過少に出ています。", file=sys.stderr)
  • 未定義モデルを実行終了時に警告する
  • 集計表では 0 円ではなく n/a として表示し、合計に注記を付ける

どちらも数行で済み、静かな33%のずれが目に見えるずれに変わります。

もうひとつの落とし穴:部分一致のフォールバック

同じツールに、単価表に完全一致しないモデル名が来たとき、名前の一部が一致する既存モデルの単価を使う処理も入っていました。世代が変わっても系統は同じだろう、という考えです。

これも壊れました。ある新しいモデルが旧世代の単価に解決され、実際より50%高い金額を出していました。

0 円のほうは「おかしい」と気づく余地がありますが、少し高いだけの数字は見ただけでは判断できません。しかもフォールバックが働いた形跡はどこにも残りません。

他のモデルで金額が合っていたのも、設計が正しかったからではなく、その世代で単価が据え置かれていたからでした。

フォールバックを残すなら、フォールバックしたことを戻り値に含めて、呼び出し側が「推定値」として扱えるようにしてください。

まだ解決できていないこと

キャッシュの書き込みには有効期間が5分のものと1時間のものがあり、単価が違います。しかしログにはトークン数しか残らず、どちらだったかを判別できません

全部が5分だと仮定すると302万円、全部が1時間だと339万円になります。実際は混ざっているので、真の値はこの間のどこかです。ログを見るかぎり、これ以上は詰められませんでした。

まとめ

  • Claude Code の利用ログはローカルの JSONL にある。assistant レコードの usage に4種類のトークンが入る
  • 金額の3分の2はキャッシュの読み出し。入力トークンは0.2%しかない
  • 長く続いた会話は1ターンあたり2.6倍かかる。区切りで会話を切るのが効く
  • 単価が引けないモデルを 0 円にすると、集計は静かに壊れる。「不明」を 0 で表さないこと
  • フォークして使う道具は、外部の変化で腐る部分(単価表など)を自分で見張る

同じことを自分のログでやりたい方へ。

上流は phuryn/claude-usage(MIT)です。この記事の数字は、それをフォークした chiisanasoft/claude-usage で出しています。フォーク側には、この記事で書いた対策——単価が引けないモデルの n/a 表示と実行後の警告、claude-sonnet-5 / claude-opus-5 の明示エントリ——を入れてあります。

なお claude-fable-5 の単価は、上流ではすでに追加済みです。本文の 33% の取りこぼしは、私が古いフォークを使い続けていたために起きたもので、上流の最新版では起きません。

金額の内訳や、会話1本あたりの偏り(コストの66%は上位6.7%の会話が使っていました)については、note のほうに書いています。ぜひご覧ください。

0
0
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
0
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?