Compare commits

...

3 Commits

Author SHA1 Message Date
Kit Langton 658008fc54 fix(core): rename legacy provider in top-level model 2026-09-28 13:32:29 -03:00
Kit Langton cd49956028 refactor(core): simplify v1 config migration cleanup 2026-09-28 13:31:25 -03:00
Kit Langton fa9277de20 refactor(core): remove duplicate v1 config migration 2026-09-28 12:37:30 -03:00
12 changed files with 101 additions and 206 deletions
+18 -13
View File
@@ -10,6 +10,7 @@ import { ConfigCompaction } from "@opencode/schema/config/compaction"
import { ConfigFormatter } from "@opencode/schema/config/formatter"
import { ConfigLSP } from "@opencode/schema/config/lsp"
import { ConfigMedia } from "@opencode/schema/config/media"
import { ConfigModel } from "@opencode/schema/config/model"
import { ConfigMCP } from "@opencode/schema/config/mcp"
import { ConfigPlugin } from "@opencode/schema/config/plugin"
import { ConfigPolicy } from "@opencode/schema/config/policy"
@@ -18,13 +19,11 @@ import { ConfigReference } from "@opencode/schema/config/reference"
import { ConfigExperimental } from "@opencode/schema/config/experimental"
import { Permission } from "@opencode/schema/permission"
import { ConfigAgentV1 } from "../v1/config/agent.js"
import { ConfigAttachmentV1 } from "../v1/config/attachment.js"
import { ConfigCommandV1 } from "../v1/config/command.js"
import { ConfigMCPV1 } from "../v1/config/mcp.js"
import { ConfigPermissionV1 } from "../v1/config/permission.js"
import { ConfigPluginV1 } from "../v1/config/plugin.js"
import { ConfigProviderV1 } from "../v1/config/provider.js"
import { ConfigV1 } from "../v1/config/config.js"
import { ConfigMigrateV1 } from "../v1/config/migrate.js"
import { PositiveInt } from "../schema.js"
@@ -53,6 +52,7 @@ const unsupportedExperimental = [
] as const
const unsupportedProvider = ["id", "whitelist", "blacklist"] as const
const unsupportedModel = ["release_date", "attachment", "reasoning", "temperature", "experimental"] as const
const LegacyAutoupdate = Schema.Literals([true, false, "notify"])
export function normalize(input: unknown): Result {
if (!isRecord(input))
@@ -71,7 +71,7 @@ export function normalize(input: unknown): Result {
? decodeEncoded(Schema.Boolean, input.snapshot, ["snapshot"], diagnostics)
: undefined
const legacyUpdate = own(input, "autoupdate")
? decodeValue(ConfigV1.Info.fields.autoupdate, input.autoupdate, ["autoupdate"], diagnostics)
? decodeValue(LegacyAutoupdate, input.autoupdate, ["autoupdate"], diagnostics)
: undefined
const nativeUpdate = own(input, "update")
? decodeEncoded(Info.fields.update, input.update, ["update"], diagnostics)
@@ -82,15 +82,18 @@ export function normalize(input: unknown): Result {
: undefined
: undefined
const legacyMedia = own(input, "attachment")
? decodeValue(ConfigAttachmentV1.Info, input.attachment, ["attachment"], diagnostics)
? decodeEncoded(ConfigMedia.Info, input.attachment, ["attachment"], diagnostics)
: undefined
if (legacyMedia !== undefined) {
const migrated = ConfigMigrateV1.migrate({ attachment: legacyMedia }).media
if (migrated !== undefined) encoded.media = canonical(ConfigMedia.Info, migrated)
}
if (legacyMedia !== undefined) encoded.media = legacyMedia
if (legacySnapshots !== undefined) encoded.snapshots = legacySnapshots
const migratedUpdate =
legacyUpdate === undefined ? undefined : ConfigMigrateV1.migrate({ autoupdate: legacyUpdate }).update
legacyUpdate === false
? "disable"
: legacyUpdate === "notify"
? "notify"
: legacyUpdate === true
? "auto"
: undefined
const update = prefer(migratedUpdate, nativeUpdate, ["update"], diagnostics)
if (update !== undefined) encoded.update = update
if (legacyShare !== undefined) encoded.share = legacyShare
@@ -135,9 +138,7 @@ export function normalize(input: unknown): Result {
const legacySmallModel = own(input, "small_model")
? decodeValue(Schema.String, input.small_model, ["small_model"], diagnostics)
: undefined
const migratedSmallModel = legacySmallModel
? ConfigMigrateV1.migrate({ small_model: legacySmallModel }).agents?.title?.model
: undefined
const migratedSmallModel = ConfigMigrateV1.modelSelection(legacySmallModel)
if (legacySmallModel && !migratedSmallModel)
diagnostics.push({
kind: "unsupported",
@@ -197,10 +198,14 @@ export function normalize(input: unknown): Result {
normalizeFormatter(input, encoded, diagnostics)
normalizeLsp(input, encoded, diagnostics)
const model = own(input, "model")
? decodeValue(ConfigModel.Selection, input.model, ["model"], diagnostics)
: undefined
if (model) encoded.model = { ...model, providerID: ConfigMigrateV1.providerID(model.providerID) }
const nativeAtomic = {
$schema: Info.fields.$schema,
shell: Info.fields.shell,
model: Info.fields.model,
default_agent: Info.fields.default_agent,
share: Info.fields.share,
enterprise: Info.fields.enterprise,
+7 -135
View File
@@ -1,9 +1,7 @@
export * as ConfigMigrateV1 from "./migrate.js"
import { Info } from "@opencode/schema/config"
import { ConfigAgent } from "@opencode/schema/config/agent"
import { Schema } from "effect"
import { ConfigV1 } from "./config.js"
import { ConfigAgentV1 } from "./agent.js"
import { ConfigCommandV1 } from "./command.js"
import { ConfigMCPV1 } from "./mcp.js"
@@ -14,102 +12,16 @@ import { Provider } from "../../provider.js"
import { Model } from "../../model.js"
const decodeOptions = { errors: "all", onExcessProperty: "ignore", propertyOrder: "original" } as const
const decodeInfo = Schema.decodeUnknownSync(Schema.fromJsonString(Info), decodeOptions)
const encodeInfo = Schema.encodeSync(Info)
const decodeAgent = Schema.decodeUnknownSync(Schema.fromJsonString(ConfigAgent.Info), decodeOptions)
const encodeAgent = Schema.encodeSync(ConfigAgent.Info)
export function migrate(info: typeof ConfigV1.Info.Type) {
return encodeInfo(
decodeInfo(
JSON.stringify({
$schema: info.$schema,
shell: info.shell,
model: modelSelection(info.model),
default_agent: info.default_agent,
update:
info.autoupdate === false
? "disable"
: info.autoupdate === "notify"
? "notify"
: info.autoupdate === true
? "auto"
: undefined,
share: info.share ?? (info.autoshare ? "auto" : undefined),
enterprise: info.enterprise,
username: info.username,
permissions: permissions(info.permission, info.tools),
agents: agents(info),
snapshots: info.snapshot,
watcher: info.watcher,
formatter: info.formatter,
lsp: info.lsp,
media: info.attachment,
tool_output: info.tool_output,
mcp: mcp(info),
compaction: info.compaction && {
auto: info.compaction.auto,
prune: info.compaction.prune,
keep: {
tokens: info.compaction.preserve_recent_tokens,
},
buffer: info.compaction.reserved,
},
skills: info.skills && [...(info.skills.paths ?? []), ...(info.skills.urls ?? [])],
commands: commands(info.command),
instructions: info.instructions,
references: info.references ?? info.reference,
experimental: experimental(info),
plugins: info.plugin?.map((plugin) =>
typeof plugin === "string" ? plugin : { package: plugin[0], options: plugin[1] },
),
providers: providers(info.provider),
}),
),
)
}
function experimental(info: typeof ConfigV1.Info.Type) {
const policies = [
...(info.enabled_providers === undefined
? []
: [
{ action: "provider.use" as const, resource: "*", effect: "deny" as const },
...info.enabled_providers.map((resource) => ({
action: "provider.use" as const,
resource: providerID(resource),
effect: "allow" as const,
})),
]),
...(info.disabled_providers ?? []).map((resource) => ({
action: "provider.use" as const,
resource: providerID(resource),
effect: "deny" as const,
})),
]
if (info.experimental?.subagent_depth === undefined && !policies.length) return
return {
subagent_depth: info.experimental?.subagent_depth,
policies: policies.length ? policies : undefined,
}
}
function permissions(info?: ConfigPermissionV1.Info, tools?: Readonly<Record<string, boolean>>) {
const rules: Array<{ action: string; resource: string; effect: ConfigPermissionV1.Action }> = Object.entries(
tools ?? {},
).map(([action, enabled]) => ({
action: normalizeAction(action),
resource: "*",
effect: enabled ? ("allow" as const) : ("deny" as const),
}))
for (const [key, rule] of Object.entries(info ?? {})) {
if (!rule) continue
function permissions(info?: ConfigPermissionV1.Info) {
const rules = Object.entries(info ?? {}).flatMap(([key, rule]) => {
if (!rule) return []
const action = normalizeAction(key)
if (typeof rule === "string") {
rules.push({ action, resource: "*", effect: rule })
continue
}
rules.push(...Object.entries(rule).map(([resource, effect]) => ({ action, resource, effect })))
}
if (typeof rule === "string") return [{ action, resource: "*", effect: rule }]
return Object.entries(rule).map(([resource, effect]) => ({ action, resource, effect }))
})
return rules.length ? rules : undefined
}
@@ -121,23 +33,6 @@ export function normalizeAction(action: string) {
return action
}
function agents(info: typeof ConfigV1.Info.Type) {
const entries = [
...Object.entries(info.agent ?? {}),
...Object.entries(info.mode ?? {}).map(([name, agent]) => [name, { ...agent, mode: "primary" as const }] as const),
]
const result = Object.fromEntries(entries.flatMap(([name, agent]) => (agent ? [[name, migrateAgent(agent)]] : [])))
const small = modelSelection(info.small_model)
if (!small) return entries.length ? result : undefined
return {
...result,
title: {
model: small,
...result.title,
},
}
}
export function migrateAgent(info: ConfigAgentV1.Info) {
const body = {
...info.options,
@@ -178,7 +73,7 @@ export function commands(info?: Readonly<Record<string, ConfigCommandV1.Info>>)
)
}
function modelSelection(input?: string, variant?: string) {
export function modelSelection(input?: string, variant?: string) {
if (input === undefined || !/^[^/#]+\/[^#]+$/.test(input)) return undefined
const separator = input.indexOf("/")
return {
@@ -188,17 +83,6 @@ function modelSelection(input?: string, variant?: string) {
}
}
function mcp(info: typeof ConfigV1.Info.Type) {
const servers = Object.fromEntries(
Object.entries(info.mcp ?? {}).flatMap(([name, server]) =>
"type" in server ? [[name, migrateMcp(server)] as const] : [],
),
)
const timeout = info.experimental?.mcp_timeout
if (!timeout && !Object.keys(servers).length) return undefined
return { timeout: timeout === undefined ? undefined : { catalog: timeout, execution: timeout }, servers }
}
export function migrateMcp(info: ConfigMCPV1.Info) {
const disabled = info.enabled === undefined ? undefined : !info.enabled
if (info.type === "local")
@@ -226,18 +110,6 @@ export function migrateMcp(info: ConfigMCPV1.Info) {
}
}
function providers(info?: Readonly<Record<string, ConfigProviderV1.Info>>) {
if (!info) return undefined
return Object.fromEntries(
Object.entries(info).flatMap(([name, provider]) => {
const id = providerID(name)
// If both names are present, keep the settings under the current name and ignore the old one.
if (id !== name && info[id]) return []
return [[id, migrateProvider(name, provider)]]
}),
)
}
export function migrateProvider(sourceID: string, info: ConfigProviderV1.Info) {
if (sourceID === "azure-cognitive-services") return migrateAzureCognitiveServicesProvider(info)
if (sourceID === "google-vertex-anthropic") return migrateGoogleVertexAnthropicProvider(info)
+9 -3
View File
@@ -15,7 +15,7 @@ import { Global } from "@opencode/util/global"
import { Permission } from "@opencode/core/permission"
import { AgentPlugin } from "@opencode/core/plugin/agent"
import { AbsolutePath } from "@opencode/core/schema"
import { ConfigMigrateV1 } from "@opencode/core/v1/config/migrate"
import { ConfigNormalize } from "@opencode/core/config/normalize"
import { ConfigAgentV1 } from "@opencode/core/v1/config/agent"
import { advance, drain } from "../lib/clock"
import { tmpdir, tmpdirScoped } from "../fixture/tmpdir"
@@ -24,6 +24,12 @@ import { agentHost, host } from "../plugin/host"
const it = testEffect(AppNodeBuilder.build(LayerNode.group([Agent.node, Bus.node, FSUtil.node, Global.node])))
const decode = Schema.decodeUnknownSync(Info)
function migrateV1(input: unknown) {
const result = ConfigNormalize.normalize(input)
if (result.type !== "normalized") throw new Error("expected normalized config")
return result.encoded
}
const defaultPermissions = (global: Global.Interface): Permission.Ruleset => [
...Agent.Info.default(Agent.ID.make("test")).permissions,
{ action: "external_directory", resource: path.join(global.data, "shell", "*", "*"), effect: "allow" },
@@ -171,7 +177,7 @@ permissions:
new Document({
type: "document",
info: decode(
ConfigMigrateV1.migrate({
migrateV1({
permission: {
bash: "ask",
edit: "ask",
@@ -752,7 +758,7 @@ function loadHomePermissions(home: string) {
new Document({
type: "document",
info: decode(
ConfigMigrateV1.migrate({
migrateV1({
permission: {
external_directory: {
"~/p/**": "allow",
+30 -24
View File
@@ -11,8 +11,7 @@ import { AppNodeBuilder } from "@opencode/core/effect/app-node-builder"
import { makeGlobalNode } from "@opencode/util/effect/app-node"
import { LayerNode } from "@opencode/util/effect/layer-node"
import { Credential } from "@opencode/core/credential"
import { ConfigMigrateV1 } from "@opencode/core/v1/config/migrate"
import { ConfigV1 } from "@opencode/core/v1/config/config"
import { ConfigV1 } from "../fixture/v1-config/config"
import { ConfigNormalize } from "@opencode/core/config/normalize"
import { Watcher } from "@opencode/core/filesystem/watcher"
import { Bus } from "@opencode/core/bus"
@@ -30,6 +29,18 @@ import { testEffect } from "../lib/effect"
const it = testEffect(Layer.empty)
const selection = Schema.decodeUnknownSync(ConfigModel.Selection)
const decodeInfo = Schema.decodeUnknownSync(Info, {
errors: "all",
onExcessProperty: "ignore",
propertyOrder: "original",
})
const encodeInfo = Schema.encodeSync(Info)
function migrateV1(input: unknown) {
const result = ConfigNormalize.normalize(input)
if (result.type !== "normalized") throw new Error("expected normalized config")
return encodeInfo(decodeInfo(result.encoded))
}
function inFixture(root: string, target: string) {
const relative = path.relative(root, target)
@@ -634,24 +645,19 @@ describe("Config", () => {
test("migrates arbitrary v1 configuration into valid v2 configuration", () => {
FastCheck.assert(
FastCheck.property(Schema.toArbitrary(ConfigV1.Info)(FastCheck), (info) => {
const parsed = Schema.decodeUnknownSync(ConfigV1.Info)(
Schema.decodeUnknownSync(Schema.fromJsonString(Schema.Unknown))(
Schema.encodeUnknownSync(Schema.fromJsonString(Schema.Unknown))(info),
),
)
Schema.decodeUnknownSync(Info)(ConfigMigrateV1.migrate(parsed), { errors: "all" })
migrateV1(JSON.parse(JSON.stringify(info)))
}),
{ numRuns: 100 },
)
}, 30_000)
test("migrates the v1 experimental subagent depth", () => {
expect(ConfigMigrateV1.migrate({ experimental: { subagent_depth: 2 } }).experimental?.subagent_depth).toBe(2)
expect(migrateV1({ experimental: { subagent_depth: 2 } }).experimental?.subagent_depth).toBe(2)
})
test("migrates the v1 small model to the title agent", () => {
expect(
ConfigMigrateV1.migrate({
migrateV1({
small_model: "anthropic/claude-haiku-4-5",
agent: { title: { prompt: "Custom title prompt" } },
}).agents?.title,
@@ -662,10 +668,10 @@ describe("Config", () => {
})
test("migrates the v1 update policy", () => {
expect(ConfigMigrateV1.migrate({ autoupdate: false }).update).toBe("disable")
expect(ConfigMigrateV1.migrate({ autoupdate: "notify" }).update).toBe("notify")
expect(ConfigMigrateV1.migrate({ autoupdate: true }).update).toBe("auto")
expect(ConfigMigrateV1.migrate({}).update).toBeUndefined()
expect(migrateV1({ autoupdate: false }).update).toBe("disable")
expect(migrateV1({ autoupdate: "notify" }).update).toBe("notify")
expect(migrateV1({ autoupdate: true }).update).toBe("auto")
expect(migrateV1({}).update).toBeUndefined()
})
test("normalizes the native auto update policy", () => {
@@ -678,7 +684,7 @@ describe("Config", () => {
test("migrates v1 provider lists to policies", () => {
expect(
ConfigMigrateV1.migrate({
migrateV1({
enabled_providers: ["anthropic", "openai"],
disabled_providers: ["openai"],
}).experimental?.policies,
@@ -688,13 +694,13 @@ describe("Config", () => {
{ action: "provider.use", resource: "openai", effect: "allow" },
{ action: "provider.use", resource: "openai", effect: "deny" },
])
expect(ConfigMigrateV1.migrate({ enabled_providers: [] }).experimental?.policies).toEqual([
expect(migrateV1({ enabled_providers: [] }).experimental?.policies).toEqual([
{ action: "provider.use", resource: "*", effect: "deny" },
])
})
test("migrates v1 provider setup options into AISDK settings", () => {
const migrated = ConfigMigrateV1.migrate({
const migrated = migrateV1({
provider: {
bedrock: {
npm: "@ai-sdk/amazon-bedrock",
@@ -719,7 +725,7 @@ describe("Config", () => {
})
test("renames old provider IDs while migrating v1 configuration", () => {
const migrated = ConfigMigrateV1.migrate({
const migrated = migrateV1({
model: "azure-cognitive-services/deployment",
enabled_providers: ["google-vertex-anthropic"],
disabled_providers: ["azure-cognitive-services"],
@@ -768,7 +774,7 @@ describe("Config", () => {
})
test("preserves the generated base URL for v1 Azure OpenAI-compatible providers", () => {
const migrated = ConfigMigrateV1.migrate({
const migrated = migrateV1({
provider: {
"azure-cognitive-services": {
npm: "@ai-sdk/openai-compatible",
@@ -787,7 +793,7 @@ describe("Config", () => {
})
test("ignores old provider IDs when the current provider ID is configured", () => {
const migrated = ConfigMigrateV1.migrate({
const migrated = migrateV1({
provider: {
azure: { models: { current: {} } },
"azure-cognitive-services": { models: { legacy: {} } },
@@ -801,7 +807,7 @@ describe("Config", () => {
})
test("preserves the built-in package for v1 Vertex Anthropic custom models", () => {
const migrated = ConfigMigrateV1.migrate({
const migrated = migrateV1({
provider: {
"google-vertex-anthropic": {
models: { claude: {} },
@@ -816,7 +822,7 @@ describe("Config", () => {
})
test("migrates v1 interleaved fields to compatibility", () => {
const migrated = ConfigMigrateV1.migrate({
const migrated = migrateV1({
provider: {
custom: {
models: {
@@ -838,7 +844,7 @@ describe("Config", () => {
for (const subtask of [true, false]) {
test(`migrates v1 command configuration with subtask: ${subtask}`, () => {
expect(
ConfigMigrateV1.migrate({
migrateV1({
command: {
review: {
template: "Review changes",
@@ -864,7 +870,7 @@ describe("Config", () => {
test("normalizes renamed permission actions when migrating v1 permissions", () => {
expect(
ConfigMigrateV1.migrate({
migrateV1({
permission: {
task: "ask",
bash: { "git status": "allow", "*": "deny" },
@@ -149,6 +149,21 @@ describe("ConfigNormalize", () => {
expect(() => Schema.decodeUnknownSync(Info)(result.encoded)).not.toThrow()
})
test("renames legacy provider IDs in the top-level model", () => {
expect(normalized({ model: "google-vertex-anthropic/claude#high" }).encoded.model).toEqual({
providerID: "google-vertex",
model: "claude",
variant: "high",
})
expect(
normalized({ model: { providerID: "azure-cognitive-services", model: "deployment" } }).encoded.model,
).toEqual({ providerID: "azure", model: "deployment" })
expect(normalized({ model: "anthropic/claude" }).encoded.model).toEqual({
providerID: "anthropic",
model: "claude",
})
})
test("migrates the legacy small model to the title agent", () => {
const result = normalized({ small_model: "anthropic/claude-haiku-4-5" })
expect(result.encoded.agents).toEqual({
@@ -1,7 +1,7 @@
export * as ConfigAttachmentV1 from "./attachment.js"
export * as ConfigAttachmentV1 from "./attachment"
import { Schema } from "effect"
import { PositiveInt } from "../../schema.js"
import { PositiveInt } from "@opencode/core/schema"
export const Image = Schema.Struct({
auto_resize: Schema.optional(Schema.Boolean).annotate({
@@ -1,27 +1,20 @@
export * as ConfigV1 from "./config.js"
export * as ConfigV1 from "./config"
import { Schema } from "effect"
import { ConfigReference } from "@opencode/schema/config/reference"
import { NonNegativeInt, PositiveInt, type DeepMutable } from "../../schema.js"
import { ConfigAgentV1 } from "./agent.js"
import { ConfigAttachmentV1 } from "./attachment.js"
import { ConfigCommandV1 } from "./command.js"
import { ConfigFormatterV1 } from "./formatter.js"
import { ConfigLayoutV1 } from "./layout.js"
import { ConfigLSPV1 } from "./lsp.js"
import { ConfigMCPV1 } from "./mcp.js"
import { ConfigPermissionV1 } from "./permission.js"
import { ConfigPluginV1 } from "./plugin.js"
import { ConfigProviderV1 } from "./provider.js"
import { ConfigServerV1 } from "./server.js"
import { ConfigSkillsV1 } from "./skills.js"
export type Layout = ConfigLayoutV1.Layout
export const WellKnown = Schema.Struct({
config: Schema.optional(Schema.Json),
remote_config: Schema.optional(Schema.Json),
})
import { NonNegativeInt, PositiveInt } from "@opencode/core/schema"
import { ConfigAgentV1 } from "@opencode/core/v1/config/agent"
import { ConfigAttachmentV1 } from "./attachment"
import { ConfigCommandV1 } from "@opencode/core/v1/config/command"
import { ConfigFormatterV1 } from "./formatter"
import { ConfigLayoutV1 } from "./layout"
import { ConfigLSPV1 } from "./lsp"
import { ConfigMCPV1 } from "@opencode/core/v1/config/mcp"
import { ConfigPermissionV1 } from "@opencode/core/v1/config/permission"
import { ConfigPluginV1 } from "@opencode/core/v1/config/plugin"
import { ConfigProviderV1 } from "@opencode/core/v1/config/provider"
import { ConfigServerV1 } from "./server"
import { ConfigSkillsV1 } from "./skills"
const LogLevelRef = Schema.Literals(["DEBUG", "INFO", "WARN", "ERROR"]).annotate({
identifier: "LogLevel",
@@ -187,5 +180,3 @@ export const Info = Schema.Struct({
}),
),
}).annotate({ identifier: "Config" })
export type Info = DeepMutable<Schema.Schema.Type<typeof Info>>
@@ -1,4 +1,4 @@
export * as ConfigFormatterV1 from "./formatter.js"
export * as ConfigFormatterV1 from "./formatter"
import { Schema } from "effect"
@@ -1,4 +1,4 @@
export * as ConfigLayoutV1 from "./layout.js"
export * as ConfigLayoutV1 from "./layout"
import { Schema } from "effect"
@@ -1,4 +1,4 @@
export * as ConfigLSPV1 from "./lsp.js"
export * as ConfigLSPV1 from "./lsp"
import { Schema } from "effect"
@@ -1,7 +1,7 @@
export * as ConfigServerV1 from "./server.js"
export * as ConfigServerV1 from "./server"
import { Schema } from "effect"
import { PositiveInt } from "../../schema.js"
import { PositiveInt } from "@opencode/core/schema"
export const Server = Schema.Struct({
port: Schema.optional(PositiveInt).annotate({
@@ -1,4 +1,4 @@
export * as ConfigSkillsV1 from "./skills.js"
export * as ConfigSkillsV1 from "./skills"
import { Schema } from "effect"