chore(core): checkpoint model capability defaults

This commit is contained in:
Aiden Cline 2026-07-07 21:19:14 -05:00
commit 0030028ebc
14 changed files with 380 additions and 28 deletions

View file

@ -530,9 +530,17 @@ describe("Config", () => {
options: { apiKey: "secret" },
models: {
model: {
attachment: true,
options: { reasoningEffort: "high" },
variants: { fast: { temperature: 0.2 } },
},
text: {
attachment: false,
},
audio: {
attachment: true,
modalities: { input: ["audio"], output: ["audio"] },
},
},
},
openai: {
@ -608,9 +616,16 @@ describe("Config", () => {
request: { body: { apiKey: "secret" } },
models: {
model: {
capabilities: { tools: false, input: ["text", "image"], output: ["text"] },
request: { body: { reasoningEffort: "high" } },
variants: [{ id: "fast", body: { temperature: 0.2 } }],
},
text: {
capabilities: { tools: false, input: ["text"], output: ["text"] },
},
audio: {
capabilities: { tools: false, input: ["audio"], output: ["audio"] },
},
},
})
expect(documents[0]?.info.providers?.openai).toMatchObject({

View file

@ -239,6 +239,7 @@ describe("ConfigProviderPlugin.Plugin", () => {
const provider = required(yield* catalog.provider.get(providerID))
const model = required(yield* catalog.model.get(providerID, modelID))
const defaultModel = required(yield* catalog.model.get(providerID, ModelV2.ID.make("default")))
expect((yield* catalog.model.default())?.id).toBe(ModelV2.ID.make("default"))
expect(provider.name).toBe("Renamed")
expect((yield* integrations.get(Integration.ID.make("custom")))?.methods).toContainEqual({
@ -252,6 +253,7 @@ describe("ConfigProviderPlugin.Plugin", () => {
expect(model.api.id).toBe(ModelV2.ID.make("api-chat"))
expect(model.name).toBe("Last")
expect(model.capabilities).toEqual({ tools: true, input: ["text"], output: ["text"] })
expect(defaultModel.capabilities).toEqual({ tools: false, input: ["text", "image"], output: ["text"] })
expect(model.enabled).toBe(false)
expect(model.limit).toEqual({ context: 100, output: 75 })
expect(model.cost).toEqual([{ input: 1, output: 2, cache: { read: 0, write: 0 }, tier: undefined }])

View file

@ -1,5 +1,5 @@
import { describe, expect, beforeAll, beforeEach, afterAll } from "bun:test"
import { Effect, Layer, Ref } from "effect"
import { Effect, Layer, Ref, Schema } from "effect"
import { HttpClient, HttpClientResponse } from "effect/unstable/http"
import { AppNodeBuilder } from "@opencode-ai/core/effect/app-node-builder"
import { LayerNodePlatform } from "@opencode-ai/core/effect/app-node-platform"
@ -127,6 +127,21 @@ const initialState: MockState = {
}
describe("ModelsDev Service", () => {
it.effect("allows models.dev entries without legacy attachment metadata", () =>
Effect.sync(() => {
const result = Schema.decodeUnknownSync(ModelsDev.Model)({
id: "no-attachment-model",
name: "No Attachment Model",
release_date: "2026-01-01",
reasoning: false,
temperature: true,
tool_call: true,
limit: { context: 128000, output: 8192 },
})
expect(result.attachment).toBeUndefined()
}),
)
it.live("get() returns providers from disk when cache file exists", () =>
Effect.gen(function* () {
yield* writeCache(fixture)

View file

@ -76,6 +76,26 @@ describe("ModelsDevPlugin", () => {
},
},
},
default: {
id: "default",
name: "Default",
release_date: "2026-01-01",
reasoning: false,
temperature: true,
tool_call: false,
limit: { context: 128_000, output: 8_192 },
},
explicit: {
id: "explicit",
name: "Explicit",
release_date: "2026-01-01",
attachment: true,
reasoning: false,
temperature: true,
tool_call: false,
modalities: { input: ["audio"], output: ["audio"] },
limit: { context: 128_000, output: 8_192 },
},
},
},
} satisfies Record<string, ModelsDev.Provider>),
@ -92,9 +112,14 @@ describe("ModelsDevPlugin", () => {
const providerID = ProviderV2.ID.make("acme")
const base = yield* catalog.model.get(providerID, ModelV2.ID.make("gpt-5.4"))
const fast = yield* catalog.model.get(providerID, ModelV2.ID.make("gpt-5.4-fast"))
const defaults = yield* catalog.model.get(providerID, ModelV2.ID.make("default"))
const explicit = yield* catalog.model.get(providerID, ModelV2.ID.make("explicit"))
expect(base?.variants).toEqual([])
expect(base?.request.body).toEqual({})
expect(base?.capabilities).toEqual({ tools: true, input: ["text"], output: ["text"] })
expect(defaults?.capabilities).toEqual({ tools: false, input: ["text", "image"], output: ["text"] })
expect(explicit?.capabilities).toEqual({ tools: false, input: ["audio"], output: ["audio"] })
expect(fast).toMatchObject({
id: "gpt-5.4-fast",
providerID: "acme",
@ -159,6 +184,9 @@ describe("ModelsDevPlugin", () => {
connections: [],
}),
])
expect(yield* catalog.model.get(ProviderV2.ID.opencode, ModelV2.ID.make("gpt-5.5"))).toMatchObject({
capabilities: { tools: true, input: ["text", "image"], output: ["text"] },
})
}).pipe(Effect.provide(AppNodeBuilder.build(ModelsDev.node))),
(previous) =>
Effect.sync(() => {

View file

@ -139,6 +139,145 @@ Recent work
])
})
test("defaults missing model capabilities to text and image input", () => {
const messages = toLLMMessages(
[
SessionMessage.User.make({
id: id("user-local-image"),
type: "user",
text: "Inspect this image",
files: [
FileAttachment.make({
uri: "data:image/png;base64,AAECAw==",
mime: "image/png",
name: "image.png",
}),
FileAttachment.make({
uri: "data:application/pdf;base64,JVBERg==",
mime: "application/pdf",
name: "document.pdf",
}),
],
time: { created },
}),
],
model,
)
expect(messages[0]?.content).toEqual([
{ type: "text", text: "Inspect this image" },
{ type: "media", mediaType: "image/png", data: "data:image/png;base64,AAECAw==", filename: "image.png" },
{
type: "text",
text: 'ERROR: Cannot read "document.pdf" (this model does not support pdf input). Inform the user.',
},
])
})
test("uses explicit model input capabilities instead of attachment defaults", () => {
const messages = toLLMMessages(
[
SessionMessage.User.make({
id: id("user-unsupported-pdf"),
type: "user",
text: "Inspect these files",
files: [
FileAttachment.make({
uri: "data:image/png;base64,AAECAw==",
mime: "image/png",
name: "image.png",
}),
FileAttachment.make({
uri: "data:application/pdf;base64,JVBERg==",
mime: "application/pdf",
name: "document.pdf",
}),
],
time: { created },
}),
],
model,
{ tools: true, input: ["text", "pdf"], output: ["text"] },
)
expect(messages[0]?.content).toEqual([
{ type: "text", text: "Inspect these files" },
{
type: "text",
text: 'ERROR: Cannot read "image.png" (this model does not support image input). Inform the user.',
},
{
type: "media",
mediaType: "application/pdf",
data: "data:application/pdf;base64,JVBERg==",
filename: "document.pdf",
},
])
})
test("treats explicit empty input capabilities as authoritative", () => {
const messages = toLLMMessages(
[
SessionMessage.User.make({
id: id("user-empty-capabilities"),
type: "user",
text: "Inspect this image",
files: [
FileAttachment.make({
uri: "data:image/png;base64,AAECAw==",
mime: "image/png",
name: "image.png",
}),
],
time: { created },
}),
],
model,
{ tools: false, input: [], output: [] },
)
expect(messages[0]?.content).toEqual([
{ type: "text", text: "Inspect this image" },
{
type: "text",
text: 'ERROR: Cannot read "image.png" (this model does not support image input). Inform the user.',
},
])
})
test("classifies audio and video MIME families through explicit capabilities", () => {
const messages = toLLMMessages(
[
SessionMessage.User.make({
id: id("user-media-capabilities"),
type: "user",
text: "Inspect this media",
files: [
FileAttachment.make({
uri: "data:audio/mpeg;base64,AAECAw==",
mime: "audio/mpeg",
name: "audio.mp3",
}),
FileAttachment.make({
uri: "data:video/webm;base64,AAECAw==",
mime: "video/webm",
name: "video.webm",
}),
],
time: { created },
}),
],
model,
{ tools: false, input: ["text", "audio", "video"], output: ["text"] },
)
expect(messages[0]?.content).toEqual([
{ type: "text", text: "Inspect this media" },
{ type: "media", mediaType: "audio/mpeg", data: "data:audio/mpeg;base64,AAECAw==", filename: "audio.mp3" },
{ type: "media", mediaType: "video/webm", data: "data:video/webm;base64,AAECAw==", filename: "video.webm" },
])
})
test("replays durable tool media into canonical tool messages without structured base64", () => {
const messages = toLLMMessages(
[

View file

@ -10,6 +10,7 @@ import { EventV2 } from "@opencode-ai/core/event"
import { EventTable } from "@opencode-ai/core/event/sql"
import { PermissionV2 } from "@opencode-ai/core/permission"
import { AgentV2 } from "@opencode-ai/core/agent"
import { Catalog } from "@opencode-ai/core/catalog"
import { Config } from "@opencode-ai/core/config"
import { Project } from "@opencode-ai/core/project"
import { ProjectTable } from "@opencode-ai/core/project/sql"
@ -32,6 +33,7 @@ import { SystemContextRegistry } from "@opencode-ai/core/system-context/registry
import { SystemContext } from "@opencode-ai/core/system-context"
import { SkillGuidance } from "@opencode-ai/core/skill/guidance"
import { ReferenceGuidance } from "@opencode-ai/core/reference/guidance"
import { ModelV2 } from "@opencode-ai/core/model"
import { describe, expect } from "bun:test"
import { eq } from "drizzle-orm"
import { Effect, Layer } from "effect"
@ -67,7 +69,21 @@ const model = OpenAIChat.route
generation: { maxTokens: 20, temperature: 0 },
})
.model({ id: "gpt-4o-mini" })
const models = SessionRunnerModel.layerWith(() => Effect.succeed(model))
const models = SessionRunnerModel.layerWith(() => Effect.succeed(SessionRunnerModel.resolved(model)))
const catalog = Layer.mock(Catalog.Service, {
provider: {
get: () => Effect.die("unused"),
all: () => Effect.die("unused"),
available: () => Effect.die("unused"),
},
model: {
get: (providerID, modelID) => Effect.succeed(ModelV2.Info.empty(providerID, modelID)),
all: () => Effect.die("unused"),
available: () => Effect.die("unused"),
default: () => Effect.die("unused"),
small: () => Effect.die("unused"),
},
})
const systemContext = AppNodeBuilder.build(SystemContextRegistry.node)
const skillGuidance = Layer.mock(SkillGuidance.Service, { load: () => Effect.succeed(SystemContext.empty) })
const referenceGuidance = Layer.mock(ReferenceGuidance.Service, { load: () => Effect.succeed(SystemContext.empty) })
@ -76,6 +92,7 @@ const runnerLayer = AppNodeBuilder.build(SessionRunnerLLM.node, [
[Snapshot.node, Snapshot.noopLayer],
[LayerNodePlatform.llmClient, client],
[SessionRunnerModel.node, models],
[Catalog.node, catalog],
[SystemContextRegistry.node, systemContext],
[Location.node, Location.boundNode({ directory: AbsolutePath.make("/project") })],
[SkillGuidance.node, skillGuidance],
@ -122,6 +139,7 @@ const it = testEffect(
[PermissionV2.node, permission],
[ToolOutputStore.node, ToolOutputStore.nodeWithoutConfig],
[SessionRunnerModel.node, models],
[Catalog.node, catalog],
[SystemContextRegistry.node, systemContext],
[Location.node, Location.boundNode({ directory: AbsolutePath.make("/project") })],
[SkillGuidance.node, skillGuidance],

View file

@ -38,6 +38,7 @@ import { SessionRunnerModel } from "@opencode-ai/core/session/runner/model"
import { ToolRegistry } from "@opencode-ai/core/tool/registry"
import { ApplicationTools } from "@opencode-ai/core/tool/application-tools"
import { AgentV2 } from "@opencode-ai/core/agent"
import { Catalog } from "@opencode-ai/core/catalog"
import { Config } from "@opencode-ai/core/config"
import { ConfigCompaction } from "@opencode-ai/core/config/compaction"
import { Tool } from "@opencode-ai/core/tool/tool"
@ -155,8 +156,33 @@ const echoNode = makeLocationNode({ name: "test/session-runner-tools", layer: ec
let modelResolveHook = Effect.void
let currentModel = model
const models = SessionRunnerModel.layerWith((session) =>
modelResolveHook.pipe(Effect.as(session.model?.id === "replacement" ? replacementModel : currentModel)),
modelResolveHook.pipe(
Effect.as(
SessionRunnerModel.resolved(
session.model?.id === "replacement" ? replacementModel : currentModel,
session.model?.variant,
),
),
),
)
let catalogCapabilities = ModelV2.Capabilities.defaults({ tools: true })
const catalog = Layer.mock(Catalog.Service, {
provider: {
get: () => Effect.die("unused"),
all: () => Effect.die("unused"),
available: () => Effect.die("unused"),
},
model: {
get: (providerID, modelID) =>
Effect.succeed(
ModelV2.Info.make({ ...ModelV2.Info.empty(providerID, modelID), capabilities: catalogCapabilities }),
),
all: () => Effect.die("unused"),
available: () => Effect.die("unused"),
default: () => Effect.die("unused"),
small: () => Effect.die("unused"),
},
})
const systemContextKey = SystemContext.Key.make("test/context")
let systemBaseline = "Initial context"
let systemRemoved = false
@ -229,6 +255,7 @@ const runnerLayer = AppNodeBuilder.build(SessionRunnerLLM.node, [
[Snapshot.node, Snapshot.noopLayer],
[LayerNodePlatform.llmClient, client],
[SessionRunnerModel.node, models],
[Catalog.node, catalog],
[SystemContextRegistry.node, systemContext],
[Location.node, Location.boundNode({ directory: AbsolutePath.make("/project") })],
[SkillGuidance.node, skillGuidance],
@ -278,6 +305,7 @@ const it = testEffect(
[LayerNodePlatform.llmClient, client],
[PermissionV2.node, permission],
[SessionRunnerModel.node, models],
[Catalog.node, catalog],
[SystemContextRegistry.node, systemContext],
[Location.node, Location.boundNode({ directory: AbsolutePath.make("/project") })],
[SkillGuidance.node, skillGuidance],
@ -318,6 +346,7 @@ const setup = Effect.gen(function* () {
systemLoadHook = Effect.void
modelResolveHook = Effect.void
currentModel = model
catalogCapabilities = ModelV2.Capabilities.defaults({ tools: true })
skillBaselines.clear()
responses = undefined
streamFailure = undefined
@ -655,6 +684,22 @@ describe("SessionRunnerLLM", () => {
}),
)
it.effect("does not advertise tools to a model without tool capability", () =>
Effect.gen(function* () {
yield* setup
catalogCapabilities = ModelV2.Capabilities.defaults()
const session = yield* SessionV2.Service
yield* session.prompt({ sessionID, prompt: Prompt.make({ text: "No tools" }), resume: false })
requests.length = 0
response = []
yield* session.resume(sessionID)
expect(requests).toHaveLength(1)
expect(requests[0]?.tools).toEqual([])
}),
)
it.effect("retries the first provider turn after system context becomes available", () =>
Effect.gen(function* () {
yield* setup