模型适配器、路由与流式响应
本页说明模型提供商适配器如何接入 LLM 注册表、解析认证与模型目录,以及 DeepSeek 请求扩展如何在发送前准备字段并在请求成功后完成接受事务。文档依据 packages/llm 中已读取的实现,重点覆盖适配器边界、路由注册、取消传播、不可变请求字段与错误映射。
Purpose and Scope
本页面向需要新增或维护模型提供商、路由和流式请求前置逻辑的工程师,覆盖以下范围:
llm-deepseek-account插件的生命周期、deepseek-account路由、账号令牌解析和模型发现。DeepSeekLlmApiExtensionRegistry的字段注册、请求准备、取消处理、字段隔离和接受回调。- 适配器与
ctx.llm、deepseekAccount、DeepSeek 连接配置之间的真实依赖关系。 - 配额、未登录、401 令牌失效和存储删除失败等实现中明确处理的失败路径。
本页不展开账号凭据存储本身、通用 llm 服务的完整实现、具体 UI 配置页面或所有其他模型供应商。对于凭据记录格式和通用认证桥接,参见 llm-pi-ai 相关页面;对于 Cordis 插件生命周期和应用启动编排,参见运行时与插件系统相关页面。
Overview
该能力由两类互补机制组成:
- 模型适配器与路由:
llm-deepseek-account作为 Cordis 插件注入llm,把配置转换为运行时连接选项,向ctx.llm注册可配置 provider,并通过registerDeepSeekProvider提供认证、模型发现和请求所需的 DeepSeek 连接行为。 - 请求扩展注册表:
DeepSeekLlmApiExtensionRegistry管理由不同插件独立拥有的顶层请求字段。每个字段只能有一个 provider;请求阶段并行准备字段,结果被structuredClone后递归冻结;所有接受回调通过一次幂等的联合事务执行。
这种拆分的设计意图是把“如何到达某个模型端点”与“某个插件如何增加官方请求字段”解耦。适配器拥有认证和模型目录职责,扩展注册表则只负责请求字段生命周期,不把具体扩展逻辑硬编码进官方适配器。
Architecture
Sources:
图中的依赖均来自实现:apply 读取 ctx.deepseekAccount,调用 ctx.llm.registerConfigurableProviders 和 registerDeepSeekProvider;扩展注册表本身是 Cordis Service,以 deepseekLlmApiExtensions 注册到上下文中。适配器和扩展注册表通过请求边界协作,但扩展 provider 的具体业务不属于账号适配器。
适配器生命周期与路由注册
apply(ctx, config) 是账号适配器的入口。它首先创建惰性 options 函数:每次调用时把 plainOptions(config) 与 launchEnvironmentOf(ctx) 交给 resolveAdapterOptions。入口阶段立即调用一次 options(),使配置和启动环境在插件应用时就得到解析;后续认证和模型发现仍重新解析连接选项,而不是共享一个可能过期的快照。
随后,插件向 ctx.llm 注册一个可配置 provider:
- 内部路由键是
deepseek-account。 - 展示名是
DeepSeek Account。 settingsNs使用当前 fiber entry 的 options id,若不存在则回退到插件名。settingsPath是空数组,表明该实现没有在此处追加嵌套设置路径。
之后 registerDeepSeekProvider 接收同一个 provider 键,并获得 options、resolveAuth、展示名和 discoverModels。因此路由的关键行为集中在 provider 配置对象中,而不是分散到调用方。
认证、模型发现与请求错误映射
认证流程
resolveAuth(connection) 从 ctx.get('deepseekAccount') 获取账号服务,然后以 connection.baseURL 为目标解析 token。没有 token 时抛出 LlmError,错误码为 ACCOUNT_SIGN_IN_REQUIRED,消息明确要求用户登录且请求目的地必须允许账号认证。
成功时返回:
headers:把 token 放到x-dsh-auth-token。onRequestError:把通用请求失败转换为账号路由语义。
这使认证凭据只在适配器边界转换为 DeepSeek 请求需要的 header,调用者无需了解账号服务的 token 获取细节。
模型发现
discoverModels 先解析当前连接选项,然后调用 resolveAuth 验证账号是否可用于目标地址。若错误码是 ACCOUNT_SIGN_IN_REQUIRED,返回空数组而不是让模型目录加载失败;其他错误继续抛出。认证成功后,将 connection.models 中每个模型映射为 catalogModelInfo(provider, model)。
因此“未登录”在模型发现阶段表现为“没有可展示模型”,而不是异常;真实请求阶段仍由 resolveAuth 强制认证。这种差异避免目录 UI 因缺少账号而进入错误态,同时不放松实际推理请求的安全要求。
错误转换
onRequestError 只转换 LlmError。非 LlmError 原样返回,避免适配器错误处理吞掉未知异常。对于已知错误:
- 通用配额错误
QUOTA_EXCEEDED_CODE被包装成ACCOUNT_QUOTA_EXCEEDED_CODE,并保留原始 failure 信息与 cause。 - 非 401 的错误原样返回。
- 401 被转换为
ACCOUNT_TOKEN_INVALID,随后尝试调用account.rejectToken(token)清除失效令牌。 - 令牌清除失败被捕获并忽略,因为存储清理失败不能替代真正的推理请求失败。
DeepSeek 请求扩展注册表
DeepSeekLlmApiExtensionRegistry 继承 Cordis Service,服务名为 deepseekLlmApiExtensions,内部用 Map<string, ErasedProvider> 保存字段 provider。类型层通过 declaration merging 把该服务暴露在 Context.deepseekLlmApiExtensions 上。
注册与作用域
register(field, provider) 的关键约束如下:
- 字段名不能为空,且必须是 trim 后不变的字符串;空字符串或带首尾空白会立即抛错。
- 同一顶层字段不能重复注册;重复注册会抛出包含字段名的错误。
- 注册动作通过
ctx.effect绑定 Cordis effect 生命周期。 - 返回的 disposer 删除该字段,因此插件卸载时不会留下陈旧 provider。
注册的“每字段唯一”规则保证了最终请求不会出现多个插件争夺同一顶层 JSON 字段,也让字段所有权可以由类型映射 DeepSeekLlmApiExtensionMap 表达。
请求准备
prepare(request) 先检查 request.signal,然后复制当前 provider entries,并用 Promise.all 并行调用每个 provider 的 prepare。如果准备工作是异步的,abortable 会让外层等待同时受取消信号控制:请求已经取消时立刻拒绝;正常完成后再次检查 signal,避免在竞态窗口中继续使用结果。
每个非 undefined 结果都会经过 structuredClone 和 freezeJson:这会切断 provider 对发送值的可变引用,并递归冻结对象或数组。最终 fields 对象本身也被冻结。provider 可以同时返回 accept 回调;这些回调被绑定到结果对象后收集起来,但不会在 prepare 内立即执行。
返回值包含 fields 和 accept。accept 使用闭包中的 acceptance 缓存,因此多次调用只会创建一次 acceptAll Promise。acceptAll 对所有回调执行 Promise.allSettled,先等待全部回调结束,再按失败数量抛出单个原始错误或 AggregateError。这避免第一个失败导致其他接受回调尚未完成,从而留下部分提交状态。
Source: index.ts
该时序图反映实现中的两个重要边界:取消只控制请求准备阶段的等待,接受阶段则统一等待所有回调;而字段值在返回前已经与 provider 的可变对象隔离。
Usage Examples
账号 provider 的最小注册
以下是仓库中 apply 的核心注册路径。它展示了配置解析、LLM provider 注册以及 DeepSeek provider 行为如何组合在一起;代码未引入文档外的 API。
1export function apply(ctx: Context, config: Config): void {
2 const options = () => resolveAdapterOptions(plainOptions(config), launchEnvironmentOf(ctx))
3 options()
4 const resolveAuth = async (connection: ResolvedDeepSeekOptions): Promise<DeepSeekRequestAuth> => {
5 const account = ctx.get('deepseekAccount')
6 const token = await account?.resolveToken(connection.baseURL)
7 if (token === undefined) throw new LlmError('Sign in to DeepSeek to use the account provider. The request destination must allow account authentication.', 'ACCOUNT_SIGN_IN_REQUIRED')
8 return {
9 headers: { 'x-dsh-auth-token': token },
10 onRequestError: async (error) => {
11 if (!(error instanceof LlmError)) return error
12 if (error.code === QUOTA_EXCEEDED_CODE) {
13 return new LlmError(error.message, ACCOUNT_QUOTA_EXCEEDED_CODE, { ...error.failure, cause: error })
14 }
15 if (error.failure.status !== 401) return error
16 const rejected = new LlmError(error.message, 'ACCOUNT_TOKEN_INVALID', { ...error.failure, cause: error })
17 try { await account?.rejectToken(token) }
18 catch (_credentialRemovalFailed) { /* Storage failure cannot replace the inference failure. */ }
19 return rejected
20 },
21 }
22 }
23 ctx.llm.registerConfigurableProviders([
24 { provider: PROVIDER, displayName: 'DeepSeek Account', settingsNs: ctx.fiber.entry?.options.id ?? name, settingsPath: [] },
25 ])
26 registerDeepSeekProvider(ctx, PROVIDER, {
27 options, resolveAuth, providerName: 'DeepSeek Account',
28 discoverModels: async (provider) => {
29 const connection = options()
30 try { await resolveAuth(connection) }
31 catch (error) {
32 if (error instanceof LlmError && error.code === 'ACCOUNT_SIGN_IN_REQUIRED') return []
33 throw error
34 }
35 return connection.models.map(model => catalogModelInfo(provider, model))
36 },
37 })
38}Source: index.ts
扩展字段的准备与接受
扩展 registry 的调用方可以先准备字段,再在请求生命周期合适的成功点调用 accept。下面的实现片段展示 registry 如何并行执行 provider、隔离字段值并延迟接受回调;accept 的具体调用时机由请求调用方决定,当前已读取源码未定义更多上层调用细节。
1async prepare(request: DeepSeekLlmApiExtensionRequest): Promise<PreparedDeepSeekLlmApiExtensions> {
2 request.signal.throwIfAborted()
3 const entries = [...this.providers.entries()]
4 const prepared = await abortable(Promise.all(entries.map(async ([field, provider]) => ({
5 field,
6 result: await provider.prepare(request),
7 }))), request.signal)
8 const fields: Record<string, DeepSeekLlmApiJson> = Object.create(null) as Record<string, DeepSeekLlmApiJson>
9 const callbacks: Array<() => void | Promise<void>> = []
10 for (const { field, result } of prepared) {
11 if (result === undefined) continue
12 fields[field] = freezeJson(structuredClone(result.value))
13 const accept = result.accept
14 if (accept !== undefined) callbacks.push(accept.bind(result))
15 }
16 Object.freeze(fields)
17 let acceptance: Promise<void> | undefined
18 return {
19 fields,
20 accept: () => acceptance ??= acceptAll(callbacks),
21 }
22}Source: index.ts
注册字段并获得 disposer
该片段体现字段所有权和 effect 作用域:成功注册后,返回的函数负责释放字段;重复字段会在 effect 执行时拒绝。
1register<K extends keyof DeepSeekLlmApiExtensionMap>(
2 field: K,
3 provider: DeepSeekLlmApiExtensionProvider<DeepSeekLlmApiExtensionMap[K]>,
4): () => Promise<void> {
5 const fieldName = field as string
6 if (fieldName.length === 0 || fieldName.trim() !== fieldName) {
7 throw new Error('deepseek-llm-api-extensions: field must be a non-blank trimmed string')
8 }
9 const providers = this.providers
10 const erased = provider as ErasedProvider
11 const dispose = this.ctx.effect(() => {
12 if (providers.has(fieldName)) {
13 throw new Error(`deepseek-llm-api-extensions: field ${JSON.stringify(fieldName)} is already registered`)
14 }
15 providers.set(fieldName, erased)
16 return () => {
17 providers.delete(fieldName)
18 }
19 }, `deepseekLlmApiExtensions.register(${JSON.stringify(fieldName)})`)
20 return dispose
21}Source: index.ts
Configuration Options
已读取的账号适配器实现将配置类型导出为 Config,并通过 plainOptions(config) 交给 resolveAdapterOptions。具体配置字段和默认值定义在 llm-deepseek-account/src/config.ts,但本页受源文件读取预算限制未读取该文件,因此不能可靠列出字段、类型或默认值。当前可确认的运行时事实如下:
| 项目 | 类型/来源 | 默认值 | 说明 |
|---|---|---|---|
Config | ./config.ts 导出的类型 | 源码未在本页读取 | 由 plainOptions(config) 转换为适配器选项 |
launchEnvironmentOf(ctx) | Cordis 上下文运行环境 | 由运行时提供 | 与配置共同传给 resolveAdapterOptions |
connection.baseURL | ResolvedDeepSeekOptions | 由解析后的连接决定 | 用作账号 token 解析的目标地址 |
connection.models | ResolvedDeepSeekOptions | 由解析后的连接提供 | 用于模型发现和 catalogModelInfo 映射 |
因此不要根据本页推断未读取的配置键或默认 URL;新增配置时应以 config.ts 和 resolveAdapterOptions 的实现为准。
API Reference
apply(ctx: Context, config: Config): void
账号插件入口。它解析适配器选项,注册 DeepSeek Account 可配置 provider,并安装认证与模型发现行为。
ctx:CordisContext,提供llm、deepseekAccount、fiber entry 和启动环境。config:插件配置,传入plainOptions。- 返回值:无;注册行为由上下文与 provider 注册机制持有。
- 可能失败:配置解析或 provider 注册阶段的异常会向调用方传播;认证错误在请求或模型发现路径中按实现转换。
DeepSeekLlmApiExtensionRegistry.register(field, provider): () => Promise<void>
为一个声明合并的顶层请求字段注册唯一 provider,并返回异步 disposer。
field:keyof DeepSeekLlmApiExtensionMap,运行时必须是非空且无首尾空白的字符串。provider:实现prepare(request)的字段 provider,可返回字段值与可选accept回调。- 返回值:释放该字段注册的异步函数。
- 可能失败:字段非法时抛出校验错误;字段已被注册时抛出重复注册错误。
DeepSeekLlmApiExtensionRegistry.prepare(request): Promise<PreparedDeepSeekLlmApiExtensions>
并行准备当前已注册字段,并返回冻结的字段集合和幂等接受事务。
request:包含序列化请求事实和AbortSignal的DeepSeekLlmApiExtensionRequest。- 返回值:
fields与accept;未返回值的 provider 不会出现在 fields 中。 - 取消行为:在 provider 工作期间或完成后检测
signal;取消会拒绝等待过程。 - 一致性行为:字段值经过
structuredClone和递归冻结;accept()的多次调用共享同一个 Promise。
DeepSeekLlmApiExtensionRegistry.acceptAll(callbacks): Promise<void>
这是模块内部函数,不是导出的公共 API。它使用 Promise.allSettled 等待所有接受回调,单个失败时抛出原始原因,多个失败时抛出 AggregateError。
Failure Modes、边界条件与并发
适配器侧
- 未登录:
resolveToken返回undefined时抛出ACCOUNT_SIGN_IN_REQUIRED。模型发现专门把该错误转为空模型列表;请求认证不静默放行。 - 配额耗尽:通用
QUOTA_EXCEEDED_CODE被重分类为账号专用错误码,同时保留 cause,便于上层区分账号额度与其他 provider 额度。 - 令牌失效:401 会转换为
ACCOUNT_TOKEN_INVALID,并尝试删除 token。删除失败不会覆盖推理失败,避免凭据存储故障掩盖真正的 HTTP 认证结果。 - 未知异常:非
LlmError不被重写,保留原异常类型和语义。
扩展注册表侧
- 字段非法或冲突:空字段、首尾有空白的字段和重复字段都会拒绝注册。由于检查位于 effect 注册逻辑中,失败不会留下该字段的有效 map 条目。
- 取消竞态:
prepare在开始前检查 signal,并通过abortable竞速 provider 工作与 abort Promise;工作完成后再次检查 signal。这覆盖了 provider 完成与取消几乎同时发生的窗口。 - 部分接受失败:
acceptAll不在第一个回调失败时立即返回,而是等待所有回调 settled,再决定抛出单一错误或AggregateError。 - 重复调用 accept:闭包缓存 acceptance Promise,使多次调用不会重复执行副作用回调。
性能与运维注意事项
扩展 provider 的 prepare 使用 Promise.all 并行执行,等待时间主要受最慢 provider 影响,而不是所有 provider 延迟之和;代价是任一未被取消的 provider 拒绝会使整体准备失败。字段通过 structuredClone 和递归冻结获得隔离,换取了额外复制与遍历成本;这适合请求级、结构化扩展字段,但不应把大型可变对象当作扩展值而忽略复制开销。
provider 列表在 prepare 开始时从 Map 快照复制,因此准备期间的注册或释放不会改变本次遍历集合。注册本身由 Cordis effect 管理,插件卸载路径应执行 disposer,避免旧 provider 继续参与后续请求。
账号适配器每次 options() 调用都重新计算解析后的连接选项。实现明确选择了动态解析,而不是缓存连接对象;如果运行环境或配置可以在插件生命周期内变化,这能避免长期使用旧连接,但也意味着调用方不应假定 discoverModels 与后续请求必然共享同一连接快照。
Extension Points
新增 DeepSeek 账号行为时,首选复用 registerDeepSeekProvider 的 options、resolveAuth 和 discoverModels 边界,而不是在 apply 外部直接读取 token。新增请求顶层字段时,使用 ctx.deepseekLlmApiExtensions.register,为字段提供独立的 prepare 和可选 accept;不要绕过 registry 直接修改最终请求对象,因为 registry 才负责唯一字段所有权、克隆冻结、取消传播和接受事务。
当前已读取源码没有显示流式 chunk 聚合、HTTP transport 或具体 accept 调用方的实现,因此这些行为在本页不做推断。若要扩展流式响应本身,应先定位 registerDeepSeekProvider 的实际发送实现和请求调用方,再补充端到端时序。
Tests and Verification Boundary
当前源材料包含测试文件路径和仓库级测试策略线索,但本页未读取具体 provider 或 registry 测试实现。因此不能据此宣称某个错误码、取消竞态或重复接受行为已有测试覆盖。建议验证时至少针对源码明确的契约建立测试:未登录模型发现返回空列表、401 后令牌拒绝、重复字段注册、abort 竞态、字段深冻结以及多个 acceptance failure 的 AggregateError 行为。