跳到主要内容

数据截至 (上游 commit e55b2a12c9a5)

模型这条路:一套管线、两种部署、一笔账

30 秒导读: agent 每说一句话都要花钱。这一章讲 Kortix 怎么把「谁在调模型、用谁的 key、走哪个上游、这次多少钱」这四件事,收敛成一条请求管线 + 一个控制面;并且讲清楚它为什么要把这条管线同时跑在 API 进程里一个独立 pod 里。

本章覆盖模型面与计费面。不讲工具调用怎么执行(见 05-executor-connectors)、也不讲沙箱里 opencode 是怎么被拉起来的(见 03-sandbox-runtime)。


1. 先建立直觉:这是一个"收费站"

把它想成高速公路上的收费站。

  • = 一次 chat completion 请求(从沙箱里的 opencode 发出)。
  • 收费站 = LLM 网关。它验票(你是谁)、查余额(还有钱吗)、查限额(这个项目这个月的预算用完没)、指路(该开哪条上游车道)、最后按里程记账。
  • 绕行的小路 = 直接把 ANTHROPIC_API_KEY 塞进 opencode 的环境变量,让它自己去连 Anthropic。

整套设计的核心动作只有一个:把所有小路堵死,让每一辆车都必须过收费站。 后面 §3 会看到 Kortix 是怎么在沙箱里物理地拆掉那条小路的。

一句话定位: POST /v1/llm/chat/completions 是一个 OpenAI 兼容端点,它对外长得像 OpenAI,对内可以把请求翻译成 Anthropic on Bedrock、OpenRouter、或 ChatGPT 的 Responses API。


2. 顶层全景:一次推理的三段路

怎么读这张图: 从左往右是一次请求的时间顺序;上面那条是"钱和身份"(控制面),下面那条是"报文"(数据面)。

沙箱 (opencode)
│ Authorization: Bearer <executor token>
│ POST /chat/completions {model:"glm-5.2", stream:true}

┌─────────────────────────────────────────────────┐
│ @kortix/llm-gateway 管线 │ ← 唯一一份实现
│ │
│ ① 准入 authenticate → billing → budget │
│ ② 解析 requested model → 有序候选列表 │
│ ③ 发车 failover(retry + 熔断) → 上游 │
│ ④ 转播 SSE 中继 + 10s 心跳 │
│ ⑤ 结算 抽用量 → 算钱 → 记账 + 落 trace │
└───────────┬──────────────────────┬──────────────┘
│ hooks(控制面调用) │ transport(数据面)
▼ ▼
apps/api/src/llm-gateway Bedrock / OpenRouter
hooks.ts(唯一真源) / ChatGPT / 用户自己的 key
DB:预算、密钥、密文、
usage_events、账本

管线的五步在 packages/llm-gateway/src/pipeline/handler.ts:238handleChatCompletions 里一眼可见:准入(admit)、解析(hooks.resolveUpstream)、发车(runFailover)、转播(relayStream)、结算(settle)。

部件职责一览:

部件干什么在哪
管线包请求全流程:准入、失败转移、熔断、SSE 中继、用量抽取packages/llm-gateway/src/
控制面认证、计费、预算、候选解析、记账、落 traceapps/api/src/llm-gateway/hooks.ts
内嵌挂载把管线跑在 API 进程里,hooks 直接函数调用apps/api/src/llm-gateway/wire.ts:381
独立 pod把同一管线跑在单独进程,hooks 走 HTTP RPCapps/llm-gateway/src/server.ts:39
RPC 端点控制面的 HTTP 包装,给独立 pod 用apps/api/src/llm-gateway/internal-routes.ts:27
反向代理API 上的 /v1/llm-gateway/* → 独立 podapps/api/src/llm-gateway/wire.ts:543
目录数据托管模型清单、BYOK 目录快照packages/llm-catalog/

3. 为什么一套管线要有两种部署

这是本章最值得学的一个决策。

3.1 问题:长流不该被滚动升级切断

一次带推理的流式对话可以跑几分钟。如果网关就住在 API 进程里,那么每一次 API 发版都会掐断所有在飞的流。API 是个高频改动的服务(路由、账单、项目管理全在里面),网关却需要长连接稳定。两者的发布节奏天然冲突。

于是:把管线再跑一份在独立 pod 里,独立扩缩、独立发版。

3.2 但绝不能变成两份实现

如果独立 pod 自己再写一遍认证和计费,两边就会漂移——线上很快会出现"内嵌路径扣了钱、独立 pod 没扣"这种事故。

Kortix 的解法:管线只有一份代码,控制面也只有一份代码,差别只在 hooks 怎么绑。

createGateway(hooks, config)

┌───────────────────┴───────────────────┐
│ │
内嵌(自托管/开发) 独立 pod(云上生产)
hooks = 直接函数调用 hooks = HTTP 调 /internal/gateway
│ │
└──────────► apps/api/src/llm-gateway/hooks.ts ◄──────────┘
authenticatePrincipal
assertGatewayBudget
recordGatewayUsage
persistGatewayTrace
  • 内嵌绑定:createInProcessGatewayHooks()(hooks.ts:202),七个 hook 全是本进程函数引用。
  • 独立绑定:createApiClient(...)(apps/llm-gateway/src/clients/api-client.ts:57),每个 hook 是一次 POST 到 /internal/gateway/*
  • 两边的 hook 最终落到同一批函数上,internal-routes.ts 里每个 handler 都只是薄薄一层包装(比如 /authorize 就是直接 await authorizeRequest(token),internal-routes.ts:51-62)。

3.3 跨进程的代价与那次合并

内嵌部署里,"认证 + 计费 + 预算"是三次本地函数调用,几乎免费。跨进程时它们变成三次串行 HTTP 往返,直接堆在首字节延迟上。

所以 hooks 接口里多了一个可选的合并门 authorize(packages/llm-gateway/src/domain/hooks.ts:36):

  • 独立 pod 提供它(apps/llm-gateway/src/server.ts:64),三次 RPC 折成一次。
  • 内嵌不提供,继续用三个细粒度 hook。
  • 管线里 admit() 负责在两种形态间抹平差异,无论走哪条,拒绝时返回的响应体和落的 trace完全一致(packages/llm-gateway/src/pipeline/handler.ts:176-236)。

这是一个很干净的模式:把"可以合并的优化"做成可选 hook,而不是分叉出第二条代码路径。

3.4 两种部署的差异清单

维度内嵌 /v1/llm独立 pod apps/llm-gateway
进程API 进程内单独 Bun 进程 / 单独 pod
hooks 绑定直接函数调用HTTP → /internal/gateway/*
准入三个细粒度 hook合并 authorize 一次 RPC
客户端怎么到达KORTIX_URL/v1/llmKORTIX_URL/v1/llm-gateway/v1/llm 反代,或直连
trace 去向只写 gateway_request_logs同时写 DB + Langfuse(配了 key 时)
健康检查/v1/llm/health,一句 ok/health/live + /health(依赖检查、熔断器状态、滚动错误率)
适用自托管 / 开发 / 兜底云上生产

独立 pod 的深度健康检查值得一看:它把"API 是否可达""哪些上游熔断器是 open""最近 300 秒错误率是否超过 50%"合成一个 status,不健康时返回 HTTP 503,让监控只看状态码就能报警(apps/llm-gateway/src/server.ts:132-180)。错误率还设了最小样本量 20,避免低流量时几个错误就误报(server.ts:15-16)。

反代那一侧也做了防御:独立 pod 不可达时返回 502 gateway_proxy_unreachable,而不是让 fetch 的 rejection 裸奔;LLM_GATEWAY_PROXY_TARGET 只接受 http/https,配错就直接关掉代理而不是转发到任意主机(wire.ts:527-569)。

3.5 一个容易忽略的细节:Bun 的 10 秒空闲超时

独立 pod 用 Bun.serve 起服务,而 Bun 默认 idleTimeout 是 10 秒。推理模型两个 token 之间停 12 秒是常事,socket 就被杀了,opencode 那边看到的是 "Connection reset by server"。

apps/llm-gateway/src/main.ts:7-25 把它顶到 255(Bun 上限),同时管线自己每 10 秒发一次 SSE 心跳(§7)。两道保险:心跳保证不空闲,超时上限做兜底。


4. 那道刻意的扣押:为什么沙箱里必须没有 provider key

4.1 问题:opencode 太"聪明"了

opencode 有个行为:只要进程环境里出现 ANTHROPIC_API_KEY 这类变量,它就自动接上原生 provider,直接打 Anthropic。 网关被完美绕过——没有日志、没有预算、没有扣费,而且用户在控制台"断开"BYOK 之后,沙箱里那个模型还在。

4.2 解法:按目录算出黑名单,交给守护进程执行

apps/api/src/llm-gateway/sandbox-credentials.ts:12-23 从 LLM 目录快照里把所有 provider 声明的环境变量名收集成一个集合(按目录 revision 缓存):

// 示意,已按当前源码改写 —— providerCredentialEnv()
const names = new Set<string>();
for (const provider of runtimeModelCatalog.snapshot().providers) {
for (const envVar of provider.env ?? []) names.add(envVar);
}

注意这是从数据推导的,不是手写清单——目录里新增一个 provider,黑名单自动跟上。

链路是这样落地的:

API 侧 沙箱侧
───── ──────
nativeProviderEnvNames()
→ "ANTHROPIC_API_KEY,OPENAI_API_KEY,..."

├─ 开机注入 KORTIX_OPENCODE_DENY_ENV (projects/lib/sessions.ts:270)
│ │
└─ 热更新 POST /env {llmGatewayDenyEnv} │ (routes/env.ts:72 applyLlmGatewayMode)

守护进程拉起 opencode 之前:
逐个 delete env[name]
(opencode.ts:608-617)

关键点:provider key 仍然进得了沙箱容器(agent 自己写的代码可能要用),被扣掉的只是 opencode 这个子进程的环境。守护进程日志会打印 withheld N provider credential(s) from opencode (gateway-only routing)

4.3 一个刻意的例外:Codex

isManagedEnv 里额外收了 CODEX_AUTH_JSONOPENCODE_AUTH_JSON(sandbox-credentials.ts:25-28),但它们是另一种处理:它们在开机时被写进 opencode 的 auth.json,是有意为之的原生 provider,而不是被扣押的对象。原因很简单——ChatGPT 订阅凭据不是按 token 计费的 API key,走网关记账没有意义。

4.4 网关关掉时会怎样

applyLlmGatewayMode(apps/kortix-sandbox-agent-server/src/routes/env.ts:93-116)是个对称开关:

llmGatewayEnabledopencode 拿到什么
trueKORTIX_LLM_BASE_URL = 网关地址、KORTIX_LLM_API_KEY = executor token、KORTIX_OPENCODE_DENY_ENV = 黑名单
false上述全部清空(设为 null),恢复原生 provider 直连

沙箱该访问哪个网关地址由 resolveLlmGatewayBaseUrl() 决定(apps/api/src/llm-gateway/sandbox-base-url.ts:17):显式配了 LLM_GATEWAY_BASE_URL 就用它;否则配了反代就是 <KORTIX_URL>/v1/llm-gateway/v1/llm;都没有就是内嵌的 <KORTIX_URL>/v1/llm


5. 认证:一把 token,三种身份

网关只认 Authorization: Bearer <token>,但 token 可能是三种东西之一。resolvePrincipal(hooks.ts:45)按固定优先级试:

顺序token 形态谁在用解析函数
1kortix_gw_… 网关 API 密钥外部客户直接调网关validateGatewayKey(gateway-keys.ts:88)
2kyolo_… 成员令牌(遗留)按成员归因的旧路径attributeYoloToken(billing/services/yolo-tokens.ts:98)
3账号 PAT / executor token沙箱里的 opencodevalidateAccountToken

文档漂移提示: apps/api/src/llm-gateway/README.md 写的是 kgw_ 前缀,但源码里的常量是 KEY_PREFIX_GATEWAY = 'kortix_gw_'(apps/api/src/shared/crypto.ts:28)。以源码为准。

5.1 executor token 为什么关键

第 3 种分支特意把 projectId / sessionId 一起带出来(hooks.ts:52-62)。因为 executor token 是按 session 签发的(session_id = sandbox_id),这意味着:

  • 每一笔用量都能精确归到某个 session,不只是某个账号;
  • 沙箱回收器也能拿这个当"还活着"的信号。

5.2 网关密钥的生命周期

gateway-keys.ts 是标准的一次性明文模型:

  • 创建时生成 kortix_gw_<随机>,只存 scrypt 哈希和前 14 位前缀用于展示(gateway-keys.ts:30-32,哈希见 crypto.ts:144)。
  • 校验时按哈希查行,状态非 active 或已过期一律拒绝(gateway-keys.ts:105-106)。
  • lastUsedAt 是 fire-and-forget 更新(gateway-keys.ts:108-112),失败不影响请求——代价是管理页上这个时间可能略微陈旧。
  • 吊销是软删除:置 status='revoked' + revokedAt(gateway-keys.ts:79)。

5.3 认证时顺手把两样东西钉在 principal 上

withResolvedTier(hooks.ts:73)在认证成功后,额外解析两件事并塞进 principal:

  1. tier + freeModelsOnly —— 账号档位。免费档不能用平台托管模型(tierGrantsAllModels,billing/services/tiers.ts:730)。内部计费关掉时(自托管)所有人都是全量。
  2. defaultModel —— 这个账号/agent 配置的默认模型(§6.2)。

为什么要在认证阶段做?因为 principal 会跨 RPC 边界传给独立 pod。钉在这里,独立 pod 就不必再回头问一次 API 档位是什么、默认模型是什么。这是"让数据随身携带"来消灭跨进程往返的典型手法(domain/principal.ts:7-29 的注释把这个意图写得很直白)。

档位查询本身还有 30 秒进程内缓存(billing/services/entitlements.ts:70 getCachedAccountTier),因为每一次 chat completion 都要认证一次,而档位几乎不变。


6. 解析:从"一个模型名"到"一串有序上游"

这是整章工程含量最高的一节。输入是一个字符串,输出是一个有序的 UpstreamDescriptor[]——第一个是首选,后面是降级备胎。

6.1 先分清三种模型 id 形态

形态例子含义用谁的 key计费模式
裸 id(无斜杠)glm-5.2平台托管模型Kortix 的 keycredits(1.2×)
provider/modelanthropic/claude-sonnet-4.6BYOK,目录里的模型项目里存的用户 keyplatform-fee(0.1×)
codex/<id>codex/gpt-5.5ChatGPT 订阅用户的 OAuth 凭据none(不计费)

为什么托管模型必须是"裸 id"? packages/llm-catalog/src/index.ts:506-511 的注释说得很清楚:一个不带斜杠的 id,让网关不需要任何额外元数据就能把"走我们的 key、按 credit 计费"和"走用户的 key"区分开,而且两个命名空间永远不会撞车。

opencode 那边把托管模型看成 kortix/<id>,所以有一对纯函数负责两种形态互转:toWireModel / toOpencodeModelRef(resolution/effective.ts:20,30)。

6.2 第一步:路由层把请求落定到具体模型

管线不再自己翻译模型名——它把「请求的模型 + 能力需求(带不带图)」交给控制面的 resolveRoute hook(契约在 packages/llm-gateway/src/domain/hooks.ts:33;API 侧绑的是 resolveGatewayRoute,hooks.ts:429),拿回一份 ModelRoutePlan。默认模型替换、按视觉能力改道、项目级路由策略,全在 apps/api/src/llm-gateway/routing/resolve-route.ts 这一层完成:

  • 请求的正好是默认模型、带了图、而默认模型没有视觉能力 → 改道到项目配置的 visionModel(resolve-route.ts:76-82)。带图请求不会被静默丢掉附件——这条原则还在,只是搬了家。
  • 项目可以声明自己的回退链与规则,由 packages/llm-gateway/src/routing/policy-engine.ts 的策略引擎执行;项目配的生成参数默认值在上线前还会拿实时目录再夹一遍(resolve-route.ts:47-58)。

auto 已退役。 目录里曾经有个合成模型 auto(不是真模型,带图就自动换视觉模型),现已移除:选择器跳过它(models/picker-catalog.ts:111),保存默认模型时直接判它不可服务(resolution/default-model.ts:188),项目路由策略校验也拒绝它(routing/project-policy.ts:11)。它的视觉改道职责由上面的路由层继承。

6.3 默认模型链:谁说了算

defaultModel 从哪来?一条最具体者胜的链,定义在唯一一处纯函数 chooseEffectiveModel(resolution/effective.ts:48):

per-agent 默认 → project 默认 → account 默认 → 平台默认
(最具体) (兜底)

两个设计细节值得记:

  • 免费档的降级方向是"直接掉到平台默认",不是"退到上一层"。 如果选中的候选是托管模型而账号是免费档,直接返回 null(effective.ts:69),而不是继续去看 project/account 层。理由:少一层歧义,行为可预测。
  • 这条链只有一份实现。 Slack 选择器、Web 选择器、频道绑定(channel-bindings.ts:78)、网关的默认模型解析(经 resolveDefaultModelForPrincipal 钉进 principal)都调它。注释里明说这是为了"三者永远不会对'什么是默认'产生分歧"(effective.ts:3-4)。

写入侧还有一道校验:isModelServableForAccount(resolution/default-model.ts:181)在保存默认模型前,直接调用请求时那个 resolveCandidates 探一遍。能解析出候选才允许存。这样"存进去的默认"和"请求时能用的模型"天然一致,而不是靠两套规则各自维护。

resolveEffectiveModel(default-model.ts:220)更进一步:一个显式的 session/频道锁定模型,只有在当下仍然可服务时才生效——BYOK key 被断开、托管模型下架,锁定会自动退回默认链,而不是把这个 session 变成死局。

6.4 核心:resolveCandidates 的三条分支

apps/api/src/llm-gateway/resolution/resolve-candidates.ts:111

怎么读这张图: 从上往下,命中即出;每个出口给的是一个候选列表,不是单个上游。

effectiveModel(auto 已解析)

├─ 以 "codex/" 开头? ─── 是 ──► 解析 ChatGPT 凭据(必要时刷新)
│ → [codexDescriptor] 计费 none
│ 否

provider 段在 BYOK 目录里,且项目存了对应的 key?

├─ 是 ──► [ BYOK 描述符 , 托管兜底模型 ]
│ 计费 platform-fee(0.1×) ← 免费档时降为 none 且无兜底
│ 否

是托管模型(裸 id),且档位允许?

├─ 是 ──► managedCandidates() → Bedrock 或 OpenRouter,计费 credits(1.2×)
│ 否

[] 空列表 → 管线返回 400 model_unavailable

BYOK 后面为什么要排一个托管模型? 这是最妙的一笔。resolve-candidates.ts:57-62 的注释直说:用户自己的 key 撞到限流/配额/账单错误时,失败转移会切到后面那个托管模型(按 Kortix credit 计费),这一轮对话不会死掉。兜底模型由 LLM_GATEWAY_BYOK_FALLBACK_MODEL 配(默认 deepseek-v4-flash,apps/api/src/config.ts:473)。

而且这个兜底是自然退化的:byokFallbackCandidates()(resolve-candidates.ts:64)依赖 managedCandidates(),后者在托管 provider 开关(KORTIX_MANAGED_PROVIDER_ENABLED)没开时返回空数组。所以一个没有任何托管 key 的自托管部署,不需要额外开关就自动没有兜底。

免费档是唯一被排除在外的:isFreeTier 时 markup 归 0、计费归 none(resolve-candidates.ts:199:239-241)——免费用户用自己的 key,Kortix 不抽成也不给托管兜底。

6.5 描述符长什么样

resolution/descriptors.ts 把三类上游各自压成一个 UpstreamDescriptor:

上游kindbaseUrl计费关键构造
托管 Claudebedrockbedrock-runtime.<region>.amazonaws.comcreditsbedrockManagedDescriptor(descriptors.ts:191)
托管其它openai-compatOPENROUTER_API_URLcreditsopenRouterManagedDescriptor(descriptors.ts:160)
ChatGPT 订阅openai-responsesCHATGPT_CODEX_BASE_URLnonecodexDescriptor(descriptors.ts:257)
BYOK由目录推导由目录推导platform-feeresolve-candidates.ts:163 起内联构造

managedCandidates(descriptors.ts:221)按 ManagedModel.transport 二选一,并且在对应的 key 没配时返回 null——所以"托管上游是否存在"完全由环境变量决定,没有第二个开关。

Codex 描述符还带一组必要的伪装头:originator: codex_cli_rs、Codex 的 User-Agent、OpenAI-Beta: responses=experimental(descriptors.ts:258-263)。OpenAI 把订阅访问绑在 Codex 客户端上,不带这些头会被拒。

BYOK 的上游地址不是硬编码的,由 resolveCatalogUpstream(apps/api/src/llm-gateway/models/provider-registry.ts:51)从目录快照推导:provider 的 npm 包名 → 传输类型(providerKindForNpm),provider.api 或一张兜底表 → baseUrl,provider.env[0] → 该去项目密钥里取哪个变量。Bedrock 是个特例:它是"项目自带 key"的 BYOK provider,端点按 region 推导、密钥变量名显式指定,不走通用路径(provider-registry.ts:60-70)。

6.6 Codex 凭据:刷新与宽限

resolveCodexCredential(credentials/codex.ts:157)做三件事:

  1. project_secrets 取出加密的 CODEX_AUTH_JSON(codex.ts:42-63 loadCodexRow;用户私有行与项目共享行的优先序由 resolveProjectSecretForConsumer 统一裁决)。
  2. 快过期就刷新,并且是单飞的——同一 secret 上并发请求共用一个刷新 Promise(refreshSingleFlight,codex.ts:144),避免把 refresh token 打爆。
  3. 刷新失败有宽限期:只要当前 access token 还在有效窗口内,就继续用它;只有真正过期了才抛错(codex.ts:173-178)。一次 OpenAI 认证抖动不该让所有 Codex 请求全挂。

凭据本身怎么来的?apps/api/src/projects/codex-device-auth.ts 用纯 HTTPS 走 OAuth device grant——不起子进程、不依赖 opencode serve:请求 user code → 轮询(pending 期间返回 403/404)→ 用服务端下发的 PKCE verifier 换 token。


7. 发车:三层防线

候选拿到了,接下来是把请求真正打出去。这里有三层职责严格分开的容错。

管什么触发条件实现
重试同一个上游的瞬时抖动超时、网络错误、429、5xxwithRetry(resilience/retry.ts:61)
熔断某个上游整体挂了60 秒窗口内 5 次"上游宕机"信号CircuitBreaker(resilience/circuit-breaker.ts:18)
转移换一个候选上游持续的 402 / 403 / 429 且后面还有候选runFailover(pipeline/failover.ts:153)

7.1 最值得抄的一条:重试集合 ≠ 熔断集合

这两个函数只差几行,但语义完全不同:

  • defaultIsRetryable(errors.ts:42):429 和 5xx 都重试
  • indicatesUpstreamDown(errors.ts:60):只有 超时、网络错误、5xx 算"上游宕机";429/402/403 一律不算。

为什么?errors.ts:52-59 的注释给了理由:熔断器是按 provider 共享的。如果把某个租户 BYOK key 的 429 记成"anthropic 挂了",那么所有 key 完全正常的其他租户也会被熔断挡住。429 是这个凭据的流控,不是这台主机的健康

这是一个很容易写错、写错了会造成跨租户故障放大的地方。

7.2 熔断器细节

  • 失败时间戳存在数组里,超过 windowMs(默认 60 秒)自动老化(circuit-breaker.ts:34 prune)。这样"几小时内零散报错"永远不会跳闸,只有真正的爆发才会。
  • half-open 状态下再失败一次,立即重新打开(circuit-breaker.ts:57-62)。
  • 熔断器是每进程、按 provider 一个,存在 createGateway 闭包里的 Map(create-gateway.ts:53-61)。注释特意点明"一个进程只建一个 gateway 实例,因为熔断器是长生命周期的"。

7.3 转移的判断

failover.ts:327-406 的逻辑:

  • 4xx 且在 LIMIT_STATUSES = {402, 403, 429} 里、且后面还有候选 → continue,试下一个。
  • 其它 4xx(参数错、认证错、模型不存在)→ 原样返回给调用方。这些是调用方自己该修的,换个上游也没用。
  • 全部候选耗尽:熔断导致的返回 503 upstream_unavailable,其它返回 502 upstream_unreachable(failover.ts:410-469)。

注释里有一句关键澄清:能走到转移分支的 429,已经在这个候选上被 callUpstream 重试过了,所以是持续性的,不是瞬时抖动(failover.ts:331-333)。

7.4 总时限:防止病态放大

老实现是 maxAttempts(3) × timeoutMs(120s) = 最坏 6 分钟,而客户端 socket 可能早就断了,服务器还在空转。

withRetry 现在带一个跨所有尝试(含退避睡眠)的墙钟预算 deadlineMs,默认 240 秒(retry.ts:28-47)。每次尝试前检查剩余预算,单次超时取 min(timeoutMs, remaining),退避睡眠也不许睡过线(retry.ts:80-105)。

7.5 传输层:一个 AI SDK 引擎吃掉所有线协议

这一层在 2026-07-18 整体换过心。 四个手写的线协议翻译器(openai-compat / openai-responses / anthropic / bedrock,各自拼请求、翻响应)被全部删除,统一换成 Vercel AI SDK 的 provider 包来驱动 streamText/generateText(transports/ai-sdk/index.ts:226 callUpstreamViaAiSdk)。descriptor.kind 的五种取值还在(domain/descriptor.ts:3-8),但它现在只用来选 AI SDK 家族:aiSdkFamilyFor(transports/ai-sdk/model.ts:41)按目录里的 npm 包名把描述符映射到 @ai-sdk/openai / openai-compatible / @ai-sdk/anthropic / @ai-sdk/amazon-bedrock

手写的部分只剩下路由谓词:transports/route-kind.ts 回答"这个请求要不要改走 OpenAI 的 Responses API"——真 OpenAI 主机 + 带 function tools + 非 none 的 reasoning effort 时,chat/completions 会拒,于是换 .responses()(route-kind.ts:14-16 的搬迁注释点名了这段"native-transport deletion"历史)。Anthropic/Bedrock 的 prompt caching 以 providerOptions 断点的形式移植进了引擎(transports/ai-sdk/request.ts:21-27)。

对客户端而言,进来出去都是 OpenAI 兼容的——这个结论不变,只是"出去"那侧的翻译官从四个手写翻译器变成了 AI SDK。

一个刻意的不作为:管线不会替客户端设默认的 reasoning effort(handler.ts:561-564)。理由有两条——推理 token 要花钱,该由客户端决定;而且 Bedrock/Anthropic 家族的传输自己拼载荷,顶层的 reasoning 字段会被静默忽略,设了反而制造假象。


8. 流:心跳与"一定会结算"

流式响应有两个独立的难题。

8.1 难题一:长静默会被各跳超时掐断

一条流要穿过若干跳(网关 → API 反代 → opencode),任何一跳的空闲超时都能杀掉它。推理模型的首 token 延迟轻松超过 10 秒。

relayStream(pipeline/streaming.ts:309)的做法:

  • 每 10 秒(HEARTBEAT_MS,streaming.ts:70)注入一个 SSE 注释行 : keep-alive\n\n(帧常量见 streaming.ts:73)。注释行被所有 SSE/OpenAI 客户端忽略,是纯粹用来重置各跳空闲计时器的隐形负载
  • 心跳只在 SSE 事件边界注入(刚写过的结尾字符正好是 \n\n,或还什么都没写),绝不切开一个半截事件(streaming.ts:455-457)。
  • 实现上始终只保持一个 reader.read() 在飞,用 Promise.race 去和心跳定时器赛跑(streaming.ts:415-428)——不会因为心跳而发出第二次读。

8.2 难题二:结算必须发生,且不能炸

流是在一个游离的 async 任务里读的。如果结算(抽用量 → 记账 → 落 trace)抛错,那就是一个 unhandled rejection,而且这一次的账悄无声息地丢了

所以结算被放在 finally 里,并且整个包在 try/catch 中,失败只打日志(streaming.ts:537-544)。

还有一个有意为之的浪费:即使客户端已经断开(downstreamAlive = false),循环仍然继续把上游读完。原因写在 RELIABILITY-BACKLOG.md 里——账目完整性优先于那点白读的带宽


9. 一笔账:从 token 到 credit

9.1 用量怎么抽出来

两条路,同一套归一化(usage/extract.ts:27 normalizeUsageChunk):

  • 非流式:extractUsageFromJson,直接读响应 JSON 的 usage
  • 流式:extractUsageFromSseBuffer(extract.ts:46),逐行扫 data:,取最后一个带 usage 的 chunk(OpenAI 兼容流把用量放在末尾)。

为此管线在流式请求上强制加了 stream_options: { include_usage: true }(handler.ts:566-568)——不加的话上游根本不发用量。

缓存 token 兼容两种字段名:顶层 cached_tokensprompt_tokens_details.cached_tokens(extract.ts:29)。

9.2 钱怎么算

calculateCost(usage/pricing.ts:68)只有三种取价来源,按优先级:

  1. 描述符自带的 pricing(来自 models.dev 的实时价)→ 按表计算(pricing.ts:18 priceFromTable)。
  2. 上游自报的 cost 提示(OpenRouter 会给)。
  3. 都没有 → 0。

然后:

finalCost = upstreamCost × markup

markup 的语义要看清楚,它不是加价百分比,是乘数:

billingModemarkup 取值含义定义处
creditsllmPriceMarkup(),默认 1.2托管模型:按上游成本 1.2 倍扣 credit(20% 毛利)billing/services/tiers.ts:34-40
platform-fee0.1BYOK:用户自己付给 provider,Kortix 只收 10% 平台费resolve-candidates.ts:26
none0Codex 订阅 / 免费档 BYOK:不收handler.ts:1036

llmPriceMarkup() 可以用 KORTIX_LLM_MARKUP 覆盖,但钳到 ≥ 1,保证永远不会低于上游成本卖(tiers.ts:38)。

价格表本身来自 models.dev:开机时 initModelPricing() 阻塞拉一次(保证第一个请求就有价),之后每 24 小时后台刷新,刷新用的是"先建新表再原子替换"的写法,读者永远看不到半成品(router/config/model-pricing.ts:98,131-173)。查询做了三级容错:精确 id → 归一化 id(小写、点转横杠)→ 前缀互相包含(model-pricing.ts:78-88)。

一个防漏收的告警: 如果 markup > 0、有 token、但算出来 upstreamCost = 0,说明目录里没这个模型的价——直接打 warn,而不是静静地按 0 扣(handler.ts:1053-1059)。

9.3 记账:一次写两个地方

recordGatewayUsage(hooks.ts:132):

recordGatewayUsage(event)

┌─────────────────┴─────────────────┐
│ │
usage_events 一行 钱包扣款
(永远写,给可观测) (仅当内部计费开 且 billingMode ≠ none)
按 projectId / sessionId 归因 deductForLlmUsage → credit_ledger
metadata 记 upstreamCost/markup type = 'llm_debit'
  • usage_events 永远写(shared/usage-events.ts:26),包括自托管、包括 billingMode: none。它是可观测性底座,不是账单。
  • 扣款只在 KORTIX_BILLING_INTERNAL_ENABLEDbillingMode !== 'none' 时发生(hooks.ts:154)。
  • deductForLlmUsage(billing/services/credits.ts:154)扣完还会把审计信息(usageEventIdupstreamCostUsdmarkuproute)回填进账本行的 metadata(credits.ts:144-166),这样一条账本能反查到具体是哪次请求、原价多少、乘了多少。

trace 是另一条独立的线:persistGatewayTrace(hooks.ts:168)写 gateway_request_logs,记录时延、试了哪些候选、重试几次、请求/响应体快照。401 这种还没认出账号的失败会被丢掉——没有可归因的对象,存了也是垃圾(hooks.ts:169-170)。body 抓取有 256KB 上限,超了就截断并标记 truncated(create-gateway.ts:68-76)。

9.4 预算:项目级与成员级

checkBudget(budgets.ts:108)是花费上限的实现:

  • projectId 直接放行(budgets.ts:109)——上限是按项目挂的。
  • 只看 action = 'block' 的预算行(budgets.ts:124),说明还有 warn 之类的档位。
  • scope = 'member' 的预算只对该成员生效(budgets.ts:119)。
  • 已花多少?直接对 gateway_request_logs.finalCost 求和,时间窗用 SQL 的 date_trunc(period, now())(budgets.ts:22-42)。也就是说 trace 表同时是账单事实表——落 trace 失败会让预算算漏。

超限返回的是 402 而不是 429,并且在 admit 里有一句注释解释原因:预算耗尽是终局状态,等待不会自愈,所以绝不能被当成瞬时限流去重试(handler.ts:223-231)。

9.5 已知的不严谨处

RELIABILITY-BACKLOG.md 自己列了:检查和扣款是两步,不是原子的。同一个主体上的并发请求可以超出上限大约"一个在飞请求"的量。修法需要预留(hold)行或数据库级原子扣减,是 schema + 计费路径改动,被有意推迟了。


10. 另一条路:router/ 下的托管出口

apps/api/src/router/ 是一套并行的出口,和 llm-gateway/ 不是同一条管线,别混淆。

llm-gateway/router/
端点/v1/llm/chat/completions/chat/completions/llm/*/{service}/*
谁在调沙箱里的 opencodeCLI、session token 持有者、代理服务的调用方
上游选择多候选 + 失败转移直连 OpenRouter(proxyToOpenRouter)
记账recordGatewayUsage + tracedeductLLMCredits + applyActorSpend
用量抽取管线内 settle把流 tee() 成两份,一份给客户端一份给计费(router/routes/llm.ts:151)

这条路上有两个值得单独一提的机制:

成员花费上限(和网关预算是两套)。 member-spend.tssandbox_members 行记月度花费,并且用 owner 的账单周期起点做惰性归零:存的周期起点和 owner 当前周期不一致,就当作 0 重新开始(router/services/member-spend.ts:39-46)。它还提供了真正原子的预留版本 reserveActorSpend——把上限检查写进 UPDATE 的 WHERE 里,更新不到行就说明超限(member-spend.ts:109-130)。这正是 §9.5 里网关预算欠缺的那个手法。

三模式代理。 router/routes/proxy/handlers.ts:51-61 定义了同一个端点的三种身份组合:

模式带什么凭据行为计费
1Kortix token注入 Kortix 的 keyKORTIX_MARKUP = 1.2×
2用户 key + X-Kortix-Token透传,不注入PLATFORM_FEE_MARKUP = 0.1×
3只有用户 key纯透传不计费、不设闸

这三档和网关的 credits / platform-fee / none 是同一套商业逻辑在另一个表面上的镜像(常量定义在 apps/api/src/config.ts:813,816)。


11. 巧妙之处(可以直接抄的)

  1. 可选的合并 hook,而不是分叉的代码路径。 authorize 只在跨进程时提供,admit() 在管线里抹平两种形态,拒绝响应和 trace 完全一致(domain/hooks.ts:36handler.ts:176)。

  2. 重试集合和熔断集合必须分开定义。 熔断器按 provider 共享,把单租户的 429 计成"上游宕机"会造成跨租户故障放大(errors.ts:52-64)。

  3. 黑名单从数据推导,不是手写。 要扣押的 provider 环境变量由目录快照算出来(sandbox-credentials.ts:12-23),新增 provider 自动覆盖。

  4. 兜底能力靠"配置缺失自然退化",不靠开关。 没配托管 key → managedCandidates() 空 → BYOK 没有兜底,不需要额外的 feature flag(resolve-candidates.ts:64-70)。

  5. 写入前用读取路径校验。 保存默认模型前直接跑一遍 resolveCandidates,保证"能存的"就是"能用的"(default-model.ts:181-207)。

  6. 一条纯函数定义所有优先级。 agent → project → account → platform 只有一处实现,Slack / Web / 网关共用,天然不会分歧(effective.ts:48)。

  7. 心跳只在协议边界注入。 SSE 注释行是隐形的,但也必须落在 \n\n 之后才安全(streaming.ts:455)。

  8. 把"算不出价"变成可见告警。 billable 却算出 $0 时打 warn,把收入泄漏从静默变成可查(handler.ts:1053-1059)。

  9. 让 principal 随身携带昂贵的解析结果。 tier / freeModelsOnly / defaultModel 在认证时一次解析好,跨 RPC 边界带走,消灭二次查询(domain/principal.ts:7-29)。

  10. 目录是预先算好的常量,不是每次请求现拼。 目录里约 5000 个模型,三种可见性组合在模块加载时各构建一次(catalog-models.ts:306-327),/models 请求只是选一个引用。


12. 边界与局限

诚实清单,大部分来自项目自己维护的 apps/api/src/llm-gateway/RELIABILITY-BACKLOG.md:

局限后果状态
预算检查与扣费非原子并发下可超出上限约"一个在飞请求"有意推迟,需要 hold 行或 DB 级原子扣减
API ↔ 独立 pod 只有共享 bearer无 mTLS / HMAC 签名已做轮换 + 常量时间比较(internal-auth.ts:22),完整方案属基建
无幂等键客户端重试可能重复记一次用量需要先约定客户端契约
只有花费预算,没有 QPS / 次数限流单账号可以用高频请求压上游未实现
requestId 不往上游传跨 provider 追踪断链未实现
客户端断开后仍读完上游少量带宽浪费有意为之:账目完整性优先
网关密钥 lastUsedAt 是 fire-and-forget管理页时间可能陈旧有意为之
预算依赖 gateway_request_logs 求和trace 落库失败 → 预算算漏代码可见的耦合
计费相关测试缺 DB 夹具预算、档位门、BYOK markup 无自动化覆盖backlog 明确列出

还有两条不算缺陷但要知道:

  • auto 合成模型已移除(见 6.2):picker、默认模型校验、路由策略校验三处都只拒绝它,视觉改道由路由层接管。
  • 托管模型清单是硬编码的——当前启用七个,另三个(Claude Opus 4.8 / Sonnet 4.6 / Kimi K3)在 2026-08-10 精简中下线、以注释形式保留(packages/llm-catalog/src/index.ts:532-811),因为托管 slug 和 models.dev 的 id 对不上(z-aizhipuai、Claude 的点号 vs 横杠),vision 和上下文窗口只能手工维护。

13. 代码地图

主题文件关键符号
三个挂载点apps/api/src/llm-gateway/wire.tsmountLlmGateway
控制面唯一真源apps/api/src/llm-gateway/hooks.tsauthenticatePrincipalwithResolvedTierauthorizeRequestrecordGatewayUsagepersistGatewayTracecreateInProcessGatewayHooks
跨进程 RPCapps/api/src/llm-gateway/internal-routes.tscreateInternalGatewayRoutes
内部 token 比较apps/api/src/llm-gateway/internal-auth.tsmatchesInternalToken
花费上限apps/api/src/llm-gateway/budgets.tscheckBudgetspendForPeriod
网关密钥apps/api/src/llm-gateway/gateway-keys.tscreateGatewayKeyvalidateGatewayKeyrevokeGatewayKey
沙箱扣押清单apps/api/src/llm-gateway/sandbox-credentials.tsPROVIDER_CREDENTIAL_ENVnativeProviderEnvNamesstripGatewayManagedCredentials
扣押的执行apps/kortix-sandbox-agent-server/src/opencode.tsdenyEnv 循环(608-617)
网关开关热更新apps/kortix-sandbox-agent-server/src/routes/env.tsapplyLlmGatewayMode
项目开关 / 公网地址apps/api/src/llm-gateway/{enablement,public-url}.tsprojectLlmGatewayEnabledpublicGatewayBaseUrl
候选解析apps/api/src/llm-gateway/resolution/resolve-candidates.tsresolveCandidatesbyokFallbackCandidatesPLATFORM_FEE_MARKUP
上游描述符apps/api/src/llm-gateway/resolution/descriptors.tsmanagedCandidatesbedrockManagedDescriptoropenRouterManagedDescriptorcodexDescriptorlivePricing
默认模型链apps/api/src/llm-gateway/resolution/{effective,choose-default-model,default-model}.tschooseEffectiveModeltoWireModelchooseDefaultModelresolveDefaultModelForPrincipalisModelServableForAccountresolveEffectiveModel
服务端目录apps/api/src/llm-gateway/models/catalog-models.tsgatewayModelCatalogmanagedModelsgatewayModelsAllgatewayCodexModels
选择器 / 自动播种apps/api/src/llm-gateway/models/{picker,picker-catalog,seed-default,codex-models}.tslistPickerModelsseedProjectDefaultModelOnConnectcodexModelIds
Codex 凭据apps/api/src/llm-gateway/credentials/{codex,codex-core}.tsresolveCodexCredentialrefreshSingleFlighttokenStillValid
Codex 设备授权apps/api/src/projects/codex-device-auth.tsstartCodexDeviceAuthpollCodexDeviceAuth
管线装配packages/llm-gateway/src/create-gateway.tscreateGatewaybreakerForcapture
主流程packages/llm-gateway/src/pipeline/handler.tshandleChatCompletionsadmitsettle
失败转移packages/llm-gateway/src/pipeline/failover.tsrunFailoverLIMIT_STATUSES
SSE 中继packages/llm-gateway/src/pipeline/streaming.tsrelayStreamHEARTBEAT_MS
trace 发射packages/llm-gateway/src/pipeline/trace.tscreateTraceEmitter
重试 / 熔断packages/llm-gateway/src/resilience/{retry,circuit-breaker}.tswithRetryCircuitBreakerwithResilience
错误语义packages/llm-gateway/src/errors.tsdefaultIsRetryableindicatesUpstreamDown
上游调用packages/llm-gateway/src/http/call-upstream.tscallUpstream
传输谓词 + AI SDK 引擎packages/llm-gateway/src/transports/{index,route-kind}.tstransports/ai-sdk/resolveTransportKindaiSdkFamilyForcallUpstreamViaAiSdkresolveAiModel
路由策略层(默认/视觉改道/回退链)apps/api/src/llm-gateway/routing/{resolve-route,project-policy}.tspackages/llm-gateway/src/routing/createGatewayRouteResolvercreateModelFallbackPolicyEngine
BYOK 上游推导apps/api/src/llm-gateway/models/provider-registry.tsresolveCatalogUpstream
用量与定价packages/llm-gateway/src/usage/{extract,pricing}.tsextractUsageFromSseBuffercalculateCostpriceFromTable
hooks 契约packages/llm-gateway/src/domain/{hooks,principal,descriptor}.tsGatewayHooksAuthorizeResultAuthedPrincipalUpstreamDescriptor
独立 podapps/llm-gateway/src/{main,server,config}.tsbuildServerBun.serve idleTimeoutrequiredApiToken
pod → API 客户端apps/llm-gateway/src/clients/api-client.tscreateApiClient
pod 镜像apps/llm-gateway/Dockerfile合成最小 workspace 的 deps stage
托管模型清单packages/llm-catalog/src/index.tsMANAGED_MODELSgetManagedModelDEFAULT_MANAGED_MODEL_IDS
档位与加价apps/api/src/billing/services/tiers.tsllmPriceMarkuptierGrantsAllModelsDEFAULT_LLM_PRICE_MARKUP
计费闸 / 档位缓存apps/api/src/billing/services/{billing-gate,entitlements}.tsassertBillingActivegetCachedAccountTier
钱包扣款apps/api/src/billing/services/credits.tsdeductForLlmUsage
自动充值常量packages/shared/src/constants/auto-topup.tsAUTO_TOPUP_DEFAULT_THRESHOLDAUTO_TOPUP_DEFAULT_AMOUNT
实时价格apps/api/src/router/config/model-pricing.tsinitModelPricinggetModelPricingstopModelPricing
另一条托管出口apps/api/src/router/routes/llm.tsextractUsageFromStreamtee()
成员花费上限apps/api/src/router/services/member-spend.tsreserveActorSpendapplyActorSpendgetSandboxMemberCapStatus
三模式代理apps/api/src/router/routes/proxy/handlers.tshandleProxy

14. 相关章节

  • 02-session-lifecycle —— executor token 是在哪一步按 session 签发的。
  • 03-sandbox-runtime —— 守护进程怎么拉起 opencode(本章只讲它拉起前扣掉了哪些环境变量)。
  • 05-executor-connectors —— 工具侧的凭据闸门,和本章的模型侧闸门是一对对照。
  • index —— 全书导读。