Skip to content

config: partial V1 capability overrides reset unspecified catalog capabilities #53280

Closed

Description

@Sinfony8838

Description

A partial legacy provider.<id>.models.<id> capability override resets the fields it omits. migrateModel fills those fields with global defaults before the config provider can inherit the existing catalog values.

For example, overriding only input modalities can turn tools on and replace audio output with text. Native V2 partial overrides already preserve those fields after #51365. #45760 fixed defaults for new models; this is the remaining inheritance case for existing models.

Plugins

None required; reproduced through the core config-provider test fixture.

OpenCode version

V2 source at 0a46301e36d7edd517dd89736acc5daf9c74898e. The same migration code is still present at 30f411945953f6de79a87b3b35e9f706bb2e9839.

Steps to reproduce

  1. Seed the catalog fixture's custom/chat model with { tools: false, input: ["audio"], output: ["audio"] }.
  2. Normalize and apply this legacy config through ConfigProviderPlugin:
    {"provider":{"custom":{"models":{"chat":{"modalities":{"input":["text","image"]}}}}}}
  3. Read the resolved model capabilities.

Expected: { tools: false, input: ["text","image"], output: ["audio"] }.
Actual: { tools: true, input: ["text","image"], output: ["text"] }.

The same problem affects output-only, tool-call-only, and empty-input overrides.

Screenshot and/or share link

Minimal patch and four regression cases. On the source base above, the four regressions failed before the fix; the focused provider/normalization suites passed afterward (59 tests, 413 assertions). This is not a full-suite pass.

The patch leaves omitted fields unset so the existing Model.mergeCapabilities logic handles inheritance and new-model defaults.

Operating System

No response

Terminal

No response

Activity

  1. opencodejr commented on Oct 27, 2026

    @opencodejr
    Contributor

    Thanks for the detailed report and the patch. I can reproduce this on the latest dev (f930aa8a).

    I seeded the catalog model custom/chat with { tools: false, input: ["audio"], output: ["audio"] }, normalized a legacy provider.custom.models.chat override and applied it through ConfigProviderPlugin. Each partial override reset the fields it didn't set to the global defaults:

    • modalities.input: ["text","image"] → { tools: true, input: ["text","image"], output: ["text"] }
    • modalities.output: ["text"] → tools and input reset to true / ["text","image"]
    • tool_call: true → input and output reset to ["text","image"] / ["text"]
    • modalities.input: [] → tools: true, output: ["text"]

    The cause looks like migrateModel in packages/core/src/v1/config/migrate.ts, which fills omitted capability fields with Model.Capabilities.default() before Model.mergeCapabilities can fall back to the catalog values. The test file below reproduces it; place it in packages/core/test/config/ to run it.

    Reproduction script (repro-53280.test.ts)
    import { describe, expect } from "bun:test"
    import { Document, Info, type Entry } from "@opencode/schema/config"
    import { Effect, Schema } from "effect"
    import { Config } from "@opencode/core/config"
    import { ConfigProviderPlugin } from "@opencode/core/config/plugin/provider"
    import { ConfigNormalize } from "@opencode/core/config/normalize"
    import { Model } from "@opencode/core/model"
    import { Plugin } from "@opencode/core/plugin"
    import { PluginHost } from "@opencode/core/plugin/host"
    import { Provider } from "@opencode/core/provider"
    import { testEffect } from "../lib/effect"
    import { PluginTestLayer } from "../plugin/fixture"
    
    // Repro for anomalyco/opencode#53280: partial legacy capability overrides reset catalog capabilities.
    const it = testEffect(PluginTestLayer)
    const decode = Schema.decodeUnknownSync(Info)
    const addPlugin = Effect.fn(function* (entries: Entry[]) {
      const host = yield* PluginHost.make(yield* Plugin.Service)
      yield* ConfigProviderPlugin.Plugin.effect(host).pipe(Effect.provide(Config.testLayer(entries)))
    })
    
    const catalog = { tools: false, input: ["audio"], output: ["audio"] }
    describe("#53280", () => {
      for (const [name, legacy, expected] of [
        ["input-only", { modalities: { input: ["text", "image"] } }, { ...catalog, input: ["text", "image"] }],
        ["output-only", { modalities: { output: ["text"] } }, { ...catalog, output: ["text"] }],
        ["tool_call-only", { tool_call: true }, { ...catalog, tools: true }],
        ["empty input", { modalities: { input: [] } }, { ...catalog, input: [] }],
      ] as const) {
        it.effect(`legacy ${name} override keeps other catalog capabilities`, () =>
          Effect.gen(function* () {
            const providers = yield* Provider.Service
            const models = yield* Model.Service
            const providerID = Provider.ID.make("custom")
            const modelID = Model.ID.make("chat")
            yield* providers.transform((editor) => {
              editor.models.update(providerID, modelID, (model) => {
                model.capabilities = { tools: false, input: ["audio"], output: ["audio"] }
              })
            })
            const result = ConfigNormalize.normalize({ provider: { custom: { models: { chat: legacy } } } })
            if (result.type !== "normalized") throw new Error("Expected normalized config")
            yield* addPlugin([new Document({ type: "document", info: decode(result.encoded) })])
            const caps = (yield* models.get(providerID, modelID))?.capabilities
            console.log(name, JSON.stringify(caps))
            expect(caps).toMatchObject(expected)
          }),
        )
      }
    })

    Run it: bun repro-53280.test.ts

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

Type

No type

Projects

No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions