自分の端末とモデルの API のあいだに、一枚はさむことにしました。動機は素朴で、どのプロジェクトがどれだけ呼んでいるのかを一箇所で数えたかったのです。
GOOGLE_GEMINI_BASE_URL に自前の URL を書いて、受け取った内容をそのまま出力するだけの小さなサーバーを立てました。動くまでは数分でした。
届いたリクエストのヘッダーを眺めていて、手が止まりました。手元の端末に置いてあるはずの API キーが、そのまま一行として並んでいたのです。
考えてみれば当たり前でした。宛先を書き換えたのは私で、資格情報を外す指示はどこにも書いていません。それでも、設定ファイルを一行いじっただけで鍵の配達先が変わる、という手触りは想像していたよりも軽いものでした。
Antigravity CLI 1.1.13 で GEMINI_API_KEY による直接認証が入り、settings.json に modelProvider: "gemini" を書いて環境変数を渡せばサインインなしで動くようになりました。同じ変更のなかで、独自エンドポイント向けに GOOGLE_GEMINI_BASE_URL を設定できることも案内されています。手順としてはこの経路がいちばん短く、ローカルのエンジンや社内のゲートウェイに向けたい方は、まずここへ行き着くはずです。
だからこそ、向けた先に何が届くのかを一度だけ自分の目で確かめておく価値があります。以下は個人開発の環境で受け側を用意し、実際に記録した内容です。
受け側を用意して、届いたヘッダーをそのまま記録します
推測ではなく記録から始めます。上流へ中継せず、受け取ったヘッダーを配列に貯めるだけのサーバーを置きます。
# echo_srv.py — 受け取ったヘッダーを記録するだけの受け側
import threading, http.server, socketserver
LOGS = {}
def make_handler (name, redirect_to = None ):
LOGS [name] = []
class H ( http . server . BaseHTTPRequestHandler ):
protocol_version = "HTTP/1.1"
def do_POST (self):
n = int ( self .headers.get( "content-length" , 0 ))
self .rfile.read(n) # 本文は捨てます(記録対象はヘッダーです)
LOGS [name].append({ "path" : self .path,
** {k.lower(): v for k, v in self .headers.items()}})
# /final 以外に来たら 1 回だけ別の宛先へ飛ばします
if redirect_to and not self .path.endswith( "/final" ):
self .send_response( 307 ) # 307 はメソッドと本文を保ちます
self .send_header( "Location" , redirect_to)
self .send_header( "Content-Length" , "0" )
self .end_headers()
return
body = b '{"ok":true}'
self .send_response( 200 )
self .send_header( "Content-Length" , str ( len (body)))
self .end_headers()
self .wfile.write(body)
def log_message (self, * a): # 標準のアクセスログは黙らせます
pass
return H
def serve (port, handler):
socketserver.TCPServer.allow_reuse_address = True
srv = socketserver.TCPServer(( "127.0.0.1" , port), handler)
threading.Thread( target = srv.serve_forever, daemon = True ).start()
return srv
307 を選んだのは、302 だと POST が GET に書き換わる実装があり、ヘッダー以外の変数が増えてしまうからです。測りたいものだけが動くようにします。
宛先を書き換えただけの場合、資格情報はそのまま届きます
まず、リダイレクトを挟まない素直な経路です。クライアントが送ったヘッダーがそのまま受け側に現れます。
import requests
HDRS = {
"Authorization" : "Bearer ADC_TOKEN" , # ダミーです。実物は置きません
"x-goog-api-key" : "KEY" ,
"x-goog-user-project" : "my-project" ,
"Content-Type" : "application/json" ,
}
serve( 8914 , make_handler( "plain" ))
requests.post( "http://127.0.0.1:8914/a" , headers = HDRS , json = {}, timeout = 5 )
d = LOGS [ "plain" ][ - 1 ]
print ( sorted (k for k in d if k in ( "authorization" , "x-goog-api-key" )))
# 実行結果: ['authorization', 'x-goog-api-key']
両方とも届きました。base_url の設定は経路を選ぶ設定であって、資格情報の扱いには手を触れていません。当然の結果ですが、ここを目で見ておくと、この後の差分が読みやすくなります。
リダイレクトを一段はさむと、落ちるヘッダーと残るヘッダーが分かれます
ゲートウェイを自前で書くと、リダイレクトはかなりの確率で登場します。ロードバランサの都合、http から https への引き上げ、旧ホストから新ホストへの引っ越し。いずれも一段の 307 で表現されます。
そこで、同じ端末のなかに三通りの経路を用意して比べました。同一ホスト同一ポートへ飛ばす場合、同一ホストの別ポートへ飛ばす場合、そしてリダイレクトなしの場合です。
serve( 8911 , make_handler( "A" , "http://127.0.0.1:8911/final" )) # 同一ホスト・同一ポート
serve( 8912 , make_handler( "B" , "http://127.0.0.1:8913/final" )) # 同一ホスト・別ポート
serve( 8913 , make_handler( "C" )) # 上の飛び先
serve( 8914 , make_handler( "D" )) # リダイレクトなし
def last (name):
d = LOGS [name][ - 1 ]
return d[ "path" ], sorted (k for k in d if k in ( "authorization" , "x-goog-api-key" ))
requests.post( "http://127.0.0.1:8911/a" , headers = HDRS , json = {}, timeout = 5 )
print ( "同一ホスト同一ポート先:" , last( "A" ))
requests.post( "http://127.0.0.1:8912/a" , headers = HDRS , json = {}, timeout = 5 )
print ( "同一ホスト別ポート先 :" , last( "C" ))
requests.post( "http://127.0.0.1:8914/a" , headers = HDRS , json = {}, timeout = 5 )
print ( "リダイレクトなし :" , last( "D" ))
手元での出力はこうなりました。
同一ホスト同一ポート先: ('/final', ['authorization', 'x-goog-api-key'])
同一ホスト別ポート先 : ('/final', ['x-goog-api-key'])
リダイレクトなし : ('/a', ['authorization', 'x-goog-api-key'])
同じ検証を httpx でも走らせたところ、follow_redirects=True を付けた場合に一行一致の同じ結果になりました。実装依存の癖ではなく、両方が同じ規則を実装している、と読むのが妥当です。
経路 Authorization x-goog-api-key
リダイレクトなし(宛先の差し替えのみ) 届く 届く
同一ホスト・同一ポートへ 307 届く 届く
同一ホスト・別ポート へ 307 落ちる 届く
ホスト名は 127.0.0.1 のままで、変わったのはポート番号だけです。それでも別のオリジンとして扱われ、Authorization は外されました。
落ちたのは守られているヘッダーで、残ったのは認証に使っているほうです
ここが、事前の予想と逆になった箇所でした。
私は「リダイレクトを越えても資格情報が付いていく」ことを心配していました。実際に外されていたのは Authorization のほうです。クライアント側に、オリジンをまたぐときは外す、という保護が入っています。
一方で x-goog-api-key は素通りしました。x-goog-user-project も同じく残りました。この二つはクライアントから見れば任意のヘッダーであり、保護の対象になる名前として知られていません。
そして、GEMINI_API_KEY を使う経路で実際に認証を成立させているのは、外された Authorization ではなく、残ったほうです。守られているのは名前が知られているヘッダーだけで、鍵として働いているヘッダーは守られていません。
この非対称は、頭で考えて出てくる種類のものではありません。ドキュメントは自分のクライアントの正しい使い方を説明しているのであって、他人のサーバーへ寄り道したときの挙動までは説明していないからです。
もう一つ、書き添えておきたいことがあります。上の検証はすべて http:// で行いました。鍵の入ったヘッダーを平文で送っているわけですが、クライアントは何も言いません。ローカルのエンジンに向けるときは 127.0.0.1 で閉じるので実害は出にくいのですが、社内のホスト名を書いた瞬間から話が変わります。
手元から鍵を外し、ゲートウェイに持たせます
原因が分かったので、対処は素直です。転送するヘッダーを許可制にして、上流への鍵はゲートウェイが自分で足します。
FORWARD_ALLOW = { "content-type" , "accept" } # 通すものを列挙します(既定は不通過)
def gateway (strict):
class H ( http . server . BaseHTTPRequestHandler ):
protocol_version = "HTTP/1.1"
def do_POST (self):
n = int ( self .headers.get( "content-length" , 0 ))
body = self .rfile.read(n)
inbound = {k.lower(): v for k, v in self .headers.items()}
if strict:
out = {k: v for k, v in inbound.items() if k in FORWARD_ALLOW }
out[ "x-goog-api-key" ] = "UPSTREAM_KEY_HELD_BY_GATEWAY" # 鍵はここだけが知ります
else :
# 素通し版。hop-by-hop ヘッダーだけ落として、あとはそのまま流します
out = {k: v for k, v in inbound.items()
if k not in ( "host" , "content-length" , "connection" )}
requests.post( "http://127.0.0.1:8923/v1" , headers = out, data = body, timeout = 5 )
r = b '{"ok":true}'
self .send_response( 200 )
self .send_header( "Content-Length" , str ( len (r)))
self .end_headers()
self .wfile.write(r)
def log_message (self, * a):
pass
return H
同じクライアントから両方に投げて、上流に届いた識別子を並べます。
素通しゲートウェイ経由で上流に届いた識別子:
authorization -> Bearer ADC_TOKEN
x-goog-api-key -> DEV_LAPTOP_KEY
x-goog-user-project -> my-project
user-agent -> antigravity-cli/1.1.25
遮断ゲートウェイ経由で上流に届いた識別子:
authorization -> (なし)
x-goog-api-key -> UPSTREAM_KEY_HELD_BY_GATEWAY
x-goog-user-project -> (なし)
user-agent -> python-requests/2.34.2
素通し版では、端末の鍵・プロジェクト名・クライアントの版番号までが上流に並びます。遮断版では、上流が受け取るのはゲートウェイの鍵だけになりました。
差分として効いているのは FORWARD_ALLOW の一行です。通さないものを列挙する書き方にすると、新しいヘッダーが増えた日に静かに漏れます。通すものだけを列挙して、残りは既定で落とします。
キャッシュを置くなら、身元の付いたリクエストを先に見分けます
ゲートウェイを立てると、次に欲しくなるのはキャッシュです。同じ問い合わせを二度払いたくない、という気持ちは自然に湧いてきます。
ここは、記事サイトを Cloudflare Workers で運用していて何度か躓いた場所でもあります。私自身、キャッシュ層に会員向けの応答を一度混ぜてしまい、身元の付いたリクエストは先に見分けて素通しさせる、という形へ組み直しました。いまはその判定を入口の最初の分岐に置いています。
モデルの API を前に置く場合も、構造は同じです。鍵やプロジェクト名がリクエストごとに違うなら、それはキャッシュの鍵の一部です。ヘッダーを剥がしてから鍵を作ると、剥がした分だけ別人の応答が混ざります。
判断する場所 見るもの 間違えたときに起きること
転送ヘッダーの許可 通すものの列挙になっているか 増えたヘッダーが黙って上流へ出ます
キャッシュの鍵 身元を表すヘッダーが鍵に入っているか 別の呼び出し元の応答が返ります
ログの保存 ヘッダーを丸ごと記録していないか 鍵がログに残り続けます
三つ目は、この記事の検証そのものが踏みかけた穴です。受け側でヘッダーを全部貯めるコードを書いたのは私で、あれをそのまま常設のゲートウェイに持ち込めば、鍵の入ったログが毎日積み上がります。調べるための道具と、置いておく道具は別に書きます。
常設に移すときに踏みかけた落とし穴
検証用の受け側から常設のゲートウェイへ移す途中で、遮断が一段抜ける書き方をしてしまいました。上流から返ってきた 307 を、そのままクライアントへ返す実装にしていたのです。
def redirecting_gateway ():
# 上流の 307 をそのままクライアントへ返してしまう実装です
class H ( http . server . BaseHTTPRequestHandler ):
protocol_version = "HTTP/1.1"
def do_POST (self):
n = int ( self .headers.get( "content-length" , 0 ))
self .rfile.read(n)
self .send_response( 307 )
self .send_header( "Location" , "http://127.0.0.1:8933/v1" ) # 上流へ直接
self .send_header( "Content-Length" , "0" )
self .end_headers()
def log_message (self, * a):
pass
return H
ゲートウェイ自体は厳格な許可制のままです。それでも、上流が受け取ったものはこうなりました。
ゲートウェイが 307 を返した場合、上流が受け取ったもの:
authorization -> (なし)
x-goog-api-key -> DEV_LAPTOP_KEY
x-goog-user-project -> my-project
端末の鍵が、そのまま上流に届いています。クライアントはリダイレクトを自分で追いかけ、二度目のリクエストを上流へ直接投げます。その二度目にゲートウェイは関与していません。FORWARD_ALLOW は一度も評価されないまま素通りしました。
authorization だけが消えているのは、先ほどのオリジンをまたぐ保護が働いたからです。皮肉なことに、この保護があるせいで「何かは外れている」ように見え、抜けていることに気づくのが遅れました。私がこの穴に気づいたのは、上流側のログを見返した翌日でした。
対処は二つあります。ゲートウェイでリダイレクトを追いかけてから応答だけを返すか、3xx を受け取ったらエラーとして扱って止めるかです。この場合は後者を推奨します。追いかける実装は、追いかけた先が許可した宛先かどうかを毎回確かめる責任を背負い込むからです。止めてしまえば、宛先の集合は設定ファイルの中だけに閉じます。
本番運用に置くなら、3xx を受け取った回数を数えておくのが安全だと感じています。上流の構成が変わった日に、経路が一段伸びたことへ最初に気づける場所になるはずです。
既存の設定を壊さない順番で切り替えます
一度に差し替えると、失敗したときにどこが原因か分からなくなります。手元では次の順で進めました。
受け側だけを立て、GOOGLE_GEMINI_BASE_URL を向けて、届いたヘッダーの名前だけを一度出力します。値は出しません
出力を見て、上流に渡してよいヘッダーを決めます。迷ったものは落とす側に置きます
ゲートウェイに上流の鍵を持たせ、遮断版として動かします。ここで初めて中継が入ります
手元の GEMINI_API_KEY を、ゲートウェイ専用の値へ置き換えます。上流の鍵とは別のものにします
端末側から上流の鍵を消します。消してもなお通ることを確認して、切り替えの完了とします
4 と 5 を分けているのには理由があります。同じ鍵を両方に置いたままだと、遮断が効いているのか、たまたま同じ鍵で通っているだけなのかを区別できません。別の値にして初めて、経路の検証になります。
宛先を決めるのは設定で、鍵を渡すのは私です。 一行の環境変数で経路が変わる手軽さは、そのまま鍵の配達先が変わる手軽さでもあります。設定の軽さと、鍵の重さは釣り合っていません。
なお、サインインできない環境で GEMINI_API_KEY を使う手順そのものはAntigravity CLI 1.1.13 の GEMINI_API_KEY 直接認証をサインイン不可の環境で使う にまとめてあります。社内プロキシの内側に置く場合の接続設計はAntigravity をプロキシの内側で動かす接続設計 が近い話です。
次にやること
まず、いま使っている GOOGLE_GEMINI_BASE_URL の宛先に、この記事の受け側を一度だけ立ててみてください。中継はしなくて構いません。届いたヘッダーの名前を一覧にするだけで、自分の端末が何を配っているかが分かります。
私自身、数えるつもりで立てた受け側から、思っていたのと違うものが見えました。測る道具を先に置くと、設計の判断はそのあとから素直についてきます。
最後までお読みくださり、ありがとうございました。