6月12日に Claude Fable 5 と Mythos 5 が外国籍ユーザー向けに停止されたとき、私が最初に感じたのは技術的な不安ではなく、段取りの崩れでした。6月9日に公開されたばかりのモデルです。検証用のスケジュールに組み込んだ翌々日、その実行枠は起動した瞬間に「このモデルは使えません」で止まりました。同じ6月15日には claude-sonnet-4 と claude-opus-4 が API から引退しています。設定ファイルに直書きしていたら、夜間ジョブが目を覚ました時にはもう存在しないモデルを呼んでいたことになります。
無人で動くジョブにとって厄介なのは、モデルが「落ちている」ことではなく、「昨日まで使えたものが、ジョブが始まる前に消えている」ことです。リクエストが失敗するのではなく、実行計画そのものが前提を失っている。この記事は、その前提を実行の入口で1回だけ確かめ、ずれていたら自分で設定を書き換えてから本作業に入る、という設計の話です。
なぜリクエスト単位のフォールバックだけでは無人実行に足りないのか
Claude Code の fallbackModel や、API でよく書く「失敗したら次のモデルへ」のリトライは、対話的な利用ではよく機能します。人が画面を見ていれば、途中で別モデルに切り替わっても気づいて判断できるからです。
ところが、私自身が4サイトの自動投稿で使っている予約実行のように、誰も見ていない処理では事情が変わります。バッチの3件目で初めて「モデルが引退していた」と気づくと、すでに1〜2件はリトライで別モデルに流れているかもしれません。生成物の品質が混在し、どの記事がどのモデルで作られたのかがログを追わないと分からなくなります。リクエスト単位のフォールバックは「途中で気づいて部分的に回復する」仕組みであって、「最初から正しいモデルで一貫して走る」ことは保証しません。
無人実行で本当に欲しいのは、リクエストごとの保険ではなく、実行が本作業を始める前に「今この瞬間、このアカウントで、このモデルは本当に応答するのか」を確定させる入口の検査です。
起動時プリフライトが解くのは、部分完了とコストのズレ
入口で1回だけ確認する方式を、ここでは起動時プリフライト(preflight)と呼びます。狙いは2つあります。
1つ目は部分完了の回避です。バッチの本処理に入る前に使用モデルを確定させれば、その実行内では全件が同じモデルで一貫して処理されます。途中で銘柄が変わって品質がまだらになることがありません。
2つ目はコストの予測可能性です。引退や撤回は、429 のレート制限や 529 Overloaded と違って、待っても回復しません。本処理の最中に高価なモデルへ何度もリトライをぶつけてから諦めるより、起動時に max_tokens: 1 の極小プローブを1回投げて適格なモデルを選ぶほうが、消費するクレジットがはるかに少なくて済みます。6月15日の課金変更で headless 実行や Agent SDK が別枠の月次クレジット(繰越なし)に移ったあとは、この「無駄打ちを起動時に潰す」効果がそのまま月末の余力に効いてきます。
「使えない」には3つの形がある
プリフライトを実装する前に、API が返す「使えない」を区別しておく必要があります。私が観測した範囲では、原因によってエラーの形がはっきり違います。
- 引退(retired): モデルIDそのものが存在しなくなった状態。
claude-sonnet-4 のように、ある日を境に 404 not_found_error 系で「model not found」が返ります。これは恒久的で、待っても戻りません。
- 撤回(withdrawn): モデルは存在するが、一時的に提供が止められている状態。Fable 5 の停止のように、
model is currently unavailable といった本文で返ることがあります。将来復旧する可能性があります。
- 地域・資格制限(restricted): アカウントの所在や資格の都合でアクセスが拒否される状態。
403 の permission_error として返り、同じモデルでも別アカウント・別リージョンなら通る、という非対称が生じます。
この3つを区別する意味は、フォールバックの判断が変わるからです。引退なら別名レイヤーを恒久的に更新すべきですし、撤回なら一時的に次点へ逃がしつつ復旧を待てます。制限なら、そもそも候補リストにそのモデルを載せてはいけません。
適格モデルを1回の安価なプローブで解決する
実装の中心は、候補モデルを優先順に並べたリストを受け取り、先頭から順に極小プローブを投げて、最初に応答したものを返す関数です。Anthropic の TypeScript SDK を使います。
import Anthropic from "@anthropic-ai/sdk";
const client = new Anthropic({ apiKey: process.env.ANTHROPIC_API_KEY });
type Ineligibility = "retired" | "withdrawn" | "restricted" | "transient";
interface ProbeResult {
model: string;
ok: boolean;
reason?: Ineligibility;
detail?: string;
}
// API が返したエラーを「使えない」の3分類+一時障害に振り分ける
function classify(err: unknown): Ineligibility {
const status = (err as { status?: number })?.status;
const message = String((err as { message?: string })?.message ?? "").toLowerCase();
if (status === 404 || message.includes("not found")) return "retired";
if (status === 403 || message.includes("permission")) return "restricted";
if (message.includes("currently unavailable") || message.includes("not available"))
return "withdrawn";
// 429 / 529 / ネットワーク断などは待てば回復する一時障害として扱う
return "transient";
}
// 1モデルだけを max_tokens:1 で叩いて応答可否を確かめる
async function probe(model: string): Promise<ProbeResult> {
try {
await client.messages.create({
model,
max_tokens: 1,
messages: [{ role: "user", content: "ping" }],
});
return { model, ok: true };
} catch (err) {
const reason = classify(err);
return { model, ok: false, reason, detail: String((err as Error)?.message ?? "") };
}
}
classify() を独立させているのは、判定ルールを1か所に集めておくためです。エラー本文の文言は将来変わり得るので、ステータスコードを第一の手がかりにし、文言は補助に留めています。
次に、候補リストを順に解決する本体です。一時障害(transient)だけは同じモデルで一度だけ短く待って再試行し、それでも駄目なら次点へ進みます。引退・撤回・制限はその場で次点へ送ります。待っても無駄だからです。
const sleep = (ms: number) => new Promise((r) => setTimeout(r, ms));
interface Resolution {
chosen: string | null;
attempts: ProbeResult[];
}
// candidates は「使いたい順」。capability で絞った後の並びを渡す前提
async function resolveEligibleModel(candidates: string[]): Promise<Resolution> {
const attempts: ProbeResult[] = [];
for (const model of candidates) {
let result = await probe(model);
// 一時障害なら1回だけ 2 秒待って再確認する(引退/撤回/制限は待たない)
if (!result.ok && result.reason === "transient") {
await sleep(2000);
result = await probe(model);
}
attempts.push(result);
if (result.ok) {
return { chosen: model, attempts };
}
}
return { chosen: null, attempts };
}
ここで候補リストの並びが品質を左右します。「使いたい順」を、単純な好みではなく「必要な能力を満たす順」で作るのが要点です。たとえば1Mコンテキストの単発生成が要件なら、その条件を満たすモデルだけを candidates に入れる。条件を満たさない安価なモデルに落ちて「動いてはいるが要件未達」になるのが、無人実行で一番たちが悪い失敗だからです。
解決結果を実行設定へ反映し、ログを1行だけ残す
プローブで決まったモデルは、その実行のあいだ設定として固定します。本処理のコードはモデルIDを直接知らず、解決済みの1個を受け取って使うだけにします。
interface RunConfig {
model: string;
resolvedAt: string;
fallbackFrom: string[]; // スキップした候補(監査用)
}
async function buildRunConfig(candidates: string[]): Promise<RunConfig> {
const { chosen, attempts } = await resolveEligibleModel(candidates);
// 構造化ログを1行だけ。無人実行は後から追えることが命綱になる
console.log(
JSON.stringify({
event: "model_preflight",
ts: new Date().toISOString(),
chosen,
skipped: attempts
.filter((a) => !a.ok)
.map((a) => ({ model: a.model, reason: a.reason })),
})
);
if (!chosen) {
// 候補が全滅したらここで止める。誤ったモデルで走り出すより安全
throw new Error(
`preflight failed: no eligible model among [${candidates.join(", ")}]`
);
}
return {
model: chosen,
resolvedAt: new Date().toISOString(),
fallbackFrom: attempts.filter((a) => !a.ok).map((a) => a.model),
};
}
// 利用側はこれだけ。本処理はモデルIDを意識しない
const config = await buildRunConfig([
"claude-opus-4-8",
"claude-haiku-4-5-20251001",
]);
// config.model を以降のすべての messages.create に渡す
ログを1行のJSONに絞っているのには理由があります。私はこの構造化ログを後から grep '"event":"model_preflight"' で拾い、どの実行がどのモデルに落ち着いたかを並べて見ます。候補が全滅したら例外で止める設計にしているのは、引退したモデルで走り出して空振りの生成物を量産するより、何も作らないほうが復旧が速いからです。無人で動くものほど、止まるべき時に潔く止まる設計が効いてきます。
プローブのコストと、結果をどこまでキャッシュしてよいか
プローブは安いとはいえ無料ではありません。max_tokens: 1 でも入力トークンと最小の出力は課金されます。とはいえ実測では、起動時プローブは1回あたり10トークン前後で収まります。私の個人開発のバッチは本処理1回で数千トークンを使うので、プローブが占める比率は0.3%未満で、ほぼ誤差です。引退モデルへ高価なリトライを何度もぶつける場合と比べれば、消費は桁違いに小さくなります。
キャッシュについては線引きが必要です。プローブ結果は「その実行のあいだ」だけ固定し、実行をまたいで使い回さないのが私の方針です。Fable 5 が3日で撤回されたことが示すとおり、可用性は時間単位で変わり得ます。前回の起動で通ったから今回も通る、とは限りません。逆に、1回の実行の中で何度も probe を打つ必要はありません。起動時に1回確定させ、その実行内では固定。この粒度が、コストと鮮度のちょうど良い妥協点だと考えています。
頻繁に発火するジョブ(たとえば5分おき)でプローブのコストが気になるなら、プローブ結果に短いTTL(数分)を持たせて同一プロセス内で共有する折衷案もあります。ただしTTLを延ばすほど「消えたモデルにしばらく気づかない」リスクが戻ってくるので、無人実行では短めに倒すのが無難です。
次の一歩
まずは手元のスケジュールジョブを1つ、次の順で書き換えてみてください。
- 使いたいモデルを優先順に並べた候補リストを、設定の入口の1か所に定義する
- 本処理の直前に
buildRunConfig() を1回だけ呼び、適格なモデルを確定する
- 本処理には解決済みの
config.model だけを渡し、モデルIDを直接触らせない
この分離ができていれば、次にどのモデルが引退・撤回されても、直すのは候補リストの1か所だけで済みます。無人で回り続けるものほど、壊れ方をあらかじめ決めておくことが、いちばんの安心材料になります。