mirror of
https://github.com/anomalyco/opencode.git
synced 2026-07-10 03:18:31 +00:00
fix(core): preserve model capability semantics
This commit is contained in:
parent
77fce8b24c
commit
44a898f3ee
7 changed files with 27 additions and 59 deletions
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
}
|
||||
}),
|
||||
})
|
||||
|
|
|
|||
|
|
@ -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"] },
|
||||
},
|
||||
},
|
||||
})
|
||||
|
|
|
|||
|
|
@ -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([
|
||||
|
|
|
|||
|
|
@ -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") })],
|
||||
|
|
|
|||
|
|
@ -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 })
|
||||
|
||||
|
|
|
|||
|
|
@ -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],
|
||||
}),
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue