エージェントプロキシ
開発版です。 このページは、まだリリースしていないブランチnextのngx_mruby 3.xを説明します。現在のリリースは2.7です。下のレシピは現在のnextで動き、CIが確かめています。図は、v3の計画に沿ってv3.0.0に向けて作るエージェントプロキシを示します。図の各ステップには、現在動くか、計画のどのステップで加わるかを書いています。
エージェントプロキシは、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の完成像
Section titled “実装状況と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の例は、実装と対応するテストが入るまで、実行可能であることの証拠にはしません。
現在動くレシピ
Section titled “現在動くレシピ”次に示す設定は、同じレシピで、両方の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行に書きます。
開発中のバージョン、3.0で追加
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"}}'; }}r = Nginx::Request.newv = Nginx::Var.newkeys = { "agent-test-key-1" => "key-1", "agent-test-key-2" => "key-2" }auth = r.headers_in["Authorization"].to_sid = 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::DECLINEDelse Nginx.return Nginx::HTTP_UNAUTHORIZEDendr = Nginx::Request.newv = Nginx::Var.newroutes = { "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::DECLINEDelse Nginx.return Nginx::HTTP_FORBIDDENendr = Nginx::Request.newv = Nginx::Var.newNginx.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}"# 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=0api_key=200 12372 truemock_requests=1# 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 truemodel_b=200 12373 Bearer upstream-test-key-b none trueunknown_model=403# 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 3same_key=429 1 application/json {"type":"error","error":{"type":"rate_limit_error","message":"too many concurrent requests"}}other_key=200 12372limit_log_lines=1after_close=200 12372# 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=200overloaded=529 application/json {"type":"error","error":{"type":"overloaded_error","message":"mock: status 529"}}ok_log~=key=key-1 upstream_status=200 status=200overloaded_log~=key=key-1 upstream_status=529 status=529CIが動くたびに、ケースのtest/t/cases/agent_proxy.rbがクライアントでリクエストを送り、クライアントが出力する値を、ここに載せた値と比べます。9個のファイルを最後に変えたのは、2026-10-29のコミット300e251cです。
テストのクライアントのtest/t/cases/_agent_proxy_client.rbは、各レシピのリクエストを/v1/messagesへ送り、値を1行に1つずつ出力します。上のファイルは、Messages APIについてケースが比べる値です。ケースは/v1/responsesでも同じレシピを比べ、各APIの50個のデルタのストリームが1バイトも違わずに届くことも確かめます。
v3.0のリクエスト
Section titled “v3.0のリクエスト”開発中のバージョン、3.0で追加
nextでagent_proxy.rbがlimit_conn。modelでbody_jsonがmodelをproxy_passが、error_page 529 = @nameで8d1500bf0の行です。docs/proposals/v3-plan.mdの407行から447行、558行から576行test/conf/conf.d/60-agent-proxy.confの14行から111行
v3.0のストリームのレスポンス
Section titled “v3.0のストリームのレスポンス”開発中のバージョン、3.0で追加
nextでagent_proxy.rbがproxy_read_timeoutはmessage_startとmessage_delta、response.*イベントからaccess_logのaccess_logが8d1500bf0の行です。docs/proposals/v3-plan.mdの407行から447行、558行から576行test/conf/conf.d/60-agent-proxy.confの44行から55行
v3.0のエンドポイント
Section titled “v3.0のエンドポイント”開発中のバージョン、3.0で追加
nextでagent_proxy.rbがGET /v1/modelsと、POST /v1/messagesとPOST /v1/responsesは、Upgradeを8d1500bf0の行です。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にあります。計画した機能のサンプルは、その機能とテストがマージされたときにこのページに載ります。
リクエストあたりのコスト
Section titled “リクエストあたりのコスト”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) |
各シナリオは、性能の比較に入れたエージェントプロキシのシナリオにあります。