From 77fce8b24c9985b0490a00bccfd1e962c61245bc Mon Sep 17 00:00:00 2001 From: Aiden Cline Date: Tue, 7 Jul 2026 21:19:14 -0500 Subject: [PATCH] chore(core): checkpoint model capability defaults --- packages/core/src/models-dev.ts | 2 +- packages/core/src/plugin/models-dev.ts | 9 +- packages/core/src/session/runner/llm.ts | 20 +++- packages/core/src/session/runner/model.ts | 6 +- .../core/src/session/runner/to-llm-message.ts | 33 +++++- packages/core/src/v1/config/migrate.ts | 12 +- packages/core/test/config/config.test.ts | 15 +++ packages/core/test/config/provider.test.ts | 2 + packages/core/test/models.test.ts | 15 +++ packages/core/test/plugin/models-dev.test.ts | 26 +++++ .../core/test/session-runner-message.test.ts | 109 +++++++++++++++++- .../core/test/session-runner-recorded.test.ts | 18 +++ packages/core/test/session-runner.test.ts | 38 ++++++ packages/schema/src/model.ts | 21 +++- 14 files changed, 301 insertions(+), 25 deletions(-) diff --git a/packages/core/src/models-dev.ts b/packages/core/src/models-dev.ts index faeee213fba..1b69d527418 100644 --- a/packages/core/src/models-dev.ts +++ b/packages/core/src/models-dev.ts @@ -65,7 +65,7 @@ export const Model = Schema.Struct({ name: Schema.String, family: Schema.optional(Schema.String), release_date: Schema.String, - attachment: Schema.Boolean, + attachment: Schema.optional(Schema.Boolean), reasoning: Schema.Boolean, reasoning_options: Schema.optional(Schema.Array(ReasoningOption)), temperature: Schema.optional(Schema.Boolean), diff --git a/packages/core/src/plugin/models-dev.ts b/packages/core/src/plugin/models-dev.ts index ef59535e81f..e33ba6e5fcb 100644 --- a/packages/core/src/plugin/models-dev.ts +++ b/packages/core/src/plugin/models-dev.ts @@ -185,11 +185,12 @@ function applyModel( draft.family = model.family ? ModelV2.Family.make(model.family) : undefined draft.package = model.provider?.npm ? ProviderV2.aisdk(model.provider.npm) : undefined draft.settings = model.provider?.api ? { ...draft.settings, baseURL: model.provider.api } : draft.settings - draft.capabilities = { + const capabilities = ModelV2.Capabilities.defaults({ tools: model.tool_call, - input: [...(model.modalities?.input ?? [])], - output: [...(model.modalities?.output ?? [])], - } + input: model.modalities?.input ?? (model.attachment === false ? ["text"] : undefined), + output: model.modalities?.output, + }) + draft.capabilities = { ...capabilities, input: [...capabilities.input], output: [...capabilities.output] } mergeVariants(draft, input.variants ?? []) draft.time.released = released(model.release_date) draft.cost = (input.cost ?? cost(model.cost)).map((item) => ({ diff --git a/packages/core/src/session/runner/llm.ts b/packages/core/src/session/runner/llm.ts index d757b19484e..ea33b515ace 100644 --- a/packages/core/src/session/runner/llm.ts +++ b/packages/core/src/session/runner/llm.ts @@ -14,6 +14,7 @@ import { SessionError } from "@opencode-ai/schema/session-error" import { Money } from "@opencode-ai/schema/money" import { Cause, Effect, Exit, Fiber, FiberSet, Layer, Option, Semaphore, Stream } from "effect" import { AgentV2 } from "../../agent" +import { Catalog } from "../../catalog" import { Config } from "../../config" import { Database } from "../../database/database" import { EventV2 } from "../../event" @@ -137,6 +138,7 @@ const layer = Layer.effect( const tools = yield* ToolRegistry.Service const hooks = yield* PluginHooks.Service const models = yield* SessionRunnerModel.Service + const catalog = yield* Catalog.Service const store = yield* SessionStore.Service const location = yield* Location.Service const builtins = yield* InstructionBuiltIns.Service @@ -234,12 +236,19 @@ const layer = Layer.effect( const resolved = yield* models.resolve(session) const model = resolved.model const providerMetadataKey = model.route.providerMetadataKey ?? model.provider + const catalogModel = yield* catalog.model.get(resolved.ref.providerID, resolved.ref.id) + if (!catalogModel) + return yield* new SessionRunnerModel.ModelUnavailableError({ + providerID: resolved.ref.providerID, + modelID: resolved.ref.id, + }) const entries = yield* SessionHistory.entriesForRunner(db, session.id, checkpoint.baselineSeq) const context = entries.map((entry) => entry.message) const isLastStep = agent.info?.steps !== undefined && currentStep >= agent.info.steps - const toolMaterialization = isLastStep - ? undefined - : yield* tools.materialize({ permissions: agent.info?.permissions, model }) + const toolMaterialization = + isLastStep || !catalogModel.capabilities.tools + ? undefined + : yield* tools.materialize({ permissions: agent.info?.permissions, model }) const promptCacheKey = /^ses_[0-9a-f]{64}$/.test(session.id) ? session.id.slice(4) : session.id const request = LLM.request({ model, @@ -251,7 +260,7 @@ const layer = Layer.effect( .filter((part): part is string => part !== undefined && part.length > 0) .map(SystemPart.make), messages: [ - ...toLLMMessages(context, resolved.ref, providerMetadataKey), + ...toLLMMessages(context, resolved.ref, providerMetadataKey, catalogModel.capabilities), ...(isLastStep ? [Message.assistant(MAX_STEPS_PROMPT)] : []), ], tools: toolMaterialization?.definitions ?? [], @@ -380,7 +389,7 @@ const layer = Layer.effect( ) const stepUsage = (settlement: NonNullable>) => ({ - cost: calculateCost(resolved.cost, settlement.tokens), + cost: calculateCost(catalogModel.cost, settlement.tokens), tokens: settlement.tokens, }) @@ -687,6 +696,7 @@ export const node = makeLocationNode({ ToolRegistry.node, PluginHooks.node, SessionRunnerModel.node, + Catalog.node, SessionStore.node, Location.node, InstructionBuiltIns.node, diff --git a/packages/core/src/session/runner/model.ts b/packages/core/src/session/runner/model.ts index defd95f9c11..6cc0d0845d5 100644 --- a/packages/core/src/session/runner/model.ts +++ b/packages/core/src/session/runner/model.ts @@ -82,8 +82,6 @@ export interface Resolved { readonly model: Model /** Selected catalog identity. Durable records and displays must use this, never the API model id. */ readonly ref: ModelV2.Ref - /** Catalog pricing in dollars per million tokens. */ - readonly cost: ModelV2.Info["cost"] } export interface Interface { @@ -96,14 +94,13 @@ export class Service extends Context.Service()("@opencode/v2 export const layerWith = (resolve: Interface["resolve"]) => Layer.succeed(Service, Service.of({ resolve })) /** Builds a Resolved whose catalog identity mirrors the route model. Test or embedding seam. */ -export const resolved = (model: Model, variant?: ModelV2.VariantID, cost: ModelV2.Info["cost"] = []): Resolved => ({ +export const resolved = (model: Model, variant?: ModelV2.VariantID): Resolved => ({ model, ref: ModelV2.Ref.make({ id: ModelV2.ID.make(model.id), providerID: ProviderV2.ID.make(model.provider), ...(variant === undefined ? {} : { variant }), }), - cost, }) const apiKey = (model: ModelV2.Info, credential?: Credential.Value) => { @@ -344,7 +341,6 @@ const layer = Layer.effect( providerID: selected.providerID, ...(session.model?.variant === undefined ? {} : { variant: session.model.variant }), }), - cost: selected.cost, } }), }) diff --git a/packages/core/src/session/runner/to-llm-message.ts b/packages/core/src/session/runner/to-llm-message.ts index bd750948780..f3c132738ec 100644 --- a/packages/core/src/session/runner/to-llm-message.ts +++ b/packages/core/src/session/runner/to-llm-message.ts @@ -11,8 +11,6 @@ import type { ModelV2 } from "../../model" import { SessionMessage } from "../message" import type { FileAttachment } from "@opencode-ai/schema/prompt" -const imageMimes = new Set(["image/png", "image/jpeg", "image/gif", "image/webp"]) - const media = (file: FileAttachment): ContentPart => ({ type: "media", mediaType: file.mime, @@ -21,6 +19,23 @@ const media = (file: FileAttachment): ContentPart => ({ metadata: file.description === undefined ? undefined : { description: file.description }, }) +const modality = (mime: string) => { + if (mime.startsWith("image/")) return "image" + if (mime.startsWith("audio/")) return "audio" + if (mime.startsWith("video/")) return "video" + if (mime === "application/pdf") return "pdf" + return undefined +} + +const attachment = (file: FileAttachment, capabilities?: ModelV2.Capabilities): ContentPart => { + const type = modality(file.mime) + if (!type || (capabilities?.input ?? ["text", "image"]).includes(type)) return media(file) + return { + type: "text", + text: `ERROR: Cannot read ${file.name ? `"${file.name}"` : type} (this model does not support ${type} input). Inform the user.`, + } +} + const textAttachment = (file: FileAttachment) => Message.make({ role: "user", @@ -168,7 +183,12 @@ const assistant = (message: SessionMessage.Assistant, model: ModelV2.Ref, provid ] } -function toLLMMessage(message: SessionMessage.Info, model: ModelV2.Ref, providerMetadataKey: string): Message[] { +function toLLMMessage( + message: SessionMessage.Info, + model: ModelV2.Ref, + providerMetadataKey: string, + capabilities?: ModelV2.Capabilities, +): Message[] { switch (message.type) { case "agent-switched": case "model-switched": @@ -183,7 +203,9 @@ function toLLMMessage(message: SessionMessage.Info, model: ModelV2.Ref, provider role: "user", content: [ { type: "text", text: message.text }, - ...files.filter((file) => imageMimes.has(file.mime)).map(media), + ...files + .filter((file) => file.mime !== "text/plain" && file.mime !== "application/x-directory") + .map((file) => attachment(file, capabilities)), ], metadata: { ...message.metadata, @@ -236,4 +258,5 @@ export const toLLMMessages = ( messages: readonly SessionMessage.Info[], model: ModelV2.Ref, providerMetadataKey: string = model.providerID, -) => messages.flatMap((message) => toLLMMessage(message, model, providerMetadataKey)) + capabilities?: ModelV2.Capabilities, +) => messages.flatMap((message) => toLLMMessage(message, model, providerMetadataKey, capabilities)) diff --git a/packages/core/src/v1/config/migrate.ts b/packages/core/src/v1/config/migrate.ts index 15b464f7761..67272c1ec31 100644 --- a/packages/core/src/v1/config/migrate.ts +++ b/packages/core/src/v1/config/migrate.ts @@ -7,6 +7,7 @@ import { ConfigMCPV1 } from "./mcp" import { ConfigPermissionV1 } from "./permission" import { ConfigProviderV1 } from "./provider" import { ConfigProviderOptionsV1 } from "./provider-options" +import { ModelV2 } from "../../model" import { ProviderV2 } from "../../provider" const keys = new Set([ @@ -245,8 +246,15 @@ function migrateModel(info: typeof ConfigProviderV1.Model.Type) { : []), ] 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.tool_call !== undefined || + info.attachment !== undefined || + info.modalities?.input !== undefined || + info.modalities?.output !== undefined + ? ModelV2.Capabilities.defaults({ + tools: info.tool_call, + input: info.modalities?.input ?? (info.attachment === false ? ["text"] : undefined), + output: info.modalities?.output, + }) : undefined return { modelID: info.id, diff --git a/packages/core/test/config/config.test.ts b/packages/core/test/config/config.test.ts index 85db0a6bf3b..2a207e2ac31 100644 --- a/packages/core/test/config/config.test.ts +++ b/packages/core/test/config/config.test.ts @@ -680,9 +680,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: { @@ -758,9 +766,16 @@ describe("Config", () => { settings: { apiKey: "secret" }, models: { model: { + capabilities: { tools: false, input: ["text", "image"], output: ["text"] }, settings: { reasoningEffort: "high" }, variants: [{ id: "fast", settings: { 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({ diff --git a/packages/core/test/config/provider.test.ts b/packages/core/test/config/provider.test.ts index 0c7c85a1ca6..06c5505faf9 100644 --- a/packages/core/test/config/provider.test.ts +++ b/packages/core/test/config/provider.test.ts @@ -237,6 +237,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.modelID).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([ diff --git a/packages/core/test/models.test.ts b/packages/core/test/models.test.ts index 9aa1cedc4df..294ca091a6f 100644 --- a/packages/core/test/models.test.ts +++ b/packages/core/test/models.test.ts @@ -165,6 +165,21 @@ 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, + 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) diff --git a/packages/core/test/plugin/models-dev.test.ts b/packages/core/test/plugin/models-dev.test.ts index c336666c865..fcba88331d1 100644 --- a/packages/core/test/plugin/models-dev.test.ts +++ b/packages/core/test/plugin/models-dev.test.ts @@ -85,6 +85,24 @@ describe("ModelsDevPlugin", () => { }, }, }, + default: { + id: "default", + name: "Default", + release_date: "2026-01-01", + reasoning: false, + tool_call: false, + limit: { context: 128_000, output: 8_192 }, + }, + explicit: { + id: "explicit", + name: "Explicit", + release_date: "2026-01-01", + attachment: true, + reasoning: false, + tool_call: false, + modalities: { input: ["audio"], output: ["audio"] }, + limit: { context: 128_000, output: 8_192 }, + }, }, }, } satisfies Record), @@ -101,9 +119,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?.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", modelID: "gpt-5.4", @@ -181,6 +204,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(() => { diff --git a/packages/core/test/session-runner-message.test.ts b/packages/core/test/session-runner-message.test.ts index c593749f374..df46aaf76a1 100644 --- a/packages/core/test/session-runner-message.test.ts +++ b/packages/core/test/session-runner-message.test.ts @@ -240,7 +240,7 @@ Recent work expect(messages[1]?.content).toEqual([{ type: "text", text: "Review this directory" }]) }) - test("uses materialized image data as provider media and drops unsupported attachments", () => { + test("defaults missing model capabilities to text and image input", () => { const data = Base64.make("AAECAw==") const messages = toLLMMessages( [ @@ -266,6 +266,113 @@ Recent work expect(messages[0]?.content).toEqual([ { type: "text", text: "Inspect this image" }, { type: "media", mediaType: "image/png", data, 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 data = Base64.make("AAECAw==") + const messages = toLLMMessages( + [ + SessionMessage.User.make({ + id: id("user-unsupported-pdf"), + type: "user", + text: "Inspect these files", + files: [ + FileAttachment.make({ data, mime: "image/png", source: { type: "inline" }, name: "image.png" }), + FileAttachment.make({ + data: Base64.make("JVBERg=="), + mime: "application/pdf", + source: { type: "inline" }, + name: "document.pdf", + }), + ], + time: { created }, + }), + ], + model, + model.providerID, + { 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: "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({ + data: Base64.make("AAECAw=="), + mime: "image/png", + source: { type: "inline" }, + name: "image.png", + }), + ], + time: { created }, + }), + ], + model, + model.providerID, + { 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({ + data: Base64.make("AAECAw=="), + mime: "audio/mpeg", + source: { type: "inline" }, + name: "audio.mp3", + }), + FileAttachment.make({ + data: Base64.make("AAECAw=="), + mime: "video/webm", + source: { type: "inline" }, + name: "video.webm", + }), + ], + time: { created }, + }), + ], + model, + model.providerID, + { 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: "AAECAw==", filename: "audio.mp3" }, + { type: "media", mediaType: "video/webm", data: "AAECAw==", filename: "video.webm" }, ]) }) diff --git a/packages/core/test/session-runner-recorded.test.ts b/packages/core/test/session-runner-recorded.test.ts index aa2484d34f8..8ba228c64c4 100644 --- a/packages/core/test/session-runner-recorded.test.ts +++ b/packages/core/test/session-runner-recorded.test.ts @@ -11,6 +11,7 @@ import { EventTable } from "@opencode-ai/core/event/sql" import { Job } from "@opencode-ai/core/job" 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" @@ -37,6 +38,7 @@ import { Instructions } from "@opencode-ai/core/instructions" import { SkillGuidance } from "@opencode-ai/core/skill/guidance" import { ReferenceGuidance } from "@opencode-ai/core/reference/guidance" import { McpGuidance } from "@opencode-ai/core/mcp/guidance" +import { ModelV2 } from "@opencode-ai/core/model" import { describe, expect } from "bun:test" import { eq } from "drizzle-orm" import { Effect, Layer } from "effect" @@ -73,6 +75,20 @@ const model = OpenAIChat.route }) .model({ id: "gpt-4o-mini" }) 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 = Layer.mock(InstructionBuiltIns.Service, { load: () => Effect.succeed(Instructions.empty) }) const instructionContext = Layer.mock(InstructionDiscovery.Service, { load: () => Effect.succeed(Instructions.empty) }) const skillGuidance = Layer.mock(SkillGuidance.Service, { load: () => Effect.succeed(Instructions.empty) }) @@ -83,6 +99,7 @@ const runnerLayer = AppNodeBuilder.build(SessionRunnerLLM.node, [ [Snapshot.node, Snapshot.noopLayer], [LayerNodePlatform.llmClient, client], [SessionRunnerModel.node, models], + [Catalog.node, catalog], [InstructionBuiltIns.node, systemContext], [InstructionDiscovery.node, instructionContext], [Location.node, Location.boundNode({ directory: AbsolutePath.make("/project") })], @@ -133,6 +150,7 @@ const it = testEffect( [PermissionV2.node, permission], [ToolOutputStore.node, ToolOutputStore.nodeWithoutConfig], [SessionRunnerModel.node, models], + [Catalog.node, catalog], [InstructionBuiltIns.node, systemContext], [InstructionDiscovery.node, instructionContext], [Location.node, Location.boundNode({ directory: AbsolutePath.make("/project") })], diff --git a/packages/core/test/session-runner.test.ts b/packages/core/test/session-runner.test.ts index ef2d24445a5..7ce0e07b38f 100644 --- a/packages/core/test/session-runner.test.ts +++ b/packages/core/test/session-runner.test.ts @@ -42,6 +42,7 @@ import { ToolRegistry } from "@opencode-ai/core/tool/registry" import { QuestionTool } from "@opencode-ai/core/tool/question" import { ToolOutputStore } from "@opencode-ai/core/tool-output-store" 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" @@ -245,6 +246,24 @@ const models = SessionRunnerModel.layerWith((session) => ), ), ) +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 = Instructions.Key.make("test/context") let systemBaseline = "Initial context" let systemRemoved = false @@ -311,6 +330,7 @@ const runnerLayer = AppNodeBuilder.build(SessionRunnerLLM.node, [ [Snapshot.node, Snapshot.noopLayer], [LayerNodePlatform.llmClient, client], [SessionRunnerModel.node, models], + [Catalog.node, catalog], [InstructionBuiltIns.node, systemContext], [InstructionDiscovery.node, instructionContext], [Location.node, Location.boundNode({ directory: AbsolutePath.make("/project") })], @@ -365,6 +385,7 @@ const it = testEffect( [LayerNodePlatform.llmClient, client], [PermissionV2.node, permission], [SessionRunnerModel.node, models], + [Catalog.node, catalog], [InstructionBuiltIns.node, systemContext], [InstructionDiscovery.node, instructionContext], [Location.node, Location.boundNode({ directory: AbsolutePath.make("/project") })], @@ -412,6 +433,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 @@ -787,6 +809,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: PromptInput.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* () { const session = yield* setup diff --git a/packages/schema/src/model.ts b/packages/schema/src/model.ts index 3539c87f770..2c792074e3f 100644 --- a/packages/schema/src/model.ts +++ b/packages/schema/src/model.ts @@ -26,7 +26,24 @@ export const Capabilities = Schema.Struct({ tools: Schema.Boolean, input: Schema.Array(Schema.String), output: Schema.Array(Schema.String), -}).annotate({ identifier: "Model.Capabilities" }) +}) + .annotate({ identifier: "Model.Capabilities" }) + .pipe( + statics((schema) => ({ + defaults: ( + input: { + readonly tools?: boolean + readonly input?: ReadonlyArray + readonly output?: ReadonlyArray + } = {}, + ) => + schema.make({ + tools: input.tools ?? false, + input: input.input === undefined ? ["text", "image"] : [...input.input], + output: input.output === undefined ? ["text"] : [...input.output], + }), + })), + ) export interface Cost extends Schema.Schema.Type {} export const Cost = Schema.Struct({ @@ -80,7 +97,7 @@ export const Info = Schema.Struct({ modelID: id, providerID, name: id, - capabilities: { tools: false, input: [], output: [] }, + capabilities: Capabilities.defaults(), variants: [], time: { released: 0 }, cost: [],