fix(core): preserve model capability semantics

This commit is contained in:
Aiden Cline 2026-07-07 23:33:15 -05:00
commit 44a898f3ee
7 changed files with 27 additions and 59 deletions

View file

@ -14,7 +14,6 @@ import { SessionError } from "@opencode-ai/schema/session-error"
import { Money } from "@opencode-ai/schema/money" import { Money } from "@opencode-ai/schema/money"
import { Cause, Effect, Exit, Fiber, FiberSet, Layer, Option, Semaphore, Stream } from "effect" import { Cause, Effect, Exit, Fiber, FiberSet, Layer, Option, Semaphore, Stream } from "effect"
import { AgentV2 } from "../../agent" import { AgentV2 } from "../../agent"
import { Catalog } from "../../catalog"
import { Config } from "../../config" import { Config } from "../../config"
import { Database } from "../../database/database" import { Database } from "../../database/database"
import { EventV2 } from "../../event" import { EventV2 } from "../../event"
@ -138,7 +137,6 @@ const layer = Layer.effect(
const tools = yield* ToolRegistry.Service const tools = yield* ToolRegistry.Service
const hooks = yield* PluginHooks.Service const hooks = yield* PluginHooks.Service
const models = yield* SessionRunnerModel.Service const models = yield* SessionRunnerModel.Service
const catalog = yield* Catalog.Service
const store = yield* SessionStore.Service const store = yield* SessionStore.Service
const location = yield* Location.Service const location = yield* Location.Service
const builtins = yield* InstructionBuiltIns.Service const builtins = yield* InstructionBuiltIns.Service
@ -236,17 +234,11 @@ const layer = Layer.effect(
const resolved = yield* models.resolve(session) const resolved = yield* models.resolve(session)
const model = resolved.model const model = resolved.model
const providerMetadataKey = model.route.providerMetadataKey ?? model.provider 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 entries = yield* SessionHistory.entriesForRunner(db, session.id, checkpoint.baselineSeq)
const context = entries.map((entry) => entry.message) const context = entries.map((entry) => entry.message)
const isLastStep = agent.info?.steps !== undefined && currentStep >= agent.info.steps const isLastStep = agent.info?.steps !== undefined && currentStep >= agent.info.steps
const toolMaterialization = const toolMaterialization =
isLastStep || !catalogModel.capabilities.tools isLastStep || !resolved.capabilities.tools
? undefined ? undefined
: yield* tools.materialize({ permissions: agent.info?.permissions, model }) : 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 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) .filter((part): part is string => part !== undefined && part.length > 0)
.map(SystemPart.make), .map(SystemPart.make),
messages: [ messages: [
...toLLMMessages(context, resolved.ref, providerMetadataKey, catalogModel.capabilities), ...toLLMMessages(context, resolved.ref, providerMetadataKey, resolved.capabilities),
...(isLastStep ? [Message.assistant(MAX_STEPS_PROMPT)] : []), ...(isLastStep ? [Message.assistant(MAX_STEPS_PROMPT)] : []),
], ],
tools: toolMaterialization?.definitions ?? [], tools: toolMaterialization?.definitions ?? [],
@ -389,7 +381,7 @@ const layer = Layer.effect(
) )
const stepUsage = (settlement: NonNullable<ReturnType<typeof publisher.stepSettlement>>) => ({ const stepUsage = (settlement: NonNullable<ReturnType<typeof publisher.stepSettlement>>) => ({
cost: calculateCost(catalogModel.cost, settlement.tokens), cost: calculateCost(resolved.cost, settlement.tokens),
tokens: settlement.tokens, tokens: settlement.tokens,
}) })
@ -696,7 +688,6 @@ export const node = makeLocationNode({
ToolRegistry.node, ToolRegistry.node,
PluginHooks.node, PluginHooks.node,
SessionRunnerModel.node, SessionRunnerModel.node,
Catalog.node,
SessionStore.node, SessionStore.node,
Location.node, Location.node,
InstructionBuiltIns.node, InstructionBuiltIns.node,

View file

@ -82,6 +82,10 @@ export interface Resolved {
readonly model: Model readonly model: Model
/** Selected catalog identity. Durable records and displays must use this, never the API model id. */ /** Selected catalog identity. Durable records and displays must use this, never the API model id. */
readonly ref: ModelV2.Ref 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 { export interface Interface {
@ -94,13 +98,20 @@ export class Service extends Context.Service<Service, Interface>()("@opencode/v2
export const layerWith = (resolve: Interface["resolve"]) => Layer.succeed(Service, Service.of({ resolve })) 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. */ /** 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, model,
ref: ModelV2.Ref.make({ ref: ModelV2.Ref.make({
id: ModelV2.ID.make(model.id), id: ModelV2.ID.make(model.id),
providerID: ProviderV2.ID.make(model.provider), providerID: ProviderV2.ID.make(model.provider),
...(variant === undefined ? {} : { variant }), ...(variant === undefined ? {} : { variant }),
}), }),
capabilities,
cost,
}) })
const apiKey = (model: ModelV2.Info, credential?: Credential.Value) => { const apiKey = (model: ModelV2.Info, credential?: Credential.Value) => {
@ -341,6 +352,8 @@ const layer = Layer.effect(
providerID: selected.providerID, providerID: selected.providerID,
...(session.model?.variant === undefined ? {} : { variant: session.model.variant }), ...(session.model?.variant === undefined ? {} : { variant: session.model.variant }),
}), }),
capabilities: selected.capabilities,
cost: selected.cost,
} }
}), }),
}) })

View file

@ -766,15 +766,15 @@ describe("Config", () => {
settings: { apiKey: "secret" }, settings: { apiKey: "secret" },
models: { models: {
model: { model: {
capabilities: { tools: false, input: ["text", "image"], output: ["text"] }, capabilities: { tools: true, input: ["text", "image"], output: ["text"] },
settings: { reasoningEffort: "high" }, settings: { reasoningEffort: "high" },
variants: [{ id: "fast", settings: { temperature: 0.2 } }], variants: [{ id: "fast", settings: { temperature: 0.2 } }],
}, },
text: { text: {
capabilities: { tools: false, input: ["text"], output: ["text"] }, capabilities: { tools: true, input: ["text"], output: ["text"] },
}, },
audio: { audio: {
capabilities: { tools: false, input: ["audio"], output: ["audio"] }, capabilities: { tools: true, input: ["audio"], output: ["audio"] },
}, },
}, },
}) })

View file

@ -253,7 +253,7 @@ describe("ConfigProviderPlugin.Plugin", () => {
expect(model.modelID).toBe(ModelV2.ID.make("api-chat")) expect(model.modelID).toBe(ModelV2.ID.make("api-chat"))
expect(model.name).toBe("Last") expect(model.name).toBe("Last")
expect(model.capabilities).toEqual({ tools: true, input: ["text"], output: ["text"] }) 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.enabled).toBe(false)
expect(model.limit).toEqual({ context: 100, output: 75 }) expect(model.limit).toEqual({ context: 100, output: 75 })
expect(model.cost).toEqual([ expect(model.cost).toEqual([

View file

@ -11,7 +11,6 @@ import { EventTable } from "@opencode-ai/core/event/sql"
import { Job } from "@opencode-ai/core/job" import { Job } from "@opencode-ai/core/job"
import { PermissionV2 } from "@opencode-ai/core/permission" import { PermissionV2 } from "@opencode-ai/core/permission"
import { AgentV2 } from "@opencode-ai/core/agent" import { AgentV2 } from "@opencode-ai/core/agent"
import { Catalog } from "@opencode-ai/core/catalog"
import { Config } from "@opencode-ai/core/config" import { Config } from "@opencode-ai/core/config"
import { Project } from "@opencode-ai/core/project" import { Project } from "@opencode-ai/core/project"
import { ProjectTable } from "@opencode-ai/core/project/sql" 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 { SkillGuidance } from "@opencode-ai/core/skill/guidance"
import { ReferenceGuidance } from "@opencode-ai/core/reference/guidance" import { ReferenceGuidance } from "@opencode-ai/core/reference/guidance"
import { McpGuidance } from "@opencode-ai/core/mcp/guidance" import { McpGuidance } from "@opencode-ai/core/mcp/guidance"
import { ModelV2 } from "@opencode-ai/core/model"
import { describe, expect } from "bun:test" import { describe, expect } from "bun:test"
import { eq } from "drizzle-orm" import { eq } from "drizzle-orm"
import { Effect, Layer } from "effect" import { Effect, Layer } from "effect"
@ -75,20 +73,6 @@ const model = OpenAIChat.route
}) })
.model({ id: "gpt-4o-mini" }) .model({ id: "gpt-4o-mini" })
const models = SessionRunnerModel.layerWith(() => Effect.succeed(SessionRunnerModel.resolved(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 = Layer.mock(InstructionBuiltIns.Service, { load: () => Effect.succeed(Instructions.empty) }) const systemContext = Layer.mock(InstructionBuiltIns.Service, { load: () => Effect.succeed(Instructions.empty) })
const instructionContext = Layer.mock(InstructionDiscovery.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) }) 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], [Snapshot.node, Snapshot.noopLayer],
[LayerNodePlatform.llmClient, client], [LayerNodePlatform.llmClient, client],
[SessionRunnerModel.node, models], [SessionRunnerModel.node, models],
[Catalog.node, catalog],
[InstructionBuiltIns.node, systemContext], [InstructionBuiltIns.node, systemContext],
[InstructionDiscovery.node, instructionContext], [InstructionDiscovery.node, instructionContext],
[Location.node, Location.boundNode({ directory: AbsolutePath.make("/project") })], [Location.node, Location.boundNode({ directory: AbsolutePath.make("/project") })],
@ -150,7 +133,6 @@ const it = testEffect(
[PermissionV2.node, permission], [PermissionV2.node, permission],
[ToolOutputStore.node, ToolOutputStore.nodeWithoutConfig], [ToolOutputStore.node, ToolOutputStore.nodeWithoutConfig],
[SessionRunnerModel.node, models], [SessionRunnerModel.node, models],
[Catalog.node, catalog],
[InstructionBuiltIns.node, systemContext], [InstructionBuiltIns.node, systemContext],
[InstructionDiscovery.node, instructionContext], [InstructionDiscovery.node, instructionContext],
[Location.node, Location.boundNode({ directory: AbsolutePath.make("/project") })], [Location.node, Location.boundNode({ directory: AbsolutePath.make("/project") })],

View file

@ -42,7 +42,6 @@ import { ToolRegistry } from "@opencode-ai/core/tool/registry"
import { QuestionTool } from "@opencode-ai/core/tool/question" import { QuestionTool } from "@opencode-ai/core/tool/question"
import { ToolOutputStore } from "@opencode-ai/core/tool-output-store" import { ToolOutputStore } from "@opencode-ai/core/tool-output-store"
import { AgentV2 } from "@opencode-ai/core/agent" import { AgentV2 } from "@opencode-ai/core/agent"
import { Catalog } from "@opencode-ai/core/catalog"
import { Config } from "@opencode-ai/core/config" import { Config } from "@opencode-ai/core/config"
import { ConfigCompaction } from "@opencode-ai/core/config/compaction" import { ConfigCompaction } from "@opencode-ai/core/config/compaction"
import { Tool } from "@opencode-ai/core/tool/tool" 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] }) const echoNode = makeLocationNode({ name: "test/session-runner-tools", layer: echo, deps: [ToolRegistry.node] })
let modelResolveHook = Effect.void let modelResolveHook = Effect.void
let currentModel = model let currentModel = model
let modelCapabilities = ModelV2.Capabilities.defaults()
const models = SessionRunnerModel.layerWith((session) => const models = SessionRunnerModel.layerWith((session) =>
modelResolveHook.pipe( modelResolveHook.pipe(
Effect.as( Effect.as(
SessionRunnerModel.resolved( SessionRunnerModel.resolved(
session.model?.id === "replacement" ? replacementModel : currentModel, session.model?.id === "replacement" ? replacementModel : currentModel,
session.model?.variant, 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") const systemContextKey = Instructions.Key.make("test/context")
let systemBaseline = "Initial context" let systemBaseline = "Initial context"
let systemRemoved = false let systemRemoved = false
@ -330,7 +314,6 @@ const runnerLayer = AppNodeBuilder.build(SessionRunnerLLM.node, [
[Snapshot.node, Snapshot.noopLayer], [Snapshot.node, Snapshot.noopLayer],
[LayerNodePlatform.llmClient, client], [LayerNodePlatform.llmClient, client],
[SessionRunnerModel.node, models], [SessionRunnerModel.node, models],
[Catalog.node, catalog],
[InstructionBuiltIns.node, systemContext], [InstructionBuiltIns.node, systemContext],
[InstructionDiscovery.node, instructionContext], [InstructionDiscovery.node, instructionContext],
[Location.node, Location.boundNode({ directory: AbsolutePath.make("/project") })], [Location.node, Location.boundNode({ directory: AbsolutePath.make("/project") })],
@ -385,7 +368,6 @@ const it = testEffect(
[LayerNodePlatform.llmClient, client], [LayerNodePlatform.llmClient, client],
[PermissionV2.node, permission], [PermissionV2.node, permission],
[SessionRunnerModel.node, models], [SessionRunnerModel.node, models],
[Catalog.node, catalog],
[InstructionBuiltIns.node, systemContext], [InstructionBuiltIns.node, systemContext],
[InstructionDiscovery.node, instructionContext], [InstructionDiscovery.node, instructionContext],
[Location.node, Location.boundNode({ directory: AbsolutePath.make("/project") })], [Location.node, Location.boundNode({ directory: AbsolutePath.make("/project") })],
@ -433,7 +415,7 @@ const setup = Effect.gen(function* () {
systemLoadHook = Effect.void systemLoadHook = Effect.void
modelResolveHook = Effect.void modelResolveHook = Effect.void
currentModel = model currentModel = model
catalogCapabilities = ModelV2.Capabilities.defaults({ tools: true }) modelCapabilities = ModelV2.Capabilities.defaults()
skillBaselines.clear() skillBaselines.clear()
responses = undefined responses = undefined
streamFailure = undefined streamFailure = undefined
@ -812,7 +794,7 @@ describe("SessionRunnerLLM", () => {
it.effect("does not advertise tools to a model without tool capability", () => it.effect("does not advertise tools to a model without tool capability", () =>
Effect.gen(function* () { Effect.gen(function* () {
yield* setup yield* setup
catalogCapabilities = ModelV2.Capabilities.defaults() modelCapabilities = ModelV2.Capabilities.defaults({ tools: false })
const session = yield* SessionV2.Service const session = yield* SessionV2.Service
yield* session.prompt({ sessionID, prompt: PromptInput.Prompt.make({ text: "No tools" }), resume: false }) yield* session.prompt({ sessionID, prompt: PromptInput.Prompt.make({ text: "No tools" }), resume: false })

View file

@ -38,7 +38,7 @@ export const Capabilities = Schema.Struct({
} = {}, } = {},
) => ) =>
schema.make({ schema.make({
tools: input.tools ?? false, tools: input.tools ?? true,
input: input.input === undefined ? ["text", "image"] : [...input.input], input: input.input === undefined ? ["text", "image"] : [...input.input],
output: input.output === undefined ? ["text"] : [...input.output], output: input.output === undefined ? ["text"] : [...input.output],
}), }),