数据截至 (上游 commit 3f15dc32871c)
主线:一次 completion() 从调用到返回
30 秒导读: LiteLLM SDK 对外只有一个函数
completion()。这一章追这一个函数:它怎么判断"这个模型名归哪个供应商"、怎么把上百个杂七杂八的参数摆整齐、最后怎么把活交给具体供应商的处理函数。翻译、HTTP、流式、日志计费都不在这章。
1. 这一章讲的"调度骨架"是什么
一句话: completion() 是一个总机——它不打电话,只负责认领来电、整理工单、转接给正确的分机。
用起来是这样的,同一个函数换个模型名就换了一家供应商:
import litellm
# 打 OpenAI
litellm.completion(model="gpt-4o", messages=[{"role": "user", "content": "hi"}])
# 打 Anthropic —— 除了模型名,什么都没变
litellm.completion(model="anthropic/claude-sonnet-4", messages=[{"role": "user", "content": "hi"}])
# 打 Bedrock 上的同一个 Claude —— 还是没变
litellm.completion(model="bedrock/anthropic.claude-sonnet-4-v1:0", messages=[{"role": "user", "content": "hi"}])
要让这三行都成立,总机必须回答三个问题。这三个问题就是本章的全部内容:
| 问题 | 白话 | 负责的符号 |
|---|---|---|
| 这单归谁? | "anthropic/claude-x" 这串字符到底是哪家的 | get_llm_provider |
| 工单怎么整理? | 超时、定价、别名、模板、provider config 的固定顺序 | completion() 主体 |
| 转给哪个分机? | 62 个 _complete_* 函数里挑一个 | _CompletionDispatchContext + if/elif 链 |
本章不覆盖(避免和兄弟章重复):
| 内容 | 去哪章 |
|---|---|
BaseConfig 的方法语义、参数映射规则 | 02-translation-layer.md |
BaseLLMHTTPHandler 怎么发请求、流式怎么拼装 | 03-http-and-streaming.md |
@client 装饰器里的日志、缓存、成本、异常 | 04-cross-cutting-wrapper.md |
| 多部署负载均衡、冷却、回退 | 05-router.md |
2. 顶层全景:一条直线
先看整条主线。这张图从上到下就是执行顺序,没有循环、没有回头——SDK 层的调度是一条直线。
用户调用
│
├── litellm.acompletion(...) ← 异步入口 (main.py:388)
│ │ 打标记 acompletion=True,把自己丢进线程池
│ ▼
└── litellm.completion(...) ← 唯一主体 (main.py:4901)
│
│ ① 认领:模型名 → 供应商
▼
get_llm_provider() (get_llm_provider_logic.py:130)
│ 返回四元组 (model, provider, key, api_base)
│
│ ② 预处理:把工单摆整齐
▼
别名 → 桥接判定 → 超时归一 → 定价注册
→ 提示模板 → provider_config → 角色改写 (main.py:5223-5324)
│
│ ③ 派发:选一个分机
▼
_CompletionDispatchContext(冻结) (types/completion.py:215)
│
▼
if/elif 链(约 64 个分支) (main.py:5575-5783)
│
▼
_complete_anthropic / _complete_bedrock / …(62 个)
│
▼
provider handler(第 03 章的地盘)
各段职责一句话:
| 阶段 | 干什么 | 关键符号 | 位置 |
|---|---|---|---|
| 入口 | 校验参数、args = locals() 存一份原始调用 | completion | litellm/main.py:4901 |
| 认领 | 模型名 → (model, provider, key, api_base) | get_llm_provider | litellm/litellm_core_utils/get_llm_provider_logic.py:130 |
| 预处理 | 八九个固定顺序的归一化步骤 | CompletionTimeout.resolve 等 | litellm/main.py:5223-5352 |
| 派发 | 冻结 ctx + 显式长链 | _CompletionDispatchContext | litellm/types/completion.py:207 |
| 出口 | 交给供应商处理函数 | _complete_* | litellm/main.py:1241-4899 |
3. 一个入口,还是两个?
3.1 它要解决的小问题
LiteLLM 支持 100 多家供应商,每家都要能同步调用也能异步调用。最笨的做法是写两遍:completion() 一套 62 个分支,acompletion() 再来一套。那就是两份永远会不同步的代码。
3.2 思路:异步入口不复制主体,只做三件事
acompletion() 的做法很反直觉——它自己不实现任何调度逻辑,它调用同步的 completion():
- 在参数里塞一个标记
acompletion=True; - 把同步的
completion()丢进线程池执行(不阻塞事件循环); - 拿回返回值:如果是协程就
await它,如果已经是结果就直接返回。
关键在于标记 acompletion=True 会一路传到最底层的 provider handler,handler 看到这个标记就返回一个协程而不是真去发同步 HTTP 请求。所以线程池里那一趟其实不做 IO,只是把上百行调度逻辑跑一遍,产出一个"待执行的协程"。
用示意代码把这个想法演出来:
# 示意,非源码:异步入口如何复用同步主体
async def acompletion(model, messages, **kwargs):
kwargs["acompletion"] = True # ① 打标记,一路传到 handler
func = partial(completion, model=model, messages=messages, **kwargs)
init = await loop.run_in_executor(None, func) # ② 线程池里跑同步调度骨架
if asyncio.iscoroutine(init): # ③ handler 因为标记返回了协程
return await init # 真正的 IO 在这里发生
return init # 缓存命中等情况:已经是结果
重点看第 ①、③ 两步:标记决定了返回值的形态,所以第 ③ 步必须做类型判断。
3.3 真实实现
打标记发生在 completion_kwargs 字典里,一行注释写着"assuming this is a required parameter":
"acompletion": True, # assuming this is a required parameter
—— litellm/main.py:593,这个字典从 main.py:553 起把 acompletion() 的每个显式参数逐个抄进去(没有用 *args/**kwargs 转发,这样 IDE 和类型检查器能看见完整签名)。
进线程池的三行在 main.py:631-635:先 kwargs.pop("acompletion", None) 去掉重复键,再 partial(completion, **completion_kwargs, **kwargs),再用 contextvars.copy_context() 把当前上下文变量复制进去——没有这一步,线程池里的日志上下文(trace id 之类)就丢了。
拿回结果的三态判断在 main.py:639-646:
| 返回值类型 | 什么情况 | 怎么处理 |
|---|---|---|
dict / ModelResponse | 缓存命中(源码注释直书 ## CACHING SCENARIO) | 直接用 |
| 协程 | 正常异步路径,handler 返回了 coroutine | await 它 |
其它(如 CustomStreamWrapper) | 流式 | 原样返回 |
同步入口 completion() 本体在 main.py:4899-4901——注意它头上叠了两个装饰器 @tracer.wrap() 和 @client,@client 就是横切层(见 04-cross-cutting-wrapper.md)。
3.4 两个容易踩的细节
异步路径提前解析了一次 provider。 main.py:600-605 里,acompletion() 在还没进线程池之前就先调了一次 get_llm_provider,只为拿 custom_llm_provider。它的用途是异常映射——main.py:695-703 的 except 里要用 provider 名把原始异常翻译成 OpenAI 风格异常。所以同一次调用里 get_llm_provider 会被执行两次(异步路径一次,completion() 主体一次)。
_async_streaming 不服务于 chat。 main.py:720 定义的 _async_streaming 是一个异步生成器:先判断 asyncio.iscoroutine(response) 决定要不要先 await,再逐行 yield,并把异常包进 exception_type。但在本 commit 里它只被 atext_completion 用到(main.py:7116,包进 TextCompletionStreamWrapper);chat completion 的异步流式走的是 CustomStreamWrapper,那条路在 03-http-and-streaming.md。
4. 认领:模型名 → 供应商
4.1 它要解决的小问题
用户手里只有一个字符串。"gpt-4o"、"anthropic/claude-sonnet-4"、"azure/command-r-plus"、"openrouter/anthropic/claude-3.5-sonnet"——这些要落到同一个答案上:这单归哪家、用哪个 key、发到哪个 URL。
get_llm_provider(litellm/litellm_core_utils/get_llm_provider_logic.py:130)返回一个四元组:
| 位置 | 名字 | 含义 |
|---|---|---|
| 1 | model | 剥掉前缀后的真实模型名(供应商 API 认的那个) |
| 2 | custom_llm_provider | 供应商标识,派发链就是按它分支 |
| 3 | dynamic_api_key | 从环境变量现取的 key(只有部分规则会填) |
| 4 | api_base | 推 断出的 endpoint(只有部分规则会填) |
4.2 决策顺序:命中即停
怎么读这张图:从上往下依次尝试,命中任何一条就立刻返回,不再往下走。 顺序本身就是优先级。
model 字符串
│
▼
┌─────────────────────────────┐
│ 特例层(改写而非返回) │
│ azure/ + 非 OpenAI 模型 → openai :169
│ cohere/ + chat 模型 → cohere_chat :175
│ anthropic/ + text 模型 → anthropic_text :177
│ openrouter/ 前缀剥离 :189
└───────────┬─────────────────┘
▼
┌─────────────────────────────┐
│ 前缀层(有 "/" 才生效) │
│ ① JSON 注册表 exists? :196 ← 优先
│ ② 前缀 ∈ provider_list? :210 / :221
└───────────┬─────────────────┘
│ 无前缀 / 未命中
▼
┌─────────────────────────────┐
│ api_base 反查层 :233 │
│ 逐个匹配 openai_compatible_endpoints │
│ 命中则连 env 里的 key 一起取出 │
└──────── ───┬─────────────────┘
│
▼
┌─────────────────────────────┐
│ 模型名表层 :358-470 │
│ model ∈ open_ai_chat_completion_models … │
│ 或 startswith("bytez/") 之类兜底前缀 │
└───────────┬─────────────────┘
│ 还没命中
▼
fallback 泛化规则 :477 → 仍没有 → BadRequestError :488
4.3 逐条看几个不显然的规则
azure 特例:azure/ 不等于 Azure OpenAI。 Azure AI Studio 上也托管 Cohere 和 Mistral。_is_non_openai_azure_model(get_llm_provider_logic.py:52)切出 / 后的模型名,如果它在 litellm.cohere_chat_models 里、或者 mistral/{name} 在 litellm.mistral_chat_models 里,就把 provider 定成 "openai"(走 OpenAI 兼容协议),而且保留完整的 azure/xxx 模型名不剥前缀(:169-172)。
openrouter 前缀剥离是有条件的。 OpenRouter 自己的模型 ID 长得像 anthropic/claude-3.5-sonnet,加上 LiteLLM 的路由前缀就变成 openrouter/anthropic/claude-3.5-sonnet。剥离规则(:189-194)是:去掉 openrouter/ 后,剩下的部分里还有 / 才剥;像 openrouter/auto 这种原生 ID 剩下 auto 没有斜杠,就原样保留。源码注释把这两种情况都写明了。
JSON 注册表优先于枚举列表。 :196-204 先问 JSONProviderRegistry.exists(provider_prefix),命中就走 _get_openai_compatible_provider_info;之后才是 model.split("/")[0] in litellm.provider_list 的枚举判断(:210、:221)。这让"用一份 JSON 配置新增一个 OpenAI 兼容供应商"不需要动枚举。
api_base 反查会顺手把 key 从环境变量里取出来。 :233 起是一条长长的 if/elif:用户没写前缀但写了 api_base="https://api.groq.com/openai/v1",就认成 groq 并且 dynamic_api_key = get_secret_str("GROQ_API_KEY")。
这条反查路径修过一个真实的凭据外泄漏洞。 匹配函数 _endpoint_matches_api_base(:15)现在用 urlparse 做主机名精确相等 + 路径按段前缀的判断,而不是朴素的子串包含。函数 docstring 直接写出了攻击形态:朴素写法下,攻击者传 https://attacker.com/api.groq.com/openai/v1 就能骗代理去读服务端的 GROQ_API_KEY 并作为 Bearer 凭据发给攻击者的主机。
兜底与失败。 全都没命中时,match_fallback_generalization(model)(:477)做一次声明式泛化(源码注释举的例子是把未来的 claude-* 路由到 anthropic);还是没有,就抛 BadRequestError,消息里附上供应商文档链接(:488-495)。
5. 预处理:一条顺序敏感的流水线
5.1 先看顺序
completion() 主体在拿到 provider 后,按固定顺序做一串归一化。顺序不是随意的:后一步经常依赖前一步的产物(比如超时归一需要先知道 provider 才能判断它支不支持 httpx.Timeout)。
| # | 步骤 | 干什么 | 位置 |
|---|---|---|---|
| 0 | 参数校验 | 修正 messages / tools / tool_choice / thinking 的形状 | main.py:5003-5011 |
| 0 | args = _locals_snapshot(locals()) | 存一份未经改动的原始调用参数 | main.py:5014 |
| 1 | fallbacks / model_list | 有就直接转给 回退或批量执行器,主线到此为止 | main.py:5213-5221 |
| 2 | model_alias_map | 把用户自定义别名换成真实模型名 | main.py:5223-5226 |
| 3 | get_llm_provider | 认领(第 4 节) | main.py:5237-5245 |
| 4 | responses_api_bridge_check | 这个模型该不该改走 Responses API | main.py:5248-5253 |
| 5 | CompletionTimeout.resolve | 超时归一 | main.py:5273-5279 |
| 6 | 自定义定价注册 | 注册本次调用带来的自定义定价 | main.py:5282-5290 |
| 7 | custom_prompt_dict | 组装自定义提示模板 | main.py:5292-5305 |
| 8 | get_provider_chat_config | 拿到该 provider 的 BaseConfig | main.py:5315-5321 |
| 9 | translate_developer_role_to_system_role | 用 config 改写消息角色 | main.py:5324 |
| 10 | get_optional_params | 参数映射(第 02 章的主题) | main.py:5383 |
| 11 | mock 出口 | 有 mock 参数就在这里返回,不发请求 | main.py:5462-5477 |
args = _locals_snapshot(locals()) 这一行值得单独记:它在所有改写之前抓快照,后面 completion_with_fallbacks(**args)(:5215)、batch_completion_models(**args)(:5220)和最外层的异常映射 completion_kwargs=args(:5791)都靠它拿到"用户原本传了什么"。