1
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

複数バージョンの Claude Code を本番で回すための互換性テスト 5 層設計:ゲートウェイ・beta・MCP の変更を版ごとに拾う

1
Posted at

Claude Code を社内の LLM ゲートウェイ越し、あるいは Bedrock・Vertex・Foundry 越しで使っていると、マイナーバージョンが上がるたびに「この版は自社の経路で通るのか」という確認が必要になります。手元で一度通ったので展開したところ、別の版を使っているメンバーだけ全リクエストが失敗する、という状況は、複数バージョンが同時に動く本番では起こり得ます。

互換性の問題は「送る内容の変化」と「拒否への反応」の組で起きます

公式の changelog ページは GitHub 上の CHANGELOG.md から生成されています(2026-10-29 取得)。このページには、直近 2 か月だけでもゲートウェイ・互換サーバー関連の修正が並んでいます。

  • 2.1.268(2026-09-10): サードパーティの Anthropic 互換エンドポイントで全ターンが HTTP 400 になる問題を修正
  • 2.1.295(2026-10-08): ゲートウェイ等が context-1m ベータを拒否すると [1m] モデルで全リクエストが失敗する問題を修正
  • 2.1.304(2026-10-19): ゲートウェイ越しで context_management フィールドが拒否される問題を修正
  • 2.1.307(2026-10-21): prompt-caching-scope ベータヘッダが効果のない経路にも送られる問題を修正
  • 2.1.310(2026-10-23): effort ベータ値や会話途中の system メッセージを拒否するサーバー越しの失敗を修正

Fixed every turn failing with HTTP 400 on third-party Anthropic-compatible endpoints

2.1.268 の記述です。「全ターン」と書かれているため、一部の機能だけが落ちるのではなく、利用者から見ると Claude Code 全体が使えなくなる種類の不具合だと読めます。

並べると型が見えます。ある版が新しい beta 値やフィールドを送り、互換サーバーが 400 で拒否し、後続の版が該当要素を外して再送するようになる。修正版が出ても、古い版を使い続ける環境は残ります。これを手動確認ではなく、版 × 拒否パターンの表として持つのがこの記事の目的です。

この記事で手に入るもの

  • 検証を 5 層に分ける設計(静的/ワイヤ契約/拒否時の挙動/自己申告/実エンドポイント)
  • 空のディレクトリからコピペで動く、偽ゲートウェイとマトリクスランナー(Python 標準ライブラリのみ)
  • 判定を PASS・FAIL・XFAIL・NOT-TRIGGERED に分ける理由と、docs に書かれていない運用上の判断

最初の一歩:いま動いている版を数える

インストール済みの版は claude --version で確認できます(changelog に記載)。

claude --version

成功条件は、終了コード 0 でバージョン番号が表示されることです。表示された番号を changelog の該当エントリと突き合わせ、自分の環境が上の修正境界のどちらの側にあるかを把握してください。複数人・複数マシンで使っているなら、この出力を集めて版の一覧を作ります。有料部分では、その一覧をそのままテスト行列にします。

通し実装:空のディレクトリから互換性マトリクスまで

前提

  • Linux または macOS、bash、Node.js と npm、Python 3.11 以上
  • 検証対象は 2.1.295、2.1.304、2.1.310、2.1.312。上の修正境界と、2026-10-29 時点の changelog 先頭の版です。npm レジストリに存在する前提で、無ければ changelog から実在する版に置き換えてください
  • API キーは不要です。偽ゲートウェイに向けるため、ダミー文字列を渡します

ディレクトリ構成は次のとおりです。

cc-compat/
  gateway.py
  run_matrix.py
  lint_shared_settings.py
  versions/<版>/   (npm が作ります)

手順 1:版ごとに隔離してインストールする

mkdir cc-compat && cd cc-compat
mkdir versions
for v in 2.1.295 2.1.304 2.1.310 2.1.312; do
  npm install --prefix "versions/$v" "@anthropic-ai/claude-code@$v"
done
for v in 2.1.295 2.1.304 2.1.310 2.1.312; do
  DISABLE_UPDATES=1 "versions/$v/node_modules/.bin/claude" --version
done

成功条件は、2 つ目のループが 4 回とも終了コード 0 で、各行に対応する版番号が含まれることです。出力の細かい書式は本稿では確認していません。npm が ETARGET などで失敗する場合は、その版がレジストリに無いので別の版に置き換えます。

DISABLE_UPDATES は 2.1.118(2026-04-23)で追加された環境変数で、手動の claude update を含む全更新経路を塞ぎます。DISABLE_AUTOUPDATER より厳格です。テスト対象の版が実行中に変わらないよう、ランナーでも常に付けます。

手順 2:偽ゲートウェイを置く

beta ヘッダまたは body のトップレベルのキーを、指定どおりに 400 で拒否する最小の Messages API 互換サーバーです。全リクエストを JSON Lines で記録します。

# gateway.py  Python 3.11+  標準ライブラリのみ
import json, os, sys
from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer

MODE = os.environ.get("GW_MODE", "ok")  # ok | beta:<部分文字列> | field:<トップレベルのキー>
LOG = os.environ["GW_LOG"]
KIND, _, KEY = MODE.partition(":")


def sse(events):
    return "".join(f"event: {e['type']}\ndata: {json.dumps(e)}\n\n" for e in events).encode()


class Handler(BaseHTTPRequestHandler):
    def log_message(self, *args):
        pass

    def reply(self, status, body, ctype="application/json"):
        self.send_response(status)
        self.send_header("content-type", ctype)
        self.send_header("content-length", str(len(body)))
        self.end_headers()
        self.wfile.write(body)

    def do_POST(self):
        raw = self.rfile.read(int(self.headers.get("content-length", 0)))
        try:
            body = json.loads(raw or b"{}")
        except ValueError:
            body = {}
        beta = self.headers.get("anthropic-beta", "")
        reject = (KIND == "beta" and KEY in beta) or (KIND == "field" and KEY in body)
        status = 400 if reject else 200
        with open(LOG, "a") as f:
            f.write(json.dumps({
                "path": self.path, "status": status, "beta": beta,
                "keys": sorted(body), "model": body.get("model"),
                "session": self.headers.get("x-claude-code-session-id"),
            }) + "\n")
        if reject:
            err = {"type": "error", "error": {
                "type": "invalid_request_error",
                "message": f"{KEY}: Extra inputs are not permitted"}}
            return self.reply(400, json.dumps(err).encode())
        if "count_tokens" in self.path:
            return self.reply(200, b'{"input_tokens": 1}')
        if not self.path.startswith("/v1/messages"):
            return self.reply(404, b'{"type":"error","error":{"type":"not_found_error","message":"not found"}}')
        msg = {"id": "msg_test", "type": "message", "role": "assistant",
               "model": body.get("model", "test"), "content": [],
               "stop_reason": None, "stop_sequence": None,
               "usage": {"input_tokens": 1, "output_tokens": 1}}
        if not body.get("stream"):
            msg.update(content=[{"type": "text", "text": "OK"}], stop_reason="end_turn")
            return self.reply(200, json.dumps(msg).encode())
        self.reply(200, sse([
            {"type": "message_start", "message": msg},
            {"type": "content_block_start", "index": 0, "content_block": {"type": "text", "text": ""}},
            {"type": "content_block_delta", "index": 0, "delta": {"type": "text_delta", "text": "OK"}},
            {"type": "content_block_stop", "index": 0},
            {"type": "message_delta", "delta": {"stop_reason": "end_turn", "stop_sequence": None},
             "usage": {"output_tokens": 1}},
            {"type": "message_stop"},
        ]), "text/event-stream")


ThreadingHTTPServer(("127.0.0.1", int(sys.argv[1])), Handler).serve_forever()

拒否の文言は 2.1.304 の changelog に出てくるエラーを模した、私の側の固定値です。実ゲートウェイの文言とは一致しない可能性があります。

Fixed context_management: Extra inputs are not permitted errors behind LLM gateways

この修正の後半は、Claude Code がそのフィールドを外してリクエストを再送するようになった、という内容です。同じ 2.1.304 の changelog には、ゲートウェイが 400 または 422 を返したとき当該機能なしで再試行される、という記述もあります。偽ゲートウェイが 400 を返すのはこの挙動を狙っているためです。

手順 3:マトリクスランナーを置く

版 × シナリオの各セルで、偽ゲートウェイを起動し、その版の claude を print mode で 1 回走らせ、記録から判定します。

# run_matrix.py  使い方: python3 run_matrix.py 2.1.295,2.1.304,2.1.310,2.1.312
import json, os, socket, subprocess, sys, tempfile, time
from pathlib import Path

ROOT = Path(__file__).resolve().parent
# (シナリオ, 使うモデル)。モデル None は既定のまま
SCENARIOS = [
    ("ok", None),
    ("beta:context-1m", "sonnet[1m]"),
    ("field:context_management", None),
    ("beta:effort-2025-11-24", None),
]
# changelog 上で「外して再送」が入った版。これより前で FAIL なら既知の不具合(XFAIL)
FIXED_IN = {
    "beta:context-1m": (2, 1, 295),
    "field:context_management": (2, 1, 304),
    "beta:effort-2025-11-24": (2, 1, 310),
}


def ver(s):
    return tuple(int(x) for x in s.split("."))


def parse(line):
    try:
        v = json.loads(line)
    except ValueError:
        return {}
    return v if isinstance(v, dict) else {}


def free_port():
    with socket.socket() as s:
        s.bind(("127.0.0.1", 0))
        return s.getsockname()[1]


def wait_port(port):
    for _ in range(50):
        try:
            socket.create_connection(("127.0.0.1", port), 0.2).close()
            return
        except OSError:
            time.sleep(0.1)
    raise RuntimeError("gateway did not start")


def judge(mode, msgs, rc):
    if mode == "ok":
        ok = rc == 0 and bool(msgs) and all(r["status"] == 200 for r in msgs)
        return "PASS" if ok else "FAIL"
    kind, _, key = mode.partition(":")
    bad = [i for i, r in enumerate(msgs) if r["status"] != 200]
    if not bad:
        return "NOT-TRIGGERED"

    def has(r):
        return key in r["beta"] if kind == "beta" else key in r["keys"]

    recovered = any(r["status"] == 200 and not has(r) for r in msgs[bad[-1] + 1:])
    return "PASS" if rc == 0 and recovered else "FAIL"


def run_cell(version, mode, model):
    claude = ROOT / "versions" / version / "node_modules" / ".bin" / "claude"
    base = {k: v for k, v in os.environ.items()
            if not k.startswith(("ANTHROPIC_", "CLAUDE_CODE_", "AWS_"))}
    with tempfile.TemporaryDirectory() as tmp:
        log = Path(tmp) / "gw.jsonl"
        log.touch()
        port = free_port()
        gw = subprocess.Popen(
            [sys.executable, "-I", str(ROOT / "gateway.py"), str(port)],
            env={**os.environ, "GW_MODE": mode, "GW_LOG": str(log)})
        try:
            wait_port(port)
            env = {**base,
                   "ANTHROPIC_BASE_URL": f"http://127.0.0.1:{port}",
                   "ANTHROPIC_API_KEY": "test-key",
                   "CLAUDE_CONFIG_DIR": str(Path(tmp) / "config"),
                   "DISABLE_UPDATES": "1"}
            cmd = [str(claude), "-p", "Reply with OK",
                   "--output-format", "stream-json", "--verbose"]
            if model:
                cmd += ["--model", model]
            try:
                p = subprocess.run(cmd, cwd=tmp, env=env, capture_output=True,
                                   text=True, timeout=180)
                rc, out, err = p.returncode, p.stdout, p.stderr
            except subprocess.TimeoutExpired:
                rc, out, err = -1, "", "timeout"
        finally:
            gw.terminate()
        recs = [json.loads(l) for l in log.read_text().splitlines()]
    msgs = [r for r in recs
            if r["path"].startswith("/v1/messages") and "count_tokens" not in r["path"]]
    result = judge(mode, msgs, rc)
    if result == "FAIL" and ver(version) < FIXED_IN.get(mode, (0,)):
        result = "XFAIL"
    init = next((e for e in map(parse, out.splitlines())
                 if e.get("type") == "system" and e.get("subtype") == "init"), {})
    return {
        "version": version, "scenario": mode, "result": result, "rc": rc,
        "requests": len(msgs), "rejected": sum(r["status"] != 200 for r in msgs),
        "session_header": any(r["session"] for r in msgs),
        "unrecognized_model": "[claude-code:unrecognized_model]" in err,
        "init_errors": {k: init[k] for k in ("mcp_server_errors", "plugin_errors") if k in init},
        "wire": [{"beta": r["beta"], "keys": r["keys"], "status": r["status"]} for r in msgs],
        "stderr_tail": err[-300:] if result == "FAIL" else "",
    }


def main():
    rows = [run_cell(v, m, model) for v in sys.argv[1].split(",") for m, model in SCENARIOS]
    for r in rows:
        print(f"{r['version']:9} {r['scenario']:26} {r['result']:14} reqs={r['requests']} "
              f"rejected={r['rejected']} rc={r['rc']} session_header={r['session_header']} "
              f"unrecognized_model={r['unrecognized_model']}")
        if r["stderr_tail"]:
            print("   stderr:", r["stderr_tail"].strip())
    Path("matrix.json").write_text(json.dumps(rows, indent=2, ensure_ascii=False))
    sys.exit(1 if any(r["result"] == "FAIL" for r in rows) else 0)


main()

env の掃除(ANTHROPIC_ / CLAUDE_CODE_ / AWS_ で始まる変数を落とす)は重要です。シェルに CLAUDE_CODE_USE_BEDROCK のようなプロバイダー切替変数が残っていると、偽ゲートウェイに向かない行が静かに混ざります。

手順 4:実行して読む

python3 run_matrix.py 2.1.295,2.1.304,2.1.310,2.1.312; echo "exit=$?"

出力は版 × シナリオの 16 行で、各行は次の形式です。

<版> <シナリオ> <PASS|FAIL|XFAIL|NOT-TRIGGERED> reqs=<n> rejected=<n> rc=<n> session_header=<True|False> unrecognized_model=<True|False>

本稿では 4 つの版すべてでの実行結果を掲載していません。実機の出力を採取していないためです。見方だけ書きます。

  • 成功とは、終了コードが 0(FAIL が 1 行も無い)で、matrix.json が作られることです
  • 最新の 2.1.312 の全セルは PASS か NOT-TRIGGERED になるはずです。FAIL なら退行の候補で、直後に stderr の末尾 300 文字が出ます
  • 境界より前の版は XFAIL、NOT-TRIGGERED、PASS のいずれもあり得ます。境界より前なのに PASS なら、FIXED_IN の表が実態と合っていません。changelog の版番号は「修正が入った版」であり、不具合の混入版とは限らないためです
  • ok シナリオが全版で FAIL なら、拒否の判定以前の問題です。stderr の末尾を見てください。認証や初回確認の扱いが版や環境で違う可能性があり、本稿では確認していません
  • 全セルが reqs=0 の場合は、リクエストが偽ゲートウェイに届いていません。ANTHROPIC_BASE_URL が効いていない可能性があります

手順 5:共有設定の静的チェック(第 0 層)

2.1.281(2026-09-23)で追加された "attribution": false について、changelog は「古い CLI 版ではこれを含む設定ファイルがスキップされるため、複数版で共有するファイルではオブジェクト形式を維持する」と案内しています。静的に検出できるので、CI の最初に置きます。

# lint_shared_settings.py  使い方: python3 lint_shared_settings.py <settings.json>...
import json, sys

bad = [p for p in sys.argv[1:] if isinstance(json.load(open(p)).get("attribution"), bool)]
for p in bad:
    print(f"{p}: attribution is a boolean; keep the object form in files shared across versions")
sys.exit(1 if bad else 0)
echo '{"attribution": false}' > /tmp/bad-settings.json
python3 lint_shared_settings.py /tmp/bad-settings.json; echo "exit=$?"

期待される出力は、メッセージ 1 行と exit=1 です(このスクリプト自身の出力で、固定です)。

/tmp/bad-settings.json: attribution is a boolean; keep the object form in files shared across versions
exit=1

docs に無い設計判断(以下は筆者の設計意見です)

1. 完全一致のスナップショットではなく、条件付きの不変条件を gate にする

changelog を見ると、送られる beta ヘッダは版ごとに増減します。2.1.43 では structured-outputs ベータヘッダが Vertex/Bedrock に無条件に送られる問題が直され、2.1.307 では prompt-caching-scope ヘッダがカスタムゲートウェイ越しと Foundry では送られなくなりました。ヘッダ集合の完全一致を gate にすると、リリースのたびに直す価値のない失敗が出ます。

そこで gate は「拒否されたら、拒否された要素を含まない 200 が後続する」という条件付きの不変条件だけにします。ヘッダ集合そのものは matrix.json の wire に記録するだけにして、版間の差分を人が見ます。

2. NOT-TRIGGERED を PASS と別の状態にする

2.1.307 のように「送られなくなった」版では、その要素を拒否するシナリオは成立しません。成立しなかったテストを PASS と数えると、実際には何も検証していないのに緑になります。判定を 4 値にしたのはこのためです。NOT-TRIGGERED が増えたら、シナリオが現行の版に追従できていないという信号として扱います。

3. 回復まで要求する

a run that ends on an API error now scores 0 without grading

これは 2.1.299 で、claude plugin eval が API に拒否された実行を合格にしていた問題の修正です。API エラーで終わった実行は 0 点になり、スイートは exit 1 になります。互換性テストにも同じ原則が当てはまります。ランナーが「拒否が記録された」だけで合格にせず、終了コード 0 かつ「拒否要素を含まない 200」まで要求しているのはこのためです。

4. 失敗系は遅いので、PR の gate から外す

再試行の上限は版ごとに変わっています。2.1.286(2026-09-30)以降、既定の再試行設定で失敗し続ける呼び出しは最大 14 リクエストを送ります。CLAUDE_CODE_MAX_RETRIES の上限は 2.1.186 で 15 になりました。ストリームが 5 分間イベントを出さないと中断して再試行する、という記述も 2.1.196 にあります(いずれも 2026-10-29 時点の changelog)。

このランナーの 3 つの拒否シナリオは「1 回拒否されて回復する」形なので短く済む想定です。ただし所要時間は実測していません。ランナーの 180 秒という timeout も私が置いた値です。常に失敗し続けるシナリオを足すなら、PR の gate ではなく夜間ジョブに置き、timeout を伸ばしてください。

5. 検証対象の版は一箇所で決める

本番側にも版の制御手段があります。

  • requiredMinimumVersion と requiredMaximumVersion の managed settings(2.1.163)。許容範囲外の Claude Code は起動を拒否し、承認済みの版へ誘導します
  • DISABLE_UPDATES(2.1.118)
  • /config のリリースチャネル(stable または latest)の切り替え(2.1.3)

versions.txt を 1 つ置き、(a) CI のマトリクス、(b) managed settings の上限・下限、の両方をそこから生成するようにすると、「テストしていない版が本番で動く」状態を防げます。managed settings の値の書式は docs で確認してください。さらに latest チャネルの最新版を毎日 1 セルだけ走らせます。この canary は gate にせず、FAIL が出たら FIXED_IN 表への追記候補として扱います。

6. 偽ゲートウェイは自社ゲートウェイの代役ではない

偽ゲートウェイが検証するのは Claude Code 側の反応(外して再送するか)だけです。自社ゲートウェイが実際に何を拒否するかは分かりません。第 4 層として、ベース URL を staging のゲートウェイに向け、ゲートウェイ側のリクエストログを同じ JSON Lines の形で出させれば、judge 関数をそのまま使えます。

観測用のヘッダも記録対象にできます。2.1.86 で追加された X-Claude-Code-Session-Id は、プロキシが本文を解析せずセッション単位で集計できるようにするものです。ランナーの session_header 列はその有無を見ています。2.1.273 の LLM ゲートウェイ向けリクエストヘッダ(x-claude-code-request-class など)は、CLAUDE_CODE_GATEWAY_HINT_HEADERS=1 でオプトインします。使う場合は、偽ゲートウェイの記録に x-claude-code- で始まるヘッダを全部足せば、版ごとの違いを matrix.json で見られます。

応用と既知の限界

応用

  • MCP のプロトコルネゴシエーション:2.1.292(2026-10-06)から、Bedrock・Vertex・Foundry を含む全インストールで、MCP プロトコルバージョン 2026-07-28 を既定でネゴシエートします。

Fixed MCP servers showing no tools, or every tool call failing

2.1.302 の修正で、サーバーが 2026-07-28 をネゴシエートする場合の症状です。自社の MCP サーバーを --mcp-config で渡し、ツールが見えること、init イベントの mcp_server_errors(2.1.219 で追加、設定検証でスキップされた項目を列挙)が空であることを版ごとに確認できます。ランナーの init_errors 列はその入口です。

  • モデル周りの自己申告:print mode では、要求したモデルが非推奨または新モデルへ自動更新される場合に警告が出ます(2.1.183)。Claude Code が認識しないモデル ID でリクエストが出るときは [claude-code:unrecognized_model] の行が stderr に書かれます(2.1.233)。ランナーは後者を unrecognized_model 列に出しています。固定したモデル ID を使う環境では、この列が True になった時点で気づけます。
  • ネットワークに依存しない検証:Bedrock・Mantle・Vertex・Foundry 上の Claude Code は、2.1.311 から downloads.claude.ai の署名付きファイルでモデル設定を読み込みます。CLAUDE_CODE_MODEL_CATALOG=off で無効化できるので、サードパーティ経路の検証を外部通信なしで固定したいときの選択肢になります。
  • プラグインの eval:2.1.269 の claude plugin eval は、プラグインの eval スイートを Claude Code に対して実行し、スコア付きの再現可能な結果(JSON と HTML レポート)を出します。版を変えて同じスイートを回せば、挙動の差を第 3 層の検証に加えられます。
  • API コード側の移行:2.1.307 では /claude-api api-version が追加され、Claude API コードを API version 2026-10-21 へ移行できるようになりました。自前の SDK 利用コードを持つ場合は、こちらの変更も同じ表で追えます。

限界

  • この記事の実装は、4 つの版すべてでの実機実行結果を掲載していません。stream-json の init イベントが type=system、subtype=init であるという前提と、-p で ANTHROPIC_API_KEY だけで走るという前提は私の手元知識で、changelog 由来ではありません。動かない場合は stderr の末尾から確認してください
  • FIXED_IN の境界は changelog の記載版です。混入版との差で、境界より前の版が PASS になることがあります
  • 偽ゲートウェイは Bedrock・Vertex・Foundry の認証やパスを再現しません。それらの経路は第 4 層の実エンドポイントで確認する必要があります
  • 対話モードは対象外です。print mode でのみ検証しています

出典について

本稿の技術記述は、公式ドキュメントとの逐語照合を通過したものだけを載せています(2026-10-29 検証)。

References

[1] https://code.claude.com/docs/en/changelog

1
0
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
1
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?