Quellcode durchsuchen

fix(core): migrate legacy small model (#40966)

Co-authored-by: Aiden Cline <rekram1-node@users.noreply.github.com>
opencode-agent[bot] vor 4 Tagen
Ursprung
Commit
439ed66c7b

+ 18 - 1
packages/core/src/config/normalize.ts

@@ -41,7 +41,7 @@ export type Result =
   | { readonly type: "rejected"; readonly diagnostics: readonly Diagnostic[] }
 
 const options = { errors: "all", onExcessProperty: "ignore", propertyOrder: "original" } as const
-const unsupportedTopLevel = ["logLevel", "server", "small_model", "subagent_depth", "layout"] as const
+const unsupportedTopLevel = ["logLevel", "server", "subagent_depth", "layout"] as const
 const unsupportedExperimental = [
   "disable_paste_summary",
   "batch_tool",
@@ -113,6 +113,23 @@ export function normalize(input: unknown): Result {
   const legacyAgents = mapValues(decodeMap(input.agent, ConfigAgentV1.Info, ["agent"], diagnostics), (value) =>
     canonical(ConfigAgent.Info, ConfigMigrateV1.migrateAgent(value)),
   )
+  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
+  if (legacySmallModel && !migratedSmallModel)
+    diagnostics.push({
+      kind: "unsupported",
+      path: ["small_model"],
+      message: "omitted unsupported legacy model reference",
+    })
+  if (migratedSmallModel)
+    legacyAgents.title = {
+      model: migratedSmallModel,
+      ...legacyAgents.title,
+    }
   const modeAgents = mapValues(decodeMap(input.mode, ConfigAgentV1.Info, ["mode"], diagnostics), (value) =>
     canonical(ConfigAgent.Info, ConfigMigrateV1.migrateAgent({ ...value, mode: "primary" })),
   )

+ 10 - 2
packages/core/src/v1/config/migrate.ts

@@ -119,8 +119,16 @@ function agents(info: typeof ConfigV1.Info.Type) {
     ...Object.entries(info.agent ?? {}),
     ...Object.entries(info.mode ?? {}).map(([name, agent]) => [name, { ...agent, mode: "primary" as const }] as const),
   ]
-  if (!entries.length) return undefined
-  return Object.fromEntries(entries.flatMap(([name, agent]) => (agent ? [[name, migrateAgent(agent)]] : [])))
+  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) {

+ 14 - 0
packages/core/test/config/config.test.ts

@@ -512,6 +512,20 @@ describe("Config", () => {
     }),
   )
 
+  it.effect("migrates the v1 small model to the title agent", () =>
+    Effect.sync(() => {
+      expect(
+        ConfigMigrateV1.migrate({
+          small_model: "anthropic/claude-haiku-4-5",
+          agent: { title: { prompt: "Custom title prompt" } },
+        }).agents?.title,
+      ).toEqual({
+        model: { providerID: "anthropic", model: "claude-haiku-4-5" },
+        system: "Custom title prompt",
+      })
+    }),
+  )
+
   it.effect("migrates v1 provider lists to policies", () =>
     Effect.sync(() => {
       expect(

+ 22 - 2
packages/core/test/config/normalization.test.ts

@@ -149,6 +149,28 @@ describe("ConfigNormalize", () => {
     expect(() => Schema.decodeUnknownSync(Info)(result.encoded)).not.toThrow()
   })
 
+  test("migrates the legacy small model to the title agent", () => {
+    const result = normalized({
+      small_model: "anthropic/claude-haiku-4-5",
+      agent: { title: { prompt: "Custom title prompt" } },
+    })
+    expect(result.encoded.agents).toEqual({
+      title: {
+        model: { providerID: "anthropic", model: "claude-haiku-4-5" },
+        system: "Custom title prompt",
+      },
+    })
+    expect(result.diagnostics).toEqual([])
+  })
+
+  test("omits an invalid legacy small model without exposing its value", () => {
+    const secret = "do-not-log-this-value"
+    const result = normalized({ small_model: secret })
+    expect(result.encoded.agents).toBeUndefined()
+    expect(result.diagnostics.map((item) => [item.kind, item.path])).toEqual([["unsupported", ["small_model"]]])
+    expect(JSON.stringify(result.diagnostics)).not.toContain(secret)
+  })
+
   test("recovers malformed named entries and retains a valid legacy collision", () => {
     const result = normalized({
       command: { fallback: { template: "legacy" } },
@@ -390,7 +412,6 @@ describe("ConfigNormalize", () => {
     const secret = "do-not-log-this-value"
     const result = normalized({
       logLevel: "DEBUG",
-      small_model: secret,
       agent: { reviewer: { name: secret, prompt: "review" } },
       provider: {
         custom: {
@@ -409,7 +430,6 @@ describe("ConfigNormalize", () => {
     })
     expect(result.diagnostics.filter((item) => item.kind === "unsupported").map((item) => item.path)).toEqual([
       ["logLevel"],
-      ["small_model"],
       ["agent", "reviewer", "name"],
       ["provider", "custom", "id"],
       ["provider", "custom", "whitelist"],

+ 2 - 1
packages/www/content/docs/migrate-v1.mdx

@@ -419,6 +419,8 @@ The V1 provider filters do not have one-to-one native V2 config fields, but thei
 
 - `enabled_providers` becomes an internal deny-by-default provider policy followed by allows for the listed providers.
 - `disabled_providers` becomes internal deny policies for the listed providers.
+- `small_model` becomes the `model` selection for the built-in `title` agent. Native V2 configuration should use
+  `agents.title.model` instead.
 
 You may keep these fields in V1 syntax. OpenCode normalizes them without warning.
 
@@ -430,7 +432,6 @@ they are not mistaken for active configuration:
 - `logLevel`: use `OPENCODE_LOG_LEVEL` when starting OpenCode.
 - `server`: use the V2 service and explicit server options; the server API is an intentional breaking change.
 - `layout`: remove it; V1 already treated it as deprecated and always used stretch layout.
-- `small_model`: V2 selects models for internal maintenance agents without a separate top-level field.
 - Top-level `subagent_depth`: use `experimental.subagent_depth` instead.
 - `compaction.tail_turns` and `compaction.prune`: V2 uses `compaction.keep.tokens` and checkpoint-based compaction instead.
 - Agent `name` inside V1 JSON configuration.