claude -p を子プロセスとして何本も起こして仕事を分担させる技術検証で避けたかったのは、何かの拍子に子が API キーで走ってしまうことでした。そうなると、気づかないまま従量課金の請求が積み上がります。そこで、起こした claude -p が API キーで走っていないかを機械で判定する方法を探しました。Web 検索で見つかる判定の方法は、実機で確かめると当てにならないものが多くありました。

結論

  • いちばん当てになったのは、stream-json に rate_limit_event が流れてくるかどうかでした。私たちが見た範囲では、このイベントはサブスクリプションのログインで走った回にだけ出ました。
  • stream-json の init にある apiKeySource は、ANTHROPIC_AUTH_TOKEN の経路を見逃しました(値は none のままで、実際には bearer トークンが使われた)。
  • claude auth status の authMethod は、ANTHROPIC_API_KEY を置いても claude.ai のままでした。
  • 確実なのは、子プロセスの環境から ANTHROPIC_API_KEY と ANTHROPIC_AUTH_TOKEN をそもそも消しておくことでした。

試した環境

  • Mac、Claude Code 2.1.170
  • サブスクリプションでログインした状態から、環境変数を足したり消したりして見比べました。

ログインの状態からは見分けられない

何も足していない状態の claude auth status は {loggedIn, authMethod, apiProvider, email, orgId, orgName, subscriptionType} を返しました。authMethod は claude.ai、apiProvider は firstParty で、subscriptionType には契約の種類が入っていました。

ここに環境変数を足すと、次のようになりました。

見たもの環境変数を足したとき判定に使えるか
claude auth status の authMethodANTHROPIC_API_KEY を置いても claude.ai のまま。apiKeySource という項目が1つ増える(値は環境変数の名前)だけ単独では使えない。apiKeySource の項目が無いことまで見る必要がある
同上ANTHROPIC_AUTH_TOKEN を置くと oauth_token になり、email と subscriptionType が消える—
stream-json の init の apiKeySource偽の ANTHROPIC_AUTH_TOKEN を置いて走らせても none のまま。実際には bearer トークンが使われ、401 で落ちたANTHROPIC_AUTH_TOKEN の経路を見逃す
rate_limit_eventサブスクリプションで走った回には出た使える

ANTHROPIC_API_KEY を置いても authMethod が変わらないところは、見落としやすい所です。項目が1つ増えるだけなので、authMethod だけを見る判定では引っかかりません。init の apiKeySource が none のまま、偽のトークンで 401 になったのを見て、init の値では判定できないと分かりました。

rate_limit_event の中身

rate_limit_event の中身は、イベントの直下ではなく rate_limit_info の下にありました。項目は status・resetsAt・rateLimitType・overageStatus・overageDisabledReason・isUsingOverage です。

実機で見えた rateLimitType は five_hour だけで、週の枠のイベントは見ていません。承認待ちを試した回では、status が allowed、overageStatus が rejected、isUsingOverage が false でした。枠の残りは割合では取れず、分かるのは status と resetsAt だけです。

出かける前のロボットの腰から鍵を外し、家の中の鍵掛けに掛ける人

守り方

判定だけには頼らず、次の4つを組み合わせました。

  1. 子プロセスの環境から ANTHROPIC_API_KEY と ANTHROPIC_AUTH_TOKEN を消してから起こす。検出できない経路があるので、そもそも渡さないのが確実でした。
  2. init の apiKeySource が none 以外なら、その場で子を止める。
  3. 走り終わっても rate_limit_event が一度も来なければ、警告を出す。
  4. --bare を使わない。Claude Code を更新したら、上の3つを確かめ直す。1回更新しただけでも、stream-json の形は変わることがあるためです。

1〜3 の流れを図にすると、次のようになります。

子プロセスを起こすときの守り。キーを消してから起こし、init と rate_limit_event で確かめる

子プロセスの環境は、次のように作りました(抜き出し)。

function safeEnv(extra: Record<string, string> = {}): NodeJS.ProcessEnv {
  const env: NodeJS.ProcessEnv = { ...process.env, ...extra };
  // -p では、あれば使われうる。AUTH_TOKEN は init の apiKeySource でも検出できない
  delete env.ANTHROPIC_API_KEY;
  delete env.ANTHROPIC_AUTH_TOKEN;
  // これを設定すると claude.ai のコネクタ(Figma など)が何も言わずに消える
  delete env.CLAUDE_CODE_OAUTH_TOKEN;
  for (const k of Object.keys(env)) {
    if (/^(ANTHROPIC_|CLAUDE_CODE_|AWS_|GOOGLE_|VERTEX_|GITHUB_|GITLAB_)/.test(k)) delete env[k];
  }
  return env;
}

stream-json を読みながらの判定は、次のとおりです(抜き出し)。

let sawRateLimit = false;
// ev は stream-json の1行を読み替えたもの
// (run_started は init、usage は rate_limit_event、finished は result に当たる)
if (ev.type === 'run_started') {
  if (ev.apiKeySource && ev.apiKeySource !== 'none') {
    child.kill('SIGTERM');
    reject(new Error(`サブスク以外の認証で走っています (apiKeySource=${ev.apiKeySource})。停止しました。`));
    return;
  }
}
if (ev.type === 'usage') sawRateLimit = true;
// …
child.on('close', () => {
  if (!sawRateLimit) {
    // サブスクで走った回には出た。出ないなら課金の経路を疑う
    emit('warning', 'rate_limit_event が観測されませんでした。認証経路を確認してください。');
  }
});

見間違えやすい所

  • --bare を付けただけでは、課金は起きませんでした。API キーが無ければ「Not logged in」で失敗し、走らない側に倒れます。--bare を付けたときに従量課金になるのは、ANTHROPIC_API_KEY もあるときです。ただし公式は、--bare を将来 -p の既定にすると予告しています。--bare は、サブスクリプションのログイン(OAuth・キーチェーン)を読みません。
  • result の total_cost_usd は、サブスクリプションで走っているときは API の定価で換算した手元の見積もりで、請求額ではありません。
  • claude setup-token で作る1年のトークンは、モデルへの要求にしか使えません。これを使うと、Remote Control と claude.ai のコネクタが使えなくなります。

確かめていないこと

  • 週の枠に当たったときの rate_limit_event の形。
  • 有効な API キーで走らせたときに、rate_limit_event が本当に出ないかどうか。
  • claude -p の課金の扱いそのもの。Anthropic は「Agent SDK と claude -p をサブスクリプションの枠から外し、別枠の月のクレジットにする」と発表し、施行の当日に一時停止しました。私たちが見た時点の公式のページでは、まだ保留のまま(サブスクリプションの枠から引かれる)でした。いまどうなっているかは確かめていません。

おわりに

どの認証で走っているかは、ログインの状態を問い合わせても、init を見ても、取りこぼす経路がありました。キーを渡さないことを第一にし、rate_limit_event が来たかどうかで後から確かめる、という二段構えに落ち着きました。

※ この記事に書いた Claude Code の振る舞いと課金の扱いは、執筆時点の情報です。いまは変わっている可能性があります。