Lab のサイトを深夜に点検する小さなスクリプトを、Google ログインの無料枠から有料の API キーへ切り替えた夜のことです。AI Studio で新しいキーを作り、ターミナルで export し、gemini -p を叩きました。返ってきたのは、切り替える前には見なかった一行でした——API key not valid. Please pass a valid API key.。
キーは作ったばかりです。コピーも確かめました。それでも同じ一行が三度続き、私は「キーが壊れている」と決めつけて AI Studio でもう一本作り直しました。結果は芳しくありませんでした。四本目を作りかけたところで手が止まり、ようやく「CLI はどのキーを読んでいるのか」を疑ったのです。
最初にお伝えしたいのは、結論です。Gemini CLI が読む GEMINI_API_KEY の出どころは一つではなく、私の手元では四か所ありました。 新しいキーを置いたのは一か所目で、CLI が読んでいたのは三か所目——半年前に置いて忘れていた、すでに削除済みの古いキーでした。
四か所のうち、どこが勝つのか
私が手元で確かめた範囲で、GEMINI_API_KEY の候補地は次の四つです。
| 順 | 場所 | 役割 | 私が忘れていたか |
|---|---|---|---|
| 1 | シェルの環境変数(`export` や `.zshrc`) | すでに値があれば、後続の .env は上書きしません | ここに新キーを置きました |
| 2 | 作業ディレクトリから上へ辿った `.gemini/.env` または `.env` | 最初に見つかった一つだけを読みます | 該当なし |
| 3 | `~/.gemini/.env`(見つからなければ `~/.env`) | 2 が無いときの受け皿 | ここに旧キーが残っていました |
| 4 | `~/.gemini/settings.json` の認証方式 | キーを使うか、Google ログインを使うかを決めます | 切り替え済みでした |
CLI は起動時に、1 のシェル環境を先に持ち、2 と 3 の順で .env を探して空いている名前を埋めます。ですから「ターミナルで export したのに旧キーが読まれる」ことは、本来は起きにくいのです。
それが起きた理由は、私のスクリプトが cron から動いていたことにありました。cron の非対話シェルは .zshrc を読みません。1 は空、2 は無し、3 に旧キー。CLI は正しく動き、正しく古いキーを送り、API は正しく 400 を返していたのです。壊れていたのはキーでも CLI でもなく、私の記憶でした。
なお、4 の認証方式は security.auth.selectedType に gemini-api-key oauth-personal vertex-ai のいずれかで保存されます(古い版では selectedAuthType という名前でした)。ここが oauth-personal のままだとキーは読まれもしないので、症状は「API key not valid」ではなく「無料枠の上限」になります。症状の違いが、疑う場所を教えてくれるのだと後から気づきました。
出どころを一枚で照らすスクリプト
同じ夜に書いて、いまも ~/bin に置いてある点検用のスクリプトです。何を解決するかというと、「CLI がどのキーを読むはずか」を推測でなく列挙で答えることです。キーそのものは先頭 6 文字と文字数だけを出し、画面に全文を残しません。
#!/usr/bin/env bash
# where-is-my-gemini-key.sh — GEMINI_API_KEY の候補地を上から順に照らす
mask() { local v="$1"; [ -z "$v" ] && { echo "(未設定)"; return; }; printf '%s… (%d 文字)\n' "${v:0:6}" "${#v}"; }
read_env() { grep '^GEMINI_API_KEY=' "$1" | head -1 | cut -d= -f2- | tr -d '"'"'"' | tr -d '\r'; }
echo "[1] シェル環境 : $(mask "${GEMINI_API_KEY:-}")"
d="$PWD"; hit=""
while :; do
for f in "$d/.gemini/.env" "$d/.env"; do
if [ -f "$f" ] && grep -q '^GEMINI_API_KEY=' "$f"; then hit="$f"; break 2; fi
done
[ "$d" = "/" ] && break
d="$(dirname "$d")"
done
[ -n "$hit" ] && echo "[2] 作業ディレクトリ側 : $hit → $(mask "$(read_env "$hit")")" || echo "[2] 作業ディレクトリ側 : 該当なし"
for f in "$HOME/.gemini/.env" "$HOME/.env"; do
[ -f "$f" ] && grep -q '^GEMINI_API_KEY=' "$f" && echo "[3] ホーム側 : $f → $(mask "$(read_env "$f")")"
done
echo "[4] settings.json : $(python3 - <<'PY'
import json, os
p = os.path.expanduser('~/.gemini/settings.json')
try:
s = json.load(open(p))
except Exception as e:
print('読めません:', e); raise SystemExit
print(s.get('security', {}).get('auth', {}).get('selectedType') or s.get('selectedAuthType') or '(未選択)')
PY
)"
# [5] CLI を通さず、いま手にしているキーそのものを API に当てる
code=$(curl -s -o /dev/null -w '%{http_code}' -H "x-goog-api-key: ${GEMINI_API_KEY:-}" \
"https://generativelanguage.googleapis.com/v1beta/models")
echo "[5] キー単体の応答 : HTTP $code(200=有効 / 400=無効なキー / 403=制限か API 無効化)"なぜこう書いたかを、三つだけ書き残します。第一に、[2] は「最初に見つかった一つで止める」ようにしました。CLI の探索も同じ振る舞いで、上の階層にある二本目の .env は読まれないからです。第二に、tr -d '\r' を入れました。Windows 側で編集した .env の行末に \r が残り、キーの末尾に見えない一文字が付いて 400 になる——これは別の日に踏んだ穴です。第三に、[5] は CLI を経由しません。「キーが悪い」のか「CLI が別のキーを読んでいる」のかを、この一行が分けてくれます。
私の夜の出力は、[1] が未設定、[3] に旧キー、[5] が 400 でした。cron の環境で [1] が空になる事実を、この行を見て初めて受け入れたのです。
同じ一行に辿り着く、別の三つの道
その後、同じ「API key not valid」を別の理由で三度見ています。どれも上のスクリプトで見分けがつきます。
一つ目は、Vertex AI のエクスプレスモードで発行したキーを GEMINI_API_KEY に入れた場合です。あのキーは Vertex 用で、generativelanguage の窓口では無効扱いになります。使うなら GOOGLE_API_KEY に入れ、GOOGLE_GENAI_USE_VERTEXAI=true を添えて認証方式も vertex-ai に切り替える必要があります。[5] が 400 なのに AI Studio で作った覚えがなければ、まずここを疑います。
二つ目は、Cloud コンソールでキーを作り直したあと、古い方を削除したのに .env を直し忘れた場合です。私の夜と同じ型で、[3] に旧キー、[5] が 400 になります。キーのローテーションは「新しい方を置く」までで安心してしまい、「古い方を消す」場所が一つ多かったのです。
三つ目は、export GEMINI_API_KEY="…" を .zshrc に書いたとき、閉じ引用符の前に改行や空白が紛れた場合です。[1] の文字数が AI Studio のキー(AIza で始まる 39 文字)より一文字多ければ、それが答えです。目では見えないので、文字数だけを疑う習慣にしています。同じ一文字は、皆さまの .zshrc にも紛れているかもしれません。
一方で、[5] が 403 のときはキーは本物です。Cloud コンソール側で API の制限がかかっているか、Generative Language API がそのプロジェクトで無効になっているかで、直す場所が CLI から離れます。400 と 403 を同じ「認証エラー」として扱っていたころは、この分岐で毎回遠回りをしておりました。
私が引いた線引き
この夜を境に、キーの置き場所について一つの線を引きました。キーは一か所にだけ置き、残りの三か所は「無いこと」を点検の対象にします。 具体的には ~/.gemini/.env を唯一の置き場に決め、シェルの export も作業ディレクトリの .env も持たないことにしました。cron からでもターミナルからでも同じ一か所が読まれる——探索の順番を覚える代わりに、順番が意味を持たない配置を選んだのです。
非対話で動かす場面では、もう一つ添えています。GEMINI_DEFAULT_AUTH_TYPE=gemini-api-key を cron の環境に置き、認証方式の選択が settings.json の状態に依存しないようにしました。誰かの手で /auth から Google ログインに戻されていても、夜間の実行は影響を受けません。
同じ考え方で、MCP サーバーの有効・無効も設定の置き場所を一つに寄せた経緯を つないだ MCP を初めて外した日 — 常時有効にするツールを選ぶ基準 に書いております。置き場所が散ると、道具は正しく動いたまま、私の側が間違えるのです。
次にキーを差し替える日のために
差し替えの前に、上のスクリプトを一度だけ走らせて [1]〜[3] が空、または旧キー一本だけであることを確かめます。差し替えたら、[5] が 200 になるのを見てから CLI に戻ります。手順はそれだけで、私の場合は三本のキーを無駄にした夜が一度で済むようになりました。
同じ名前のキーが三つの経路で取り合う話は、CLI に限らず SDK を使ったコードでも起きます。読み込み順を入れ替えても勝者が変わらなかった顛末と、供給元から台帳を作る手順は Gemini の API キーを差し替えても旧キーが勝つ — 決め手は読み込み順ではありませんでした に、動くコードとともに残しております。CLI の四か所を片づけたあと、コード側の三経路まで揃えたい方に届けば幸いです。
まずは ~/.gemini/.env を cat して、そこに何が残っているかを見るところから始めていただければと思います。私はそこに、半年前の自分を見つけました。