先月、私の自動投稿パイプラインの API 請求を見積もりと照合したところ、想定より数百円多い差額が残りました。コール数もトークン数もログと一致しているのに、合計だけが合いません。原因を追ったところ、私が要求していたモデルと「実際に応答したモデル」が一部のリクエストで食い違っていたのです。出力テキストとトークン数は記録していましたが、どのモデルが応答を返したかを残していなかったため、差額の発生箇所を特定するのに半日を要しました。
headless で Claude を回している方なら、似た経験があるかもしれません。model を固定して投げているのだから、返ってくるのも当然その固定モデルだ、と私も思い込んでいました。実際には、応答に含まれる model フィールドこそが「課金の根拠になるモデル」であり、要求した文字列と一致する保証はありません。応答したモデルの実体を毎回記録し、コストと品質の両面で突き合わせる仕組みを、私のパイプラインで実装したコードとともに、ここから順に組み立てます。
月末の請求額が見積もりと合わなかった
私のパイプラインは4サイト分の生成を回しており、1日あたり約480回のリクエストを投げています。月間では約14,000コールです。各コールのプロンプト・出力・入出力トークン数はすべて JSON Lines で残していました。月末に「入力トークン合計 × 単価 + 出力トークン合計 × 単価」で見積もると、請求額とおおむね一致するはずでした。
ところが2026年6月の請求では、見積もりに対して合計が上振れしていました。コール単位で割り戻すと、ごく一部のリクエストだけ、私が想定したモデルより高い単価で課金されているように見えます。私のログには「要求したモデル名」しか入っておらず、「請求された単価が何のモデルのものか」を後から証明できませんでした。これが今回の出発点です。
教訓は単純です。要求したモデルではなく、応答が申告したモデルこそがコストの真実 です。そして6月15日からの usage credits 移行で、モデルごとの単価差が請求に直結するようになります。ズレを後から説明できる状態を作っておく価値が、これまで以上に大きくなりました。
API 応答は「実際に応答したモデル」を返している
Messages API の応答ボディには、最初から model フィールドと usage オブジェクトが含まれています。これは要求のエコーバックではなく、サーバ側で「この応答を生成したモデル」を申告したものです。多くの実装は本文だけを取り出して捨ててしまいますが、ここにコスト照合の鍵があります。
import Anthropic from "@anthropic-ai/sdk" ;
const client = new Anthropic ({ apiKey: process.env. ANTHROPIC_API_KEY });
const res = await client.messages. create ({
model: "claude-fable-5" , // 私が「要求した」モデル
max_tokens: 4096 ,
messages: [{ role: "user" , content: "記事の下書きを生成してください" }],
});
console. log (res.model); // ← 実際に応答したモデル(課金の根拠)
console. log (res.usage); // { input_tokens, output_tokens, ... }
console. log (res.id); // リクエストごとの一意 ID
res.model が要求した "claude-fable-5" と常に一致するとは限りません。ここがポイントです。一致していれば安心、ずれていれば「なぜずれたのか」を調べる入口になります。res.id はサポート問い合わせや再現調査の際の照合キーになるので、合わせて残します。
なぜ要求と実体がずれるのか — フォールバックは正常動作
ズレは異常ではなく、多くは設計どおりの正常動作です。代表的な発生源を3つ挙げます。
1つ目はモデル側の安全フォールバック です。Fable 5(Mythos クラス)は、サイバーセキュリティや生物・化学といった高リスク領域の要求に対して応答をブロックし、Claude Opus 4.8 へフォールバックする設計が公表されています。発動はセッションの5%未満とされていますが、ゼロではありません。私のような無害な記事生成でも、プロンプトに含まれる語のいずれかが境界に触れれば、応答は別モデルから返ってくる可能性があります。
2つ目はクライアント側のフォールバック設定 です。Claude Code には fallbackModel(最大3モデルの順次フォールバック)があり、過負荷時に下位モデルへ自動で切り替わります。headless 実行でこれを有効にしている場合、過負荷の瞬間に応答モデルが変わります。
3つ目はエイリアスとバージョン解決 です。claude-3-5-sonnet-latest のようなエイリアスを使うと、解決後の実バージョンが res.model に入ります。要求文字列と応答文字列が字面として一致しないのは、この場合むしろ正常です。
いずれも「壊れている」わけではありません。問題は、ずれが起きたこと自体ではなく、ずれを記録していないために後から説明できないこと です。
Before / After — モデル実体を記録するログへ
まず、私が最初に使っていた素朴なログです。出力とトークン数は残っていますが、応答モデルも要求モデルもリクエスト ID もありません。
// Before: 出力しか残らない
async function generate ( prompt : string ) {
const res = await client.messages. create ({
model: "claude-fable-5" ,
max_tokens: 4096 ,
messages: [{ role: "user" , content: prompt }],
});
const text = res.content[ 0 ].type === "text" ? res.content[ 0 ].text : "" ;
appendJsonl ( "gen.log" , { text, tokens: res.usage.output_tokens });
return text;
}
このログでは、月末に「どのモデルで課金されたか」を証明できません。次が、モデル実体を毎回記録する版です。要求モデル・応答モデル・usage・ID・タイムスタンプを1行ずつ残します。私はこれを「モデル実体ログ(attestation log)」と呼んでいます。
// After: 要求モデルと応答モデルの両方を残す
import { appendFileSync } from "node:fs" ;
type Attestation = {
at : string ; // ISO8601
requestId : string ; // res.id
requestedModel : string ;
servedModel : string ; // res.model
inputTokens : number ;
outputTokens : number ;
drift : boolean ; // requested !== served
};
async function generate ( prompt : string , requestedModel = "claude-fable-5" ) {
const res = await client.messages. create ({
model: requestedModel,
max_tokens: 4096 ,
messages: [{ role: "user" , content: prompt }],
});
const rec : Attestation = {
at: new Date (). toISOString (),
requestId: res.id,
requestedModel,
servedModel: res.model,
inputTokens: res.usage.input_tokens,
outputTokens: res.usage.output_tokens,
drift: res.model !== requestedModel,
};
appendFileSync ( "attestation.jsonl" , JSON . stringify (rec) + " \n " );
return res.content[ 0 ].type === "text" ? res.content[ 0 ].text : "" ;
}
差分は servedModel と drift の2フィールドだけですが、得られる説明力は大きく変わります。drift が立った行だけを抽出すれば、月末の差額がどのリクエストから来たのかを数秒で特定できます。エイリアスを使っている場合は drift が常に立ってしまうため、後述の照合では字面一致ではなくモデル系統で判定するのが実用的です。
モデル別単価でコストを突き合わせる
実体ログがあれば、要求モデル基準の見積もりと、応答モデル基準の実コストを並べて出せます。単価はモデルごとに大きく異なります。たとえば Fable 5 の API 単価は入力 100万トークンあたり $10、出力 $50 と公表されています。フォールバック先のモデルは単価が違うため、5%未満の発動でも合計には効いてきます。
単価表はコード内の定数として持ち、料金ページの最新値で更新します(古い値での照合は事故のもとなので、私は月初に確認しています)。
// 単価は 100万トークンあたりの USD。最新値は料金ページで必ず確認する。
const PRICE : Record < string , { in : number ; out : number }> = {
"claude-fable-5" : { in: 10 , out: 50 },
"claude-opus-4-8" : { in: 0 , out: 0 }, // ← 最新単価を料金ページから埋める
};
function costUsd ( model : string , inTok : number , outTok : number ) : number {
const p = PRICE [model] ?? { in: 0 , out: 0 };
return (inTok / 1_000_000 ) * p.in + (outTok / 1_000_000 ) * p.out;
}
function reconcile ( records : Attestation []) {
let estimated = 0 ; // 要求モデル基準(事前見積もり)
let actual = 0 ; // 応答モデル基準(実コスト)
for ( const r of records) {
estimated += costUsd (r.requestedModel, r.inputTokens, r.outputTokens);
actual += costUsd (r.servedModel, r.inputTokens, r.outputTokens);
}
return { estimated, actual, gap: actual - estimated };
}
gap がプラスなら、フォールバックなどで単価の高いモデルに振れた分です。私の6月のデータでは、drift が立ったのは全14,000コール中の約1.3%でしたが、その差額が見積もりとのズレをほぼ説明しきっていました。「コールの1%が原因」を数値で言い切れるようになったことが、何より安心材料でした。
ズレ率を品質ゲートに組み込む
照合を月末まで放置すると、異常なフォールバックの多発に気づくのが遅れます。私は push 前の品質ゲート群に、ズレ率の閾値チェックを1つ加えました。直近のウィンドウで drift 率が想定を超えたら、生成は止めずに警告ログを出して人の確認を促す方針です。
function driftGate ( records : Attestation [], windowSize = 500 , threshold = 0.05 ) {
const recent = records. slice ( - windowSize);
if (recent. length === 0 ) return { ok: true , rate: 0 };
const drifted = recent. filter (( r ) => r.drift). length ;
const rate = drifted / recent. length ;
if (rate > threshold) {
console. warn ( `⚠️ drift rate ${ ( rate * 100 ). toFixed ( 1 ) }% > ${ threshold * 100 }%` );
// 直近のズレ内訳を出して原因切り分けを助ける
const byModel = new Map < string , number >();
for ( const r of recent. filter (( x ) => x.drift)) {
byModel. set (r.servedModel, (byModel. get (r.servedModel) ?? 0 ) + 1 );
}
console. warn ( "served breakdown:" , Object. fromEntries (byModel));
}
return { ok: rate <= threshold, rate };
}
ここで生成を強制停止しないのは意図的です。Fable 5 の安全フォールバックは正常動作であり、たまたま境界に触れる語が増えただけのこともあります。止めるのではなく「気づけるようにする」のがこのゲートの役割です。閾値は私の環境では 5% から始め、平常時の実測(約1.3%)を見ながら調整しています。
headless / Agent SDK への組み込み位置
実装上の肝は、attestation を書く場所を「API クライアントの最も外側のラッパー1か所」に集約することです。生成ロジックの各所で messages.create を直接呼ぶと、記録漏れが必ず発生します。私はクライアント呼び出しを1つの関数に通し、そこで必ず実体ログを書くようにしました。
// すべての生成はこの1関数を経由する
export async function callClaude ( params : Anthropic . MessageCreateParams ) {
const res = await client.messages. create (params);
appendFileSync ( "attestation.jsonl" , JSON . stringify ({
at: new Date (). toISOString (),
requestId: res.id,
requestedModel: typeof params.model === "string" ? params.model : "" ,
servedModel: res.model,
inputTokens: res.usage.input_tokens,
outputTokens: res.usage.output_tokens,
drift: res.model !== params.model,
}) + " \n " );
return res;
}
Agent SDK を使っている場合も考え方は同じで、ツール実行や多段委譲の各ステップが内部で発行するリクエストを、できるだけ共通の計測層で受けます。サブエージェントを入れ子にする構成では、どの階層がどのモデルで応答したかが見えにくくなるため、requestId と階層 ID を一緒に残しておくと、後追い調査が一気に楽になります。
次の一手
導入は次の3手順で進めることを推奨します。
既存の生成ログに servedModel(res.model)と requestId(res.id)の2フィールドを足す。コードの変更はわずかですが、月末の請求が「説明できる」状態に変わります。
1週間ほど実体ログを溜めて reconcile を回し、要求基準と実体基準の差、そして自分の環境の平常時 drift 率を把握する。
driftGate を push 前のゲート群に加え、平常時の実測値を基準に閾値を調整する。
この3手順だけで、6月15日以降のモデル別課金にそのまま備えられます。個人開発でパイプラインを回している規模でも、計測層は1か所に集約できるので導入コストは小さく済みます。
応答が申告したモデルを淡々と記録しておくこと。それだけで、コストの不一致は「謎」から「特定済みの差分」に変わります。私自身、この2フィールドを足してからは月末の照合作業が確認だけで済むようになりました。同じように headless で運用している方の参考になれば幸いです。