跳到主要内容
路径文档

Remote API 网关

一句话版:Typert Remote 网关是 Web UI 的双向 RPC 端点:宿主侧 ctx.typertGateway 按生成的 InvocationDescriptor 校验具名参数、解析 agent/session 身份并注入取消信号;客户端 @deepseek-ai/dsh-api-gateway/client 提供 ctx.remote$mount()/$on()/$dispatch());插件作者用 @Remote/@RemoteScope 装饰器把宿主能力暴露给远端,api-remotes 封装 Agent 身份策略与转发事件名单。它演进自 legacy apiproxy,两者共享连接层。

一、两代网关:从 apiproxy 到 Typert Gateway

packages/api/gateway 是"宿主 + 客户端"两侧的 Typert RPC endpoint 包:

  • Host 入口(packages/api/gateway/src/index.ts)注册 ctx.typertGateway
  • @deepseek-ai/dsh-api-gateway/client(packages/api/gateway/src/client/index.ts)注册 ctx.remote
  • 消费同一份生成的 InvocationDescriptor 约定;传输、请求关联、信任与响应封装交给 Connection

与 apiproxy 的关系:packages/host/apiproxy(包名 @deepseek-ai/dsh-host-apiproxy)是每个 client 共享的旧网关,它消费 ctx.apiProxy{nativeOpen?, sessionExportCompressionLevel?, coldBlankProbeMaxBytes?} 配置、注册在 Web 组合 id api-gateway(见 Web UI 架构)。新 Gateway 在 Connection 共享的 /api FetchHandler 上注册 trusted-host interceptor,把"已认领 endpoint 分发给 Gateway、未认领 endpoint 交给 API Proxy"——所以已迁移与未迁移方法并行共存,api-remotes 的 resolver 同时被 legacy 方法与 Typert lookup 使用(见下)。

二、Host 服务:ctx.typertGateway.invoke()

TypertGatewayService(packages/api/gateway/src/index.ts)每次 invoke() 做五件事:

步骤源码动作
解析描述符resolveDescriptor:先查严格定义(ctx.typert.local),无则 SRC 回退;已观测定义被撤回时直接报错,不降级
校验具名参数assertExactArguments:args 必须与描述符 wire 字段完全匹配(缺 + 多都抛 arguments-invalid)
解析 receiverresolveReceiverContext:direct 用 ctx 根;context 走注册的 Host Context provider 解析身份
校验绑定validateBinding:服务必须带一致的 typertRemote binding,否则 binding-invalid
调用并校验结果Reflect.apply 方法,经 codec 边界 decode 校验返回值
// packages/api/gateway/src/index.ts —— 取消信号是 descriptor 元数据,不是 wire 参数
if (descriptor.cancellation !== undefined) args.push(request.signal ?? NEVER_ABORTED_SIGNAL)

支持取消的 Remote 方法把 signal: AbortSignal 声明为最后一个 Host 参数——它由 Connection 提供给 Gateway,在解码后的业务参数之后注入,而非来自 wire。

错误分类

TypertGatewayError 携带稳定的机器码分类:

code含义触发场景
arguments-invalid参数与描述符不匹配缺字段 / 多余字段
lookup-not-found / lookup-failed / lookup-unavailablelookup 解析失败identity 解析不到 / provider 出错 / 无 key
context-not-found / context-failed / context-unavailable@RemoteScope 接收者解析失败Host Context provider 未注册或无法解析
binding-invalid / service-unavailable / method-unavailable绑定或服务缺失typertRemote binding / 无活跃 Service / 无可调用方法
input-invalid / result-invalid边界校验失败wire 字段或返回值不通过 codec
ambiguous-endpoint / signature-invalid / definition-unavailableSRC 推导失败多服务导出同端点 / SRC 签名非法 / 定义被撤回

resolver 可用 TypertLookupFailure 携带既有 RPC 错误,使冷恢复失败或 ownership fence 等策略拒绝保持原错误码(不压成 internal)。直接调用 invoke() 保留业务错误;经 RPC 适配器时,普通分发故障与业务异常映射到 internal,TypertLookupFailure 原样返回。

SRC 模式

SRC(strict-required 的开发回退)用于从未严谨定义过的端点:从 JS 方法签名解析简单参数名(要求唯一标识符参数,不支持解构/默认值/rest),只允许非 lookup 参数是 JSON 安全值,不推断可选字段。SRC 的 cancellation 参数必须是末位。

三、插件作者:@Remote / @RemoteScope 装饰器

装饰器生活在 @deepseek-ai/dsh-typert-protocol(packages/typert/protocol/src/index.ts)。业务服务继承 TypertRemoteService,用装饰器标记方法:

// packages/typert/protocol/src/index.ts —— 两种标记
export function Remote(exportName?: string): RemoteMethodDecorator // 直接 Remote
export function RemoteScope(
key: Extract<keyof TypertContextMap, string>, exportName?: string,
): RemoteMethodDecorator // 按 Scope 解析 receiver

export abstract class TypertRemoteService extends Service {
readonly typertRemote: TypertGatewayBinding<this> // 绑定服务 key 与 wire namespace
}
  • @Remote 把一个公开实例方法标记为直接 Remote 调用(endpoint = <namespace>/<method>)
  • @RemoteScope(key) 让方法被 key 对应的 Host Context provider 解析 receiver——每个调用针对某个具体 session/agent 的 scope 上下文
  • 已有其他基类无法继承 TypertRemoteService 时,用 bindTypertRemote(service, serviceKey, options) 显式绑定
  • @RemoteScope 在 SRC 推导中还对应 wire 上的 context 身份字段(invocation.kind === 'context')

四、Client 服务:ctx.remote

ClientRemoteService(packages/api/gateway/src/client/index.ts)把生成的 Host-for-Client 贡献挂载为可调用方法:

API作用
$mount(contribution)校验并注册生成的贡献项,为调用 fiber 安装直接/作用域方法;每个 namespace 是可追踪的 remote.<namespace> 子 Service,最后一个方法撤回后卸载
$on(event, listener)订阅一条被转发的 Host 事件;合法键恰好是 Host 装配的转发选择;listener 属于调用 fiber,随 fiber 消失
$dispatch(event, args)属于载体的另一半:持有 Host 帧 sink 的 Client 半把解码后的帧交进来;无人订阅的事件名丢弃
方法调用校验位置参数 → 构造精确具名 argsctx.connection.rpc.call('/api', endpoint, {args})
// packages/api/gateway/src/client/index.ts —— 取消合并贡献挂载生命周期与调用者 signal
const signal = callerSignal === undefined
? token.abort.signal
: AbortSignal.any([token.abort.signal, callerSignal])
  • 无 JavaScript Proxy:方法查找与调用用普通对象与函数(getter 闭包绑定调用时 fiber 的 ctx)
  • 撤回语义:撤回贡献会移除其描述符与方法、中止 in-flight 调用;仍被外部持有的方法句柄在调用时返回 withdrawn 拒绝
  • 每个订阅按注册顺序投递,抛错的 listener 被隔离(logged),不影响其余 listener 的帧泵
  • Client 只挂载严格生成的贡献项;SRC 标记没有 Client codec/类型投影
  • 只分发一元方法;增量 session 数据走同一个 Connection 上的独立具名流协议

五、api-remotes:应用侧的 BFF 与身份策略

packages/api/remotes(包名 @deepseek-ai/dsh-api-remotes)是应用选定的 Host Remote 能力的双侧 BFF:Host 入口持有 Agent/Session 身份策略,Client 入口把生成的 /remote 产物以运行时值导入、$mount() 挂载并重导出声明合并。Client 业务包依赖此外观,不依赖 Gateway 实现或单独 Remote 运行时入口。

createApiRemoteAgentResolver:live 复用 / 冷恢复 / ownership fence

packages/api/remotes/src/agent-lookup.tscreateApiRemoteAgentResolver() 做四件事:

策略行为
复用 live AgentfencedLiveAgent:ctx.agents.get(sessionId) 命中直接用(除非被 fence)
普通冷会话恢复无 live 时经 persistence.inspect 读冷 session,再 ctx.agents.resume
并发去重resumes Map 对同一 identity 共享同一个 resume Promise,完成后删除
subagent ownership fencehasApiRemoteSubagentOwner:originsubagent 或挂在父 subagent 下的 session 拒绝通用路由
// packages/api/remotes/src/agent-lookup.ts —— fence 判定
export function hasApiRemoteSubagentOwner(ctx, session, agent): boolean {
if (session.header.origin === 'subagent') return true
const parentId = session.header.parentSession
if (parentId === undefined || agent === undefined) return false
const parent = ctx.agents.get(parentId)
return parent !== undefined && ctx.agents.isOwnedBy(agent.id, parent)
}

被 fence 的 identity 返回原 agent-busy RPC 形状(apiRemoteSubagentOwnershipError),提示"这是子 Agent 会话,用 subagent delivery"。resolver 同时配置 Typert 的 agentsession lookup,以及 contexts.configureHost('agent', ...),因此迁移与未迁移方法共用同一份身份策略实现options.setup 在冷恢复时按被恢复的 session 组装 Agent-scope 组合(以 session 自身 keyed,因为 agent preset 固定了其历史产生的工具集合)。

转发事件白名单:API_REMOTE_FORWARDED_EVENTS

packages/api/remotes/src/remote-events.ts唯一控制点——它同时决定 Host 转发循环与 ctx.remote.$on 的合法键集:

// packages/api/remotes/src/remote-events.ts —— 原样转发:无投影、无脱敏、无改名
export const API_REMOTE_FORWARDED_EVENTS = [
'agent-preset/selected',
'commands/change',
'credentials/updated',
'llm/adapters-updated',
'settings/document-updated',
] as const
  • 多转发一个事件 = 往数组加一行;类型投影、消费端键面、Host 转发循环全部由它派生
  • Host face 额外断言该数组满足 TypertForwardableEvent:未声明的事件名、绑定 AgentScope 的事件、非单向(wave 非 void)的事件都会被编译器拒绝
  • listener 签名来自各 owner 包 client-safe 的 ./types 出口(dsh-agent-presets/dsh-commands/dsh-credentials/dsh-llm/dsh-settings),"原样转发"是构造性成立
  • 事件原样到达 $on:无载荷投影/脱敏,无 Scope 订阅,重连不重放

构建边界

api-remotes 是仓库里唯一刻意跨 Host/Client 两 face 的包:根 tsconfig.json 只是引用 tsconfig.host.json + tsconfig.client.json 的 solution。src/remote-events.tssrc/types.ts 同时列入两个 facefiles,确保转发名单只有一份声明;tsconfig.base.json@deepseek-ai/dsh-api-remotes/types 映射到源平面

六、Client 组合:挂载了哪些贡献

packages/api/remotes/src/client/index.ts 显式导入并挂载四个 remote 贡献:

commandsRemote · goalsRemote · pluginInventoryRemote · messageFeedbackRemote

插件库存贡献由 @deepseek-ai/dsh-host-plugin-inventory 提供(见 插件库存)。能力集由构建时显式值导入固定;Client 不在运行时发现 Host 的活跃 Service 或 Remote 定义。

验证

# 在 Web 组合树里确认网关 + remotes 相关的行
dsh web --dump-config | grep -iE "api-gateway|api-remotes|apiproxy|plugin-inventory"

# 看 host 进程监听的端口
lsof -nP -iTCP:3080 -sTCP:LISTEN

# 在浏览器设置「插件」标签页可看到插件库存(经 pluginInventory/list)

下一步