fix(core): preserve model capability semantics

This commit is contained in:
Aiden Cline 2026-07-07 23:33:15 -05:00
parent 77fce8b24c
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 { 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<ReturnType<typeof publisher.stepSettlement>>) => ({
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,

View file

@ -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<Service, Interface>()("@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,
}
}),
})

View file

@ -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"] },
},
},
})

View file

@ -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([

View file

@ -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") })],

View file

@ -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 })

View file

@ -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],
}),