模型适配层

adapters 包的 model 目录怎样用 Vercel AI SDK 的三个 provider 包对接 anthropic-messages、openai-chat-completions、openai-responses 三种协议,把流式输出归一成运行时事件;推理签名、缓存断点、请求头、超时重试、两个 SDK 补丁和官方套餐网关都落在这一层。

作者 David更新于 24 篇(共 47 篇)

回合循环只认 @zcode/contracts 里的 Model 接口:streamText 吐出统一的 ModelStreamEventgenerateText 返回一次性结果。把它接到真实模型服务上的,是 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 构造唯一的 AiSdkModelAdapterapps/zcode-cli/packages/bootstrap/src/app/create-app.ts:523530),ApiProviderModelRuntime 每次按 Registry 查到的 Provider 与模型调用 createModelapps/zcode-cli/packages/bootstrap/src/app/provider-registry-model-runtime.ts:65)。一次流式请求自上而下经过这些层:

图表加载中…
主要文件做什么
ExecutableModelmodel.ts校验输出上限与推理档位落在规格内;模型不支持工具、结构化输出、图片、PDF 或视频时,请求发出前就拒绝(apps/zcode-cli/packages/adapters/src/model/model.ts:106155
AiSdkModelAdapterrunner.ts绑定 Provider 快照,账号型模型每次尝试前取鉴权材料,anthropic 协议下投影推理历史
runStreamTextrunner-stream.tsrunner-generate.ts尝试循环:准入、空闲超时、失败分类、退避、状态事件
createStreamTextOptionsrunner-options.ts转换消息与工具,合并请求头,关闭 SDK 自带重试
AiSdkModelExecutionmodel-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:102112),锁文件里后两者解析为 3.0.65 与 2.0.60(pnpm-lock.yaml:141144)。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-messagescreateAnthropic地址不以 /v1 结尾就补上,因为 SDK 只在后面拼 /messages;除 x-api-key 外再补一个 Authorization: Bearer,兼容两种都读的网关(model-execution.ts:388411
openai-responsescreateOpenAIresponses非流式响应缺 message.idannotations 时补齐,免得 SDK 整个拒收(apps/zcode-cli/packages/adapters/src/model/openai-responses-json-compat.ts:5066
openai-chat-completionscreateOpenAICompatible显式打开流式用量 includeUsage,结构化输出按模型能力开启,否则 SDK 会把 JSON Schema 静默降级(model-execution.ts:310313

@ai-sdk/openai 只用于 Responses 协议,Chat Completions 一律走 openai-compatible 包。SDK 自己的重试被 maxRetries: 0 关掉,重试全部由这一层掌控;allowSystemInMessages 打开,因为系统提示词由 core 组装(apps/zcode-cli/packages/adapters/src/model/runner-options.ts:149150)。bindModel 在创建 Model 时冻结 Provider 快照,之后 Registry 热更新不会让已有 Model 悄悄换地址或协议,请求期只允许替换 API Key 和请求头(model-execution.ts:170374)。

账号型模型的鉴权是逐次取的:适配层给每次尝试挂一个 refreshRuntimeHeadersBeforeAttempt,闲时模型只认创建时注入的执行作用域来源,其他账号模型走 core 的请求头端口,取不到就以 ModelRequestAuthMissing 失败(apps/zcode-cli/packages/adapters/src/model/runner.ts:164181)。端口在桌面端经协议向 Host 请求、总时限 180000 毫秒(apps/zcode-cli/packages/bootstrap/src/zcode-protocol/provider-runtime-headers.ts:16),独立 CLI 则直接读本地凭据,细节见账号、Coding Plan 与闲时计划

请求头

来源请求头出处
进程默认User-Agent: ZCode/<版本>HTTP-Referer(控制面地址)、X-TitleX-ZCode-App-VersionX-Release-ChannelX-Client-LanguageX-Client-TimezoneX-ZCode-Agent: glmapps/zcode-cli/packages/bootstrap/src/model-config.ts:47
运行平台X-Platform(平台加架构)、X-Os-CategoryX-Os-Versionapps/zcode-cli/packages/bootstrap/src/runtime-platform-headers.ts:5
仅 openrouter.aiX-OpenRouter-Title: ZCodeX-OpenRouter-Categories: programming-apppackages/shared/src/openrouter-attribution.ts:1
每次请求x-request-idx-zcode-trace-idx-zcode-session-typex-session-idx-query-id;OpenCode Go 另加 x-opencode-sessionapps/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-serveragent-server(即被桌面端拉起)时是 Z Code@electronmodel-config.ts:75)。进程默认头与 Provider 配置里的自定义头先按不区分大小写的方式合并,后者覆盖前者(model-execution.ts:205apps/zcode-cli/packages/adapters/src/model/model-request-headers.ts:1);每次请求的归因头最后叠上(runner-options.ts:143)。x-zcode-session-typemainsubagentother 之一,注释说 Coding Plan 服务端用它区分请求来源;工作流子会话一律记为 otherrunner-attribution.ts:20apps/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 的 ModelMessageapps/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 提升到 $defsapps/zcode-cli/packages/adapters/src/model/tool-transform.ts:8595232),规则集只为 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:292apps/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.cacheControltransform.ts:499),没有为另外两种协议做任何映射。

三段 system 加一条消息,最多正好四个断点。命中情况由 normalizeUsage 从 SDK 用量里取出 cacheReadTokenscacheWriteTokensapps/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:153runner-stream.ts:1129),见上下文压缩

推理内容:签名与回放

推理强度本身由选项映射写进请求体,见上一篇。难的是把上一轮的思考块原样回传:Anthropic 协议要求 thinking 块带签名,而各家兼容服务的实现参差不齐。

  • 收的时候:有的服务把签名放在 content_block_start 里,SDK 只从 signature_delta 读,兼容 fetch 在块结束前补一个合成的 signature_delta;助手侧冒出来的裸 tool_result 块整块丢弃;非流式 JSON 里没签名的 thinking 块删掉,保住正文与用量(anthropic-stream-compat.ts:141256269)。
  • 发的时候:只对 anthropic 协议,每个逻辑请求投影一次推理历史(runner.ts:358)。其他模型留下的带签名推理删掉,但同一服务的新旧身份视为兼容,例如旧的 builtin:zai-coding-plan 与个人、团队 Coding Plan;随后删掉只剩推理的助手消息、结尾的推理和纯空白消息(apps/zcode-cli/packages/adapters/src/model/reasoning-history-normalization.ts:1831)。同模型留下的无签名块不删,序列化时带一个空签名(apps/zcode-cli/packages/adapters/src/model/anthropic-reasoning-metadata.ts:23)。
  • 被拒的时候:服务端返回 400 且文案指向 thinking 块签名,就只在本次请求的副本里删掉带签名的推理,再额外试一次,不占普通重试预算,也不写回会话历史(reasoning-history-normalization.ts:76runner-stream.ts:181)。
  • Responses 协议:没有 previousResponseId 的无状态回放里,丢掉推理的 item 引用,因为部分兼容端点不支持(transform.ts:300335)。

超时与重试

默认值出处
流空闲超时600000 毫秒,配置键 modelStream.idleTimeoutMs;每多重试一次加 30000apps/zcode-cli/packages/contracts/src/config/index.ts:284apps/zcode-cli/packages/adapters/src/model/stream-idle-timeout.ts:5
重试次数10 次,即最多 11 次请求apps/zcode-cli/packages/adapters/src/model/retry-policy.ts:1328
退避2000 毫秒起、每次乘 2、封顶 60000,再乘 0.5 到 1 的随机系数retry-policy.ts:14apps/zcode-cli/packages/adapters/src/model/runner-retry.ts:38
环境变量ZCODE_MODEL_RETRY_MAX_RETRIESZCODE_MODEL_RETRY_BASE_DELAY_MSZCODE_MODEL_RETRY_BACKOFF_FACTORZCODE_MODEL_RETRY_MAX_DELAY_MSretry-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:15223)。哪些失败可重试由 classifyModelFailure 决定:429、529、5xx、超时、网络与代理错误、流空闲超时可以重试,401、403、400、422、TLS 错误和上下文超限不重试(apps/zcode-cli/packages/adapters/src/model/failure-classifier.ts:183207247);供应商业务码另有一张表,例如 3008 到 3010 的并发上限默认不重试,只有工作流流量才重试(apps/zcode-cli/packages/adapters/src/model/failure-provider-business-codes.ts:108runner-stream.ts:1466)。每次尝试发出前要向进程级准入端口取票,退避期间把票还回去,所以并发上限约束的是服务端真正看到的在途请求数(runner-stream.ts:238825)。

两个补丁:让 SDK 认识视频

package.jsonpatchedDependencies 给两个 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:9984012)。补丁按精确版本登记(pnpm-lock.yaml:15),而 @ai-sdk/openai-compatible 的依赖声明是范围版本 ^2.0.58apps/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:147437474)。错误里保留响应头,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:87146)。注释说“用户自建 provider 与第三方模型服务不受影响”;从代码看,判断只看最终 URL,用 Coding Plan API Key 模板建的个人 Provider 同样指向这两个端点,也会经过网关。改写放在代理之前,noProxy 按实际发送的网关地址判定(model-execution.ts:511)。

能力元数据从哪来

适配层不自己维护任何模型能力表,全部来自 Registry 里解析好的 ModelConfig,也就是上一篇那套规则的结果:

字段在哪里起作用
contextWindowcore 按剩余窗口裁剪单次请求的输出预算,也用于压缩阈值(apps/zcode-cli/packages/core/src/runtime/methods/model-token-limits.ts:17
inputFormat请求前拒绝或替换不支持的图片、PDF、视频(model.ts:155media-transform-policy.ts:8
supportsToolCallsupportsJsonSchemaOutput请求前校验;后者决定 openai-compatible 的结构化输出,anthropic 协议的非流式结构化输出强制走 outputFormatrunner-options.ts:191
supportsNativeWebSearch能否编码服务端 WebSearch 工具(tool-transform.ts:260
supportsMidConversationSystemcore 是否把会话中途的提醒投影成 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 之后,账号登录态怎样变成这一层每次请求前取到的那份凭据。

本页目录