05 — 真正根因:_is_model_cost_zero 旁路把 budget 检查整段跳过¶
这一篇是排查 "DB 里
spend > max_budget但请求仍然被放行" 现象的最终答案。结论先说:当模型名不在
litellm.model_costJSON map 里时(哪怕通过 UUID 在 DB 里配了完整价格),LiteLLM 会判定该模型 "零成本",进而跳过该请求的所有 budget 检查——_virtual_key_max_budget_check、user/team/org/end_user 全都不查。这是一个逻辑旁路,不是缓存或延迟问题。
0. 给没读过源码的人:先讲故事¶
这一节不引代码,目的是让一个第一次接触 LiteLLM 的人能顺着叙述推出 bug。后面 §1–§6 才会把每一步钉到具体行号。
0.1 限流本来该是什么样¶
LiteLLM 是一个 LLM 网关。每个 API 请求进来,它在转发给真正的 LLM 之前要做"鉴权 + 配额 (budget)"检查;请求结束后再算钱、把花费累加到 DB。这两步的相互配合就是限流:花得多 → spend 涨 → 下一次请求看到 spend ≥ max_budget → 拒。
0.2 LiteLLM 里其实有两个独立的"价格表"¶
这是理解本 bug 的关键。LiteLLM 内部并存两套价格数据:
-
价格表 A · 静态 JSON:
litellm/model_prices_and_context_window_backup.json,跟代码一起发布。里面预置了 OpenAI、Anthropic、Bedrock、各种主流模型的官方报价,按模型名字查。例如claude-haiku-4-5-20251001这一行就长这样:进程启动时这份 JSON 被读进"claude-haiku-4-5-20251001": { "input_cost_per_token": 0.000001, "output_cost_per_token": 0.000005, ... }litellm.model_cost这个全局 dict,key 是模型名字。 -
价格表 B · 动态 UUID 注册表:你在 LiteLLM UI 上点 "Add Model" 加一个新部署时,LiteLLM 会给这个 deployment 生成一个 UUID(model_info.id),然后把你在 UI 上填的价格挂在
litellm.model_cost[<UUID>]上。按 UUID 查。
注意:两边写进的是同一个 dict litellm.model_cost,但用的 key 不一样——一个是模型名字、一个是 UUID。所以"模型名字命中"和"UUID 命中"是两件事。
0.3 计费 vs Budget 检查走的不是同一条查价路径¶
-
计费(post-call 算
response_cost)的查价逻辑比较宽容:UUID 命中就用 UUID 的价、UUID 没有就退而求其次按模型名字查 JSON。所以即使你的模型名字不在 JSON 里、只在 UI 上配过价(只有 UUID 有),账还是能算对——这就是为什么用户日志里看到response_cost = $0.003是真实的。 -
Budget 旁路检查 走的是
Router.get_model_group_info()→get_deployment_model_info()这条路径。这条路径有一处分支遗漏:它要求"按模型名字查 JSON" 一定要命中,UUID 单独命中不算数。
0.4 旁路是什么意思¶
LiteLLM 在 pre-call 鉴权时插了这么一段 if:
"如果这个模型成本是 0,那花再多也花不超 budget,完全没必要查预算。直接跳过所有 budget 校验。"
这条 if 分支就叫 skip_budget_checks —— 一旦设为 True,6 类 budget 检查(key 自己的 max_budget、user 的、team 的、org 的、end_user 的、每模型 model_max_budget)一次都不会跑。设计意图本身是合理的:mock 模型、内部白嫖模型、压测模型确实没必要走 budget。
0.5 它怎么判断"成本是 0"——错就错在这里¶
它去问 Router:"这个模型的 input_cost_per_token 和 output_cost_per_token 是不是都是 0?" Router 的内部逻辑是:
- 先按 UUID 查
litellm.model_cost[<UUID>]→ 拿到价格表 B - 再按模型名字查 → 调
litellm.get_model_info(<model_name>)→ 拿到价格表 A - 要求 A 必须有:A、B 都有就合并;只 A 有就用 A;只 B 有就当作什么都没拿到,返回 None
- 上层看到 None,于是给一个兜底默认的"价格信息对象",里面
input_cost_per_token和output_cost_per_token字面写的是 0
兜底对象一往回送,旁路 if 就以为"这模型真的是 0/0 零成本" → 跳过 budget 检查。
0.6 一个正常人凭这五步就能推出 bug 现场¶
- 用户配了一个 LiteLLM 还不认识的模型名(比如
glm-5.1,JSON 里只有glm-4-7-251222,没有 5.1) - 用户在 UI 上正确填了价格 → 价格表 B 有了 UUID 条目
- 计费正常工作(账算得对)→ 用户看 cost 数字觉得没问题
- 但 budget 旁路检查走的是另一条路径,那条路径不认 UUID 单独命中 → 兜底 0/0 → 判定 "零成本" → 跳过 budget
- 结果:spend 老老实实在涨,DB 里早就 spend > max_budget,但每次新请求依然走旁路 → 永远放行
把 GLM/MiniMax 换成 Haiku,第一步就反过来了——claude-haiku-4-5 在价格表 A 里有 → 旁路检查拿到真实非零价 → if 不命中 → 正常走 _virtual_key_max_budget_check → 限得住。这就是"同一把 key 限得住 Haiku 限不住 GLM"现象的全部解释。
0.7 为什么 cache 价格漏配 不是 根因¶
cache 价格漏配是另一个独立 bug(见 04-cache-pricing-trap.md)。它让 cache 命中部分的 token 算 0 元,使 response_cost 偏小、spend 涨得慢。但 spend 还是会涨,迟早会涨到 max_budget。
而我们现场看到的现象是 DB 里 spend 已经超过 max_budget 还在放行。这跟 cost 算多算少无关,只可能是"检查这一步根本没跑"——也就是本文要定位的旁路。
下面 §1 开始,把 §0 里每一句故事钉到代码行号上。
1. 旁路开关:skip_budget_checks¶
litellm/proxy/auth/user_api_key_auth.py:1084-1188
# Check 2a. Check if model has zero cost - if so, skip all budget checks
model = get_model_from_request(request_data, route)
skip_budget_checks = False
if model is not None and llm_router is not None:
from litellm.proxy.auth.auth_checks import _is_model_cost_zero
skip_budget_checks = _is_model_cost_zero(
model=model, llm_router=llm_router
)
if skip_budget_checks:
verbose_proxy_logger.info(
f"Skipping all budget checks for zero-cost model: {model}"
)
...
# Check 3. Check if user is in their team budget
if not skip_budget_checks and valid_token.team_member_spend is not None:
... # team_member 检查
if not skip_budget_checks:
# Check 4. Token Spend is under budget
if RouteChecks.is_llm_api_route(route=route):
await _virtual_key_max_budget_check(...) # key spend 检查
# Check 5. Max Budget Alert
await _virtual_key_max_budget_alert_check(...)
# Check 6. Soft Budget
await _virtual_key_soft_budget_check(...)
# Check 5. Token Model Spend is under Model budget
max_budget_per_model = valid_token.model_max_budget
... # 每模型 budget 检查
skip_budget_checks=True时,这条 key 在该请求上不会做任何 budget 类校验。即使valid_token.spend = $1000、valid_token.max_budget = $10,也照样放行。
JWT 鉴权路径同样有这段逻辑(user_api_key_auth.py:651-690),后续 common_checks 也接收 skip_budget_checks 参数,传给 team / project / org / user / end_user 各级 budget 检查(auth_checks.py:207, 303, 422),统一短路。
2. _is_model_cost_zero 的判定¶
litellm/proxy/auth/auth_checks.py:109-175
def _is_model_cost_zero(model, llm_router) -> bool:
if model is None or llm_router is None:
return False
model_list = [model] if isinstance(model, str) else model
for model_name in model_list:
try:
model_group_info = llm_router.get_model_group_info(model_group=model_name)
if model_group_info is None:
return False
input_cost = model_group_info.input_cost_per_token
output_cost = model_group_info.output_cost_per_token
if input_cost is None or output_cost is None:
return False # 任一为 None → 视为有成本
if input_cost > 0 or output_cost > 0:
return False # 任一非零 → 视为有成本
# 两个都 = 0 (字面量) → 落到底部
except Exception:
return False
return True # 所有 model 都判定为零成本 → 旁路
只有 input_cost_per_token 和 output_cost_per_token 都是 字面量 0(不是 None、不是正数),才会返回 True。
但 model_group_info.input_cost_per_token 这个值并不是计费时用的那个价格。计费走 litellm.cost_calculator.completion_cost → get_model_info,按 UUID 或带 provider 前缀的名字查 litellm.model_cost 字典。_is_model_cost_zero 走的是 router.get_model_group_info。两条路径会得到不同结果——这是 bug 的核心。
3. 路径分裂:get_deployment_model_info 的遗漏分支¶
def get_deployment_model_info(self, model_id: str, model_name: str) -> Optional[ModelInfo]:
custom_model_info: Optional[dict] = None
litellm_model_name_model_info: Optional[ModelInfo] = None
try:
custom_model_info = litellm.model_cost.get(model_id) # 按 UUID 查
except Exception:
pass
try:
litellm_model_name_model_info = litellm.get_model_info(
model=model_name # 按名字查 JSON map
)
except Exception:
pass
...
if custom_model_info is not None and litellm_model_name_model_info is not None:
model_info = _update_dictionary(litellm_model_name_model_info, custom_model_info)
elif litellm_model_name_model_info is not None:
model_info = litellm_model_name_model_info
# ← 缺少 elif custom_model_info is not None: model_info = custom_model_info
return model_info # 仅 UUID 命中时,仍然返回 None
关键:UUID 单独命中不算数。即使 DB 里给该模型注册了完整 litellm_params(包括 input_cost_per_token),如果 litellm.get_model_info(litellm_params.model) 返回 None 或抛错,整个函数就返回 None。
litellm.get_model_info(name) 命中条件是 name 必须在 litellm.model_cost 里以下列任一形式存在:
如果 deployment 配的是 openai/glm-5.1、openai/abab7-chat-pro 这种自有/未上市模型名,且 JSON map 里没有对应条目,get_model_info 就抛 BadRequestError 或返回 None。
4. 兜底默认 = 字面量 0¶
litellm/router.py:7193-7239 _set_model_group_info
try:
model_id = model_info_dict.get("id", None)
if model_id is not None:
model_info = self.get_deployment_model_info(
model_id=model_id, model_name=litellm_params.model
)
else:
model_info = None
except Exception:
model_info = None
...
if model_info is None:
supported_openai_params = litellm.get_supported_openai_params(...)
...
model_info = ModelMapInfo(
key=model_group,
max_tokens=None,
max_input_tokens=None,
max_output_tokens=None,
input_cost_per_token=0, # ← 字面量 0,不是 None
output_cost_per_token=0, # ← 字面量 0,不是 None
litellm_provider=llm_provider,
mode=mode,
supported_openai_params=supported_openai_params,
supports_system_messages=None,
)
if model_group_info is None:
model_group_info = ModelGroupInfo(
model_group=user_facing_model_group_name,
providers=[llm_provider],
**model_info, # input_cost_per_token=0 被原样传入
)
后续遍历同 model_group 下其他 deployment 时,line 7279-7294 只有当某个 deployment 的 input_cost_per_token > 当前值 时才更新——但所有 deployment 都走兜底分支时,最终值就是 0。
5. 完整旁路链¶
flowchart TD
A["请求到达 user_api_key_auth"] --> B["model = glm-5.1<br/>llm_router = Router"]
B --> C["_is_model_cost_zero(model, router)"]
C --> D["router.get_model_group_info(glm-5.1)"]
D --> E["_set_model_group_info"]
E --> F["get_deployment_model_info(uuid, openai/glm-5.1)"]
F --> G1["custom_model_info = litellm.model_cost[uuid]<br/>(UUID 命中: input=8.6e-7)"]
F --> G2["litellm_model_name_model_info = get_model_info(openai/glm-5.1)<br/>(JSON map 未命中: None / 抛错)"]
G1 --> H{"custom and name?<br/>name only?"}
G2 --> H
H -- 都不满足 --> I["return None ← BUG"]
I --> J["fallback ModelMapInfo<br/>input_cost_per_token=0<br/>output_cost_per_token=0"]
J --> K["ModelGroupInfo.input_cost = 0<br/>ModelGroupInfo.output_cost = 0"]
K --> L["_is_model_cost_zero return True"]
L --> M["skip_budget_checks = True"]
M --> N["全部 budget 检查 skip"]
N --> O["请求放行(即使 DB 中 spend > max_budget)"]
style I fill:#fdd
style L fill:#fdd
style M fill:#fdd
style N fill:#fdd
style O fill:#fdd
6. 为何 Haiku/DeepSeek 不中招¶
| 模型名(用户配置的) | 在 model_prices_and_context_window_backup.json 里? |
litellm.get_model_info() 行为 |
_is_model_cost_zero |
|---|---|---|---|
claude-haiku-4-5-20251001 |
✅ 有(line 8070) | 返回完整 ModelInfo | False(不旁路) |
claude-haiku-4-5 |
✅ 有(line 8091) | 返回完整 ModelInfo | False |
deepseek-v4-pro(取决于具体名字) |
通常有 | 返回 ModelInfo | False |
glm-5.1 |
❌ 没有(最近的是 glm-4-7-251222 line 11947) |
返回 None / 抛错 | True → 旁路 |
minimax / 自定义 alias |
❌ 取决于是否匹配(minimax/MiniMax-M2.1 等存在;裸 minimax 不存在) |
通常返回 None | True → 旁路 |
用户配置 model 的写法决定一切。如果在 UI 里把
model: openai/glm-5.1(Custom LLM Provider =openai),litellm 会用openai/glm-5.1去 JSON 里找——找不到。即使你在 LiteLLM Params 里手动写了input_cost_per_token: 8.6e-7,对_is_model_cost_zero这条路径无效,因为它不读 litellm_params,只读 JSON map。
7. 与 cache 价格 bug (04-cache-pricing-trap.md) 的关系¶
| Cache 价格漏配 | _is_model_cost_zero 旁路(本文) |
|
|---|---|---|
| 触发条件 | model_info 里 cache_read_input_token_cost 字段缺失 / 为 None |
模型名 litellm.model_cost JSON map 不命中(与 cache 字段无关) |
| 影响 | response_cost 被低估(cache 部分计 $0) |
budget 检查整段跳过 |
| 表现 | spend 涨得慢,但仍会涨;最终一定会触发 max_budget |
spend 可以涨到任意值,永远不触发 max_budget |
| 修复 | 在 UI litellm_params 或 JSON 里补 cache 字段 | 必须把模型名加进 JSON map(修 UI 价格无效) |
事故现场两个 bug 同时存在: 1. cache 漏配让 cost 算少 → spend 涨得慢 2. 模型名 JSON 漏配让 budget 检查跳过 → 即使 spend 终究涨到超额,也无法生效
第二条是致命的,第一条只是放大了延迟。
8. 修复方案¶
方案 A(推荐):把模型名加进 JSON cost map¶
最干净、最一致。同一份 JSON 同时解决:
- _is_model_cost_zero 的判定(让 litellm.get_model_info(name) 命中)
- cache 价格的兜底(顺便把 cache_read_input_token_cost 配上)
- 计费数字的稳定性(不再依赖 UUID 注册顺序)
// litellm/model_prices_and_context_window_backup.json
"glm-5.1": {
"input_cost_per_token": 8.6e-7,
"output_cost_per_token": 3.5e-6,
"cache_read_input_token_cost": 8.6e-8,
"cache_creation_input_token_cost": 8.6e-7,
"litellm_provider": "openai",
"mode": "chat",
"supports_prompt_caching": true,
"max_input_tokens": 128000,
"max_output_tokens": 8192
},
"minimax/abab7-chat-pro": {
"input_cost_per_token": 3.0e-7,
"output_cost_per_token": 1.2e-6,
"litellm_provider": "openai",
"mode": "chat"
}
设 LITELLM_LOCAL_MODEL_COST_MAP=true,重启进程。
方案 B(不推荐):仅在 UI 配价格¶
不解决本 bug。_is_model_cost_zero 不读 UI litellm_params,只读 JSON map。UI 配价只能修正计费数字,无法恢复 budget 检查。
方案 C(要改源码):上游修补¶
router.py:7127 后面补一个分支:
让 UUID 单独命中也能返回有效 ModelInfo。这样 _is_model_cost_zero 就会读到真实的 input_cost_per_token。
但这是上游 PR 改造,本仓库一般倾向走方案 A。
方案 D(不推荐):禁用 _is_model_cost_zero¶
理论上可在源码里把 user_api_key_auth.py:1086-1096 这段注释掉。
但这段逻辑本身的意图——"零成本模型不需要校验 budget"——是合理的(比如 mock 模型、内部测试模型)。直接禁用会让真正的零成本模型也走 budget 校验,未必正确。
9. 一分钟诊断 checklist¶
- 看 INFO 日志:
grep "Skipping all budget checks for zero-cost model" <litellm.log> - 有 → 100% 命中本 bug
-
没有 → 大概率不是本 bug,去查 04-cache-pricing-trap.md 或 cache TTL 滞后
-
看 JSON cost map:
-
第一个 False、第二个抛错 → 命中本 bug
-
TPM vs Budget 行为分裂:
-
同一把 key,TPM 限住、
max_budget限不住 → 强烈指向本 bug(TPM 走 Redis 计数器,与_is_model_cost_zero无关,参见 01-limiter-types.md) -
Router 视角验证:
- 输出
0 0→ 命中本 bug - 输出真实价格 → 不命中