From 44a898f3eec4d8b99a36f6f7da65ecfd03c07e3b Mon Sep 17 00:00:00 2001 From: Aiden Cline Date: Tue, 7 Jul 2026 23:33:15 -0500 Subject: [PATCH] fix(core): preserve model capability semantics --- packages/core/src/session/runner/llm.ts | 15 ++-------- packages/core/src/session/runner/model.ts | 15 +++++++++- packages/core/test/config/config.test.ts | 6 ++-- packages/core/test/config/provider.test.ts | 2 +- .../core/test/session-runner-recorded.test.ts | 18 ------------ packages/core/test/session-runner.test.ts | 28 ++++--------------- packages/schema/src/model.ts | 2 +- 7 files changed, 27 insertions(+), 59 deletions(-) diff --git a/packages/core/src/session/runner/llm.ts b/packages/core/src/session/runner/llm.ts index ea33b515ac..31da0725c3 100644 --- a/packages/core/src/session/runner/llm.ts +++ b/packages/core/src/session/runner/llm.ts @@ -14,7 +14,6 @@ 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" @@ -138,7 +137,6 @@ 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 @@ -236,17 +234,11 @@ 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 || !catalogModel.capabilities.tools + isLastStep || !resolved.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 @@ -260,7 +252,7 @@ const layer = Layer.effect( .filter((part): part is string => part !== undefined && part.length > 0) .map(SystemPart.make), messages: [ - ...toLLMMessages(context, resolved.ref, providerMetadataKey, catalogModel.capabilities), + ...toLLMMessages(context, resolved.ref, providerMetadataKey, resolved.capabilities), ...(isLastStep ? [Message.assistant(MAX_STEPS_PROMPT)] : []), ], tools: toolMaterialization?.definitions ?? [], @@ -389,7 +381,7 @@ const layer = Layer.effect( ) const stepUsage = (settlement: NonNullable>) => ({ - cost: calculateCost(catalogModel.cost, settlement.tokens), + cost: calculateCost(resolved.cost, settlement.tokens), tokens: settlement.tokens, }) @@ -696,7 +688,6 @@ 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 6cc0d0845d..041701ad97 100644 --- a/packages/core/src/session/runner/model.ts +++ b/packages/core/src/session/runner/model.ts @@ -82,6 +82,10 @@ 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 + /** Capabilities of the selected catalog model. */ + readonly capabilities: ModelV2.Capabilities + /** Catalog pricing in dollars per million tokens. */ + readonly cost: ModelV2.Info["cost"] } export interface Interface { @@ -94,13 +98,20 @@ 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): Resolved => ({ +export const resolved = ( + model: Model, + variant?: ModelV2.VariantID, + cost: ModelV2.Info["cost"] = [], + capabilities = ModelV2.Capabilities.defaults(), +): Resolved => ({ model, ref: ModelV2.Ref.make({ id: ModelV2.ID.make(model.id), providerID: ProviderV2.ID.make(model.provider), ...(variant === undefined ? {} : { variant }), }), + capabilities, + cost, }) const apiKey = (model: ModelV2.Info, credential?: Credential.Value) => { @@ -341,6 +352,8 @@ const layer = Layer.effect( providerID: selected.providerID, ...(session.model?.variant === undefined ? {} : { variant: session.model.variant }), }), + capabilities: selected.capabilities, + cost: selected.cost, } }), }) diff --git a/packages/core/test/config/config.test.ts b/packages/core/test/config/config.test.ts index 2a207e2ac3..8ace1468a2 100644 --- a/packages/core/test/config/config.test.ts +++ b/packages/core/test/config/config.test.ts @@ -766,15 +766,15 @@ describe("Config", () => { settings: { apiKey: "secret" }, models: { model: { - capabilities: { tools: false, input: ["text", "image"], output: ["text"] }, + capabilities: { tools: true, input: ["text", "image"], output: ["text"] }, settings: { reasoningEffort: "high" }, variants: [{ id: "fast", settings: { temperature: 0.2 } }], }, text: { - capabilities: { tools: false, input: ["text"], output: ["text"] }, + capabilities: { tools: true, input: ["text"], output: ["text"] }, }, audio: { - capabilities: { tools: false, input: ["audio"], output: ["audio"] }, + capabilities: { tools: true, input: ["audio"], output: ["audio"] }, }, }, }) diff --git a/packages/core/test/config/provider.test.ts b/packages/core/test/config/provider.test.ts index 06c5505faf..4695fdbf47 100644 --- a/packages/core/test/config/provider.test.ts +++ b/packages/core/test/config/provider.test.ts @@ -253,7 +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(defaultModel.capabilities).toEqual({ tools: true, 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/session-runner-recorded.test.ts b/packages/core/test/session-runner-recorded.test.ts index 8ba228c64c..aa2484d34f 100644 --- a/packages/core/test/session-runner-recorded.test.ts +++ b/packages/core/test/session-runner-recorded.test.ts @@ -11,7 +11,6 @@ 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" @@ -38,7 +37,6 @@ 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" @@ -75,20 +73,6 @@ 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) }) @@ -99,7 +83,6 @@ 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") })], @@ -150,7 +133,6 @@ 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 7ce0e07b38..a92cf8bafd 100644 --- a/packages/core/test/session-runner.test.ts +++ b/packages/core/test/session-runner.test.ts @@ -42,7 +42,6 @@ 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" @@ -236,34 +235,19 @@ const echo = Layer.effectDiscard( const echoNode = makeLocationNode({ name: "test/session-runner-tools", layer: echo, deps: [ToolRegistry.node] }) let modelResolveHook = Effect.void let currentModel = model +let modelCapabilities = ModelV2.Capabilities.defaults() const models = SessionRunnerModel.layerWith((session) => modelResolveHook.pipe( Effect.as( SessionRunnerModel.resolved( session.model?.id === "replacement" ? replacementModel : currentModel, session.model?.variant, + [], + modelCapabilities, ), ), ), ) -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 @@ -330,7 +314,6 @@ 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") })], @@ -385,7 +368,6 @@ 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") })], @@ -433,7 +415,7 @@ const setup = Effect.gen(function* () { systemLoadHook = Effect.void modelResolveHook = Effect.void currentModel = model - catalogCapabilities = ModelV2.Capabilities.defaults({ tools: true }) + modelCapabilities = ModelV2.Capabilities.defaults() skillBaselines.clear() responses = undefined streamFailure = undefined @@ -812,7 +794,7 @@ describe("SessionRunnerLLM", () => { it.effect("does not advertise tools to a model without tool capability", () => Effect.gen(function* () { yield* setup - catalogCapabilities = ModelV2.Capabilities.defaults() + modelCapabilities = ModelV2.Capabilities.defaults({ tools: false }) const session = yield* SessionV2.Service yield* session.prompt({ sessionID, prompt: PromptInput.Prompt.make({ text: "No tools" }), resume: false }) diff --git a/packages/schema/src/model.ts b/packages/schema/src/model.ts index 2c792074e3..b60dc725e8 100644 --- a/packages/schema/src/model.ts +++ b/packages/schema/src/model.ts @@ -38,7 +38,7 @@ export const Capabilities = Schema.Struct({ } = {}, ) => schema.make({ - tools: input.tools ?? false, + tools: input.tools ?? true, input: input.input === undefined ? ["text", "image"] : [...input.input], output: input.output === undefined ? ["text"] : [...input.output], }),