API gateway 是 Host 与浏览器 Client 之间类型化的远程调用桥梁。它构建于 Typert 类型生成系统之上:业务包用装饰器声明一元 RPC 方法,构建生成匹配的 Host 与 Client 契约,每一次调用都经共享的 Connection RPC /api 路由传输。权威设计文档是 docs/api-gateway.md;实现在 packages/api/gateway(gateway)与 packages/api/remotes(应用级 BFF 门面)中。
| 包 | 角色 |
|---|---|
@deepseek-ai/dsh-api-gateway | 双端 Typert RPC 端点:Host ctx.typertGateway、Client ctx.remote |
@deepseek-ai/dsh-api-remotes | 应用级 BFF:Agent/Session 身份策略、转发事件白名单、Client 挂载门面 |
@deepseek-ai/dsh-typert-protocol | @Remote / @RemoteScope 装饰器、TypertRemoteService、调用描述符 |
为什么需要 gateway
web client 运行在浏览器中;agent 循环、sandbox 与存储运行在 Node host 中。与其为每个功能手写一套 REST surface,dsh 选择生成一套:
- 业务服务用
@Remote('name')(根上下文服务)或@RemoteScope('name')(per-agent 作用域上下文服务)标记它们对外暴露的方法。未标记的方法永远到不了 client——无论生成类型还是运行时都是如此。流式端点用@Remote({ mode: 'stream' }),经网关拥有的 WebSocket mux 伺服,而非一元 HTTP。 - Typert 生成(见 SDK:Typert)读取这些声明,发射
InvocationDescriptor以及类型化的 client stub。 - Connection 提供一元物理通道:带 300 MiB 请求体上限(
DEFAULT_MAX_REQUEST_BODY_BYTES)与application/json强制(415)的共享/api前缀。 - gateway 刻意设计为双端:Host 入口注册
TypertGatewayService(ctx.typertGateway),Client 入口提供ClientRemote(ctx.remote),两者消费同一套生成的描述符契约。
Host 侧:TypertGatewayService
ctx.typertGateway.invoke() 是唯一入口:它为每次调用解析当前的描述符与 Cordis 服务,校验精确的具名参数,解析注册的对象或 Context 身份,调用公开业务方法,并校验结果。
关键机制,来自 packages/api/gateway/src 与 docs/api-gateway.md:
- Strict 与 SRC 模式。 Strict 模式从
ctx.typert.local读取生成的调用描述符。SRC 模式是对从未有过 strict 定义的端点的开发期兜底:它解析简单参数名,并且只接受非查找参数的 JSON 安全值。撤销一条已被观察到的 strict 定义会失败,而不是弱化校验。 - 查找(Lookups)。 复杂的 Host 对象无法跨线传输。业务包通过
TypertLookupMap以及经由ctx.typert.lookups的默认解析 provider 注册其身份映射;一个名为agent的Agent参数会变成一个agentId线字段,在调用前解析回一个存活的 Host 对象。Host 装配可以通过 effect 作用域的ctx.typert.lookups.configure()覆盖策略。 @RemoteScope通过ctx.typert.contexts把一个身份解析到作用域 Context,然后从那个 Context 获取服务——用于方法依赖 per-agent 装配时。- 取消(Cancellation)。 一个 Remote 方法把
signal: AbortSignal声明为其最后的 Host 参数。它是描述符元数据,而非线参数:Connection 把 signal 交给 gateway,由它在解码完业务参数后注入。 - 错误(Errors)。 直接的
invoke()调用保留业务错误;TypertGatewayError区分由派发、绑定、provider、查找、Context、参数与编解码器所拥有的失败。resolve 器可以在TypertLookupFailure中携带一个已有的 RPC 错误以保留其原始错误码(用于如冷恢复失败之类的策略拒绝)。
Client 侧:ClientRemote
ctx.remote.$mount() 校验并注册一次生成的 Host-for-Client contribution,随后为调用方的 Cordis fiber 安装具体的 direct 与 scoped 方法。每个 namespace 是一个带追踪的 remote.<namespace> 子服务,在其最后一个方法被撤销后卸载。重复的端点、namespace 冲突以及缺少严格生成编解码器的描述符都会在方法可调用之前失败。
每次调用都校验位置参数,构造描述符精确的具名 args,并通过 ctx.connection.rpc.call('/api', endpoint, ...) 发送。生成的取消感知方法接受一个可选的最后 AbortSignal 参数,与其 contribution 挂载生命周期结合。
流式端点不落一元调用,而以类型化流载体呈现:RemoteStream<Item>(增量)、RemoteSnapshotStream<Snapshot, Delta>(完整基线后跟替换帧)、RemoteJournalStream(分页/条目/游标日志读取)——全都多路复用进一条位于 /api/remote.mux 的共享 WebSocket(REMOTE_STREAM_MUX_PATH,packages/api/gateway/src/stream-protocol.ts),由网关的 WebSocket Ping 心跳帧保活。因此网关的一元分派与流 mux 共享同一契约:@Remote({ mode: 'stream' }) 装饰的方法走实时流,其余走一元。
ctx.remote.$on() 订阅一个转发的 Host 事件;它的合法键恰是 Host 装配的转发选择(见 packages/api/remotes/src/remote-events.ts 中的 API_REMOTE_FORWARDED_EVENTS),且监听器类型是所属包自己的 Cordis Events 声明——因此任何第二个签名都不可能与之产生漂移。订阅属于调用它的 fiber,并随之消失。
dsh-api-remotes:应用 BFF
packages/api/remotes 是由本应用选定的双端门面:
- Host 入口拥有 Agent/Session 身份策略。
createApiRemoteAgentResolver()复用存活的 agent、恢复普通的冷会话、对并发的恢复去重、保留 subagent 所有权护栏,并为 Typert 的agent与session查找配置同一个 resolve-器——因此已迁移与未迁移的方法共享同一套策略实现。 - Client 入口把生成的
/remote工件作为运行时值导入,通过ctx.remote.$mount()挂载每个 contribution,并仅以类型方式重新导出声明合并。在本修订中,Client 装配在一个循环里挂载 12 个命名空间(packages/api/remotes/src/client/index.ts:146-150):agentPresets、commands、settingsController、goals、llm、dynamic、pluginInventory、messageFeedback、sessionReferences、subagents、session、workspace——四个控制器/域新增(agentPresets、settingsController、llm、sessionReferences)与旧五个以及 session/workspace 控制器的命名空间并排。 - 这个包是本仓库唯一刻意做成"分裂面"(split-face)的包:其 Host 入口必须参与 Host Typert 图,而其 Client 入口在 Host tsdown 生成各业务包的
/remote声明之前无法编译(见packages/api/remotes/README.md)。
一个完整示例
来自 docs/api-gateway.md —— 一个暴露 goal 创建的业务服务:
import type { Agent } from '@deepseek-ai/dsh-agent'
import { TypertRemoteService, Remote, RemoteScope } from '@deepseek-ai/dsh-typert-protocol'
export interface CreateGoalRequest { objective: string }
export interface CreateGoalResult { accepted: boolean }
export class GoalService extends TypertRemoteService {
constructor(ctx: Context) { super(ctx, 'goals') }
@Remote('create')
createForClient(agent: Agent, request: CreateGoalRequest, signal: AbortSignal): CreateGoalResult {
// agent is resolved from agentId by the gateway before this runs
}
@RemoteScope('agent', 'current')
currentForClient(): CreateGoalResult {
// 当前 agent 变体;完整示例还展示了一个两者共享的私有 create() 辅助方法
}
}线路上永远不会出现 Agent;它看到的是 agentId,并由 gateway 在由 api-remotes 配置的身份策略下,将其解析为 Host 存活的 agent 注册表中的一个条目。
与 API 代理的关系
旧 packages/host/apiproxy 曾拥有 /api 的未被认领半边,它已被删除。如今共享 /api 路由只有一组属主:Connection Node 面分派进一个按精确注册 fetch 路由选择目标的 fetch 处理器,其余一律 404——不存在「落入」机制。类型化业务 RPC 骑网关;实时流骑网关的 /api/remote.mux WebSocket。模型流量经 session-controller 的 prompt 路径进入(session.prompt 恢复 Session 并接收 prompt;随后 agent 循环在宿主侧调用 ctx.llm.stream),客户端只拥有 llm.discoverModels/llm.providers 供选择器使用。客户端如今经由 dsh-api-remotes 挂载的控制器命名空间触达这一切(见 API 层)。
关键源文件
| 仓库相对路径 | 提供什么 |
|---|---|
packages/api/gateway/src/index.ts | Host TypertGatewayService、ctx.typertGateway、流 mux 服务器 |
packages/api/gateway/src/stream-protocol.ts | REMOTE_STREAM_MUX_PATH = '/api/remote.mux'、流线上帧、心跳 |
packages/api/gateway/src/client/ | Client 面:ClientRemote(ctx.remote)、RemoteStream/RemoteSnapshotStream/RemoteJournalStream |
packages/api/gateway/src/types.ts | 线类型、TypertGatewayError |
packages/api/remotes/src/remote-events.ts | 转发事件白名单 |
packages/api/remotes/src/client/index.ts | 12 命名空间 Remote 组装($mount 循环) |
packages/api/remotes/src/index.ts | createApiRemoteAgentResolver、身份策略 |
docs/api-gateway.md | 设计参考(双语) |
延伸阅读
- API 层(网关、remotes 与控制器) ——
/api路由、session/settings/workspace 控制器与浏览器会话鉴权 - Typert:类型生成器 —— 描述符与 client stub 如何生成
- Host 平台 —— 挂载这些服务的 webserver
- LLM 层 —— 模型流量与 provider
- 仓库文件:
docs/api-gateway.md、packages/api/gateway/README.md、packages/api/remotes/README.md