HTTPトレースを読みながらAIツールをデバッグするのに疲れました。コードを書く代わりに。
Claude CodeはAnthropic Messagesを求めました。Codex CLIはOpenAI Responsesを求め、場合によっては自分自身の内部の /backend-api/codex/responses パスを求めました。Gemini CLIはGoogleの v1beta/models/* エンドポイントを求めました。どのツールも「自分のプロトコルが標準だ」と思い込んで動作していました。
面倒だったのは認証ではありませんでした。互換性です。
3つのツールすべてに対して1つのローカルゲートウェイが欲しいなら、同時に3つの問題を解決する必要がありました:
- リクエストのスキーマが異なる
- ストリーミングの形式が異なる
- 画像、ツール、モデル名に関する前提が異なる
変更前の状態
このプロジェクトの前は、「AIのコーディングツールを1つのローカルプロキシで全部使う」という考えは、実際よりも簡単に聞こえていました。
Claude Codeは POST /v1/messages を期待しています。
Codex CLIは次のように呼び出せます:
POST /v1/responses
POST /backend-api/codex/responses
Gemini CLIは次のようなルートを期待しています:
POST /v1beta/models/{model}:generateContent
POST /v1beta/models/{model}:streamGenerateContent
つまり、すべてを同じアップストリームに向けて「うまくいくことを祈る」だけではできません。モデルが概念的に同じであっても、ペイロードやストリームは同じではありません。
私が作ったもの
私はこの互換性レイヤーを CliGate の中に組み込みました。これはローカルのNode.jsプロキシ兼ダッシュボードで、localhost:8081 で動きます。
考え方はシンプルです:
- 各ツールは自分がネイティブに話すプロトコルのままでいる。
- どのプロトコルが到達したかを検出する。
- 選択されたプロバイダが実際に理解できるアップストリーム形式へ、リクエストを翻訳する。
- 元のツールが期待する形式で、レスポンスをストリーミングして返す。
リポジトリのアーキテクチャドキュメントから見ると、公に見える面はこのようになります:
Claude Code -> /v1/messages
Codex CLI -> /v1/responses
Codex CLI -> /backend-api/codex/responses
Gemini CLI -> /v1beta/models/*
サーバーの起動パスは意図的に単純です:
app.post('/responses', handleResponses);
app.post('/v1/responses', handleResponses);
app.use(express.json({ limit: '10mb' }));
registerApiRoutes(app, { port });
この順序は重要です。Codexは、最初に express.json() が触ってはいけないリクエストボディを送ります。
最初の問題:Codexは通常のJSONのように振る舞わない
最も実用的な意外性はCodex CLIでした。
このリポジトリでは src/routes/responses-route.js が /responses と /v1/responses を、express.json() より先に処理します。これはCodexが圧縮されたリクエストボディを送れるためです。このルートはまず生のバイト列を集め、必要に応じて条件付きでそれを伸長(decompress)します:
function decompressZstd(buf) {
if (typeof zlib.zstdDecompressSync === 'function') {
return zlib.zstdDecompressSync(buf);
}
return Buffer.from(fzstdDecompress(buf));
}
小さな違いに聞こえますが、ルート設計全体を変えます。プロキシが早い段階で無条件にJSONだと決め打ちすると、サポートしていると主張する主要なクライアントの1つを壊してしまいます。
そのため、コードパスは次のようになります:
- 生のボディを読み取る
content-encodingを検出する- 必要なら伸長する(decompressする)
- モデルとリクエストの要約を取り出す
- そこから先は転送するか翻訳する
こういった細部が、「マルチツールプロキシ」が本物なのか、READMEレベルでしか本物ではないのかを決めます。
第2の問題:ストリーミングは1つではない
リクエストの翻訳は扱いやすいです。プロキシプロジェクトが通常ぐちゃぐちゃになりがちなのはストリーミングのほうです。
Claude Codeは message_start、content_block_delta、message_stop のようなAnthropicスタイルのSSEイベントを期待しています。
OpenAI Responsesのストリームは別のイベントモデルです。Geminiもまた独自の形を使います。
CliGateはそれを src/translators/ 配下に専用のトランスレータを用意することで解決しています。OpenAI ResponsesのSSEブリッジは良い例です。これはResponsesのイベントを読み取り、ブロックの状態を追跡し、それらをAnthropicのイベントとして再発行します:
if (item?.type === 'function_call') {
currentBlockType = 'tool_use';
yield buildContentBlockStart({
index: blockIndex,
contentBlock: {
type: 'tool_use',
id: currentBlockId,
name: item.name,
input: {}
}
});
}
そのトランスレータはまた、次もマッピングします:
- テキストのデルタ
- 推論のデルタ
- ツール呼び出し引数のデルタ
tool_useやmax_tokensのような停止理由- 使用量メタデータ
そのためClaude Codeは、ネイティブにAnthropicを話したことがない上流に対しても会話でき、しかも理解できるストリームとして受け取れます。
第3の問題:「互換性」は通常、画像やツールでは崩れます
多くのプロジェクトは、最初の画像、ファイル、またはツール呼び出しが出てくるまでは互換性があります。
このリポジトリには、多モーダルおよびツールペイロード用の明示的なノーマライザがあります。たとえば、Anthropicの画像ブロックは OpenAI 形式の input_image パーツに正規化され、リッチな tool_result ペイロードは、プレーンテキストにフラット化されることなく、その構造化された内容を保持します。
対応するユニットテストは、私が最も信頼している部分です。なぜなら、醜いエッジケースをコード化しているからです。
assert.equal(result.toolResults[0].output[1].type, 'input_image');
assert.equal(result.fileParts[0].type, 'input_file');
assert.equal(result.unsupportedTools[0].hostedType, 'web_search_20250305');
また、Anthropicルートには厳密な互換性モードもあります。翻訳によってサイレントにサポートされていないツールが格下げされてしまう場合、プロキシは「すべて問題ない」と見せかけるのではなく、リクエストを拒否できます。
このトレードオフは重要です。偽の互換性は、正直な 400 よりも悪いです。
トランスレータ層がどのように見えるか
トランスレータのコードは、もはや巨大な1つのルートファイルの中に埋もれていません。このリポジトリでは、リクエスト、レスポンス、ノーマライザ、機能(capability)の部品に分割しています:
src/translators/
request/
response/
normalizers/
shared/
1つのリクエストパスは、モデル、指示、ツール選択、リクエストオプションといったメタデータを保持しながら、Anthropic Messages を OpenAI Responses の入力へ変換します:
const request = {
model: anthropicRequest.model || context.defaultModel || 'gpt-5.2-codex',
input: convertAnthropicMessagesToResponsesInput(anthropicRequest.messages || []),
tools,
tool_choice: toolChoice,
...requestOptions,
stream: context.stream ?? anthropicRequest.stream ?? true
};
この分離のおかげで、プロジェクトを拡張しやすくなりました。新しいプロバイダやルートは、そのたびに互換性の全ストーリーを作り直す必要がありません。
私がもっとプロキシ系プロジェクトに真似してほしい部分
このプロジェクトには、純粋なトランスレータ用のユニットテストだけではありません。さらに tests/e2e/ 配下に、実際のツールが使うのと同じエンドポイントを叩くプロトコルシナリオもあります。
テストのドキュメントでは、3つの層が説明されています:
- ユニットおよびプロトコル変換テスト
- プロトコルシナリオテスト
- CLI スモークテスト
シナリオランナーは、明示的に許可されない限り、実際に稼働中のライブサービスで使用中の設定を変更することすら拒否します。プロキシを、おもちゃのテストサーバではなく、すでに実トラフィックを処理している可能性のあるものとして扱っている点が気に入っています。
実際のセットアップは次のようになります
互換性レイヤができたら、ユーザー向けの実際のセットアップは気持ちよく退屈になります。
プロキシを起動します:
npx cligate@latest start
ツールを localhost に向けます:
# Claude Code
export ANTHROPIC_BASE_URL=http://localhost:8081
export ANTHROPIC_API_KEY=any-key
# Codex CLI
chatgpt_base_url = "http://localhost:8081/backend-api/"
openai_base_url = "http://localhost:8081"
その後、プロキシは、リクエストをアカウントプールに送るのか、APIキー提供元に送るのか、ローカル実行環境に送るのか、または別の上流ブリッジに送るのかを判断します。
ツール側は知る必要がありません。
学んだこと
「多くのAIツールのための1つのローカルゲートウェイ」を実現する上で難しいのは、ダッシュボードではありません。
難しいのはプロトコルの表面積です:
- 生のリクエストボディとパース済みのリクエストボディ
- 圧縮されたペイロードとプレーンなペイロード
- SSEのイベントセマンティクス
- ツール呼び出しの形の違い
- モーダル入力(マルチモーダル)の取り扱い
- ロスのある翻訳を拒否するタイミングを決めること
プロキシを「資格情報ルータ」ではなく「互換性プロダクト」として扱うようにしたら、アーキテクチャがずっと明確になりました。
それが、プロジェクトで最も興味深いコードが設定画面ではなく src/routes/* と src/translators/* にある理由でもあります。
もし今まさにAIツールのインフラを作っているなら、次の境界線をどこに引いているのか気になっています:
- 「十分に互換(compatible enough)」
- 「完全に翻訳された(fully translated)」
- 「嘘をつくよりは悪いので、リクエストを拒否する」
CliGateはGitHub上にあります。実装を確認したい場合は、こちらを参照してください。




