プログラミング

claude -pを150行のシムでOpenAI互換APIにする(APIキー不要)

Claude Codeの非対話モード claude -p を、OpenAI互換のAPIサーバーに見せかける小さなシムの中身を解説します。追加ライブラリゼロ・Node標準モジュールだけの実装から、プロンプトをstdinで渡す理由、擬似SSEとハートビート、total_cost_usdでのコスト可視化、起動オーバーヘッドと日本語文字化けの罠までまとめました。

目次

はじめに

OpenAIのAPIに対応した便利なツール(RAGツールやチャットUIなど)はたくさんあります。
そこに自分のClaude Maxの契約をそのまま繋げたら、APIキーの課金を増やさずに使えるのに、と思ったことはありませんか。

Claude Codeには claude -pclaude --print の略)という、対話画面ではなくコマンド一発で呼ぶ非対話モードがあります。
これを「OpenAI互換のAPIサーバー」に見せかける小さな変換役(シム)を1枚かませれば、Max契約をいろいろなアプリの生成エンジンにできます。

今回作ったのは、まさにそのシムです。
追加ライブラリはゼロ、Nodeの標準モジュールだけで書きました(コメント込みで260行ほど、変換ロジックの実質は150行くらいの小さなものです)。
この記事では、その中身がどう動いているかを、実装のコードを抜粋しながら説明します。

技術的な細部まで書きますが、コードは雰囲気だけ眺めて読み飛ばしても話が繋がるように、各コードの直後に必ず日本語で種明かしを置きます。
「使えればいい」という方は地の文だけ追ってもらって大丈夫です。
逆に詳しい方は、読み終わる頃には自分で同じものを組める解像度になるはずです。

この記事は、OSS版NotebookLMをclaude -pで動かす検証で実際に作って動かしたシムに基づいています。

全体像:シムは「翻訳機」

やることは一言でいうと翻訳です。
OSSツールは「OpenAIの言葉」で話しかけてきます。
claude -p は「Claude Codeの言葉」しか分かりません。
間に立って、行きと帰りで言葉を翻訳するのがシムの役割です。

[OSSツール]  --OpenAIの言葉(/v1/chat/completions)-->  [シム :8080]  --claudeの言葉(claude -p)-->  [claude (Max)]
[OSSツール]  <--OpenAI互換のJSON--  [シム :8080]  <--結果のJSON(.result)--  [claude (Max)]

シムが公開するエンドポイントは3つだけです。
POST /v1/chat/completions がチャット応答のメインで、ストリーミングにも対応しています。
GET /v1/models はモデル一覧を返すもので、多くのツールが接続時にここを叩いて「繋がったかどうか」を判定します。
GET /health は稼働確認用です。

埋め込み(embeddings)のエンドポイントは実装していません。
claude -p に埋め込みを作るAPIが無いためです。
RAGツールに繋ぐ場合は、埋め込みだけ別の手段を使う前提になります。

心臓部:claude -pを叩いて、結果を取り出す

シムの中心になるのは、claude を起動して結果を受け取る関数です。
ここさえ動けば、あとは形式の詰め替えだけになります。

function runClaude(prompt, model) {
  return new Promise((resolve, reject) => {
    const args = ["-p", "--output-format", "json", "--model", model];
    // (ここに中立化フラグを足す。後述)

    // 実体exeを直接spawnする(shell:trueを使わない)
    const child = spawn(CLAUDE_BIN, args, { cwd: os.tmpdir(), windowsHide: true });

    let out = "";
    child.stdout.on("data", (d) => (out += d));
    child.on("close", (code) => {
      if (code !== 0) return reject(new Error(`claude exited ${code}`));
      try { resolve(JSON.parse(out)); }
      catch { resolve({ result: out.trim(), usage: {} }); }  // JSONでなければ生テキスト扱い
    });

    // プロンプトはUTF-8バイトでstdinに書き込む(argvには乗せない)
    child.stdin.write(Buffer.from(prompt, "utf8"));
    child.stdin.end();
  });
}

日本語にすると「claudeを起動して、出てきた出力をJSONとして読み、ダメなら生テキストとして扱う」だけです。
読みどころは、コードの最後の印の行です。

プロンプトは、コマンドの引数(argv)ではなく、標準入力(stdin)から渡しています。
これは趣味ではなく必然です。
RAGの長い文脈(検索で見つけた文書を全部詰めたプロンプト)を引数に乗せると、Windowsのコマンド長制限に引っかかって落ちます。
stdin から流し込めば、その制限を回避できます。
あわせて Buffer.from(prompt, "utf8") でUTF-8のバイト列として書き込んでいるのは、日本語が文字化けしないようにするためです(このあたりは最後にハマりどころとして書きます)。

cwd: os.tmpdir() で作業ディレクトリを一時フォルダにしているのは、ホスト側の CLAUDE.md などを巻き込まないための用心です。

OpenAIの問い合わせを、1つのプロンプトに畳む

OpenAIの messages は「role(役割)と content の配列」です。
claude -p には1本のプロンプト文字列を渡すので、この配列を1つに畳む必要があります。
その関数がこれです。

function flattenMessages(messages) {
  const parts = [];
  for (const msg of messages || []) {
    const role = msg.role || "user";
    let content = /* 文字列 or 配列を文字列に正規化 */;
    if (role === "system") parts.push(`\n${content}\n`);
    else if (role === "assistant") parts.push(`\n${content}\n`);
    else parts.push(`\n${content}\n`);
  }
  return parts.join("\n\n");
}

やっていることは、system・assistant・userの各メッセージを、それぞれ役割を示すタグで囲んでから縦に連結しているだけです。
タグで囲むのは、claudeに「ここからはシステム指示」「ここからはユーザーの発言」と境界を伝えるためです。
素朴ですが、これで役割の区別が保たれます。

ストリーミングは「擬似SSE+ハートビート」で乗り切る

多くのチャットUIは、答えが少しずつ流れてくるストリーミング表示を期待します。
ところが claude -p は、生成が全部終わってから結果をまとめて返すので、本物のストリーミングはできません。

そこで、「最初に開始の合図だけ送り、生成が終わったら全文を1チャンクで流す」という擬似的なストリーミング(擬似SSE)で対応しました。
ただし生成に時間がかかると、待っている間にクライアント側が「応答が来ない」と接続を切ってしまいます。
これを防ぐために、生成中は5秒おきに合図(ハートビート)を送り続けて接続を生かしておきます。
中心はこれだけです。

res.write(chunk({ role: "assistant" }));          // すぐ開始デルタを送って接続を確立
const hb = setInterval(() => res.write(": ping\n\n"), 5000);  // 5秒ごとに生存信号
const j = await runClaude(prompt, model);          // ここで生成完了を待つ(長い)
clearInterval(hb);
res.write(chunk({ content: j.result }));            // 全文を1チャンクで流す
res.write(chunk({}, "stop"));
res.write("data: [DONE]\n\n");

たとえるなら、宅配便を待っている人に「まだ準備中です」と5秒おきに声をかけ続けて、出来上がったら一気に手渡しする感じです。
見た目はストリーミングっぽく振る舞いつつ、中身は一括、という割り切りです。

計測のおまけ:コストとレイテンシをログに残す

claude -p の返すJSONには total_cost_usd が入っています。
なので1リクエストごとに「どのモデルで・何ミリ秒かかって・いくら換算か」をログに出すようにしました。
Max契約なので実際に課金されるわけではありませんが、API換算のコストとして負荷を可視化できるのは便利です。

ここで1つ注意点があります。
返ってくる usage(入力・出力トークン数)は過少に出ます。
claudeの usage.input_tokens はキャッシュ後の差分だからです。
なので、正確な負荷の指標としては total_cost_usd の方を見るのが正解でした。

使う前に知っておくべき2つのクセ

実用するうえで、最初に知っておいた方がいいクセが2つあります。

1つ目はオーバーヘッドです。
このシムはリクエストごとに毎回claudeプロセスを起動するので、その都度Claude Code自身のシステムコンテキスト(中立化前は約5万トークン)を読み込みます。
そのため、短い応答でもプロセス起動+コンテキスト読込の分のレイテンシが必ず乗ります(haikuでも体感25秒前後のことがありました)。
連打する用途には向きません。

2つ目は、claude -p が「素のLLM」ではないことです。
既定のままだと、あなたの CLAUDE.md やMCP、組み込みツールを継承してしまいます。
外部アプリの入力を食わせるバックエンドとしては危険なので、このシムは中立化フラグを既定でオンにしています。
先ほど省略した runClaude のフラグ部分が、まさにそれです。
これは1本の記事になるくらい大事な話なので、claude -p は素のLLMじゃない、という回に分けて書きました。
あわせて読むことを強くおすすめします。

ハマりどころ:日本語の文字化け

最後に、検証中に一番混乱したハマりを共有します。
シムの動作確認をしていると、claudeが「メッセージが文字化けしています」と返してくることがありました。

切り分けたところ、シム自体は正常でした。
原因は、テストでGit Bashから打っていた curl -d '…日本語…' が、日本語を CP932(Windowsの文字コード)で送ってしまっていたことです。
やっかいなのは、診断のために打ったこちらのテスト文字列まで同じように化けるので、どこが悪いのか分からなくなることです。

回避策は2つあります。

  • テスト用のJSONをUTF-8のファイルで用意して、curl -d @ファイル名 で送る。
  • Pythonのテストスクリプトをファイルに書いて実行し、結果はエディタ(や Read)で見る。

要は、コンソールを直接経由した日本語のやり取りを信用しないことです。
実際のアプリ(AnythingLLMやOpen Notebook)は正しいUTF-8のJSONを送ってくるので、この問題は出ません。
あくまでコンソールでテストするときの罠でした。

まとめ

いかがでしたか。
今回は、claude -p をOpenAI互換のAPIに見せかける小さなシムの中身を、実装の抜粋を交えて説明しました。

振り返ると、肝は次の4つです。

  • claude -p --output-format json.result を、OpenAIの choices[0].message.content に詰め替える翻訳
  • 長い文脈を通すために、プロンプトを stdin からUTF-8で流し込む
  • ストリーミングは擬似SSE+ハートビートで見た目だけ成立させる
  • total_cost_usd でコストとレイテンシを可視化する

追加ライブラリゼロでここまでできるので、「OpenAI互換」と書いてあるツールに手元のClaudeを繋いでみたい方は、作ってみると面白いと思います。
この記事の構成と抜粋をそのままClaudeやChatGPTに渡して、自分の環境向けに組み立て直してもらうのもアリです。

シム一式(ZIP・MITライセンス)を動かしてみたい方は、コメントでお知らせください。
無料でお渡しします。
ただし、繋ぐ前にclaude -p は素のLLMじゃない」の回で中立化の話を必ず確認してください。

この記事が誰かの役に立てばうれしいです。