1
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 × 従量課金 | transcriptのJSONLからモデル別コストを集計し、サブスクとAPIの損益分岐を出すまで

1
Posted at

「サブスクで足りているのか、APIキーに切り替えたほうが安いのか、判断材料がない」

——この感覚は正しいです。Claude Code の画面に出る使用量は、月単位でもモデル別でもキャッシュ込みでもなく、自分で集計しないと答えが出ません。この記事では、手元に残っているセッションログ(JSONL)から日別・モデル別のトークンを集計し、単価表を掛けて「API従量課金だったらいくらか」を出すスクリプトを作ります。

なぜログから集計するのか

Claude Code は会話の記録を ~/.claude/projects/<プロジェクトのスラッグ>/*.jsonl に1行1イベントで保存します。assistant の行には message.usage があり、入力・出力・キャッシュ書き込み・キャッシュ読み取りのトークン数が入っています。外部サービスに送らず、ローカルだけで完結します。

注意点が2つあります。

  • 同じ message.id の行がストリーミングの途中経過として複数回出ることがあります。そのまま足すと二重計上になるので、message.id で重複を除きます。
  • 単価はこの記事にハードコードしません。改定されるので、公式の料金ページを見て prices.json に自分で書きます。

Step 1 単価表を用意する

数値は公式の料金ページで確認して埋めます(単位は USD / 100万トークン)。以下は形だけで、値は仮です。

{
  "claude-sonnet-5-5": { "input": 0, "output": 0, "cacheWrite": 0, "cacheRead": 0 },
  "claude-opus-5-5":   { "input": 0, "output": 0, "cacheWrite": 0, "cacheRead": 0 }
}

値が 0 のままのモデルは、集計時に「単価未設定」として警告します。黙って 0 円にしないためです。

Step 2 JSONL を読んで重複を除く

// usage-report.ts
import { readdirSync, readFileSync, statSync } from "node:fs";
import { homedir } from "node:os";
import { join } from "node:path";

type Usage = {
  input_tokens?: number;
  output_tokens?: number;
  cache_creation_input_tokens?: number;
  cache_read_input_tokens?: number;
};

type Row = { day: string; model: string; usage: Usage };

function* walk(dir: string): Generator<string> {
  for (const name of readdirSync(dir)) {
    const p = join(dir, name);
    if (statSync(p).isDirectory()) yield* walk(p);
    else if (p.endsWith(".jsonl")) yield p;
  }
}

export function load(root = join(homedir(), ".claude", "projects")): Row[] {
  // 同一 message.id は最後に現れた行(= 最終の usage)だけを採る
  const byId = new Map<string, Row>();
  for (const file of walk(root)) {
    for (const line of readFileSync(file, "utf8").split("\n")) {
      if (!line.trim()) continue;
      let ev: any;
      try {
        ev = JSON.parse(line);
      } catch {
        continue; // 書き込み途中の行は捨てる
      }
      const msg = ev?.message;
      if (ev?.type !== "assistant" || !msg?.usage || !msg?.id) continue;
      byId.set(msg.id, {
        // JST の日付に揃える
        day: new Date(new Date(ev.timestamp).getTime() + 9 * 3600e3)
          .toISOString()
          .slice(0, 10),
        model: msg.model ?? "unknown",
        usage: msg.usage,
      });
    }
  }
  return [...byId.values()];
}

Step 3 単価を掛けて日別・モデル別に出す

// 続き
import prices from "./prices.json" with { type: "json" };

const M = 1_000_000;

export function report(rows: Row[]) {
  const acc = new Map<string, { usd: number; read: number; fresh: number }>();
  const missing = new Set<string>();

  for (const { day, model, usage: u } of rows) {
    const p = (prices as Record<string, Record<string, number>>)[model];
    if (!p || p.input === 0) {
      missing.add(model);
      continue;
    }
    const fresh = (u.input_tokens ?? 0) + (u.cache_creation_input_tokens ?? 0);
    const read = u.cache_read_input_tokens ?? 0;
    const usd =
      ((u.input_tokens ?? 0) * p.input +
        (u.output_tokens ?? 0) * p.output +
        (u.cache_creation_input_tokens ?? 0) * p.cacheWrite +
        read * p.cacheRead) / M;
    const key = `${day}\t${model}`;
    const cur = acc.get(key) ?? { usd: 0, read: 0, fresh: 0 };
    acc.set(key, { usd: cur.usd + usd, read: cur.read + read, fresh: cur.fresh + fresh });
  }

  for (const [key, v] of [...acc].sort()) {
    const hit = v.read / Math.max(1, v.read + v.fresh);
    console.log(`${key}\t$${v.usd.toFixed(2)}\tcache読取率 ${(hit * 100).toFixed(0)}%`);
  }
  if (missing.size) console.warn("単価未設定(集計から除外):", [...missing].join(", "));

  const total = [...acc.values()].reduce((s, v) => s + v.usd, 0);
  return total;
}

if (import.meta.url === `file://${process.argv[1]}`) {
  const total = report(load());
  const plan = Number(process.env.PLAN_USD ?? 0); // 契約中のプラン月額(USD)
  console.log(`\nAPI換算の合計: $${total.toFixed(2)}`);
  if (plan > 0) {
    console.log(total > plan ? "サブスクのほうが安い" : "API従量課金のほうが安い");
  }
}

実行は次のとおりです。

PLAN_USD=100 pnpm dlx tsx usage-report.ts

読み方の落とし穴

  • 期間を揃える。 ログには全期間が入るので、月額と比べるなら対象月だけに絞ってから合計を出します。Step 3 の出力を grep '^2026-10' で月ごとに切れば足ります。
  • cache読取率が低い日は高くつく。 読み取りは入力より安い単価なので、読取率が下がった日は同じ作業でも換算額が跳ねます。CLAUDE.md を頻繁に書き換えた日などが該当します。
  • サブスクには利用上限があります。 この比較は金額だけです。上限で作業が止まる影響は入っていないので、損益分岐の一要素として扱ってください。
  • ログが消えると集計も消える。 ~/.claude/projects の保持期間設定やクリーンアップで古い分が無くなります。月次で集計結果だけ別に残しておくと安全です。

まとめ

  • セッションログの message.usage を message.id で重複除去して足せば、ローカルだけでモデル別・日別のトークンが出ます。
  • 単価はコードに埋め込まず prices.json に分け、未設定は警告して除外すると、改定時に黙って狂いません。
  • 「API換算額 vs プラン月額」を月単位で並べると、サブスク継続かAPI切り替えかを自分の使用実績で判断できます。

次の一手として、このスクリプトを月初に cron で回し、結果を1行ずつ CSV に追記しておくのがおすすめです。ログが消えても推移が残ります。

1
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
1
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?