通过 OpenRouter 路由

OpenRouter 是 HybridInference 可以路由到的 provider 之一,也是全新检出默认使用的那一个。本页专门讲 OpenRouter adapter。路由总体上如何工作见架构;模型注册表的语法见添加新模型

默认的模型目录

在没有任何环境变量、也没有部署 overlay 的情况下,配置解析会一路回落到 config/examples/models.openrouter.yaml,它针对 OpenRouter 注册了三个模型——两个直连,另一个演示了本地优先、以 OpenRouter 作为回退分支(leg)的混合路由。给出一把 API key 就足以跑起一个可用的网关:

source .venv/bin/activate            # the env `make setup-dev` creates
export OPENROUTER_API_KEY=sk-or-...
uvicorn serving.servers.app:app --host 127.0.0.1 --port 8080

要从仓库根目录运行:默认路径都是相对路径;启动日志会用类似 Registered 3 routes from config/examples/models.openrouter.yaml 的一行确认选中的是哪份注册表。

curl -s --noproxy '*' http://127.0.0.1:8080/routing

这份目录是起点,不是一成不变的样板:OpenRouter 的模型 slug 会变动,而那个文件里的价格数字只是近似值,仅用于网关自己的用量核算。复制一份随意改。

路由语法

一条 OpenRouter 路由就是某个模型 route: 列表里的一项。kind: 有两种写法。

kind: openrouter

由 OpenRouter 自己按它的默认策略挑选上游 provider。

- kind: openrouter
  weight: 1.0
  base_url: https://openrouter.ai/api/v1
  api_keys:
    - ${OPENROUTER_API_KEY}
  provider_model_id: meta-llama/llama-3.3-70b-instruct

provider_model_id 是 OpenRouter 自己对该模型的 slug。客户端请求时用的 id: 是网关的;两者相互独立,这是有意为之。

kind: openrouter[<slug>]

通过发送 provider: {order: [<slug>], allow_fallbacks: false},把这条分支上的每个请求都钉死在某一个 OpenRouter 上游。

- kind: openrouter[deepinfra]
  weight: 1.0
  base_url: https://openrouter.ai/api/v1
  api_keys:
    - ${OPENROUTER_API_KEY}
  provider_model_id: meta-llama/llama-3.3-70b-instruct

slug 必须匹配 [A-Za-z0-9_.-]+,也可以带以 / 分隔的多段(deepinfradeepinfra/turbo)。写坏的方括号形式——空的 pin、空白字符、嵌套或不配对的方括号——会在注册时报错,而不是被悄悄忽略。OpenRouter 的 provider slug 列表见 https://openrouter.ai/docs/features/provider-routing

两条共用同一个 base_url 但钉了不同上游的分支,仍然会拿到各自不同的 endpoint_id,因为带方括号的 kind 会保留进标识符里。因此它们的熔断器(circuit breaker)和可用性状态是彼此隔离的:一个不稳定的上游不会把另一个一起带下去。不过在 api_logs 里,两种写法记录的都是 provider = "openrouter",所以分析侧看到的是同一个 OpenRouter 群组。

排序策略

通过 admin provider-routes API 创建的 OpenRouter 路由,可以带一个取值为 pricethroughputlatencyopenrouter_sort 策略,它会以 provider: {sort: <policy>} 发出。它只对未钉死的路由生效——带方括号 pin 的路由发的是 order,pin 优先。对非 OpenRouter 路由或无法识别的取值,该 API 会以 422 拒绝这个字段。

adapter 发出的内容

OpenRouterAdapterapps/backend/serving/adapters/openrouter.py)是通用 OpenAI 兼容 adapter 的一个很薄的子类。在普通请求之上,它额外加了:

  • 归属请求头X-Title 带的是站点名,HTTP-Referer 带的是站点的公开 base URL,两者都从站点身份解析而来(SITE_NAME / SITE_PUBLIC_BASE_URL,其次是当前生效的 distribution manifest,再次是中性默认值)。OpenRouter 会把流量归属到这两个头指向的对象,用于排行榜排名和免费额度限制。没有值的头会被省略,而不是发一个空值,所以未声明 SITE_PUBLIC_BASE_URL 就意味着完全不发 HTTP-RefererX-Title 则一定会发出,在没有设置站点名时回退到字面量 HybridInference。想让流量记在你自己的账号上,就声明 SITE_NAME

  • usage: {include: true} 加在每一个请求上,好让 OpenRouter 返回它按请求计的 cost 字段。

  • stream_options: {include_usage: true} 加在流式请求上,好让最后一个 SSE 分块带上 usage 块。

  • provider: {...} 用于上面讲的钉死和排序两种情况。

成本核算

两个数字,刻意分开:

api_logs 中的列

含义

cost_usd

向调用方收取的费用:token 数 × 你在注册表里为该模型声明的价格。不受 OpenRouter 影响

upstream_cost_usd

OpenRouter 报告的、它就这次请求向你收取的费用

对非 OpenRouter 路由,upstream_cost_usdNULL;对 OpenRouter 路由,如果响应里没有带成本数字,它同样是 NULL

API key

路由的 api_keys: 是一个列表。只有一项时——常见情况——adapter 直接用它。有多项时,继承来的密钥池逻辑会轮换:某把密钥遇到与密钥相关的或临时性的失败(429、401/402/403、408/425、5xx 或超时)时会被静默五分钟,请求转到下一把。像 400422 这类与请求本身有关的失败在每把密钥上都会同样失败,所以它们会立刻向上传递,而不是白白耗掉整个池。

如果 api_keys: 里的某一项展开自一个未设置的环境变量,它会被当作不存在。此时标了 optional: true 的路由会被跳过,并打出一条点名模型和 kind 的警告;其他路由则会抛出 MissingEnvBackedKeyError,启动失败。无论哪种情况,都不会注册一条指向自己无法完成鉴权的端点的路由。

错误处理

OpenRouter 的错误走的路径和任何其他 OpenAI 兼容 provider 的一样。有两套机制处理它们,而且彼此独立:

回退。发往某条 OpenRouter 分支的请求因任何原因失败时,router 会记下这次失败,并按路由顺序尝试该模型剩下的分支,跳过被管理员禁用的、模态不兼容的和熔断已打开的分支。例外是调用方用 X-Route-Pin 显式钉死的请求,它永远不会回退。如果每条分支都失败,客户端看到的是第一个错误。失败的状态码并不决定「回退」还是「向上传递」——那个决定只取决于是否还有另一条分支可用。

熔断器。失败按 endpoint_id 累计;连续失败达到 CIRCUIT_FAILURE_THRESHOLD 次(默认 3)之后,该端点会在 CIRCUIT_COOLDOWN_SECONDS(默认 30)内停止接收流量,然后才做一次半开探测。客户端错误不计入:除 408、429、401 和 407 之外的 4xx 不会打开熔断,因为让一个调用方的错误请求把这个端点从所有人手里拿走,正是这条豁免要防的那种连锁反应。408 和 429 表示 OpenRouter 过载,计入。401 和 407 表示网关自己配置的凭据被拒——401 是 OPENROUTER_API_KEY,407 是出网代理在要它自己的凭据——这是任何调用方都绕不过去的,所以它们既计入,还会额外触发告警。

402 和 403 是尴尬的一对:它们在这里被熔断器豁免,但 KeyPool 仍然把它们当作 key 相关的失败,会静默看到它们的那把 key(_KEY_SPECIFIC_STATUSES)。于是一个欠费账号对所有请求都答 402 时,每把 key 都会被静默,随之而来的 KeyPoolExhausted 不带任何 HTTP 状态码、不在豁免之列,照样会打开熔断。403 被刻意排除在鉴权升级集合之外,是因为远端 provider 把它当作按请求拒绝的通用出口——内容策略、地域封锁——这些情况下上游本身是健康的;理由见 apps/backend/routing/endpoint_health.py

排查

每个 OpenRouter 请求都返回 401。被拒的是网关的密钥,不是调用方的。检查后端进程实际看到的那个环境里的 OPENROUTER_API_KEY。完全没设置的密钥根本走不到这一步——如上所述,路由会被跳过,或者启动直接失败——所以 401 意味着密钥是有的,只是被 OpenRouter 拒了。

/v1/models 里没有这个模型。确认网关加载的是哪份注册表:启动日志会打出 Registered N routes from <path>,那就是解析链选中的文件。如果它不是你改的那个文件,说明有显式的 MODELS_CONFIG_PATH 或某个 distribution manifest 压过了它——见 配置

请求打到了错误的上游。用 GET /routing 查看每个模型实际生效的权重分布。注意这个端点没有鉴权,而且会泄露上游的 base URL;见 架构 里的警告。