---
title: "エージェントプロキシ"
language: "ja"
documentation_version: "3.x"
channel: "development"
release_context: null
source_revision: "10297d1e28653321d6996609fc53718d3b7c8278"
working_tree_clean: true
source_path: "docs/ja/guides/agent-proxy.md"
source_sha256: "946e510b5dc488e90c26707895dc7d3f73ca8703f6f93e64501dbc134c68f387"
source_at_revision: true
source_url: "https://github.com/matsumotory/ngx_mruby/blob/10297d1e28653321d6996609fc53718d3b7c8278/docs/ja/guides/agent-proxy.md"
html_url: "https://ngx.mruby.org/ja/guides/agent-proxy/"
markdown_url: "https://ngx.mruby.org/markdown/ja/guides/agent-proxy.md"
last_updated: "2026-10-30"
---

# エージェントプロキシ

**開発版です。** このページは、まだリリースしていないブランチ`next`のngx_mruby 3.xを説明します。現在のリリースは2.7です。下のレシピは現在の`next`で動き、CIが確かめています。図は、v3の計画に沿ってv3.0.0に向けて作るエージェントプロキシを示します。図の各ステップには、現在動くか、[計画](https://github.com/matsumotory/ngx_mruby/blob/next/docs/proposals/v3-plan.md#agent-proxy-the-v30-use-case)のどのステップで加わるかを書いています。

エージェントプロキシは、LLMのプロバイダが提供するAPIの前に置くnginxです。このnginxは、コーディングエージェントとプロバイダの間に入ります。Rubyは、リクエストごとに次の3つを決めます。クライアントのキーが既知か、リクエストがどのupstreamとどのプロバイダの認証情報を使うか、`limit_conn`が数えるキーは何かです。バイトを運ぶのはnginxです。nginxはリクエストを読み、プロバイダのレスポンスを届くたびに中継し、nginx自身のモジュールの制限をかけます。このプロキシは、Anthropic Messages APIの`/v1/messages`と、OpenAI Responses APIの`/v1/responses`を、server-sent eventsのストリームのレスポンスも含めてそのまま通します。2つのAPIの間で形式の変換はしません。

## 実装状況とv3の完成像

以下の図はv3の完成予定の構成です。状態の意味を次に示します。

- `implemented`：`next`に存在します。2.7でリリース済みとは限りません。
- `planned`：合意済みの計画です。APIや実行可能な例は、まだ提供していません。
- `under-design`：詳細が未決定です。文書に構想を載せても、実装済みの契約にはなりません。

| 状態 | 機能 | 根拠・次の段階 |
|---|---|---|
| implemented | クライアントキーの検査、キーごとの同時リクエスト制限、モデルによる振り分けと認証情報の差し替え | 以下のレシピと`test/t/cases/agent_proxy.rb` |
| implemented | Messages/Responsesのバイト列とupstreamエラーを保つ中継 | 同じ結合テストとmock upstream |
| planned | 共有使用量の計上、イベント分割・フィルタ、初回バイトの期限 | 計画7a・7b・7c、以下の図 |
| planned | Host/SNIの選択、フォールバック方針、参照プロキシの通信境界 | 計画の7eとリクエスト・エンドポイントの図 |

安定版2.7の文書は現在の挙動を説明します。予定しているv3のAPIを2.7の設定へコピーしないでください。将来のAPIの例は、実装と対応するテストが入るまで、実行可能であることの証拠にはしません。

## 現在動くレシピ

次に示す設定は、同じレシピで、両方のAPIを処理します。プロキシの後ろには、2つのAPIのモックがあります。モックは`test/soak/mock_llm.rb`で、テストのクライアントがポート12372と12373で起動します。

- server rewriteハンドラの`agent_proxy_auth.rb`は、`Authorization`の`Bearer`か`x-api-key`から、クライアントのキーを探します。既知でないキーには401を返します。nginxは、`error_page 401 @agent_proxy_401`に従って、そのレスポンスにAPIのエラーの形式のJSONのボディを付けます。このリクエストはupstreamに届かずに終わります。
- クライアントのキーごとに数える`limit_conn`は、同時に1つのリクエストだけを許します。1つ目のレスポンスがストリームで届いている間に同じキーで送った2つ目のリクエストには、JSONのボディと`retry-after`の付いた429が返ります。この429は、どのupstreamも送らないステータス460を経由して返すので、upstream自身の429はそのまま通ります。
- accessハンドラの`agent_proxy_route.rb`は、JSONのボディの`model`を読んでupstreamブロックを選び、クライアントの認証情報の代わりにupstreamが受け取る認証情報を設定します。upstreamの無いモデルには403を返します。
- `proxy_buffering off`にした`proxy_pass`は、レスポンスを届くたびに中継します。529のようなupstreamのエラーは、ボディとともにクライアントに届きます。
- logハンドラの`agent_proxy_log.rb`は、リクエストごとに、キーのidと`$upstream_status`を1行に書きます。

<p class="caption development-label">開発中のバージョン、3.0で追加</p>

```nginx title="test/conf/conf.d/60-agent-proxy.conf"
upstream agent_proxy_llm_a {
    server 127.0.0.1:12372;
}

upstream agent_proxy_llm_b {
    server 127.0.0.1:12373;
}

# Concurrent requests per client key: agent_proxy_auth.rb sets the key.
limit_conn_zone $agent_proxy_stream_key zone=agent_proxy_streams:1m;

server {
    listen 18132;

    # Declares the variables that the hooks assign; never requested.
    location = /.agent-proxy-vars {
        internal;
        set $agent_proxy_key_id "";
        set $agent_proxy_stream_key "";
        set $agent_proxy_backend "";
        set $agent_proxy_upstream_auth "";
        return 404;
    }

    # Most variables stay unset on most requests (a rejected key, a
    # request that no hook routed).
    uninitialized_variable_warn off;

    # The client key, checked for every request of this server before a
    # location is chosen. A request without a known key gets 401, and the
    # server-level error_page gives it a JSON body.
    mruby_server_rewrite_handler build/nginx/html/agent_proxy_auth.rb;
    error_page 401 @agent_proxy_401;

    # The upstream gets the credential that agent_proxy_route.rb picked and
    # never the client's.
    proxy_http_version 1.1;
    proxy_set_header Connection close;
    proxy_set_header Authorization $agent_proxy_upstream_auth;
    proxy_set_header x-api-key "";
    proxy_buffering off;

    # The Anthropic Messages API, and the OpenAI Responses API below with
    # the same recipes.
    location = /v1/messages {
        # One request at a time per client key; a second one gets 429 from
        # @agent_proxy_too_many. limit_conn_status is a status that no
        # upstream sends, so that an upstream's own 429 passes unchanged.
        limit_conn agent_proxy_streams 1;
        limit_conn_status 460;
        limit_conn_log_level notice;
        error_page 460 = @agent_proxy_too_many;

        # Picks the upstream block and the credential by the model of the
        # JSON body.
        mruby_enable_read_request_body on;
        mruby_access_handler build/nginx/html/agent_proxy_route.rb;

        # Logs $upstream_status after the response.
        mruby_log_handler build/nginx/html/agent_proxy_log.rb;

        proxy_pass http://$agent_proxy_backend/v1/messages;
    }

    # The zone of limit_conn is per client key, so a stream of a key on
    # either location holds the slot of that key on both.
    location = /v1/responses {
        limit_conn agent_proxy_streams 1;
        limit_conn_status 460;
        limit_conn_log_level notice;
        error_page 460 = @agent_proxy_too_many;

        mruby_enable_read_request_body on;
        mruby_access_handler build/nginx/html/agent_proxy_route.rb;
        mruby_log_handler build/nginx/html/agent_proxy_log.rb;

        proxy_pass http://$agent_proxy_backend/v1/responses;
    }

    # The proxy's own error bodies, in the error format of the API that the
    # client asked for: a named location keeps the request's $uri.
    location @agent_proxy_401 {
        default_type application/json;
        if ($uri = /v1/responses) {
            return 401 '{"error":{"message":"invalid API key","type":"invalid_request_error","param":null,"code":null}}';
        }
        return 401 '{"type":"error","error":{"type":"authentication_error","message":"invalid API key"}}';
    }

    location @agent_proxy_too_many {
        default_type application/json;
        add_header retry-after 1 always;
        if ($uri = /v1/responses) {
            return 429 '{"error":{"message":"too many concurrent requests","type":"rate_limit_error","param":null,"code":null}}';
        }
        return 429 '{"type":"error","error":{"type":"rate_limit_error","message":"too many concurrent requests"}}';
    }
}
```

```ruby title="test/html/agent_proxy_auth.rb"
r = Nginx::Request.new
v = Nginx::Var.new
keys = { "agent-test-key-1" => "key-1", "agent-test-key-2" => "key-2" }
auth = r.headers_in["Authorization"].to_s
id = keys[auth.start_with?("Bearer ") ? auth[7..-1] : r.headers_in["x-api-key"].to_s]
if id
  v.agent_proxy_key_id = id
  v.agent_proxy_stream_key = id
  Nginx.return Nginx::DECLINED
else
  Nginx.return Nginx::HTTP_UNAUTHORIZED
end
```

```ruby title="test/html/agent_proxy_route.rb"
r = Nginx::Request.new
v = Nginx::Var.new
routes = {
  "mock-model-a" => ["agent_proxy_llm_a", "Bearer upstream-test-key-a"],
  "mock-model-b" => ["agent_proxy_llm_b", "Bearer upstream-test-key-b"]
}
route = routes[JSON.parse(r.body)["model"]]
if route
  v.agent_proxy_backend = route[0]
  v.agent_proxy_upstream_auth = route[1]
  Nginx.return Nginx::DECLINED
else
  Nginx.return Nginx::HTTP_FORBIDDEN
end
```

```ruby title="test/html/agent_proxy_log.rb"
r = Nginx::Request.new
v = Nginx::Var.new
Nginx.errlogger Nginx::LOG_NOTICE,
                "agent_proxy_log test_id=#{r.headers_in["x-test-id"]} key=#{v.agent_proxy_key_id} " \
                "upstream_status=#{v.upstream_status} status=#{v.status}"
```

```text title="test/html/agent_proxy_auth.expected：ケースが比べる値"
# What test/t/cases/agent_proxy.rb compares for the client key, one value
# per line: status, Content-Type and body of the response; mock_requests
# counts the requests that reached the mock upstream.
wrong_key=401 application/json {"type":"error","error":{"type":"authentication_error","message":"invalid API key"}}
no_key=401 application/json {"type":"error","error":{"type":"authentication_error","message":"invalid API key"}}
mock_requests_after_rejects=0
api_key=200 12372 true
mock_requests=1
```

```text title="test/html/agent_proxy_route.expected：ケースが比べる値"
# What test/t/cases/agent_proxy.rb compares for routing by model: status,
# port of the upstream, Authorization and x-api-key at the upstream, and
# whether the upstream got the client's body unchanged.
model_a=200 12372 Bearer upstream-test-key-a none true
model_b=200 12373 Bearer upstream-test-key-b none true
unknown_model=403
```

```text title="test/html/agent_proxy_limit.expected：ケースが比べる値"
# What test/t/cases/agent_proxy.rb compares for limit_conn per client key:
# held is the first stream of key-1 (status, Content-Type, how the client
# stopped reading, events read); same_key is a second request of key-1
# while it is open (status, retry-after, Content-Type, body); other_key and
# after_close are requests of key-2 and of key-1 after the stream (status,
# port of the upstream); limit_log_lines counts the lines of limit_conn in
# error.log.
held=200 text/event-stream stopped 3
same_key=429 1 application/json {"type":"error","error":{"type":"rate_limit_error","message":"too many concurrent requests"}}
other_key=200 12372
limit_log_lines=1
after_close=200 12372
```

```text title="test/html/agent_proxy_log.expected：ケースが比べる値"
# What test/t/cases/agent_proxy.rb compares for an upstream error and the
# log handler: ok and overloaded are the status (with Content-Type and body)
# of a request that the mock answers with 200 and with 529; a key with ~=
# is a text that the line of the log handler in error.log contains.
ok=200
overloaded=529 application/json {"type":"error","error":{"type":"overloaded_error","message":"mock: status 529"}}
ok_log~=key=key-1 upstream_status=200 status=200
overloaded_log~=key=key-1 upstream_status=529 status=529
```

<p class="caption sample-caption">CIが動くたびに、ケースの<code>test/t/cases/agent_proxy.rb</code>がクライアントでリクエストを送り、クライアントが出力する値を、ここに載せた値と比べます。<span class="nowrap">9個</span>のファイルを最後に変えたのは、<span class="nowrap">2026-10-29</span>のコミット<span class="nowrap"><code>300e251c</code>です。</span></p>

テストのクライアントの`test/t/cases/_agent_proxy_client.rb`は、各レシピのリクエストを`/v1/messages`へ送り、値を1行に1つずつ出力します。上のファイルは、Messages APIについてケースが比べる値です。ケースは`/v1/responses`でも同じレシピを比べ、各APIの50個のデルタのストリームが1バイトも違わずに届くことも確かめます。

## v3.0のリクエスト

v3.0のエージェントプロキシを通るリクエストのステップを、それぞれが現在動くか、v3の計画のどのステップで加わるかとともに示します。

開発中のバージョン、3.0で追加

図のラベル（順序は接続を表しません）:

- leg-now: 現在：`next`で動き、`agent_proxy.rb`が確かめる
- leg-next: ステップ4：v3の計画の次のステップ
- leg-later: ステップ7aから7e：v3の計画の後のステップ
- key: 現在：server rewriteハンドラがクライアントのキーを確かめる。既知でないキーにはJSONのボディの401
- limit: 現在：クライアントのキーごとの`limit_conn`。同じキーの2つ目のストリームには429
- route: 現在：accessハンドラがボディの`model`で振り分け、認証情報を差し替える。既知でないモデルには403
- budget: ステップ7aと7b：キーのトークンと金額の予算を共有ディクショナリに置く。`body_json`がCで`model`を読む
- upstream: 現在：`proxy_pass`が、選んだupstreamへ、プロバイダの認証情報でリクエストを中継する
- overloaded: 現在：upstreamの529は、ボディとともにクライアントに届く
- fallback: ステップ7e：参照実装のプロキシがプロバイダごとにHostとSNIを設定し、`error_page 529 = @name`で529になったリクエストを別のプロバイダへ送る

Source: ngx_mruby 8d1500bf0 (2026-10-29)

- `docs/proposals/v3-plan.md`: 407-447, 558-576
- `test/conf/conf.d/60-agent-proxy.conf`: 14-111

## v3.0のストリームのレスポンス

v3.0のエージェントプロキシがストリームのレスポンスをどう中継し、使用量をどう計上するかを、各ステップが現在動くか、v3の計画のどのステップで加わるかとともに示します。

開発中のバージョン、3.0で追加

図のラベル（順序は接続を表しません）:

- leg-now: 現在：`next`で動き、`agent_proxy.rb`が確かめる
- leg-next: ステップ4：v3の計画の次のステップ
- leg-later: ステップ7aから7e：v3の計画の後のステップ
- deadline: ステップ7c：最初のバイトの期限で、ヘッダを送らないプロバイダを打ち切る。`proxy_read_timeout`はアイドルのタイムアウトのまま
- relay: 現在：nginxがプロバイダのイベントを、届くたびに、1バイトも変えずに中継する
- frame: ステップ7b：イベントフィルタがCでイベントを区切り、一覧にあるイベントごとにRubyを1回呼ぶ
- usage: ステップ7b：Rubyが`message_start`と`message_delta`、または最後の`response.*`イベントから使用量を読む
- charge: ステップ7a：レスポンスが終わるか切れたときに、共有ディクショナリのカウンタに計上する
- log: ステップ4：logハンドラが`access_log`の前に動き、その後で`access_log`が費用とトークンをログに書く

Source: ngx_mruby 8d1500bf0 (2026-10-29)

- `docs/proposals/v3-plan.md`: 407-447, 558-576
- `test/conf/conf.d/60-agent-proxy.conf`: 44-55

## v3.0のエンドポイント

v3.0のエージェントプロキシが自分で答えるもの、そのまま通すもの、拒否するものを、リクエストの種類ごとに示します。それぞれ現在動くか、v3の計画のどのステップで加わるかも示します。

開発中のバージョン、3.0で追加

図のラベル（順序は接続を表しません）:

- leg-now: 現在：`next`で動き、`agent_proxy.rb`が確かめる
- leg-next: ステップ4：v3の計画の次のステップ
- leg-later: ステップ7aから7e：v3の計画の後のステップ
- own-errors: 現在：プロキシが401と429をJSONのボディで自分で返し、403をnginxのエラーページで返す
- models: ステップ7cと7e：クライアントのモデルの許可リストから答える`GET /v1/models`と、最初のバイトの期限の後の504
- pass: 現在：`POST /v1/messages`と`POST /v1/responses`は、ストリームも含めてそのまま通る
- upstream-errors: 現在：upstreamのエラーは、ボディとともにクライアントに届く
- upgrade: ステップ7e：参照実装のプロキシは、Responses APIのWebSocketのトランスポートである`Upgrade`を拒否する

Source: ngx_mruby 8d1500bf0 (2026-10-29)

- `docs/proposals/v3-plan.md`: 424-447
- `test/conf/conf.d/60-agent-proxy.conf`: 93-110

エージェントプロキシの計画、プロトコルの範囲、ステップの順は、v3の計画の[Agent proxy: the v3.0 use case](https://github.com/matsumotory/ngx_mruby/blob/next/docs/proposals/v3-plan.md#agent-proxy-the-v30-use-case)にあります。計画した機能のサンプルは、その機能とテストがマージされたときにこのページに載ります。

## リクエストあたりのコスト

perfのレーンの`test/perf/compare.sh`は、nginxがリクエストごとに実行する命令の数を、callgrindで数えます。`test/soak/handlers/`にあるレーンのハンドラの`agent_auth.rb`と`agent_route.rb`は、レーン自身の設定で、上のレシピと同じようにクライアントのキーを探し、ボディの`model`で振り分けます。測った日は2026年10月14日です。条件は、`PERF_N=20000`、nginx 1.31.6、aarch64のUbuntu 22.04のコンテナです。測ったのは、`next`のコミット`bc2488a`のツリーに、#561のmruby 4.0.0を入れたものです。結果を次に示します。

| 測った処理 | リクエストあたりの命令の数。括弧の中はGCを除いた数 |
|---|---|
| server rewriteハンドラでの認証の確認。Rubyなしで2 KBのPOSTをプロキシしたときと比べた増分 | 25,276 (23,305) |
| 2 KBのボディの`JSON.parse`と`model`での振り分け。Rubyを使わない同じプロキシと比べた増分 | 85,228 (82,097) |

各シナリオは、[性能の比較に入れたエージェントプロキシのシナリオ](/ja/contributing/test/#agent-proxy-scenarios-in-the-comparison)にあります。
