12-Provider 配置与模型路由:多 provider 接入机制

12-Provider 配置与模型路由:多 provider 接入机制

简要概括:models.dev快照获取元数据,auth支持API key/OAuth/SigV4,Transform转换消息,resolve解析可执行Model。
对应问题 Q12:Provider 配置与模型路由。涵盖 models.dev 快照获取元数据、Provider auth 处理(API key / OAuth / Bedrock SigV4 / Copilot token)、ProviderTransform 的消息与 provider options 转换、SessionRunnerModel.resolve 从 session 解析可执行 Model、catalog.ts 的 correction 优先级管理。 勘察依据:provider/provider.ts(2001 行)、provider/transform.tsprovider/auth.tscore/src/catalog.tscore/src/models-dev.tscore/src/provider.tscore/src/model.tscore/src/session/runner/model.tsauth/index.ts

一、全景:四层模型信息来源

OpenCode 的模型元数据来自四层来源,按优先级从低到高叠加覆盖:

bash 复制代码
┌─────────────────────────────────────────────────────────────────────┐
│ Layer 1: models.dev snapshot(最低优先级)                           │
│   core/src/models-dev.ts                                            │
│   ├── 编译时内联快照 OPENCODE_MODELS_DEV                             │
│   ├── 运行时缓存文件 ~/.cache/opencode/models.json(5 分钟 TTL)     │
│   └── 后台刷新 Schedule.spaced("60 minutes")                        │
│   提供:capabilities / context-output limits / pricing / modalities  │
└──────────────────────────┬──────────────────────────────────────────┘
                           │ fromModelsDevProvider()
                           ▼
┌─────────────────────────────────────────────────────────────────────┐
│ Layer 2: Provider correction(Plugin provider hook)               │
│   provider/provider.ts → plugin.provider.models()                   │
│   Plugin 可修正/替换 models.dev 的模型列表(如 Copilot OAuth 发现)  │
└──────────────────────────┬──────────────────────────────────────────┘
                           │ mergeDeep()
                           ▼
┌─────────────────────────────────────────────────────────────────────┐
│ Layer 3: Config override(opencode.json)                           │
│   provider/provider.ts → configProviders                             │
│   用户配置覆盖:models / options / npm / api / variants             │
└──────────────────────────┬──────────────────────────────────────────┘
                           │ mergeDeep()
                           ▼
┌─────────────────────────────────────────────────────────────────────┐
│ Layer 4: Call override(session.model + variant,最高优先级)       │
│   core/src/session/runner/model.ts → SessionRunnerModel.resolve()   │
│   运行时从 session 的 agent+model+variant 解析出可执行 Model          │
└─────────────────────────────────────────────────────────────────────┘

二、models.dev 快照获取模型元数据

2.1 models.dev 数据结构

models.dev 是一个社区维护的 LLM 模型目录 API,提供所有 provider 的模型元数据。OpenCode 通过 core/src/models-dev.ts 获取并缓存这些数据。

ts 复制代码
// 62:114:opencode/packages/core/src/models-dev.ts
export const Model = Schema.Struct({
  id: Schema.String,
  name: Schema.String,
  family: Schema.optional(Schema.String),
  release_date: Schema.String,
  attachment: Schema.Boolean,           // 支持附件
  reasoning: Schema.Boolean,           // 支持推理
  temperature: Schema.Boolean,         // 支持温度参数
  tool_call: Schema.Boolean,           // 支持工具调用
  reasoning_options: Schema.optional(Schema.Array(ReasoningOption)),  // 推理控制选项
  interleaved: Schema.optional(...),   // 交错推理字段
  cost: Schema.optional(Cost),         // 定价(input/output/cache_read/cache_write/tiers)
  limit: Schema.Struct({
    context: Schema.Finite,            // 上下文窗口大小
    input: Schema.optional(Schema.Finite),
    output: Schema.Finite,             // 最大输出 token
  }),
  modalities: Schema.optional(Schema.Struct({
    input: Schema.Array(Schema.Literals(["text", "audio", "image", "video", "pdf"])),
    output: Schema.Array(Schema.Literals(["text", "audio", "image", "video", "pdf"])),
  })),
  experimental: Schema.optional(Schema.Struct({
    modes: Schema.optional(Schema.Record(Schema.String, Schema.Struct({
      cost: Schema.optional(Cost),
      provider: Schema.optional(Schema.Struct({
        body: Schema.optional(Schema.Record(Schema.String, Schema.MutableJson)),
        headers: Schema.optional(Schema.Record(Schema.String, Schema.String)),
      })),
    }))),
  })),
  status: Schema.optional(CatalogModelStatus),  // "alpha" | "beta" | "deprecated"
  provider: Schema.optional(Schema.Struct({
    npm: Schema.optional(Schema.String),
    api: Schema.optional(Schema.String),
  })),
})

export const Provider = Schema.Struct({
  api: Schema.optional(Schema.String),  // 默认 API URL
  name: Schema.String,
  env: Schema.Array(Schema.String),     // 环境变量名(如 OPENAI_API_KEY)
  id: Schema.String,
  npm: Schema.optional(Schema.String),  // 默认 AI SDK npm 包
  models: Schema.Record(Schema.String, Model),
})

2.2 三级数据源加载

ts 复制代码
// 162:225:opencode/packages/core/src/models-dev.ts
const populate = Effect.gen(function* () {
  // ── 优先级 1: 磁盘缓存文件 ──
  const fromDisk = yield* loadFromDisk
  if (fromDisk) return fromDisk

  // ── 优先级 2: 编译时内联快照 ──
  const snapshot = yield* loadSnapshot
  if (snapshot) return snapshot

  // ── 优先级 3: 从 models.dev 拉取 ──
  if (Flag.OPENCODE_DISABLE_MODELS_FETCH) return {}
  const text = yield* Effect.scoped(
    Effect.gen(function* () {
      yield* Flock.effect(lockKey)     // 跨进程文件锁,防止并发写
      return yield* fetchAndWrite()
    }),
  )
  return JSON.parse(text) as Record<string, Provider>
}).pipe(Effect.withSpan("ModelsDev.populate"), Effect.orDie)

三级数据源

优先级 来源 说明
1 磁盘缓存 ~/.cache/opencode/models.json 5 分钟 TTL,原子写入(temp file → rename)
2 编译时内联 OPENCODE_MODELS_DEV 打包时注入的快照,离线可用
3 远程 https://models.dev/api.json 实时拉取,带重试(2 次指数退避)和 10 秒超时

2.3 原子缓存写入

ts 复制代码
// 196:209:opencode/packages/core/src/models-dev.ts
const fetchAndWrite = Effect.fn("ModelsDev.fetchAndWrite")(function* () {
  const text = yield* fetchApi()
  const tempfile = `${filepath}.${process.pid}.${Date.now()}.tmp`
  yield* fs.writeWithDirs(tempfile, text).pipe(
    Effect.andThen(fs.rename(tempfile, filepath)),    // 原子 rename
    Effect.catch((error) =>
      Effect.gen(function* () {
        yield* fs.remove(tempfile, { force: true }).pipe(Effect.ignore)
        return yield* Effect.fail(error)
      }),
    ),
  )
  return text
})

原子写入流程fetch → write tempfile → rename → 完成。如果 rename 失败,清理 tempfile。rename 在同一文件系统上是原子的,保证读到的缓存文件不会损坏。

2.4 后台定时刷新

ts 复制代码
// 249:252:opencode/packages/core/src/models-dev.ts
if (!Flag.OPENCODE_DISABLE_MODELS_FETCH && !process.argv.includes("--get-yargs-completions")) {
  yield* Effect.forkScoped(refresh().pipe(Effect.repeat(Schedule.spaced("60 minutes")), Effect.ignore))
}

每 60 分钟刷新一次,Effect.forkScoped 确保进程退出时自动取消。refresh() 在文件锁内检查 TTL,避免并发进程重复拉取。

2.5 从 models.dev 转换为 Provider.Info

ts 复制代码
// 1205:1257:opencode/packages/opencode/src/provider/provider.ts
function fromModelsDevModel(provider: ModelsDev.Provider, model: ModelsDev.Model): Model {
  const base: Model = {
    id: ModelV2.ID.make(model.id),
    providerID: ProviderV2.ID.make(provider.id),
    name: model.name,
    family: model.family,
    api: {
      id: model.id,
      url: model.provider?.api ?? provider.api ?? "",
      npm: model.provider?.npm ?? provider.npm ?? "@ai-sdk/openai-compatible",
    },
    status: model.status ?? "active",
    headers: {},
    options: {},
    cost: cost(model.cost),
    limit: {
      context: model.limit.context,
      input: model.limit.input,
      output: model.limit.output,
    },
    capabilities: {
      temperature: model.temperature ?? false,
      reasoning: model.reasoning ?? false,
      attachment: model.attachment ?? false,
      toolcall: model.tool_call ?? true,
      input: {
        text: model.modalities?.input?.includes("text") ?? false,
        audio: model.modalities?.input?.includes("audio") ?? false,
        image: model.modalities?.input?.includes("image") ?? false,
        video: model.modalities?.input?.includes("video") ?? false,
        pdf: model.modalities?.input?.includes("pdf") ?? false,
      },
      output: { /* 同上 */ },
      interleaved: model.interleaved ?? false,
    },
    release_date: model.release_date ?? "",
    variants: {},
  }

  // reasoning variants: 优先从 models.dev 的 reasoning_options 生成
  // 回退到 ProviderTransform.variants(base) 的 hardcoded 逻辑
  const variants = ProviderTransform.reasoningVariants(model, base) ?? ProviderTransform.variants(base)

  return {
    ...base,
    variants: mapValues(variants, (v) => v),
  }
}

关键字段映射

models.dev 字段 Provider.Model 字段 说明
cost.input/output cost.input/output 定价
cost.cache_read/write cost.cache.read/write 缓存定价
cost.tiers cost.tiers 分层定价
limit.context/output limit.context/output token 限制
modalities.input/output capabilities.input/output 支持的多模态
reasoning capabilities.reasoning 是否支持推理
tool_call capabilities.toolcall 是否支持工具调用
reasoning_options variants 推理变体(effort/toggle/budget_tokens)
provider.npm api.npm AI SDK 包名
provider.api api.url API endpoint

三、Provider Auth 处理

3.1 Auth 数据模型

ts 复制代码
// 14:36:opencode/packages/opencode/src/auth/index.ts
export class Oauth extends Schema.Class<Oauth>("OAuth")({
  type: Schema.Literal("oauth"),
  refresh: Schema.String,         // refresh token
  access: Schema.String,         // access token
  expires: NonNegativeInt,       // 过期时间戳
  accountId: Schema.optional(Schema.String),
  enterpriseUrl: Schema.optional(Schema.String),
}) {}

export class Api extends Schema.Class<Api>("ApiAuth")({
  type: Schema.Literal("api"),
  key: Schema.String,            // API key
  metadata: Schema.optional(Schema.Record(Schema.String, Schema.String)),  // 额外元数据
}) {}

export class WellKnown extends Schema.Class<WellKnown>("WellKnownAuth")({
  type: Schema.Literal("wellknown"),
  key: Schema.String,
  token: Schema.String,
}) {}

export const Info = Schema.Union([Oauth, Api, WellKnown]).annotate({ discriminator: "type", identifier: "Auth" })

三种认证类型

类型 字段 适用场景
api key + metadata API Key 认证(OpenAI、Anthropic 等)
oauth refresh + access + expires OAuth 认证(GitHub Copilot、Google Vertex 等)
wellknown key + token Well-known token 认证

3.2 Auth 存储与加载

ts 复制代码
// 58:91:opencode/packages/opencode/src/auth/index.ts
const all = Effect.fn("Auth.all")(function* () {
  // 优先级 1: 环境变量注入
  if (process.env.OPENCODE_AUTH_CONTENT) {
    try {
      return JSON.parse(process.env.OPENCODE_AUTH_CONTENT)
    } catch (err) {}
  }

  // 优先级 2: 磁盘文件 ~/.local/share/opencode/auth.json
  const data = (yield* fsys.readJson(file).pipe(Effect.orElseSucceed(() => ({})))) as Record<string, unknown>
  return Record.filterMap(data, (value) => Result.fromOption(decode(value), () => undefined))
})

const set = Effect.fn("Auth.set")(function* (key: string, info: Info) {
  const norm = key.replace(/\/+$/, "")  // 规范化 key(去除尾部斜杠)
  const data = yield* all()
  if (norm !== key) delete data[key]
  delete data[norm + "/"]
  yield* fsys.writeJson(file, { ...data, [norm]: info }, 0o600)  // 权限 0600
})

3.3 Provider 初始化中的 Auth 加载流程

ts 复制代码
// 1511:1556:opencode/packages/opencode/src/provider/provider.ts
// ── 步骤 1: 从环境变量加载 API Key ──
const envs = yield* env.all()
for (const [id, provider] of Object.entries(database)) {
  const providerID = ProviderV2.ID.make(id)
  if (disabled.has(providerID)) continue
  const apiKey = provider.env.map((item) => envs[item]).find(Boolean)  // 检查 env 列表中的变量
  if (!apiKey) continue
  mergeProvider(providerID, {
    source: "env",
    key: provider.env.length === 1 ? apiKey : undefined,
  })
}

// ── 步骤 2: 从 auth store 加载 API Key ──
const auths = yield* auth.all().pipe(Effect.orDie)
for (const [id, provider] of Object.entries(auths)) {
  const providerID = ProviderV2.ID.make(id)
  if (disabled.has(providerID)) continue
  if (provider.type === "api") {
    mergeProvider(providerID, { source: "api", key: provider.key })
  }
}

// ── 步骤 3: Plugin auth loader(OAuth 等自定义认证)──
for (const plugin of plugins) {
  if (!plugin.auth) continue
  const providerID = ProviderV2.ID.make(plugin.auth.provider)
  if (disabled.has(providerID)) continue

  const stored = yield* auth.get(providerID).pipe(Effect.orDie)
  if (!stored) continue
  if (!plugin.auth.loader) continue

  const options = yield* Effect.promise(() =>
    plugin.auth!.loader!(
      () => bridge.promise(auth.get(providerID).pipe(Effect.orDie)) as any,
      toPublicInfo(database[plugin.auth!.provider]),
    ),
  )
  mergeProvider(providerID, { source: "custom", options: options ?? {} })
}

3.4 Amazon Bedrock SigV4 认证

Bedrock 使用 AWS Signature V4 认证,支持多种凭证来源:

ts 复制代码
// 292:365:opencode/packages/opencode/src/provider/provider.ts
"amazon-bedrock": Effect.fnUntraced(function* () {
  const providerConfig = (yield* dep.config()).provider?.["amazon-bedrock"]
  const auth = yield* dep.auth("amazon-bedrock")
  const env = yield* dep.env()

  // Region 优先级:config > env > default
  const defaultRegion = configRegion ?? envRegion ?? "us-east-1"
  // Profile 优先级:config > env
  const profile = configProfile ?? envProfile

  // ── 五种 AWS 凭证来源 ──
  const awsAccessKeyId = env["AWS_ACCESS_KEY_ID"]
  const configApiKey = providerConfig?.options?.apiKey
  const awsBearerToken = iife(() => {
    const envToken = process.env.AWS_BEARER_TOKEN_BEDROCK
    if (envToken) return envToken
    if (auth?.type === "api") {
      process.env.AWS_BEARER_TOKEN_BEDROCK = auth.key    // 注入 auth key 为 bearer token
      return auth.key
    }
    return undefined
  })
  const awsWebIdentityTokenFile = env["AWS_WEB_IDENTITY_TOKEN_FILE"]    // IRSA / OIDC
  const containerCreds = Boolean(
    process.env.AWS_CONTAINER_CREDENTIALS_RELATIVE_URI ||
    process.env.AWS_CONTAINER_CREDENTIALS_FULL_URI,
  )

  if (!profile && !awsAccessKeyId && !awsBearerToken && !configApiKey && !awsWebIdentityTokenFile && !containerCreds)
    return { autoload: false }

  // ── 使用 AWS SDK 的 credential provider chain ──
  const { fromNodeProviderChain } = yield* Effect.promise(() => import("@aws-sdk/credential-providers"))

  const providerOptions: Record<string, any> = { region: defaultRegion }

  // Bearer token 优先于 credential chain
  if (!awsBearerToken && !configApiKey) {
    const credentialProviderOptions = profile ? { profile } : {}
    providerOptions.credentialProvider = fromNodeProviderChain(credentialProviderOptions)
  }

  return {
    autoload: true,
    options: providerOptions,
    vars(options) { return { AWS_REGION: options.region ?? defaultRegion } },
    async getModel(sdk, modelID, options, model) {
      // ... 跨区域推理前缀处理
      return sdk.languageModel(modelID)
    },
  }
})

Bedrock SigV4 认证的五种凭证来源

来源 环境变量/字段 说明
Bearer Token AWS_BEARER_TOKEN_BEDROCK 最高优先级,直接注入
API Key (config) provider.options.apiKey 配置文件中的 apiKey
Access Key AWS_ACCESS_KEY_ID + AWS_SECRET_ACCESS_KEY 静态凭证
Profile AWS_PROFILE AWS 命名 profile
Web Identity AWS_WEB_IDENTITY_TOKEN_FILE IRSA/OIDC(K8s pod、GitHub OIDC)
Container AWS_CONTAINER_CREDENTIALS_* ECS 任务凭证

凭证链选择逻辑 :有 Bearer Token 或 config API Key 时跳过 credential chain(直接用 token),否则使用 fromNodeProviderChain 按顺序尝试所有凭证来源。

3.5 GitHub Copilot Token 认证

Copilot 使用 OAuth 认证,通过 plugin auth loader 动态注入 token:

ts 复制代码
// 225:239:opencode/packages/opencode/src/provider/provider.ts
"github-copilot": () =>
  Effect.succeed({
    autoload: false,    // 不自动加载,需要用户先 opencode auth login
    async getModel(sdk: any, modelID: string, _options?, model?) {
      if (sdk.responses === undefined && sdk.chat === undefined) return sdk.languageModel(modelID)
      if (model && "endpoint" in model.api) {
        if (model.api.endpoint === "responses" && sdk.responses) return sdk.responses(modelID)
        if (model.api.endpoint === "chat" && sdk.chat) return sdk.chat(modelID)
      }
      // GPT-5+ 用 Responses API,其他用 Chat API
      const match = /^gpt-(\d+)/.exec(modelID)
      if (match && Number(match[1]) >= 5 && !modelID.startsWith("gpt-5-mini")) return sdk.responses(modelID)
      return sdk.chat(modelID)
    },
    options: {},
  }),

Copilot 的 auth 通过 ProviderAuth 服务(provider/auth.ts)管理 OAuth 流程:

ts 复制代码
// 163:221:opencode/packages/opencode/src/provider/auth.ts
const authorize = Effect.fn("ProviderAuth.authorize")(function* (input) {
  const { hooks, pending } = yield* InstanceState.get(state)
  const method = hooks[input.providerID].methods[input.method]
  if (method.type !== "oauth") return

  // 验证用户输入
  if (method.prompts && input.inputs) { ... }

  // 调用 plugin 的 authorize 方法获取授权 URL
  const result = yield* Effect.promise(() => method.authorize(input.inputs))
  pending.set(input.providerID, result)    // 暂存 pending OAuth 状态
  return { url: result.url, method: result.method, instructions: result.instructions }
})

const callback = Effect.fn("ProviderAuth.callback")(function* (input) {
  const pending = (yield* InstanceState.get(state)).pending
  const match = pending.get(input.providerID)
  if (!match) return yield* new OauthMissing({ providerID: input.providerID })
  if (match.method === "code" && !input.code) return yield* new OauthCodeMissing({ providerID: input.providerID })

  // 调用 plugin 的 callback 方法完成 OAuth
  const result = yield* Effect.promise(() =>
    match.method === "code" ? match.callback(input.code!) : match.callback(),
  )
  if (!result || result.type !== "success") return yield* new OauthCallbackFailed({})

  // 保存 OAuth token 或 API key
  if ("key" in result) {
    yield* auth.set(input.providerID, { type: "api", key: result.key, ...(result.metadata ? { metadata: result.metadata } : {}) })
  }
  if ("refresh" in result) {
    yield* auth.set(input.providerID, { type: "oauth", access: result.access, refresh: result.refresh, expires: result.expires, ...extra })
  }
})

3.6 Google Vertex ADC 认证

ts 复制代码
// 496:547:opencode/packages/opencode/src/provider/provider.ts
"google-vertex": Effect.fnUntraced(function* (provider: Info) {
  const env = yield* dep.env()
  const project = provider.options?.project ?? env["GOOGLE_VERTEX_PROJECT"] ?? env["GOOGLE_CLOUD_PROJECT"] ?? env["GCP_PROJECT"] ?? env["GCLOUD_PROJECT"]
  const location = String(provider.options?.location ?? env["GOOGLE_VERTEX_LOCATION"] ?? env["GOOGLE_CLOUD_LOCATION"] ?? env["VERTEX_LOCATION"] ?? "us-central1")

  const autoload = Boolean(project)
  if (!autoload) return { autoload: false }
  return {
    autoload: true,
    vars() { return { GOOGLE_VERTEX_PROJECT: project, GOOGLE_VERTEX_LOCATION: location, GOOGLE_VERTEX_ENDPOINT: endpoint } },
    options: {
      project,
      location,
      fetch: async (input, init) => {
        // 使用 GoogleAuth 库获取 ADC (Application Default Credentials) token
        const { GoogleAuth } = await import("google-auth-library")
        const auth = new GoogleAuth({ scopes: ["https://www.googleapis.com/auth/cloud-platform"] })
        const client = await auth.getClient()
        const token = await client.getAccessToken()
        const headers = new Headers(init?.headers)
        headers.set("Authorization", `Bearer ${token.token}`)
        return fetch(input, { ...init, headers })
      },
    },
    async getModel(sdk, modelID) { return sdk.languageModel(String(modelID).trim()) },
  }
})

Vertex ADC 认证 :通过自定义 fetch 函数注入 Google Auth Library 的 Bearer token,支持多种 ADC 来源(环境变量、gcloud 登录、metadata server)。

3.7 Auth 在 resolveSDK 中的使用

ts 复制代码
// 1687:1757:opencode/packages/opencode/src/provider/provider.ts
async function resolveSDK(model: Model, s: State, envs: Record<string, string | undefined>) {
  const provider = s.providers[model.providerID]
  const options = { ...provider.options }

  // baseURL 模板替换(${KEY} → env 变量)
  const baseURL = iife(() => {
    let url = typeof options["baseURL"] === "string" && options["baseURL"] !== "" ? options["baseURL"] : model.api.url
    if (!url) return
    const loader = s.varsLoaders[model.providerID]
    if (loader) {
      const vars = loader(options)
      for (const [key, value] of Object.entries(vars)) {
        url = url.replaceAll("${" + key + "}", value)
      }
    }
    url = url.replace(/\$\{([^}]+)\}/g, (item, key) => envs[String(key)] ?? item)
    return url
  })

  if (baseURL !== undefined) options["baseURL"] = baseURL
  // ── 注入 API Key ──
  if (options["apiKey"] === undefined && provider.key) options["apiKey"] = provider.key
  if (model.headers) options["headers"] = { ...options["headers"], ...model.headers }

  // ... fetch 包装(chunk timeout / header timeout)
  // ... SDK 加载(bundled import 或 Npm.add 动态安装)
  const bundledLoader = BUNDLED_PROVIDERS[model.api.npm]
  if (bundledLoader) {
    const factory = await bundledLoader()
    const loaded = factory({ name: model.providerID, ...options })
    s.sdk.set(key, loaded)
    return loaded as SDK
  }
  // 动态安装未 bundled 的包
  const installedPath = await Npm.add(model.api.npm)
  const mod = await import(pathToFileURL(installedPath).href)
  const fn = mod[Object.keys(mod).find((key) => key.startsWith("create"))!]
  const loaded = fn({ name: model.providerID, ...options })
  return loaded as SDK
}

SDK 加载两种方式

方式 条件 示例
Bundled import model.api.npmBUNDLED_PROVIDERS @ai-sdk/openai@ai-sdk/anthropic 等 20+ 个预装包
动态安装 其他 Npm.add(model.api.npm) 动态安装 → import() → 找 create* 函数

四、ProviderTransform --- 消息与 Provider Options 转换

ProviderTransformprovider/transform.ts)负责 provider 特定的消息和选项转换,是 AI SDK 调用前的最后适配层。

4.1 消息转换流水线

ts 复制代码
// 430:479:opencode/packages/opencode/src/provider/transform.ts
export function message(msgs: ModelMessage[], model: Provider.Model, options: Record<string, unknown>) {
  msgs = unsupportedParts(msgs, model)       // 步骤 1: 过滤不支持的模态
  msgs = normalizeMessages(msgs, model, options)  // 步骤 2: provider 特定规范化

  // 步骤 3: Anthropic/Claude 应用 prompt caching
  if (isAnthropicFamily(model)) {
    msgs = applyCaching(msgs, model)
  }

  // 步骤 4: providerOptions key 重映射(providerID → SDK key)
  const key = sdkKey(model.api.npm)
  if (key && key !== model.providerID) {
    msgs = mapProviderOptions(msgs, remap)
  }

  // 步骤 5: 剥离 Responses API 的 itemId(store=false 时)
  if (options.store !== true && key && isResponsesApiProvider(model)) {
    msgs = mapProviderOptions(msgs, stripItemId)
  }

  return msgs
}

4.2 sdkKey --- NPM 包到 providerOptions key 的映射

ts 复制代码
// 30:62:opencode/packages/opencode/src/provider/transform.ts
function sdkKey(npm: string): string | undefined {
  switch (npm) {
    case "@ai-sdk/github-copilot": return "copilot"
    case "@ai-sdk/azure": return "azure"
    case "@ai-sdk/openai": return "openai"
    case "@ai-sdk/amazon-bedrock/mantle": return "openai"          // Mantle 走 OpenAI Responses
    case "@ai-sdk/amazon-bedrock": return "bedrock"
    case "@ai-sdk/anthropic":
    case "@ai-sdk/google-vertex/anthropic": return "anthropic"
    case "@ai-sdk/google-vertex": return "vertex"
    case "@ai-sdk/google": return "google"
    case "@ai-sdk/gateway": return "gateway"
    case "@openrouter/ai-sdk-provider": return "openrouter"
    case "ai-gateway-provider": return "openaiCompatible"        // camelCase 避免 deprecation warning
  }
  return undefined
}

为什么需要 key 映射 :opencode 内部用 providerID(如 github-copilot)存储 providerOptions,但 AI SDK 包期望特定的 key(如 copilot)。message()providerOptions["github-copilot"] 重映射为 providerOptions["copilot"]

4.3 normalizeMessages --- Provider 特定消息规范化

ts 复制代码
// 65:321:opencode/packages/opencode/src/provider/transform.ts
function normalizeMessages(msgs: ModelMessage[], model: Provider.Model, _options): ModelMessage[] {
  // ── 步骤 A: 清理 Unicode 代理对(sanitizeSurrogates)──
  msgs = msgs.map((msg) => {
    switch (msg.role) {
      case "tool": /* sanitize tool result output */
      case "system": msg.content = sanitizeSurrogates(msg.content)
      case "user": /* sanitize text */
      case "assistant": /* sanitize text + reasoning */
    }
  })

  // ── 步骤 B: Anthropic 过滤空内容(Anthropic 拒绝空消息)──
  if (model.api.npm === "@ai-sdk/anthropic") {
    msgs = msgs
      .map((msg) => {
        // 过滤空文本、空推理(除非有 signature/redactedData)
        if (typeof msg.content === "string" && msg.content === "") return undefined
        if (Array.isArray(msg.content)) {
          const filtered = msg.content.filter((part) => {
            if (part.type === "text") return part.text !== ""
            if (part.type === "reasoning") return part.text.trim().length > 0 || part.providerOptions?.anthropic?.signature != null
            return true
          })
          if (filtered.length === 0) return undefined
          return { ...msg, content: filtered }
        }
      })
      .filter((msg): msg is ModelMessage => msg !== undefined && msg.content !== "")
  }

  // ── 步骤 C: Bedrock 同样过滤空内容 ──
  if (model.api.npm === "@ai-sdk/amazon-bedrock") { /* 同上,用 bedrock 命名空间 */ }

  // ── 步骤 D: Claude 系列 scrub toolCallId(只允许 a-zA-Z0-9_-)──
  if (model.api.id.includes("claude")) {
    const scrub = (id: string) => id.replace(/[^a-zA-Z0-9_-]/g, "_")
    msgs = msgs.map((msg) => {
      if (msg.role === "assistant" && Array.isArray(msg.content)) {
        return { ...msg, content: msg.content.map((part) => {
          if (part.type === "tool-call" || part.type === "tool-result") return { ...part, toolCallId: scrub(part.toolCallId) }
          return part
        }) }
      }
      // ...
    })
  }

  // ── 步骤 E: Mistral 系列 scrub toolCallId(只允许字母数字,截断 9 字符)──
  if (isMistral(model)) {
    const scrub = (id: string) => id.replace(/[^a-zA-Z0-9]/g, "").substring(0, 9).padEnd(9, "0")
    // ... 还修复 tool 消息后不能直接跟 user 消息的序列问题
  }

  // ── 步骤 F: DeepSeek 强制所有 assistant 消息有 reasoning ──
  if (model.api.id.toLowerCase().includes("deepseek")) {
    msgs = msgs.map((msg) => {
      if (msg.role !== "assistant") return msg
      if (Array.isArray(msg.content)) {
        if (msg.content.some((part) => part.type === "reasoning")) return msg
        return { ...msg, content: [...msg.content, { type: "reasoning", text: "" }] }
      }
      return { ...msg, content: [...(msg.content ? [{ type: "text", text: msg.content }] : []), { type: "reasoning", text: "" }] }
    })
  }

  // ── 步骤 G: 交错推理模型(interleaved.field)──
  if (typeof model.capabilities.interleaved === "object" && model.capabilities.interleaved.field) {
    // 把 reasoning parts 合并为 providerOptions[openaiCompatible][field]
    return msgs.map((msg) => {
      if (msg.role === "assistant" && Array.isArray(msg.content)) {
        const reasoningParts = msg.content.filter((part) => part.type === "reasoning")
        const reasoningText = reasoningParts.map((part) => part.text).join("")
        const filteredContent = msg.content.filter((part) => part.type !== "reasoning")
        return { ...msg, content: filteredContent, providerOptions: { ...msg.providerOptions, openaiCompatible: { ...msg.providerOptions?.openaiCompatible, [field]: reasoningText } } }
      }
      return msg
    })
  }

  return msgs
}

normalizeMessages 的 7 步处理

步骤 Provider 处理
A 所有 清理 Unicode 代理对
B Anthropic 过滤空文本/空推理(保留有 signature 的)
C Bedrock 同 B,用 bedrock 命名空间
D Claude scrub toolCallId([^a-zA-Z0-9_-]_
E Mistral scrub toolCallId(截断 9 字符)+ 修复消息序列
F DeepSeek 强制 assistant 消息有 reasoning part
G 交错推理 reasoning → providerOptions[openaiCompatible][field]

4.4 applyCaching --- Prompt 缓存

ts 复制代码
// 323:372:opencode/packages/opencode/src/provider/transform.ts
function applyCaching(msgs: ModelMessage[], model: Provider.Model): ModelMessage[] {
  const system = msgs.filter((msg) => msg.role === "system").slice(0, 2)  // 前 2 条 system
  const final = msgs.filter((msg) => msg.role !== "system").slice(-2)    // 最后 2 条非 system

  const providerOptions = {
    anthropic: { cacheControl: { type: "ephemeral" } },
    openrouter: { cacheControl: { type: "ephemeral" } },
    bedrock: { cachePoint: { type: "default" } },
    openaiCompatible: { cache_control: { type: "ephemeral" } },
    copilot: { copilot_cache_control: { type: "ephemeral" } },
    alibaba: { cacheControl: { type: "ephemeral" } },
  }

  for (const msg of unique([...system, ...final])) {
    // Anthropic/Bedrock 用 message-level cacheControl
    // 其他用 content-level providerOptions(最后一个 part)
    // ...
  }
  return msgs
}

缓存策略 :对前 2 条 system 消息和最后 2 条非 system 消息添加 cacheControl: { type: "ephemeral" }。不同 provider 用不同的 key 名:

Provider cacheControl key
Anthropic anthropic.cacheControl { type: "ephemeral" }
OpenRouter openrouter.cacheControl { type: "ephemeral" }
Bedrock bedrock.cachePoint { type: "default" }
OpenAI-Compatible openaiCompatible.cache_control { type: "ephemeral" }
Copilot copilot.copilot_cache_control { type: "ephemeral" }
Alibaba alibaba.cacheControl { type: "ephemeral" }

4.5 options --- 默认 Provider Options

ts 复制代码
// 1086:1253:opencode/packages/opencode/src/provider/transform.ts
export function options(input: { model: Provider.Model; sessionID: string; providerOptions? }): Record<string, any> {
  const result: Record<string, any> = {}

  // ── Anthropic: 禁用 tool streaming(非 Claude 模型)──
  if (isAnthropicNonClaude(input.model)) result["toolStreaming"] = false

  // ── OpenAI: store=false(禁用服务端存储)──
  if (isOpenAI(input.model)) result["store"] = false

  // ── Azure: store=false + promptCacheKey=sessionID ──
  if (input.model.api.npm === "@ai-sdk/azure") { result["store"] = false; result["promptCacheKey"] = input.sessionID }

  // ── OpenRouter: usage.include=true ──
  if (isRouter(input.model)) { result["usage"] = { include: true } }

  // ── OpenAI: promptCacheKey=sessionID ──
  if (shouldSetCacheKey(input)) result["promptCacheKey"] = input.sessionID

  // ── GPT-5: reasoningEffort=medium + reasoningSummary=auto + include=encrypted ──
  if (isGPT5(input.model)) {
    if (!isGPT5Pro(input.model)) {
      result["reasoningEffort"] = "medium"
      if (isOpenAIOrCopilot(input.model)) result["reasoningSummary"] = "auto"
      if (isOpenAIOrMantle(input.model)) result["include"] = INCLUDE_ENCRYPTED_REASONING
    }
    if (isGPT5NonChatNonCodex(input.model)) result["textVerbosity"] = "low"
    if (isOpencode(input.model)) { result["promptCacheKey"] = input.sessionID; result["include"] = INCLUDE_ENCRYPTED_REASONING; result["reasoningSummary"] = "auto" }
  }

  // ── Google: thinkingConfig.includeThoughts=true ──
  if (isGoogle(input.model) && input.model.capabilities.reasoning) {
    result["thinkingConfig"] = { includeThoughts: true }
    if (isGemini3(input.model)) result["thinkingConfig"]["thinkingLevel"] = "high"
  }

  // ── Anthropic SDK + Kimi: 启用 thinking ──
  if (isAnthropicKimi(input.model)) {
    result["thinking"] = { type: "enabled", budgetTokens: Math.min(16_000, Math.floor(input.model.limit.output / 2 - 1)) }
  }

  // ── Alibaba-CN (DashScope): enable_thinking=true ──
  if (isAlibabaCNReasoning(input.model)) result["enable_thinking"] = true

  return result
}

INCLUDE_ENCRYPTED_REASONING

ts 复制代码
// 22:23:opencode/packages/opencode/src/provider/transform.ts
const INCLUDE_ENCRYPTED_REASONING = ["reasoning.encrypted_content"] as const

OpenAI Responses API 的 include 参数,返回加密的推理状态,支持 stateless 多轮推理(store: false)。所有使用 OpenAI Responses API 的 provider(OpenAI、Azure、Copilot、Bedrock Mantle)都设置此值。

4.6 variants --- 推理变体生成

ts 复制代码
// 673:1084:opencode/packages/opencode/src/provider/transform.ts
export function variants(model: Provider.Model): Record<string, Record<string, any>> {
  if (!model.capabilities.reasoning) return {}

  // ── Minimax M3: none/thinking toggle ──
  if (isMinimaxM3(model)) return { none: { thinking: { type: "disabled" } }, thinking: { thinking: { type: "adaptive" } } }

  // ── Anthropic adaptive: low/medium/high/xhigh/max ──
  if (adaptiveEfforts) {
    return Object.fromEntries(adaptiveEfforts.map((effort) => [
      effort,
      { thinking: { type: "adaptive", ...(adaptiveThinkingOmitted ? { display: "summarized" } : {}) }, effort },
    ]))
  }

  switch (model.api.npm) {
    case "@ai-sdk/openai":
    case "@ai-sdk/amazon-bedrock/mantle":
      return Object.fromEntries(openaiReasoningEfforts(model.api.id, model.release_date).map((effort) => [
        effort,
        { reasoningEffort: effort, reasoningSummary: "auto", include: INCLUDE_ENCRYPTED_REASONING },
      ]))

    case "@ai-sdk/github-copilot":
      // Copilot: reasoningEffort + reasoningSummary + include(Gemini 除外)
      if (model.id.includes("gemini")) return {}
      if (model.id.includes("claude")) return Object.fromEntries(WIDELY_SUPPORTED_EFFORTS.map((effort) => [effort, { reasoningEffort: effort }]))
      return Object.fromEntries(copilotEfforts.map((effort) => [effort, { reasoningEffort: effort, reasoningSummary: "auto", include: INCLUDE_ENCRYPTED_REASONING }]))

    case "@ai-sdk/anthropic":
      // adaptive: low/medium/high/xhigh/max(4.7+)
      // 4.5: effort
      // <4.5: high/max with budgetTokens
      // ...

    case "@ai-sdk/google":
    case "@ai-sdk/google-vertex":
      return googleThinkingVariants(model)  // thinkingLevel 或 thinkingBudget

    case "@ai-sdk/amazon-bedrock":
      // adaptive: reasoningConfig.type="adaptive"
      // Anthropic: reasoningConfig.type="enabled" + budgetTokens
      // Nova: reasoningConfig.type="enabled" + maxReasoningEffort
      // ...
  }
  return {}
}

变体生成的 provider 分支(截取关键分支):

NPM 包 变体格式 示例
@ai-sdk/openai { reasoningEffort, reasoningSummary, include } medium{ reasoningEffort: "medium", reasoningSummary: "auto", include: ["reasoning.encrypted_content"] }
@ai-sdk/github-copilot 同上(Gemini 除外) Claude → { reasoningEffort: "high" }
@ai-sdk/anthropic (adaptive) { thinking: { type: "adaptive" }, effort } high{ thinking: { type: "adaptive", display: "summarized" }, effort: "high" }
@ai-sdk/anthropic (<4.5) { thinking: { type: "enabled", budgetTokens } } high{ thinking: { type: "enabled", budgetTokens: 16000 } }
@ai-sdk/google { thinkingConfig: { includeThoughts, thinkingLevel } } high{ thinkingConfig: { includeThoughts: true, thinkingLevel: "high" } }
@ai-sdk/amazon-bedrock { reasoningConfig: { type, ... } } high{ reasoningConfig: { type: "adaptive", maxReasoningEffort: "high" } }

4.7 schema --- JSON Schema 兼容性修正

ts 复制代码
// 1439:1579:opencode/packages/opencode/src/provider/transform.ts
export function schema(model: Provider.Model, schema: JSONSchema7): JSONSchema7 {
  // ── OpenAI/Azure: sanitize schema(简化 JSON Schema)──
  if (model.api.npm === "@ai-sdk/openai" || model.api.npm === "@ai-sdk/azure") {
    schema = sanitizeOpenAISchema(schema) as JSONSchema7
    // 过滤不支持的关键字、推断缺失的 type、简化 $ref/composition
  }

  // ── Moonshot/Kimi: sanitize schema ──
  if (model.providerID === "moonshotai" || model.api.id.toLowerCase().includes("kimi")) {
    // $ref 节点不允许 sibling keywords
    // tuple-style items → 单一 items schema
  }

  // ── Google/Gemini: sanitize schema ──
  if (model.providerID === "google" || model.api.id.includes("gemini")) {
    // integer enum → string enum
    // type array → anyOf + nullable
    // 过滤 required 中不存在的字段
    // 非 object type 删除 properties/required
  }

  return schema
}

4.8 reasoningVariants --- 从 models.dev 的 reasoning_options 生成变体

ts 复制代码
// 1581:1597:opencode/packages/opencode/src/provider/transform.ts
export function reasoningVariants(model: ModelsDev.Model, target: Provider.Model): Provider.Model["variants"] {
  const options = model.reasoning_options
  if (options === undefined) return              // models.dev 没有声明 → 用 hardcoded variants()
  if (options.length === 0) return {}

  const effort = options.find((option) => option.type === "effort")
  if (effort) return nonEmptyVariants(effortVariants(target, effort.values))   // ["low", "medium", "high", null→"none"]

  const toggle = options.some((option) => option.type === "toggle")
  const budget = options.find((option) => option.type === "budget_tokens")
  if (!budget) return toggle ? nonEmptyVariants(reasoningToggle(target)) : undefined

  return nonEmptyVariants({
    ...(toggle ? reasoningToggle(target) : {}),
    ...budgetVariants(target, budget.min, budget.max),    // high: min(16000, max), max: min(max, limit.output-1)
  })
}

变体来源优先级

ts 复制代码
// 1250:opencode/packages/opencode/src/provider/provider.ts
const variants =
  ProviderTransform.reasoningVariants(model, base)   // 优先级 1: models.dev 的 reasoning_options
  ?? ProviderTransform.variants(base)               // 优先级 2: hardcoded provider-specific 逻辑

五、SessionRunnerModel.resolve --- V2 模型解析

5.1 resolve 入口

ts 复制代码
// 188:214:opencode/packages/core/src/session/runner/model.ts
export const locationLayer = Layer.effect(
  Service,
  Effect.gen(function* () {
    const catalog = yield* Catalog.Service
    const integrations = yield* Integration.Service
    return Service.of({
      resolve: Effect.fn("SessionRunnerModel.resolve")(function* (session) {
        // ── 步骤 1: 确定 model ──
        // session 有 model → 从 available 中查找
        // session 无 model → 用 catalog 默认模型或第一个 available
        const defaultModel = session.model ? undefined : yield* catalog.model.default()
        const selected = session.model
          ? (yield* catalog.model.available()).find(
              (model) => model.providerID === session.model?.providerID && model.id === session.model.id,
            )
          : defaultModel && supported(defaultModel)
            ? defaultModel
            : (yield* catalog.model.available()).find(supported)

        // ── 步骤 2: 错误处理 ──
        if (!selected && session.model)
          return yield* new ModelUnavailableError({ providerID: session.model.providerID, modelID: session.model.id })
        if (!selected) return yield* new ModelNotSelectedError({ sessionID: session.id })

        // ── 步骤 3: 获取 provider 和 integration connection ──
        const provider = yield* catalog.provider.get(selected.providerID)
        const connection = yield* integrations.connection.active(
          provider?.integrationID ?? Integration.ID.make(selected.providerID),
        )

        // ── 步骤 4: 解析为可执行 Model ──
        return yield* resolve(
          session,
          selected,
          connection ? yield* integrations.connection.resolve(connection) : undefined,
        )
      }),
    })
  }),
)

resolve 的三步解析

  1. 模型选择session.model 有值 → 从 available 中查找;无值 → 用默认模型或第一个 available
  2. 认证解析 :通过 integrations.connection.active() 获取 Integration connection(OAuth 等)
  3. Model 构建resolve(session, selected, credential)withVariantfromCatalogModel

5.2 withVariant --- 变体覆盖

ts 复制代码
// 104:126:opencode/packages/core/src/session/runner/model.ts
const withVariant = (
  model: ModelV2.Info,
  variantID: ModelV2.VariantID | undefined,
): Effect.Effect<ModelV2.Info, VariantUnavailableError> => {
  const id = variantID === "default" || variantID === undefined ? model.request.variant : variantID
  const variant = model.variants.find((item) => item.id === id)
  if (!variant && variantID !== undefined && variantID !== "default")
    return Effect.fail(new VariantUnavailableError({ providerID: model.providerID, modelID: model.id, variant: variantID }))

  return Effect.succeed(
    variant
      ? produce(model, (draft) => {
          Object.assign(draft.request.headers, variant.headers)    // 合并 variant headers
          Object.assign(draft.request.body, variant.body)          // 合并 variant body
        })
      : model,
  )
}

变体覆盖逻辑 :使用 immer 的 produce 不可变更新,把 variant 的 headersbody 合并到 model 的 request 中。variantID === "default" 时使用 model 的默认 variant。

5.3 fromCatalogModel --- 转换为 LLM Route

ts 复制代码
// 131:170:opencode/packages/core/src/session/runner/model.ts
export const fromCatalogModel = (
  model: ModelV2.Info,
  credential?: Credential.Value,
): Effect.Effect<Model, UnsupportedApiError> => {
  // ── 注入 credential metadata 到 request body ──
  const resolved =
    credential?.type !== "key" || credential.metadata === undefined
      ? model
      : produce(model, (draft) => { Object.assign(draft.request.body, credential.metadata) })

  const key = apiKey(resolved, credential)    // 提取 API key

  // ── 按 AI SDK 包名路由到不同的 LLM protocol ──
  if (resolved.api.type === "aisdk" && resolved.api.package === "@ai-sdk/openai") {
    return Effect.succeed(
      withDefaults(resolved, OpenAIResponses.route)         // OpenAI Responses API
        .with({ auth: key === undefined ? Auth.none : Auth.bearer(key) })
        .model({ id: resolved.api.id }),
    )
  }
  if (resolved.api.type === "aisdk" && resolved.api.package === "@ai-sdk/anthropic") {
    return Effect.succeed(
      withDefaults(resolved, AnthropicMessages.route)      // Anthropic Messages API
        .with({ auth: key === undefined ? Auth.none : Auth.header("x-api-key", key) })
        .model({ id: resolved.api.id }),
    )
  }
  if (resolved.api.type === "aisdk" && resolved.api.package === "@ai-sdk/openai-compatible" && resolved.api.url) {
    return Effect.succeed(
      withDefaults(resolved, OpenAICompatibleChat.route)    // OpenAI-compatible Chat API
        .with({ auth: key === undefined ? Auth.none : Auth.bearer(key) })
        .model({ id: resolved.api.id }),
    )
  }

  return Effect.fail(new UnsupportedApiError({ providerID: resolved.providerID, modelID: resolved.id, api: apiName(resolved) }))
}

三种 LLM Protocol 路由

AI SDK 包 LLM Protocol Auth 方式
@ai-sdk/openai OpenAIResponses.route Auth.bearer(key)
@ai-sdk/anthropic AnthropicMessages.route Auth.header("x-api-key", key)
@ai-sdk/openai-compatible OpenAICompatibleChat.route Auth.bearer(key)

5.4 apiKey --- 凭证到 Auth value 的转换

ts 复制代码
// 83:88:opencode/packages/core/src/session/runner/model.ts
const apiKey = (model: ModelV2.Info, credential?: Credential.Value) => {
  if (credential?.type === "key") return Auth.value(credential.key)       // Integration key credential
  if (credential?.type === "oauth") return Auth.value(credential.access)   // OAuth access token
  const value = model.request.body.apiKey ?? model.api.settings?.apiKey     // Model 内嵌的 API key
  if (typeof value === "string") return Auth.value(value)
}

5.5 withDefaults --- Route 配置

ts 复制代码
// 90:102:opencode/packages/core/src/session/runner/model.ts
const withDefaults = (model: ModelV2.Info, route: AnyRoute) => {
  const body = model.request.body
  // 剥离 apiKey(已通过 Auth.value 注入)
  const httpBody = Object.hasOwn(body, "apiKey")
    ? Object.fromEntries(Object.entries(body).filter(([key]) => key !== "apiKey"))
    : body
  return route.with({
    provider: model.providerID,
    endpoint: model.api.url === undefined ? undefined : { baseURL: model.api.url },
    headers: model.request.headers,
    http: { body: httpBody },
    limits: { context: model.limit.context, output: model.limit.output },
  })
}

5.6 supported --- 判断模型是否可路由

ts 复制代码
// 175:179:opencode/packages/core/src/session/runner/model.ts
export const supported = (model: ModelV2.Info) =>
  model.api.type === "aisdk" &&
  (model.api.package === "@ai-sdk/openai" ||
    model.api.package === "@ai-sdk/anthropic" ||
    (model.api.package === "@ai-sdk/openai-compatible" && model.api.url !== undefined))

只有支持三种 LLM protocol 之一的模型才被认为 "supported"。其他模型(如 native 类型 API)返回 UnsupportedApiError

六、catalog.ts --- Correction 优先级管理

6.1 Catalog Service 接口

ts 复制代码
// 47:60:opencode/packages/core/src/catalog.ts
export interface Interface extends State.Transformable<Draft> {
  readonly provider: {
    readonly get: (providerID: ProviderV2.ID) => Effect.Effect<ProviderV2.Info | undefined>
    readonly all: () => Effect.Effect<ProviderV2.Info[]>
    readonly available: () => Effect.Effect<ProviderV2.Info[]>
  }
  readonly model: {
    readonly get: (providerID: ProviderV2.ID, modelID: ModelV2.ID) => Effect.Effect<ModelV2.Info | undefined>
    readonly all: () => Effect.Effect<ModelV2.Info[]>
    readonly available: () => Effect.Effect<ModelV2.Info[]>
    readonly default: () => Effect.Effect<ModelV2.Info | undefined>
    readonly small: (providerID: ProviderV2.ID) => Effect.Effect<ModelV2.Info | undefined>
  }
}

6.2 State.Transformable --- 增量更新

ts 复制代码
// 105:170:opencode/packages/core/src/catalog.ts
const state = State.create<Data, Draft>({
  initial: () => ({ providers: new Map() }),

  draft: (draft) => ({
    provider: {
      list: () => Array.fromIterable(draft.providers.values()),
      get: (providerID) => draft.providers.get(providerID),
      update: (providerID, fn) => {
        let current = draft.providers.get(providerID)
        if (!current) {
          current = { provider: ProviderV2.Info.empty(providerID), models: new Map() }
          draft.providers.set(providerID, current)
        }
        fn(current.provider)
        normalizeApi(current.provider)    // baseURL → api.url 规范化
      },
      remove: (providerID) => { draft.providers.delete(providerID) },
    },
    model: {
      get: (providerID, modelID) => draft.providers.get(providerID)?.models.get(modelID),
      update: (providerID, modelID, fn) => { /* 同上,自动创建空 model */ },
      remove: (providerID, modelID) => { draft.providers.get(providerID)?.models.delete(modelID) },
      default: {
        get: () => draft.defaultModel,
        set: (providerID, modelID) => { draft.defaultModel = { providerID, modelID } },
      },
    },
  }),

  finalize: Effect.fn("CatalogV2.finalize")(function* (catalog) {
    // ── Policy 过滤:移除被 deny 的 provider ──
    if (policy.hasStatements()) {
      for (const record of [...catalog.provider.list()]) {
        if ((yield* policy.evaluate("provider.use", record.provider.id, "allow")) === "deny") {
          catalog.provider.remove(record.provider.id)
        }
      }
    }
    yield* events.publish(Event.Updated, {})
  }),
})

6.3 projectModel --- Provider 与 Model 的合并

ts 复制代码
// 78:97:opencode/packages/core/src/catalog.ts
const projectModel = (model: ModelV2.Info, provider: ProviderV2.Info) => {
  const api =
    // ── 情况 1: model 用 native API 且无自定义 url/settings → 继承 provider 的 api ──
    model.api.type === "native" && !model.api.url && Object.keys(model.api.settings).length === 0
      ? { ...provider.api, id: model.api.id }
      // ── 情况 2: model 用 aisdk 且 provider 也用 aisdk 且 model 无 url → 继承 provider url ──
      : model.api.type === "aisdk" && provider.api.type === "aisdk" && !model.api.url
        ? { ...model.api, url: provider.api.url, settings: { ...provider.api.settings, ...model.api.settings } }
        // ── 情况 3: model 用 aisdk 且 provider 也用 aisdk → 合并 settings ──
        : model.api.type === "aisdk" && provider.api.type === "aisdk"
          ? { ...model.api, settings: { ...provider.api.settings, ...model.api.settings } }
          // ── 情况 4: 用 model 自己的 api ──
          : model.api

  const request = {
    headers: { ...provider.request.headers, ...model.request.headers },   // provider + model headers
    body: { ...provider.request.body, ...model.request.body },             // provider + model body
    variant: model.request.variant,
  }

  return ModelV2.Info.make({ ...model, api, request })
}

projectModel 的合并策略

条件 API 合并 说明
model native + 无 url/settings 继承 provider api model 完全委托给 provider
model aisdk + provider aisdk + 无 url 继承 provider url + 合并 settings model 用 provider 的 endpoint
model aisdk + provider aisdk 合并 settings model 有自己的 url,但合并 provider 的 settings
其他 用 model api model 独立配置

request 合并provider.headers + model.headersprovider.body + model.body,model 优先。

6.4 available --- 可用性判断

ts 复制代码
// 71:76:opencode/packages/core/src/catalog.ts
const available = (provider: ProviderV2.Info, integration: Integration.Info | undefined) => {
  if (provider.disabled) return false                                          // 被禁用
  if (typeof provider.request.body.apiKey === "string") return true            // 有 API key
  if (integration?.connections.length) return true                             // 有 integration 连接
  return provider.integrationID === undefined && !integration                  // 无 integration 且无 key
}

provider 可用性:有 API key 或有 Integration 连接(OAuth 等),或完全没有 integration(纯 public provider)。

6.5 small --- 小模型选择

ts 复制代码
// 234:286:opencode/packages/core/src/catalog.ts
small: Effect.fn("CatalogV2.model.small")(function* (providerID) {
  const record = state.get().providers.get(providerID)
  if (!record) return
  const provider = record.provider

  // ── Azure: 不支持(TODO 等 model syncing 可靠)──
  if (providerID === ProviderV2.ID.azure) return

  // ── Opencode provider: 优先 gpt-5-nano ──
  if (providerID === ProviderV2.ID.opencode) {
    const gpt5Nano = record.models.get(ModelV2.ID.make("gpt-5-nano"))
    if (gpt5Nano?.enabled && gpt5Nano.status === "active") return projectModel(gpt5Nano, provider)
  }

  // ── 其他: 按成本*0.8 + 年龄*0.2 排序 ──
  const candidates = pipe(
    Array.fromIterable(record.models.values()),
    Array.filter((model) =>
      model.providerID === providerID &&
      model.enabled &&
      model.status === "active" &&
      model.capabilities.input.some((item) => item.startsWith("text")) &&
      model.capabilities.output.some((item) => item.startsWith("text")),
    ),
    Array.map((model) => ({
      model,
      cost: model.cost[0] ? model.cost[0].input + model.cost[0].output : 999,
      age: (Date.now() - model.time.released) / (1000 * 60 * 60 * 24 * 30),  // 月数
      small: SMALL_MODEL_RE.test(`${model.id} ${model.family ?? ""} ${model.name}`.toLowerCase()),
    })),
    Array.filter((item) => item.cost > 0 && item.age <= 18),   // 18 个月以内
  )

  const pick = (items) => {
    const maxCost = Math.max(...items.map((item) => item.cost), 0.01)
    const maxAge = Math.max(...items.map((item) => item.age), 0.01)
    return pipe(
      items,
      Array.sortWith((item) => (item.cost / maxCost) * 0.8 + (item.age / maxAge) * 0.2, Order.Number),  // 成本权重 80%,年龄 20%
      Array.map((item) => projectModel(item.model, provider)),
      Array.head,
    )
  }

  return Option.getOrUndefined(
    pipe(
      candidates,
      Array.filter((item) => item.small),    // 优先 small 命名的模型
      (items) => (items.length > 0 ? pick(items) : pick(candidates)),
    ),
  )
})

小模型选择算法

  1. 过滤:enabled + active + text input/output
  2. 计算成本(input + output price)和年龄(月)
  3. 过滤掉 18 个月以上的模型
  4. 优先匹配 nano|flash|lite|mini|haiku|small|fast 的模型
  5. 排序:(cost/maxCost) * 0.8 + (age/maxAge) * 0.2(成本权重 80%,新度权重 20%)
  6. 取第一个

6.6 Provider.ts 的初始化流程 --- 四层叠加

ts 复制代码
// 1333:1657:opencode/packages/opencode/src/provider/provider.ts
{
  const bridge = yield* EffectBridge.make()
  const cfg = yield* config.get()
  const modelsDev = yield* modelsDevSvc.get()

  // ── Layer 1: models.dev snapshot → catalog ──
  const catalog = mapValues(modelsDev, fromModelsDevProvider)
  const database = mapValues(catalog, toPublicInfo)

  const providers: Record<ProviderV2.ID, Info> = {}
  const languages = new Map<string, LanguageModelV3>()
  const sdk = new Map<string, BundledSDK>()

  const mergeProvider = (providerID, provider) => {
    const existing = providers[providerID]
    if (existing) {
      providers[providerID] = mergeDeep(existing, provider)    // 深合并
      return
    }
    const match = database[providerID]
    if (!match) return
    providers[providerID] = mergeDeep(match, provider)         // 与 models.dev 基线合并
  }

  // ── Layer 2: Plugin provider correction ──
  const plugins = yield* plugin.list()
  for (const hook of plugins) {
    const p = hook.provider
    if (!p?.models) continue
    const provider = database[ProviderV2.ID.make(p.id)]
    if (!provider) continue
    provider.models = yield* Effect.promise(async () => {
      const next = await models(toPublicInfo(provider), { auth: pluginAuth })
      return Object.fromEntries(Object.entries(next).map(([id, model]) => [{ ...model, id: ModelV2.ID.make(id), providerID }]))
    })
  }

  // ── Layer 3: Config override ──
  for (const [providerID, provider] of Object.entries(cfg.provider ?? {})) {
    const existing = database[providerID]
    const parsed: Info = {
      id: ProviderV2.ID.make(providerID),
      name: provider.name ?? existing?.name ?? providerID,
      env: provider.env ?? existing?.env ?? [],
      options: mergeDeep(existing?.options ?? {}, provider.options ?? {}),
      source: "config",
      models: existing?.models ?? {},
    }
    for (const [modelID, model] of Object.entries(provider.models ?? {})) {
      // 每个 model 字段都 fallback 到 existingModel(models.dev 数据)
      const parsedModel: Model = {
        id: ModelV2.ID.make(modelID),
        api: { id: apiID, npm: apiNpm, url: ... },  // model.provider.npm ?? provider.npm ?? existingModel.api.npm ?? modelsDev[providerID].npm
        capabilities: { ...existingModel?.capabilities, ...model.capabilities },  // fallback 到 existing
        cost: { ...existingModel?.cost, ...model.cost },
        limit: { ...existingModel?.limit, ...model.limit },
        // ...
      }
      parsed.models[modelID] = parsedModel
    }
    database[providerID] = parsed
  }

  // ── 加载 env 和 auth(触发 autoload)──
  // ... env API keys → mergeProvider(source: "env")
  // ... auth store API keys → mergeProvider(source: "api")
  // ... plugin auth loaders → mergeProvider(source: "custom", options)

  // ── Layer 4: Custom provider loaders(Bedrock/Copilot/Azure/Vertex 等)──
  for (const [id, fn] of Object.entries(custom(dep))) {
    const data = database[providerID]
    if (!data) continue
    const result = yield* fn(data)    // 执行 custom loader
    if (result && (result.autoload || providers[providerID])) {
      if (result.getModel) modelLoaders[providerID] = result.getModel
      if (result.vars) varsLoaders[providerID] = result.vars
      mergeProvider(providerID, { source: "custom", options: result.options ?? {} })
    }
  }

  // ── 重新应用 config(确保 config 优先级最高)──
  for (const [id, provider] of configProviders) {
    mergeProvider(providerID, { source: "config", ...partial })
  }

  // ── 后处理:过滤 alpha/deprecated/blacklist/whitelist,设置 variants ──
  for (const [id, provider] of Object.entries(providers)) {
    if (!isProviderAllowed(providerID)) { delete providers[providerID]; continue }
    for (const [modelID, model] of Object.entries(provider.models)) {
      if (model.status === "alpha" && !runtimeFlags.enableExperimentalModels) delete provider.models[modelID]
      if (model.status === "deprecated") delete provider.models[modelID]
      if (configProvider?.blacklist?.includes(modelID)) delete provider.models[modelID]
      if (configProvider?.whitelist && !configProvider.whitelist.includes(modelID)) delete provider.models[modelID]
      if (model.variants === undefined) model.variants = mapValues(ProviderTransform.variants(model), (v) => v)
      // 合并 config variants
      const configVariants = configProvider?.models?.[modelID]?.variants
      if (configVariants && model.variants) {
        const merged = mergeDeep(model.variants, configVariants)
        model.variants = mapValues(pickBy(merged, (v) => !v.disabled), (v) => omit(v, ["disabled"]))
      }
    }
    if (Object.keys(provider.models).length === 0) delete providers[providerID]
  }

  return { models: languages, providers, catalog, sdk, modelLoaders, varsLoaders }
}

6.7 四层叠加优先级总结

bash 复制代码
Layer 1: models.dev snapshot
    │ fromModelsDevProvider()
    │ → capabilities / limits / pricing / modalities / api.npm / api.url
    ▼
Layer 2: Plugin provider correction
    │ plugin.provider.models(provider, auth)
    │ → 替换整个 models 列表(如 Copilot OAuth 发现可用模型)
    ▼
Layer 3: Config override (opencode.json)
    │ mergeDeep(existing, configModel)
    │ → 覆盖/新增 model 字段(每个字段 fallback 到 existing)
    │ → blacklist/whitelist 过滤
    │ → variants 合并
    ▼
Layer 4: Call override (session.model + variant)
    │ SessionRunnerModel.resolve(session)
    │ → withVariant(model, session.model.variant) → 合并 variant body/headers
    │ → fromCatalogModel(model, credential) → LLM Route
    ▼
    可执行的 Model(LLM Route + Auth + Endpoint + Headers + Body)

七、完整模型解析流程图

bash 复制代码
                          ┌───────────────────────────────┐
                          │      models.dev API           │
                          │  https://models.dev/api.json  │
                          └───────────────┬───────────────┘
                                          │ fetch + cache (5min TTL)
                                          ▼
                    ┌─────────────────────────────────────────┐
                    │    Layer 1: models.dev snapshot         │
                    │    fromModelsDevProvider()               │
                    │    → Provider.Info + Model[]             │
                    │    → capabilities / limits / pricing     │
                    └───────────────┬─────────────────────────┘
                                    │
                    ┌───────────────▼─────────────────────────┐
                    │    Layer 2: Plugin correction            │
                    │    plugin.provider.models()               │
                    │    → 替换 models 列表                    │
                    └───────────────┬─────────────────────────┘
                                    │
                    ┌───────────────▼─────────────────────────┐
                    │    Layer 3: Config override             │
                    │    cfg.provider[].models[]               │
                    │    → mergeDeep + fallback to existing    │
                    │    → blacklist/whitelist                 │
                    │    → variants 合并                       │
                    └───────────────┬─────────────────────────┘
                                    │
                    ┌───────────────▼─────────────────────────┐
                    │    Auth + Custom Loader                  │
                    │    env API key / auth store / plugin     │
                    │    Bedrock SigV4 / Copilot OAuth /       │
                    │    Vertex ADC / Azure resource           │
                    └───────────────┬─────────────────────────┘
                                    │
                    ┌───────────────▼─────────────────────────┐
                    │    Provider.Service (V1)                 │
                    │    getProvider / getModel / getLanguage  │
                    │    → resolveSDK() → SDK + LanguageModel   │
                    └───────────────┬─────────────────────────┘
                                    │
          ┌─────────────────────────┼─────────────────────────┐
          │                         │                         │
          ▼                         ▼                         ▼
┌─────────────────┐    ┌─────────────────────┐    ┌─────────────────────┐
│  V1: AI SDK     │    │  V2: Catalog       │    │  V2: SessionRunner   │
│  streamText()   │    │  projectModel()    │    │  Model.resolve()    │
│                 │    │  → available()     │    │                     │
│ ProviderTransform│   │  → default()      │    │  Layer 4: Call override│
│  message()      │    │  → small()        │    │  session.model + variant│
│  options()      │    └─────────────────────┘    │                     │
│  variants()     │                                │  withVariant()      │
│  schema()      │                                │  → merge variant body│
└─────────────────┘                                │                     │
                                                   │  fromCatalogModel() │
                                                   │  → LLM Route + Auth │
                                                   │  → OpenAIResponses  │
                                                   │  / AnthropicMessages│
                                                   │  / OpenAICompatible │
                                                   └─────────────────────┘

八、关键设计决策

1. "models.dev 作为统一基线,四层叠加覆盖"

models.dev 提供所有 provider 的模型元数据基线(capabilities、limits、pricing)。Plugin correction、Config override、Call override 四层按优先级叠加覆盖。每一层用 mergeDeep 深合并,确保上层覆盖下层,未覆盖的字段 fallback 到下层。这让用户可以在 config 中只覆盖部分字段(如只设 options.region),其余字段继承 models.dev 数据。

2. "三级 models.dev 数据源 + 跨进程文件锁"

models.dev 数据有三个来源(磁盘缓存→编译时快照→远程拉取),确保离线可用、启动快速、数据新鲜。Flock 跨进程文件锁防止并发进程重复拉取。原子写入(temp file → rename)保证缓存文件不会损坏。后台 60 分钟定时刷新保持数据新鲜。

3. "Auth 作为 Provider 的 options 而非独立层"

Auth 不作为独立的认证层,而是注入到 Provider 的 options.apiKeyoptions.fetch(自定义 fetch 注入 token)。这让 AI SDK 包直接消费认证信息,无需额外适配层。Bedrock 用 credentialProvider 注入 SigV4 签名,Vertex 用自定义 fetch 注入 ADC token,Copilot 通过 provider.key 注入 OAuth access token。

4. "ProviderTransform 作为 AI SDK 调用前的统一适配层"

所有 provider 特定的差异(空消息过滤、toolCallId scrub、prompt caching、reasoning variants、JSON schema 修正)集中在 ProviderTransform 中处理。message() 处理消息规范化,options() 处理默认参数,variants() 处理推理变体,schema() 处理工具 schema 兼容性。这让 llm.tsstreamText() 调用无需关心 provider 差异。

5. "SessionRunnerModel.resolve 作为 V2 的运行时解析入口"

V2 不使用 V1 的 Provider.getLanguage(),而是通过 SessionRunnerModel.resolve(session) 从 session 的 model+variant 配置解析出 @opencode-ai/llmModel(包含 LLM Route + Auth + Endpoint)。withVariant 用 immer 合并 variant body/headers,fromCatalogModel 按 AI SDK 包名路由到三种 LLM protocol(OpenAIResponses / AnthropicMessages / OpenAICompatibleChat)。

6. "sdkKey 映射解决 providerID 与 SDK key 不一致"

opencode 用 providerID(如 github-copilot)作为内部标识和 providerOptions 的 key,但 AI SDK 包期望特定的 key(如 copilot)。sdkKey() 函数做这个映射,message() 在发送前把 providerOptions["github-copilot"] 重映射为 providerOptions["copilot"]。Azure 特殊处理:同时传 openaiazure 两个 key(因为 Chat 模型读 openai,Responses 模型读 azure)。

7. "reasoning variants 双源生成"

推理变体有两个来源:models.dev 的 reasoning_options(effort/toggle/budget_tokens 类型)和 ProviderTransform.variants() 的 hardcoded provider 逻辑。reasoningVariants() 优先使用 models.dev 声明的选项,回退到 hardcoded 逻辑。这让 models.dev 可以声明新模型的 reasoning 选项,而 hardcoded 逻辑处理已知 provider 的特殊需求。

8. "Catalog projectModel 合并 Provider 和 Model 的 API 配置"

projectModel 合并 Provider 级和 Model 级的 API 配置。native API 的 model 可以继承 provider 的 api(url/settings),aisdk API 的 model 可以继承 provider 的 url 和 settings。这让 Provider 级配置(如 baseURL、headers)自动应用到所有 model,而 Model 级配置可以覆盖特定字段。

相关推荐
SelectDB技术团队1 小时前
Agent 可观测性:Apache Doris / SelectDB 的技术能力、选型对比与实践
数据库·人工智能·agent·可观测·ai-native·apache doris·selectdb
拾起_3691 小时前
10-Plugin 系统与 Skill 发现:扩展边界与上下文注入
人工智能·开源
阿虎儿1 小时前
Dify v1.15.0 Chatflow附件上传存储机制调研报告
人工智能
zhiSiBuYu05171 小时前
RAG 性能优化与缓存策略实战指南
人工智能·python·机器学习
大鱼>1 小时前
智能喂食器远程控制系统:硬件设计与软件实现
人工智能·iot·宠物
苏州邦恩精密1 小时前
浙江蔡司3D扫描仪应用领域及厂家选择分析
人工智能·科技·3d·自动化·制造
想会飞的蒲公英2 小时前
集成学习入门:Bagging、Boosting 到底在组合什么
人工智能·python·机器学习·集成学习·boosting
海外数字观察家2 小时前
品未云:博兹瓦纳华商批发零售一体化 ERP,破解本土软件适配难题|CSDN 技术分享
大数据·网络·人工智能·博兹瓦纳进销存系统·博兹瓦纳收银系统·博兹瓦纳erp系统·博兹瓦纳仓库管理系统
_老码2 小时前
AI-reader阅读助手开发过程
人工智能·python·ai