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.ts、provider/auth.ts、core/src/catalog.ts、core/src/models-dev.ts、core/src/provider.ts、core/src/model.ts、core/src/session/runner/model.ts、auth/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.npm 在 BUNDLED_PROVIDERS 中 |
@ai-sdk/openai、@ai-sdk/anthropic 等 20+ 个预装包 |
| 动态安装 | 其他 | Npm.add(model.api.npm) 动态安装 → import() → 找 create* 函数 |
四、ProviderTransform --- 消息与 Provider Options 转换
ProviderTransform(provider/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 的三步解析:
- 模型选择 :
session.model有值 → 从 available 中查找;无值 → 用默认模型或第一个 available - 认证解析 :通过
integrations.connection.active()获取 Integration connection(OAuth 等) - Model 构建 :
resolve(session, selected, credential)→withVariant→fromCatalogModel
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 的 headers 和 body 合并到 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.headers、provider.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)),
),
)
})
小模型选择算法:
- 过滤:enabled + active + text input/output
- 计算成本(input + output price)和年龄(月)
- 过滤掉 18 个月以上的模型
- 优先匹配
nano|flash|lite|mini|haiku|small|fast的模型 - 排序:
(cost/maxCost) * 0.8 + (age/maxAge) * 0.2(成本权重 80%,新度权重 20%) - 取第一个
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.apiKey 或 options.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.ts 的 streamText() 调用无需关心 provider 差异。
5. "SessionRunnerModel.resolve 作为 V2 的运行时解析入口"
V2 不使用 V1 的 Provider.getLanguage(),而是通过 SessionRunnerModel.resolve(session) 从 session 的 model+variant 配置解析出 @opencode-ai/llm 的 Model(包含 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 特殊处理:同时传 openai 和 azure 两个 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 级配置可以覆盖特定字段。