コンテンツにスキップ

エージェントプロキシ

開発版です。 このページは、まだリリースしていないブランチ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の完成予定の構成です。状態の意味を次に示します。

  • 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行に書きます。

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

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"}}';
}
}
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
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
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}"
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
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
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
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

CIが動くたびに、ケースの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バイトも違わずに届くことも確かめます。

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

v3.0のエージェントプロキシを通るリクエストのステップを、それぞれが現在動くか、v3の計画のどのステップで加わるかとともに示します。ngx_mrubyのソースの次の行から描きました。2026-10-29のコミット8d1500bf0の行です。
  • docs/proposals/v3-plan.mdの407行から447行、558行から576行
  • test/conf/conf.d/60-agent-proxy.confの14行から111行

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

v3.0のエージェントプロキシがストリームのレスポンスをどう中継し、使用量をどう計上するかを、各ステップが現在動くか、v3の計画のどのステップで加わるかとともに示します。ngx_mrubyのソースの次の行から描きました。2026-10-29のコミット8d1500bf0の行です。
  • docs/proposals/v3-plan.mdの407行から447行、558行から576行
  • test/conf/conf.d/60-agent-proxy.confの44行から55行

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

v3.0のエージェントプロキシが自分で答えるもの、そのまま通すもの、拒否するものを、リクエストの種類ごとに示します。それぞれ現在動くか、v3の計画のどのステップで加わるかも示します。ngx_mrubyのソースの次の行から描きました。2026-10-29のコミット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にあります。計画した機能のサンプルは、その機能とテストがマージされたときにこのページに載ります。

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)

各シナリオは、性能の比較に入れたエージェントプロキシのシナリオにあります。