模型适配层
adapters 包的 model 目录怎样用 Vercel AI SDK 的三个 provider 包对接 anthropic-messages、openai-chat-completions、openai-responses 三种协议,把流式输出归一成运行时事件;推理签名、缓存断点、请求头、超时重试、两个 SDK 补丁和官方套餐网关都落在这一层。
回合循环只认 @zcode/contracts 里的 Model 接口:streamText 吐出统一的 ModelStreamEvent,generateText 返回一次性结果。把它接到真实模型服务上的,是 apps/zcode-cli/packages/adapters/src/model,52 个文件、12327 行,入口的第一行注释就写明“Model adapters backed by the Vercel AI SDK”(apps/zcode-cli/packages/adapters/src/model/index.ts:1)。同级的 adapters/src/provider 目前只有一行 export {};,是个空占位(apps/zcode-cli/packages/adapters/src/provider/index.ts:2)。
上一篇讲了 Registry 怎样给出一个模型的全部静态事实;这一篇从 ModelFactory 拿到这些事实开始,一直讲到字节离开进程。回合里怎样消费事件、流断了怎样续上,见一次模型请求:流式、工具并发与恢复。
分层
装配点在 bootstrap:createRuntimeAiSdkModelExecutionConfig 准备默认请求头与网络配置,createModelAdapter 构造唯一的 AiSdkModelAdapter(apps/zcode-cli/packages/bootstrap/src/app/create-app.ts:523、530),ApiProviderModelRuntime 每次按 Registry 查到的 Provider 与模型调用 createModel(apps/zcode-cli/packages/bootstrap/src/app/provider-registry-model-runtime.ts:65)。一次流式请求自上而下经过这些层:
| 层 | 主要文件 | 做什么 |
|---|---|---|
ExecutableModel | model.ts | 校验输出上限与推理档位落在规格内;模型不支持工具、结构化输出、图片、PDF 或视频时,请求发出前就拒绝(apps/zcode-cli/packages/adapters/src/model/model.ts:106、155) |
AiSdkModelAdapter | runner.ts | 绑定 Provider 快照,账号型模型每次尝试前取鉴权材料,anthropic 协议下投影推理历史 |
runStreamText 等 | runner-stream.ts、runner-generate.ts | 尝试循环:准入、空闲超时、失败分类、退避、状态事件 |
createStreamTextOptions | runner-options.ts | 转换消息与工具,合并请求头,关闭 SDK 自带重试 |
AiSdkModelExecution | model-execution.ts | 按 API 类型选 provider 工厂,拼好 fetch 链 |
三个 SDK 包对应三种协议
依赖声明是 ai 6.0.193、@ai-sdk/anthropic 3.0.81、@ai-sdk/openai 与 @ai-sdk/openai-compatible 各一个范围版本(apps/zcode-cli/packages/adapters/package.json:102、112),锁文件里后两者解析为 3.0.65 与 2.0.60(pnpm-lock.yaml:141、144)。Registry 的 API 类型先映射成三种 kind(apps/zcode-cli/packages/adapters/src/model/model-execution.ts:363),再各自创建工厂(model-execution.ts:281):
switch (providerConfig.kind) {
case "openai": {
const provider = createOpenAI({
apiKey,
baseURL: providerConfig.baseURL,
fetch: createOpenAIResponsesJsonCompatFetch(optionFetch),
headers,
});
return provider.responses as LanguageModelFactory;
}
case "anthropic": {
const provider = createAnthropic({
apiKey,
baseURL: normalizeAnthropicBaseURL(providerConfig.baseURL),
fetch: createAnthropicCompatFetch(optionFetch),
headers: withAnthropicAuthorizationHeader(apiKey, headers),
});
return provider as LanguageModelFactory;
}| API 类型 | 工厂 | 这一层额外做的事 |
|---|---|---|
anthropic-messages | createAnthropic | 地址不以 /v1 结尾就补上,因为 SDK 只在后面拼 /messages;除 x-api-key 外再补一个 Authorization: Bearer,兼容两种都读的网关(model-execution.ts:388、411) |
openai-responses | createOpenAI 的 responses | 非流式响应缺 message.id 或 annotations 时补齐,免得 SDK 整个拒收(apps/zcode-cli/packages/adapters/src/model/openai-responses-json-compat.ts:50、66) |
openai-chat-completions | createOpenAICompatible | 显式打开流式用量 includeUsage,结构化输出按模型能力开启,否则 SDK 会把 JSON Schema 静默降级(model-execution.ts:310、313) |
@ai-sdk/openai 只用于 Responses 协议,Chat Completions 一律走 openai-compatible 包。SDK 自己的重试被 maxRetries: 0 关掉,重试全部由这一层掌控;allowSystemInMessages 打开,因为系统提示词由 core 组装(apps/zcode-cli/packages/adapters/src/model/runner-options.ts:149、150)。bindModel 在创建 Model 时冻结 Provider 快照,之后 Registry 热更新不会让已有 Model 悄悄换地址或协议,请求期只允许替换 API Key 和请求头(model-execution.ts:170、374)。
账号型模型的鉴权是逐次取的:适配层给每次尝试挂一个 refreshRuntimeHeadersBeforeAttempt,闲时模型只认创建时注入的执行作用域来源,其他账号模型走 core 的请求头端口,取不到就以 ModelRequestAuthMissing 失败(apps/zcode-cli/packages/adapters/src/model/runner.ts:164、181)。端口在桌面端经协议向 Host 请求、总时限 180000 毫秒(apps/zcode-cli/packages/bootstrap/src/zcode-protocol/provider-runtime-headers.ts:16),独立 CLI 则直接读本地凭据,细节见账号、Coding Plan 与闲时计划。
请求头
| 来源 | 请求头 | 出处 |
|---|---|---|
| 进程默认 | User-Agent: ZCode/<版本>、HTTP-Referer(控制面地址)、X-Title、X-ZCode-App-Version、X-Release-Channel、X-Client-Language、X-Client-Timezone、X-ZCode-Agent: glm | apps/zcode-cli/packages/bootstrap/src/model-config.ts:47 |
| 运行平台 | X-Platform(平台加架构)、X-Os-Category、X-Os-Version | apps/zcode-cli/packages/bootstrap/src/runtime-platform-headers.ts:5 |
| 仅 openrouter.ai | X-OpenRouter-Title: ZCode、X-OpenRouter-Categories: programming-app | packages/shared/src/openrouter-attribution.ts:1 |
| 每次请求 | x-request-id、x-zcode-trace-id、x-zcode-session-type、x-session-id、x-query-id;OpenCode Go 另加 x-opencode-session | apps/zcode-cli/packages/adapters/src/model/runner-attribution.ts:16 |
| 仅 anthropic 协议 | 请求体 metadata.user_id:含设备 ID 与会话 ID 的 JSON 串 | apps/zcode-cli/packages/adapters/src/model/anthropic-request-metadata.ts:7 |
X-Title 在命令行里是 Z Code@cli,进程参数里有 app-server 或 agent-server(即被桌面端拉起)时是 Z Code@electron(model-config.ts:75)。进程默认头与 Provider 配置里的自定义头先按不区分大小写的方式合并,后者覆盖前者(model-execution.ts:205,apps/zcode-cli/packages/adapters/src/model/model-request-headers.ts:1);每次请求的归因头最后叠上(runner-options.ts:143)。x-zcode-session-type 取 main、subagent、other 之一,注释说 Coding Plan 服务端用它区分请求来源;工作流子会话一律记为 other(runner-attribution.ts:20,apps/zcode-cli/packages/core/src/runtime/methods/model-request-session-type.ts:6)。会话 ID 与查询 ID 发出前会剥掉内部前缀 sess_、subagent_agent_、query_(runner-attribution.ts:73)。写日志与状态事件时,名字里带 authorization、api-key、token、secret、cookie 的头以及 x-off-peak-ticket-id 都被替换成 [redacted](apps/zcode-cli/packages/adapters/src/model/runner-network-headers.ts:3)。
消息与工具的翻译
toAiSdkMessages 把 core 的消息翻成 SDK 的 ModelMessage(apps/zcode-cli/packages/adapters/src/model/transform.ts:46),多数处理只发生在序列化边界,不改会话里存的事实:
- 开头的多段 system:core 为了缓存断点故意保留多段,openai-compatible 协议下按原顺序合并成一段,因为部分旧式 chat 模板只认一个 system(
apps/zcode-cli/packages/adapters/src/model/system-message-compat.ts:21);anthropic 协议下,SDK 把会话中途 system 的纯文本扩成块数组,兼容 fetch 再把它恢复成字符串(apps/zcode-cli/packages/adapters/src/model/anthropic-stream-compat.ts:28)。 - 空内容与媒体:只有附件的用户消息正文为空时补
(no content)(transform.ts:386);模型不支持的媒体换成一段文字说明(apps/zcode-cli/packages/adapters/src/model/media-transform-policy.ts:8);视频以video/*的 file 部件交给打过补丁的 SDK 包(transform.ts:459),带视频的工具结果则转成文字,媒体挪到紧随其后的用户消息里(transform.ts:109)。 - 工具:anthropic 协议下关闭 SDK 默认的细粒度工具输入流
eagerInputStreaming,注释说不少兼容网关不认这个字段;strict模式只给 Anthropic 首方模型;模型要求 MFJS 形态时,把局部$ref提升到$defs(apps/zcode-cli/packages/adapters/src/model/tool-transform.ts:85、95、232),规则集只为 Moonshot 站点上的 Kimi K3 打开这一项(config/provider/zcode-builtin.json:4062)。 - 原生 WebSearch:只有 anthropic 协议且模型声明支持时,才编码成
webSearch_20260209服务端工具,maxUses缺省 8,其余情况直接报错(tool-transform.ts:249)。WebSearch 工具本身见 WebFetch 与 WebSearch。
提示词缓存:四个断点
放不放、放在哪由 core 决定,适配层只负责搬运:
- 系统提示词最多拼成三段,CLI 前缀、稳定正文、动态段,每段都带
{ type: "ephemeral" }(apps/zcode-cli/packages/core/src/context/builder.ts:230)。 - 请求消息投影完成后,先清掉所有非 system 消息上的标记,再给最后一条非 system 消息打上;主动压缩的请求不拿压缩提示本身当缓存写入点,标记前移一条,落在它之前的真实上下文上(
apps/zcode-cli/packages/core/src/runtime/helpers/provider-request-messages.ts:292,apps/zcode-cli/packages/core/src/runtime/methods/compact-active-helpers.ts:88)。回合循环刻意在投影之后才打标记,免得被合成的条目抢走锚点(apps/zcode-cli/packages/core/src/runtime/methods/turn-loop.ts:170)。 - 适配层把每条消息的
cacheControl原样放进providerOptions.anthropic.cacheControl(transform.ts:499),没有为另外两种协议做任何映射。
三段 system 加一条消息,最多正好四个断点。命中情况由 normalizeUsage 从 SDK 用量里取出 cacheReadTokens 与 cacheWriteTokens(apps/zcode-cli/packages/adapters/src/model/runner-normalization.ts:16)。系统提示词各段的内容见系统提示词、上下文与提醒。
流式事件归一
toModelStreamEvent 把 SDK 的 TextStreamPart 逐个翻成运行时事件:text-*、reasoning-*、tool-input-* 改成下划线形式,推理事件带上 providerMetadata(签名在里面),tool-call 的参数只取 SDK 最终解析的那份,finish 带结束原因、归一后的用量和合并了响应体的元数据,识别不了的块只记日志(runner-normalization.ts:62)。工具调用还要过 StreamingToolCallAssembler 做名称与参数校验(runner-stream.ts:163)。
难点在“什么时候还能重试”。一次尝试开始时,SDK 会先吐出一串不含正文的前奏事件;这些事件先暂存,等到第一个真正的输出才一并放行(apps/zcode-cli/packages/adapters/src/model/stream-retry-boundary.ts:6):
const RETRY_SAFE_PRELUDE_STREAM_EVENT_TYPES = new Set<ModelStreamEvent["type"]>([
"start",
"text_start",
"text_end",
"reasoning_start",
"reasoning_end",
"tool_input_start",
"tool_input_delta",
"tool_input_end",
]);
export function isRetrySafePreludeStreamEvent(event: ModelStreamEvent): boolean {
// SDK 把纯签名转成空 reasoning_delta;将其视为已输出会停止 adapter retry,
// 而 core 无文本、无完整工具调用时也无法恢复。空 delta 随前奏暂存,成功时保留签名原序释放。
if (event.type === "reasoning_delta" || event.type === "text_delta") {
return event.text.length === 0;
}
return RETRY_SAFE_PRELUDE_STREAM_EVENT_TYPES.has(event.type);
}在此之前失败,适配层可以换一次物理请求重来,用户看不到半截输出;一旦有可见事件发出就不再重放,交给 core 的流恢复(apps/zcode-cli/packages/adapters/src/model/runner-stream.ts:1469)。另外几种“看起来成功”的失败也在这里截住:HTTP 200 的 SSE 里夹带业务错误帧时,fetch 层让流以 ProviderBusinessError 出错,再按业务码决定重不重试(model-execution.ts:815);业务码只出现在 finish 块的响应体里时,自然结束后以终止错误抛出,不当成可重试的空响应(runner-stream.ts:478);既无输出也无业务码的空补全最多再试 1 次(apps/zcode-cli/packages/adapters/src/model/empty-completion-retry.ts:8)。压缩请求另开 raw 块,从 anthropic 原始事件里提炼 compact_stream_boundary,供 core 判断能否降级为非流式请求(runner-options.ts:153,runner-stream.ts:1129),见上下文压缩。
推理内容:签名与回放
推理强度本身由选项映射写进请求体,见上一篇。难的是把上一轮的思考块原样回传:Anthropic 协议要求 thinking 块带签名,而各家兼容服务的实现参差不齐。
- 收的时候:有的服务把签名放在
content_block_start里,SDK 只从signature_delta读,兼容 fetch 在块结束前补一个合成的signature_delta;助手侧冒出来的裸tool_result块整块丢弃;非流式 JSON 里没签名的 thinking 块删掉,保住正文与用量(anthropic-stream-compat.ts:141、256、269)。 - 发的时候:只对 anthropic 协议,每个逻辑请求投影一次推理历史(
runner.ts:358)。其他模型留下的带签名推理删掉,但同一服务的新旧身份视为兼容,例如旧的builtin:zai-coding-plan与个人、团队 Coding Plan;随后删掉只剩推理的助手消息、结尾的推理和纯空白消息(apps/zcode-cli/packages/adapters/src/model/reasoning-history-normalization.ts:18、31)。同模型留下的无签名块不删,序列化时带一个空签名(apps/zcode-cli/packages/adapters/src/model/anthropic-reasoning-metadata.ts:23)。 - 被拒的时候:服务端返回 400 且文案指向 thinking 块签名,就只在本次请求的副本里删掉带签名的推理,再额外试一次,不占普通重试预算,也不写回会话历史(
reasoning-history-normalization.ts:76,runner-stream.ts:181)。 - Responses 协议:没有
previousResponseId的无状态回放里,丢掉推理的 item 引用,因为部分兼容端点不支持(transform.ts:300、335)。
超时与重试
| 项 | 默认值 | 出处 |
|---|---|---|
| 流空闲超时 | 600000 毫秒,配置键 modelStream.idleTimeoutMs;每多重试一次加 30000 | apps/zcode-cli/packages/contracts/src/config/index.ts:284、apps/zcode-cli/packages/adapters/src/model/stream-idle-timeout.ts:5 |
| 重试次数 | 10 次,即最多 11 次请求 | apps/zcode-cli/packages/adapters/src/model/retry-policy.ts:13、28 |
| 退避 | 2000 毫秒起、每次乘 2、封顶 60000,再乘 0.5 到 1 的随机系数 | retry-policy.ts:14、apps/zcode-cli/packages/adapters/src/model/runner-retry.ts:38 |
| 环境变量 | ZCODE_MODEL_RETRY_MAX_RETRIES、ZCODE_MODEL_RETRY_BASE_DELAY_MS、ZCODE_MODEL_RETRY_BACKOFF_FACTOR、ZCODE_MODEL_RETRY_MAX_DELAY_MS | retry-policy.ts:18 |
| 工作流子会话 | 重试不设上限 | model-request-session-type.ts:21 |
| 闲时排队 | 429 或业务码 3105 按服务端给的等待时间、最多 5 分钟,缺省 60 秒,不占重试预算 | apps/zcode-cli/packages/adapters/src/model/offpeak-retry.ts:21 |
README 里的默认值与代码一致(apps/zcode-cli/README.md:248)。空闲超时由 readNextWithStreamIdleTimeout 在每次读事件时计时,超时就中止本次尝试并记为可重试(stream-idle-timeout.ts:98)。退避计算如下(runner-retry.ts:38):
export function calculateRetryDelay(
retry: ResolvedAiSdkModelRetryOptions,
attempt: number,
retryAfterMs?: number,
): number {
const uncapped = retry.baseDelayMs * retry.backoffFactor ** Math.max(0, attempt - 1);
const capped = Math.min(uncapped, retry.maxDelayMs);
// provider 会返回几十秒到数分钟的 retry-after;
// 旧 60s 上限会把合法限流等待退化为本地短退避。
if (isReasonableRetryAfterMs(retryAfterMs, uncapped)) {
return retryAfterMs;
}
if (!retry.jitter || capped === 0) {
return capped;
}
return Math.round(capped * (0.5 + Math.random() * 0.5));
}服务端给的 Retry-After 不超过 5 分钟、或小于未封顶的指数值时直接采用,可以超过 60 秒的本地封顶(runner-retry.ts:15、223)。哪些失败可重试由 classifyModelFailure 决定:429、529、5xx、超时、网络与代理错误、流空闲超时可以重试,401、403、400、422、TLS 错误和上下文超限不重试(apps/zcode-cli/packages/adapters/src/model/failure-classifier.ts:183、207、247);供应商业务码另有一张表,例如 3008 到 3010 的并发上限默认不重试,只有工作流流量才重试(apps/zcode-cli/packages/adapters/src/model/failure-provider-business-codes.ts:108,runner-stream.ts:1466)。每次尝试发出前要向进程级准入端口取票,退避期间把票还回去,所以并发上限约束的是服务端真正看到的在途请求数(runner-stream.ts:238、825)。
两个补丁:让 SDK 认识视频
根 package.json 的 patchedDependencies 给两个 AI SDK 包打了补丁,第三个补丁针对桌面端的 @arms/rum-electron 日志采集,与模型无关(package.json:87)。两个 AI SDK 补丁做的是同一件事:在 SDK 把 file 部件转成请求内容的分支里,插入一个 video/* 分支。anthropic 补丁把它转成与图片块同构的 video 块(patches/@ai-sdk__anthropic@3.0.81.patch:5):
@@ -2265,6 +2265,19 @@ async function convertToAnthropicMessagesPrompt({
},
cache_control: cacheControl
});
+ } else if (part.mediaType.startsWith("video/")) {
+ // zcode patch: video/* file part -> video content block (image-block-isomorphic
+ // shape used by Anthropic-style compatible gateways; official Anthropic API has
+ // no video block yet). Video bytes are serialized as base64.
+ anthropicContent.push({
+ type: "video",
+ source: {
+ type: "base64",
+ media_type: part.mediaType,
+ data: (0, import_provider_utils14.convertToBase64)(part.data)
+ },
+ cache_control: cacheControl
+ });
} else if (part.mediaType === "application/pdf") {openai-compatible 补丁则转成 video_url 部件,内容是 base64 的 data URL(patches/@ai-sdk__openai-compatible@2.0.60.patch:9)。补丁注释自己也说明,官方 Anthropic API 还没有视频块,这个形状是给兼容 Anthropic 协议的网关用的;规则集里声明支持视频输入的正是 GLM-5.3-Flash 这类模型和 Coding Plan 站点(zcode-builtin.json:998、4012)。补丁按精确版本登记(pnpm-lock.yaml:15),而 @ai-sdk/openai-compatible 的依赖声明是范围版本 ^2.0.58(apps/zcode-cli/packages/adapters/package.json:104),锁文件变动时补丁要一并核对。
业务错误与官方套餐网关
fetch 链里有两层与智谱的服务形态直接相关。
业务错误。不少兼容服务(包括 zcode-plan)用 HTTP 200 加 success: false 或非零 code 报错,403 也未必带 JSON 的 Content-Type(model-execution.ts:450)。createProviderBusinessErrorFetch 对非 2xx 响应一律尝试读业务体,对 SSE 逐帧检查错误帧,读取上限 64000 字符,识别后先把原始响应体消费完、最多等 1000 毫秒,保证连接能复用(model-execution.ts:147、437、474)。错误里保留响应头,Retry-After 才不会在分类前丢失(model-execution.ts:554)。
官方套餐网关。发往两个官方 Coding Plan 端点的请求,在进入用户代理之前被改写到 ZCode 平台网关(apps/zcode-cli/packages/adapters/src/model/official-coding-plan-gateway.ts:22):
| 官方端点 | 网关路径(相对控制面地址) |
|---|---|
https://open.bigmodel.cn/api/anthropic/v1/messages | /api/v1/ultra/anthropic/v1/messages |
https://api.z.ai/api/anthropic/v1/messages | /api/v1/ultra-zai/anthropic/v1/messages |
文件头注释说网关负责套餐权益校验等平台侧处理,客户端只换地址,方法、请求体、鉴权头与响应原样透传(official-coding-plan-gateway.ts:4)。匹配按协议、主机、端口、路径精确比较,显式的 Host 头会被去掉(official-coding-plan-gateway.ts:87、146)。注释说“用户自建 provider 与第三方模型服务不受影响”;从代码看,判断只看最终 URL,用 Coding Plan API Key 模板建的个人 Provider 同样指向这两个端点,也会经过网关。改写放在代理之前,noProxy 按实际发送的网关地址判定(model-execution.ts:511)。
能力元数据从哪来
适配层不自己维护任何模型能力表,全部来自 Registry 里解析好的 ModelConfig,也就是上一篇那套规则的结果:
| 字段 | 在哪里起作用 |
|---|---|
contextWindow | core 按剩余窗口裁剪单次请求的输出预算,也用于压缩阈值(apps/zcode-cli/packages/core/src/runtime/methods/model-token-limits.ts:17) |
inputFormat | 请求前拒绝或替换不支持的图片、PDF、视频(model.ts:155,media-transform-policy.ts:8) |
supportsToolCall、supportsJsonSchemaOutput | 请求前校验;后者决定 openai-compatible 的结构化输出,anthropic 协议的非流式结构化输出强制走 outputFormat(runner-options.ts:191) |
supportsNativeWebSearch | 能否编码服务端 WebSearch 工具(tool-transform.ts:260) |
supportsMidConversationSystem | core 是否把会话中途的提醒投影成 system 消息(apps/zcode-cli/packages/core/src/runtime/helpers/runtime-provider-request-messages.ts:17) |
requiresMfjsToolSchema | 工具 schema 的 $ref 提升(tool-transform.ts:100) |
optionSpecs | 档位与输出上限的合法范围,以及编译进 fetch 的选项映射(model-execution.ts:187) |
输出上限的具体值每次请求由 core 决定,ModelFactory 不替它绑定默认值(provider-registry-model-runtime.ts:71)。
下一篇:账号、Coding Plan 与闲时计划——zcode login 之后,账号登录态怎样变成这一层每次请求前取到的那份凭据。