9344 star 的 LLM 路由层,236 家 provider 差异藏进一个 endpoint
- Omniroute 五个月斩获 9344 Star、1447 Fork,TypeScript 实现,MIT 协议,release 节奏极快 v3.8.38
- 核心能力:236 家 provider 的差异封装进单一 endpoint,开发者无需适配各家 SDK 细节
- 支持白嫖 Claude 和 GPT 等付费模型的能力是社区爆发的重要驱动力,解决开发者体验痛点
- 路由层已成为 LLM 应用基础设施:屏蔽模型差异、统一错误处理、成本优化,是 2026 年 AI 开发栈的标配组件
上周有个朋友在群里发了个链接,说找到一个「白嫖 Claude 和 GPT」的路由层。我点进去一看,9344 star,1447 fork,MIT 协议,TypeScript 写的。创建时间是 2026 年 2 月 13 号,到今天才五个月。
五个月涨九千多 star,坦白讲,这个速度不正常。
更不正常的是它的 release 节奏。我拉了下 releases 接口,v3.8.38 在 6 月 27 号,v3.8.42 在 6 月 30 号,五天发了五个版本。open-sse/ 目录底下塞了 1186 个文件,光 services/combo/ 就有 27 个 ts 文件,services/autoCombo/ 又是 14 个。这不是个玩具项目,这是个往死里卷的路由层。
它的名字叫 OmniRoute,定位是「Free AI Gateway」,把 200 多家 LLM provider 统一到一个 /v1 endpoint 后面,让 Claude Code、Codex、Cursor、Cline 这些工具都指过来用。
听起来挺像 LiteLLM 和 OpenRouter 的,对吧。我往下读源码,发现事情比 README 写的复杂得多,也乱得多。
一个 endpoint 背后,provider 数自己都对不齐
先把最碍眼的事说在前面。我读 README 的时候注意到一个细节,badge 上写着「231 AI Providers」,紧挨着的标题却写「236 providers」,往下翻到 docs/comparison/OMNIROUTE_VS_ALTERNATIVES.md 这份自家对比文档(标注 v3.8.40,2026-06-28 更新),表格里又写「207+」。仓库 description 里是「231+ providers」。同一个仓库,四个地方四个数。
231、236、207、231+,到底信哪个,老实说我也不知道。
说实话这种数字打架在很多快速迭代的项目里都常见,provider 今天加明天删,README 没人同步。但 OmniRoute 把 provider 数量当核心卖点(标题里就写「236 providers」),数字却对不齐,这个细节后面还会再出现。先记住它,等会儿看批判段。
定位上,它做的事其实不新鲜。一个 HTTP 网关,前面接各种 coding Agent,后面接各种 LLM provider,中间做请求转换、失败重试、fallback 降级。LiteLLM 做这个好多年了,OpenRouter 做成 SaaS 也好几年了。OmniRoute 想赢的点,在于它把这套东西做成了「combo」(组合链)的概念,并且把 fallback 拆成了好几层独立机制。
我把它拆成三层来看,请求转换层、失败处理层、fallback 梯队。每一层都有具体源码能对上。
把 provider 差异翻译成同一种话
路由层最基础的问题,是各家 API 长得不一样。OpenAI 用 messages 数组,Claude 用 system + messages,Gemini 用 contents,Responses API 又是另一套 input 结构。工具调用的字段名、思考模式的参数、temperature 的取舍,每家都不一样。
OmniRoute 的做法是在 open-sse/translator/ 下挂一个注册表。registry.ts 里核心就这么几行。
function makeKey(from: string, to: string) {
return `${from}:${to}`;
}
export function register(from, to, requestFn?, responseFn?) { ... }
export function getRequestTranslator(from, to) {
return requestRegistry.get(makeKey(from, to));
}
请求翻译器和响应翻译器按 from:to 注册,比如 claude-to-openai、claude-to-gemini、gemini-to-openai、antigravity-to-openai,request/ 目录下一溜排开。调路由的时候,拿 getRequestTranslator(from, to) 查表,命中就转,没命中就透传。
但翻译只是第一步。真正让我觉得有意思的,其实是个不起眼的文件 translator/paramSupport.ts。它解决的是「转完之后 provider 还是会因为某些参数返回 400」的问题。
const STRIP_RULES: StripRule[] = [
// claude-opus-4 series: temperature is deprecated (Anthropic returns 400). #1748
{ match: /claude-opus-4/i, drop: ["temperature"] },
// GitHub Copilot gpt-5.4: temperature unsupported.
{ provider: "github", match: /gpt-5\.4/i, drop: ["temperature"] },
// GitHub Copilot Claude (except opus/sonnet 4.6): thinking + reasoning_effort rejected. #713
{
provider: "github",
match: (m) => /claude/i.test(m) && !/claude.*(opus|sonnet).*4\.6/i.test(m),
drop: ["thinking", "reasoning_effort"],
},
];
注释里直接挂了 issue 编号,#1748 是 claude-opus-4 系列 temperature 被废弃、Anthropic 会返回 400,#713 是 GitHub Copilot 的 Claude 不吃 thinking 和 reasoning_effort。这种「按规则把不该传的参数删掉」的做法,比在每个 executor 里散落 delete body.temperature 干净得多。加一条规则就行,不用动调用方。
你想想看,这种细节才是路由层真正的成本。不是「能不能转发」,是「转发之后会不会被 provider 用 400 打回来」。OmniRoute 把这些坑收进了一个配置驱动的 STRIP_RULES 数组,这是它比很多自研代理强的地方。
失败了别急着放弃,先熔断再短冷却重试
请求发出去了,失败了,怎么办。说真的,这是路由层第二个核心问题。OmniRoute 这里堆了三层,熔断器、账户级冷却、combo 级短冷却重试。
熔断器在 src/shared/utils/circuitBreaker.ts。状态机比常见的三态多两态,是 CLOSED → DEGRADED → OPEN → HALF_OPEN → CLOSED。多出来的 DEGRADED 是个预警态,失败率抬头但还没到熔断阈值时,请求照过,但记 warning。这个设计比无脑熔断友好,给你一个「快不行了」的缓冲带。
更妙的是这个文件里有专门处理自家 bug 的函数。
export function isLocalStreamLifecycleError(error: unknown): boolean {
// ...
return /controller is already closed/i.test(message);
}
注释 #4602 写得很直白。Codex 的 WebSocket 转 SSE 桥接代码会抛一个 Invalid state: Controller is already closed,这是 OmniRoute 自己 ReadableStream controller 的 bug,不是 upstream 的错。但这个错误没有 statusCode,默认会被当成 HTTP 502,把整个 Codex provider 拉黑。所以他们加了个 isFailure 钩子,让熔断器忽略这个自家 bug,同时真正的 upstream 5xx 照样计数。
这种「为自家 bug 打补丁」的代码,反而是项目真实在跑的证据。一个没人用的项目不会有这种边角修复。
账户级冷却在 open-sse/services/accountFallback.ts。两个常量值得记一下。
const PROVIDER_FAILURE_ERROR_CODES = new Set([408, 429, 500, 502, 503, 504]);
const CONNECTION_FAILURE_DEDUP_MS = 5000;
const MAX_CONNECTION_FAILURE_DEDUP_ENTRIES = 10_000;
408、429、500、502、503、504 这六个状态码会计入 provider 级失败阈值。注释特意提了 Issue #1846 和 #3200,429 也算进去但每个失败类型有独立冷却(rate_limit 60 秒、quota_exhausted 1 小时),避免大规模 429 把整个 provider 级联熔断。CONNECTION_FAILURE_DEDUP_MS = 5000 是个去重窗口,同一个连接 5 秒内的重复失败只算一次,防止一个抽风的连接把 provider 拉黑。MAX_CONNECTION_FAILURE_DEDUP_ENTRIES = 10_000 是去重表的上限,防内存泄漏。
第三层是 combo 级短冷却重试,在 open-sse/services/combo/comboCooldownRetry.ts。这个文件解决一个很具体的问题,单连接的 quota-share combo 里,upstream 返回一个几秒就恢复的短 429,combo 循环会把唯一的目标标记成锁定,立刻给客户端返 429,但其实等两秒就能成功。
它用了一个 allow-list 来决定要不要等。
export const COMBO_COOLDOWN_NON_RETRYABLE_REASONS: ReadonlySet<string> = new Set([
"quota_exhausted", "auth_error", "not_found", "not_found_local",
]);
export const COMBO_COOLDOWN_RETRYABLE_REASONS: ReadonlySet<string> = new Set([
"rate_limit", "rate_limited", "transient", "overloaded", "server_error",
]);
关键设计是 quota_exhausted 被排除。注释写得很清楚,auth 那边的分类器认为 quota_exhausted 是可重试的,但 recordModelLockoutFailure 会把配额耗尽的模型锁到午夜。如果信任 auth 的分类器,combo 会傻等到午夜。所以这个 helper 用自己的 allow-list,只认 rate_limit、transient、overloaded 这种短时原因,配额耗尽绝对不等。
这种「不信任下游分类器,自己再过一遍」的防御性写法,是踩过坑的人才会写的。
兜底的兜底,4 层 combo 怎么排
前面两层处理的是「单次请求内的失败」,combo 处理的是「跨 provider 的兜底」。我一直觉得 combo 才是 OmniRoute 真正的招牌。OmniRoute 的招牌是 combo,一条链上挂 4 个 model,第一个额度用完自动滑到第二个。
打分引擎在 open-sse/services/autoCombo/scoring.ts。打分函数 calculateScore 把每个候选 provider 按 12 个因子加权求和,权重在 DEFAULT_WEIGHTS 里写死。
export const DEFAULT_WEIGHTS: ScoringWeights = {
quota: 0.15, health: 0.2, costInv: 0.15, latencyInv: 0.12,
taskFit: 0.08, stability: 0.05, tierPriority: 0.05, tierAffinity: 0.05,
specificityMatch: 0.05, contextAffinity: 0.05, resetWindowAffinity: 0,
connectionDensity: 0.05,
};
12 个因子,从配额、健康度、成本、延迟、任务匹配度,到账户层级(ultra/pro/standard/free)、配额重置窗口亲和度、连接密度。health 权重最高 0.2,quota 和 costInv 并列 0.15,resetWindowAffinity 默认是 0(没启用)。
候选对象 ProviderCandidate 里直接带 circuitBreakerState: "CLOSED"|"HALF_OPEN"|"OPEN",打分前先过滤掉 OPEN 的。
这里有个值得记的设计。engine.ts 里有个 ScoreTierRotator,它不直接选最高分,而是把候选分成 top/mid/rest 三档,按 combo 名字偏好选档,档内轮询。
const TIER_PREFERENCES: Record<string, Record<TierName, number>> = {
smart: { top: 0.5, mid: 0.3, rest: 0.2 },
fast: { top: 0.3, mid: 0.5, rest: 0.2 },
cheap: { top: 0.2, mid: 0.3, rest: 0.5 },
coding: { top: 0.6, mid: 0.25, rest: 0.15 },
default: { top: 0.45, mid: 0.35, rest: 0.2 },
};
const CLEAR_WINNER_THRESHOLD = 0.1;
如果最高分和最低分差距超过 0.1,直接选 top 档里的一个。差距不大就按偏好走档。coding 模式给 top 档 0.6 权重,cheap 给 rest 档 0.5,fast 给 mid 档 0.5。这个设计让 combo 不会总锁死在一个 provider 上,top 档内部轮询,避免把一个 provider 打爆。
兜底的兜底是 emergencyFallback.ts。钱包空了(HTTP 402 或错误信息里命中 budget 关键词),自动转到 nvidia 的 openai/gpt-oss-120b,$0 一百万 token。配置长这样。
export const EMERGENCY_FALLBACK_CONFIG: EmergencyFallbackConfig = {
enabled: true,
provider: "nvidia",
model: "openai/gpt-oss-120b",
triggerOn402: true,
triggerOnBudgetKeywords: true,
budgetKeywords: ["insufficient funds", "budget exceeded", "quota exceeded",
"billing", "payment required", "out of credits", ...],
skipForToolRequests: true,
maxOutputTokens: 4096,
};
注意 skipForToolRequests: true。注释说 gpt-oss-120b 可能不支持结构化工具调用,所以带工具的请求不走这个兜底。这种「兜底 provider 能力有限,主动跳过不适用的场景」的诚实,比无脑兜底强。
第三层 fallback 在 modelFamilyFallback.ts,叫「模型家族兜底」。逻辑是某个 model 返回 400/404 说「这个 model 不可用」,就按家族表找兄弟 model 重试。比如。
"claude-fable-5": ["claude-opus-4-8", "claude-opus-4-7", "claude-sonnet-4-6"],
"claude-opus-4-8": ["claude-opus-4-7", "claude-opus-4-6", "claude-sonnet-4-6"],
"gpt-5": ["gpt-5-mini", "gpt-4o"],
claude-fable-5 不可用,就先试 opus-4-8,再试 opus-4-7,最后退到 sonnet-4-6。家族表是手写的有序列表,第一个最优先。这种兜底对那些用别名调 model 的场景特别有用,比如你写 claude-fable-5,但某个 provider 账号没这个 model,路由层自动换到同档的别的型号,客户端无感知。
横向看一眼,它和 LiteLLM、OpenRouter 站哪儿
OmniRoute 自家有一份对比文档 docs/comparison/OMNIROUTE_VS_ALTERNATIVES.md,标的是 v3.8.40,2026-06-28。注意这是 OmniRoute 自己写的对比表,数字对自己有利,得打折看。讲真,自家对比文档总是往自己脸上贴金。但有几个事实是能交叉验证的。
| 维度 | OmniRoute 3.8 | LiteLLM 1.x | OpenRouter | Portkey |
|---|---|---|---|---|
| 部署方式 | 自托管 MIT | 自托管 MIT | SaaS 专有 | 付费 SaaS |
| provider 数 | 207+(自家口径) | ~100 | ~50 | ~30 |
| fallback 策略 | 17 种 | priority | tier-based | weighted |
| 熔断器 | 5 态 + 自适应 | 基础 | 无 | 有 |
| 压缩 | RTK + Caveman | 无 | 无 | 无 |
| MCP server | 87 工具 | 无 | 无 | 无 |
| 语言栈 | TypeScript | Python | 专有 | 专有 |
几个判断点比较清楚。
OpenRouter 是 SaaS,不自托管,按 token 收钱,但胜在一个支付方式打通所有 provider,不用自己管 key。不想自己运维的,OpenRouter 是最省心的。它和 OmniRoute 根本不是一类,一个自托管一个托管,硬比没意义。
Portkey 是商业产品,卖 SLA 和合规,贵。企业要合规和 uptime 保证的,选它。OmniRoute 也带了 guardrails 和 audit,但那是开源自托管,没商业 SLA。
真正和 OmniRoute 同生态位的是 LiteLLM。两者都 MIT、都自托管、都做路由。差别在于,LiteLLM 是 Python 生态,和 litellm.completion() 深度集成,k8s/Helm 部署配方成熟,适合 Python 团队塞进现有微服务。OmniRoute 是 TypeScript,带 Next.js 16 dashboard,17 种路由策略比 LiteLLM 的 priority-based 复杂得多,还塞了 MCP server、A2A 协议、压缩引擎这些 LiteLLM 完全没有的东西。
功能上 OmniRoute 是 LiteLLM 的超集。但功能多不等于该选它,见下一段。
数字对不上的地方,以及什么时候用它纯属多余
回到前面埋的线。README 说 Auto-Combo 引擎「9-factor scoring」,footnote 里写「9 factors (health, quota, cost, latency, success rate, freshness…)」。但 routerStrategy.ts 里 RulesStrategy 的 description 字符串写的是「6-factor weighted scoring: quota, health, cost, latency, taskFit, stability」。我打开 scoring.ts 数 DEFAULT_WEIGHTS 的字段,是 12 个。9、6、12,三个数,我盯着屏幕愣了半分钟。
provider 数也是,231、236、207、231+,四个数。
这种数字打架在小项目里无伤大雅,但在一个把「打分因子数」「provider 数」当卖点反复贴 badge 的项目里,就有点不对劲。它说明 README 的营销话术和实际代码已经脱节了,文档没人维护,或者营销先行代码后补。我倾向后者,因为代码确实在快速迭代,resetWindowAffinity 这个因子权重是 0,明显是占位等激活。
还有个 bus factor 的问题。贡献者接口返回 30 个人,但 issue #5606 在讨论迁移到 bun runtime,#3932(12 条评论,是高赞 issue)在讲「Performance Improvements and ToolStack maturation」,这种核心架构的 issue 评论数才 12 条,说明真正的核心开发者很可能就 diegosouzapw 一两个人。五个月 9344 star,1186 个文件,5 天 5 个 release,这个节奏不是健康社区该有的样子,更像一个人在拼命卷。哪天作者累了,这个项目就停了。
那什么时候用它纯属多余,我倒觉得有几种典型情况。
第一种,你只用一家 provider。比如就 OpenAI,或者就 Claude。你不需要 17 种路由策略,不需要 combo,不需要家族 fallback。装个 OmniRoute 等于给自己加一层没必要的复杂度,key 还得过它一道。直接用 SDK 就完事。
第二种,你是 Python 团队,已经有成熟的 Python 微服务体系。LiteLLM 的 litellm.completion() 能直接嵌进你的代码,k8s 部署配方成熟。OmniRoute 是 TypeScript + Next.js,你得另起一个 Node 服务,运维链路多一截。为了 17 种路由策略多养一个 Node 服务,不划算。
第三种,你不想自托管。那就别看 OmniRoute 了,直接 OpenRouter,一个支付方式打通所有 provider,省心。OmniRoute 的全部价值都建立在「你愿意自己跑一个网关」这个前提上。
反过来,什么时候该用它。
你同时接 Claude、GPT、Gemini,并且有订阅额度(Claude Code 订阅、Copilot 订阅)想榨干,又不想被任何一家限流卡死。你愿意自托管,能接受 TypeScript 栈,需要 MCP server 把 LLM 工具暴露给 Agent 用。这种「多 provider + 订阅榨干 + 自托管 + agent 工具链」的组合需求,LiteLLM 满足不了,OpenRouter 不自托管,只有 OmniRoute 把这套全塞进来了。
它真正的刀刃,不是 236 个 provider 的数字,是 combo 这个概念。把 fallback 从「provider 级」拆到「model 级」「account 级」「家族级」「预算级」四层,每层独立可关,这是它和 LiteLLM 那种「priority-based」单层 fallback 拉开差距的地方。
一个可命名的方法论,分层兜底,每层独立可关
拆完源码,我觉得 OmniRoute 最值得带走的不是某个具体函数,是它的兜底分层思路。
大部分人写 fallback,是一个 try-catch 套一个备用 URL,失败了就换。OmniRoute 把「失败」拆成了至少四种独立形态,配额耗尽、短时限流、模型不可用、预算枯竭,每种走不同的恢复路径。配额耗尽锁到午夜不等,短时限流等两秒重试,模型不可用换家族兄弟,预算枯竭转免费模型。四种机制互不依赖,一个坏了不影响别的。
而且每层都能单独关掉。OMNIROUTE_EMERGENCY_FALLBACK=false 关掉预算兜底,resetWindowAffinity 权重设 0 关掉重置窗口亲和度,STRIP_RULES 增删一条不影响别的。这种「可独立开关的分层兜底」是个能迁移的模式,不止用在 LLM 路由,任何需要多级容错的网关都适用。数据库代理、支付路由、CDN 回源,都能套这个骨架。
不过要记住,这套东西的复杂度也是真的。1186 个文件、27 个 combo 文件、14 个 autoCombo 文件、12 个打分因子、17 种路由策略。你不是在用一个工具,你是在运营一个小型中间件。没有专人维护的话,五个月后作者停更,你这摊子就是你的了。
选它之前,先问自己一句,你需要的是「一个能用的路由层」,还是「一个能把所有 provider 差异和失败模式都吃掉的路由层」。反正我觉得,前者 LiteLLM 够了,后者才轮到 OmniRoute。
评论互动