mirror of
https://github.com/anomalyco/opencode.git
synced 2026-07-24 02:33:33 +00:00
chore(core): checkpoint model capability defaults
This commit is contained in:
parent
ed4f833813
commit
77fce8b24c
14 changed files with 301 additions and 25 deletions
|
|
@ -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),
|
||||
|
|
|
|||
|
|
@ -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) => ({
|
||||
|
|
|
|||
|
|
@ -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<ReturnType<typeof publisher.stepSettlement>>) => ({
|
||||
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,
|
||||
|
|
|
|||
|
|
@ -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<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, 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,
|
||||
}
|
||||
}),
|
||||
})
|
||||
|
|
|
|||
|
|
@ -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))
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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({
|
||||
|
|
|
|||
|
|
@ -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([
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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<string, ModelsDev.Provider>),
|
||||
|
|
@ -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(() => {
|
||||
|
|
|
|||
|
|
@ -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" },
|
||||
])
|
||||
})
|
||||
|
||||
|
|
|
|||
|
|
@ -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") })],
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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<string>
|
||||
readonly output?: ReadonlyArray<string>
|
||||
} = {},
|
||||
) =>
|
||||
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<typeof Cost> {}
|
||||
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: [],
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue