diff --git a/packages/client/src/promise/generated/types.ts b/packages/client/src/promise/generated/types.ts index 4d51de939f7..ce99934284b 100644 --- a/packages/client/src/promise/generated/types.ts +++ b/packages/client/src/promise/generated/types.ts @@ -2130,7 +2130,7 @@ export type ConfigEntry = settings?: ConfigModelSettings headers?: { [x: string]: string } body?: { [x: string]: JsonValue } - capabilities?: ModelCapabilities + capabilities?: { tools?: boolean; input?: Array; output?: Array } variants?: Array<{ id: string settings?: ConfigModelSettings diff --git a/packages/core/src/config/plugin/provider.ts b/packages/core/src/config/plugin/provider.ts index 41a7f80d4cd..a3f6265b943 100644 --- a/packages/core/src/config/plugin/provider.ts +++ b/packages/core/src/config/plugin/provider.ts @@ -117,13 +117,8 @@ export const Plugin = define({ if (config.settings !== undefined) model.settings = Provider.mergeOverlay(model.settings, config.settings) if (config.headers !== undefined) model.headers = Provider.mergeHeaders(model.headers, config.headers) if (config.body !== undefined) model.body = Provider.mergeOverlay(model.body, config.body) - if (config.capabilities !== undefined) { - model.capabilities = { - tools: config.capabilities.tools, - input: [...config.capabilities.input], - output: [...config.capabilities.output], - } - } + if (config.capabilities !== undefined) + model.capabilities = Model.mergeCapabilities(config.capabilities, model.capabilities) if (config.variants !== undefined) { model.variants ??= [] for (const variant of config.variants) { diff --git a/packages/core/src/model.ts b/packages/core/src/model.ts index 53563d1cc5d..a440578a366 100644 --- a/packages/core/src/model.ts +++ b/packages/core/src/model.ts @@ -28,6 +28,16 @@ export type Compatibility = Model.Compatibility export const Capabilities = Model.Capabilities export type Capabilities = Model.Capabilities +/** Merges partial config capabilities onto a base model's capabilities, defaulting unset fields. */ +export const mergeCapabilities = (config: Partial, base: Capabilities | undefined) => { + const fallback = base ?? Capabilities.default() + return { + tools: config.tools ?? fallback.tools, + input: [...(config.input ?? fallback.input)], + output: [...(config.output ?? fallback.output)], + } +} + export const Cost = Model.Cost export const Ref = Model.Ref diff --git a/packages/core/src/plugin/provider/opencode.ts b/packages/core/src/plugin/provider/opencode.ts index 3621683d257..2f65f8bff4f 100644 --- a/packages/core/src/plugin/provider/opencode.ts +++ b/packages/core/src/plugin/provider/opencode.ts @@ -9,6 +9,7 @@ import { Credential } from "../../credential.js" import { Integration } from "../../integration.js" import { IntegrationConnection } from "../../integration/connection.js" import { ManagedPolicy } from "../../managed-policy.js" +import { Model } from "../../model.js" import { Provider } from "../../provider.js" import { WebSearch } from "../../websearch.js" import { ConfigPolicy } from "@opencode/schema/config/policy" @@ -223,11 +224,7 @@ export const OpencodePlugin = define { ]) }) + test("accepts partial model capabilities without reporting a diagnostic", () => { + const result = normalized({ + providers: { + demo: { name: "Demo", models: { demo: { name: "Demo", capabilities: { input: ["text"], output: ["text"] } } } }, + }, + }) + expect(result.diagnostics).toEqual([]) + expect(result.encoded.providers).toEqual({ + demo: { name: "Demo", models: { demo: { name: "Demo", capabilities: { input: ["text"], output: ["text"] } } } }, + }) + }) + test("uses a valid retired provider alias when the canonical legacy entry is malformed", () => { const result = normalized({ provider: { diff --git a/packages/core/test/config/provider.test.ts b/packages/core/test/config/provider.test.ts index f660bb14ba6..462fd755d76 100644 --- a/packages/core/test/config/provider.test.ts +++ b/packages/core/test/config/provider.test.ts @@ -255,6 +255,70 @@ describe("ConfigProviderPlugin.Plugin", () => { }), ) + it.effect("merges partial config capabilities onto the base model's existing fields", () => + Effect.gen(function* () { + const providers = yield* Provider.Service + const models = yield* Model.Service + const providerID = Provider.ID.make("custom") + const modelID = Model.ID.make("partial") + yield* providers.transform((editor) => { + editor.models.update(providerID, modelID, (model) => { + model.capabilities = { tools: false, input: ["text"], output: ["text"] } + }) + }) + const entries = [ + new Document({ + type: "document", + info: decode({ + providers: { + custom: { + package: "aisdk:@ai-sdk/openai-compatible", + models: { partial: { capabilities: { input: ["text", "image"] } } }, + }, + }, + }), + }), + ] + + yield* addPlugin(entries) + + expect((yield* models.get(providerID, modelID))?.capabilities).toEqual({ + tools: false, + input: ["text", "image"], + output: ["text"], + }) + }), + ) + + it.effect("resolves a config model with only input/output capabilities using default tools", () => + Effect.gen(function* () { + const models = yield* Model.Service + const providerID = Provider.ID.make("custom") + const modelID = Model.ID.make("demo") + const entries = [ + new Document({ + type: "document", + info: decode({ + providers: { + custom: { + package: "aisdk:@ai-sdk/openai-compatible", + models: { demo: { name: "Demo", capabilities: { input: ["text"], output: ["text"] } } }, + }, + }, + }), + }), + ] + + yield* addPlugin(entries) + + expect((yield* models.get(providerID, modelID))?.capabilities).toEqual({ + tools: true, + input: ["text"], + output: ["text"], + }) + }), + ) + for (const scenario of [ { name: "omitted capabilities", legacy: {}, overrides: {} }, { diff --git a/packages/schema/src/config/provider.ts b/packages/schema/src/config/provider.ts index 1ae813582f5..166a4377b5c 100644 --- a/packages/schema/src/config/provider.ts +++ b/packages/schema/src/config/provider.ts @@ -2,7 +2,7 @@ export * as ConfigProvider from "./provider.js" import { Schema } from "effect" import { Money } from "../money.js" -import { Capabilities, Compatibility, Family, ID, VariantID } from "../model.js" +import { Compatibility, Family, ID, VariantID } from "../model.js" import { Provider } from "../provider.js" import { optional } from "../schema.js" @@ -59,6 +59,13 @@ class Cost extends Schema.Class("Config.Model.Cost")({ cache: Cache.pipe(optional), }) {} +// Partial: unset fields fall back to the existing/base model's capabilities or Model.Capabilities.default(). +class ModelCapabilities extends Schema.Class("Config.Model.Capabilities")({ + tools: Schema.Boolean.pipe(optional), + input: Schema.Array(Schema.String).pipe(optional), + output: Schema.Array(Schema.String).pipe(optional), +}) {} + class Limit extends Schema.Class("Config.Model.Limit")({ context: Schema.Int.pipe(optional), input: Schema.Int.pipe(optional), @@ -72,7 +79,7 @@ class Model extends Schema.Class("Config.Model")({ compatibility: Compatibility.pipe(optional), package: Schema.String.pipe(optional), ...ModelOverlays, - capabilities: Capabilities.pipe(optional), + capabilities: ModelCapabilities.pipe(optional), variants: Schema.Struct({ id: VariantID, ...ModelOverlays,