6月30日に Opus 4.8 と Haiku 4.5 が Messages API に加わった翌朝、私の手元の夜間コスト集計は静かにズレていました。エラーは一行も出ていません。ただ、前日比のグラフが不自然に低かったのです。
原因はすぐに分かりました。新しいモデルIDのリクエストはちゃんと成功して課金もされているのに、私が書いた集計スクリプトの単価テーブルにそのIDが無く、該当分が単価ゼロ=コスト0円として素通りしていただけでした。落ちてくれた方がまだ親切で、黙って過少報告されるのが一番たちが悪いと感じた瞬間です。
個人開発で複数サイトの自動投稿を回していると、新モデルは「試したい対象」であると同時に「会計を壊しに来る存在」でもあります。ここでは、その壊れ方を二度と起こさないための単価レジストリの作り方を、動くコードでまとめます。
なぜ単価がコード中に散らばると壊れるのか
最初に書いたとき、私はコストを出したい場所それぞれに if (model === "...") rate = ... を直書きしていました。集計スクリプト、月次の請求リコンサイル、ダッシュボードの3か所です。
この形は、モデルが固定されている間は何の問題も起こしません。壊れるのは「新しいモデルIDが増えた瞬間」だけです。そして増えた瞬間に壊れることが、最悪のタイミングを意味します。新モデルを試すのは普通、コストを注視したい時期だからです。
散在した単価の問題は3つに整理できます。
症状 起きること 気づきにくさ
未知モデルが単価0 新モデル分がコスト0円で集計される 例外が出ないので非常に高い
更新漏れ 3か所のうち1か所だけ旧単価のまま 場所ごとに金額が食い違う
キャッシュ未考慮 プロンプトキャッシュの読み書きを入力単価で計算 キャッシュ多用時ほど誤差が拡大
どれも「例外が飛ばない」のが共通点です。だからこそ、設計でしか防げません。
単価を1か所に集約する
まず、単価の正本(single source of truth)を1つのレジストリに固めます。ポイントは、表示価格をハードコードする代わりに「モデルIDをキーにした構造」を持つことです。単価そのものは更新されますが、構造は安定します。
// pricing.ts — 単価の正本。ここ以外に単価を書かない
// 値は USD / 100万トークン。⚠️ 必ず公式の最新値で埋めること
export interface ModelRate {
input : number ; // 入力トークン単価
output : number ; // 出力トークン単価
cacheWrite5m : number ; // 5分キャッシュ書き込み(= input の 1.25倍が目安)
cacheWrite1h : number ; // 1時間キャッシュ書き込み(= input の 2倍が目安)
cacheRead : number ; // キャッシュ読み込み(= input の 0.1倍が目安)
}
// 単価は公式の料金ページで最新値を確認してから埋めます。
// モデル更新時に変わるため、ここに「記憶で書いた数字」を置かないのが要点です。
const RATES : Record < string , ModelRate > = {
"claude-opus-4-8" : rate ( 15.0 , 75.0 ),
"claude-sonnet-4-6" : rate ( 3.0 , 15.0 ),
"claude-haiku-4-5" : rate ( /* input */ 0 , /* output */ 0 ), // TODO: 公式値で更新
};
// 入力・出力単価からキャッシュ系を倍率で導出するヘルパー。
// 倍率(5分1.25倍・1時間2倍・読込0.1倍)は単価が変わっても安定します。
function rate ( input : number , output : number ) : ModelRate {
return {
input,
output,
cacheWrite5m: input * 1.25 ,
cacheWrite1h: input * 2.0 ,
cacheRead: input * 0.1 ,
};
}
export { RATES };
ここで rate() ヘルパーを挟んでいるのは意図的です。入力単価さえ正しく入れれば、キャッシュ書き込み・読み込みの単価が自動で揃います。私はかつて「入力単価だけ更新してキャッシュ単価を直し忘れる」を実際にやったので、導出に寄せておくと安心できます。
usage ブロックから実コストを出す
レスポンスの usage には、課金の根拠になるトークン内訳がそのまま入っています。これを単価レジストリに通すだけで、推測ではなく実測のコストが出ます。
// cost.ts — usage と単価から1リクエストのコストを算出
import { RATES, ModelRate } from "./pricing" ;
interface Usage {
input_tokens : number ;
output_tokens : number ;
cache_creation_input_tokens ?: number ; // キャッシュ書き込み分
cache_read_input_tokens ?: number ; // キャッシュ読み込み分
}
// 1トークンあたりに直すため 100万で割る
const PER = 1_000_000 ;
export function costOf ( model : string , usage : Usage , cacheTtl : "5m" | "1h" = "5m" ) : number {
const r : ModelRate | undefined = RATES [model];
if ( ! r) {
// ここが本題。未知モデルを 0 にしない(次節で実装)
throw new UnknownModelError (model);
}
const write = cacheTtl === "1h" ? r.cacheWrite1h : r.cacheWrite5m;
return (
(usage.input_tokens * r.input +
usage.output_tokens * r.output +
(usage.cache_creation_input_tokens ?? 0 ) * write +
(usage.cache_read_input_tokens ?? 0 ) * r.cacheRead) /
PER
);
}
cache_creation_input_tokens と cache_read_input_tokens を入力単価で計算してしまうと、キャッシュを効かせているリクエストほど誤差が膨らみます。読み込みは入力の約1割なので、ここを正しく分けるだけで集計の精度がはっきり変わりました。
未知のモデルIDで「黙って0円」にしない
最初の事故の核心はここでした。未知モデルを 0 円ではなく「例外」にする。つまり fail-closed です。コストは安全側(過大ではなく、検知できる側)に倒します。
export class UnknownModelError extends Error {
constructor ( public readonly model : string ) {
super ( `未登録のモデル単価: ${ model } — pricing.ts に追加してください` );
this .name = "UnknownModelError" ;
}
}
// 集計ループでの受け止め方。1件の未知モデルで全体を止めない代わりに、
// 必ず可視化する(握りつぶさない)
export function aggregate ( records : { model : string ; usage : Usage }[]) {
let total = 0 ;
const unpriced = new Set < string >();
for ( const rec of records) {
try {
total += costOf (rec.model, rec.usage);
} catch (e) {
if (e instanceof UnknownModelError ) {
unpriced. add (e.model); // 0で素通りさせず、後で必ず気づける形に
continue ;
}
throw e;
}
}
return { total, unpriced: [ ... unpriced] };
}
ここで私が大事にしているのは「止める」と「握りつぶす」の中間を取ることです。未知モデルが1件混ざっただけで夜間ジョブ全体を落とすのは過剰です。かといって 0 円で素通りさせると、最初の事故に逆戻りします。unpriced を返り値に乗せ、集計結果と一緒に必ず表に出すことで、「数字は出るが、抜けも同時に見える」状態を保てます。
新モデル投入を事故にしないテスト
設計で防いだら、次は「次の新モデルが来たときに気づける」状態を作ります。実運用のログからモデルIDを集め、レジストリに無いものがあれば落ちる軽いテストを1本だけ置きます。
// pricing.test.ts — 実ログに現れた全モデルが単価登録済みかを検査
import { RATES } from "./pricing" ;
// 直近のリクエストログから抽出したモデルID一覧(運用で自動更新)
import { observedModels } from "./fixtures/observed-models" ;
test ( "観測された全モデルが単価レジストリに存在する" , () => {
const missing = observedModels. filter (( m ) => ! (m in RATES ));
expect (missing). toEqual ([]); // 1つでも未登録なら CI が赤くなる
});
test ( "単価は正の値で、出力は入力以上である" , () => {
for ( const [ model , r ] of Object. entries ( RATES )) {
expect (r.input, model). toBeGreaterThan ( 0 );
expect (r.output, model). toBeGreaterThanOrEqual (r.input);
}
});
2つ目のテストは地味ですが効きます。haiku-4-5 の欄を 0 のまま TODO で放置したことに、私はこのテストで気づきました。「出力単価が入力単価を下回ったら異常」という素朴な不変条件は、コピペ更新時の取りこぼしをよく拾ってくれます。
どのくらいズレるのか、数字で見る
抽象的だと対策が後回しになるので、実際の桁感を出しておきます。仮に夜間ジョブが1回で入力50万トークン・出力8万トークンを使い、その3割が新しく増えた未登録モデルだったとします。未登録分が0円で素通りすると、その回のコストは本来より約3割低く出ます。
私のケースで効いたのは、金額そのものよりも「前日比」が壊れることでした。新モデルを試した日は使用量がむしろ増えているのに、グラフ上は下がって見える。増減の符号が逆になると、人は異常に気づけません。fail-closed にして未登録モデルを表に出した翌日からは、unpriced が空でないこと自体がアラートになり、数字を疑う前に原因へ直行できるようになりました。
運用で小さく効いた判断
単価まわりで、数字には出にくいけれど運用を楽にしてくれた判断がいくつかあります。
ひとつは、エイリアス(claude-3-5-haiku-latest のような可変名)を集計に持ち込まないことです。実際に課金されたレスポンスの model フィールドは解決済みの具体IDで返るので、集計はそちらを正本にしました。リクエスト時に何を指定したかではなく、レスポンスが名乗った実IDで突き合わせる、という単純な原則です。
もうひとつは、単価更新を「コード変更」として履歴に残すことです。単価は外部要因で変わるのに、変えた記録がどこにも残らないと、後で過去コストを再計算したときに合いません。私は pricing.ts の変更を有効日コメント付きでコミットし、過去分は当時の単価で計算するようにしています。
判断 理由 避けられた事故
レスポンスの実モデルIDで突合 エイリアスは指す先が動く 新旧モデルの取り違え
単価変更を有効日付きでコミット 外部要因で単価は動く 過去コスト再計算の不一致
fail-closed + 可視化 0円素通りが最も危険 静かな過少報告
次の一歩
もし今、単価がコードのあちこちに直書きされているなら、まず pricing.ts を1ファイル作り、レスポンスの model フィールドをキーに集約するところから始めてみてください。未知モデルを例外にする costOf を1つ通すだけで、次にモデルが増えた朝、グラフが静かにズレる代わりに CI が赤くなって教えてくれます。
私自身まだ運用しながら直している途中ですが、「壊れるなら、黙ってではなく見える形で壊れてほしい」という一点だけは、コスト計算ではいつも正しい指針になっています。お読みいただきありがとうございました。