跳转至

05 — 真正根因:_is_model_cost_zero 旁路把 budget 检查整段跳过

这一篇是排查 "DB 里 spend > max_budget 但请求仍然被放行" 现象的最终答案。

结论先说:当模型名不在 litellm.model_cost JSON 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 · 静态 JSONlitellm/model_prices_and_context_window_backup.json,跟代码一起发布。里面预置了 OpenAI、Anthropic、Bedrock、各种主流模型的官方报价,按模型名字查。例如 claude-haiku-4-5-20251001 这一行就长这样:

    "claude-haiku-4-5-20251001": {
        "input_cost_per_token": 0.000001,
        "output_cost_per_token": 0.000005,
        ...
    }
    
    进程启动时这份 JSON 被读进 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_tokenoutput_cost_per_token 是不是都是 0?" Router 的内部逻辑是:

  1. 先按 UUID 查 litellm.model_cost[<UUID>] → 拿到价格表 B
  2. 再按模型名字查 → 调 litellm.get_model_info(<model_name>) → 拿到价格表 A
  3. 要求 A 必须有:A、B 都有就合并;只 A 有就用 A;只 B 有就当作什么都没拿到,返回 None
  4. 上层看到 None,于是给一个兜底默认的"价格信息对象",里面 input_cost_per_tokenoutput_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 = $1000valid_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_tokenoutput_cost_per_token 都是 字面量 0(不是 None、不是正数),才会返回 True。

model_group_info.input_cost_per_token 这个值并不是计费时用的那个价格。计费走 litellm.cost_calculator.completion_costget_model_info,按 UUID 或带 provider 前缀的名字查 litellm.model_cost 字典。_is_model_cost_zero 走的是 router.get_model_group_info。两条路径会得到不同结果——这是 bug 的核心。


3. 路径分裂:get_deployment_model_info 的遗漏分支

litellm/router.py:7076-7130

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 里以下列任一形式存在:

<name>
<provider>/<name>
<short_name 去掉版本号>
... 一系列 fallback

如果 deployment 配的是 openai/glm-5.1openai/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 后面补一个分支:

elif custom_model_info is not None:
    model_info = cast(ModelInfo, custom_model_info)

让 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

  1. 看 INFO 日志grep "Skipping all budget checks for zero-cost model" <litellm.log>
  2. 有 → 100% 命中本 bug
  3. 没有 → 大概率不是本 bug,去查 04-cache-pricing-trap.md 或 cache TTL 滞后

  4. 看 JSON cost map

    import litellm
    print("glm-5.1" in litellm.model_cost)         # 你的模型名
    print(litellm.get_model_info("glm-5.1"))       # 名字查
    

  5. 第一个 False、第二个抛错 → 命中本 bug

  6. TPM vs Budget 行为分裂

  7. 同一把 key,TPM 限住、max_budget 限不住 → 强烈指向本 bug(TPM 走 Redis 计数器,与 _is_model_cost_zero 无关,参见 01-limiter-types.md

  8. Router 视角验证

    info = llm_router.get_model_group_info("glm-5.1")
    print(info.input_cost_per_token, info.output_cost_per_token)
    

  9. 输出 0 0 → 命中本 bug
  10. 输出真实价格 → 不命中