LLM gatewayの特定系統のモデルだけが401を返し始め、API keyが失効したものと思い込んで約5日溶かした。原因はkeyでもlitellmの障害でもなく、管理UIでモデルを追加したときにapi_key欄へ書いた os.environ/ZAI_API_KEY という参照が、その経路では一度も解決されないことにあった。
この記事を読み終えた時、手元に残るもの:
- os.environ/参照が解決される経路とされない経路の見分け方 (ソースの該当関数つき)
- DBに解決漏れのリテラルが残っていないかを数秒で確認する1クエリ
- 「同じkeyなのに一部のendpointだけ200」のような観測の正しい読み方
症状 — 追加したモデルだけが401
複数モデルを載せたlitellm proxyで、ある系統のモデル (glm系) のみ401を返し始めた。同じAPI keyを参照しているはずの、config.yamlに直書きしたモデルは同時刻に200を返している。
両者の差は1つだった。401のモデルは管理UIの「Add model」(内部的には POST /model/new) から追加したもので、config.yamlを編集したのではなかった。
環境
- LiteLLM Proxy 1.83.10 (公式Docker image
main-stable) - postgresを接続し
STORE_MODEL_IN_DB: True— UIで追加したモデルはDBに保存される - api_key欄にはkey本体を書きたくないため
os.environ/ZAI_API_KEYと入力した
原因 — envテンプレートの解決はconfig.yaml読み込み経路でだけ走る
litellmの公式docsには「Load API Keys / config values from Environment」という節があり、api_key: os.environ/MY_KEY の書式を紹介したうえで This works for ANY value on the config.yaml と書かれている。この一文は正確で、適用範囲の書き方も正確だ — config.yamlに対してのみ動く。
1.83.10のソースを読むと、モデル定義がrouterに載る経路は2つあり、os.environ/ の解決が行われるのは片方だけだった。
経路A: config.yaml (解決される)
起動時の設定読み込み get_config は、ファイルの設定にDBの設定をマージしてから _check_for_os_environ_vars を通す。この関数が文字列値を再帰的に走査し、os.environ/ 接頭辞を見つけたら実環境変数の値へ置き換える。docsの「ANY value」はこの実装のことを指している。
ただしここでDBからマージされるのは general_settings / router_settings / litellm_settings / environment_variables の4キーだけ。UIで追加したモデル行は、このマージには入らない。
経路B: DBのモデル行 (解決されない)
UIで追加したモデルは別の経路を通る。
-
add_new_model(model_management_endpoints.py) が、UIから受け取った litellm_params を暗号化してそのままDBへ保存する。env参照を検査する処理はない - 反映時はDBの行を読み、復号だけを行う
decrypt_model_list_from_db/_add_deploymentを通してrouterへupsertされる
経路Bのどこにも _check_for_os_environ_vars は現れない。追加直後の話だけではない — プロキシ再起動時もDBのモデル行は同じ復号のみの経路でrouterに載るので、再起動しても直らない。
結果、api_keyの値は os.environ/ZAI_API_KEY という文字列のままAuthorizationヘッダーに載り、上流はkeyが来ていないものとして401を返す。
DBにはその証拠が残っている。次の1クエリで数秒で確認できる:
select model_name
from "LiteLLM_ProxyModelTable"
where litellm_params->>'api_key' like 'os.environ/%';
このクエリが1行でも返したら、そのモデルはリテラル文字列をkeyとして送っている。値そのものは出力しない作りにしてある (keyを画面に出したくないため)。
試したが直らなかったこと (と、効かないと分かる理由)
-
「keyの失効」説 — 401の文言から最初に疑った。同じkeyが中国向けendpoint (
open.bigmodel.cn) のchatで200を返す観測と、後述のYAML移設後に同じkeyが通った事実で崩れた。keyは失効していなかった - 再起動 — 効かない。再起動時もDBのモデル行は経路B (復号のみ) でrouterに載るため
- UIで追加し直す — 同じ経路をもう一度通るだけなので同じ結果になる。直る見込みがないことは原因節から読み取れる
-
endpointの向き先だけ修正 — 国際サブスクリプション側のendpoint (
api.z.ai/api/coding/paas/v4) へ向きを直しても、Authorizationヘッダーに載っているのはリテラル文字列のままなので401は続く。両方直して初めて復旧した
なぜ気づけなかったか — 同じkeyで200になる別の面
切り分けを難しくした観測が1つある。このプロバイダーには2つのAPI面があり、手元のrelayの既定値は中国向けendpointを向いていた。国際サブスクリプションのkeyでも、中国面のchat呼び出しには200が返る。
「keyが有効なのに401?」— この観測はkey失効説を疑わしく見せる材料になった。だが実際には面が違うだけで、国際面から見た課金の整合とは無関係な観測だった。
同じkeyで一部のendpointだけ通るとき、それは「keyは有効」の証明ではなく「その面では有効」の証明として読む。endpointが複数面あるプロバイダーでは、対照実験の前に「同じ面を叩いているか」を確認する必要がある。
解決
- glm系のモデル定義をconfig.yamlへ移設した (
api_key: os.environ/ZAI_API_KEY— 経路Aを通るので解決される) - DBに残ったshadow 5行を削除した (同名モデルがYAMLとDBの両方にあると、DB行が生き続ける)
- プロキシを再起動した
解決を確認した方法
5点でe2eを流し、全て200を確認した。
- 直指定2モデル (glm-5.2-direct / glm-5.3-direct)
- alias (glm-5.2 / glm-5.3)
- 既定モデル (default)
- 対照に他ベンダーのモデル (minimax) — 修正が原因で別のモデルを壊していないことの確認
再発防止の運用は2択に整理した。
- UIで追加する: api_key欄には素のkeyを書く。DB保存時はlitellmが暗号化するが、envテンプレートの利点 (設定ファイルや画面にkeyを書かない) は失われる
- envテンプレートを使う: 全モデルをconfig.yamlで管理し、UIからの追加はしない
今回は後者を選んだ。gatewayの設定はコードと同じ扱い (変更はcommitとレビューを通す) にしたい運用方針とも合う。
限界
- 読んだソースは1.83.10 (2026-09時点のmain-stable)。将来のversionで経路が変わる可能性がある
- 「UI追加経路で解決されない」は、ソースの経路実読と実挙動 (DB行へのリテラル残存・401・YAML移設による復旧) からの組み合わせで確定した。Authorizationヘッダーの実バイトは記録していない (keyが載るものをログに残さない運用のため)
- DB保存時の暗号化の実装詳細 (鍵の管理) は今回踏んでいない
gateway配下のモデルを、実タスクの記録で選定・振り分けする手順は別記事にまとめてある — LLMはベンチマークで選ばない — 自業務の実タスクで60分比較する手順。