月初に Claude API の請求額を眺めて、手が止まりました。合計は分かるのに、内訳が分からないのです。
私の環境では、App Store レビューへの多言語返信の自動化、ブログ記事の生成パイプライン、そして思いつきで書いた実験用スクリプトが、同じ API キーで動いておりました。どれがいくら使ったのか、請求画面からは読み取れません。
そんな折に、Amazon Bedrock と Google Cloud 向けにセルフホスト型の Claude apps gateway が登場したという発表を目にしました。SSO、ポリシーの一元適用、ロールベースアクセス、ユーザー別のコスト帰属、支出上限。並んでいる言葉はエンタープライズ向けですが、根っこにある課題は月初の私とまったく同じでした。
この発表を「自分には関係のない企業向け機能」と流さずに、設計として読み解いてみます。そして、その考え方を個人開発の規模に縮約した小さなプロキシを実際に作りましたので、実装と移行の記録を共有いたします。
gateway は何を1箇所に集めたのか
公式の説明を分解すると、Claude apps gateway が提供するのは次の4点に整理できます。
認可の一元化 — 誰が(どのアプリが)モデルを呼べるかを、呼び出し経路の入口で判定する
ポリシーの一元適用 — 使ってよいモデル・機能の範囲を、アプリ側のコードではなく経路側で強制する
コスト帰属 — 利用量をユーザー(アプリ)単位で記録し、請求を分解可能にする
支出上限 — 帰属されたコストに対して上限を設け、超過時は通す前に止める
注目すべきは、これらがすべて「モデル呼び出しの経路を1本に束ねる」ことで初めて成立する点です。各アプリが api.anthropic.com を直接呼んでいる限り、どれも実現できません。逆に経路さえ束ねれば、認可も計測も方針も、その1点に載せられます。
ネットワーク設計の言葉を借りれば、これは管制面(control plane)と実行面(data plane)の分離です。アプリ本体は推論の入出力という実行面に専念し、鍵・方針・計測という管制の関心事は経路側へ抜き出す。gateway という製品の本質は、この分離を買える形にしたものだと私は読みました。
個人開発の規模で残すもの、削るもの
エンタープライズの管制面をそのまま持ち込む必要はありません。私の規模(1人・プロダクト3つ)で判断した取捨選択は次の通りです。
削ってよいもの : SSO とロールベースアクセスです。操作するのが自分1人である以上、人の認証を経路に挟む意味は薄いと判断しました。
残すべきもの は3つありました。
アプリ別トークン — 実キーを配らず、アプリごとに発行した内部トークンで呼ばせる。帰属の最小単位になり、漏洩時は該当アプリのトークンだけ無効化すればよくなります
モデル許可リスト — アプリごとに使えるモデルを経路側で固定する。コード内のモデル指定ミスや、実験コードの高価なモデル指定をここで弾けます
支出上限 — 月次の上限をアプリ別に持ち、超えたら通さない。後述しますが、これは fail-closed で設計します
移行の手順も先に示しておきます。私は次の順で進めました。
API キーを使っているプロダクトを棚卸しする(意外な場所から見つかります。私は cron に1つ忘れていました)
プロキシを配置し、実キーはプロキシのシークレットにのみ置く
アプリごとに内部トークンを発行し、ベース URL をプロキシに向け替える
全アプリの切り替えを確認してから、実キーをローテーションする(旧キー直呼びの残党がここで炙り出されます)
手順4を最後に置くのが要点です。ローテーションを先にやると、切り替え漏れのアプリが突然死にます。
Cloudflare Workers 上の最小実装
実装は Cloudflare Workers と Durable Objects で構成しました。Workers を選んだのは、既に他の用途で使っていて運用が増えないことと、月次カウンタの直列化に Durable Objects がちょうど良かったためです。
// gateway.ts — 最小の管制面プロキシ
interface AppPolicy {
token : string ; // アプリ別の内部トークン
allowedModels : string []; // モデル許可リスト
monthlyCapUsd : number ; // 月次支出上限
}
// 方針はコードに直書きせず環境変数 APP_POLICIES(JSON) から読む
// 価格表(USD / 1M tokens)。改定時はここだけ更新する
const PRICING : Record < string , { in : number ; out : number }> = {
"claude-sonnet-5" : { in: 2 , out: 10 }, // 導入価格・2026-08-31 まで
"claude-opus-4-8" : { in: 5 , out: 25 },
"claude-haiku-4-5" : { in: 1 , out: 5 },
};
export default {
async fetch ( req : Request , env : Env ) : Promise < Response > {
const policies : AppPolicy [] = JSON . parse (env. APP_POLICIES );
const token = req.headers. get ( "x-app-token" ) ?? "" ;
const app = policies. find (( p ) => p.token === token);
if ( ! app) return new Response ( "unknown app" , { status: 401 });
const body = await req. json <{ model : string }>();
if ( ! app.allowedModels. includes (body.model)) {
return new Response ( "model not allowed for this app" , { status: 403 });
}
// 支出上限チェック(fail-closed: 台帳が読めなければ通さない)
const ledger = env. LEDGER . get (env. LEDGER . idFromName ( ledgerKey (app.token)));
const spent = await ledger. fetch ( "https://ledger/get" ). then (( r ) => r. json < number >());
if (spent >= app.monthlyCapUsd * 0.9 ) {
return new Response ( "monthly cap reached" , { status: 429 });
}
// 実キーはここでだけ付与する
const upstream = await fetch ( "https://api.anthropic.com/v1/messages" , {
method: "POST" ,
headers: {
"x-api-key" : env. ANTHROPIC_API_KEY , // Workers secret
"anthropic-version" : "2023-06-01" ,
"content-type" : "application/json" ,
},
body: JSON . stringify (body),
});
// 事後計上: usage からコストを計算して台帳に加算
const json = await upstream. json < any >();
if (json.usage && PRICING [body.model]) {
const p = PRICING [body.model];
const cost =
(json.usage.input_tokens / 1e6 ) * p.in +
(json.usage.output_tokens / 1e6 ) * p.out;
await ledger. fetch ( "https://ledger/add" , {
method: "POST" ,
body: JSON . stringify ({ cost, requestId: json.id }),
});
}
return new Response ( JSON . stringify (json), {
status: upstream.status,
headers: { "content-type" : "application/json" },
});
} ,
} ;
function ledgerKey ( token : string ) : string {
const m = new Date (). toISOString (). slice ( 0 , 7 ); // YYYY-MM
return `${ token }:${ m }` ;
}
台帳側の Durable Object は、加算の直列化と冪等化だけを担う小さなクラスです。
// ledger.ts — 月次台帳(Durable Object)
export class SpendLedger {
constructor ( private state : DurableObjectState ) {}
async fetch ( req : Request ) : Promise < Response > {
const url = new URL (req.url);
if (url.pathname === "/get" ) {
const v = ( await this .state.storage. get < number >( "spent" )) ?? 0 ;
return Response. json (v);
}
if (url.pathname === "/add" ) {
const { cost , requestId } = await req. json < any >();
// リトライで同じレスポンスを二重計上しないよう request id で冪等化
const seen = await this .state.storage. get < boolean >( `seen:${ requestId }` );
if ( ! seen) {
const v = ( await this .state.storage. get < number >( "spent" )) ?? 0 ;
await this .state.storage. put ( "spent" , v + cost);
await this .state.storage. put ( `seen:${ requestId }` , true );
}
return Response. json ( true );
}
return new Response ( "not found" , { status: 404 });
}
}
アプリ側の変更は、ベース URL の向き先と、ヘッダーを x-api-key から x-app-token に替えるだけです。SDK を使っている場合も baseURL とカスタムヘッダーの指定で済みますので、アプリ本体のロジックには手を入れずに移行できました。
支出上限を fail-closed にした理由と、その代償
この実装でいちばん考えたのは支出上限の性格です。
上限チェックには構造的な限界があります。トークン使用量はレスポンスが返るまで確定しないため、厳密な事前判定はできません。できるのは「これまでの累計が上限に近いか」という事前チェックと、返ってきた usage の事後計上の組み合わせ、つまり近似です。並行リクエストが重なれば、上限を多少すり抜けます。
だからこそ、判定は上限の90%で手前に倒しました。すり抜け分を織り込んで、止まるべきときに確実に止まる側へ寄せる。上限を1円単位で守ることより、「気づいたら数万円」を構造的に不可能にすることを優先しています。
もう1つの判断は、台帳が読めないときに通すか止めるかです。私は止める(fail-closed)を選びました。ここを fail-open にすると、障害時にいちばん高くつく経路が開きっぱなしになります。生成パイプラインが数時間止まる損失と、暴走したリトライループが一晩走る損失を比べて、前者のほうが安いというのが私の答えでした。この感覚は、以前 API キーの影響範囲を絞る設計を考えたとき から変わっていません。
代償も明記しておきます。この構成はストリーミングを想定していません。SSE では usage が最後のイベントまで確定せず、プロキシでの事後計上が複雑になります。私の用途(バッチ生成と非対話の自動返信)は非ストリーミングで完結するため割り切りましたが、対話 UI を持つアプリを載せるなら、ストリーム末尾の message_delta から usage を拾う実装が追加で必要になります。
移行して最初の月に見えたもの
6月分をこの台帳で分解した結果が次の通りです。合計 $41.72 の内訳は、記事生成パイプラインが $27.9、レビュー返信の自動化が $8.6、実験用スクリプトが $5.2 でした。
私自身、数字を出すまでは記事パイプラインが最大の支出源だという確信すら持てていませんでした。数字そのものより、分解して初めて見えたものが2つありました。
1つ目は、実験用スクリプトのモデル指定が claude-opus-4-8 のまま放置されていたことです。書き捨てのつもりが cron に残っており、月 $5 強を静かに使い続けていました。モデル許可リストを実験用アプリでは Haiku のみに絞ったので、この種の放置は今後、403 で即座に発覚します。
2つ目は、支出の67%を占める記事パイプラインが Sonnet 5 の導入価格($2/$10、8月末まで)の恩恵をそのまま受けている点です。導入価格の失効日をまたぐとこの内訳が変わるため、価格の発効日を持たせた予測の仕組み と組み合わせて、9月の内訳を先に試算しておきました。帰属ができると、価格改定の影響も「合計が増えそう」ではなく「このアプリがいくら増える」と言えるようになります。
自前・LiteLLM・公式 gateway の使い分け
同じ課題への道具は3つあります。実際に検討した際の私の整理を置いておきます。
選択肢 向いている状況 注意点
自前プロキシ (本記事)1人〜数人。プロダクト数個。必要な統制がコスト帰属・許可リスト・上限程度 自分で保守する。ストリーミング等の機能追加は自作
LiteLLM 等の OSS ゲートウェイ 複数プロバイダを併用し、フォールバックやキー管理も束ねたい 常駐プロセスの運用が増える。設定の学習コストは相応にある
公式 Claude apps gateway Bedrock / Google Cloud 上でチーム運用し、SSO・RBAC まで必要 セルフホスト型の管制面であり、個人規模には過剰
判断の軸は「必要な統制の粒度」です。帰属と上限だけなら150行のプロキシで足ります。プロバイダ横断の抽象化まで欲しくなった時点で LiteLLM のようなゲートウェイ を検討し、人の認証・権限が絡んだら公式の管制面を見る。逆に言えば、SSO が要らないうちに重いものを入れると、保守だけが残ります。
まとめ — 経路を1本にするところから
管制面という言葉は大袈裟に聞こえますが、実体は「モデル呼び出しの経路を1本にして、そこに帰属・方針・上限を載せる」だけのことです。エンタープライズはそれを製品として買い、個人は150行で書ける。規模は違っても、設計の骨格は同じでした。
まずは請求の内訳が説明できるか、自問してみてください。説明できなければ、プロキシと台帳を置くのが最初の一歩です。上限も許可リストも、経路が1本になった後ならいつでも足せます。
この小さな管制面は、来月以降の請求書を読む時間を確実に減らしてくれています。どなたかの月初の憂鬱も、同じように軽くなれば嬉しく思います。