個人で複数のサービスを並行運用していると、Claude API を組み込んだ SaaS を「動く」状態から「複数の顧客に課金できる」状態へ引き上げる瞬間が必ず来ます。そこで最初に壊れるのは、機能でもレイテンシでもありません。月末の帳尻です。
自社の Anthropic の請求は $312 なのに、テナント別に積み上げた金額の合計は $270 にしかならない。差額の $42 が誰の利用だったのか、もう追えない。この「合計が合わない」状態は、料金プランの設計そのものを足元から崩します。原価が分からなければ、いくらで売れば黒字なのかも分からないからです。
ここでは、テナント分離とコスト帰属を「だいたい」ではなく「突合できる」水準で実装する方法を、本番で動く TypeScript と、そのとき迫られる運用判断とあわせて書いていきます。Next.js + PostgreSQL + Redis(または Cloudflare KV) を前提にしていますが、考え方はスタックを問いません。
なぜ「だいたい合っている」では破綻するのか
通常の Web API なら、コストはサーバー費用として固定費に近く、誰が何回叩いたかをログで追えば十分です。Claude API はそうではありません。コストはリクエスト数ではなくトークン数で決まり、しかもプロンプト長・モデル・キャッシュの効き具合で 1 リクエストの原価が数円から数百円まで動きます。
この変動を「リクエスト数 × 平均単価」で近似すると、ヘビーユーザーのコストを必ず過小評価します。長文を投げる大口テナントほど 1 リクエストが高く、平均で割った瞬間に実態とずれるからです。結果として、最も課金すべき相手のコストを取りこぼします。
だからコスト帰属の出発点は単純です。すべての Claude API レスポンスに入っている usage を、そのリクエストを発生させたテナントに、その場で正確に紐付ける。推定で埋めない。レスポンスに書いてある実数だけを記録する。これを徹底できるかどうかで、月末の突合が成立するかどうかが決まります。
計測は API 呼び出しと不可分にする
帳尻が合わなくなる最大の原因は、計測の付け忘れです。新しい機能を追加するたびに anthropic.messages.create を直接呼ぶと、そのたびに計測コードを書き足す必要が生まれ、いつか必ず忘れます。忘れた経路のコストは静かに消え、月末に差額となって現れます。
対策は、Claude API へ到達する経路を 1 本に絞ることです。テナントコンテキストを受け取る単一の関数を作り、アプリ内のどこからもこの関数経由でしか Claude を呼べないようにします。
// lib/claude-client.ts
import Anthropic from '@anthropic-ai/sdk' ;
import type { TenantContext } from '@/types/tenant' ;
import { checkRateLimit } from '@/lib/rate-limiter' ;
import { recordUsage } from '@/lib/usage-tracker' ;
const anthropic = new Anthropic ({ apiKey: process.env. ANTHROPIC_API_KEY ! });
export interface ClaudeRequestParams {
model : string ;
messages : Anthropic . MessageParam [];
system ?: string ;
maxTokens ?: number ;
}
export async function callClaudeForTenant (
tenant : TenantContext ,
params : ClaudeRequestParams ,
) : Promise < Anthropic . Message > {
// ① まずレート制限。ここで弾けばトークンを 1 つも消費しない
if ( ! ( await checkRateLimit (tenant.tenantId, tenant.requestsPerMinuteLimit))) {
throw new Error ( `RATE_LIMIT_EXCEEDED:${ tenant . tenantId }` );
}
const message = await anthropic.messages. create ({
model: params.model,
max_tokens: params.maxTokens ?? 4096 ,
system: params.system,
messages: params.messages,
});
// ② 計測は await しない。失敗してもレスポンスは返す。ただし握りつぶさない
recordUsage (tenant.tenantId, {
inputTokens: message.usage.input_tokens,
outputTokens: message.usage.output_tokens,
cacheReadTokens: message.usage.cache_read_input_tokens ?? 0 ,
cacheWriteTokens: message.usage.cache_creation_input_tokens ?? 0 ,
model: params.model,
requestId: message.id,
}). catch (( err ) => {
// ここを console.error だけで終わらせると、計測欠損に誰も気づけない
reportCriticalError ( 'usage_tracking_failed' , { tenantId: tenant.tenantId, err });
});
return message;
}
ポイントは 2 つあります。1 つは、usage から cache_read_input_tokens と cache_creation_input_tokens まで拾うこと。キャッシュ読み出しは通常入力より安く、キャッシュ書き込みは少し高い。この区別を捨てると、キャッシュを多用するテナントのコストを系統的に間違えます。
もう 1 つは、計測の失敗を console.error で終わらせないこと。私自身、個人開発で 4 サイトを自動運用するなかで「ログには出ていたが誰も見ていなかった」欠損を経験しました。計測失敗は機能のバグより静かで、しかも金額に直結します。Sentry や Slack など、人間が必ず気づく経路へ流すべき種類のエラーです。
カウンターは原子的に、そして月末に突合する
トークン数をテナント別に積み上げるとき、通常の「読んで足して書き戻す」では並行リクエストで競合します。Redis のパイプラインでインクリメント系コマンドをまとめ、原子的に更新します。
// lib/usage-tracker.ts
import { redis } from '@/lib/redis' ;
// USD per 1M tokens(2026 年 6 月時点・必ず公式の最新値で更新する)
const MODEL_PRICING : Record < string , { input : number ; output : number ; cacheRead : number }> = {
'claude-opus-4-8' : { input: 15.0 , output: 75.0 , cacheRead: 1.5 },
'claude-sonnet-4-6' : { input: 3.0 , output: 15.0 , cacheRead: 0.3 },
'claude-haiku-4-5-20251001' : { input: 0.8 , output: 4.0 , cacheRead: 0.08 },
};
interface UsageRecord {
inputTokens : number ; outputTokens : number ;
cacheReadTokens : number ; cacheWriteTokens : number ;
model : string ; requestId : string ;
}
export async function recordUsage ( tenantId : string , r : UsageRecord ) : Promise < void > {
const p = MODEL_PRICING [r.model] ?? { input: 3.0 , output: 15.0 , cacheRead: 0.3 };
// キャッシュ書き込みは入力の 1.25 倍で概算(モデル別に正確化する余地あり)
const costUsd =
(r.inputTokens / 1e6 ) * p.input +
(r.outputTokens / 1e6 ) * p.output +
(r.cacheReadTokens / 1e6 ) * p.cacheRead +
(r.cacheWriteTokens / 1e6 ) * p.input * 1.25 ;
const ym = new Date (). toISOString (). slice ( 0 , 7 ); // YYYY-MM
const key = `saas:v1:usage:monthly:${ tenantId }:${ ym }` ;
const pipe = redis. pipeline ();
pipe. hincrbyfloat (key, 'costUsd' , costUsd);
pipe. hincrby (key, 'inputTokens' , r.inputTokens);
pipe. hincrby (key, 'outputTokens' , r.outputTokens);
pipe. hincrby (key, 'requestCount' , 1 );
pipe. expire (key, 60 * 60 * 24 * 120 ); // 突合が終わる頃に消える 120 日
await pipe. exec ();
}
ここまでで「テナント別の積み上げ」はできますが、それが正しい保証はまだありません。正しさは突合で確かめます。月初に Anthropic の前月請求額(Usage/Cost ページや組織の請求 API で取得できる総額)を取り、自分のテナント別合計と比べる。
私は、両者の誤差が 3% を超えたら調査対象にしています。3% 以内なら、丸めやキャッシュ単価の概算による許容範囲。それを超えるなら、どこかの呼び出し経路が計測窓口を通っていないか、recordUsage がサイレントに失敗しているかのどちらかです。突合は「数字が合っていることの確認」ではなく、「計測の穴を見つける唯一の手段」だと考えています。請求との照合をさらに踏み込んで詰めたい場合は、Claude API のコスト突合の実装メモ も参考になります。
ノイジーネイバーを止める — テナント別レート制限
Anthropic のレート制限は API キー単位でかかります。100 テナントを 1 キーで捌いていると、1 テナントが上限近くまで叩いた瞬間、無関係な 99 テナントが 429 を浴びます(429 を受けたときの本番リトライ はこちらにまとめています)。これを防ぐには、Anthropic に届く前にアプリ層でテナント別の枠を設けます。
スライディングウィンドウを Lua スクリプトで原子的に実装するのが堅実です。「古いエントリ削除 → 現在数の確認 → 追加」を別々のコマンドで書くと、同時着弾時に枠を二重に使われます。
// lib/rate-limiter.ts
import { redis } from '@/lib/redis' ;
const SLIDING_WINDOW = `
local key = KEYS[1]
local now, win_start, limit = tonumber(ARGV[1]), tonumber(ARGV[2]), tonumber(ARGV[3])
redis.call('ZREMRANGEBYSCORE', key, '-inf', win_start)
if redis.call('ZCARD', key) >= limit then return 0 end
redis.call('ZADD', key, now, now .. ':' .. math.random(1, 1e9))
redis.call('EXPIRE', key, 120)
return 1
` ;
export async function checkRateLimit ( tenantId : string , rpm : number ) : Promise < boolean > {
const now = Date. now ();
const res = await redis. eval (
SLIDING_WINDOW , [ `saas:v1:ratelimit:${ tenantId }` ],
[ String (now), String (now - 60_000 ), String (rpm)],
);
return res === 1 ;
}
export const PLAN_LIMITS = {
starter: { requestsPerMinute: 10 , monthlyBudgetUsd: 5 },
pro: { requestsPerMinute: 60 , monthlyBudgetUsd: 50 },
enterprise: { requestsPerMinute: 300 , monthlyBudgetUsd: 500 },
} as const ;
全テナントの上限の総和は、Anthropic 側の実上限より低く抑えます。私は実上限の 7 割を SaaS 全体の天井にしています。残り 3 割は、計測のないバッチ処理や、想定外のスパイクのための緩衝です。テナント別上限を足し合わせた瞬間に Anthropic の上限を超える設計だと、アプリ層のレート制限が「気休め」になります。
予算チェックと API 呼び出しの競合をどう扱うか
月次予算でテナントを止めたいとき、必ず競合に突き当たります。予算を確認してから API を呼ぶまでの間に、別のリクエストが予算を使い切るからです。これは本番でだけ顕在化する落とし穴で、完全には回避できません。割り切り方を先に決めておくほうが現実的です。
// ❌ チェックと実行の間に隙間がある
const { allowed } = await checkMonthlyBudget (tenantId, budget);
if ( ! allowed) throw new Error ( 'BUDGET_EXCEEDED' );
const msg = await anthropic.messages. create ({ /* ... */ }); // ここで超過しうる
ここで「厳密に 1 円も超えさせない」を目指すと、実装が重くなります。判断基準はこうです。超過分を自社が被るのが痛いか、それともユーザーに少し多めに使われても回収できるか。
多くの個人 SaaS では後者です。であれば、予算の 90% でソフトに止めれば実用上は十分です。残り 10% が、競合で漏れ出るぶんの緩衝になります。
// ✅ ソフトリミット: 競合で多少漏れても緩衝内に収まる
const { allowed , remainingUsd } = await checkMonthlyBudget (tenantId, budget * 0.9 );
厳密さが必要なのは、超過が即赤字になる原価率の高いプランだけです。その場合だけ、リクエスト前に Redis の DECRBY で予算を原子的に引き、超過したら戻すパターンを使います。全テナントに厳密管理を被せるのは、得られる正確さに対してコードの複雑さが見合いません。どちらを選ぶかは、原価率という数字で決めるべき判断だと考えています。
データ分離は「テストで証明する」もの
会話履歴をアプリ側で持つなら、テナント間の分離はアプリの責任です。「Claude API は外部だから分離されている」は誤解で、漏れるとしたら自分の DB です。PostgreSQL なら Row Level Security で強制します。
ALTER TABLE messages ENABLE ROW LEVEL SECURITY ;
CREATE POLICY tenant_isolation ON messages
USING (tenant_id = current_setting( 'app.current_tenant_id' , true));
// lib/tenant-db.ts — クエリ前にテナントをセッション変数へ。第3引数 true でトランザクションスコープ
export async function withTenant < T >( tenantId : string , fn : ( c : PoolClient ) => Promise < T >) : Promise < T > {
const client = await pool. connect ();
try {
await client. query ( `SELECT set_config('app.current_tenant_id', $1, true)` , [tenantId]);
return await fn (client);
} finally {
await client. query ( `SELECT set_config('app.current_tenant_id', '', true)` );
client. release ();
}
}
ただ実装しただけでは、分離が効いている確証は得られません。RLS のポリシーを 1 行書き間違えても、正常系のテストはすべて通ってしまうからです。分離は、敵対的なテストで証明します。私はこれを CI のゲートに組み込んでいます。
// __tests__/tenant-isolation.test.ts
it ( 'テナント B の会話を、テナント A の文脈からは絶対に読めない' , async () => {
const convB = await withTenant ( 'tenant-B' , ( c ) =>
c. query ( `INSERT INTO messages (tenant_id, content) VALUES ('tenant-B', 'secret') RETURNING id` ),
);
const rows = await withTenant ( 'tenant-A' , ( c ) =>
c. query ( `SELECT content FROM messages WHERE id = $1` , [convB.rows[ 0 ].id]),
);
expect (rows.rowCount). toBe ( 0 ); // RLS が効いていれば、ID を直接指定しても 0 件
});
it ( 'RLS を一時的に外すと漏れることを確認し、テスト自身の有効性を担保する' , async () => {
// RLS 依存を意図的に検証。漏れなければ、上のテストは何も守っていない可能性がある
});
2 つ目のテストが重要です。「RLS を外したら漏れる」ことまで確認しないと、1 つ目のテストが本当に分離を検証しているのか分かりません。常に 0 件を返すだけのバグでも、1 つ目は通ってしまうからです。守りのテストは、わざと破って初めて信用できます。
Redis や KV が落ちたら何が起きるか
レート制限と予算管理を Redis に載せた瞬間、Redis が単一障害点になります。設計時に必ず決めておくべきは「Redis が落ちたとき、リクエストを通すか拒むか」です。
通す(fail-open)なら、可用性は守られますが、障害中はレート制限も予算ガードも無効になり、コストが青天井になりえます。拒む(fail-closed)なら、コストは守られますが、Redis の一時的な瞬断でサービス全体が止まります。
私は、レート制限は fail-open、予算ガードは fail-closed にしています。瞬断のたびに全テナントを止めるのは過剰ですが、予算という金銭の防壁は、計測できないときこそ閉じておくほうが安全だからです。この非対称な判断は、可用性とコストのどちらをそのガードで守りたいのかを、ガードごとに言語化した結果です。
export async function checkRateLimit ( tenantId : string , rpm : number ) : Promise < boolean > {
try {
return ( await redis. eval ( /* ... */ )) === 1 ;
} catch {
reportDegraded ( 'ratelimit_failopen' , { tenantId });
return true ; // fail-open: 可用性優先
}
}
次の一手
まず、自分の SaaS の Claude 呼び出しを grep して、anthropic.messages.create が計測窓口の外から直接呼ばれていないか数えてみてください。1 箇所でも外にあれば、そこが月末の差額の源です。窓口を 1 本に絞り、原子的に積み上げ、月初に Anthropic の請求と突合する。この 3 つが揃って初めて、料金プランを数字で設計できる土台ができます。
マルチテナント対応は「後から足せる」と思われがちですが、データ分離だけは既存データの移行コストが跳ね上がります。最初に組み込んでおく価値があるのは、この一点だと感じています。