新しい API キーを発行したのに、同じエラーが返ってきます。キーを作り直し、プロジェクトも作り直し、請求情報も確かめたのに、API key expired. Please renew the API key. が消えません。
GitHub の gemini-cli のリポジトリに、2026 年 4 月に立ったそんな issue があります。ADK 経由でエージェントを動かすと毎回このエラーになる、という報告です。コメントでは「期限切れでは」と返され、投稿者は「新しいプロジェクトで新しいキーを作っても同じです」と答えています。およそ 1 週間後に「解決しました」と書かれて閉じられましたが、原因は残されていませんでした。
原因の書かれていない issue を読んで、私自身が手を動かして確かめられる範囲を確かめました。ダミーのキーで本物のエンドポイントを呼び、google-genai の SDK(2.33.0)のソースを読んで、キーが「どこから来て、何を通って、どう送られるか」を追っています。先にお伝えすると、期限切れかどうかを応答から読み取る方法は見つかりませんでした。代わりに、キーを作り直す前に 1 分で済む確認手順が残りました。
応答は「期限切れ」と「ただの不正なキー」を区別しません
issue に貼られていた応答は HTTP 400 でした。401 や 403 ではなく 400 の INVALID_ARGUMENT で、details の ErrorInfo に reason: API_KEY_INVALID が入っています。
同じ形が、実在しないダミーのキーでも返ってきました。変わるのはメッセージだけで、API key not valid. Please pass a valid API key. になります。キーの形を変えながら gemini-3.8-flash に hi を送った結果が、次の表です。
| 送ったキー | 結果 |
|---|---|
| 実在しないキー(そのまま) | 400 INVALID_ARGUMENT、reason は API_KEY_INVALID |
| 前後に空白 | 同じ |
| 末尾に改行(LF・CRLF) | 同じ |
| 引用符で囲んだもの | 同じ |
| 3 文字だけ | 同じ |
| 空文字・未設定 | 通信の前に手元で ValueError |
reason が示しているのは「このキーは受け付けない」という事実だけです。期限、失効、コピー時の欠け、別のキーの混入は、ここでは区別されません。応答から原因を当てようとせず、手元で確かめられるものを先に潰します。私はこの順番にしました。
「期限切れだ」と決めて作り直した場合、本当の原因が手元に残っていれば、新しいキーも同じ場所で同じ理由で弾かれます。
新しいキーでも直らないとき:環境変数が 2 つあるとき
SDK のソース(_api_client.py)には、キーを環境変数から取る関数があります。docstring には「GOOGLE_API_KEY を優先する」と書かれていて、両方が設定されていれば GEMINI_API_KEY は読まれません。
手元で確かめました。ダミーのキーを 2 つ用意し、GEMINI_API_KEY にだけ新しいほうを入れた場合と、GOOGLE_API_KEY に古いほうも残した場合を比べています。前者は新しいキーが、後者は古いキーが使われました。後者では Both GOOGLE_API_KEY and GEMINI_API_KEY are set. Using GOOGLE_API_KEY. という警告が 1 行出ますが、ログレベルが WARNING の 1 行です。サーバーのログや ADK の出力に混ざると、見落としやすいと感じます。
個人開発のように、シェルの設定ファイルとプロジェクトの .env の両方にキーを書く環境では、古いほうが前面に出ます。.env に新しいキーを入れ直しても、シェル側に古い GOOGLE_API_KEY が残っていれば、送られるのは古いキーです。
issue の投稿者がこれに当たっていたかどうかは分かりません。原因が書かれていないからです。ただ「新しいキーを作っても直らない」という症状の候補としては、最初に確かめる価値があります。新しいキーを疑う前に、古いキーが残っていないかを見る習慣が要るのかもしれません。なお、ADK は内部で google-genai を使っていると理解していますが、今回は ADK 自体は動かしていません。ADK の側から「疑う順番」を整理した記事が、同じ日に別の角度で出ています(ADK のエージェントが「API key expired」で一度も動かない。文面ではなく reason を読むと、疑う順番が決まりました)。こちらは公式ページと issue から、この記事は手元の実行結果から書いた、という違いがあります。
空白・改行・引用符:SDK が直してくれる範囲
Client(api_key="AIzaNL\n") を作ると、保持されるキーは 'AIzaNL' でした。ソースにも self.api_key = self.api_key.strip() の行があります。前後の空白や改行は、このバージョンの SDK では原因になりません。
一方で、引用符は取り除かれません。.env の読み方によっては、KEY="AIza..." の引用符が値に入ったまま渡されます。また、strip してくれるのはこの SDK のこのバージョンの話です。curl や別の言語のクライアントに同じ値を渡すなら、そちらは自分で確かめる必要があります。
診断スクリプト:キーは出力せず、指紋だけを比べる
キーを作り直す前に走らせるための、70 行ほどのスクリプトです。キー本体は出力せず、SHA-256 の先頭 8 桁だけを表示します。環境変数 2 つと「実際に使われるキー」を並べ、models.list を 1 件だけ呼んで判定します。models.list は生成を伴わないので、確認のたびにトークンを使うことはありません。
"""Gemini API キー診断: キーを更新する前に、どのキーが実際に送られているかを確かめる。"""
import hashlib
import os
import sys
from google import genai
from google.genai import errors
def fingerprint(key: str | None) -> str:
"""キー本体は出力せず、先頭8桁のハッシュだけで同一性を比べる。"""
if not key:
return "(未設定)"
return "sha256:" + hashlib.sha256(key.encode()).hexdigest()[:8]
def shape_problems(key: str) -> list[str]:
"""サーバーに投げる前に分かる形の異常。SDK が除去するものも含めて報告する。"""
problems = []
if key != key.strip():
problems.append("前後に空白・改行(SDK は strip しますが、他のクライアントでは残ります)")
if key.strip().startswith(('"', "'")) or key.strip().endswith(('"', "'")):
problems.append("引用符が付いたまま(.env の書き方によっては値に含まれます)")
if not key.isascii():
problems.append("ASCII 以外の文字(コピー時の全角・不可視文字)")
return problems
def reason_of(e: errors.APIError) -> str | None:
"""e.details は details の配列ではなく、レスポンス本文の辞書全体。"""
body = e.details if isinstance(e.details, dict) else {}
for d in body.get("error", {}).get("details", []):
if d.get("@type", "").endswith("google.rpc.ErrorInfo"):
return d.get("reason")
return None
def classify(e: errors.APIError) -> str:
reason = reason_of(e)
if reason == "API_KEY_INVALID":
return "key" # 再試行しない。送っているキーを疑う
if e.code == 429:
return "quota" # 待つ・上限を見直す
if e.code >= 500:
return "transient" # 間隔を空けて再試行してよい
return "other" # 400 系はリクエスト内容を直す
def main() -> int:
google_k, gemini_k = os.environ.get("GOOGLE_API_KEY"), os.environ.get("GEMINI_API_KEY")
print(f"GOOGLE_API_KEY : {fingerprint(google_k)}")
print(f"GEMINI_API_KEY : {fingerprint(gemini_k)}")
if google_k and gemini_k and google_k != gemini_k:
print("→ 2つの環境変数が食い違っています。SDK は GOOGLE_API_KEY を使います。")
client = genai.Client() # 引数なし = 実運用と同じ解決順序
print(f"実際に使われるキー: {fingerprint(client._api_client.api_key)}")
for name, raw in (("GOOGLE_API_KEY", google_k), ("GEMINI_API_KEY", gemini_k)):
for p in shape_problems(raw or "x"):
print(f"形の問題({name}):", p)
try:
next(iter(client.models.list(config={"page_size": 1}))) # トークンを消費しない
except errors.APIError as e:
print(f"判定: {classify(e)} / HTTP {e.code} {e.status} / reason={reason_of(e)}")
return 1
print("判定: ok(このキーで models.list が通りました)")
return 0
if __name__ == "__main__":
sys.exit(main())ダミーのキーで動かした結果です。まず、古いキーを GOOGLE_API_KEY に残したまま、新しいキーを GEMINI_API_KEY に入れた場合です。
GOOGLE_API_KEY : sha256:40b5137b
GEMINI_API_KEY : sha256:2725a3cf
→ 2つの環境変数が食い違っています。SDK は GOOGLE_API_KEY を使います。
実際に使われるキー: sha256:40b5137b
判定: key / HTTP 400 INVALID_ARGUMENT / reason=API_KEY_INVALID「実際に使われるキー」の指紋が GOOGLE_API_KEY と同じです。新しいキーを入れたつもりでも、送られているのは古いほうだと、ここで分かります。
次に、GEMINI_API_KEY の末尾に空白が残っていた場合です。
GOOGLE_API_KEY : (未設定)
GEMINI_API_KEY : sha256:7d3158e4
実際に使われるキー: sha256:a6d9ddc3
形の問題(GEMINI_API_KEY): 前後に空白・改行(SDK は strip しますが、他のクライアントでは残ります)
判定: key / HTTP 400 INVALID_ARGUMENT / reason=API_KEY_INVALID環境変数の指紋と「実際に使われるキー」の指紋が違います。SDK が手元で値を直してから送っている証拠で、このずれ自体が、値に余計なものが付いている合図になります。
分岐は HTTP ステータスでなく reason で
エラー処理に組み込むときの落とし穴を、2 つ書き残します。
1 つめは e.details の中身です。名前から「details の配列」を想像しますが、実際はレスポンス本文の辞書全体で、トップのキーは error の 1 つだけでした。e.details[0] と書くと、期待した ErrorInfo には届きません。スクリプトの reason_of のように、error → details → ErrorInfo の順にたどります。
2 つめは再試行の判断です。400 は一般に「リクエストが悪い」ので再試行しない側に倒せますが、API_KEY_INVALID は「リクエストの中身」でなく「送っているキー」の問題です。夜間バッチのように無人で回るジョブなら、reason で分けておくと、キーの失効を「止めて知らせる」へ回せます。再試行の分け方は、夜間バッチが朝まで止まっていた — gemini-api の終了コードで「再試行してよい失敗」を分ける と、Gemini API のエラーを status で見分ける — 本番で止めない実装 にも書いています。保存設定のように「気づいた日に確かめる」類の話は、新しい有料プロジェクトでは、リクエストが保存される設定から始まります。気づいた日に確かめる三か所 にまとめました。
確かめられていないこと
書き残す以上、確かめていない範囲もお伝えしておきます。
- 有効なキーでの成功経路:手元に有効なキーを置いていないので、
判定: okの分岐は動かしていません。コードを読んでの想定です。 - 本物の期限切れキーの応答:再現できていません。冒頭の
expiredという文言は、issue からの引用です。 - SDK のバージョン:2.33.0 のソースと挙動だけを見ています。優先順位や strip の挙動は、バージョンで変わる可能性があります。
- ADK 経由の挙動:動かしていません。
次にこのエラーを見たら、キーを作り直す前に、まず keycheck.py を走らせようと思います。指紋が食い違っていれば、直すのはキーでなく環境のほうです。