提供方适配层

transform.ts 如何按提供方规整消息、打缓存断点、设定采样与推理参数、清洗工具 schema、限定输出上限,以及 error.ts 如何识别上下文溢出。

作者 David更新于 第 20 篇(共 38 篇)

上一篇拿到了 LanguageModelV3,可同一段会话发给 Anthropic、Gemini、Mistral 或 DeepSeek,各家对消息格式、参数名和 JSON Schema 的要求都不一样。V1 把这些差异收进 packages/opencode/src/provider/transform.ts:约 1900 行纯函数,判断依据只有三样——model.api.npm(用哪个 SDK 包)、model.providerID 和 model.api.id。

调用点地图

导出调用方作用
options()、smallOptions()session/llm/request.ts默认提供方选项
temperature()、topP()、topK()同上采样默认值
maxOutputTokens()同上,以及 session/overflow.ts输出上限与压缩阈值
message()、providerOptions()session/llm.ts、session/llm/native-runtime.ts消息规整、选项命名空间
schema()session/tools.ts工具参数 schema 清洗
variants()、reasoningVariants()provider/provider.ts推理档位表

在 AI SDK 路径上,message() 以 transformParams 中间件的形式接入。

选项的四层叠加

一次请求的提供方选项在 session/llm/request.ts:80 组装:

const variant =
  !input.small && input.model.variants && input.user.model.variant
    ? input.model.variants[input.user.model.variant]
    : {}
const base = input.small
  ? ProviderTransform.smallOptions(input.model)
  : ProviderTransform.options({
      model: input.model,
      sessionID: input.sessionID,
      providerOptions: input.provider.options,
    })
const options = mergeOptions(mergeOptions(mergeOptions(base, input.model.options), input.agent.options), variant)

优先级由低到高:transform.ts 的默认值、模型配置的 options、Agent 的 options、用户选中的 variant;之后插件还能在 chat.params 钩子里改掉任意一项。标题生成这类 small 请求不叠 variant,改用 smallOptions()。

options() 的默认值里有几条值得记住:OpenAI 系(含 Azure、Copilot、xAI)一律 store: false;OpenAI、Azure、xAI、Mistral 等把会话 ID 作为 promptCacheKey;GPT-5 系列默认 reasoningEffort: "medium",在 OpenAI 原生包上还要求返回加密推理内容——服务端不存状态,多轮推理就靠它接续。

消息规整:一张踩坑清单

message() 先把模型不支持的附件(按 MIME 判断图片、音频、视频、PDF)换成一段 ERROR: Cannot read ... 文本,让模型转告用户,而不是让请求失败。然后处理各家禁忌:

对象处理
所有消息孤立的 UTF-16 代理项替换成 U+FFFD
@ai-sdk/anthropic删掉空文本与空推理块,Anthropic 拒绝空内容
Claude 模型工具调用 ID 里的非法字符改成下划线
Mistral 系工具调用 ID 压成 9 位字母数字;tool 消息后紧跟 user 消息时插一条 Done.
DeepSeek没有推理块的 assistant 消息补一个空推理块
声明 interleaved 字段的模型推理文本移进 reasoning_content 之类的专用字段回传

接着 applyCaching() 给 Anthropic 系打提示缓存断点:

function applyCaching(msgs: ModelMessage[], model: Provider.Model): ModelMessage[] {
  const system = msgs.filter((msg) => msg.role === "system").slice(0, 2)
  const final = msgs.filter((msg) => msg.role !== "system").slice(-2)
  // ...
}

(transform.ts:358,删减后)

前两条 system 消息加最后两条非 system 消息,最多四个断点,与 Anthropic 每次请求最多四个缓存断点的限制相符。标记对象同时写了 anthropic、bedrock、openaiCompatible、copilot 等键,由实际使用的 SDK 各取所需;若合并后的选项里已有 cacheControl(交给 @ai-sdk/anthropic 自动缓存),这一步跳过。

采样与推理参数

采样默认值是按模型 ID 匹配的经验值:Claude 不设温度;Gemini 2.5、3 系列的部分型号用 temperature 1.0、topP 0.95、topK 64;kimi-k2 思考版 1.0、非思考版 0.6。温度只在目录声明支持时发送,Agent 配置可以覆盖。

推理参数最繁琐。同一个“高档推理”,各 SDK 写法各不相同:

SDK 包档位的选项片段
@ai-sdk/openaireasoningEffort,外加 reasoningSummary: "auto" 与加密推理 include
@ai-sdk/anthropic(Claude 4.6 起)thinking: { type: "adaptive" } 加 effort
@ai-sdk/anthropic(更早)thinking 里的 budgetTokens
@ai-sdk/googlethinkingConfig 的 thinkingLevel 或 thinkingBudget
@openrouter/ai-sdk-providerreasoning: { effort }

哪些档位可用也有讲究:OpenAI 的 none、xhigh 是后加的,旧模型收到会返回 400,所以代码按目录中的发布日期门控(transform.ts:583 的注释写明了原因)。数据驱动的 reasoningVariants() 则直接读 models.dev 的 reasoning_options,手写规则只在目录没给数据时兜底。

anthropicBlockBinding() 上方的注释(transform.ts:700)值得一读:

Fable 5.1 binds each thinking signature to the system prompt, tool list, and messages above it, and rejects the request when any of that changes. opencode re-renders parts of that prefix between turns (system prompt, tools, compaction), so ask the API to drop the affected blocks instead of failing the request.

新一代 Claude 把思考签名绑定到它之前的整段前缀,而 OpenCode 在轮次之间会重渲染系统提示、工具表,还会做上下文压缩,前缀必然变化。所以对 Claude 5.1 及以上(Mythos 5.1 除外)默认加上 prefixMismatchBehavior: "drop_block",让 API 丢弃失效的思考块而不是拒绝请求;配置 blockBinding: false 可以关掉。

工具 schema 与输出上限

schema() 在注册工具时调用(session/tools.ts:98):OpenAI、Azure 走 sanitizeOpenAISchema(),照着 Codex 的 Rust 实现做兼容降级,只保留有限的关键字、布尔 schema 改成 { type: "string" },request.ts 还给这一系的工具统一设 strict: false,让 MCP 送来的不规范 schema 也能注册;Moonshot、Kimi 删掉 $ref 节点的兄弟关键字;Gemini 把整数枚举转成字符串,把 type 数组拆成 anyOf。

输出上限由 maxOutputTokens()(transform.ts:1468)给出:目录值与常量 OUTPUT_TOKEN_MAX(32000)取小,目录写 0 时回落到 32000,环境变量 OPENCODE_EXPERIMENTAL_OUTPUT_TOKEN_MAX 可以替换常量。session/overflow.ts 也用它预留输出空间,算出上下文压缩的触发阈值。插件可以在 chat.params 里把它清成 undefined,openai 提供方和 Copilot 上的 GPT 模型就是这样处理的。

error.ts:把失败翻译成会话能处理的类型

packages/opencode/src/provider/error.ts 不到两百行,却决定了失败之后会话怎么走。上一篇 fetch 包装抛出的 HeaderTimeoutError、ResponseStreamError 被映射成可重试的 APIError;AI SDK 的 APICallError 则交给:

export function parseAPICallError(input: { providerID: ProviderV2.ID; error: APICallError }): ParsedAPICallError {
  const m = message(input.providerID, input.error)
  const body = json(input.error.responseBody)
  if (isContextOverflow(m) || input.error.statusCode === 413 || body?.error?.code === "context_length_exceeded") {
    return {
      type: "context_overflow",
      message: m,
      responseBody: input.error.responseBody,
    }
  }
  // ...
}

(error.ts:172,删减后)

message() 先从响应体挖出 message、error 字段,遇到网关返回的 HTML 页面就为 401、403 换上可读提示。上下文溢出有三条判据:HTTP 413、context_length_exceeded 错误码,以及 @opencode-ai/llm 导出的 isContextOverflow()——它用二十多条正则匹配各家的溢出措辞,并排除限流类消息。另外,openai 开头的提供方返回 404 也算可重试(注释说 OpenAI 偶尔会对实际可用的模型返回 404)。

这些结果在 session/message-v2.ts 里变成 ContextOverflowError 或 APIError:前者从不重试,处理器把它转成“需要压缩”的信号;后者交给重试逻辑,5xx 即便 SDK 没标可重试也会重试。

下一篇:认证与提供方插件——API key 与 OAuth 凭据存在哪里,GitHub Copilot、ChatGPT 订阅等登录方式如何经插件钩子注入请求。

本页目录