Compare commits

...

1 Commits

Author SHA1 Message Date
Aiden Cline 628e886808 fix(provider): default custom models to image input 2026-06-22 17:08:44 -05:00
8 changed files with 243 additions and 10 deletions
+9 -3
View File
@@ -63,16 +63,22 @@ export const Plugin = define({
const providerPackage = providerApi?.type === "aisdk" ? providerApi.package : undefined
for (const [id, config] of Object.entries(item.models ?? {})) {
const existing = catalog.model.get(providerID, id)
catalog.model.update(providerID, id, (model) => {
if (config.family !== undefined) model.family = config.family
if (config.name !== undefined) model.name = config.name
if (config.api !== undefined) model.api = { ...model.api, ...config.api }
const packageName = model.api.type === "aisdk" ? model.api.package : providerPackage
// TODO: Move these defaults to a dedicated configured-model constructor when one exists.
if (existing === undefined) {
model.capabilities.input = ["text", "image"]
model.capabilities.output = ["text"]
}
if (config.capabilities !== undefined) {
model.capabilities = {
tools: config.capabilities.tools,
input: [...config.capabilities.input],
output: [...config.capabilities.output],
tools: config.capabilities.tools ?? model.capabilities.tools,
input: [...(config.capabilities.input ?? model.capabilities.input)],
output: [...(config.capabilities.output ?? model.capabilities.output)],
}
}
if (config.request !== undefined) {
+7 -1
View File
@@ -44,11 +44,17 @@ const ModelApi = Schema.Union([
}),
])
class Capabilities extends Schema.Class<Capabilities>("ConfigV2.Model.Capabilities")({
tools: ModelV2.Capabilities.fields.tools.pipe(Schema.optional),
input: ModelV2.Capabilities.fields.input.pipe(Schema.optional),
output: ModelV2.Capabilities.fields.output.pipe(Schema.optional),
}) {}
class Model extends Schema.Class<Model>("ConfigV2.Model")({
family: ModelV2.Family.pipe(Schema.optional),
name: Schema.String.pipe(Schema.optional),
api: ModelApi.pipe(Schema.optional),
capabilities: ModelV2.Capabilities.pipe(Schema.optional),
capabilities: Capabilities.pipe(Schema.optional),
request: Schema.Struct({
...Request.fields,
variant: Schema.String.pipe(Schema.optional),
+17 -2
View File
@@ -215,8 +215,23 @@ function migrateModel(info: typeof ConfigProviderV1.Model.Type, packageName?: st
: []),
]
const capabilities =
info.tool_call !== undefined || info.modalities?.input !== undefined || info.modalities?.output !== undefined
? { tools: info.tool_call ?? false, input: info.modalities?.input ?? [], output: info.modalities?.output ?? [] }
info.attachment !== undefined ||
info.tool_call !== undefined ||
info.modalities?.input !== undefined ||
info.modalities?.output !== undefined
? {
...(info.tool_call === undefined ? {} : { tools: info.tool_call }),
...(info.modalities?.input !== undefined
? { input: info.modalities.input }
: info.attachment !== undefined
? { input: info.attachment ? ["text", "image"] : ["text"] }
: {}),
...(info.modalities?.output !== undefined
? { output: info.modalities.output }
: info.attachment !== undefined
? { output: ["text"] }
: {}),
}
: undefined
return {
family: info.family,
+28
View File
@@ -116,6 +116,34 @@ describe("Config", () => {
}),
)
it.effect("migrates v1 custom model capability overrides without replacing unspecified fields", () =>
Effect.sync(() => {
const migrated = ConfigMigrateV1.migrate({
provider: {
custom: {
models: {
default: { tool_call: true },
text: { attachment: false },
explicit: { modalities: { input: ["audio"], output: ["audio"] } },
},
},
},
})
expect(migrated.providers?.custom?.models?.default?.capabilities).toEqual({
tools: true,
})
expect(migrated.providers?.custom?.models?.text?.capabilities).toEqual({
input: ["text"],
output: ["text"],
})
expect(migrated.providers?.custom?.models?.explicit?.capabilities).toEqual({
input: ["audio"],
output: ["audio"],
})
}),
)
it.effect("migrates v1 command configuration", () =>
Effect.sync(() => {
expect(
+67 -1
View File
@@ -3,6 +3,7 @@ import { Effect, Schema } from "effect"
import { Catalog } from "@opencode-ai/core/catalog"
import { Config } from "@opencode-ai/core/config"
import { ConfigProviderPlugin } from "@opencode-ai/core/config/plugin/provider"
import { ConfigMigrateV1 } from "@opencode-ai/core/v1/config/migrate"
import { Integration } from "@opencode-ai/core/integration"
import { ModelV2 } from "@opencode-ai/core/model"
import { PluginV2 } from "@opencode-ai/core/plugin"
@@ -244,7 +245,9 @@ describe("ConfigProviderPlugin.Plugin", () => {
const provider = required(yield* catalog.provider.get(providerID))
const model = required(yield* catalog.model.get(providerID, modelID))
expect((yield* catalog.model.default())?.id).toBe(ModelV2.ID.make("default"))
const defaultModel = required(yield* catalog.model.default())
expect(defaultModel.id).toBe(ModelV2.ID.make("default"))
expect(defaultModel.capabilities).toEqual({ tools: false, input: ["text", "image"], output: ["text"] })
expect(provider.name).toBe("Renamed")
expect((yield* integrations.get(Integration.ID.make("custom")))?.methods).toContainEqual({
type: "env",
@@ -271,4 +274,67 @@ describe("ConfigProviderPlugin.Plugin", () => {
}),
),
)
it.effect("lowers a migrated attachment override into modalities", () =>
Effect.gen(function* () {
const catalog = yield* Catalog.Service
const providerID = ProviderV2.ID.make("custom")
const modelID = ModelV2.ID.make("chat")
yield* catalog.transform((catalog) =>
catalog.model.update(providerID, modelID, (model) => {
model.capabilities = { tools: true, input: ["text", "image", "audio"], output: ["text", "audio"] }
}),
)
const config = Config.Service.of({
entries: () =>
Effect.succeed([
new Config.Document({
type: "document",
info: decode(
ConfigMigrateV1.migrate({
provider: { custom: { models: { chat: { attachment: false } } } },
}),
),
}),
]),
})
yield* addPlugin(config)
expect(required(yield* catalog.model.get(providerID, modelID)).capabilities).toEqual({
tools: true,
input: ["text"],
output: ["text"],
})
}),
)
it.effect("defaults a migrated custom model with attachment disabled to text only", () =>
Effect.gen(function* () {
const catalog = yield* Catalog.Service
const providerID = ProviderV2.ID.make("custom")
const modelID = ModelV2.ID.make("chat")
const config = Config.Service.of({
entries: () =>
Effect.succeed([
new Config.Document({
type: "document",
info: decode(
ConfigMigrateV1.migrate({
provider: { custom: { models: { chat: { attachment: false } } } },
}),
),
}),
]),
})
yield* addPlugin(config)
expect(required(yield* catalog.model.get(providerID, modelID)).capabilities).toEqual({
tools: false,
input: ["text"],
output: ["text"],
})
}),
)
})
+8
View File
@@ -21,3 +21,11 @@ describe("ModelV2.Ref", () => {
})
})
})
describe("ModelV2.Info", () => {
test("creates an empty model without modalities", () => {
const model = ModelV2.Info.empty(ProviderV2.ID.make("custom"), ModelV2.ID.make("model"))
expect(model.capabilities).toEqual({ tools: false, input: [], output: [] })
})
})
+5 -2
View File
@@ -1413,12 +1413,15 @@ export const layer = Layer.effect(
capabilities: {
temperature: model.temperature ?? existingModel?.capabilities.temperature ?? false,
reasoning: model.reasoning ?? existingModel?.capabilities.reasoning ?? false,
attachment: model.attachment ?? existingModel?.capabilities.attachment ?? false,
attachment: model.attachment ?? existingModel?.capabilities.attachment ?? true,
toolcall: model.tool_call ?? existingModel?.capabilities.toolcall ?? true,
input: {
text: model.modalities?.input?.includes("text") ?? existingModel?.capabilities.input.text ?? true,
audio: model.modalities?.input?.includes("audio") ?? existingModel?.capabilities.input.audio ?? false,
image: model.modalities?.input?.includes("image") ?? existingModel?.capabilities.input.image ?? false,
image:
model.modalities?.input?.includes("image") ??
existingModel?.capabilities.input.image ??
model.attachment !== false,
video: model.modalities?.input?.includes("video") ?? existingModel?.capabilities.input.video ?? false,
pdf: model.modalities?.input?.includes("pdf") ?? existingModel?.capabilities.input.pdf ?? false,
},
@@ -544,6 +544,46 @@ it.instance(
},
)
it.instance(
"inherited modalities take precedence over attachment false",
Effect.gen(function* () {
yield* set("ANTHROPIC_API_KEY", "test-api-key")
const providers = yield* list
const model = providers[ProviderV2.ID.anthropic].models["claude-sonnet-4-20250514"]
expect(model.capabilities.attachment).toBe(false)
expect(model.capabilities.input.text).toBe(true)
expect(model.capabilities.input.image).toBe(true)
expect(model.capabilities.output.text).toBe(true)
}),
{
config: {
provider: { anthropic: { models: { "claude-sonnet-4-20250514": { attachment: false } } } },
},
},
)
it.instance(
"inherited modalities take precedence over attachment true",
Effect.gen(function* () {
const providers = yield* list
const model = providers[ProviderV2.ID.make("perplexity")].models.sonar
expect(model.capabilities.attachment).toBe(true)
expect(model.capabilities.input.text).toBe(true)
expect(model.capabilities.input.image).toBe(false)
expect(model.capabilities.output.text).toBe(true)
}),
{
config: {
provider: {
perplexity: {
env: [],
models: { sonar: { attachment: true } },
},
},
},
},
)
it.instance(
"disabled_providers prevents loading even with env var",
Effect.gen(function* () {
@@ -589,11 +629,13 @@ it.instance(
)
it.instance(
"model modalities default correctly",
"custom model media capabilities default correctly",
Effect.gen(function* () {
const providers = yield* list
const model = providers[ProviderV2.ID.make("test-provider")].models["test-model"]
expect(model.capabilities.attachment).toBe(true)
expect(model.capabilities.input.text).toBe(true)
expect(model.capabilities.input.image).toBe(true)
expect(model.capabilities.output.text).toBe(true)
}),
{
@@ -611,6 +653,65 @@ it.instance(
},
)
it.instance(
"custom model media capabilities can be disabled explicitly",
Effect.gen(function* () {
const providers = yield* list
const model = providers[ProviderV2.ID.make("test-provider")].models["test-model"]
expect(model.capabilities.attachment).toBe(false)
expect(model.capabilities.input.text).toBe(true)
expect(model.capabilities.input.image).toBe(false)
}),
{
config: {
provider: {
"test-provider": {
name: "Test",
npm: "@ai-sdk/openai-compatible",
env: [],
models: {
"test-model": {
name: "Test Model",
attachment: false,
},
},
},
},
},
},
)
it.instance(
"custom model explicit modalities override media defaults",
Effect.gen(function* () {
const providers = yield* list
const provider = providers[ProviderV2.ID.make("test-provider")]
const audio = provider.models.audio
expect(audio.capabilities.input.text).toBe(false)
expect(audio.capabilities.input.audio).toBe(true)
expect(audio.capabilities.input.image).toBe(false)
expect(audio.capabilities.output.audio).toBe(true)
const empty = provider.models.empty
expect(Object.values(empty.capabilities.input).some(Boolean)).toBe(false)
expect(Object.values(empty.capabilities.output).some(Boolean)).toBe(false)
}),
{
config: {
provider: {
"test-provider": {
name: "Test",
npm: "@ai-sdk/openai-compatible",
env: [],
models: {
audio: { modalities: { input: ["audio"], output: ["audio"] } },
empty: { modalities: { input: [], output: [] } },
},
},
},
},
},
)
it.instance(
"model with custom cost values",
Effect.gen(function* () {